Skip to content

feat: version 2 asset unlocks with stable txids and InstantSend locks (DIP-0027 amendment, v24) - #7639

Draft
PastaPastaPasta wants to merge 10 commits into
dashpay:developfrom
PastaPastaPasta:asset-unlock-v2-stable-txid
Draft

feat: version 2 asset unlocks with stable txids and InstantSend locks (DIP-0027 amendment, v24)#7639
PastaPastaPasta wants to merge 10 commits into
dashpay:developfrom
PastaPastaPasta:asset-unlock-v2-stable-txid

Conversation

@PastaPastaPasta

@PastaPastaPasta PastaPastaPasta commented Aug 24, 2026

Copy link
Copy Markdown
Member

Issue being fixed or feature implemented

Users want Platform→Core withdrawals to be rapidly respendable with InstantSend finality. Today that is impossible: an Asset Unlock can expire before it is mined, Platform then re-signs the withdrawal, and because the re-signed transaction has a different txid, any transaction spending the unmined unlock's outputs is invalidated — so spends of unmined unlocks can never be islocked.

This PR implements version 2 Asset Unlock transactions (spec: dashpay/dips#189), activating with DEPLOYMENT_V24: the txid itself is computed with the quorum signing info (requestedHeight, quorumHash, quorumSig) zeroed — exactly and provably the only fields Platform changes when it re-signs an expired withdrawal. Every re-signed instance of one withdrawal is therefore the same transaction: children reference one stable txid forever and survive expiry and re-signing. This is segwit's txid/wtxid split applied to the quorum-sig fields — no aliasing in the mempool, UTXO set, or wallet layers; the spending model stays completely standard.

On top of that, the unlock itself is InstantSend-locked as soon as it can be mined in the next block, using its withdrawal index as a synthetic input. An islock attests "this will be mined and nothing in consensus prevents it"; for an unlock that holds as long as Platform keeps re-signing, which it is obligated to do (there is no refund path), and signing only minable-now instances makes any failure a double fault. Once locked, the withdrawal is like any other locked transaction: children are ordinary islocked spends, the wallet trusts its outputs, and Platform→Core transfers become rapidly respendable.

What was done?

Consensus — hashing rule (primitives/transaction, evo/assetlocktx)

  • v2 payloads are serialized byte-identically to v1; the version byte (gated on v24, bad-assetunlocktx-version-2, mirroring Asset Lock v2) changes hashing: the txid zeroes the trailing 132 payload bytes. The full-serialization hash remains available as GetInstanceHash() (cached member, equal to the txid for every other transaction).
  • The signed message hash is unchanged — it zeroes only quorumSig and still commits to requestedHeight/quorumHash — and is now computed explicitly from the full serialization (using GetHash() on the sig-zeroed copy would silently zero all three fields under the new rule). Signature validity rules (48-block window, active-quorum-set+1 recency) are identical to v1.

Consensus — coinbase commitment (evo/cbtx, validation, node/miner, blockencodings)

  • v2 txids exclude the sig bytes, so the block merkle root no longer commits to them. CbTx version 4 (required post-v24) adds merkleRootAssetUnlocks: the merkle root over the instance hashes of the block's v2 unlocks (null when none). Verified in CheckMerkleRoot as a mutation check (bad-cbtx-assetunlockmerkleroot, BLOCK_MUTATED), mirroring segwit's witness commitment: a middleman can flip sig bytes without breaking the merkle root, and treating that as invalidity would let it poison an honest block's hash.
  • Compact block short IDs are computed from instance hashes (BIP152v2's wtxid move): a mempool entry holding a different re-signed instance of a mined withdrawal is requested via getblocktxn instead of being spliced into the reconstructed block; FillBlock's existing IsBlockMutated check backstops short-ID collisions.

Mempool (validation, txmempool, node/transaction, node/miner)

  • A re-signed instance shares the entry's txid; ATMP routes it through a refresh path that fully validates it and, when requestedHeight is higher, swaps the CTransactionRef in place — descendants, ancestry, and fee accounting untouched because everything the txid covers is identical. Stale/duplicate instances are rejected (assetunlock-stale-instance). sendrawtransaction submits refreshes instead of short-circuiting on the known txid.
  • v2 unlocks are not expiry-evicted: an expired instance waits in the mempool for its replacement, so children never die with it; the miner instead skips instances that aren't currently minable. Since unlocks have no inputs, a new outputs-already-known check prevents an already-mined instance from re-entering (and, for v2, lingering).
  • The mempool tracks the pending withdrawal total (outputs + fee of every unlock it holds, the quantity the credit pool charges) and a withdrawal-index map. The credit pool limit is enforced only at block connect, so this is what lets InstantSend tell an over-limit unlock from a minable one. Exposed as getmempoolinfo.pendingassetunlocks. Mining any instance of a withdrawal evicts every other instance claiming its index.

InstantSend (instantsend/*, validation)

  • The v2 unlock itself is islocked, not just its children. Unlocks have no inputs, so the lock pins one synthetic outpoint: {DIP-27 request id = SHA256d("plwdtx" ‖ index), 0} (instantsend::GetLockInputs). Every instance of one withdrawal, whatever its version or txid, maps to that outpoint, so a lock binds the index to one txid, any other claimant conflicts through the ordinary outpoint conflict path, and a re-sign (same txid) leaves the lock intact. Wire format unchanged.
  • Masternodes sign the lock only when the unlock is minable in the next block (CheckCanLockAssetUnlock): stable-txid instance, passes the full special-tx check at the tip including its quorum signature, no other instance of its index in the mempool (a withdrawal signed as v1 pre-fork can be re-signed as v2 post-fork under a different txid), and the mempool's pending withdrawal total fits the credit pool's current limit. Platform pools withdrawals under the same limit, so a pending total above it indicates a fault and nothing is signed until the window clears. Both the height window and the limit move with the tip, so every tracked unmined unlock is re-evaluated on each connected block; a refresh re-triggers an attempt too.
  • Consequences that fall out for free: children are ordinary islocked spends (the rev-3 CheckCanLock exception is gone), the wallet trusts a locked withdrawal's outputs via IsTxLockedByInstantSend, and the mempool's time-based expiry already spares locked transactions.
  • Every vin.empty() early-out in InstantSend (including the IS-DB block hooks that mark locks mined and the block-connect conflict filter) goes through HasLockInputs. A peer islock on an unlock whose inputs are anything but the synthetic outpoint is dropped. Mined unlocks are tracked but not locked retroactively, since ChainLocks never wait for them.
  • getassetunlockstatuses reports instantlock for mempooled indexes.

P2P relay (net_processing, protocol, version)

  • txid-based announcement can never propagate a refresh (known-txid dedup; rejects-filter poisoning). New MSG_ASSET_UNLOCK inventory type (protocol 70242) announces v2 unlocks by instance hash; getdata is answered with a plain tx message; requests and the rejects filter are tracked per instance. Older peers get a MSG_TX announcement of the current instance and never see refreshes.

RPC & signing tooling (core_write, rpc/quorums, llmq/signing*)

  • instanceHash in v2 unlock JSON. platformsign allows re-signing a request id with a different message hash (truncating the prior recovered sig so the new session isn't short-circuited), and ProcessRecoveredSig lets a fresher recovered sig supersede the stored one for the platform quorum type — Platform legitimately re-signs one request id with changing message hashes. Production Platform signing (Tenderdash vote extensions) is unaffected; this aligns Core's local signing path used by tests/tooling.

Tests

  • Unit: txid invariance across the signing fields (and only those), CMutableTransaction agreement, msgHash semantics, v1 hashing unchanged, DIP-0027 worked-example vectors, CbTx unlock-root calculation.
  • Unit: lock inputs of an unlock (synthetic outpoint, same for every version/instance of an index, distinct per index; ordinary txs / commitments / coinbase unchanged); mempool pending amount and index map across add, refresh, cross-version duplicate, index-conflict eviction and removal.
  • Functional (feature_asset_locks.py): pre-fork v2 rejection; spend of an unmined v2 unlock by its stable txid; refresh in place (same txid, child untouched, instanceHash rotates); MSG_ASSET_UNLOCK inv observed for both the initial instance and the refresh; stale-instance rejection; survival of the expired instance + child; window clearing; fresh re-sign mined together with the child; CbTx v4 commitment asserted against the mined instance hash. With InstantSend enabled: the unlock is not locked while the pending total exceeds the limit (an ordinary tx is), the wallet does not trust the child's output, the re-signed minable instance within the limit is locked with the withdrawal index as its single input, the child is then locked through the ordinary path and trusted by the wallet, and a second withdrawal refused on the limit is locked by the per-block retry once the window clears and it is refreshed.

How Has This Been Tested?

  • feature_asset_locks.py passes locally (macOS arm64) including the extended test_asset_unlock_v2 scenario; also feature_llmq_is_retroactive.py, feature_llmq_is_cl_conflicts.py, feature_llmq_chainlocks.py, feature_llmq_singlenode.py, feature_notifications.py, rpc_netinfo.py, p2p_dstx.py, feature_protx_version.py, mempool_unbroadcast.py, interface_rest.py, wallet_basic.py.
  • Full test_dash unit suite passes.
  • Lints: circular dependencies (two new expected entries registered), whitespace, python, assertions.
  • The DIP worked-example vectors produced by dip-0027/dip-0027-txid-calc.py match Core's hashing byte-for-byte (pinned in a unit test).

Breaking Changes

  • Consensus (v24 EHF, inactive until params are set): v2 Asset Unlock payloads become acceptable and CbTx v4 becomes required once v24 activates; before activation both are rejected. This must be code-complete before the v24 EHF parameters (bit 12, currently NEVER_ACTIVE) are finalized.
  • Hashing: for v2 unlocks (which cannot exist pre-fork), txid ≠ H(full serialization). Light clients verifying merkle proofs for these transactions and explorer libraries computing txids from raw bytes need the one scoped rule; SPV output tracking and spending are otherwise completely standard.
  • P2P: protocol bumped to 70242 for the MSG_ASSET_UNLOCK inventory type.

Known follow-ups (deliberately out of scope):

  • Platform-side emitter PR (payload version byte + deterministic v24 gate on core_chain_locked_height); Platform's Tenderdash signing already produces the unchanged message hash.
  • Restart gap: LoadMempool re-runs acceptance, so an expired v2 instance (and its children) is dropped on restart until the refresh arrives; the islock itself is persisted in the IS DB and wallet rebroadcast heals it. Accepting an expired instance whose txid is islocked on reload is a possible refinement.
  • Ecosystem: anything computing txids from raw bytes (rust-dashcore Transaction::txid(), dash-spv, DashSync, dashj, explorers) needs the scoped v2 rule before activation.
  • p2p-level regression tests for the legacy-peer (<70242) MSG_TX announcement path and for the rejects-filter poisoning scenario a rejected instance is announced over p2p, then a fresh instance must still propagate. The current functional test exercises the mempool refresh and MSG_ASSET_UNLOCK inv end-to-end but drives the stale-instance rejection via sendrawtransaction.
  • The wallet keeps whatever instance it first saw (AddToWallet is a no-op on a known txid), so gettransaction may show a stale instance's requestedHeight/quorumSig; ZMQ/index consumers do observe each refresh. No fund-safety impact (outputs are identical across instances).
  • TryAssetUnlockRefresh is wired into single-tx acceptance only; a refresh submitted via package acceptance would be rejected as a duplicate txid (safe, and not a path Platform/RPC uses).

Checklist:

🤖 Generated with Claude Code

@knst

knst commented Aug 24, 2026

Copy link
Copy Markdown
Collaborator

CI failed because:

txmempool.cpp:690:13: error: reading variable 'mapAssetUnlockWithdrawalIds' requires holding mutex 'cs' [-Werror,-Wthread-safety-analysis]
  690 |             mapAssetUnlockWithdrawalIds.insert_or_assign(*withdrawal_id, tx_hash);
      |             ^
txmempool.cpp:691:13: error: calling function 'linkAssetUnlockChildren' requires holding mutex 'cs' exclusively [-Werror,-Wthread-safety-analysis]
  691 |             linkAssetUnlockChildren(newit, *withdrawal_id);
      |             ^
txmempool.cpp:792:27: error: reading variable 'mapAssetUnlockWithdrawalIds' requires holding mutex 'cs' [-Werror,-Wthread-safety-analysis]
  792 |             if (auto it = mapAssetUnlockWithdrawalIds.find(*withdrawal_id);
      |                           ^
txmempool.cpp:793:23: error: reading variable 'mapAssetUnlockWithdrawalIds' requires holding mutex 'cs' [-Werror,-Wthread-safety-analysis]
  793 |                 it != mapAssetUnlockWithdrawalIds.end() && it->second == tx_hash) {
      |                       ^
txmempool.cpp:794:17: error: reading variable 'mapAssetUnlockWithdrawalIds' requires holding mutex 'cs' [-Werror,-Wthread-safety-analysis]
  794 |                 mapAssetUnlockWithdrawalIds.erase(it);
      |                 ^
5 errors generated.

… signing info zeroed

Version 2 asset unlock payloads are serialized identically to version 1; the version byte, gated on DEPLOYMENT_V24, changes how the transaction is hashed: the txid excludes the trailing requestedHeight, quorumHash and quorumSig payload fields - exactly the fields Platform changes when it re-signs an expired withdrawal - so every re-signed instance of one withdrawal is the same transaction. Spends of its outputs reference that stable txid and stay valid across re-signs with no aliasing in the mempool, UTXO or wallet layers.

The full-serialization hash remains available as GetInstanceHash() to distinguish the instances of one withdrawal for relay and for the coinbase commitment introduced in the next commit. The signed message is unchanged: it zeroes only quorumSig and must be computed from the full serialization, never via GetHash().
@PastaPastaPasta
PastaPastaPasta force-pushed the asset-unlock-v2-stable-txid branch from 403b6f9 to da43856 Compare August 25, 2026 10:21
@PastaPastaPasta PastaPastaPasta changed the title feat: version 2 asset unlocks with a stable withdrawal id (DIP-0027 amendment, v24) feat: version 2 asset unlocks with stable txids (DIP-0027 amendment, v24) Aug 25, 2026
…se transaction

Version 2 asset unlock txids exclude the quorum signing info, so the block merkle root no longer commits to those bytes. Coinbase transaction version 4, required once v24 activates, adds merkleRootAssetUnlocks: a merkle root over the instance hashes of the block's version 2 asset unlocks in block order, null when there are none. The root is verified in CheckMerkleRoot as a mutation check, mirroring segwit's witness commitment: a middleman can alter signing-info bytes without breaking the merkle root, and treating the mismatch as block invalidity would let it poison an honest block's hash.

Compact block short IDs are computed from instance hashes (equal to the txid for every other transaction), so a mempool entry holding a different re-signed instance of a withdrawal is requested via getblocktxn instead of being spliced into the reconstructed block; the FillBlock mutation check backstops any remaining short ID collision.
… EvoDB transaction

CCreditPoolManager::AddToCache persists a snapshot every 576th height via evoDb.WriteDerived. When a pool is constructed on a cold cache from a transaction-less context - mempool acceptance or block template creation right after startup - that write lands in an EvoDB transaction nobody commits and trips the clean-transaction assertion (evodb.cpp:99) at the next root commit, aborting the node at flush/shutdown. Skip the optional snapshot outside a block-scoped transaction; a skipped snapshot is simply reconstructed from an earlier one.
…d version 2 asset unlocks

A re-signed instance of a pending withdrawal shares the mempool entry's txid; AcceptToMemoryPool routes it through a refresh path that fully validates the fresh instance and, when its requestedHeight is higher, swaps it into the existing entry in place - descendants, ancestry and fee accounting are untouched because everything the txid covers is identical. Stale or duplicate instances are rejected (assetunlock-stale-instance). BroadcastTransaction submits such refreshes instead of short-circuiting on the known txid.

Version 2 unlocks are not expiry-evicted: an expired instance stays in the mempool awaiting its replacement so descendants never die with it, and the miner instead skips instances that are not currently minable.
Re-signed instances of one withdrawal share a txid, so txid-based announcement can never propagate a refresh: peers holding the stale instance see a known txid and don't fetch, and a rejected stale instance in the rejects filter would poison the fresh one. A new MSG_ASSET_UNLOCK inventory type (protocol 70242) announces these transactions by instance hash; getdata for it is answered with a plain tx message, requests and rejects are tracked per instance, and AlreadyHave consults the mempool's instance map. Peers on older protocol versions receive a plain MSG_TX announcement of the current instance and never see refreshes.
…ning with withdrawal re-signs

Transaction JSON for version 2 asset unlocks gains instanceHash, the full-serialization hash distinguishing the re-signed instances that share one txid.

platformsign allows signing a request id again with a different message hash, truncating the previously recovered signature so the new session is not short-circuited, and ProcessRecoveredSig lets a fresher recovered signature supersede the stored one for the platform quorum type. Platform legitimately re-signs one withdrawal (one request id) with changing message hashes - the message hash commits to the signing height and quorum - so the one-recovered-sig-per-id constraint must not pin the first signature forever. This also removes a narrow pre-existing race for EHF signals.
Pre-fork rejection; spending an unmined version 2 unlock by its stable txid; an in-place refresh by a fresher re-signed instance with the child untouched and the instanceHash rotating; MSG_ASSET_UNLOCK announcements observed for both the initial instance and the refresh; stale-instance rejection; survival of the expired instance and its child; flushing leftover withdrawals and clearing the window; and mining a fresh re-sign together with the child, asserting the CbTx version 4 commitment against the mined instance hash.

sync_mempools() compares txid sets and is satisfied before a refresh (same txid) has propagated, so a sync_unlock_instance helper waits for every node to hold the exact instance. The test framework negotiates protocol 70242 to receive MSG_ASSET_UNLOCK invs.
The credit pool's withdrawal limit is enforced only when a block is connected, so an unlock that exceeds the day's remaining limit is indistinguishable in the mempool from one miners will include. The mempool now keeps the sum of the withdrawal amounts (outputs plus fee, the quantity the credit pool charges) of every asset unlock it holds, and a withdrawal-index map over them. When the pending total does not exceed the credit pool's current limit every pending withdrawal fits the next block; InstantSend uses this in the next commit to decide whether an unlock may be locked. The total is exposed as pendingassetunlocks in getmempoolinfo.

Instances of one withdrawal signed under different versions have different txids but claim the same index, so mining any one of them evicts the others (removeAssetUnlockConflicts), including version 2 instances that are never expiry-evicted. Sanity checks in check() recompute both the total and the index map.
…ndex

A version 2 asset unlock is InstantSend-locked while unmined so that Platform-to-Core transfers become rapidly respendable through the ordinary machinery: spends of a locked withdrawal are plain InstantSend transactions, the wallet trusts its outputs via IsTxLockedByInstantSend, and the mempool's time-based expiry already spares locked transactions. The rev 3 CheckCanLock exception that let children of an unlocked unlock be locked is removed.

Unlocks have no inputs, so the lock pins one synthetic outpoint: {DIP-27 signing request id of the withdrawal index, 0} (instantsend::GetLockInputs). Every instance of one withdrawal, whatever its version or txid, maps to that outpoint, so a lock binds the index to one txid and any other claimant conflicts through the existing outpoint conflict handling; a re-signed instance shares the txid and leaves the lock intact. The islock wire format is unchanged. A peer's lock whose inputs are not the transaction's lock inputs is dropped once the transaction is known, since for an unlock it could otherwise poison conflict tracking of unrelated coins. Every vin.empty() early-out in InstantSend, including the IS-DB block hooks that mark locks mined and the block-connect conflict filter in validation, now goes through HasLockInputs so unlocks are tracked like other lockable transactions; mined unlocks are not locked retroactively since ChainLocks never wait for them.

Masternodes sign the lock only when the unlock is minable in the next block (CheckCanLockAssetUnlock): a stable-txid instance passing the full special-transaction check at the tip including its quorum signature, with no other instance of its withdrawal index in the mempool (a withdrawal signed as version 1 before v24 can be re-signed as version 2 after it under a different txid), and with the mempool's pending withdrawal total within the credit pool's current limit. Platform pools withdrawals under that same limit, so a pending total above it indicates a fault and nothing is signed until the window clears. Both the height window and the limit move with the tip, so every tracked unmined unlock is queued for another attempt on each connected block (RetryUnminedAssetUnlocks); a refresh re-triggers an attempt through TransactionAddedToMempool as well.

The DIP-27 request id prefix and the payload index accessor move to primitives/transaction.h so evo/assetlocktx and instantsend/lock share one definition.
feature_asset_locks.py now enables InstantSend for the version 2 phase and checks that an unlock is not locked while pending withdrawals exceed the limit (getmempoolinfo pendingassetunlocks, getassetunlockstatuses instantlock), that the wallet does not trust a child's output until the parent is locked, that a re-signed minable instance within the limit gets locked with the withdrawal index as its single input, and that the child is then locked through the ordinary path and trusted by the wallet. Adds the release note for version 2 asset unlocks.
@PastaPastaPasta
PastaPastaPasta force-pushed the asset-unlock-v2-stable-txid branch from da43856 to 1f45727 Compare September 7, 2026 21:34
@PastaPastaPasta PastaPastaPasta changed the title feat: version 2 asset unlocks with stable txids (DIP-0027 amendment, v24) feat: version 2 asset unlocks with stable txids and InstantSend locks (DIP-0027 amendment, v24) Sep 7, 2026
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