diff --git a/js/src/sails-idl-v2.ts b/js/src/sails-idl-v2.ts index bc4a889bb..2cca50024 100644 --- a/js/src/sails-idl-v2.ts +++ b/js/src/sails-idl-v2.ts @@ -2,6 +2,7 @@ import { GearApi, HexString, UserMessageSent } from '@gear-js/api'; import { u8aToHex, u8aToU8a } from '@polkadot/util'; import type { + Type, TypeDecl, IIdlDoc, IServiceExpo, @@ -126,6 +127,10 @@ export class SailsProgram { private _api?: GearApi; private _programId?: HexString; private _services: Map; + // Lazy indices for resolveInService. Populated on first call; not invalidated because + // `_doc` is immutable after parse. + private _serviceTypeIndex?: Map>; + private _programTypeIndex?: Map; private _resolveServiceUnit = (ident: IServiceIdent): IServiceUnit | undefined => { if (!ident.interface_id) { throw new Error(`Service "${ident.name}" is missing interface_id in IDL`); @@ -141,7 +146,7 @@ export class SailsProgram { constructor(doc: IIdlDoc) { this._doc = doc; if (this._doc.program) { - this._typeResolver = new TypeResolver(this._doc.program.types); + this._typeResolver = new TypeResolver(this._doc.program.types ?? []); } this._services = this._initServices(); } @@ -215,11 +220,43 @@ export class SailsProgram { this._programId, expo.route_idx, this._resolveServiceUnit, + program.types ?? [], ); } return services; } + /** + * Resolve a `TypeDecl` to its user-type definition in the scope of a named service. + * Service-local types shadow program-level types; program-level types are visible to + * every service. Returns `undefined` when the service doesn't exist, or the `TypeDecl` + * isn't a named user type. + * + * Backed by a lazy `Map`-based index — O(1) per call after the first invocation. + * Safe to call in tight loops while walking large IDL trees. + */ + resolveInService(serviceName: string, typeDecl: TypeDecl): Type | undefined { + if (typeof typeDecl === 'string' || typeDecl.kind !== 'named') return undefined; + if (!this._serviceTypeIndex) this._buildTypeIndex(); + return ( + this._serviceTypeIndex!.get(serviceName)?.get(typeDecl.name) ?? + this._programTypeIndex!.get(typeDecl.name) + ); + } + + private _buildTypeIndex(): void { + const serviceIndex = new Map>(); + for (const unit of this._doc.services ?? []) { + const local = new Map(); + for (const t of unit.types ?? []) local.set(t.name, t); + serviceIndex.set(unit.name, local); + } + const programIndex = new Map(); + for (const t of this._doc.program?.types ?? []) programIndex.set(t.name, t); + this._serviceTypeIndex = serviceIndex; + this._programTypeIndex = programIndex; + } + /** #### Constructor functions with arguments from the parsed IDL */ get ctors(): Record | null { if (!this._doc.program) { @@ -332,6 +369,7 @@ export class SailsService implements ISailsService { private _typeResolver: TypeResolver; private _api?: GearApi; private _routeIdx: number; + private _ambientTypes: Type[] = []; private _resolveServiceUnit?: (ident: IServiceIdent) => IServiceUnit | undefined; @@ -341,12 +379,15 @@ export class SailsService implements ISailsService { programId?: HexString, routeIdx = 0, resolveServiceUnit?: (ident: IServiceIdent) => IServiceUnit | undefined, + ambientTypes: Type[] = [], ) { this._service = service; this._api = api; this._programId = programId; this._resolveServiceUnit = resolveServiceUnit; - this._typeResolver = new TypeResolver(service.types); + // Ambient types come first so service-local types shadow them on name collision. + this._typeResolver = new TypeResolver([...ambientTypes, ...(service.types ?? [])]); + this._ambientTypes = ambientTypes; this._routeIdx = routeIdx; this.events = this._getEvents(service); @@ -569,6 +610,7 @@ export class SailsService implements ISailsService { this._programId, this.routeIdx, this._resolveServiceUnit, + this._ambientTypes, ); } diff --git a/js/src/type-resolver-idl-v2.ts b/js/src/type-resolver-idl-v2.ts index 0a0b23162..2f5b36af8 100644 --- a/js/src/type-resolver-idl-v2.ts +++ b/js/src/type-resolver-idl-v2.ts @@ -21,18 +21,176 @@ export class TypeResolver { const scaleTypes: Record = {}; const userTypes: Record = {}; + // Iteration order is last-write-wins: callers that want ambient-type shadowing should + // pass `[...ambientTypes, ...localTypes]` so locals overwrite on name collision. for (const type of types) { userTypes[type.name] = type; + } + this._userTypes = userTypes; + for (const type of Object.values(userTypes)) { if (!type.type_params?.length) { - // register non-generic by name scaleTypes[type.name] = this.getTypeDef(type); } } - this._userTypes = userTypes; this.registry.setKnownTypes({ types: scaleTypes }); this.registry.register(scaleTypes); } + /** + * Resolve a named user type to its `Type` definition. + * + * Two call shapes: + * - `resolveNamed(typeDecl)` — pass a `TypeDecl`; returns the user type for a + * `{ kind: 'named', name, generics? }` decl, or `undefined` for primitives, slices, + * arrays, tuples, type parameters, and unknown names. + * - `resolveNamed(name, generics?)` — pass the user type's name directly, with an + * optional concrete generics list. + * + * When concrete generics are provided (either via the overload or via `typeDecl.generics`), + * the returned `Type` is a **concrete** substituted copy: every `{ kind: 'generic', name }` + * leaf is replaced according to the user type's declared `type_params`, and `type_params` + * is omitted from the result. When no generics are provided, the raw user `Type` is + * returned as-is (shared with the resolver's internal state — do not mutate). + */ + resolveNamed(type: TypeDecl): Type | undefined; + resolveNamed(name: string, generics?: TypeDecl[]): Type | undefined; + resolveNamed(typeOrName: TypeDecl | string, generics?: TypeDecl[]): Type | undefined { + let name: string; + let concrete: TypeDecl[] | undefined; + + if (typeof typeOrName === 'string') { + // String is ambiguous: it's either a primitive (`'u32'`) or a user-type name. + // Primitives aren't in `_userTypes`, so lookup naturally returns `undefined` for them. + name = typeOrName; + concrete = generics; + } else if (typeOrName.kind === 'named') { + name = typeOrName.name; + concrete = generics ?? typeOrName.generics; + } else { + return undefined; + } + + const userType = this._userTypes[name]; + if (!userType) return undefined; + if (!concrete?.length) return userType; + + const substitutions = this._genericsSubstitutions(userType, concrete); + return this._resolveTypeGenerics(userType, substitutions); + } + + /** + * Recursively resolve type parameters through a `TypeDecl` tree. + * + * Pure: does not mutate inputs. Idempotent: passing an already-resolved tree yields an + * equivalent tree. Only `{ kind: 'generic', name: 'T' }` leaves whose `name` appears in + * `substitutions` are replaced; wrapper shapes (`Option`, `Vec`, `Result`, custom + * generics, and any `named` decl) are preserved and their inner types resolved in place. + * + * Recurses through replacement chains (`{ T: U, U: u32 }` resolves `T` to `u32`). + * Cyclic maps (`{ T: { kind: 'generic', name: 'T' } }` or `{ T: U, U: T }`) are detected at + * runtime and throw rather than stack-overflowing. + */ + resolveGenerics(type: TypeDecl, substitutions: Record = {}): TypeDecl { + return this._resolveGenerics(type, substitutions, new Set()); + } + + private _resolveGenerics( + type: TypeDecl, + substitutions: Record, + visited: Set, + ): TypeDecl { + if (typeof type === 'string') return type; + if (type.kind === 'slice') { + const item = this._resolveGenerics(type.item, substitutions, visited); + return item === type.item ? type : { kind: 'slice', item }; + } + if (type.kind === 'array') { + const item = this._resolveGenerics(type.item, substitutions, visited); + return item === type.item ? type : { kind: 'array', item, len: type.len }; + } + if (type.kind === 'tuple') { + const next = type.types.map((t) => this._resolveGenerics(t, substitutions, visited)); + return next.every((t, i) => t === type.types[i]) ? type : { kind: 'tuple', types: next }; + } + if (type.kind === 'generic') { + // Explicit type-parameter leaf. Track visited names so a cyclic map + // (`{ T: T }`, `{ T: U, U: T }`) throws instead of stack-overflowing. + const replacement = substitutions[type.name]; + if (replacement === undefined) return type; + if (visited.has(type.name)) { + throw new Error( + `Cyclic substitution detected while resolving type parameter "${type.name}" — ` + + `substitution chain: ${[...visited, type.name].join(' → ')}`, + ); + } + const nextVisited = new Set(visited); + nextVisited.add(type.name); + return this._resolveGenerics(replacement, substitutions, nextVisited); + } + if (type.kind === 'named') { + if (!type.generics?.length) return type; + const next = type.generics.map((g) => this._resolveGenerics(g, substitutions, visited)); + return next.every((g, i) => g === type.generics![i]) + ? type + : { kind: 'named', name: type.name, generics: next }; + } + throw new Error('Unknown TypeDecl kind :: ' + JSON.stringify(type)); + } + + // Build a substitution map by zipping a user type's declared `type_params` with a concrete + // generics list. Internal: callers should use `resolveNamed(name, generics)` instead of + // building substitution maps by hand. + private _genericsSubstitutions(userType: Type, generics: TypeDecl[] = []): Record { + const map: Record = {}; + const params = userType.type_params ?? []; + const len = Math.min(params.length, generics.length); + for (let i = 0; i < len; i++) { + map[params[i].name] = generics[i]; + } + return map; + } + + // Return a `Type` with every `generic` leaf substituted and `type_params` stripped. The result + // is a shallow copy — fields/variants/targets are recursively resolved via `resolveGenerics`, + // which preserves unchanged subtrees by reference. + private _resolveTypeGenerics(type: Type, substitutions: Record): Type { + if (type.kind === 'struct') { + return { + kind: 'struct', + name: type.name, + docs: type.docs, + annotations: type.annotations, + fields: type.fields.map((f) => ({ + ...f, + type: this.resolveGenerics(f.type, substitutions), + })), + }; + } + if (type.kind === 'enum') { + return { + kind: 'enum', + name: type.name, + docs: type.docs, + annotations: type.annotations, + variants: type.variants.map((v) => ({ + ...v, + fields: v.fields.map((f) => ({ + ...f, + type: this.resolveGenerics(f.type, substitutions), + })), + })), + }; + } + // alias + return { + kind: 'alias', + name: type.name, + docs: type.docs, + annotations: type.annotations, + target: this.resolveGenerics(type.target, substitutions), + }; + } + /** * Convert a `TypeDecl` into a concrete string name, resolving generic parameters. * @@ -131,11 +289,7 @@ export class TypeResolver { .map((t: TypeDecl) => this.getTypeDeclString(t, generics, 'canonical')) .join('')}`; if (!this.registry.hasType(canonicalName)) { - // type param to generic map, i.e, { "T": "String", "U": { "kind": "named", "name": "Option", "generics": ["u32"]} } - const generics_map: Record = {}; - for (let i = 0; i < userType.type_params.length; i++) { - generics_map[userType.type_params[i].name] = type.generics[i]; - } + const generics_map = this._genericsSubstitutions(userType, type.generics); const typeDef = this.getTypeDef(userType, generics_map); /// When a user type with generics is resolved, the resolver constructs two names: // - genericName: readable, type-like syntax (example MyType>). diff --git a/js/test/idl-v2-parser-type-resolver.test.ts b/js/test/idl-v2-parser-type-resolver.test.ts index 6cd6fba93..249727454 100644 --- a/js/test/idl-v2-parser-type-resolver.test.ts +++ b/js/test/idl-v2-parser-type-resolver.test.ts @@ -341,3 +341,165 @@ describe('type-resolver-v2 generics', () => { ).toEqual({ three: { p1: arrayTupleU8, p2: [['a', 'b', 'c', 'd'], null] } }); }); }); + +describe('sails v2 service-scoped type resolution', () => { + const TWO_SERVICES_SAME_NAME = ` + !@sails: 1.0.0-beta.3 + + service A@0xa667a3b129e57f5c { + functions { + Set(p: Packet); + } + types { + struct Packet { + payload: [u8; 4], + } + } + } + + service B@0x8b02064fa4f2f602 { + functions { + Set(p: Packet); + } + types { + struct Packet { + payload: [u8; 8], + } + } + } + + program Test { + services { + A@0xa667a3b129e57f5c, + B@0x8b02064fa4f2f602, + } + } + `; + + test('resolveInService returns the service-local Type on name collision', () => { + const program = new SailsProgram(parser.parse(TWO_SERVICES_SAME_NAME)); + + const a = program.resolveInService('A', { kind: 'named', name: 'Packet' }); + const b = program.resolveInService('B', { kind: 'named', name: 'Packet' }); + expect(a?.kind).toBe('struct'); + expect(b?.kind).toBe('struct'); + // Differentiate by the array length on the single field. + const aField = (a as any).fields[0].type; + const bField = (b as any).fields[0].type; + expect(aField).toEqual({ kind: 'array', item: 'u8', len: 4 }); + expect(bField).toEqual({ kind: 'array', item: 'u8', len: 8 }); + }); + + test('resolveInService returns undefined for unknown service names', () => { + const program = new SailsProgram(parser.parse(TWO_SERVICES_SAME_NAME)); + expect(program.resolveInService('Nonexistent', { kind: 'named', name: 'Packet' })).toBeUndefined(); + }); + + test('program-level (ambient) types are visible inside service resolvers', () => { + // Program-level `Shared` is referenced by the ctor (parser rejects it in service signatures) + // but must still resolve through the service's resolver for consumers walking ctor args. + const text = ` + !@sails: 1.0.0-beta.3 + + service A@0x4071744d7e684110 { + functions { + Ping() -> u32; + } + } + + program Test { + constructors { + Default(shared: Shared); + } + services { + A@0x4071744d7e684110, + } + types { + struct Shared { + v: u32, + } + } + } + `; + const program = new SailsProgram(parser.parse(text)); + const t = program.resolveInService('A', { kind: 'named', name: 'Shared' }); + expect(t?.kind).toBe('struct'); + expect(t?.name).toBe('Shared'); + }); + + test('extended services see program-level (ambient) types through the extends chain', () => { + const text = ` + !@sails: 1.0.0-beta.3 + + service Base@0x4071744d7e684110 { + functions { + Ping() -> u32; + } + } + + service Child@0x1f2c78d96df31861 { + extends { + Base@0x4071744d7e684110, + } + } + + program Test { + constructors { + Default(shared: Shared); + } + services { + Child@0x1f2c78d96df31861, + } + types { + struct Shared { + v: u32, + } + } + } + `; + const program = new SailsProgram(parser.parse(text)); + const child = program.services['Child']; + const baseThroughExtends = child.extends['Base']; + // Program-level Shared must be resolvable via the extended service's own resolver. + const t = baseThroughExtends.typeResolver.resolveNamed({ kind: 'named', name: 'Shared' }); + expect(t?.kind).toBe('struct'); + expect(t?.name).toBe('Shared'); + }); + + test('generic substitution: Envelope<[u8]>.payload resolves to [u8]', () => { + const text = ` + !@sails: 1.0.0-beta.3 + + service Gen@0x8c5db6384e4cf753 { + functions { + SetPayload(p: Envelope<[u8]>); + } + types { + struct Envelope { + id: u32, + payload: T, + } + } + } + + program Test { + services { + Gen@0x8c5db6384e4cf753, + } + } + `; + const program = new SailsProgram(parser.parse(text)); + const service = program.services['Gen']; + // resolveNamed(name, generics) returns a concrete substituted Type. + const envelope = service.typeResolver.resolveNamed({ + kind: 'named', + name: 'Envelope', + generics: [{ kind: 'slice', item: 'u8' }], + }); + expect(envelope?.kind).toBe('struct'); + // type_params are stripped from the concrete result. + expect(envelope?.type_params).toBeUndefined(); + const payloadField = (envelope as any).fields.find((f: any) => f.name === 'payload'); + expect(payloadField?.type).toEqual({ kind: 'slice', item: 'u8' }); + }); +}); diff --git a/js/test/idl-v2-type-resolver.test.ts b/js/test/idl-v2-type-resolver.test.ts index 1ee369301..98e0951e8 100644 --- a/js/test/idl-v2-type-resolver.test.ts +++ b/js/test/idl-v2-type-resolver.test.ts @@ -288,6 +288,234 @@ describe('type-resolver-v2 structs', () => { }); }); +describe('type-resolver-v2 resolveGenerics', () => { + const resolver = new TypeResolver([]); + + test('replaces a type parameter leaf with a primitive', () => { + expect(resolver.resolveGenerics(generic('T'), { T: 'u32' })).toBe('u32'); + }); + + test('recurses through slice / array / tuple', () => { + const input: TypeDecl = { + kind: 'tuple', + types: [ + { kind: 'slice', item: generic('T') }, + { kind: 'array', item: generic('T'), len: 4 }, + ], + }; + expect(resolver.resolveGenerics(input, { T: 'u8' })).toEqual({ + kind: 'tuple', + types: [ + { kind: 'slice', item: 'u8' }, + { kind: 'array', item: 'u8', len: 4 }, + ], + }); + }); + + test('recurses through named-with-generics (Option, custom wrappers)', () => { + const input: TypeDecl = named('Envelope', [named('Option', [generic('T')])]); + expect( + resolver.resolveGenerics(input, { T: { kind: 'slice', item: 'u8' } }), + ).toEqual(named('Envelope', [named('Option', [{ kind: 'slice', item: 'u8' }])])); + }); + + test('passes through named refs unchanged (named is a concrete user type, never a param)', () => { + expect(resolver.resolveGenerics(named('Unknown'), {})).toEqual(named('Unknown')); + // Even if substitutions map has a matching name, a `named` decl is not substituted. + expect(resolver.resolveGenerics(named('Unknown'), { Unknown: 'u32' })).toEqual( + named('Unknown'), + ); + }); + + test('is a no-op on primitives and on inputs with no type params', () => { + expect(resolver.resolveGenerics('u32')).toBe('u32'); + expect(resolver.resolveGenerics({ kind: 'slice', item: 'u8' }, { T: 'u64' })).toEqual({ + kind: 'slice', + item: 'u8', + }); + }); + + test('is idempotent', () => { + const input: TypeDecl = named('Envelope', [generic('T')]); + const once = resolver.resolveGenerics(input, { T: 'u32' }); + expect(resolver.resolveGenerics(once, { T: 'u32' })).toEqual(once); + }); + + test('does not mutate the input tree', () => { + const input: TypeDecl = { kind: 'slice', item: generic('T') }; + const snapshot = structuredClone(input); + resolver.resolveGenerics(input, { T: 'u8' }); + expect(input).toEqual(snapshot); + }); + + test('resolves substitution chains (T -> U -> u32)', () => { + expect(resolver.resolveGenerics(generic('T'), { T: generic('U'), U: 'u32' })).toBe('u32'); + }); + + test('throws on self-referential substitution map', () => { + expect(() => resolver.resolveGenerics(generic('T'), { T: generic('T') })).toThrow( + /[Cc]yclic/, + ); + }); + + test('throws on cyclic substitution chain (T -> U -> T)', () => { + expect(() => + resolver.resolveGenerics(generic('T'), { T: generic('U'), U: generic('T') }), + ).toThrow(/[Cc]yclic/); + }); + + test('throws on unknown TypeDecl kind', () => { + // A `Type` (kind: 'struct') is not a valid TypeDecl — catch the misuse loudly. + const bogus = { kind: 'struct', name: 'X', fields: [] } as unknown as TypeDecl; + expect(() => resolver.resolveGenerics(bogus)).toThrow(/Unknown TypeDecl kind/); + }); +}); + +describe('type-resolver-v2 resolveNamed', () => { + const packet: Type = { + kind: 'struct', + name: 'Packet', + fields: [{ name: 'payload', type: { kind: 'array', item: 'u8', len: 4 } }], + }; + const envelope: Type = { + kind: 'struct', + name: 'Envelope', + type_params: [{ name: 'T' }], + fields: [ + { name: 'id', type: 'u32' }, + { name: 'payload', type: generic('T') }, + ], + }; + const maybe: Type = { + kind: 'enum', + name: 'Maybe', + type_params: [{ name: 'T' }], + variants: [ + { name: 'None', fields: [] }, + { name: 'Some', fields: [{ type: generic('T') }] }, + ], + }; + const genericAlias: Type = { + kind: 'alias', + name: 'MaybeOpt', + type_params: [{ name: 'T' }], + target: named('Option', [generic('T')]), + }; + + test('returns the raw user Type for a known named decl with no generics', () => { + const resolver = new TypeResolver([packet]); + expect(resolver.resolveNamed(named('Packet'))).toBe(packet); + }); + + test('returns a concrete substituted Type when the named decl carries generics', () => { + const resolver = new TypeResolver([envelope]); + const result = resolver.resolveNamed(named('Envelope', ['u32'])); + expect(result).toEqual({ + kind: 'struct', + name: 'Envelope', + docs: undefined, + annotations: undefined, + fields: [ + { name: 'id', type: 'u32' }, + { name: 'payload', type: 'u32' }, + ], + }); + // type_params must be omitted on the concrete result. + expect(result?.type_params).toBeUndefined(); + }); + + test('substitutes generics across enum variants', () => { + const resolver = new TypeResolver([maybe]); + const result = resolver.resolveNamed(named('Maybe', [{ kind: 'slice', item: 'u8' }])); + expect(result?.kind).toBe('enum'); + const variants = (result as any).variants; + expect(variants[0]).toEqual({ name: 'None', fields: [] }); + expect(variants[1].fields[0].type).toEqual({ kind: 'slice', item: 'u8' }); + expect(result?.type_params).toBeUndefined(); + }); + + test('substitutes generics through alias targets', () => { + const resolver = new TypeResolver([genericAlias]); + const result = resolver.resolveNamed(named('MaybeOpt', ['u32'])); + expect(result).toEqual({ + kind: 'alias', + name: 'MaybeOpt', + docs: undefined, + annotations: undefined, + target: named('Option', ['u32']), + }); + }); + + test('string overload: by name returns the raw user Type', () => { + const resolver = new TypeResolver([packet]); + expect(resolver.resolveNamed('Packet')).toBe(packet); + }); + + test('string overload: name + concrete generics returns substituted Type', () => { + const resolver = new TypeResolver([envelope]); + const result = resolver.resolveNamed('Envelope', [{ kind: 'slice', item: 'u8' }]); + expect((result as any).fields[1].type).toEqual({ kind: 'slice', item: 'u8' }); + expect(result?.type_params).toBeUndefined(); + }); + + test('string overload prefers the explicit generics list over any on the decl', () => { + const resolver = new TypeResolver([envelope]); + // Caller has a name string, provides its own generics. + const result = resolver.resolveNamed('Envelope', ['u64']); + expect((result as any).fields[1].type).toBe('u64'); + }); + + test('explicit generics arg on TypeDecl overload overrides the decl.generics field', () => { + const resolver = new TypeResolver([envelope]); + // Pass generics as a separate argument — should win over typeDecl.generics. + const result = resolver.resolveNamed(named('Envelope', ['u32']), ['String']); + expect((result as any).fields[1].type).toBe('String'); + }); + + test('returns undefined for primitives, slices, arrays, tuples, type params', () => { + const resolver = new TypeResolver([]); + expect(resolver.resolveNamed('u32')).toBeUndefined(); + expect(resolver.resolveNamed({ kind: 'slice', item: 'u8' })).toBeUndefined(); + expect(resolver.resolveNamed({ kind: 'array', item: 'u8', len: 4 })).toBeUndefined(); + expect(resolver.resolveNamed({ kind: 'tuple', types: ['u8', 'u16'] })).toBeUndefined(); + expect(resolver.resolveNamed(generic('T'))).toBeUndefined(); + }); + + test('returns undefined for unknown names', () => { + const resolver = new TypeResolver([]); + expect(resolver.resolveNamed(named('Unknown'))).toBeUndefined(); + expect(resolver.resolveNamed('Unknown')).toBeUndefined(); + expect(resolver.resolveNamed('Unknown', ['u32'])).toBeUndefined(); + }); +}); + +describe('type-resolver-v2 last-write-wins merge (shadowing via call-site merge)', () => { + // Callers wire ambient-vs-local shadowing by passing `[...ambient, ...local]` so locals + // come last and the constructor's last-write-wins loop registers the local shape. + const ambientPacket: Type = { + kind: 'struct', + name: 'Packet', + fields: [{ name: 'payload', type: { kind: 'array', item: 'u8', len: 4 } }], + }; + const localPacket: Type = { + kind: 'struct', + name: 'Packet', + fields: [{ name: 'payload', type: { kind: 'array', item: 'u8', len: 8 } }], + }; + + test('last-declared type wins on name collision', () => { + const resolver = new TypeResolver([ambientPacket, localPacket]); + expect(resolver.resolveNamed(named('Packet'))).toBe(localPacket); + }); + + test('registry reflects the last-declared shape', () => { + const resolver = new TypeResolver([ambientPacket, localPacket]); + const encoded = resolver.registry.createType('Packet', { payload: [1, 2, 3, 4, 5, 6, 7, 8] }); + expect(encoded.toJSON()).toEqual({ payload: '0x0102030405060708' }); + expect(() => resolver.registry.createType('Packet', { payload: [1, 2, 3, 4] })).toThrow(); + }); +}); + describe('type-resolver-v2 aliases', () => { test('simple alias', () => { const userType: any = {