Skip to content

fix(solana): never return an unsigned or wrongly signed transaction - #1056

Closed
bryzettler wants to merge 4 commits into
mainfrom
fix/sign-txn-missing-key
Closed

bryzettler wants to merge 4 commits into
mainfrom
fix/sign-txn-missing-key

Conversation

@bryzettler

Copy link
Copy Markdown
Contributor

Problem

signTxn returned the transaction unsigned when a software account had no private key on the device. This happens when accounts sync through iCloud but the keychain entry does not, for example on a new phone or after a reinstall. The app then submitted the unsigned bytes, and blockchain-api rejected them with Transaction is not signed by its fee payer. The user saw a server error, not the existing "Private Key Not Found" prompt. Hotspot transfers, claims, votes, swaps and dApp signing were all affected.

Fix

  • Fail loudly on a missing key. The software path shows the existing checkSecureAccount restore prompt and throws restoreAccount.missingAlert.message.
  • Check that the key matches the address. A cached keypair is used only when its pubkey equals solanaAddress. Otherwise the app reloads it from SecureStore, which closes the stale window after an account switch. A stored key for another account throws.
  • Verify before returning. After a software, Ledger or Keystone signature, assertSignedBy checks with ed25519 that the user's slot verifies over the message bytes. A missing, zero or wrong-key signature now fails in the app, not at the server.
  • Browser. onMessage catches a signing error and posts signatureDeclined, so the dApp promise does not hang.
  • Testability. The signing logic moves into signAsAccount in src/solana/signing.ts, with device and storage calls passed in. signTxn keeps the solanaAddress check and the isRequiredSigner early return.

Tests

  • src/solana/__tests__/signing.test.ts adds 19 tests on real web3.js transactions and real keys. They cover: versioned and legacy txs, a missing key, a mismatched key, a stale cached key, a Ledger with no account index, Ledger and Keystone signatures by the user and by another key, an empty device signature, a message changed after signing, and a malformed signature.
  • Mutation check: I broke each new guard in turn, and the tests failed every time.
  • yarn lint: 0 errors. yarn test: 131/131 pass.
  • jest.config.js now transforms @noble/ed25519, because it ships ESM only.

Not tested on device

I did not run iOS or Android, and I did not run Ledger or Keystone end to end. Their path changes only by the added verify step.

Not in this PR

  • signMsg checks !accountIndex, so message signing on a Ledger at index 0 takes the software path.
  • In the browser, signAndSendTransaction signs with Promise.all. A missing key can show one alert per transaction.
  • The Ledger by-name BLE fallback does not check the device pubkey.

bryzettler and others added 4 commits October 14, 2026 10:40
signTxn returned the transaction unsigned when the software wallet's
private key was missing from the keychain. The app then submitted it,
and blockchain-api rejected it with "Transaction is not signed by its
fee payer". This happens when the account list syncs to a new device
through iCloud but the keychain entry does not.

- Throw on a missing key and show the existing "Private Key Not Found"
  restore prompt.
- Reload the key when the cached keypair is unset or belongs to another
  account, and throw if the key does not match the wallet.
- After any signer (software, Ledger, Keystone), verify the user's
  signature slot against the message before returning.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
signTxn now throws on a missing or wrong key. Catch it in onMessage and
post signatureDeclined so the dApp promise does not hang.

Also drop the dead undefined-keypair branch in signWithKeypair and pin
the verifyEd25519 catch with a malformed-signature test.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Move the wallet-type branch, key reload, missing-key path, device
signature checks and assertSignedBy out of the SolanaProvider callback
into signing.ts. Device and storage calls come in as callbacks, so the
tests use real transactions and keys with no React Native harness.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@bryzettler
bryzettler requested a review from a team as a code owner October 14, 2026 16:06
@bryzettler
bryzettler requested review from allenan and removed request for a team October 14, 2026 16:06
@bryzettler

Copy link
Copy Markdown
Contributor Author

Folded into #1055 so 2.15.13 ships as one PR.

@bryzettler bryzettler closed this Oct 14, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant