Skip to content

fix(platform-wallet)!: keep a contact's payment account across relaunches and replace it cleanly on rotation - #5587

Draft
romchornyi wants to merge 9 commits into
v5.1-devfrom
fix/contact-rebuild-keeps-pool
Draft

romchornyi wants to merge 9 commits into
v5.1-devfrom
fix/contact-rebuild-keeps-pool

Conversation

@romchornyi

@romchornyi romchornyi commented Oct 26, 2026 •

Copy link
Copy Markdown
Contributor

Basic explanation

What this does: To pay a DashPay contact, the wallet derives addresses from an "outbound account" built from the key the contact sent us. The wallet now keeps that account across restarts when the contact's key is unchanged, instead of rebuilding it from scratch on every launch. When the contact sends a new key, the account is replaced cleanly. The account row records which request it was built from, so a restart still knows whether the account matches the contact's current key. This PR holds the rule, in Rust, and the SQLite backend; the iOS and Android storage follow in the stacked PRs below.

Value: Contact payments survive a relaunch without losing the record of which addresses were already used. On SQLite, a contact's new key is actually saved; before, the new public keys were rejected and the rotation never persisted. A payment never goes to addresses the contact stopped watching, even across a restart.

Risks: Medium.

Stack (review and merge in order):

  1. fix(platform-wallet)!: keep a contact's payment account across relaunches and replace it cleanly on rotation #5587: the rule, in Rust, plus SQLite (this PR).
  2. feat(platform-wallet-ffi)!: let hosts reset an outbound contact account's pool and keep its request digest #5589: the FFI slots plus Swift storage.
  3. feat(kotlin-sdk)!: reset an outbound contact account's pool and keep its request digest on Android #5590: JNI plus Kotlin storage (Room v16) and the parity entry. Merge it together with feat(platform-wallet-ffi)!: let hosts reset an outbound contact account's pool and keep its request digest #5589, or right after it: once feat(platform-wallet-ffi)!: let hosts reset an outbound contact account's pool and keep its request digest #5589 is in, the FFI fails a replacement on any host that keeps pools without the reset slot, and the Android host gets that slot only in feat(kotlin-sdk)!: reset an outbound contact account's pool and keep its request digest on Android #5590.

Issue being fixed or feature implemented

Follow-up to #5548 / #5549, found while reviewing them.

  • Rebuilt on every launch. SQLite and the Swift host never restore the contact row's external_account_reference marker. So every launch treated every outbound account as rotated, tore it down and rebuilt it from an empty pool. That undid fix(platform-wallet-storage): restore DashPay contact address pools on SQLite load #5549's pool restore in memory and cleared reserved_at on disk.
  • A replacement never persisted on SQLite. A contact that rotated its xpub keeps the same account key, so the new pool's (pool_type, index) slots collide with the old ones. apply_pools rejected the new public keys (TypedPoolKeyConflict), and old rows above the new window kept used = MAX.
  • Not durable. Whether an account matches the live request was not stored. A rotation seen before a relaunch was forgotten, and a failed drain then paid the old xpub.

What was done?

platform-wallet

  • Keep instead of tear down. The contact sweep only queues a rebuild. register_external_contact_account keeps a registered account whose xpub (key and chain code) and pools match the decrypted payload, and replaces it otherwise. It does the replacement under lock_contact_payments, so a payment's deferred pool store cannot land after the reset.
  • Digest on the account row. AccountRegistrationEntry::encrypted_xpub_digest is the SHA-256 of the request's encrypted_public_key the xpub was decrypted from. It is written in the same round as the xpub, on keep and on replace.
  • Pool reset. PlatformWalletChangeSet::account_pool_resets drops an account's stored pool before the round's snapshots. It is emitted only when an already registered account is replaced. On merge, a later reset drops the earlier rounds' snapshots of that account.
  • Load. apply_outbound_account_digests (manager/load.rs):
    • a digest naming the live request, with pools that derive from it, stamps the marker;
    • anything else (another request, malformed, unreadable, or beside foreign pools) clears the marker and records a pending rotation.
  • Payment gate. reserve_contact_payment_address refuses exactly the registered accounts that outbound_account_is_current rejects: a pending rotation, or a marker not naming the live request. Those are the accounts the payment's own drain is queued to rebuild (queue_outbound_rebuild in send_payment / reserve_payment_address). A broken channel gets its own message.

