Skip to main content

tx3_sdk/tii/
encode.rs

1//! Type-directed argument encoding into the TRP `TaggedArg` wire form.
2//!
3//! A resolve request carries an untyped TIR, so the resolver can't recover the
4//! structure of an aggregate argument (record, list, tuple, map) on its own. The
5//! type lives in the `.tii`, so the SDK walks the resolved [`ParamType`] with the
6//! user value and emits the self-describing `TaggedArg` (single-key tagged,
7//! recursive — schema in `core/trp/v1beta0/trp.json`, prose in the SDK spec's
8//! `api-surface/args.md`); the resolver then decodes it without a schema.
9//!
10//! [`encode`] runs for every mapped arg as one recursive walk: a top-level
11//! scalar comes back bare (the resolver coerces it via the flat type), while
12//! aggregates and any nested leaf are tagged.
13
14use serde_json::{json, Value};
15use thiserror::Error;
16
17use super::schema::{ParamType, VariantCase};
18
19/// An argument value whose shape does not match its declared [`ParamType`],
20/// surfaced before the request is sent rather than as an opaque resolver error.
21#[derive(Debug, Error)]
22pub enum EncodeError {
23    /// A value's JSON kind didn't match what the param type expects.
24    #[error("expected {expected} for a `{kind}` argument, got `{got}`")]
25    WrongShape {
26        /// The `ParamType` kind being encoded (e.g. `list`, `record`).
27        kind: &'static str,
28        /// The JSON shape that was required (e.g. `array`, `object`).
29        expected: &'static str,
30        /// The JSON shape actually provided.
31        got: String,
32    },
33
34    /// A tuple value had the wrong number of elements.
35    #[error("tuple arity mismatch: expected {expected} element(s), got {got}")]
36    TupleArity {
37        /// The declared tuple arity.
38        expected: usize,
39        /// The arity of the provided value.
40        got: usize,
41    },
42
43    /// A record value was missing a declared field.
44    #[error("missing record field `{0}`")]
45    MissingField(String),
46
47    /// A record value carried a field the type does not declare.
48    #[error("unknown record field `{0}`")]
49    UnknownField(String),
50
51    /// A variant value named a case the type does not declare.
52    #[error("unknown variant case `{0}`")]
53    UnknownCase(String),
54
55    /// A variant value was not a single-key object naming its case.
56    #[error("variant value must be a single-key object naming the case")]
57    BadVariant,
58}
59
60/// The JSON shape name of a value, for [`EncodeError`] messages.
61fn shape_of(value: &Value) -> &'static str {
62    match value {
63        Value::Null => "null",
64        Value::Bool(_) => "bool",
65        Value::Number(_) => "number",
66        Value::String(_) => "string",
67        Value::Array(_) => "array",
68        Value::Object(_) => "object",
69    }
70}
71
72/// Marshals an argument `value` to its TRP wire form, directed by `param`.
73///
74/// One recursive walk over `(type, value)`. A leaf renders bare at the top level
75/// — the resolver coerces it via the param's flat type — and tagged when it sits
76/// inside an aggregate, where the resolver has no element type. Aggregates always
77/// render to their tagged structural form. Errors if `value`'s shape can't match
78/// `param`.
79pub fn encode(param: &ParamType, value: &Value) -> Result<Value, EncodeError> {
80    marshal(param, value, false)
81}
82
83/// `nested` is true when `value` sits inside an aggregate, where scalar leaves
84/// must be tagged for the schema-less resolver.
85fn marshal(param: &ParamType, value: &Value, nested: bool) -> Result<Value, EncodeError> {
86    match param {
87        ParamType::Integer => match value {
88            Value::Number(_) | Value::String(_) => Ok(leaf("int", value, nested)),
89            other => Err(wrong_shape(
90                "integer",
91                "number or decimal/hex string",
92                other,
93            )),
94        },
95        ParamType::Boolean => match value {
96            // Same lenient forms the resolver coerces: bool, 0/1, "true"/"false".
97            Value::Bool(_) | Value::Number(_) | Value::String(_) => Ok(leaf("bool", value, nested)),
98            other => Err(wrong_shape("boolean", "bool", other)),
99        },
100        ParamType::Bytes => match value {
101            Value::String(_) | Value::Object(_) => Ok(leaf("bytes", value, nested)),
102            // A native byte array (e.g. a codegen `Vec<u8>` param) serializes to
103            // a JSON array of integers; canonicalize it to 0x-prefixed hex, the
104            // wire form the resolver coerces (SDK spec §3.9).
105            Value::Array(items) => {
106                let bytes = byte_array(items).ok_or_else(|| {
107                    wrong_shape("bytes", "hex string, bytes envelope, or byte array", value)
108                })?;
109                let hex = Value::String(format!("0x{}", hex::encode(bytes)));
110                Ok(leaf("bytes", &hex, nested))
111            }
112            other => Err(wrong_shape(
113                "bytes",
114                "hex string, bytes envelope, or byte array",
115                other,
116            )),
117        },
118        ParamType::Address => match value {
119            Value::String(_) => Ok(leaf("address", value, nested)),
120            other => Err(wrong_shape("address", "bech32 or hex string", other)),
121        },
122        ParamType::UtxoRef => match value {
123            Value::String(_) => Ok(leaf("utxoRef", value, nested)),
124            other => Err(wrong_shape("utxoRef", "txid#index string", other)),
125        },
126
127        // Unit lowers to a nullary struct.
128        ParamType::Unit => Ok(json!({ "struct": { "constructor": 0, "fields": [] } })),
129
130        ParamType::List(inner) => {
131            let items = value
132                .as_array()
133                .ok_or_else(|| wrong_shape("list", "array", value))?;
134            let encoded = items
135                .iter()
136                .map(|v| marshal(inner, v, true))
137                .collect::<Result<Vec<_>, _>>()?;
138            Ok(json!({ "list": encoded }))
139        }
140
141        ParamType::Tuple(elem_types) => {
142            let items = value
143                .as_array()
144                .ok_or_else(|| wrong_shape("tuple", "array", value))?;
145            if items.len() != elem_types.len() {
146                return Err(EncodeError::TupleArity {
147                    expected: elem_types.len(),
148                    got: items.len(),
149                });
150            }
151            let encoded = elem_types
152                .iter()
153                .zip(items)
154                .map(|(t, v)| marshal(t, v, true))
155                .collect::<Result<Vec<_>, _>>()?;
156            Ok(json!({ "tuple": encoded }))
157        }
158
159        ParamType::Map(value_type) => {
160            let obj = value
161                .as_object()
162                .ok_or_else(|| wrong_shape("map", "object", value))?;
163            // The `.tii` erases the key type (JSON object keys are strings), so
164            // keys become `string` leaves; sort for a deterministic pair order.
165            let mut keys: Vec<&String> = obj.keys().collect();
166            keys.sort();
167            let pairs = keys
168                .into_iter()
169                .map(|k| {
170                    Ok(json!([
171                        json!({ "string": k }),
172                        marshal(value_type, &obj[k], true)?
173                    ]))
174                })
175                .collect::<Result<Vec<_>, EncodeError>>()?;
176            Ok(json!({ "map": pairs }))
177        }
178
179        // Record → constructor 0; variant resolves its case index. Both emit the
180        // positional `struct` form.
181        ParamType::Record(fields) => Ok(json!({
182            "struct": { "constructor": 0, "fields": marshal_record_fields(fields, value)? }
183        })),
184
185        ParamType::Variant(cases) => marshal_variant(cases, value),
186
187        // No wire-leaf form and no element types to drive encoding: pass the value
188        // through and let the resolver coerce it via the flat type.
189        ParamType::Utxo | ParamType::AnyAsset | ParamType::Unknown(_) => Ok(value.clone()),
190    }
191}
192
193/// Interprets a JSON array as raw bytes: every element must be an integer in
194/// `0..=255`. `None` if any element is not.
195fn byte_array(items: &[Value]) -> Option<Vec<u8>> {
196    items
197        .iter()
198        .map(|v| v.as_u64().filter(|b| *b <= u8::MAX as u64).map(|b| b as u8))
199        .collect()
200}
201
202/// Renders a scalar leaf: bare at the top level (the resolver knows the param's
203/// type), tagged when nested inside an aggregate (it doesn't).
204fn leaf(tag: &str, value: &Value, nested: bool) -> Value {
205    if nested {
206        json!({ tag: value })
207    } else {
208        value.clone()
209    }
210}
211
212fn wrong_shape(kind: &'static str, expected: &'static str, got: &Value) -> EncodeError {
213    EncodeError::WrongShape {
214        kind,
215        expected,
216        got: shape_of(got).to_string(),
217    }
218}
219
220/// Marshals a record's fields **positionally** in declared order, mapping the
221/// user's by-name object. Rejects missing or extra fields up front.
222fn marshal_record_fields(
223    fields: &[(String, ParamType)],
224    value: &Value,
225) -> Result<Vec<Value>, EncodeError> {
226    let obj = value
227        .as_object()
228        .ok_or_else(|| wrong_shape("record", "object", value))?;
229
230    for key in obj.keys() {
231        if !fields.iter().any(|(name, _)| name == key) {
232            return Err(EncodeError::UnknownField(key.clone()));
233        }
234    }
235
236    fields
237        .iter()
238        .map(|(name, ty)| {
239            let field_value = obj
240                .get(name)
241                .ok_or_else(|| EncodeError::MissingField(name.clone()))?;
242            marshal(ty, field_value, true)
243        })
244        .collect()
245}
246
247/// Marshals an externally-tagged variant value `{ "<Case>": <payload> }` into a
248/// `struct` whose `constructor` is the case index from the `.tii` `oneOf` order.
249fn marshal_variant(cases: &[VariantCase], value: &Value) -> Result<Value, EncodeError> {
250    let obj = value.as_object().ok_or(EncodeError::BadVariant)?;
251    if obj.len() != 1 {
252        return Err(EncodeError::BadVariant);
253    }
254    let (tag, payload) = obj.iter().next().expect("one entry");
255
256    let index = cases
257        .iter()
258        .position(|c| &c.tag == tag)
259        .ok_or_else(|| EncodeError::UnknownCase(tag.clone()))?;
260
261    let fields = match &*cases[index].fields {
262        ParamType::Record(field_types) => marshal_record_fields(field_types, payload)?,
263        // Defensive: a non-record payload encodes as a single field.
264        other => vec![marshal(other, payload, true)?],
265    };
266
267    Ok(json!({ "struct": { "constructor": index, "fields": fields } }))
268}
269
270#[cfg(test)]
271mod tests {
272    use super::*;
273    use serde_json::json;
274    use std::collections::HashMap;
275
276    /// Builds a `ParamType` from a JSON schema node + components (mirrors how the
277    /// SDK interprets a `.tii`).
278    fn param_type(schema: &Value, components: &HashMap<String, Value>) -> ParamType {
279        ParamType::from_json_schema(schema, components)
280    }
281
282    /// Loads the shared wire-vectors oracle. The vectors live in the umbrella's
283    /// `sdk-spec`; this repo also keeps a copy under `tests/fixtures`. We resolve
284    /// whichever is reachable so the suite passes both standalone and in-tree.
285    fn wire_vectors() -> Value {
286        let manifest = env!("CARGO_MANIFEST_DIR");
287        let candidates = [
288            format!("{manifest}/tests/fixtures/wire-vectors.json"),
289            format!("{manifest}/../../sdk-spec/test-vectors/complex-types/wire-vectors.json"),
290            format!(
291                "{manifest}/../../../sdks/sdk-spec/test-vectors/complex-types/wire-vectors.json"
292            ),
293        ];
294        for path in candidates {
295            if let Ok(contents) = std::fs::read_to_string(&path) {
296                return serde_json::from_str(&contents).expect("wire-vectors.json parses");
297            }
298        }
299        panic!("could not locate wire-vectors.json in any known path");
300    }
301
302    fn components(vectors: &Value) -> HashMap<String, Value> {
303        vectors["components"]
304            .as_object()
305            .map(|m| m.iter().map(|(k, v)| (k.clone(), v.clone())).collect())
306            .unwrap_or_default()
307    }
308
309    #[test]
310    fn encodes_all_accept_vectors() {
311        let vectors = wire_vectors();
312        let components = components(&vectors);
313
314        for vector in vectors["accept"].as_array().unwrap() {
315            let name = vector["name"].as_str().unwrap();
316            let param = param_type(&vector["schema"], &components);
317            let got = encode(&param, &vector["value"])
318                .unwrap_or_else(|e| panic!("vector `{name}` failed to encode: {e}"));
319            assert_eq!(got, vector["tagged"], "vector `{name}` wire mismatch");
320        }
321    }
322
323    #[test]
324    fn rejects_all_reject_vectors() {
325        let vectors = wire_vectors();
326        let components = components(&vectors);
327
328        for vector in vectors["reject"].as_array().unwrap() {
329            let name = vector["name"].as_str().unwrap();
330            let param = param_type(&vector["schema"], &components);
331            let result = encode(&param, &vector["value"]);
332            assert!(
333                result.is_err(),
334                "vector `{name}` should have been rejected, got {result:?}"
335            );
336        }
337    }
338
339    #[test]
340    fn record_field_order_follows_required_not_alphabetical() {
341        // Meta { tags: List<Int>, level: Int } — required = [tags, level], while
342        // `properties` alphabetizes to [level, tags]. The struct fields must be
343        // [list, int], not [int, list].
344        let schema = json!({
345            "type": "object",
346            "properties": {
347                "level": { "type": "integer" },
348                "tags": { "type": "array", "items": { "type": "integer" } }
349            },
350            "required": ["tags", "level"]
351        });
352        let param = param_type(&schema, &HashMap::new());
353        let got = encode(&param, &json!({ "level": 7, "tags": [1, 2, 3] })).unwrap();
354        assert_eq!(
355            got,
356            json!({
357                "struct": {
358                    "constructor": 0,
359                    "fields": [{ "list": [{ "int": 1 }, { "int": 2 }, { "int": 3 }] }, { "int": 7 }]
360                }
361            })
362        );
363    }
364
365    #[test]
366    fn top_level_scalars_render_bare() {
367        // A scalar at the top level is sent bare; the resolver coerces it.
368        let int = param_type(&json!({ "type": "integer" }), &HashMap::new());
369        assert_eq!(encode(&int, &json!(5)).unwrap(), json!(5));
370
371        let bytes = param_type(
372            &json!({ "$ref": "https://tx3.land/specs/v1beta0/tii#/$defs/Bytes" }),
373            &HashMap::new(),
374        );
375        assert_eq!(encode(&bytes, &json!("cafe")).unwrap(), json!("cafe"));
376    }
377
378    #[test]
379    fn nested_scalars_are_tagged() {
380        // The same scalar nested inside a list is tagged.
381        let list = param_type(
382            &json!({ "type": "array", "items": { "type": "integer" } }),
383            &HashMap::new(),
384        );
385        assert_eq!(
386            encode(&list, &json!([5])).unwrap(),
387            json!({ "list": [{ "int": 5 }] })
388        );
389    }
390
391    fn bytes_schema() -> Value {
392        json!({ "$ref": "https://tx3.land/specs/v1beta0/tii#/$defs/Bytes" })
393    }
394
395    fn list_of_bytes_schema() -> Value {
396        json!({ "type": "array", "items": bytes_schema() })
397    }
398
399    #[test]
400    fn native_byte_arrays_canonicalize_to_hex() {
401        // A codegen `Vec<u8>` param serializes to a JSON array of integers; the
402        // encoder must canonicalize it to 0x-prefixed hex, not send it raw
403        // (regression: TRP `(-32005) value is not bytes: [1,1]`).
404        let bytes = param_type(&bytes_schema(), &HashMap::new());
405        assert_eq!(
406            encode(&bytes, &json!([1, 1])).unwrap(),
407            json!("0x0101"),
408            "top-level byte array renders bare canonical hex"
409        );
410
411        let list = param_type(&list_of_bytes_schema(), &HashMap::new());
412        assert_eq!(
413            encode(&list, &json!([[1, 2]])).unwrap(),
414            json!({ "list": [{ "bytes": "0x0102" }] }),
415            "nested byte array renders tagged canonical hex"
416        );
417    }
418
419    #[test]
420    fn rejects_non_byte_arrays_for_bytes() {
421        let bytes = param_type(&bytes_schema(), &HashMap::new());
422        assert!(encode(&bytes, &json!([1, 256])).is_err());
423        assert!(encode(&bytes, &json!([1, -1])).is_err());
424        assert!(encode(&bytes, &json!(["aa", 1])).is_err());
425        assert!(encode(&bytes, &json!(true)).is_err());
426    }
427
428    #[test]
429    fn hydra_init_arg_shapes() {
430        // Hydra `init` from the feedback repro: `participants` / `parties` are
431        // `List<Bytes>`, `head_id` is `Bytes`. Both hex-string and native
432        // byte-array element forms must produce a CBOR-decodable list of
433        // byte-strings, never a bare byte-string and never raw arrays
434        // (regression: `(-32005) target type not supported: List` /
435        // `value is not bytes: [1,2]`).
436        let list = param_type(&list_of_bytes_schema(), &HashMap::new());
437
438        // participants as hex strings
439        assert_eq!(
440            encode(&list, &json!(["0102", "0304"])).unwrap(),
441            json!({ "list": [{ "bytes": "0102" }, { "bytes": "0304" }] })
442        );
443        // participants as native byte arrays (`vec![vec![1, 2]]`)
444        assert_eq!(
445            encode(&list, &json!([[1, 2]])).unwrap(),
446            json!({ "list": [{ "bytes": "0x0102" }] })
447        );
448        // parties: same List<Bytes> shape, verifier-key bytes
449        assert_eq!(
450            encode(&list, &json!([[222, 173, 190, 239]])).unwrap(),
451            json!({ "list": [{ "bytes": "0xdeadbeef" }] })
452        );
453        // head_id: scalar Bytes stays bare at the top level
454        let bytes = param_type(&bytes_schema(), &HashMap::new());
455        assert_eq!(
456            encode(&bytes, &json!("abcd0123")).unwrap(),
457            json!("abcd0123")
458        );
459    }
460
461    #[test]
462    fn asteria_name_arg_shapes() {
463        // Asteria `create_ship`: `ship_name` / `pilot_name` are `Bytes` params.
464        // Hex-string input passes through bare; native byte arrays canonicalize
465        // (regression: `(-32005) value is not bytes: [1,1]`).
466        let bytes = param_type(&bytes_schema(), &HashMap::new());
467        assert_eq!(
468            encode(&bytes, &json!("53484950313233")).unwrap(),
469            json!("53484950313233")
470        );
471        assert_eq!(
472            encode(&bytes, &json!([83, 72, 73, 80])).unwrap(),
473            json!("0x53484950")
474        );
475    }
476}