Skip to content

fix: read Solana v1 transactions and reject v1 dApp signing (2.15.13) - #1055

Merged
bryzettler merged 28 commits into
mainfrom
fix/tx-v1-read-support
Oct 15, 2026
Merged

bryzettler merged 28 commits into
mainfrom
fix/tx-v1-read-support

Conversation

@bryzettler

@bryzettler bryzettler commented Sep 22, 2026 •

Copy link
Copy Markdown
Contributor

Summary

Mainnet has enabled transaction v1 since epoch 1035 (2026-09-15), and v1 transactions are already landing in blocks. With web3.js 1.91.4 and maxSupportedTransactionVersion: 0, any v1 transaction in a wallet's history makes the RPC fetch fail.

 dApp sign request
   deserializeTransactionInputs        # VersionedTransaction.deserialize only
   hasUnsupportedTransaction           # reject anything not legacy or v0
+  isRequiredSigner (every tx)         # reject when the wallet does not sign
   sign sheet
   sendInSequence                      # one at a time, stop at first failure

Changes

  • Bump @solana/web3.js from 1.91.4 to 1.99.0, the first 1.x release that can decode v1.
  • Set maxSupportedTransactionVersion: 1 in getTxn, getTransactions and the activity history fetch (src/utils/solanaUtils.ts), and in the getParsedTransaction call in src/features/txnDelegation/useSolTxns.ts, which passed no version before. This must ship with the bump: 1.91.4's response schema only accepts 0 | 'legacy'.
  • dApp browser: web3.js 1.x can decode v1 but throws when it serializes v1 for signing. The browser now rejects every sign request that is not legacy or v0 before the sign sheet opens. walletStandard.js passes the reason to the dApp. Before this change, the dApp's promise hung.
  • dApp browser: a sign request that fails to decode, sign or send now rejects the dApp's promise with the error. Before this change, the promise hung on each of those failures.
  • dApp browser: signAndSendTransaction now signs and sends a batch one transaction at a time. Before this change, it sent the batch in parallel. If a send fails after the RPC accepted an earlier one, the error names the signatures already submitted, and the rest of the batch is not sent.
  • dApp browser: a sign request is now decoded with VersionedTransaction.deserialize only, the same way as the sign sheet. That call reads legacy transactions too. A request that the sign sheet cannot decode is rejected.
  • dApp browser: a sign request where the wallet is not a required signer of every transaction is now declined before the sign sheet opens. Before this change, the dApp got transactionSigned (or the batch was sent) with no wallet signature.
  • The decode step and the version guard moved to src/features/browser/dappTransactions.ts, with a test that runs legacy, v0, v1 and undecodable inputs through them. The sequential send is in the same file and has its own tests.
  • Version bump to 2.15.13.

Also folded in

These PRs are now part of this branch, so the 2.15.13 release gets one review:

Review fixes

  • Onboarding no longer throws while the HNT price is missing (useOnboardingBalances). When the wallet holds HNT and no price is available, the DC+HNT total reports loading (loadingMyDcWithHnt). Hotspot onboarding and location asserts wait instead of showing "insufficient funds", unless the DC balance alone covers the cost (waitingOnHntPrice in src/utils/dcWithHnt.ts). The location assert compares against the higher of the IOT and MOBILE costs while it waits, because the network is picked after the press.
  • signMsg now uses the same wallet-type and key-match logic as signAsAccount (signMessageAsAccount in src/solana/signing.ts). A software key that does not match the account, or a missing key, is refused. Ledger and Keystone accounts take the device path.
  • Message signing refuses bytes that decode as a Solana transaction message (isTransactionMessage). The dApp browser declines such a request before the sign sheet opens.
  • dApp browser: signMessage reads the message bytes from every shape the bridge delivers (messageBytes in dappTransactions.ts). Before this change, a Uint8Array message failed to decode and the request was declined.
  • dApp bridge: each request in walletStandard.js carries an id, and each reply carries the same id. A listener ignores replies for other requests. A second request while one is open is declined with "Request pending" (runOneRequest in dappRequestGate.ts). A request that arrives before the wallet is ready is declined with its id.
  • The sign sheet settles the previous request with false before it opens a new one, so no awaited show() hangs (signRequestResolver.ts).
  • The swap button is disabled when no route is found.
  • On devnet, entering a DC amount no longer sets the HNT input to 0.
  • DeserializedTransaction holds a VersionedTransaction only; the dead legacy branches are gone.
  • isRequiredSigner moved to src/solana/signing.ts, with tests for a non-payer signer, an absent wallet, and a wallet that is only a read-only account.

