Repository navigation
DashPay contact address pool: a used address can be stored back as available (ungated event adapter, verbatim isUsed) #5563
Description
Expected Behavior
Once a DashPay contact payment address is used, it is never read back as available after a relaunch. Two payments to one contact never go to the same address, as DIP-15 per-payment rotation requires.
Current Behavior
Three gaps in how the contact's external address pool is persisted can each leave a used address stored as available. After a relaunch, the wallet can then hand that address out again.
-
The wallet-event adapter skips the contact-payment gate. The payment paths take
WalletPersister::lock_contact_paymentswhile they snapshot and store a contact's whole pool. The adapter storesaddresses_marked_usedthrough the raw persister without that gate (core_bridge.rsL1291, usage read incollect_usage_deltas). This is the sequence:- A gated writer snapshots the pool with address X available. This writer is the rollback after a rejected broadcast on the base (
payments.rsL1888-L1940), or a release in the settle step of feat(platform-wallet)!: sign a DashPay contact payment before broadcasting it #5465 (settle_payment_address_gated). - The wallet then observes a payment to X and marks it used, and the adapter stores that mark.
- The older snapshot is stored last, and X becomes available on disk.
On the base, the rollback helper
return_contact_payment_address_to_poolalso clears a genuine Used mark in memory. feat(platform-wallet)!: sign a DashPay contact payment before broadcasting it #5465 narrows this: a release only lowers a reservation. - A gated writer snapshots the pool with address X available. This writer is the rollback after a rejected broadcast on the base (
-
The Swift host applies
isUsedas it is written. The FFI encodesReservedandUsedalike asis_used = true(persistence.rsL4622-L4625). The Swift core-address writer assigns the flag verbatim (PlatformWalletPersistenceHandler.swiftL5236), so whichever write lands last wins. SQLite is already monotonic (core_pool.rsL65:used = MAX(used, excluded.used)). -
A used mark whose store failed lives only in memory. A send that finds an address paid by a resident record marks it used and stores the pool. If that store fails, the error is reported (feat(platform-wallet)!: sign a DashPay contact payment before broadcasting it #5465), but the mark exists only in memory. While the paying record is resident, the next send re-checks the records (
contact_address_paid_on_chain) and refuses the address or marks it again. After a relaunch where that record is no longer resident, nothing protects the address.
Possible Solution
- Gap 1: make the adapter hold the same contact-payment gate while it commits rounds that carry
DashpayExternalAccountpool rows. Key-wallet marks the address in memory before the adapter reads it, so the store order then follows the read order. Before doing this, check that no gated section awaits the adapter (flush paths, durable-cursor locks). - Gap 2: make
isUsedmonotonic for core-address rows on the Swift host, the same way SQLite does: raise it on every write, and lower it only for an explicit release of a reservation. This hardens the other two gaps as well. - Gap 3: decide whether the persisted pool needs to be authoritative on its own, or whether the record-based re-check is enough once 1 and 2 are fixed.
A prototype for #5465 tried an alternative: re-read and re-store the pool after each gated store, plus an in-memory per-contact "unstored" marker. It was dropped. It patches every call site instead of the one writer that skips the gate, and the marker does not survive a relaunch.
Steps to Reproduce (for bugs)
A natural reproduction needs a race window of a few milliseconds plus a relaunch, so it is not practical by hand. A deterministic test can inject a used mark from a persister store hook. The hook runs while a gated pool snapshot is being stored. The test then asserts on the last stored pool. The test persister in payments.rs already has a pre-store hook point.
- Register a sender and a contact external account.
- Make a release path store a pool snapshot with address X available, for example a send whose broadcast is rejected.
- Between the snapshot and its store, mark X used and store that mark the way the adapter does.
- Inspect the last stored pool: X is available.
Context
- Impact: no funds at risk. In the worst case, one contact address is reused after a crash or relaunch, which links two payments on-chain. Low priority.
- Raised by the automated review of feat(platform-wallet)!: sign a DashPay contact payment before broadcasting it #5465 (findings
578739b2db90ande26d6758520f) and deferred to this issue. - Related: SQLite rehydration never restores a DashPay contact's external address pool #5548 / fix(platform-wallet-storage): restore DashPay contact address pools on SQLite load #5549 (SQLite does not restore contact pools on load), Address-reservation lifecycle is half-wired: no release/sweep, no FFI representation #4188 (address-reservation lifecycle).
Your Environment
- Version used:
v5.1-devat41b6acb39e; feat(platform-wallet)!: sign a DashPay contact payment before broadcasting it #5465 headb7c21009c2 - Environment name and version: rs-platform-wallet, rs-platform-wallet-ffi, swift-sdk
- Operating System and version: iOS host (Swift persistence); SQLite backend for comparison
Activity