Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 55 additions & 0 deletions packages/hdwallet-core/src/solana.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Uint8Array | string>;
/** 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<Uint8Array | string>;
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 {
Expand Down
131 changes: 131 additions & 0 deletions packages/hdwallet-keepkey/src/solana-certified-wire.test.ts
Original file line number Diff line number Diff line change
@@ -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: <T>(fn: () => Promise<T>) => 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<number, Uint8Array | number> = {};
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: <T>(fn: () => Promise<T>) => 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();
});
});
Loading
Loading