Private key restore

A user who restored their private key from the "Private Key Not Found" alert still got "not signed by its fee payer". This section fixes three defects in that flow.

  • The Solana provider cached the keypair by address. A restore keeps the address, so the cache held the missing key until the app restarted. A key-version counter in AccountStorageProvider now makes the provider load the key again after each key write.
  • Restore replaced the account record with only the alias and address, so it dropped derivationPath. When the record had no path, the fallback scan did not try the app's default path (m/44'/501'/0'/0') or sub-account paths. So after one restore, the next restore of a default-path account failed with "The words you entered do not match", even with the correct words. Restore now tries every stored path plus the same scan set that Import uses (matchRestoredAccounts in src/features/onboarding/import/accountRestore.ts). It keeps the record and saves the path where it found the key.
  • One set of words now restores every listed software account that the words hold a key for. Before this change, a restore repaired only the selected account. Ledger and Keystone accounts never get a software key.
  • The derivation-path helpers moved to src/utils/derivationPaths.ts, so jest can load them. useDerivationAccounts re-exports them.

Known limitation

The wallet still cannot sign v1 transactions. Signing needs web3.js 2.x or @solana/kit. HPL keeps wallet-signed paths on v0 until then.

Type-check note

@helium/onboarding@5.0.4 has .d.ts files that reference jito-ts/node_modules/@solana/web3.js, which is web3.js 1.77.4. Its ambient declarations hide the installed web3.js types, so tsc checks against 1.77.4 types. dappTransactions.ts has one cast as a workaround, with a TODO that points at helium/helium-js#385. The real fix belongs in @helium/onboarding.

Evidence

  • yarn lint: 0 errors
  • yarn test: 23 suites, 199/199 pass (all folded PRs merged). matchRestoredAccounts tests: when the default and sub-account paths are removed from the scan set, the default-path, sub-account, wrong-stored-path, sibling and hardware-wallet cases fail. New: signWithKeypair signs the wallet's slot of a versioned and a legacy transaction paid by another account; each test fails when the slot lookup is replaced with slot 0. isRequiredSigner tests fail when the check reads every account key instead of the signer keys.
  • iOS and Android bundles (expo export:embed) build
  • Mainnet v1 transaction 5hyG1Lbu5bL2FV13DdshJua5RK5Vj7nqTJZuVV8YwN6A2Awq2WdbcFb8DgPV87xCSWsJPPzHVYnfMKVTgNkLAxYC:
    • Before (flag 0): "Transaction version (1) is not supported by the requesting client"
    • After (flag 1): getTransaction and getParsedTransactions return version 1
    • serialize() throws, which confirms the browser must reject v1 up front
  • Not yet tested on a device. Device-only paths: the bridge id filtering in the WebView, the sign-sheet behaviour, Ledger and Keystone message signing, the onboarding and assert screens, and the restore flow (the key write, the alert, and the cache reload).

Merge Danger

Door: two-way

No storage, migration or on-chain change. A revert restores the old fetch flags and browser behaviour.

Blast Radius: wide

Every activity fetch, every dApp sign request, message signing for every wallet type, every private key restore and every reader of the provider's cached keypair, the dApp connect flow, the bridge protocol between walletStandard.js and the app, and the HNT→DC swap path change. A dApp that sends transactions the wallet does not sign now gets a decline instead of a silent pass-through. walletStandard.js ships in the app bundle, so the script and the app change together.

