feat: version 2 asset unlocks with stable txids (DIP-0027 amendment, v24) - #7639
Draft
PastaPastaPasta wants to merge 7 commits into
Draft
feat: version 2 asset unlocks with stable txids (DIP-0027 amendment, v24)#7639PastaPastaPasta wants to merge 7 commits into
PastaPastaPasta wants to merge 7 commits into
Conversation
Collaborator
|
CI failed because: |
… 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().
…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.
…f unmined 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. InstantSend can lock spends of unmined version 2 unlocks: once quorum-signed the withdrawal is irreversible on Platform and will be re-signed until mined under the same txid.
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.
PastaPastaPasta
force-pushed
the
asset-unlock-v2-stable-txid
branch
from
August 25, 2026 10:21
403b6f9 to
da43856
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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, survive expiry and re-signing, and can be islocked as soon as one validly signed instance is observed (at which point the withdrawal is irreversible on Platform and will be re-signed until mined). 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.What was done?
Consensus — hashing rule (
primitives/transaction,evo/assetlocktx)bad-assetunlocktx-version-2, mirroring Asset Lock v2) changes hashing: the txid zeroes the trailing 132 payload bytes. The full-serialization hash remains available asGetInstanceHash()(cached member, equal to the txid for every other transaction).quorumSigand still commits torequestedHeight/quorumHash— and is now computed explicitly from the full serialization (usingGetHash()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)merkleRootAssetUnlocks: the merkle root over the instance hashes of the block's v2 unlocks (null when none). Verified inCheckMerkleRootas 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.getblocktxninstead of being spliced into the reconstructed block;FillBlock's existingIsBlockMutatedcheck backstops short-ID collisions.Mempool (
validation,txmempool,node/transaction,node/miner)requestedHeightis higher, swaps theCTransactionRefin place — descendants, ancestry, and fee accounting untouched because everything the txid covers is identical. Stale/duplicate instances are rejected (assetunlock-stale-instance).sendrawtransactionsubmits refreshes instead of short-circuiting on the known txid.CheckCanLockallows locking spends of unmined v2 unlocks.P2P relay (
net_processing,protocol,version)MSG_ASSET_UNLOCKinventory type (protocol 70242) announces v2 unlocks by instance hash; getdata is answered with a plaintxmessage; requests and the rejects filter are tracked per instance. Older peers get aMSG_TXannouncement of the current instance and never see refreshes.RPC & signing tooling (
core_write,rpc/quorums,llmq/signing*)instanceHashin v2 unlock JSON.platformsignallows re-signing a request id with a different message hash (truncating the prior recovered sig so the new session isn't short-circuited), andProcessRecoveredSiglets 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
CMutableTransactionagreement, msgHash semantics, v1 hashing unchanged, DIP-0027 worked-example vectors, CbTx unlock-root calculation.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,instanceHashrotates);MSG_ASSET_UNLOCKinv 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.How Has This Been Tested?
feature_asset_locks.pypasses locally (macOS arm64) including the newtest_asset_unlock_v2scenario.evo_assetlocks_tests,evo_cbtx_tests,mempool_tests,miner_tests,validation_block_tests,blockencodings_testspass.dip-0027/dip-0027-txid-calc.pymatch Core's hashing byte-for-byte (pinned in a unit test).Breaking Changes
NEVER_ACTIVE) are finalized.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.MSG_ASSET_UNLOCKinventory type.Known follow-ups (deliberately out of scope):
core_chain_locked_height); Platform's Tenderdash signing already produces the unchanged message hash.MSG_TXannouncement 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 andMSG_ASSET_UNLOCKinv end-to-end but drives the stale-instance rejection viasendrawtransaction.AddToWalletis a no-op on a known txid), sogettransactionmay show a stale instance'srequestedHeight/quorumSig; ZMQ/index consumers do observe each refresh. No fund-safety impact (outputs are identical across instances).TryAssetUnlockRefreshis 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