Skip to content

feat(platform-wallet)!: keep masternode collateral out of coin selection - #5113

Draft
QuantumExplorer wants to merge 5 commits into
chore/bump-rust-dashcore-secp-033from
claude/lock-masternode-collateral
Draft

QuantumExplorer wants to merge 5 commits into
chore/bump-rust-dashcore-secp-033from
claude/lock-masternode-collateral

Conversation

@QuantumExplorer

@QuantumExplorer QuantumExplorer commented Sep 28, 2026 •

Copy link
Copy Markdown
Member

Basic explanation

The wallet now keeps outputs that back a registered masternode out of coin selection, persists those locks, and exposes explicit lock and unlock. Spending the collateral would end the masternode registration.

The lock itself lives in key-wallet (dashpay/rust-dashcore#1078): a ProRegTx the wallet sees in a block locks the collateral it names, a ProRegTx that creates its collateral as its own output locks it from any sighting, mempool included, and every coin selection path skips a locked coin. This PR makes platform-wallet persist that lock set in every store, bring it back on load, lock the collateral of masternodes the wallet already knows about, and let hosts list, lock and unlock outpoints.

Example: a wallet holding its own 1,000 DASH collateral and a 5 DASH coin sends 1 DASH.

Before:

balance:     spendable 1005 DASH, locked 0
send 1 DASH: largest-first spends the 1000 DASH collateral
send max:    offers 1005 DASH minus fee

After:

balance:     spendable 5 DASH, locked 1000 DASH
send 1 DASH: spends the 5 DASH coin
send 6 DASH: refused, insufficient funds
send max:    offers 5 DASH minus fee
restart:     the collateral is still locked
unlock_outpoint(collateral), then send 1 DASH: largest-first spends the collateral

Value: a masternode owner can keep the collateral in the wallet they spend from. Plain sends, DashPay payments, asset locks (identity funding, top-ups, platform-address and shielded funding) and the fee of a ProUpServTx leave it alone, the locked balance shows it, and the lock survives a restart.

Risks:

  • A lock the user did not ask for. A ProRegTx in a block locks the collateral it names, and so does a tracked masternode whose registration is known. An unconfirmed ProRegTx (mempool or InstantSend) locks only a collateral it creates as its own output. The lock lasts until unlock_outpoint. A tracked masternode's collateral is locked in every wallet of the manager, so a wallet that never holds that coin lists an entry that matches nothing.
  • Re-locking. Like Dash Core on load, a load re-locks the collateral of every registration in a block in the wallet's history (an unconfirmed one only for a collateral it creates) and of every tracked masternode, including one the user unlocked in an earlier session.
  • Hosts without the new callbacks keep locks for the session only. A lock made by a transaction check comes back when the wallet sees the registration again; an explicit lock does not. Kotlin is such a host today (see below).
  • Stacking. This PR is stacked on chore(platform)!: bump rust-dashcore to 719de34b (secp256k1 0.33) #4932 and pins a backport of feat(key-wallet)!: lock masternode collateral out of coin selection rust-dashcore#1078 onto 719de34b (backport/key-wallet-collateral-lock-on-719de34b, 0c0dca374e). The pin should move to the merged rev once chore(drive): payout fees only to single well-known Identity #1078 lands.

Issue being fixed or feature implemented

key-wallet can now keep masternode collateral out of coin selection, but platform-wallet did not persist its lock set: the SQLite store reloaded every coin with is_locked: false, the mobile stores had nowhere to keep a lock, a lock can exist before its coin arrives, and a registration the wallet never processed (funded and signed elsewhere) never locked anything.

What was done?

Persistence pipeline (rs-platform-wallet)

  • CoreChangeSet::outpoint_locks: BTreeMap<OutPoint, bool>: true locks, false unlocks; merge is last-write-wins per outpoint.
  • PlatformWalletInfo::check_core_transaction queues TransactionCheckResult::locked_outpoints (filled also for otherwise irrelevant transactions). No WalletEvent carries them, so the wallet-event adapter drains the queue into the wallet's next projected changeset. The SyncHeightAdvanced watermark that certifies a block is a later event for the same wallet, so a lock is stored with it or before it.
  • The load-time replay of unconfirmed sends queues its locks too: once that registration confirms it is never replayed again.
  • PlatformWallet::lock_outpoint, unlock_outpoint and locked_outpoints. Lock and unlock update the UI balance, drop any queued lock for the outpoint, and store the change before returning.
  • Masternodes the wallet already knows: on load, when a wallet is registered, and after a tracked masternode's refresh learns its registration, the collateral of every ProRegTx in a block in a wallet's history and of every tracked masternode with a known registration is locked (null collateral txid means the ProRegTx's own output). The history pass follows the same rule as a transaction check: a collateral a ProRegTx names locks only from a record in a block, one it creates from any record. New locks are queued for persistence.
  • PersistenceCapabilities::OUTPOINT_LOCKS (bit 13).

SQLite (rs-platform-wallet-storage)

  • V019 adds core_locked_outpoints (wallet_id, outpoint), cascading with the wallet. A table of its own, not a core_utxos column, because a lock can exist before its coin and outlives the coin's spend.
  • core_state::apply inserts and deletes rows; load_state reads them into outpoint_locks and sets each restored coin's is_locked from them; apply_persisted_core_state hands each lock to ManagedWalletInfo::lock_outpoint before the balance refresh, so the loaded locks win.
  • The SQLite store attests OUTPOINT_LOCKS.

FFI (rs-platform-wallet-ffi)

  • Persistence extension trio, appended under version 1 and size-negotiated like the others: on_persist_wallet_changeset_outpoint_locks_fn (fired in the round after the other core callbacks, only when the round changes a lock), on_load_wallet_locked_outpoints_fn and on_load_wallet_locked_outpoints_free_fn (called per restored wallet). OUTPOINT_LOCKS is attested only with all three wired and declared.
  • platform_wallet_locked_outpoints / _free, platform_wallet_lock_outpoint, platform_wallet_unlock_outpoint.

Swift SDK

  • PersistentLockedOutpoint (network, wallet id, outpoint), added to the unreleased live schema V3, purged with its wallet.
  • The trio's callbacks in PlatformWalletPersistenceHandler, and the outpointLocks capability declared.
  • ManagedPlatformWallet.lockedOutpoints(), lockOutpoint(_:), unlockOutpoint(_:).

Kotlin (follow-up)

The JNI bridge builds its extension with ..Default::default(), so it compiles unchanged and leaves the trio unwired: Android keeps locks for the session only and does not attest OUTPOINT_LOCKS. Persisting them needs a Room table and migration, three bridge methods and their trampolines, and the lock / unlock / list bindings; that is a separate change.

How Has This Been Tested?

New tests, all named should_..., each with a wallet holding a 1,000 DASH collateral locked by a processed ProRegTx and a 5 DASH coin unless noted:

  • Plain send (wallet::core::transaction): largest-first 1 DASH and a drain spend only the spare; 6 DASH is refused; pooled_spendable_balance is the spare and pooled_max_sendable the spare less the fee; a reservation-only build seeded with the collateral has nothing to fund from; after an unlock largest-first spends the collateral.
  • DashPay payment (payments): 1 DASH to a contact spends only the spare (checked on the broadcast transaction); 6 DASH is refused.
  • Asset lock (asset_lock::build): a 6 DASH lock is refused; a drain locks only the spare.
  • Masternode update service (update_service): the ProUpServTx fee comes from the spare; with the spare locked too, the build is refused.
  • Pipeline (core_bridge): a registration's lock rides the wallet's next changeset once, a lock-only changeset still reaches the persister, and a registration seen again queues nothing.
  • Explicit API (wallet::outpoint_locks): unlock and lock are persisted, the queued lock is dropped by the unlock, the UI balance follows, the unlocked collateral funds a send and the relocked one does not.
  • Known masternodes: a registration restored into the history without a check is locked once; one restored unconfirmed leaves the collateral it names spendable until the block holding it locks it; a tracked collateral locked before its coin arrives makes the coin arrive locked; load_from_persistor locks a tracked masternode's collateral (registration never processed by the wallet), keeps it out of the pooled balance, and queues it for persistence.
  • SQLite (tests/sqlite_locked_outpoints.rs): a locked collateral comes back in the locked balance after reopen and out of get_spendable_utxos; a lock stored before its coin and a coin arriving after the reload both come back locked; an unlock is not restored; a lock-then-unlock fold keeps the unlock; locks go with their wallet; the store attests OUTPOINT_LOCKS.
  • FFI: the round hands lock changes to the host in outpoint order and never fires without one; a load restores the host's locks and frees its array once; OUTPOINT_LOCKS needs the whole trio declared; lock, list and unlock through the entry points; extension layout and capability pins updated.

Run locally: the 16 new platform-wallet tests and the whole cargo test -p platform-wallet --lib (1168 passed); cargo test -p platform-wallet-storage (984 passed, including sqlite_locked_outpoints, sqlite_schema_pinning, every_table_in_the_schema_is_accounted_for and the capability test; the prepare check's allow-list gains the read-only locked-outpoint load query); the capability bit tests in persistence_capabilities.rs; rs-platform-wallet-ffi builds with its tests, and cargo test -p platform-wallet-ffi --lib passes (388, the 4 new lock tests included); cargo clippy --tests -- -D warnings on platform-wallet, platform-wallet-ffi and platform-wallet-storage; cargo fmt; cargo metadata --locked on the repinned lockfile. The V019 fingerprints in sqlite_schema_pinning were refreshed from that test's own output. The rust-dashcore backport (0c0dca374e): cargo test -p key-wallet (764 passed) and -p key-wallet-manager (84 passed), cargo fmt --all --check, cargo clippy -p key-wallet -p key-wallet-manager --all-features --all-targets -- -D warnings.

Not yet run locally: the Swift SDK build and tests. CI is the first compile of the Swift changes.

Breaking Changes

  • CoreChangeSet gains outpoint_locks, and PlatformWalletInfo a crate-private field.
  • PersistenceCallbacksExtension and PersistenceExtensionCallbacks gain three fields (additive and size-negotiated for C hosts).
  • SQLite schema V019; Swift live schema V3 gains an entity.
  • Through key-wallet: TransactionCheckResult gains locked_outpoints, key-wallet-manager's CheckTransactionsResult gains per_wallet_locked_outpoints, and a coin a ProRegTx names as collateral is no longer spendable, once the ProRegTx is in a block, until unlocked.

Checklist:

  • I have performed a self-review of my own code
  • 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

For repository code-owners and collaborators only

  • I have assigned this pull request to a milestone

🤖 Generated with Claude Code

QuantumExplorer and others added 2 commits September 28, 2026 12:59
…l lock backport

Moves every rust-dashcore dependency from 719de34b to beb262b0, the
backport of dashpay/rust-dashcore#1078 onto 719de34b
(branch backport/key-wallet-collateral-lock-on-719de34b). The pin should
move to the merged rev once #1078 lands.

Only the rust-dashcore source lines of Cargo.lock change.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The wallet now keeps outputs that back a registered masternode out of
coin selection, persists those locks, and exposes explicit lock and
unlock. Spending the collateral would end the masternode registration.

key-wallet locks the collateral of every ProRegTx a transaction check
processes and skips locked coins in every selection strategy, the
asset-lock builder and special-transaction funding. This carries that
lock set through platform-wallet:

- PlatformWalletInfo::check_core_transaction queues the outpoints a check
  locked. No WalletEvent carries them, so the wallet-event adapter drains
  the queue into the wallet's next changeset, as
  CoreChangeSet::outpoint_locks. The load-time replay of unconfirmed
  sends queues its locks the same way.
- PlatformWallet::lock_outpoint / unlock_outpoint / locked_outpoints lock
  and unlock by hand and persist the change before returning.
- On load, and when a wallet is registered, the collateral of every
  ProRegTx in a wallet's history and of every tracked masternode whose
  registration is known is locked; a tracked masternode's refresh locks
  it once its registration is fetched. This covers a registration the
  wallet never processed and one restored without a check.
- SQLite: V019 adds core_locked_outpoints, keyed (wallet_id, outpoint),
  written by core_state::apply and handed back through lock_outpoint on
  load; restored coins carry their lock.
- FFI: a persist / load / free trio on the persistence extension, the
  OUTPOINT_LOCKS capability (bit 13), and platform_wallet_lock_outpoint,
  platform_wallet_unlock_outpoint and platform_wallet_locked_outpoints.
- Swift: PersistentLockedOutpoint rows, the trio's callbacks, and
  ManagedPlatformWallet.lockedOutpoints / lockOutpoint / unlockOutpoint.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Sep 28, 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

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 28, 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 b5c401d. Normal review starts when eligible; priority review starts as soon as a slot is available.

QuantumExplorer and others added 3 commits September 29, 2026 02:20
… is mined

Move the rust-dashcore pin to e49cb97310, the collateral lock backport
with its follow-up: key-wallet locks a collateral a ProRegTx names only
once the ProRegTx is in a block (InBlock or InChainLockedBlock), and a
collateral the ProRegTx creates as its own output from any sighting. An
InstantSend context counts as unconfirmed. A lock also refreshes the
balance on the mempool path. Only the rust-dashcore `source` lines of
Cargo.lock change.

The known-masternode pass follows the same rule. It locked the
collateral of every ProRegTx in a wallet's transaction history,
including one recorded unconfirmed; it now locks a collateral a ProRegTx
names only from a record in a block, and one it creates from any record.
The transaction check that confirms an unconfirmed registration locks
its collateral, and the lock rides the wallet's next changeset as
before.

The docs that said every processed ProRegTx locks its collateral now
state the rule.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… the prepare check

`load_state` reads `core_locked_outpoints` with `prepare`, a read-only
SELECT like the unspent-UTXO read beside it, so it joins the allow-list
of `tc_p1_003_prepare_cached_in_writers`.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Moves the eight rust-dashcore pins from e49cb973 to 0c0dca37: the key-wallet
collateral lock backport plus one fix. A transaction builder now drops a
coin a funding account holds locked from the candidates just before coin
selection, so a stale copy of the masternode collateral passed to
`add_inputs` after `add_funding` is never spent. Before, only a copy seeded
before the funding call was dropped. As with a reserved coin, the locked one
is dropped silently.

Only the rust-dashcore `source` lines of Cargo.lock change.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

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.

2 participants