platform-wallet-storage (SQLite)

  • V020 adds account_registrations.encrypted_xpub_digest (CHECK length 32).
  • AccountRegistrationWire keeps the published blob layout, so old rows decode unchanged.
  • core_pool::reset_pools deletes by the full account key, inside the round's transaction.
  • Digests are handed to the load as ClientWalletStartState::outbound_account_digests.

platform-wallet-ffi: only fills the new struct fields. The host slots come in #5589.

Follow-ups, not in this stack:

How Has This Been Tested?

cargo test --all-features for platform-wallet (1608), platform-wallet-storage (432 lib + 556 integration), platform-wallet-ffi (474) and rs-unified-sdk-jni (52). cargo clippy --all-features --tests -D warnings is clean. New tests:

  • network/payments.rs:
    • keep vs replace, and the rounds each one stores;
    • what a relaunch decides from each kind of digest;
    • the replacement waiting on the payment gate;
    • the marker-binding host keep;
    • the broken-channel refusal;
    • refused accounts always queued;
    • the payment paths queueing the rebuild.
  • changeset.rs: merge rules for account_pool_resets.
  • sqlite_outbound_account_replace.rs: the replacement stores and keeps only the new rows, the conflict without a reset, and digest load / overwrite / clear.

The key tests were mutation-checked: removing the gate lock, the queue call sites, the digest branches or the SQLite reset each fails a test.

Breaking Changes

  • Rust API: new public fields AccountRegistrationEntry::encrypted_xpub_digest, PlatformWalletChangeSet::account_pool_resets and ClientWalletStartState::outbound_account_digests. Struct literals outside this repository must add them.
  • Payments: a payment to a contact is refused ("awaits a rebuild") until its outbound account is confirmed from the contact's live request. Hosts already map this text to "payment channel isn't ready".
  • Storage: SQLite migration V020.

Checklist:

  • I have performed a self-review of my own code
  • My Rust, FFI, WASM, Swift or Kotlin changes follow the coding conventions
  • I have commented my code, particularly in hard-to-understand areas
  • I have added or updated relevant unit/integration/functional/e2e tests
  • I have added "!" to the title and described breaking changes in the corresponding section if my code contains any
  • I have made corresponding changes to the documentation if needed
  • If I added or changed GroveDB structure, I described it in the area's structure.rs, regenerated grovedb-structure.json, and checked the structure viewer link posted on this pull request
  • If I added or changed a system contract's document schemas, I checked the contract viewer link posted on this pull request

For repository code-owners and collaborators only

  • I have assigned this pull request to a milestone

🤖 Generated with Claude Code

jeanpierreroma and others added 8 commits October 26, 2026 15:58
… finds the same xpub

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…t was built from

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ool and keep its request digest

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WYhtAUUCkvoBmp19LA8Z31
… its request digest

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WYhtAUUCkvoBmp19LA8Z31
…s request digest

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WYhtAUUCkvoBmp19LA8Z31
…ts request digest

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WYhtAUUCkvoBmp19LA8Z31
… live request confirms

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WYhtAUUCkvoBmp19LA8Z31
…is queued for

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WYhtAUUCkvoBmp19LA8Z31
@coderabbitai

coderabbitai Bot commented Oct 26, 2026

Copy link
Copy Markdown
Contributor

Important

Draft PR not reviewed

Draft PRs are not automatically reviewed by default.

  • Trigger a manual review

To automatically review draft PRs, update your CodeRabbit configuration:

reviews:
  auto_review:
    drafts: true
  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

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 Oct 26, 2026 •

Copy link
Copy Markdown
Collaborator

🕓 Review not started yet because this PR is a draft.

  • Request normal review — click when the PR is ready for review.
  • Request priority review — click to move this review to the front of the queue.

Commit e091889. Normal review starts when eligible; priority review starts as soon as a slot is available.

The FFI slots, Swift and Kotlin storage of the pool reset and request
digest land in their own PRs on top of this one.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WYhtAUUCkvoBmp19LA8Z31

This branch has not been deployed

No deployments
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.

3 participants