From a0cbc4ac9efdfff3993b52c2ef217dbebd71fa66 Mon Sep 17 00:00:00 2001 From: pasta Date: Tue, 25 Aug 2026 10:33:47 +0200 Subject: [PATCH 1/7] feat(consensus): compute version 2 asset unlock txids with the quorum 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(). --- src/evo/assetlocktx.cpp | 18 +++-- src/evo/assetlocktx.h | 11 +++- src/evo/specialtxman.cpp | 8 ++- src/primitives/transaction.cpp | 31 ++++++++- src/primitives/transaction.h | 32 +++++++++ src/test/evo_assetlocks_tests.cpp | 105 +++++++++++++++++++++++++++--- 6 files changed, 180 insertions(+), 25 deletions(-) diff --git a/src/evo/assetlocktx.cpp b/src/evo/assetlocktx.cpp index 9388e1c29b15..d8d9a27fbaaa 100644 --- a/src/evo/assetlocktx.cpp +++ b/src/evo/assetlocktx.cpp @@ -175,7 +175,8 @@ bool CAssetUnlockPayload::VerifySig(const llmq::CQuorumManager& qman, const CCha template static bool CheckAssetUnlockTxImpl(const BlockManager& blockman, VerifySig&& verify_sig, const CTransaction& tx, gsl::not_null pindexPrev, - const std::optional& indexes, TxValidationState& state) + const std::optional& indexes, bool is_v24_active, + TxValidationState& state) { // Some checks depends from blockchain status also, such as `known indexes` and `withdrawal limits` // They are omitted here and done by CCreditPool @@ -200,6 +201,9 @@ static bool CheckAssetUnlockTxImpl(const BlockManager& blockman, VerifySig&& ver if (assetUnlockTx.getVersion() == 0 || assetUnlockTx.getVersion() > CAssetUnlockPayload::CURRENT_VERSION) { return state.Invalid(TxValidationResult::TX_BAD_SPECIAL, "bad-assetunlocktx-version"); } + if (!is_v24_active && assetUnlockTx.getVersion() > CAssetUnlockPayload::INITIAL_VERSION) { + return state.Invalid(TxValidationResult::TX_BAD_SPECIAL, "bad-assetunlocktx-version-2"); + } if (indexes != std::nullopt && indexes->Contains(assetUnlockTx.getIndex())) { return state.Invalid(TxValidationResult::TX_CONSENSUS, "bad-assetunlock-duplicated-index"); @@ -214,30 +218,32 @@ static bool CheckAssetUnlockTxImpl(const BlockManager& blockman, VerifySig&& ver const CAssetUnlockPayload payload_copy{assetUnlockTx.getVersion(), assetUnlockTx.getIndex(), assetUnlockTx.getFee(), assetUnlockTx.getRequestedHeight(), assetUnlockTx.getQuorumHash(), CBLSSignature{}}; SetTxPayload(tx_copy, payload_copy); - uint256 msgHash = tx_copy.GetHash(); + // The signed message must commit to requestedHeight and quorumHash even though the version 2 + // txid excludes them, so hash the full serialization rather than using GetHash(). + uint256 msgHash = ::SerializeHash(tx_copy); return verify_sig(assetUnlockTx, msgHash, pindexPrev, state); } bool CheckAssetUnlockTx(const BlockManager& blockman, const llmq::CQuorumManager& qman, const CTransaction& tx, gsl::not_null pindexPrev, const std::optional& indexes, - TxValidationState& state) + bool is_v24_active, TxValidationState& state) { return CheckAssetUnlockTxImpl(blockman, [&](const CAssetUnlockPayload& payload, const uint256& msg_hash, const CBlockIndex* pindex, TxValidationState& tx_state) { return payload.VerifySig(qman, msg_hash, pindex, tx_state); - }, tx, pindexPrev, indexes, state); + }, tx, pindexPrev, indexes, is_v24_active, state); } bool CheckAssetUnlockTx(const BlockManager& blockman, const llmq::CQuorumManager& qman, const CChain& chain, const CTransaction& tx, gsl::not_null pindexPrev, - const std::optional& indexes, TxValidationState& state) + const std::optional& indexes, bool is_v24_active, TxValidationState& state) { AssertLockHeld(::cs_main); return CheckAssetUnlockTxImpl(blockman, [&](const CAssetUnlockPayload& payload, const uint256& msg_hash, const CBlockIndex* pindex, TxValidationState& tx_state) NO_THREAD_SAFETY_ANALYSIS { return payload.VerifySig(qman, chain, msg_hash, pindex, tx_state); - }, tx, pindexPrev, indexes, state); + }, tx, pindexPrev, indexes, is_v24_active, state); } bool GetAssetUnlockFee(const CTransaction& tx, CAmount& txfee, TxValidationState& state) diff --git a/src/evo/assetlocktx.h b/src/evo/assetlocktx.h index 3dea2c5e13a8..ae1165f402b0 100644 --- a/src/evo/assetlocktx.h +++ b/src/evo/assetlocktx.h @@ -75,8 +75,13 @@ class CAssetLockPayload class CAssetUnlockPayload { public: - static constexpr uint8_t CURRENT_VERSION = 1; + static constexpr uint8_t INITIAL_VERSION = 1; + /** Serialized identically to version 1, but the transaction hash excludes the quorum signing + * info (requestedHeight, quorumHash, quorumSig) so every re-signed instance of one withdrawal + * shares one txid; see IsAssetUnlockWithStableTxid(). Gated on DEPLOYMENT_V24. */ + static constexpr uint8_t CURRENT_VERSION = 2; static constexpr auto SPECIALTX_TYPE = TRANSACTION_ASSET_UNLOCK; + static_assert(CURRENT_VERSION >= ASSET_UNLOCK_STABLE_TXID_VERSION); static constexpr size_t MAXIMUM_WITHDRAWALS = 32; @@ -163,10 +168,10 @@ class CAssetUnlockPayload }; bool CheckAssetLockTx(const CTransaction& tx, TxValidationState& state, bool is_v24_active); -bool CheckAssetUnlockTx(const node::BlockManager& blockman, const llmq::CQuorumManager& qman, const CTransaction& tx, gsl::not_null pindexPrev, const std::optional& indexes, TxValidationState& state); +bool CheckAssetUnlockTx(const node::BlockManager& blockman, const llmq::CQuorumManager& qman, const CTransaction& tx, gsl::not_null pindexPrev, const std::optional& indexes, bool is_v24_active, TxValidationState& state); bool CheckAssetUnlockTx(const node::BlockManager& blockman, const llmq::CQuorumManager& qman, const CChain& chain, const CTransaction& tx, gsl::not_null pindexPrev, - const std::optional& indexes, TxValidationState& state) + const std::optional& indexes, bool is_v24_active, TxValidationState& state) EXCLUSIVE_LOCKS_REQUIRED(::cs_main); bool GetAssetUnlockFee(const CTransaction& tx, CAmount& txfee, TxValidationState& state); diff --git a/src/evo/specialtxman.cpp b/src/evo/specialtxman.cpp index d8b89b43a89b..53659fd1708b 100644 --- a/src/evo/specialtxman.cpp +++ b/src/evo/specialtxman.cpp @@ -241,9 +241,11 @@ static bool CheckSpecialTxInner(CDeterministicMNManager& dmnman, llmq::CQuorumSn CheckMNHFTx(chainman, qman, tx, pindexPrev, state); case TRANSACTION_ASSET_LOCK: return CheckAssetLockTx(tx, state, DeploymentActiveAfter(pindexPrev, chainman, Consensus::DEPLOYMENT_V24)); - case TRANSACTION_ASSET_UNLOCK: - return chain ? CheckAssetUnlockTx(chainman.m_blockman, qman, *chain, tx, pindexPrev, indexes, state) : - CheckAssetUnlockTx(chainman.m_blockman, qman, tx, pindexPrev, indexes, state); + case TRANSACTION_ASSET_UNLOCK: { + const bool is_v24_active{DeploymentActiveAfter(pindexPrev, chainman, Consensus::DEPLOYMENT_V24)}; + return chain ? CheckAssetUnlockTx(chainman.m_blockman, qman, *chain, tx, pindexPrev, indexes, is_v24_active, state) : + CheckAssetUnlockTx(chainman.m_blockman, qman, tx, pindexPrev, indexes, is_v24_active, state); + } } } catch (const std::exception& e) { LogPrintf("%s -- failed: %s\n", __func__, e.what()); diff --git a/src/primitives/transaction.cpp b/src/primitives/transaction.cpp index a56f8c3677f4..04ce81e0c841 100644 --- a/src/primitives/transaction.cpp +++ b/src/primitives/transaction.cpp @@ -9,6 +9,7 @@ #include #include