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
95 changes: 95 additions & 0 deletions sdk/src/tii/encode.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
import * as fs from 'node:fs';
import * as path from 'node:path';
import { fileURLToPath } from 'node:url';
import { encode } from './encode.js';
import { EncodeError } from './errors.js';
import { ParamType } from './paramType.js';
import type { JsonSchema } from './spec.js';

const __dirname = path.dirname(fileURLToPath(import.meta.url));

interface AcceptVector {
name: string;
schema: JsonSchema;
value: unknown;
tagged: unknown;
}

interface RejectVector {
name: string;
schema: JsonSchema;
value: unknown;
reason: string;
}

interface WireVectors {
components: Record<string, JsonSchema>;
accept: AcceptVector[];
reject: RejectVector[];
}

// The shared cross-language oracle for the `TaggedArg` wire form, copied from
// the umbrella's sdk-spec into this SDK's fixtures.
const vectors: WireVectors = JSON.parse(
fs.readFileSync(path.resolve(__dirname, '../../tests/fixtures/wire-vectors.json'), 'utf8'),
);

const paramType = (schema: JsonSchema): ParamType =>
ParamType.fromJsonSchema(schema, vectors.components);

describe('encode — accept vectors', () => {
it.each(vectors.accept.map((v) => [v.name, v] as const))(
'encodes %s to its wire form',
(_name, vector) => {
expect(encode(paramType(vector.schema), vector.value)).toEqual(vector.tagged);
},
);
});

describe('encode — reject vectors', () => {
it.each(vectors.reject.map((v) => [v.name, v] as const))(
'rejects %s',
(_name, vector) => {
expect(() => encode(paramType(vector.schema), vector.value)).toThrow(EncodeError);
},
);
});

describe('encode — record field order', () => {
it('follows `required` (declared order), not alphabetical `properties`', () => {
// Meta { tags: List<Int>, level: Int } — required = [tags, level], while
// `properties` alphabetizes to [level, tags]. The struct fields must be
// [list, int], not [int, list].
const schema: JsonSchema = {
type: 'object',
properties: {
level: { type: 'integer' },
tags: { type: 'array', items: { type: 'integer' } },
},
required: ['tags', 'level'],
};
expect(encode(paramType(schema), { level: 7, tags: [1, 2, 3] })).toEqual({
struct: {
constructor: 0,
fields: [{ list: [{ int: 1 }, { int: 2 }, { int: 3 }] }, { int: 7 }],
},
});
});
});

describe('encode — leaf position', () => {
it('renders a top-level scalar bare', () => {
// A scalar at the top level is sent bare; the resolver coerces it.
expect(encode(paramType({ type: 'integer' }), 5)).toEqual(5);
expect(
encode(paramType({ $ref: 'https://tx3.land/specs/v1beta0/tii#/$defs/Bytes' }), 'cafe'),
).toEqual('cafe');
});

it('renders a nested scalar tagged', () => {
// The same scalar nested inside a list is tagged.
expect(encode(paramType({ type: 'array', items: { type: 'integer' } }), [5])).toEqual({
list: [{ int: 5 }],
});
});
});
192 changes: 192 additions & 0 deletions sdk/src/tii/encode.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,192 @@
//! 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 (record, list, tuple, map) on
//! its own. The full type lives in the `.tii`, a client-side artifact — so the
//! SDK is authoritative: it walks the resolved `ParamType` alongside the user
//! value and emits the 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.
//!
//! This is **one** recursive walk over `(type, value)`; scalars are just the leaf
//! cases. A scalar leaf at the top level renders **bare** (the resolver coerces
//! it via the flat TIR type); the same scalar nested inside an aggregate renders
//! **tagged**, because the resolver has no element/field type there.

import { EncodeError } from './errors.js';
import type { ParamType, VariantCase } from './paramType.js';

/** The JSON shape name of a value, for {@link EncodeError} messages. */
function shapeOf(value: unknown): string {
if (value === null) return 'null';
if (Array.isArray(value)) return 'array';
return typeof value;
}

function isObject(value: unknown): value is Record<string, unknown> {
return !!value && typeof value === 'object' && !Array.isArray(value);
}

function wrongShape(kind: string, expected: string, got: unknown): EncodeError {
return new EncodeError(
`expected ${expected} for a \`${kind}\` argument, got \`${shapeOf(got)}\``,
);
}

/**
* Marshals an argument `value` to its TRP wire form, directed by `param`.
*
* 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.
*
* Throws an {@link EncodeError} if `value`'s shape cannot match `param`.
*/
export function encode(param: ParamType, value: unknown): unknown {
return marshal(param, value, false);
}

