Skip to content

fix: never reuse an address whose public key was exposed by spending - #805

Merged
joemarct merged 7 commits into
masterfrom
fix/no-spent-address-reuse
Oct 22, 2026
Merged

joemarct merged 7 commits into
masterfrom
fix/no-spent-address-reuse

Conversation

@joemarct

@joemarct joemarct commented Oct 22, 2026 •

Copy link
Copy Markdown
Member

Problem

Spending from a P2PKH address reveals its public key on chain. Once revealed, the address is exposed to a future key-recovery attack, so any funds later sent to it can be drained. The wallet was doing this in two ways:

  1. Reusing spent receiving addresses — the receive page displayed the current address without checking whether it had already been used.
  2. Returning change to the just-spent address — many spend paths passed no change address, so the SDK default took over: changeAddress = privateKeyToCashaddress(inputPrivKey).

Approach

Receiving and change addresses are one pair per index. lastAddressIndex N gives receiving 0/N and change 1/N, and that single value is the only cursor. A spend advances it, which rotates both addresses; the next receive therefore also moves to a pair whose change address has never been spent from.

getChangeAddress in src/utils/send-page-utils.js is the single implementation: it scans the change chain for the first index with no on-chain history, subscribes the address to watchtower, advances the cursor before broadcast, and throws if it cannot confirm.

Changes

Receive page (transaction/receive.vue) — with auto-generate on, only displays an address positively confirmed unused, via POST /api/wallet/address-discover/ (get_first_use). Auto-generate off still reuses the current pair by design.

Receive page getAddress() fallback (receive.vue:859) — the fallback used to derive an address straight from the stored index with no freshness check and no 0/0 guard. It now resolves through refreshDynamicAddress(), the same verified path the page uses on mount, so a websocket listener or API call can never attach to an unverified address. It also bails out when a POS target is selected, since POS addresses use a different index scheme and substituting a personal address would be wrong.

Freshness check sends the real wallet hash (address-generation-utils.js) — the discovery request carried a placeholder hash ('0'.repeat(64)). It now computes the actual wallet hash from the mnemonic and derivation path and requires it, so a future server-side change that keys anything on wallet_hash cannot silently attribute scanned addresses to a sentinel wallet. The response stays untrusted: non-boolean has_history entries are dropped and the scan aborts instead of assuming freshness. findUnusedAddressIndex now derives the mnemonic once and passes it into deriveAddressBatch, instead of re-reading the vault on every batch.

Serialised change selection (send-page-utils.js) — read → scan → commit spans network round trips, so concurrent spends could both settle on the same index. Now queued per wallet type and network.

Consolidated call sites — Connecta, marketplace order/checkout, JPP, e-load, AI-admin, Rewards redeem and Lift token all delegate to getChangeAddress, replacing duplicated copies. Seven paths that passed no change address now pass a verified one: createFundingUtxo, consolidateUtxos, CashInDialog, RampDepositInfo, EscrowTransfer, create-gift, and the gift resubmit flow.

1/0 builders — escrow/tx-builder.js and payment-hub/services.js hardcoded change to 1/0, so every escrow funding and subscription top-up piled change onto one fixed address. Both now use the shared helper. Escrow takes the network from the bchWallet that funds it rather than the store, since the caller derives the network from the escrow contract address and a disagreement would produce an unspendable output.

No silent index-0 change for JPP (payment-uri.js:472-481) — with no change address supplied, prepareTransaction derived getAddressSetAt(0).change for multi-address wallets, reusing an address whose public key may already be exposed. It now throws Missing change address, matching the contract that callers pass a verified-unused address. The SingleWallet branch keeps its own single address, since that address is the wallet.

Marketplace — checkout.vue looked up a change address that nothing used: escrow funding builds its own inside escrow/tx-builder.js, so the lookup only burned a wallet index and subscribed a dead address on every payment attempt. Removed. In order.vue, sendBchPayment now initialises the wallet before selecting the change address and derives the network from the funding wallet (BCH_CHIP vs BCH) rather than from the invoice address prefix.

