Skip to content

Hardware wallet support: Ledger accounts across wallet, dApps, and x402 - #149

Merged
meinharrd merged 19 commits into
mainfrom
feature/hardware-wallet
Aug 12, 2026
Merged

Hardware wallet support: Ledger accounts across wallet, dApps, and x402#149
meinharrd merged 19 commits into
mainfrom
feature/hardware-wallet

Conversation

@flotob

@flotob flotob commented Jul 7, 2026

Copy link
Copy Markdown
Collaborator

Summary

Adds Ledger hardware-wallet support end to end: connect a device, add its accounts alongside mnemonic wallets, and sign everything — manual sends, dApp transactions/messages/typed data, and x402 payment authorizations — on the device. Verified end to end against a Ledger Stax (connect flow, send, dApp signing, x402 pay).

Architecture

  • Signer abstraction (WP1). All signing flows through getSigner(walletIndex){getAddress, signTransaction, signMessage, signTypedData} in src/main/wallet/signers.js. The account's type in vault-meta picks the backend: vault-borrowed mnemonic key or Ledger device. Transaction signing is split from broadcasting (signer.signTransactionprovider.broadcastTransaction), which a hardware signer requires. The x402 client (createX402Client) is backend-agnostic by construction.
  • Account model + transport (WP2). derivedWallets[] entries gain type: 'mnemonic' | 'ledger'; Ledger entries persist the device-read address and derivation path (nothing is derivable locally). New src/main/wallet/ledger/ module: node-hid transport behind a serialization queue (one APDU exchange at a time; native module lazy-loaded so boot pays nothing), account discovery over Ledger Live + legacy path schemes, and stable LEDGER_* error codes with user-facing instructions. Safety chokepoint: withVaultPrivateKey refuses to derive a vault key at a hardware account's index — no code path can silently sign with a phantom mnemonic key.
  • Device signing (WP3). Transactions, EIP-191 personal messages, and EIP-712 typed data (full-payload signing with a hashed-message fallback for older Ethereum apps). Every signing session first verifies the attached device derives the account's stored address — a different Ledger/seed fails with LEDGER_WRONG_DEVICE instead of signing from a foreign address.
  • x402 (WP4). EIP-3009 authorizations sign on-device through the same seam. Auto-pay caps still require a physical confirmation per payment; the grant editor says so.

UX

  • "Connect Hardware Wallet" in the wallet selector: polls until a device with the Ethereum app open appears, pages through device accounts, adds the chosen one — works with the vault locked (no mnemonic involved).
  • Ledger accounts show a badge in the selector; private-key export is blocked for them.
  • Approval dialogs (dApp tx/sign, send, x402 pay) skip the vault-unlock gate for Ledger accounts and show "Confirm on your Ledger…" while the device waits.

Privacy decision (reviewers: please weigh in)

Ledger's hosted clear-signing resolution (token/plugin metadata) is deliberately not used — it would post full transaction contents to Ledger's registry before signing, against the project's no-hosted-services rule. Plain ETH transfers display normally on-device; ERC-20 sends show raw calldata and may need blind signing enabled in the device's Ethereum app. Follow-up options: opt-in setting, or a bundled offline token registry.

Testing

  • ~150 new/updated unit tests, including full cryptographic round-trips through a fake hw-app-eth device (signature reassembly verified with ethers recovery) and vault-meta account-model coverage.
  • Manual end-to-end pass on a Ledger Stax: connect + discovery, send, dApp personal_sign / eth_signTypedData_v4, transaction approval, x402 payment, wrong-device and reject-on-device error paths.
  • Known unrelated failure: bee-to-ant-migration.test.js also fails on main (antd identity adoption) — not touched by this branch.

🤖 Generated with Claude Code

https://claude.ai/code/session_01VtT8ToNv26pYdwB1BvXVyu

flotob and others added 4 commits July 7, 2026 20:22
…et support

Replace raw-private-key threading with a Signer interface resolved by
getSigner(walletIndex): {getAddress, signTransaction, signMessage,
signTypedData}. All signing call sites (wallet IPC, tx recorder, x402
client) consume the interface; only the vault backend inside signers.js
ever touches key material.

signAndSendTransaction now signs then broadcasts as separate steps
(signer.signTransaction → provider.broadcastTransaction), which a
hardware signer requires — the provider only ever sees the serialized
signed tx. dApp wire-shape normalization (0x-hex personal messages,
JSON-string typed data) happens once in the factory so future backends
can't drift. x402's createVaultBackedX402Client becomes createX402Client:
it is backend-agnostic by construction now.

No behavior change intended.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VtT8ToNv26pYdwB1BvXVyu
Adds hardware-wallet accounts to the wallet list: derivedWallets[]
entries gain a type ('mnemonic' | 'ledger'); Ledger entries persist the
device-read address and derivation path since nothing can be re-derived
locally. getWalletRecord/getWalletList become the shared normalization
seam, and withVaultPrivateKey now refuses to derive a vault key at a
hardware account's index — the chokepoint guard that keeps any future
caller from silently signing with a phantom mnemonic key.

Main-process ledger module (src/main/wallet/ledger/):
- transport.js: node-hid transport behind a serialization queue (one
  APDU exchange at a time), lazy native-module load, account discovery
  over Ledger Live and legacy derivation schemes
- errors.js: APDU status words and transport errors mapped to stable
  LEDGER_* codes with user-facing instructions
- signer.js: signer-factory backend; getAddress from the stored record,
  signing fails closed until the device-confirmation flow lands

UI: "Connect Hardware Wallet" in the wallet selector opens a subscreen
that polls for a device with the Ethereum app open, pages through
device accounts, and adds the chosen one (no vault unlock needed).
Ledger accounts show a badge in the selector; private-key export is
blocked for them.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VtT8ToNv26pYdwB1BvXVyu
The Ledger signer backend now signs for real: transactions (sign/
broadcast split — the device signs the unsigned serialization, the
provider broadcasts), EIP-191 personal messages, and EIP-712 typed data
(full payload via TypedDataEncoder.getPayload, with a hashed-message
fallback for older Ethereum apps). Every signing session first verifies
the attached device derives the account's address at the stored path,
so a different Ledger/seed fails with LEDGER_WRONG_DEVICE instead of
silently signing from a foreign address.

Transactions are deliberately signed without @LedgerHQ's hosted
clear-signing resolution: it would post full tx contents to Ledger's
registry, against the project's no-hosted-services rule. Plain ETH
transfers display normally on-device; contract calls show as raw data
until an opt-in setting or offline token registry lands.

Approval UX: Ledger accounts skip the vault-unlock gate (no vault key
involved) in the dApp tx/sign dialogs and the send flow; pending states
read "Confirm on your Ledger" while the device waits for a physical
confirmation.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VtT8ToNv26pYdwB1BvXVyu
x402 signing already flows through the signer factory since the WP1
refactor, so Ledger accounts produce EIP-3009 payment authorizations
on-device with no protocol changes. This wires the flow around it:

- The payment approval card skips the vault-unlock gate for Ledger
  accounts (no vault key involved) and shows "Confirm on your Ledger"
  while the device waits for the physical confirmation.
- The auto-pay grant editor states plainly that auto-pay skips the
  dialog but never the device confirmation — a spend cap on a hardware
  account cannot pay silently, by design.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VtT8ToNv26pYdwB1BvXVyu

@flotob flotob left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Adversarial review of head ab23552: I found no additional blocking issue in the Ledger account model, serialized device transport, wrong-device check, signer abstraction, transaction/message/typed-data paths, x402 integration, or native-build coverage. The deliberate blind-signing/privacy tradeoff is clearly surfaced and the cryptographic verification tests are appropriate. CI is green; refresh from main before merge. This remains the required base for #159.

@flotob flotob left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fresh re-review of unchanged head ab23552f, including signer abstraction boundaries, account-type isolation, Ledger address verification, transaction/message/x402 paths, packaging, and current base/CI state: no blocking findings.

Current status: 36/36 checks successful. This stacked base is still behind main; refresh it before merge.

@flotob flotob left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-re-review of unchanged head ab23552f: no new code, review follow-up, CI regression, or signer/account-isolation finding. No blocking issues. 36/36 checks successful; stacked branch remains behind main.

@meinharrd meinharrd added alan:reviewing alan loop currently running on this PR and removed alan:reviewing alan loop currently running on this PR labels Aug 10, 2026
@meinharrd

Copy link
Copy Markdown
Contributor

[alan-review R1] Blocking — Ledger accounts are offered as Swarm publisher identities but can never sign, leaving a persistently broken origin identity

src/main/swarm/feed-store.js:569 (getOriginIdentityStateWithOwners) appends every entry from getDerivedWallets() as a selectable ethereum-wallet publisher identity — and this PR makes getDerivedWallets() return Ledger accounts (with their stored device address, so they render as perfectly normal, selectable rows even with the vault locked). Neither ensureEthereumWalletIdentity (feed-store.js:650) nor setFeedIdentity checks wallet.type, so the selection persists to the feed store.

