Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions sdk/src/tx3_sdk/tii/__init__.py
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -12,6 +14,7 @@
from tx3_sdk.tii.protocol import Protocol

__all__ = [
"EncodeArgError",
"InvalidJsonError",
"InvalidParamsSchemaError",
"Invocation",
Expand All @@ -22,4 +25,5 @@
"UnknownProfileError",
"UnknownTxError",
"VariantCase",
"encode",
]
203 changes: 203 additions & 0 deletions sdk/src/tx3_sdk/tii/encode.py
Original file line number Diff line number Diff line change
@@ -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 ``{ "<Case>": <payload> }``
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}}
10 changes: 10 additions & 0 deletions sdk/src/tx3_sdk/tii/errors.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
"""
20 changes: 18 additions & 2 deletions sdk/src/tx3_sdk/tii/invocation.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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
35 changes: 29 additions & 6 deletions sdk/src/tx3_sdk/tii/param_type.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
Loading
Loading