Store guards — the cursor never moves backwards (setWalletLastAddressAndIndex monotonic, loadWalletAddresses takes the max of both index fields, generateNewAddressSet only overwrites provided fields), so a lagging server index or a second device cannot roll it back.

Settings copy — the "Auto generate address" caption now states the reason: a spent address has its public key published on chain.

What this does not cover

The guarantee applies to paths routed through getChangeAddress. Deliberately excluded:

  • Cauldron change output (wallet/cauldron/transact.js:195) derives its locking bytecode from spendableCoins[0].key, so change can still land on an address just spent from — the same exposure class as the pre-PR privateKeyToCashaddress(inputPrivKey) default. Not fixed here; it needs chipnet testing, not just unit tests.
  • Cauldron pool owner addresses (pools.vue:228, add-pool.vue:586, trade.vue:1207) must stay on 0/0. fetchWalletPools resolves pools by pkh from the owner address, so rotating them would make existing pools and their liquidity undiscoverable.
  • WalletConnect FT sends (send.vue:2132-2136) still use userSelectedChangeAddress when the user picked one, bypassing getChangeAddress. BCH-only sends go through the helper. Left as-is: it is an explicit user choice, and changing it is a separate ticket.
  • Auto-generate off re-displays the stored pair even if it has history. That is the documented meaning of the setting; a one-time warning toast would need a freshness probe on every receive-page load plus new copy, so it is not included.

Failure behaviour

getChangeAddress fails closed: if watchtower cannot confirm freshness, the send aborts rather than proceeding with a possibly-used address. This adds one address-discover round trip per send.

A failed selection burns an index instead of rolling the cursor back — rolling back would reintroduce reuse if a concurrent call had already advanced past it, and a skipped index is harmless since the scan steps over used ones.

Risk

The only loss-like failure mode is a network mismatch: deriving a change address on the other network produces an unspendable output. Two groups of call sites exist:

  • Paths that pay from a specifically chosen wallet (gifts, ramp, card cash-in, escrow funding, subscription top-up, marketplace order) pass isChipnet from the wallet being spent.
  • Paths that pay from the active wallet (JPP, e-load, AI-admin, Connecta, Cauldron send, the main send form) omit the override and use the store default.

The second group is internally consistent rather than exempt: those paths resolve inputs, signing keys and change all through getWalletByNetwork(), which itself reads the store, so the network cannot diverge within one transaction. Note that the composite Wallet class carries no isChipnet of its own — the value lives on BCH/BCH_CHIP — so props.wallet.isChipnet would be undefined and a no-op; an explicit override on such a path must go through getWalletByNetwork(wallet, 'bch').isChipnet.

Gift claim and creation resolve the change address before the server POST, so a verification failure cannot leave a gift registered but unpaid.

Verification

  • Lint: no new errors. Repo-wide 16323 → 16322 problems; across the five files touched by the last two commits, 21 → 20 (the removed dead helper).
  • npm run test:unit — 15 passing.
  • No import cycles introduced.
  • Change-address serialisation verified by simulation: two concurrent callers get distinct indices with the queue (3, 4) and the same index without it (3, 3); a rejected task does not block the next.

The receive page could display an address that already had transactions:

- Only checked a single index (lastAddressIndex) and blindly displayed
  index+1 when it was used, without ever verifying index+1 itself, so a
  stale stored index (restored wallet, second device, server lag) let a
  used address through.
- isAddressUsed failed open: every request had .catch(() => ({ is_used:
  false })), so any watchtower error was treated as "unused".
- The "Auto generate address" off branch reused lastAddressIndex as-is
  with no verification (kept as-is per setting semantics).
- generateNewAddress ("New Address" button / leave rotation) advanced one
  index and displayed it unchecked; also had a string-concat "+ 1" bug
  and never reset the generating flag on the SLP branch.

Changes in refreshDynamicAddress (auto-generate on):
- Sync lastAddressIndex from watchtower first, never regressing below the
  locally tracked index.
- Scan up to 20 indexes via findUnusedAddress and only display an address
  positively confirmed unused (zero balance, is_used false).
- Fail closed: if watchtower cannot confirm status, surface the existing
  error notification instead of rendering the address.
