diff --git a/packages/hdwallet-core/src/solana.ts b/packages/hdwallet-core/src/solana.ts index 70fec107..6ca7efd0 100644 --- a/packages/hdwallet-core/src/solana.ts +++ b/packages/hdwallet-core/src/solana.ts @@ -9,9 +9,64 @@ export interface SolanaAddress { address: string; } +export interface SolanaTokenInfo { + /** 32-byte SPL mint, encoded as bytes, hex, base64, or base58. */ + mint: Uint8Array | string; + symbol?: string; + decimals?: number; + signature?: Uint8Array | string; + signerKeyId?: number; +} + export interface SolanaSignTx { addressNList: BIP32Path; rawTx: Uint8Array | string; + /** Optional token definitions used by firmware display policy. */ + tokenInfo?: SolanaTokenInfo[]; + /** + * Candidate owners for signed SPL token destinations (for example x402 + * payTo). Firmware displays one only after deriving and matching its ATA. + */ + tokenRecipientOwners?: Array; + /** One-request opaque-signing authorization; does not mutate AdvancedMode. */ + allowBlindSigning?: boolean; + /** + * Transaction-bound, signer-attested resolution of the Address Lookup + * Table accounts this exact message references (KKSOLSW1). `accounts` is + * the raw canonical account list: all writable lookup keys, then all + * readonly lookup keys, in lookup-table/index order — max 8. Firmware + * verifies `signature` (64-byte compact secp256k1) over + * SHA256("KeepKeySolanaTxAccounts/1" || message_hash(32) || count(LE32) || + * account[0..count-1]). + * + * `signerKeyId` is a runtime clear-sign signer slot (0-3) for the + * annotation-only path (Advanced Mode still required), or the certified + * delegate sentinel 0x80 when `certificate` is also set on the request. + */ + lutProof?: { + accounts: Array; + signature: Uint8Array | string; + signerKeyId: number; + }; + /** + * Signer-attested KKSOLSC1 instruction schema. Unlike lutProof this is + * NOT bound to one transaction: it describes how to read a program's + * instruction, so a single signature is reused for every transaction to + * that program and the device decodes values from the bytes it signs. + */ + schema?: { + payload: Uint8Array | string; + signature: Uint8Array | string; + signerKeyId: number; + }; + /** + * 139-byte KeepKey root certificate authorizing the delegate that signed + * `schema` and, when present, `lutProof`. Required for the certified path — + * schema.signerKeyId and any present lutProof.signerKeyId MUST be 0x80. + * Self-contained legacy/v0 messages intentionally omit lutProof because all + * instruction accounts are already committed by rawTx. + */ + certificate?: Uint8Array | string; } export interface SolanaSignedTx { diff --git a/packages/hdwallet-keepkey/src/solana-certified-wire.test.ts b/packages/hdwallet-keepkey/src/solana-certified-wire.test.ts new file mode 100644 index 00000000..d4caefbf --- /dev/null +++ b/packages/hdwallet-keepkey/src/solana-certified-wire.test.ts @@ -0,0 +1,131 @@ +import * as jspb from "google-protobuf"; + +import { SolanaSignedTx, solanaSignTx } from "./solana"; + +const SOLANA_SIGN_TX = 752; +const SOLANA_SIGNED_TX = 753; +const PATH = [0x8000002c, 0x800001f5, 0x80000000, 0x80000000]; + +function makeTransport(inspect: (bytes: Uint8Array) => void) { + return { + debugLink: false, + lockDuring: (fn: () => Promise) => fn(), + call: jest.fn().mockImplementation((messageType: number, msg: jspb.Message) => { + expect(messageType).toBe(SOLANA_SIGN_TX); + inspect((msg as any).serializeBinary()); + const response = new SolanaSignedTx(); + response.setSignature(new Uint8Array(64).fill(0x42)); + return Promise.resolve({ + message_enum: SOLANA_SIGNED_TX, + message_type: "SolanaSignedTx", + proto: response, + }); + }), + } as any; +} + +function decodeFieldNumbers(bytes: Uint8Array): number[] { + const reader = new jspb.BinaryReader(bytes); + const fields: number[] = []; + while (reader.nextField()) { + if (reader.isEndGroup()) break; + fields.push(reader.getFieldNumber()); + reader.skipField(); + } + return fields; +} + +describe("certified Solana wire shapes", () => { + it("encodes schema + certificate without manufacturing LUT fields", async () => { + const schemaPayload = new Uint8Array([0x4b, 0x4b, 0x53, 0x4f, 0x4c]); + const schemaSignature = new Uint8Array(64).fill(0x22); + const certificate = new Uint8Array(139).map((_, i) => i); + const transport = makeTransport((bytes) => { + const fields = decodeFieldNumbers(bytes); + expect(fields).toEqual(expect.arrayContaining([1, 3, 9, 10, 11, 13])); + expect(fields).not.toContain(5); + expect(fields).not.toContain(6); + expect(fields).not.toContain(7); + + const reader = new jspb.BinaryReader(bytes); + const decoded: Record = {}; + while (reader.nextField()) { + if (reader.isEndGroup()) break; + const field = reader.getFieldNumber(); + if (field === 9 || field === 10 || field === 13) decoded[field] = reader.readBytes(); + else if (field === 11) decoded[field] = reader.readUint32(); + else reader.skipField(); + } + expect(decoded[9]).toEqual(schemaPayload); + expect(decoded[10]).toEqual(schemaSignature); + expect(decoded[11]).toBe(0x80); + expect(decoded[13]).toEqual(certificate); + }); + + await solanaSignTx(transport, { + addressNList: PATH, + rawTx: new Uint8Array([0x80, 0x00]), + schema: { + payload: schemaPayload, + signature: schemaSignature, + signerKeyId: 0x80, + }, + certificate, + }); + }); + + it("adds the LUT account/signature/id only for the ALT-backed shape", async () => { + const transport = makeTransport((bytes) => { + const fields = decodeFieldNumbers(bytes); + expect(fields.filter((field) => field === 5)).toHaveLength(2); + expect(fields).toEqual(expect.arrayContaining([6, 7, 9, 10, 11, 13])); + }); + + await solanaSignTx(transport, { + addressNList: PATH, + rawTx: new Uint8Array([0x80, 0x00]), + lutProof: { + accounts: [new Uint8Array(32).fill(0x11), new Uint8Array(32).fill(0x12)], + signature: new Uint8Array(64).fill(0x21), + signerKeyId: 0x80, + }, + schema: { + payload: new Uint8Array([0x4b, 0x4b, 0x53, 0x4f, 0x4c]), + signature: new Uint8Array(64).fill(0x22), + signerKeyId: 0x80, + }, + certificate: new Uint8Array(139).fill(0x33), + }); + }); + + it("rejects partial or mixed certified material before transport", async () => { + const call = jest.fn(); + const transport = { + debugLink: false, + lockDuring: (fn: () => Promise) => fn(), + call, + } as any; + + await expect( + solanaSignTx(transport, { + addressNList: PATH, + rawTx: new Uint8Array([0x80, 0x00]), + certificate: new Uint8Array(139), + }) + ).rejects.toThrow(/requires schema signerKeyId 0x80/); + + await expect( + solanaSignTx(transport, { + addressNList: PATH, + rawTx: new Uint8Array([0x80, 0x00]), + schema: { + payload: new Uint8Array([1]), + signature: new Uint8Array(64), + signerKeyId: 0x80, + }, + }) + ).rejects.toThrow(/requires a certificate/); + + expect(call).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/hdwallet-keepkey/src/solana.ts b/packages/hdwallet-keepkey/src/solana.ts index cdbe2689..fa97639d 100644 --- a/packages/hdwallet-keepkey/src/solana.ts +++ b/packages/hdwallet-keepkey/src/solana.ts @@ -1,6 +1,7 @@ /* eslint-disable @typescript-eslint/no-namespace */ import * as Messages from "@keepkey/device-protocol/lib/messages_pb"; import * as core from "@keepkey/hdwallet-core"; +import * as bs58 from "bs58"; import * as jspb from "google-protobuf"; import { Transport } from "./transport"; @@ -9,6 +10,206 @@ import { messageNameRegistry, messageTypeRegistry } from "./typeRegistry"; // Cast to access methods that exist at runtime but not in @types/google-protobuf const Msg = jspb.Message as any; +// ── Minimal protobuf wire encoding ──────────────────────────────────── +// Used to carry fields the installed @keepkey/device-protocol build predates. +// Encoded fields concatenate in any order, so appending well-formed bytes to a +// serialized message is valid protobuf, not a hack around it. + +function toBytes(value: Uint8Array | string): Uint8Array { + if (value instanceof Uint8Array) return value; + return /^[0-9a-fA-F]+$/.test(value) && value.length % 2 === 0 + ? core.fromHexString(value) + : Uint8Array.from(Buffer.from(value, "base64")); +} + +function toSolanaPubkey(value: Uint8Array | string, label: string): Uint8Array { + let bytes: Uint8Array; + if (value instanceof Uint8Array) { + bytes = value; + } else if (/^[0-9a-fA-F]{64}$/.test(value)) { + bytes = core.fromHexString(value); + } else { + try { + const decoded = bs58.decode(value); + bytes = Uint8Array.from(decoded); + } catch (_e) { + bytes = Uint8Array.from(Buffer.from(value, "base64")); + } + } + if (bytes.length !== 32) { + throw new Error(`${label} must decode to exactly 32 bytes, got ${bytes.length}`); + } + return bytes; +} + +function encodeVarint(value: number): number[] { + if (!Number.isInteger(value) || value < 0) { + throw new Error(`varint must be a non-negative integer, got ${value}`); + } + const out: number[] = []; + let v = value; + do { + let byte = v & 0x7f; + v >>>= 7; + if (v > 0) byte |= 0x80; + out.push(byte); + } while (v > 0); + return out; +} + +/** field << 3 | 0 (varint) */ +function encodeVarintField(fieldNumber: number, value: number): Uint8Array { + return Uint8Array.from([...encodeVarint((fieldNumber << 3) | 0), ...encodeVarint(value)]); +} + +/** field << 3 | 2 (length-delimited) */ +function encodeLengthDelimited(fieldNumber: number, bytes: Uint8Array): Uint8Array { + return Uint8Array.from([...encodeVarint((fieldNumber << 3) | 2), ...encodeVarint(bytes.length), ...bytes]); +} + +function concatBytes(...chunks: Uint8Array[]): Uint8Array { + const total = chunks.reduce((n, c) => n + c.length, 0); + const out = new Uint8Array(total); + let off = 0; + for (const c of chunks) { + out.set(c, off); + off += c.length; + } + return out; +} + +function bytesEqual(left: Uint8Array, right: Uint8Array): boolean { + if (left.length !== right.length) return false; + let diff = 0; + for (let i = 0; i < left.length; i++) diff |= left[i] ^ right[i]; + return diff === 0; +} + +type CertifiedWireEnvelope = { + schemaPayload: Uint8Array; + schemaSignature: Uint8Array; + schemaSignerKeyId: number; + certificate: Uint8Array; + lutAccounts: Uint8Array[]; + lutSignature?: Uint8Array; + lutSignerKeyId?: number; +}; + +/** + * Decode the final serialized request and require byte-for-byte agreement with + * the certified material supplied by the caller. This checks the actual bytes + * Transport.call() will frame, so a jspb shim, bundler, or field-number + * regression fails on the host instead of becoming a misleading device-side + * certificate error. + */ +function assertCertifiedWireEnvelope(wire: Uint8Array, expected: CertifiedWireEnvelope): void { + const reader = new jspb.BinaryReader(wire); + const lutAccounts: Uint8Array[] = []; + let schemaPayload: Uint8Array | undefined; + let schemaSignature: Uint8Array | undefined; + let schemaSignerKeyId: number | undefined; + let certificate: Uint8Array | undefined; + let lutSignature: Uint8Array | undefined; + let lutSignerKeyId: number | undefined; + + while (reader.nextField()) { + if (reader.isEndGroup()) break; + switch (reader.getFieldNumber()) { + case 5: + lutAccounts.push(reader.readBytes()); + break; + case 6: + if (lutSignature !== undefined) throw new Error("duplicate Solana LUT signature field"); + lutSignature = reader.readBytes(); + break; + case 7: + if (lutSignerKeyId !== undefined) throw new Error("duplicate Solana LUT signer field"); + lutSignerKeyId = reader.readUint32(); + break; + case 9: + if (schemaPayload !== undefined) throw new Error("duplicate Solana schema payload field"); + schemaPayload = reader.readBytes(); + break; + case 10: + if (schemaSignature !== undefined) throw new Error("duplicate Solana schema signature field"); + schemaSignature = reader.readBytes(); + break; + case 11: + if (schemaSignerKeyId !== undefined) throw new Error("duplicate Solana schema signer field"); + schemaSignerKeyId = reader.readUint32(); + break; + case 13: + if (certificate !== undefined) throw new Error("duplicate Solana certificate field"); + certificate = reader.readBytes(); + break; + default: + reader.skipField(); + break; + } + } + + const sameAccounts = + lutAccounts.length === expected.lutAccounts.length && + lutAccounts.every((account, i) => bytesEqual(account, expected.lutAccounts[i])); + if ( + !schemaPayload || + !bytesEqual(schemaPayload, expected.schemaPayload) || + !schemaSignature || + !bytesEqual(schemaSignature, expected.schemaSignature) || + schemaSignerKeyId !== expected.schemaSignerKeyId || + !certificate || + !bytesEqual(certificate, expected.certificate) || + !sameAccounts || + (lutSignature === undefined) !== (expected.lutSignature === undefined) || + (lutSignature !== undefined && !bytesEqual(lutSignature, expected.lutSignature!)) || + lutSignerKeyId !== expected.lutSignerKeyId + ) { + throw new Error("serialized certified Solana ClearSign envelope does not match its source material"); + } +} + +function encodeSolanaTokenInfo(info: core.SolanaTokenInfo): Uint8Array { + const fields: Uint8Array[] = [encodeLengthDelimited(1, toSolanaPubkey(info.mint, "token mint"))]; + if (info.symbol !== undefined) { + const symbol = Uint8Array.from(Buffer.from(info.symbol, "utf8")); + if (symbol.length === 0 || symbol.length > 12) { + throw new Error(`token symbol must contain 1-12 UTF-8 bytes, got ${symbol.length}`); + } + fields.push(encodeLengthDelimited(2, symbol)); + } + if (info.decimals !== undefined) { + fields.push(encodeVarintField(3, info.decimals)); + } + if (info.signature !== undefined) { + const signature = toBytes(info.signature); + if (signature.length !== 64) { + throw new Error(`token metadata signature must be 64 bytes, got ${signature.length}`); + } + fields.push(encodeLengthDelimited(4, signature)); + } + if (info.signerKeyId !== undefined) { + fields.push(encodeVarintField(5, info.signerKeyId)); + } + return concatBytes(...fields); +} + +/** + * Wrap a jspb message so serializeBinary() yields the original encoding plus + * `extra`. Transport.call() only ever calls serializeBinary(), so a duck-typed + * wrapper is sufficient and leaves the original message untouched. + */ +function withAppendedFields(msg: jspb.Message, extra: Uint8Array): jspb.Message { + return new Proxy(msg, { + get(target, prop, receiver) { + if (prop === "serializeBinary") { + return () => concatBytes((target as any).serializeBinary(), extra); + } + const value = Reflect.get(target, prop, receiver); + return typeof value === "function" ? value.bind(target) : value; + }, + }) as jspb.Message; +} + // ── Solana Message Type IDs (from messages.proto) ───────────────────── const MESSAGETYPE_SOLANAGETADDRESS = 750; const MESSAGETYPE_SOLANAADDRESS = 751; @@ -65,7 +266,7 @@ export class SolanaGetAddress extends jspb.Message { return writer.getResultBuffer(); } - toObject(_includeInstance?: boolean): SolanaGetAddressAsObject { + toObject(_includeInstance?: boolean): SolanaGetAddress.AsObject { return { addressNList: this.getAddressNList(), coinName: this.getCoinName(), @@ -73,7 +274,7 @@ export class SolanaGetAddress extends jspb.Message { }; } - static toObject(_includeInstance: boolean, msg: SolanaGetAddress): SolanaGetAddressAsObject { + static toObject(_includeInstance: boolean, msg: SolanaGetAddress): SolanaGetAddress.AsObject { return msg.toObject(_includeInstance); } @@ -123,10 +324,12 @@ export class SolanaGetAddress extends jspb.Message { } } -export interface SolanaGetAddressAsObject { - addressNList: number[]; - coinName?: string; - showDisplay?: boolean; +export namespace SolanaGetAddress { + export type AsObject = { + addressNList: number[]; + coinName?: string; + showDisplay?: boolean; + }; } /** @@ -151,11 +354,11 @@ export class SolanaAddress extends jspb.Message { return writer.getResultBuffer(); } - toObject(_includeInstance?: boolean): SolanaAddressAsObject { + toObject(_includeInstance?: boolean): SolanaAddress.AsObject { return { address: this.getAddress() }; } - static toObject(_includeInstance: boolean, msg: SolanaAddress): SolanaAddressAsObject { + static toObject(_includeInstance: boolean, msg: SolanaAddress): SolanaAddress.AsObject { return msg.toObject(_includeInstance); } @@ -189,12 +392,16 @@ export class SolanaAddress extends jspb.Message { } } -export interface SolanaAddressAsObject { - address?: string; +export namespace SolanaAddress { + export type AsObject = { + address?: string; + }; } /** - * SolanaSignTx: address_n(1, repeated uint32), coin_name(2, string), raw_tx(3, bytes) + * SolanaSignTx: address_n(1), coin_name(2), raw_tx(3), token_info(4), + * swap_metadata_payload(5), swap_metadata_signature(6), + * swap_metadata_signer_key_id(7), allow_opaque(8) */ export class SolanaSignTx extends jspb.Message { static repeatedFields_ = [1]; @@ -238,21 +445,34 @@ export class SolanaSignTx extends jspb.Message { jspb.Message.setField(this, 3, value); } + // Fields 5 (lut_account, repeated), 6 (lut_signature), 7 (lut_signer_key_id), + // and 13 (clearsign_certificate) are carried via the wire-append shim below + // (see solanaSignTx), matching the schema/tokenRecipientOwner fields — + // field 5 is a repeated raw account list, not a single opaque blob. + + getAllowOpaque(): boolean { + return jspb.Message.getFieldWithDefault(this, 8, false) as boolean; + } + setAllowOpaque(value: boolean): void { + jspb.Message.setField(this, 8, value); + } + serializeBinary(): Uint8Array { const writer = new jspb.BinaryWriter(); SolanaSignTx.serializeBinaryToWriter(this, writer); return writer.getResultBuffer(); } - toObject(_includeInstance?: boolean): SolanaSignTxAsObject { + toObject(_includeInstance?: boolean): SolanaSignTx.AsObject { return { addressNList: this.getAddressNList(), coinName: this.getCoinName(), rawTx: this.getRawTx(), + allowOpaque: this.getAllowOpaque(), }; } - static toObject(_includeInstance: boolean, msg: SolanaSignTx): SolanaSignTxAsObject { + static toObject(_includeInstance: boolean, msg: SolanaSignTx): SolanaSignTx.AsObject { return msg.toObject(_includeInstance); } @@ -278,6 +498,9 @@ export class SolanaSignTx extends jspb.Message { case 3: msg.setRawTx(reader.readBytes()); break; + case 8: + msg.setAllowOpaque(reader.readBool()); + break; default: reader.skipField(); break; @@ -299,13 +522,20 @@ export class SolanaSignTx extends jspb.Message { if (rawTx.length > 0) { writer.writeBytes(3, rawTx); } + const allowOpaque = jspb.Message.getField(message, 8) as boolean | null; + if (allowOpaque != null) { + writer.writeBool(8, allowOpaque); + } } } -export interface SolanaSignTxAsObject { - addressNList: number[]; - coinName?: string; - rawTx: Uint8Array | string; +export namespace SolanaSignTx { + export type AsObject = { + addressNList: number[]; + coinName?: string; + rawTx: Uint8Array | string; + allowOpaque?: boolean; + }; } /** @@ -340,11 +570,11 @@ export class SolanaSignedTx extends jspb.Message { return writer.getResultBuffer(); } - toObject(_includeInstance?: boolean): SolanaSignedTxAsObject { + toObject(_includeInstance?: boolean): SolanaSignedTx.AsObject { return { signature: this.getSignature() }; } - static toObject(_includeInstance: boolean, msg: SolanaSignedTx): SolanaSignedTxAsObject { + static toObject(_includeInstance: boolean, msg: SolanaSignedTx): SolanaSignedTx.AsObject { return msg.toObject(_includeInstance); } @@ -378,8 +608,10 @@ export class SolanaSignedTx extends jspb.Message { } } -export interface SolanaSignedTxAsObject { - signature: Uint8Array | string; +export namespace SolanaSignedTx { + export type AsObject = { + signature: Uint8Array | string; + }; } /** @@ -965,8 +1197,87 @@ export async function solanaSignTx(transport: Transport, msg: core.SolanaSignTx) rawBytes = new Uint8Array(msg.rawTx as any); } signTx.setRawTx(rawBytes); + if (msg.allowBlindSigning === true) { + signTx.setAllowOpaque(true); + } + + /* + * Additive SolanaSignTx fields are appended at the wire level because this + * file intentionally carries a small jspb compatibility shim. Protobuf + * fields are order-independent, and firmware's nanopb decoder reads the + * same canonical field numbers emitted by device-protocol. + */ + const extraFields: Uint8Array[] = []; + const certifiedWire: Partial & { lutAccounts: Uint8Array[] } = { + lutAccounts: [], + }; + const certified = msg.certificate !== undefined; + if (certified) { + if (!msg.schema || msg.schema.signerKeyId !== 0x80) { + throw new Error("certified Solana request requires schema signerKeyId 0x80"); + } + if (msg.lutProof && msg.lutProof.signerKeyId !== 0x80) { + throw new Error("certified Solana LUT proof requires signerKeyId 0x80"); + } + } else if (msg.schema?.signerKeyId === 0x80 || msg.lutProof?.signerKeyId === 0x80) { + throw new Error("Solana delegate signerKeyId 0x80 requires a certificate"); + } + for (const tokenInfo of msg.tokenInfo || []) { + extraFields.push(encodeLengthDelimited(4, encodeSolanaTokenInfo(tokenInfo))); + } + if (msg.lutProof) { + if (msg.lutProof.accounts.length < 1 || msg.lutProof.accounts.length > 8) { + throw new Error("lutProof.accounts must contain 1-8 accounts"); + } + for (const account of msg.lutProof.accounts) { + const accountBytes = toSolanaPubkey(account, "lut account"); + certifiedWire.lutAccounts.push(accountBytes); + extraFields.push(encodeLengthDelimited(5, accountBytes)); + } + const lutSignature = toBytes(msg.lutProof.signature); + if (lutSignature.length !== 64) { + throw new Error(`lutProof.signature must be exactly 64 bytes, got ${lutSignature.length}`); + } + extraFields.push(encodeLengthDelimited(6, lutSignature), encodeVarintField(7, msg.lutProof.signerKeyId)); + certifiedWire.lutSignature = lutSignature; + certifiedWire.lutSignerKeyId = msg.lutProof.signerKeyId; + } + if (msg.schema) { + const payload = toBytes(msg.schema.payload); + const signature = toBytes(msg.schema.signature); + if (payload.length === 0) { + throw new Error("schema.payload must not be empty"); + } + if (signature.length !== 64) { + throw new Error(`schema.signature must be exactly 64 bytes, got ${signature.length}`); + } + extraFields.push( + encodeLengthDelimited(9, payload), + encodeLengthDelimited(10, signature), + encodeVarintField(11, msg.schema.signerKeyId) + ); + certifiedWire.schemaPayload = payload; + certifiedWire.schemaSignature = signature; + certifiedWire.schemaSignerKeyId = msg.schema.signerKeyId; + } + for (const owner of msg.tokenRecipientOwners || []) { + extraFields.push(encodeLengthDelimited(12, toSolanaPubkey(owner, "token recipient owner"))); + } + if (msg.certificate) { + const certificate = toBytes(msg.certificate); + if (certificate.length !== 139) { + throw new Error(`certificate must be exactly 139 bytes, got ${certificate.length}`); + } + extraFields.push(encodeLengthDelimited(13, certificate)); + certifiedWire.certificate = certificate; + } + const outbound: jspb.Message = + extraFields.length > 0 ? withAppendedFields(signTx, concatBytes(...extraFields)) : signTx; + if (certified) { + assertCertifiedWireEnvelope(outbound.serializeBinary(), certifiedWire as CertifiedWireEnvelope); + } - const resp = await transport.call(MESSAGETYPE_SOLANASIGNTX, signTx, { + const resp = await transport.call(MESSAGETYPE_SOLANASIGNTX, outbound, { msgTimeout: core.LONG_TIMEOUT, omitLock: true, }); diff --git a/packages/hdwallet-keepkey/src/typeRegistry.test.ts b/packages/hdwallet-keepkey/src/typeRegistry.test.ts new file mode 100644 index 00000000..297f00ce --- /dev/null +++ b/packages/hdwallet-keepkey/src/typeRegistry.test.ts @@ -0,0 +1,16 @@ +import * as jspb from "google-protobuf"; + +import { messageTypeRegistry } from "./typeRegistry"; + +describe("protobuf type registry", () => { + it("contains only message constructors, never generated enum maps", () => { + // Retired protocol MessageType values intentionally have no constructor; + // every populated entry must still be a real protobuf message class. + const constructors = Object.values(messageTypeRegistry).filter(Boolean); + expect(constructors.length).toBeGreaterThan(0); + for (const constructor of constructors) { + expect(typeof constructor).toBe("function"); + expect(constructor.prototype).toBeInstanceOf(jspb.Message); + } + }); +}); diff --git a/packages/hdwallet-keepkey/src/typeRegistry.ts b/packages/hdwallet-keepkey/src/typeRegistry.ts index 76b91568..af336999 100644 --- a/packages/hdwallet-keepkey/src/typeRegistry.ts +++ b/packages/hdwallet-keepkey/src/typeRegistry.ts @@ -15,30 +15,29 @@ import * as TronMessages from "@keepkey/device-protocol/lib/messages-tron_pb"; import * as ZcashMessages from "@keepkey/device-protocol/lib/messages-zcash_pb"; import * as core from "@keepkey/hdwallet-core"; import * as jspb from "google-protobuf"; -function omit(obj: Record, ...keys: string[]): Record { - const result = { ...obj }; - for (const key of keys) delete result[key]; - return result; +function messageEntries(obj: Record): Array<[string, core.Constructor]> { + return Object.entries(obj).filter( + (entry): entry is [string, core.Constructor] => + typeof entry[1] === "function" && entry[1].prototype instanceof jspb.Message + ); } -// Conflict between typedef and actual js export - const AllMessages = ([] as Array<[string, core.Constructor]>) - .concat(Object.entries(omit(Messages, "MessageType", "MessageTypeMap"))) - .concat(Object.entries(BinanceMessages)) - .concat(Object.entries(CosmosMessages)) - .concat(Object.entries(EthereumMessages)) - .concat(Object.entries(OsmosisMessages)) - .concat(Object.entries(RippleMessages)) - .concat(Object.entries(NanoMessages)) - .concat(Object.entries(omit(EosMessages, "EosPublicKeyKind", "EosPublicKeyKindMap"))) - .concat(Object.entries(SolanaMessages)) - .concat(Object.entries(TendermintMessages)) - .concat(Object.entries(ThorchainMessages)) - .concat(Object.entries(TonMessages)) - .concat(Object.entries(TronMessages)) - .concat(Object.entries(MayachainMessages)) - .concat(Object.entries(ZcashMessages)); + .concat(messageEntries(Messages)) + .concat(messageEntries(BinanceMessages)) + .concat(messageEntries(CosmosMessages)) + .concat(messageEntries(EthereumMessages)) + .concat(messageEntries(OsmosisMessages)) + .concat(messageEntries(RippleMessages)) + .concat(messageEntries(NanoMessages)) + .concat(messageEntries(EosMessages)) + .concat(messageEntries(SolanaMessages)) + .concat(messageEntries(TendermintMessages)) + .concat(messageEntries(ThorchainMessages)) + .concat(messageEntries(TonMessages)) + .concat(messageEntries(TronMessages)) + .concat(messageEntries(MayachainMessages)) + .concat(messageEntries(ZcashMessages)); const upperCasedMessageClasses = AllMessages.reduce((registry, entry: [string, core.Constructor]) => { registry[entry[0].toUpperCase()] = entry[1];