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("integer", "number or decimal/hex string", other)),
90        },
91        ParamType::Boolean => match value {
92            // Same lenient forms the resolver coerces: bool, 0/1, "true"/"false".
93            Value::Bool(_) | Value::Number(_) | Value::String(_) => Ok(leaf("bool", value, nested)),
94            other => Err(wrong_shape("boolean", "bool", other)),
95        },
96        ParamType::Bytes => match value {
97            Value::String(_) | Value::Object(_) => Ok(leaf("bytes", value, nested)),
98            other => Err(wrong_shape("bytes", "hex string or bytes envelope", other)),
99        },
100        ParamType::Address => match value {
101            Value::String(_) => Ok(leaf("address", value, nested)),
102            other => Err(wrong_shape("address", "bech32 or hex string", other)),
103        },
104        ParamType::UtxoRef => match value {
105            Value::String(_) => Ok(leaf("utxoRef", value, nested)),
106            other => Err(wrong_shape("utxoRef", "txid#index string", other)),
107        },
108
109        // Unit lowers to a nullary struct.
110        ParamType::Unit => Ok(json!({ "struct": { "constructor": 0, "fields": [] } })),
111
112        ParamType::List(inner) => {
113            let items = value
114                .as_array()
115                .ok_or_else(|| wrong_shape("list", "array", value))?;
116            let encoded = items
117                .iter()
118                .map(|v| marshal(inner, v, true))
119                .collect::<Result<Vec<_>, _>>()?;
120            Ok(json!({ "list": encoded }))
121        }
122
123        ParamType::Tuple(elem_types) => {
124            let items = value
125                .as_array()
126                .ok_or_else(|| wrong_shape("tuple", "array", value))?;
127            if items.len() != elem_types.len() {
128                return Err(EncodeError::TupleArity {
129                    expected: elem_types.len(),
130                    got: items.len(),
131                });
132            }
133            let encoded = elem_types
134                .iter()
135                .zip(items)
136                .map(|(t, v)| marshal(t, v, true))
137                .collect::<Result<Vec<_>, _>>()?;
138            Ok(json!({ "tuple": encoded }))
139        }
140
141        ParamType::Map(value_type) => {
142            let obj = value
143                .as_object()
144                .ok_or_else(|| wrong_shape("map", "object", value))?;
145            // The `.tii` erases the key type (JSON object keys are strings), so
146            // keys become `string` leaves; sort for a deterministic pair order.
147            let mut keys: Vec<&String> = obj.keys().collect();
148            keys.sort();
149            let pairs = keys
150                .into_iter()
151                .map(|k| Ok(json!([json!({ "string": k }), marshal(value_type, &obj[k], true)?])))
152                .collect::<Result<Vec<_>, EncodeError>>()?;
153            Ok(json!({ "map": pairs }))
154        }
155
156        // Record → constructor 0; variant resolves its case index. Both emit the
157        // positional `struct` form.
158        ParamType::Record(fields) => Ok(json!({
159            "struct": { "constructor": 0, "fields": marshal_record_fields(fields, value)? }
160        })),
161
162        ParamType::Variant(cases) => marshal_variant(cases, value),
163
164        // No wire-leaf form and no element types to drive encoding: pass the value
165        // through and let the resolver coerce it via the flat type.
166        ParamType::Utxo | ParamType::AnyAsset | ParamType::Unknown(_) => Ok(value.clone()),
167    }
168}
169
170/// Renders a scalar leaf: bare at the top level (the resolver knows the param's
171/// type), tagged when nested inside an aggregate (it doesn't).
172fn leaf(tag: &str, value: &Value, nested: bool) -> Value {
173    if nested {
174        json!({ tag: value })
175    } else {
176        value.clone()
177    }
178}
179
180fn wrong_shape(kind: &'static str, expected: &'static str, got: &Value) -> EncodeError {
181    EncodeError::WrongShape {
182        kind,
183        expected,
184        got: shape_of(got).to_string(),
185    }
186}
187
188/// Marshals a record's fields **positionally** in declared order, mapping the
189/// user's by-name object. Rejects missing or extra fields up front.
190fn marshal_record_fields(
191    fields: &[(String, ParamType)],
192    value: &Value,
193) -> Result<Vec<Value>, EncodeError> {
194    let obj = value
195        .as_object()
196        .ok_or_else(|| wrong_shape("record", "object", value))?;
197
198    for key in obj.keys() {
199        if !fields.iter().any(|(name, _)| name == key) {
200            return Err(EncodeError::UnknownField(key.clone()));
201        }
202    }
203
204    fields
205        .iter()
206        .map(|(name, ty)| {
207            let field_value = obj
208                .get(name)
209                .ok_or_else(|| EncodeError::MissingField(name.clone()))?;
210            marshal(ty, field_value, true)
211        })
212        .collect()
213}
214
215/// Marshals an externally-tagged variant value `{ "<Case>": <payload> }` into a
216/// `struct` whose `constructor` is the case index from the `.tii` `oneOf` order.
217fn marshal_variant(cases: &[VariantCase], value: &Value) -> Result<Value, EncodeError> {
218    let obj = value.as_object().ok_or(EncodeError::BadVariant)?;
219    if obj.len() != 1 {
220        return Err(EncodeError::BadVariant);
221    }
222    let (tag, payload) = obj.iter().next().expect("one entry");
223
224    let index = cases
225        .iter()
226        .position(|c| &c.tag == tag)
227        .ok_or_else(|| EncodeError::UnknownCase(tag.clone()))?;
228
229    let fields = match &*cases[index].fields {
230        ParamType::Record(field_types) => marshal_record_fields(field_types, payload)?,
231        // Defensive: a non-record payload encodes as a single field.
232        other => vec![marshal(other, payload, true)?],
233    };
234
235    Ok(json!({ "struct": { "constructor": index, "fields": fields } }))
236}
237
238#[cfg(test)]
239mod tests {
240    use super::*;
241    use serde_json::json;
242    use std::collections::HashMap;
243
244    /// Builds a `ParamType` from a JSON schema node + components (mirrors how the
245    /// SDK interprets a `.tii`).
246    fn param_type(schema: &Value, components: &HashMap<String, Value>) -> ParamType {
247        ParamType::from_json_schema(schema, components)
248    }
249
250    /// Loads the shared wire-vectors oracle. The vectors live in the umbrella's
251    /// `sdk-spec`; this repo also keeps a copy under `tests/fixtures`. We resolve
252    /// whichever is reachable so the suite passes both standalone and in-tree.
253    fn wire_vectors() -> Value {
254        let manifest = env!("CARGO_MANIFEST_DIR");
255        let candidates = [
256            format!("{manifest}/tests/fixtures/wire-vectors.json"),
257            format!("{manifest}/../../sdk-spec/test-vectors/complex-types/wire-vectors.json"),
258            format!("{manifest}/../../../sdks/sdk-spec/test-vectors/complex-types/wire-vectors.json"),
259        ];
260        for path in candidates {
261            if let Ok(contents) = std::fs::read_to_string(&path) {
262                return serde_json::from_str(&contents).expect("wire-vectors.json parses");
263            }
264        }
265        panic!("could not locate wire-vectors.json in any known path");
266    }
267
268    fn components(vectors: &Value) -> HashMap<String, Value> {
269        vectors["components"]
270            .as_object()
271            .map(|m| m.iter().map(|(k, v)| (k.clone(), v.clone())).collect())
272            .unwrap_or_default()
273    }
274
275    #[test]
276    fn encodes_all_accept_vectors() {
277        let vectors = wire_vectors();
278        let components = components(&vectors);
279
280        for vector in vectors["accept"].as_array().unwrap() {
281            let name = vector["name"].as_str().unwrap();
282            let param = param_type(&vector["schema"], &components);
283            let got = encode(&param, &vector["value"])
284                .unwrap_or_else(|e| panic!("vector `{name}` failed to encode: {e}"));
285            assert_eq!(got, vector["tagged"], "vector `{name}` wire mismatch");
286        }
287    }
288
289    #[test]
290    fn rejects_all_reject_vectors() {
291        let vectors = wire_vectors();
292        let components = components(&vectors);
293
294        for vector in vectors["reject"].as_array().unwrap() {
295            let name = vector["name"].as_str().unwrap();
296            let param = param_type(&vector["schema"], &components);
297            let result = encode(&param, &vector["value"]);
298            assert!(
299                result.is_err(),
300                "vector `{name}` should have been rejected, got {result:?}"
301            );
302        }
303    }
304
305    #[test]
306    fn record_field_order_follows_required_not_alphabetical() {
307        // Meta { tags: List<Int>, level: Int } — required = [tags, level], while
308        // `properties` alphabetizes to [level, tags]. The struct fields must be
309        // [list, int], not [int, list].
310        let schema = json!({
311            "type": "object",
312            "properties": {
313                "level": { "type": "integer" },
314                "tags": { "type": "array", "items": { "type": "integer" } }
315            },
316            "required": ["tags", "level"]
317        });
318        let param = param_type(&schema, &HashMap::new());
319        let got = encode(&param, &json!({ "level": 7, "tags": [1, 2, 3] })).unwrap();
320        assert_eq!(
321            got,
322            json!({
323                "struct": {
324                    "constructor": 0,
325                    "fields": [{ "list": [{ "int": 1 }, { "int": 2 }, { "int": 3 }] }, { "int": 7 }]
326                }
327            })
328        );
329    }
330
331    #[test]
332    fn top_level_scalars_render_bare() {
333        // A scalar at the top level is sent bare; the resolver coerces it.
334        let int = param_type(&json!({ "type": "integer" }), &HashMap::new());
335        assert_eq!(encode(&int, &json!(5)).unwrap(), json!(5));
336
337        let bytes = param_type(
338            &json!({ "$ref": "https://tx3.land/specs/v1beta0/tii#/$defs/Bytes" }),
339            &HashMap::new(),
340        );
341        assert_eq!(encode(&bytes, &json!("cafe")).unwrap(), json!("cafe"));
342    }
343
344    #[test]
345    fn nested_scalars_are_tagged() {
346        // The same scalar nested inside a list is tagged.
347        let list = param_type(
348            &json!({ "type": "array", "items": { "type": "integer" } }),
349            &HashMap::new(),
350        );
351        assert_eq!(
352            encode(&list, &json!([5])).unwrap(),
353            json!({ "list": [{ "int": 5 }] })
354        );
355    }
356}