- Reconcile the store index to the displayed address.
- Console.debug logs each candidate index and verdict for repro debugging.

Auto-generate off: unchanged semantics - reuse lastAddressIndex as-is,
no sync, no usage check, no index advance.

generateNewAddress: numeric index advance, unified bch/slp address-set
handling, always resets generating, then re-resolves through the verified
scan.

New getAddressUsageStatus returns 'used' | 'unused' | 'unknown' instead
of coercing failures to false.
Receiving and change addresses are generated as a pair from one index:
lastAddressIndex N gives receiving 0/N and change 1/N. lastAddressIndex is
the only cursor; there is no separate change-address index.

Previously getChangeAddress returned the stored change address, so two
consecutive spends both used 1/N. The second spend put change back onto an
address whose public key the first spend had already exposed, so a
quantum-capable attacker watching the mempool could derive the key and
drain the funds.

getChangeAddress now:
- Scans the change chain from lastAddressIndex via address-discover
  (node get_first_use, not the local-DB /isused/, which fails open) and
  takes the first address with no on-chain history, so an index already
  spent from (auto-generate off, second device, restored wallet, lagging
  server index) is skipped instead of reused.
- Subscribes that address to watchtower before returning.
- Advances the shared cursor to N+1 as soon as the address is handed out,
  before broadcast, so a second spend cannot pick 1/N again while
  watchtower has not indexed the first change output. Indices skipped by
  a cancelled send are harmless.
- Throws on subscription or verification failure instead of falling back
  to a stored change address that may already be used.

Since the cursor is shared, a spend also rotates the receiving address,
including when "Auto generate address" is off. With the setting off the
receive page still reuses the current index for receiving; a payment is
what moves the pair forward.

Store guards against a lagging server index pulling the cursor back:
- setWalletLastAddressAndIndex no longer moves lastAddressIndex backwards.
- loadWalletAddresses takes the max of lastAddressAndIndex and
  lastAddressIndex.
- generateNewAddressSet only overwrites the fields it is given, so the
  cursor can advance without clobbering the cached pair.

Consolidated the duplicated change-address implementations in Connecta,
marketplace order/checkout and the JPP payment panel into the shared
helper. send.vue skips the helper when the user picked a change address,
so an unused address does not advance the cursor. JPP passes the change
address to prepareTransaction.

Settings copy now states that the pair moves forward after every payment,
and why.

Scope: this guarantee holds only for spend paths that route through
getChangeAddress (send page BCH/SLP, Connecta, marketplace order and
checkout, JPP, e-load, AI-admin, Rewards redeem, Lift token). It does not
yet cover call sites that omit the change address or hardcode 1/0: the
watchtower-cash-js SDK then derives change from the first input key, which
sends change back to the address whose public key the transaction just
exposed. Those call sites are a separate follow-up:
- CashInDialog, RampDepositInfo, EscrowTransfer, gifts/index,
  gifts/create-gift, services/wallet createFundingUtxo and
  consolidateUtxos (no change address passed)
- marketplace/escrow/tx-builder and payment-hub/services (hardcoded 1/0)
- wallet/cauldron/transact change output derived from the first input key
Seven spend paths called sendBch without a change address. The SDK then
derives change from the first input key (watchtower-cash-js
dist/bch/index.js), so the leftover was returned to the very address the
transaction had just exposed a public key for.

Pass a verified fresh change address on:
- services/wallet createFundingUtxo and consolidateUtxos
- card CashInDialog
- ramp RampDepositInfo and EscrowTransfer
- gifts create-gift and the claim/resubmit path

Each call site passes isChipnet from the wallet it actually spends from
rather than the store default, so the derived change address matches the
network of the transaction. A mismatch there would produce an
unspendable output, which is the only failure mode of this change that
looks like loss.

Both gift paths resolve the change address before the server POST that
registers the gift, so a verification failure cannot leave a gift
registered server-side but unpaid.

Untouched on purpose:
- marketplace/escrow/tx-builder and payment-hub/services hardcode 1/0;
  change already on 1/0 stays spendable because UTXO lookup is
  wallet-wide, so these need separate review.
