From 98235d02097be0daf14f0d14a5ca030b967f2dc9 Mon Sep 17 00:00:00 2001 From: thedavidmeister Date: Sat, 22 Aug 2026 18:24:36 +0000 Subject: [PATCH 1/4] test: AMT coverage for graphql query processing, authoring meta v1/v2 and solidity abi meta Adds discriminating tests for the mutation-probe survivors of the g3-graphql-query-processing behaviour list: process_deployer_query guards/len/hex-decode error mapping, AuthoringMeta v1 item-level encode/decode/validate + json fallback + unpack, AuthoringMetaV2 word trimming/utf8 and the fetch_for_contract error-wrap pipeline over httpmock, and SolidityAbiMeta per-kind serialization, exact field order, indexed guards, required-field errors and defaults. Co-Authored-By: Claude Fable 5 --- crates/cli/src/meta/query/mod.rs | 203 +++++++++++ crates/cli/src/meta/types/authoring/v1.rs | 128 +++++++ crates/cli/src/meta/types/authoring/v2.rs | 358 +++++++++++++++++++ crates/cli/src/meta/types/solidity_abi/v2.rs | 309 ++++++++++++++++ 4 files changed, 998 insertions(+) diff --git a/crates/cli/src/meta/query/mod.rs b/crates/cli/src/meta/query/mod.rs index 7f6ab792..6a1d12f2 100644 --- a/crates/cli/src/meta/query/mod.rs +++ b/crates/cli/src/meta/query/mod.rs @@ -170,3 +170,206 @@ pub(super) async fn process_deployer_query( Err(Error::NoRecordFound) } } + +#[cfg(all(test, not(target_family = "wasm")))] +mod tests { + use super::*; + use httpmock::Method::POST; + use httpmock::MockServer; + use serde_json::json; + + /// A fully populated, valid expressionDeployers entry as the subgraph + /// would return it: every hex field decodes and exactly one meta. + fn deployer_entry() -> serde_json::Value { + json!({ + "constructorMetaHash": "0x0102", + "constructorMeta": "0x0304", + "deployTransaction": { "id": "0x0506" }, + "bytecode": "0x0708", + "parser": { "parser": { "deployedBytecode": "0x090a" } }, + "store": { "store": { "deployedBytecode": "0x0b0c" } }, + "interpreter": { "interpreter": { "deployedBytecode": "0x0d0e" } }, + "meta": [ { "__typename": "RainMetaV1", "id": "0x0f10" } ] + }) + } + + async fn run_deployer_query(entry: serde_json::Value) -> Result { + let server = MockServer::start_async().await; + server.mock(|when, then| { + when.method(POST).path("/"); + then.status(200) + .json_body(json!({ "data": { "expressionDeployers": [entry] } })); + }); + let request_body = DeployerQuery::build_query(deployer_query::Variables { + hash: Some("0xabcd".to_string()), + }); + let client = Arc::new(Client::new()); + process_deployer_query(client, &request_body, &server.url("/")).await + } + + #[tokio::test] + async fn test_process_deployer_query_success_decodes_all_fields() { + let res = run_deployer_query(deployer_entry()).await.unwrap(); + assert_eq!(res.meta_hash, vec![0x01, 0x02]); + assert_eq!(res.meta_bytes, vec![0x03, 0x04]); + assert_eq!(res.tx_hash, vec![0x05, 0x06]); + assert_eq!(res.bytecode, vec![0x07, 0x08]); + assert_eq!(res.parser, vec![0x09, 0x0a]); + assert_eq!(res.store, vec![0x0b, 0x0c]); + assert_eq!(res.interpreter, vec![0x0d, 0x0e]); + assert_eq!(res.bytecode_meta_hash, vec![0x0f, 0x10]); + } + + #[tokio::test] + async fn test_process_deployer_query_null_bytecode_is_no_record_found() { + let mut entry = deployer_entry(); + entry["bytecode"] = serde_json::Value::Null; + assert!(matches!( + run_deployer_query(entry).await, + Err(Error::NoRecordFound) + )); + } + + #[tokio::test] + async fn test_process_deployer_query_null_parser_is_no_record_found() { + let mut entry = deployer_entry(); + entry["parser"] = serde_json::Value::Null; + assert!(matches!( + run_deployer_query(entry).await, + Err(Error::NoRecordFound) + )); + } + + #[tokio::test] + async fn test_process_deployer_query_null_store_is_no_record_found() { + let mut entry = deployer_entry(); + entry["store"] = serde_json::Value::Null; + assert!(matches!( + run_deployer_query(entry).await, + Err(Error::NoRecordFound) + )); + } + + #[tokio::test] + async fn test_process_deployer_query_null_interpreter_is_no_record_found() { + let mut entry = deployer_entry(); + entry["interpreter"] = serde_json::Value::Null; + assert!(matches!( + run_deployer_query(entry).await, + Err(Error::NoRecordFound) + )); + } + + #[tokio::test] + async fn test_process_deployer_query_null_deploy_transaction_is_no_record_found() { + let mut entry = deployer_entry(); + entry["deployTransaction"] = serde_json::Value::Null; + assert!(matches!( + run_deployer_query(entry).await, + Err(Error::NoRecordFound) + )); + } + + #[tokio::test] + async fn test_process_deployer_query_zero_metas_is_no_record_found() { + let mut entry = deployer_entry(); + entry["meta"] = json!([]); + assert!(matches!( + run_deployer_query(entry).await, + Err(Error::NoRecordFound) + )); + } + + #[tokio::test] + async fn test_process_deployer_query_two_metas_is_no_record_found() { + let mut entry = deployer_entry(); + entry["meta"] = json!([ + { "__typename": "RainMetaV1", "id": "0x0f10" }, + { "__typename": "RainMetaV1", "id": "0x1112" } + ]); + assert!(matches!( + run_deployer_query(entry).await, + Err(Error::NoRecordFound) + )); + } + + #[tokio::test] + async fn test_process_deployer_query_invalid_bytecode_hex_is_no_record_found() { + let mut entry = deployer_entry(); + entry["bytecode"] = json!("0xZZ"); + assert!(matches!( + run_deployer_query(entry).await, + Err(Error::NoRecordFound) + )); + } + + #[tokio::test] + async fn test_process_deployer_query_invalid_parser_hex_is_no_record_found() { + let mut entry = deployer_entry(); + entry["parser"]["parser"]["deployedBytecode"] = json!("0xZZ"); + assert!(matches!( + run_deployer_query(entry).await, + Err(Error::NoRecordFound) + )); + } + + #[tokio::test] + async fn test_process_deployer_query_invalid_store_hex_is_no_record_found() { + let mut entry = deployer_entry(); + entry["store"]["store"]["deployedBytecode"] = json!("0xZZ"); + assert!(matches!( + run_deployer_query(entry).await, + Err(Error::NoRecordFound) + )); + } + + #[tokio::test] + async fn test_process_deployer_query_invalid_interpreter_hex_is_no_record_found() { + let mut entry = deployer_entry(); + entry["interpreter"]["interpreter"]["deployedBytecode"] = json!("0xZZ"); + assert!(matches!( + run_deployer_query(entry).await, + Err(Error::NoRecordFound) + )); + } + + #[tokio::test] + async fn test_process_deployer_query_invalid_meta_id_hex_is_no_record_found() { + let mut entry = deployer_entry(); + entry["meta"][0]["id"] = json!("0xZZ"); + assert!(matches!( + run_deployer_query(entry).await, + Err(Error::NoRecordFound) + )); + } + + #[tokio::test] + async fn test_process_deployer_query_invalid_tx_id_hex_is_no_record_found() { + let mut entry = deployer_entry(); + entry["deployTransaction"]["id"] = json!("0xZZ"); + assert!(matches!( + run_deployer_query(entry).await, + Err(Error::NoRecordFound) + )); + } + + #[tokio::test] + async fn test_process_deployer_query_invalid_constructor_meta_hash_hex_is_no_record_found() { + let mut entry = deployer_entry(); + entry["constructorMetaHash"] = json!("0xZZ"); + assert!(matches!( + run_deployer_query(entry).await, + Err(Error::NoRecordFound) + )); + } + + #[tokio::test] + async fn test_process_deployer_query_invalid_constructor_meta_hex_is_no_record_found() { + let mut entry = deployer_entry(); + entry["constructorMeta"] = json!("0xZZ"); + assert!(matches!( + run_deployer_query(entry).await, + Err(Error::NoRecordFound) + )); + } +} diff --git a/crates/cli/src/meta/types/authoring/v1.rs b/crates/cli/src/meta/types/authoring/v1.rs index d820b8f1..0f33c75d 100644 --- a/crates/cli/src/meta/types/authoring/v1.rs +++ b/crates/cli/src/meta/types/authoring/v1.rs @@ -226,4 +226,132 @@ mod tests { Ok(()) } + + #[test] + fn test_item_encode_decode_roundtrip_offset_and_word_bytes() -> Result<(), Error> { + let item = AuthoringMetaItem { + word: "stack".to_string(), + operand_parser_offset: 16u8, + description: "some description.".to_string(), + }; + let encoded = item.abi_encode()?; + // ABI layout for a single dynamic (bytes32, uint8, string) value: one + // indirection word (offset 0x20), then the tuple body whose first + // word carries the word left-aligned and zero padded and whose second + // word carries the uint8 in its last byte. + assert_eq!(&encoded[0..31], &[0u8; 31][..]); + assert_eq!(encoded[31], 0x20u8); + assert_eq!(&encoded[32..37], &b"stack"[..]); + assert_eq!(&encoded[37..64], &[0u8; 27][..]); + assert_eq!(&encoded[64..95], &[0u8; 31][..]); + assert_eq!(encoded[95], 16u8); + let decoded = AuthoringMetaItem::abi_decode(&encoded)?; + assert_eq!(decoded, item); + Ok(()) + } + + #[test] + fn test_item_abi_encode_validate_rejects_invalid_word() { + let item = AuthoringMetaItem { + // printable ASCII (passes RAIN_STRING) but not lower-kebab-case + // (fails RAIN_SYMBOL), so word validation specifically must fire. + word: "Bad Word".to_string(), + operand_parser_offset: 0u8, + description: "fine description.".to_string(), + }; + // encoding itself works, so any failure below is validation + assert!(item.abi_encode().is_ok()); + assert!(matches!( + item.abi_encode_validate(), + Err(Error::ValidationErrors(_)) + )); + } + + #[test] + fn test_item_abi_decode_validate_rejects_invalid_word() { + let item = AuthoringMetaItem { + word: "Bad Word".to_string(), + operand_parser_offset: 0u8, + description: "fine description.".to_string(), + }; + let encoded = item.abi_encode().unwrap(); + // plain decode accepts the bytes + assert_eq!(AuthoringMetaItem::abi_decode(&encoded).unwrap(), item); + // validating decode rejects them + assert!(matches!( + AuthoringMetaItem::abi_decode_validate(&encoded), + Err(Error::ValidationErrors(_)) + )); + } + + #[test] + fn test_array_validate_rejects_and_annotates_offending_index() { + let am = AuthoringMeta(vec![ + AuthoringMetaItem { + word: "stack".to_string(), + operand_parser_offset: 0u8, + description: "fine description.".to_string(), + }, + AuthoringMetaItem { + word: "Bad Word".to_string(), + operand_parser_offset: 0u8, + description: "fine description.".to_string(), + }, + ]); + match am.abi_encode_validate() { + Err(Error::ValidationErrors(v)) => { + let errors = v.errors(); + assert!(errors.contains_key("at index 1")); + assert!(!errors.contains_key("at index 0")); + } + other => panic!("expected ValidationErrors, got {:?}", other.err()), + } + } + + #[test] + fn test_try_from_bytes_json_fallback() -> Result<(), Error> { + let json_bytes = + br#"[{"word":"stack","description":"a description.","operandParserOffset":16}]"# + .to_vec(); + let expected = AuthoringMeta(vec![AuthoringMetaItem { + word: "stack".to_string(), + operand_parser_offset: 16u8, + description: "a description.".to_string(), + }]); + // json bytes resolve through the serde_json fallback arm + let from_vec = AuthoringMeta::try_from(json_bytes.clone())?; + assert_eq!(from_vec, expected); + let from_slice = AuthoringMeta::try_from(json_bytes.as_slice())?; + assert_eq!(from_slice, expected); + // abi encoded bytes resolve through the abi_decode arm + let encoded = expected.abi_encode()?; + assert_eq!(AuthoringMeta::try_from(encoded)?, expected); + Ok(()) + } + + #[test] + fn test_try_from_meta_item_unpacks_content_encoding() -> Result<(), Error> { + use crate::meta::{ + ContentEncoding, ContentLanguage, ContentType, KnownMagic, RainMetaDocumentV1Item, + }; + let expected = AuthoringMeta(vec![AuthoringMetaItem { + word: "stack".to_string(), + operand_parser_offset: 16u8, + description: "a description.".to_string(), + }]); + let encoded = expected.abi_encode_validate()?; + let deflated = ContentEncoding::Deflate.encode(&encoded); + assert_ne!(deflated, encoded); + let item = RainMetaDocumentV1Item { + payload: serde_bytes::ByteBuf::from(deflated), + magic: KnownMagic::AuthoringMetaV1, + content_type: ContentType::Cbor, + content_encoding: ContentEncoding::Deflate, + content_language: ContentLanguage::None, + schema: None, + }; + // TryFrom must unpack (inflate) the payload before decoding + assert_eq!(AuthoringMeta::try_from(item)?, expected); + Ok(()) + } } diff --git a/crates/cli/src/meta/types/authoring/v2.rs b/crates/cli/src/meta/types/authoring/v2.rs index 6d524738..fb874ca0 100644 --- a/crates/cli/src/meta/types/authoring/v2.rs +++ b/crates/cli/src/meta/types/authoring/v2.rs @@ -417,4 +417,362 @@ mod tests { } } } + + // ---- helpers for fetch_for_contract tests ---- + + /// hex payload of an abi encoded AuthoringMetaV2Sol[] with three words + /// ("test" with descriptions 1..3), same fixture as the decode tests. + static WORDS_PAYLOAD_HEX: &str = "0x00000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000000003000000000000000000000000000000000000000000000000000000000000006000000000000000000000000000000000000000000000000000000000000000e0000000000000000000000000000000000000000000000000000000000000016074657374000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000040000000000000000000000000000000000000000000000000000000000000000d6465736372697074696f6e20310000000000000000000000000000000000000074657374000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000040000000000000000000000000000000000000000000000000000000000000000d6465736372697074696f6e20320000000000000000000000000000000000000074657374000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000040000000000000000000000000000000000000000000000000000000000000000d6465736372697074696f6e203300000000000000000000000000000000000000"; + + fn bool_word(b: bool) -> String { + let mut s = "0x".to_string(); + s.push_str(&"0".repeat(63)); + s.push_str(if b { "1" } else { "0" }); + s + } + + /// Mocks the full JSON-RPC flow implements_i_described_by_meta_v1 walks + /// (erc165 check1, check2, interface check) plus the describedByMetaV1 + /// call returning `metahash`. + fn mock_described_by_rpc(rpc_server: &MockServer, metahash: [u8; 32]) { + let sel = encode(IDescribedByMetaV1::describedByMetaV1Call::SELECTOR); + // erc165 check1: supportsInterface(0x01ffc9a7) -> true + rpc_server.mock(|when, then| { + when.method(POST) + .path("/") + .body_contains("01ffc9a701ffc9a7"); + then.status(200).json_body_obj(&serde_json::json!({ + "jsonrpc": "2.0", "id": 1, "result": bool_word(true) + })); + }); + // erc165 check2: supportsInterface(0xffffffff) -> false + rpc_server.mock(|when, then| { + when.method(POST) + .path("/") + .body_contains("01ffc9a7ffffffff"); + then.status(200).json_body_obj(&serde_json::json!({ + "jsonrpc": "2.0", "id": 1, "result": bool_word(false) + })); + }); + // supportsInterface(IDescribedByMetaV1 interface id) -> true + rpc_server.mock(|when, then| { + when.method(POST) + .path("/") + .body_contains(format!("01ffc9a7{}", sel)); + then.status(200).json_body_obj(&serde_json::json!({ + "jsonrpc": "2.0", "id": 1, "result": bool_word(true) + })); + }); + // describedByMetaV1() -> metahash + rpc_server.mock(|when, then| { + when.method(POST) + .path("/") + .body_contains(format!("0x{}\"", sel)); + then.status(200).json_body_obj(&serde_json::json!({ + "jsonrpc": "2.0", "id": 1, + "result": format!("0x{}", encode(metahash)) + })); + }); + } + + fn metaboard_meta_entry(meta_hex: &str) -> serde_json::Value { + serde_json::json!({ + "meta": meta_hex, + "metaHash": "0x00", + "sender": "0x00", + "id": "0x00", + "metaBoard": { + "id": "0x00", + "metas": [], + "address": "0x00", + }, + "subject": "0x00", + }) + } + + /// cbor encoded RainMetaDocumentV1Item carrying the three word payload + /// under the AuthoringMetaV2 magic. + fn authoring_meta_v2_cbor_hex() -> String { + let payload = decode::(WORDS_PAYLOAD_HEX.into()).unwrap(); + let item = RainMetaDocumentV1Item { + magic: KnownMagic::AuthoringMetaV2, + payload: ByteBuf::from(payload), + content_encoding: ContentEncoding::None, + content_language: ContentLanguage::None, + schema: None, + content_type: ContentType::None, + }; + format!("0x{}", encode(item.cbor_encode().unwrap())) + } + + #[tokio::test] + async fn test_abi_decode_full_32_byte_word_kept_whole() { + let word = [b'a'; 32]; + let encoded = AuthoringMetasV2Sol::abi_encode(&vec![AuthoringMetaV2Sol { + word: word.into(), + description: "full width".to_string(), + }]); + let decoded = AuthoringMetaV2::abi_decode(&encoded).unwrap(); + assert_eq!(decoded.words.len(), 1); + // no NUL anywhere: the full 32 bytes are the word + assert_eq!(decoded.words[0].word, "a".repeat(32)); + assert_eq!(decoded.words[0].description, "full width"); + } + + #[tokio::test] + async fn test_abi_decode_invalid_utf8_word_is_utf8_error() { + let mut word = [0u8; 32]; + // 0xc3 followed by 0x28 is an invalid utf8 sequence, before any NUL + word[0] = 0xc3; + word[1] = 0x28; + let encoded = AuthoringMetasV2Sol::abi_encode(&vec![AuthoringMetaV2Sol { + word: word.into(), + description: "bad word bytes".to_string(), + }]); + let result = AuthoringMetaV2::abi_decode(&encoded); + match result { + Err(AuthoringMetaV2Error::Utf8Error(_)) => {} + other => panic!("expected Utf8Error, got {:?}", other), + } + } + + #[tokio::test] + async fn test_fetch_for_contract_empty_rpcs_is_no_rpcs_error() { + let result = AuthoringMetaV2::fetch_for_contract( + Address::from([7u8; 20]), + vec![], + "http://metaboard.test/".to_string(), + ) + .await; + let error = result.unwrap_err(); + assert_eq!(error.contract_address, Address::from([7u8; 20])); + assert!(error.rpcs.is_empty()); + assert_eq!(error.metaboard_url, "http://metaboard.test/"); + match error.error { + AuthoringMetaV2Error::NoRpcs => {} + other => panic!("expected NoRpcs, got {:?}", other), + } + } + + #[tokio::test] + async fn test_fetch_for_contract_invalid_rpc_url_is_url_parse_error() { + let result = AuthoringMetaV2::fetch_for_contract( + Address::from([7u8; 20]), + vec!["not a url".to_string()], + "http://metaboard.test/".to_string(), + ) + .await; + let error = result.unwrap_err(); + assert_eq!(error.rpcs, vec!["not a url".to_string()]); + match error.error { + AuthoringMetaV2Error::UrlParseError(_) => {} + other => panic!("expected UrlParseError, got {:?}", other), + } + } + + #[tokio::test] + async fn test_fetch_for_contract_rpc_error_on_describe_call() { + let rpc_server = MockServer::start_async().await; + let sel = encode(IDescribedByMetaV1::describedByMetaV1Call::SELECTOR); + // erc165 detection succeeds + rpc_server.mock(|when, then| { + when.method(POST) + .path("/") + .body_contains("01ffc9a701ffc9a7"); + then.status(200).json_body_obj(&serde_json::json!({ + "jsonrpc": "2.0", "id": 1, "result": bool_word(true) + })); + }); + rpc_server.mock(|when, then| { + when.method(POST) + .path("/") + .body_contains("01ffc9a7ffffffff"); + then.status(200).json_body_obj(&serde_json::json!({ + "jsonrpc": "2.0", "id": 1, "result": bool_word(false) + })); + }); + rpc_server.mock(|when, then| { + when.method(POST) + .path("/") + .body_contains(format!("01ffc9a7{}", sel)); + then.status(200).json_body_obj(&serde_json::json!({ + "jsonrpc": "2.0", "id": 1, "result": bool_word(true) + })); + }); + // the describedByMetaV1 call itself errors at the rpc level + rpc_server.mock(|when, then| { + when.method(POST) + .path("/") + .body_contains(format!("0x{}\"", sel)); + then.status(200).json_body_obj(&serde_json::json!({ + "jsonrpc": "2.0", "id": 1, + "error": { "code": -32000, "message": "boom" } + })); + }); + + let result = AuthoringMetaV2::fetch_for_contract( + Address::from([0u8; 20]), + vec![rpc_server.url("/")], + "http://metaboard.test/".to_string(), + ) + .await; + let error = result.unwrap_err(); + match error.error { + AuthoringMetaV2Error::RpcError(_) => {} + other => panic!("expected RpcError, got {:?}", other), + } + } + + #[tokio::test] + async fn test_fetch_for_contract_abi_decode_error_on_describe_call() { + let rpc_server = MockServer::start_async().await; + let sel = encode(IDescribedByMetaV1::describedByMetaV1Call::SELECTOR); + rpc_server.mock(|when, then| { + when.method(POST) + .path("/") + .body_contains("01ffc9a701ffc9a7"); + then.status(200).json_body_obj(&serde_json::json!({ + "jsonrpc": "2.0", "id": 1, "result": bool_word(true) + })); + }); + rpc_server.mock(|when, then| { + when.method(POST) + .path("/") + .body_contains("01ffc9a7ffffffff"); + then.status(200).json_body_obj(&serde_json::json!({ + "jsonrpc": "2.0", "id": 1, "result": bool_word(false) + })); + }); + rpc_server.mock(|when, then| { + when.method(POST) + .path("/") + .body_contains(format!("01ffc9a7{}", sel)); + then.status(200).json_body_obj(&serde_json::json!({ + "jsonrpc": "2.0", "id": 1, "result": bool_word(true) + })); + }); + // describedByMetaV1 succeeds at the rpc level but returns bytes that + // cannot decode as bytes32 + rpc_server.mock(|when, then| { + when.method(POST) + .path("/") + .body_contains(format!("0x{}\"", sel)); + then.status(200).json_body_obj(&serde_json::json!({ + "jsonrpc": "2.0", "id": 1, "result": "0x" + })); + }); + + let result = AuthoringMetaV2::fetch_for_contract( + Address::from([0u8; 20]), + vec![rpc_server.url("/")], + "http://metaboard.test/".to_string(), + ) + .await; + let error = result.unwrap_err(); + match error.error { + AuthoringMetaV2Error::AbiDecodeError(_) => {} + other => panic!("expected AbiDecodeError, got {:?}", other), + } + } + + #[tokio::test] + async fn test_fetch_for_contract_invalid_metaboard_url_is_url_parse_error() { + let hash = [1u8; 32]; + let rpc_server = MockServer::start_async().await; + mock_described_by_rpc(&rpc_server, hash); + + let result = AuthoringMetaV2::fetch_for_contract( + Address::from([0u8; 20]), + vec![rpc_server.url("/")], + "not a url".to_string(), + ) + .await; + let error = result.unwrap_err(); + assert_eq!(error.metaboard_url, "not a url"); + match error.error { + AuthoringMetaV2Error::UrlParseError(_) => {} + other => panic!("expected UrlParseError, got {:?}", other), + } + } + + #[tokio::test] + async fn test_fetch_for_contract_empty_metaboard_response_is_subgraph_error() { + let hash = [1u8; 32]; + let rpc_server = MockServer::start_async().await; + mock_described_by_rpc(&rpc_server, hash); + + let metaboard_server = MockServer::start_async().await; + metaboard_server.mock(|when, then| { + when.method(POST).path("/"); + then.status(200).json_body_obj(&serde_json::json!({ + "data": { "metaV1S": [] } + })); + }); + + let result = AuthoringMetaV2::fetch_for_contract( + Address::from([0u8; 20]), + vec![rpc_server.url("/")], + metaboard_server.url("/"), + ) + .await; + let error = result.unwrap_err(); + match error.error { + AuthoringMetaV2Error::MetaboardSubgraphError(_) => {} + other => panic!("expected MetaboardSubgraphError, got {:?}", other), + } + } + + #[tokio::test] + async fn test_fetch_for_contract_success_decodes_first_meta() { + let hash = [1u8; 32]; + let rpc_server = MockServer::start_async().await; + mock_described_by_rpc(&rpc_server, hash); + + let metaboard_server = MockServer::start_async().await; + metaboard_server.mock(|when, then| { + when.method(POST).path("/").body_contains(encode(hash)); + then.status(200).json_body_obj(&serde_json::json!({ + "data": { + "metaV1S": [ + // the first meta is the authoring meta document and is + // the one that must be decoded + metaboard_meta_entry(&authoring_meta_v2_cbor_hex()), + // a trailing non-decodable meta must be ignored + metaboard_meta_entry("0x00"), + ] + } + })); + }); + + let result = AuthoringMetaV2::fetch_for_contract( + Address::from([0u8; 20]), + vec![rpc_server.url("/")], + metaboard_server.url("/"), + ) + .await; + let meta = result.unwrap(); + assert_eq!(meta.words.len(), 3); + assert_eq!(meta.words[0].word, "test"); + assert_eq!(meta.words[0].description, "description 1"); + assert_eq!(meta.words[2].description, "description 3"); + } + + #[tokio::test] + async fn test_try_from_deflate_encoded_item_unpacks() { + let payload = decode::(WORDS_PAYLOAD_HEX.into()).unwrap(); + let deflated = ContentEncoding::Deflate.encode(&payload); + assert_ne!(deflated, payload); + let item = RainMetaDocumentV1Item { + magic: KnownMagic::AuthoringMetaV2, + payload: ByteBuf::from(deflated), + content_encoding: ContentEncoding::Deflate, + content_language: ContentLanguage::None, + schema: None, + content_type: ContentType::None, + }; + let result = AuthoringMetaV2::try_from(item).unwrap(); + assert_eq!(result.words.len(), 3); + assert_eq!(result.words[0].word, "test"); + assert_eq!(result.words[1].description, "description 2"); + } } diff --git a/crates/cli/src/meta/types/solidity_abi/v2.rs b/crates/cli/src/meta/types/solidity_abi/v2.rs index f143689b..db50ad99 100644 --- a/crates/cli/src/meta/types/solidity_abi/v2.rs +++ b/crates/cli/src/meta/types/solidity_abi/v2.rs @@ -651,4 +651,313 @@ mod tests { )); Ok(()) } + + #[test] + fn test_from_artifact_extracts_abi_key() -> anyhow::Result<()> { + let artifact = serde_json::json!({ + "abi": [{ + "inputs": [], + "name": "f", + "outputs": [], + "stateMutability": "view", + "type": "function" + }], + "bytecode": { "object": "0x" } + }); + let meta = SolidityAbiMeta::from_artifact(serde_json::to_vec(&artifact)?.as_slice())?; + assert_eq!(meta.0.len(), 1); + assert_eq!( + serde_json::to_value(&meta)?, + artifact["abi"], + "from_artifact must surface exactly the artifact's abi section" + ); + Ok(()) + } + + #[test] + fn test_try_from_bytes_rejects_invalid_json() { + let garbage = b"definitely not json".to_vec(); + assert!(matches!( + SolidityAbiMeta::try_from(garbage.clone()), + Err(Error::SerdeJsonError(_)) + )); + assert!(matches!( + SolidityAbiMeta::try_from(garbage.as_slice()), + Err(Error::SerdeJsonError(_)) + )); + } + + #[test] + fn test_try_from_item_unpacks_content_encoding() -> anyhow::Result<()> { + use serde_bytes::ByteBuf; + use crate::meta::{ + ContentEncoding, ContentLanguage, ContentType, KnownMagic, RainMetaDocumentV1Item, + }; + let abi = serde_json::json!([{ + "inputs": [], + "name": "f", + "outputs": [], + "stateMutability": "view", + "type": "function" + }]); + let abi_bytes = serde_json::to_vec(&abi)?; + let deflated = ContentEncoding::Deflate.encode(&abi_bytes); + assert_ne!(deflated, abi_bytes); + let item = RainMetaDocumentV1Item { + payload: ByteBuf::from(deflated), + magic: KnownMagic::SolidityAbiV2, + content_type: ContentType::Json, + content_encoding: ContentEncoding::Deflate, + content_language: ContentLanguage::None, + schema: None, + }; + let meta = SolidityAbiMeta::try_from(item.clone())?; + assert_eq!(serde_json::to_value(&meta)?, abi); + let json_abi = JsonAbi::try_from(item)?; + assert_eq!(json_abi.functions().count(), 1); + Ok(()) + } + + #[test] + fn test_serialize_all_item_kinds_roundtrip() -> anyhow::Result<()> { + // every item kind the serializers support, including nested tuple + // components on fn/event/error inputs; committed interface abis only + // exercise function and event without components. + let abi = serde_json::json!([ + { + "inputs": [{ + "components": [{ + "internalType": "uint256", + "name": "amount", + "type": "uint256" + }], + "internalType": "struct Order", + "name": "order", + "type": "tuple" + }], + "name": "takeOrder", + "outputs": [], + "stateMutability": "payable", + "type": "function" + }, + { + "inputs": [{ + "internalType": "address", + "name": "owner", + "type": "address" + }], + "stateMutability": "nonpayable", + "type": "constructor" + }, + { "stateMutability": "payable", "type": "receive" }, + { "stateMutability": "nonpayable", "type": "fallback" }, + { + "anonymous": false, + "inputs": [{ + "components": [{ + "internalType": "uint8", + "name": "kind", + "type": "uint8" + }], + "indexed": false, + "internalType": "struct Info", + "name": "info", + "type": "tuple" + }], + "name": "Traded", + "type": "event" + }, + { + "inputs": [{ + "components": [{ + "internalType": "bytes32", + "name": "id", + "type": "bytes32" + }], + "internalType": "struct Ctx", + "name": "ctx", + "type": "tuple" + }], + "name": "BadOrder", + "type": "error" + } + ]); + let meta: SolidityAbiMeta = serde_json::from_value(abi.clone())?; + assert_eq!(serde_json::to_value(&meta)?, abi); + Ok(()) + } + + #[test] + fn test_serialize_fn_exact_field_order_and_component_skipping() -> anyhow::Result<()> { + let abi = serde_json::json!([{ + "inputs": [{ + "internalType": "uint256", + "name": "a", + "type": "uint256" + }], + "name": "f", + "outputs": [], + "stateMutability": "view", + "type": "function" + }]); + let meta: SolidityAbiMeta = serde_json::from_value(abi)?; + // exact serialized text: solc artifact field order, camelCase + // stateMutability, and NO components key when components is None. + assert_eq!( + serde_json::to_string(&meta)?, + "[{\"inputs\":[{\"internalType\":\"uint256\",\"name\":\"a\",\"type\":\"uint256\"}],\"name\":\"f\",\"outputs\":[],\"stateMutability\":\"view\",\"type\":\"function\"}]" + ); + Ok(()) + } + + #[test] + fn test_deserialize_rejects_indexed_on_fn_io() { + let abi = serde_json::json!([{ + "inputs": [{ + "indexed": true, + "internalType": "uint256", + "name": "a", + "type": "uint256" + }], + "name": "f", + "outputs": [], + "stateMutability": "view", + "type": "function" + }]); + let result: Result = serde_json::from_value(abi); + let message = result.unwrap_err().to_string(); + assert!( + message.contains("indexed found on fn io"), + "unexpected message: {}", + message + ); + } + + #[test] + fn test_deserialize_requires_indexed_on_event_input() { + let abi = serde_json::json!([{ + "anonymous": false, + "inputs": [{ + "internalType": "uint256", + "name": "a", + "type": "uint256" + }], + "name": "E", + "type": "event" + }]); + let result: Result = serde_json::from_value(abi); + let message = result.unwrap_err().to_string(); + assert!( + message.contains("indexed missing on event input"), + "unexpected message: {}", + message + ); + } + + #[test] + fn test_deserialize_rejects_indexed_on_event_component() { + let abi = serde_json::json!([{ + "anonymous": false, + "inputs": [{ + "components": [{ + "indexed": true, + "internalType": "uint8", + "name": "kind", + "type": "uint8" + }], + "indexed": false, + "internalType": "struct Info", + "name": "info", + "type": "tuple" + }], + "name": "E", + "type": "event" + }]); + let result: Result = serde_json::from_value(abi); + let message = result.unwrap_err().to_string(); + assert!( + message.contains("indexed found on event component"), + "unexpected message: {}", + message + ); + } + + #[test] + fn test_deserialize_rejects_indexed_on_error_input() { + let abi = serde_json::json!([{ + "inputs": [{ + "indexed": true, + "internalType": "uint256", + "name": "a", + "type": "uint256" + }], + "name": "Bad", + "type": "error" + }]); + let result: Result = serde_json::from_value(abi); + assert!(result.unwrap_err().to_string().contains("indexed found"),); + } + + #[test] + fn test_deserialize_missing_required_fields_error_messages() { + let cases: Vec<(serde_json::Value, &str)> = vec![ + ( + serde_json::json!([{"inputs": [], "outputs": [], "stateMutability": "view", "type": "function"}]), + "function missing name", + ), + ( + serde_json::json!([{"inputs": [], "outputs": [], "name": "f", "type": "function"}]), + "function missing mutability", + ), + ( + serde_json::json!([{"inputs": [], "type": "constructor"}]), + "constructor missing mutability", + ), + ( + serde_json::json!([{"type": "receive"}]), + "receive missing mutability", + ), + ( + serde_json::json!([{"type": "fallback"}]), + "fallback missing mutability", + ), + ( + serde_json::json!([{"anonymous": false, "inputs": [], "type": "event"}]), + "event missing name", + ), + ( + serde_json::json!([{"inputs": [], "name": "E", "type": "event"}]), + "event missing anonymous", + ), + ( + serde_json::json!([{"inputs": [], "type": "error"}]), + "error missing name", + ), + ]; + for (abi, expected_message) in cases { + let result: Result = serde_json::from_value(abi.clone()); + let message = result.unwrap_err().to_string(); + assert!( + message.contains(expected_message), + "abi {} produced {:?} instead of {:?}", + abi, + message, + expected_message + ); + } + } + + #[test] + fn test_deserialize_missing_inputs_outputs_default_to_empty() -> anyhow::Result<()> { + let abi = serde_json::json!([{ + "name": "f", + "stateMutability": "view", + "type": "function" + }]); + let meta: SolidityAbiMeta = serde_json::from_value(abi)?; + let round = serde_json::to_value(&meta)?; + assert_eq!(round[0]["inputs"], serde_json::json!([])); + assert_eq!(round[0]["outputs"], serde_json::json!([])); + Ok(()) + } } From 6abd15ffd489a5f473d89c729b0c809129af1fb1 Mon Sep 17 00:00:00 2001 From: thedavidmeister Date: Sat, 22 Aug 2026 22:24:59 +0000 Subject: [PATCH 2/4] test: compare the wrapped metaboard_url against the input, not itself The destructured metaboard_url binding in test_get_metabytes_by_hash_success shadowed the server url it was meant to be checked against, so the assertion compared a String with its own to_string. Renaming the binding makes the assertion read the error field against the url the fetch was actually given. Co-Authored-By: Claude Fable 5 --- crates/cli/src/meta/types/authoring/v2.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/cli/src/meta/types/authoring/v2.rs b/crates/cli/src/meta/types/authoring/v2.rs index fb874ca0..4fa1388a 100644 --- a/crates/cli/src/meta/types/authoring/v2.rs +++ b/crates/cli/src/meta/types/authoring/v2.rs @@ -404,12 +404,12 @@ mod tests { let FetchAuthoringMetaV2WordError { contract_address, rpcs, - metaboard_url, + metaboard_url: err_metaboard_url, error, } = error; assert_eq!(contract_address, Address::from([0u8; 20])); assert_eq!(rpcs, vec![rpc_url.to_string()]); - assert_eq!(metaboard_url, metaboard_url.to_string()); + assert_eq!(err_metaboard_url, metaboard_url.to_string()); match error { AuthoringMetaV2Error::HasNoWords => {} _ => panic!("Unexpected error: {:?}", error), From ef51fb80c8ab2cae448289e90a22d57eb15caf78 Mon Sep 17 00:00:00 2001 From: thedavidmeister Date: Sat, 22 Aug 2026 22:40:27 +0000 Subject: [PATCH 3/4] test: close six mutation gaps a cross-probe found in the g3 suite A second independently derived mutant set probed against this branch surfaced six killable survivors: description validation removal in AuthoringMetaItem, missing-inputs defaults for the constructor, event and error deserialize arms, nested event tuple component recursion, and the cbor_decode error wrap in fetch_for_contract. One new discriminating test per gap. Co-Authored-By: Claude Fable 5 --- crates/cli/src/meta/types/authoring/v1.rs | 24 +++++++ crates/cli/src/meta/types/authoring/v2.rs | 29 ++++++++ crates/cli/src/meta/types/solidity_abi/v2.rs | 74 ++++++++++++++++++++ 3 files changed, 127 insertions(+) diff --git a/crates/cli/src/meta/types/authoring/v1.rs b/crates/cli/src/meta/types/authoring/v1.rs index 0f33c75d..cb7a2d2c 100644 --- a/crates/cli/src/meta/types/authoring/v1.rs +++ b/crates/cli/src/meta/types/authoring/v1.rs @@ -354,4 +354,28 @@ mod tests { assert_eq!(AuthoringMeta::try_from(item)?, expected); Ok(()) } + #[test] + fn test_description_rejects_unprintable_chars() { + use validator::Validate; + // printable ASCII description passes REGEX_RAIN_STRING + let item = AuthoringMetaItem { + word: "stack".to_string(), + operand_parser_offset: 0u8, + description: "All printable ASCII is fine.".to_string(), + }; + assert!(item.validate().is_ok()); + + // a non-printable control character is rejected, and the rejection + // surfaces through abi_encode_validate as ValidationErrors + let item = AuthoringMetaItem { + word: "stack".to_string(), + operand_parser_offset: 0u8, + description: "bell \u{7} is not printable".to_string(), + }; + assert!(item.validate().is_err()); + assert!(matches!( + item.abi_encode_validate(), + Err(Error::ValidationErrors(_)) + )); + } } diff --git a/crates/cli/src/meta/types/authoring/v2.rs b/crates/cli/src/meta/types/authoring/v2.rs index 4fa1388a..b424cee0 100644 --- a/crates/cli/src/meta/types/authoring/v2.rs +++ b/crates/cli/src/meta/types/authoring/v2.rs @@ -775,4 +775,33 @@ mod tests { assert_eq!(result.words[0].word, "test"); assert_eq!(result.words[1].description, "description 2"); } + #[tokio::test] + async fn test_fetch_for_contract_invalid_cbor_is_meta_error() { + let hash = [1u8; 32]; + let rpc_server = MockServer::start_async().await; + mock_described_by_rpc(&rpc_server, hash); + + // the metaboard answers with bytes that are not valid cbor, so the + // pipeline must surface the cbor_decode failure as MetaError rather + // than any other variant + let metaboard_server = MockServer::start_async().await; + metaboard_server.mock(|when, then| { + when.method(POST).path("/"); + then.status(200).json_body_obj(&serde_json::json!({ + "data": { "metaV1S": [metaboard_meta_entry("0x01")] } + })); + }); + + let result = AuthoringMetaV2::fetch_for_contract( + Address::from([0u8; 20]), + vec![rpc_server.url("/")], + metaboard_server.url("/"), + ) + .await; + let error = result.unwrap_err(); + match error.error { + AuthoringMetaV2Error::MetaError(_) => {} + other => panic!("expected MetaError, got {:?}", other), + } + } } diff --git a/crates/cli/src/meta/types/solidity_abi/v2.rs b/crates/cli/src/meta/types/solidity_abi/v2.rs index db50ad99..d2854e39 100644 --- a/crates/cli/src/meta/types/solidity_abi/v2.rs +++ b/crates/cli/src/meta/types/solidity_abi/v2.rs @@ -960,4 +960,78 @@ mod tests { assert_eq!(round[0]["outputs"], serde_json::json!([])); Ok(()) } + // constructor, event and error arms each default missing inputs to an + // empty vec, independently of the function arm + #[test] + fn test_deserialize_missing_inputs_default_for_constructor_event_error() -> anyhow::Result<()> + { + let meta: SolidityAbiMeta = + serde_json::from_str(r#"[{"type":"constructor","stateMutability":"nonpayable"}]"#)?; + assert_eq!( + serde_json::to_value(&meta)?, + serde_json::json!([{ + "inputs": [], + "stateMutability": "nonpayable", + "type": "constructor" + }]) + ); + + let meta: SolidityAbiMeta = + serde_json::from_str(r#"[{"type":"event","name":"E","anonymous":false}]"#)?; + assert_eq!( + serde_json::to_value(&meta)?, + serde_json::json!([{ + "anonymous": false, + "inputs": [], + "name": "E", + "type": "event" + }]) + ); + + let meta: SolidityAbiMeta = serde_json::from_str(r#"[{"type":"error","name":"X"}]"#)?; + assert_eq!( + serde_json::to_value(&meta)?, + serde_json::json!([{ + "inputs": [], + "name": "X", + "type": "error" + }]) + ); + Ok(()) + } + + // an event input whose tuple component itself carries components: the + // inner list only survives serialization if + // map_item_event_input_component recurses into it + #[test] + fn test_event_component_nested_components_roundtrip() -> anyhow::Result<()> { + let original: serde_json::Value = serde_json::from_str( + r#"[{ + "anonymous": false, + "inputs": [ + { + "components": [ + { + "components": [ + { "internalType": "uint256", "name": "q", "type": "uint256" } + ], + "internalType": "struct T", + "name": "t", + "type": "tuple" + } + ], + "indexed": false, + "internalType": "struct U", + "name": "u", + "type": "tuple" + } + ], + "name": "E", + "type": "event" + }]"#, + )?; + let meta: SolidityAbiMeta = serde_json::from_value(original.clone())?; + assert_eq!(serde_json::to_value(&meta)?, original); + Ok(()) + } } From 3b5a506e8387eb918e77b72e39ac308eebabb350 Mon Sep 17 00:00:00 2001 From: thedavidmeister Date: Sat, 22 Aug 2026 22:45:59 +0000 Subject: [PATCH 4/4] style: rustfmt the wrapped test signature Co-Authored-By: Claude Fable 5 --- crates/cli/src/meta/types/solidity_abi/v2.rs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/crates/cli/src/meta/types/solidity_abi/v2.rs b/crates/cli/src/meta/types/solidity_abi/v2.rs index d2854e39..18b41ff5 100644 --- a/crates/cli/src/meta/types/solidity_abi/v2.rs +++ b/crates/cli/src/meta/types/solidity_abi/v2.rs @@ -963,8 +963,7 @@ mod tests { // constructor, event and error arms each default missing inputs to an // empty vec, independently of the function arm #[test] - fn test_deserialize_missing_inputs_default_for_constructor_event_error() -> anyhow::Result<()> - { + fn test_deserialize_missing_inputs_default_for_constructor_event_error() -> anyhow::Result<()> { let meta: SolidityAbiMeta = serde_json::from_str(r#"[{"type":"constructor","stateMutability":"nonpayable"}]"#)?; assert_eq!(