From 802db766ed51d7cff638901416213fb530c2b6be Mon Sep 17 00:00:00 2001 From: Santiago Date: Mon, 22 Jun 2026 09:39:49 -0300 Subject: [PATCH] feat(tii): type-directed argument encoding to the TaggedArg wire form Mirror the Rust reference: marshal every arg through one recursive walk over (ParamType, value) in `tii/encode.py`, wired into `into_resolve_request`. A scalar leaf renders bare at the top level (the resolver coerces it via the flat type) and tagged when nested in an aggregate; aggregates (list/tuple/map/record/variant) render to the self-describing tagged form. No separate scalar/aggregate path. Records map the user's by-name dict to positional struct fields in declared (`required`-array) order; variants resolve the case index from the `oneOf` ordering; map keys are `string` leaves, pairs sorted. Ill- shaped values are rejected before sending. The record ParamType stays a `dict[str, ParamType]` but is now built in `required` order so iteration is positional; the public type is unchanged. Tests drive the shared cross-SDK oracle (accept + reject vectors). Co-Authored-By: Claude Opus 4.8 (1M context) --- sdk/src/tx3_sdk/tii/__init__.py | 4 + sdk/src/tx3_sdk/tii/encode.py | 203 +++++++++++++++++++++++++++ sdk/src/tx3_sdk/tii/errors.py | 10 ++ sdk/src/tx3_sdk/tii/invocation.py | 20 ++- sdk/src/tx3_sdk/tii/param_type.py | 35 ++++- sdk/tests/fixtures/invoke.tii | 91 ++++++++++++ sdk/tests/fixtures/wire-vectors.json | 164 ++++++++++++++++++++++ sdk/tests/test_encode.py | 99 +++++++++++++ sdk/tests/test_tii.py | 38 +++++ 9 files changed, 656 insertions(+), 8 deletions(-) create mode 100644 sdk/src/tx3_sdk/tii/encode.py create mode 100644 sdk/tests/fixtures/invoke.tii create mode 100644 sdk/tests/fixtures/wire-vectors.json create mode 100644 sdk/tests/test_encode.py diff --git a/sdk/src/tx3_sdk/tii/__init__.py b/sdk/src/tx3_sdk/tii/__init__.py index 42d5584..e3a7035 100644 --- a/sdk/src/tx3_sdk/tii/__init__.py +++ b/sdk/src/tx3_sdk/tii/__init__.py @@ -1,6 +1,8 @@ """TII protocol loading and invocation helpers.""" +from tx3_sdk.tii.encode import encode from tx3_sdk.tii.errors import ( + EncodeArgError, InvalidJsonError, InvalidParamsSchemaError, MissingParamsError, @@ -12,6 +14,7 @@ from tx3_sdk.tii.protocol import Protocol __all__ = [ + "EncodeArgError", "InvalidJsonError", "InvalidParamsSchemaError", "Invocation", @@ -22,4 +25,5 @@ "UnknownProfileError", "UnknownTxError", "VariantCase", + "encode", ] diff --git a/sdk/src/tx3_sdk/tii/encode.py b/sdk/src/tx3_sdk/tii/encode.py new file mode 100644 index 0000000..67a38b5 --- /dev/null +++ b/sdk/src/tx3_sdk/tii/encode.py @@ -0,0 +1,203 @@ +"""Type-directed argument encoding into the TRP ``TaggedArg`` wire form. + +A TRP resolve request carries an **untyped** TIR, so the resolver cannot recover +the structure of an aggregate argument (a record, list, tuple, or map) on its +own. The full type lives in the ``.tii``, a client-side artifact — so the SDK is +authoritative: it walks the resolved :class:`~tx3_sdk.tii.param_type.ParamType` +alongside the user value and emits the deterministic, self-describing +``TaggedArg`` (single-key tagged, recursive — see the ``TaggedArg`` schema in +``core/trp/v1beta0/trp.json`` and the SDK spec's ``api-surface/args.md``). The +resolver then decodes it structurally, without a schema. + +It is one recursive walk over ``(type, value)``. A scalar 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. +""" + +from __future__ import annotations + +from typing import Any + +from tx3_sdk.tii.errors import EncodeArgError +from tx3_sdk.tii.param_type import ParamKind, ParamType, VariantCase + + +def _shape_of(value: Any) -> str: + """The JSON shape name of a value, for :class:`EncodeArgError` messages.""" + if value is None: + return "null" + if isinstance(value, bool): + return "bool" + if isinstance(value, (int, float)): + return "number" + if isinstance(value, str): + return "string" + if isinstance(value, list): + return "array" + if isinstance(value, dict): + return "object" + return type(value).__name__ + + +def _wrong_shape(kind: str, expected: str, value: Any) -> EncodeArgError: + return EncodeArgError( + f"expected {expected} for a `{kind}` argument, got `{_shape_of(value)}`" + ) + + +def _leaf(tag: str, value: Any, nested: bool) -> Any: + """Renders a scalar leaf: bare at the top level (the resolver knows the + param's flat type), tagged when nested inside an aggregate (it doesn't).""" + return {tag: value} if nested else value + + +def encode(param: ParamType, value: Any) -> Any: + """Marshals an argument ``value`` into its TRP wire form, directed by ``param``. + + One recursive walk over ``(type, value)``: a scalar leaf renders bare at the + top level and tagged when nested inside an aggregate; aggregates always render + to their tagged structural form. + + Raises :class:`EncodeArgError` if ``value``'s shape cannot match ``param``. + """ + return _marshal(param, value, nested=False) + + +def _marshal(param: ParamType, value: Any, nested: bool) -> Any: + """``nested`` is true when ``value`` sits inside an aggregate, where scalar + leaves must be tagged for the schema-less resolver.""" + kind = param.kind + + # Scalar leaves. Shape checks here are the "reject before sending" pass; the + # resolver still performs the authoritative coercion. + if kind is ParamKind.INTEGER: + # Accept number or decimal/hex string. + if isinstance(value, str) or (isinstance(value, int) and not isinstance(value, bool)): + return _leaf("int", value, nested) + raise _wrong_shape("integer", "number or decimal/hex string", value) + + if kind is ParamKind.BOOLEAN: + # Accept the same lenient forms the resolver coerces (bool, 0/1, + # "true"/"false"). + if isinstance(value, (bool, int, str)): + return _leaf("bool", value, nested) + raise _wrong_shape("boolean", "bool", value) + + if kind is ParamKind.BYTES: + # Hex string or a BytesEnvelope object. + if isinstance(value, (str, dict)): + return _leaf("bytes", value, nested) + raise _wrong_shape("bytes", "hex string or bytes envelope", value) + + if kind is ParamKind.ADDRESS: + if isinstance(value, str): + return _leaf("address", value, nested) + raise _wrong_shape("address", "bech32 or hex string", value) + + if kind is ParamKind.UTXO_REF: + if isinstance(value, str): + return _leaf("utxoRef", value, nested) + raise _wrong_shape("utxoRef", "txid#index string", value) + + # A unit field has no payload; it lowers to a nullary struct. + if kind is ParamKind.UNIT: + return {"struct": {"constructor": 0, "fields": []}} + + if kind is ParamKind.LIST: + if not isinstance(value, list): + raise _wrong_shape("list", "array", value) + inner = param.inner + assert inner is not None # LIST always carries its element type. + return {"list": [_marshal(inner, item, nested=True) for item in value]} + + if kind is ParamKind.TUPLE: + if not isinstance(value, list): + raise _wrong_shape("tuple", "array", value) + if len(value) != len(param.elements): + raise EncodeArgError( + f"tuple arity mismatch: expected {len(param.elements)} " + f"element(s), got {len(value)}" + ) + return { + "tuple": [ + _marshal(t, v, nested=True) + for t, v in zip(param.elements, value, strict=True) + ] + } + + if kind is ParamKind.MAP: + if not isinstance(value, dict): + raise _wrong_shape("map", "object", value) + value_type = param.inner + assert value_type is not None # MAP always carries its value type. + # The `.tii` erases the Tx3 key type (JSON object keys are strings), so + # keys are carried as `string` leaves. Sort by key for a deterministic, + # language-neutral pair order. + pairs = [ + [{"string": key}, _marshal(value_type, value[key], nested=True)] + for key in sorted(value) + ] + return {"map": pairs} + + # A record is constructor 0; a variant resolves its case index. Both emit the + # same positional `struct` form. + if kind is ParamKind.RECORD: + return { + "struct": { + "constructor": 0, + "fields": _marshal_record_fields(param.fields, value), + } + } + + if kind is ParamKind.VARIANT: + return _marshal_variant(param.cases, value) + + # No wire-leaf form and no element types to drive encoding (`utxo`, + # `anyAsset`, `unknown`): pass the value through and let the resolver coerce + # it via the flat type. + return value + + +def _marshal_record_fields(fields: dict[str, ParamType], value: Any) -> list[Any]: + """Marshals a record's fields **positionally** in declared order, mapping the + user's by-name object. Rejects missing or extra fields up front.""" + if not isinstance(value, dict): + raise _wrong_shape("record", "object", value) + + # Reject any field the record does not declare. + for key in value: + if key not in fields: + raise EncodeArgError(f"unknown record field `{key}`") + + encoded: list[Any] = [] + for name, ty in fields.items(): + if name not in value: + raise EncodeArgError(f"missing record field `{name}`") + encoded.append(_marshal(ty, value[name], nested=True)) + return encoded + + +def _marshal_variant(cases: tuple[VariantCase, ...], value: Any) -> Any: + """Marshals an externally-tagged variant value ``{ "": }`` + into a ``struct`` whose ``constructor`` is the case index from the ``.tii`` + ``oneOf`` order.""" + if not isinstance(value, dict) or len(value) != 1: + raise EncodeArgError("variant value must be a single-key object naming the case") + + tag, payload = next(iter(value.items())) + + index = next((i for i, c in enumerate(cases) if c.tag == tag), None) + if index is None: + raise EncodeArgError(f"unknown variant case `{tag}`") + + # A case payload is a record (possibly empty). Marshal its fields positionally + # and stamp the case index as the constructor. + case_fields = cases[index].fields + if case_fields.kind is ParamKind.RECORD: + fields = _marshal_record_fields(case_fields.fields, payload) + else: + # Defensive: a non-record payload encodes as the single field. + fields = [_marshal(case_fields, payload, nested=True)] + + return {"struct": {"constructor": index, "fields": fields}} diff --git a/sdk/src/tx3_sdk/tii/errors.py b/sdk/src/tx3_sdk/tii/errors.py index 23573b9..5036544 100644 --- a/sdk/src/tx3_sdk/tii/errors.py +++ b/sdk/src/tx3_sdk/tii/errors.py @@ -33,3 +33,13 @@ class MissingParamsError(TiiError): def __init__(self, params: list[str]) -> None: super().__init__(f"missing required params: {params}") self.params = params + + +class EncodeArgError(TiiError): + """Raised when a complex argument value does not match its declared + :class:`~tx3_sdk.tii.param_type.ParamType`. + + Surfaced **before** the request is sent (the SDK is authoritative for complex + types), so a malformed complex arg fails fast at the client rather than as an + opaque resolver error. + """ diff --git a/sdk/src/tx3_sdk/tii/invocation.py b/sdk/src/tx3_sdk/tii/invocation.py index f5ba746..5f144f8 100644 --- a/sdk/src/tx3_sdk/tii/invocation.py +++ b/sdk/src/tx3_sdk/tii/invocation.py @@ -7,6 +7,7 @@ from tx3_sdk.core.args import normalize_arg_key from tx3_sdk.core.bytes import TirEnvelope +from tx3_sdk.tii.encode import encode from tx3_sdk.tii.errors import MissingParamsError from tx3_sdk.tii.param_type import ParamType @@ -38,8 +39,23 @@ def unspecified_params(self) -> list[str]: return missing def into_resolve_request(self) -> tuple[TirEnvelope, dict[str, Any]]: - """Converts invocation into the TRP resolve payload.""" + """Converts invocation into the TRP resolve payload. + + Every mapped arg is marshalled by its ``.tii`` :class:`ParamType`: + top-level scalars come back bare (coerced server-side via the flat TIR + type), aggregates come back tagged in the self-describing ``TaggedArg`` + wire form. An unmapped arg has no type, so it passes through untouched. + Arg keys are lowercased on set; params keep their original case, so match + case-insensitively. + """ missing = self.unspecified_params() if missing: raise MissingParamsError(missing) - return self.tir, self.args + + params_by_key = {name.lower(): ty for name, ty in self.params.items()} + args: dict[str, Any] = {} + for key, value in self.args.items(): + param = params_by_key.get(key.lower()) + args[key] = encode(param, value) if param is not None else value + + return self.tir, args diff --git a/sdk/src/tx3_sdk/tii/param_type.py b/sdk/src/tx3_sdk/tii/param_type.py index 64e7db9..221b6e0 100644 --- a/sdk/src/tx3_sdk/tii/param_type.py +++ b/sdk/src/tx3_sdk/tii/param_type.py @@ -138,15 +138,38 @@ def _object_type( ) properties = schema.get("properties") if isinstance(properties, dict): - fields = { - str(key): param_type_from_schema(value, components) - for key, value in properties.items() - if isinstance(value, dict) - } - return ParamType(ParamKind.RECORD, fields=fields) + return ParamType(ParamKind.RECORD, fields=_record_fields(schema, properties, components)) return ParamType(ParamKind.UNKNOWN, schema=schema) +def _record_fields( + schema: dict[str, object], + properties: dict[str, object], + components: dict[str, dict[str, object]], +) -> dict[str, ParamType]: + """Builds record fields in **declared order**: the schema's ``required`` array + first (the order ``tx3c`` emits, = source declaration), then any remaining + ``properties`` (which JSON alphabetizes). Python dicts preserve insertion + order, so the resulting field order drives the positional ``struct`` wire form + the encoder produces.""" + fields: dict[str, ParamType] = {} + + required = schema.get("required") + if isinstance(required, list): + for name in required: + key = str(name) + value = properties.get(key) + if isinstance(value, dict): + fields[key] = param_type_from_schema(value, components) + + for key, value in properties.items(): + skey = str(key) + if skey not in fields and isinstance(value, dict): + fields[skey] = param_type_from_schema(value, components) + + return fields + + def _variant_case( case: dict[str, object], components: dict[str, dict[str, object]] ) -> VariantCase: 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" + } + ] +} diff --git a/sdk/tests/test_encode.py b/sdk/tests/test_encode.py new file mode 100644 index 0000000..de904a3 --- /dev/null +++ b/sdk/tests/test_encode.py @@ -0,0 +1,99 @@ +"""Tests for the type-directed complex-argument `TaggedArg` encoder. + +The `accept`/`reject` vectors come from the shared cross-SDK oracle +(`sdk-spec/test-vectors/complex-types/wire-vectors.json`, mirrored into +`tests/fixtures/`). Each `accept` vector pins (schema + components, value) → +tagged; each `reject` vector is a value the encoder MUST refuse before sending. +""" + +from __future__ import annotations + +import json +from pathlib import Path + +import pytest + +from tx3_sdk.tii.encode import encode +from tx3_sdk.tii.errors import EncodeArgError +from tx3_sdk.tii.param_type import param_type_from_schema + +_FIXTURES = Path(__file__).parent / "fixtures" + + +def _load_vectors() -> dict: + return json.loads((_FIXTURES / "wire-vectors.json").read_text(encoding="utf-8")) + + +_VECTORS = _load_vectors() +_COMPONENTS = _VECTORS.get("components", {}) + + +@pytest.mark.parametrize( + "vector", + _VECTORS["accept"], + ids=[v["name"] for v in _VECTORS["accept"]], +) +def test_accept_vectors(vector: dict) -> None: + param = param_type_from_schema(vector["schema"], _COMPONENTS) + assert encode(param, vector["value"]) == vector["tagged"] + + +@pytest.mark.parametrize( + "vector", + _VECTORS["reject"], + ids=[v["name"] for v in _VECTORS["reject"]], +) +def test_reject_vectors(vector: dict) -> None: + param = param_type_from_schema(vector["schema"], _COMPONENTS) + with pytest.raises(EncodeArgError): + encode(param, vector["value"]) + + +def test_record_field_order_follows_required_not_alphabetical() -> None: + # Meta { tags: List, level: Int } — required = [tags, level], while + # `properties` alphabetizes to [level, tags]. The struct fields must be + # [list, int], not [int, list]. + schema = { + "type": "object", + "properties": { + "level": {"type": "integer"}, + "tags": {"type": "array", "items": {"type": "integer"}}, + }, + "required": ["tags", "level"], + } + param = param_type_from_schema(schema) + assert list(param.fields) == ["tags", "level"] + + encoded = encode(param, {"level": 7, "tags": [1, 2, 3]}) + assert encoded == { + "struct": { + "constructor": 0, + "fields": [ + {"list": [{"int": 1}, {"int": 2}, {"int": 3}]}, + {"int": 7}, + ], + } + } + + +def test_top_level_scalars_render_bare() -> None: + # A scalar at the top level is sent bare; the resolver coerces it via the + # param's flat type. + assert encode(param_type_from_schema({"type": "integer"}), 5) == 5 + assert encode(param_type_from_schema({"type": "boolean"}), True) is True + address = param_type_from_schema( + {"$ref": "https://tx3.land/specs/v1beta0/tii#/$defs/Address"} + ) + assert encode(address, "addr1abc") == "addr1abc" + + +def test_nested_scalars_are_tagged() -> None: + # The same scalar nested inside an aggregate is tagged. + schema = {"type": "array", "items": {"type": "integer"}} + assert encode(param_type_from_schema(schema), [5]) == {"list": [{"int": 5}]} + + +def test_unit_lowers_to_nullary_struct() -> None: + assert encode(param_type_from_schema({"type": "null"}), None) == { + "struct": {"constructor": 0, "fields": []} + } diff --git a/sdk/tests/test_tii.py b/sdk/tests/test_tii.py index c8c4862..a050777 100644 --- a/sdk/tests/test_tii.py +++ b/sdk/tests/test_tii.py @@ -105,3 +105,41 @@ def test_invoke_interprets_complex_params() -> None: # The component-$ref Variant must have resolved its cases. assert len(params["side"].cases) == 2 + + +def test_invoke_encodes_complex_arg_into_wire_form() -> None: + """End-to-end through the exact path ``cshell``/``trix invoke`` take + (``set_args`` → ``into_resolve_request``), on the real ``05-invoke`` TII. + The complex ``meta`` record must serialize to the tagged ``TaggedArg``; + scalar args must stay bare for the resolver to coerce by flat type.""" + protocol = Protocol.from_file("tests/fixtures/invoke.tii") + invocation = protocol.invoke("transfer") + invocation.set_args( + { + "sender": "addr_test1vqx…", + "receiver": "addr_test1vqyy…", + "quantity": 2_000_000, + "urgent": True, + "memo": "deadbeef", + "meta": {"tags": [1, 2, 3], "level": 7}, + } + ) + + _tir, args = invocation.into_resolve_request() + + # The complex record nests a parametric List; fields are positional in + # declared order (tags, level) — required order, not alphabetical. + assert args["meta"] == { + "struct": { + "constructor": 0, + "fields": [ + {"list": [{"int": 1}, {"int": 2}, {"int": 3}]}, + {"int": 7}, + ], + } + } + + # Scalars stay bare (back-compat; resolver coerces via the flat type). + assert args["quantity"] == 2_000_000 + assert args["urgent"] is True + assert args["memo"] == "deadbeef"