- Cauldron pool owner addresses (pools, add-pool, trade) are bound to a
  specific pkh via fetchWalletPools; moving them would make existing
  pools undiscoverable. Only the change output in cauldron/transact is a
  candidate, and that needs manual chipnet testing.
getChangeAddress read the cursor, scanned for an unused change index and
committed the advance, with network round trips between each step. Two
spends started at the same time could read the same index and put their
change on the same address.

Reserving the index before the first await is not sufficient: when the
scan has to skip indices that already have history, both callers converge
on the same free index further along, so the reservation alone still
collides.

Serialise the whole read-scan-commit per wallet type and network instead.
Each caller now reads the cursor the previous caller committed. The queue
chains on both settle paths, so one failed selection does not block later
spends.

Verified with a simulation of the same queue and scan: two concurrent
callers get distinct indices with the queue (3, 4) and the same index
without it (3, 3), and a rejected task does not block the next one.

A failed selection still burns an index. Rolling the cursor back would
reintroduce reuse if another caller had already advanced past it, and a
skipped index is harmless because the scan steps over used indices and
the receive page only shows a confirmed-unused address.
…p-ups

Two transaction builders hardcoded change to 1/0:

- marketplace/escrow/tx-builder generateEscrowFundingTransaction
- payment-hub/services topUpSubscription

1/0 is a single fixed address, so every escrow funding and every
subscription top-up added change to the same address, whose public key
was exposed by the first spend from it.

Both now use getChangeAddress, inheriting the chain verification,
watchtower subscription and cursor advance. Escrow passes isChipnet from
the bchWallet that funds the transaction rather than the store: the
caller derives the network from the escrow contract address, so a store
default could derive an address for the wrong network and produce an
unspendable change output.

Change already sitting on 1/0 stays spendable, since UTXO lookup is
wallet-wide rather than per-cursor, so no migration is needed.

Cauldron pool owner addresses are deliberately unchanged: pools are
looked up by pkh from the owner address, so rotating them would make
existing pools and their liquidity undiscoverable.
@github-actions

Copy link
Copy Markdown

I'll provide a thorough security review of this PR focusing on the cryptographic and protocol-level implications, since this directly touches address generation, change output handling, and on-chain privacy.

Summary

The PR addresses a real and material exposure: every prior spend that returned change to privateKeyToCashaddress(inputPrivKey) (the SDK default) or to a recycled address already had its public key revealed on-chain, opening the change output to future quantum recovery. Replacing those paths with a verified-fresh, queued, monotonically-advancing cursor is the right fix, and most of the implementation is conservative and correct.

