From a83c4023d0aa8bbe2fe3e6ec35058367b0b48ab2 Mon Sep 17 00:00:00 2001 From: baha Date: Wed, 25 Feb 2026 16:13:02 +0300 Subject: [PATCH 1/4] feat: add plugin-owned primitive registry and firmware-gated requirements Migrate EVM/Solana/Cosmos/XRP lattice signers to resolve primitives via registry, add transactional chain registration, and extend unit test coverage. --- packages/chains/chain-core/src/index.ts | 26 ++ .../chain-core/src/primitiveRegistry.ts | 220 +++++++++++++++++ packages/chains/cosmos/src/devices/lattice.ts | 52 ++-- packages/chains/evm/src/devices/lattice.ts | 107 +++++--- packages/chains/solana/src/devices/lattice.ts | 52 ++-- packages/chains/xrp/src/devices/lattice.ts | 52 ++-- .../unit/chainRuntimePrimitives.test.ts | 166 +++++++++++++ .../sdk/src/__test__/unit/context.test.ts | 22 ++ .../unit/latticeSignerPrimitives.test.ts | 230 ++++++++++++++++++ .../unit/primitiveRegistry.core.test.ts | 94 +++++++ .../sdk/src/__test__/unit/primitives.test.ts | 155 ++++++++++++ .../__test__/unit/setupChainPlugins.test.ts | 29 ++- packages/sdk/src/api/setup.ts | 2 + packages/sdk/src/chains/context.ts | 8 +- packages/sdk/src/chains/index.ts | 11 + packages/sdk/src/chains/primitives.ts | 154 ++++++++++++ packages/sdk/src/chains/registry.ts | 60 ++++- packages/types/src/firmware.ts | 3 + 18 files changed, 1335 insertions(+), 108 deletions(-) create mode 100644 packages/chains/chain-core/src/primitiveRegistry.ts create mode 100644 packages/sdk/src/__test__/unit/chainRuntimePrimitives.test.ts create mode 100644 packages/sdk/src/__test__/unit/context.test.ts create mode 100644 packages/sdk/src/__test__/unit/latticeSignerPrimitives.test.ts create mode 100644 packages/sdk/src/__test__/unit/primitiveRegistry.core.test.ts create mode 100644 packages/sdk/src/__test__/unit/primitives.test.ts create mode 100644 packages/sdk/src/chains/primitives.ts diff --git a/packages/chains/chain-core/src/index.ts b/packages/chains/chain-core/src/index.ts index c31caf00..b7066af9 100644 --- a/packages/chains/chain-core/src/index.ts +++ b/packages/chains/chain-core/src/index.ts @@ -33,6 +33,25 @@ export type ChainCapabilities = { getXpub?: boolean; }; +export type PrimitiveKind = 'hash' | 'curve' | 'encoding'; + +export type PrimitiveDefinition = { + kind: PrimitiveKind; + name: string; + code: number; +}; + +export type PrimitiveRequirement = { + kind: PrimitiveKind; + name: string; + minFirmware: [number, number, number]; +}; + +export type PluginPrimitives = { + definitions?: PrimitiveDefinition[]; + requirements?: PrimitiveRequirement[]; +}; + export type GetAddressParams = { path?: DerivationPath; accountIndex?: number; @@ -127,6 +146,7 @@ export type ChainPlugin< signer: TSigner, options?: TOptions, ) => Promise | TAdapter; + primitives?: PluginPrimitives; }; export type ChainRegistryResolveOptions = { @@ -248,6 +268,12 @@ export function createChainRegistry( }; } +export { + createPrimitiveRegistry, + PrimitiveConflictError, + type PrimitiveRegistry, +} from './primitiveRegistry'; + // --------------------------------------------------------------------------- // Shared chain utilities // --------------------------------------------------------------------------- diff --git a/packages/chains/chain-core/src/primitiveRegistry.ts b/packages/chains/chain-core/src/primitiveRegistry.ts new file mode 100644 index 00000000..ca22640a --- /dev/null +++ b/packages/chains/chain-core/src/primitiveRegistry.ts @@ -0,0 +1,220 @@ +import type { PrimitiveDefinition, PrimitiveKind } from './index'; + +const PRIMITIVE_KINDS: PrimitiveKind[] = ['hash', 'curve', 'encoding']; + +type PrimitiveDefinitionMap = { + [K in PrimitiveKind]: Map; +}; + +type PrimitiveReverseDefinitionMap = { + [K in PrimitiveKind]: Map; +}; + +const createNameMaps = (): PrimitiveDefinitionMap => ({ + hash: new Map(), + curve: new Map(), + encoding: new Map(), +}); + +const createCodeMaps = (): PrimitiveReverseDefinitionMap => ({ + hash: new Map(), + curve: new Map(), + encoding: new Map(), +}); + +const normalizePrimitiveKind = (kind: unknown): PrimitiveKind => { + if (kind === 'hash' || kind === 'curve' || kind === 'encoding') { + return kind; + } + throw new Error(`Invalid primitive kind: ${String(kind)}`); +}; + +const normalizePrimitiveName = (name: unknown): string => { + if (typeof name !== 'string') { + throw new Error(`Invalid primitive name: ${String(name)}`); + } + const normalized = name.trim().toUpperCase(); + if (!normalized) { + throw new Error('Primitive name cannot be empty'); + } + return normalized; +}; + +const normalizePrimitiveCode = (code: unknown): number => { + if ( + typeof code !== 'number' || + !Number.isFinite(code) || + !Number.isInteger(code) || + code < 0 + ) { + throw new Error(`Invalid primitive code: ${String(code)}`); + } + return code; +}; + +const normalizeDefinition = (definition: PrimitiveDefinition) => { + const kind = normalizePrimitiveKind(definition.kind); + const name = normalizePrimitiveName(definition.name); + const code = normalizePrimitiveCode(definition.code); + return { kind, name, code }; +}; + +const sortDefinitions = ( + definitions: PrimitiveDefinition[], +): PrimitiveDefinition[] => { + return [...definitions].sort((a, b) => { + if (a.kind !== b.kind) return a.kind.localeCompare(b.kind); + if (a.name !== b.name) return a.name.localeCompare(b.name); + return a.code - b.code; + }); +}; + +const normalizeDefinitions = ( + definitions: PrimitiveDefinition[], +): PrimitiveDefinition[] => { + if (!Array.isArray(definitions)) { + throw new Error('Primitive definitions must be an array'); + } + return definitions.map(normalizeDefinition); +}; + +export class PrimitiveConflictError extends Error { + public readonly kind: PrimitiveKind; + public readonly primitiveName: string; + public readonly code: number; + + constructor(message: string, def: PrimitiveDefinition) { + super(message); + this.name = 'PrimitiveConflictError'; + this.kind = def.kind; + this.primitiveName = def.name; + this.code = def.code; + } +} + +export type PrimitiveRegistry = { + register: (definitions: PrimitiveDefinition[]) => void; + preflight: (definitions: PrimitiveDefinition[]) => void; + resolve: (kind: PrimitiveKind, name: string) => number | undefined; + resolveOrThrow: (kind: PrimitiveKind, name: string) => number; + reverseResolve: (kind: PrimitiveKind, code: number) => string | undefined; + has: (kind: PrimitiveKind, name: string) => boolean; + list: (kind?: PrimitiveKind) => PrimitiveDefinition[]; + reset: () => void; +}; + +export function createPrimitiveRegistry(): PrimitiveRegistry { + const nameToCode = createNameMaps(); + const codeToName = createCodeMaps(); + + const checkConflict = ( + stagedNameToCode: PrimitiveDefinitionMap, + stagedCodeToName: PrimitiveReverseDefinitionMap, + def: PrimitiveDefinition, + ) => { + const existingCode = stagedNameToCode[def.kind].get(def.name); + if (existingCode !== undefined && existingCode !== def.code) { + throw new PrimitiveConflictError( + `Primitive conflict for ${def.kind}:${def.name}. Existing code=${existingCode}, new code=${def.code}.`, + def, + ); + } + + const existingName = stagedCodeToName[def.kind].get(def.code); + if (existingName !== undefined && existingName !== def.name) { + throw new PrimitiveConflictError( + `Primitive conflict for ${def.kind} code=${def.code}. Existing name=${existingName}, new name=${def.name}.`, + def, + ); + } + }; + + const preflight = (definitions: PrimitiveDefinition[]) => { + const normalized = normalizeDefinitions(definitions); + const stagedNameToCode = createNameMaps(); + const stagedCodeToName = createCodeMaps(); + + for (const kind of PRIMITIVE_KINDS) { + nameToCode[kind].forEach((code, name) => { + stagedNameToCode[kind].set(name, code); + }); + codeToName[kind].forEach((name, code) => { + stagedCodeToName[kind].set(code, name); + }); + } + + for (const def of normalized) { + checkConflict(stagedNameToCode, stagedCodeToName, def); + stagedNameToCode[def.kind].set(def.name, def.code); + stagedCodeToName[def.kind].set(def.code, def.name); + } + }; + + const register = (definitions: PrimitiveDefinition[]) => { + const normalized = normalizeDefinitions(definitions); + preflight(normalized); + + for (const def of normalized) { + nameToCode[def.kind].set(def.name, def.code); + codeToName[def.kind].set(def.code, def.name); + } + }; + + const resolve = (kind: PrimitiveKind, name: string): number | undefined => { + return nameToCode[kind].get(normalizePrimitiveName(name)); + }; + + const resolveOrThrow = (kind: PrimitiveKind, name: string): number => { + const code = resolve(kind, name); + if (code === undefined) { + throw new Error(`Primitive not found: ${kind}:${name}`); + } + return code; + }; + + const reverseResolve = ( + kind: PrimitiveKind, + code: number, + ): string | undefined => { + return codeToName[kind].get(normalizePrimitiveCode(code)); + }; + + const has = (kind: PrimitiveKind, name: string): boolean => { + return resolve(kind, name) !== undefined; + }; + + const list = (kind?: PrimitiveKind): PrimitiveDefinition[] => { + const definitions: PrimitiveDefinition[] = []; + const kinds = kind ? [kind] : PRIMITIVE_KINDS; + + for (const currentKind of kinds) { + nameToCode[currentKind].forEach((code, name) => { + definitions.push({ + kind: currentKind, + name, + code, + }); + }); + } + + return sortDefinitions(definitions); + }; + + const reset = () => { + for (const kind of PRIMITIVE_KINDS) { + nameToCode[kind].clear(); + codeToName[kind].clear(); + } + }; + + return { + register, + preflight, + resolve, + resolveOrThrow, + reverseResolve, + has, + list, + reset, + }; +} diff --git a/packages/chains/cosmos/src/devices/lattice.ts b/packages/chains/cosmos/src/devices/lattice.ts index 999d5845..2407bbd8 100644 --- a/packages/chains/cosmos/src/devices/lattice.ts +++ b/packages/chains/cosmos/src/devices/lattice.ts @@ -3,6 +3,7 @@ import type { ChainPlugin, DerivationPath, DeviceContext, + PrimitiveKind, PublicKey, SignResult, } from '@gridplus/chain-core'; @@ -16,48 +17,44 @@ import { import { buildSigResultFromRsv, compressSecp256k1Pubkey } from './shared'; type LatticeCosmosContext = DeviceContext & { + resolvePrimitive: (kind: PrimitiveKind, name: string) => number; constants: { EXTERNAL: { GET_ADDR_FLAGS: { SECP256K1_PUB: number; }; - SIGNING: { - CURVES: { - SECP256K1: number; - }; - HASHES: { - SHA256: number; - }; - ENCODINGS: { - COSMOS: number; - }; - }; }; }; }; -function getLatticeCosmosConstants( +function getLatticeCosmosContext( context: DeviceContext, -): LatticeCosmosContext['constants'] { - const constants = (context as LatticeCosmosContext).constants; +): LatticeCosmosContext { + const typed = context as LatticeCosmosContext; + const constants = typed.constants; const hasNumber = (value: unknown): value is number => typeof value === 'number' && Number.isFinite(value); + if (typeof typed.resolvePrimitive !== 'function') { + throw new Error('Lattice Cosmos signer requires primitive resolver'); + } if ( - !hasNumber(constants?.EXTERNAL?.GET_ADDR_FLAGS?.SECP256K1_PUB) || - !hasNumber(constants?.EXTERNAL?.SIGNING?.CURVES?.SECP256K1) || - !hasNumber(constants?.EXTERNAL?.SIGNING?.HASHES?.SHA256) || - !hasNumber(constants?.EXTERNAL?.SIGNING?.ENCODINGS?.COSMOS) + !hasNumber(constants?.EXTERNAL?.GET_ADDR_FLAGS?.SECP256K1_PUB) ) { throw new Error('Lattice Cosmos signer requires EXTERNAL constants'); } - return constants; + return typed; } export function createLatticeCosmosSigner( context: DeviceContext, ): CosmosSigner { - const { queue } = context; - const { EXTERNAL } = getLatticeCosmosConstants(context); + const { queue, resolvePrimitive, constants } = getLatticeCosmosContext( + context, + ); + const { EXTERNAL } = constants; + const curveSecp256k1 = resolvePrimitive('curve', 'SECP256K1'); + const hashSha256 = resolvePrimitive('hash', 'SHA256'); + const encodingCosmos = resolvePrimitive('encoding', 'COSMOS'); return { getAddress: async (path: DerivationPath): Promise
=> { @@ -101,9 +98,9 @@ export function createLatticeCosmosSigner( const signPayload = { signerPath: path, - curveType: EXTERNAL.SIGNING.CURVES.SECP256K1, - hashType: EXTERNAL.SIGNING.HASHES.SHA256, - encodingType: EXTERNAL.SIGNING.ENCODINGS.COSMOS, + curveType: curveSecp256k1, + hashType: hashSha256, + encodingType: encodingCosmos, payload: Buffer.from(request.payload as any), }; @@ -141,4 +138,11 @@ export const latticePlugin: ChainPlugin< device: 'lattice', module: cosmos, createSigner: createLatticeCosmosSigner, + primitives: { + requirements: [ + { kind: 'curve', name: 'SECP256K1', minFirmware: [0, 14, 0] }, + { kind: 'hash', name: 'SHA256', minFirmware: [0, 14, 0] }, + { kind: 'encoding', name: 'COSMOS', minFirmware: [0, 18, 10] }, + ], + }, }; diff --git a/packages/chains/evm/src/devices/lattice.ts b/packages/chains/evm/src/devices/lattice.ts index 552c14a1..ad88eb57 100644 --- a/packages/chains/evm/src/devices/lattice.ts +++ b/packages/chains/evm/src/devices/lattice.ts @@ -3,6 +3,7 @@ import type { ChainPlugin, DerivationPath, DeviceContext, + PrimitiveKind, PublicKey, SignResult, } from '@gridplus/chain-core'; @@ -33,24 +34,12 @@ export type LatticeEvmSignerOptions = { }; type LatticeEvmContext = DeviceContext & { + resolvePrimitive: (kind: PrimitiveKind, name: string) => number; constants: { EXTERNAL: { GET_ADDR_FLAGS: { SECP256K1_PUB: number; }; - SIGNING: { - CURVES: { - SECP256K1: number; - }; - HASHES: { - KECCAK256: number; - }; - ENCODINGS: { - EVM: number; - EIP7702_AUTH: number; - EIP7702_AUTH_LIST: number; - }; - }; }; CURRENCIES: { ETH_MSG: string; @@ -70,11 +59,11 @@ function getLatticeEvmContext(context: DeviceContext): LatticeEvmContext { const constants = typed.constants; const hasNumber = (value: unknown): value is number => typeof value === 'number' && Number.isFinite(value); + if (typeof typed.resolvePrimitive !== 'function') { + throw new Error('Lattice EVM signer requires primitive resolver'); + } if ( !hasNumber(constants?.EXTERNAL?.GET_ADDR_FLAGS?.SECP256K1_PUB) || - !hasNumber(constants?.EXTERNAL?.SIGNING?.CURVES?.SECP256K1) || - !hasNumber(constants?.EXTERNAL?.SIGNING?.HASHES?.KECCAK256) || - !hasNumber(constants?.EXTERNAL?.SIGNING?.ENCODINGS?.EVM) || !constants?.CURRENCIES?.ETH_MSG ) { throw new Error( @@ -103,25 +92,71 @@ function normalizeRawEvmTx(tx: EvmRawTransaction): Hex | Buffer { function getEvmEncodingType( tx: TransactionSerializable, - EXTERNAL: LatticeEvmContext['constants']['EXTERNAL'], + resolvePrimitive: LatticeEvmContext['resolvePrimitive'], ): number { if ((tx as any).type === 'eip7702') { const eip7702 = tx as TransactionSerializableEIP7702; const hasAuthList = eip7702.authorizationList && eip7702.authorizationList.length > 0; return hasAuthList - ? EXTERNAL.SIGNING.ENCODINGS.EIP7702_AUTH_LIST - : EXTERNAL.SIGNING.ENCODINGS.EIP7702_AUTH; + ? resolvePrimitive('encoding', 'EIP7702_AUTH_LIST') + : resolvePrimitive('encoding', 'EIP7702_AUTH'); } - return EXTERNAL.SIGNING.ENCODINGS.EVM; + return resolvePrimitive('encoding', 'EVM'); } +const EIP7702_MIN_FIRMWARE: [number, number, number] = [0, 18, 0]; + +const getFirmwareVersion = (client: unknown): [number, number, number] => { + const maybeClient = client as { + getFwVersion?: () => { major?: unknown; minor?: unknown; fix?: unknown }; + }; + if (typeof maybeClient?.getFwVersion !== 'function') return [0, 0, 0]; + const fw = maybeClient.getFwVersion(); + const normalize = (value: unknown): number => + typeof value === 'number' && Number.isFinite(value) + ? Math.max(0, Math.trunc(value)) + : 0; + return [normalize(fw?.major), normalize(fw?.minor), normalize(fw?.fix)]; +}; + +const isAtLeastFirmware = ( + current: [number, number, number], + minimum: [number, number, number], +): boolean => { + if (current[0] !== minimum[0]) return current[0] > minimum[0]; + if (current[1] !== minimum[1]) return current[1] > minimum[1]; + return current[2] >= minimum[2]; +}; + +const assertEip7702FirmwareSupport = async ( + context: DeviceContext, + resolvePrimitive: LatticeEvmContext['resolvePrimitive'], + encodingType: number, +): Promise => { + const eip7702Encodings = new Set([ + resolvePrimitive('encoding', 'EIP7702_AUTH'), + resolvePrimitive('encoding', 'EIP7702_AUTH_LIST'), + ]); + if (!eip7702Encodings.has(encodingType)) return; + const client = await context.getClient(); + const fwVersion = getFirmwareVersion(client); + if (!isAtLeastFirmware(fwVersion, EIP7702_MIN_FIRMWARE)) { + throw new Error( + `EIP-7702 signing requires firmware ${EIP7702_MIN_FIRMWARE.join('.')} or newer. Device firmware: ${fwVersion.join('.')}.`, + ); + } +}; + export function createLatticeEvmSigner( context: DeviceContext, options: LatticeEvmSignerOptions = {}, ): EvmSigner { - const { queue, services } = getLatticeEvmContext(context); - const { EXTERNAL, CURRENCIES } = getLatticeEvmContext(context).constants; + const latticeContext = getLatticeEvmContext(context); + const { queue, services, resolvePrimitive } = latticeContext; + const { EXTERNAL, CURRENCIES } = latticeContext.constants; + const curveSecp256k1 = resolvePrimitive('curve', 'SECP256K1'); + const hashKeccak256 = resolvePrimitive('hash', 'KECCAK256'); return { getAddress: async (path: DerivationPath): Promise
=> { @@ -169,11 +204,16 @@ export function createLatticeEvmSigner( : serializeTransaction(request.payload as TransactionSerializable); const encodingType = isRaw - ? EXTERNAL.SIGNING.ENCODINGS.EVM + ? resolvePrimitive('encoding', 'EVM') : getEvmEncodingType( request.payload as TransactionSerializable, - EXTERNAL, + resolvePrimitive, ); + await assertEip7702FirmwareSupport( + latticeContext, + resolvePrimitive, + encodingType, + ); let decoder: Buffer | undefined; const fetchDecoder = services?.fetchDecoder; @@ -190,8 +230,8 @@ export function createLatticeEvmSigner( const signPayload = { signerPath: path, - curveType: EXTERNAL.SIGNING.CURVES.SECP256K1, - hashType: EXTERNAL.SIGNING.HASHES.KECCAK256, + curveType: curveSecp256k1, + hashType: hashKeccak256, encodingType, payload, decoder, @@ -232,8 +272,8 @@ export function createLatticeEvmSigner( client.sign({ data: { signerPath: path, - curveType: EXTERNAL.SIGNING.CURVES.SECP256K1, - hashType: EXTERNAL.SIGNING.HASHES.KECCAK256, + curveType: curveSecp256k1, + hashType: hashKeccak256, payload: request.payload as any, protocol, }, @@ -255,8 +295,8 @@ export function createLatticeEvmSigner( client.sign({ data: { signerPath: path, - curveType: EXTERNAL.SIGNING.CURVES.SECP256K1, - hashType: EXTERNAL.SIGNING.HASHES.KECCAK256, + curveType: curveSecp256k1, + hashType: hashKeccak256, payload: request.payload as any, protocol: 'eip712', }, @@ -291,4 +331,11 @@ export const latticePlugin: ChainPlugin< device: 'lattice', module: evm, createSigner: (context) => createLatticeEvmSigner(context), + primitives: { + requirements: [ + { kind: 'curve', name: 'SECP256K1', minFirmware: [0, 14, 0] }, + { kind: 'hash', name: 'KECCAK256', minFirmware: [0, 14, 0] }, + { kind: 'encoding', name: 'EVM', minFirmware: [0, 15, 0] }, + ], + }, }; diff --git a/packages/chains/solana/src/devices/lattice.ts b/packages/chains/solana/src/devices/lattice.ts index a32cfeab..c2682ddd 100644 --- a/packages/chains/solana/src/devices/lattice.ts +++ b/packages/chains/solana/src/devices/lattice.ts @@ -3,6 +3,7 @@ import type { ChainPlugin, DerivationPath, DeviceContext, + PrimitiveKind, PublicKey, SignResult, } from '@gridplus/chain-core'; @@ -17,48 +18,44 @@ import { import { buildSigResultFromRsv, toBuffer } from './shared'; type LatticeSolanaContext = DeviceContext & { + resolvePrimitive: (kind: PrimitiveKind, name: string) => number; constants: { EXTERNAL: { GET_ADDR_FLAGS: { ED25519_PUB: number; }; - SIGNING: { - CURVES: { - ED25519: number; - }; - HASHES: { - NONE: number; - }; - ENCODINGS: { - SOLANA: number; - }; - }; }; }; }; -function getLatticeSolanaConstants( +function getLatticeSolanaContext( context: DeviceContext, -): LatticeSolanaContext['constants'] { - const constants = (context as LatticeSolanaContext).constants; +): LatticeSolanaContext { + const typed = context as LatticeSolanaContext; + const constants = typed.constants; const hasNumber = (value: unknown): value is number => typeof value === 'number' && Number.isFinite(value); + if (typeof typed.resolvePrimitive !== 'function') { + throw new Error('Lattice Solana signer requires primitive resolver'); + } if ( - !hasNumber(constants?.EXTERNAL?.GET_ADDR_FLAGS?.ED25519_PUB) || - !hasNumber(constants?.EXTERNAL?.SIGNING?.CURVES?.ED25519) || - !hasNumber(constants?.EXTERNAL?.SIGNING?.HASHES?.NONE) || - !hasNumber(constants?.EXTERNAL?.SIGNING?.ENCODINGS?.SOLANA) + !hasNumber(constants?.EXTERNAL?.GET_ADDR_FLAGS?.ED25519_PUB) ) { throw new Error('Lattice Solana signer requires EXTERNAL constants'); } - return constants; + return typed; } export function createLatticeSolanaSigner( context: DeviceContext, ): SolanaSigner { - const { queue } = context; - const { EXTERNAL } = getLatticeSolanaConstants(context); + const { queue, resolvePrimitive, constants } = getLatticeSolanaContext( + context, + ); + const { EXTERNAL } = constants; + const curveEd25519 = resolvePrimitive('curve', 'ED25519'); + const hashNone = resolvePrimitive('hash', 'NONE'); + const encodingSolana = resolvePrimitive('encoding', 'SOLANA'); const getPublicKey = async (path: DerivationPath): Promise => { const res = (await queue((client: any) => @@ -90,9 +87,9 @@ export function createLatticeSolanaSigner( const signPayload = { signerPath: path, - curveType: EXTERNAL.SIGNING.CURVES.ED25519, - hashType: EXTERNAL.SIGNING.HASHES.NONE, - encodingType: EXTERNAL.SIGNING.ENCODINGS.SOLANA, + curveType: curveEd25519, + hashType: hashNone, + encodingType: encodingSolana, payload: toBuffer(request.payload as any), }; @@ -130,4 +127,11 @@ export const latticePlugin: ChainPlugin< device: 'lattice', module: solana, createSigner: createLatticeSolanaSigner, + primitives: { + requirements: [ + { kind: 'curve', name: 'ED25519', minFirmware: [0, 14, 0] }, + { kind: 'hash', name: 'NONE', minFirmware: [0, 14, 0] }, + { kind: 'encoding', name: 'SOLANA', minFirmware: [0, 14, 0] }, + ], + }, }; diff --git a/packages/chains/xrp/src/devices/lattice.ts b/packages/chains/xrp/src/devices/lattice.ts index 7ac1d9d8..2f5d8a13 100644 --- a/packages/chains/xrp/src/devices/lattice.ts +++ b/packages/chains/xrp/src/devices/lattice.ts @@ -3,6 +3,7 @@ import type { ChainPlugin, DerivationPath, DeviceContext, + PrimitiveKind, PublicKey, SignResult, } from '@gridplus/chain-core'; @@ -21,48 +22,40 @@ import { } from './shared'; type LatticeXrpContext = DeviceContext & { + resolvePrimitive: (kind: PrimitiveKind, name: string) => number; constants: { EXTERNAL: { GET_ADDR_FLAGS: { SECP256K1_PUB: number; }; - SIGNING: { - CURVES: { - SECP256K1: number; - }; - HASHES: { - SHA512HALF: number; - }; - ENCODINGS: { - XRP: number; - }; - }; }; }; }; -function getLatticeXrpConstants( +function getLatticeXrpContext( context: DeviceContext, -): LatticeXrpContext['constants'] { - const constants = (context as LatticeXrpContext).constants; +): LatticeXrpContext { + const typed = context as LatticeXrpContext; + const constants = typed.constants; const hasNumber = (value: unknown): value is number => typeof value === 'number' && Number.isFinite(value); + if (typeof typed.resolvePrimitive !== 'function') { + throw new Error('Lattice XRP signer requires primitive resolver'); + } - if ( - !hasNumber(constants?.EXTERNAL?.GET_ADDR_FLAGS?.SECP256K1_PUB) || - !hasNumber(constants?.EXTERNAL?.SIGNING?.CURVES?.SECP256K1) || - !hasNumber(constants?.EXTERNAL?.SIGNING?.HASHES?.SHA512HALF) || - !hasNumber(constants?.EXTERNAL?.SIGNING?.ENCODINGS?.XRP) - ) { + if (!hasNumber(constants?.EXTERNAL?.GET_ADDR_FLAGS?.SECP256K1_PUB)) { throw new Error('Lattice XRP signer requires EXTERNAL constants'); } - return constants; + return typed; } export function createLatticeXrpSigner(context: DeviceContext): XrpSigner { - const { queue } = context; - const { EXTERNAL } = getLatticeXrpConstants(context); + const { queue, resolvePrimitive, constants } = getLatticeXrpContext(context); + const { EXTERNAL } = constants; + const curveSecp256k1 = resolvePrimitive('curve', 'SECP256K1'); + const hashSha512Half = resolvePrimitive('hash', 'SHA512HALF'); + const encodingXrp = resolvePrimitive('encoding', 'XRP'); const getPublicKey = async ( path: DerivationPath, @@ -107,9 +100,9 @@ export function createLatticeXrpSigner(context: DeviceContext): XrpSigner { const signPayload = { signerPath: path, - curveType: EXTERNAL.SIGNING.CURVES.SECP256K1, - hashType: EXTERNAL.SIGNING.HASHES.SHA512HALF, - encodingType: EXTERNAL.SIGNING.ENCODINGS.XRP, + curveType: curveSecp256k1, + hashType: hashSha512Half, + encodingType: encodingXrp, payload: toBuffer(request.payload as any), }; @@ -149,4 +142,11 @@ export const latticePlugin: ChainPlugin< device: 'lattice', module: xrp, createSigner: createLatticeXrpSigner, + primitives: { + requirements: [ + { kind: 'curve', name: 'SECP256K1', minFirmware: [0, 14, 0] }, + { kind: 'hash', name: 'SHA512HALF', minFirmware: [0, 18, 10] }, + { kind: 'encoding', name: 'XRP', minFirmware: [0, 18, 10] }, + ], + }, }; diff --git a/packages/sdk/src/__test__/unit/chainRuntimePrimitives.test.ts b/packages/sdk/src/__test__/unit/chainRuntimePrimitives.test.ts new file mode 100644 index 00000000..63206306 --- /dev/null +++ b/packages/sdk/src/__test__/unit/chainRuntimePrimitives.test.ts @@ -0,0 +1,166 @@ +import type { + ChainAdapter, + ChainModule, + ChainPlugin, + PluginPrimitives, + Signer, +} from '@gridplus/chain-core'; +import { setLoadClient } from '../../api/state'; +import { + configureChainRuntime, + getChain, + registerChainPlugin, + unregisterChain, + useChain, +} from '../../chains'; +import { + getPrimitiveRegistry, + resetPrimitiveRegistry, +} from '../../chains/primitives'; + +const mockSigner: Signer = { + getAddress: async () => 'mock', + getPublicKey: async () => new Uint8Array([1]), + sign: async () => ({ signature: { bytes: new Uint8Array([2]) } }), +}; + +const mockAdapter: ChainAdapter = { + getAddress: async () => 'mock', + getAddresses: async () => ['mock'], + getPublicKey: async () => new Uint8Array([1]), + sign: async () => ({ signature: { bytes: new Uint8Array([2]) } }), +}; + +const buildPlugin = ( + chainId: string, + primitives?: PluginPrimitives, +): ChainPlugin => { + const module: ChainModule = { + id: chainId, + name: chainId, + coinType: 1, + curve: 'secp256k1', + defaultPath: [44, 60, 0, 0, 0], + supports: { + signTransaction: true, + signMessage: false, + signTypedData: false, + signArbitrary: false, + getPublicKey: true, + }, + create: () => mockAdapter, + utils: {}, + }; + + return { + chainId, + device: 'lattice', + module, + createSigner: async () => mockSigner, + primitives, + }; +}; + +describe('chain runtime primitive integration', () => { + afterEach(() => { + unregisterChain('chain-a', 'lattice'); + unregisterChain('chain-b', 'lattice'); + unregisterChain('dup-chain', 'lattice'); + unregisterChain('req-chain', 'lattice'); + resetPrimitiveRegistry(); + configureChainRuntime({ + autoRegisterChains: true, + defaultDevice: 'lattice', + resetCache: true, + }); + setLoadClient(async () => undefined); + }); + + test('primitive conflict does not register conflicting chain', () => { + registerChainPlugin( + buildPlugin('chain-a', { + definitions: [{ kind: 'encoding', name: 'TESTCHAIN', code: 99 }], + }), + ); + + expect(() => + registerChainPlugin( + buildPlugin('chain-b', { + definitions: [{ kind: 'encoding', name: 'TESTCHAIN', code: 100 }], + }), + ), + ).toThrow(); + + expect(getChain('chain-a', 'lattice')).toBeDefined(); + expect(getChain('chain-b', 'lattice')).toBeUndefined(); + }); + + test('chain registration failure does not leave new primitive definitions', () => { + registerChainPlugin( + buildPlugin('dup-chain', { + definitions: [{ kind: 'encoding', name: 'DUP_A', code: 201 }], + }), + ); + + expect(() => + registerChainPlugin( + buildPlugin('dup-chain', { + definitions: [{ kind: 'encoding', name: 'DUP_B', code: 202 }], + }), + ), + ).toThrow('already registered'); + + const primitiveRegistry = getPrimitiveRegistry(); + expect(primitiveRegistry.resolve('encoding', 'DUP_A')).toBe(201); + expect(primitiveRegistry.resolve('encoding', 'DUP_B')).toBeUndefined(); + }); + + test('useChain enforces primitive minFirmware requirements', async () => { + configureChainRuntime({ + autoRegisterChains: false, + defaultDevice: 'lattice', + resetCache: true, + }); + registerChainPlugin( + buildPlugin('req-chain', { + definitions: [{ kind: 'encoding', name: 'REQ_CHAIN', code: 88 }], + requirements: [ + { kind: 'encoding', name: 'REQ_CHAIN', minFirmware: [0, 20, 0] }, + ], + }), + ); + + setLoadClient( + async () => + ({ + getFwVersion: () => ({ major: 0, minor: 19, fix: 0 }), + }) as any, + ); + + await expect(useChain('req-chain')).rejects.toThrow('Please update firmware'); + }); + + test('useChain succeeds when firmware satisfies requirements', async () => { + configureChainRuntime({ + autoRegisterChains: false, + defaultDevice: 'lattice', + resetCache: true, + }); + registerChainPlugin( + buildPlugin('req-chain', { + definitions: [{ kind: 'encoding', name: 'REQ_CHAIN', code: 88 }], + requirements: [ + { kind: 'encoding', name: 'REQ_CHAIN', minFirmware: [0, 20, 0] }, + ], + }), + ); + setLoadClient( + async () => + ({ + getFwVersion: () => ({ major: 0, minor: 20, fix: 0 }), + }) as any, + ); + + await expect(useChain('req-chain')).resolves.toBeDefined(); + }); +}); diff --git a/packages/sdk/src/__test__/unit/context.test.ts b/packages/sdk/src/__test__/unit/context.test.ts new file mode 100644 index 00000000..79dd2848 --- /dev/null +++ b/packages/sdk/src/__test__/unit/context.test.ts @@ -0,0 +1,22 @@ +import { createDeviceContext } from '../../chains/context'; +import { resetPrimitiveRegistry } from '../../chains/primitives'; + +describe('chain context primitive resolution', () => { + afterEach(() => { + resetPrimitiveRegistry(); + }); + + test('resolvePrimitive resolves seeded builtins', () => { + const context = createDeviceContext(); + expect(context.resolvePrimitive('hash', 'KECCAK256')).toBeDefined(); + expect(context.resolvePrimitive('curve', 'SECP256K1')).toBeDefined(); + expect(context.resolvePrimitive('encoding', 'EVM')).toBeDefined(); + }); + + test('resolvePrimitive throws for unknown primitive', () => { + const context = createDeviceContext(); + expect(() => + context.resolvePrimitive('encoding', 'UNKNOWN_CHAIN'), + ).toThrow('Primitive not found'); + }); +}); diff --git a/packages/sdk/src/__test__/unit/latticeSignerPrimitives.test.ts b/packages/sdk/src/__test__/unit/latticeSignerPrimitives.test.ts new file mode 100644 index 00000000..4bf5f8a6 --- /dev/null +++ b/packages/sdk/src/__test__/unit/latticeSignerPrimitives.test.ts @@ -0,0 +1,230 @@ +import { createLatticeCosmosSigner } from '@gridplus/cosmos'; +import { createLatticeEvmSigner } from '@gridplus/evm'; +import { createLatticeSolanaSigner } from '@gridplus/solana'; +import { createLatticeXrpSigner } from '@gridplus/xrp'; +import type { PrimitiveKind } from '@gridplus/chain-core'; + +type PrimitiveMap = Record; + +type MockContextOptions = { + primitives: PrimitiveMap; + firmware?: [number, number, number]; +}; + +const primitiveKey = (kind: PrimitiveKind, name: string): string => + `${kind}:${name}`; + +const buildMockContext = (options: MockContextOptions) => { + const firmware = options.firmware ?? [1, 0, 0]; + const signCalls: Array = []; + + const client = { + sign: vi.fn(async (request: any) => { + signCalls.push(request); + return { sig: {} }; + }), + getFwVersion: () => ({ + major: firmware[0], + minor: firmware[1], + fix: firmware[2], + }), + getAddresses: vi.fn(async () => []), + }; + + const resolvePrimitive = vi.fn((kind: PrimitiveKind, name: string) => { + const key = primitiveKey(kind, name); + const code = options.primitives[key]; + if (code === undefined) { + throw new Error(`Missing primitive mapping for ${key}`); + } + return code; + }); + + const context = { + queue: async (fn: (client: unknown) => Promise) => fn(client), + getClient: async () => client, + resolvePrimitive, + constants: { + EXTERNAL: { + GET_ADDR_FLAGS: { + SECP256K1_PUB: 1, + ED25519_PUB: 2, + }, + }, + CURRENCIES: { + ETH_MSG: 'ETH_MSG', + }, + }, + services: {}, + } as any; + + return { + context, + client, + signCalls, + resolvePrimitive, + }; +}; + +const buildEip7702AuthListTx = () => ({ + type: 'eip7702', + chainId: 1, + nonce: 0, + maxPriorityFeePerGas: 1n, + maxFeePerGas: 2n, + gas: 21000n, + to: '0x1111111111111111111111111111111111111111', + value: 0n, + data: '0x', + accessList: [], + authorizationList: [ + { + chainId: 1, + address: '0x2222222222222222222222222222222222222222', + nonce: 0, + signature: { + yParity: 0, + r: `0x${'1'.repeat(64)}`, + s: `0x${'2'.repeat(64)}`, + }, + }, + ], +}); + +describe('lattice signer primitive resolution', () => { + test('evm signer uses resolved primitive codes in sign payload', async () => { + const { context, signCalls } = buildMockContext({ + primitives: { + 'curve:SECP256K1': 91, + 'hash:KECCAK256': 92, + 'encoding:EVM': 93, + 'encoding:EIP7702_AUTH': 94, + 'encoding:EIP7702_AUTH_LIST': 95, + }, + }); + const signer = createLatticeEvmSigner(context); + + await signer.sign({ + kind: 'transaction', + payload: '0x01', + options: { path: [44, 60, 0] }, + }); + + expect(signCalls).toHaveLength(1); + expect(signCalls[0].data.curveType).toBe(91); + expect(signCalls[0].data.hashType).toBe(92); + expect(signCalls[0].data.encodingType).toBe(93); + }); + + test('solana signer uses resolved primitive codes in sign payload', async () => { + const { context, signCalls } = buildMockContext({ + primitives: { + 'curve:ED25519': 11, + 'hash:NONE': 12, + 'encoding:SOLANA': 13, + }, + }); + const signer = createLatticeSolanaSigner(context); + + await signer.sign({ + kind: 'transaction', + payload: new Uint8Array([1, 2, 3]), + options: { path: [44, 501, 0] }, + }); + + expect(signCalls).toHaveLength(1); + expect(signCalls[0].data.curveType).toBe(11); + expect(signCalls[0].data.hashType).toBe(12); + expect(signCalls[0].data.encodingType).toBe(13); + }); + + test('cosmos signer uses resolved primitive codes in sign payload', async () => { + const { context, signCalls } = buildMockContext({ + primitives: { + 'curve:SECP256K1': 21, + 'hash:SHA256': 22, + 'encoding:COSMOS': 23, + }, + }); + const signer = createLatticeCosmosSigner(context); + + await signer.sign({ + kind: 'transaction', + payload: new Uint8Array([4, 5, 6]), + options: { path: [44, 118, 0, 0, 0] }, + }); + + expect(signCalls).toHaveLength(1); + expect(signCalls[0].data.curveType).toBe(21); + expect(signCalls[0].data.hashType).toBe(22); + expect(signCalls[0].data.encodingType).toBe(23); + }); + + test('xrp signer uses resolved primitive codes in sign payload', async () => { + const { context, signCalls } = buildMockContext({ + primitives: { + 'curve:SECP256K1': 31, + 'hash:SHA512HALF': 32, + 'encoding:XRP': 33, + }, + }); + const signer = createLatticeXrpSigner(context); + + await signer.sign({ + kind: 'transaction', + payload: new Uint8Array([7, 8, 9]), + options: { path: [44, 144, 0, 0, 0] }, + }); + + expect(signCalls).toHaveLength(1); + expect(signCalls[0].data.curveType).toBe(31); + expect(signCalls[0].data.hashType).toBe(32); + expect(signCalls[0].data.encodingType).toBe(33); + }); + + test('evm EIP-7702 signing fails below minimum firmware', async () => { + const { context, client } = buildMockContext({ + primitives: { + 'curve:SECP256K1': 41, + 'hash:KECCAK256': 42, + 'encoding:EVM': 43, + 'encoding:EIP7702_AUTH': 44, + 'encoding:EIP7702_AUTH_LIST': 45, + }, + firmware: [0, 17, 9], + }); + const signer = createLatticeEvmSigner(context); + + await expect( + signer.sign({ + kind: 'transaction', + payload: buildEip7702AuthListTx() as any, + options: { path: [44, 60, 0] }, + }), + ).rejects.toThrow('requires firmware 0.18.0'); + expect(client.sign).not.toHaveBeenCalled(); + }); + + test('evm EIP-7702 signing succeeds at minimum firmware and uses EIP-7702 encoding', async () => { + const { context, signCalls } = buildMockContext({ + primitives: { + 'curve:SECP256K1': 51, + 'hash:KECCAK256': 52, + 'encoding:EVM': 53, + 'encoding:EIP7702_AUTH': 54, + 'encoding:EIP7702_AUTH_LIST': 55, + }, + firmware: [0, 18, 0], + }); + const signer = createLatticeEvmSigner(context); + + await signer.sign({ + kind: 'transaction', + payload: buildEip7702AuthListTx() as any, + options: { path: [44, 60, 0] }, + }); + + expect(signCalls).toHaveLength(1); + expect(signCalls[0].data.encodingType).toBe(55); + }); +}); diff --git a/packages/sdk/src/__test__/unit/primitiveRegistry.core.test.ts b/packages/sdk/src/__test__/unit/primitiveRegistry.core.test.ts new file mode 100644 index 00000000..a2fa8b23 --- /dev/null +++ b/packages/sdk/src/__test__/unit/primitiveRegistry.core.test.ts @@ -0,0 +1,94 @@ +import { + PrimitiveConflictError, + createPrimitiveRegistry, + type PrimitiveDefinition, +} from '@gridplus/chain-core'; + +describe('primitive registry core', () => { + test('registers and resolves by name and code', () => { + const registry = createPrimitiveRegistry(); + registry.register([{ kind: 'hash', name: 'SHA256', code: 2 }]); + + expect(registry.resolve('hash', 'SHA256')).toBe(2); + expect(registry.reverseResolve('hash', 2)).toBe('SHA256'); + expect(registry.has('hash', 'SHA256')).toBe(true); + }); + + test('ignores exact duplicates', () => { + const registry = createPrimitiveRegistry(); + registry.register([{ kind: 'hash', name: 'SHA256', code: 2 }]); + registry.register([{ kind: 'hash', name: 'SHA256', code: 2 }]); + + expect(registry.resolve('hash', 'SHA256')).toBe(2); + expect(registry.list('hash')).toHaveLength(1); + }); + + test('throws on name collision', () => { + const registry = createPrimitiveRegistry(); + registry.register([{ kind: 'hash', name: 'SHA256', code: 2 }]); + + expect(() => + registry.register([{ kind: 'hash', name: 'SHA256', code: 99 }]), + ).toThrow(PrimitiveConflictError); + }); + + test('throws on code collision', () => { + const registry = createPrimitiveRegistry(); + registry.register([{ kind: 'hash', name: 'SHA256', code: 2 }]); + + expect(() => + registry.register([{ kind: 'hash', name: 'BLAKE2B', code: 2 }]), + ).toThrow(PrimitiveConflictError); + }); + + test('namespaces collisions by kind', () => { + const registry = createPrimitiveRegistry(); + registry.register([ + { kind: 'hash', name: 'SHA256', code: 2 }, + { kind: 'encoding', name: 'SHA256', code: 2 }, + ]); + + expect(registry.resolve('hash', 'SHA256')).toBe(2); + expect(registry.resolve('encoding', 'SHA256')).toBe(2); + }); + + test('preflight catches conflicts without mutation', () => { + const registry = createPrimitiveRegistry(); + registry.register([{ kind: 'hash', name: 'SHA256', code: 2 }]); + + expect(() => + registry.preflight([{ kind: 'hash', name: 'SHA256', code: 77 }]), + ).toThrow(PrimitiveConflictError); + expect(registry.resolve('hash', 'SHA256')).toBe(2); + expect(registry.resolve('hash', 'BLAKE2B')).toBeUndefined(); + }); + + test('register is atomic for a batch', () => { + const registry = createPrimitiveRegistry(); + registry.register([{ kind: 'hash', name: 'SHA256', code: 2 }]); + + const batch: PrimitiveDefinition[] = [ + { kind: 'hash', name: 'BLAKE2B', code: 4 }, + { kind: 'hash', name: 'SHA256', code: 99 }, + ]; + + expect(() => registry.register(batch)).toThrow(PrimitiveConflictError); + expect(registry.resolve('hash', 'BLAKE2B')).toBeUndefined(); + }); + + test('resolveOrThrow throws for missing primitive', () => { + const registry = createPrimitiveRegistry(); + expect(() => registry.resolveOrThrow('hash', 'MISSING')).toThrow( + 'Primitive not found', + ); + }); + + test('reset clears registered mappings', () => { + const registry = createPrimitiveRegistry(); + registry.register([{ kind: 'curve', name: 'SECP256K1', code: 0 }]); + registry.reset(); + + expect(registry.resolve('curve', 'SECP256K1')).toBeUndefined(); + expect(registry.list()).toHaveLength(0); + }); +}); diff --git a/packages/sdk/src/__test__/unit/primitives.test.ts b/packages/sdk/src/__test__/unit/primitives.test.ts new file mode 100644 index 00000000..f606bce6 --- /dev/null +++ b/packages/sdk/src/__test__/unit/primitives.test.ts @@ -0,0 +1,155 @@ +import type { + ChainAdapter, + ChainModule, + ChainPlugin, + PluginPrimitives, + Signer, +} from '@gridplus/chain-core'; +import { + ensurePrimitivesSeeded, + getPrimitiveRegistry, + registerPluginPrimitives, + resetPrimitiveRegistry, + validatePluginPrimitiveRequirements, +} from '../../chains/primitives'; + +const mockSigner: Signer = { + getAddress: async () => 'mock', + getPublicKey: async () => new Uint8Array([1]), + sign: async () => ({ signature: { bytes: new Uint8Array([2]) } }), +}; + +const mockAdapter: ChainAdapter = { + getAddress: async () => 'mock', + getAddresses: async () => ['mock'], + getPublicKey: async () => new Uint8Array([1]), + sign: async () => ({ signature: { bytes: new Uint8Array([2]) } }), +}; + +const buildPlugin = ( + chainId: string, + primitives?: PluginPrimitives, +): ChainPlugin => { + const module: ChainModule = { + id: chainId, + name: chainId, + coinType: 1, + curve: 'secp256k1', + defaultPath: [44, 60, 0, 0, 0], + supports: { + signTransaction: true, + signMessage: true, + signTypedData: false, + signArbitrary: false, + getPublicKey: true, + }, + create: () => mockAdapter, + utils: {}, + }; + + return { + chainId, + device: 'lattice', + module, + createSigner: async () => mockSigner, + primitives, + }; +}; + +describe('sdk primitives module', () => { + afterEach(() => { + resetPrimitiveRegistry(); + }); + + test('ensurePrimitivesSeeded is idempotent and registers builtins', () => { + ensurePrimitivesSeeded(); + ensurePrimitivesSeeded(); + + const registry = getPrimitiveRegistry(); + expect(registry.resolve('hash', 'KECCAK256')).toBeDefined(); + expect(registry.resolve('curve', 'SECP256K1')).toBeDefined(); + expect(registry.resolve('encoding', 'EVM')).toBeDefined(); + }); + + test('registerPluginPrimitives registers custom definitions', () => { + const plugin = buildPlugin('testchain', { + definitions: [{ kind: 'encoding', name: 'TESTCHAIN', code: 99 }], + requirements: [ + { kind: 'encoding', name: 'TESTCHAIN', minFirmware: [0, 20, 0] }, + ], + }); + + registerPluginPrimitives(plugin); + expect(getPrimitiveRegistry().resolve('encoding', 'TESTCHAIN')).toBe(99); + }); + + test('registerPluginPrimitives fails on conflicts without partial commit', () => { + registerPluginPrimitives( + buildPlugin('chain-a', { + definitions: [{ kind: 'encoding', name: 'TESTCHAIN', code: 99 }], + }), + ); + + const conflicting = buildPlugin('chain-b', { + definitions: [ + { kind: 'encoding', name: 'TESTCHAIN', code: 100 }, + { kind: 'hash', name: 'BLAKE2B', code: 4 }, + ], + }); + + expect(() => registerPluginPrimitives(conflicting)).toThrow(); + expect(getPrimitiveRegistry().resolve('hash', 'BLAKE2B')).toBeUndefined(); + }); + + test('validatePluginPrimitiveRequirements passes when firmware requirement is met', () => { + ensurePrimitivesSeeded(); + const plugin = buildPlugin('cosmos-like', { + requirements: [ + { kind: 'encoding', name: 'COSMOS', minFirmware: [0, 18, 10] }, + ], + }); + + expect(() => + validatePluginPrimitiveRequirements(plugin, [0, 19, 0]), + ).not.toThrow(); + }); + + test('validatePluginPrimitiveRequirements throws when firmware requirement is unmet', () => { + ensurePrimitivesSeeded(); + const plugin = buildPlugin('cosmos-like', { + requirements: [ + { kind: 'encoding', name: 'COSMOS', minFirmware: [0, 18, 10] }, + ], + }); + + expect(() => + validatePluginPrimitiveRequirements(plugin, [0, 18, 9]), + ).toThrow('Please update firmware'); + }); + + test('validatePluginPrimitiveRequirements throws for missing primitive mapping', () => { + const plugin = buildPlugin('custom-chain', { + requirements: [ + { kind: 'encoding', name: 'UNREGISTERED_CHAIN', minFirmware: [0, 20, 0] }, + ], + }); + + expect(() => + validatePluginPrimitiveRequirements(plugin, [0, 20, 0]), + ).toThrow('not registered'); + }); + + test('rejects invalid requirement shape (fail-closed)', () => { + const plugin = buildPlugin('invalid') as ChainPlugin; + (plugin as any).primitives = { + requirements: [{ kind: 'hash', name: 'SHA256' }], + }; + + expect(() => registerPluginPrimitives(plugin)).toThrow( + 'invalid primitive requirement', + ); + expect(() => + validatePluginPrimitiveRequirements(plugin, [9, 9, 9]), + ).toThrow('invalid primitive requirement'); + }); +}); diff --git a/packages/sdk/src/__test__/unit/setupChainPlugins.test.ts b/packages/sdk/src/__test__/unit/setupChainPlugins.test.ts index 2c280305..22218dbc 100644 --- a/packages/sdk/src/__test__/unit/setupChainPlugins.test.ts +++ b/packages/sdk/src/__test__/unit/setupChainPlugins.test.ts @@ -2,6 +2,7 @@ import type { ChainAdapter, ChainModule, ChainPlugin, + PluginPrimitives, Signer, } from '@gridplus/chain-core'; import { setup } from '../../api/setup'; @@ -10,6 +11,10 @@ import { DEFAULT_CHAIN_PLUGIN_KEYS, DEFAULT_CHAIN_PLUGINS, } from '../../chains/defaultManifest'; +import { + getPrimitiveRegistry, + resetPrimitiveRegistry, +} from '../../chains/primitives'; const mockSigner: Signer = { getAddress: async () => 'custom', @@ -24,7 +29,11 @@ const mockAdapter: ChainAdapter = { sign: async () => ({ signature: { bytes: new Uint8Array([2]) } }), }; -const buildPlugin = (chainId: string, device: string): ChainPlugin => { +const buildPlugin = ( + chainId: string, + device: string, + primitives?: PluginPrimitives, +): ChainPlugin => { const module: ChainModule = { id: chainId, name: `test-${chainId}`, @@ -47,6 +56,7 @@ const buildPlugin = (chainId: string, device: string): ChainPlugin => { device, module, createSigner: async () => mockSigner, + primitives, }; }; @@ -58,6 +68,7 @@ const setupParamsBase = { describe('setup chainPlugins', () => { afterEach(() => { + resetPrimitiveRegistry(); unregisterChain('unit-test-chain', 'unit-test-device'); DEFAULT_CHAIN_PLUGIN_KEYS.forEach((key) => { const [chainId, device] = key.split(':'); @@ -120,4 +131,20 @@ describe('setup chainPlugins', () => { }), ).rejects.toThrow('Invalid chain plugin in setup().chainPlugins'); }); + + test('registerChainPlugin works without setup and lazily seeds builtins', () => { + const customPlugin = buildPlugin('unit-test-chain', 'unit-test-device', { + definitions: [{ kind: 'encoding', name: 'TESTCHAIN', code: 99 }], + requirements: [ + { kind: 'encoding', name: 'TESTCHAIN', minFirmware: [0, 14, 0] }, + ], + }); + + registerChainPlugin(customPlugin); + + const registry = getPrimitiveRegistry(); + expect(registry.resolve('encoding', 'TESTCHAIN')).toBe(99); + expect(registry.resolve('encoding', 'EVM')).toBeDefined(); + expect(getChain('unit-test-chain', 'unit-test-device')).toBeDefined(); + }); }); diff --git a/packages/sdk/src/api/setup.ts b/packages/sdk/src/api/setup.ts index c0e3b80e..8d9ac4ae 100644 --- a/packages/sdk/src/api/setup.ts +++ b/packages/sdk/src/api/setup.ts @@ -7,6 +7,7 @@ import { import { configureChainRuntime, discoverAndRegisterChains, + ensurePrimitivesSeeded, getChain, registerChainPlugin, unregisterChain, @@ -99,6 +100,7 @@ export const setup = async (params: SetupParameters): Promise => { if (!params.setStoredClient) throw new Error('Client data setter required'); setSaveClient(buildSaveClientFn(params.setStoredClient)); + ensurePrimitivesSeeded(); configureChainRuntime({ autoRegisterChains: params.autoRegisterChains ?? true, defaultDevice: params.defaultDevice ?? 'lattice', diff --git a/packages/sdk/src/chains/context.ts b/packages/sdk/src/chains/context.ts index 4e5e4907..1b938d3d 100644 --- a/packages/sdk/src/chains/context.ts +++ b/packages/sdk/src/chains/context.ts @@ -1,8 +1,9 @@ -import type { DeviceContext } from '@gridplus/chain-core'; +import type { DeviceContext, PrimitiveKind } from '@gridplus/chain-core'; import { CURRENCIES } from '@gridplus/types'; import { getClient, queue } from '../api/utilities'; import { EXTERNAL } from '../constants'; import { fetchDecoder } from '../functions/fetchDecoder'; +import { ensurePrimitivesSeeded, getPrimitiveRegistry } from './primitives'; export type SdkDeviceContext = DeviceContext & { constants: { @@ -12,6 +13,7 @@ export type SdkDeviceContext = DeviceContext & { services: { fetchDecoder: typeof fetchDecoder; }; + resolvePrimitive: (kind: PrimitiveKind, name: string) => number; }; // Bridges SDK runtime primitives into the generic chain-core DeviceContext shape. @@ -25,4 +27,8 @@ export const createDeviceContext = (): SdkDeviceContext => ({ services: { fetchDecoder, }, + resolvePrimitive: (kind, name) => { + ensurePrimitivesSeeded(); + return getPrimitiveRegistry().resolveOrThrow(kind, name); + }, }); diff --git a/packages/sdk/src/chains/index.ts b/packages/sdk/src/chains/index.ts index 117058c1..6430be5d 100644 --- a/packages/sdk/src/chains/index.ts +++ b/packages/sdk/src/chains/index.ts @@ -9,6 +9,13 @@ export { unregisterChain, useChain, } from './registry'; +export { + ensurePrimitivesSeeded, + getPrimitiveRegistry, + preflightPluginPrimitives, + registerPluginPrimitives, + validatePluginPrimitiveRequirements, +} from './primitives'; export type { ChainKey, @@ -18,6 +25,10 @@ export type { ChainRegistryResolveOptions, DeviceContext, DeviceId, + PluginPrimitives, + PrimitiveDefinition, + PrimitiveKind, + PrimitiveRequirement, } from '@gridplus/chain-core'; export type { SdkDeviceContext } from './context'; diff --git a/packages/sdk/src/chains/primitives.ts b/packages/sdk/src/chains/primitives.ts new file mode 100644 index 00000000..136e5d72 --- /dev/null +++ b/packages/sdk/src/chains/primitives.ts @@ -0,0 +1,154 @@ +import { + createPrimitiveRegistry, + type ChainPlugin, + type PrimitiveDefinition, + type PrimitiveRequirement, +} from '@gridplus/chain-core'; +import { EXTERNAL } from '../constants'; + +type FirmwareVersionTuple = [number, number, number]; + +const registry = createPrimitiveRegistry(); +let seeded = false; + +const isFirmwareVersionTuple = ( + value: unknown, +): value is FirmwareVersionTuple => { + if (!Array.isArray(value) || value.length !== 3) return false; + return value.every( + (part) => + typeof part === 'number' && + Number.isInteger(part) && + Number.isFinite(part) && + part >= 0, + ); +}; + +const isPrimitiveRequirement = ( + requirement: unknown, +): requirement is PrimitiveRequirement => { + if (!requirement || typeof requirement !== 'object') return false; + const req = requirement as PrimitiveRequirement; + const kind = + req.kind === 'hash' || req.kind === 'curve' || req.kind === 'encoding'; + return ( + kind && + typeof req.name === 'string' && + req.name.trim().length > 0 && + isFirmwareVersionTuple(req.minFirmware) + ); +}; + +const compareFirmwareVersions = ( + current: FirmwareVersionTuple, + required: FirmwareVersionTuple, +): number => { + if (current[0] !== required[0]) return current[0] - required[0]; + if (current[1] !== required[1]) return current[1] - required[1]; + return current[2] - required[2]; +}; + +const getPluginPrimitiveDefinitions = ( + plugin: ChainPlugin, +): PrimitiveDefinition[] => { + const definitions = plugin.primitives?.definitions; + if (!definitions || !Array.isArray(definitions)) return []; + return definitions; +}; + +const getPluginPrimitiveRequirements = ( + plugin: ChainPlugin, +): PrimitiveRequirement[] => { + const requirements = plugin.primitives?.requirements; + if (!requirements || !Array.isArray(requirements)) return []; + return requirements as PrimitiveRequirement[]; +}; + +const validatePluginRequirementShape = (plugin: ChainPlugin): void => { + const requirements = plugin.primitives?.requirements; + if (!requirements) return; + if (!Array.isArray(requirements)) { + throw new Error( + `Chain "${plugin.chainId}" has invalid primitive requirements: expected an array.`, + ); + } + requirements.forEach((requirement, index) => { + if (!isPrimitiveRequirement(requirement)) { + throw new Error( + `Chain "${plugin.chainId}" has invalid primitive requirement at index ${index}.`, + ); + } + }); +}; + +export function getPrimitiveRegistry() { + return registry; +} + +export function ensurePrimitivesSeeded(): void { + if (seeded) return; + const definitions: PrimitiveDefinition[] = []; + + Object.entries(EXTERNAL.SIGNING.HASHES).forEach(([name, code]) => { + definitions.push({ kind: 'hash', name, code }); + }); + Object.entries(EXTERNAL.SIGNING.CURVES).forEach(([name, code]) => { + definitions.push({ kind: 'curve', name, code }); + }); + Object.entries(EXTERNAL.SIGNING.ENCODINGS).forEach(([name, code]) => { + definitions.push({ kind: 'encoding', name, code }); + }); + + registry.register(definitions); + seeded = true; +} + +export function preflightPluginPrimitives(plugin: ChainPlugin): void { + validatePluginRequirementShape(plugin); + ensurePrimitivesSeeded(); + + const definitions = getPluginPrimitiveDefinitions(plugin); + if (definitions.length === 0) return; + registry.preflight(definitions); +} + +export function registerPluginPrimitives(plugin: ChainPlugin): void { + validatePluginRequirementShape(plugin); + ensurePrimitivesSeeded(); + + const definitions = getPluginPrimitiveDefinitions(plugin); + if (definitions.length === 0) return; + registry.register(definitions); +} + +export function validatePluginPrimitiveRequirements( + plugin: ChainPlugin, + fwVersion: FirmwareVersionTuple, +): void { + validatePluginRequirementShape(plugin); + ensurePrimitivesSeeded(); + + const requirements = getPluginPrimitiveRequirements(plugin); + if (requirements.length === 0) return; + + requirements.forEach((requirement) => { + if (!registry.has(requirement.kind, requirement.name)) { + throw new Error( + `Chain "${plugin.chainId}" requires ${requirement.kind}:${requirement.name}, but it is not registered.`, + ); + } + + if (compareFirmwareVersions(fwVersion, requirement.minFirmware) < 0) { + throw new Error( + `Chain "${plugin.chainId}" requires ${requirement.kind}:${requirement.name} (min firmware ${requirement.minFirmware.join( + '.', + )}), but device is running ${fwVersion.join('.')}. Please update firmware.`, + ); + } + }); +} + +export function resetPrimitiveRegistry(): void { + registry.reset(); + seeded = false; +} diff --git a/packages/sdk/src/chains/registry.ts b/packages/sdk/src/chains/registry.ts index 61951fcb..dac258ba 100644 --- a/packages/sdk/src/chains/registry.ts +++ b/packages/sdk/src/chains/registry.ts @@ -5,6 +5,12 @@ import { } from '@gridplus/chain-core'; import { createDeviceContext, type SdkDeviceContext } from './context'; import { discoverAndRegisterChains as discoverChains } from './discovery'; +import { + ensurePrimitivesSeeded, + preflightPluginPrimitives, + registerPluginPrimitives, + validatePluginPrimitiveRequirements, +} from './primitives'; type ConfigureChainRuntimeOptions = { autoRegisterChains?: boolean; @@ -53,9 +59,38 @@ const stringifyAdapterOptions = (options: unknown): string => { } }; +type FirmwareVersionTuple = [number, number, number]; + +type FirmwareVersionSource = { + getFwVersion?: () => { + major?: unknown; + minor?: unknown; + fix?: unknown; + }; +}; + +const normalizeFirmwarePart = (value: unknown): number => { + if (typeof value !== 'number' || !Number.isFinite(value)) return 0; + return Math.max(0, Math.trunc(value)); +}; + +const getFirmwareVersion = (client: unknown): FirmwareVersionTuple => { + const maybeClient = client as FirmwareVersionSource; + if (typeof maybeClient?.getFwVersion !== 'function') { + return [0, 0, 0]; + } + + const fw = maybeClient.getFwVersion(); + return [ + normalizeFirmwarePart(fw?.major), + normalizeFirmwarePart(fw?.minor), + normalizeFirmwarePart(fw?.fix), + ]; +}; + const registerDiscoveredPlugin = (plugin: ChainPlugin): boolean => { if (registry.has(plugin.chainId, plugin.device)) return false; - registry.register(plugin as ChainPlugin); + registerChainPlugin(plugin); return true; }; @@ -88,7 +123,20 @@ export function getDefaultDevice(): DeviceId { } export function registerChainPlugin(plugin: ChainPlugin): void { - registry.register(plugin as ChainPlugin); + ensurePrimitivesSeeded(); + preflightPluginPrimitives(plugin); + + let chainRegistered = false; + try { + registry.register(plugin as ChainPlugin); + chainRegistered = true; + registerPluginPrimitives(plugin); + } catch (err) { + if (chainRegistered) { + registry.unregister(plugin.chainId, plugin.device); + } + throw err; + } } export function unregisterChain(chainId: string, device?: DeviceId): boolean { @@ -151,6 +199,14 @@ export async function useChain( // Signer is created once per (chain, device) generation and reused by adapters. if (!cached || cached.generation !== cacheGeneration) { const context = createDeviceContext(); + if (resolved.primitives?.requirements?.length) { + const client = await context.getClient(); + const fwVersion = getFirmwareVersion(client); + validatePluginPrimitiveRequirements( + resolved as ChainPlugin, + fwVersion, + ); + } const signer = await resolved.createSigner(context); cached = { generation: cacheGeneration, diff --git a/packages/types/src/firmware.ts b/packages/types/src/firmware.ts index 527211da..ba509e1c 100644 --- a/packages/types/src/firmware.ts +++ b/packages/types/src/firmware.ts @@ -26,11 +26,13 @@ export interface GenericSigningData { KECCAK256: typeof LatticeSignHash.keccak256; SHA256: typeof LatticeSignHash.sha256; SHA512HALF?: typeof LatticeSignHash.sha512half; + [key: string]: number | undefined; }; curveTypes: { SECP256K1: typeof LatticeSignCurve.secp256k1; ED25519: typeof LatticeSignCurve.ed25519; BLS12_381_G2: typeof LatticeSignCurve.bls12_381; + [key: string]: number | undefined; }; encodingTypes: { NONE: typeof LatticeSignEncoding.none; @@ -38,6 +40,7 @@ export interface GenericSigningData { COSMOS?: typeof LatticeSignEncoding.cosmos; EVM?: typeof LatticeSignEncoding.evm; XRP?: typeof LatticeSignEncoding.xrp; + [key: string]: number | undefined; }; } From deb4cb92309abf8d0ae9c30f2f9872526ce0b4b3 Mon Sep 17 00:00:00 2001 From: baha Date: Wed, 25 Feb 2026 16:49:09 +0300 Subject: [PATCH 2/4] fix: lint --- packages/chains/cosmos/src/devices/lattice.ts | 13 ++++--------- packages/chains/solana/src/devices/lattice.ts | 13 ++++--------- packages/chains/xrp/src/devices/lattice.ts | 4 +--- .../__test__/unit/chainRuntimePrimitives.test.ts | 4 +++- packages/sdk/src/__test__/unit/context.test.ts | 6 +++--- packages/sdk/src/__test__/unit/primitives.test.ts | 6 +++++- 6 files changed, 20 insertions(+), 26 deletions(-) diff --git a/packages/chains/cosmos/src/devices/lattice.ts b/packages/chains/cosmos/src/devices/lattice.ts index 2407bbd8..6aa90533 100644 --- a/packages/chains/cosmos/src/devices/lattice.ts +++ b/packages/chains/cosmos/src/devices/lattice.ts @@ -27,9 +27,7 @@ type LatticeCosmosContext = DeviceContext & { }; }; -function getLatticeCosmosContext( - context: DeviceContext, -): LatticeCosmosContext { +function getLatticeCosmosContext(context: DeviceContext): LatticeCosmosContext { const typed = context as LatticeCosmosContext; const constants = typed.constants; const hasNumber = (value: unknown): value is number => @@ -37,9 +35,7 @@ function getLatticeCosmosContext( if (typeof typed.resolvePrimitive !== 'function') { throw new Error('Lattice Cosmos signer requires primitive resolver'); } - if ( - !hasNumber(constants?.EXTERNAL?.GET_ADDR_FLAGS?.SECP256K1_PUB) - ) { + if (!hasNumber(constants?.EXTERNAL?.GET_ADDR_FLAGS?.SECP256K1_PUB)) { throw new Error('Lattice Cosmos signer requires EXTERNAL constants'); } return typed; @@ -48,9 +44,8 @@ function getLatticeCosmosContext( export function createLatticeCosmosSigner( context: DeviceContext, ): CosmosSigner { - const { queue, resolvePrimitive, constants } = getLatticeCosmosContext( - context, - ); + const { queue, resolvePrimitive, constants } = + getLatticeCosmosContext(context); const { EXTERNAL } = constants; const curveSecp256k1 = resolvePrimitive('curve', 'SECP256K1'); const hashSha256 = resolvePrimitive('hash', 'SHA256'); diff --git a/packages/chains/solana/src/devices/lattice.ts b/packages/chains/solana/src/devices/lattice.ts index c2682ddd..6cd720d5 100644 --- a/packages/chains/solana/src/devices/lattice.ts +++ b/packages/chains/solana/src/devices/lattice.ts @@ -28,9 +28,7 @@ type LatticeSolanaContext = DeviceContext & { }; }; -function getLatticeSolanaContext( - context: DeviceContext, -): LatticeSolanaContext { +function getLatticeSolanaContext(context: DeviceContext): LatticeSolanaContext { const typed = context as LatticeSolanaContext; const constants = typed.constants; const hasNumber = (value: unknown): value is number => @@ -38,9 +36,7 @@ function getLatticeSolanaContext( if (typeof typed.resolvePrimitive !== 'function') { throw new Error('Lattice Solana signer requires primitive resolver'); } - if ( - !hasNumber(constants?.EXTERNAL?.GET_ADDR_FLAGS?.ED25519_PUB) - ) { + if (!hasNumber(constants?.EXTERNAL?.GET_ADDR_FLAGS?.ED25519_PUB)) { throw new Error('Lattice Solana signer requires EXTERNAL constants'); } return typed; @@ -49,9 +45,8 @@ function getLatticeSolanaContext( export function createLatticeSolanaSigner( context: DeviceContext, ): SolanaSigner { - const { queue, resolvePrimitive, constants } = getLatticeSolanaContext( - context, - ); + const { queue, resolvePrimitive, constants } = + getLatticeSolanaContext(context); const { EXTERNAL } = constants; const curveEd25519 = resolvePrimitive('curve', 'ED25519'); const hashNone = resolvePrimitive('hash', 'NONE'); diff --git a/packages/chains/xrp/src/devices/lattice.ts b/packages/chains/xrp/src/devices/lattice.ts index 2f5d8a13..2ba15fda 100644 --- a/packages/chains/xrp/src/devices/lattice.ts +++ b/packages/chains/xrp/src/devices/lattice.ts @@ -32,9 +32,7 @@ type LatticeXrpContext = DeviceContext & { }; }; -function getLatticeXrpContext( - context: DeviceContext, -): LatticeXrpContext { +function getLatticeXrpContext(context: DeviceContext): LatticeXrpContext { const typed = context as LatticeXrpContext; const constants = typed.constants; const hasNumber = (value: unknown): value is number => diff --git a/packages/sdk/src/__test__/unit/chainRuntimePrimitives.test.ts b/packages/sdk/src/__test__/unit/chainRuntimePrimitives.test.ts index 63206306..7b34d3aa 100644 --- a/packages/sdk/src/__test__/unit/chainRuntimePrimitives.test.ts +++ b/packages/sdk/src/__test__/unit/chainRuntimePrimitives.test.ts @@ -137,7 +137,9 @@ describe('chain runtime primitive integration', () => { }) as any, ); - await expect(useChain('req-chain')).rejects.toThrow('Please update firmware'); + await expect(useChain('req-chain')).rejects.toThrow( + 'Please update firmware', + ); }); test('useChain succeeds when firmware satisfies requirements', async () => { diff --git a/packages/sdk/src/__test__/unit/context.test.ts b/packages/sdk/src/__test__/unit/context.test.ts index 79dd2848..f028f635 100644 --- a/packages/sdk/src/__test__/unit/context.test.ts +++ b/packages/sdk/src/__test__/unit/context.test.ts @@ -15,8 +15,8 @@ describe('chain context primitive resolution', () => { test('resolvePrimitive throws for unknown primitive', () => { const context = createDeviceContext(); - expect(() => - context.resolvePrimitive('encoding', 'UNKNOWN_CHAIN'), - ).toThrow('Primitive not found'); + expect(() => context.resolvePrimitive('encoding', 'UNKNOWN_CHAIN')).toThrow( + 'Primitive not found', + ); }); }); diff --git a/packages/sdk/src/__test__/unit/primitives.test.ts b/packages/sdk/src/__test__/unit/primitives.test.ts index f606bce6..acd6bcc9 100644 --- a/packages/sdk/src/__test__/unit/primitives.test.ts +++ b/packages/sdk/src/__test__/unit/primitives.test.ts @@ -130,7 +130,11 @@ describe('sdk primitives module', () => { test('validatePluginPrimitiveRequirements throws for missing primitive mapping', () => { const plugin = buildPlugin('custom-chain', { requirements: [ - { kind: 'encoding', name: 'UNREGISTERED_CHAIN', minFirmware: [0, 20, 0] }, + { + kind: 'encoding', + name: 'UNREGISTERED_CHAIN', + minFirmware: [0, 20, 0], + }, ], }); From d308cf86e28441fb6f576dd6ab0bb4a3ce470d76 Mon Sep 17 00:00:00 2001 From: baha Date: Wed, 25 Feb 2026 23:51:51 +0300 Subject: [PATCH 3/4] chore: apply final lint and wiring updates for primitive registry --- packages/chains/chain-core/src/index.ts | 44 ++++++++++++ packages/chains/evm/src/devices/lattice.ts | 78 ++++++++++------------ packages/sdk/src/chains/index.ts | 1 - packages/sdk/src/chains/primitives.ts | 13 +--- packages/sdk/src/chains/registry.ts | 30 +-------- 5 files changed, 81 insertions(+), 85 deletions(-) diff --git a/packages/chains/chain-core/src/index.ts b/packages/chains/chain-core/src/index.ts index b7066af9..77117248 100644 --- a/packages/chains/chain-core/src/index.ts +++ b/packages/chains/chain-core/src/index.ts @@ -274,6 +274,50 @@ export { type PrimitiveRegistry, } from './primitiveRegistry'; +// --------------------------------------------------------------------------- +// Firmware version utilities +// --------------------------------------------------------------------------- + +export type FirmwareVersionTuple = [number, number, number]; + +const normalizeFirmwarePart = (value: unknown): number => { + if (typeof value !== 'number' || !Number.isFinite(value)) return 0; + return Math.max(0, Math.trunc(value)); +}; + +export const getFirmwareVersion = (client: unknown): FirmwareVersionTuple => { + const maybeClient = client as { + getFwVersion?: () => { + major?: unknown; + minor?: unknown; + fix?: unknown; + }; + }; + if (typeof maybeClient?.getFwVersion !== 'function') { + return [0, 0, 0]; + } + const fw = maybeClient.getFwVersion(); + return [ + normalizeFirmwarePart(fw?.major), + normalizeFirmwarePart(fw?.minor), + normalizeFirmwarePart(fw?.fix), + ]; +}; + +export const compareFirmwareVersions = ( + current: FirmwareVersionTuple, + required: FirmwareVersionTuple, +): number => { + if (current[0] !== required[0]) return current[0] - required[0]; + if (current[1] !== required[1]) return current[1] - required[1]; + return current[2] - required[2]; +}; + +export const isAtLeastFirmware = ( + current: FirmwareVersionTuple, + minimum: FirmwareVersionTuple, +): boolean => compareFirmwareVersions(current, minimum) >= 0; + // --------------------------------------------------------------------------- // Shared chain utilities // --------------------------------------------------------------------------- diff --git a/packages/chains/evm/src/devices/lattice.ts b/packages/chains/evm/src/devices/lattice.ts index ad88eb57..cb6031f0 100644 --- a/packages/chains/evm/src/devices/lattice.ts +++ b/packages/chains/evm/src/devices/lattice.ts @@ -1,11 +1,14 @@ -import type { - Address, - ChainPlugin, - DerivationPath, - DeviceContext, - PrimitiveKind, - PublicKey, - SignResult, +import { + getFirmwareVersion, + isAtLeastFirmware, + type Address, + type ChainPlugin, + type DerivationPath, + type DeviceContext, + type FirmwareVersionTuple, + type PrimitiveKind, + type PublicKey, + type SignResult, } from '@gridplus/chain-core'; import { Hash } from 'ox'; import { @@ -90,54 +93,32 @@ function normalizeRawEvmTx(tx: EvmRawTransaction): Hex | Buffer { return Buffer.from(tx); } +type EvmEncodingCodes = { + evm: number; + eip7702Auth: number; + eip7702AuthList: number; +}; + function getEvmEncodingType( tx: TransactionSerializable, - resolvePrimitive: LatticeEvmContext['resolvePrimitive'], + encodings: EvmEncodingCodes, ): number { if ((tx as any).type === 'eip7702') { const eip7702 = tx as TransactionSerializableEIP7702; const hasAuthList = eip7702.authorizationList && eip7702.authorizationList.length > 0; - return hasAuthList - ? resolvePrimitive('encoding', 'EIP7702_AUTH_LIST') - : resolvePrimitive('encoding', 'EIP7702_AUTH'); + return hasAuthList ? encodings.eip7702AuthList : encodings.eip7702Auth; } - return resolvePrimitive('encoding', 'EVM'); + return encodings.evm; } -const EIP7702_MIN_FIRMWARE: [number, number, number] = [0, 18, 0]; - -const getFirmwareVersion = (client: unknown): [number, number, number] => { - const maybeClient = client as { - getFwVersion?: () => { major?: unknown; minor?: unknown; fix?: unknown }; - }; - if (typeof maybeClient?.getFwVersion !== 'function') return [0, 0, 0]; - const fw = maybeClient.getFwVersion(); - const normalize = (value: unknown): number => - typeof value === 'number' && Number.isFinite(value) - ? Math.max(0, Math.trunc(value)) - : 0; - return [normalize(fw?.major), normalize(fw?.minor), normalize(fw?.fix)]; -}; - -const isAtLeastFirmware = ( - current: [number, number, number], - minimum: [number, number, number], -): boolean => { - if (current[0] !== minimum[0]) return current[0] > minimum[0]; - if (current[1] !== minimum[1]) return current[1] > minimum[1]; - return current[2] >= minimum[2]; -}; +const EIP7702_MIN_FIRMWARE: FirmwareVersionTuple = [0, 18, 0]; const assertEip7702FirmwareSupport = async ( context: DeviceContext, - resolvePrimitive: LatticeEvmContext['resolvePrimitive'], + eip7702Encodings: Set, encodingType: number, ): Promise => { - const eip7702Encodings = new Set([ - resolvePrimitive('encoding', 'EIP7702_AUTH'), - resolvePrimitive('encoding', 'EIP7702_AUTH_LIST'), - ]); if (!eip7702Encodings.has(encodingType)) return; const client = await context.getClient(); const fwVersion = getFirmwareVersion(client); @@ -157,6 +138,15 @@ export function createLatticeEvmSigner( const { EXTERNAL, CURRENCIES } = latticeContext.constants; const curveSecp256k1 = resolvePrimitive('curve', 'SECP256K1'); const hashKeccak256 = resolvePrimitive('hash', 'KECCAK256'); + const encodings: EvmEncodingCodes = { + evm: resolvePrimitive('encoding', 'EVM'), + eip7702Auth: resolvePrimitive('encoding', 'EIP7702_AUTH'), + eip7702AuthList: resolvePrimitive('encoding', 'EIP7702_AUTH_LIST'), + }; + const eip7702Encodings = new Set([ + encodings.eip7702Auth, + encodings.eip7702AuthList, + ]); return { getAddress: async (path: DerivationPath): Promise
=> { @@ -204,14 +194,14 @@ export function createLatticeEvmSigner( : serializeTransaction(request.payload as TransactionSerializable); const encodingType = isRaw - ? resolvePrimitive('encoding', 'EVM') + ? encodings.evm : getEvmEncodingType( request.payload as TransactionSerializable, - resolvePrimitive, + encodings, ); await assertEip7702FirmwareSupport( latticeContext, - resolvePrimitive, + eip7702Encodings, encodingType, ); diff --git a/packages/sdk/src/chains/index.ts b/packages/sdk/src/chains/index.ts index 6430be5d..f14dc574 100644 --- a/packages/sdk/src/chains/index.ts +++ b/packages/sdk/src/chains/index.ts @@ -11,7 +11,6 @@ export { } from './registry'; export { ensurePrimitivesSeeded, - getPrimitiveRegistry, preflightPluginPrimitives, registerPluginPrimitives, validatePluginPrimitiveRequirements, diff --git a/packages/sdk/src/chains/primitives.ts b/packages/sdk/src/chains/primitives.ts index 136e5d72..16b4ee40 100644 --- a/packages/sdk/src/chains/primitives.ts +++ b/packages/sdk/src/chains/primitives.ts @@ -1,13 +1,13 @@ import { + compareFirmwareVersions, createPrimitiveRegistry, type ChainPlugin, + type FirmwareVersionTuple, type PrimitiveDefinition, type PrimitiveRequirement, } from '@gridplus/chain-core'; import { EXTERNAL } from '../constants'; -type FirmwareVersionTuple = [number, number, number]; - const registry = createPrimitiveRegistry(); let seeded = false; @@ -39,15 +39,6 @@ const isPrimitiveRequirement = ( ); }; -const compareFirmwareVersions = ( - current: FirmwareVersionTuple, - required: FirmwareVersionTuple, -): number => { - if (current[0] !== required[0]) return current[0] - required[0]; - if (current[1] !== required[1]) return current[1] - required[1]; - return current[2] - required[2]; -}; - const getPluginPrimitiveDefinitions = ( plugin: ChainPlugin, ): PrimitiveDefinition[] => { diff --git a/packages/sdk/src/chains/registry.ts b/packages/sdk/src/chains/registry.ts index dac258ba..b6095f11 100644 --- a/packages/sdk/src/chains/registry.ts +++ b/packages/sdk/src/chains/registry.ts @@ -1,5 +1,6 @@ import { createChainRegistry, + getFirmwareVersion, type ChainPlugin, type DeviceId, } from '@gridplus/chain-core'; @@ -59,35 +60,6 @@ const stringifyAdapterOptions = (options: unknown): string => { } }; -type FirmwareVersionTuple = [number, number, number]; - -type FirmwareVersionSource = { - getFwVersion?: () => { - major?: unknown; - minor?: unknown; - fix?: unknown; - }; -}; - -const normalizeFirmwarePart = (value: unknown): number => { - if (typeof value !== 'number' || !Number.isFinite(value)) return 0; - return Math.max(0, Math.trunc(value)); -}; - -const getFirmwareVersion = (client: unknown): FirmwareVersionTuple => { - const maybeClient = client as FirmwareVersionSource; - if (typeof maybeClient?.getFwVersion !== 'function') { - return [0, 0, 0]; - } - - const fw = maybeClient.getFwVersion(); - return [ - normalizeFirmwarePart(fw?.major), - normalizeFirmwarePart(fw?.minor), - normalizeFirmwarePart(fw?.fix), - ]; -}; - const registerDiscoveredPlugin = (plugin: ChainPlugin): boolean => { if (registry.has(plugin.chainId, plugin.device)) return false; registerChainPlugin(plugin); From b06f89de6100eed959238f25044298d353cf2f2d Mon Sep 17 00:00:00 2001 From: baha Date: Thu, 26 Feb 2026 00:35:18 +0300 Subject: [PATCH 4/4] fix(sdk): preserve DeviceContext compatibility and clean up plugin primitives on unregister Add lattice signer fallback to EXTERNAL.SIGNING when resolvePrimitive is absent, and remove plugin-owned primitive definitions during unregisterChain to prevent stale conflicts. --- packages/chains/cosmos/src/devices/lattice.ts | 59 +++++++++-- packages/chains/evm/src/devices/lattice.ts | 99 ++++++++++++------- packages/chains/solana/src/devices/lattice.ts | 59 +++++++++-- packages/chains/xrp/src/devices/lattice.ts | 59 +++++++++-- .../unit/chainRuntimePrimitives.test.ts | 21 ++++ .../unit/latticeSignerPrimitives.test.ts | 52 +++++++++- packages/sdk/src/chains/primitives.ts | 84 +++++++++++++--- packages/sdk/src/chains/registry.ts | 8 +- 8 files changed, 359 insertions(+), 82 deletions(-) diff --git a/packages/chains/cosmos/src/devices/lattice.ts b/packages/chains/cosmos/src/devices/lattice.ts index 6aa90533..d2bc9f61 100644 --- a/packages/chains/cosmos/src/devices/lattice.ts +++ b/packages/chains/cosmos/src/devices/lattice.ts @@ -16,29 +16,70 @@ import { } from '../chain'; import { buildSigResultFromRsv, compressSecp256k1Pubkey } from './shared'; -type LatticeCosmosContext = DeviceContext & { - resolvePrimitive: (kind: PrimitiveKind, name: string) => number; +type PrimitiveCodeMaps = { + HASHES?: Record; + CURVES?: Record; + ENCODINGS?: Record; +}; + +type LatticeCosmosContextInput = DeviceContext & { + resolvePrimitive?: (kind: PrimitiveKind, name: string) => number; constants: { EXTERNAL: { GET_ADDR_FLAGS: { SECP256K1_PUB: number; }; + SIGNING?: PrimitiveCodeMaps; }; }; }; +type LatticeCosmosContext = DeviceContext & { + resolvePrimitive: (kind: PrimitiveKind, name: string) => number; + constants: LatticeCosmosContextInput['constants']; +}; + +const hasNumber = (value: unknown): value is number => + typeof value === 'number' && Number.isFinite(value); + +const getPrimitiveFromConstants = ( + signing: PrimitiveCodeMaps | undefined, + kind: PrimitiveKind, + name: string, +): number | undefined => { + const byKind: Record | undefined> = { + hash: signing?.HASHES, + curve: signing?.CURVES, + encoding: signing?.ENCODINGS, + }; + const code = byKind[kind]?.[name]; + return hasNumber(code) ? code : undefined; +}; + function getLatticeCosmosContext(context: DeviceContext): LatticeCosmosContext { - const typed = context as LatticeCosmosContext; + const typed = context as LatticeCosmosContextInput; const constants = typed.constants; - const hasNumber = (value: unknown): value is number => - typeof value === 'number' && Number.isFinite(value); - if (typeof typed.resolvePrimitive !== 'function') { - throw new Error('Lattice Cosmos signer requires primitive resolver'); - } + const resolvePrimitive = (kind: PrimitiveKind, name: string): number => { + if (typeof typed.resolvePrimitive === 'function') { + return typed.resolvePrimitive(kind, name); + } + const fromConstants = getPrimitiveFromConstants( + constants?.EXTERNAL?.SIGNING, + kind, + name, + ); + if (fromConstants !== undefined) return fromConstants; + throw new Error( + `Lattice Cosmos signer requires resolvePrimitive() or EXTERNAL.SIGNING mapping for ${kind}:${name}.`, + ); + }; if (!hasNumber(constants?.EXTERNAL?.GET_ADDR_FLAGS?.SECP256K1_PUB)) { throw new Error('Lattice Cosmos signer requires EXTERNAL constants'); } - return typed; + return { + ...typed, + resolvePrimitive, + }; } export function createLatticeCosmosSigner( diff --git a/packages/chains/evm/src/devices/lattice.ts b/packages/chains/evm/src/devices/lattice.ts index cb6031f0..860d1674 100644 --- a/packages/chains/evm/src/devices/lattice.ts +++ b/packages/chains/evm/src/devices/lattice.ts @@ -36,13 +36,20 @@ export type LatticeEvmSignerOptions = { fetchEvmDecoder?: boolean; }; -type LatticeEvmContext = DeviceContext & { - resolvePrimitive: (kind: PrimitiveKind, name: string) => number; +type PrimitiveCodeMaps = { + HASHES?: Record; + CURVES?: Record; + ENCODINGS?: Record; +}; + +type LatticeEvmContextInput = DeviceContext & { + resolvePrimitive?: (kind: PrimitiveKind, name: string) => number; constants: { EXTERNAL: { GET_ADDR_FLAGS: { SECP256K1_PUB: number; }; + SIGNING?: PrimitiveCodeMaps; }; CURRENCIES: { ETH_MSG: string; @@ -57,14 +64,46 @@ type LatticeEvmContext = DeviceContext & { }; }; +type LatticeEvmContext = DeviceContext & { + resolvePrimitive: (kind: PrimitiveKind, name: string) => number; + constants: LatticeEvmContextInput['constants']; + services?: LatticeEvmContextInput['services']; +}; + +const hasNumber = (value: unknown): value is number => + typeof value === 'number' && Number.isFinite(value); + +const getPrimitiveFromConstants = ( + signing: PrimitiveCodeMaps | undefined, + kind: PrimitiveKind, + name: string, +): number | undefined => { + const byKind: Record | undefined> = { + hash: signing?.HASHES, + curve: signing?.CURVES, + encoding: signing?.ENCODINGS, + }; + const code = byKind[kind]?.[name]; + return hasNumber(code) ? code : undefined; +}; + function getLatticeEvmContext(context: DeviceContext): LatticeEvmContext { - const typed = context as LatticeEvmContext; + const typed = context as LatticeEvmContextInput; const constants = typed.constants; - const hasNumber = (value: unknown): value is number => - typeof value === 'number' && Number.isFinite(value); - if (typeof typed.resolvePrimitive !== 'function') { - throw new Error('Lattice EVM signer requires primitive resolver'); - } + const resolvePrimitive = (kind: PrimitiveKind, name: string): number => { + if (typeof typed.resolvePrimitive === 'function') { + return typed.resolvePrimitive(kind, name); + } + const fromConstants = getPrimitiveFromConstants( + constants?.EXTERNAL?.SIGNING, + kind, + name, + ); + if (fromConstants !== undefined) return fromConstants; + throw new Error( + `Lattice EVM signer requires resolvePrimitive() or EXTERNAL.SIGNING mapping for ${kind}:${name}.`, + ); + }; if ( !hasNumber(constants?.EXTERNAL?.GET_ADDR_FLAGS?.SECP256K1_PUB) || !constants?.CURRENCIES?.ETH_MSG @@ -73,7 +112,10 @@ function getLatticeEvmContext(context: DeviceContext): LatticeEvmContext { 'Lattice EVM signer requires EXTERNAL and CURRENCIES constants', ); } - return typed; + return { + ...typed, + resolvePrimitive, + }; } function isRawEvmTx( @@ -93,33 +135,26 @@ function normalizeRawEvmTx(tx: EvmRawTransaction): Hex | Buffer { return Buffer.from(tx); } -type EvmEncodingCodes = { - evm: number; - eip7702Auth: number; - eip7702AuthList: number; -}; - function getEvmEncodingType( tx: TransactionSerializable, - encodings: EvmEncodingCodes, + resolvePrimitive: (kind: PrimitiveKind, name: string) => number, ): number { if ((tx as any).type === 'eip7702') { const eip7702 = tx as TransactionSerializableEIP7702; const hasAuthList = eip7702.authorizationList && eip7702.authorizationList.length > 0; - return hasAuthList ? encodings.eip7702AuthList : encodings.eip7702Auth; + return hasAuthList + ? resolvePrimitive('encoding', 'EIP7702_AUTH_LIST') + : resolvePrimitive('encoding', 'EIP7702_AUTH'); } - return encodings.evm; + return resolvePrimitive('encoding', 'EVM'); } const EIP7702_MIN_FIRMWARE: FirmwareVersionTuple = [0, 18, 0]; const assertEip7702FirmwareSupport = async ( context: DeviceContext, - eip7702Encodings: Set, - encodingType: number, ): Promise => { - if (!eip7702Encodings.has(encodingType)) return; const client = await context.getClient(); const fwVersion = getFirmwareVersion(client); if (!isAtLeastFirmware(fwVersion, EIP7702_MIN_FIRMWARE)) { @@ -138,15 +173,7 @@ export function createLatticeEvmSigner( const { EXTERNAL, CURRENCIES } = latticeContext.constants; const curveSecp256k1 = resolvePrimitive('curve', 'SECP256K1'); const hashKeccak256 = resolvePrimitive('hash', 'KECCAK256'); - const encodings: EvmEncodingCodes = { - evm: resolvePrimitive('encoding', 'EVM'), - eip7702Auth: resolvePrimitive('encoding', 'EIP7702_AUTH'), - eip7702AuthList: resolvePrimitive('encoding', 'EIP7702_AUTH_LIST'), - }; - const eip7702Encodings = new Set([ - encodings.eip7702Auth, - encodings.eip7702AuthList, - ]); + const encodingEvm = resolvePrimitive('encoding', 'EVM'); return { getAddress: async (path: DerivationPath): Promise
=> { @@ -194,16 +221,14 @@ export function createLatticeEvmSigner( : serializeTransaction(request.payload as TransactionSerializable); const encodingType = isRaw - ? encodings.evm + ? encodingEvm : getEvmEncodingType( request.payload as TransactionSerializable, - encodings, + resolvePrimitive, ); - await assertEip7702FirmwareSupport( - latticeContext, - eip7702Encodings, - encodingType, - ); + if (!isRaw && (request.payload as any).type === 'eip7702') { + await assertEip7702FirmwareSupport(latticeContext); + } let decoder: Buffer | undefined; const fetchDecoder = services?.fetchDecoder; diff --git a/packages/chains/solana/src/devices/lattice.ts b/packages/chains/solana/src/devices/lattice.ts index 6cd720d5..7917ca40 100644 --- a/packages/chains/solana/src/devices/lattice.ts +++ b/packages/chains/solana/src/devices/lattice.ts @@ -17,29 +17,70 @@ import { } from '../chain'; import { buildSigResultFromRsv, toBuffer } from './shared'; -type LatticeSolanaContext = DeviceContext & { - resolvePrimitive: (kind: PrimitiveKind, name: string) => number; +type PrimitiveCodeMaps = { + HASHES?: Record; + CURVES?: Record; + ENCODINGS?: Record; +}; + +type LatticeSolanaContextInput = DeviceContext & { + resolvePrimitive?: (kind: PrimitiveKind, name: string) => number; constants: { EXTERNAL: { GET_ADDR_FLAGS: { ED25519_PUB: number; }; + SIGNING?: PrimitiveCodeMaps; }; }; }; +type LatticeSolanaContext = DeviceContext & { + resolvePrimitive: (kind: PrimitiveKind, name: string) => number; + constants: LatticeSolanaContextInput['constants']; +}; + +const hasNumber = (value: unknown): value is number => + typeof value === 'number' && Number.isFinite(value); + +const getPrimitiveFromConstants = ( + signing: PrimitiveCodeMaps | undefined, + kind: PrimitiveKind, + name: string, +): number | undefined => { + const byKind: Record | undefined> = { + hash: signing?.HASHES, + curve: signing?.CURVES, + encoding: signing?.ENCODINGS, + }; + const code = byKind[kind]?.[name]; + return hasNumber(code) ? code : undefined; +}; + function getLatticeSolanaContext(context: DeviceContext): LatticeSolanaContext { - const typed = context as LatticeSolanaContext; + const typed = context as LatticeSolanaContextInput; const constants = typed.constants; - const hasNumber = (value: unknown): value is number => - typeof value === 'number' && Number.isFinite(value); - if (typeof typed.resolvePrimitive !== 'function') { - throw new Error('Lattice Solana signer requires primitive resolver'); - } + const resolvePrimitive = (kind: PrimitiveKind, name: string): number => { + if (typeof typed.resolvePrimitive === 'function') { + return typed.resolvePrimitive(kind, name); + } + const fromConstants = getPrimitiveFromConstants( + constants?.EXTERNAL?.SIGNING, + kind, + name, + ); + if (fromConstants !== undefined) return fromConstants; + throw new Error( + `Lattice Solana signer requires resolvePrimitive() or EXTERNAL.SIGNING mapping for ${kind}:${name}.`, + ); + }; if (!hasNumber(constants?.EXTERNAL?.GET_ADDR_FLAGS?.ED25519_PUB)) { throw new Error('Lattice Solana signer requires EXTERNAL constants'); } - return typed; + return { + ...typed, + resolvePrimitive, + }; } export function createLatticeSolanaSigner( diff --git a/packages/chains/xrp/src/devices/lattice.ts b/packages/chains/xrp/src/devices/lattice.ts index 2ba15fda..196e7760 100644 --- a/packages/chains/xrp/src/devices/lattice.ts +++ b/packages/chains/xrp/src/devices/lattice.ts @@ -21,31 +21,72 @@ import { toBuffer, } from './shared'; -type LatticeXrpContext = DeviceContext & { - resolvePrimitive: (kind: PrimitiveKind, name: string) => number; +type PrimitiveCodeMaps = { + HASHES?: Record; + CURVES?: Record; + ENCODINGS?: Record; +}; + +type LatticeXrpContextInput = DeviceContext & { + resolvePrimitive?: (kind: PrimitiveKind, name: string) => number; constants: { EXTERNAL: { GET_ADDR_FLAGS: { SECP256K1_PUB: number; }; + SIGNING?: PrimitiveCodeMaps; }; }; }; +type LatticeXrpContext = DeviceContext & { + resolvePrimitive: (kind: PrimitiveKind, name: string) => number; + constants: LatticeXrpContextInput['constants']; +}; + +const hasNumber = (value: unknown): value is number => + typeof value === 'number' && Number.isFinite(value); + +const getPrimitiveFromConstants = ( + signing: PrimitiveCodeMaps | undefined, + kind: PrimitiveKind, + name: string, +): number | undefined => { + const byKind: Record | undefined> = { + hash: signing?.HASHES, + curve: signing?.CURVES, + encoding: signing?.ENCODINGS, + }; + const code = byKind[kind]?.[name]; + return hasNumber(code) ? code : undefined; +}; + function getLatticeXrpContext(context: DeviceContext): LatticeXrpContext { - const typed = context as LatticeXrpContext; + const typed = context as LatticeXrpContextInput; const constants = typed.constants; - const hasNumber = (value: unknown): value is number => - typeof value === 'number' && Number.isFinite(value); - if (typeof typed.resolvePrimitive !== 'function') { - throw new Error('Lattice XRP signer requires primitive resolver'); - } + const resolvePrimitive = (kind: PrimitiveKind, name: string): number => { + if (typeof typed.resolvePrimitive === 'function') { + return typed.resolvePrimitive(kind, name); + } + const fromConstants = getPrimitiveFromConstants( + constants?.EXTERNAL?.SIGNING, + kind, + name, + ); + if (fromConstants !== undefined) return fromConstants; + throw new Error( + `Lattice XRP signer requires resolvePrimitive() or EXTERNAL.SIGNING mapping for ${kind}:${name}.`, + ); + }; if (!hasNumber(constants?.EXTERNAL?.GET_ADDR_FLAGS?.SECP256K1_PUB)) { throw new Error('Lattice XRP signer requires EXTERNAL constants'); } - return typed; + return { + ...typed, + resolvePrimitive, + }; } export function createLatticeXrpSigner(context: DeviceContext): XrpSigner { diff --git a/packages/sdk/src/__test__/unit/chainRuntimePrimitives.test.ts b/packages/sdk/src/__test__/unit/chainRuntimePrimitives.test.ts index 7b34d3aa..271a5954 100644 --- a/packages/sdk/src/__test__/unit/chainRuntimePrimitives.test.ts +++ b/packages/sdk/src/__test__/unit/chainRuntimePrimitives.test.ts @@ -115,6 +115,27 @@ describe('chain runtime primitive integration', () => { expect(primitiveRegistry.resolve('encoding', 'DUP_B')).toBeUndefined(); }); + test('unregisterChain removes plugin-owned primitive definitions', () => { + registerChainPlugin( + buildPlugin('chain-a', { + definitions: [{ kind: 'encoding', name: 'REPLACE_ME', code: 301 }], + }), + ); + + const primitiveRegistry = getPrimitiveRegistry(); + expect(primitiveRegistry.resolve('encoding', 'REPLACE_ME')).toBe(301); + + expect(unregisterChain('chain-a', 'lattice')).toBe(true); + expect(primitiveRegistry.resolve('encoding', 'REPLACE_ME')).toBeUndefined(); + + registerChainPlugin( + buildPlugin('chain-a', { + definitions: [{ kind: 'encoding', name: 'REPLACE_ME', code: 302 }], + }), + ); + expect(primitiveRegistry.resolve('encoding', 'REPLACE_ME')).toBe(302); + }); + test('useChain enforces primitive minFirmware requirements', async () => { configureChainRuntime({ autoRegisterChains: false, diff --git a/packages/sdk/src/__test__/unit/latticeSignerPrimitives.test.ts b/packages/sdk/src/__test__/unit/latticeSignerPrimitives.test.ts index 4bf5f8a6..4c3f96e8 100644 --- a/packages/sdk/src/__test__/unit/latticeSignerPrimitives.test.ts +++ b/packages/sdk/src/__test__/unit/latticeSignerPrimitives.test.ts @@ -9,13 +9,33 @@ type PrimitiveMap = Record; type MockContextOptions = { primitives: PrimitiveMap; firmware?: [number, number, number]; + includeResolver?: boolean; }; const primitiveKey = (kind: PrimitiveKind, name: string): string => `${kind}:${name}`; +const buildSigningConstants = (primitives: PrimitiveMap) => { + const signing = { + HASHES: {} as Record, + CURVES: {} as Record, + ENCODINGS: {} as Record, + }; + + Object.entries(primitives).forEach(([key, code]) => { + const [kind, name] = key.split(':'); + if (!name) return; + if (kind === 'hash') signing.HASHES[name] = code; + if (kind === 'curve') signing.CURVES[name] = code; + if (kind === 'encoding') signing.ENCODINGS[name] = code; + }); + + return signing; +}; + const buildMockContext = (options: MockContextOptions) => { const firmware = options.firmware ?? [1, 0, 0]; + const includeResolver = options.includeResolver ?? true; const signCalls: Array = []; const client = { @@ -40,23 +60,26 @@ const buildMockContext = (options: MockContextOptions) => { return code; }); - const context = { + const context: any = { queue: async (fn: (client: unknown) => Promise) => fn(client), getClient: async () => client, - resolvePrimitive, constants: { EXTERNAL: { GET_ADDR_FLAGS: { SECP256K1_PUB: 1, ED25519_PUB: 2, }, + SIGNING: buildSigningConstants(options.primitives), }, CURRENCIES: { ETH_MSG: 'ETH_MSG', }, }, services: {}, - } as any; + }; + if (includeResolver) { + context.resolvePrimitive = resolvePrimitive; + } return { context, @@ -182,6 +205,29 @@ describe('lattice signer primitive resolution', () => { expect(signCalls[0].data.encodingType).toBe(33); }); + test('evm signer falls back to EXTERNAL.SIGNING when resolvePrimitive is missing', async () => { + const { context, signCalls } = buildMockContext({ + primitives: { + 'curve:SECP256K1': 61, + 'hash:KECCAK256': 62, + 'encoding:EVM': 63, + }, + includeResolver: false, + }); + const signer = createLatticeEvmSigner(context); + + await signer.sign({ + kind: 'transaction', + payload: '0x01', + options: { path: [44, 60, 0] }, + }); + + expect(signCalls).toHaveLength(1); + expect(signCalls[0].data.curveType).toBe(61); + expect(signCalls[0].data.hashType).toBe(62); + expect(signCalls[0].data.encodingType).toBe(63); + }); + test('evm EIP-7702 signing fails below minimum firmware', async () => { const { context, client } = buildMockContext({ primitives: { diff --git a/packages/sdk/src/chains/primitives.ts b/packages/sdk/src/chains/primitives.ts index 16b4ee40..07e5b6dd 100644 --- a/packages/sdk/src/chains/primitives.ts +++ b/packages/sdk/src/chains/primitives.ts @@ -1,7 +1,9 @@ import { compareFirmwareVersions, createPrimitiveRegistry, + toChainKey, type ChainPlugin, + type DeviceId, type FirmwareVersionTuple, type PrimitiveDefinition, type PrimitiveRequirement, @@ -10,6 +12,48 @@ import { EXTERNAL } from '../constants'; const registry = createPrimitiveRegistry(); let seeded = false; +const pluginDefinitionsByKey = new Map(); + +const getBuiltinPrimitiveDefinitions = (): PrimitiveDefinition[] => { + const definitions: PrimitiveDefinition[] = []; + + Object.entries(EXTERNAL.SIGNING.HASHES).forEach(([name, code]) => { + definitions.push({ kind: 'hash', name, code }); + }); + Object.entries(EXTERNAL.SIGNING.CURVES).forEach(([name, code]) => { + definitions.push({ kind: 'curve', name, code }); + }); + Object.entries(EXTERNAL.SIGNING.ENCODINGS).forEach(([name, code]) => { + definitions.push({ kind: 'encoding', name, code }); + }); + + return definitions; +}; + +const cloneDefinitions = ( + definitions: PrimitiveDefinition[], +): PrimitiveDefinition[] => + definitions.map((definition) => ({ + kind: definition.kind, + name: definition.name, + code: definition.code, + })); + +const toPluginPrimitiveKey = (chainId: string, device: DeviceId): string => + toChainKey(chainId, device); + +const rebuildRegistryFromTrackedPrimitives = (): void => { + registry.reset(); + seeded = false; + ensurePrimitivesSeeded(); + + const sortedKeys = [...pluginDefinitionsByKey.keys()].sort(); + sortedKeys.forEach((key) => { + const definitions = pluginDefinitionsByKey.get(key); + if (!definitions || definitions.length === 0) return; + registry.register(definitions); + }); +}; const isFirmwareVersionTuple = ( value: unknown, @@ -78,19 +122,7 @@ export function getPrimitiveRegistry() { export function ensurePrimitivesSeeded(): void { if (seeded) return; - const definitions: PrimitiveDefinition[] = []; - - Object.entries(EXTERNAL.SIGNING.HASHES).forEach(([name, code]) => { - definitions.push({ kind: 'hash', name, code }); - }); - Object.entries(EXTERNAL.SIGNING.CURVES).forEach(([name, code]) => { - definitions.push({ kind: 'curve', name, code }); - }); - Object.entries(EXTERNAL.SIGNING.ENCODINGS).forEach(([name, code]) => { - definitions.push({ kind: 'encoding', name, code }); - }); - - registry.register(definitions); + registry.register(getBuiltinPrimitiveDefinitions()); seeded = true; } @@ -108,8 +140,31 @@ export function registerPluginPrimitives(plugin: ChainPlugin): void { ensurePrimitivesSeeded(); const definitions = getPluginPrimitiveDefinitions(plugin); - if (definitions.length === 0) return; + const pluginKey = toPluginPrimitiveKey(plugin.chainId, plugin.device); + if (definitions.length === 0) { + pluginDefinitionsByKey.delete(pluginKey); + return; + } registry.register(definitions); + pluginDefinitionsByKey.set(pluginKey, cloneDefinitions(definitions)); +} + +export function unregisterPluginPrimitives( + chainId: string, + device?: DeviceId, +): void { + if (!seeded) return; + + const keysToDelete = device + ? [toPluginPrimitiveKey(chainId, device)] + : [...pluginDefinitionsByKey.keys()].filter((key) => + key.startsWith(`${chainId}:`), + ); + + if (keysToDelete.length === 0) return; + + keysToDelete.forEach((key) => pluginDefinitionsByKey.delete(key)); + rebuildRegistryFromTrackedPrimitives(); } export function validatePluginPrimitiveRequirements( @@ -141,5 +196,6 @@ export function validatePluginPrimitiveRequirements( export function resetPrimitiveRegistry(): void { registry.reset(); + pluginDefinitionsByKey.clear(); seeded = false; } diff --git a/packages/sdk/src/chains/registry.ts b/packages/sdk/src/chains/registry.ts index b6095f11..15e118e0 100644 --- a/packages/sdk/src/chains/registry.ts +++ b/packages/sdk/src/chains/registry.ts @@ -10,6 +10,7 @@ import { ensurePrimitivesSeeded, preflightPluginPrimitives, registerPluginPrimitives, + unregisterPluginPrimitives, validatePluginPrimitiveRequirements, } from './primitives'; @@ -106,6 +107,7 @@ export function registerChainPlugin(plugin: ChainPlugin): void { } catch (err) { if (chainRegistered) { registry.unregister(plugin.chainId, plugin.device); + unregisterPluginPrimitives(plugin.chainId, plugin.device); } throw err; } @@ -114,7 +116,11 @@ export function registerChainPlugin(plugin: ChainPlugin): void { export function unregisterChain(chainId: string, device?: DeviceId): boolean { cacheGeneration += 1; cache.clear(); - return registry.unregister(chainId, device); + const unregistered = registry.unregister(chainId, device); + if (unregistered) { + unregisterPluginPrimitives(chainId, device); + } + return unregistered; } export function listChains(): ChainPlugin[] {