diff --git a/packages/chains/chain-core/src/index.ts b/packages/chains/chain-core/src/index.ts index c31caf00..77117248 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,56 @@ export function createChainRegistry( }; } +export { + createPrimitiveRegistry, + PrimitiveConflictError, + 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/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..d2bc9f61 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'; @@ -15,49 +16,81 @@ import { } from '../chain'; import { buildSigResultFromRsv, compressSecp256k1Pubkey } from './shared'; -type LatticeCosmosContext = DeviceContext & { +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: { - CURVES: { - SECP256K1: number; - }; - HASHES: { - SHA256: number; - }; - ENCODINGS: { - COSMOS: number; - }; - }; + SIGNING?: PrimitiveCodeMaps; }; }; }; -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 & { + 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 LatticeCosmosContextInput; + const constants = typed.constants; + 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 constants; + return { + ...typed, + resolvePrimitive, + }; } 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 +134,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 +174,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..860d1674 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 PrimitiveKind, + type PublicKey, + type SignResult, } from '@gridplus/chain-core'; import { Hash } from 'ox'; import { @@ -32,25 +36,20 @@ export type LatticeEvmSignerOptions = { fetchEvmDecoder?: boolean; }; -type LatticeEvmContext = DeviceContext & { +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: { - CURVES: { - SECP256K1: number; - }; - HASHES: { - KECCAK256: number; - }; - ENCODINGS: { - EVM: number; - EIP7702_AUTH: number; - EIP7702_AUTH_LIST: number; - }; - }; + SIGNING?: PrimitiveCodeMaps; }; CURRENCIES: { ETH_MSG: string; @@ -65,23 +64,58 @@ 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); + 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) || - !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, + resolvePrimitive, + }; } function isRawEvmTx( @@ -103,25 +137,43 @@ function normalizeRawEvmTx(tx: EvmRawTransaction): Hex | Buffer { function getEvmEncodingType( tx: TransactionSerializable, - EXTERNAL: LatticeEvmContext['constants']['EXTERNAL'], + 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 - ? 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: 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, resolvePrimitive } = latticeContext; + const { EXTERNAL, CURRENCIES } = latticeContext.constants; + const curveSecp256k1 = resolvePrimitive('curve', 'SECP256K1'); + const hashKeccak256 = resolvePrimitive('hash', 'KECCAK256'); + const encodingEvm = resolvePrimitive('encoding', 'EVM'); return { getAddress: async (path: DerivationPath): Promise
=> { @@ -169,11 +221,14 @@ export function createLatticeEvmSigner( : serializeTransaction(request.payload as TransactionSerializable); const encodingType = isRaw - ? EXTERNAL.SIGNING.ENCODINGS.EVM + ? encodingEvm : getEvmEncodingType( request.payload as TransactionSerializable, - EXTERNAL, + resolvePrimitive, ); + if (!isRaw && (request.payload as any).type === 'eip7702') { + await assertEip7702FirmwareSupport(latticeContext); + } let decoder: Buffer | undefined; const fetchDecoder = services?.fetchDecoder; @@ -190,8 +245,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 +287,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 +310,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 +346,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..7917ca40 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'; @@ -16,49 +17,81 @@ import { } from '../chain'; import { buildSigResultFromRsv, toBuffer } from './shared'; -type LatticeSolanaContext = DeviceContext & { +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: { - CURVES: { - ED25519: number; - }; - HASHES: { - NONE: number; - }; - ENCODINGS: { - SOLANA: number; - }; - }; + SIGNING?: PrimitiveCodeMaps; }; }; }; -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 & { + 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 LatticeSolanaContextInput; + const constants = typed.constants; + 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 constants; + return { + ...typed, + resolvePrimitive, + }; } 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 +123,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 +163,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..196e7760 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'; @@ -20,49 +21,80 @@ import { toBuffer, } from './shared'; -type LatticeXrpContext = DeviceContext & { +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: { - CURVES: { - SECP256K1: number; - }; - HASHES: { - SHA512HALF: number; - }; - ENCODINGS: { - XRP: number; - }; - }; + SIGNING?: PrimitiveCodeMaps; }; }; }; -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 & { + 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 LatticeXrpContextInput; + const constants = typed.constants; + 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 constants; + return { + ...typed, + resolvePrimitive, + }; } 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 +139,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 +181,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..271a5954 --- /dev/null +++ b/packages/sdk/src/__test__/unit/chainRuntimePrimitives.test.ts @@ -0,0 +1,189 @@ +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('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, + 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..f028f635 --- /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..4c3f96e8 --- /dev/null +++ b/packages/sdk/src/__test__/unit/latticeSignerPrimitives.test.ts @@ -0,0 +1,276 @@ +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]; + 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 = { + 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: 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.primitives), + }, + CURRENCIES: { + ETH_MSG: 'ETH_MSG', + }, + }, + services: {}, + }; + if (includeResolver) { + context.resolvePrimitive = resolvePrimitive; + } + + 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 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: { + '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..acd6bcc9 --- /dev/null +++ b/packages/sdk/src/__test__/unit/primitives.test.ts @@ -0,0 +1,159 @@ +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..f14dc574 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 { + ensurePrimitivesSeeded, + preflightPluginPrimitives, + registerPluginPrimitives, + validatePluginPrimitiveRequirements, +} from './primitives'; export type { ChainKey, @@ -18,6 +24,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..07e5b6dd --- /dev/null +++ b/packages/sdk/src/chains/primitives.ts @@ -0,0 +1,201 @@ +import { + compareFirmwareVersions, + createPrimitiveRegistry, + toChainKey, + type ChainPlugin, + type DeviceId, + type FirmwareVersionTuple, + type PrimitiveDefinition, + type PrimitiveRequirement, +} from '@gridplus/chain-core'; +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, +): 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 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; + registry.register(getBuiltinPrimitiveDefinitions()); + 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); + 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( + 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(); + pluginDefinitionsByKey.clear(); + seeded = false; +} diff --git a/packages/sdk/src/chains/registry.ts b/packages/sdk/src/chains/registry.ts index 61951fcb..15e118e0 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 { + ensurePrimitivesSeeded, + preflightPluginPrimitives, + registerPluginPrimitives, + unregisterPluginPrimitives, + validatePluginPrimitiveRequirements, +} from './primitives'; 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); + 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); + unregisterPluginPrimitives(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) { + unregisterPluginPrimitives(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.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; }; }