Strengths

  • getChangeAddress is the single point of truth at src/utils/send-page-utils.js:170. All seven newly-routed call sites (createFundingUtxo, consolidateUtxos, CashInDialog, RampDepositInfo, EscrowTransfer, create-gift, gifts resubmit, plus topUpSubscription, generateEscrowFundingTransaction, connecta, marketplace/checkout, marketplace/order, JppPaymentPanel, send.vue) delegate to it.
  • Freshness comes from the chain, not the local DB. findUnusedAddressIndex at src/utils/address-generation-utils.js:374 uses the watchtower discovery endpoint's blockchain.address.get_first_use (per the doc comment), so a used address subscribed by another wallet instance is still detected. This is essential — a cache-only check would have left the same gap.
  • Per-chain history check. Receiving and change are checked independently (chain: 'change' vs 'receiving'); the docstring at line 308 explicitly flags that querying one does not say anything about the other. Correct.
  • Failure-closed behaviour. getChangeAddress throws if the scan cannot positively confirm an address's status rather than falling back to a stored lastChangeAddress that may already have been spent.
  • Per-wallet/per-network queue at src/utils/send-page-utils.js:42. The key includes both walletType and isChipnet, so a concurrent mainnet spend cannot collide with a chipnet spend, and the failure path (.then(task, task) then .catch(() => {})) does not block subsequent callers.
  • Cursor never moves backwards in setWalletLastAddressAndIndex at src/store/global/mutations.js:645 and loadWalletAddresses at src/store/global/actions.js:1073 (max of the two index sources). This stops a lagging server, a second device, or a lastAddressAndIndex from a different network from rolling the index.
  • Network is sourced from the spending wallet, not the store default at every call site (e.g., escrow/tx-builder.js:16 uses bchWallet.isChipnet, payment-hub/services.js:25 uses the caller's isChipnet, JppPaymentPanel.vue:448 uses wallet.BCH indirectly, connecta/index.vue:352 uses the store for the same-network case). A wrong network would produce an unspendable output on the other chain; sourcing from the wallet closes that.
  • Out-of-scope items are correctly flagged in the description (cauldron change at wallet/cauldron/transact.js:195, cauldron pool owners, and the JPP 1/0 fallback). Worth re-reading cauldron/transact.js:194-195: privateKey = spendableCoins[0].key plus privateKeyToP2pkhLockingBytecode({ privateKey }) — change locks to the input pubkey's P2PKH, so it lands on the just-spent address. This is the same class of bug. The PR explanation ("needs chipnet testing, not just unit tests") is honest; I'd want a follow-up issue opened for it before merging if possible.
  • generateNewAddressSet mutation at src/store/global/mutations.js:571 only overwrites provided fields, so a concurrent setWalletLastAddressAndIndex (which sets lastAddressIndex but not lastChangeAddress) cannot erase a freshly-derived change address. Good regression-resistance.

Issues worth addressing

1. Receive page fallback path skips validation (Medium)

src/pages/transaction/receive.vue:836-899 — getAddress() falls back to generateReceivingAddress({ ...validAddressIndex }) without running the freshness scan when dynamicAddressRegular and dynamicAddress are both empty. The selectPosReceiveTarget flow clears these during switching, and setupListener() (line 1080) is called with whatever getAddress() returns. A user who opens the receive page in autoGenerateAddress: true mode and the dynamic address hasn't loaded yet can have the listener attached to an unverified address.

Suggested fix: have the fallback path call findUnusedAddressIndex too instead of subscribing the stored lastAddressIndex directly.

2. HISTORY_CHECK_WALLET_HASH placeholder (Low–Medium)

src/utils/address-generation-utils.js:297 uses '0'.repeat(64) for the watchtower discovery endpoint on the rationale that the endpoint "only reports... and does not subscribe or persist anything". The PR description echoes this. The watchtower source is not in this repo, so this is currently a comment-only claim. If watchtower ever changes the endpoint's behaviour (e.g., adds a cache row keyed on wallet_hash), every scan will silently attach wallet history under a sentinel hash, polluting future lookups.

Suggested fix: gate the discovery call behind an explicit address-discover response shape check, or have the server respond with a requires-subscription error so the client surfaces it.

3. payment-uri.js:476 dead-branch risk (Low)

src/wallet/payment-uri.js:472-478 still has the (await getWalletByNetwork(wallet, 'bch').getAddressSetAt(0)).change fallback when changeAddress is missing. The PR description says "nothing reaches it now that JPP passes a change address", which is currently true. But the dead branch is still a foot-gun for the next caller. Either delete it or route it through getChangeAddress.

4. setDefaultFtChangeAddress in send.vue is out of scope but concerning (Low, pre-existing)

src/pages/transaction/send.vue:2706-2710 assigns this.connectedApps[0].wallet_address as the user-selected change address for FT sends. This is the app's own address (provided by WalletConnect), but the route at line 2132 uses this string as the change output destination without freshness verification. Out of scope for this PR, but worth a separate ticket. Note that line 2132 only takes this branch when token?.tokenId && this.userSelectedChangeAddress, so BCH-only sends go through getChangeAddress — only FT sends are exposed.

5. Receive page cache race (Low)

src/pages/transaction/receive.vue:915-1035 (refreshDynamicAddress) and :738-787 (generateNewAddress) both write to dynamicAddress / dynamicAddressRegular. The "New Address" button commits a new pair synchronously, then calls refreshDynamicAddress, which may advance the index further if the scan finds the just-set index already used. If watchtower indexed the change output between the commit and the scan, the resulting address is fine, but the user briefly sees an address that may have been the one they just clicked past. Cosmetic; no security impact.

6. commerce/marketplace/checkout.vue chipnet heuristic (Low)

src/pages/apps/marketplace/checkout.vue:1812-1813 derives chipnet = address.indexOf('bchtest:') >= 0, and src/pages/apps/marketplace/order.vue:1256 does the same. This is correct for the case where the payment address is chipnet-prefixed, but it doesn't cross-check that the active wallet is also chipnet. If the BCH wallet were active while paying a chipnet invoice (which the UI usually prevents), isChipnet to getChangeAddress would be the wrong network and produce an unspendable output. Not introduced by this PR, but now that every marketplace payment passes through getChangeAddress, the inconsistency is worth flagging.

7. autoGenerateAddress: false re-displays spent address (By design, but worth noting)

When auto-generate is off, src/pages/transaction/receive.vue:938-962 displays lastAddressIndex directly without verification. The PR description acknowledges this: "Auto-generate off still reuses the current pair by design." This is the documented behaviour, but it means that users who spend once and then turn auto-generate off (or who restore an old wallet) will continue to show spent receiving addresses. I'd consider a one-time warning toast when the displayed index is known to be spent.

Verification status

  • npx eslint src/utils/send-page-utils.js src/utils/address-generation-utils.js src/store/global/mutations.js src/store/global/actions.js src/services/wallet.js src/wallet/payment-hub/services.js src/marketplace/escrow/tx-builder.js src/components/JppPaymentPanel.vue src/components/card/CashInDialog.vue src/components/ramp/crypto/RampDepositInfo.vue src/components/ramp/fiat/EscrowTransfer.vue src/pages/apps/connecta/index.vue src/pages/apps/gifts/create-gift.vue src/pages/apps/gifts/index.vue src/pages/apps/marketplace/checkout.vue src/pages/apps/marketplace/order.vue src/pages/apps/settings.vue src/pages/transaction/receive.vue src/pages/transaction/send.vue — no errors attributable to this PR. The 1301 errors reported by npm run lint are all pre-existing and unrelated. The PR description's claim of "Lint error counts identical to baseline on every touched file" is correct.
  • npm run test:unit — 15 passing as claimed. However, no tests specifically exercise findUnusedAddressIndex, getChangeAddress, the queue serialisation, or toWatchtowerAddress (SLP conversion case). I'd recommend adding at least:
    • findUnusedAddressIndex with a mocked axios that returns a mix of has_history: true/false/missing
    • queue with two concurrent callers (3+4 expected; 3+3 without serialisation)
    • getChangeAddress failure when the scan throws (must not call generateNewAddressSet)
    • toWatchtowerAddress('simpleledger:...') strips simpleledger: prefix
    • setWalletLastAddressAndIndex monotonicity

Verdict

Approve with follow-ups. The fix is real, the centralisation on getChangeAddress is well done, and the safety hooks (monotonicity, queue, fail-closed, network-from-wallet-not-store) are appropriate for a wallet handling user funds. The receive page fallback at receive.vue:836-899 is the most concrete security gap left open and should be tightened. The reference to HISTORY_CHECK_WALLET_HASH is a claim worth verifying with the watchtower maintainers. The two pre-existing issues (cauldron change at transact.js:195, send.vue:2706-2710 WalletConnect change) should each get a separate ticket rather than being silently absorbed.

github run

The freshness scan sent a placeholder wallet hash to watchtower's
address-discover endpoint. Send the wallet's actual hash instead, and
require it so a caller cannot silently fall back to a sentinel.

The receive page's getAddress fallback derived an address from the stored
index without any freshness check. Route it through refreshDynamicAddress
so a listener or API call never attaches to an unverified address, and
never substitute a personal address for a selected POS target.
prepareTransaction derived index 0 for multi-address wallets when no
change address was supplied, silently reusing an address whose public key
may already be exposed. Throw instead, matching the shared getChangeAddress
contract that callers must pass a verified-unused address.

In the marketplace, remove checkout's dead change-address lookup (escrow
funding builds its own, so it only burned a wallet index per attempt) and
derive the order payment's network from the funding wallet rather than the
invoice prefix.
@github-actions

Copy link
Copy Markdown

Code review

Verified the seven commits across 22 files. Mostly secondary review; I'm pinpointing a few things the run's review missed or got wrong.

Confirmed strengths

  • getChangeAddress in src/utils/send-page-utils.js:170 is the only writer. Transactional semantics (scan → generateChangeAddress → commit) live in one place.
  • findUnusedAddressIndex at src/utils/address-generation-utils.js:375 uses watchtower's authoritative blockchain.address.get_first_use, not the local subscription DB — addresses whose history lives on a different instance still surface as used. This is the right call.
  • Per-chain check (chain: 'receiving' | 'change'); receiving and change are independent. Docstring at :308 flags this. Correct.
  • Queue key at src/utils/send-page-utils.js:172 covers both walletType and isChipnet. previous.then(task, task) then .catch(() => {}) means a single failure does not stall subsequent callers. Self-cleaning on completion. Correct.
  • Cursor monotonicity at src/store/global/mutations.js:645 and src/store/global/actions.js:1073 (Math.max(...) of lastAddressAndIndex.address_index vs lastAddressIndex) blocks rolling the index back from a lagging server or a second device.
  • generateNewAddressSet (mutations.js:571) only overwrites fields that are explicitly provided; a concurrent setWalletLastAddressAndIndex cannot erase a fresh change address.
  • Network is taken from the spending wallet at every changed call site except one — escrow/tx-builder.js:16 uses bchWallet.isChipnet, payment-hub/services.js:25 uses the caller's isChipnet, services/wallet.js:342/375 use this.isChipnet, create-gift.vue:732/gifts/index.vue:1087 use vm.wallet.BCH.isChipnet. Correct.

