Skip to content

fix(receive): serve the receive address from the SDK engine, not the held dashj chain - #1570

Merged
HashEngineering merged 40 commits into
feat/kotlin-sdk-phase-1from
fix/receive-address-from-sdk-engine
Oct 7, 2026
Merged

HashEngineering merged 40 commits into
feat/kotlin-sdk-phase-1from
fix/receive-address-from-sdk-engine

Conversation

@HashEngineering

@HashEngineering HashEngineering commented Sep 22, 2026 •

Copy link
Copy Markdown
Collaborator

Fixes SR-03 / D-003 — reproduced in both QA runs.

The defect

After Reset Wallet + restore from the phrase, the Receive screen served yhAK8CWoeNP9Um9jvqSkLZURQnzenjK3v8 again — the address that had already received the 0.11 funding before the reset.

Post-cutover the dashj wallet is held. It never sees a block, so its receive key chain's pointer stays wherever the restore left it: index 0 on a fresh restore. currentReceiveAddress() returned that, and freshReceiveAddress() walked forward from the same index-0 base through the used range instead of skipping it. That is a correctness defect for the payer and an address-reuse privacy defect for the user.

The fix

The engine's own pointer is derived from the SPV scan's used-set, so it skips every address the chain has already paid. This reads it over the FFI — core_wallet_next_receive_address → ManagedCoreWallet.nextReceiveAddress(0).

Deliberately not the SDK's Room core_addresses mirror. That mirror is a projection the engine writes behind itself (onPersistAccountAddressPoolEntry), so it trails every used-marker by a persistence pass; the engine's own used-set is the only thing that answers "which address has nobody paid yet" at the instant the Receive screen asks. The BIP70 refund_to source was reading the mirror and now takes the same FFI call — the upstream Kotlin plumbing gap documented there closed in the v42int21 AAR (iOS had bound the same FFI all along).

Two accessors on CutoverUiDataService, following the existing sdkBalanceOrNull / sdkSpendableUtxoCountOrNull overlay idiom:

sdkReceiveAddressOrNull() Synchronous, non-blocking, serves a cache refreshed on the balance cadence. That cadence is right by construction: the only thing that moves the engine's pointer is a receive landing on the current address, which is an engine tx event. Reachable from the main thread.
sdkReceiveAddressLive{,Blocking}OrNull() Bypasses the cache with a live FFI read, for callers already off the main thread. Publishes what it reads, so the cache follows.

Two decisions worth reviewing:

  • A failed engine read holds the last known address rather than dropping to null. Null would send the overlay back to the frozen dashj address, which is the defect itself — so unlike the balance split and the UTXO count, this one does not clear on failure.
  • No l1Synced gate, unlike the UTXO count. Holding back has no safe direction here: the dashj fallback post-cutover is a known-wrong address, not a sound over-estimate.

Call sites moved onto the live read

The Receive screen and its QR, the amount-dialog path, the …OffMain helpers on both WalletData and the neutral WalletDataProvider (so the Coinbase, Uphold, CrowdNode, ExploreDash and Maya deposit addresses follow), Maya's swap source/refund address, the shielded unshield destination, the paper-wallet sweep, and the mixed-funds CoinJoin migration destination.

WalletApplication's plain synchronous accessors are overlaid too, so call sites not touched individually still get the engine's answer rather than the frozen one.

Two deliberate narrowings

Both documented at the call sites rather than buried here:

  • Post-cutover fresh and current coincide. The engine tracks used, not issued, and the FFI exposes no index-addressable call, so there is no per-invoice handout. Still strictly better than what it replaces, which was handing out already-paid addresses. It also stops forcing dashj's synchronous full-wallet save on that path (~1.2 s at 215 DashPay friend chains), since no dashj key is issued.
  • Mid-scan the engine can return an address whose payment the scan has not found yet. That is the engine's honest best answer and it converges as the scan advances.

WalletAddressFragment also reads the dashj wallet directly, but nothing references that fragment — no layout, no navigation graph — so it is left alone.

Verification

  • :wallet:assemble_testNet3Debug succeeds; ktlintCheck clean.
  • Six new CutoverUiDataServiceTest cases pin the contract: pre-cutover never touches the SDK, post-cutover serves the engine address, it advances on a tx event, a failed read holds the last value, an unavailable read falls back to dashj, and the live read bypasses the cache.
  • Full suite (wallet + common + maya): 2076 tests, 151 failures — a byte-identical failure set to the pre-change baseline captured on a clean tree. All 151 are UnsatisfiedLinkError: no conscrypt_jni, an environment issue on this machine, not a regression. No new failures.

Not yet re-run against the live SR-03 repro on the emulator — worth doing before merge.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • Improvements
    • Receive addresses now reflect the wallet’s active address source across payment, purchase, and swap flows.
    • Refund destinations use the next unused receive address when available.
    • CoinJoin consolidation, wallet sweeps, and shielded transfers use an unadvertised destination instead of a receive address.
  • Bug Fixes
    • When a receive address is unavailable, affected actions now stop and show an error rather than proceeding with an unsuitable address.
    • Transfers requiring an unadvertised destination stop if none is available, rather than falling back to a receive address.

@coderabbitai

coderabbitai Bot commented Sep 22, 2026 •

Copy link
Copy Markdown
Contributor

Review in Change Stack →

Navigate logical layers of code changes, visualize relationships, and explore their blast radius.

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

This change adds live SDK-backed address reads and a cache tied to the active wallet binding. Receive-address consumers use live accessors, and internal-transfer paths use unadvertised destinations. Callers handle unavailable-address failures without continuing the affected operation.

Changes

Wallet address sourcing

Layer / File(s) Summary
SDK address reads and cache lifecycle
wallet/src/de/schildbach/wallet/service/platform/sdk/CutoverUiDataService.kt, wallet/test/de/schildbach/wallet/service/platform/sdk/CutoverUiDataServiceTest.kt
The service reads SDK receive and change addresses. Receive-address cache updates are checked against wallet bindings and read order. Tests cover refresh, rollback, and concurrent reads.
Live address accessors and receive flows
common/src/main/java/org/dash/wallet/common/*, wallet/src/de/schildbach/wallet/data/*, wallet/src/de/schildbach/wallet/WalletApplication.java, wallet/src/de/schildbach/wallet/ui/payments/PaymentsViewModel.kt, wallet/test/de/schildbach/wallet/data/WalletDataExtTest.kt
Wallet interfaces and adapters expose live address reads. Off-main-thread helpers use those accessors. WalletApplication selects SDK-backed live addresses after cutover and retains dashj fallbacks before cutover.
BIP70 refund address source
wallet/src/de/schildbach/wallet/service/platform/sdk/SdkL1SendService.kt, wallet/src/de/schildbach/wallet/payments/SendCoinsTaskRunner.kt
BIP70 refund_to uses the engine’s next unused external address. The optional output remains omitted when no address is available.
Internal destinations for wallet transfers
wallet/src/de/schildbach/wallet/WalletApplication.java, wallet/src/de/schildbach/wallet/service/platform/sdk/CoinJoinFundsMigrationService.kt, wallet/src/de/schildbach/wallet/ui/payments/SweepWalletFragment.java, wallet/src/de/schildbach/wallet/ui/shielded/ShieldedTransferExecutor.kt, wallet/test/de/schildbach/wallet/UnadvertisedDestinationDecisionTest.kt, wallet/test/de/schildbach/wallet/ui/shielded/*
CoinJoin migration, sweep, and shielded-transfer paths use an unadvertised destination. After cutover, it comes from the SDK internal chain.
Unavailable receive-address handling
common/src/main/java/org/dash/wallet/common/services/ReceiveAddressUnavailableException.kt, features/exploredash/src/main/java/org/dash/wallet/features/exploredash/ui/ExploreTestNetFragment.kt, integrations/*/src/main/java/org/dash/wallet/integrations/*, wallet/src/de/schildbach/wallet/ui/*
Wallet and integration flows handle unavailable-address errors with operation-specific error states, toasts, canceled results, or failed responses.

Priority: ⬆️ High

Estimated code review effort: 4 (Complex) | ~60 minutes

Change: Bug fix

Suggested reviewers: thepastaclaw

Merge Risk: ⚪ Minimal · up to c084e

The change now fails closed instead of serving a possibly already-used address when the SDK address is not yet available, and callers show an error or retry. Only a minor documentation cleanup remains, so it is ready to merge.

Security Architecture Review

Security architecture risk: 🟡 Moderate · up to c084e

Address-dependent operations gain safer failure handling, and private transfers are separated from publicly advertised receive addresses. However, selecting private-transfer destinations from a shared address pool can still link them to ordinary payments. Reset and concurrent-operation guarantees remain only partially established.

Retained concerns

  • Medium · security · inferred: The new internal-destination API is not an exclusive allocation. Its documented next-unused change address can also be selected by ordinary payment change while it remains unused. A payment recipient who identifies that change output could correlate a subsequent private self-transfer using the same address. Separating these destinations from advertised receive addresses reduces one exposure but does not preserve fresh-key isolation across operations.
Security review details

Security Blast Radius

  • inferred — The supported privacy exposure concerns transaction linkage within an affected wallet. A relevant observer is an ordinary payment recipient able to identify its sender’s change output; the inferred attack does not require wallet signing privileges.

Security Findings and Attack Paths

  • inferred — If an internal address remains unused when an ordinary payment and a private self-transfer select destinations, both can select the same change address under the documented allocation contract. A recipient identifying that address could monitor it to correlate the transfer and amount. This is an inferred design concern, not an independently verified native-builder exploit.

Trust Boundaries and Controls

  • observed — The changed CrowdNode producers construct navigation from resolved account addresses rather than accepting a new external URL or Intent. Address unavailability stops signup and linking; retry signup proceeds only after successful address reset. Existing API address validation remains in the path.

Resilience and Maintainability Implications

  • observed — Binding waits are bounded and interruption returns an unavailable result. Live public accessors reject that result when cutover requires an engine address. These controls contain unavailable-address failures, but do not establish atomic ownership from address return through every downstream transaction’s consumption.

Hardening Proposals

  • proposed — Use an exclusive reservation mechanism honored by ordinary change builders, or an isolated destination pool, for private self-transfers. Define release and recovery behavior for failed or interrupted transfers rather than treating a next-unused read as exclusive allocation.
🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 36.09% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 133 functions across 35 files. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and concisely states the main change: receive addresses now come from the SDK engine instead of the held dashj chain.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Commit to this branch
  • Create a new PR
🧪 Generate unit tests (beta)
  • Commit to this branch
  • Create a new PR

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@thepastaclaw

thepastaclaw commented Sep 22, 2026 •

Copy link
Copy Markdown
Contributor

⛔ Final review complete — 1 blocking finding(s) (commit 1abb621) · triage: critical

@HashEngineering

Copy link
Copy Markdown
Collaborator Author

Verified on device — SR-03 fixed, 3/3 vs 3/3

Ran on emulator-5560 (AVD qa_pr, API 36), the dedicated PR device, against the exact wallet the defect was reported on — the run-2 Test 1 fixture, whose index-0 address yhAK8CWoeNP9Um9jvqSkLZURQnzenjK3v8 is already on chain. No funding node needed, which is just as well: the local dashd is unusable (Error upgrading evo database, datadir ~18 months stale).

Assertion: after restore, the Receive screen must serve an address not already on chain.

Procedure (scripts/sr03-trial.sh, identical every trial): uninstall → install → restore the phrase with the creation-date picker left unset (SR-22) → wait for phase=SYNCED 100.0% → read the address from a uiautomator dump. Teardown was a full uninstall between trials — stricter than in-app Reset Wallet.

Build Trials Address served On chain
BASE 70569d61e 3/3 identical yhAK8CWoeNP9Um9jvqSkLZURQnzenjK3v8 2 tx, received 0.11
HEAD 1fd56ecda 3/3 identical yLQFsqQqJuayzP3j1usaFpuTL1KjjLyjLv 0 tx, never seen

On-chain status from insight.testnet.networks.dash.org:

yhAK8CWoeNP9Um9jvqSkLZURQnzenjK3v8   txAppearances=2  totalReceived=0.11
yLQFsqQqJuayzP3j1usaFpuTL1KjjLyjLv   txAppearances=0  totalReceived=0

Same wallet either way — username qasyncrerun2026wallet, the run-2 history rows (Received +0.11, Shielded −0.109993), transparent balance 0.000007. The builds differ only in which address they advertise. The base APK is the same artifact run 2 produced the finding on; both are versionCode 12000014, signed with the shared QA key.

Regression sweep (HEAD)

  • Fresh install → onboarding → create wallet → PIN → home renders, balance 0. Pass
  • Fresh wallet's address ygG65bJF4EHDwxtYoRjdHd2s7rkMLf1UBP — txAppearances=0, so the engine does not over-skip when nothing is used yet. Pass
  • Close from recents: pidof empty and BlockchainServiceImpl count 0 after ~50 s (7 → 3 → 0). Pass
  • Relaunch + unlock: service back at 7, phase=SYNCED, balance coherent, no FATAL in logcat -b crash. Pass
  • Address stable across the process restart — same yLQFsq…, matching the engine's idempotent next-unused contract. Pass

Not covered at runtime — please weigh before merge

  • Pre-cutover fallback. A restore on this branch auto-commits to CUT_OVER within seconds, so the dashj path was never exercised on device. Unit test only (preCutover_receiveAddressKeepsTheDashjChain).
  • Pointer advance after a receive. Needs funding the displayed address; no usable node, and spending the shared fixture's shielded balance was out of scope. Unit test only (postCutover_receiveAddressAdvancesOnAnEngineTxEvent).
  • The other call sites this PR moves onto the live read — unshield destination, paper-wallet sweep, Maya, buy/sell deposit addresses, BIP70 refund_to — were not driven on device.

Full write-up and evidence paths: artifacts/sr03-verification.md in the QA workspace (screenshots, UI dumps, and the two trial scripts).

🤖 Generated with Claude Code

@thepastaclaw thepastaclaw left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ DEGRADED — Final validation — Phase 2 only (queue backlog)

⚠️ DEGRADED review. The primary review models were unavailable (gpt-6-astra unavailable: All credentials for model gpt-6-astra are cooling down (last error: usage_limit_reached: The usage limit has been reache), so this review ran on stand-in models: gpt-5.6-luna → muse-spark-1.3-contributor, gpt-5.6-sol → muse-spark-1.3-contributor, gpt-5.6-terra → muse-spark-1.3-contributor, gpt-6-astra → muse-spark-1.3-contributor. Both review phases and the independent verifiers still ran, but on weaker models, with Phase 1 capped at high effort. Treat the verdict as provisional; a full-strength re-review will run on the next push once the primary models are back.

The fix is correct and well-tested; all three remaining notes check out against the source as minor doc staleness and one untested rollback path. No blocking issues, so this is a COMMENT review.

🟡 1 suggestion(s) | 💬 2 nitpick(s)

1 finding(s) not shown inline (the lines are not part of this PR's diff)

💬 Nitpick: refundAddressOrNull KDoc still describes the removed Room-pool read
wallet/src/de/schildbach/wallet/service/platform/sdk/SdkL1SendService.kt:1771-1778

Verified: the interface KDoc on SdkL1SendSource.unusedExternalAddress (lines 620-634) was updated to describe the engine FFI read, and the implementation (lines 875-896) reads core.nextReceiveAddress over the FFI. But refundAddressOrNull's KDoc (lines 1771-1778) still says 'the SDK's persisted address pool' with null 'when ... the pool rows are missing', and the call-site comment at SendCoinsTaskRunner.kt:939 repeats the same stale 'persisted address pool' wording. Behavior is correct; only the two comments misdescribe the source and its null conditions (now: wallet unbound, no BIP44 account yet, or read failure).

    /**
     * The BIP70 `Payment.refund_to` source, post-cutover: the ENGINE's next
     * unused external address over the FFI
     * ([SdkL1SendSource.unusedExternalAddress]) — no dashj keychain read.
     * Contained: null (⇒ the caller omits refund_to, which BIP70 makes
     * optional) when the wallet is unbound, has no BIP44 account yet, or
     * the read fails.
     */

source: muse-spark-1.3-contributor (phase2-reviewer: general)

Review provenance

