Skip to content

DashPay contact address pool: a used address can be stored back as available (ungated event adapter, verbatim isUsed) #5563

Open

Description

@romchornyi

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.

  1. The wallet-event adapter skips the contact-payment gate. The payment paths take WalletPersister::lock_contact_payments while they snapshot and store a contact's whole pool. The adapter stores addresses_marked_used through the raw persister without that gate (core_bridge.rs L1291, usage read in collect_usage_deltas). This is the sequence:

    1. A gated writer snapshots the pool with address X available. This writer is the rollback after a rejected broadcast on the base (payments.rs L1888-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).
    2. The wallet then observes a payment to X and marks it used, and the adapter stores that mark.
    3. The older snapshot is stored last, and X becomes available on disk.

    On the base, the rollback helper return_contact_payment_address_to_pool also 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.

  2. The Swift host applies isUsed as it is written. The FFI encodes Reserved and Used alike as is_used = true (persistence.rs L4622-L4625). The Swift core-address writer assigns the flag verbatim (PlatformWalletPersistenceHandler.swift L5236), so whichever write lands last wins. SQLite is already monotonic (core_pool.rs L65: used = MAX(used, excluded.used)).

  3. 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 DashpayExternalAccount pool 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 isUsed monotonic 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.

  1. Register a sender and a contact external account.
  2. Make a release path store a pool snapshot with address X available, for example a send whose broadcast is rejected.
  3. Between the snapshot and its store, mark X used and store that mark the way the adapter does.
  4. Inspect the last stored pool: X is available.

Context

Your Environment

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions