diff --git a/CHANGELOG.md b/CHANGELOG.md index 72b2d76..e164391 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,35 @@ All notable changes to `@railgun-community/ledger-client` are documented here. This project is pre-1.0 and experimental; expect breaking changes on minor versions. +## 0.4.0 — 2026-07-24 + +Grows the CLEAR_SIGN transact surface from the 0.3.0 builders into full, engine-ready +signing. Still experimental and requires firmware **1.6.1 (clear-sign-v1)** on the device. +Verified against device-measured response layouts; a live on-hardware transact round-trip +is still pending. + +### Added + +- **CLEAR_SIGN transact signing (single-tx).** A session orchestrator streams the full + sequence (`CS_INIT → NULLIFIER×n → BP_FIELDS → OUT_* → FINALIZE`), collects each output + response, and parses the 129-byte finalize (signature + message hash). Exposed through + the signer and the controller. +- **Clear-sign toggle on the connector `sign` flow (engine-ready).** `sign` accepts an + optional plaintext transact; when supplied, the device reviews the recipients, tokens, + and amounts and generates the output ciphertexts, and the result carries the device + message hash and per-output responses alongside the signature. Without it, `sign` + blind-signs exactly as before — a pure addition. +- **Dual-tx (txToken ≠ feeToken) clear-sign** via `signClearMultiTransact` on the connector + — one signature per sub-transaction, capped at the device maximum of two. +- **`decodeClearSignOutput`** — decodes a raw `OUT_*` response into its structured fields + (random, sender/recipient blinding keys, IV, tag, ciphertext, senderRandom, and the + transfer annotation IV) off the firmware reference layout. + +### Changed + +- The `RAILGUN_CLEAR_SIGN_STATE` status word (`0xb007`) now maps to a clear, best-effort + error instead of a generic device failure. + ## 0.3.1 — 2026-07-24 ### Changed diff --git a/docs/api/signers.md b/docs/api/signers.md index cc8c04c..0b1ae37 100644 --- a/docs/api/signers.md +++ b/docs/api/signers.md @@ -50,6 +50,22 @@ value so the user can compare it against an out-of-band source). Neither exposes | `getViewingPublicKey()` | `Uint8Array` (32B) | Compressed Ed25519 viewing **public** key (`INS 0x10`). Display/verify only — does **not** export the viewing secret; wallet loading still uses `getWalletArtifacts()`. | | `getRailgunAddress()` | `string` | The canonical 127-char `0zk1…` address (`INS 0x14`) — a device-confirmed cross-check of the host-derived address. | +### CLEAR_SIGN transact (experimental) + +`signClearSignTransact(request)` clear-signs a RAILGUN transact via `INS 0x11`: the device +displays the actual recipients / tokens / amounts (not just a hash) and signs on approval. It +streams the session in order (init → nullifiers → bound-params → outputs → finalize) and returns +the EdDSA signature, the echoed message hash, and the raw per-output device responses. + +| Method | Purpose | +|--------|---------| +| `signClearSignTransact(request)` | Clear-sign a single transact (n inputs, m outputs; `n,m ≤ 3`, `n+m ≤ 5`). | +| `signClearSignMultiTransact(request)` | Clear-sign a multi-tx bundle (`txToken ≠ feeToken` → one signature per tx). | + +Both are also exposed on [`LedgerController`](./controller.md) (managed lifecycle). Splicing the +returned output responses into the on-chain transact calldata (full engine integration) is not +included. + ### Ethereum / EIP-7702 methods (under development) The RAILGUN app derives Ethereum EOAs from a **caller-chosen** path diff --git a/docs/engine-clear-sign-integration.md b/docs/engine-clear-sign-integration.md new file mode 100644 index 0000000..740c215 --- /dev/null +++ b/docs/engine-clear-sign-integration.md @@ -0,0 +1,168 @@ +# Engine integration — Ledger CLEAR_SIGN transacts + +What `@railgun-community/engine` needs to do to sign RAILGUN transacts on a Ledger +using **clear-signing** (the device shows recipients / tokens / amounts and signs on +approval) instead of blind-signing a hash. + +**Status:** experimental. Requires RAILGUN firmware **1.6.1 (clear-sign-v1)** on the +device. The ledger-client side is complete (this repo); the work below is the engine side. + +--- + +## TL;DR + +1. The connector's `sign` gained an **optional 4th argument** — a plaintext transact. + When present, the device clear-signs it; when absent, nothing changes (blind sign). +2. Clear-sign returns **more than a signature**: the device generates the output + note ciphertexts itself (with its viewing key), so the engine must **use the device's + output bytes** in the on-chain calldata — it can't compute them host-side as it does + for blind signing. +3. So the engine: builds a `ClearSignTransactRequest` from the transact plaintext → + calls `sign(hash, publicInputs, subSession, request)` → uses the returned `Signature` + for the proof **and** splices `result.clearSign.outputs` into the on-chain transact. + +--- + +## The interface (already shipped in ledger-client) + +```ts +// connector.sign — 4th arg is the toggle +type HardwareConnectorSignFn = ( + expectedHash: bigint, + publicInputs?: PublicInputsRailgun, + subSession?: string, + clearSign?: ClearSignTransactRequest, // ← NEW: provide to clear-sign +) => Promise; + +// return: still a Signature; clearSign present only when clear-signing +type HardwareConnectorSignResult = Signature & { + readonly clearSign?: { + readonly msgHash: Uint8Array; // the message the device signed + readonly outputs: readonly ClearSignOutputResult[]; // device-generated per-output bytes + }; +}; +type ClearSignOutputResult = { readonly kind: ClearSignOutput['kind']; readonly response: Uint8Array }; +``` + +`HardwareConnectorSignResult` **is** a `Signature` (has `R8`/`S`), so existing blind +callers are unaffected — the clear-sign data is additive. + +For the **txToken ≠ feeToken** case (two signatures), the controller also exposes +`signClearSignMultiTransact(request)`; the single-arg `sign` toggle covers the common +single-tx case. + +### `ClearSignTransactRequest` + +```ts +type ClearSignTransactRequest = { + readonly account?: number; // default 0 + readonly merkleRoot: Uint8Array; // 32 bytes + readonly nullifiers: readonly Uint8Array[]; // one 32-byte nullifier per input; n ∈ [1,3] + readonly boundParams: ClearSignBpFieldsRequest; + readonly outputs: readonly ClearSignOutput[]; // m ∈ [1,3]; sent in this order +}; + +type ClearSignBpFieldsRequest = { + readonly treeNumber: number; + readonly minGasPrice: bigint; // uint48 — values >= 2^48 are rejected + readonly unshield: boolean; + readonly chainId: bigint; // uint64 + readonly adaptContract?: Uint8Array; // 20 bytes; default all-zero (non-adapt) + readonly adaptParams?: Uint8Array; // 32 bytes; default all-zero (non-adapt) +}; + +type ClearSignOutput = + | { kind: 'broadcaster'; recipientMasterPublicKey: Uint8Array; // 32 + recipientViewingPublicKey: Uint8Array; // 32 + tokenHash: Uint8Array; value: bigint } // 32, uint256 + | { kind: 'change'; tokenHash: Uint8Array; value: bigint } + | { kind: 'transfer'; recipient0zk: string; // 127-char 0zk1… address + tokenHash: Uint8Array; value: bigint; + outputType?: number; memo?: Uint8Array } // memo ≤ 32 bytes + | { kind: 'unshield'; recipientAddress: Uint8Array; // 20-byte EVM address + tokenHash: Uint8Array; value: bigint }; +``` + +- **`tokenHash`** is the 32-byte token field: `12 zero bytes ‖ 20-byte ERC-20 address`. + Helper: `encodeErc20TokenHash(address20) → Uint8Array(32)`. +- **Shape limits:** `n, m ∈ [1,3]` and `n + m ≤ 5` (validated before any APDU is sent). +- Constraints and field widths are all validated host-side up front — an invalid request + throws before the device is touched. + +All of `ClearSignTransactRequest`, `ClearSignOutput`, `ClearSignBpFieldsRequest`, +`ClearSignOutputResult`, `HardwareConnectorSignResult`, and `encodeErc20TokenHash` are +exported from `@railgun-community/ledger-client`. + +--- + +## What the engine must implement + +### 1. Detect capability +Only clear-sign when the device supports it. The RAILGUN app profile advertises +`capabilities.railgunClearSign` (and `CAPABILITY_STATUS.clearSign === 'experimental'`). +If unavailable (older firmware, or the wallet isn't a clear-sign Ledger), fall back to the +existing blind `sign(expectedHash, publicInputs)`. + +### 2. Build the request from the transact plaintext +When assembling a transact for a clear-sign Ledger wallet, translate the engine's transact +into a `ClearSignTransactRequest`: +- `merkleRoot`, one `nullifier` per input; +- `boundParams` from the transact's bound parameters (tree, minGasPrice, unshield flag, + chainId, and RelayAdapt contract/params — all-zero for a plain, non-adapt transact); +- one `outputs[]` entry per note, in the **canonical order** the device expects: + **broadcaster → change → (transfer | unshield)**. For a transfer, `recipient0zk` is the + 127-char bech32m `0zk1…` string; for an unshield, the 20-byte EVM address. + +`expectedHash`/`publicInputs` stay what they are today (the poseidon hash + public inputs), +so the connector can still cross-check. + +### 3. Call sign with the toggle +```ts +const result = await connector.sign(expectedHash, publicInputs, subSession, clearSignRequest); +// result.R8 / result.S → the EdDSA signature for the SNARK/proof (as today) +// result.clearSign.msgHash → verify it matches the transact hash you expected +// result.clearSign.outputs → the device-generated output bytes (see §4) +``` + +### 4. Splice the device outputs into the on-chain calldata ← the key difference +In **blind** signing the engine encrypts each output note host-side. In **clear** signing +the **device** encrypts them (it holds the viewing key and commits to its own ciphertext), +so the engine must use the device's bytes or the on-chain commitment won't match. + +`result.clearSign.outputs[i].response` is the raw device response for output `i`, in the +same order as `request.outputs`. **Device-measured lengths:** + +| kind | length | contents (from RAILGUN-HW `js/clear-sign-apdus.js`) | +|------|--------|------| +| `broadcaster`, `change` | **223 B** | 208-B tuple + `senderRandom(15)` | +| `transfer` | **239 B** | 208-B tuple + `senderRandom(15)` + `annotationIv(16)` | +| `unshield` | **32 B** | the output commitment | + +The **208-byte tuple** = `random(16) ‖ Blind1(32) ‖ Blind2(32) ‖ blocks[0..3](4×32)`, +where `blocks[0] = IV(16) ‖ tag(16)` and `blocks[1..3]` are the 96-byte ciphertext. Map +these into the transact's `commitments` / `ciphertext` calldata fields. + +> **Confirm with the firmware team before relying on the exact splice:** the tuple layout +> and the `senderRandom` / `annotationIv` trailers come from the reference host code, not a +> formal spec. This doc records what's device-observed; the engine team should validate the +> commitment-hash match end-to-end on a device. + +### 5. Multi-tx (txToken ≠ feeToken) +Use `LedgerController.signClearSignMultiTransact({ transactions: [txA, txB] })` (max 2 txs). +It returns `signatures[]` (one per tx, same key) + `outputs[]` across both. The FINALIZE is +256 B (two `R8x‖R8y‖S‖msgHash` quads). + +--- + +## Open questions for the RAILGUN-HW / firmware team + +- Exact `OUT_*` tuple → on-chain calldata mapping (the note-ciphertext splice) — validate a + clear-signed transact lands on-chain with matching commitments. +- The `0xb007` status word (surfaced as a "CLEAR_SIGN session/state" error) — confirm its + precise meaning. + +## Fallback / rollout + +Clear-sign is strictly opt-in and capability-gated. With the toggle absent (or on older +firmware) the connector behaves exactly as before, so this can ship behind a per-wallet +setting and be enabled once validated on hardware. diff --git a/src/core/connector/ledger-connector.ts b/src/core/connector/ledger-connector.ts index 08e648c..458f783 100644 --- a/src/core/connector/ledger-connector.ts +++ b/src/core/connector/ledger-connector.ts @@ -18,13 +18,15 @@ import type { HardwareConnector, HardwareConnectorSignFn, + HardwareConnectorSignResult, LedgerConnectorConfig, - Signature, PublicInputsRailgun, RequestApprovalOptions, } from './types.js'; +import type { ClearSignTransactRequest, ClearSignMultiTransactRequest } from '../transport/clear-sign-apdu.js'; import type { HWTransport } from '../transport/types.js'; import { RailgunSigner } from '../signers/railgun-signer.js'; +import type { ClearSignMultiTransactResult } from '../signers/railgun-signer.js'; import { getActiveApp, isVersionSatisfied } from '../device/device-manager.js'; import { HWError, HWErrorCode } from '../errors.js'; import { assertExpectedHashMatchesPublicInputs } from '../../validation/public-inputs.js'; @@ -112,12 +114,19 @@ export function createLedgerConnector( expectedHash: bigint, publicInputs?: PublicInputsRailgun, _subSession?: string, - ): Promise => { + clearSign?: ClearSignTransactRequest, + ): Promise => { return serialized(async () => { if (publicInputs !== undefined) { await assertExpectedHashMatchesPublicInputs(expectedHash, publicInputs); } await ensureAppReady(); + // Toggle: clear-sign the plaintext transact (device reviews it) and return its + // outputs; otherwise blind-sign the expected hash. + if (clearSign !== undefined) { + const result = await withTimeout(signer.signClearSignTransact(clearSign), signTimeout); + return { ...result.signature, clearSign: { msgHash: result.msgHash, outputs: result.outputs } }; + } return withTimeout(signer.sign(expectedHash), signTimeout); }); }; @@ -130,6 +139,15 @@ export function createLedgerConnector( return Promise.resolve(true); }; + const signClearMultiTransact = ( + request: ClearSignMultiTransactRequest, + ): Promise => { + return serialized(async () => { + await ensureAppReady(); + return withTimeout(signer.signClearSignMultiTransact(request), signTimeout); + }); + }; + const getPublicKey = async (): Promise<{ readonly x: bigint; readonly y: bigint }> => { await ensureAppReady(); return withTimeout(signer.getPublicKey(), signTimeout); @@ -147,6 +165,7 @@ export function createLedgerConnector( type: 'ledger', deviceId: `ledger:${config.appName}`, sign, + signClearMultiTransact, requestBatchApproval, getPublicKey, isConnected, diff --git a/src/core/connector/types.ts b/src/core/connector/types.ts index 4415b52..07d18c5 100644 --- a/src/core/connector/types.ts +++ b/src/core/connector/types.ts @@ -1,5 +1,7 @@ import type { ApduProfile } from '../transport/apdu-profile.js'; import type { Assert, Equals, Resolve } from '../internal/type-assert.js'; +import type { ClearSignTransactRequest, ClearSignOutputResult, ClearSignMultiTransactRequest } from '../transport/clear-sign-apdu.js'; +import type { ClearSignMultiTransactResult } from '../signers/railgun-signer.js'; /** * RAILGUN engine-facing connector types. @@ -38,17 +40,38 @@ export type RequestApprovalOptions = { readonly hash: bigint; }; +/** + * Result of a connector sign. It IS a `Signature` (R8/S) — callers that only need + * the signature are unaffected — with, when the clear-sign toggle was used, the + * device-computed message hash and per-output responses attached. + */ +export type HardwareConnectorSignResult = Signature & { + /** + * Present only when `clearSign` was passed: the device-computed `msgHash` and the + * per-output device responses (the caller splices these into the on-chain + * transact calldata). Absent for a normal (blind) sign. + */ + readonly clearSign?: { + readonly msgHash: Uint8Array; + readonly outputs: readonly ClearSignOutputResult[]; + }; +}; + /** * Sign function signature — matches engine's expected connector.sign() shape. * @param expectedHash - poseidon hash of the sign message (32 bytes as bigint) * @param publicInputs - optional public inputs for display/validation * @param subSession - optional sub-session ID for batch correlation + * @param clearSign - toggle: when provided, the device clear-signs the plaintext + * transact (reviewing recipients/tokens/amounts) and returns its outputs, instead + * of blind-signing `expectedHash`. Requires the `railgunClearSign` capability. */ export type HardwareConnectorSignFn = ( expectedHash: bigint, publicInputs?: PublicInputsRailgun, subSession?: string, -) => Promise; + clearSign?: ClearSignTransactRequest, +) => Promise; /** Connector config. */ export type LedgerConnectorConfig = { @@ -77,6 +100,15 @@ export type CommonConnectorBase = { /** Sign a poseidon hash, returning a BabyJubjub EdDSA signature. */ sign: HardwareConnectorSignFn; + /** + * Clear-sign a multi-tx transact (txToken != feeToken → one signature per tx). + * The single-tx case is the `clearSign` toggle on `sign`; this covers the + * two-signature case that doesn't fit a single-signature return. Experimental. + */ + signClearMultiTransact: ( + request: ClearSignMultiTransactRequest, + ) => Promise; + /** Get the BabyJubjub public key from the device. */ getPublicKey: () => Promise<{ readonly x: bigint; readonly y: bigint }>; @@ -111,6 +143,9 @@ type HardwareConnector_Reference = { readonly type: 'ledger'; readonly deviceId: string; sign: HardwareConnectorSignFn; + signClearMultiTransact: ( + request: ClearSignMultiTransactRequest, + ) => Promise; requestBatchApproval: ( requests: readonly RequestApprovalOptions[], ) => Promise; diff --git a/src/core/signers/railgun-signer.ts b/src/core/signers/railgun-signer.ts index 6944c7f..633371c 100644 --- a/src/core/signers/railgun-signer.ts +++ b/src/core/signers/railgun-signer.ts @@ -12,10 +12,22 @@ * connecting/disconnecting. */ -import type { HWTransport } from '../transport/types.js'; +import type { HWTransport, ApduCommand } from '../transport/types.js'; import type { Signature } from '../connector/types.js'; import type { ApduProfile, RailgunAppCapabilities } from '../transport/apdu-profile.js'; import { RAILGUN_PROFILE } from '../transport/apdu-profile.js'; +import { + buildClearSignInit, + buildClearSignNullifier, + buildClearSignBpFields, + buildClearSignOutput, + buildClearSignFinalize, + buildClearSignInitMultiTx, + validateClearSignShape, + type ClearSignTransactRequest, + type ClearSignMultiTransactRequest, + type ClearSignOutputResult, +} from '../transport/clear-sign-apdu.js'; import { buildGetPublicKey, buildSignHash, @@ -40,6 +52,9 @@ import { parseViewingKeyResponse, parseViewingPublicKeyResponse, parseRailgunAddressResponse, + parseClearSignFinalize, + parseClearSignFinalizeMulti, + parseClearSignOutputResponse, extractEchoedHash, } from '../../validation/apdu-response.js'; import { validateSignature } from '../../validation/signature.js'; @@ -143,6 +158,19 @@ export type EthereumTxHashSignOptions = { readonly allowBlind?: boolean; }; +/** Result of a CLEAR_SIGN transact: the EdDSA signature, the echoed message hash, and the raw per-output responses. */ +export type ClearSignTransactResult = { + readonly signature: Signature; + readonly msgHash: Uint8Array; + readonly outputs: readonly ClearSignOutputResult[]; +}; + +/** Result of a multi-tx CLEAR_SIGN transact: one signature per tx (same key), plus all output responses in order. */ +export type ClearSignMultiTransactResult = { + readonly signatures: ReadonlyArray<{ readonly signature: Signature; readonly msgHash: Uint8Array }>; + readonly outputs: readonly ClearSignOutputResult[]; +}; + /** * RAILGUN signer — sends custom APDU commands to the RAILGUN Ledger app. * @@ -239,6 +267,111 @@ export class RailgunSigner { return parseRailgunAddressResponse(response.data); } + private async sendClearSignStep(command: ApduCommand): Promise { + const response = await this.transport.send(command); + validateApduResponse(response); + } + + /** + * Clear-sign a RAILGUN transact (INS 0x11). Streams the session in order — + * CS_INIT → NULLIFIER×n → BP_FIELDS → OUT_*×m → FINALIZE — collecting each + * output's opaque device response, then parses the FINALIZE signature. The + * device shows the actual recipients/tokens/amounts and signs on approval. + * + * Returns the EdDSA signature, the echoed message hash, and the raw per-output + * responses (which the caller splices into the on-chain transact calldata). + * Experimental — see `CAPABILITY_STATUS.clearSign`. + */ + async signClearSignTransact(request: ClearSignTransactRequest): Promise { + this.requireCapability( + (capabilities) => capabilities.railgunClearSign, + 'RAILGUN app does not advertise CLEAR_SIGN transact support.', + ); + const nIn = request.nullifiers.length; + const nOut = request.outputs.length; + validateClearSignShape(nIn, nOut); + const account = request.account ?? this.account; + + // Pre-build the whole session first: every builder validates its field widths + // and ranges, so any illegal input throws BEFORE the first APDU is sent and + // never opens a session on the stateful device. + const initCommand = buildClearSignInit({ account, merkleRoot: request.merkleRoot, nIn, nOut }, this.profile); + const nullifierCommands = request.nullifiers.map((nullifier) => buildClearSignNullifier(nullifier, this.profile)); + const bpCommand = buildClearSignBpFields(request.boundParams, this.profile); + const outputPlan = request.outputs.map((output) => ({ kind: output.kind, ...buildClearSignOutput(output, this.profile) })); + const finalizeCommand = buildClearSignFinalize(this.profile); + + await this.sendClearSignStep(initCommand); + for (const command of nullifierCommands) { + await this.sendClearSignStep(command); + } + await this.sendClearSignStep(bpCommand); + + const outputs: ClearSignOutputResult[] = []; + for (const { kind, command, responseLength } of outputPlan) { + const response = await this.transport.send(command); + validateApduResponse(response); + outputs.push({ kind, response: parseClearSignOutputResponse(response.data, responseLength, `OUT_${kind}`) }); + } + + const finalizeResponse = await this.transport.send(finalizeCommand); + validateApduResponse(finalizeResponse); + const { signature, msgHash } = parseClearSignFinalize(finalizeResponse.data); + validateSignature(signature); + return { signature, msgHash, outputs }; + } + + /** + * Clear-sign a multi-tx transact (txToken ≠ feeToken). Sends the multi-tx + * CS_INIT, then streams each sub-transaction (NULLIFIER×n → BP_FIELDS → + * OUT_*×m) in order, and parses the combined FINALIZE into one signature per + * transaction (all under the same key). Experimental — see + * `CAPABILITY_STATUS.clearSign`. + */ + async signClearSignMultiTransact(request: ClearSignMultiTransactRequest): Promise { + this.requireCapability( + (capabilities) => capabilities.railgunClearSign, + 'RAILGUN app does not advertise CLEAR_SIGN transact support.', + ); + const txCount = request.transactions.length; + if (txCount < 2) { + throw new Error(`CLEAR_SIGN multi-tx requires at least 2 transactions, got ${String(txCount)}. Use signClearSignTransact for a single tx.`); + } + for (const tx of request.transactions) { + validateClearSignShape(tx.nullifiers.length, tx.outputs.length); + } + + // Pre-build the whole multi-tx session (the multi CS_INIT also enforces nTx <= 2 + // and each sub-tx's field widths) so any illegal input throws before any APDU. + const initCommand = buildClearSignInitMultiTx({ ...request, account: request.account ?? this.account }, this.profile); + const txPlans = request.transactions.map((tx) => ({ + nullifierCommands: tx.nullifiers.map((nullifier) => buildClearSignNullifier(nullifier, this.profile)), + bpCommand: buildClearSignBpFields(tx.boundParams, this.profile), + outputPlan: tx.outputs.map((output) => ({ kind: output.kind, ...buildClearSignOutput(output, this.profile) })), + })); + const finalizeCommand = buildClearSignFinalize(this.profile); + + await this.sendClearSignStep(initCommand); + const outputs: ClearSignOutputResult[] = []; + for (const plan of txPlans) { + for (const command of plan.nullifierCommands) { + await this.sendClearSignStep(command); + } + await this.sendClearSignStep(plan.bpCommand); + for (const { kind, command, responseLength } of plan.outputPlan) { + const response = await this.transport.send(command); + validateApduResponse(response); + outputs.push({ kind, response: parseClearSignOutputResponse(response.data, responseLength, `OUT_${kind}`) }); + } + } + + const finalizeResponse = await this.transport.send(finalizeCommand); + validateApduResponse(finalizeResponse); + const signatures = parseClearSignFinalizeMulti(finalizeResponse.data, txCount); + for (const { signature } of signatures) validateSignature(signature); + return { signatures, outputs }; + } + private async getEthereumPublicKeyAtPath(request: RailgunEthereumPreloadRequest, display: boolean): Promise { this.requireCapability( (capabilities) => capabilities.ethereumAddress, diff --git a/src/core/transport/apdu.ts b/src/core/transport/apdu.ts index b02c91e..64841fd 100644 --- a/src/core/transport/apdu.ts +++ b/src/core/transport/apdu.ts @@ -284,9 +284,10 @@ export function buildSignHash( * Device displays a confirmation prompt. * * P1 stays `0x00` here: unlike the *public* key commands (spending pubkey 0x01, - * viewing pubkey 0x10), the viewing-privkey export uses `P1 = 0x00` — the app - * already gates it behind an on-device confirmation. `P1 = 0x01` would return - * `SW_WRONG_P1P2`. + * viewing pubkey 0x10) which require `P1 = 0x01`, the viewing-privkey export is + * already gated behind its own on-device confirmation and does not vary on P1 — + * device testing shows INS 0x13 ignores P1 (returns the key for both 0x00 and + * 0x01). We send `0x00`. * @param account - Account index (default 0). * @param profile - APDU profile (default RAILGUN_PROFILE). */ diff --git a/src/core/transport/clear-sign-apdu.ts b/src/core/transport/clear-sign-apdu.ts index 285863c..a97d268 100644 --- a/src/core/transport/clear-sign-apdu.ts +++ b/src/core/transport/clear-sign-apdu.ts @@ -44,6 +44,15 @@ export const CLEAR_SIGN_MIN_GAS_PRICE_MAX = 1n << 48n; /** Transfer output type — 0 = Transfer. */ export const CLEAR_SIGN_OUTPUT_TYPE_TRANSFER = 0; +/** + * Per-output device response lengths, measured on firmware 1.6.1 clear-sign-v1. + * The device returns opaque ciphertext material the host later splices into the + * on-chain transact calldata; the orchestrator length-checks and collects it raw. + */ +export const CLEAR_SIGN_OUTPUT_TUPLE_RESPONSE_LENGTH = 223; // OUT_BROADCASTER / OUT_CHANGE +export const CLEAR_SIGN_TRANSFER_RESPONSE_LENGTH = 239; // OUT_TRANSFER (tuple + senderRandom + ann_iv) +export const CLEAR_SIGN_UNSHIELD_RESPONSE_LENGTH = 32; // OUT_UNSHIELD commitment + // ─── Encoding helpers ───────────────────────────────────────────────────────── function encodeUintBE(value: bigint, byteLength: number, label: string): Uint8Array { @@ -306,6 +315,157 @@ export function buildClearSignFinalize(profile: ApduProfile = RAILGUN_PROFILE): return { cla: profile.cla, ins: clearSignIns(profile), p1: ClearSignP1.FINALIZE, p2: 0, data: new Uint8Array([0]) }; } +// ─── Session orchestration surface ──────────────────────────────────────────── + +/** A transact output, tagged so the orchestrator can pick the right OUT_* sub-command. */ +export type ClearSignOutput = + | ({ readonly kind: 'broadcaster' } & ClearSignBroadcasterOutput) + | ({ readonly kind: 'change' } & ClearSignChangeOutput) + | ({ readonly kind: 'transfer' } & ClearSignTransferOutput) + | ({ readonly kind: 'unshield' } & ClearSignUnshieldOutput); + +/** + * Build the OUT_* APDU for a tagged output and report the exact device response + * length to expect, so the session orchestrator can length-check each reply. + */ +export function buildClearSignOutput( + output: ClearSignOutput, + profile: ApduProfile = RAILGUN_PROFILE, +): { readonly command: ApduCommand; readonly responseLength: number } { + switch (output.kind) { + case 'broadcaster': + return { command: buildClearSignOutBroadcaster(output, profile), responseLength: CLEAR_SIGN_OUTPUT_TUPLE_RESPONSE_LENGTH }; + case 'change': + return { command: buildClearSignOutChange(output, profile), responseLength: CLEAR_SIGN_OUTPUT_TUPLE_RESPONSE_LENGTH }; + case 'transfer': + return { command: buildClearSignOutTransfer(output, profile), responseLength: CLEAR_SIGN_TRANSFER_RESPONSE_LENGTH }; + case 'unshield': + return { command: buildClearSignOutUnshield(output, profile), responseLength: CLEAR_SIGN_UNSHIELD_RESPONSE_LENGTH }; + } +} + +/** A single-tx CLEAR_SIGN transact to sign (n inputs, m outputs). */ +export type ClearSignTransactRequest = { + readonly account?: number; + /** 32-byte merkle root. */ + readonly merkleRoot: Uint8Array; + /** One 32-byte nullifier per input (1..3). */ + readonly nullifiers: readonly Uint8Array[]; + /** Bound-params fields (tree, minGasPrice, unshield, chainID, adapt*). */ + readonly boundParams: ClearSignBpFieldsRequest; + /** Outputs (1..3), sent in array order. */ + readonly outputs: readonly ClearSignOutput[]; +}; + +/** The raw device response for one streamed output (opaque ciphertext material). */ +export type ClearSignOutputResult = { + readonly kind: ClearSignOutput['kind']; + readonly response: Uint8Array; +}; + +/** Structured decode of a broadcaster/change/transfer OUT_* response (the 208-byte tuple + trailers). */ +export type ClearSignDecodedTuple = { + readonly random: Uint8Array; // 16 + /** Blind1 — sender blinding key (32). */ + readonly senderBlindingKey: Uint8Array; + /** Blind2 — recipient blinding key (32). */ + readonly recipientBlindingKey: Uint8Array; + readonly iv: Uint8Array; // 16 + readonly tag: Uint8Array; // 16 + readonly ciphertext: Uint8Array; // 96 + readonly senderRandom: Uint8Array; // 15 + /** Present only for OUT_TRANSFER (16). */ + readonly annotationIv?: Uint8Array; +}; + +/** A decoded OUT_* response: a note tuple, or an unshield commitment. */ +export type ClearSignDecodedOutput = + | ({ readonly kind: 'broadcaster' | 'change' | 'transfer' } & ClearSignDecodedTuple) + | { readonly kind: 'unshield'; readonly commitment: Uint8Array }; + +/** + * Decode a raw OUT_* device response into its structured fields, using the byte + * layout the firmware author's reference (`clear-sign-apdus.js`) documents: + * tuple(208) = random(16) ‖ Blind1(32) ‖ Blind2(32) ‖ IV(16) ‖ tag(16) ‖ ciphertext(96) + * + senderRandom(15) [+ annotationIv(16) for transfer]; unshield = commitment(32). + * These fields are what the RAILGUN engine assembles into the on-chain transact + * calldata (that assembly is protocol/ABI-specific and lives in the engine). + */ +export function decodeClearSignOutput(result: ClearSignOutputResult): ClearSignDecodedOutput { + const { kind, response } = result; + if (kind === 'unshield') { + assertBytes(response, CLEAR_SIGN_UNSHIELD_RESPONSE_LENGTH, 'OUT_UNSHIELD response'); + return { kind, commitment: response.slice() }; + } + const expected = kind === 'transfer' + ? CLEAR_SIGN_TRANSFER_RESPONSE_LENGTH + : CLEAR_SIGN_OUTPUT_TUPLE_RESPONSE_LENGTH; + assertBytes(response, expected, `OUT_${kind} response`); + const tuple = { + kind, + random: response.slice(0, 16), + senderBlindingKey: response.slice(16, 48), + recipientBlindingKey: response.slice(48, 80), + iv: response.slice(80, 96), + tag: response.slice(96, 112), + ciphertext: response.slice(112, 208), + senderRandom: response.slice(208, 223), + } as const; + return kind === 'transfer' ? { ...tuple, annotationIv: response.slice(223, 239) } : tuple; +} + +// ─── Multi-tx (txToken ≠ feeToken) ──────────────────────────────────────────── + +/** One sub-transaction within a multi-tx CLEAR_SIGN session. */ +export type ClearSignSubTransact = { + readonly merkleRoot: Uint8Array; + readonly nullifiers: readonly Uint8Array[]; + readonly boundParams: ClearSignBpFieldsRequest; + readonly outputs: readonly ClearSignOutput[]; +}; + +/** + * A multi-tx CLEAR_SIGN transact — the txToken ≠ feeToken case bundles two + * sub-transactions (value transfer in token A + broadcaster fee in token B), + * each with its own nullifiers / bound-params / outputs, signed together. + */ +export type ClearSignMultiTransactRequest = { + readonly account?: number; + /** 15-byte wallet-source tag; defaults to all-zero. */ + readonly walletSource?: Uint8Array; + /** The sub-transactions (nTx ≥ 2 for the multi flow). */ + readonly transactions: readonly ClearSignSubTransact[]; +}; + +/** + * CS_INIT multi-tx (P1 0x00): + * account(4) ‖ nTx(1) ‖ walletSource(15) ‖ [merkleRoot(32) ‖ nIn(1) ‖ nOut(1)] × nTx. + */ +export function buildClearSignInitMultiTx( + request: ClearSignMultiTransactRequest, + profile: ApduProfile = RAILGUN_PROFILE, +): ApduCommand { + const nTx = request.transactions.length; + // Device caps a multi-tx session at CS_MAX_TXS = 2 (txToken != feeToken). + if (!Number.isInteger(nTx) || nTx < 1 || nTx > 2) { + throw new Error(`CLEAR_SIGN multi-tx supports 1..2 transactions, got ${String(nTx)}`); + } + const walletSource = request.walletSource ?? new Uint8Array(15); + assertBytes(walletSource, 15, 'CLEAR_SIGN walletSource'); + const perTx = request.transactions.map((tx) => { + assertBytes(tx.merkleRoot, 32, 'CLEAR_SIGN merkleRoot'); + validateClearSignShape(tx.nullifiers.length, tx.outputs.length); + return concatBytes(tx.merkleRoot, new Uint8Array([tx.nullifiers.length, tx.outputs.length])); + }); + const data = concatBytes( + encodeAccountIndex(request.account ?? 0), + new Uint8Array([nTx]), + walletSource, + ...perTx, + ); + return { cla: profile.cla, ins: clearSignIns(profile), p1: ClearSignP1.INIT, p2: 0, data }; +} + function encodeAscii127(value: string): Uint8Array { const out = new Uint8Array(127); if (value.length !== 127) { diff --git a/src/core/transport/status-words.ts b/src/core/transport/status-words.ts index 9d69aca..4356ede 100644 --- a/src/core/transport/status-words.ts +++ b/src/core/transport/status-words.ts @@ -21,6 +21,12 @@ export function statusWordToHWError(statusWord: number): HWError { return new HWError(HWErrorCode.APDU_STATUS_ERROR, 'Device is locked. Unlock and retry.'); case StatusWord.APP_NOT_OPEN: return new HWError(HWErrorCode.APDU_STATUS_ERROR, 'Required app is not open on the device.'); + case StatusWord.RAILGUN_CLEAR_SIGN_STATE: + // Best-effort — exact meaning unconfirmed with the firmware author. + return new HWError( + HWErrorCode.APDU_STATUS_ERROR, + 'RAILGUN CLEAR_SIGN session/state error (SW 0xb007) — a sub-command was likely sent out of order or without an active session.', + ); default: return new HWError( HWErrorCode.APDU_STATUS_ERROR, diff --git a/src/core/transport/types.ts b/src/core/transport/types.ts index 7858231..241a598 100644 --- a/src/core/transport/types.ts +++ b/src/core/transport/types.ts @@ -41,6 +41,12 @@ export const StatusWord = { LOCKED_DEVICE: 0x5515, /** BOLOS: returned by OPEN_APP when the requested application is not installed. */ APP_NOT_FOUND: 0x6807, + /** + * RAILGUN-app-specific: observed on CLEAR_SIGN sub-commands sent out of order or + * without an active session (device probe, 2026-07-24). Best-effort — the exact + * meaning is not documented; confirm with the firmware author. + */ + RAILGUN_CLEAR_SIGN_STATE: 0xb007, } as const; export type StatusWord = (typeof StatusWord)[keyof typeof StatusWord]; diff --git a/src/index.ts b/src/index.ts index e0db5c2..6e7e0cf 100644 --- a/src/index.ts +++ b/src/index.ts @@ -8,6 +8,7 @@ export type { HardwareConnector, HardwareConnectorSignFn, + HardwareConnectorSignResult, LedgerConnectorConfig, Signature, PublicInputsRailgun, @@ -165,13 +166,19 @@ export { export type { RailgunEthereumPathRequest, EthereumSignatureParts } from './core/transport/apdu.js'; // ─── CLEAR_SIGN transact protocol (INS 0x11) — EXPERIMENTAL (firmware 1.6.1) ── -// Pure APDU builders + shape validator for the stateful clear-sign session. -// Session orchestration and engine wiring are not yet wired up; see CAPABILITY_STATUS.clearSign. +// CLEAR_SIGN transact signing: pure builders + shape validator + the +// RailgunSigner / LedgerController orchestrators (single-tx + dual-tx). The +// device-generated output responses are returned raw; splicing them into the +// on-chain transact calldata (full RAILGUN-engine integration) is not included. +// See CAPABILITY_STATUS.clearSign. export { ClearSignP1, CLEAR_SIGN_MAX_MEMO_LEN, CLEAR_SIGN_MIN_GAS_PRICE_MAX, CLEAR_SIGN_OUTPUT_TYPE_TRANSFER, + CLEAR_SIGN_OUTPUT_TUPLE_RESPONSE_LENGTH, + CLEAR_SIGN_TRANSFER_RESPONSE_LENGTH, + CLEAR_SIGN_UNSHIELD_RESPONSE_LENGTH, encodeErc20TokenHash, validateClearSignShape, buildClearSignInit, @@ -181,7 +188,10 @@ export { buildClearSignOutChange, buildClearSignOutTransfer, buildClearSignOutUnshield, + buildClearSignOutput, buildClearSignFinalize, + buildClearSignInitMultiTx, + decodeClearSignOutput, } from './core/transport/clear-sign-apdu.js'; export type { ClearSignInitRequest, @@ -190,6 +200,13 @@ export type { ClearSignChangeOutput, ClearSignTransferOutput, ClearSignUnshieldOutput, + ClearSignOutput, + ClearSignTransactRequest, + ClearSignOutputResult, + ClearSignSubTransact, + ClearSignMultiTransactRequest, + ClearSignDecodedTuple, + ClearSignDecodedOutput, } from './core/transport/clear-sign-apdu.js'; // ─── Device registry ───────────────────────────────────────────────────────── @@ -217,6 +234,8 @@ export { parseViewingPublicKeyResponse, parseRailgunAddressResponse, parseClearSignFinalize, + parseClearSignFinalizeMulti, + parseClearSignOutputResponse, extractEchoedHash, } from './validation/apdu-response.js'; @@ -339,6 +358,8 @@ export type { RailgunEthereumSignerSession, RailgunEthereumAddressResult, RailgunSignerConfig, + ClearSignTransactResult, + ClearSignMultiTransactResult, } from './core/signers/railgun-signer.js'; export { EthSigner, diff --git a/src/sdk/controller/ledger-controller.ts b/src/sdk/controller/ledger-controller.ts index 2f067b0..c1c7e45 100644 --- a/src/sdk/controller/ledger-controller.ts +++ b/src/sdk/controller/ledger-controller.ts @@ -28,7 +28,13 @@ import { classifyDeviceError } from '../../core/transport/status-words.js'; import type { RailgunEthereumPreloadRequest, RailgunEthereumSignerSession, + ClearSignTransactResult, + ClearSignMultiTransactResult, } from '../../core/signers/railgun-signer.js'; +import type { + ClearSignTransactRequest, + ClearSignMultiTransactRequest, +} from '../../core/transport/clear-sign-apdu.js'; import type { ActiveAppInfo, AppRequirement, @@ -1299,6 +1305,30 @@ export function createLedgerController( } }), + signClearSignTransact: (request: ClearSignTransactRequest): Promise => + enqueue(async () => { + ensureNotDisposed(); + if (connector === null) { + await ensureReadyInternal(); + } + if (transport === null) { + throw new HWError(HWErrorCode.TRANSPORT_DISCONNECTED, 'Transport not connected.'); + } + return new RailgunSigner({ transport }).signClearSignTransact(request); + }), + + signClearSignMultiTransact: (request: ClearSignMultiTransactRequest): Promise => + enqueue(async () => { + ensureNotDisposed(); + if (connector === null) { + await ensureReadyInternal(); + } + if (transport === null) { + throw new HWError(HWErrorCode.TRANSPORT_DISCONNECTED, 'Transport not connected.'); + } + return new RailgunSigner({ transport }).signClearSignMultiTransact(request); + }), + sign: ( expectedHash: bigint, publicInputs?: PublicInputsRailgun, diff --git a/src/sdk/controller/types.ts b/src/sdk/controller/types.ts index 8e24267..2b3e4b4 100644 --- a/src/sdk/controller/types.ts +++ b/src/sdk/controller/types.ts @@ -12,7 +12,13 @@ import type { EthereumSignatureParts } from '../../core/transport/apdu.js'; import type { RailgunEthereumPreloadRequest, RailgunEthereumSignerSession, + ClearSignTransactResult, + ClearSignMultiTransactResult, } from '../../core/signers/railgun-signer.js'; +import type { + ClearSignTransactRequest, + ClearSignMultiTransactRequest, +} from '../../core/transport/clear-sign-apdu.js'; import type { RailgunWalletArtifacts } from '../../core/wallet-artifacts.js'; import type { AppRequirement } from '../../core/device/types.js'; import type { HWError } from '../../core/errors.js'; @@ -96,6 +102,14 @@ export interface LedgerController { readonly nonce: bigint; }, ): Promise; + /** Clear-sign a RAILGUN transact (INS 0x11) — the device reviews the recipients/tokens/amounts. Experimental. */ + signClearSignTransact( + request: ClearSignTransactRequest, + ): Promise; + /** Clear-sign a multi-tx transact (txToken != feeToken → one signature per tx). Experimental. */ + signClearSignMultiTransact( + request: ClearSignMultiTransactRequest, + ): Promise; sign( expectedHash: bigint, publicInputs?: PublicInputsRailgun, diff --git a/src/sdk/engine/create-engine-ledger-connector.ts b/src/sdk/engine/create-engine-ledger-connector.ts index a08ea83..ff3b9e0 100644 --- a/src/sdk/engine/create-engine-ledger-connector.ts +++ b/src/sdk/engine/create-engine-ledger-connector.ts @@ -1,4 +1,4 @@ -import type { RequestApprovalOptions, PublicInputsRailgun, Signature } from '../../core/connector/types.js'; +import type { RequestApprovalOptions, PublicInputsRailgun, HardwareConnectorSignResult } from '../../core/connector/types.js'; import { HWError, HWErrorCode } from '../../core/errors.js'; import type { LedgerController } from '../controller/types.js'; import type { @@ -31,10 +31,21 @@ function createEngineConnectorMethods( controller: LedgerController, ): Omit { return { - sign: (expectedHash, publicInputs, subSession): Promise => { + sign: (expectedHash, publicInputs, subSession, clearSign): Promise => { requirePublicInputs(publicInputs); + // Toggle: when a plaintext transact is supplied, clear-sign it (device reviews + // recipients/tokens/amounts) and return its outputs; otherwise blind-sign. + if (clearSign !== undefined) { + return controller + .signClearSignTransact(clearSign) + .then((result) => ({ + ...result.signature, + clearSign: { msgHash: result.msgHash, outputs: result.outputs }, + })); + } return controller.sign(expectedHash, publicInputs, subSession); }, + signClearMultiTransact: (request) => controller.signClearSignMultiTransact(request), hwSignShield: (derivationIndex) => controller.hwSignShield(derivationIndex), ...(controller.signShieldOwnershipMarker === undefined diff --git a/src/sdk/engine/types.ts b/src/sdk/engine/types.ts index 772f8ff..c99aa4b 100644 --- a/src/sdk/engine/types.ts +++ b/src/sdk/engine/types.ts @@ -7,6 +7,8 @@ import type { Assert, Equals, Resolve } from '../../core/internal/type-assert.js import type { EthSignResult } from '../../core/signers/types.js'; import type { HwSignShieldResult, ShieldOwnershipMarkerResult } from '../../core/signers/eth-signer.js'; import type { LedgerBatchApprovalSession } from '../controller/types.js'; +import type { ClearSignMultiTransactRequest } from '../../core/transport/clear-sign-apdu.js'; +import type { ClearSignMultiTransactResult } from '../../core/signers/railgun-signer.js'; export type EngineLedgerSignFn = HardwareConnectorSignFn; @@ -47,6 +49,9 @@ type EngineLedgerConnector_Reference = { readonly type: 'ledger'; readonly deviceId: string; sign: EngineLedgerSignFn; + signClearMultiTransact: ( + request: ClearSignMultiTransactRequest, + ) => Promise; hwSignShield: (derivationIndex: number) => Promise; signShieldOwnershipMarker?: ( derivationIndex: number, @@ -70,6 +75,9 @@ type LegacyEngineLedgerConnector_Reference = { readonly type: 'ledger'; readonly deviceId: string; sign: EngineLedgerSignFn; + signClearMultiTransact: ( + request: ClearSignMultiTransactRequest, + ) => Promise; hwSignShield: (derivationIndex: number) => Promise; signShieldOwnershipMarker?: ( derivationIndex: number, diff --git a/src/validation/apdu-response.ts b/src/validation/apdu-response.ts index fd5885c..d9a43f2 100644 --- a/src/validation/apdu-response.ts +++ b/src/validation/apdu-response.ts @@ -186,6 +186,57 @@ export function parseClearSignFinalize( return { signature, msgHash }; } +/** + * Parse a multi-tx CLEAR_SIGN FINALIZE response (txToken ≠ feeToken). + * + * Layout: `txCount` × 128-byte quads `R8.x(32) ‖ R8.y(32) ‖ S(32) ‖ msgHash(32)` + * — no `0x60` length prefix (unlike the single-tx 129-byte form). Signatures are + * returned positionally (index i → transaction i), all under the same key. + * Device-verified: two txs → 256 bytes. + */ +export function parseClearSignFinalizeMulti( + data: Uint8Array, + txCount: number, +): ReadonlyArray<{ readonly signature: Signature; readonly msgHash: Uint8Array }> { + const expected = txCount * 128; + if (data.length !== expected) { + throw new HWError( + HWErrorCode.SIGN_INVALID_RESPONSE, + `Expected ${String(expected)} bytes for CLEAR_SIGN multi-tx FINALIZE (${String(txCount)} txs), got ${String(data.length)}`, + ); + } + const results: Array<{ readonly signature: Signature; readonly msgHash: Uint8Array }> = []; + for (let i = 0; i < txCount; i++) { + const quad = data.subarray(i * 128, i * 128 + 128); + const signature = parseSignResponse(quad, false); // 128B = sig(96) + msgHash(32), no prefix + const msgHash = extractEchoedHash(quad, false); + if (msgHash === null) { + throw new HWError(HWErrorCode.SIGN_INVALID_RESPONSE, `CLEAR_SIGN multi-tx FINALIZE quad ${String(i)} is missing its message hash`); + } + results.push({ signature, msgHash }); + } + return results; +} + +/** + * Length-check a streamed CLEAR_SIGN OUT_* response and return a copy of the raw + * bytes — opaque ciphertext material the host later splices into the on-chain + * transact calldata. Device response lengths are fixed per output kind. + */ +export function parseClearSignOutputResponse( + data: Uint8Array, + expectedLength: number, + label: string, +): Uint8Array { + if (data.length !== expectedLength) { + throw new HWError( + HWErrorCode.APDU_INVALID_RESPONSE, + `Expected ${String(expectedLength)} bytes for CLEAR_SIGN ${label} response, got ${String(data.length)}`, + ); + } + return data.slice(); +} + /** * Convert a big-endian Uint8Array to bigint. */ diff --git a/test/unit/clear-sign-apdus.test.ts b/test/unit/clear-sign-apdus.test.ts index bdf7bcc..73cba65 100644 --- a/test/unit/clear-sign-apdus.test.ts +++ b/test/unit/clear-sign-apdus.test.ts @@ -20,6 +20,7 @@ import { buildClearSignOutTransfer, buildClearSignOutUnshield, buildClearSignFinalize, + decodeClearSignOutput, } from '../../src/core/transport/clear-sign-apdu.js'; import { parseClearSignFinalize } from '../../src/validation/apdu-response.js'; import { serializeApdu } from '../../src/core/transport/apdu-wire.js'; @@ -224,3 +225,48 @@ describe('parseClearSignFinalize', () => { expect(() => parseClearSignFinalize(bad)).toThrow(/0x60/); }); }); + +describe('decodeClearSignOutput', () => { + const fill = (n: number, v: number): Uint8Array => new Uint8Array(n).fill(v); + const concat = (...parts: Uint8Array[]): Uint8Array => { + const out = new Uint8Array(parts.reduce((s, p) => s + p.length, 0)); + let off = 0; + for (const p of parts) { out.set(p, off); off += p.length; } + return out; + }; + // 208-byte tuple: random(16) Blind1(32) Blind2(32) IV(16) tag(16) ciphertext(96) + const tuple = concat(fill(16, 0x01), fill(32, 0x02), fill(32, 0x03), fill(16, 0x04), fill(16, 0x05), fill(96, 0x06)); + + it('decodes a broadcaster/change tuple (223B) at the reference offsets', () => { + const d = decodeClearSignOutput({ kind: 'broadcaster', response: concat(tuple, fill(15, 0x07)) }); + expect(d.kind).toBe('broadcaster'); + if (d.kind === 'unshield') throw new Error('unexpected'); + expect(d.random).toEqual(fill(16, 0x01)); + expect(d.senderBlindingKey).toEqual(fill(32, 0x02)); + expect(d.recipientBlindingKey).toEqual(fill(32, 0x03)); + expect(d.iv).toEqual(fill(16, 0x04)); + expect(d.tag).toEqual(fill(16, 0x05)); + expect(d.ciphertext).toEqual(fill(96, 0x06)); + expect(d.senderRandom).toEqual(fill(15, 0x07)); + expect(d.annotationIv).toBeUndefined(); + }); + + it('decodes a transfer tuple (239B) with the annotation IV', () => { + const d = decodeClearSignOutput({ kind: 'transfer', response: concat(tuple, fill(15, 0x07), fill(16, 0x08)) }); + if (d.kind === 'unshield') throw new Error('unexpected'); + expect(d.senderRandom).toEqual(fill(15, 0x07)); + expect(d.annotationIv).toEqual(fill(16, 0x08)); + }); + + it('decodes an unshield commitment (32B)', () => { + const d = decodeClearSignOutput({ kind: 'unshield', response: fill(32, 0x09) }); + expect(d.kind).toBe('unshield'); + if (d.kind !== 'unshield') throw new Error('unexpected'); + expect(d.commitment).toEqual(fill(32, 0x09)); + }); + + it('rejects a wrong-length response', () => { + expect(() => decodeClearSignOutput({ kind: 'broadcaster', response: fill(222, 0) })).toThrow(); + expect(() => decodeClearSignOutput({ kind: 'unshield', response: fill(33, 0) })).toThrow(); + }); +}); diff --git a/test/unit/clear-sign-dual.test.ts b/test/unit/clear-sign-dual.test.ts new file mode 100644 index 0000000..e14c716 --- /dev/null +++ b/test/unit/clear-sign-dual.test.ts @@ -0,0 +1,140 @@ +/** + * Dual-tx CLEAR_SIGN (txToken ≠ feeToken) — multi-tx CS_INIT, the 256-byte + * FINALIZE split, and RailgunSigner.signClearSignMultiTransact. Vectors match the + * on-device probe: multi CS_INIT = 88B, dual FINALIZE = 256B (two quads). + */ + +import { describe, expect, it } from 'vitest'; +import { + buildClearSignInitMultiTx, + encodeErc20TokenHash, +} from '../../src/core/transport/clear-sign-apdu.js'; +import { parseClearSignFinalizeMulti } from '../../src/validation/apdu-response.js'; +import { serializeApdu } from '../../src/core/transport/apdu-wire.js'; +import { RailgunSigner } from '../../src/core/signers/railgun-signer.js'; +import { StatusWord } from '../../src/core/transport/types.js'; +import { HWError } from '../../src/core/errors.js'; +import { MockTransport } from '../integration/mock-transport.js'; + +function hex(b: Uint8Array): string { + return Array.from(b).map((x) => x.toString(16).padStart(2, '0')).join(''); +} +const DAI_HASH = encodeErc20TokenHash(Uint8Array.from(Buffer.from('6b175474e89094c44da98b954eedeac495271d0f', 'hex'))); +const ok = (data: Uint8Array): { data: Uint8Array; statusWord: number } => ({ data, statusWord: StatusWord.SUCCESS }); + +/** 256-byte dual FINALIZE: two 128B quads R8x(0) || R8y(1) || S(7) || msgHash. */ +function finalize256(): Uint8Array { + const f = new Uint8Array(256); + f[63] = 0x01; f[95] = 0x07; f.set(new Uint8Array(32).fill(0xc0), 96); // quad 0 + f[191] = 0x01; f[223] = 0x07; f.set(new Uint8Array(32).fill(0xc1), 224); // quad 1 + return f; +} + +describe('CLEAR_SIGN dual-tx', () => { + it('buildClearSignInitMultiTx matches the device 88B nTx=2 vector', () => { + const cmd = buildClearSignInitMultiTx({ + transactions: [ + { merkleRoot: new Uint8Array(32).fill(0x11), nullifiers: [new Uint8Array(32)], boundParams: { treeNumber: 0, minGasPrice: 0n, unshield: false, chainId: 1n }, outputs: [{ kind: 'change', tokenHash: DAI_HASH, value: 1n }, { kind: 'change', tokenHash: DAI_HASH, value: 1n }] }, + { merkleRoot: new Uint8Array(32).fill(0x22), nullifiers: [new Uint8Array(32)], boundParams: { treeNumber: 0, minGasPrice: 0n, unshield: false, chainId: 1n }, outputs: [{ kind: 'change', tokenHash: DAI_HASH, value: 1n }, { kind: 'change', tokenHash: DAI_HASH, value: 1n }] }, + ], + }); + expect(hex(serializeApdu(cmd))).toBe( + 'e011000058' + '00000000' + '02' + '00'.repeat(15) + + '11'.repeat(32) + '01' + '02' + + '22'.repeat(32) + '01' + '02', + ); + }); + + describe('parseClearSignFinalizeMulti', () => { + it('splits 256B into two positional signatures', () => { + const sigs = parseClearSignFinalizeMulti(finalize256(), 2); + expect(sigs).toHaveLength(2); + expect(sigs[0]?.signature.R8[1]).toBe(1n); + expect(sigs[0]?.signature.S).toBe(7n); + expect(sigs[0]?.msgHash).toEqual(new Uint8Array(32).fill(0xc0)); + expect(sigs[1]?.msgHash).toEqual(new Uint8Array(32).fill(0xc1)); + }); + + it('rejects a wrong length', () => { + expect(() => parseClearSignFinalizeMulti(new Uint8Array(192), 2)).toThrow(HWError); + expect(() => parseClearSignFinalizeMulti(new Uint8Array(256), 3)).toThrow(HWError); + }); + }); + + it('signClearSignMultiTransact streams both txs in order and returns 2 signatures', async () => { + const transport = new MockTransport(); + await transport.connect(); + transport.enqueueResponses([ + ok(new Uint8Array(0)), // CS_INIT (multi) + ok(new Uint8Array(0)), // tx0 NULLIFIER + ok(new Uint8Array(0)), // tx0 BP_FIELDS + ok(new Uint8Array(223).fill(0x01)), // tx0 OUT_CHANGE + ok(new Uint8Array(239).fill(0x02)), // tx0 OUT_TRANSFER + ok(new Uint8Array(0)), // tx1 NULLIFIER + ok(new Uint8Array(0)), // tx1 BP_FIELDS + ok(new Uint8Array(223).fill(0x03)), // tx1 OUT_CHANGE + ok(new Uint8Array(223).fill(0x04)), // tx1 OUT_BROADCASTER + ok(finalize256()), // FINALIZE + ]); + + const signer = new RailgunSigner({ transport }); + const bp = { treeNumber: 0, minGasPrice: 0n, unshield: false, chainId: 1n } as const; + const result = await signer.signClearSignMultiTransact({ + transactions: [ + { merkleRoot: new Uint8Array(32).fill(0x11), nullifiers: [new Uint8Array(32).fill(0x33)], boundParams: bp, outputs: [ + { kind: 'change', tokenHash: DAI_HASH, value: 5n }, + { kind: 'transfer', recipient0zk: `0zk1${'q'.repeat(123)}`, tokenHash: DAI_HASH, value: 7n }, + ] }, + { merkleRoot: new Uint8Array(32).fill(0x22), nullifiers: [new Uint8Array(32).fill(0x44)], boundParams: bp, outputs: [ + { kind: 'change', tokenHash: DAI_HASH, value: 9n }, + { kind: 'broadcaster', recipientMasterPublicKey: new Uint8Array(32).fill(0xaa), recipientViewingPublicKey: new Uint8Array(32).fill(0xbb), tokenHash: DAI_HASH, value: 3n }, + ] }, + ], + }); + + expect(transport.sentCommands.map((c) => c.p1)).toEqual([ + 0x00, 0x20, 0x10, 0x31, 0x32, 0x20, 0x10, 0x31, 0x30, 0x40, + ]); + expect(result.signatures).toHaveLength(2); + expect(result.signatures.map((s) => s.signature.S)).toEqual([7n, 7n]); + expect(result.outputs.map((o) => [o.kind, o.response.length])).toEqual([ + ['change', 223], ['transfer', 239], ['change', 223], ['broadcaster', 223], + ]); + }); + + it('rejects a single-transaction request (use signClearSignTransact)', async () => { + const transport = new MockTransport(); + await transport.connect(); + const signer = new RailgunSigner({ transport }); + await expect(signer.signClearSignMultiTransact({ + transactions: [{ merkleRoot: new Uint8Array(32), nullifiers: [new Uint8Array(32)], boundParams: { treeNumber: 0, minGasPrice: 0n, unshield: false, chainId: 1n }, outputs: [{ kind: 'change', tokenHash: DAI_HASH, value: 1n }] }], + })).rejects.toThrow(/at least 2/); + expect(transport.sentCommands).toHaveLength(0); + }); + + const bp = { treeNumber: 0, minGasPrice: 0n, unshield: false, chainId: 1n } as const; + const change = { kind: 'change', tokenHash: DAI_HASH, value: 1n } as const; + + it('rejects an illegal shape in a later sub-tx before any APDU', async () => { + const transport = new MockTransport(); + await transport.connect(); + const signer = new RailgunSigner({ transport }); + await expect(signer.signClearSignMultiTransact({ + transactions: [ + { merkleRoot: new Uint8Array(32).fill(0x11), nullifiers: [new Uint8Array(32)], boundParams: bp, outputs: [change, change] }, + // second tx: n=3, m=3 → n+m=6 > 5 + { merkleRoot: new Uint8Array(32).fill(0x22), nullifiers: [new Uint8Array(32), new Uint8Array(32), new Uint8Array(32)], boundParams: bp, outputs: [change, change, change] }, + ], + })).rejects.toThrow(/n\+m/); + expect(transport.sentCommands).toHaveLength(0); + }); + + it('rejects more than 2 transactions (device CS_MAX_TXS = 2) before any APDU', async () => { + const transport = new MockTransport(); + await transport.connect(); + const signer = new RailgunSigner({ transport }); + const tx = { merkleRoot: new Uint8Array(32), nullifiers: [new Uint8Array(32)], boundParams: bp, outputs: [change] } as const; + await expect(signer.signClearSignMultiTransact({ transactions: [tx, tx, tx] })).rejects.toThrow(/1\.\.2/); + expect(transport.sentCommands).toHaveLength(0); + }); +}); diff --git a/test/unit/clear-sign-session.test.ts b/test/unit/clear-sign-session.test.ts new file mode 100644 index 0000000..5e07351 --- /dev/null +++ b/test/unit/clear-sign-session.test.ts @@ -0,0 +1,174 @@ +/** + * RailgunSigner.signClearSignTransact — the CLEAR_SIGN session orchestrator. + * + * Verifies the emitted APDU sequence (CS_INIT → NULLIFIER×n → BP_FIELDS → + * OUT_*×m → FINALIZE) against the device-verified golden vectors, and that the + * FINALIZE signature + per-output responses are collected. Response sizes match + * the on-device probe (OUT_BROADCASTER/CHANGE 223B, OUT_TRANSFER 239B, + * OUT_UNSHIELD 32B, FINALIZE 129B). + */ + +import { describe, expect, it } from 'vitest'; +import { RailgunSigner } from '../../src/core/signers/railgun-signer.js'; +import { encodeErc20TokenHash } from '../../src/core/transport/clear-sign-apdu.js'; +import { serializeApdu } from '../../src/core/transport/apdu-wire.js'; +import { StatusWord } from '../../src/core/transport/types.js'; +import { HWErrorCode } from '../../src/core/errors.js'; +import { MockTransport } from '../integration/mock-transport.js'; + +function hex(b: Uint8Array): string { + return Array.from(b).map((x) => x.toString(16).padStart(2, '0')).join(''); +} +function bytes(h: string): Uint8Array { + const c = h.replace(/\s/g, ''); + const o = new Uint8Array(c.length / 2); + for (let i = 0; i < o.length; i++) o[i] = parseInt(c.slice(i * 2, i * 2 + 2), 16); + return o; +} +const VITALIK = bytes('d8da6bf26964af9d7eed9e03e53415d37aa96045'); +const DAI_HASH = encodeErc20TokenHash(bytes('6b175474e89094c44da98b954eedeac495271d0f')); + +/** 129-byte FINALIZE: 0x60 || R8x(0) || R8y(1) || S(7) || msgHash(0xcd…). */ +function finalize129(): Uint8Array { + const f = new Uint8Array(129); + f[0] = 0x60; + f[64] = 0x01; // R8.y = 1 + f[96] = 0x07; // S = 7 + f.set(new Uint8Array(32).fill(0xcd), 97); + return f; +} +const ok = (data: Uint8Array): { data: Uint8Array; statusWord: number } => ({ data, statusWord: StatusWord.SUCCESS }); + +describe('RailgunSigner.signClearSignTransact', () => { + it('streams the README 1×1 unshield session and parses FINALIZE', async () => { + const transport = new MockTransport(); + await transport.connect(); + transport.enqueueResponses([ + ok(new Uint8Array(0)), // CS_INIT + ok(new Uint8Array(0)), // NULLIFIER + ok(new Uint8Array(0)), // BP_FIELDS + ok(new Uint8Array(32).fill(0xab)), // OUT_UNSHIELD commitment (32B) + ok(finalize129()), // FINALIZE + ]); + + const signer = new RailgunSigner({ transport }); + const result = await signer.signClearSignTransact({ + merkleRoot: new Uint8Array(32).fill(0x11), + nullifiers: [new Uint8Array(32).fill(0x22)], + boundParams: { treeNumber: 0, minGasPrice: 1n, unshield: true, chainId: 1n }, + outputs: [{ kind: 'unshield', recipientAddress: VITALIK, tokenHash: DAI_HASH, value: 0x40000n }], + }); + + expect(transport.sentCommands.map((c) => hex(serializeApdu(c)))).toEqual([ + 'e0110000260000000011111111111111111111111111111111111111111111111111111111111111110101', + 'e0112000202222222222222222222222222222222222222222222222222222222222222222', + 'e011100045' + '0000' + '000000000001' + '01' + '0000000000000001' + '00'.repeat(20) + '00'.repeat(32), + 'e011330054' + 'd8da6bf26964af9d7eed9e03e53415d37aa96045' + + '000000000000000000000000' + '6b175474e89094c44da98b954eedeac495271d0f' + '0'.repeat(58) + '040000', + 'e01140000100', + ]); + expect(result.signature.R8[1]).toBe(1n); + expect(result.signature.S).toBe(7n); + expect(result.msgHash).toEqual(new Uint8Array(32).fill(0xcd)); + expect(result.outputs).toEqual([{ kind: 'unshield', response: new Uint8Array(32).fill(0xab) }]); + }); + + it('streams a 2×3 transfer session (broadcaster + change + transfer) in order', async () => { + const transport = new MockTransport(); + await transport.connect(); + transport.enqueueResponses([ + ok(new Uint8Array(0)), // CS_INIT + ok(new Uint8Array(0)), // NULLIFIER 0 + ok(new Uint8Array(0)), // NULLIFIER 1 + ok(new Uint8Array(0)), // BP_FIELDS + ok(new Uint8Array(223).fill(0x01)), // OUT_BROADCASTER (223B) + ok(new Uint8Array(223).fill(0x02)), // OUT_CHANGE (223B) + ok(new Uint8Array(239).fill(0x03)), // OUT_TRANSFER (239B) + ok(finalize129()), // FINALIZE + ]); + + const signer = new RailgunSigner({ transport }); + const result = await signer.signClearSignTransact({ + merkleRoot: new Uint8Array(32).fill(0x11), + nullifiers: [new Uint8Array(32).fill(0x33), new Uint8Array(32).fill(0x44)], + boundParams: { treeNumber: 0, minGasPrice: 0n, unshield: false, chainId: 1n }, + outputs: [ + { kind: 'broadcaster', recipientMasterPublicKey: new Uint8Array(32).fill(0xaa), recipientViewingPublicKey: new Uint8Array(32).fill(0xbb), tokenHash: DAI_HASH, value: 5n }, + { kind: 'change', tokenHash: DAI_HASH, value: 9n }, + { kind: 'transfer', recipient0zk: `0zk1${'q'.repeat(123)}`, tokenHash: DAI_HASH, value: 7n }, + ], + }); + + const p1s = transport.sentCommands.map((c) => c.p1); + expect(p1s).toEqual([0x00, 0x20, 0x20, 0x10, 0x30, 0x31, 0x32, 0x40]); // init, null×2, bp, broadcaster, change, transfer, finalize + expect(result.outputs.map((o) => [o.kind, o.response.length])).toEqual([ + ['broadcaster', 223], ['change', 223], ['transfer', 239], + ]); + }); + + it('rejects an illegal shape before touching the device', async () => { + const transport = new MockTransport(); + await transport.connect(); + const signer = new RailgunSigner({ transport }); + await expect(signer.signClearSignTransact({ + merkleRoot: new Uint8Array(32), + nullifiers: [new Uint8Array(32), new Uint8Array(32), new Uint8Array(32)], + boundParams: { treeNumber: 0, minGasPrice: 0n, unshield: false, chainId: 1n }, + outputs: [ // n=3, m=3 → n+m=6 > 5 + { kind: 'change', tokenHash: DAI_HASH, value: 1n }, + { kind: 'change', tokenHash: DAI_HASH, value: 1n }, + { kind: 'change', tokenHash: DAI_HASH, value: 1n }, + ], + })).rejects.toThrow(/n\+m/); + expect(transport.sentCommands).toHaveLength(0); + }); + + it('surfaces a mid-session device error (e.g. rejected output)', async () => { + const transport = new MockTransport(); + await transport.connect(); + transport.enqueueResponses([ + ok(new Uint8Array(0)), // CS_INIT + ok(new Uint8Array(0)), // NULLIFIER + ok(new Uint8Array(0)), // BP_FIELDS + { data: new Uint8Array(0), statusWord: StatusWord.USER_REJECTED }, // OUT_UNSHIELD rejected + ]); + const signer = new RailgunSigner({ transport }); + await expect(signer.signClearSignTransact({ + merkleRoot: new Uint8Array(32).fill(0x11), + nullifiers: [new Uint8Array(32).fill(0x22)], + boundParams: { treeNumber: 0, minGasPrice: 1n, unshield: true, chainId: 1n }, + outputs: [{ kind: 'unshield', recipientAddress: VITALIK, tokenHash: DAI_HASH, value: 1n }], + })).rejects.toMatchObject({ code: HWErrorCode.APDU_REJECTED }); + }); + + it('validates every field width before opening a session (illegal nullifier → no APDU)', async () => { + const transport = new MockTransport(); + await transport.connect(); + const signer = new RailgunSigner({ transport }); + await expect(signer.signClearSignTransact({ + merkleRoot: new Uint8Array(32).fill(0x11), + nullifiers: [new Uint8Array(31)], // wrong width — must throw before any APDU + boundParams: { treeNumber: 0, minGasPrice: 1n, unshield: true, chainId: 1n }, + outputs: [{ kind: 'unshield', recipientAddress: VITALIK, tokenHash: DAI_HASH, value: 1n }], + })).rejects.toThrow(/nullifier/); + expect(transport.sentCommands).toHaveLength(0); + }); + + it('rejects a wrong-length OUT_* response', async () => { + const transport = new MockTransport(); + await transport.connect(); + transport.enqueueResponses([ + ok(new Uint8Array(0)), // CS_INIT + ok(new Uint8Array(0)), // NULLIFIER + ok(new Uint8Array(0)), // BP_FIELDS + ok(new Uint8Array(31)), // OUT_UNSHIELD — expected 32B + ]); + const signer = new RailgunSigner({ transport }); + await expect(signer.signClearSignTransact({ + merkleRoot: new Uint8Array(32).fill(0x11), + nullifiers: [new Uint8Array(32).fill(0x22)], + boundParams: { treeNumber: 0, minGasPrice: 1n, unshield: true, chainId: 1n }, + outputs: [{ kind: 'unshield', recipientAddress: VITALIK, tokenHash: DAI_HASH, value: 1n }], + })).rejects.toMatchObject({ code: HWErrorCode.APDU_INVALID_RESPONSE }); + }); +}); diff --git a/test/unit/ledger-connector.test.ts b/test/unit/ledger-connector.test.ts index b6e3640..dca7747 100644 --- a/test/unit/ledger-connector.test.ts +++ b/test/unit/ledger-connector.test.ts @@ -11,6 +11,23 @@ import type { HardwareConnector, LedgerConnectorConfig } from '../../src/core/co import { MockTransport } from '../integration/mock-transport.js'; import { successResponse } from '../fixtures/apdu-responses.js'; import { HWError, HWErrorCode } from '../../src/core/errors.js'; +import { encodeErc20TokenHash } from '../../src/core/transport/clear-sign-apdu.js'; + +/** 129-byte CLEAR_SIGN FINALIZE: 0x60 || R8x(0) || R8y(1) || S(7) || msgHash. */ +function clearSignFinalizeResponse() { + const data = new Uint8Array(129); + data[0] = 0x60; data[64] = 0x01; data[96] = 0x07; + data.set(new Uint8Array(32).fill(0xcd), 97); + return successResponse(data); +} + +/** 256-byte dual-tx FINALIZE: two R8x(0)||R8y(1)||S(7)||msgHash quads. */ +function clearSignFinalize256() { + const data = new Uint8Array(256); + data[63] = 0x01; data[95] = 0x07; data.set(new Uint8Array(32).fill(0xc0), 96); + data[191] = 0x01; data[223] = 0x07; data.set(new Uint8Array(32).fill(0xc1), 224); + return successResponse(data); +} // ─── Helpers ────────────────────────────────────────────────────────────────── @@ -104,6 +121,58 @@ describe('createLedgerConnector', () => { expect(transport.sentCommands).toHaveLength(2); }); + it('clear-signs when a plaintext transact is passed (toggle), returning outputs', async () => { + transport.enqueueResponse(appAndVersionResponse('RAILGUN', '0.1.0')); // ensureAppReady + transport.enqueueResponse(successResponse(new Uint8Array(0))); // CS_INIT + transport.enqueueResponse(successResponse(new Uint8Array(0))); // NULLIFIER + transport.enqueueResponse(successResponse(new Uint8Array(0))); // BP_FIELDS + transport.enqueueResponse(successResponse(new Uint8Array(32).fill(0xab))); // OUT_UNSHIELD + transport.enqueueResponse(clearSignFinalizeResponse()); // FINALIZE + + const result = await connector.sign(12345n, undefined, undefined, { + merkleRoot: new Uint8Array(32).fill(0x11), + nullifiers: [new Uint8Array(32).fill(0x22)], + boundParams: { treeNumber: 0, minGasPrice: 1n, unshield: true, chainId: 1n }, + outputs: [{ + kind: 'unshield', + recipientAddress: new Uint8Array(20).fill(0xd8), + tokenHash: encodeErc20TokenHash(new Uint8Array(20).fill(0x6b)), + value: 0x40000n, + }], + }); + + expect(result.R8[1]).toBe(1n); + expect(result.S).toBe(7n); + expect(result.clearSign?.msgHash).toEqual(new Uint8Array(32).fill(0xcd)); + expect(result.clearSign?.outputs).toEqual([{ kind: 'unshield', response: new Uint8Array(32).fill(0xab) }]); + // GET_APP_AND_VERSION + 5 clear-sign APDUs + expect(transport.sentCommands).toHaveLength(6); + }); + + it('exposes dual-tx clear-sign via signClearMultiTransact (2 signatures)', async () => { + const tokenHash = encodeErc20TokenHash(new Uint8Array(20).fill(0x6b)); + const bp = { treeNumber: 0, minGasPrice: 0n, unshield: false, chainId: 1n } as const; + transport.enqueueResponse(appAndVersionResponse('RAILGUN', '0.1.0')); // ensureAppReady + transport.enqueueResponse(successResponse(new Uint8Array(0))); // CS_INIT (multi) + transport.enqueueResponse(successResponse(new Uint8Array(0))); // tx0 NULLIFIER + transport.enqueueResponse(successResponse(new Uint8Array(0))); // tx0 BP_FIELDS + transport.enqueueResponse(successResponse(new Uint8Array(223).fill(0x01))); // tx0 OUT_CHANGE + transport.enqueueResponse(successResponse(new Uint8Array(0))); // tx1 NULLIFIER + transport.enqueueResponse(successResponse(new Uint8Array(0))); // tx1 BP_FIELDS + transport.enqueueResponse(successResponse(new Uint8Array(223).fill(0x02))); // tx1 OUT_CHANGE + transport.enqueueResponse(clearSignFinalize256()); // FINALIZE + + const result = await connector.signClearMultiTransact({ + transactions: [ + { merkleRoot: new Uint8Array(32).fill(0x11), nullifiers: [new Uint8Array(32).fill(0x33)], boundParams: bp, outputs: [{ kind: 'change', tokenHash, value: 5n }] }, + { merkleRoot: new Uint8Array(32).fill(0x22), nullifiers: [new Uint8Array(32).fill(0x44)], boundParams: bp, outputs: [{ kind: 'change', tokenHash, value: 9n }] }, + ], + }); + + expect(result.signatures.map((s) => s.signature.S)).toEqual([7n, 7n]); + expect(result.outputs.map((o) => o.kind)).toEqual(['change', 'change']); + }); + it('throws when no app is open (dashboard)', async () => { // Dashboard returns name="BOLOS" which getActiveApp returns as null transport.enqueueResponse(appAndVersionResponse('BOLOS', '2.1.0')); diff --git a/test/unit/sdk-controller.test.ts b/test/unit/sdk-controller.test.ts index 29afb26..da4a85c 100644 --- a/test/unit/sdk-controller.test.ts +++ b/test/unit/sdk-controller.test.ts @@ -6,6 +6,15 @@ import { RAILGUN_APP } from '../../src/core/device/app-registry.js'; import { EthSigner, RAILGUN_SHIELD_MESSAGE } from '../../src/core/signers/eth-signer.js'; import { HWError, HWErrorCode } from '../../src/core/errors.js'; import { StatusWord } from '../../src/core/transport/types.js'; +import { encodeErc20TokenHash } from '../../src/core/transport/clear-sign-apdu.js'; + +/** 129-byte FINALIZE: 0x60 || R8x(0) || R8y(1) || S(7) || msgHash. */ +function clearSignFinalize129(): Uint8Array { + const f = new Uint8Array(129); + f[0] = 0x60; f[64] = 0x01; f[96] = 0x07; + f.set(new Uint8Array(32).fill(0xcd), 97); + return f; +} function buildVersionResponse( targetId: number, @@ -442,6 +451,41 @@ describe('createLedgerController', () => { expect(controller.getSnapshot().requiredApp?.name).toBe('RAILGUN'); }); + it('clear-signs a transact through the controller (auto-readies, delegates to the signer)', async () => { + const controller = createController(); + await controller.connect(); + transport.enqueueResponses([ + // ensureReady + successResponse(buildAppVersionResponse('BOLOS', '0.0.0')), + successResponse(buildVersionResponse(0x33100004, '1.5.1', 0, '1.1')), + successResponse(new Uint8Array(0)), + successResponse(buildAppVersionResponse('RAILGUN', '0.1.0')), + // clear-sign session: CS_INIT, NULLIFIER, BP_FIELDS, OUT_UNSHIELD (32B), FINALIZE (129B) + successResponse(new Uint8Array(0)), + successResponse(new Uint8Array(0)), + successResponse(new Uint8Array(0)), + successResponse(new Uint8Array(32).fill(0xab)), + successResponse(clearSignFinalize129()), + ]); + + const result = await controller.signClearSignTransact({ + merkleRoot: new Uint8Array(32).fill(0x11), + nullifiers: [new Uint8Array(32).fill(0x22)], + boundParams: { treeNumber: 0, minGasPrice: 1n, unshield: true, chainId: 1n }, + outputs: [{ + kind: 'unshield', + recipientAddress: new Uint8Array(20).fill(0xd8), + tokenHash: encodeErc20TokenHash(new Uint8Array(20).fill(0x6b)), + value: 0x40000n, + }], + }); + + expect(result.signature.S).toBe(7n); + expect(result.msgHash).toEqual(new Uint8Array(32).fill(0xcd)); + expect(result.outputs).toEqual([{ kind: 'unshield', response: new Uint8Array(32).fill(0xab) }]); + expect(controller.getSnapshot().deviceSession?.activeApp?.name).toBe('RAILGUN'); + }); + it('surfaces openApp cancellation instead of hanging in opening_app', async () => { const controller = createController(); const readinessSequence: string[] = []; diff --git a/test/unit/status-words.test.ts b/test/unit/status-words.test.ts index 2dc8885..1c8fdb8 100644 --- a/test/unit/status-words.test.ts +++ b/test/unit/status-words.test.ts @@ -25,6 +25,12 @@ describe('statusWordToHWError', () => { expect(err.code).toBe(HWErrorCode.APDU_STATUS_ERROR); expect(err.message).toContain('0x1234'); }); + + it('maps the RAILGUN CLEAR_SIGN state SW (0xb007) to a clear message', () => { + const err = statusWordToHWError(StatusWord.RAILGUN_CLEAR_SIGN_STATE); + expect(err.code).toBe(HWErrorCode.APDU_STATUS_ERROR); + expect(err.message).toContain('CLEAR_SIGN'); + }); }); describe('classifyDeviceError', () => {