Source: reviewer 1: muse-spark-1.3-contributor (standing in for gpt-6-astra) (agent: phase2-reviewer, role: general); reviewer 2: muse-spark-1.3-contributor (standing in for gpt-6-astra) (agent: phase2-reviewer, role: security-auditor); final verifier: muse-spark-1.3-contributor (standing in for gpt-6-astra) (agent: astra-verifier, role: final-verifier)

  • Degraded mode: gpt-6-astra unavailable: All credentials for model gpt-6-astra are cooling down (last error: usage_limit_reached: The usage limit has been reache (detected by probe, since 2026-09-20T20:45:34Z); stand-ins gpt-5.6-luna → muse-spark-1.3-contributor, gpt-5.6-sol → muse-spark-1.3-contributor, gpt-5.6-terra → muse-spark-1.3-contributor, gpt-6-astra → muse-spark-1.3-contributor; Phase 1 effort capped at high
  • Triage: normal by muse-spark-1.3-contributor (standing in for gpt-6-astra) (effort low) — Large cross-cutting receive-address sourcing change spans 18 files but only switches the address provider without altering consensus, coin selection, signing, cryptography, or migrations.
  • Phase 1 reviewers: not run (skipped for throughput: 34 PRs queued, above the 10 limit)
  • Fresh verifier: muse-spark-1.3-contributor (standing in for gpt-6-astra) — final-verifier; agent astra-verifier
  • Phase 2 reviewers: muse-spark-1.3-contributor (standing in for gpt-6-astra) — general (completed, effort high); agent phase2-reviewer, muse-spark-1.3-contributor (standing in for gpt-6-astra) — security-auditor (completed, effort high); agent phase2-reviewer
🤖 Prompt for all review comments with AI agents
These findings are from an automated code review. Verify each finding against the current code and only fix it if needed.

In `wallet/src/de/schildbach/wallet/service/platform/sdk/SdkL1SendService.kt`:
- [NITPICK] wallet/src/de/schildbach/wallet/service/platform/sdk/SdkL1SendService.kt:1771-1778: refundAddressOrNull KDoc still describes the removed Room-pool read
  Verified: the interface KDoc on SdkL1SendSource.unusedExternalAddress (lines 620-634) was updated to describe the engine FFI read, and the implementation (lines 875-896) reads core.nextReceiveAddress over the FFI. But refundAddressOrNull's KDoc (lines 1771-1778) still says 'the SDK's persisted address pool' with null 'when ... the pool rows are missing', and the call-site comment at SendCoinsTaskRunner.kt:939 repeats the same stale 'persisted address pool' wording. Behavior is correct; only the two comments misdescribe the source and its null conditions (now: wallet unbound, no BIP44 account yet, or read failure).

In `wallet/src/de/schildbach/wallet/service/platform/sdk/CutoverUiDataService.kt`:
- [NITPICK] wallet/src/de/schildbach/wallet/service/platform/sdk/CutoverUiDataService.kt:2450-2452: Live-read KDoc claims null on unavailable read, but failures hold the cache
  Verified: sdkReceiveAddressLiveBlockingOrNull (lines 2470-2479) returns `address ?: _sdkReceiveAddress.value`, so a failed read with a warm cache returns the held address — the deliberate behavior pinned by the postCutover hold-on-failure test. But the sdkReceiveAddressLiveOrNull KDoc (lines 2450-2452) says 'Null when the cutover is not active or the read is unavailable — the caller falls back, it never returns a guessed address.' The second clause is only true when no address was ever published; otherwise the caller gets the held (possibly slightly stale) address, which is the intended outcome but not what the sentence states.
- [SUGGESTION] wallet/src/de/schildbach/wallet/service/platform/sdk/CutoverUiDataService.kt:2599-2610: Rollback clears the receive-address overlay but no test pins it
  Verified: the six new tests (CutoverUiDataServiceTest lines 1393-1494) cover pre-cutover passthrough, post-cutover serving, advance-on-tx-event, hold-on-failure, unavailable-from-start, and live-bypass — but nothing drives the cutover flag CUT_OVER to inactive and asserts the overlay clears. If a future change drops either clear (lines 2599-2610 on rollback, or the same clearing in the pipeline-failure catch at lines 2615-2624), the synchronous overlay would keep serving an engine address from an abandoned wallet binding after dashj retakes the key chain. A test asserting sdkReceiveAddressOrNull and the live read both return null after deactivation would close the gap cheaply.

Comment on lines +2450 to +2452
* BLOCKS on the FFI (it takes the engine's wallet-manager write lock), hence
* [Dispatchers.IO]. Null when the cutover is not active or the read is
* unavailable — the caller falls back, it never returns a guessed address.

@thepastaclaw thepastaclaw Sep 22, 2026 •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

✅ No longer applies at 3862d6c4; see the replies below.

💬 Nitpick: Live-read KDoc claims null on unavailable read, but failures hold the cache

Verified: sdkReceiveAddressLiveBlockingOrNull (lines 2470-2479) returns address ?: _sdkReceiveAddress.value, so a failed read with a warm cache returns the held address — the deliberate behavior pinned by the postCutover hold-on-failure test. But the sdkReceiveAddressLiveOrNull KDoc (lines 2450-2452) says 'Null when the cutover is not active or the read is unavailable — the caller falls back, it never returns a guessed address.' The second clause is only true when no address was ever published; otherwise the caller gets the held (possibly slightly stale) address, which is the intended outcome but not what the sentence states.

Suggested change
* BLOCKS on the FFI (it takes the engine's wallet-manager write lock), hence
* [Dispatchers.IO]. Null when the cutover is not active or the read is
* unavailable — the caller falls back, it never returns a guessed address.
* BLOCKS on the FFI (it takes the engine's wallet-manager write lock), hence
* [Dispatchers.IO]. Null when the cutover is not active, or when the read is
* unavailable and no address has ever been published — otherwise a failed
* read holds and returns the last known address rather than falling back
* to the frozen dashj pointer.

source: muse-spark-1.3-contributor (phase2-reviewer: general)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Resolved (re-reviewed at 9231bf22): Your updated Kotlin live-read KDoc explicitly distinguishes an unavailable read with an empty cache from a failed read that retains a warm cache. It now matches the implementation and the assertions in postCutover_failedEngineReadHoldsTheLastAddress.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

No longer applies (re-reviewed at 3862d6c4): Your revised implementation returns null after failed live revalidation on either a warm or cold cache, preserving the hold only for the synchronous overlay. The current KDoc matches that split, so the earlier contradiction with a live warm-cache hold has been superseded.

Comment on lines 2601 to 2610
_sdkConfirmedBalance.value = null
_sdkMaxSendable.value = null
_sdkSpendableUtxoCount.value = null
// A rollback puts dashj back in charge of the key chain,
// so the engine address must stop being served — and the
// bound id must go with it, or a live read could still
// answer from the wallet the rollback just abandoned.
_sdkReceiveAddress.value = null
activeWalletIdHex = null
return@collectLatest

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 Suggestion: Rollback clears the receive-address overlay but no test pins it

Verified: the six new tests (CutoverUiDataServiceTest lines 1393-1494) cover pre-cutover passthrough, post-cutover serving, advance-on-tx-event, hold-on-failure, unavailable-from-start, and live-bypass — but nothing drives the cutover flag CUT_OVER to inactive and asserts the overlay clears. If a future change drops either clear (lines 2599-2610 on rollback, or the same clearing in the pipeline-failure catch at lines 2615-2624), the synchronous overlay would keep serving an engine address from an abandoned wallet binding after dashj retakes the key chain. A test asserting sdkReceiveAddressOrNull and the live read both return null after deactivation would close the gap cheaply.

source: muse-spark-1.3-contributor (phase2-reviewer: general)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Resolved (re-reviewed at 9231bf22): Your MutableStateFlow-backed fixture now reaches the CUT_OVER → DUAL_RUNNING branch, and rollback_clearsTheReceiveAddressOverlayAndTheBoundWallet asserts that the cached/live receive accessors and balance/UTXO overlays stop serving. This resolves the missing regression coverage; the new finding concerns a separate interleaving where a live read is already in flight when the clear occurs.

HashEngineering added a commit that referenced this pull request Sep 23, 2026
Review feedback on #1570. All three findings verified against the source
first; none were false positives.

- `refundAddressOrNull`'s KDoc and the `SendCoinsTaskRunner` call-site comment
  still described the Room address-pool read this PR deleted, including its
  null conditions ("the pool rows are missing"). Both now describe the engine
  FFI read and its real null cases: wallet unbound, no BIP44 account yet, or
  the read failed.

- `sdkReceiveAddressLiveOrNull`'s KDoc claimed null "when the read is
  unavailable", which contradicted both the implementation and this PR's own
  `postCutover_failedEngineReadHoldsTheLastAddress` test: a failed read on a
  warm cache deliberately returns the last known engine address, because
  dropping to null would send the caller back to the frozen dashj pointer.
  The doc now states the hold and keeps the guarantee that actually holds —
  every value it yields came from the engine, none are guessed.

- The rollback clear (CUT_OVER -> DUAL_RUNNING) had no test, and could not
  have had one: `configWithState` returns a one-shot `flowOf`, so the
  deactivation branch was unreachable from the suite. Added
  `configWithMutableState` and a test asserting that after a rollback the
  cached overlay, the live read, the balance and the UTXO count all stop
  serving. Confirmed non-vacuous by mutation: deleting
  `_sdkReceiveAddress.value = null` fails it.

Rebased onto 02e741b; the upstream CutoverUiDataService changes merged
without conflict. Full suite: 152 failures, an identical set to the base at
02e741b (pre-existing conscrypt native-lib failures in this environment).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@HashEngineering
HashEngineering force-pushed the fix/receive-address-from-sdk-engine branch from 1fd56ec to 9231bf2 Compare September 23, 2026 21:43
@HashEngineering

Copy link
Copy Markdown
Collaborator Author

Review feedback addressed, rebased, and re-verified on device

All three findings from the (degraded-mode) review checked against the source first — none were false positives, all three fixed.

  1. refundAddressOrNull KDoc and the SendCoinsTaskRunner.kt:939 call-site comment still described the Room address-pool read this PR deletes, including its null conditions. Both now describe the engine FFI read and its real null cases: wallet unbound, no BIP44 account yet, or the read failed.

  2. The live-read KDoc claimed null "when the read is unavailable", which contradicted both the implementation and this PR's own postCutover_failedEngineReadHoldsTheLastAddress test — a failed read on a warm cache deliberately holds the last engine address, because dropping to null would send the caller back to the frozen dashj pointer. The doc now states the hold and keeps the guarantee that is actually true: every value it yields came from the engine, none are guessed.

  3. The rollback clear now has a test. Worth noting why it had none: configWithState returned a one-shot flowOf, so the deactivation branch was unreachable from the suite — no test could have covered it. Added configWithMutableState and rollback_clearsTheReceiveAddressOverlayAndTheBoundWallet, asserting the cached overlay, the live read, the balance and the UTXO count all stop serving after CUT_OVER → DUAL_RUNNING. I mutation-tested it rather than trusting a green run: deleting _sdkReceiveAddress.value = null makes it fail.

Rebased onto 02e741b0c

The base had moved 10 commits, including changes to CutoverUiDataService.kt. Rebase was conflict-free.

Re-verified on device — both sides rebuilt

I rebuilt the base at 02e741b0c as well, rather than reusing the old base APK — a result against the base I originally branched from would say nothing about the tree that will actually merge.

Build Commit Trials Address served On chain
BASE 02e741b0c 3/3 identical yhAK8CWoeNP9Um9jvqSkLZURQnzenjK3v8 txAppearances=2, received 0.11
HEAD 9231bf227 3/3 identical yLQFsqQqJuayzP3j1usaFpuTL1KjjLyjLv txAppearances=0

Same addresses as the first run, so neither the upstream commits nor the review fixes moved the result. Both APKs versionCode 12000017, same QA key, emulator-5560 (qa_pr) only. All six trials reached phase=SYNCED 100.0%.

Unit tests on the rebased head: 152 failures, an identical set to a fresh baseline captured on 02e741b0c itself — no new failures. The count rose from 151 because the base gained a failing test, not this branch. ktlint clean.

Still not covered at runtime

Unchanged from before, and worth weighing at merge: pre-cutover fallback (a restore auto-commits to CUT_OVER within seconds, so the dashj path never runs on device), pointer-advance-after-receive (needs funding the displayed address; no usable node), and the other call sites moved onto the live read — unshield destination, paper-wallet sweep, Maya, buy/sell deposit addresses, BIP70 refund_to.

This push should also trigger the full-strength re-review the degraded run promised.

🤖 Generated with Claude Code

@HashEngineering HashEngineering self-assigned this Sep 24, 2026
@HashEngineering HashEngineering added this to the 12.0.0 milestone Sep 24, 2026

@thepastaclaw thepastaclaw left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Re-review — Final validation — Phase 1 + Phase 2

The three prior findings are fixed at the exact requested head. Source inspection confirms one blocking lifecycle race introduced by the receive-address overlay: an outstanding read can republish the previous wallet's address after reset clears it. A separate Java accessor documentation mismatch also remains; this verification did not rerun the reviewers' reported tests.

🔴 1 blocking | 💬 1 nitpick(s)

Review provenance

Source: reviewer 1: muse-spark-1.3-contributor (agent: phase1-reviewer, role: general); reviewer 2: muse-spark-1.3-contributor (agent: phase1-reviewer, role: security-auditor); reviewer 3: gpt-6-astra (agent: phase2-reviewer, role: general); reviewer 4: gpt-6-astra (agent: phase2-reviewer, role: security-auditor); final verifier: gpt-6-astra (agent: astra-verifier, role: final-verifier)

  • Triage: critical by gpt-6-astra (effort low) — The cross-cutting change introduces lifecycle-sensitive cached and live FFI address selection in CutoverUiDataService.kt and changes actual funds destinations in ShieldedTransferExecutor.kt, SweepWalletFragment.java, and CoinJoinFundsMigrationService.kt, meeting both the intricacy and funds-movement criteria.
  • Phase 1 reviewers: muse-spark-1.3-contributor — general (completed, effort xhigh); agent phase1-reviewer, muse-spark-1.3-contributor — security-auditor (completed, effort xhigh); agent phase1-reviewer
  • Phase 1 model: muse-spark-1.3-contributor — not quota-gated; passed over gemini-3.8-flash-high (antigravity below 15% reserve: weekly 1% left, 5h 100% left), glm-5.3-flash (not used above high effort; tier asks max)
  • Fresh verifier: gpt-6-astra — final-verifier; agent astra-verifier
  • Phase 2 reviewers: gpt-6-astra — general (completed, effort xhigh); agent phase2-reviewer, gpt-6-astra — security-auditor (completed, effort xhigh); agent phase2-reviewer
🤖 Prompt for all review comments with AI agents
These findings are from an automated code review. Verify each finding against the current code and only fix it if needed.

In `wallet/src/de/schildbach/wallet/service/platform/sdk/CutoverUiDataService.kt`:
- [BLOCKING] wallet/src/de/schildbach/wallet/service/platform/sdk/CutoverUiDataService.kt:2496-2504: Prevent in-flight receive reads from republishing a cleared wallet address
  The active-state and wallet-ID checks occur only before the blocking engine read. A caller can capture wallet A's address, be delayed before publication, and resume after the deactivation branch has cleared `_sdkReceiveAddress` and `activeWalletIdHex`. Line 2503 then restores A's address. These live reads run in UI coroutines or Java background handlers outside `runPipelines`, so cancelling the pipeline does not prevent this write. This is a wallet-isolation issue, not just rollback hardening: `WalletApplicationExt.clearDatabasesInner()` resets cutover state during Reset Wallet without restarting the process, while `sdkReceiveAddressOrNull()` serves the cache without checking activation or wallet ownership. The next wallet can therefore receive A's cached address, including when its own live read is unavailable; `WalletApplication` checks the address's network, not ownership by the current seed. Payments to that destination can go to the erased wallet rather than the current wallet. Associate cached values and outstanding reads with a wallet/activation generation, and serialize generation validation plus publication with deactivation and rebinding. A second active-state check alone still permits a check/write race and cannot distinguish successive activations. Add a regression that pauses A's read before publication, deactivates and clears the overlay, then releases the read and activates B with its engine read unavailable.

In `wallet/src/de/schildbach/wallet/WalletApplication.java`:
- [NITPICK] wallet/src/de/schildbach/wallet/WalletApplication.java:2060-2067: Document the Java live accessor's warm-cache fallback
  The Javadoc lists an unavailable SDK or failed read as returning null, but this wrapper delegates to `sdkReceiveAddressLiveBlockingOrNull()`, which deliberately returns the last cached engine address when a read is unavailable on a warm cache. A valid cached address passes through `toDashjAddressOrNull()`. Document the cold-cache versus warm-cache distinction here as well, matching the corrected Kotlin accessor documentation.

Comment on lines +2496 to +2504
fun sdkReceiveAddressLiveBlockingOrNull(): String? {
if (!_cutoverActive.value) return null
val walletIdHex = activeWalletIdHex ?: return null
val address = source.nextReceiveAddressOrNull(walletIdHex)
// A failed read leaves the last known value in place rather than
// clearing it: an empty cache sends the synchronous overlay back to the
// frozen dashj address, which is the defect this exists to fix.
if (address != null) _sdkReceiveAddress.value = address
return address ?: _sdkReceiveAddress.value

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🔴 Blocking: Prevent in-flight receive reads from republishing a cleared wallet address

The active-state and wallet-ID checks occur only before the blocking engine read. A caller can capture wallet A's address, be delayed before publication, and resume after the deactivation branch has cleared _sdkReceiveAddress and activeWalletIdHex. Line 2503 then restores A's address. These live reads run in UI coroutines or Java background handlers outside runPipelines, so cancelling the pipeline does not prevent this write. This is a wallet-isolation issue, not just rollback hardening: WalletApplicationExt.clearDatabasesInner() resets cutover state during Reset Wallet without restarting the process, while sdkReceiveAddressOrNull() serves the cache without checking activation or wallet ownership. The next wallet can therefore receive A's cached address, including when its own live read is unavailable; WalletApplication checks the address's network, not ownership by the current seed. Payments to that destination can go to the erased wallet rather than the current wallet. Associate cached values and outstanding reads with a wallet/activation generation, and serialize generation validation plus publication with deactivation and rebinding. A second active-state check alone still permits a check/write race and cannot distinguish successive activations. Add a regression that pauses A's read before publication, deactivates and clears the overlay, then releases the read and activates B with its engine read unavailable.

source: muse-spark-1.3-contributor (phase1-reviewer: general, security-auditor); gpt-6-astra (phase2-reviewer: general, security-auditor)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Resolved (re-reviewed at 3c0a76e8): Your generation capture and publication check share the binding-invalidation lock, so a read returning after invalidation cannot restore or return the erased binding's address. The parked-read regression passed on rerun; the first run stopped at its initial cache-population assertion, before exercising the race.

Comment on lines +2060 to +2067
/**
* A LIVE engine read of the next unused receive address, bypassing the cache
* {@link #sdkReceiveAddressOrNull()} serves, or null when the engine has no
* answer (pre-cutover, rolled back, SDK not up, read failed).
*
* <p>BLOCKS on the SDK FFI — it takes the engine's wallet-manager write
* lock — so both public callers below are contracted off-main.
*/

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

💬 Nitpick: Document the Java live accessor's warm-cache fallback

The Javadoc lists an unavailable SDK or failed read as returning null, but this wrapper delegates to sdkReceiveAddressLiveBlockingOrNull(), which deliberately returns the last cached engine address when a read is unavailable on a warm cache. A valid cached address passes through toDashjAddressOrNull(). Document the cold-cache versus warm-cache distinction here as well, matching the corrected Kotlin accessor documentation.

Suggested change
/**
* A LIVE engine read of the next unused receive address, bypassing the cache
* {@link #sdkReceiveAddressOrNull()} serves, or null when the engine has no
* answer (pre-cutover, rolled back, SDK not up, read failed).
*
* <p>BLOCKS on the SDK FFI — it takes the engine's wallet-manager write
* lock — so both public callers below are contracted off-main.
*/
/**
* A LIVE engine read of the next unused receive address. When an active,
* bound SDK read is unavailable, retains the last cached engine address;
* returns null if that cache is empty. Also returns null when the service
* is absent, cutover is inactive, no wallet is bound, or the resulting
* address is invalid for the current network.
*
* <p>BLOCKS on the SDK FFI — it takes the engine's wallet-manager write
* lock — so both public callers below are contracted off-main.
*/

source: muse-spark-1.3-contributor (phase1-reviewer: general, security-auditor)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Resolved (re-reviewed at 3c0a76e8): Your revised Java documentation matches the implementation: a cold-cache failure returns null, a warm-cache failure retains the engine address, and a changed binding invalidates the in-flight answer.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

No longer applies (re-reviewed at 373ca4bb): Your later revalidation change removed the live accessor's warm-cache fallback, so documenting that former behavior would now be incorrect. The current Java documentation accurately separates the synchronous overlay's held value from a failed live answer.

HashEngineering added a commit that referenced this pull request Sep 25, 2026
BLOCKING review finding on #1570, and it is a real one — verified against the
source before changing anything.

`sdkReceiveAddressLiveBlockingOrNull` checked the cutover flag and the bound
wallet id, then made a BLOCKING FFI read, then published unconditionally. That
is check-then-act across a call that cannot be cancelled, so:

  1. wallet A's live read captures A's id and blocks in the engine;
  2. Reset Wallet wipes A. `WalletApplicationExt.clearDatabasesInner` says in
     as many words that it does NOT restart the process, and it calls
     `cutoverCoordinator.resetForWalletWipe()`, so the deactivation branch runs
     and clears the overlay;
  3. the parked read returns and republishes A's address into the cache;
  4. wallet B restores. Until B's own engine read lands, `sdkReceiveAddressOrNull`
     — which checked neither activation nor ownership — serves A's address, and
     `WalletApplication` validates the address's NETWORK, not which seed owns
     it, so nothing downstream catches it.

The Receive screen then advertises an address only the ERASED wallet can spend.
That is worse than the SR-03 defect this PR set out to fix, and the existing
`_cutoverActive` gate does not prevent it: re-checking the flag on the way out
is still check-then-act, and a flag cannot tell a surviving binding from a
different one that activated meanwhile.

So the cache now carries the binding GENERATION it was read under. The
generation is bumped on every bind and unbind under a lock; a read captures it
before the FFI and may publish only if it is still current; the synchronous
accessor serves an entry only while its stamp matches. Cross-wallet reuse is
unrepresentable rather than merely unlikely. The lock is never held across the
FFI call, so a deactivation never waits on the engine.

Also removed the public `sdkReceiveAddress` StateFlow: it had no consumers and
would have been a fourth, unstamped way to read the cache.

Regression: `inFlightReadCannotRepublishAWipedWalletsAddress` parks a read for
wallet A inside the FFI, wipes, releases it, then activates B with B's own read
unavailable — the only way A's leftover could surface. Confirmed non-vacuous by
mutation: deleting the generation check in the live read fails it. The fixture
gates by calling thread rather than a one-shot flag, because the balance
pipeline reads through the same method — an earlier one-shot version was eaten
by the pipeline and passed vacuously.

Nitpick also fixed: `WalletApplication.liveSdkReceiveAddressOrNull`'s Javadoc
now documents the cold-cache/warm-cache distinction and the changed-binding null.

Full suite: 152 failures, an identical set to the previous head — no new failures.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@HashEngineering

Copy link
Copy Markdown
Collaborator Author

Blocking finding fixed — it was real, and worse than the bug this PR set out to fix

Head is now 3c0a76e81. I verified the finding against the source before changing anything; it holds in full, including the escalation from "rollback hardening" to wallet isolation.

The defect

sdkReceiveAddressLiveBlockingOrNull checked the cutover flag and bound wallet id, made a blocking, uncancellable FFI read, then published unconditionally. Check-then-act. And WalletApplicationExt.clearDatabasesInner states outright that Reset Wallet does not restart the process, calling cutoverCoordinator.resetForWalletWipe(). So:

  1. Wallet A's read parks inside the engine
  2. Reset Wallet wipes A; the deactivation branch clears the overlay
  3. The parked read returns and republishes A's address
  4. Wallet B restores; until B's own read lands, the sync accessor — checking neither activation nor ownership — serves A's address
  5. WalletApplication validates the address's network, not its owning seed, so nothing downstream catches it

B's Receive screen advertises an address only the erased wallet can spend. My existing _cutoverActive gate cannot prevent this, exactly as the review says: re-checking a flag on the way out is still check-then-act, and a boolean cannot distinguish a surviving binding from a different one that activated meanwhile.

The fix

The cache now carries the binding generation it was read under, bumped on every bind/unbind under a lock. A read captures it before the FFI and may publish only if it is still current; the synchronous accessor serves an entry only while its stamp matches. Cross-wallet reuse is unrepresentable rather than unlikely. The lock is never held across the FFI call, so deactivation never waits on the engine.

I also removed the public sdkReceiveAddress StateFlow — it had no consumers and would have been a fourth, unstamped way to read the cache.

The nitpick is fixed too: liveSdkReceiveAddressOrNull's Javadoc now documents the cold-cache/warm-cache distinction and the changed-binding null.

Verification

Unit — inFlightReadCannotRepublishAWipedWalletsAddress parks a read for A inside the FFI, wipes, releases it, then activates B with B's own read unavailable. Mutation-confirmed: deleting the generation check in the live read fails it.

Device — SR-03 unchanged at 3/3 (yLQFsqQqJuayzP3j1usaFpuTL1KjjLyjLv), plus a new wallet-isolation check driving the in-app wipe, since the uninstall-based trial script restarts the process and cannot reach this path:

Step Observed
Wallet A synced yLQFsq…, username qasyncrerun2026wallet, PID 21257
After in-app wipe OnboardingActivity, PID still 21257 — in-process path genuinely exercised
Wallet B synced yjRygCqFNvMUvHcSRQ2M7wXJ18sgjzJgqJ, username qab17rerun2026wallet, PID still 21257

On chain: both served addresses 0 appearances; B's own previously funded ySEvuJYNUQin48jh3JPj9x4kAU38a8k1Zk 2 appearances / 0.11 — so B independently re-demonstrates SR-03 by skipping its used address, and nothing crossed between wallets.

What the device run does not prove: it shows isolation in the ordinary case; it does not reproduce the race, which needs a read parked in the FFI at the instant of the wipe and is not drivable from the UI. The unit test is the proof for that.

Full suite: 152 failures, identical set to the previous head — none new. ktlint clean.

One correction worth stating

The wallet-switch check initially reported B serving A's address — an apparent reproduction on the fixed build. It was a fixture failure, not a defect: tap_text "More" can never work because that tab node carries no text (only id/moreFragment), BACK from the Receive sheet drops to the launcher, and the confirm is two steps with different labels. The wallet never switched. The giveaway was the username in the UI dump — identical across both captures. The script now asserts OnboardingActivity after the wipe and aborts otherwise, so it cannot silently pass again.

🤖 Generated with Claude Code

@thepastaclaw thepastaclaw left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Re-review — Final validation — Phase 1 + Phase 2

Verified head 3c0a76e: all five prior findings are fixed, but the shared receive/unshield destination introduces a privacy regression, and one CoinJoin KDoc reference remains stale. The 99 focused tests passed on rerun; the first run failed the isolation test's initial cache-population assertion before reaching the parked-read scenario. No tracked files were changed.

🔴 1 blocking | 💬 1 nitpick(s)

1 finding(s) not shown inline (the lines are not part of this PR's diff)

💬 Nitpick: CoinJoin drain KDoc still names the plain freshReceiveAddress accessor
wallet/src/de/schildbach/wallet/service/platform/sdk/SdkL1SendService.kt:1419-1421

The migration caller now obtains this destination through walletData.freshReceiveAddressLive() on Dispatchers.IO, but the KDoc still names freshReceiveAddress(). Update the reference to preserve the live-read requirement. At this head the plain accessor also has an SDK overlay, but it serves the cache rather than performing the live engine read used by the migration.

     * [ownAddressBase58] MUST be an address of THIS wallet's unmixed BIP44
     * account (callers derive it via `WalletData.freshReceiveAddressLive()`); the
     * only validation possible here is network/format.

source: muse-spark-1.3-contributor (phase1-reviewer: general)

Review provenance

Source: reviewer 1: muse-spark-1.3-contributor (agent: phase1-reviewer, role: general); reviewer 2: muse-spark-1.3-contributor (agent: phase1-reviewer, role: security-auditor); reviewer 3: gpt-6-astra (agent: phase2-reviewer, role: general); reviewer 4: gpt-6-astra (agent: phase2-reviewer, role: security-auditor); final verifier: gpt-6-astra (agent: astra-verifier, role: final-verifier)

  • Triage: critical by gpt-6-astra (effort low) — The diff introduces intricate wallet-generation-aware FFI address caching in CutoverUiDataService.sdkReceiveAddressLiveBlockingOrNull and changes actual funds destinations in ShieldedTransferExecutor and CoinJoinFundsMigrationService, making wallet isolation and address selection critical to funds safety.
  • Phase 1 reviewers: muse-spark-1.3-contributor — general (completed, effort xhigh); agent phase1-reviewer, muse-spark-1.3-contributor — security-auditor (completed, effort xhigh); agent phase1-reviewer
  • Phase 1 model: muse-spark-1.3-contributor — not quota-gated; passed over gemini-3.8-flash-high (antigravity below 15% reserve: weekly 21% left, 5h 14% left), glm-5.3-flash (not used above high effort; tier asks max)
  • Fresh verifier: gpt-6-astra — final-verifier; agent astra-verifier
  • Phase 2 reviewers: gpt-6-astra — general (completed, effort xhigh); agent phase2-reviewer, gpt-6-astra — security-auditor (completed, effort xhigh); agent phase2-reviewer
🤖 Prompt for all review comments with AI agents
These findings are from an automated code review. Verify each finding against the current code and only fix it if needed.

In `wallet/src/de/schildbach/wallet/ui/shielded/ShieldedTransferExecutor.kt`:
- [BLOCKING] wallet/src/de/schildbach/wallet/ui/shielded/ShieldedTransferExecutor.kt:605-612: Do not unshield to an address already handed to a payer
  Post-cutover, this destination and the Receive screen now resolve to the same engine next-unused address. A counterparty can obtain the user's receive QR, leave it unpaid, and monitor it; a subsequent unshield then pays that disclosed address, linking the withdrawal and its amount to the user. `withdrawToCore()` forwards this address to the withdrawal source without allocating a separate destination. This occurs with successful reads and a fully synced engine, not just during the documented mid-scan window. Previously, the Receive screen's dashj current key was already issued, and the unshield path requested another fresh key. The documented fresh/current narrowing therefore removes destination separation from a privacy-sensitive self-transfer, rather than merely changing invoice allocation. Use an SDK-owned destination reserved separately from externally advertised receive addresses, and add coverage proving that an unpaid receive handout is not reused for unshielding.

In `wallet/src/de/schildbach/wallet/service/platform/sdk/SdkL1SendService.kt`:
- [NITPICK] wallet/src/de/schildbach/wallet/service/platform/sdk/SdkL1SendService.kt:1419-1421: CoinJoin drain KDoc still names the plain freshReceiveAddress accessor
  The migration caller now obtains this destination through `walletData.freshReceiveAddressLive()` on `Dispatchers.IO`, but the KDoc still names `freshReceiveAddress()`. Update the reference to preserve the live-read requirement. At this head the plain accessor also has an SDK overlay, but it serves the cache rather than performing the live engine read used by the migration.

Comment on lines 605 to 612
shieldedBalanceService.withdrawToCore(
debugUnshieldDestinationOverride()
?: walletDataProvider.freshReceiveAddressString(),
// `Live`, not the plain accessor: this whole function runs
// on [ioDispatcher], and post-cutover the dashj chain is
// HELD — the plain read would unshield into an address the
// chain has already paid (SR-03 / D-003).
?: walletDataProvider.freshReceiveAddressLive().toBase58(),
amount

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🔴 Blocking: Do not unshield to an address already handed to a payer

Post-cutover, this destination and the Receive screen now resolve to the same engine next-unused address. A counterparty can obtain the user's receive QR, leave it unpaid, and monitor it; a subsequent unshield then pays that disclosed address, linking the withdrawal and its amount to the user. withdrawToCore() forwards this address to the withdrawal source without allocating a separate destination. This occurs with successful reads and a fully synced engine, not just during the documented mid-scan window. Previously, the Receive screen's dashj current key was already issued, and the unshield path requested another fresh key. The documented fresh/current narrowing therefore removes destination separation from a privacy-sensitive self-transfer, rather than merely changing invoice allocation. Use an SDK-owned destination reserved separately from externally advertised receive addresses, and add coverage proving that an unpaid receive handout is not reused for unshielding.

source: gpt-6-astra (phase2-reviewer: security-auditor)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Resolved (re-reviewed at 41b48c28): Your withdrawal executor now obtains its destination through unadvertisedDestinationLive(), not the advertised receive accessors, before invoking withdrawToCore(). The focused executor tests passed, including the assertion that destination preparation failure prevents withdrawal.

HashEngineering added a commit that referenced this pull request Sep 25, 2026
…dress

BLOCKING review finding on #1570, and it is correct — a privacy regression this
PR introduced, not a pre-existing one.

Post-cutover `fresh` and `current` receive addresses coincide, because the
engine tracks USED, not ISSUED. The unshield destination and the Receive screen
therefore resolved to the SAME engine next-unused address. A counterparty handed
the user's receive QR who simply never pays it can watch that address; the user's
next unshield then pays it, disclosing the withdrawal and its amount — the one
thing shielding exists to prevent. Before this PR the two were separate: the
Receive screen showed dashj's already-issued current key while the unshield
requested a NEW key. The fresh/current collapse removed that separation, and the
"deliberate narrowing" note under-weighted the consequence.

The engine already has a chain that is never handed out — the BIP-44 internal
(change) chain, `core_wallet_next_change_address`. Self-transfers now draw from
it via a new `WalletData.unadvertisedDestinationLive()`:

- the shielded withdrawal (`ShieldedTransferExecutor`), the finding's subject;
- the post-upgrade CoinJoin combine (`CoinJoinFundsMigrationService`), NOT
  flagged but the identical defect, and its own KDoc already said that reusing a
  published address would link the mixed coins. Fixed with it rather than left
  as a known instance of a bug being fixed one line away.

Pre-cutover, and whenever the engine has no answer, it falls back to dashj's
freshly issued receive key — exactly what these paths used before the overlay,
and already distinct from the advertised current key.

Deliberately NOT cached: every caller consumes it at spend time, and caching
would make two successive self-transfers share a destination for no benefit. Two
unshields raced before either confirms can still share one internal address
(the FFI exposes no reservation), but that is reuse between two of the user's own
transfers, invisible to any payer. Generation-validated like the receive read, so
a wipe mid-read cannot send our funds to a wallet that is no longer current.

The paper-wallet sweep still draws from the receive chain: it is semantically a
receive, and its funds are already linked to the paper key. Flagged for review
rather than changed silently.

Tests: `unshieldDestinationIsNeverTheAdvertisedReceiveAddress` pins the
separation and holds it across repeated UNPAID handouts (the attack), and
`unshieldDestinationIsRefusedWhenTheBindingIsGone` pins wallet isolation. Both
mutation-confirmed: pointing the accessor back at the receive chain fails them.
`ShieldedTransferExecutorTest` additionally verifies the receive accessors are
never called on the withdraw path.

Also fixed a latent flake this PR introduced:
`postCutover_receiveAddressAdvancesOnAnEngineTxEvent` asserted after a bare
`runCurrent()` against a refresh that hops to a real dispatcher — it passed by
luck. It now pumps until the condition holds, and the hold-on-failure case waits
for the read to have been ATTEMPTED instead of passing vacuously. Green 3/3 on
repeat runs.

Nitpick fixed: the CoinJoin drain KDoc named the plain `freshReceiveAddress()`.

Full suite: 152 failures, an identical set to the previous head — none new.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@HashEngineering

Copy link
Copy Markdown
Collaborator Author

Second blocking finding fixed — and it was a regression this PR introduced

Head is now c0f5bfbf1. Verified against the source first: the finding is correct, and it was hiding inside the "deliberate narrowing" I documented earlier and waved through.

The defect

Post-cutover fresh and current receive addresses coincide (the engine tracks USED, not ISSUED), so the unshield destination and the Receive screen resolved to the same address. Hand someone your receive QR, they never pay it, they watch it — your next unshield pays it, disclosing the withdrawal and its amount. That is exactly what shielding exists to prevent.

Before this PR the two were separate: Receive showed dashj's already-issued current key while the unshield requested a new key. My fresh/current collapse merged them. The narrowing note under-weighted this.

The fix

The engine already has a chain that is never handed out — the BIP-44 internal (change) chain, core_wallet_next_change_address. Self-transfers now draw from it through a new WalletData.unadvertisedDestinationLive(), generation-validated like the receive read so a wipe mid-read cannot send funds to a wallet that is no longer current.

Deliberately not cached: each caller consumes it at spend time, and caching would make two successive self-transfers share a destination for no benefit. Two unshields raced before either confirms can still share one internal address — the FFI exposes no reservation — but that is reuse between two of the user's own transfers, invisible to any payer.

I also fixed the CoinJoin combine, which you did not flag. It is the identical defect, and its own KDoc already said that reusing a published address would link the mixed coins; leaving a known instance of a bug one line from its fix was not defensible. The paper-wallet sweep stays on the receive chain — it is semantically a receive and those funds are already linked to the paper key — flagged here rather than changed silently, in case you disagree.

Coverage

unshieldDestinationIsNeverTheAdvertisedReceiveAddress pins the separation and holds it across repeated unpaid handouts, which is the actual attack; unshieldDestinationIsRefusedWhenTheBindingIsGone pins wallet isolation. Both mutation-confirmed — pointing the accessor back at the receive chain fails them. ShieldedTransferExecutorTest additionally verifies the receive accessors are never called on the withdraw path.

Evidence that the internal chain is credited

Wallet A's existing shield spend 5b0ea751…:

out 0.10999000 -> (shielded pool, no address)
out 0.00000737 -> yM9uCfkYnDbBwfHiSSQ4sNDLiKQRhMTeYH   [internal/change chain]

The app displays 0.000007 as that wallet's transparent balance, so it already credits internal-chain outputs. That address has txAppearances=1, so the engine's next-unused internal address is a different one and the fix cannot reuse it.

One gap, stated plainly

No live unshield was executed on device. The end-to-end credit is supported by the chain evidence above plus unit tests, not by an observed withdrawal. That step spends a testnet fee from a shared QA fixture and I am holding it pending an explicit go-ahead. This is a funds-destination change, so please weigh that gap rather than assume it closed.

SR-03 itself is unchanged: 3/3 on c0f5bfbf1 (yLQFsqQqJuayzP3j1usaFpuTL1KjjLyjLv) — the Receive screen is untouched, only the self-transfer destinations moved.

Also fixed: a latent flake this PR introduced

postCutover_receiveAddressAdvancesOnAnEngineTxEvent asserted after a bare runCurrent() against a refresh that hops to a real dispatcher — it had been passing by luck. It now pumps until the condition holds, and the hold-on-failure case waits for the read to have been attempted rather than passing vacuously. Green 3/3 on repeats.

Nitpick fixed: the CoinJoin drain KDoc named the plain freshReceiveAddress().

Full suite: 152 failures, identical set to the previous head — none new. ktlint clean.

🤖 Generated with Claude Code

@thepastaclaw thepastaclaw left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Re-review — Preliminary review — Phase 1 blocker gate

Verified the findings against head c0f5bfb. The internal-chain destination fixes the normal self-transfer path, but its failure fallback still permits the previously reported advertised-address privacy leak. The other six prior findings are addressed; one new documentation nit remains.

Validated blockers were found by the Phase-1 review and confirmed by a fresh verifier. Phase 2 is deferred until a fresh same-head revalidation clears the blocker gate.

🔴 1 blocking | 💬 1 nitpick(s)

Review provenance

Source: reviewer 1: muse-spark-1.3-contributor (agent: phase1-reviewer, role: general); reviewer 2: muse-spark-1.3-contributor (agent: phase1-reviewer, role: security-auditor); final verifier: gpt-6-astra (agent: astra-gate-verifier, role: verifier)

  • Triage: critical by gpt-6-astra (effort low) — The diff combines intricate wallet-binding and receive-address cache lifecycle logic in CutoverUiDataService.kt with changes to actual funds destinations in ShieldedTransferExecutor.kt, CoinJoinFundsMigrationService.kt, and SdkL1SendService.kt, meeting both the complexity and funds-movement criteria.
  • Phase 1 reviewers: muse-spark-1.3-contributor — general (completed, effort xhigh); agent phase1-reviewer, muse-spark-1.3-contributor — security-auditor (completed, effort xhigh); agent phase1-reviewer
  • Phase 1 model: muse-spark-1.3-contributor — not quota-gated; passed over gemini-3.8-flash-high (antigravity below 15% reserve: weekly 12% left, 5h 51% left), glm-5.3-flash (not used above high effort; tier asks max)
  • Fresh verifier: gpt-6-astra — verifier; agent astra-gate-verifier
  • Phase 2 reviewers: not run (deferred by blocker gate)
🤖 Prompt for all review comments with AI agents
These findings are from an automated code review. Verify each finding against the current code and only fix it if needed.

In `wallet/src/de/schildbach/wallet/WalletApplication.java`:
- [BLOCKING] wallet/src/de/schildbach/wallet/WalletApplication.java:2138-2146: Do not unshield to an address already handed to a payer
  The internal-chain read restores separation on success, but the fallback still pays self-transfers to the advertised address. The production nextChangeAddressOrNull() returns null on an unavailable wallet manager, an empty result, or a caught FFI failure. If the receive cache is warm, unadvertisedDestinationLive() then calls the overlaid freshReceiveAddress(), which returns that cached engine receive address—not a freshly issued dashj key as the Javadoc promises. ShieldedTransferExecutor and CoinJoinFundsMigrationService both consume this result. A counterparty holding an unpaid receive QR can therefore observe the withdrawal or combine and its amount when the change-address read fails. Keep the fallback out of the advertised receive overlay; fail the operation before broadcast when a safe post-cutover destination cannot be obtained, and test the warm-receive-cache/change-read-null case through the WalletApplication accessor.

In `wallet/src/de/schildbach/wallet/service/platform/sdk/CutoverUiDataService.kt`:
- [NITPICK] wallet/src/de/schildbach/wallet/service/platform/sdk/CutoverUiDataService.kt:2573-2584: Restore the blocking receive accessor's KDoc and update its callers
  The new internal-chain accessor was inserted between sdkReceiveAddressLiveBlockingOrNull() and its KDoc. The receive-read documentation now precedes a second KDoc for the internal-chain method, leaving the receive method without its attached documentation. Move the receive-read KDoc directly above sdkReceiveAddressLiveBlockingOrNull(). Also remove the unshield destination from the caller lists in sdkReceiveAddressLiveOrNull() and WalletApplication.currentReceiveAddressLive(), since unshield now uses unadvertisedDestinationLive().

Comment on lines +2138 to +2146
public Address unadvertisedDestinationLive() {
if (cutoverUiDataService != null) {
final Address unadvertised = toDashjAddressOrNull(
cutoverUiDataService.sdkUnadvertisedAddressLiveBlockingOrNull());
if (unadvertised != null) {
return unadvertised;
}
}
return freshReceiveAddress();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🔴 Blocking: Do not unshield to an address already handed to a payer

The internal-chain read restores separation on success, but the fallback still pays self-transfers to the advertised address. The production nextChangeAddressOrNull() returns null on an unavailable wallet manager, an empty result, or a caught FFI failure. If the receive cache is warm, unadvertisedDestinationLive() then calls the overlaid freshReceiveAddress(), which returns that cached engine receive address—not a freshly issued dashj key as the Javadoc promises. ShieldedTransferExecutor and CoinJoinFundsMigrationService both consume this result. A counterparty holding an unpaid receive QR can therefore observe the withdrawal or combine and its amount when the change-address read fails. Keep the fallback out of the advertised receive overlay; fail the operation before broadcast when a safe post-cutover destination cannot be obtained, and test the warm-receive-cache/change-read-null case through the WalletApplication accessor.

source: muse-spark-1.3-contributor (phase1-reviewer: general, security-auditor)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Resolved (re-reviewed at 41b48c28): Your WalletApplication accessor now throws when the post-cutover internal address is unavailable or invalid, closing the warm-cache receive fallback. Its pre-cutover branch calls wallet.freshReceiveAddress() directly rather than the overlaid application accessor.

Comment on lines +2573 to +2584
/**
* [sdkReceiveAddressLiveOrNull] without the coroutine — the whole read is
* synchronous (the FFI call needs no suspension), so the Java call sites
* that are already on a background thread
* ([de.schildbach.wallet.WalletApplication.currentReceiveAddressLive] and
* [de.schildbach.wallet.WalletApplication.freshReceiveAddressLive], which
* the paper-wallet sweep drives off its background handler) can use it
* directly instead of bridging into `runBlocking`.
*
* BLOCKS on the engine's wallet-manager write lock — off-main only.
*/
/**

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

💬 Nitpick: Restore the blocking receive accessor's KDoc and update its callers

The new internal-chain accessor was inserted between sdkReceiveAddressLiveBlockingOrNull() and its KDoc. The receive-read documentation now precedes a second KDoc for the internal-chain method, leaving the receive method without its attached documentation. Move the receive-read KDoc directly above sdkReceiveAddressLiveBlockingOrNull(). Also remove the unshield destination from the caller lists in sdkReceiveAddressLiveOrNull() and WalletApplication.currentReceiveAddressLive(), since unshield now uses unadvertisedDestinationLive().

source: muse-spark-1.3-contributor (phase1-reviewer: general, security-auditor)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Resolved (re-reviewed at 41b48c28): Your blocking-accessor KDoc is now attached directly to sdkReceiveAddressLiveBlockingOrNull(), and the live-receive caller documentation correctly directs unshield and CoinJoin self-transfers to the separate unadvertised accessor.

HashEngineering added a commit that referenced this pull request Sep 25, 2026
…dress

BLOCKING review finding on #1570. Correct, and a defect in my own previous fix.

`unadvertisedDestinationLive()` fell back to `freshReceiveAddress()` when the
engine's change read returned null — but that accessor is OVERLAID by this very
PR, so on a warm cache it returns the cached engine RECEIVE address: the
advertised one. The fallback therefore reintroduced the exact leak the internal
chain was added to prevent, and did so precisely when the engine read was
already failing. The Javadoc claiming "dashj's freshly issued receive key" was
simply false post-cutover; I wrote it believing the plain accessor was still
dashj's, having overlaid it myself two commits earlier.

Post-cutover the method now THROWS instead. There is no safe fallback on that
side, and failing is cheap where leaking is not: both callers
(`ShieldedTransferExecutor`, `CoinJoinFundsMigrationService`) already catch
strictly BEFORE broadcast and report not-sent/not-attempted, so a failure costs
a retry while a leak is permanent and invisible to the user.

Pre-cutover it now reads `wallet.freshReceiveAddress()` directly rather than
through the overlaid accessor, which is what it always meant to do.

Tests: `unadvertisedDestinationNeverFallsBackToTheWarmReceiveCache` sets up the
dangerous shape exactly — warm receive cache, change read unavailable — and
asserts the accessor refuses. Mutation-confirmed: letting the read borrow
`cachedReceiveAddress` fails it. `freshAddressFailure_isNotSent_notAmbiguous`
now also asserts nothing was broadcast and that no advertised accessor was
consulted, which is what makes failing closed safe.

Nitpick fixed: inserting the internal-chain method had orphaned
`sdkReceiveAddressLiveBlockingOrNull`'s KDoc above the wrong function; it is
reattached, and the unshield destination is removed from the caller lists on
`sdkReceiveAddressLiveOrNull` and `WalletApplication.currentReceiveAddressLive`.

Full suite: 152 failures, an identical set to the previous head — none new.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@HashEngineering

Copy link
Copy Markdown
Collaborator Author

Fixed — and this one was a defect in my own previous fix

Head is now c790597bd. Verified against the source: the finding is exactly right.

unadvertisedDestinationLive() fell back to freshReceiveAddress() when the change read returned null — but that accessor is overlaid by this very PR, so on a warm cache it returns the cached engine receive address. The fallback therefore reintroduced the precise leak the internal chain was added to prevent, and did so exactly when the engine read was already failing. My Javadoc promising "dashj's freshly issued receive key" was false post-cutover; I wrote it believing the plain accessor was still dashj's, having overlaid it myself two commits earlier.

The fix

Post-cutover it now throws instead of falling back. There is no safe fallback on that side, and the asymmetry is decisive: both callers already catch strictly before broadcast and report not-sent / not-attempted, so failing costs a retry, whereas leaking is permanent and invisible to the user.

Pre-cutover it now reads wallet.freshReceiveAddress() directly rather than through the overlaid accessor — what it always meant to do.

Coverage

WalletApplication isn't unit-testable here (Android + Hilt), so I pinned the two invariants that carry the guarantee instead of asserting through it:

  • unadvertisedDestinationNeverFallsBackToTheWarmReceiveCache builds the dangerous shape exactly — warm receive cache, change read unavailable — and asserts the accessor refuses rather than borrowing. Mutation-confirmed: letting it fall back to cachedReceiveAddress fails it.
  • freshAddressFailure_isNotSent_notAmbiguous now also asserts nothing was broadcast and that no advertised accessor was consulted — that is what makes failing closed safe rather than merely different.

If you'd still prefer an assertion driven through the WalletApplication accessor itself, say so and I'll add a Robolectric-backed one; I judged the two above to cover the behaviour without that dependency, but it's your call.

Nitpick

Inserting the internal-chain method had orphaned sdkReceiveAddressLiveBlockingOrNull's KDoc above the wrong function — reattached. The unshield destination is removed from the caller lists on sdkReceiveAddressLiveOrNull and WalletApplication.currentReceiveAddressLive.

Full suite: 152 failures, identical set to the previous head — none new. ktlint clean.

Still open, unchanged: no live unshield has been executed on device, so the end-to-end credit into the internal chain rests on chain evidence plus unit tests, not an observed withdrawal. Flagging again rather than letting it fade.

🤖 Generated with Claude Code

HashEngineering added a commit that referenced this pull request Sep 26, 2026
Review feedback on #1570. All three findings verified against the source
first; none were false positives.

- `refundAddressOrNull`'s KDoc and the `SendCoinsTaskRunner` call-site comment
  still described the Room address-pool read this PR deleted, including its
  null conditions ("the pool rows are missing"). Both now describe the engine
  FFI read and its real null cases: wallet unbound, no BIP44 account yet, or
  the read failed.

- `sdkReceiveAddressLiveOrNull`'s KDoc claimed null "when the read is
  unavailable", which contradicted both the implementation and this PR's own
  `postCutover_failedEngineReadHoldsTheLastAddress` test: a failed read on a
  warm cache deliberately returns the last known engine address, because
  dropping to null would send the caller back to the frozen dashj pointer.
  The doc now states the hold and keeps the guarantee that actually holds —
  every value it yields came from the engine, none are guessed.

- The rollback clear (CUT_OVER -> DUAL_RUNNING) had no test, and could not
  have had one: `configWithState` returns a one-shot `flowOf`, so the
  deactivation branch was unreachable from the suite. Added
  `configWithMutableState` and a test asserting that after a rollback the
  cached overlay, the live read, the balance and the UTXO count all stop
  serving. Confirmed non-vacuous by mutation: deleting
  `_sdkReceiveAddress.value = null` fails it.

Rebased onto 02e741b; the upstream CutoverUiDataService changes merged
without conflict. Full suite: 152 failures, an identical set to the base at
02e741b (pre-existing conscrypt native-lib failures in this environment).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
HashEngineering added a commit that referenced this pull request Sep 26, 2026
BLOCKING review finding on #1570, and it is a real one — verified against the
source before changing anything.

`sdkReceiveAddressLiveBlockingOrNull` checked the cutover flag and the bound
wallet id, then made a BLOCKING FFI read, then published unconditionally. That
is check-then-act across a call that cannot be cancelled, so:

  1. wallet A's live read captures A's id and blocks in the engine;
  2. Reset Wallet wipes A. `WalletApplicationExt.clearDatabasesInner` says in
     as many words that it does NOT restart the process, and it calls
     `cutoverCoordinator.resetForWalletWipe()`, so the deactivation branch runs
     and clears the overlay;
  3. the parked read returns and republishes A's address into the cache;
  4. wallet B restores. Until B's own engine read lands, `sdkReceiveAddressOrNull`
     — which checked neither activation nor ownership — serves A's address, and
     `WalletApplication` validates the address's NETWORK, not which seed owns
     it, so nothing downstream catches it.

The Receive screen then advertises an address only the ERASED wallet can spend.
That is worse than the SR-03 defect this PR set out to fix, and the existing
`_cutoverActive` gate does not prevent it: re-checking the flag on the way out
is still check-then-act, and a flag cannot tell a surviving binding from a
different one that activated meanwhile.

So the cache now carries the binding GENERATION it was read under. The
generation is bumped on every bind and unbind under a lock; a read captures it
before the FFI and may publish only if it is still current; the synchronous
accessor serves an entry only while its stamp matches. Cross-wallet reuse is
unrepresentable rather than merely unlikely. The lock is never held across the
FFI call, so a deactivation never waits on the engine.

Also removed the public `sdkReceiveAddress` StateFlow: it had no consumers and
would have been a fourth, unstamped way to read the cache.

Regression: `inFlightReadCannotRepublishAWipedWalletsAddress` parks a read for
wallet A inside the FFI, wipes, releases it, then activates B with B's own read
unavailable — the only way A's leftover could surface. Confirmed non-vacuous by
mutation: deleting the generation check in the live read fails it. The fixture
gates by calling thread rather than a one-shot flag, because the balance
pipeline reads through the same method — an earlier one-shot version was eaten
by the pipeline and passed vacuously.

Nitpick also fixed: `WalletApplication.liveSdkReceiveAddressOrNull`'s Javadoc
now documents the cold-cache/warm-cache distinction and the changed-binding null.

Full suite: 152 failures, an identical set to the previous head — no new failures.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
HashEngineering added a commit that referenced this pull request Sep 26, 2026
…dress

BLOCKING review finding on #1570, and it is correct — a privacy regression this
PR introduced, not a pre-existing one.

Post-cutover `fresh` and `current` receive addresses coincide, because the
engine tracks USED, not ISSUED. The unshield destination and the Receive screen
therefore resolved to the SAME engine next-unused address. A counterparty handed
the user's receive QR who simply never pays it can watch that address; the user's
next unshield then pays it, disclosing the withdrawal and its amount — the one
thing shielding exists to prevent. Before this PR the two were separate: the
Receive screen showed dashj's already-issued current key while the unshield
requested a NEW key. The fresh/current collapse removed that separation, and the
"deliberate narrowing" note under-weighted the consequence.

The engine already has a chain that is never handed out — the BIP-44 internal
(change) chain, `core_wallet_next_change_address`. Self-transfers now draw from
it via a new `WalletData.unadvertisedDestinationLive()`:

- the shielded withdrawal (`ShieldedTransferExecutor`), the finding's subject;
- the post-upgrade CoinJoin combine (`CoinJoinFundsMigrationService`), NOT
  flagged but the identical defect, and its own KDoc already said that reusing a
  published address would link the mixed coins. Fixed with it rather than left
  as a known instance of a bug being fixed one line away.

Pre-cutover, and whenever the engine has no answer, it falls back to dashj's
freshly issued receive key — exactly what these paths used before the overlay,
and already distinct from the advertised current key.

Deliberately NOT cached: every caller consumes it at spend time, and caching
would make two successive self-transfers share a destination for no benefit. Two
unshields raced before either confirms can still share one internal address
(the FFI exposes no reservation), but that is reuse between two of the user's own
transfers, invisible to any payer. Generation-validated like the receive read, so
a wipe mid-read cannot send our funds to a wallet that is no longer current.

The paper-wallet sweep still draws from the receive chain: it is semantically a
receive, and its funds are already linked to the paper key. Flagged for review
rather than changed silently.

Tests: `unshieldDestinationIsNeverTheAdvertisedReceiveAddress` pins the
separation and holds it across repeated UNPAID handouts (the attack), and
`unshieldDestinationIsRefusedWhenTheBindingIsGone` pins wallet isolation. Both
mutation-confirmed: pointing the accessor back at the receive chain fails them.
`ShieldedTransferExecutorTest` additionally verifies the receive accessors are
never called on the withdraw path.

Also fixed a latent flake this PR introduced:
`postCutover_receiveAddressAdvancesOnAnEngineTxEvent` asserted after a bare
`runCurrent()` against a refresh that hops to a real dispatcher — it passed by
luck. It now pumps until the condition holds, and the hold-on-failure case waits
for the read to have been ATTEMPTED instead of passing vacuously. Green 3/3 on
repeat runs.

Nitpick fixed: the CoinJoin drain KDoc named the plain `freshReceiveAddress()`.

Full suite: 152 failures, an identical set to the previous head — none new.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
HashEngineering added a commit that referenced this pull request Sep 26, 2026
…dress

BLOCKING review finding on #1570. Correct, and a defect in my own previous fix.

`unadvertisedDestinationLive()` fell back to `freshReceiveAddress()` when the
engine's change read returned null — but that accessor is OVERLAID by this very
PR, so on a warm cache it returns the cached engine RECEIVE address: the
advertised one. The fallback therefore reintroduced the exact leak the internal
chain was added to prevent, and did so precisely when the engine read was
already failing. The Javadoc claiming "dashj's freshly issued receive key" was
simply false post-cutover; I wrote it believing the plain accessor was still
dashj's, having overlaid it myself two commits earlier.

Post-cutover the method now THROWS instead. There is no safe fallback on that
side, and failing is cheap where leaking is not: both callers
(`ShieldedTransferExecutor`, `CoinJoinFundsMigrationService`) already catch
strictly BEFORE broadcast and report not-sent/not-attempted, so a failure costs
a retry while a leak is permanent and invisible to the user.

Pre-cutover it now reads `wallet.freshReceiveAddress()` directly rather than
through the overlaid accessor, which is what it always meant to do.

Tests: `unadvertisedDestinationNeverFallsBackToTheWarmReceiveCache` sets up the
dangerous shape exactly — warm receive cache, change read unavailable — and
asserts the accessor refuses. Mutation-confirmed: letting the read borrow
`cachedReceiveAddress` fails it. `freshAddressFailure_isNotSent_notAmbiguous`
now also asserts nothing was broadcast and that no advertised accessor was
consulted, which is what makes failing closed safe.

Nitpick fixed: inserting the internal-chain method had orphaned
`sdkReceiveAddressLiveBlockingOrNull`'s KDoc above the wrong function; it is
reattached, and the unshield destination is removed from the caller lists on
`sdkReceiveAddressLiveOrNull` and `WalletApplication.currentReceiveAddressLive`.

Full suite: 152 failures, an identical set to the previous head — none new.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@HashEngineering
HashEngineering force-pushed the fix/receive-address-from-sdk-engine branch from c790597 to 41b48c2 Compare September 26, 2026 05:39
@HashEngineering

Copy link
Copy Markdown
Collaborator Author

Rebased onto the current base; SR-03 still 3/3. Plus a scope note on run 8.

Head is now 41b48c280. The base had moved 11 commits (several touching SdkL1SendService.kt); the rebase was conflict-free.

  • Tests: 161 failures on head vs 162 on a fresh baseline at base 6cd6c09ca — no new failures. The one-test difference is CutoverCoordinatorTest#sdkOwnsL1Flow_staysFalse_whenCommittedButSdkL1EngineDisabled, which is flaky on base (passes 3/3 in isolation there). This branch did not fix it and I am not claiming it did.
  • SR-03 on device: 3/3 on 41b48c280, yLQFsqQqJuayzP3j1usaFpuTL1KjjLyjLv — unchanged across the rebase.
  • ktlint clean.

On the QA sign-off — what it covers, and what it doesn't

qa-run8 confirms SR-03 on device with a third independent wallet (throwaway T3, yLs9xwzG6Poz…), after run 7 reproduced it on yg5K7RNbctmXVfVpZboo16DQP9rWDf12dH. Together with my own fixture that is three wallets, three runs, one verdict. SR-03 I would call settled.

However, the run-8 integration build (qa/integration-run8 @ cd5b1c6bf) carries this PR at 6509cdae3 + 9231bf227 — the first two commits. It predates all three fixes that came out of blocking findings, and nothing under qa-run8/ mentions unshield or withdrawToCore, so the shielded withdrawal path was not exercised there at all.

So the sign-off is a statement about SR-03 and about the code two commits in. It is not yet evidence about the funds-destination change in 8244429aa / 41b48c280.

What I would want before merge

  1. Re-cut the integration build at 41b48c280 and re-run the SR-03 regression — confirmation rather than discovery, since I have 3/3 on that exact commit. Budget more wall-clock than before: the post-rebase release build took 17 min against ~2–4 min incremental, because the 11 upstream commits invalidate most of the build cache.
  2. Exercise a shield → unshield cycle. This is the real gap. 8244429aa redirects unshielded funds to the engine's internal chain and 41b48c280 makes that path throw rather than fall back to an advertised address. Neither behaviour has been observed running on any device — not in run 8, not in mine.

The automated review lane had failed on infra (503 auth_unavailable) and never retried for c790597bd; this push re-triggered it and it is now queued for 41b48c28. Worth waiting for, given that each of the last three commits exists because the round before found a blocking defect.

Still no human review at any point.

🤖 Generated with Claude Code

@thepastaclaw thepastaclaw left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Re-review — Final validation — Phase 1 + Phase 2

At 41b48c2, the nine previously tracked findings are fixed, but three in-scope blockers remain: out-of-order address-cache publication, delayed binding invalidation during rollback, and unshield destinations sharing the ordinary payment-change allocator without reservation. All 132 focused existing tests passed; temporary deterministic probes reproduced both concurrency defects, and inspection of the bundled SDK confirmed the shared, unreserved address allocation. Temporary source changes were removed, the working tree is clean, and git diff --check passed; a live shield-to-unshield cycle remains unverified.

🔴 3 blocking | 🟡 1 suggestion(s)

Review provenance

Source: reviewer 1: muse-spark-1.3-contributor (agent: phase1-reviewer, role: general); reviewer 2: muse-spark-1.3-contributor (agent: phase1-reviewer, role: security-auditor); reviewer 3: gpt-6-astra (agent: phase2-reviewer, role: general); reviewer 4: gpt-6-astra (agent: phase2-reviewer, role: security-auditor); reviewer 5: gpt-6-astra (agent: phase2-reviewer, role: general); reviewer 6: gpt-6-astra (agent: phase2-reviewer, role: security-auditor); final verifier: gpt-6-astra (agent: astra-verifier, role: final-verifier)

  • Triage: critical by gpt-6-astra (effort low) — The diff combines intricate wallet-binding and address-cache lifecycle logic in CutoverUiDataService.kt with changes to actual funds destinations, including ShieldedTransferExecutor’s unshield withdrawal and CoinJoinFundsMigrationService’s migration output, meeting both the complexity and funds-movement criteria.
  • Phase 1 reviewers: muse-spark-1.3-contributor — general (completed, effort xhigh); agent phase1-reviewer, muse-spark-1.3-contributor — security-auditor (completed, effort xhigh); agent phase1-reviewer
  • Phase 1 model: muse-spark-1.3-contributor — not quota-gated; passed over gemini-3.8-flash-high (antigravity below 15% reserve: weekly 13% left, 5h 100% left), glm-5.3-flash (not used above high effort; tier asks max)
  • Fresh final gate: an independent Phase-2 review ran after iterative findings were reconciled
  • Fresh verifier: gpt-6-astra — final-verifier; agent astra-verifier
  • Phase 2 reviewers: gpt-6-astra — general (completed, effort xhigh); agent phase2-reviewer, gpt-6-astra — security-auditor (completed, effort xhigh); agent phase2-reviewer, gpt-6-astra — general (completed, effort xhigh); agent phase2-reviewer, gpt-6-astra — security-auditor (completed, effort xhigh); agent phase2-reviewer
🤖 Prompt for all review comments with AI agents
These findings are from an automated code review. Verify each finding against the current code and only fix it if needed.

In `wallet/src/de/schildbach/wallet/service/platform/sdk/CutoverUiDataService.kt`:
- [BLOCKING] wallet/src/de/schildbach/wallet/service/platform/sdk/CutoverUiDataService.kt:2610-2617: Reserve the unshield destination against ordinary payment change
  The internal address is not exclusive to this withdrawal. In the bundled v42int21 SDK, this FFI accessor and TransactionBuilder.fund_from both call ManagedCoreFundsAccount.next_change_address, which returns AddressPool.next_unused without reserving the address. An unshield can therefore select C, remain in progress or await its Core output, and an ordinary payment from existing transparent funds can also select C for change. The transfer continues in application scope after its progress dialog is dismissed, and ordinary L1 payments are not gated on it. A merchant can identify the customer's change output and then observe the withdrawal paying that same address, exposing the withdrawal amount even though the purchase did not spend withdrawn funds. This is not only the documented collision between two self-transfers; moving this destination from a freshly issued external key to the shared internal allocator introduces cross-operation linkage. Allocate and durably reserve the destination so competing change builders skip it, or use a separately issued destination pool. Add a regression that pauses a withdrawal after destination selection and verifies an ordinary payment uses different change.
- [BLOCKING] wallet/src/de/schildbach/wallet/service/platform/sdk/CutoverUiDataService.kt:2799-2804: Invalidate the address binding before waiting for pipeline cancellation
  This unbind executes inside collectLatest's replacement action, after collectLatest cancels and joins the previous runPipelines invocation. refreshNativeSplit performs a blocking FFI read in a child of that invocation, so a parked read prevents the DUAL_RUNNING action from reaching either this unbind or the _cutoverActive update. The cache remains valid under its old generation and live reads remain authorized for the retired binding throughout that interval. A deterministic probe that parked the pipeline read, emitted DUAL_RUNNING, and queried the accessors reproduced both cached and live old-binding results; invalidation completed only after releasing the read. The existing in-flight regression parks an independent caller, so it does not exercise this dependency. Invalidate ownership before awaiting cancellation, with binding/publication guards that prevent canceled work from restoring it, or add an explicit synchronous wallet-wipe invalidation hook. Cover rollback with a blocked pipeline read.
- [BLOCKING] wallet/src/de/schildbach/wallet/service/platform/sdk/CutoverUiDataService.kt:3094-3098: Prevent older engine reads from overwriting a newer cached address
  The generation check distinguishes wallet bindings but does not order concurrent reads within one binding. A refresh can obtain address A and pause before publication; after the engine advances, a live read can publish B; the older refresh then overwrites B with A because both generations match. The synchronous accessors, and subsequent failed live reads, consequently advertise the already-used address again until another successful refresh. A temporary deterministic overlapping-read probe reproduced exactly this regression: the live accessor published B, then the released older refresh replaced the cache with A. The native wallet-manager lock does not protect the later Kotlin cache publication. Serialize reads through publication across both refreshNativeSplit and sdkReceiveAddressLiveBlockingOrNull, or introduce ordering that prevents obsolete results from replacing newer ones, without holding the invalidation lock across FFI calls. Add an overlapping-read regression.

In `wallet/test/de/schildbach/wallet/service/platform/sdk/CutoverUiDataServiceTest.kt`:
- [SUGGESTION] wallet/test/de/schildbach/wallet/service/platform/sdk/CutoverUiDataServiceTest.kt:1666-1669: Exercise the fail-closed decision through WalletApplication
  This assertion verifies that CutoverUiDataService returns null when the change-address read is unavailable, but the previous fallback defect was in WalletApplication, which converted that null into an advertised receive address. The executor regression separately mocks unadvertisedDestinationLive() to throw, so neither test executes the application's null-to-exception decision. Reintroducing a freshReceiveAddress() fallback in WalletApplication would recreate the leak while both tests still passed. Add a test through the real application accessor with cutover active, a warm receive cache, and an unavailable change address, asserting that it throws without consulting either receive fallback. Robolectric is already a wallet test dependency.

Comment on lines +2610 to +2617
val (generation, walletIdHex) = bound
val address = source.nextChangeAddressOrNull(walletIdHex)
return synchronized(receiveAddressLock) {
// The wallet was wiped or rolled back while we were blocked: this
// address belongs to a wallet that is no longer current, and paying
// our own funds to it would send them somewhere the CURRENT wallet
// cannot spend. Answer nothing; the caller falls back to dashj.
if (generation != receiveAddressGeneration) null else address

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🔴 Blocking: Reserve the unshield destination against ordinary payment change

The internal address is not exclusive to this withdrawal. In the bundled v42int21 SDK, this FFI accessor and TransactionBuilder.fund_from both call ManagedCoreFundsAccount.next_change_address, which returns AddressPool.next_unused without reserving the address. An unshield can therefore select C, remain in progress or await its Core output, and an ordinary payment from existing transparent funds can also select C for change. The transfer continues in application scope after its progress dialog is dismissed, and ordinary L1 payments are not gated on it. A merchant can identify the customer's change output and then observe the withdrawal paying that same address, exposing the withdrawal amount even though the purchase did not spend withdrawn funds. This is not only the documented collision between two self-transfers; moving this destination from a freshly issued external key to the shared internal allocator introduces cross-operation linkage. Allocate and durably reserve the destination so competing change builders skip it, or use a separately issued destination pool. Add a regression that pauses a withdrawal after destination selection and verifies an ordinary payment uses different change.

source: gpt-6-astra (phase2-reviewer: security-auditor)

Comment on lines 2799 to 2804
// A rollback puts dashj back in charge of the key chain,
// so the engine address must stop being served — and the
// bound id must go with it, or a live read could still
// answer from the wallet the rollback just abandoned.
unbindReceiveAddressWallet()
return@collectLatest

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🔴 Blocking: Invalidate the address binding before waiting for pipeline cancellation

This unbind executes inside collectLatest's replacement action, after collectLatest cancels and joins the previous runPipelines invocation. refreshNativeSplit performs a blocking FFI read in a child of that invocation, so a parked read prevents the DUAL_RUNNING action from reaching either this unbind or the _cutoverActive update. The cache remains valid under its old generation and live reads remain authorized for the retired binding throughout that interval. A deterministic probe that parked the pipeline read, emitted DUAL_RUNNING, and queried the accessors reproduced both cached and live old-binding results; invalidation completed only after releasing the read. The existing in-flight regression parks an independent caller, so it does not exercise this dependency. Invalidate ownership before awaiting cancellation, with binding/publication guards that prevent canceled work from restoring it, or add an explicit synchronous wallet-wipe invalidation hook. Cover rollback with a blocked pipeline read.

source: gpt-6-astra (phase2-reviewer: general)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Resolved (re-reviewed at 32d58473): Your ownership collector now updates activation and unbinds independently of the collectLatest pipeline's cancellation and join. A blocked pipeline read therefore no longer prevents that collector from revoking the binding.

Comment on lines +3094 to +3098
when {
generation != receiveAddressGeneration -> false
nextReceive != null -> {
cachedReceiveAddress = ReceiveAddressSnapshot(generation, nextReceive)
true

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🔴 Blocking: Prevent older engine reads from overwriting a newer cached address

The generation check distinguishes wallet bindings but does not order concurrent reads within one binding. A refresh can obtain address A and pause before publication; after the engine advances, a live read can publish B; the older refresh then overwrites B with A because both generations match. The synchronous accessors, and subsequent failed live reads, consequently advertise the already-used address again until another successful refresh. A temporary deterministic overlapping-read probe reproduced exactly this regression: the live accessor published B, then the released older refresh replaced the cache with A. The native wallet-manager lock does not protect the later Kotlin cache publication. Serialize reads through publication across both refreshNativeSplit and sdkReceiveAddressLiveBlockingOrNull, or introduce ordering that prevents obsolete results from replacing newer ones, without holding the invalidation lock across FFI calls. Add an overlapping-read regression.

source: gpt-6-astra (phase2-reviewer: general)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Resolved (re-reviewed at 32d58473): Your shared begin/complete ticket gate now prevents a lower-ticket result from overwriting a newer cached address, and the regression checks that cache invariant. The direct return of the rejected address is a distinct remaining defect, recorded separately rather than reopening this cache-publication finding.

Comment on lines +1666 to +1669
assertNull(
"an unavailable change read must not borrow the advertised address",
service.sdkUnadvertisedAddressLiveBlockingOrNull()
)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 Suggestion: Exercise the fail-closed decision through WalletApplication

This assertion verifies that CutoverUiDataService returns null when the change-address read is unavailable, but the previous fallback defect was in WalletApplication, which converted that null into an advertised receive address. The executor regression separately mocks unadvertisedDestinationLive() to throw, so neither test executes the application's null-to-exception decision. Reintroducing a freshReceiveAddress() fallback in WalletApplication would recreate the leak while both tests still passed. Add a test through the real application accessor with cutover active, a warm receive cache, and an unavailable change address, asserting that it throws without consulting either receive fallback. Robolectric is already a wallet test dependency.

source: gpt-6-astra (phase2-reviewer: general)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Resolved (re-reviewed at 11ef19c4): decideUnadvertisedDestination() is extracted and covered by UnadvertisedDestinationDecisionTest, pinning the fail-closed decision without requiring the full Hilt graph.

@HashEngineering

Copy link
Copy Markdown
Collaborator Author

Two of three blockers fixed. The third is not fixable in-app — detail below.

Head is now 32d58473e. I verified all three findings against the source before changing anything; all three are real.

Fixed — #2, ownership revoked too late

The unbind ran inside collectLatest's replacement action, and collectLatest cancels and joins the previous runPipelines first. refreshNativeSplit blocks in an uncancellable FFI read, so a parked read delayed the replacement — and with it the unbind and the _cutoverActive update — for as long as it stayed parked. Your point about the existing regression is exactly right: it parks an independent caller, so it never exercised that dependency.

Ownership now lives in its own collector, which depends on nothing that can be parked. New regression parks the pipeline's read specifically. Mutation-confirmed.

Fixed — #3, unordered publication within a binding

Generation separates wallets, not two reads of the same wallet. Every read now takes a ticket before it blocks and may publish only if no later-started read already has. beginReceiveRead/completeReceiveRead is the single pair both the live accessor and the refresh go through; the lock is never held across an FFI call. Mutation-confirmed.

Worth flagging: that regression was vacuous as first written. The fake read its address field after the gate, so the parked read returned the new value and the test passed with the ordering check deleted. The fake now captures its answer before parking. I only caught it because I mutation-test every one of these.

Not fixed — #1, the shared unreserved change allocator

Your analysis is correct, including the part I got wrong: I had documented the collision as "between two of the user's own transfers, invisible to any payer". It isn't — the change output of an ordinary payment is visible to that payee, so the linkage is real and cross-operation.

But neither remedy you propose exists in the bundled SDK. I checked:

  • "durably reserve the destination so competing change builders skip it" — there is no address-reservation API. releaseReservation is for UTXO reservations on signed transactions, not addresses.
  • "use a separately issued destination pool" — no API creates or derives one. next_change_address_for_account errors with BIP-44 account N not found for an account the wallet does not already have, and nothing exposes account creation.

coreWalletNextChangeAddress bottoms out in account.next_change_address(Some(&xpub), true) → AddressPool.next_unused, which is the same allocator the engine's own change selection uses. So the collision is structural at this SDK version.

That leaves three options, none of them clean:

  1. Keep the internal chain (current). Residual: a payee of an ordinary payment whose change lands on C can later observe a withdrawal to C. Requires the attacker to be that payee and to watch C.
  2. Revert to the engine receive chain. Reintroduces the previous blocker: a counterparty handed an unpaid QR watches it. Requires only a QR handout — strictly easier to mount.
  3. Revert to dashj's freshly issued external key. Post-cutover dashj's issued pointer and the engine's next-unused can coincide, so this collapses into option 2 non-deterministically.

I read option 1 as the narrowest residual of the three, which is why I have left it in place rather than pick a worse one to clear the gate. The real fix is an upstream address-reservation or dedicated-pool API in rs-platform-wallet — I can file that if you want it tracked.

This is a judgement call above my pay grade on this PR, so I am flagging it rather than deciding it: merge with the residual documented, or hold #1570 until the SDK gains the API.

On the suggestion

Driving the decision through the real WalletApplication accessor means standing up its 24-field Hilt graph under Robolectric. I extracted decideUnadvertisedDestination instead and tested it directly, pinning the invariant that matters — the post-cutover arm never reaches the dashj supplier, since a fallback there is exactly how the leak shipped once. Mutation-confirmed. It does not stop a future caller passing a receive-address supplier in; only the full application-level test would, and I would rather state that limit than imply coverage I do not have.

Suite

162 failures vs 161 on the previous head. The one difference, AssetLockKindResolverNegativeCacheTest#nullTransactionKind_isNotPinned_soLateUnshieldStillResolves, passes 3/3 in isolation and this branch touches no asset-lock files. ktlint clean.

Still unverified, unchanged: no live shield→unshield cycle on any device — which your review also noted independently.

🤖 Generated with Claude Code

HashEngineering added a commit that referenced this pull request Sep 26, 2026
Two of the three blocking findings on #1570. Both verified against the source;
the reviewer reproduced each with a deterministic probe and so did I.

1. OWNERSHIP WAS REVOKED TOO LATE. The unbind ran inside `collectLatest`'s
   replacement action, and `collectLatest` cancels AND JOINS the previous
   `runPipelines` before running it. `refreshNativeSplit` blocks in an
   uncancellable FFI read, so a parked read delayed the replacement — and with
   it the unbind and the `_cutoverActive` update — for as long as it stayed
   parked. Throughout that window the retired binding's address was still
   cached under a matching generation and still served by live reads. The
   earlier in-flight regression parks an INDEPENDENT caller, so it never
   exercised this dependency.

   Ownership now lives in its own collector, which depends on nothing that can
   be parked, so a rollback or an in-place wipe revokes it at once.

2. PUBLICATIONS WERE UNORDERED WITHIN A BINDING. The generation separates
   wallets; it says nothing about two reads of the SAME wallet. A refresh could
   take address A, pause in the FFI, a live read publish the engine's newer B,
   and the released refresh overwrite B with A — putting an already-used address
   back on the Receive screen until the next successful refresh, which is the
   very defect this PR exists to fix.

   Every read now takes a ticket before it blocks and may publish only if no
   later-started read has published first. `beginReceiveRead`/`completeReceiveRead`
   are the single pair both the live accessor and the refresh go through; the
   lock is never held across an FFI call.

Also addresses the review's suggestion, though not the way it proposed. Driving
the decision through the real `WalletApplication` accessor would mean standing
up a 24-field Hilt graph under Robolectric; instead the decision is extracted as
`decideUnadvertisedDestination` and tested directly. The invariant pinned is the
one that matters: the post-cutover arm never reaches the dashj supplier, because
a fallback there is exactly how the leak shipped once. Mutation-confirmed —
reinstating the fallback fails it. This does not stop a future caller passing a
receive-address supplier in; only a full application-level test would, and that
trade-off is stated rather than hidden.

The two new concurrency regressions are mutation-confirmed too. Note the
ordering one was VACUOUS on first writing: the fake read its address field after
the gate, so the parked read returned the new value and the test passed with the
ordering check deleted. The fake now captures its answer before parking.

Suite: 162 failures vs 161 on the previous head. The one difference,
`AssetLockKindResolverNegativeCacheTest#nullTransactionKind_isNotPinned_soLateUnshieldStillResolves`,
passes 3/3 in isolation and this branch touches no asset-lock files.

The third blocking finding — the internal chain being shared, unreserved, with
ordinary payment change — is NOT addressed here; see the PR discussion. Neither
remedy it proposes exists in the bundled SDK.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

@thepastaclaw thepastaclaw left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Re-review — Preliminary review — Phase 1 blocker gate

At the exact reviewed head, the generation checks and independent ownership collector address the earlier wallet-isolation defects, and ticket ordering protects the receive cache. However, an older read can still return its stale address directly to its caller, and the unreserved internal-chain destination retains a cross-operation privacy exposure. Validation was by source and test inspection; no build, test execution, or device verification was performed.

Validated blockers were found by the Phase-1 review and confirmed by a fresh verifier. Phase 2 is deferred until a fresh same-head revalidation clears the blocker gate.

🔴 2 blocking | 🟡 2 suggestion(s)

1 carried-forward finding(s) already raised on this PR; not re-posting as new inline comments.

Review provenance

Source: reviewer 1: muse-spark-1.3-contributor (agent: phase1-reviewer, role: general); reviewer 2: muse-spark-1.3-contributor (agent: phase1-reviewer, role: security-auditor); final verifier: gpt-6-astra (agent: astra-gate-verifier, role: verifier)

  • Triage: critical by gpt-6-astra (effort low) — The diff combines intricate SDK address-cache ownership and publication ordering with direct changes to funds-movement destinations, notably WalletApplication.unadvertisedDestinationLive() and its shielded-withdrawal and CoinJoin-migration callers, including fail-closed behavior and internal-versus-advertised address selection.
  • Phase 1 reviewers: muse-spark-1.3-contributor — general (completed, effort xhigh); agent phase1-reviewer, muse-spark-1.3-contributor — security-auditor (completed, effort xhigh); agent phase1-reviewer
  • Phase 1 model: muse-spark-1.3-contributor — not quota-gated; passed over gemini-3.8-flash-high (antigravity below 15% reserve: weekly 13% left, 5h 100% left), glm-5.3-flash (not used above high effort; tier asks max)
  • Fresh verifier: gpt-6-astra — verifier; agent astra-gate-verifier
  • Phase 2 reviewers: not run (deferred by blocker gate)
🤖 Prompt for all review comments with AI agents
These findings are from an automated code review. Verify each finding against the current code and only fix it if needed.

In `wallet/src/de/schildbach/wallet/service/platform/sdk/CutoverUiDataService.kt`:
- [BLOCKING] wallet/src/de/schildbach/wallet/service/platform/sdk/CutoverUiDataService.kt:2505-2511: Return the newer cached address when a receive read loses ticket ordering
  The ticket comparison prevents an older read from overwriting the cache, but `return address ?: cachedReceiveAddress...` still returns that rejected address directly to its caller. If one live read captures A, a subsequent read publishes B after the engine advances, and the first read then completes, the first caller receives A despite the cache already holding B. WalletApplication's live accessors return this non-null result unchanged, so the Receive screen or an integration can advertise the stale address. The existing ordering regression reproduces the necessary interleaving but discards `olderResult.get()`. Return the current generation's cached address after applying the publication gate, and assert that the older caller receives B as well.
- [BLOCKING] wallet/src/de/schildbach/wallet/service/platform/sdk/CutoverUiDataService.kt:2638-2646: Reserve the unshield destination against ordinary payment change
  (existing thread: https://github.com/dashpay/dash-wallet/pull/1570#discussion_r4110572828)
  The destination wrapper calls `core.nextChangeAddress(accountIndex)` and returns the next-unused internal address without issuing or reserving it. Selecting the internal chain separates withdrawals from Receive QR handouts, but does not isolate them from ordinary-payment change. While an internal address C remains unused from the engine's perspective, an ordinary payment and an unshield or CoinJoin combine can select C; that payment's recipient can observe its change output and link the other transfer to it. This is cross-operation linkage, not merely reuse between private self-transfers. Keeping the internal chain avoids the easier unpaid-QR attack, and the lack of an exposed reservation or dedicated-pool API explains why the proposed alternatives cannot simply be implemented here. It does not establish the privacy guarantee claimed by the current KDoc. Resolving the exposure requires an SDK-supported allocation mechanism that competing change builders honor, or an explicit maintainer decision to accept this residual; document the residual accurately in either case.

In `wallet/src/de/schildbach/wallet/WalletApplication.java`:
- [SUGGESTION] wallet/src/de/schildbach/wallet/WalletApplication.java:2167-2179: Recheck cutover ownership before selecting the dashj destination
  The initial `isCutoverActive()` snapshot controls both whether the SDK is consulted and whether the dashj supplier is allowed. If ownership changes to CUT_OVER after that snapshot, this invocation still skips the SDK and issues a key from the held dashj chain. Unlike the internal-chain path, that key is not guaranteed to differ from the engine's advertised external address. Attempt the generation-validated SDK read before taking the cutover snapshot used for the fallback decision, and cover activation during destination lookup. This narrows the avoidable stale-snapshot window; a strict guarantee across arbitrary ownership changes would require coordination with the transition itself.

In `wallet/src/de/schildbach/wallet/ui/payments/SweepWalletFragment.java`:
- [SUGGESTION] wallet/src/de/schildbach/wallet/ui/payments/SweepWalletFragment.java:621-627: Paper-wallet sweeps now share the advertised receive address
  Post-cutover, `freshReceiveAddressLive()` returns the same next-unused external address as the Receive screen rather than issuing a separate key. A counterparty holding an unpaid Receive QR can therefore observe a later paper-wallet sweep to that address and associate its amount and paper-wallet inputs with the recipient. The stated reason for leaving this path unchanged—that the funds are already linked to the paper key—does not eliminate the newly introduced link from that paper key to the identity behind the QR. This is less sensitive than an unshield, but it is a privacy regression from the previous fresh-key behavior. Use the self-transfer destination policy for sweeps, with preparation failures handled before sending, or explicitly document acceptance of this additional linkage.

Comment on lines +2505 to +2511
private fun completeReceiveRead(generation: Long, ticket: Long, address: String?): String? {
if (generation != receiveAddressGeneration) return null
if (address != null && ticket > lastPublishedReceiveTicket) {
cachedReceiveAddress = ReceiveAddressSnapshot(generation, address)
lastPublishedReceiveTicket = ticket
}
return address ?: cachedReceiveAddress?.takeIf { it.generation == generation }?.address

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🔴 Blocking: Return the newer cached address when a receive read loses ticket ordering

The ticket comparison prevents an older read from overwriting the cache, but return address ?: cachedReceiveAddress... still returns that rejected address directly to its caller. If one live read captures A, a subsequent read publishes B after the engine advances, and the first read then completes, the first caller receives A despite the cache already holding B. WalletApplication's live accessors return this non-null result unchanged, so the Receive screen or an integration can advertise the stale address. The existing ordering regression reproduces the necessary interleaving but discards olderResult.get(). Return the current generation's cached address after applying the publication gate, and assert that the older caller receives B as well.

Suggested change
private fun completeReceiveRead(generation: Long, ticket: Long, address: String?): String? {
if (generation != receiveAddressGeneration) return null
if (address != null && ticket > lastPublishedReceiveTicket) {
cachedReceiveAddress = ReceiveAddressSnapshot(generation, address)
lastPublishedReceiveTicket = ticket
}
return address ?: cachedReceiveAddress?.takeIf { it.generation == generation }?.address
private fun completeReceiveRead(generation: Long, ticket: Long, address: String?): String? {
if (generation != receiveAddressGeneration) return null
if (address != null && ticket > lastPublishedReceiveTicket) {
cachedReceiveAddress = ReceiveAddressSnapshot(generation, address)
lastPublishedReceiveTicket = ticket
}
return cachedReceiveAddress?.takeIf { it.generation == generation }?.address
}

source: muse-spark-1.3-contributor (phase1-reviewer: general, security-auditor); security-auditor

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Resolved (re-reviewed at 11ef19c4): completeReceiveRead() answers every caller from the current cache, and the regression verifies that an older read receives the newer cached address rather than its stale result.

Comment on lines +2167 to +2179
public Address unadvertisedDestinationLive() {
final boolean cutoverActive =
cutoverUiDataService != null && cutoverUiDataService.isCutoverActive();
final Address unadvertised = cutoverActive
? toDashjAddressOrNull(cutoverUiDataService.sdkUnadvertisedAddressLiveBlockingOrNull())
: null;
return decideUnadvertisedDestination(cutoverActive, unadvertised, () -> {
// NOT freshReceiveAddress(): that accessor is overlaid and would
// serve the cached engine RECEIVE address. Go straight to dashj.
org.bitcoinj.core.Context.propagate(Constants.CONTEXT);
return wallet.freshReceiveAddress();
});
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 Suggestion: Recheck cutover ownership before selecting the dashj destination

The initial isCutoverActive() snapshot controls both whether the SDK is consulted and whether the dashj supplier is allowed. If ownership changes to CUT_OVER after that snapshot, this invocation still skips the SDK and issues a key from the held dashj chain. Unlike the internal-chain path, that key is not guaranteed to differ from the engine's advertised external address. Attempt the generation-validated SDK read before taking the cutover snapshot used for the fallback decision, and cover activation during destination lookup. This narrows the avoidable stale-snapshot window; a strict guarantee across arbitrary ownership changes would require coordination with the transition itself.

source: muse-spark-1.3-contributor (phase1-reviewer: general, security-auditor)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Resolved (re-reviewed at 11ef19c4): The SDK destination read now occurs before the ownership snapshot, removing the previously identified path where a cutover could commit before a dashj destination was selected.

Comment on lines +621 to +627
// Through the application, not the dashj wallet directly, and through
// the LIVE accessor: post-cutover the dashj chain is HELD and its
// pointer frozen, so sweeping the paper wallet straight into it would
// land the funds on an address the chain has already paid
// (SR-03 / D-003). Already on the background handler, as the live
// read requires.
final Address receivingAddress = application.freshReceiveAddressLive();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 Suggestion: Paper-wallet sweeps now share the advertised receive address

Post-cutover, freshReceiveAddressLive() returns the same next-unused external address as the Receive screen rather than issuing a separate key. A counterparty holding an unpaid Receive QR can therefore observe a later paper-wallet sweep to that address and associate its amount and paper-wallet inputs with the recipient. The stated reason for leaving this path unchanged—that the funds are already linked to the paper key—does not eliminate the newly introduced link from that paper key to the identity behind the QR. This is less sensitive than an unshield, but it is a privacy regression from the previous fresh-key behavior. Use the self-transfer destination policy for sweeps, with preparation failures handled before sending, or explicitly document acceptance of this additional linkage.

source: security-auditor

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Resolved (re-reviewed at 11ef19c4): The sweep now selects unadvertisedDestinationLive() and restores CONFIRM_SWEEP with an error when preparation fails, so it no longer sends to the advertised receive address.

HashEngineering added a commit that referenced this pull request Sep 28, 2026
Review feedback on #1570. All three findings verified against the source
first; none were false positives.

- `refundAddressOrNull`'s KDoc and the `SendCoinsTaskRunner` call-site comment
  still described the Room address-pool read this PR deleted, including its
  null conditions ("the pool rows are missing"). Both now describe the engine
  FFI read and its real null cases: wallet unbound, no BIP44 account yet, or
  the read failed.

- `sdkReceiveAddressLiveOrNull`'s KDoc claimed null "when the read is
  unavailable", which contradicted both the implementation and this PR's own
  `postCutover_failedEngineReadHoldsTheLastAddress` test: a failed read on a
  warm cache deliberately returns the last known engine address, because
  dropping to null would send the caller back to the frozen dashj pointer.
  The doc now states the hold and keeps the guarantee that actually holds —
  every value it yields came from the engine, none are guessed.

- The rollback clear (CUT_OVER -> DUAL_RUNNING) had no test, and could not
  have had one: `configWithState` returns a one-shot `flowOf`, so the
  deactivation branch was unreachable from the suite. Added
  `configWithMutableState` and a test asserting that after a rollback the
  cached overlay, the live read, the balance and the UTXO count all stop
  serving. Confirmed non-vacuous by mutation: deleting
  `_sdkReceiveAddress.value = null` fails it.

Rebased onto 02e741b; the upstream CutoverUiDataService changes merged
without conflict. Full suite: 152 failures, an identical set to the base at
02e741b (pre-existing conscrypt native-lib failures in this environment).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
HashEngineering added a commit that referenced this pull request Sep 28, 2026
BLOCKING review finding on #1570, and it is a real one — verified against the
source before changing anything.

`sdkReceiveAddressLiveBlockingOrNull` checked the cutover flag and the bound
wallet id, then made a BLOCKING FFI read, then published unconditionally. That
is check-then-act across a call that cannot be cancelled, so:

  1. wallet A's live read captures A's id and blocks in the engine;
  2. Reset Wallet wipes A. `WalletApplicationExt.clearDatabasesInner` says in
     as many words that it does NOT restart the process, and it calls
     `cutoverCoordinator.resetForWalletWipe()`, so the deactivation branch runs
     and clears the overlay;
  3. the parked read returns and republishes A's address into the cache;
  4. wallet B restores. Until B's own engine read lands, `sdkReceiveAddressOrNull`
     — which checked neither activation nor ownership — serves A's address, and
     `WalletApplication` validates the address's NETWORK, not which seed owns
     it, so nothing downstream catches it.

The Receive screen then advertises an address only the ERASED wallet can spend.
That is worse than the SR-03 defect this PR set out to fix, and the existing
`_cutoverActive` gate does not prevent it: re-checking the flag on the way out
is still check-then-act, and a flag cannot tell a surviving binding from a
different one that activated meanwhile.

So the cache now carries the binding GENERATION it was read under. The
generation is bumped on every bind and unbind under a lock; a read captures it
before the FFI and may publish only if it is still current; the synchronous
accessor serves an entry only while its stamp matches. Cross-wallet reuse is
unrepresentable rather than merely unlikely. The lock is never held across the
FFI call, so a deactivation never waits on the engine.

Also removed the public `sdkReceiveAddress` StateFlow: it had no consumers and
would have been a fourth, unstamped way to read the cache.

Regression: `inFlightReadCannotRepublishAWipedWalletsAddress` parks a read for
wallet A inside the FFI, wipes, releases it, then activates B with B's own read
unavailable — the only way A's leftover could surface. Confirmed non-vacuous by
mutation: deleting the generation check in the live read fails it. The fixture
gates by calling thread rather than a one-shot flag, because the balance
pipeline reads through the same method — an earlier one-shot version was eaten
by the pipeline and passed vacuously.

Nitpick also fixed: `WalletApplication.liveSdkReceiveAddressOrNull`'s Javadoc
now documents the cold-cache/warm-cache distinction and the changed-binding null.

Full suite: 152 failures, an identical set to the previous head — no new failures.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
HashEngineering and others added 2 commits October 2, 2026 22:10
Two blocking review findings on the SR-03 receive path.

1. completeReceiveRead answered a FAILED live read out of the warm cache,
   so a live caller got a non-null address that
   WalletApplication.decideLiveReceiveAddress returns unchanged. The
   fail-closed handling added last round — PaymentsViewModel dropping the
   address rather than advertising a stale one — was therefore unreachable
   in production: cache A, take a payment on A, let both the event refresh
   and the next live lookup fail, and the paid QR stayed on screen.

   The cache is still HELD on failure (the synchronous overlay's only
   alternative is the held dashj chain's frozen pointer), but whether the
   newest ACCEPTED read actually reached the engine is now tracked beside
   it and returned with it. Live callers answer nothing when it did not,
   and reach ReceiveAddressUnavailableException. The verdict is written
   under the SAME ticket fence as the address, so an older completion
   arriving after a newer failed read cannot reinstate a success — which
   would have re-authorized exactly the address the fence just rejected.

2. The eager deactivation collector deduplicated on the Boolean it was
   DELIVERED, not on the decision it APPLIED after the authoritative
   reread. An initial `false` delivered after CUT_OVER commits leaves the
   dedupe remembering `false` while ownership rightly stands; if the
   intervening `true` is conflated away, the rollback's `false` is
   suppressed as a repeat and nothing revokes eagerly. The gated collector
   sees it but must join a pipeline parked in an uncancellable FFI read,
   and until then the retired generation and its cached address stay
   authorized. Revocation is now driven by the ownership this service has
   APPLIED (the flag and the binding, read together), which cannot drift
   from itself — and which skips the reread for every repeated `false`
   that could not change anything, the only thing the dedupe bought.

Tests: a live read refused from the hold while the overlay keeps it; an
older completion after a newer failed read; the same refusal carried
through WalletApplication.decideLiveReceiveAddress to the throw; an
overtaken `false`, a missed `true` and another `false` with the pipeline
read genuinely parked; and the repeated-`false` reread economy.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ns it

The confirm single-flight was acquired in the retained view model but
released from fragment-instance state, so ownership did not survive the
fragment being recreated. Rotate while an attempt is parked in the
destination read — post cutover an uncancellable wait for the engine to
bind — and cancellation cannot finish that attempt until the read
returns, while the new fragment, which has no confirm of its own, clears
the shared guard and lets the user confirm again. When the cancelled
attempt finally unwinds, its `finally` released a flight it no longer
owned: the live confirm, which had already prepared its destination,
then failed buyDash()'s check and showed a purchase error.

Ownership now stays in the view model as an attempt id. tryBeginConfirm()
issues one, and every release names the attempt it releases, so a stale
attempt's cleanup is a no-op. The completed-navigation latch, which keeps
the button guarded between the purchase and the navigation transaction
actually executing, is now separate state: onResume drops that latch and
only that latch, and an attempt that is still running keeps its flight.
The destroyed fragment hands its own attempt back in onDestroy, so the
recreated screen can confirm without waiting out the blocking read.
buyDash() checks that the caller still owns the flight rather than that
somebody holds it. Address-before-order and the unavailable-address
retry are unchanged.

CoinbaseBuyDashConfirmGuardTest models the fragment's three call sites
and covers the recreation race with a real handshake: the stale attempt
unwinds inside the live attempt's window and the live attempt still
buys. A real fragment would need Robolectric, which cannot start here.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@HashEngineering

Copy link
Copy Markdown
Collaborator Author

All three blockers fixed — head a1f5ae763

Two commits: e6765fdf9 (the two CutoverUiDataService findings) and a1f5ae763 (the Coinbase confirm guard). All three premises were verified against the code before anything was changed, and all three held.


1. CutoverUiDataService:2870 — a held address is not a successful revalidation

Confirmed exactly as described, and worth stating plainly: the previous round's fail-closed handling was unreachable in production. completeReceiveRead ended with cachedReceiveAddress?.takeIf { it.generation == generation }?.address, so a live read that got null from the FFI returned the warm A; decideLiveReceiveAddress's first branch is if (live != null) return live, so neither the cutoverOwned consultation nor ReceiveAddressUnavailableException was ever reached. PaymentsViewModel's catch passed its test only because that test injects the exception from the mock, above this path.

postCutover_failedEngineReadHoldsTheLastAddress had two assertions and only the second was wrong. The overlay assertion is the intentional hold and stays. The trailing one — "and the live read reports the same held value rather than nothing" — is the line that encoded the defect; it is now assertNull("a live caller must not be answered from the hold", ...). A green test was pinning the bug in place, which is why earlier rounds reported this area fixed.

The fix: completeReceiveRead returns ReceiveReadAnswer(address, revalidated), with latestReceiveReadRevalidated = address != null set inside the ticket > lastPublishedReceiveTicket fence — the same fence as the address — so an older completion cannot reinstate a success a newer failed read retired. The live accessor returns answer.address?.takeIf { answer.revalidated }; refreshNativeSplit takes .address only, since the overlay is the caller the hold exists for. No signal threading to WalletApplication was needed — the live accessor's existing null already means "no fresh answer" and decideLiveReceiveAddress already handles it correctly. Nothing was widened into a sentinel; only the stale Javadoc promising the warm-cache hold was corrected.

2. CutoverUiDataService:3299 — revocation driven by applied ownership, not Boolean edges

Confirmed: distinctUntilChanged keyed on the emitted Boolean while revokeReceiveOwnershipIfStillDashjOwned re-read authoritatively and could decline, so the dedupe state and the applied state could diverge.

distinctUntilChanged() is dropped and the body guarded on receiveOwnershipApplied() — _cutoverActive.value || activeWalletIdHex != null, both halves in one critical section. Chosen over a transition version because applied state cannot drift from itself, and it is a stricter filter than the Boolean edge in the steady state: while ownership is revoked the feed emits false and there is nothing to revoke; while granted it emits true, which the collector ignores. The authoritative reread therefore runs only for a false that could change something — everything the dedupe bought, without the divergent state. Mutex, lock ordering, ticket fences and observePreservingErrors untouched.

3. CoinbaseOrderReviewFragment:227 — release only to the owning attempt

Confirmed, including the mechanism: onResume called releaseConfirm() whenever confirmJob?.isActive != true, and a recreated fragment has confirmJob == null, so it always cleared the shared guard. A mutation run reproduced the exact user-visible symptom — IllegalStateException: buyDash() outside its own confirm single-flight — i.e. a legitimate confirmation failing because a cancelled one cleaned up after it. confirmInFlight was indeed doing double duty: after a successful order the same boolean served as a navigation latch.

Ownership now lives in the retained ViewModel as a monotonic attempt id under one lock: tryBeginConfirm(): Long?, endConfirm(attemptId) (a no-op unless that id still owns the flight — the core of the fix), latchConfirmForNavigation(attemptId) and releaseConfirmNavigationLatch() as the now-separate latch, and buyDash(attemptId) asserting the caller still owns the flight rather than that somebody holds it. onDestroy hands this instance's attempt back so the recreated screen can confirm immediately instead of waiting out the uncancellable read. Address-before-order ordering, the ReceiveAddressUnavailableException arm and the purchase-error dialog are untouched. No argument here rests on a duplicate-order-on-third-tap claim.


Mutation testing

13 mutations across the three fixes; 12 died against named tests. Full matrices are in the commit messages. Two things worth reporting rather than burying:

  • One mutation survives and is disclosed: the flag resets in bindReceiveAddressWallet/unbindReceiveAddressWallet are unobservable by construction, because the verdict is held != null && revalidated and the cache clears on the same generation bump. Kept as the invariant's belt, not claimed as covered.
  • A test caught itself being vacuous. The first version of the Finding-2 test used a Channel(CONFLATED) and survived having the fix reverted — a conflated channel hands a value straight to a waiting receiver, so the "missed true" was never missed. Rewritten to park the collector inside its authoritative reread via CompletableDeferred handshakes; the pipeline read is genuinely parked and the test asserts receiveAddressGate.count == 1L, proving the gated collector could not have been the revoker. Same class of vacuity as last round's verify(exactly = 1) race, found by mutation testing rather than by review.

No trailing verify(exactly = N) racing a background thread anywhere in the new tests; all counting happens inside the mocks, and sequencing is by handshake, never by timing.

Coverage gaps — stated, not stubbed

  • The Coinbase fragment's three call sites (tap / onResume / onDestroy) are modelled in the test, not driven, because a real fragment needs Robolectric → the no conscrypt_jni wall. So reverting the onDestroy line is caught only by a model-level mutation. Everything in the ViewModel, where ownership now lives, is covered by production mutations.
  • The Finding-2 test is attached to the deactivation collector positionally (the mock routes by observePreservingErrors call order) — the same limitation already documented on staleDeactivationObservationCannotRevokeANewerActivation. Swapping the two scope.launch blocks in start() would silently re-point it.
  • ktlint does not cover integrations/coinbase — the plugin is applied only to exploredash, crowdnode, uphold and maya. ktlintFormat passing says nothing about those files; style was matched by hand.

Verification

  • :wallet:compile_testNet3DebugKotlin + :integrations:coinbase:compileDebugKotlin — BUILD SUCCESSFUL.
  • Full wallet suite, 2352 tests: failures are 175 conscrypt + PlatformSyncEngineRestartTest#resume_isRearmableAcrossRepeatedStopRestartCycles. Zero new by name.
  • Coinbase module independently re-run with --rerun-tasks: BUILD SUCCESSFUL, CoinbaseBuyDashConfirmGuardTest 8 tests / 0 failures / 0 errors.
  • PlatformSyncEngineRestartTest#resume_isRearmableAcrossRepeatedStopRestartCycles is not from this PR — verified by running that class at parent commit 8194dc7e8, which contains none of this work: 5 tests completed, 1 failed. It arrived with merged fix(upgrade): v12 cutover and sync lifecycle, plus a measured account of what still loses money #1568 (87b7a9965) and references nothing in this diff. Note it passes in some full-suite orderings, so it is order-dependent in itself — flagging it for whoever owns fix(upgrade): v12 cutover and sync lifecycle, plus a measured account of what still loses money #1568, as it will mislead the next person who diffs failure counts.

The suggestion — device verification

Not addressed in code, and acknowledged as the real outstanding gap: a testnet shield-to-unshield cycle and a sweep on the current head, confirming destinations differ from the advertised Receive address and that the resulting transparent funds are credited and spendable. The existing device evidence covers Receive on earlier heads only. This needs an authorized testnet run and is pending the maintainer's go-ahead; the procedure is written up in artifacts/pr1570-run19-inclusion.md.

🤖 Generated with Claude Code

@thepastaclaw thepastaclaw left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Re-review — Final validation — Phase 2 only (Phase 1 failed)

Static verification at a1f5ae7 confirms 38 prior findings are fixed, with SDK address reservation remaining a separate follow-up and end-to-end self-transfer validation still outstanding. One new blocking race remains: the applied-ownership filter can discard a rollback while a grant is pending under the ownership mutex. The supplied exact-head CI snapshot reports successful check and build jobs; no local builds, tests, or device operations were performed.

🔴 1 blocking | 🟡 1 suggestion(s)

1 carried-forward finding(s) already raised on this PR; not re-posting as new inline comments.

Review provenance

Source: reviewer 1: gpt-6.1-sol (agent: phase2-reviewer, role: general); reviewer 2: gpt-6.1-sol (agent: phase2-reviewer, role: security-auditor); reviewer 3: gpt-6-astra (agent: phase2-reviewer, role: security-auditor); final verifier: gpt-6.1-sol (agent: sol-verifier, role: final-verifier)

  • Triage: critical by gpt-6.1-sol (effort low) — The large, intricate diff changes funds-movement destinations directly in CoinJoinFundsMigrationService and ShieldedTransferExecutor, alongside cross-cutting SDK address selection, caching, and failure handling.
  • Phase 1 reviewers: failed (failed on every model; its output was dropped)
  • Fresh verifier: gpt-6.1-sol — final-verifier; agent sol-verifier
  • Phase 2 reviewers: gpt-6.1-sol — general (completed, effort xhigh); agent phase2-reviewer, gpt-6.1-sol — security-auditor (completed, effort xhigh); agent phase2-reviewer, gpt-6-astra — security-auditor (completed, effort xhigh); agent phase2-reviewer
  • Model comparison: every Phase-2 reviewer also ran on gpt-6-astra; the verifier weighed both sets without knowing which model wrote which
🤖 Prompt for all review comments with AI agents
These findings are from an automated code review. Verify each finding against the current code and only fix it if needed.

In `wallet/src/de/schildbach/wallet/service/platform/sdk/CutoverUiDataService.kt`:
- [BLOCKING] wallet/src/de/schildbach/wallet/service/platform/sdk/CutoverUiDataService.kt:3391-3395: Check applied ownership under the ownership decision mutex
  The no-op filter runs outside ownershipDecisionMutex, so it can miss a grant that already holds that mutex. Starting with ownership unapplied, grantReceiveOwnershipIfStillCommitted() can read CUT_OVER and be preempted before publishing _cutoverActive. If rollback commits during that interval, this independent collector observes false, sees receiveOwnershipApplied() return false, and discards the rollback instead of waiting for the pending grant. The grant then publishes true and can start and bind its pipeline before the gated collector processes its own rollback. If a pipeline read enters the uncancellable FFI, collectLatest must join that read before its deactivation action runs, while the independent collector has already discarded the emission needed for eager revocation. The production Dispatchers.Default scope permits this interleaving. Move the applied-state check inside ownershipDecisionMutex in the revocation helper, and invoke that helper for every false observation. Keep its authoritative ownership reread, and add a regression staging rollback between the grant's authoritative read and flag publication.

In `wallet/src/de/schildbach/wallet/ui/shielded/ShieldedTransferExecutor.kt`:
- [SUGGESTION] wallet/src/de/schildbach/wallet/ui/shielded/ShieldedTransferExecutor.kt:605-617: Verify internal-chain self-transfer destinations end to end
  (existing thread: https://github.com/dashpay/dash-wallet/pull/1570#discussion_r4171723484)
  The withdrawal now targets the SDK internal/change chain, but ShieldedTransferExecutorTest mocks both unadvertisedDestinationLive() and withdrawToCore(), and the service tests use synthetic change addresses. These tests verify routing and refusal before broadcast, not that an actual withdrawal is recognized, credited once, and spendable after restart. The recorded historical change output demonstrates ordinary internal-chain recognition but does not exercise this withdrawal route or the changed paper-wallet sweep. Keep the queued integration validation explicit: an authorized shield/unshield cycle and paper-wallet sweep should check destination ownership, credit recognition, and subsequent spendability after restart. Successful CI and the earlier SR-03 device runs do not close this funds-destination validation gap.
Out-of-scope follow-up suggestions (2)

These are valid observations, but they are outside this PR's scope and should be handled in separate issues or author/maintainer-requested PRs rather than blocking this review.

  • Reserve self-transfer destinations against ordinary SDK change — The production destination source calls core.nextChangeAddress(accountIndex) without an application-level reservation. As documented in the current accessor, this internal allocator is shared with ordinary payment change, so concurrent preparation can reuse a destination visible to a payment recipient. Avoiding advertised receive addresses fixes the unpaid-QR exposure, but does not establish cross-operation isolation; addressing that residual requires allocation coordination shared by SDK transaction builders.
    • Follow-up: Track an upstream SDK address-reservation or dedicated self-transfer allocation API honored by competing change builders.
  • Validate destination ownership against the funding wallet at spend time — WalletApplication revalidates the destination generation before returning it, but ShieldedTransferExecutor and CoinJoinFundsMigrationService subsequently pass a bare address to spending services that resolve the funding wallet separately. A wallet replacement after the accessor's final check remains outside that check's protection. This is a transaction-boundary coordination follow-up, distinct from the now-fixed generation gap inside the accessor.
    • Follow-up: Track carrying wallet identity and generation with self-transfer destinations and validating them against the funding wallet during transaction preparation.

Comment on lines +3391 to +3395
cutoverUiActive()
.collect { active ->
if (!active && receiveOwnershipApplied()) {
revokeReceiveOwnershipIfStillDashjOwned()
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🔴 Blocking: Check applied ownership under the ownership decision mutex

The no-op filter runs outside ownershipDecisionMutex, so it can miss a grant that already holds that mutex. Starting with ownership unapplied, grantReceiveOwnershipIfStillCommitted() can read CUT_OVER and be preempted before publishing _cutoverActive. If rollback commits during that interval, this independent collector observes false, sees receiveOwnershipApplied() return false, and discards the rollback instead of waiting for the pending grant. The grant then publishes true and can start and bind its pipeline before the gated collector processes its own rollback. If a pipeline read enters the uncancellable FFI, collectLatest must join that read before its deactivation action runs, while the independent collector has already discarded the emission needed for eager revocation. The production Dispatchers.Default scope permits this interleaving. Move the applied-state check inside ownershipDecisionMutex in the revocation helper, and invoke that helper for every false observation. Keep its authoritative ownership reread, and add a regression staging rollback between the grant's authoritative read and flag publication.

source: gpt-6.1-sol (phase2-reviewer: general)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Resolved (re-reviewed at 373ca4bb): Your final change passes every false observation into the helper and checks applied ownership only after taking ownershipDecisionMutex. The handshake regression holds the grant before publication and verifies that the eager rollback waits and then revokes, rather than discarding the observation.

The eager deactivation collector filtered its own emissions on
`receiveOwnershipApplied()`, read OUTSIDE `ownershipDecisionMutex`. That
asks whether there is anything to revoke of a decision that may be
half-made. `grantReceiveOwnershipIfStillCommitted` takes the mutex, reads
CUT_OVER and can be descheduled before it publishes `_cutoverActive` —
the service scope is Dispatchers.Default, so the window is real. Starting
from unapplied ownership, a rollback committing inside it read "nothing
applied", so the collector discarded the emission instead of waiting for
the grant it would have had to undo. The grant then published and bound,
and nothing eager was left: the gated collector sees that rollback too,
but `collectLatest` cancels AND JOINS, and a pipeline parked in an
uncancellable FFI read holds the join — which is the entire reason the
independent collector exists. Until that read returned, the retired
generation and its cached address stayed authorized.

Every `false` now reaches `revokeReceiveOwnershipIfStillDashjOwned`, and
both filters live inside the mutex, the cheap one first: nothing applied
→ return, no authoritative read owed (what the deleted
`distinctUntilChanged` used to buy, without the dedupe state that could
diverge from the applied decision); applied but the state has moved on →
the observation was overtaken. A grant in flight is now WAITED for rather
than raced, and the decision is made against ownership that is actually
applied. The authoritative reread, the ticket/generation fences, the
`ReceiveReadAnswer` revalidation and `observePreservingErrors` are
unchanged.

aRollbackObservedWhileTheGrantIsMidDecisionStillRevokesEagerly stages the
interleaving with handshakes rather than timing: the grant is held inside
its authoritative read, so it provably owns the mutex and provably has
not published; the rollback is emitted from a `flow {}` whose `emit` runs
the collector body synchronously, so one `runCurrent()` is enough to have
made the collector's decision whichever shape the code has; and the gated
collector's own feed never delivers the rollback, so only the eager path
can produce the revocation asserted on. The grant's publication is proved
by the pipeline's bind call, a monotone counter, not by a flag read that
races the revocation.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@HashEngineering

Copy link
Copy Markdown
Collaborator Author

Fixed — head 373ca4bbb

Premise confirmed, and it is a regression from e6765fdf9 — the fix for the previous round's finding on this same collector. Verified before changing anything: receiveOwnershipApplied() takes receiveAddressLock, never ownershipDecisionMutex, while grantReceiveOwnershipIfStillCommitted() holds that mutex across cutoverUiActive().first() (a DataStore hit) and only then writes _cutoverActive.value = true. Starting from unapplied ownership, a rollback committing in that gap reads false || null != null = false, so the && short-circuits and the emission never reaches the helper at all — discarded, not deferred. The grant then publishes, runPipelines binds, and the only remaining revoker is the gated collectLatest, which must join a pipeline that may be parked in nextReceiveAddressOrNull's uncancellable FFI read.

The change — two lines

  • The collector becomes if (!active) revokeReceiveOwnershipIfStillDashjOwned(), so every false reaches the helper.
  • revokeReceiveOwnershipIfStillDashjOwned() gains if (!receiveOwnershipApplied()) return@withLock as the first statement inside ownershipDecisionMutex.withLock, ahead of the unchanged authoritative reread.

Applied-check-first keeps the steady-state false free of a DataStore hit — what distinctUntilChanged used to buy, and what repeatedDashjEmissionsDoNotReReadTheCutoverState pins — while putting it under the mutex turns the grant-in-flight window into a wait rather than a race: the helper blocks on the mutex the grant holds, then re-asks the applied question against ownership that is actually applied.

distinctUntilChanged() was not reintroduced (that was the prior finding). No lock-ordering inversion: receiveOwnershipApplied() and revokeReceiveOwnership() are non-suspending synchronized(receiveAddressLock) blocks called from inside the coroutine Mutex; nothing holds a monitor across a suspension point, and nothing acquires ownershipDecisionMutex while holding receiveAddressLock (it is suspend-only). Authoritative reread, ticket/generation fences, the ReceiveReadAnswer revalidation from e6765fdf9 and observePreservingErrors are all unchanged.

The regression test

aRollbackObservedWhileTheGrantIsMidDecisionStillRevokesEagerly stages exactly what the finding describes — rollback between the grant's authoritative read and its flag publication. Fully deterministic, by handshake rather than timing:

The grant's authoritative read is held inside a flow { grantReadEntered.complete(Unit); emit(grantReadHold.await()) }, so the grant provably owns the mutex and provably has not published (asserted: !isCutoverActive(), boundCalls == baseline). The rollback is then emitted to the deactivation collector from a flow {} — whose emit invokes the collector body synchronously in the same coroutine, so a single runCurrent() guarantees the collector has made its decision (discard if broken, park on the mutex if fixed) before anything else happens. Publication is proved by source.boundCalls > baseline, a monotone counter rather than a flag read racing the revocation. The gated collector's feed stays CUT_OVER throughout, so the revocation asserted on can only come from the eager path.

Mutation matrix

# Mutation Result
M1 Full revert (check back outside, in the collector) died — new test
M2 Keep the in-mutex check but re-add the collector pre-filter died — new test
M3 Drop only the in-mutex applied check died — repeatedDashjEmissionsDoNotReReadTheCutoverState and the new test's zero-point-reads precondition
M4 Drop the authoritative reread guard died — aDeactivationAppliedLateCannotRevokeANewerActivation, rollbackRevokesOwnershipAfterAnOvertakenFalseAndAMissedTrue

M2 is the one that specifically pins "every false observation must reach the helper"; M3 confirms repeatedDashjEmissionsDoNotReReadTheCutoverState is still a live detector rather than passing trivially; M4 confirms the previous round's protections are intact.

On the three vacuity traps this PR has hit: no verify(exactly=N) anywhere in the new test (counts are boundCalls and an AtomicInteger read only after a handshake has established the state); no channel at all, so nothing can hand a value to a waiting receiver; and the assertion-reachability trap is ruled out empirically — M1 and M2 revert the production change and the test goes red by name, so the asserted outcome is reachable only through the changed branch. One assertion on sdkReceiveAddressLiveOrNull() was deliberately dropped because beginReceiveReadAwaitingBinding would have spent its whole binding wait and measured that rather than the revocation; a comment records why.

Verification

  • :wallet:compile_testNet3DebugKotlin — BUILD SUCCESSFUL.
  • Full suite, 2353 tests, 176 failures: 175 conscrypt + PlatformSyncEngineRestartTest#resume_isRearmableAcrossRepeatedStopRestartCycles (pre-existing, fix(upgrade): v12 cutover and sync lifecycle, plus a measured account of what still loses money #1568). Zero new by name.
  • CutoverUiDataServiceTest run 3× with --rerun-tasks — green all three, covering every timing-sensitive test in the class.
  • ktlint clean. Noting again that ktlint's source sets cover common/, integrations/ and features/ only — wallet/ is not in scope, so neither changed file is actually linted by it.

Known limitations, restated rather than quietly carried

  • Positional coupling at the config seam. Both the existing laggingGateConfig tests and this new one identify collectors by observePreservingErrors call order (0 = the deactivation collector, which start() launches first). In the new test the coupling fails loudly — the zero-point-reads precondition and the grant-held assertion both depend on the routing being right — but reordering the scope.launch blocks in start() would change what it exercises. Fixing it needs the two collectors distinguishable at the config seam, which a single-key mock cannot do.
  • The new test models "the gated collector has not been delivered the rollback yet" rather than "delivered but parked on a join". That is the stronger isolation for this assertion and realistic for a conflated DataStore feed; the parked-FFI join stays covered by rollbackRevokesOwnershipEvenWhileThePipelineReadIsParked and rollbackRevokesOwnershipAfterAnOvertakenFalseAndAMissedTrue, both still passing.
  • Pre-existing and untouched: a pipeline that re-binds via bindReceiveAddressWallet after an eager revocation leaves activeWalletIdHex non-null with _cutoverActive false, so receiveOwnershipApplied() reads true for a subsequent false. Harmless — the helper would simply unbind again — and outside this finding.

The suggestion

Unchanged and acknowledged: the end-to-end self-transfer validation is a device matter that no host-JVM change can close. The sharpened criteria are noted — destination ownership, credit recognition, and spendability after restart, across a shield/unshield cycle and a paper-wallet sweep. Pending maintainer go-ahead for an authorized testnet run; procedure in artifacts/pr1570-run19-inclusion.md.

🤖 Generated with Claude Code

@thepastaclaw thepastaclaw left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Re-review — Final validation — Phase 1 + Phase 2

Static verification of the complete afa91bf..373ca4b range and all 41 prior findings found no remaining in-scope blocking defect. Two non-blocking coverage suggestions remain: verify the internal-chain self-transfer destinations through the real SDK, and restore delayed pipeline-gate scenarios weakened by the shared test fixture. The supplied exact-head CI snapshot reports successful build and check jobs; no local builds or tests were run.

🟡 2 suggestion(s)

1 carried-forward finding(s) already raised on this PR; not re-posting as new inline comments.

Review provenance

Source: reviewer 1: muse-spark-1.3-contributor (agent: phase1-reviewer, role: general); reviewer 2: gpt-6.1-sol (agent: phase2-reviewer, role: general); reviewer 3: gpt-6.1-sol (agent: phase2-reviewer, role: security-auditor); reviewer 4: gpt-6-astra (agent: phase2-reviewer, role: general); reviewer 5: gpt-6-astra (agent: phase2-reviewer, role: security-auditor); reviewer 6: gpt-6.1-sol (agent: phase2-reviewer, role: general); reviewer 7: gpt-6.1-sol (agent: phase2-reviewer, role: security-auditor); final verifier: gpt-6.1-sol (agent: sol-verifier, role: final-verifier)

  • Triage: critical by gpt-6.1-sol (effort low) — The large, intricate diff changes transaction destination and key-chain selection in WalletApplication.unadvertisedDestinationLive(), ShieldedTransferExecutor’s unshield withdrawal, and CoinJoinFundsMigrationService, alongside cross-cutting receive-address ownership and concurrency logic.
  • Phase 1 reviewers: muse-spark-1.3-contributor — general (completed, effort xhigh); agent phase1-reviewer
  • Phase 1 model: muse-spark-1.3-contributor — not quota-gated; passed over gemini-3.8-flash-high (antigravity below 15% reserve: weekly 15% left, 5h 100% left), glm-5.3-flash (not used above high effort; tier asks max)
  • Single stage: Phase 1 and Phase 2 reviewed this head side by side, with no blocker gate between them (triage tier)
  • Fresh final gate: an independent Phase-2 review ran after iterative findings were reconciled
  • Fresh verifier: gpt-6.1-sol — final-verifier; agent sol-verifier
  • Phase 2 reviewers: gpt-6.1-sol — general (completed, effort xhigh); agent phase2-reviewer, gpt-6.1-sol — security-auditor (completed, effort xhigh); agent phase2-reviewer, gpt-6-astra — general (completed, effort xhigh); agent phase2-reviewer, gpt-6-astra — security-auditor (completed, effort xhigh); agent phase2-reviewer, gpt-6.1-sol — general (completed, effort xhigh); agent phase2-reviewer, gpt-6.1-sol — security-auditor (completed, effort xhigh); agent phase2-reviewer
  • Model comparison: every Phase-2 reviewer also ran on gpt-6-astra; the verifier weighed both sets without knowing which model wrote which
🤖 Prompt for all review comments with AI agents
These findings are from an automated code review. Verify each finding against the current code and only fix it if needed.

In `wallet/test/de/schildbach/wallet/service/platform/sdk/CutoverUiDataServiceTest.kt`:
- [SUGGESTION] wallet/test/de/schildbach/wallet/service/platform/sdk/CutoverUiDataServiceTest.kt:2995-2999: Preserve the delayed pipeline-gate scenarios in the wipe tests
  laggingGateConfig() gives gateFeed only to the first subscription, which start() now assigns to the independent deactivation collector; the pipeline gate instead subscribes to persisted. This correctly stages delayed deactivation, but the existing wipe tests still use the helper to model an undelivered pipeline-gate reset. In stopForWalletWipe_conflatedReset_restartsForNextWallet_withoutTheWipedWalletsPendingWalk(), persisted changes to DUAL_RUNNING, the scheduler runs, and persisted later changes to CUT_OVER, so the pipeline gate receives both transitions despite the test's stated continuously active gate. The in-flight-walk test similarly no longer withholds the pipeline gate's reset. Separate the delayed-deactivation and delayed-pipeline-gate fixtures so these tests retain coverage of stopGeneration-driven restart when the pipeline gate misses the reset.

In `wallet/src/de/schildbach/wallet/ui/shielded/ShieldedTransferExecutor.kt`:
- [SUGGESTION] wallet/src/de/schildbach/wallet/ui/shielded/ShieldedTransferExecutor.kt:605-617: Verify internal-chain self-transfer destinations end to end
  (existing thread: https://github.com/dashpay/dash-wallet/pull/1570#discussion_r4171723484)
  The withdrawal now pays an engine-derived internal-chain address, but ShieldedTransferExecutorTest substitutes the destination and mocks withdrawToCore(), while CutoverUiDataServiceTest supplies fixed receive/change strings. These tests establish routing and refusal before broadcast, not native derivation, output discovery, crediting, or spendability. The existing change-output balance evidence supports internal-chain recognition, but does not exercise a withdrawal to the newly selected destination, and the queued integration run is not a completed result. Record an authorized testnet run on the final head, or add a native-backed integration test, confirming that the destination differs from the advertised receive address and that the resulting credit remains available and spendable after restart. Include equivalent checks for the redirected sweep and CoinJoin consolidation destinations.
Out-of-scope follow-up suggestions (2)

These are valid observations, but they are outside this PR's scope and should be handled in separate issues or author/maintainer-requested PRs rather than blocking this review.

  • Coordinate self-transfer destinations with ordinary payment change — The internal-chain accessor calls nextChangeAddress(0) without reserving its result. Under the documented next-unused contract, a competing ordinary payment can select the same unused address for change, allowing that payment's recipient to correlate a self-transfer with its change output. The accessor avoids the direct unpaid-Receive-QR linkage but does not provide allocation isolation; closing this residual requires an SDK-wide allocation contract beyond this receive-source correction.
    • Follow-up: Track a separate SDK reservation or dedicated self-transfer allocation mechanism honored by competing transaction builders, with concurrent payment/self-transfer coverage.
  • Bind self-transfer destinations to the funding wallet at spend time — WalletApplication rechecks the destination generation before returning, but returns a bare Address. ShieldedBalanceServiceImpl.runShieldedWrite() and SdkL1SendService.drainCoinJoinAccountTo() select the funding wallet afterward, leaving a destination-versus-funding-wallet replacement window. This address-then-spend lifetime gap also exists in the previous orchestration and needs a separate transaction-boundary change, not another accessor snapshot.
    • Follow-up: Track a wallet-bound destination API that validates ownership against the funding wallet under spend/wipe coordination before submitting funds.

Comment on lines 2995 to 2999
return mockk {
every { observe(DashPayConfig.CUTOVER_STATE) } answers {
every { observePreservingErrors(DashPayConfig.CUTOVER_STATE) } answers {
if (observeCalls++ == 0) gateFeed else persisted
}
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 Suggestion: Preserve the delayed pipeline-gate scenarios in the wipe tests

laggingGateConfig() gives gateFeed only to the first subscription, which start() now assigns to the independent deactivation collector; the pipeline gate instead subscribes to persisted. This correctly stages delayed deactivation, but the existing wipe tests still use the helper to model an undelivered pipeline-gate reset. In stopForWalletWipe_conflatedReset_restartsForNextWallet_withoutTheWipedWalletsPendingWalk(), persisted changes to DUAL_RUNNING, the scheduler runs, and persisted later changes to CUT_OVER, so the pipeline gate receives both transitions despite the test's stated continuously active gate. The in-flight-walk test similarly no longer withholds the pipeline gate's reset. Separate the delayed-deactivation and delayed-pipeline-gate fixtures so these tests retain coverage of stopGeneration-driven restart when the pipeline gate misses the reset.

source: gpt-6.1-sol (phase2-reviewer: general)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Resolved (re-reviewed at 66facee5): Your delayedPipelineGateConfig now routes by the named pipeline-gate seam and asserts all three subscription identities. Both wipe scenarios keep the gate CUT_OVER throughout, so their restart assertions can no longer be satisfied by a reset transition delivered to the wrong collector.

…scription order

The wipe tests meant to withhold the pipeline gate's reset so the restart
under test could only come from `stopForWalletWipe`'s generation bump. They
routed positionally — "the first `observePreservingErrors` call" — and when
the independent deactivation collector was launched in FRONT of the gated one
they quietly started staging that collector instead. The pipeline gate was
left on `persisted`, which does change across the wipe, so the gate restarted
the pipeline and the scenario stopped being staged at all. The tests went on
passing.

Measured: with `stopGeneration` removed from the gate's `combine`,
`stopForWalletWipe_conflatedReset_restartsForNextWallet_withoutTheWipedWalletsPendingWalk`,
`stopForWalletWipe_inFlightWalkCannotWriteAfterStop_andPipelineRestartsForNextWallet`
and `stopForWalletWipe_reconcileRequestedMidWipe_isNotReplayedForTheNextWallet`
all PASSED at 373ca4b. They now all fail.

Fixtures split and made non-positional:
- `laggingGateConfig` is gone. `delayedDeactivationConfig` stages a late
  deactivation; `delayedPipelineGateConfig` stages a reset the pipeline gate
  never sees. The distinction is now visible at the call site.
- `RoutedCutoverConfig` routes by WHICH production seam is subscribing, read
  from the frames below the mock, so reordering the `scope.launch` blocks in
  `start()` can no longer re-point a fixture. `assertWired()` fails loudly if
  any of the three long-lived subscriptions did not arrive as itself.
- The three other hand-rolled positional mocks (the two rollback tests and
  `repeatedDashjEmissionsDoNotReReadTheCutoverState`) use the same router, and
  the KNOWN LIMITATION note on `aDeactivationAppliedLateCannotRevokeANewerActivation`
  is resolved rather than restated.

Production: the three subscriptions get names — `cutoverDeactivationFeed`,
`cutoverOwnershipFeed`, `cutoverPipelineGateFeed` — which is what makes them
distinguishable at the config seam. No behaviour change; each is the same flow
the launch built inline.

`stopForWalletWipe_inFlightWalkCannotWriteAfterStop_andPipelineRestartsForNextWallet`
no longer delivers the gate's reset late: its gate feed stays CUT_OVER for the
whole test, so the restart it asserts on can only be the stop's re-evaluation.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@HashEngineering

Copy link
Copy Markdown
Collaborator Author

Fixed — head 66facee53

Taken as a real defect rather than a suggestion, because it was a silent coverage regression this PR introduced: the tests still passed while no longer staging their stated scenario.

Premise confirmed, and measured

start() launches the independent deactivation collector FIRST, so laggingGateConfig's "first observePreservingErrors call" was handing the withheld feed to that collector. The pipeline gate was left on persisted, which does change across the wipe (DUAL_RUNNING → CUT_OVER). The scenario was not being staged at all.

Quantified, with the stopGeneration-driven restart path deleted from production (combine(gate, stopGeneration) → gate):

test at 373ca4bbb + mutation after this commit + mutation
stopForWalletWipe_conflatedReset_restartsForNextWallet_withoutTheWipedWalletsPendingWalk PASSED (vacuous) FAILED
stopForWalletWipe_inFlightWalkCannotWriteAfterStop_andPipelineRestartsForNextWallet PASSED (vacuous) FAILED
stopForWalletWipe_reconcileRequestedMidWipe_isNotReplayedForTheNextWallet PASSED (vacuous) FAILED
stopForWalletWipe_failedReset_staysStoppedUntilResume_thenRestarts FAILED FAILED
stopForWalletWipe_sdkClearAlsoFailed_waitsForTheNextWalletsBind FAILED FAILED

2 of 5 → 5 of 5. The path was never entirely uncovered — the two failedReset/sdkClearAlsoFailed tests still caught it — but all three conflated-reset scenarios were vacuous. Both named wipe tests now fail by name on assertTrue("the stop's re-evaluation must restart the pipeline — the gate never saw the reset, so nothing else can", service.isCutoverActive()).

A third test had the same regression and is not in the finding: stopForWalletWipe_reconcileRequestedMidWipe_isNotReplayedForTheNextWallet. Same cause, fixed by the same change.

Routing is now non-positional

laggingGateConfig is deleted, replaced by two fixtures named for what they stage: delayedDeactivationConfig(...) and delayedPipelineGateConfig(...).

The mock classifies each subscription by which production seam is on the stack below it, not by call order. Three other hand-rolled positional mocks were migrated to the same router, and no observeCalls counter remains in the file. assertWired() is the loud half: each of the three long-lived subscriptions must arrive exactly once as itself, so a renamed seam, a collector that stops subscribing, or a fourth subscription fails the test instead of silently re-routing. It earned its keep during development by catching cutoverOwnershipCommittedBlocking being mis-classified as the ownership feed.

The KNOWN LIMITATION block on aDeactivationAppliedLateCannotRevokeANewerActivation is now resolved and removed, rather than restated.

One further correction: stopForWalletWipe_inFlightWalkCannotWriteAfterStop_… was delivering the gate's reset late, which gave the gate a distinct transition to restart on — that is why it still passed under the mutation even after the routing was fixed. Its gate feed now stays CUT_OVER for the whole test, so the restart it asserts on can only be the stop's re-evaluation.

The production change, and why it was necessary

Three one-line named seams:

private fun cutoverDeactivationFeed(): Flow<Boolean> = cutoverUiActive()
private fun cutoverOwnershipFeed(): Flow<Boolean> = cutoverOwnershipCommitted()
private fun cutoverPipelineGateFeed(): Flow<Boolean> = cutoverUiActive().distinctUntilChanged()

No behaviour change — each is byte-equivalent to the flow its launch built inline (distinctUntilChanged() simply moved into the gate's seam). A test-only fix was preferred and attempted first; it is genuinely not possible here. All three subscriptions resolve to dashPayConfig.observePreservingErrors(CUTOVER_STATE) — same method, same key, same argument. The only other discriminator is the synthetic lambda class name (CutoverUiDataService$start$1 vs $start$3), which is itself source-order-derived, i.e. still positional, just spelled differently. Naming the seams is what makes the routing survive a reordering of the scope.launch blocks. The KDoc records this so a future refactor knows not to inline them away.

Verification

  • :wallet:compile_testNet3DebugKotlin — BUILD SUCCESSFUL.
  • Full suite, 2353 tests, 176 failures: 175 conscrypt + PlatformSyncEngineRestartTest#resume_isRearmableAcrossRepeatedStopRestartCycles (pre-existing, fix(upgrade): v12 cutover and sync lifecycle, plus a measured account of what still loses money #1568). Zero new by name.
  • CutoverUiDataServiceTest — 124 tests, 0 failures, 3× with --rerun-tasks, no flakiness.
  • All six tests required to keep passing do, and four now carry a loud wiring precondition they did not have before.
  • ktlint clean (its source sets do not cover wallet/).
  • The mutation used for the proof was reverted and the production file verified byte-for-byte against a copy taken before any edit.

Note on assertWired()

It requires each long-lived subscription exactly once. If a future change legitimately adds a second subscription through one of those seams, it will fail — deliberately. That should be reviewed, not auto-accommodated.

The remaining suggestion

The end-to-end self-transfer validation is unchanged and still open. Stated plainly: it cannot be closed by any host-JVM change, and a queued plan is not a result. It needs an authorized testnet run on the final head covering destination ownership, credit recognition and spendability after restart, across the shield/unshield cycle, the redirected sweep and CoinJoin consolidation. Awaiting maintainer go-ahead; procedure is in artifacts/pr1570-run19-inclusion.md.

🤖 Generated with Claude Code

@thepastaclaw thepastaclaw left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Re-review — Final validation — Phase 2 only (Phase 1 failed)

Static verification at the exact head confirms the receive-address ownership, generation, ordering, and financial-side-effect fixes, but the new unavailable-address handlers can still crash after fragment cancellation. One blocking lifecycle finding and the existing end-to-end self-transfer verification suggestion remain. The supplied CI snapshot at 2026-10-03T08:55:11Z reports successful check and build jobs; no local builds or tests were run.

🔴 1 blocking | 🟡 1 suggestion(s)

1 carried-forward finding(s) already raised on this PR; not re-posting as new inline comments.

Review provenance

Source: reviewer 1: gpt-6.1-sol (agent: phase2-reviewer, role: general); reviewer 2: gpt-6.1-sol (agent: phase2-reviewer, role: security-auditor); reviewer 3: gpt-6.1-sol (agent: phase2-reviewer, role: general); reviewer 4: gpt-6.1-sol (agent: phase2-reviewer, role: security-auditor); final verifier: gpt-6.1-sol (agent: sol-verifier, role: final-verifier)

  • Triage: critical by gpt-6.1-sol (effort low) — The diff is large and intricate and directly changes funds destination selection in CoinJoinFundsMigrationService.migrateMixedFunds and ShieldedTransferExecutor, alongside cross-cutting receive-address ownership, caching, and failure handling.
  • Phase 1 reviewers: failed (failed on every model; its output was dropped)
  • Fresh final gate: an independent Phase-2 review ran after iterative findings were reconciled
  • Fresh verifier: gpt-6.1-sol — final-verifier; agent sol-verifier
  • Phase 2 reviewers: gpt-6.1-sol — general (completed, effort xhigh); agent phase2-reviewer, gpt-6.1-sol — security-auditor (completed, effort xhigh); agent phase2-reviewer, gpt-6.1-sol — general (completed, effort xhigh); agent phase2-reviewer, gpt-6.1-sol — security-auditor (completed, effort xhigh); agent phase2-reviewer
🤖 Prompt for all review comments with AI agents
These findings are from an automated code review. Verify each finding against the current code and only fix it if needed.

In `integrations/coinbase/src/main/java/org/dash/wallet/integrations/coinbase/ui/CoinbaseOrderReviewFragment.kt`:
- [BLOCKING] integrations/coinbase/src/main/java/org/dash/wallet/integrations/coinbase/ui/CoinbaseOrderReviewFragment.kt:126-131: Check lifecycle cancellation before displaying an address-read failure
  Rotate or leave this screen while getTransferDashParams() is waiting for an unbound SDK wallet. The fragment's lifecycleScope is cancelled, but the underlying Thread.sleep/FFI work continues. When that work subsequently fails, ReceiveAddressUnavailableException can still reach this catch: withContext's cancellation check discards successful results, but does not replace an exception produced by its body. The handler then calls requireContext() after the fragment has detached, throwing an uncaught IllegalStateException instead of presenting a retry. ReceiveFragment and the Topper entry-point fragments contain the same newly added failure-handler pattern. Check coroutine cancellation before performing error UI, and use a still-attached, appropriate lifecycle owner/context. Add coverage for cancellation followed by a failed address read; the current confirmation tests model cancellation cleanup without exercising the fragment's requireContext() call.

In `wallet/src/de/schildbach/wallet/ui/shielded/ShieldedTransferExecutor.kt`:
- [SUGGESTION] wallet/src/de/schildbach/wallet/ui/shielded/ShieldedTransferExecutor.kt:605-617: Verify internal-chain self-transfer destinations end to end
  (existing thread: https://github.com/dashpay/dash-wallet/pull/1570#discussion_r4171723484)
  The normal withdrawal now obtains its destination through unadvertisedDestinationLive(), which selects the SDK's internal chain. ShieldedTransferExecutorTest verifies the accessor wiring and pre-broadcast refusal, but mocks withdrawToCore, so it does not establish that a real withdrawal to this destination is detected, credited, and subsequently spendable. The ordinary change-output evidence demonstrates internal-chain ownership, not completion of this withdrawal route. Your plan to exercise this in the next integration run leaves that verification pending, and the sweep and CoinJoin combine now use the same destination primitive. Run a testnet shield-to-unshield cycle that checks the actual output, credited transparent balance, and subsequent spend; exercise sweep/combine separately to cover their transaction construction.
Out-of-scope follow-up suggestions (2)

These are valid observations, but they are outside this PR's scope and should be handled in separate issues or author/maintainer-requested PRs rather than blocking this review.

  • Reserve self-transfer destinations independently of ordinary payment change — sdkUnadvertisedAddressLiveBlockingWithBinding() obtains nextChangeAddress without reserving it. An ordinary payment and a self-transfer can therefore select the same unused internal address, allowing the payment recipient to correlate a self-transfer with the visible change output. The current code documents this residual, and the discussion explicitly defers the allocation contract to upstream SDK support rather than this receive-address fix.
    • Follow-up: Track an upstream durable address-reservation or dedicated destination-pool API that competing change builders honor, then adopt it in a separately authorized wallet change.
  • Bind self-transfer destinations to the funding wallet at spend time — unadvertisedDestinationLive() validates the destination generation before returning a plain Address, but withdrawToCore() and drainCoinJoinAccountTo() select their funding wallet later. A wallet replacement after the accessor's final check can still pair the previous wallet's destination with the new wallet's funds. The accessor-local race is fixed; transaction-level destination/funding association is the explicitly deferred orchestration follow-up.
    • Follow-up: Track a binding-aware spend contract that carries destination wallet identity through preparation and validates it against the funding wallet under spend synchronization.

Comment on lines +126 to +131
} catch (ex: ReceiveAddressUnavailableException) {
// Depositing to the held dashj chain's frozen address is the
// SR-03 defect; nothing has been bought yet, so this is safe
// to retry.
Toast.makeText(
requireContext(),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🔴 Blocking: Check lifecycle cancellation before displaying an address-read failure

Rotate or leave this screen while getTransferDashParams() is waiting for an unbound SDK wallet. The fragment's lifecycleScope is cancelled, but the underlying Thread.sleep/FFI work continues. When that work subsequently fails, ReceiveAddressUnavailableException can still reach this catch: withContext's cancellation check discards successful results, but does not replace an exception produced by its body. The handler then calls requireContext() after the fragment has detached, throwing an uncaught IllegalStateException instead of presenting a retry. ReceiveFragment and the Topper entry-point fragments contain the same newly added failure-handler pattern. Check coroutine cancellation before performing error UI, and use a still-attached, appropriate lifecycle owner/context. Add coverage for cancellation followed by a failed address read; the current confirmation tests model cancellation cleanup without exercising the fragment's requireContext() call.

source: gpt-6.1-sol (phase2-reviewer: general)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Resolved (re-reviewed at f4374a46): Your Coinbase failure handler now calls toastIfStillAttached(), which checks coroutine activity and uses nullable context rather than requireContext(). AttachedUiTest also explicitly stages an exceptional withContext completion after cancellation, so the guard addresses the reported late-failure path.

The address reads this PR made fail closed park on an uncancellable
Thread.sleep for up to BINDING_WAIT_MS (5s) waiting for the SDK engine to
bind, so seconds can pass between a screen going away and the read
unwinding. Cancelling a withContext discards a SUCCESSFUL result and
resumes the caller with CancellationException, but an exception thrown by
its body is delivered as itself -- DispatchedTask.run consults the job
only when the resumed result carries no exception. The failure handlers
therefore ran on dead screens, where requireContext() threw
IllegalStateException instead of offering the retry.

runIfStillAttached (common/ui/AttachedUi.kt) is the one guard all seven UI
handlers now go through, via Fragment/Activity toastIfStillAttached. Both
of its checks are load-bearing and neither implies the other: the
cancellation check catches a view-scoped coroutine cancelled while the
fragment itself is still attached on the back stack (ReceiveFragment,
ExploreTestNetFragment), and the nullable context catches a
fragment-scoped coroutine resuming after onDetach. It takes
Fragment.getContext() rather than requireContext(), and deliberately not
the application context: these are "try again" prompts that belong to the
screen the user is on, so a user who has left should not be interrupted.

CrowdNodeViewModel's three apiError writes take the same cancellation
check for a different reason: CrowdNodeApi.apiError is an app-scoped
singleton and only a dialog actually shown clears it, so a read abandoned
with the staking screen latched a failure that greeted the NEXT staking
session before anything had been read.

PaymentsReceiveFragment is unchanged and keeps its RESUMED gating: its
handler is reached from a StateFlow collect, which calls
collectorJob.ensureActive() before each emit on the same main thread that
cancels it, so it cannot deliver after cancellation.

Tests. AttachedUiTest pins the library premise directly --
aCancelledWithContextStillDeliversItsBodysException parks a read inside
withContext(IO), cancels the job, only then throws, and asserts the catch
still runs -- and covers all three arms of the guard.
CoinbaseBuyDashConfirmGuardTest drives the same sequence through the
modelled review screen and counts handler ENTRIES separately from prompts
SHOWN, so "showed nothing" cannot pass by the handler never running at
all. CrowdNodeViewModelTest covers the reported and the abandoned read as
a pair.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@HashEngineering

Copy link
Copy Markdown
Collaborator Author

Fixed — head f4374a46b

Premise confirmed on all three legs, and the third was verified rather than read.

  1. The uncancellable wait is real: beginReceiveReadAwaitingBinding is a while (true) around Thread.sleep(BINDING_POLL_MS) bounded by BINDING_WAIT_MS, breaking only on InterruptedException, which coroutine cancellation does not raise.
  2. The exception crosses a withContext: getTransferDashParams() → freshReceiveAddressStringOffMain() = withContext(Dispatchers.IO) { ... }.
  3. The coroutines subtlety holds. In kotlinx-coroutines 1.6.4 DispatchedTask.run consults the Job only when the resumed result carries no exception, and JobSupport.getFinalRootCause prefers the first non-CancellationException. Rather than rest on that reading, AttachedUiTest.aCancelledWithContextStillDeliversItsBodysException parks a body inside withContext(IO), cancels the job, releases, throws, and asserts the catch runs — it passes, and mutating the body to return a value instead of throwing makes it fail. If kotlinx ever changes this, the suite says so instead of going quietly vacuous.

Full audit — not just the named file

From git diff afa91bff2..HEAD, not memory: 22 sites touch ReceiveAddressUnavailableException, 18 are handlers. 8 fixed, 10 safe with reasons given.

Fixed Why it was unsafe
CoinbaseOrderReviewFragment:126 requireContext() after onDestroy → crash. The named finding.
ReceiveFragment:97 Worse than crash-only — view scope dies on navigating forward while the fragment stays attached on the back stack, so getContext() is still non-null. Only the cancellation check suppresses a toast over the next screen.
ExploreTestNetFragment:62 Same view-scope shape.
WalletFragment:466 (Topper), BuyAndSellIntegrationsFragment:76, UpholdPortalFragment:118 requireContext() after detach.
UpholdTransferActivity:147 No crash, but a finished activity toasted over whatever the user moved to.
CrowdNodeViewModel ×3 See below — the worst one.

The CrowdNode one is more serious than the reported crash. crowdNodeApi.apiError is a singleton MutableStateFlow that outlives the ViewModel, and clearError() only runs where a dialog was actually shown. The non-cancelled path self-clears; the cancelled path latches. StakingActivity calls recheckState() from onCreate, so backing out of staking during the 5 s park left an error that greeted the next staking session with a dialog before anything had been read. Now routed through a single reportAddressFailure(ex) with a cancellation check.

Safe, with reasons — the notable one is PaymentsReceiveFragment.showReceiveAddress, where the reviewer's suspicion does not apply: the handler is reached from viewModel.receiveAddress.collect(...), and StateFlowImpl.collect calls collectorJob?.ensureActive() immediately before each emit; both that and the viewLifecycleOwner cancellation run on the main thread, so they cannot interleave. Left unchanged, with shouldSurfaceAddressFailure, the RESUMED gate and the quiet-offscreen behaviour intact. Also safe: WalletUriHandlerActivity (no UI, setResult + finish() already outside the try), StakingActivity.handleCrowdNodeError (a LiveData observer removed at DESTROYED), three ViewModels writing only to scope-owned state, two SwapKit paths returning ResponseResource.Failure, and the two background executors.

On requirement 4 (CancellationException rethrow): no change was needed and none was made. All 18 production handlers catch ReceiveAddressUnavailableException, which extends IllegalStateException, so none can swallow a cancellation. The only broad catches added already rethrow it.

Shape

One shared helper rather than 7 copies — the call sites were byte-identical already, and the reasoning is too long to repeat 7× without drifting.

suspend fun <T : Any> runIfStillAttached(host: T?, block: (T) -> Unit): Boolean {
    if (!currentCoroutineContext().isActive) return false
    val attached = host ?: return false
    block(attached); return true
}

Both checks, because neither implies the other. Cancellation alone misses the detached-fragment case (lifecycleScope cancels at onDestroy, context nulls later at onDetach); the null-context check alone misses the view-scoped case (ReceiveFragment, ExploreTestNetFragment) where the fragment is still attached on the back stack.

isActive rather than ensureActive(): every handler ends at the UI it guards, so raising out of a catch would replace plain control flow with a throw for no gain; the two sites with work afterwards already return@launch, and Coinbase's finally { releaseConfirm(attemptId) } runs either way. Not the application context — these are "no address yet, try again" prompts owned by the screen that asked; surviving it would be noise. Ordering fixes and the a1f5ae763 attempt-id semantics are untouched.

Mutation matrix — 6 mutations, all died

# Mutation Named test that failed
M1 drop the isActive check aFailureArrivingAfterCancellationSurfacesNothing + anAttemptCancelledInTheDestinationReadSurfacesNothingWhenItFails
M2 host ?: return false → checkNotNull(host) aDetachedHostSurfacesNothing
M3 never invoke block anAttachedHostOnALiveCoroutineSurfacesTheFailure + aRetryableFailureGivesTheFlightBackAndTheNextTapBuys
M4 drop the check in CrowdNodeViewModel.reportAddressFailure recheckState_cancelledBeforeTheAddressReadFails_reportsNothing
M5 reportAddressFailure → no-op recheckState_addressUnavailable_reportsTheError
M6 premise: parked body returns instead of throwing aCancelledWithContextStillDeliversItsBodysException

On the three vacuity traps: no verify on counters anywhere (counts are AtomicIntegers incremented inside the stubbed read, asserted only after join()); no Channel(CONFLATED) — two-way CountDownLatch handshakes, and the cancelled-attempt test asserts the attempt is still parked before releasing it; and the masking-layer trap is why unavailableAddressHandlers exists alongside unavailableAddressRetries — "surfaced nothing" would be satisfied for free if the exception never reached the catch, so both tests assert the handler ran before asserting it showed nothing.

Verification

  • :wallet:compile_testNet3DebugKotlin — BUILD SUCCESSFUL.
  • wallet 2353 tests / 176 failed, every JUnit XML parsed by name: 175 conscrypt + PlatformSyncEngineRestartTest#resume_isRearmableAcrossRepeatedStopRestartCycles. Zero new. common 49, coinbase 21, crowdnode 34, uphold 11, maya 84, exploredash 24 — all 0 failures, freshness confirmed by XML mtime.
  • ktlint reformatted only import order in touched files. Running exploredash's tests rewrote two binary .db fixtures; reverted, not in the commit.

Open items

  1. The Toast.makeText + context-vs-requireContext() substitution itself is not unit-covered — the Robolectric wall. The decision is: runIfStillAttached is the real production function under test and each wrapper is one expression. Not claimed as more than that.
  2. Pre-existing, flagged not fixed: StakingActivity.handleCrowdNodeError can AdaptiveDialog.show() after onSaveInstanceState on API 28+. Not a cancellation bug, predates this PR, and identical for the MISSING_PRIMARY branch beside it — left alone rather than widen scope. Say if you want it.
  3. Product assumption, stated: a toast for an abandoned address read is dropped, not re-delivered or deferred to the recreated screen. If the prompt should survive (e.g. a recreated Coinbase review re-prompting), that is ViewModel-held state, not a context swap — a different change.

The suggestion

Unchanged and fairly put: a plan is not a result. The end-to-end self-transfer verification needs an authorized testnet run on the final head — real withdrawal to the engine-derived internal-chain destination, output discovery, credited transparent balance, and a subsequent spend after restart, with the sweep and CoinJoin combine exercised separately. Pending maintainer go-ahead.

🤖 Generated with Claude Code

@thepastaclaw thepastaclaw left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Re-review — Final validation — Phase 1 + Phase 2

At f4374a4, the receive-address ordering, binding isolation, ownership refusal, and previously reported consumer failures are addressed. One remaining lifecycle defect allows a newly handled Topper failure to surface after forward navigation; end-to-end validation of the internal-chain self-transfer destinations also remains outstanding. This was a static review with no builds or tests executed locally; the supplied exact-head CI snapshot reports successful build and check jobs.

🔴 1 blocking | 🟡 1 suggestion(s)

1 carried-forward finding(s) already raised on this PR; not re-posting as new inline comments.

Review provenance

Source: reviewer 1: gpt-6.1-sol (agent: phase2-reviewer, role: general); reviewer 2: gpt-6.1-sol (agent: phase2-reviewer, role: security-auditor); reviewer 3: muse-spark-1.3-contributor (agent: phase1-reviewer, role: general); reviewer 4: gpt-6.1-sol (agent: phase2-reviewer, role: general); reviewer 5: gpt-6.1-sol (agent: phase2-reviewer, role: security-auditor); final verifier: gpt-6.1-sol (agent: sol-verifier, role: final-verifier)

  • Triage: critical by gpt-6.1-sol (effort low) — The large, intricate cross-module change directly alters funds-movement destinations in ShieldedTransferExecutor.withdrawToCore and CoinJoinFundsMigrationService, replacing dashj-derived addresses with live SDK internal-chain addresses while adding ownership, caching, and failure-handling logic.
  • Phase 1 reviewers: muse-spark-1.3-contributor — general (completed, effort xhigh); agent phase1-reviewer
  • Phase 1 model: muse-spark-1.3-contributor — not quota-gated; passed over gemini-3.8-flash-high (antigravity below 15% reserve: weekly 15% left, 5h 100% left), glm-5.3-flash (not used above high effort; tier asks max)
  • Single stage: Phase 1 and Phase 2 reviewed this head side by side, with no blocker gate between them (triage tier)
  • Fresh final gate: an independent Phase-2 review ran after iterative findings were reconciled
  • Fresh verifier: gpt-6.1-sol — final-verifier; agent sol-verifier
  • Phase 2 reviewers: gpt-6.1-sol — general (completed, effort xhigh); agent phase2-reviewer, gpt-6.1-sol — security-auditor (completed, effort xhigh); agent phase2-reviewer, gpt-6.1-sol — general (completed, effort xhigh); agent phase2-reviewer, gpt-6.1-sol — security-auditor (completed, effort xhigh); agent phase2-reviewer
🤖 Prompt for all review comments with AI agents
These findings are from an automated code review. Verify each finding against the current code and only fix it if needed.

In `wallet/src/de/schildbach/wallet/ui/main/WalletFragment.kt`:
- [BLOCKING] wallet/src/de/schildbach/wallet/ui/main/WalletFragment.kt:462: Scope the Topper address request to the originating view
  The Topper request runs in the fragment's lifecycleScope, which remains active when forward navigation destroys its view but retains the fragment on the back stack. Tap Topper while the SDK address read is waiting, then tap Send: the Send navigation explicitly retains walletFragment with setPopUpTo(R.id.walletFragment, false). If the address read subsequently fails, toastIfStillAttached() finds an active coroutine and a non-null fragment context, so the newly added Home/Topper error appears over Payments instead of being suppressed. Launch this click-owned request in viewLifecycleOwner.lifecycleScope so destruction of the originating view makes the existing cancellation guard effective. Apply the same correction to BuyAndSellIntegrationsFragment's Topper launch, which can remain on the back stack after navigating to another integration.

In `wallet/src/de/schildbach/wallet/ui/shielded/ShieldedTransferExecutor.kt`:
- [SUGGESTION] wallet/src/de/schildbach/wallet/ui/shielded/ShieldedTransferExecutor.kt:605-617: Verify internal-chain self-transfer destinations end to end
  (existing thread: https://github.com/dashpay/dash-wallet/pull/1570#discussion_r4171723484)
  The current tests establish destination routing and pre-broadcast refusal, but not receipt and spendability through the real SDK. ShieldedTransferExecutorTest supplies a mocked unadvertisedDestinationLive() address and mocks withdrawToCore() success, while the service separation tests use fake receive/change strings. The supplied on-chain change-output evidence supports internal-chain ownership, but does not execute this redirected withdrawal path. The queued integration run remains the appropriate place to close the gap: record a shield-to-unshield cycle on the current implementation, verify that the destination differs from the advertised Receive address, and verify that the output is credited and subsequently spendable. Exercise the paper-wallet sweep destination as well, since it now uses the same seam.
Out-of-scope follow-up suggestions (2)

These are valid observations, but they are outside this PR's scope and should be handled in separate issues or author/maintainer-requested PRs rather than blocking this review.

  • Coordinate self-transfer destinations with ordinary change allocation — The service selects nextChangeAddress(0) without reserving it against ordinary transaction change selection. An ordinary payment and a self-transfer can therefore select the same unused internal address, allowing that payment's recipient to correlate the self-transfer through the visible change output. The implementation documents this concrete residual; closing it requires an SDK allocation contract shared with competing transaction builders and is outside this accessor-level fix.
    • Follow-up: Track a separate upstream SDK change for durable address reservation or a dedicated self-transfer pool honored by ordinary change builders.
  • Validate destination ownership against the funding wallet at spend time — WalletApplication revalidates the destination generation before returning, but returns only an Address. ShieldedBalanceServiceImpl and SdkL1SendService select their funding wallet afterward, so a wallet wipe and replacement after the final accessor check can separate the destination's owning wallet from the funding wallet. This operation-wide race is not closed by the read-time generation check and remains a separate spend/wipe coordination follow-up.
    • Follow-up: Carry destination wallet identity and generation through transaction preparation, then validate them against the funding wallet under spend/wipe coordination before submission.

The Topper entry points launch the receive-address read in the FRAGMENT's
`lifecycleScope`, which outlives the view. Home, Buy & Sell and the Uphold
portal all stay on the back stack behind the screens they navigate to
(Home's Send shortcut pops up to `walletFragment` with inclusive=false),
so a read still parked in `CutoverUiDataService`'s uncancellable binding
wait resumes into its `catch` with the coroutine still active AND the
fragment still attached. Both halves of `toastIfStillAttached` therefore
pass and the "loading error" toast lands over whatever the user moved to.

Launch from `viewLifecycleOwner.lifecycleScope` instead, so destroying the
originating view cancels the request and the existing guard suppresses the
toast — the guard is right, the scope under it was not. A successful read
is dropped the same way, which is also what we want: opening a Topper
custom tab out of nowhere over Payments is worse than doing nothing.

`ReceiveFragment`, `ExploreTestNetFragment` and `PaymentsReceiveFragment`
were already view-scoped. The activity sites (`UpholdTransferActivity`,
`WalletUriHandlerActivity`) have no view scope, and the Coinbase confirm
and `PaymentsViewModel` reads deliberately outlive the view; all are left
alone.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@HashEngineering

Copy link
Copy Markdown
Collaborator Author

Fixed — head 6c850d2e7

Premise confirmed, and it extends to a third site the finding did not name.

Verified rather than assumed: Home's Send shortcut navigates with .setPopUpTo(R.id.walletFragment, false) (WalletFragment.kt:437-439), so the fragment is retained while only its view is destroyed. lifecycleScope is not cancelled there and getContext() is still non-null, so both halves of toastIfStillAttached pass and the Topper error surfaces over Payments. The counter-cases were checked too — ReceiveFragment:90, ExploreTestNetFragment:59 and PaymentsReceiveFragment are all already on viewLifecycleOwner.lifecycleScope, which is what the cancellation check was written for.

This is residue from the previous commit: the guard was right, the scope underneath it was not.

Fixed (3 — the finding named 2)

Site Why
WalletFragment.kt:462 Click-owned Topper read, fragment scope, retained by Send's popUpTo(inclusive = false)
BuyAndSellIntegrationsFragment.kt:71 Same, retained when another integration is picked
UpholdPortalFragment.kt:113 Found by the audit. Same Topper launch, same guard added by this PR, same fragment scope; the portal stays on the back stack behind UpholdTransferActivity, the custom tab and showErrorAlert. Identical defect.

The diff is three one-word changes — lifecycleScope → viewLifecycleOwner.lifecycleScope — plus a comment at each site. The toastIfStillAttached guards are untouched; this is what makes their cancellation half mean anything at these sites. On the Compose case: in BuyAndSellIntegrationsFragment the launch sits in a setContent click lambda declared inside onCreateView, and viewLifecycleOwner is read inside the lambda at click time when getView() is non-null, so it cannot throw the "View's LifecycleOwner when getView() is null" ISE.

Audit of every launch site in afa91bff2..HEAD

Correctly scoped, left alone, with reasons:

  • Activity sites have no finer scope: UpholdTransferActivity:142 uses the Activity.toastIfStillAttached overload (isFinishing/isDestroyed), which is the activity-shaped equivalent; WalletUriHandlerActivity:187 must complete for the calling app (setResult + finish()), so view-scoping would be meaningless.
  • Deliberately outliving the view, confirmed not "corrected": CoinbaseOrderReviewFragment:107 must stay fragment-scoped — its single-flight bookkeeping is keyed to the fragment (onDestroy hands the attempt back, onResume distinguishes finished from running via confirmJob?.isActive, releaseConfirm is written for a gone view), and since the purchase submits under an idempotency UUID, cancelling at view destruction would widen the window in which a bought-but-unlatched attempt exists. PaymentsViewModel.requestReceiveAddress and the four other viewModelScope sites are likewise deliberate.

Flagged, not fixed — SweepWalletFragment.java:620-650. Its destination read runs on backgroundHandler and posts back guarded only by isAdded(), which is the attachment half only, so in principle a late failure could toast from a back-stacked fragment. Not changed, for two reasons: it is not a lifecycleScope site so it is outside this fix, and its failure arm does setState(State.CONFIRM_SWEEP) — state-machine restoration that must run even with the view gone, or the fragment returns stuck in PREPARATION with a dead sweep button. Gating it on the view would be a behaviour regression. Sweep also has no forward navigation out of preparation, so the exposure is theoretical. Closing it properly needs a view-presence check that does not skip setState — say if you want that.

Testability — none at the JVM level, stated plainly

There is nothing honest to extract. The decision is literally "which LifecycleOwner owns this scope"; a shouldUseViewScope() helper would assert its own definition. Real coverage needs a FragmentScenario that starts the request, destroys only the view while keeping the fragment on the back stack, and asserts no toast — i.e. Robolectric, which fails at class-load here with UnsatisfiedLinkError: no conscrypt_jni in java.library.path (the cause of all 175 known failures). No stub was written, no tests added, so there is no mutation matrix to report. AttachedUiTest already covers the guard given a cancelled scope; it is the scope delivery that is untested, and that is exactly what this commit changes.

Manual check that would catch a regression — all three screens, post-cutover with the engine unbound so the read parks ~5 s: tap Topper, then immediately navigate forward (Home → Send; Buy & Sell → Uphold or Coinbase; Uphold portal → Transfer). Expected: nothing on the destination screen. Before this commit a "loading error" toast appears there about five seconds later. Second check: back out to the originating screen before the read unwinds — also suppressed, since the view was destroyed either way.

Verification

  • :wallet:compile_testNet3DebugKotlin + :integrations:uphold:compileDebugKotlin — BUILD SUCCESSFUL.
  • wallet 2353 tests / 176 failed: 175 conscrypt + PlatformSyncEngineRestartTest#resume_isRearmableAcrossRepeatedStopRestartCycles (pre-existing, fix(upgrade): v12 cutover and sync lifecycle, plus a measured account of what still loses money #1568). Zero new by name.
  • :integrations:uphold:testDebugUnitTest — 11 tests, 0 failures.
  • ktlint clean, no files rewritten. exploredash tests not run, so its binary .db fixtures are untouched; the commit contains exactly three source files.

The suggestion

Agreed, and I will stop describing a plan as if it were evidence. The end-to-end verification needs an authorized testnet run on this head: a shield-to-unshield cycle confirming the destination differs from the advertised Receive address and that the output is credited and subsequently spendable, with the paper-wallet sweep exercised separately since it uses the same seam. That run is also the only thing that would catch the class of lifecycle defect in this round and the last — the JVM suite structurally cannot reach it, as the two "no coverage" notes above make explicit.

🤖 Generated with Claude Code

@thepastaclaw thepastaclaw left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Re-review — Final validation — Phase 1 + Phase 2

Verified the complete diff at 6c850d2 and revalidated all 44 prior findings. The receive-read ordering, fail-closed handling, wallet-generation checks, and lifecycle fixes address the earlier defects, but the new unreserved internal-chain destination still permits linkage between ordinary-payment change and self-transfers. The supplied exact-head CI snapshot reports successful check and build jobs; this static review ran no builds or tests, and end-to-end unshield and sweep validation remains outstanding.

🔴 1 blocking | 🟡 1 suggestion(s)

2 carried-forward finding(s) already raised on this PR; not re-posting as new inline comments.

Review provenance

Source: reviewer 1: gpt-6.1-sol (agent: phase2-reviewer, role: general); reviewer 2: gpt-6.1-sol (agent: phase2-reviewer, role: security-auditor); reviewer 3: muse-spark-1.3-contributor (agent: phase1-reviewer, role: general); reviewer 4: gpt-6.1-sol (agent: phase2-reviewer, role: general); reviewer 5: gpt-6.1-sol (agent: phase2-reviewer, role: security-auditor); final verifier: gpt-6.1-sol (agent: sol-verifier, role: final-verifier)

  • Triage: critical by gpt-6.1-sol (effort low) — The large, intricate diff changes funds-movement destination selection in CoinJoinFundsMigrationService and ShieldedTransferExecutor, replacing fresh receive addresses with live engine internal/change addresses while adding cross-cutting wallet ownership and address-cache coordination.
  • Phase 1 reviewers: muse-spark-1.3-contributor — general (completed, effort xhigh); agent phase1-reviewer
  • Phase 1 model: muse-spark-1.3-contributor — not quota-gated; passed over gemini-3.8-flash-high (antigravity below 15% reserve: weekly 15% left, 5h 100% left), glm-5.3-flash (not used above high effort; tier asks max)
  • Single stage: Phase 1 and Phase 2 reviewed this head side by side, with no blocker gate between them (triage tier)
  • Fresh final gate: an independent Phase-2 review ran after iterative findings were reconciled
  • Fresh verifier: gpt-6.1-sol — final-verifier; agent sol-verifier
  • Phase 2 reviewers: gpt-6.1-sol — general (completed, effort xhigh); agent phase2-reviewer, gpt-6.1-sol — security-auditor (completed, effort xhigh); agent phase2-reviewer, gpt-6.1-sol — general (completed, effort xhigh); agent phase2-reviewer, gpt-6.1-sol — security-auditor (completed, effort xhigh); agent phase2-reviewer
🤖 Prompt for all review comments with AI agents
These findings are from an automated code review. Verify each finding against the current code and only fix it if needed.

In `wallet/src/de/schildbach/wallet/service/platform/sdk/CutoverUiDataService.kt`:
- [BLOCKING] wallet/src/de/schildbach/wallet/service/platform/sdk/CutoverUiDataService.kt:3146-3147: Reserve the unshield destination against ordinary payment change
  (existing thread: https://github.com/dashpay/dash-wallet/pull/1570#discussion_r4110572828)
  This accessor returns the next unused internal address without claiming it for the self-transfer. The source delegates directly to core.nextChangeAddress(accountIndex), and the documented allocator contract at lines 3104–3118 explicitly says ordinary payment builders select from the same pool. WalletApplication returns that address, then ShieldedTransferExecutor passes it to withdrawToCore without any allocation coordination. A withdrawal can therefore capture C, an ordinary payment can subsequently select C for change while the withdrawal is being prepared, and both transactions can pay C. The ordinary payment's recipient can identify its change output and associate the shielded withdrawal and amount with the paying customer. Binding-generation validation does not prevent this same-wallet collision.

  This is caused by the PR: the base withdrawal requested a newly issued external dashj key, whereas this change selects an unreserved address from the ordinary-payment internal pool. Separating self-transfers from the advertised QR fixes one linkage path but introduces this different counterparty-visible path. The documented SDK limitation explains why an accessor-only reservation fix is unavailable; it does not make the regression pre-existing or enforce a privacy boundary. Use a durable allocation mechanism honored by competing change builders or a separately issued destination pool. Until a safe allocation is available, refuse the affected self-transfer rather than accepting the shared pointer.

In `wallet/src/de/schildbach/wallet/ui/shielded/ShieldedTransferExecutor.kt`:
- [SUGGESTION] wallet/src/de/schildbach/wallet/ui/shielded/ShieldedTransferExecutor.kt:604-617: Verify internal-chain self-transfer destinations end to end
  (existing thread: https://github.com/dashpay/dash-wallet/pull/1570#discussion_r4171723484)
  Unshield, paper-wallet sweep, and CoinJoin combine now use the internal-chain destination. The tests verify accessor routing and refusal before submission, but the service fixture supplies a fabricated change address and the executor tests mock withdrawToCore. They do not exercise ManagedCoreWallet derivation, the resulting transaction output, SDK discovery, or subsequent spending. The supplied device evidence establishes ordinary change-output crediting and Receive correctness, but explicitly leaves these changed self-transfer paths unexecuted; a queued integration run is not execution evidence. Add an instrumented regression or attach a targeted testnet shield→unshield cycle and sweep run that verifies separation from the advertised receive address, credited balance/UTXOs, spendability after restart, and a destination-unavailable attempt that remains retryable without broadcasting.
Out-of-scope follow-up suggestions (1)

These are valid observations, but they are outside this PR's scope and should be handled in separate issues or author/maintainer-requested PRs rather than blocking this review.

  • Validate prepared destinations against the funding wallet at spend time — WalletApplication now rechecks the destination generation after resolving ownership, but downstream operations still receive a bare address and independently resolve the funding wallet. A wallet replacement after that final accessor check can pair the previous wallet's destination with the replacement wallet's funds. The base already prepared an address before independently selecting the funding wallet, so this operation-wide identity boundary is a concrete funds-safety follow-up rather than a newly introduced receive-accessor defect.
    • Follow-up: Track a separate transaction-lifecycle change carrying wallet identity through destination preparation and validating it against the funding wallet before signing or broadcast.

HashEngineering and others added 2 commits October 4, 2026 16:48
Comments only on `sdkUnadvertisedAddressLiveBlockingOrNull`. No behaviour
change; `git diff` touches KDoc lines exclusively. Three corrections and one
addition, each checked against the pinned artifacts rather than taken on faith.

1. Wrong SDK version. The block said "no reservation or dedicated-pool API is
   exposed at v42int21". `dashSdkVersion` in the root `build.gradle:17` is
   `0.1.0-v42int27-SNAPSHOT`. Now names the gradle property and flags the
   literal as re-readable rather than freezing another number into prose.

2. Overclaimed "no API exposed". True of the public Kotlin surface, misleading
   otherwise, so the block now qualifies it twice:

   - A change address can be PINNED, just not RESERVED.
     `CoreTransactionBuilder.setChangeAddress$sdk_release(String)` is in the
     pinned AAR's `classes.jar` (verified with javap), bound through
     `WalletManagerNative.coreTxBuilderSetChangeAddress` to the exported FFI
     symbol `core_wallet_tx_builder_set_change_address` (verified in the
     dynamic symbol table of `jni/arm64-v8a/libdash_sdk_jni.so`), and
     `CoreSendAllNative` is the in-tree precedent for reaching an
     SDK-`internal` `$sdk_release` member from Java. It names a change output
     on ONE build and does not take the address out of the pool, so a
     concurrent builder can still draw it. Whether a pinned address wins over
     the pool draw is a Rust detail NOT verified here and is written up as
     such.
   - The reservation primitive upstream (`AddressState::{Available, Reserved,
     Used}`, `next_unused_and_reserve`, `release_reservation`,
     `sweep_expired_reservations`) is recorded as REPORTED, from a Rust
     revision that is not necessarily the one this AAR was built from, with an
     explicit instruction to re-check before quoting. What the comment does
     assert is what was verified here: none of it is exported from the pinned
     `.so`. The whole address-pool FFI surface is read-only
     (`address_pool_get_*`, `managed_*_get_*_address_pool`) plus
     `core_wallet_next_change_address` / `core_wallet_next_receive_address` —
     no `*_and_reserve`, no release, no sweep, and no internal-chain
     counterpart of the receive-side trio. Also notes that the
     `releaseReservation` the SDK does expose reserves UTXOs, not addresses,
     since the name invites exactly that confusion.

3. Wrong rationale for not caching. "Caching would also make two successive
   self-transfers share one destination for no benefit" does not hold: not
   caching does not avoid that either, because nothing is reserved and both
   transfers draw the same next-unused address regardless. 4f593cb carries
   both halves — the same claim in its "Deliberately NOT cached" paragraph and
   the correct fact one sentence later ("Two unshields raced before either
   confirms can still share one internal address (the FFI exposes no
   reservation)"). The KDoc now says the reachable thing: there is no screen to
   keep warm, so a cache buys nothing, and not caching buys nothing either.

Also added the real exposure window, which the old text implied was build-time.
The engine marks an internal address used on network OBSERVATION of the
spending transaction, so for the unshield the window is up to ~10 minutes: the
withdrawal runs the platform withdrawal queue (`ProvingOverlay`'s parameter
KDoc in `ShieldedTransferScreen.kt:916-919` records the up-to-10-minute wait
for this direction) and the proving overlay is dismissable (`:904-911` — Hide
keeps the spend on the app scope and hands the wallet back), so sending an
ordinary payment mid-withdrawal is expected, and an ordinary payment is the
builder that can draw the same address. The sweep and CoinJoin-combine callers
broadcast in seconds. This makes the residual read larger than before, which is
the point.

The "## KNOWN RESIDUAL — this is NOT an isolation guarantee" framing and its
cross-operation-linkage paragraph are unchanged; they were already right.

Verified: `:wallet:compile_testNet3DebugKotlin` BUILD SUCCESSFUL. No test run —
nothing executable changed. `./gradlew ktlintFormat` clean and nothing
reformatted (`:wallet` does not apply ktlint; the task does not exist there).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The preceding commit hedged the key-wallet reservation primitive as
"reported" because the Rust sources could not be found locally. They are
present, under ~/.cargo/git/checkouts, and the claims check out at
rust-dashcore@f3dc260: AddressState::{Available, Reserved{at}, Used},
AddressPool::next_unused_and_reserve / release_reservation /
sweep_expired_reservations, and next_unused gating on is_available() so
that it already skips Reserved. The receive-side trio is on
ManagedCoreFundsAccount; there is no change-side counterpart.

That last point is why the upstream ask is small: the selector needs no
change, only an internal-pool equivalent of methods that already exist.

The revision-mismatch caveat stays — f3dc260 is not proven to be what the
pinned v42int27 AAR was built from — but "could not verify" was written
on a false premise and overstated the doubt.

Also records dashpay/platform#5276 and why this codebase accepts the
residual rather than refusing the self-transfer: the alternatives are the
advertised QR chain, or the held dashj chain's already-paid address, which
is SR-03 itself.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@HashEngineering

Copy link
Copy Markdown
Collaborator Author

Contesting this blocker — head 3862d6c43

I'm not taking this one, and I want to lay out the evidence rather than just disagree. The mechanism is right and I've strengthened its documentation. The premise that this PR caused it does not survive the base behaviour, and the prescribed fallback would trap user funds.

Where you are right — conceded, and under-stated

The collision is worse than "can": it is deterministic. AddressPool::next_unused (address_pool.rs:580) returns the first entry whose is_available() holds and does not change its state. The engine's own change selection calls the same path from TransactionBuilder::fund_from. So while C is unused, the self-transfer destination is the next ordinary payment's change address — not a race that might collide.

And the window is wider than the finding says. It is not "while the withdrawal is being prepared". The destination stops being shared only when the engine marks it used on network observation of the spending transaction. For the unshield that is the full platform withdrawal queue — ShieldedTransferScreen.kt:916-920 documents "can take up to 10 minutes" — and :904-911 documents that the proving overlay's dismiss button only hides it while the spend continues, so "user hides the overlay and pays a merchant meanwhile" is a flow the UI actively invites. Sweep and CoinJoin combine are seconds.

Both points are now recorded in the KDoc under a new ## How long the collision window actually is.

Where the premise fails

the base withdrawal requested a newly issued external dashj key

Literally true; materially inverted. Post-cutover the dashj wallet is held — BlockchainServiceImpl gates the peergroup and blockchain off entirely. freshReceiveAddress() still advances issuedExternalKeys in memory, but the wallet never sees a block, so markPubKeyAsUsed never runs and the issuance pointer stays where the restore left it. This branch's own first commit, 8c8e107a0, says so:

Post-cutover the dashj wallet is HELD — it never sees a block, so its receive key chain's pointer stays wherever the restore left it (index 0 on a fresh restore). currentReceiveAddress() returned that, and freshReceiveAddress() walked forward from the same index-0 base through the used range instead of skipping it.

At afa91bff2, all three consumers went through that accessor: ShieldedTransferExecutor:607, SweepWalletFragment.java:621, CoinJoinFundsMigrationService:520. So the base destination for every unshield, sweep and combine was an address the chain had already paid. That is SR-03/D-003 itself — the defect this PR exists to fix.

A withdrawal paid to an address a counterparty has already paid is a more direct and more certain disclosure than a conditional collision with the user's own change. The PR replaces a near-certain leak with a narrower one. The particular linkage path is new, as you say; the net privacy position is better, which is what "caused by the PR" has to mean to carry a blocking label.

Why the fallback is not available

Until a safe allocation is available, refuse the affected self-transfer

CutoverStateMachine.kt:20-45 is explicit that there is no pre-cutover user — every shipping install is effectively CUT_OVER from first launch. So "post-cutover only" means all users, always, and refusing means:

  • Unshield dies. ToShielded needs no destination and keeps working, so shielding becomes one-way — funds go into the shielded pool and cannot come out. That is a funds-availability defect, and a worse one than the disclosure it would prevent.
  • Paper-wallet sweep fails permanently behind a generic error toast.
  • CoinJoin combine returns NOT_ATTEMPTED forever, so the migration prompt reappears every launch with mixed funds stranded.

The code cost is trivial; the product cost is not, and it is not a trade I can make on a review thread.

The other two options, costed and rejected

A dedicated address pool is a fund-loss mechanism, not a privacy fix. There is no derive-at-index FFI and no create-account FFI, so it is not mechanically available — but more importantly it would be unsafe if it were. Discovery is gap-limit bounded (defaults 30/30); an output beyond highest_used + gap_limit is never derived, so BIP158 cannot match it and rescan cannot recover it. This is not theoretical here: docs/upgrade-memory-and-sync-plan.md:3225-3230 records a measured 0.449 DASH invisible at change indices 828-1630, after set_gap_limit turned out to be in-memory only. Any other wallet restoring the same seed uses 20-30 regardless.

A soft reservation would work but is disproportionate. Post-cutover every ordinary payment is built inside the SDK (SendCoinsTaskRunner:188-195 routes to SDK_BRIDGED; neither sendToAddresses nor buildSignedPayment takes a change address), so closing this in-process means the app taking over the plain-send build path — funding, UTXO reservation, fee, error classification — on a branch that already has a long tail of send-path fixes. And setChangeAddress pins rather than reserves: with no derive-at-index the only pin available is changeToFirstInput, which trades this leak for change going back to a spent input's address, which a recipient identifies trivially. I'd be swapping one linkage for another on the core money path.

What I did instead

Filed upstream: dashpay/platform#5276. The fix is small because the primitive already exists and already composes — verified at rust-dashcore@f3dc260: AddressState::{Available, Reserved { at }, Used } (address_pool.rs:222), next_unused_and_reserve (:649), release_reservation (:690), sweep_expired_reservations (:713), and next_unused (:580) gates on is_available() so it already skips Reserved. The receive-side trio is on ManagedCoreFundsAccount (:1077, :1112, :1137). There is no change-side counterpart, and none of it is bridged to FFI — that is the entire gap. Because the selector already honours reservations, this needs no cooperation from competing builders. The issue also asks for durability + TTL sweep explicitly, since set_gap_limit set the precedent for a signature that looks persistent and isn't.

Corrected three KDoc claims that were wrong or misleading (4e4cedd97, 3862d6c43 — comment-only, verified by diff):

  1. The pinned version was stated as v42int21; it is v42int27 (build.gradle:17).
  2. "No reservation or dedicated-pool API is exposed" over-claimed. CoreTransactionBuilder.setChangeAddress$sdk_release is present in the AAR and bound to core_wallet_tx_builder_set_change_address, reachable by the CoreSendAllNative technique — it pins rather than reserves, which is the real limitation. Also disambiguated the releaseReservation the SDK does expose: that is UTXO reservation for a pending spend, not address reservation, and a future reader grepping "reservation" would otherwise conclude the opposite of the truth.
  3. The "not caching avoids two self-transfers sharing a destination" rationale was simply wrong — nothing is reserved, so they share it either way. Rewritten to the reachable claim.

The residual, stated honestly

A motivated counterparty who is paid by this wallet while a self-transfer is in flight can, with high confidence, identify their payment's change output and link the self-transfer and its amount to the customer. The concurrency is uncommon; the collision given concurrency is near-certain. It is narrower than what the base shipped, every available alternative is worse, and the real fix is upstream and specified.

I've recorded all of that in the KDoc rather than leaving it to review archaeology, including the bit that argues against us — the 10-minute window with a dismissable overlay. If a maintainer weighs it differently and wants the refusal despite one-way shielded funds, that is a product call and I'll implement it on request.

Still outstanding

The end-to-end verification suggestion. Unchanged, and I'll stop calling a queued run evidence.

🤖 Generated with Claude Code

@thepastaclaw thepastaclaw left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Re-review — Final validation — Phase 1 + Phase 2

At head 3862d6c, the receive-path generation and ordering fences, fail-closed handling, and lifecycle fixes address the prior correctness defects. One in-scope privacy blocker remains in the shared, unreserved internal-address allocator; native end-to-end destination validation and one stale KDoc reference remain non-blocking items. This was static verification only; the supplied exact-head CI snapshot reports successful check and build jobs.

🔴 1 blocking | 🟡 1 suggestion(s) | 💬 1 nitpick(s)

3 carried-forward finding(s) already raised on this PR; not re-posting as new inline comments.

Review provenance

Source: reviewer 1: muse-spark-1.3-contributor (agent: phase1-reviewer, role: general); reviewer 2: gpt-6.1-sol (agent: phase2-reviewer, role: general); reviewer 3: gpt-6.1-sol (agent: phase2-reviewer, role: security-auditor); final verifier: gpt-6.1-sol (agent: sol-verifier, role: final-verifier)

  • Triage: critical by gpt-6.1-sol (effort low) — The large, intricate diff directly changes funds destination selection in CoinJoinFundsMigrationService and ShieldedTransferExecutor, replacing dashj receive addresses with live SDK internal-chain addresses for mixed-funds migration and unshielding.
  • Phase 1 reviewers: muse-spark-1.3-contributor — general (completed, effort xhigh); agent phase1-reviewer
  • Phase 1 model: muse-spark-1.3-contributor — not quota-gated; passed over gemini-3.8-flash-high (antigravity below 15% reserve: weekly 15% left, 5h 100% left), glm-5.3-flash (not used above high effort; tier asks max)
  • Single stage: Phase 1 and Phase 2 reviewed this head side by side, with no blocker gate between them (triage tier)
  • Fresh verifier: gpt-6.1-sol — final-verifier; agent sol-verifier
  • Phase 2 reviewers: gpt-6.1-sol — general (completed, effort xhigh); agent phase2-reviewer, gpt-6.1-sol — security-auditor (completed, effort xhigh); agent phase2-reviewer
🤖 Prompt for all review comments with AI agents
These findings are from an automated code review. Verify each finding against the current code and only fix it if needed.

In `wallet/src/de/schildbach/wallet/service/platform/sdk/CutoverUiDataService.kt`:
- [BLOCKING] wallet/src/de/schildbach/wallet/service/platform/sdk/CutoverUiDataService.kt:3213-3214: Reserve the unshield destination against ordinary payment change
  (existing thread: https://github.com/dashpay/dash-wallet/pull/1570#discussion_r4110572828)
  This lookup obtains the next internal address without reserving it. ShieldedTransferExecutor fixes that address as the withdrawal destination, while ordinary payments in SdkL1SendService use sendToAddresses() with default BIP44 change selection. Until the engine observes a transaction using that address, an ordinary payment can therefore select the withdrawal destination for its change. The merchant receiving that payment can identify the change output and associate a subsequent withdrawal to it with the paying user, disclosing the withdrawal and its amount. The withdrawal overlay can be hidden while the operation continues, and its documented queue can last up to ten minutes, so this is a supported overlapping flow rather than a tiny scheduling window.

  The base comparison does not remove this PR's causal contribution. At afa91bff2, the withdrawal called dashj's freshReceiveAddressString(), which issues external-chain keys; ordinary SDK payment change uses the internal chain. A held restore can reuse paid external keys, but that does not make every newly issued external key already paid: issuance can advance beyond the funded external indices. Moving those destinations onto the shared internal allocator adds merchant-change linkage in those cases even though it improves the restored-wallet receive defect.

  Do not globally disable withdrawals: the resulting funds-availability problem is valid. Preserve them by allocating destinations that competing change builders cannot select, or by coordinating ordinary-change construction against outstanding self-transfer destinations until they are observed. The documented residual and upstream tracking accurately describe the limitation but do not prevent this newly introduced disclosure; pinning one builder's change address alone does not reserve it against the others.
- [NITPICK] wallet/src/de/schildbach/wallet/service/platform/sdk/CutoverUiDataService.kt:3245-3248: Restore the blocking receive accessor's KDoc and update its callers
  (existing thread: https://github.com/dashpay/dash-wallet/pull/1570#discussion_r4108292164)
  The KDoc is attached to the correct accessor, but it still says the paper-wallet sweep drives currentReceiveAddressLive()/freshReceiveAddressLive(). SweepWalletFragment.handleSweep() now calls unadvertisedDestinationLive(), which reads the separate internal/change-address path. Remove the obsolete sweep reference so the documentation does not direct readers of a self-transfer destination flow to the advertised receive accessor.

In `wallet/src/de/schildbach/wallet/ui/shielded/ShieldedTransferExecutor.kt`:
- [SUGGESTION] wallet/src/de/schildbach/wallet/ui/shielded/ShieldedTransferExecutor.kt:605-617: Verify internal-chain self-transfer destinations end to end
  (existing thread: https://github.com/dashpay/dash-wallet/pull/1570#discussion_r4171723484)
  ShieldedTransferExecutorTest supplies a mocked internal destination and returns SdkWriteResult.Broadcast(Unit) from a mocked withdrawToCore(); the address-service tests likewise supply fake receive/change addresses. These verify address routing and refusal before submission, but do not exercise the native SDK's crediting, persistence, and subsequent spending of the changed withdrawal destination. Existing evidence that an ordinary internal change output is credited supports ownership, but does not establish this withdrawal path end to end. Keep the queued device check as a non-blocking verification item: execute a shield→unshield cycle using the actual destination, confirm the output is credited and spendable after restart, and verify that a temporarily unavailable destination can recover without submitting a withdrawal to an advertised address.
Out-of-scope follow-up suggestions (1)

These are valid observations, but they are outside this PR's scope and should be handled in separate issues or author/maintainer-requested PRs rather than blocking this review.

  • Bind self-transfer destinations to the funding wallet at spend time — WalletApplication now validates the destination's generation after consulting ownership, closing the reviewed accessor window. Callers then pass an Address or String to a spending service that independently selects its funding wallet, so a wallet replacement after that final check can still mismatch the destination and funding wallet. This separate-operation contract existed at the base and requires coordinated spend/wipe changes rather than another accessor snapshot check.
    • Follow-up: Track a separate issue to carry wallet identity through destination preparation and validate it when selecting the funding wallet, with wallet-replacement regression coverage.

SweepWalletFragment.handleSweep() moved to unadvertisedDestinationLive()
(the internal/change path), but several comments still described the old
wiring:

- CutoverUiDataService.sdkReceiveAddressLiveBlockingOrNull said the
  paper-wallet sweep drives the two live receive accessors. It does not;
  that sent a reader of a self-transfer destination flow to the ADVERTISED
  accessor. Replaced with what the accessor is for, and a pointer to the
  unadvertised one.
- WalletApplication.unadvertisedDestinationLive and WalletData's interface
  doc both said "Both callers" catch before broadcast. There are three now
  (ShieldedTransferExecutor, CoinJoinFundsMigrationService,
  SweepWalletFragment) and all three do. The invariant is now stated as
  the requirement on every caller, with the current set as an aside.
- The "NOT the unshield or CoinJoin-combine destinations" asides on the
  two advertised-read accessors, and the self-transfer examples on
  CutoverUiSource.nextChangeAddressOrNull, were enumerations that go stale
  on every new caller. They now describe the category (a self-transfer
  destination) instead of listing who asks for one.

Comments only: stripping comments from all three files leaves byte-identical
code, and every changed line is a KDoc body line.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@HashEngineering

Copy link
Copy Markdown
Collaborator Author

Nitpick fixed — head 3f09fd18c. And conceding part of the blocker.

The nitpick: you found one, there were six

Confirmed: SweepWalletFragment.handleSweep() (:635) calls unadvertisedDestinationLive() and neither live receive accessor, while the KDoc on the advertised accessor still pointed at it. Fixed — and sweeping the surrounding docs found the same staleness five more times, all from the sweep becoming a third self-transfer caller:

  • WalletApplication.java:2541 and WalletData.kt:101 — "Both callers … catch this strictly BEFORE broadcast". There are three. Verified all three do catch before broadcast (ShieldedTransferExecutor.kt:619, CoinJoinFundsMigrationService.kt:530, SweepWalletFragment.java:634).
  • WalletData.kt:83, WalletApplication.java:2427, CutoverUiDataService.kt:3053, :1211 — four more two-item enumerations that are now three-item.

Two treatments, by whether the list carries an argument. Incidental enumerations became the category ("a self-transfer destination"), because they only ever told the reader which kind of consumer belongs on which accessor, and the category cannot go stale. The one load-bearing list was kept, corrected, and inverted: the "catch before broadcast" claim is a safety argument over the whole caller set, so it now reads as the requirement first and the roster second — "Every caller must catch this strictly BEFORE broadcast … today's three (…) all do". A future caller that doesn't now reads as violating a stated rule rather than as a list someone forgot.

Comment-only: verified two ways — comment-stripped source is byte-identical across all three files, and git diff -U0 | grep '^[+-]' | grep -v '^[+-]\s*\*' returns nothing. Compiles; no tests run because nothing executable changed.

On the blocker — you are right about the causal point, and I was overstating

issuance can advance beyond the funded external indices

Correct, and my earlier framing did not account for it. freshReceiveAddress() advances issuedExternalKeys in memory on every call, and at afa91bff2 every address consumer in the app went through that accessor — so for an active wallet the pointer walks past the funded range and the base then returned genuinely-unused external keys, which cannot collide with internal change at all. So this PR does fix the already-paid case and does introduce merchant-change linkage in the case that was previously clean. "Strictly better" was wrong; it is a trade. I've corrected that in our own defect record rather than leave the one-sided version standing.

New evidence that was not available when this was first argued

I checked whether the other wallet on this SDK has the same problem. dashwallet-ios @ develop (7c0064d) has zero nextChangeAddress call sites anywhere — and routes every self-transfer through the receive chain instead, via the same non-reserving call that feeds the Receive QR:

this PR dashwallet-ios
Self-transfers draw from internal / change external / receive
Collides with the SDK's own change selection every advertised address
Observable by a payment counterparty, by inference Coinbase, Uphold, CrowdNode, Topper, SwapKit — directly

iOS sites: unshield ShieldedTransferCoordinator.swift:919, :1223; CoinJoin consolidation WalletSendService.swift:440; identity withdrawal IdentityWithdrawViewModel.swift:138 — all through SwiftDashSDKReceiveAddressReader.receiveAddress() → nextReceiveAddress(accountIndex: 0), the same function behind PaymentsLandingViewModel.swift:452 (Receive QR), CBAccount.swift:111 (Coinbase), CrowdNodeModel.swift:148 (CrowdNode account identity), and nine others.

The sharpest case there: consolidating freshly de-anonymised CoinJoin funds pays whatever address the Receive QR is currently showing.

This matters here because the receive chain is the only alternative destination this PR could have chosen, and it is demonstrably the worse one — not as a prediction, but as shipping behaviour in a sibling app. Both chains are unreserved; next_receive_address and next_change_address both land on AddressPool::next_unused, which leaves state untouched.

What I am not doing, and why

Your remaining two remedies are "allocate destinations competing builders cannot select" and "coordinate ordinary-change construction against outstanding self-transfer destinations".

The first needs an FFI that does not exist, and carving out an index range would be worse than the disclosure: discovery is gap-limit bounded (defaults 30/30), so an output past highest_used + gap_limit is never derived, BIP158 cannot match it, and rescan cannot recover it — measured in-tree at 0.449 DASH invisible across change indices 828-1630.

The second means the app taking over sendToAddresses — coin selection, UTXO reservation, fee, error classification — on the money path, and the only avoidance control the SDK exposes (changeToFirstInput) sends change to an address the transaction already spent from, which the recipient identifies as change trivially. That trades this leak for another one.

So the fix stays upstream: dashpay/platform#5276, now widened to ask for reserving variants on both chains, since one change fixes this PR's collision and iOS's. The primitive already exists (AddressPool::next_unused_and_reserve, release_reservation, sweep_expired_reservations, and the receive-side trio on ManagedCoreFundsAccount) and next_unused already skips Reserved, so it needs no cooperation from competing builders — only the change-chain wrapper and the FFI bridge.

If a maintainer weighs the residual differently, I'll implement whatever they decide. It is a product call, not a review-thread one.

Suggestion

Unchanged and accepted as non-blocking. Still no device verification of any self-transfer destination.

🤖 Generated with Claude Code

@thepastaclaw thepastaclaw left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Re-review — Final validation — Phase 1 + Phase 2

At the exact head, 42 of the 44 prior findings are fixed, including the receive-address fail-closed behavior, ownership and ordering races, purchase preparation, and lifecycle handling. The shared, unreserved change destination remains an in-scope privacy blocker; end-to-end verification of the changed self-transfer destinations remains a non-blocking recommendation. The supplied exact-head CI snapshot reports successful check and build jobs; this verification was static, with no local builds or tests run.

🔴 1 blocking | 🟡 1 suggestion(s)

2 carried-forward finding(s) already raised on this PR; not re-posting as new inline comments.

Review provenance

Source: reviewer 1: muse-spark-1.3-contributor (agent: phase1-reviewer, role: general); reviewer 2: gpt-6.1-sol (agent: phase2-reviewer, role: general); reviewer 3: gpt-6.1-sol (agent: phase2-reviewer, role: security-auditor); final verifier: gpt-6.1-sol (agent: sol-verifier, role: final-verifier)

  • Triage: critical by gpt-6.1-sol (effort low) — The large, intricate diff changes funds destinations in CoinJoinFundsMigrationService.kt, ShieldedTransferExecutor.kt and SweepWalletFragment.java, alongside synchronized wallet/key-chain ownership and engine-backed address selection in CutoverUiDataService.kt.
  • Phase 1 reviewers: muse-spark-1.3-contributor — general (completed, effort xhigh); agent phase1-reviewer
  • Phase 1 model: muse-spark-1.3-contributor — not quota-gated; passed over gemini-3.8-flash-high (antigravity below 15% reserve: weekly 15% left, 5h 100% left), glm-5.3-flash (not used above high effort; tier asks max)
  • Single stage: Phase 1 and Phase 2 reviewed this head side by side, with no blocker gate between them (triage tier)
  • Fresh verifier: gpt-6.1-sol — final-verifier; agent sol-verifier
  • Phase 2 reviewers: gpt-6.1-sol — general (completed, effort xhigh); agent phase2-reviewer, gpt-6.1-sol — security-auditor (completed, effort xhigh); agent phase2-reviewer
🤖 Prompt for all review comments with AI agents
These findings are from an automated code review. Verify each finding against the current code and only fix it if needed.

In `wallet/src/de/schildbach/wallet/service/platform/sdk/CutoverUiDataService.kt`:
- [BLOCKING] wallet/src/de/schildbach/wallet/service/platform/sdk/CutoverUiDataService.kt:3212-3213: Reserve the unshield destination against ordinary payment change
  (existing thread: https://github.com/dashpay/dash-wallet/pull/1570#discussion_r4110572828)
  This reads the next available internal address without making it unavailable to ordinary payment builders. The production source calls core.nextChangeAddress(accountIndex), while DashSdkL1SendSource.sendToAddress() uses automatic BIP44 change with no exclusion for this destination. I independently checked the cited rust-dashcore revision f3dc260: ManagedCoreFundsAccount.next_change_address() delegates to AddressPool.next_unused(), which returns an available entry without changing its state; TransactionBuilder.fund_from() obtains change through that same method. Bumping the account's monitoring revision does not reserve the address.

  A user can queue a withdrawal to C, hide the proving overlay, and pay a merchant before the withdrawal is observed on L1. The merchant payment can use C as change, allowing the merchant to associate the later withdrawal output and its amount with that customer. ShieldedTransferScreen explicitly permits hiding the overlay while the operation continues and documents a withdrawal wait of up to ten minutes. UTXO reservations do not isolate this destination: the shielded withdrawal and the merchant payment need not spend the same inputs.

  The base called dashj's fresh external-address accessor; once issuance had advanced past funded external indices, those destinations were unused and separate from internal change. This PR therefore introduces linkage in a previously clean case even though it improves restored-wallet behavior. The iOS receive-chain exposure, corrected documentation, and upstream issue do not exclude C from this Android allocator. Do not resolve this by permanently disabling withdrawals; provide a reservation or competing-change exclusion mechanism that lasts through network observation. The supplied discussion contains no explicit maintainer acceptance of this newly introduced privacy tradeoff.

In `wallet/src/de/schildbach/wallet/ui/shielded/ShieldedTransferExecutor.kt`:
- [SUGGESTION] wallet/src/de/schildbach/wallet/ui/shielded/ShieldedTransferExecutor.kt:605-617: Verify internal-chain self-transfer destinations end to end
  (existing thread: https://github.com/dashpay/dash-wallet/pull/1570#discussion_r4171723484)
  The withdrawal now pays an engine internal-chain destination, and paper-wallet sweeps and CoinJoin combines use the same destination seam. The tests establish accessor selection, binding validation, and refusal before broadcast, but ShieldedTransferExecutorTest mocks withdrawToCore(), so it does not demonstrate that an actual withdrawal is discovered, credited, spendable, and recovered after phrase restoration. The cited historical change output supports general internal-chain recognition, not execution of these changed self-transfer paths, and the supplied device evidence explicitly leaves unshield and sweep untested.

  Use a disposable testnet wallet to run a shield-to-unshield cycle and a paper-wallet sweep on the pinned build, verify the resulting outputs can be spent, and restore the phrase to confirm recovery. This closes the funds-destination verification gap without spending the shared QA fixture.
Out-of-scope follow-up suggestions (1)

These are valid observations, but they are outside this PR's scope and should be handled in separate issues or author/maintainer-requested PRs rather than blocking this review.

  • Bind self-transfer destinations to the funding wallet at spend time — The final accessor-level generation check is present, but callers still pass a bare address to operations that select the funding wallet separately. An in-process wallet reset after the accessor returns can therefore separate destination ownership from funding-wallet ownership. The base already separated these decisions, so transaction-wide wallet binding is a concrete funds-safety follow-up rather than a blocker introduced by this PR.
    • Follow-up: Track a separate change carrying wallet identity with the destination and validating it against the funding wallet before signing or withdrawal submission.

…ured latency

The `## How long the collision window actually is` KDoc on
`sdkUnadvertisedAddressLiveBlockingOrNull` gave the window as "up to ~10
MINUTES, not seconds", sourced from the app's own user-facing copy
("could take up to 10 minutes" / "Up to 10 minutes to spend"). That is a
promise to the user, not a measurement, and reading it as the window's
actual length overstates the residual.

A disposable-wallet device run on this head measured it: unshield
submitted 16:37:58Z, L1 output visible to dashd 16:39:29Z — 91 seconds of
platform withdrawal-queue latency, first confirmation at 2 min 13 s.

The section now carries both numbers with their provenance and their
weight: the 91 s as a single observation on one emulator against testnet
(explicitly not a distribution or a guaranteed latency), and ~10 minutes
as the app's stated upper bound. The reasoning the section exists for is
unchanged — the window is bounded by network observation of the spending
transaction, not by build time, and the proving overlay can be hidden
while it runs, so a concurrent ordinary payment remains a supported flow
rather than a tiny scheduling race. Only the magnitude moved.

Comment-only: stripping all comments from the file at HEAD and at this
commit yields byte-identical code.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@HashEngineering

Copy link
Copy Markdown
Collaborator Author

Device verification done — head 1abb62121

The suggestion is closed on evidence rather than another promise. Your framing was taken exactly: a disposable testnet wallet, not the shared QA fixture, on the pinned build.

Method — chain membership is a positive claim, not an absence of evidence

"Differs from every Receive address we happened to observe" would have been the weak answer, so it wasn't used. Instead the disposable wallet's entire BIP44 address space was derived from its recovery phrase — coin types 1/5, accounts 0/1, both chains, indices 0-59, 480 addresses — with an independent BIP32 implementation validated against the standard BIP39/BIP44 test vectors and against the app's own first Receive address. All 480 were imported into a watch-only dashd wallet labelled with each derivation path.

Every amount, destination and chain membership below is read from dash-cli, never from the app under test. The watch wallet is left loaded as pr1570watch, so this is reproducible by anyone:

yiMYMn5K…  m/44'/1'/0'/0/0   EXTERNAL   advertised Receive, funded 3.00000003
yisfEHQN…  m/44'/1'/0'/1/1   INTERNAL   unshield destination, 0.4321
yLc4fAdj…  m/44'/1'/0'/1/0   INTERNAL   shield change
yR9rvm81…  m/44'/1'/0'/1/6   INTERNAL   paper-wallet sweep destination

Results

Assertion Result
Unshield destination ≠ advertised address PASS — internal 1/1 vs step-2 external 0/0 and the at-withdrawal external 0/1
Destination is on the internal chain PASS — positively established, not inferred
Discovered and credited PASS — 1.765497 → 2.197597; history row "Unshielded +0.4321"
Spendable PASS — the onward spend was sized to force it: 1.95 when the only other UTXO was 1.7654974, so input[0] had to be the unshield output. Confirmed in the raw tx; dashd received 1.95000000
Paper-wallet sweep ×3 PASS — destinations 1/3, 1/5, 1/6, all internal; the sweep spend's sole input was the swept output
Recovery after phrase restore PASS — balance 0.959786, matching dashd's 0.95978561 exactly; phase=SYNCED 100.0% in ~2.5 min
Spendable after restore PASS — one 0.90 send consumed all four recovered UTXOs
Destination-unavailable path NOT INDUCED — no coverage claimed (below)

Whole-run chain usage: the external chain was touched exactly twice — 0/0 (the inbound funding) and 0/1 (the advertised address, still unpaid and unchanged across every self-transfer and across the restore). All eight self-transfer destinations and change outputs landed on internal 1/0 … 1/7, monotonic, no reuse, no collision with the advertised address.

Asset Unlock was type 9 as expected. debugUnshieldDestinationOverride() is DEBUG-gated and inert in this release build — no QA debug override active line in the log — so every destination came from production code.

Correcting a figure I had wrong, which cut against my own argument

The unshield queue was measured at 91 seconds: submitted 16:37:58Z → L1 output visible to dashd 16:39:29Z → confirmed 16:40:11Z, end to end 2 min 13 s.

I had been citing "~10 minutes" as the window's length, including in arguing that this residual was worse than you had described. That figure is the app's own user-facing upper bound — the ProvingOverlay KDoc and the confirm sheet's "Up to 10 minutes to spend" — not a measurement of what the queue does. One sample is not a distribution and the bound still stands, but quoting it as typical overstated the exposure. 1abb62121 corrects the KDoc to carry both numbers with their provenance and their weight, and the same correction went into our own defect record.

The argument the comment exists to make is unchanged at either magnitude: the window closes on network observation of the spending transaction, not at build time, and the overlay's Hide button leaves the spend running — so a concurrent ordinary payment remains an expected flow rather than a tiny scheduling race.

What was not verified — stated, not papered over

  • The destination-unavailable path could not be induced. Three attempts, all failed for understood reasons: trim-memory COMPLETE stops the service but it restarts within ~2 s; RUNNING_CRITICAL foregrounded is refused by AMS; am stopservice BlockchainServiceImpl does stop it, but SweepWalletActivity restarts it before Sweep can be tapped. The only genuinely reachable window is the ~5 s BINDING_WAIT_MS after a cold process start, which neither flow reaches because both need PIN entry first. The not-broadcast behaviour remains unverified at runtime.
  • The collision itself is not device-verified. This run was sequential by construction; it demonstrates the destinations are correct, not that a concurrent ordinary payment cannot draw the same address. That residual stands as documented, tracked at kotlin-sdk: bridge the change-address reservation primitive to FFI (self-transfer destinations collide with ordinary payment change) platform#5276.
  • CoinJoinFundsMigrationService, the third caller of unadvertisedDestinationLive(), was not exercised.
  • The unit suite was not run on this host — Robolectric fails at class load with no conscrypt_jni, as throughout this PR. No claim is made about it from this run.

Environment notes

The first build failed on uploadCrashlyticsMappingFile_testNet3Release → HTTP 503, a network error with no APK emitted; rebuilt with that task excluded. The in-app sweep offers only QR scanning and the emulator's virtual-scene camera could not be driven to it, so the sweep was exercised through the app's own exported ImportSharedImageActivity (ACTION_SEND image/*), which routes the decoded key into the same SweepWalletFragment under test — a real user path, not a test hook.

Full evidence, timings and per-step logs: artifacts/pr1570-disposable-wallet-verification.md.

On the blocker

Unchanged, and still the one item outstanding. The residual is real, documented in code with its provenance, and tracked upstream with the fix specified. What you identified as missing — explicit maintainer acceptance — is the right thing to be missing, and it is not something I can supply on a maintainer's behalf.

🤖 Generated with Claude Code

@thepastaclaw thepastaclaw left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Re-review — Final validation — Phase 1 + Phase 2

Revalidated all 44 prior findings against head 1abb621: 43 are addressed, while the unreserved self-transfer destination still introduces merchant-change linkage. The receive-read fences, fail-closed decisions, retry handling, and view-scoped Topper requests are present; the supplied device report establishes internal-chain credit, spendability, and recovery but does not establish allocation isolation. The exact-head CI snapshot reports successful check and build jobs; this verification was static, with no builds or tests run.

🔴 1 blocking

1 carried-forward finding(s) already raised on this PR; not re-posting as new inline comments.

Review provenance

Source: reviewer 1: gpt-6.1-sol (agent: phase2-reviewer, role: general); reviewer 2: gpt-6.1-sol (agent: phase2-reviewer, role: security-auditor); reviewer 3: muse-spark-1.3-contributor (agent: phase1-reviewer, role: general); final verifier: gpt-6.1-sol (agent: sol-verifier, role: final-verifier)

  • Triage: critical by gpt-6.1-sol (effort low) — The large, intricate diff directly changes funds destination selection in ShieldedTransferExecutor.executeTransfer and CoinJoinFundsMigrationService, alongside engine-backed address derivation and wallet-ownership/cache handling in CutoverUiDataService.
  • Phase 1 reviewers: muse-spark-1.3-contributor — general (completed, effort xhigh); agent phase1-reviewer
  • Phase 1 model: muse-spark-1.3-contributor — not quota-gated; passed over gemini-3.8-flash-high (antigravity below 15% reserve: weekly 15% left, 5h 100% left), glm-5.3-flash (not used above high effort; tier asks max)
  • Single stage: Phase 1 and Phase 2 reviewed this head side by side, with no blocker gate between them (triage tier)
  • Fresh verifier: gpt-6.1-sol — final-verifier; agent sol-verifier
  • Phase 2 reviewers: gpt-6.1-sol — general (completed, effort xhigh); agent phase2-reviewer, gpt-6.1-sol — security-auditor (completed, effort xhigh); agent phase2-reviewer
🤖 Prompt for all review comments with AI agents
These findings are from an automated code review. Verify each finding against the current code and only fix it if needed.

In `wallet/src/de/schildbach/wallet/service/platform/sdk/CutoverUiDataService.kt`:
- [BLOCKING] wallet/src/de/schildbach/wallet/service/platform/sdk/CutoverUiDataService.kt:3226-3227: Reserve the unshield destination against ordinary payment change
  (existing thread: https://github.com/dashpay/dash-wallet/pull/1570#discussion_r4110572828)
  This fixes the withdrawal destination through `nextChangeAddressOrNull()` without reserving it or coordinating with ordinary payment change selection. `ShieldedTransferExecutor` passes that address C into `withdrawToCore`, while `SdkL1SendService` independently calls `wallet.sendToAddresses()` with default BIP44 change handling. A user can hide the proving overlay and make a merchant payment before the queued withdrawal reaches L1. If that payment produces change at C, the merchant can identify the other output as change and associate the later withdrawal to C, including its amount, with the customer.

  This requires overlapping operations, not simultaneous engine calls: once C is embedded in the withdrawal, observing the merchant payment first cannot update that destination. Binding-generation checks, receive-read serialization, and UTXO reservations do not exclude C from the address allocator. The measured 91-second queue is sufficient for the supported Hide-and-send flow; the finding does not assume the ten-minute UI bound is typical latency.

  The disposable-wallet results establish that these outputs belong to the wallet and remain spendable after recovery. Correct balances and sequentially advancing internal indices cannot establish privacy isolation, because a colliding output would still belong to the same wallet and be credited correctly. This is also an introduced regression for active wallets whose base dashj issuance had advanced beyond funded external indices: those newly issued external destinations could not collide with internal payment change. Fixing restored-wallet address reuse does not remove that previously clean case.

  Use destination allocation or reservation honored by competing change builders, or coordinate those builders across the pending withdrawal. Tracking dashpay/platform#5276 and documenting the limitation provide no executable protection, and the supplied discussion does not establish a maintainer acceptance of this tradeoff. Permanently disabling unshielding or reverting to advertised receive addresses is not the requested remedy.
Out-of-scope follow-up suggestions (1)

These are valid observations, but they are outside this PR's scope and should be handled in separate issues or author/maintainer-requested PRs rather than blocking this review.

  • Bind self-transfer destinations to the funding wallet at spend time — The accessor now validates the destination generation after resolving ownership, but returns an Address without that identity. ShieldedBalanceServiceImpl.runShieldedWrite subsequently selects readyWalletIdHex, and the CoinJoin drain resolves its funding wallet downstream. A wipe and rebind between these steps can pair an old destination with a new funding wallet. This is a concrete funds-isolation follow-up, but the separate destination and funding-wallet selection already existed at the PR base; it does not invalidate the repaired accessor-boundary check.
    • Follow-up: Track a separate issue to carry wallet identity with prepared self-transfer destinations and validate it against the funding wallet during construction or submission.

@HashEngineering

Copy link
Copy Markdown
Collaborator Author

Maintainer acceptance of the unreserved self-transfer destination.

The finding is correct and I'm accepting it as a known residual for this PR. Recording that explicitly, since the review has rightly pointed out that nothing in the thread so far does.

What I'm accepting. Post-cutover, unshield, paper-wallet sweep and CoinJoin combine take their destination from nextChangeAddress(), which does not reserve. While that address is unused the engine's own change selection can draw it for an ordinary payment, so a merchant paid during an in-flight withdrawal can identify their change output and associate the later withdrawal and its amount with the customer. The 91-second measured queue is ample for that flow, and the Hide button on the proving overlay makes it a supported one. I'm also accepting the review's point that this is an introduced regression for wallets whose dashj issuance had already advanced past the funded external indices — fixing the restored-wallet reuse does not make that previously clean case clean.

Why I'm shipping it anyway. The alternatives are worse, and I've had them costed rather than assumed:

  • Refusing self-transfers applies to every user, since there is no pre-cutover install. Shielding needs no destination and would keep working, so shielded funds become one-way. That's a funds-availability defect traded for a privacy one.
  • A dedicated address range is not merely unavailable — it would lose money. Discovery is gap-limit bounded, and we have an in-tree measurement of 0.449 DASH going permanently invisible past the window.
  • Coordinating change in-app means taking over sendToAddresses — coin selection, UTXO reservation, fees, error classification — on the money path. And the only avoidance control the SDK exposes sends change back to an address the transaction already spent, which a recipient reads as change immediately. That trades one linkage for another.

The status quo this PR replaces is not neutral either: it sent these destinations to addresses the chain had already paid, which is the defect the PR exists to fix.

What happens next. The real fix is upstream and specified: dashpay/platform#5276. The primitive already exists in key-wallet (next_unused_and_reserve, release_reservation, sweep_expired_reservations) and next_unused already skips Reserved, so it needs no cooperation from competing builders — only a change-chain wrapper and the FFI bridge. That issue now covers both chains, because dashwallet-ios has the same defect on the receive chain, where it collides with the addresses we hand to exchanges. One upstream change fixes both apps.

When that lands I'll wire it up here and drop the residual. Until then it's documented in CutoverUiDataService with its provenance and the measured window, and tracked in our QA defect record as D-SDK-01.

What is still unverified, so it's on the record rather than implied: the collision itself is not device-verified — the disposable-wallet run was sequential and shows only that the destinations are correct, credited, spendable and recoverable. The destination-unavailable path could not be induced at runtime, and CoinJoinFundsMigrationService was not exercised.

Thanks for the persistence on this one — the base-behaviour correction and the push for real device evidence both improved the change.

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