Corrections to issues 1 and 2 from the prior review

Issue #1 (Medium, receive.vue fallback skips validation) — overstated. With autoGenerateAddress: true, getAddress() falls back to refreshDynamicAddress() (receive.vue:869), which goes through loadWalletLastAddressIndex → findUnusedAddress → generateReceivingAddress (lines 940-998). The unverified fallback path the previous review described was the auto-generate-OFF branch (receive.vue:911-934), which is documented as by-design. Real gap would be if dynamicAddressRegular were cleared without triggering refreshDynamicAddress; that does not currently happen on a normal flow.

Issue #2 (HISTORY_CHECK_WALLET_HASH placeholder) — stale. Current code at address-generation-utils.js:396 calls computeWalletHash(mnemonic, derivationPath) and at :332 sends wallet_hash: walletHash to /api/wallet/address-discover/. No '0'.repeat(64) sentinel is in the implementation; the docstring at :295-301 is forward-compat commentary, not a TODO.

Issue worth flagging that the prior review did not catch

JppPaymentPanel.vue:448 calls await getChangeAddress('bch') without an isChipnet override. Every other call site passes the wallet's isChipnet explicitly. The active wallet (props.wallet) here can be either mainnet or chipnet, so any divergence between $store.getters['global/isChipnet'] and props.wallet.isChipnet would derive a change address on the wrong network, producing an unspendable output. UI gates typically align them, but the convention across the PR is isChipnet: <wallet>.isChipnet; a one-line addition is worth it.

Other notes (out-of-scope but real)

  • src/wallet/payment-uri.js:472-481 — the dead 1/0 branch is now an explicit 'Missing change address' throw for multi-address wallets, which is safer than silently falling back. Worth keeping the comment; deleting the line entirely is also fine.
  • src/pages/transaction/send.vue:2132-2136 — the WalletConnect FT path still bypasses getChangeAddress when userSelectedChangeAddress is set. BCH-only sends go through getChangeAddress. The right scope for a separate ticket, as the prior reviewer noted.
  • src/wallet/cauldron/transact.js:195 derives change P2PKH from spendableCoins[0].key. Same exposure class as the pre-PR privateKeyToCashaddress(inputPrivKey) default. Out of scope, but should not slip without a tracking issue.

Test coverage

Confirmed missing: findUnusedAddressIndex, getChangeAddress, queue serialisation, toWatchtowerAddress SLP→BCH conversion, and setWalletLastAddressAndIndex monotonicity. The PR's verification claim of "15 passing" is for unrelated tests. At minimum the queue-concurrency test the author added in their head should be committed.