/**
* `nested` is true when `value` sits inside an aggregate, where scalar leaves
* must be tagged for the schema-less resolver.
*/
function marshal(param: ParamType, value: unknown, nested: boolean): unknown {
switch (param.kind) {
// Scalar leaves: bare at the top level, tagged when nested. Shape checks here
// are the "reject before sending" pass; the resolver still performs the
// authoritative coercion.
case 'integer':
// number or decimal/hex string.
if (typeof value === 'number' || typeof value === 'bigint' || typeof value === 'string') {
return leaf('int', value, nested);
}
throw wrongShape('integer', 'number or decimal/hex string', value);
case 'boolean':
// Accept the lenient forms the resolver coerces (bool, 0/1, "true"/"false").
if (typeof value === 'boolean' || typeof value === 'number' || typeof value === 'string') {
return leaf('bool', value, nested);
}
throw wrongShape('boolean', 'bool', value);
case 'bytes':
// Hex string or a BytesEnvelope object.
if (typeof value === 'string' || isObject(value)) {
return leaf('bytes', value, nested);
}
throw wrongShape('bytes', 'hex string or bytes envelope', value);
case 'address':
if (typeof value === 'string') return leaf('address', value, nested);
throw wrongShape('address', 'bech32 or hex string', value);
case 'utxoRef':
if (typeof value === 'string') return leaf('utxoRef', value, nested);
throw wrongShape('utxoRef', 'txid#index string', value);

// A unit field has no payload; it lowers to a nullary struct.
case 'unit':
return { struct: { constructor: 0, fields: [] } };

case 'list': {
if (!Array.isArray(value)) throw wrongShape('list', 'array', value);
return { list: value.map((v) => marshal(param.inner, v, true)) };
}

case 'tuple': {
if (!Array.isArray(value)) throw wrongShape('tuple', 'array', value);
if (value.length !== param.elements.length) {
throw new EncodeError(
`tuple arity mismatch: expected ${param.elements.length} element(s), got ${value.length}`,
);
}
return { tuple: param.elements.map((t, i) => marshal(t, value[i], true)) };
}

case 'map': {
if (!isObject(value)) throw wrongShape('map', 'object', value);
// 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.
const keys = Object.keys(value).sort();
const pairs = keys.map((k) => [{ string: k }, marshal(param.value, value[k], true)]);
return { map: pairs };
}

// A record is constructor 0; a variant resolves its case index. Both emit the
// same positional `struct` form.
case 'record':
return {
struct: { constructor: 0, fields: encodeRecordFields(param.fields, value) },
};

case 'variant':
return encodeVariant(param.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.
case 'utxo':
case 'anyAsset':
case 'unknown':
return value;
}
}

/**
* 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).
*/
function leaf(tag: string, value: unknown, nested: boolean): unknown {
return nested ? { [tag]: value } : value;
}

/**
* Encodes a record's fields **positionally** in declared order, mapping the
* user's by-name object. Rejects missing or extra fields up front.
*/
function encodeRecordFields(
fields: ReadonlyArray<readonly [string, ParamType]>,
value: unknown,
): unknown[] {
if (!isObject(value)) throw wrongShape('record', 'object', value);

// Reject any field the record does not declare.
for (const key of Object.keys(value)) {
if (!fields.some(([name]) => name === key)) {
throw new EncodeError(`unknown record field \`${key}\``);
}
}

return fields.map(([name, ty]) => {
if (!(name in value)) throw new EncodeError(`missing record field \`${name}\``);
return marshal(ty, value[name], true);
});
}

/**
* Encodes an externally-tagged variant value `{ "<Case>": <payload> }` into a
* `struct` whose `constructor` is the case index from the `.tii` `oneOf` order.
*/
function encodeVariant(cases: VariantCase[], value: unknown): unknown {
if (!isObject(value)) throw badVariant();
const tags = Object.keys(value);
if (tags.length !== 1) throw badVariant();
const tag = tags[0];
const payload = value[tag];

const index = cases.findIndex((c) => c.tag === tag);
if (index < 0) throw new EncodeError(`unknown variant case \`${tag}\``);

// A case payload is a record (possibly empty). Encode its fields positionally
// and stamp the case index as the constructor.
const caseFields = cases[index].fields;
const fields =
caseFields.kind === 'record'
? encodeRecordFields(caseFields.fields, payload)
: // Defensive: a non-record payload encodes as the single field.
[marshal(caseFields, payload, true)];

return { struct: { constructor: index, fields } };
}

function badVariant(): EncodeError {
return new EncodeError('variant value must be a single-key object naming the case');
}
9 changes: 9 additions & 0 deletions sdk/src/tii/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,3 +23,12 @@ export class UnknownProfileError extends TiiError {
this.profile = profile;
}
}

/**
* An argument value whose shape does not match its declared `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.
*/
export class EncodeError extends TiiError {}
1 change: 1 addition & 0 deletions sdk/src/tii/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
export * from './errors.js';
export * from './spec.js';
export * from './paramType.js';
export * from './encode.js';
export * from './invocation.js';
export * from './protocol.js';
20 changes: 19 additions & 1 deletion sdk/src/tii/invocation.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import type { ArgMap, TirEnvelope } from '../core/index.js';
import type { ResolveParams } from '../trp/spec.js';
import { encode } from './encode.js';
import type { ParamMap, ParamType } from './paramType.js';

export class Invocation {
Expand Down Expand Up @@ -44,9 +45,26 @@ export class Invocation {
}

intoResolveRequest(): ResolveParams {
// Every arg is marshalled by its `.tii` `ParamType`: top-level scalars come
// back bare (the resolver coerces them via the flat TIR type), aggregates
// tagged into 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.
const args: ArgMap = {};
for (const [key, value] of Object.entries(this._args)) {
let paramType: ParamType | undefined;
for (const [name, type] of this._params) {
if (name.toLowerCase() === key) {
paramType = type;
break;
}
}
args[key] = paramType ? encode(paramType, value) : value;
}

return {
tir: this.tir,
args: { ...this._args },
args,
};
}
}
Loading
Loading