Follow-ups

  • Verify Ledger and Keystone message signatures against the account key before they return. This needs a device pass first: the check depends on what each device returns.
  • Treat an HNT oracle price of 0 or below as no price. Today it only produces a refusal.
  • Show the oracle error on the onboarding and assert screens while they wait for a price.

Mainnet enabled transaction v1 at epoch 1035. With web3.js 1.91.4 and
maxSupportedTransactionVersion 0, any v1 tx in a wallet's history fails
the RPC fetch.

- Bump @solana/web3.js 1.91.4 -> 1.99.0 (first 1.x that decodes v1)
- Set maxSupportedTransactionVersion: 1 on getTxn, getTransactions and
  the activity history fetch
- dApp browser: web3.js 1.x cannot serialize v1 for signing, so reject v1
  requests up front and pass the reason to the dApp instead of leaving
  its promise hanging

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@bryzettler
bryzettler requested a review from a team as a code owner September 22, 2026 20:33
@bryzettler
bryzettler requested review from allenan and removed request for a team September 22, 2026 20:33
@bryzettler
bryzettler requested a review from madninja September 22, 2026 20:33

@madninja madninja left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Confirmed the premise independently before reviewing the fix. That signature really is version 1 on mainnet at slot 449486272, and the same request with maxSupportedTransactionVersion: 0 returns exactly the documented -32015. So the problem is real and the three flag changes in solanaUtils.ts are right.

Coverage of the dApp path is complete: both handleSignAndSendTransaction and handleSignTransaction get the guard, and handleSignMessage has no transaction to check. Threading the reason through parsedData.error in walletStandard.js is the right shape, and it fixes a genuinely hung promise.

Three things to fix or answer before this ships.

1. The guard blocklists v1 instead of allowlisting what the wallet can sign. hasV1Transaction tests version === 1, so the first transaction version after this one decodes, passes the check, reaches serialize() and throws, and the dApp promise hangs again. That is the exact bug this PR fixes, reintroduced by the shape of the fix. Inverting it to reject anything that is not 'legacy' or 0 costs nothing and does not need revisiting when the next version lands. Worth renaming the constant to match, since the message would then be version-specific.

2. A transaction that decodes as neither still hangs the dApp. In deserializeTransactionInputs, a failed VersionedTransaction.deserialize falls through to Transaction.from(tx), and if that throws, the rejection propagates out of Promise.all with no postMessage, so nothing ever answers the dApp. That is pre-existing, but it is the same hung-promise failure this PR exists to close, and it sits three lines from the new guard. A try/catch that posts signatureDeclined with a reason would close the class rather than the instance.

3. @helium/spl-utils is pinned at 0.13.0, which still asks for max version 0. I checked the published tarballs rather than the changelog: 0.13.0, 0.13.1 and 0.13.2 all ship four maxSupportedTransactionVersion: 0 call sites, and 0.13.3 is the first with : 1 (0.13.4 is current). The call sites are in sendAndConfirmWithRetry, bulkSendRawTransactions and getAllTxns. This is latent rather than breaking today, since those read back transactions the wallet itself sent and web3.js 1.x cannot build a v1, so the app is not hitting it. But it means "the wallet reads v1" is true of solanaUtils.ts and not of the SDK underneath it, and the fix is a one-line bump to 0.13.4 in the same PR that claims v1 read support.

Two smaller notes.

The cast in hasV1Transaction is carrying helium-js#385's problem. Once 5.0.5 is published and picked up, the cast and its comment should go; worth a TODO tied to that PR so it does not outlive the cause.

The bump from 1.91.4 to 1.99.0 is not only web3.js. The lockfile takes rpc-websockets from 7.x to 9.x and superstruct from 0.14 to 2.0, both major, and rpc-websockets is the transport behind every Connection subscription in a React Native bundle. Combined with "not yet tested on a device", that is the risk in this PR rather than the flag changes. Worth exercising account and signature subscriptions on a real build before release, not just yarn test.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@bryzettler

Copy link
Copy Markdown
Contributor Author

07b7892 answers this review: 3 fixed, 1 declined, 1 answered.