Suggested additional tests

test/change-address-queue.spec.mjs       — concurrent calls yield distinct indices; failure of one does not block next
test/find-unused-index.spec.mjs          — mocked axios returns mix of has_history boolean / missing; missing throws
test/to-watchtower-address.spec.mjs      — 'simpleledger:qq...' → 'bitcoincash:qq...'; prefix-less stays prefix-less
test/set-last-address-monotonic.spec.mjs — local advance cannot be regressed by server lastAddressAndIndex

Verdict

Approve. The addressed exposure (post-spend public-key leakage through reused change addresses) is real and material, and the centralisation on getChangeAddress plus the freshness-via-watchtower check closes it on every routed path. Ready for merge after the JppPaymentPanel inconsistency is closed, and with separate tickets opened for the cauldron change output (cauldron/transact.js:195) and the WalletConnect FT path (send.vue:2132).

github run

@joemarct

Copy link
Copy Markdown
Member Author

Thanks — three of the four notes are now addressed in the branch, and the JPP one doesn't hold as written. Details below.

JppPaymentPanel.vue:448 — no override is the correct call here

I looked at adding isChipnet: props.wallet.isChipnet, and it would be a no-op rather than a fix:

  • props.wallet is the composite Wallet from src/wallet/index.js (passed as :wallet="wallet" from send.vue:37, built by initWallet() at send.vue:1328). That class has no isChipnet property — the network lives on its children BCH / BCH_CHIP. So props.wallet.isChipnet is undefined, getChangeAddress ignores a non-boolean override at send-page-utils.js:171, and the store default applies — the current behaviour, behind a line that only looks explicit. If we want the override spelled out it has to be getWalletByNetwork(props.wallet, 'bch').isChipnet.

  • The divergence described isn't reachable on this path, because the entire JPP spend already takes its network from the store, not from the wallet object: getUtxosFromWallet → getWalletByNetwork(wallet, 'bch') (payment-uri.js:443-453), watchtowerUtxoToCashscriptP2pkh → same helper (utxo-utils.js:206), and the change address. Inputs, signing keys and change move together. If the store said chipnet while the invoice was mainnet, the whole transaction would be built on chipnet — the change output would not be the odd one out, and a wrong-network change address isn't possible in isolation. That's a pre-existing invoice/network mismatch question, not this PR's exposure.

  • The convention isn't uniform either. Eleven call sites deliberately omit the override: cauldron/send.ts:319, send.vue:2135,2196,2197, lift-token.js:609, RedeemPointsDialog.vue:900, eload-form.vue:1086, ai-admin-buy-form.vue:402, ai-admin-image-generate.vue:553,594, connecta/index.vue:352. The eight that pass it are the ones paying from a specifically chosen wallet (gifts, ramp, card cash-in, escrow, payment-hub, marketplace order). JPP belongs to the store-default group. I've documented that split in the PR description instead of forcing the override.

Happy to make it explicit via getWalletByNetwork if you prefer the readability, but it wouldn't change behaviour.

payment-uri.js:472-481 — kept as an explicit throw

Kept the throw and the comment: failing closed is better than a silent 1/0 fallback, and the comment records why (the pubkey at that index may already be on chain) so it isn't "simplified" back later.

send.vue:2132-2136 — confirmed, left for a separate ticket

Verified: when userSelectedChangeAddress is set on a token send the code uses it directly, while BCH-only sends go through getChangeAddress. Since it's an explicit user choice rather than a silent default, I've listed it under "What this does not cover" rather than changing it here.

cauldron/transact.js:195 — agreed, not slipped

createInputAndOutput derives the change locking bytecode from spendableCoins[0].key, i.e. the key of a coin being spent as an input, so its pubkey is already public — same exposure class as the pre-PR default. It's called out explicitly in the PR description as a known gap rather than fixed here, since it needs chipnet testing.

@joemarct
joemarct merged commit e147b8f into master Oct 22, 2026
1 check passed
@joemarct
joemarct deleted the fix/no-spent-address-reuse branch October 22, 2026 10:32
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