diff --git a/packages/chains/chain-core/src/index.ts b/packages/chains/chain-core/src/index.ts index c31caf00..0cbb1d04 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 SigningComponentKind = 'hash' | 'curve' | 'encoding'; + +export type SigningComponentDefinition = { + kind: SigningComponentKind; + name: string; + code: number; +}; + +export type SigningComponentRequirement = { + kind: SigningComponentKind; + name: string; + minFirmware: [number, number, number]; +}; + +export type ChainSigningSuite = { + definitions?: SigningComponentDefinition[]; + requirements?: SigningComponentRequirement[]; +}; + export type GetAddressParams = { path?: DerivationPath; accountIndex?: number; @@ -127,6 +146,7 @@ export type ChainPlugin< signer: TSigner, options?: TOptions, ) => Promise | TAdapter; + signingSuite?: ChainSigningSuite; }; export type ChainRegistryResolveOptions = { @@ -248,6 +268,56 @@ export function createChainRegistry( }; } +export { + createSigningComponentRegistry, + SigningComponentConflictError, + type SigningComponentRegistry, +} from './signingComponentRegistry'; + +// --------------------------------------------------------------------------- +// 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/chain-core/src/signingComponentRegistry.ts b/packages/chains/chain-core/src/signingComponentRegistry.ts new file mode 100644 index 00000000..53e38023 --- /dev/null +++ b/packages/chains/chain-core/src/signingComponentRegistry.ts @@ -0,0 +1,230 @@ +import type { SigningComponentDefinition, SigningComponentKind } from './index'; + +const SIGNING_COMPONENT_KINDS: SigningComponentKind[] = [ + 'hash', + 'curve', + 'encoding', +]; + +type SigningComponentDefinitionMap = { + [K in SigningComponentKind]: Map; +}; + +type SigningComponentReverseDefinitionMap = { + [K in SigningComponentKind]: Map; +}; + +const createNameMaps = (): SigningComponentDefinitionMap => ({ + hash: new Map(), + curve: new Map(), + encoding: new Map(), +}); + +const createCodeMaps = (): SigningComponentReverseDefinitionMap => ({ + hash: new Map(), + curve: new Map(), + encoding: new Map(), +}); + +const normalizeSigningComponentKind = (kind: unknown): SigningComponentKind => { + if (kind === 'hash' || kind === 'curve' || kind === 'encoding') { + return kind; + } + throw new Error(`Invalid signing component kind: ${String(kind)}`); +}; + +const normalizeSigningComponentName = (name: unknown): string => { + if (typeof name !== 'string') { + throw new Error(`Invalid signing component name: ${String(name)}`); + } + const normalized = name.trim().toUpperCase(); + if (!normalized) { + throw new Error('Signing component name cannot be empty'); + } + return normalized; +}; + +const normalizeSigningComponentCode = (code: unknown): number => { + if ( + typeof code !== 'number' || + !Number.isFinite(code) || + !Number.isInteger(code) || + code < 0 + ) { + throw new Error(`Invalid signing component code: ${String(code)}`); + } + return code; +}; + +const normalizeDefinition = (definition: SigningComponentDefinition) => { + const kind = normalizeSigningComponentKind(definition.kind); + const name = normalizeSigningComponentName(definition.name); + const code = normalizeSigningComponentCode(definition.code); + return { kind, name, code }; +}; + +const sortDefinitions = ( + definitions: SigningComponentDefinition[], +): SigningComponentDefinition[] => { + 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: SigningComponentDefinition[], +): SigningComponentDefinition[] => { + if (!Array.isArray(definitions)) { + throw new Error('Signing component definitions must be an array'); + } + return definitions.map(normalizeDefinition); +}; + +export class SigningComponentConflictError extends Error { + public readonly kind: SigningComponentKind; + public readonly componentName: string; + public readonly code: number; + + constructor(message: string, def: SigningComponentDefinition) { + super(message); + this.name = 'SigningComponentConflictError'; + this.kind = def.kind; + this.componentName = def.name; + this.code = def.code; + } +} + +export type SigningComponentRegistry = { + register: (definitions: SigningComponentDefinition[]) => void; + preflight: (definitions: SigningComponentDefinition[]) => void; + resolve: (kind: SigningComponentKind, name: string) => number | undefined; + resolveOrThrow: (kind: SigningComponentKind, name: string) => number; + reverseResolve: ( + kind: SigningComponentKind, + code: number, + ) => string | undefined; + has: (kind: SigningComponentKind, name: string) => boolean; + list: (kind?: SigningComponentKind) => SigningComponentDefinition[]; + reset: () => void; +}; + +export function createSigningComponentRegistry(): SigningComponentRegistry { + const nameToCode = createNameMaps(); + const codeToName = createCodeMaps(); + + const checkConflict = ( + stagedNameToCode: SigningComponentDefinitionMap, + stagedCodeToName: SigningComponentReverseDefinitionMap, + def: SigningComponentDefinition, + ) => { + const existingCode = stagedNameToCode[def.kind].get(def.name); + if (existingCode !== undefined && existingCode !== def.code) { + throw new SigningComponentConflictError( + `Signing component 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 SigningComponentConflictError( + `Signing component conflict for ${def.kind} code=${def.code}. Existing name=${existingName}, new name=${def.name}.`, + def, + ); + } + }; + + const preflight = (definitions: SigningComponentDefinition[]) => { + const normalized = normalizeDefinitions(definitions); + const stagedNameToCode = createNameMaps(); + const stagedCodeToName = createCodeMaps(); + + for (const kind of SIGNING_COMPONENT_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: SigningComponentDefinition[]) => { + 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: SigningComponentKind, + name: string, + ): number | undefined => { + return nameToCode[kind].get(normalizeSigningComponentName(name)); + }; + + const resolveOrThrow = (kind: SigningComponentKind, name: string): number => { + const code = resolve(kind, name); + if (code === undefined) { + throw new Error(`Signing component not found: ${kind}:${name}`); + } + return code; + }; + + const reverseResolve = ( + kind: SigningComponentKind, + code: number, + ): string | undefined => { + return codeToName[kind].get(normalizeSigningComponentCode(code)); + }; + + const has = (kind: SigningComponentKind, name: string): boolean => { + return resolve(kind, name) !== undefined; + }; + + const list = (kind?: SigningComponentKind): SigningComponentDefinition[] => { + const definitions: SigningComponentDefinition[] = []; + const kinds = kind ? [kind] : SIGNING_COMPONENT_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 SIGNING_COMPONENT_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..dac94f6b 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, + SigningComponentKind, PublicKey, SignResult, } from '@gridplus/chain-core'; @@ -15,49 +16,90 @@ import { } from '../chain'; import { buildSigResultFromRsv, compressSecp256k1Pubkey } from './shared'; -type LatticeCosmosContext = DeviceContext & { +type SigningComponentCodes = { + HASHES?: Record; + CURVES?: Record; + ENCODINGS?: Record; +}; + +type LatticeCosmosContextInput = DeviceContext & { + resolveSigningComponent?: ( + kind: SigningComponentKind, + name: string, + ) => number; constants: { EXTERNAL: { GET_ADDR_FLAGS: { SECP256K1_PUB: number; }; - SIGNING: { - CURVES: { - SECP256K1: number; - }; - HASHES: { - SHA256: number; - }; - ENCODINGS: { - COSMOS: number; - }; - }; + SIGNING?: SigningComponentCodes; }; }; }; -function getLatticeCosmosConstants( - context: DeviceContext, -): LatticeCosmosContext['constants'] { - const constants = (context as LatticeCosmosContext).constants; - const hasNumber = (value: unknown): value is number => - typeof value === 'number' && Number.isFinite(value); - 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) - ) { +type LatticeCosmosContext = DeviceContext & { + resolveSigningComponent: (kind: SigningComponentKind, name: string) => number; + constants: LatticeCosmosContextInput['constants']; +}; + +const hasNumber = (value: unknown): value is number => + typeof value === 'number' && Number.isFinite(value); + +const getSigningComponentFromConstants = ( + signing: SigningComponentCodes | undefined, + kind: SigningComponentKind, + name: string, +): number | undefined => { + const byKind: Record< + SigningComponentKind, + 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 LatticeCosmosContextInput; + const constants = typed.constants; + const resolveSigningComponent = ( + kind: SigningComponentKind, + name: string, + ): number => { + if (typeof typed.resolveSigningComponent === 'function') { + return typed.resolveSigningComponent(kind, name); + } + const fromConstants = getSigningComponentFromConstants( + constants?.EXTERNAL?.SIGNING, + kind, + name, + ); + if (fromConstants !== undefined) return fromConstants; + throw new Error( + `Lattice Cosmos signer requires resolveSigningComponent() 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 constants; + return { + ...typed, + resolveSigningComponent, + }; } export function createLatticeCosmosSigner( context: DeviceContext, ): CosmosSigner { - const { queue } = context; - const { EXTERNAL } = getLatticeCosmosConstants(context); + const { queue, resolveSigningComponent, constants } = + getLatticeCosmosContext(context); + const { EXTERNAL } = constants; + const curveSecp256k1 = resolveSigningComponent('curve', 'SECP256K1'); + const hashSha256 = resolveSigningComponent('hash', 'SHA256'); + const encodingCosmos = resolveSigningComponent('encoding', 'COSMOS'); return { getAddress: async (path: DerivationPath): Promise
=> { @@ -101,9 +143,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 +183,11 @@ export const latticePlugin: ChainPlugin< device: 'lattice', module: cosmos, createSigner: createLatticeCosmosSigner, + signingSuite: { + 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..e12da7cb 100644 --- a/packages/chains/evm/src/devices/lattice.ts +++ b/packages/chains/evm/src/devices/lattice.ts @@ -1,10 +1,14 @@ -import type { - Address, - ChainPlugin, - DerivationPath, - DeviceContext, - PublicKey, - SignResult, +import { + getFirmwareVersion, + isAtLeastFirmware, + type Address, + type ChainPlugin, + type DerivationPath, + type DeviceContext, + type FirmwareVersionTuple, + type SigningComponentKind, + type PublicKey, + type SignResult, } from '@gridplus/chain-core'; import { Hash } from 'ox'; import { @@ -32,25 +36,23 @@ export type LatticeEvmSignerOptions = { fetchEvmDecoder?: boolean; }; -type LatticeEvmContext = DeviceContext & { +type SigningComponentCodes = { + HASHES?: Record; + CURVES?: Record; + ENCODINGS?: Record; +}; + +type LatticeEvmContextInput = DeviceContext & { + resolveSigningComponent?: ( + kind: SigningComponentKind, + 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; - }; - }; + SIGNING?: SigningComponentCodes; }; CURRENCIES: { ETH_MSG: string; @@ -65,23 +67,64 @@ type LatticeEvmContext = DeviceContext & { }; }; +type LatticeEvmContext = DeviceContext & { + resolveSigningComponent: (kind: SigningComponentKind, name: string) => number; + constants: LatticeEvmContextInput['constants']; + services?: LatticeEvmContextInput['services']; +}; + +const hasNumber = (value: unknown): value is number => + typeof value === 'number' && Number.isFinite(value); + +const getSigningComponentFromConstants = ( + signing: SigningComponentCodes | undefined, + kind: SigningComponentKind, + name: string, +): number | undefined => { + const byKind: Record< + SigningComponentKind, + 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); + const resolveSigningComponent = ( + kind: SigningComponentKind, + name: string, + ): number => { + if (typeof typed.resolveSigningComponent === 'function') { + return typed.resolveSigningComponent(kind, name); + } + const fromConstants = getSigningComponentFromConstants( + constants?.EXTERNAL?.SIGNING, + kind, + name, + ); + if (fromConstants !== undefined) return fromConstants; + throw new Error( + `Lattice EVM signer requires resolveSigningComponent() or EXTERNAL.SIGNING mapping for ${kind}:${name}.`, + ); + }; 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( 'Lattice EVM signer requires EXTERNAL and CURRENCIES constants', ); } - return typed; + return { + ...typed, + resolveSigningComponent, + }; } function isRawEvmTx( @@ -103,25 +146,43 @@ function normalizeRawEvmTx(tx: EvmRawTransaction): Hex | Buffer { function getEvmEncodingType( tx: TransactionSerializable, - EXTERNAL: LatticeEvmContext['constants']['EXTERNAL'], + resolveSigningComponent: (kind: SigningComponentKind, 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 - ? EXTERNAL.SIGNING.ENCODINGS.EIP7702_AUTH_LIST - : EXTERNAL.SIGNING.ENCODINGS.EIP7702_AUTH; + ? resolveSigningComponent('encoding', 'EIP7702_AUTH_LIST') + : resolveSigningComponent('encoding', 'EIP7702_AUTH'); } - return EXTERNAL.SIGNING.ENCODINGS.EVM; + return resolveSigningComponent('encoding', 'EVM'); } +const EIP7702_MIN_FIRMWARE: FirmwareVersionTuple = [0, 18, 0]; + +const assertEip7702FirmwareSupport = async ( + context: DeviceContext, +): Promise => { + 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, resolveSigningComponent } = latticeContext; + const { EXTERNAL, CURRENCIES } = latticeContext.constants; + const curveSecp256k1 = resolveSigningComponent('curve', 'SECP256K1'); + const hashKeccak256 = resolveSigningComponent('hash', 'KECCAK256'); + const encodingEvm = resolveSigningComponent('encoding', 'EVM'); return { getAddress: async (path: DerivationPath): Promise
=> { @@ -169,11 +230,14 @@ export function createLatticeEvmSigner( : serializeTransaction(request.payload as TransactionSerializable); const encodingType = isRaw - ? EXTERNAL.SIGNING.ENCODINGS.EVM + ? encodingEvm : getEvmEncodingType( request.payload as TransactionSerializable, - EXTERNAL, + resolveSigningComponent, ); + if (!isRaw && (request.payload as any).type === 'eip7702') { + await assertEip7702FirmwareSupport(latticeContext); + } let decoder: Buffer | undefined; const fetchDecoder = services?.fetchDecoder; @@ -190,8 +254,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 +296,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 +319,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 +355,11 @@ export const latticePlugin: ChainPlugin< device: 'lattice', module: evm, createSigner: (context) => createLatticeEvmSigner(context), + signingSuite: { + 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..0d42b35a 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, + SigningComponentKind, PublicKey, SignResult, } from '@gridplus/chain-core'; @@ -16,49 +17,90 @@ import { } from '../chain'; import { buildSigResultFromRsv, toBuffer } from './shared'; -type LatticeSolanaContext = DeviceContext & { +type SigningComponentCodes = { + HASHES?: Record; + CURVES?: Record; + ENCODINGS?: Record; +}; + +type LatticeSolanaContextInput = DeviceContext & { + resolveSigningComponent?: ( + kind: SigningComponentKind, + name: string, + ) => number; constants: { EXTERNAL: { GET_ADDR_FLAGS: { ED25519_PUB: number; }; - SIGNING: { - CURVES: { - ED25519: number; - }; - HASHES: { - NONE: number; - }; - ENCODINGS: { - SOLANA: number; - }; - }; + SIGNING?: SigningComponentCodes; }; }; }; -function getLatticeSolanaConstants( - context: DeviceContext, -): LatticeSolanaContext['constants'] { - const constants = (context as LatticeSolanaContext).constants; - const hasNumber = (value: unknown): value is number => - typeof value === 'number' && Number.isFinite(value); - 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) - ) { +type LatticeSolanaContext = DeviceContext & { + resolveSigningComponent: (kind: SigningComponentKind, name: string) => number; + constants: LatticeSolanaContextInput['constants']; +}; + +const hasNumber = (value: unknown): value is number => + typeof value === 'number' && Number.isFinite(value); + +const getSigningComponentFromConstants = ( + signing: SigningComponentCodes | undefined, + kind: SigningComponentKind, + name: string, +): number | undefined => { + const byKind: Record< + SigningComponentKind, + 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 LatticeSolanaContextInput; + const constants = typed.constants; + const resolveSigningComponent = ( + kind: SigningComponentKind, + name: string, + ): number => { + if (typeof typed.resolveSigningComponent === 'function') { + return typed.resolveSigningComponent(kind, name); + } + const fromConstants = getSigningComponentFromConstants( + constants?.EXTERNAL?.SIGNING, + kind, + name, + ); + if (fromConstants !== undefined) return fromConstants; + throw new Error( + `Lattice Solana signer requires resolveSigningComponent() 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 constants; + return { + ...typed, + resolveSigningComponent, + }; } export function createLatticeSolanaSigner( context: DeviceContext, ): SolanaSigner { - const { queue } = context; - const { EXTERNAL } = getLatticeSolanaConstants(context); + const { queue, resolveSigningComponent, constants } = + getLatticeSolanaContext(context); + const { EXTERNAL } = constants; + const curveEd25519 = resolveSigningComponent('curve', 'ED25519'); + const hashNone = resolveSigningComponent('hash', 'NONE'); + const encodingSolana = resolveSigningComponent('encoding', 'SOLANA'); const getPublicKey = async (path: DerivationPath): Promise => { const res = (await queue((client: any) => @@ -90,9 +132,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 +172,11 @@ export const latticePlugin: ChainPlugin< device: 'lattice', module: solana, createSigner: createLatticeSolanaSigner, + signingSuite: { + 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..4dd4aba0 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, + SigningComponentKind, PublicKey, SignResult, } from '@gridplus/chain-core'; @@ -20,49 +21,90 @@ import { toBuffer, } from './shared'; -type LatticeXrpContext = DeviceContext & { +type SigningComponentCodes = { + HASHES?: Record; + CURVES?: Record; + ENCODINGS?: Record; +}; + +type LatticeXrpContextInput = DeviceContext & { + resolveSigningComponent?: ( + kind: SigningComponentKind, + name: string, + ) => number; constants: { EXTERNAL: { GET_ADDR_FLAGS: { SECP256K1_PUB: number; }; - SIGNING: { - CURVES: { - SECP256K1: number; - }; - HASHES: { - SHA512HALF: number; - }; - ENCODINGS: { - XRP: number; - }; - }; + SIGNING?: SigningComponentCodes; }; }; }; -function getLatticeXrpConstants( - context: DeviceContext, -): LatticeXrpContext['constants'] { - const constants = (context as LatticeXrpContext).constants; - const hasNumber = (value: unknown): value is number => - typeof value === 'number' && Number.isFinite(value); - - 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) - ) { +type LatticeXrpContext = DeviceContext & { + resolveSigningComponent: (kind: SigningComponentKind, name: string) => number; + constants: LatticeXrpContextInput['constants']; +}; + +const hasNumber = (value: unknown): value is number => + typeof value === 'number' && Number.isFinite(value); + +const getSigningComponentFromConstants = ( + signing: SigningComponentCodes | undefined, + kind: SigningComponentKind, + name: string, +): number | undefined => { + const byKind: Record< + SigningComponentKind, + 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 LatticeXrpContextInput; + const constants = typed.constants; + const resolveSigningComponent = ( + kind: SigningComponentKind, + name: string, + ): number => { + if (typeof typed.resolveSigningComponent === 'function') { + return typed.resolveSigningComponent(kind, name); + } + const fromConstants = getSigningComponentFromConstants( + constants?.EXTERNAL?.SIGNING, + kind, + name, + ); + if (fromConstants !== undefined) return fromConstants; + throw new Error( + `Lattice XRP signer requires resolveSigningComponent() 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 constants; + return { + ...typed, + resolveSigningComponent, + }; } export function createLatticeXrpSigner(context: DeviceContext): XrpSigner { - const { queue } = context; - const { EXTERNAL } = getLatticeXrpConstants(context); + const { queue, resolveSigningComponent, constants } = + getLatticeXrpContext(context); + const { EXTERNAL } = constants; + const curveSecp256k1 = resolveSigningComponent('curve', 'SECP256K1'); + const hashSha512Half = resolveSigningComponent('hash', 'SHA512HALF'); + const encodingXrp = resolveSigningComponent('encoding', 'XRP'); const getPublicKey = async ( path: DerivationPath, @@ -107,9 +149,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 +191,11 @@ export const latticePlugin: ChainPlugin< device: 'lattice', module: xrp, createSigner: createLatticeXrpSigner, + signingSuite: { + 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/chainRuntimeSigningComponents.test.ts b/packages/sdk/src/__test__/unit/chainRuntimeSigningComponents.test.ts new file mode 100644 index 00000000..ba3b7891 --- /dev/null +++ b/packages/sdk/src/__test__/unit/chainRuntimeSigningComponents.test.ts @@ -0,0 +1,197 @@ +import type { + ChainAdapter, + ChainModule, + ChainPlugin, + ChainSigningSuite, + Signer, +} from '@gridplus/chain-core'; +import { setLoadClient } from '../../api/state'; +import { + configureChainRuntime, + getChain, + registerChainPlugin, + unregisterChain, + useChain, +} from '../../chains'; +import { + getSigningComponentRegistry, + resetSigningComponentRegistry, +} from '../../chains/signingComponents'; + +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, + signingSuite?: ChainSigningSuite, +): 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, + signingSuite, + }; +}; + +describe('chain runtime signing component integration', () => { + afterEach(() => { + unregisterChain('chain-a', 'lattice'); + unregisterChain('chain-b', 'lattice'); + unregisterChain('dup-chain', 'lattice'); + unregisterChain('req-chain', 'lattice'); + resetSigningComponentRegistry(); + configureChainRuntime({ + autoRegisterChains: true, + defaultDevice: 'lattice', + resetCache: true, + }); + setLoadClient(async () => undefined); + }); + + test('signing component 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 signing component 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 signingComponentRegistry = getSigningComponentRegistry(); + expect(signingComponentRegistry.resolve('encoding', 'DUP_A')).toBe(201); + expect( + signingComponentRegistry.resolve('encoding', 'DUP_B'), + ).toBeUndefined(); + }); + + test('unregisterChain removes plugin-owned signing component definitions', () => { + registerChainPlugin( + buildPlugin('chain-a', { + definitions: [{ kind: 'encoding', name: 'REPLACE_ME', code: 301 }], + }), + ); + + const signingComponentRegistry = getSigningComponentRegistry(); + expect(signingComponentRegistry.resolve('encoding', 'REPLACE_ME')).toBe( + 301, + ); + + expect(unregisterChain('chain-a', 'lattice')).toBe(true); + expect( + signingComponentRegistry.resolve('encoding', 'REPLACE_ME'), + ).toBeUndefined(); + + registerChainPlugin( + buildPlugin('chain-a', { + definitions: [{ kind: 'encoding', name: 'REPLACE_ME', code: 302 }], + }), + ); + expect(signingComponentRegistry.resolve('encoding', 'REPLACE_ME')).toBe( + 302, + ); + }); + + test('useChain enforces signing component 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..8f71d259 --- /dev/null +++ b/packages/sdk/src/__test__/unit/context.test.ts @@ -0,0 +1,22 @@ +import { createDeviceContext } from '../../chains/context'; +import { resetSigningComponentRegistry } from '../../chains/signingComponents'; + +describe('chain context signing component resolution', () => { + afterEach(() => { + resetSigningComponentRegistry(); + }); + + test('resolveSigningComponent resolves seeded builtins', () => { + const context = createDeviceContext(); + expect(context.resolveSigningComponent('hash', 'KECCAK256')).toBeDefined(); + expect(context.resolveSigningComponent('curve', 'SECP256K1')).toBeDefined(); + expect(context.resolveSigningComponent('encoding', 'EVM')).toBeDefined(); + }); + + test('resolveSigningComponent throws for unknown signing component', () => { + const context = createDeviceContext(); + expect(() => + context.resolveSigningComponent('encoding', 'UNKNOWN_CHAIN'), + ).toThrow('Signing component not found'); + }); +}); diff --git a/packages/sdk/src/__test__/unit/latticeSignerSigningComponents.test.ts b/packages/sdk/src/__test__/unit/latticeSignerSigningComponents.test.ts new file mode 100644 index 00000000..a63d36f7 --- /dev/null +++ b/packages/sdk/src/__test__/unit/latticeSignerSigningComponents.test.ts @@ -0,0 +1,280 @@ +import { createLatticeCosmosSigner } from '@gridplus/cosmos'; +import { createLatticeEvmSigner } from '@gridplus/evm'; +import { createLatticeSolanaSigner } from '@gridplus/solana'; +import { createLatticeXrpSigner } from '@gridplus/xrp'; +import type { SigningComponentKind } from '@gridplus/chain-core'; + +type SigningComponentMap = Record; + +type MockContextOptions = { + signingComponents: SigningComponentMap; + firmware?: [number, number, number]; + includeResolver?: boolean; +}; + +const signingComponentKey = ( + kind: SigningComponentKind, + name: string, +): string => `${kind}:${name}`; + +const buildSigningConstants = (signingComponents: SigningComponentMap) => { + const signing = { + HASHES: {} as Record, + CURVES: {} as Record, + ENCODINGS: {} as Record, + }; + + Object.entries(signingComponents).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 = { + 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 resolveSigningComponent = vi.fn( + (kind: SigningComponentKind, name: string) => { + const key = signingComponentKey(kind, name); + const code = options.signingComponents[key]; + if (code === undefined) { + throw new Error(`Missing signing component mapping for ${key}`); + } + return code; + }, + ); + + const context: any = { + queue: async (fn: (client: unknown) => Promise) => fn(client), + getClient: async () => client, + constants: { + EXTERNAL: { + GET_ADDR_FLAGS: { + SECP256K1_PUB: 1, + ED25519_PUB: 2, + }, + SIGNING: buildSigningConstants(options.signingComponents), + }, + CURRENCIES: { + ETH_MSG: 'ETH_MSG', + }, + }, + services: {}, + }; + if (includeResolver) { + context.resolveSigningComponent = resolveSigningComponent; + } + + return { + context, + client, + signCalls, + resolveSigningComponent, + }; +}; + +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 signing component resolution', () => { + test('evm signer uses resolved signing component codes in sign payload', async () => { + const { context, signCalls } = buildMockContext({ + signingComponents: { + '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 signing component codes in sign payload', async () => { + const { context, signCalls } = buildMockContext({ + signingComponents: { + '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 signing component codes in sign payload', async () => { + const { context, signCalls } = buildMockContext({ + signingComponents: { + '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 signing component codes in sign payload', async () => { + const { context, signCalls } = buildMockContext({ + signingComponents: { + '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 signer falls back to EXTERNAL.SIGNING when resolveSigningComponent is missing', async () => { + const { context, signCalls } = buildMockContext({ + signingComponents: { + '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({ + signingComponents: { + '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({ + signingComponents: { + '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/setupChainPlugins.test.ts b/packages/sdk/src/__test__/unit/setupChainPlugins.test.ts index 2c280305..99f80208 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, + ChainSigningSuite, 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 { + getSigningComponentRegistry, + resetSigningComponentRegistry, +} from '../../chains/signingComponents'; 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, + signingSuite?: ChainSigningSuite, +): 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, + signingSuite, }; }; @@ -58,6 +68,7 @@ const setupParamsBase = { describe('setup chainPlugins', () => { afterEach(() => { + resetSigningComponentRegistry(); 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 = getSigningComponentRegistry(); + 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/__test__/unit/signingComponentRegistry.core.test.ts b/packages/sdk/src/__test__/unit/signingComponentRegistry.core.test.ts new file mode 100644 index 00000000..89209641 --- /dev/null +++ b/packages/sdk/src/__test__/unit/signingComponentRegistry.core.test.ts @@ -0,0 +1,96 @@ +import { + SigningComponentConflictError, + createSigningComponentRegistry, + type SigningComponentDefinition, +} from '@gridplus/chain-core'; + +describe('signing component registry core', () => { + test('registers and resolves by name and code', () => { + const registry = createSigningComponentRegistry(); + 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 = createSigningComponentRegistry(); + 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 = createSigningComponentRegistry(); + registry.register([{ kind: 'hash', name: 'SHA256', code: 2 }]); + + expect(() => + registry.register([{ kind: 'hash', name: 'SHA256', code: 99 }]), + ).toThrow(SigningComponentConflictError); + }); + + test('throws on code collision', () => { + const registry = createSigningComponentRegistry(); + registry.register([{ kind: 'hash', name: 'SHA256', code: 2 }]); + + expect(() => + registry.register([{ kind: 'hash', name: 'BLAKE2B', code: 2 }]), + ).toThrow(SigningComponentConflictError); + }); + + test('namespaces collisions by kind', () => { + const registry = createSigningComponentRegistry(); + 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 = createSigningComponentRegistry(); + registry.register([{ kind: 'hash', name: 'SHA256', code: 2 }]); + + expect(() => + registry.preflight([{ kind: 'hash', name: 'SHA256', code: 77 }]), + ).toThrow(SigningComponentConflictError); + expect(registry.resolve('hash', 'SHA256')).toBe(2); + expect(registry.resolve('hash', 'BLAKE2B')).toBeUndefined(); + }); + + test('register is atomic for a batch', () => { + const registry = createSigningComponentRegistry(); + registry.register([{ kind: 'hash', name: 'SHA256', code: 2 }]); + + const batch: SigningComponentDefinition[] = [ + { kind: 'hash', name: 'BLAKE2B', code: 4 }, + { kind: 'hash', name: 'SHA256', code: 99 }, + ]; + + expect(() => registry.register(batch)).toThrow( + SigningComponentConflictError, + ); + expect(registry.resolve('hash', 'BLAKE2B')).toBeUndefined(); + }); + + test('resolveOrThrow throws for missing signing component', () => { + const registry = createSigningComponentRegistry(); + expect(() => registry.resolveOrThrow('hash', 'MISSING')).toThrow( + 'Signing component not found', + ); + }); + + test('reset clears registered mappings', () => { + const registry = createSigningComponentRegistry(); + 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/signingComponents.test.ts b/packages/sdk/src/__test__/unit/signingComponents.test.ts new file mode 100644 index 00000000..3bc3e995 --- /dev/null +++ b/packages/sdk/src/__test__/unit/signingComponents.test.ts @@ -0,0 +1,163 @@ +import type { + ChainAdapter, + ChainModule, + ChainPlugin, + ChainSigningSuite, + Signer, +} from '@gridplus/chain-core'; +import { + ensureSigningComponentsSeeded, + getSigningComponentRegistry, + registerPluginSigningComponents, + resetSigningComponentRegistry, + validatePluginSigningRequirements, +} from '../../chains/signingComponents'; + +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, + signingSuite?: ChainSigningSuite, +): 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, + signingSuite, + }; +}; + +describe('sdk signing components module', () => { + afterEach(() => { + resetSigningComponentRegistry(); + }); + + test('ensureSigningComponentsSeeded is idempotent and registers builtins', () => { + ensureSigningComponentsSeeded(); + ensureSigningComponentsSeeded(); + + const registry = getSigningComponentRegistry(); + expect(registry.resolve('hash', 'KECCAK256')).toBeDefined(); + expect(registry.resolve('curve', 'SECP256K1')).toBeDefined(); + expect(registry.resolve('encoding', 'EVM')).toBeDefined(); + }); + + test('registerPluginSigningComponents registers custom definitions', () => { + const plugin = buildPlugin('testchain', { + definitions: [{ kind: 'encoding', name: 'TESTCHAIN', code: 99 }], + requirements: [ + { kind: 'encoding', name: 'TESTCHAIN', minFirmware: [0, 20, 0] }, + ], + }); + + registerPluginSigningComponents(plugin); + expect(getSigningComponentRegistry().resolve('encoding', 'TESTCHAIN')).toBe( + 99, + ); + }); + + test('registerPluginSigningComponents fails on conflicts without partial commit', () => { + registerPluginSigningComponents( + 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(() => registerPluginSigningComponents(conflicting)).toThrow(); + expect( + getSigningComponentRegistry().resolve('hash', 'BLAKE2B'), + ).toBeUndefined(); + }); + + test('validatePluginSigningRequirements passes when firmware requirement is met', () => { + ensureSigningComponentsSeeded(); + const plugin = buildPlugin('cosmos-like', { + requirements: [ + { kind: 'encoding', name: 'COSMOS', minFirmware: [0, 18, 10] }, + ], + }); + + expect(() => + validatePluginSigningRequirements(plugin, [0, 19, 0]), + ).not.toThrow(); + }); + + test('validatePluginSigningRequirements throws when firmware requirement is unmet', () => { + ensureSigningComponentsSeeded(); + const plugin = buildPlugin('cosmos-like', { + requirements: [ + { kind: 'encoding', name: 'COSMOS', minFirmware: [0, 18, 10] }, + ], + }); + + expect(() => validatePluginSigningRequirements(plugin, [0, 18, 9])).toThrow( + 'Please update firmware', + ); + }); + + test('validatePluginSigningRequirements throws for missing signing component mapping', () => { + const plugin = buildPlugin('custom-chain', { + requirements: [ + { + kind: 'encoding', + name: 'UNREGISTERED_CHAIN', + minFirmware: [0, 20, 0], + }, + ], + }); + + expect(() => validatePluginSigningRequirements(plugin, [0, 20, 0])).toThrow( + 'not registered', + ); + }); + + test('rejects invalid requirement shape (fail-closed)', () => { + const plugin = buildPlugin('invalid') as ChainPlugin; + (plugin as any).signingSuite = { + requirements: [{ kind: 'hash', name: 'SHA256' }], + }; + + expect(() => registerPluginSigningComponents(plugin)).toThrow( + 'invalid signing component requirement', + ); + expect(() => validatePluginSigningRequirements(plugin, [9, 9, 9])).toThrow( + 'invalid signing component requirement', + ); + }); +}); diff --git a/packages/sdk/src/api/setup.ts b/packages/sdk/src/api/setup.ts index c0e3b80e..bfcd6c30 100644 --- a/packages/sdk/src/api/setup.ts +++ b/packages/sdk/src/api/setup.ts @@ -7,6 +7,7 @@ import { import { configureChainRuntime, discoverAndRegisterChains, + ensureSigningComponentsSeeded, 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)); + ensureSigningComponentsSeeded(); 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..78d7d139 100644 --- a/packages/sdk/src/chains/context.ts +++ b/packages/sdk/src/chains/context.ts @@ -1,8 +1,12 @@ -import type { DeviceContext } from '@gridplus/chain-core'; +import type { DeviceContext, SigningComponentKind } 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 { + ensureSigningComponentsSeeded, + getSigningComponentRegistry, +} from './signingComponents'; export type SdkDeviceContext = DeviceContext & { constants: { @@ -12,9 +16,10 @@ export type SdkDeviceContext = DeviceContext & { services: { fetchDecoder: typeof fetchDecoder; }; + resolveSigningComponent: (kind: SigningComponentKind, name: string) => number; }; -// Bridges SDK runtime primitives into the generic chain-core DeviceContext shape. +// Bridges SDK runtime signing components into the generic chain-core DeviceContext shape. export const createDeviceContext = (): SdkDeviceContext => ({ queue, getClient, @@ -25,4 +30,8 @@ export const createDeviceContext = (): SdkDeviceContext => ({ services: { fetchDecoder, }, + resolveSigningComponent: (kind, name) => { + ensureSigningComponentsSeeded(); + return getSigningComponentRegistry().resolveOrThrow(kind, name); + }, }); diff --git a/packages/sdk/src/chains/index.ts b/packages/sdk/src/chains/index.ts index 117058c1..cae33b66 100644 --- a/packages/sdk/src/chains/index.ts +++ b/packages/sdk/src/chains/index.ts @@ -9,6 +9,12 @@ export { unregisterChain, useChain, } from './registry'; +export { + ensureSigningComponentsSeeded, + preflightPluginSigningComponents, + registerPluginSigningComponents, + validatePluginSigningRequirements, +} from './signingComponents'; export type { ChainKey, @@ -16,8 +22,12 @@ export type { ChainRegistry, ChainRegistryOptions, ChainRegistryResolveOptions, + ChainSigningSuite, DeviceContext, DeviceId, + SigningComponentDefinition, + SigningComponentKind, + SigningComponentRequirement, } from '@gridplus/chain-core'; export type { SdkDeviceContext } from './context'; diff --git a/packages/sdk/src/chains/registry.ts b/packages/sdk/src/chains/registry.ts index 61951fcb..39d941fa 100644 --- a/packages/sdk/src/chains/registry.ts +++ b/packages/sdk/src/chains/registry.ts @@ -1,10 +1,18 @@ import { createChainRegistry, + getFirmwareVersion, type ChainPlugin, type DeviceId, } from '@gridplus/chain-core'; import { createDeviceContext, type SdkDeviceContext } from './context'; import { discoverAndRegisterChains as discoverChains } from './discovery'; +import { + ensureSigningComponentsSeeded, + preflightPluginSigningComponents, + registerPluginSigningComponents, + unregisterPluginSigningComponents, + validatePluginSigningRequirements, +} from './signingComponents'; type ConfigureChainRuntimeOptions = { autoRegisterChains?: boolean; @@ -55,7 +63,7 @@ const stringifyAdapterOptions = (options: unknown): string => { const registerDiscoveredPlugin = (plugin: ChainPlugin): boolean => { if (registry.has(plugin.chainId, plugin.device)) return false; - registry.register(plugin as ChainPlugin); + registerChainPlugin(plugin); return true; }; @@ -88,13 +96,31 @@ export function getDefaultDevice(): DeviceId { } export function registerChainPlugin(plugin: ChainPlugin): void { - registry.register(plugin as ChainPlugin); + ensureSigningComponentsSeeded(); + preflightPluginSigningComponents(plugin); + + let chainRegistered = false; + try { + registry.register(plugin as ChainPlugin); + chainRegistered = true; + registerPluginSigningComponents(plugin); + } catch (err) { + if (chainRegistered) { + registry.unregister(plugin.chainId, plugin.device); + unregisterPluginSigningComponents(plugin.chainId, plugin.device); + } + throw err; + } } 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) { + unregisterPluginSigningComponents(chainId, device); + } + return unregistered; } export function listChains(): ChainPlugin[] { @@ -151,6 +177,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.signingSuite?.requirements?.length) { + const client = await context.getClient(); + const fwVersion = getFirmwareVersion(client); + validatePluginSigningRequirements( + resolved as ChainPlugin, + fwVersion, + ); + } const signer = await resolved.createSigner(context); cached = { generation: cacheGeneration, diff --git a/packages/sdk/src/chains/signingComponents.ts b/packages/sdk/src/chains/signingComponents.ts new file mode 100644 index 00000000..988d82f8 --- /dev/null +++ b/packages/sdk/src/chains/signingComponents.ts @@ -0,0 +1,208 @@ +import { + compareFirmwareVersions, + createSigningComponentRegistry, + toChainKey, + type ChainPlugin, + type DeviceId, + type FirmwareVersionTuple, + type SigningComponentDefinition, + type SigningComponentRequirement, +} from '@gridplus/chain-core'; +import { EXTERNAL } from '../constants'; + +const registry = createSigningComponentRegistry(); +let seeded = false; +const pluginDefinitionsByKey = new Map(); + +const getBuiltinSigningComponentDefinitions = + (): SigningComponentDefinition[] => { + const definitions: SigningComponentDefinition[] = []; + + 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: SigningComponentDefinition[], +): SigningComponentDefinition[] => + definitions.map((definition) => ({ + kind: definition.kind, + name: definition.name, + code: definition.code, + })); + +const toPluginSigningComponentKey = ( + chainId: string, + device: DeviceId, +): string => toChainKey(chainId, device); + +const rebuildRegistryFromTrackedComponents = (): void => { + registry.reset(); + seeded = false; + ensureSigningComponentsSeeded(); + + 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, +): 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 isSigningComponentRequirement = ( + requirement: unknown, +): requirement is SigningComponentRequirement => { + if (!requirement || typeof requirement !== 'object') return false; + const req = requirement as SigningComponentRequirement; + 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 getPluginSigningComponentDefinitions = ( + plugin: ChainPlugin, +): SigningComponentDefinition[] => { + const definitions = plugin.signingSuite?.definitions; + if (!definitions || !Array.isArray(definitions)) return []; + return definitions; +}; + +const getPluginSigningComponentRequirements = ( + plugin: ChainPlugin, +): SigningComponentRequirement[] => { + const requirements = plugin.signingSuite?.requirements; + if (!requirements || !Array.isArray(requirements)) return []; + return requirements as SigningComponentRequirement[]; +}; + +const validatePluginRequirementShape = (plugin: ChainPlugin): void => { + const requirements = plugin.signingSuite?.requirements; + if (!requirements) return; + if (!Array.isArray(requirements)) { + throw new Error( + `Chain "${plugin.chainId}" has invalid signing component requirements: expected an array.`, + ); + } + requirements.forEach((requirement, index) => { + if (!isSigningComponentRequirement(requirement)) { + throw new Error( + `Chain "${plugin.chainId}" has invalid signing component requirement at index ${index}.`, + ); + } + }); +}; + +export function getSigningComponentRegistry() { + return registry; +} + +export function ensureSigningComponentsSeeded(): void { + if (seeded) return; + registry.register(getBuiltinSigningComponentDefinitions()); + seeded = true; +} + +export function preflightPluginSigningComponents( + plugin: ChainPlugin, +): void { + validatePluginRequirementShape(plugin); + ensureSigningComponentsSeeded(); + + const definitions = getPluginSigningComponentDefinitions(plugin); + if (definitions.length === 0) return; + registry.preflight(definitions); +} + +export function registerPluginSigningComponents( + plugin: ChainPlugin, +): void { + validatePluginRequirementShape(plugin); + ensureSigningComponentsSeeded(); + + const definitions = getPluginSigningComponentDefinitions(plugin); + const pluginKey = toPluginSigningComponentKey(plugin.chainId, plugin.device); + if (definitions.length === 0) { + pluginDefinitionsByKey.delete(pluginKey); + return; + } + registry.register(definitions); + pluginDefinitionsByKey.set(pluginKey, cloneDefinitions(definitions)); +} + +export function unregisterPluginSigningComponents( + chainId: string, + device?: DeviceId, +): void { + if (!seeded) return; + + const keysToDelete = device + ? [toPluginSigningComponentKey(chainId, device)] + : [...pluginDefinitionsByKey.keys()].filter((key) => + key.startsWith(`${chainId}:`), + ); + + if (keysToDelete.length === 0) return; + + keysToDelete.forEach((key) => pluginDefinitionsByKey.delete(key)); + rebuildRegistryFromTrackedComponents(); +} + +export function validatePluginSigningRequirements( + plugin: ChainPlugin, + fwVersion: FirmwareVersionTuple, +): void { + validatePluginRequirementShape(plugin); + ensureSigningComponentsSeeded(); + + const requirements = getPluginSigningComponentRequirements(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 resetSigningComponentRegistry(): void { + registry.reset(); + pluginDefinitionsByKey.clear(); + seeded = false; +}