But Swarm signing needs a raw private key: resolveSignerKeygetUserWalletKey(identity.walletIndex) (src/main/swarm/swarm-provider-ipc.js:963), which this PR now — correctly — makes throw 'Hardware wallet accounts have no derivable private key' for Ledger records. There is no Signer-based path for Swarm feeds (SOC/feed signing can't be done via the Ethereum app), so the guard is right; offering the account is the bug.

Failure scenario: user adds a Ledger account (this PR's connect flow) → a Swarm-enabled site requests feed access → the identity chooser (swarm-connect prompt, or Publisher Identities settings via activateIdentityForDetail) lists the Ledger account → user picks it → ensureEthereumWalletIdentity persists + activates it → every subsequent feed operation for that origin (all five resolveSignerKey call sites: swarm-provider-ipc.js:780,983,1045,1129,1219) fails with an opaque INTERNAL_ERROR: Hardware wallet accounts have no derivable private key, until the user figures out they must switch identity.

Fix: filter wallet.type !== WALLET_TYPES.MNEMONIC (or mark unavailable) in the getOriginIdentityStateWithOwners wallet loop, and reject non-mnemonic indexes in ensureEthereumWalletIdentity as defense in depth. This is the classic sibling-call-site miss: the PR guarded the wallet/dApp/x402 signing paths but not the Swarm consumer of the same wallet list.

@meinharrd

Copy link
Copy Markdown
Contributor

[alan-review R1] Blocking — sign/broadcast split regresses fee handling: a tx with no fee params is now signed with maxFeePerGas = 0 instead of being populated from the network

src/main/wallet/transaction-service.js:235: the old path was new Wallet(privateKey, provider).sendTransaction(tx), which populated missing fee fields from the network before signing. The new path signs exactly what buildTransaction produced — and when neither the EIP-1559 pair nor gasPrice is present, buildTransaction emits no fee fields at all, so Wallet.signTransaction (no provider) silently signs a type-2 tx with maxFeePerGas = 0. Verified empirically with ethers v6:

signed OK — type: 2 maxFeePerGas: 0n maxPriority: 0n

Every node rejects that broadcast (underpriced / below base fee), surfaced to the user as the misleading 'Gas estimation error. The transaction may fail.'

Reachable path: wallet:send-transaction / wallet:dapp-send-transaction only validate to/chainId/gasLimit — fees are optional. In src/renderer/lib/wallet/dapp-tx.js (populateDappTxDetails), if estimateGas or getGasPrice fails (e.g. the dApp's tx would revert during estimation, or one flaky RPC call), dappTxPending.gasPrice is never set, the dialog shows 'Unable to estimate' but Approve stays enabled, and approveDappTx builds the tx with no fee fields (gasLimit falls back to the dApp-supplied txParams.gas, so the main-side validation passes). Previously that tx still went out with network-populated fees; now it is guaranteed to fail — and on a Ledger the user physically confirms a 0-fee tx on-device before it fails.

Fix options: populate fees in signAndSendTransaction when absent (e.g. provider.getFeeData() before buildTransaction), or reject fee-less params up front with a clear error, and/or disable Approve in dapp-tx when estimation failed. (Applies identically to both the vault and Ledger backends — the signer interface can't populate.)

@meinharrd

Copy link
Copy Markdown
Contributor

[alan-review R1] Minor findings (combined):

  1. src/main/wallet/ledger/signer.js:84signMessage corrupts Uint8Array input. The Signer typedef advertises (message: string|Uint8Array), and the vault backend handles a Uint8Array correctly (ethers signs the bytes). The Ledger backend only special-cases Buffer; a plain Uint8Array falls into Buffer.from(String(message), 'utf8')String(uint8arr) is "1,2,3…", so the device would sign garbage bytes that don't match the caller's message. Currently unreachable (the factory normalizes 0x-hex to Buffer, and IPC callers pass strings), but it's a latent divergence between backends — use ArrayBuffer.isView(message) / Buffer.from(message) instead.

  2. src/renderer/lib/wallet/connect-ledger.js:131 — closing and reopening the connect screen within the 1.5 s poll window leaks a duplicate detect loop. detectTick re-arms detectTimer after its awaited IPC returns, even if closeConnectLedger already ran stopDetectLoop() during the await. Reopen the screen before that stale timer fires and two polling loops run concurrently (the shared detectTimer variable is overwritten, so only one can ever be cleared). Self-heals on the next close (the hidden-screen guard stops orphans), but while open it double-polls the device queue. Guard the re-arm with the same hidden-screen check, or use a generation counter.

  3. On-device rejection isn't surfaced to dApps as EIP-1193 4001. executeSign / auto-approve throw a generic Error(result.error) when the wallet IPC returns LEDGER_USER_REJECTED, so a dApp (wagmi/viem etc.) sees an internal error rather than 'user rejected request'. The stable code is already on the IPC response's error path in main — plumb it through wallet:sign-message/sign-typed-data/dapp-send-transaction responses and map to {code: 4001} in dapp-provider.

  4. .github/workflows/ci.ymlsudo apt-get install -y libudev-dev without a preceding apt-get update can 404 on stale package lists on GitHub runners; cheap to add sudo apt-get update && for flake-proofing.

Verification notes: full unit suite passes locally on the merge head (only the known pre-existing vault auto-locks after timeout flake fails); harness e2e boots and passes (tabs.spec.js) with the new native deps; signature reassembly for legacy (EIP-155, incl. hw-app-eth's truncated-v reconstruction) and EIP-1559 txs verified empirically against ethers — that path is sound.

meinharrd added a commit to solardev-xyz/alan-artifacts that referenced this pull request Aug 11, 2026
@meinharrd

Copy link
Copy Markdown
Contributor

[alan-verify R1] Both findings CONFIRMED — I tried to refute each and could not; both reproduce empirically.


R1-F1 — Ledger accounts offered as Swarm publisher identities — CONFIRMED

I tried three refutation angles, all failed:

  1. "getDerivedWallets() filters hardware accounts" — it does not. identity-manager.js:850 explicitly special-cases WALLET_TYPES.LEDGER and returns the stored device address (there is even a test for it: getDerivedWallets returns the stored device address without derivation). So feed-store.js:568 enumerates Ledger accounts alongside mnemonic ones with no type check.

  2. "the enriched identity carries a marker the UI can filter on" — it does not. Ran getOriginIdentityStateWithOwners() against the real module with a mnemonic + Ledger wallet; the Ledger comes back as a fully normal, available entry:

    { "id": "ethereum-wallet:1", "mode": "ethereum-wallet",
      "owner": "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913", "unavailable": false }
    

    Selecting it then works: ensureEthereumWalletIdentity(origin, 1, {activate:true}) persists and activates it with no type check (feed-store.js:650-685), leaving activeIdentityId = "ethereum-wallet:1".

  3. "the renderer filters it" — it does not. publisher-identity-selector.js:111 filters only on mode === 'ethereum-wallet'. Rendered the real selector module with the exact state the main process emits:

    ledger-swarm-identity

    "Ledger 1" is listed as a normal, clickable "Ethereum wallet" identity, visually indistinguishable from Main Wallet. Clicking it hands the app {id: "ethereum-wallet:1", walletIndex: 1}.

Downstream is then unconditionally broken for that origin: resolveSignerKey (swarm-provider-ipc.js:959) → getUserWalletKey(1)identity-manager.js:288 throws Hardware wallet accounts have no derivable private key, which all five call sites (780/983/1045/1129/1219) wrap into an opaque INTERNAL_ERROR. Sticky until the user manually switches identity.

Note this is a reachability regression, not a diff-line regression — src/main/swarm/ is untouched by the PR; it's the newly-addable Ledger accounts that make the existing unguarded path reachable.


R1-F2 — 0-fee signing after the sign/broadcast split — CONFIRMED

Reproduced end-to-end through the real signAndSendTransaction with a stub provider and the exact backend signers.js:createVaultBackend uses (new Wallet(pk).signTransaction(tx)no provider attached, so ethers does no population):

params: { to, value: '0', data: '0xa9059cbb', gasLimit: '60000', chainId: 8453 }   // no fee fields
BROADCAST  type=2  maxFeePerGas=0  maxPriorityFeePerGas=0  gasLimit=60000

Same input on main (new Wallet(pk, provider).sendTransaction(tx)), same stub RPC:

PRE-PR BROADCAST  type=2  maxFeePerGas=2100000000  maxPriorityFeePerGas=100000000

So the fee data really was coming from populateTransactionprovider.getFeeData() and that population is gone. Refutation attempts that failed:

  • "a fee is always supplied" — no. dapp-tx.js:200 only sets dappTxPending.gasPrice when both estimateGas and getGasPrice succeed, so a getGasPrice failure alone (or a tx that reverts during estimation) leaves gasPrice undefined while the Approve button stays enabled (checkDappTxUnlockStatus gates on vault/hardware state only, never on fee availability).
  • "main-side validation catches it" — no. wallet-ipc.js:61 requires only to, chainId, gasLimit, and dapp-tx.js:332 falls back to dApp-supplied txParams.gas, so validation passes.
  • "buildTransaction defaults the fee" — no. transaction-service.js:168 sets fee fields only if provided; ethers then infers type 2 with zeroed fees.

Result: a type-2 tx every node rejects as underpriced — and on a Ledger, only after the user physically confirmed it on-device.


No repo state changed; scratch test files removed.

…ssing tx fees

R1-F1: Swarm feed/SOC signing needs a raw private key (resolveSignerKey),
which a Ledger account can never hand out. The identity chooser still
offered them, and ensureEthereumWalletIdentity persisted+activated one
with no type check, leaving the origin with an identity whose every feed
operation failed with an opaque INTERNAL_ERROR. feed-store now filters
hardware accounts out of the offered identity list, refuses to persist
one, and flags any already-stored hardware-backed identity as
unavailable; the shared publisher identity selector renders unavailable
rows greyed out and inert.

R1-F2: the sign/broadcast split dropped ethers' fee population — with no
fee params the tx was signed with maxFeePerGas = 0 and rejected by every
node as underpriced, on Ledger only after the user confirmed on-device.
signAndSendTransaction now resolves missing fees from the network before
signing, and refuses to sign at all when no usable price is available.
meinharrd added a commit to solardev-xyz/alan-artifacts that referenced this pull request Aug 11, 2026
@meinharrd

Copy link
Copy Markdown
Contributor

[alan-fix R1] Both confirmed findings fixed in 32f44fc.

R1-F1 — Ledger accounts offered as Swarm publisher identities (src/main/swarm/feed-store.js)

Root cause: Swarm feed/SOC signing needs a raw private key (resolveSignerKey), which a hardware account can never hand out — so an ethereum-wallet identity backed by a Ledger account is structurally unusable. Fixed main-side so every entry point is covered at once:

  • new isSwarmSignableWallet() helper (wallet type must be mnemonic);
  • getOriginIdentityStateWithOwners() no longer offers hardware accounts as selectable identities — this is the single source for all three chooser UIs (feed prompt, Swarm permission screen, publisher-identity management), so none of them can list one;
  • ensureEthereumWalletIdentity() refuses to persist/activate one, with an actionable message instead of a later opaque INTERNAL_ERROR;
  • enrichIdentityOwner() flags an already-stored hardware-backed identity as unavailable (covers data written before this guard), and the shared publisher-identity-selector now renders unavailable rows greyed out, inert (click is a no-op, disabled), and labelled "Can't sign feeds" — this also improves the pre-existing deleted-wallet case.

Acceptance evidence — the greyed, unselectable row as a user sees it (real app, harness run):

publisher-identity-unavailable

R1-F2 — sign/broadcast split regressed fee handling (src/main/wallet/transaction-service.js)

ethers' Wallet.sendTransaction used to populate missing fees from the network; after the split nothing did, so an unpriced tx was signed as type-2 with maxFeePerGas = 0 and rejected by every node as underpriced — on Ledger, only after the user physically confirmed it. New resolveFeeParams() runs before the signer is ever asked to sign: it passes caller-supplied fees through unchanged, otherwise fetches from the network via the existing getGasPrices() (EIP-1559, falling back to legacy gasPrice), and throws Unable to determine a gas price for this transaction when no usable price exists rather than signing a tx that can't broadcast. Resolution happens outside the try so the error isn't remapped to the generic "gas estimation" message.

Tests / verification

  • New unit tests: 3 in transaction-service.test.js (EIP-1559 populated from network → maxFeePerGas 5 gwei not 0; legacy fallback; refuses to sign — asserts signTransaction is never called and nothing is broadcast) and 3 in feed-store.test.js (hardware account rejected and nothing persisted; not offered in the identity list; stored one reported unavailable).
  • New e2e spec test-e2e/publisher-identity-selector.spec.js asserting the unavailable row is disabled, labelled, and click-inert while a usable sibling still selects (this is the screenshot above).
  • npx jest: 2189 passed, 1 failed — the known pre-existing vault auto-locks after timeout flake only.
  • xvfb-run npx playwright test --project=harness: 21/21 passed. npx eslint . clean.

@meinharrd meinharrd added alan:reviewing alan loop currently running on this PR and removed alan:reviewing alan loop currently running on this PR labels Aug 11, 2026
@meinharrd

Copy link
Copy Markdown
Contributor

[alan-review R1] (post-fix review of head 32f44fc) Blocking — Ledger entries share the mnemonic BIP-44 index space, so a freed index silently rebinds persisted walletIndex references to a different account type/address

src/main/identity-manager.js:952addLedgerWallet allocates newIndex = max(index)+1, the same rule createDerivedWallet uses (line 1026), and deleteDerivedWallet (line 1122) just splices entries out. For mnemonic wallets index is the BIP-44 account index (m/44'/60'/{index}'/0/0); for Ledger entries it is just a list slot. Mixing both types in one reusable numbering space breaks two invariants that held before this PR:

Failure A — in-app stranded funds. Main(0) + mnemonic Wallet 2 (index 1, funded). Delete Wallet 2, then add a Ledger account → the Ledger takes index 1. The mnemonic account at derivation index 1 is now unreachable in-app: withVaultPrivateKey(1) throws the hardware guard (vault-access.js:61), getUserWalletKey(1) throws, private-key export refuses, and createDerivedWallet can never re-mint index 1 (always max+1). Pre-PR, delete+create re-derived the identical key/address at index 1, so nothing was ever lost.

Failure B — silent signer swap without consent. dapp-permissions.js:94 persists walletIndex per origin and is not cleaned on wallet deletion; wallet:dapp-send-transaction / wallet:sign-message / wallet:sign-typed-data resolve it via getSigner(walletIndex). After the index takeover, an origin granted mnemonic account 1 (address B) transparently gets the Ledger backend (address C) — or in reverse (add Ledger at 1, delete it, createDerivedWallet reuses 1), an origin that connected to the Ledger's address gets vault-key signatures from a mnemonic account the user never consented to, with no device confirmation. Swarm publisher identities are keyed the same way (ethereum-wallet:<walletIndex>, feed-store.js:100), and a stored-but-inactive, non-pinned identity survives deletion (getEthereumWalletIdentityReferences skips !active && feedNames.length === 0, feed-store.js:865), after which it reports the Ledger's address as owner.

Fix direction: never reuse indexes (persist a monotonic nextWalletIndex in vault-meta), or give hardware accounts a separate id namespace so they can never occupy a BIP-44 derivation index.

@meinharrd

Copy link
Copy Markdown
Contributor

[alan-review R1] (post-fix review of head 32f44fc) Blocking — Reject/Back stay clickable during an in-flight Ledger confirmation: the tx still broadcasts after the dApp was told 4001, and the auto-approve rule still installs

src/renderer/lib/wallet/dapp-tx.js:323approveDappTx() disables only the Approve button before await window.wallet.dappSendTransaction(...). The Reject and Back handlers (lines 71–81) stay live and call rejectDappTx() (rejects the dApp promise with {code: 4001}) + closeDappTx(). Nothing anywhere disables dappTxRejectBtn (grep: it is never touched), and main has no cancellation path once signAndRecordsigner.signTransaction(tx) is awaiting the device.

Failure scenario (Ledger account): user clicks Confirm → sidebar shows "Confirm on your Ledger…", device shows the tx. User changes their mind and clicks Reject in the sidebar: the dApp promise settles as rejected (4001) and the screen closes — but the device prompt is still active. If the user then presses approve on the Ledger (plausibly believing that's how to clear the prompt), main signs and broadcasts the transaction: funds move for a request the dApp saw as rejected, and the hash is delivered to no one (resolve(result.hash) is a no-op on the settled promise; only payment history records it). Worse, the continuation still runs addTransactionAutoApprove (line 358) when the checkbox was ticked — closeDappTx() never resets it — so a standing auto-approve permission is installed off the back of a "rejected" request, and future matching txs from that origin sign without any approval UI. Pre-PR the vulnerable window was the ~1 s of local signing; the Ledger flow stretches it to an open-ended interval during which the UI directs the user to go interact with the device.

Fix: disable Reject/Back (and the screen-hider path) while the sign IPC is in flight for the hardware path — or track a generation/closed flag and make the continuation drop broadcast + auto-approve when the screen was closed. Note the same pattern exists in dapp-sign.js, dapp-x402.js (Back button), and send.js — fix all siblings in the same commit (see the combined minor findings).

@meinharrd

Copy link
Copy Markdown
Contributor

[alan-review R1] (post-fix review of head 32f44fc) Minor findings (combined):

  1. src/renderer/lib/wallet/dapp-sign.js:302 — sibling of the blocking dapp-tx race. approveDappSign() disables only Approve before await executeSign(...); Reject/Back stay live during "Confirm on your Ledger…". Reject → dApp gets 4001 → user confirms on the still-active device prompt → the signature is dropped (settled promise), but setSigningAutoApprove(permissionKey, true) (line 310) still runs because closeDappSign() doesn't reset the checkbox — the origin's future signing requests then bypass the approval UI after a visibly rejected request.

  2. src/renderer/lib/wallet/dapp-x402.js:755 — Back button not disabled during payment sign. approve() disables approveBtn and rejectBtn but backBtn is wired to the same reject() handler (line 300) and stays enabled. Clicking Back mid-Ledger-confirmation runs closeAndReset() + x402Cancel while main is still awaiting the device; a subsequent on-device confirm signs/settles a payment for a page already navigated away, and the late continuation calls restoreCardWithError() against the reset card. rejectBtn was deliberately disabled here — give backBtn the same treatment.

  3. src/renderer/lib/wallet/send.js:1201 — closing Send during the Ledger wait leaves the old confirm's continuation live. The subscreen Back button stays enabled on the pending view; closeSend()/resetSendState() don't invalidate the in-flight window.wallet.sendTransaction(...) await, so reopening Send for a new transfer gets abruptly hijacked by showSendSuccessView()/showSendErrorView() (lines 1215/1221) belonging to the previous transaction. Pre-existing for slow RPCs, but the Ledger wait makes it routine. Add a generation/staleness check before flipping views.

  4. src/renderer/lib/wallet/connect-ledger.js:177 — stale error text persists next to a successful account list. loadAccountsPage()'s success path never calls hideError(). E.g. scheme change fails (device unplugged) → showError(...) + back to detect step; when the device returns, the accounts step re-reveals with the fresh list and the old error still rendered. Call hideError() on load success.

  5. src/main/identity-manager.js:95 + src/main/wallet/vault-access.js:61 — hardware guard chain fails open when vault-meta is unreadable. getVaultMeta() returns null for an unparseable file (not just a missing one), getWalletRecord then returns null, and both getSigner and the withVaultPrivateKey chokepoint treat a null record as mnemonic. saveVaultMeta writes with a bare fs.writeFileSync (no tmp+rename, unlike feed-store's atomic write), so a crash mid-write leaves exactly such a file — after which a persisted dApp permission for a Ledger walletIndex silently gets a vault-derived key at an address the user never saw (the precise scenario the chokepoint comment says it exists to prevent), and all Ledger records (unrederivable address/path) are silently lost. Make saveVaultMeta atomic and/or fail closed when vault-meta exists but can't be parsed.

  6. src/main/wallet/transaction-service.js:195resolveFeeParams discards a caller-supplied partial fee spec. With maxFeePerGas set but maxPriorityFeePerGas absent, the pass-through condition fails and the network fetch replaces the caller's cap entirely (old Wallet.sendTransaction population kept provided fields and filled only missing ones). Currently unreachable — both renderer flows send a complete pair or nothing — so latent only; worth either honoring provided fields or asserting completeness.

Verification notes: touched unit suites pass at head 32f44fc (390/390 across wallet/x402/feed-store/identity/preload). Checked and clean: hw-app-eth sig reassembly (v normalization incl. EIP-155), explicit resolution: null (no hosted clear-signing — matches the stated privacy rule), transport queue serialization, error-code mapping, x402 signTypedData call shape (single-object, matches signer factory; BigInts handled by TypedDataEncoder.getPayload), window.ledger exposed only to the trusted chrome preload (not webviews), R1 fix commit (feed-store guards + fee population) sound, all new innerHTML sinks escaped. The blocking dapp-tx race is reported from structural evidence (no code path disables Reject during the await; a live-device mid-sign state isn't reproducible in the headless harness).

meinharrd added a commit to solardev-xyz/alan-artifacts that referenced this pull request Aug 12, 2026
@meinharrd

Copy link
Copy Markdown
Contributor

[alan-review R1] (review of head b22f94f) Blocking — Ledger signTypedData routes every EIP-712 signature through Ledger's hosted services, violating the PR's own stated privacy rule — and does the un-timed HTTP calls while holding the exclusive device queue

src/main/wallet/ledger/signer.js:102 calls eth.signEIP712Message(record.path, payload) on an EthApp constructed with no loadConfig (src/main/wallet/ledger/transport.js:62, new EthApp(transport)). In hw-app-eth 7.8.8 that means the default load config (lib/services/ledger/loadConfig.js: calServiceURL = 'https://crypto-assets-service.api.ledger.com', cryptoassetsBaseURL = 'https://cdn.live.ledger.com/cryptoassets'), so every typed-data signature performs:

  • an axios GET {calServiceURL}/v1/dapps with chain_id, the dApp's verifyingContract address, and a schema hash of the typed-data types (@ledgerhq/evm-tools getFiltersForMessage — verified in the installed package), and
  • for app ≥ 1.11.1, a GET of the ERC-20 signatures blob from cdn.live.ledger.com.

The module header (signer.js:14-17) says transactions are deliberately signed without hosted clear-signing resolution because it "violates this project's rule against routing user data through hosted services" — but that opt-out (resolution: null) only covers signTransaction. The typed-data path leaks to Ledger's registry on every dApp eth_signTypedData_v4 and every x402 payment (EIP-3009 authorizations go through this exact seam), telling Ledger which contract/chain the user is transacting with, from the user's IP. This contradicts the PR description's headline privacy decision ("Ledger's hosted clear-signing resolution is deliberately not used").

Secondary harm: the axios calls have no timeout and run inside withEthApp while the transport is open and the module-global device queue is held — a hung connection (captive portal, blackholed route) stalls every subsequent Ledger operation, and with the signature-flight lock held by the waiting approval screen, the whole sidebar with it.

Fix: pass a load config that nulls the hosted endpoints (new EthApp(transport, 'w0w', { calServiceURL: null, cryptoassetsBaseURL: null, pluginBaseURL: null, nftExplorerBaseURL: null }) or setLoadConfig). Both fetches fail soft in hw-app-eth (catch → undefined/null → no filters), so nulling them does not break signing — the device just shows the message without clear-signing filters, exactly matching the transaction path's chosen tradeoff.

@meinharrd

Copy link
Copy Markdown
Contributor

[alan-review R1] (review of head b22f94f) Blocking — x402 approve() has no re-entrance or stale-continuation guard: a chooser flip mid-flight enables a double device sign, and after b22f94f's tab-death cancel a stale continuation releases a different card's signature lock under a live device prompt

Two distinct holes in src/renderer/lib/wallet/dapp-x402.js approve() (line 785), both ending in the same class of harm the signature-flight lock exists to prevent:

(a) Re-entrance via the accept chooser. approve() only checks if (!pending) return; — no pending.signing guard (contrast dapp-tx.js:356 / dapp-sign.js:334, which both check .signing). Pay/Reject/Back are disabled, but the chooser radios stay live, and the chooser change handler (line 273) calls renderCard(), which unconditionally re-enables Pay (line 467) while pending.signing === true. Scenario (multi-accept paywall, Ledger account): Pay → device shows payment A → user flips a radio → Pay re-enables (still labelled "Confirm on your Ledger…") → second click fires a second x402:approve; main's signAndQueueRetry has no concurrency guard, so a second real device sign starts for accept B while A is still up. Whichever call fails then hits restoreCardWithError()endSignatureFlight(pending) — the same token the live flight holds — releasing the global lock while the device confirmation is still pending, so every other surface (dapp-tx, dapp-sign, sidebar X, tab bar) is free to repaint over it, and the -32002 gate drops. Reproduced with a fake-DOM harness at head: Pay re-enabled mid-flight, x402Approve invoked twice, isSignatureInFlight() === false while the first IPC is unsettled.

(b) Stale mainFrame continuation clobbers the next card. For a mainFrame 402, approve()'s await x402Approve(...) spans the whole device wait. b22f94f newly allows the card to be torn down during that wait (tab death → handleApprovalResult({cancelled})closeAndReset() — the intended fix). But unlike handleApprovalResult (detectionId guard, line 851), the approve() continuation has no identity check: when the stale device prompt finally settles it operates on the module-global pending, which by then can be a new card B. A fails → restoreCardWithError sets B.signing = false, calls endSignatureFlight(B) (releases B's lock mid-device-prompt), re-enables Pay/Reject/Back and paints A's error on B. A succeeds → closeAndReset() tears B down entirely with B's detection still live in main. Scenario: card A → Pay → device prompt → close the paying tab (b22f94f frees the sidebar, as designed) → another 402 → card B → Pay on B → clear A's stale prompt on the device → A's continuation unlocks/destroys B mid-flight. Reproduced against the real module: isSignatureInFlight() false after A's failure continuation while B shows "Signing…"; card hidden after A's success continuation while B was on screen.

Also worth fixing in the same pass: buildGrantPayloadFromInputs() (line 800) runs between beginSignatureFlight(pending) (791) and the try — any throw there leaks the lock permanently, which since 44eb174 bricks the entire sidebar chrome until restart.

Fix shape: if (pending.signing) return; in approve() and the chooser handler (or keep Pay disabled in renderCard() while signing); capture const flight = pending before the await and bail from the continuation when pending !== flight (mirroring handleApprovalResult's guard); move the lock take inside a try/finally like dapp-tx/dapp-sign.

@meinharrd

Copy link
Copy Markdown
Contributor

[alan-review R1] (review of head b22f94f) Blocking — the one Ledger error this PR's design guarantees users will hit (blind signing disabled) maps to LEDGER_UNKNOWN with the actively wrong instruction "Reconnect the device and try again"

The PR deliberately signs with resolution: null (no hosted metadata — see the privacy decision), so any contract call — every ERC-20 send, every dApp contract interaction, on a device with factory-default settings — makes the Ethereum app return APDU 0x6a80. hw-app-eth 7.8.8 remaps that (remapTransactionRelatedErrors, lib/Eth.js:38-43) to EthAppPleaseEnableContractData ("Please enable Blind signing or Contract data in the Ethereum app Settings") with no statusCode property.

src/main/wallet/ledger/errors.js has no mapping for it: STATUS_TO_CODE (line 49) has no 0x6a80 entry, NAME_TO_CODE (line 61) has no EthAppPleaseEnableContractData, and no message regex matches — so classifyLedgerError returns LEDGER_UNKNOWN and the user sees "Ledger error. Reconnect the device and try again." Verified empirically against the installed packages:

e2 name: EthAppPleaseEnableContractData | statusCode: undefined
mapped2 code: LEDGER_UNKNOWN | message: Ledger error. Reconnect the device and try again.

Failure scenario: fresh Ledger (blind signing off — the factory default), user tries a USDC send (or any dApp tx / x402 payment with calldata). The device shows nothing signable, the app fails, and the UI tells them to reconnect the device — which cannot help. Reconnect-retry loops forever; the actual fix (enable Blind signing in the device's Ethereum app settings) is never surfaced anywhere, even though signer.js's own header comment (lines 18-20) predicts exactly this situation. This is a guaranteed dead end on a core advertised flow ("ERC-20 sends … may need blind signing enabled" — the PR description), not an exotic path.

Fix: add a dedicated code (e.g. LEDGER_BLIND_SIGNING_DISABLED) with the instruction "Enable Blind signing in the Ethereum app's settings on your Ledger, then retry", mapped from the EthAppPleaseEnableContractData name and from statusCode 0x6a80 (the message-signing paths signPersonalMessage/signEIP712* are not remapped by hw-app-eth and surface the raw TransportStatusError with statusCode: 0x6a80, so both entries are needed).

@meinharrd

Copy link
Copy Markdown
Contributor

[alan-review R1] (review of head b22f94f) Minor findings (combined):

  1. src/main/wallet/ledger/errors.js:61 — the normal "no device attached yet" state shows "Ledger error. Reconnect the device and try again." instead of the message written for it. TransportNodeHid.open('') with no device throws TransportError('NoDevice','NoDevice') (name TransportError, discriminator on .id, which classifyLedgerError never checks; the /no device/i regex misses the space-less "NoDevice"). So LEDGER_DEVICE_NOT_FOUND — and its "No Ledger device found. Connect it via USB and unlock it." copy — is unreachable in its primary scenario, and the connect screen's detect loop shows the wrong instruction the entire time before the user plugs in. Verified in the real app (harness, real IPC, no device):
    connect-ledger-nodevice
    Fix: check err.id in the name lookup (or add /nodevice/i). Borderline blocking since it misleads on every first run of the connect flow; grading minor because the flow self-heals once a device appears.

  2. src/main/x402/intercept.js:557notifyApprovalCardsCancelled misses the detection whose signature is on the device when a newer 402 superseded the map slot. detectedPayments holds only the latest detection and the mid-signature card's pendingApprovals entry was removed by the Pay-click settle; if a second 402 fired on the tab while the device prompt was up (renderer refused its card, so the old card is still on screen), tab death cancels only the newer detectionId — the on-screen card ignores it (detectionId guard) and stays frozen in "Signing…" holding the sidebar lock until the user physically resolves the device prompt. b22f94f's "closing the paying tab frees the sidebar" guarantee doesn't hold for this case (the bound-host delivery fallback eventually settles it, so it's not a permanent leak). Reproduced with a unit repro: cancel events on cleanupWebContents = ["req-1002"], missing the in-flight req-1001.

  3. src/main/x402/intercept.js:1008 — the subresource retry loop re-arms setPendingApproval after the tab is destroyed. Tab dies mid-sign → cleanupWebContents aborts pending approvals → the sign-failure catch falls through and creates a fresh entry for the dead tab that nothing can abort; it sits the full 5-min TTL and, combined with (2)'s zombie card, a Pay click on that card settles it approved:true and drives another Ledger prompt for a payment that can never be delivered. Bail out of the loop when the tab is gone before re-arming.

  4. src/main/wallet/transaction-service.js:258 — nonce is fetched before the device wait, so queued/concurrent sends from one account collide. Two auto-approved dApp sends back-to-back (dapp-provider.js auto-approve takes no signature-flight lock) both read getTransactionCount(from,'pending') before either broadcasts → same nonce; on a Ledger the device queue serializes signing but not nonce acquisition, so tx B is always signed stale after the device wait. Worst case: fees resolved independently rose ≥10% → B silently replaces A, which was already reported as success and recorded in payment history (A never lands; its row pends forever). The race pre-exists on main for vault sends, but the device wait widens it from milliseconds to minutes and makes it deterministic for queued device txs. Reproduced with a mocked provider + device-queue signer. Fix: fetch the nonce inside the device-serialized section, or a per-account send mutex.

  5. src/main/identity-manager.js:157 — the never-reuse hardware index counter doesn't survive vault recreation, but dapp-permissions.json does. createNewVault/importExistingMnemonic rewrite vault-meta from scratch (dropping nextHardwareWalletIndex and the Ledger records that serve as its high-water mark) while dApp permissions are never cleared on vault lifecycle. Delete vault → recreate → re-add a Ledger → it gets index 1000000 again and any stale origin permission (incl. auto-approve rules) rebinds to the new device account without a grant. Re-opens the 16fbe52/ce31333 invariant via a path those fixes don't cover; physical device confirmation still gates actual signing, hence minor. Clear permissions (or persist the counter) across vault recreation.

  6. src/main/wallet/transaction-service.js:295 — error remapping strips LEDGER_* codes and mislabels broadcast-phase failures. All sign/broadcast errors funnel through substring remapping: an on-device decline loses its code (so auto-approved dApp flows can never map to EIP-1193 4001 — sibling of the known open 4001 minor), and a broadcast rejection containing "gas" ("max fee per gas less than block base fee") becomes "Gas estimation error. The transaction may fail." right after the user confirmed on-device — implying it went out when it never broadcast. Also: signed-but-broadcast-failed txs are recorded nowhere, so an ambiguous broadcast (timeout after node acceptance) can land on-chain with the UI reporting failure and no history row.

  7. src/main/identity-manager.js:89 — vault-meta is now the sole home of unrecoverable data but written non-atomically. Pre-PR everything in vault-meta was re-derivable from the mnemonic; Ledger records (address + path) and the never-reuse counter exist only here, and saveVaultMeta is an in-place writeFileSync. A torn write (crash mid-addLedgerWallet) loses all hardware accounts and re-enables index reuse (see 5). Write tmp + rename.

  8. src/renderer/lib/wallet/send.js:1212 — every mnemonic (software) send holds the global signature lock across an un-timed RPC broadcast. handleSendConfirm takes the lock unconditionally; ethers' default fetch timeout is 300 s (plus retry), during which the sidebar X/toggle/tab bar are disabled with the tooltip "Finish the confirmation on your device first" and dApp requests get -32002 — for a pure software signature stuck on a slow RPC. Take the lock only for hardware accounts, or make the copy signature-type-aware and bound the provider timeout.

  9. src/main/wallet/ledger/transport.js:21 — partial lazy-load latch. If require('@ledgerhq/hw-transport-node-hid') succeeds but require('@ledgerhq/hw-app-eth') throws once, TransportNodeHid is non-null so loadLedgerLibs never retries and every later device call permanently fails "EthApp is not a constructor" (LEDGER_UNKNOWN). Also loadLedgerLibs() at line 54 sits outside any mapLedgerError wrapper, so native-load failures reach the renderer with no LEDGER_* code.

  10. src/main/wallet/signers.js:42normalizeMessage silently mangles malformed 0x-hex personal_sign input. Buffer.from(hex,'hex') truncates at the first invalid char and drops a trailing odd nibble ('0xabc' signs bytes 0xab; '0xzz…' signs the empty message) — a valid signature over different bytes than the dApp sent, on both backends. Validate the hex (even length, [0-9a-f]) and otherwise treat as UTF-8 (MetaMask behavior) or reject.

  11. Two latent, fail-loud/latent gaps, noted for completeness: signers.js:51 ignores the dApp-declared primaryTypeTypedDataEncoder.getPayload throws "ambiguous primary types" on wire-valid payloads carrying extra unreferenced type definitions (both backends, fails loudly, no mis-sign); ledger/transport.js:91 listAccounts with a non-numeric start through the unvalidated IPC options returns {success:true, accounts:[]} instead of an input error (current renderer always passes numbers).

Verification notes for this round: full unit suite at head passes (2230 passed; only the known pre-existing vault auto-locks after timeout flake). v/r/s signature reassembly re-verified empirically (type-2 + legacy EIP-155 incl. large chain IDs, byte-identical to ethers); the EIP-712 hashed fallback matches TypedDataEncoder.hash including partial domains; the b22f94f hostSenders lifecycle, the ce31333/fd0f65f/611efaf/44eb174 lock guards, wallet/chain switchers mid-flight, x402:cancel/reject on dead ids, EIP-3009 nonce/validity construction, cap accounting on tab death, and the hardware-index fail-closed guards (incl. string-index IPC probes) all checked out sound. Previously-reported open minors not re-listed.

meinharrd added a commit to solardev-xyz/alan-artifacts that referenced this pull request Aug 12, 2026
@meinharrd

Copy link
Copy Markdown
Contributor

[alan-verify R1] All four findings CONFIRMED, each reproduced against the code as written (b22f94f).

R1-F1 — CONFIRMED (empirical). Drove the real createLedgerBackend(...).signTypedData() with a stub transport in place of ./transport, an x402-shaped EIP-3009 TransferWithAuthorization payload, and axios.get patched to record instead of send (both the top-level and the @ledgerhq/evm-tools nested axios). One outbound request fires before any signing APDU:

https://crypto-assets-service.api.ledger.com/v1/dapps
  ?output=eip712_signatures&eip712_signatures_version=v2
  &chain_id=0x2105&contracts=0x833589fcd6edb6e08f4c7c32d4f71b54bda02913

new EthApp(transport) leaves loadConfig = {}, and getLoadConfig fills in calServiceURL + cryptoassetsBaseURL defaults (hw-app-eth 7.8.8, lib/services/ledger/loadConfig.js), so signEIP712MessagegetFiltersForMessage hits Ledger's CAL and findERC20SignaturesInfo then fetches cdn.live.ledger.com/cryptoassets/evm/<chainId>/erc20-signatures.json. signTransaction correctly passes resolution: null and stays offline; typed data is the gap. Minor correction to the finding's wording: the schema hash is computed locally and used to index the response — what leaves the machine is chain id + verifyingContract (+ the user's IP). The un-timed axios call inside withVerifiedDevice does hold the exclusive deviceQueue (axios has no default timeout).

R1-F2 — CONFIRMED (e2e, real renderer + real app). Multi-accept card, Ledger account, hanging x402:approve: after Pay the chooser radios are not disabled, and flipping one runs renderCard(), which unconditionally sets approveBtn.disabled = false for a fundable selection. The button stays labelled "Confirm on your Ledger…" and is fully clickable; a second click issues a second x402:approve for the other accept while the first prompt is live:

APPROVE CALLS: [{"webContentsId":7,"detectionId":"det-1","selectedAcceptIndex":0},
                {"webContentsId":7,"detectionId":"det-1","selectedAcceptIndex":1}]

On the mainFrame path that second call falls through the staleness check (stored.detectionId still matches) into signAndQueueRetry — a genuine second device sign. Resolving either call with a failure runs restoreCardWithErrorendSignatureFlight(pending) on the shared token: isSignatureInFlight() went false and Back re-enabled while the first device prompt was still standing. Same repro also passes as a unit test against the real module.

f2-pay-rearmed-mid-flight

R1-F3 — CONFIRMED (unit test against the real module). approve()'s continuation reads the module-global pending after the await with no identity check. Sequence: mainFrame card A → Pay (hangs on device) → x402:approval-result {cancelled:true} (b22f94f's new tab-destroy path — notifyApprovalCardsCancelled includes the tab-keyed detectedPayments entry, i.e. mainFrame cards) tears A down and releases the lock → card B renders and takes the lock on its own Pay → A's IPC finally settles → A's continuation ran restoreCardWithError against B: B's error row showed A's "Rejected on the Ledger device.", B's Back re-enabled, and isSignatureInFlight() flipped to false with B's device prompt live. A success settle instead hits closeAndReset() and hides B outright.

R1-F4 — CONFIRMED (empirical). Fed TransportStatusError(0x6a80) from the signing APDU through the real signer.js + errors.js for a USDC transfer() — the exact factory-default-device case this PR's resolution: null design guarantees:

code: LEDGER_UNKNOWN
user-facing message: Ledger error. Reconnect the device and try again.
cause: EthAppPleaseEnableContractData: Please enable Blind signing or Contract data in the Ethereum app Settings

hw-app-eth's remapTransactionRelatedErrors strips statusCode when it mints EthAppPleaseEnableContractData, so neither STATUS_TO_CODE[0x6a80] nor NAME_TO_CODE matches and the actionable instruction the library already produced is discarded. grep -rn "6a80\|EnableContractData\|blind" src/ finds no handling anywhere else.

Verdict: 4 confirmed, 0 refuted. Repo left clean (probe scripts and the temporary spec deleted).

Four confirmed review findings on the hardware-wallet PR.

R1-F1: hw-app-eth was constructed with no load config, so its defaults
applied and signTypedData routed every EIP-712 signature through Ledger's
hosted registry (crypto-assets-service.api.ledger.com): chain id,
verifying contract and a schema hash of the typed data left the machine
before the device was touched, from inside the exclusive device queue.
The transport now builds Eth with an offline load config that nulls every
service URL, which is what makes the library skip the lookups. Verified
empirically against the installed hw-app-eth: default config attempts one
request to crypto-assets-service; the offline config attempts none.

R1-F2: x402 approve() had no re-entrance guard, and a chooser flip
mid-signature re-rendered the card and re-enabled Pay — a second click
started a second, concurrent device signature. approve() now guards on
pending.signing (as dapp-tx/dapp-sign do), renderCard re-applies the
in-flight lock, and a chooser change while signing snaps the radio back
to the accept that is actually on the device.

R1-F3: the approve() continuation had no flight-identity check, so a
stale one (its card torn down by the cancelled-tab path) could release or
tear down the card that replaced it. approve() captures its request and
settles only that: endSignatureFlight is identity-scoped, and both the
success and failure continuations bail out when the card has moved on.

R1-F4: blind-signing refusals (APDU 0x6a80 / EthAppPleaseEnableContractData)
mapped to LEDGER_UNKNOWN and told the user to reconnect the device — the
one failure the null-resolution design guarantees on a factory-default
device. They now map to LEDGER_BLIND_SIGNING_REQUIRED with an instruction
naming the setting to enable.
meinharrd added a commit to solardev-xyz/alan-artifacts that referenced this pull request Aug 12, 2026
meinharrd added a commit to solardev-xyz/alan-artifacts that referenced this pull request Aug 12, 2026
@meinharrd

Copy link
Copy Markdown
Contributor

[alan-fix R1] All four confirmed findings fixed in de8a434.

R1-F1 — EIP-712 signing went through Ledger's hosted services (src/main/wallet/ledger/transport.js)
new EthApp(transport) left loadConfig at {}, and getLoadConfig fills the blanks with Ledger's hosted defaults — so signEIP712Message posted chain id, verifying contract and a schema hash to crypto-assets-service.api.ledger.com on every typed-data signature, from inside the exclusive device queue. Root fix: the transport builds every Eth with an OFFLINE_LOAD_CONFIG that nulls calServiceURL, cryptoassetsBaseURL, pluginBaseURL, nftExplorerBaseURL and extraPlugins — each library call site is guarded by its URL, so the lookups (and their un-timed axios calls) are skipped entirely.

Verified empirically against the installed hw-app-eth@7.8.8, real Eth class + a fake transport, with http(s).request intercepted:

default hw-app-eth config : 1 outbound HTTP attempt(s)
   → https://crypto-assets-service.api.ledger.com/v1/dapps?output=eip712_signatures&…&chain_id=8453&contracts=0x833589fc…
OFFLINE_LOAD_CONFIG       : 0 outbound HTTP attempt(s)

New tests: the Eth app is always constructed with that config, plus a guard that resolves it through the library's own getLoadConfig and fails if any *URL key (including one a future version adds) comes back non-null.

R1-F2 — second concurrent device sign from the chooser (src/renderer/lib/wallet/dapp-x402.js)
approve() now has the if (!pending || pending.signing) return; re-entrance guard the dapp-tx/dapp-sign siblings already had; renderCard() re-applies the in-flight control state (extracted as lockCardForFlight()) so no re-render path — chooser flip, unlock re-check, balance update — can re-arm Pay under a live prompt; and a chooser change while signing repaints the rows from the pinned selection so the radio snaps back to the accept that is actually on the device (the first screenshot caught the card showing "DAI" selected while USDC was being signed).

R1-F3 — stale continuation settling the next card (src/renderer/lib/wallet/dapp-x402.js)
approve() captures const request = pending and passes it through: restoreCardWithError(request, error) and the success path both bail out when pending !== request, settling only their own flight (endSignatureFlight is identity-scoped, so a stale release is a no-op). A card A continuation that lands after b22f94f's cancelled-path teardown can no longer release card B's lock or hide B's card.

R1-F4 — blind signing disabled surfaced as "Reconnect the device" (src/main/wallet/ledger/errors.js)
Added LEDGER_BLIND_SIGNING_REQUIRED, mapped from APDU 0x6a80 and from the named EthAppPleaseEnableContractData error hw-app-eth rewrites the tx-signing path into (it carries no statusCode, asserted against the installed library in the test). Message: Ledger cannot display this contract call. Enable "Blind signing" (called "Contract data" on older app versions) in the Ethereum app settings, then try again.

Verification — full unit suite (2238 passed; only the known vault auto-locks after timeout flake), full harness e2e suite (31 passed), eslint clean. Three new unit tests and two new e2e tests; all five fail against the pre-fix code.

Acceptance evidence, real app, test-e2e/x402-payment-in-flight.spec.js:

Chooser flipped mid-payment — the radio stays on the accept the device holds, details still read 2.5 USDC, Pay stays "Confirm on your Ledger…" and disabled, Reject/Back inert, and main sees exactly one x402:approve:

chooser-flip-frozen

Blind-signing refusal (message produced by the real main-process mapper) — actionable instruction, card usable again for a retry:

blind-signing-instruction

One note for the author: the PR description's clear-signing paragraph now understates the change — the no-hosted-services rule is enforced for typed data as well as transactions, via the transport's load config. Worth a line before merge.

@meinharrd

Copy link
Copy Markdown
Contributor

[alan-review R2] Blocking — CI is red at head de8a434: the PR's own libudev-dev install step 404s on stale package lists, exactly as the earlier apt-get minor predicted

.github/workflows/ci.yml:161,231,285,337 — all four Install Linux native USB build dependency steps run sudo apt-get install -y libudev-dev with no preceding sudo apt-get update. At head, three ubuntu jobs fail in ~15s with:

Err:2 mirror+file:/etc/apt/apt-mirrors.txt noble-updates/main amd64 libudev-dev amd64 255.4-1ubuntu8.16
  404  Not Found
E: Unable to fetch some archives, maybe run apt-get update or try with --fix-missing?

Failing checks right now: e2e-address-bar-clipboard (ubuntu-latest), e2e-ant, e2e-profiles (ubuntu-latest) (run 31628550415). The runner image's cached package index points at a libudev-dev version the mirror no longer serves; whether a given job passes depends on which runner image it lands on, so this will keep flapping (and currently blocks a green merge).

This was raised as R1 minor #4 ("cheap to add sudo apt-get update && for flake-proofing") and never applied; it has since materialized as a hard failure, so re-grading blocking.

Fix: sudo apt-get update && sudo apt-get install -y libudev-dev in all four places.

@meinharrd

Copy link
Copy Markdown
Contributor

[alan-review R2] Blocking — x402 EIP-3009 validBefore is stamped before the Ledger device wait: a confirmation slower than the server's maxTimeoutSeconds (typically 60s) signs an already-expired authorization that is guaranteed to be rejected — after the user physically confirmed the payment

src/main/x402/sign-flow.js:98createX402Client (src/main/x402/client.js:54) → vendored @x402/evm createEIP3009Payload (node_modules/@x402/evm/dist/cjs/exact/client/index.js:141-150):

const now = Math.floor(Date.now() / 1e3);
const authorization = {, validBefore: (now + paymentRequirements.maxTimeoutSeconds).toString(),};
const signature = await signEIP3009Authorization(signer, authorization, );   // ← open-ended device wait

validBefore is computed from wall-clock time at call entry, and only then does signTypedData run — which with this PR's Ledger backend is the open-ended physical confirmation (multi-screen EIP-712 review; possibly a detour into the device settings to enable blind signing first, the exact flow the new LEDGER_BLIND_SIGNING_REQUIRED message instructs). intercept.js:189's own comment notes the window is "typically the requirements' maxTimeoutSeconds, default 60s".

Failure scenario: paywall card → Pay → "Confirm on your Ledger…" → user takes >60s on the device (reading the payload, or enabling blind signing mid-flow and retrying navigation) → signature succeeds, header stashed (setPendingPayment) → retry rides out with validBefore already in the past → facilitator/contract rejects, server re-402s → the loop guard (intercept.js:832: awaitingResponse.has(details.id) → "not re-signing") passes the 402 through and logs a failed payment row. Nothing re-checks the authorization window or re-signs: the user physically confirmed a payment that then deterministically fails, and recovery requires a full reload + a second device confirmation. Pre-PR this was unreachable — vault signing completes in milliseconds; the PR's device wait makes the expiry window routine. (Same escalation logic that made the earlier 0-fee-signing finding blocking. Distinct from the known-open nonce-before-device-wait minor, which is transaction-service; this is the x402 authorization window.)

Fix direction (writer's call): after the signature resolves, check validBefore against now + expected-settle margin and either re-sign automatically with a fresh window (one more device prompt, explained in the card) or fail fast with an actionable "confirmation took too long — pay again" instead of sending a doomed retry; and/or floor the stamped window at sign time (max(maxTimeoutSeconds, LEDGER_CONFIRM_ALLOWANCE)) for hardware signers if the facilitator contract only requires validBefore > settle time. The stamp lives in the vendored client, but sign-flow.js can wrap the requirements it passes in (maxTimeoutSeconds override) or validate the returned payload's authorization.validBefore before setPendingPayment.

@meinharrd

Copy link
Copy Markdown
Contributor

[alan-review R2] Minor findings (combined):

  1. src/renderer/lib/wallet/wallet-settings.js:100 — the settings screen offers "Export Private Key" for Ledger accounts and walks the user through a full vault unlock before the inevitable refusal. openWalletSettings() never checks activeWallet.type; the export section renders unconditionally, and configureExportPkUnlockUI() even auto-fires the Touch ID prompt after 100ms (line ~218). The user authenticates — window.identity.unlock() actually unlocks the vault as a side effect — and only then does the main-side guard (identity-manager.js:~1391, correct) refuse with "Hardware wallet accounts have no exportable private key". Hide/disable the export section for type: 'ledger' (the PR description says export "is blocked for them" — main-side it is; the UI wasn't adapted).

  2. src/renderer/lib/wallet/wallet-settings.js:145 — delete confirmation text is wrong for Ledger accounts. "The wallet can be recovered from your mnemonic phrase…" is false for a device-read account (nothing is derivable locally), and deletion now also permanently revokes its dApp permissions with a never-reused index (revokePermissionsForWalletIndex). The dialog invites a deletion the user believes is trivially reversible. Branch the copy on wallet.type.

  3. src/renderer/lib/wallet/connect-ledger.js:229 (handleAddAccount) — Back stays live during "Adding…", so cancelling mid-add still switches the active wallet afterward, with stale balances. Only submitBtn is disabled during the addAccount IPC. Click Back mid-flight → closeConnectLedger() runs with accountAdded === false (no loadDerivedWallets/refreshBalances), identity view restored → the in-flight continuation then completes: pushes the wallet, setActiveWallet(newIndex), repaints the selector header with the Ledger account — over a balance panel still showing the previous wallet. Disable Back during the add (and/or guard the continuation on the screen still being open, per the established staleness-token pattern).

  4. src/main/x402/sign-flow.js:129 — the from-the-map path clears the current tab slot after an arbitrarily long device wait, deleting a newer detection it never signed. The if (!opts.detection) guard protects only the snapshot path, but the x402:approve mainFrame path (ipc.js passes no detection) sources from the map, awaits the device (minutes on a Ledger), then clearDetectedPayment(webContentsId) unconditionally deletes whatever occupies the slot now — the detector keeps detectedPayments.set-ing new 402s during the wait (intercept.js:817; the renderer merely suppresses their cards while the flight lock is held). A subresource 402 that fired mid-wait loses its detection: no card, tab-keyed x402:get-details fallback gone, its held-open fetch strands until the 5-min TTL, and only a reload re-offers the charge. The function's own comment (lines 124-127) states exactly why clearing a replaced slot is wrong. Fix: snapshot the map entry at entry and clear only if the slot still holds that same detection (compare detectionId).

  5. src/main/identity-manager.js:~1004 (addLedgerWallet duplicate check) — compares only wallet.address, missing the index-0 legacy-meta fallback. Both getWalletRecord and getDerivedWallets fall back to meta.addresses.userWallet for a derivedWallets[0] record persisted without an address field (legacy metas from old rename/delete paths); the duplicate guard doesn't. A vault whose mnemonic is the Ledger's own seed (main wallet address == device's Ledger-Live account 0) with such a legacy meta lets the user add a second account with the identical address — the exact case the guard exists to reject — after which selectors/dApp exposure/history show two indistinguishable accounts with different signing backends. Reuse the same address-resolution helper in the guard.

  6. src/main/wallet/ledger/transport.js:113 (listAccounts) — PATH_SCHEMES[scheme] is a prototype-chain lookup. scheme: "constructor"/"toString" passes the if (!pathScheme) existence check and only dies later (pathScheme.buildPath is not a function) after the HID transport opened, surfacing as the misleading generic LEDGER_UNKNOWN "Reconnect the device". Trusted-chrome caller only, fails closed — robustness. Use Object.hasOwn(PATH_SCHEMES, scheme). (The sibling non-numeric-start → silent {success:true, accounts:[]} gap is already tracked from an earlier round.)

  7. src/renderer/lib/wallet/dapp-x402.js:814 — the "lock take outside try/finally" sub-claim of the previous round's x402 finding was not addressed in de8a434. buildGrantPayloadFromInputs() (and lockCardForFlight/hideError) still run between beginSignatureFlight(request) and the try; a throw there leaks the lock and — since 44eb174 — bricks the sidebar chrome until restart. I verified the throw is currently unreachable (decimals comes from the strict local token allowlist, and the cap input's snap-back change fires before any Pay click can), so this stays latent/minor — but it's one refactor away from the R3 lock-leak class. Cheap to wrap in try/finally like dapp-tx/dapp-sign.

Verification notes for this round: the four de8a434 fixes check out — OFFLINE_LOAD_CONFIG verified against the installed hw-app-eth 7.8.8 (getLoadConfig spread preserves explicit nulls; getFiltersForMessage and findERC20SignaturesInfo both skip the network on a null URL and fail soft to no-filters, so signing still works; new EthApp(transport, undefined, cfg) correctly hits the scrambleKey default), the approve() re-entrance guard + lockCardForFlight re-application in renderCard + identity-scoped settleFlight are correct across the cancelled-mid-flight interleavings I traced (applyFreshBalances already skips while signing, so the insufficient-state early-return can't strip the lock), and LEDGER_BLIND_SIGNING_REQUIRED maps both the named error and raw 0x6a80. Locally at head: the 4 touched unit suites pass (48/48) and the 7 flight-lock e2e specs pass (dapp-tx-ledger-confirm + x402-payment-in-flight, harness, xvfb). Hardware-guard probe classes (string/NaN/float indexes, __proto__ records, webview exposure of window.ledger, wrong-device verification) all fail closed. Previously-raised open minors are not re-listed.

@meinharrd

Copy link
Copy Markdown
Contributor

[alan-verify R2] Both findings survive refutation — confirmed.

R2-F1 — .github/workflows/ci.yml:161 (libudev-dev without apt-get update): CONFIRMED.
Not a prediction — it's already red at head (de8a434, run 31628550415). All three failures are the same step, exit 100:

Err:2 mirror+file:/etc/apt/apt-mirrors.txt noble-updates/main amd64 libudev-dev amd64 255.4-1ubuntu8.16
  404  Not Found [IP: 52.147.219.192 80]
E: Failed to fetch .../libudev-dev_255.4-1ubuntu8.16_amd64.deb  404  Not Found
E: Unable to fetch some archives, maybe run apt-get update or try with --fix-missing?

Failing: e2e-ant, e2e-address-bar-clipboard (ubuntu-latest), e2e-profiles (ubuntu-latest). The image's cached index pins a point release the mirror has since rotated out, so the .deb URL 404s — classic missing apt-get update. Note e2e-onboarding-identity (ubuntu-latest) passed in the same run on the identical step: that's the "flaps by runner image" character, not evidence the step is fine.
Fixer note (sibling-sites lesson): there are four identical call sites — lines 161 (e2e-onboarding-identity), 231 (e2e-ant), 285 (e2e-address-bar-clipboard), 337 (e2e-profiles). Fix all four in one commit (sudo apt-get update before the install), not just the three currently red.

R2-F2 — src/main/x402/sign-flow.js:98 (EIP-3009 validBefore stamped before the device wait): CONFIRMED.
Verified in the SDK source rather than by inference: @x402/evm createEIP3009Payload computes validBefore = now + maxTimeoutSeconds and then await signer.signTypedData(...) (node_modules/@x402/evm/dist/cjs/exact/client/index.js; the V1 scheme does the same). The wall-clock human wait therefore sits entirely inside the validity window. Empirical probe with a signer that blocks longer than maxTimeoutSeconds (2s window, 3s "device" wait — same shape as 60s/90s, just faster to run):

{ "stampedAt": 1786561039, "validBefore": 1786561041,
  "signatureReturnedAt": 1786561042, "expiredOnArrival": true, "secondsAlreadyExpired": 1 }

Refutation attempts that failed:

  • "Something aborts the device wait before 60s." No — there is no timeout anywhere in src/main/wallet/ledger/ (transport.js, signer.js, ipc.js) or on the x402 approve path; withVerifiedDeviceeth.signEIP712Message blocks until the user confirms or rejects. The 0x6d00 blind-signing fallback (signer.js:106-113) issues a second device prompt, making >60s more reachable, not less.
  • "The stale signature gets dropped before it goes out." No — PENDING_TTL_MS (intercept.js:194) and the expiresAt check at intercept.js:1187 are both stamped after signing, so a 90s-old-authorization payload has a fresh 60s TTL and rides out.
  • "It recovers by re-signing." No — the retry carries the same details.id the injector armed at intercept.js:1204, so the loop guard at intercept.js:832 matches on the server's second 402 and returns null (not re-signing), logging a failed receipt instead.
  • "Pre-existing, not this PR." No — the vault path resolves unlock before signAndQueueRetry (pendingUnlockWaits), so the stamp→signature gap was previously sub-millisecond. This branch is what puts unbounded human latency inside that window; maxTimeoutSeconds default 60s is the repo's own stated assumption (intercept.js:188-189).
    Worst case is the one described: a physically-confirmed on-device signature that can never settle, no auto-recovery, second device prompt after a reload.

No visual evidence attached: F1 is a CI-log fact and F2 needs a physical Ledger (the harness e2e project has no device fixture), so the SDK probe above is the empirical layer. Probe script was throwaway and is deleted; no repo state changed.

…ization

R2-F1: all four Linux `libudev-dev` installs ran against the runner image's
pre-seeded package index, which can name a build the mirrors have already
rotated out — the fetch 404s and three ubuntu jobs fail before they start.
`apt-get update` first, at every site.

R2-F2: the x402 SDK stamps `validBefore = now + maxTimeoutSeconds` before it
asks the signer to sign, and on a Ledger that call blocks for as long as the
user takes to review the EIP-712 payload. A confirmation slower than the
server's window (often ~60s) yields a valid signature over an already-expired
authorization; dispatching it burns the charge — the facilitator refuses it,
the server re-402s, the loop guard declines to re-sign, and a `failed` row
lands after the user physically confirmed on the device.

Check the runway left on the signed authorization against the facilitator's
own `validBefore < now + 6` rule and throw before anything is stashed or
navigated. The detection stays in the map and no pending payment is armed, so
the approval card shows the error with Pay live again — a second device
prompt, but no reload and no bogus failure row. Widening the window instead
isn't available: v2 echoes the selected requirements as `payload.accepted`,
which the server deep-equals against its own accepts.
meinharrd added a commit to solardev-xyz/alan-artifacts that referenced this pull request Aug 12, 2026
@meinharrd

Copy link
Copy Markdown
Contributor

[alan-fix R2] Both confirmed findings fixed in 948df10.

R2-F1 — libudev-dev install 404s without apt-get update (.github/workflows/ci.yml)
The failure is exactly the stale-index case: the runner image's pre-seeded index named libudev-dev 255.4-1ubuntu8.16, which the mirror no longer serves —
E: Failed to fetch mirror+file:/etc/apt/apt-mirrors.txt/pool/main/s/systemd/libudev-dev_255.4-1ubuntu8.16_amd64.deb 404 Not Found in e2e-ant, e2e-address-bar-clipboard (ubuntu-latest) and e2e-profiles (ubuntu-latest) at run 31628550415.
Fixed by running sudo apt-get update before the install at all four sites (the fourth, e2e-onboarding-identity, happened to pass on its runner image — same latent bug, same fix). Verified the workflow still parses and that all four steps carry the update.

R2-F2 — EIP-3009 validBefore stamped before the device wait (src/main/x402/sign-flow.js)
Root cause confirmed in the SDK: @x402/evm's exact client builds validBefore = now + maxTimeoutSeconds and then awaits signer.signTypedData, which on a Ledger blocks for the whole on-device EIP-712 review. A confirmation slower than the window produces a valid signature over an already-dead authorization.

The obvious fix — padding maxTimeoutSeconds on the accept we sign — is not available: for x402 v2 the client echoes the selected requirements back as payload.accepted, and @x402/core's server does a deepEqual against its advertised accepts (paymentRequirementsMatchAccepted), so a padded window would be rejected as a non-matching requirement. That's now recorded in the comment so nobody re-attempts it.

So the fix stops the doomed dispatch instead: after signing, signAndQueueRetry measures the runway left on the authorization against the facilitator's own rule (validBefore < now + 6 → refuse, per @x402/evm's exact facilitator) and throws before setPendingPayment, clearDetectedPayment, the cap grant, and wc.loadURL. Consequences of the ordering:

  • no signature is armed for the injector → no retry goes out → the intercept.js:832 loop guard is never tripped and no failed row is logged;
  • the detection stays in the map and the approval card is restored with the message and Pay re-enabled → retry in place, no reload. A second device prompt is unavoidable (the authorization genuinely expired), but that's the only cost now.
  • payloads without a validBefore (non-EIP-3009 schemes) are left alone.

Tests: 4 new cases in sign-flow.test.js (expired; inside the 6s skew window; detection preserved for retry — seeded through the real detector; happy path still dispatches). Mutation-checked — 3 of the 4 fail with the guard disabled.

Visual acceptance evidence — the card after the guard fires, error inline, Pay and Reject live again:

x402-expired-retryable

Verification run locally: npx jest → 2242 passed, only the known pre-existing vault auto-locks after timeout flake failing; eslint . clean; test-e2e/x402-payment-in-flight.spec.js → 4/4 passed on the harness project. CI on 948df10 is still pending as I hand off — the apt fix is the thing to watch there.

@meinharrd

Copy link
Copy Markdown
Contributor

[alan-review R3] No blocking findings at head 948df10. Minor findings (combined):

  1. src/main/x402/sign-flow.js:41MIN_AUTHORIZATION_RUNWAY_SECONDS = 6 equals the facilitator's own threshold exactly, leaving zero margin for dispatch transit and clock skew. Verified the rule against the vendored @x402/evm facilitator (index.js:873 and :2034, both validBefore < now + 6): the R2-F2 gate is checked on the client's clock at sign-completion time, but the facilitator evaluates the same rule on its clock, seconds later — after the mainFrame wc.loadURL re-navigation, the server round-trip, and the server→facilitator verify hop. A device confirmation landing with ~6–10s of runway therefore passes the new gate yet can still be refused server-side, reproducing the exact burned-charge/failed-row scenario the fix exists to prevent (and a client clock a few seconds behind the facilitator widens the window). Cheap fix: raise the constant to ~15–30s — the only cost of a false positive is the same "try again" card the gate already produces.

  2. test-e2e/publisher-identity-selector.spec.js:88 — flaked again under a parallel full-suite run, and not with the documented launch-timeout signature. Running the five PR e2e specs together (harness, xvfb), it failed on the post-selection reopen assertion (.wallet-selector-list not visible after the second selector-button click) — an assertion flake under CPU contention, not the 20s app-launch timeout the alan-fix R2 note attributed the previous flake to. Passes in isolation (re-ran per the team lesson). It isn't in ci.yml's spec list so CI is unaffected, but two flakes in two loaded runs will train developers to ignore this suite; consider waiting on the dropdown state via expect.poll/locator auto-wait before the screenshot clip, or dropping the reopen-for-screenshot step from the assertion path.

CI note: head 948df10 currently shows one failed check — freedom-ipfs-native-addon (ubuntu-latest, linux-x64) — but it is unrelated infra: the Download IPFS native addon step got HTTP 503/socket hang-up from GitHub Releases fetching freedom-ipfs-node-electron41-linux-x64.tar.gz (run 31631058309); the three sibling platform jobs passed and the PR's own R2-F1 apt fix executed cleanly (Install dependencies succeeded on all Linux jobs). Needs a re-run before merge, not a code change.

Verification notes for this round: both 948df10 fixes check out — the runway check's payload.payload.authorization.validBefore shape verified against both the V2 (@x402/evm/exact/client) and V1 (exact/v1/client, wrapped unmodified by @x402/core's x402Version == 1 branch) payload construction, it fires before setPendingPayment/clearDetectedPayment/grant/navigation so nothing is stashed and the detection survives for retry, the error can't be mistaken for a vault-locked error, and the subresource approval loop re-arms correctly on it; apt-get update now precedes all four libudev-dev installs and the three npm ci --ignore-scripts jobs correctly need none. Also checked clean at this head: full jest suite locally (2242 passed, only the known pre-existing vault auto-locks after timeout flake), eslint ., the other four PR e2e specs (dapp-tx-ledger-confirm, send-ledger-confirm, sidebar-in-flight-chrome, x402-payment-in-flight — 10/11 with the one flake above, flaker green in isolation), signature-flight lock coverage swept across every subscreen opener (all guarded via refuseSubscreenWhileInFlight/assertNoSignatureInFlight; the wallet selector and its Connect-Ledger entry live inside sidebar-identity, which every approval screen hides, so no mid-flight wallet switch is reachable), and the getSigner/withVaultPrivateKey/export-private-key hardware guards at head. Previously-raised open minors (zero-fee pass-through, connect-ledger poll/staleness set, EIP-1193 4001 mapping, vault-meta fail-open, wallet-settings export/delete copy, sign-flow map-path clear, addLedgerWallet legacy-address dup check, prototype-chain scheme lookup, x402 lock-take outside try/finally) are all still present at this head and are not re-listed.

@meinharrd meinharrd added alan:clean alan loop finished: no confirmed blocking findings and removed alan:reviewing alan loop currently running on this PR labels Aug 12, 2026
…tor spec

1. MIN_AUTHORIZATION_RUNWAY_SECONDS 6 -> 20: the client-side runway gate
   matched the facilitator's bare 6s threshold exactly, leaving no margin
   for the re-navigation + server round-trip + verify hop, so a signature
   landing with ~6-10s of runway passed the gate yet could still be refused
   server-side (the burned-charge case the gate exists to prevent). A false
   positive only re-shows the 'try again' card.
2. publisher-identity-selector.spec: wait for the dropdown to settle closed
   before the reopen-for-screenshot toggle — under CPU contention a reopen
   click landing mid-close raced the toggle and left the list hidden.
@meinharrd

Copy link
Copy Markdown
Contributor

Fixed both R3 minors in be9ab94:

  1. Authorization runway marginMIN_AUTHORIZATION_RUNWAY_SECONDS 6 → 20. The gate matched the facilitator's bare 6s threshold exactly, so a signature landing with ~6–10s left passed the client check but could still be refused server-side after the re-navigation + round-trip + verify hop (the exact burned-charge case). 20s gives comfortable margin; a false positive only re-shows the 'try again' card. Freshness tests still 9/9.
  2. identity-selector e2e flake — now waits for the dropdown to settle closed (toBeHidden) before the reopen-for-screenshot toggle, so a reopen click can't race the mid-close state under CPU contention. Passes.

Lint clean. Note: the one red check on the prior head (freedom-ipfs-native-addon (ubuntu-latest)) was an unrelated HTTP 503 from GitHub Releases fetching the addon tarball — a fresh CI run on this head should clear it.

# Conflicts:
#	package-lock.json
#	package.json
#	src/main/preload.test.js
#	src/renderer/lib/sidebar.js
#	src/renderer/lib/wallet/swarm-connect.js
The main-merge regenerated package-lock.json from main's lockfile, which
re-resolved node-hid to compile its libusb backend from source; the e2e
jobs then failed at 'Install dependencies' on 'libusb.h: No such file'
(only libudev-dev is installed). Restore #149's original node-hid
resolution (verified byte-identical) so the working prebuilt/hidraw install
path is used again.
@meinharrd

Copy link
Copy Markdown
Contributor

The behind-main update surfaced a real CI regression I introduced and have now fixed in ddece55:

Cause: merging main regenerated package-lock.json from main's lockfile, which re-resolved node-hid (transitive dep of @ledgerhq/hw-transport-node-hid) to compile its libusb backend from source. The e2e jobs then failed at Install dependencies on libusb.h: No such file — the apt step installs libudev-dev (hidraw), not libusb-1.0-0-dev.

Fix: restored #149's original node-hid lockfile resolution (verified byte-identical to be9ab94), so the working prebuilt/hidraw install path is used again — the exact state your CI passed with before the merge.

Recommended follow-up (I couldn't push it — the bot token lacks workflow scope): add libusb-1.0-0-dev alongside libudev-dev in the four e2e Install Linux native USB build dependency steps in ci.yml, so the build is robust regardless of which hidapi backend node-hid selects on a given runner image. One-line change:

sudo apt-get install -y libudev-dev libusb-1.0-0-dev

node-hid can compile either its hidraw (libudev) or libusb backend
depending on the runner image; the e2e jobs only installed libudev-dev,
so a libusb-backend build failed on 'libusb.h: No such file'. Install
both dev libraries in the four e2e 'Install Linux native USB build
dependency' steps so the compile succeeds regardless of backend.
@meinharrd

Copy link
Copy Markdown
Contributor

Landed the recommended follow-up in e06b39e (pushed via SSH — the earlier block was the bot's OAuth token lacking workflow scope): the four e2e Install Linux native USB build dependency steps now install libusb-1.0-0-dev alongside libudev-dev, so node-hid's compile succeeds whether it selects the hidraw or libusb backend on a given runner image. Belt-and-braces on top of the lockfile-resolution restore.

@meinharrd
meinharrd merged commit 73ce29a into main Aug 12, 2026
85 of 90 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

alan:clean alan loop finished: no confirmed blocking findings needs review

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants