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
29 changes: 29 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
16 changes: 16 additions & 0 deletions docs/api/signers.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
168 changes: 168 additions & 0 deletions docs/engine-clear-sign-integration.md
Original file line number Diff line number Diff line change
@@ -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<HardwareConnectorSignResult>;

// 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.
23 changes: 21 additions & 2 deletions src/core/connector/ledger-connector.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -112,12 +114,19 @@ export function createLedgerConnector(
expectedHash: bigint,
publicInputs?: PublicInputsRailgun,
_subSession?: string,
): Promise<Signature> => {
clearSign?: ClearSignTransactRequest,
): Promise<HardwareConnectorSignResult> => {
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);
});
};
Expand All @@ -130,6 +139,15 @@ export function createLedgerConnector(
return Promise.resolve(true);
};

const signClearMultiTransact = (
request: ClearSignMultiTransactRequest,
): Promise<ClearSignMultiTransactResult> => {
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);
Expand All @@ -147,6 +165,7 @@ export function createLedgerConnector(
type: 'ledger',
deviceId: `ledger:${config.appName}`,
sign,
signClearMultiTransact,
requestBatchApproval,
getPublicKey,
isConnected,
Expand Down
37 changes: 36 additions & 1 deletion src/core/connector/types.ts
Original file line number Diff line number Diff line change
@@ -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.
Expand Down Expand Up @@ -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<Signature>;
clearSign?: ClearSignTransactRequest,
) => Promise<HardwareConnectorSignResult>;

/** Connector config. */
export type LedgerConnectorConfig = {
Expand Down Expand Up @@ -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<ClearSignMultiTransactResult>;

/** Get the BabyJubjub public key from the device. */
getPublicKey: () => Promise<{ readonly x: bigint; readonly y: bigint }>;

Expand Down Expand Up @@ -111,6 +143,9 @@ type HardwareConnector_Reference = {
readonly type: 'ledger';
readonly deviceId: string;
sign: HardwareConnectorSignFn;
signClearMultiTransact: (
request: ClearSignMultiTransactRequest,
) => Promise<ClearSignMultiTransactResult>;
requestBatchApproval: (
requests: readonly RequestApprovalOptions[],
) => Promise<boolean>;
Expand Down
Loading
Loading