# Finding Outcome Result
1 Guard blocklists v1 fix 07b7892
2 Undecodable transaction hangs the dApp fix 07b7892
3 @helium/spl-utils pinned at 0.13.0 decline package.json:60 already pins 0.13.3
4 TODO for the cast fix 07b7892
5 rpc-websockets and superstruct majors answer The lockfile had both majors before this PR. The app's own Connection is the part that changes.

1

1. The guard blocklists v1 instead of allowlisting what the wallet can sign.

The guard tested version === 1, so a later version passed it. 07b7892 inverts it. hasUnsupportedTransaction now rejects every versioned transaction that is not 'legacy' or 0. The constant is now UNSUPPORTED_VERSION_ERROR, and its text names no version.

src/features/browser/__tests__/dappTransactions.test.ts pins it with legacy, v0, v1 and a mixed v0 + v1 batch. The test fails if the guard returns false for v1, and fails if the allowed version changes.

One note: web3.js 1.99.0 does not decode a version above 1 at all. Today that input goes to the catch in item 2, not to this guard. The allowlist starts to matter when web3.js learns the next version.

2

2. A transaction that decodes as neither still hangs the dApp.

A failed decode rejected out of Promise.all with no postMessage. 07b7892 puts the body of handleSignAndSendTransaction and handleSignTransaction in one try/catch each. The catch posts signatureDeclined with the error message. This also covers a throw from the sign step and from sendRawTransaction, which hung the dApp in the same way.

The decode step and the guard moved to src/features/browser/dappTransactions.ts. The same test file shows that undecodable bytes reject in deserializeTransactionInputs.

Unproven: no test runs the handlers. The path from the catch through the WebView bridge to the dApp's promise was not run on a device.

3

3. @helium/spl-utils is pinned at 0.13.0, which still asks for max version 0.

Declined, because the direct dependency is not at 0.13.0. package.json:60 pins "@helium/spl-utils": "0.13.3" on main and on this branch. The installed copy has maxSupportedTransactionVersion: 1 at lib/cjs/transaction.js:279, :294, :448 and :471.

