From 68a3bf3dc348ca3b19f0424e0f79a0ea9b96ba2e Mon Sep 17 00:00:00 2001 From: Santiago Date: Mon, 8 Jun 2026 14:11:21 -0300 Subject: [PATCH] feat(tii): resolve custom-type params instead of throwing ParamType.fromJsonSchema threw on object schemas and unknown $refs, so any transaction with a record/variant param broke when building the param map. Map inline objects, `oneOf`, and `#/components/schemas` refs to ParamType.custom (resolving the ref against components when present), and match builtin refs by trailing name so both the `tii#/$defs/` and legacy `core#` forms resolve. Thread components through paramsFromSchema. Also regenerate the codegen template to declare named types from components.schemas. Co-Authored-By: Claude Opus 4.8 (1M context) --- .trix/client-lib/protocol.ts.hbs | 4 ++ sdk/src/tii/paramType.test.ts | 70 ++++++++++++++++++++++++++++++++ sdk/src/tii/paramType.ts | 36 ++++++++++++---- sdk/src/tii/protocol.ts | 6 ++- 4 files changed, 107 insertions(+), 9 deletions(-) create mode 100644 sdk/src/tii/paramType.test.ts diff --git a/.trix/client-lib/protocol.ts.hbs b/.trix/client-lib/protocol.ts.hbs index 8ea14c1..c20c68c 100644 --- a/.trix/client-lib/protocol.ts.hbs +++ b/.trix/client-lib/protocol.ts.hbs @@ -36,6 +36,10 @@ export const {{constantCase @key}}_TIR = { } as const satisfies TirEnvelope; {{/each}} +{{#if tii.components.schemas}} +// Named types for the protocol's custom (record / variant) types. +{{{componentTypes tii.components.schemas "typescript"}}} +{{/if}} {{#each tii.transactions}} export type {{pascalCase @key}}Params = { {{#each params.properties}} diff --git a/sdk/src/tii/paramType.test.ts b/sdk/src/tii/paramType.test.ts new file mode 100644 index 0000000..71bbc89 --- /dev/null +++ b/sdk/src/tii/paramType.test.ts @@ -0,0 +1,70 @@ +import { ParamType, paramsFromSchema } from './paramType.js'; +import { InvalidParamTypeError } from './errors.js'; +import type { JsonSchema } from './spec.js'; + +describe('ParamType.fromJsonSchema', () => { + it('maps builtin refs in the tii#/$defs form', () => { + const bytes: JsonSchema = { $ref: 'https://tx3.land/specs/v1beta0/tii#/$defs/Bytes' }; + const addr: JsonSchema = { $ref: 'https://tx3.land/specs/v1beta0/tii#/$defs/Address' }; + const utxo: JsonSchema = { $ref: 'https://tx3.land/specs/v1beta0/tii#/$defs/UtxoRef' }; + + expect(ParamType.fromJsonSchema(bytes).kind).toBe('bytes'); + expect(ParamType.fromJsonSchema(addr).kind).toBe('address'); + expect(ParamType.fromJsonSchema(utxo).kind).toBe('utxoRef'); + }); + + it('still maps the legacy core# ref form', () => { + const bytes: JsonSchema = { $ref: 'https://tx3.land/specs/v1beta0/core#Bytes' }; + expect(ParamType.fromJsonSchema(bytes).kind).toBe('bytes'); + }); + + it('resolves a component ref to its schema as a custom param', () => { + const record: JsonSchema = { + type: 'object', + properties: { policy_id: { type: 'string' } }, + }; + const ref: JsonSchema = { $ref: '#/components/schemas/AssetClass' }; + + const param = ParamType.fromJsonSchema(ref, { AssetClass: record }); + expect(param.kind).toBe('custom'); + expect(param).toEqual({ kind: 'custom', schema: record }); + }); + + it('falls back to the ref schema when components are absent', () => { + const ref: JsonSchema = { $ref: '#/components/schemas/AssetClass' }; + const param = ParamType.fromJsonSchema(ref); + expect(param).toEqual({ kind: 'custom', schema: ref }); + }); + + it('maps an inline object (record) to a custom param', () => { + const record: JsonSchema = { type: 'object', properties: { x: { type: 'integer' } } }; + expect(ParamType.fromJsonSchema(record)).toEqual({ kind: 'custom', schema: record }); + }); + + it('maps a oneOf (variant) to a custom param', () => { + const variant: JsonSchema = { oneOf: [{ type: 'object' }] }; + expect(ParamType.fromJsonSchema(variant)).toEqual({ kind: 'custom', schema: variant }); + }); + + it('throws on an unknown builtin ref', () => { + const ref: JsonSchema = { $ref: 'https://tx3.land/specs/v1beta0/tii#/$defs/Nope' }; + expect(() => ParamType.fromJsonSchema(ref)).toThrow(InvalidParamTypeError); + }); +}); + +describe('paramsFromSchema', () => { + it('threads components through to each property', () => { + const components = { AssetClass: { type: 'object' } as JsonSchema }; + const params: JsonSchema = { + type: 'object', + properties: { + asset: { $ref: '#/components/schemas/AssetClass' }, + quantity: { type: 'integer' }, + }, + }; + + const map = paramsFromSchema(params, components); + expect(map.get('asset')).toEqual({ kind: 'custom', schema: components.AssetClass }); + expect(map.get('quantity')?.kind).toBe('integer'); + }); +}); diff --git a/sdk/src/tii/paramType.ts b/sdk/src/tii/paramType.ts index 3c6d516..55c5f3b 100644 --- a/sdk/src/tii/paramType.ts +++ b/sdk/src/tii/paramType.ts @@ -19,21 +19,37 @@ export const ParamType = { list: (inner: ParamType): ParamType => ({ kind: 'list', inner }), custom: (schema: JsonSchema): ParamType => ({ kind: 'custom', schema }), - fromJsonSchema(schema: JsonSchema): ParamType { + fromJsonSchema( + schema: JsonSchema, + components?: Record, + ): ParamType { const ref = schema['$ref']; if (typeof ref === 'string') { - switch (ref) { - case 'https://tx3.land/specs/v1beta0/core#Bytes': + // User-defined record / variant, referenced into components.schemas. + if (ref.includes('/components/schemas/')) { + const name = ref.split('/').pop() as string; + return ParamType.custom(components?.[name] ?? schema); + } + // Builtin core type, matched by trailing name so both the + // `…/tii#/$defs/` and legacy `…/core#` forms resolve. + const name = ref.split('#').pop()?.split('/').pop(); + switch (name) { + case 'Bytes': return ParamType.bytes(); - case 'https://tx3.land/specs/v1beta0/core#Address': + case 'Address': return ParamType.address(); - case 'https://tx3.land/specs/v1beta0/core#UtxoRef': + case 'UtxoRef': return ParamType.utxoRef(); default: throw new InvalidParamTypeError(`unknown $ref: ${ref}`); } } + // Variant: a tagged union of cases. + if (Array.isArray(schema['oneOf'])) { + return ParamType.custom(schema); + } + const type = schema['type']; if (typeof type === 'string') { switch (type) { @@ -41,6 +57,9 @@ export const ParamType = { return ParamType.integer(); case 'boolean': return ParamType.boolean(); + // Inline record / custom object shape. + case 'object': + return ParamType.custom(schema); default: throw new InvalidParamTypeError(`unsupported type: ${type}`); } @@ -52,7 +71,10 @@ export const ParamType = { export type ParamMap = Map; -export function paramsFromSchema(schema: JsonSchema): ParamMap { +export function paramsFromSchema( + schema: JsonSchema, + components?: Record, +): ParamMap { const params: ParamMap = new Map(); const properties = schema['properties']; if (properties && typeof properties === 'object' && !Array.isArray(properties)) { @@ -60,7 +82,7 @@ export function paramsFromSchema(schema: JsonSchema): ParamMap { if (!value || typeof value !== 'object' || Array.isArray(value)) { throw new InvalidParamTypeError(`property ${key} is not a schema object`); } - params.set(key, ParamType.fromJsonSchema(value as JsonSchema)); + params.set(key, ParamType.fromJsonSchema(value as JsonSchema, components)); } } return params; diff --git a/sdk/src/tii/protocol.ts b/sdk/src/tii/protocol.ts index dd27aec..aaa82e1 100644 --- a/sdk/src/tii/protocol.ts +++ b/sdk/src/tii/protocol.ts @@ -94,13 +94,15 @@ export class Protocol { params.set(party.toLowerCase(), ParamType.address()); } + const components = this.spec.components?.schemas; + if (this.spec.environment) { - for (const [k, v] of paramsFromSchema(this.spec.environment)) { + for (const [k, v] of paramsFromSchema(this.spec.environment, components)) { params.set(k, v); } } - for (const [k, v] of paramsFromSchema(tx.params)) { + for (const [k, v] of paramsFromSchema(tx.params, components)) { params.set(k, v); }