diff --git a/sdk/src/tii/encode.rs b/sdk/src/tii/encode.rs new file mode 100644 index 0000000..9fc07db --- /dev/null +++ b/sdk/src/tii/encode.rs @@ -0,0 +1,356 @@ +//! Type-directed argument encoding into the TRP `TaggedArg` wire form. +//! +//! A resolve request carries an untyped TIR, so the resolver can't recover the +//! structure of an aggregate argument (record, list, tuple, map) on its own. The +//! type lives in the `.tii`, so the SDK walks the resolved [`ParamType`] with the +//! user value and emits the self-describing `TaggedArg` (single-key tagged, +//! recursive — schema in `core/trp/v1beta0/trp.json`, prose in the SDK spec's +//! `api-surface/args.md`); the resolver then decodes it without a schema. +//! +//! [`encode`] runs for every mapped arg as one recursive walk: a top-level +//! scalar comes back bare (the resolver coerces it via the flat type), while +//! aggregates and any nested leaf are tagged. + +use serde_json::{json, Value}; +use thiserror::Error; + +use super::schema::{ParamType, VariantCase}; + +/// An argument value whose shape does not match its declared [`ParamType`], +/// surfaced before the request is sent rather than as an opaque resolver error. +#[derive(Debug, Error)] +pub enum EncodeError { + /// A value's JSON kind didn't match what the param type expects. + #[error("expected {expected} for a `{kind}` argument, got `{got}`")] + WrongShape { + /// The `ParamType` kind being encoded (e.g. `list`, `record`). + kind: &'static str, + /// The JSON shape that was required (e.g. `array`, `object`). + expected: &'static str, + /// The JSON shape actually provided. + got: String, + }, + + /// A tuple value had the wrong number of elements. + #[error("tuple arity mismatch: expected {expected} element(s), got {got}")] + TupleArity { + /// The declared tuple arity. + expected: usize, + /// The arity of the provided value. + got: usize, + }, + + /// A record value was missing a declared field. + #[error("missing record field `{0}`")] + MissingField(String), + + /// A record value carried a field the type does not declare. + #[error("unknown record field `{0}`")] + UnknownField(String), + + /// A variant value named a case the type does not declare. + #[error("unknown variant case `{0}`")] + UnknownCase(String), + + /// A variant value was not a single-key object naming its case. + #[error("variant value must be a single-key object naming the case")] + BadVariant, +} + +/// The JSON shape name of a value, for [`EncodeError`] messages. +fn shape_of(value: &Value) -> &'static str { + match value { + Value::Null => "null", + Value::Bool(_) => "bool", + Value::Number(_) => "number", + Value::String(_) => "string", + Value::Array(_) => "array", + Value::Object(_) => "object", + } +} + +/// Marshals an argument `value` to its TRP wire form, directed by `param`. +/// +/// One recursive walk over `(type, value)`. A leaf renders bare at the top level +/// — the resolver coerces it via the param's flat type — and tagged when it sits +/// inside an aggregate, where the resolver has no element type. Aggregates always +/// render to their tagged structural form. Errors if `value`'s shape can't match +/// `param`. +pub fn encode(param: &ParamType, value: &Value) -> Result { + marshal(param, value, false) +} + +/// `nested` is true when `value` sits inside an aggregate, where scalar leaves +/// must be tagged for the schema-less resolver. +fn marshal(param: &ParamType, value: &Value, nested: bool) -> Result { + match param { + ParamType::Integer => match value { + Value::Number(_) | Value::String(_) => Ok(leaf("int", value, nested)), + other => Err(wrong_shape("integer", "number or decimal/hex string", other)), + }, + ParamType::Boolean => match value { + // Same lenient forms the resolver coerces: bool, 0/1, "true"/"false". + Value::Bool(_) | Value::Number(_) | Value::String(_) => Ok(leaf("bool", value, nested)), + other => Err(wrong_shape("boolean", "bool", other)), + }, + ParamType::Bytes => match value { + Value::String(_) | Value::Object(_) => Ok(leaf("bytes", value, nested)), + other => Err(wrong_shape("bytes", "hex string or bytes envelope", other)), + }, + ParamType::Address => match value { + Value::String(_) => Ok(leaf("address", value, nested)), + other => Err(wrong_shape("address", "bech32 or hex string", other)), + }, + ParamType::UtxoRef => match value { + Value::String(_) => Ok(leaf("utxoRef", value, nested)), + other => Err(wrong_shape("utxoRef", "txid#index string", other)), + }, + + // Unit lowers to a nullary struct. + ParamType::Unit => Ok(json!({ "struct": { "constructor": 0, "fields": [] } })), + + ParamType::List(inner) => { + let items = value + .as_array() + .ok_or_else(|| wrong_shape("list", "array", value))?; + let encoded = items + .iter() + .map(|v| marshal(inner, v, true)) + .collect::, _>>()?; + Ok(json!({ "list": encoded })) + } + + ParamType::Tuple(elem_types) => { + let items = value + .as_array() + .ok_or_else(|| wrong_shape("tuple", "array", value))?; + if items.len() != elem_types.len() { + return Err(EncodeError::TupleArity { + expected: elem_types.len(), + got: items.len(), + }); + } + let encoded = elem_types + .iter() + .zip(items) + .map(|(t, v)| marshal(t, v, true)) + .collect::, _>>()?; + Ok(json!({ "tuple": encoded })) + } + + ParamType::Map(value_type) => { + let obj = value + .as_object() + .ok_or_else(|| wrong_shape("map", "object", value))?; + // The `.tii` erases the key type (JSON object keys are strings), so + // keys become `string` leaves; sort for a deterministic pair order. + let mut keys: Vec<&String> = obj.keys().collect(); + keys.sort(); + let pairs = keys + .into_iter() + .map(|k| Ok(json!([json!({ "string": k }), marshal(value_type, &obj[k], true)?]))) + .collect::, EncodeError>>()?; + Ok(json!({ "map": pairs })) + } + + // Record → constructor 0; variant resolves its case index. Both emit the + // positional `struct` form. + ParamType::Record(fields) => Ok(json!({ + "struct": { "constructor": 0, "fields": marshal_record_fields(fields, value)? } + })), + + ParamType::Variant(cases) => marshal_variant(cases, value), + + // No wire-leaf form and no element types to drive encoding: pass the value + // through and let the resolver coerce it via the flat type. + ParamType::Utxo | ParamType::AnyAsset | ParamType::Unknown(_) => Ok(value.clone()), + } +} + +/// Renders a scalar leaf: bare at the top level (the resolver knows the param's +/// type), tagged when nested inside an aggregate (it doesn't). +fn leaf(tag: &str, value: &Value, nested: bool) -> Value { + if nested { + json!({ tag: value }) + } else { + value.clone() + } +} + +fn wrong_shape(kind: &'static str, expected: &'static str, got: &Value) -> EncodeError { + EncodeError::WrongShape { + kind, + expected, + got: shape_of(got).to_string(), + } +} + +/// Marshals a record's fields **positionally** in declared order, mapping the +/// user's by-name object. Rejects missing or extra fields up front. +fn marshal_record_fields( + fields: &[(String, ParamType)], + value: &Value, +) -> Result, EncodeError> { + let obj = value + .as_object() + .ok_or_else(|| wrong_shape("record", "object", value))?; + + for key in obj.keys() { + if !fields.iter().any(|(name, _)| name == key) { + return Err(EncodeError::UnknownField(key.clone())); + } + } + + fields + .iter() + .map(|(name, ty)| { + let field_value = obj + .get(name) + .ok_or_else(|| EncodeError::MissingField(name.clone()))?; + marshal(ty, field_value, true) + }) + .collect() +} + +/// Marshals an externally-tagged variant value `{ "": }` into a +/// `struct` whose `constructor` is the case index from the `.tii` `oneOf` order. +fn marshal_variant(cases: &[VariantCase], value: &Value) -> Result { + let obj = value.as_object().ok_or(EncodeError::BadVariant)?; + if obj.len() != 1 { + return Err(EncodeError::BadVariant); + } + let (tag, payload) = obj.iter().next().expect("one entry"); + + let index = cases + .iter() + .position(|c| &c.tag == tag) + .ok_or_else(|| EncodeError::UnknownCase(tag.clone()))?; + + let fields = match &*cases[index].fields { + ParamType::Record(field_types) => marshal_record_fields(field_types, payload)?, + // Defensive: a non-record payload encodes as a single field. + other => vec![marshal(other, payload, true)?], + }; + + Ok(json!({ "struct": { "constructor": index, "fields": fields } })) +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + use std::collections::HashMap; + + /// Builds a `ParamType` from a JSON schema node + components (mirrors how the + /// SDK interprets a `.tii`). + fn param_type(schema: &Value, components: &HashMap) -> ParamType { + ParamType::from_json_schema(schema, components) + } + + /// Loads the shared wire-vectors oracle. The vectors live in the umbrella's + /// `sdk-spec`; this repo also keeps a copy under `tests/fixtures`. We resolve + /// whichever is reachable so the suite passes both standalone and in-tree. + fn wire_vectors() -> Value { + let manifest = env!("CARGO_MANIFEST_DIR"); + let candidates = [ + format!("{manifest}/tests/fixtures/wire-vectors.json"), + format!("{manifest}/../../sdk-spec/test-vectors/complex-types/wire-vectors.json"), + format!("{manifest}/../../../sdks/sdk-spec/test-vectors/complex-types/wire-vectors.json"), + ]; + for path in candidates { + if let Ok(contents) = std::fs::read_to_string(&path) { + return serde_json::from_str(&contents).expect("wire-vectors.json parses"); + } + } + panic!("could not locate wire-vectors.json in any known path"); + } + + fn components(vectors: &Value) -> HashMap { + vectors["components"] + .as_object() + .map(|m| m.iter().map(|(k, v)| (k.clone(), v.clone())).collect()) + .unwrap_or_default() + } + + #[test] + fn encodes_all_accept_vectors() { + let vectors = wire_vectors(); + let components = components(&vectors); + + for vector in vectors["accept"].as_array().unwrap() { + let name = vector["name"].as_str().unwrap(); + let param = param_type(&vector["schema"], &components); + let got = encode(¶m, &vector["value"]) + .unwrap_or_else(|e| panic!("vector `{name}` failed to encode: {e}")); + assert_eq!(got, vector["tagged"], "vector `{name}` wire mismatch"); + } + } + + #[test] + fn rejects_all_reject_vectors() { + let vectors = wire_vectors(); + let components = components(&vectors); + + for vector in vectors["reject"].as_array().unwrap() { + let name = vector["name"].as_str().unwrap(); + let param = param_type(&vector["schema"], &components); + let result = encode(¶m, &vector["value"]); + assert!( + result.is_err(), + "vector `{name}` should have been rejected, got {result:?}" + ); + } + } + + #[test] + fn record_field_order_follows_required_not_alphabetical() { + // Meta { tags: List, level: Int } — required = [tags, level], while + // `properties` alphabetizes to [level, tags]. The struct fields must be + // [list, int], not [int, list]. + let schema = json!({ + "type": "object", + "properties": { + "level": { "type": "integer" }, + "tags": { "type": "array", "items": { "type": "integer" } } + }, + "required": ["tags", "level"] + }); + let param = param_type(&schema, &HashMap::new()); + let got = encode(¶m, &json!({ "level": 7, "tags": [1, 2, 3] })).unwrap(); + assert_eq!( + got, + json!({ + "struct": { + "constructor": 0, + "fields": [{ "list": [{ "int": 1 }, { "int": 2 }, { "int": 3 }] }, { "int": 7 }] + } + }) + ); + } + + #[test] + fn top_level_scalars_render_bare() { + // A scalar at the top level is sent bare; the resolver coerces it. + let int = param_type(&json!({ "type": "integer" }), &HashMap::new()); + assert_eq!(encode(&int, &json!(5)).unwrap(), json!(5)); + + let bytes = param_type( + &json!({ "$ref": "https://tx3.land/specs/v1beta0/tii#/$defs/Bytes" }), + &HashMap::new(), + ); + assert_eq!(encode(&bytes, &json!("cafe")).unwrap(), json!("cafe")); + } + + #[test] + fn nested_scalars_are_tagged() { + // The same scalar nested inside a list is tagged. + let list = param_type( + &json!({ "type": "array", "items": { "type": "integer" } }), + &HashMap::new(), + ); + assert_eq!( + encode(&list, &json!([5])).unwrap(), + json!({ "list": [{ "int": 5 }] }) + ); + } +} diff --git a/sdk/src/tii/mod.rs b/sdk/src/tii/mod.rs index 15abb8f..5266091 100644 --- a/sdk/src/tii/mod.rs +++ b/sdk/src/tii/mod.rs @@ -74,9 +74,11 @@ use crate::{ tii::spec::{Profile, Transaction}, }; +pub mod encode; mod schema; pub mod spec; +pub use encode::{encode, EncodeError}; pub use schema::{ParamMap, ParamType, VariantCase}; /// Error type for TII operations. @@ -100,6 +102,10 @@ pub enum Error { /// Profile name not found in the protocol. #[error("unknown profile: {0}")] UnknownProfile(String), + + /// A complex argument value did not match its declared parameter type. + #[error("failed to encode argument: {0}")] + EncodeArg(#[from] EncodeError), } /// A TX3 protocol loaded from a TII file. @@ -476,7 +482,23 @@ impl Invocation { /// Currently this method always succeeds, but returns `Result` for future /// compatibility. pub fn into_resolve_request(self) -> Result { - let args = self.args.clone().into_iter().collect(); + // Every arg is marshalled by its `.tii` `ParamType`: top-level scalars + // come back bare, aggregates tagged. An unmapped arg has no type, so it + // passes through untouched. Arg keys are lowercased on set while params + // keep their original case, so match case-insensitively. + let args = self + .args + .clone() + .into_iter() + .map(|(key, value)| match self + .params + .iter() + .find(|(name, _)| name.to_lowercase() == key) + { + Some((_, ty)) => Ok((key, encode::encode(ty, &value)?)), + None => Ok((key, value)), + }) + .collect::>()?; let tir = self.tir.clone(); @@ -562,7 +584,7 @@ mod tests { // a record (AssetClass) and a variant (Side). This exercises the // `components` threading through `Protocol::invoke`. match ¶ms["asset"] { - ParamType::Record(fields) => assert!(matches!(fields["policy"], ParamType::Bytes)), + rec @ ParamType::Record(_) => assert!(matches!(rec.field("policy"), Some(ParamType::Bytes))), other => panic!("expected asset record, got {other:?}"), } match ¶ms["side"] { @@ -570,5 +592,49 @@ mod tests { other => panic!("expected side variant, got {other:?}"), } } + + #[test] + fn invoke_encodes_aggregate_arg_into_wire_form() { + // End-to-end through the path `cshell`/`trix invoke` take (`set_args` → + // `into_resolve_request`) on a real TII: the `meta` record serializes to + // the tagged form while scalars stay bare. + let manifest_dir = env!("CARGO_MANIFEST_DIR"); + let tii = format!("{manifest_dir}/tests/fixtures/invoke.tii"); + + let protocol = Protocol::from_file(&tii).unwrap(); + let invoke = protocol.invoke("transfer", None).unwrap().with_args( + serde_json::from_value(json!({ + "sender": "addr_test1vqx…", + "receiver": "addr_test1vqyy…", + "quantity": 2_000_000, + "urgent": true, + "memo": "deadbeef", + "meta": { "tags": [1, 2, 3], "level": 7 } + })) + .unwrap(), + ); + + let request = invoke.into_resolve_request().unwrap(); + + // Fields are positional in declared order (tags, level) — `required` + // order, not alphabetical. + assert_eq!( + request.args["meta"], + json!({ + "struct": { + "constructor": 0, + "fields": [ + { "list": [{ "int": 1 }, { "int": 2 }, { "int": 3 }] }, + { "int": 7 } + ] + } + }) + ); + + // Scalars stay bare; the resolver coerces them via the flat type. + assert_eq!(request.args["quantity"], json!(2_000_000)); + assert_eq!(request.args["urgent"], json!(true)); + assert_eq!(request.args["memo"], json!("deadbeef")); + } } diff --git a/sdk/src/tii/schema.rs b/sdk/src/tii/schema.rs index 4a1c003..3d6edc5 100644 --- a/sdk/src/tii/schema.rs +++ b/sdk/src/tii/schema.rs @@ -7,7 +7,7 @@ //! fails: any shape it does not recognize becomes [`ParamType::Unknown`]. use serde_json::Value; -use std::collections::{BTreeMap, HashMap}; +use std::collections::HashMap; /// Map of parameter names to their types. /// @@ -60,8 +60,11 @@ pub enum ParamType { Tuple(Vec), /// String-keyed homogeneous map (`object` + `additionalProperties`). Map(Box), - /// User-defined record (`object` + `properties`), field name → type. - Record(BTreeMap), + /// User-defined record (`object` + `properties`), `(field name, type)` in + /// **declared order** (the schema's `required` array, which `tx3c` emits in + /// source order — `properties` is alphabetized and must not drive field + /// order). Encoding maps the user's by-name object to positional fields. + Record(Vec<(String, ParamType)>), /// User-defined tagged union (`oneOf`), externally tagged. Variant(Vec), /// A schema shape that could not be interpreted; carries the raw schema. @@ -78,6 +81,17 @@ pub struct VariantCase { } impl ParamType { + /// Looks up a field type by name in a [`ParamType::Record`]; `None` for any + /// other kind or an absent field. + pub fn field(&self, name: &str) -> Option<&ParamType> { + match self { + ParamType::Record(fields) => { + fields.iter().find(|(k, _)| k == name).map(|(_, ty)| ty) + } + _ => None, + } + } + /// Maps a built-in core `$ref` to its kind by trailing name, so both the /// canonical `…/tii#/$defs/` and legacy `…/core#` forms resolve. fn core_ref_type(reference: &str) -> Option { @@ -162,17 +176,40 @@ impl ParamType { if let Some(value) = schema.get("additionalProperties").filter(|v| v.is_object()) { ParamType::Map(Box::new(Self::from_json_schema(value, components))) } else if let Some(props) = schema.get("properties").and_then(Value::as_object) { - ParamType::Record( - props - .iter() - .map(|(k, v)| (k.clone(), Self::from_json_schema(v, components))) - .collect(), - ) + ParamType::Record(Self::record_fields(schema, props, components)) } else { ParamType::Unknown(schema.clone()) } } + /// Builds record fields in declared order: the `required` array first (source + /// order, as `tx3c` emits), then any remaining (alphabetized) `properties`. + fn record_fields( + schema: &Value, + props: &serde_json::Map, + components: &HashMap, + ) -> Vec<(String, ParamType)> { + let mut fields = Vec::with_capacity(props.len()); + let mut seen = std::collections::HashSet::new(); + + if let Some(required) = schema.get("required").and_then(Value::as_array) { + for name in required.iter().filter_map(Value::as_str) { + if let Some(field_schema) = props.get(name) { + fields.push((name.to_string(), Self::from_json_schema(field_schema, components))); + seen.insert(name.to_string()); + } + } + } + + for (k, v) in props { + if !seen.contains(k) { + fields.push((k.clone(), Self::from_json_schema(v, components))); + } + } + + fields + } + /// Creates a parameter type from a JSON schema node. /// /// Interprets every shape `tx3c` can emit (see the SDK spec's @@ -308,9 +345,9 @@ mod tests { "required": ["price", "live"] }); match pt(schema) { - ParamType::Record(fields) => { - assert!(matches!(fields["price"], ParamType::Integer)); - assert!(matches!(fields["live"], ParamType::Boolean)); + rec @ ParamType::Record(_) => { + assert!(matches!(rec.field("price"), Some(ParamType::Integer))); + assert!(matches!(rec.field("live"), Some(ParamType::Boolean))); } other => panic!("expected record, got {other:?}"), } @@ -331,12 +368,12 @@ mod tests { assert_eq!(cases.len(), 2); assert_eq!(cases[0].tag, "Buy"); assert_eq!(cases[1].tag, "Sell"); - match &*cases[1].fields { - ParamType::Record(fields) => { - assert!(matches!(fields["price"], ParamType::Integer)) - } - other => panic!("expected record fields, got {other:?}"), - } + let sell_fields = &*cases[1].fields; + assert!(matches!(sell_fields, ParamType::Record(_))); + assert!(matches!( + sell_fields.field("price"), + Some(ParamType::Integer) + )); } other => panic!("expected variant, got {other:?}"), } @@ -355,7 +392,7 @@ mod tests { ); let schema = json!({"$ref": "#/components/schemas/AssetClass"}); match ParamType::from_json_schema(&schema, &components) { - ParamType::Record(fields) => assert!(matches!(fields["policy"], ParamType::Bytes)), + rec @ ParamType::Record(_) => assert!(matches!(rec.field("policy"), Some(ParamType::Bytes))), other => panic!("expected record, got {other:?}"), } // Missing component → Unknown, never panics. diff --git a/sdk/tests/fixtures/invoke.tii b/sdk/tests/fixtures/invoke.tii new file mode 100644 index 0000000..0c29886 --- /dev/null +++ b/sdk/tests/fixtures/invoke.tii @@ -0,0 +1,91 @@ +{ + "components": { + "schemas": { + "Meta": { + "properties": { + "level": { + "type": "integer" + }, + "tags": { + "items": { + "type": "integer" + }, + "type": "array" + } + }, + "required": [ + "tags", + "level" + ], + "type": "object" + } + } + }, + "environment": { + "properties": {}, + "required": [], + "type": "object" + }, + "parties": { + "receiver": {}, + "sender": {} + }, + "profiles": { + "local": { + "environment": {}, + "parties": {} + }, + "mainnet": { + "environment": {}, + "parties": {} + }, + "preprod": { + "environment": {}, + "parties": {} + }, + "preview": { + "environment": {}, + "parties": {} + } + }, + "protocol": { + "name": "inv-tii", + "scope": "unknown", + "version": "0.0.0" + }, + "tii": { + "version": "v1beta0" + }, + "transactions": { + "transfer": { + "params": { + "properties": { + "memo": { + "$ref": "https://tx3.land/specs/v1beta0/tii#/$defs/Bytes" + }, + "meta": { + "$ref": "#/components/schemas/Meta" + }, + "quantity": { + "type": "integer" + }, + "urgent": { + "type": "boolean" + } + }, + "required": [ + "quantity", + "urgent", + "memo", + "meta" + ], + "type": "object" + }, + "tir": { + "content": "ab6466656573a1694576616c506172616d6a457870656374466565736a7265666572656e6365738066696e7075747381a3646e616d6566736f75726365657574786f73a1694576616c506172616da16b457870656374496e7075748266736f75726365a56761646472657373a1694576616c506172616da16b45787065637456616c7565826673656e64657267416464726573736a6d696e5f616d6f756e74a16b4576616c4275696c74496ea16341646482a16641737365747381a366706f6c696379644e6f6e656a61737365745f6e616d65644e6f6e6566616d6f756e74a1694576616c506172616da16b45787065637456616c756582687175616e7469747963496e74a1694576616c506172616d6a4578706563744665657363726566644e6f6e65646d616e79f46a636f6c6c61746572616cf46872656465656d6572644e6f6e65676f75747075747382a46761646472657373a1694576616c506172616da16b45787065637456616c756582687265636569766572674164647265737365646174756da166537472756374a26b636f6e7374727563746f7200666669656c647383a1694576616c506172616da16b45787065637456616c756582646d656d6f654279746573a1694576616c506172616da16b45787065637456616c75658266757267656e7464426f6f6ca1694576616c506172616da16b45787065637456616c756582646d657461a166437573746f6d644d65746166616d6f756e74a16641737365747381a366706f6c696379644e6f6e656a61737365745f6e616d65644e6f6e6566616d6f756e74a1694576616c506172616da16b45787065637456616c756582687175616e7469747963496e74686f7074696f6e616cf4a46761646472657373a1694576616c506172616da16b45787065637456616c7565826673656e646572674164647265737365646174756d644e6f6e6566616d6f756e74a16b4576616c4275696c74496ea16353756282a16b4576616c4275696c74496ea16353756282a16a4576616c436f65726365a16a496e746f417373657473a1694576616c506172616da16b457870656374496e7075748266736f75726365a56761646472657373a1694576616c506172616da16b45787065637456616c7565826673656e64657267416464726573736a6d696e5f616d6f756e74a16b4576616c4275696c74496ea16341646482a16641737365747381a366706f6c696379644e6f6e656a61737365745f6e616d65644e6f6e6566616d6f756e74a1694576616c506172616da16b45787065637456616c756582687175616e7469747963496e74a1694576616c506172616d6a4578706563744665657363726566644e6f6e65646d616e79f46a636f6c6c61746572616cf4a16641737365747381a366706f6c696379644e6f6e656a61737365745f6e616d65644e6f6e6566616d6f756e74a1694576616c506172616da16b45787065637456616c756582687175616e7469747963496e74a1694576616c506172616d6a45787065637446656573686f7074696f6e616cf46876616c6964697479f6656d696e747380656275726e7380656164686f63806a636f6c6c61746572616c80677369676e657273f6686d6574616461746180", + "encoding": "hex", + "version": "v1beta0" + } + } + } +} \ No newline at end of file diff --git a/sdk/tests/fixtures/wire-vectors.json b/sdk/tests/fixtures/wire-vectors.json new file mode 100644 index 0000000..e909ddb --- /dev/null +++ b/sdk/tests/fixtures/wire-vectors.json @@ -0,0 +1,164 @@ +{ + "description": "Shared oracle for the argument wire encoding (TRP `TaggedArg`, see core/trp/v1beta0/trp.json). Each `accept` vector pins a `.tii`-typed parameter — its JSON `schema` (the node a ParamType is built from), the flat TIR `type` the resolver sees, the native user `value`, and the `tagged` wire form. SDK encoders MUST turn (schema, value) into `tagged`; the resolver decoder MUST turn (type, tagged) back into the matching ArgValue. `reject` vectors are values an SDK encoder MUST refuse before sending. `components` holds the user-defined record/variant schemas referenced via `#/components/schemas/`; struct field order follows each schema's `required` array (source-declaration order, which tx3c emits — note `properties` is alphabetized and MUST NOT be used for order). Map pairs are emitted sorted by the JSON key string for determinism.", + "components": { + "Meta": { + "type": "object", + "properties": { + "level": { "type": "integer" }, + "tags": { "type": "array", "items": { "type": "integer" } } + }, + "required": ["tags", "level"] + }, + "AssetClass": { + "type": "object", + "properties": { + "policy": { "$ref": "https://tx3.land/specs/v1beta0/tii#/$defs/Bytes" }, + "name": { "$ref": "https://tx3.land/specs/v1beta0/tii#/$defs/Bytes" } + }, + "required": ["policy", "name"] + }, + "Side": { + "oneOf": [ + { + "type": "object", + "additionalProperties": false, + "required": ["Buy"], + "properties": { "Buy": { "type": "object", "properties": {}, "required": [] } } + }, + { + "type": "object", + "additionalProperties": false, + "required": ["Sell"], + "properties": { + "Sell": { + "type": "object", + "properties": { "price": { "type": "integer" } }, + "required": ["price"] + } + } + } + ] + } + }, + "accept": [ + { + "name": "list_of_int", + "schema": { "type": "array", "items": { "type": "integer" } }, + "type": "list", + "value": [1, 2, 3], + "tagged": { "list": [{ "int": 1 }, { "int": 2 }, { "int": 3 }] } + }, + { + "name": "list_of_bytes", + "schema": { + "type": "array", + "items": { "$ref": "https://tx3.land/specs/v1beta0/tii#/$defs/Bytes" } + }, + "type": "list", + "value": ["deadbeef", "0xcafe"], + "tagged": { "list": [{ "bytes": "deadbeef" }, { "bytes": "0xcafe" }] } + }, + { + "name": "empty_list", + "schema": { "type": "array", "items": { "type": "integer" } }, + "type": "list", + "value": [], + "tagged": { "list": [] } + }, + { + "name": "tuple_int_bytes", + "schema": { + "type": "array", + "prefixItems": [ + { "type": "integer" }, + { "$ref": "https://tx3.land/specs/v1beta0/tii#/$defs/Bytes" } + ], + "items": false + }, + "type": "tuple", + "value": [42, "cafe"], + "tagged": { "tuple": [{ "int": 42 }, { "bytes": "cafe" }] } + }, + { + "name": "map_int_to_int", + "schema": { "type": "object", "additionalProperties": { "type": "integer" } }, + "type": "map", + "value": { "2": 200, "1": 100 }, + "tagged": { + "map": [ + [{ "string": "1" }, { "int": 100 }], + [{ "string": "2" }, { "int": 200 }] + ] + } + }, + { + "name": "record_asset_class", + "schema": { "$ref": "#/components/schemas/AssetClass" }, + "type": { "custom": "AssetClass" }, + "value": { "name": "0011", "policy": "aabb" }, + "tagged": { + "struct": { "constructor": 0, "fields": [{ "bytes": "aabb" }, { "bytes": "0011" }] } + } + }, + { + "name": "variant_side_buy", + "schema": { "$ref": "#/components/schemas/Side" }, + "type": { "custom": "Side" }, + "value": { "Buy": {} }, + "tagged": { "struct": { "constructor": 0, "fields": [] } } + }, + { + "name": "variant_side_sell", + "schema": { "$ref": "#/components/schemas/Side" }, + "type": { "custom": "Side" }, + "value": { "Sell": { "price": 5 } }, + "tagged": { "struct": { "constructor": 1, "fields": [{ "int": 5 }] } } + }, + { + "name": "meta_record_nested_list", + "comment": "The journey-critical 05-invoke shape: a record nesting a parametric List. Field order is required = [tags, level], NOT alphabetical [level, tags].", + "schema": { "$ref": "#/components/schemas/Meta" }, + "type": { "custom": "Meta" }, + "value": { "level": 7, "tags": [1, 2, 3] }, + "tagged": { + "struct": { + "constructor": 0, + "fields": [{ "list": [{ "int": 1 }, { "int": 2 }, { "int": 3 }] }, { "int": 7 }] + } + } + } + ], + "reject": [ + { + "name": "record_missing_field", + "schema": { "$ref": "#/components/schemas/Meta" }, + "value": { "tags": [1, 2, 3] }, + "reason": "missing required field 'level'" + }, + { + "name": "record_extra_field", + "schema": { "$ref": "#/components/schemas/Meta" }, + "value": { "tags": [1, 2, 3], "level": 7, "bogus": 1 }, + "reason": "unknown field 'bogus' not declared by the record" + }, + { + "name": "tuple_wrong_arity", + "schema": { + "type": "array", + "prefixItems": [ + { "type": "integer" }, + { "$ref": "https://tx3.land/specs/v1beta0/tii#/$defs/Bytes" } + ], + "items": false + }, + "value": [42], + "reason": "tuple arity 1 != declared 2" + }, + { + "name": "variant_unknown_case", + "schema": { "$ref": "#/components/schemas/Side" }, + "value": { "Nope": {} }, + "reason": "variant case 'Nope' not declared by Side" + } + ] +}