The 0.13.0 that you saw is real, but it is a nested copy. yarn.lock resolves @helium/spl-utils@^0.13.0 to 0.13.0 for several @helium/* SDKs. None of those SDKs calls sendAndConfirmWithRetry, bulkSendRawTransactions or getAllTxns in its lib/. The app's only call into those functions is src/utils/solanaUtils.ts:1922, which imports the top-level 0.13.3.

4

The cast in hasV1Transaction is carrying helium-js#385's problem.

07b7892 adds a TODO above the cast in src/features/browser/dappTransactions.ts. It says to drop the cast when @helium/onboarding 5.0.5 ships, and it links helium/helium-js#385.

5

The bump from 1.91.4 to 1.99.0 is not only web3.js.

The two majors are not new to the lockfile. yarn.lock on main already has rpc-websockets@^9.0.2 and superstruct@^2.0.2, because @helium/spl-utils asks for web3.js ^1.99.0. This PR's lock diff only merges the app's web3.js descriptor into that entry.

The runtime risk that you name is real. The app's own Connection (src/solana/SolanaProvider.tsx:260) now runs on rpc-websockets 9.x. Its subscriptions are onAccountChange at src/utils/solanaUtils.ts:513 and onLogs at :526. No test covers them, and the PR is not yet tested on a device.

Also in 07b7892

src/features/txnDelegation/useSolTxns.ts:145 passed no version to getParsedTransaction. It now passes maxSupportedTransactionVersion: 1. The review did not list this call.

@bryzettler
bryzettler requested a review from madninja October 8, 2026 16:23
bryzettler and others added 2 commits October 8, 2026 12:31
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@bryzettler

Copy link
Copy Markdown
Contributor Author

Two more commits landed after the review summary above. Both are in the dApp browser's sign path, and both come from a second pass over the fix for item 2.

2deaf07: send batches in sequence

Item 2's catch made handleSignAndSendTransaction answer the dApp on every failure. That exposed a case in the old parallel send: one transaction of a batch could go out while another failed, and the dApp got a plain decline. The handler now signs and sends one transaction at a time through sendInSequence in src/features/browser/dappTransactions.ts.

  • If the first send fails, the dApp gets that error, as before.
  • If a later send fails, the error names the signatures already sent, and the rest of the batch is not sent.

src/features/browser/__tests__/dappTransactions.test.ts pins the order, the stop at the first failure and the error text.

9efacae: decode the same way as the sign sheet

deserializeTransactionInputs tried VersionedTransaction.deserialize and fell back to Transaction.from. The sign sheet decodes with VersionedTransaction.deserialize only. The browser now does the same, so the wallet signs only what the sheet can decode. Legacy transactions still work, because that call reads them too. The same test file covers both cases.

Checks

  • yarn lint: 0 errors
  • yarn test: 123/123 pass
  • Not yet tested on a device. That includes a multi-transaction signAndSendTransaction.

The PR description has both changes.

bryzettler and others added 7 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>
Anchor 0.31.2 ships Solana v1 transaction parsing in AnchorProvider and
fetches failed txs with maxSupportedTransactionVersion 1 so program logs
come back instead of a parse error. It is published only as
@anchor-lang/core.

- Replace the direct dependency and all src imports
- Alias @coral-xyz/anchor@^0.31.0 (used by the @helium/* SDKs) to
  @anchor-lang/core so the app keeps a single anchor copy and the
  AnchorProvider/Program types line up across SDK boundaries
- Leave Pyth's @coral-xyz/anchor@^0.29.0 alone (older IDL format)
- Bump engines.node to >=20.18.0 per the release requirement

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
- Bump the 21 HPL packages and the four HPL resolutions to the
  published versions (spl-utils 0.14.1, idls 0.12.1,
  blockchain-api 0.16.1)
- Allow @solana/web3.js ^1.99.0 and @anchor-lang/core ^0.31.2
- Regenerate yarn.lock from the base branch

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- metro: send @coral-xyz/anchor (aliased to @anchor-lang/core) and
  @anchor-lang/core to one module path; Pyth keeps its anchor 0.29 copy
- AddGatewayBle: deserialize onboarding transactions with
  VersionedTransaction.deserialize in place of bufferToTransaction

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
bryzettler and others added 8 commits October 14, 2026 11:46
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Match the exact-pin style the repo used before the bump, in
dependencies and resolutions.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Node 18 is end of life, and the Agave 4.2 dependency bump sets
engines.node to >=20.18.0. Run CI on the lowest version that range allows.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Pin CI to Node 20.18.0, the engines floor, and update the README setup steps to Node 20.18.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- AddGatewayBle: pass unsigned onboarding transactions to toTransactionData so the wallet signs once.
- useLedger: drop the duplicate bs58 import and use base58.
- metro.config.js: update the dedup comment and add a resolver test for the anchor alias.
- Pin @helium/cron-sdk, @helium/tuktuk-idls and @helium/tuktuk-sdk to 0.1.1.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
HNT to DC fell through to Jupiter when the oracle price was unset, which
failed with "Swap routes missing". The price was fetched once per
connection, so one failed read left it unset.

Route HNT to DC to the mint-DC path every time and show the oracle
error instead. Poll the price every 5 minutes, retry after 30 seconds
on error, and drop a price older than 10 minutes. Disable the swap
button for HNT to DC while the price is missing.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Read the oracle without the persisted account cache, time out a hung
request, expire a price older than 10 minutes, keep the price object
when it is unchanged, and show a hint while the first price loads.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

@madninja madninja left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Items 1, 2 and 4 from my earlier review are fixed, and the new tests in dappTransactions.test.ts cover the allowlist, the undecodable-input rejection and the sequential stop-on-failure. On item 3, the reply was right when written. After the HPL bump no 0.13.x spl-utils remains: the copies are 0.14.1 and a nested 0.11.14 under @helium/onboarding 5.0.4 and @helium/react-native-sdk 4.0.4. That 0.11.14 copy still passes maxSupportedTransactionVersion: 0, and its helpers are called by those SDKs' submit paths, which the app does not use today. No change needed for it here.

Keeping changes requested for the folded-in work. I couldn't run the suite locally (no node_modules in my checkout), so the test-coverage points come from reading the code.

Before merge

  1. Onboarding can throw while the HNT price is missing. The new polling in Balance.tsx sets the price to undefined when it expires and when a refresh fails after it went stale, not only before the first fetch. networkTokensToDc returns undefined without a price, and src/hooks/useOnboardingBalances.ts:87 calls networkTokensToDc(...)!.div(...) during render, so onboarding throws for as long as the price is missing. Handle undefined there, and check the other networkTokensToDc callers (PaymentItem, BurnScreen, useImplicitBurn) for the same non-null assertion.
  2. The swap button ignores routeNotFound (inline). The screen shows the "route not found" text, but the button stays enabled, so a pair with no route (for example a non-HNT token into DC on mainnet) can still reach getSwapTx with whatever quote was cached last. This predates the PR, but the PR rewrites this condition and it is one line.
  3. No test puts the wallet in a slot other than 0 (inline on signing.ts:41). Every case in signing.test.ts has the user as fee payer, so replacing the findIndex lookup with index 0 (or the legacy .find with signatures[0]) stays green. Add a case where the wallet signs but is not the fee payer, for both versioned and legacy transactions.

Here or in a tracked follow-up

  1. signMsg (SolanaProvider.tsx:110) still signs with secureAcct without checking that it belongs to the current account, which is the stale-key case the new signAsAccount handles for transactions (its own comment says the cached keypair "can be unset or stale after an account switch"). It also tests !accountIndex where signAsAccount tests for undefined. Routing messages through the same wallet-type and key-match logic would close both.
  2. The dApp bridge matches replies by message type only, and the sign sheet's show() replaces the pending resolver. With two requests in flight, the first never gets its own reply and both dApp promises settle on the second. A request id on each message, or refusing a request while one is pending, fixes it. Predates this PR.

Smaller

  1. When the expiry timer clears the price, nothing fetches a new one until the next 5-minute poll, so the button can sit disabled with "Fetching HNT price" and no request in flight. Fetch on expiry, or schedule the next poll at the earlier of the poll interval and the expiry. Related: the UI price only feeds the preview, since mint sends dcAmount and the program prices the burn from its own oracle, so worth saying in the PR that the gate is about the displayed figure.
  2. If the wallet is not a required signer, signTxn returns the transaction unchanged, and the browser then answers the dApp with "transactionSigned". No key is used, but declining in the browser path would be more accurate.
  3. DeserializedTransaction.isVersioned is always true now that decoding uses VersionedTransaction.deserialize only, so the legacy branches and casts in dappTransactions.ts and BrowserWebViewScreen.tsx are dead. The comment at dappTransactions.ts:60 says a send "landed", but with skipPreflight it means the RPC accepted it. The route decision lives in swapRoute, getOutputAmount and getInputAmount; in getInputAmount the devnet check comes first, so entering a DC amount on devnet sets the HNT input to 0.

Comment thread src/features/swaps/SwapScreen.tsx
Comment thread src/solana/signing.ts
- Swap button is disabled when no route is found.
- On devnet, a DC amount in "you receive" no longer zeroes the HNT input.
- Onboarding balances no longer throw while the HNT price is missing;
  the HNT part is left out until a price arrives.
- An expired HNT price triggers a fetch at once, unless one is in flight.
- The dApp browser declines a sign request when the wallet is not a
  required signer of every transaction.
- isRequiredSigner moves to src/solana/signing.ts, with tests.
- DeserializedTransaction holds a VersionedTransaction only; the dead
  legacy branches are gone. The sendInSequence comment says "accepted
  by the RPC" instead of "landed".
- signWithKeypair tests cover a wallet that signs but does not pay.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@bryzettler

Copy link
Copy Markdown
Contributor Author

Commit eb4df6f addresses the review: 6 fixed, 2 deferred.

Item Finding Outcome Result
1 Onboarding throws while the HNT price is missing fix eb4df6f
2 Swap button ignores routeNotFound fix reply
3 No signing test with the wallet outside slot 0 fix reply
4 signMsg skips the key-match check defer Follow-ups in the PR description
5 Bridge replies have no request id defer Follow-ups in the PR description
6 Price expiry does not refetch; gate claim fix eb4df6f, PR description updated
7 dApp told "signed" when the wallet is not a signer fix eb4df6f, PR description updated
8 Dead legacy branches, "landed" comment, devnet input order fix eb4df6f

1 — "Onboarding can throw while the HNT price is missing."
useOnboardingBalances has no non-null assertion now. It counts the DC balance alone until a price arrives, then adds the HNT part. Hotspot onboarding and location asserts also stop reading a missing price as 0 DC. PaymentItem, BurnScreen, useImplicitBurn and SwapScreen have no ! on networkTokensToDc. No test pins this; jest cannot load modules that import through the @hooks alias.

4 — "signMsg still signs with secureAcct without checking it belongs to the current account."
Valid. It is listed under Follow-ups in the PR description. The bug predates this PR, and the fix needs a new message-signing path in src/solana/signing.ts.

5 — dApp bridge replies are not matched to their requests.
Valid. It is listed under Follow-ups in the PR description. It predates this PR, and the fix changes the bridge protocol in walletStandard.js.

6 — Price expiry clears the price but does not fetch a new one.
The expiry timer in Balance.tsx now starts a fetch at once, unless a fetch is in flight. That guard stops two poll loops from running. The PR description now says the gate covers the displayed figure, and that the mint sends a DC amount the program prices from its own oracle. No test pins the timer; it needs a hook harness.

7 — The browser posts transactionSigned when the wallet is not a required signer.
Both browser handlers now decline before the sign sheet opens when the wallet is missing or is not a required signer of every transaction. The dApp gets "Wallet is not a signer of this transaction". isRequiredSigner moved to src/solana/signing.ts, with 8 tests under describe('isRequiredSigner'). Two of them fail when the check reads every account key instead of the signer keys. The handler wiring has no test. The #1056 line in the PR description is corrected.

8 — Dead legacy branches, a wrong comment, and devnet input order.

  • DeserializedTransaction.transaction is a VersionedTransaction. isVersioned and the dead casts are gone. The existing decode test pins the type.
  • The sendInSequence comment says "accepted by the RPC" and "submitted" instead of "landed". No user-facing string said "landed".
  • getInputAmount checks the DC output before devnet, the same order as getOutputAmount. No test pins it.

@madninja madninja left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Approving at eb4df6f. The three must-fix items are in: the onboarding non-null assertion is gone, routeNotFound is in the button's disabled, and the new tests sign as a wallet another account pays for, versioned and legacy. The browser now declines before the sign sheet when the wallet is not a required signer, and the dead legacy branches, the "landed" comment and the devnet input order are fixed. The two deferred items are listed under Follow-ups.

Notes, none blocking:

  1. The expiry refetch depends on two 10-minute constants agreeing. It is bounded today: ORACLE_MAX_AGE (10 min) equals MAX_PRICE_AGE_SECS in @helium/currency-utils 0.12.1, so a successful fetch always leaves a positive expiry, and a stale feed is rejected and goes to the 30 s retry. If the library's limit is ever raised above ORACLE_MAX_AGE, a feed aged between the two would fetch successfully with an expiry of 0 and refetch in a tight loop. Scheduling the expiry refetch with ORACLE_RETRY_INTERVAL instead of 0 would bound it whatever the library does.
  2. Onboarding undercounts while the price is missing. useOnboardingBalances now counts the DC balance alone until a price arrives, so a wallet that can pay with HNT can briefly read as short. Returning undefined (still loading) while the price is missing would avoid that. The reply's "stop reading a missing price as 0 DC" reads the other way from the code, which does count the HNT part as 0 in that window.
  3. Follow-ups as issues. The signMsg key match and the bridge request ids are only in the PR description, which stops being read once this merges. Opening an issue for each keeps them visible.
  4. Device test before release. The signing and dApp bridge changes are covered by jest only. One pass on a device (a dApp sign, a multi-transaction signAndSendTransaction, and a decline) before 2.15.13 ships.

- Onboarding: report DC+HNT as loading while the HNT price is missing;
  screens wait unless DC alone covers the cost.
- signMsg: use the wallet-type and key-match logic of signAsAccount;
  refuse bytes that decode as a transaction message.
- dApp bridge: request ids on every request and reply; decline a second
  request while one is open; read every message shape.
- Sign sheet: settle the previous request before a new one opens.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@bryzettler

Copy link
Copy Markdown
Contributor Author

Addressed in d210564: 2 fixed, 1 declined. The device test (item 4) stays open until a device pass is done.

Item Finding Outcome Result
1 Expiry refetch depends on two 10-minute constants decline see below
2 Onboarding undercounts while the price is missing fix d210564
3 Follow-ups as issues fix d210564 (both fixed in this PR)

1

The expiry refetch depends on two 10-minute constants agreeing.

The loop cannot happen with the installed library. MAX_PRICE_AGE_SECS in @helium/currency-utils 0.12.1 is 600, and ORACLE_MAX_AGE in src/utils/oraclePrice.ts:6 is 600. A stale feed throws and goes to the 30 s retry (src/utils/Balance.tsx:158). The immediate refetch at src/utils/Balance.tsx:151 is intended: with ORACLE_RETRY_INTERVAL, the swap would show no price for 30 s after every expiry. A library bump that raises the limit would change this, and that bump gets its own review.

2

Onboarding undercounts while the price is missing.

Fixed. When the wallet holds HNT and no price is available, useOnboardingBalances now reports the DC+HNT total as loading (loadingMyDcWithHnt). Hotspot onboarding and the location assert wait instead of showing "insufficient funds", unless the DC balance alone covers the cost (waitingOnHntPrice in src/utils/dcWithHnt.ts). The location assert compares against the higher of the IOT and MOBILE costs while it waits, because the network is picked after the press. Pinned by src/utils/__tests__/dcWithHnt.test.ts. The screen wiring has no test.

3

Follow-ups as issues.

Both are fixed in this PR instead.

  • signMsg now uses the wallet-type and key-match logic of signAsAccount (signMessageAsAccount in src/solana/signing.ts). A mismatched or missing software key is refused, and Ledger and Keystone accounts take the device path. Message signing also refuses bytes that decode exactly as a transaction message. Pinned by src/solana/__tests__/signing.test.ts.
  • Every bridge request in walletStandard.js carries an id, and every reply carries the same id. A second request while one is open is declined with "Request pending" (src/features/browser/dappRequestGate.ts). The sign sheet settles the previous request before it opens a new one (src/solana/signRequestResolver.ts). Pinned by dappRequestGate.test.ts and signRequestResolver.test.ts.
  • Not run on a device: the id filtering inside the WebView, the sheet behaviour, and Ledger and Keystone message signing. These fall under item 4.

The remaining follow-ups are listed in the PR description.

bryzettler and others added 2 commits October 15, 2026 08:57
Restore replaced the account record with alias and address only, so it
dropped derivationPath. Its fallback scan also skipped the app's own
default path (m/44'/501'/0'/0') and sub-account paths. After one restore,
the next restore of a default-path account failed with "words do not
match".

Restore now scans the stored paths plus the Import scan set, keeps the
record, and saves the path it found. One set of words restores every
listed software account it holds a key for. Hardware wallet accounts
never get a software key.

The Solana provider cached the keypair by address. A restore keeps the
address, so the cache kept the missing key until relaunch. A key-version
counter now reloads it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@bryzettler
bryzettler merged commit dec2601 into main Oct 15, 2026
4 checks passed
@bryzettler
bryzettler deleted the fix/tx-v1-read-support branch October 15, 2026 14:22
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.

2 participants