Skip to content

feat: asset lock tx v2 with basic RPC support + tests#7294

Open
knst wants to merge 8 commits into
dashpay:developfrom
knst:feat-asset-locks-v2
Open

feat: asset lock tx v2 with basic RPC support + tests#7294
knst wants to merge 8 commits into
dashpay:developfrom
knst:feat-asset-locks-v2

Conversation

@knst

@knst knst commented Apr 27, 2026

Copy link
Copy Markdown
Collaborator

Issue being fixed or feature implemented

Implements asset lock txes v2, see dashpay/dips#182

What was done?

  • Added consensus changes for v2, activated by fork v24
  • implemented asset lock v2
  • added address field for ToJson helper for asset locks
  • added support of dash1.... tdash1... addresses as a recipient for spending RPCs
  • regressions tests for assetlocks v2
  • functional tets for assetlocks v2

Also prior works: #7285, #7284

How Has This Been Tested?

See updates for unit & functional tests

Breaking Changes

Changes consensus rules for v24 activation to enable assetlock txes v2.

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 made corresponding changes to the documentation
  • I have assigned this pull request to a milestone (for repository code-owners and collaborators only)

@knst knst added this to the 24 milestone Apr 27, 2026
@github-actions

github-actions Bot commented Apr 27, 2026

Copy link
Copy Markdown

✅ No Merge Conflicts Detected

This PR currently has no conflicts with other open PRs.

@github-actions

Copy link
Copy Markdown

This pull request has conflicts, please rebase.

@knst knst force-pushed the feat-asset-locks-v2 branch from ff9b2f9 to 303a71c Compare May 19, 2026 13:46
@knst knst marked this pull request as ready for review May 19, 2026 13:47

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 303a71cb69

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment thread src/wallet/spend.cpp
}
}

opt_assetLockPayload.emplace(creditOutputs);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Gate platform asset-lock sends on v24 activation

When a platform address is passed to sendtoaddress/sendmany before v24 is active, this default construction creates a version-2 CAssetLockPayload. The consensus path rejects v2 asset locks until v24 (bad-assetlocktx-version-2), but SendMoney still commits the signed transaction to the wallet before only logging the mempool rejection, so users get a txid and their inputs are marked spent by a transaction that cannot relay until the fork activates. Please reject platform recipients or otherwise guard this path until v24 is active.

Useful? React with 👍 / 👎.

Comment thread src/wallet/spend.cpp
Comment on lines +806 to +807
vecSend.insert(vecSend.begin(),
{CScript() << OP_RETURN << ParseHex(""), platform_total, /*fSubtractFeeFromAmount=*/false});

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve subtract-fee handling for platform recipients

When the caller sets subtractfeefromamount for a platform recipient, ParseRecipients records that flag, but this replacement removes the original recipient and hard-codes the OP_RETURN recipient to not subtract fees. As a result the fee deduction loop never sees the selected platform output, so exact-spend cases can fail with insufficient funds and non-exact sends charge the fee to change/extra inputs instead of reducing the locked amount requested by the RPC option.

Useful? React with 👍 / 👎.

@coderabbitai

coderabbitai Bot commented May 19, 2026

Copy link
Copy Markdown

Review Change Stack

Walkthrough

Adds asset-lock payload versioning (INITIAL_VERSION=1, CURRENT_VERSION=2) and a v24 activation gate that rejects v2 payloads before activation. V2 relaxes credit-output rules to accept P2PKH or P2SH; v1 remains P2PKH-only. Extends asset-lock JSON to include platform addresses for v2, updates validateaddress to recognize platform addresses, adds wallet parsing/recipient flag for platform transfers, builds asset-lock transactions (nVersion=3, TRANSACTION_ASSET_LOCK) from platform recipients, and adds unit and functional tests for pre/post-v24 and v1/v2 behaviors.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Suggested reviewers

  • UdjinM6
  • PastaPastaPasta
🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 10.53% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title concisely and clearly describes the main feature being added: asset lock transaction v2 with RPC support and tests, which aligns with the primary objective of implementing DIP-182.
Description check ✅ Passed The description is clearly related to the changeset, detailing the issue being fixed, what was implemented, testing approach, and breaking changes regarding asset lock v2 functionality.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Warning

There were issues while running some tools. Please review the errors and either fix the tool's configuration or disable the tool if it's a critical failure.

🔧 Infer (1.2.0)
src/evo/assetlocktx.cpp

src/evo/assetlocktx.cpp:5:10: fatal error: 'evo/assetlocktx.h' file not found
5 | #include <evo/assetlocktx.h>
| ^~~~~~~~~~~~~~~~~~~
1 error generated.
src/evo/assetlocktx.cpp:88:5-12: ERROR translating statement 'ReturnStmt'
Aborting translation of method 'CheckAssetLockTx' in file 'src/evo/assetlocktx.cpp': "Assert_failure src/clang/cAst_utils.ml:249:53"
Uncaught Internal Error: "Assert_failure src/clang/cAst_utils.ml:249:53"
Error backtrace:
Raised at ClangFrontend__CAst_utils.get_decl_from_typ_ptr in file "src/clang/cAst_utils.ml", line 249, characters 53-65
Called from ClangFrontend__CTrans.CTrans_funct.get_destructor_decl_ref in file "src/clang/cTrans.ml", line 658, characters 12-59
Called from ClangFrontend__CTrans.CTrans_funct.destructor_calls.(fun) in file "src/clang/cTrans.ml", line 2048, characters 12-69
Called from Base__List.rev_filter_map.loop in file "src/list.ml", line 944, characters 13-17
Called from Base__List.filter_map in file "src/list.ml" (inli

... [truncated 2200 characters] ...

cters 10-1023
Called from ClangFrontend__CTrans.CTrans_funct.instruction in file "src/clang/cTrans.ml" (inlined), line 4765, characters 38-71
Called from ClangFrontend__CTrans.CTrans_funct.exec_with_node_creation in file "src/clang/cTrans.ml" (inlined), line 104, characters 20-38
Called from ClangFrontend__CTrans.CTrans_funct.get_clang_stmt_trans in file "src/clang/cTrans.ml" (inlined), line 5395, characters 4-69
Called from ClangFrontend__CTrans.CTrans_funct.get_custom_stmt_trans in file "src/clang/cTrans.ml", line 5401, characters 8-55
Called from ClangFrontend__CTrans.CTrans_funct.exec_trans_instrs.exec_trans_instrs_rev in file "src/clang/cTrans.ml" (inlined), line 5365, characters 28-54
Called from ClangFrontend__CTrans.CTrans_funct.exec_trans_instrs in file "src/clang/cTrans.ml" (inli

src/evo/core_write.cpp

src/evo/core_write.cpp:5:10: fatal error: 'evo/assetlocktx.h' file not found
5 | #include <evo/assetlocktx.h>
| ^~~~~~~~~~~~~~~~~~~
1 error generated.
Error: the following clang command did not run successfully:
/opt/infer-linux-x86_64-v1.2.0/lib/infer/facebook-clang-plugins/clang/install/bin/clang-18
@/tmp/coderabbit-infer/834d56a6c28454c04352a9d82d1fe1936fec5ea5-3a87bb1f7ee2b853/tmp/clang_command_.tmp.81ef14.txt
++Contents of '/tmp/coderabbit-infer/834d56a6c28454c04352a9d82d1fe1936fec5ea5-3a87bb1f7ee2b853/tmp/clang_command_.tmp.81ef14.txt':
"-cc1" "-load"
"/opt/infer-linux-x86_64-v1.2.0/lib/infer/infer/bin/../../facebook-clang-plugins/libtooling/build/FacebookClangPlugin.dylib"
"-add-plugin" "BiniouASTExporter" "-plugin-arg-BiniouASTExporter" "-"
"-plugin-arg-BiniouASTExporter" "PREPEND_CURRENT_DIR=1"
"-plugin-arg-BiniouASTExporter" "MAX_STRING_SIZE=65535" "-cc1" "-triple"
"x86_64-unknown-linux-gnu" "-emit-obj" "-mrelax-all" "-disa

... [truncated 1071 characters] ...

lang/install/lib/clang/18/include"
"-internal-isystem" "/usr/local/include" "-internal-isystem"
"/usr/lib/gcc/x86_64-linux-gnu/12/../../../../x86_64-linux-gnu/include"
"-internal-externc-isystem" "/usr/include/x86_64-linux-gnu"
"-internal-externc-isystem" "/include" "-internal-externc-isystem"
"/usr/include" "-Wno-ignored-optimization-argument" "-Wno-everything"
"-fdeprecated-macro" "-ferror-limit" "19" "-fgnuc-version=4.2.1"
"-fskip-odr-check-in-gmf" "-fcxx-exceptions" "-fexceptions"
"-D__GCC_HAVE_DWARF2_CFI_ASM=1" "-o"
"/tmp/coderabbit-infer/3a87bb1f7ee2b853/file.o" "-x" "c++"
"src/evo/core_write.cpp" "-O0" "-fno-builtin" "-include"
"/opt/infer-linux-x86_64-v1.2.0/lib/infer/infer/bin/../lib/clang_wrappers/global_defines.h"
"-Wno-everything"

src/evo/specialtxman.cpp

src/evo/specialtxman.cpp:5:10: fatal error: 'evo/specialtxman.h' file not found
5 | #include <evo/specialtxman.h>
| ^~~~~~~~~~~~~~~~~~~~
1 error generated.
src/evo/specialtxman.cpp:98:5-12: ERROR translating statement 'ReturnStmt'
Aborting translation of method 'CheckCbTxBestChainlock' in file 'src/evo/specialtxman.cpp': "Assert_failure src/clang/cAst_utils.ml:249:53"
Uncaught Internal Error: "Assert_failure src/clang/cAst_utils.ml:249:53"
Error backtrace:
Raised at ClangFrontend__CAst_utils.get_decl_from_typ_ptr in file "src/clang/cAst_utils.ml", line 249, characters 53-65
Called from ClangFrontend__CTrans.CTrans_funct.get_destructor_decl_ref in file "src/clang/cTrans.ml", line 658, characters 12-59
Called from ClangFrontend__CTrans.CTrans_funct.destructor_calls.(fun) in file "src/clang/cTrans.ml", line 2048, characters 12-69
Called from Base__List.rev_filter_map.loop in file "src/list.ml", line 944, characters 13-17
Called from Base__List.filter_map in file "src/li

... [truncated 2200 characters] ...

4784, characters 10-1023
Called from ClangFrontend__CTrans.CTrans_funct.instruction in file "src/clang/cTrans.ml" (inlined), line 4765, characters 38-71
Called from ClangFrontend__CTrans.CTrans_funct.exec_with_node_creation in file "src/clang/cTrans.ml" (inlined), line 104, characters 20-38
Called from ClangFrontend__CTrans.CTrans_funct.get_clang_stmt_trans in file "src/clang/cTrans.ml" (inlined), line 5395, characters 4-69
Called from ClangFrontend__CTrans.CTrans_funct.get_custom_stmt_trans in file "src/clang/cTrans.ml", line 5401, characters 8-55
Called from ClangFrontend__CTrans.CTrans_funct.exec_trans_instrs.exec_trans_instrs_rev in file "src/clang/cTrans.ml" (inlined), line 5365, characters 28-54
Called from ClangFrontend__CTrans.CTrans_funct.exec_trans_instrs in file "src/clang/cTra

  • 4 others

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 and usage tips.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 6

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/evo/assetlocktx.cpp (1)

56-80: 🛠️ Refactor suggestion | 🟠 Major | ⚡ Quick win

Pin consensus checks to explicit payload versions.

These branches are keyed off CURRENT_VERSION and >= 2, so a future version bump can silently change historical validation behavior. In this path, please dispatch on exact versions instead of relative comparisons, so v1/v2 rules stay fixed unless someone intentionally edits them.

Suggested change
-    if (opt_assetLockTx->getVersion() == 0 || opt_assetLockTx->getVersion() > CAssetLockPayload::CURRENT_VERSION) {
+    const uint8_t version = opt_assetLockTx->getVersion();
+    if (version == 0 || version > CAssetLockPayload::CURRENT_VERSION) {
         return state.Invalid(TxValidationResult::TX_BAD_SPECIAL, "bad-assetlocktx-version");
     }
-    if (!is_v24_active && opt_assetLockTx->getVersion() == CAssetLockPayload::CURRENT_VERSION) {
-        return state.Invalid(TxValidationResult::TX_BAD_SPECIAL, "bad-assetlocktx-version-2");
+    switch (version) {
+    case CAssetLockPayload::INITIAL_VERSION:
+        break;
+    case 2:
+        if (!is_v24_active) {
+            return state.Invalid(TxValidationResult::TX_BAD_SPECIAL, "bad-assetlocktx-version-2");
+        }
+        break;
+    default:
+        return state.Invalid(TxValidationResult::TX_BAD_SPECIAL, "bad-assetlocktx-version");
     }
@@
-        if (opt_assetLockTx->getVersion() >= 2) {
-            if (!out.scriptPubKey.IsPayToPublicKeyHash() && !out.scriptPubKey.IsPayToScriptHash()) {
-                return state.Invalid(TxValidationResult::TX_BAD_SPECIAL, "bad-assetlocktx-script-pubkey");
-            }
-        } else {
+        switch (version) {
+        case CAssetLockPayload::INITIAL_VERSION:
             if (!out.scriptPubKey.IsPayToPublicKeyHash()) {
                 return state.Invalid(TxValidationResult::TX_BAD_SPECIAL, "bad-assetlocktx-pubKeyHash");
             }
+            break;
+        case 2:
+            if (!out.scriptPubKey.IsPayToPublicKeyHash() && !out.scriptPubKey.IsPayToScriptHash()) {
+                return state.Invalid(TxValidationResult::TX_BAD_SPECIAL, "bad-assetlocktx-script-pubkey");
+            }
+            break;
         }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/evo/assetlocktx.cpp` around lines 56 - 80, The code currently gates
behavior using CAssetLockPayload::CURRENT_VERSION and a relative check
(opt_assetLockTx->getVersion() >= 2), which will change historical rules when
CURRENT_VERSION increments; update the validation in the asset lock payload path
to dispatch on exact payload versions instead of relative comparisons: read the
payload version once (e.g., auto ver = opt_assetLockTx->getVersion()) and
replace the CURRENT_VERSION equality/inequality and the ">= 2" branch with
explicit version checks (e.g., ver == 1, ver == 2, etc.), ensuring
getCreditOutputs and the scriptPubKey checks (IsPayToPublicKeyHash /
IsPayToScriptHash) are applied only for the exact intended versions so past
rules remain fixed until intentionally modified.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/evo/assetlocktx.h`:
- Line 158: The declaration of CheckAssetLockTx currently has a defaulted
parameter is_v24_active = false which allows callers to omit the activation
flag; remove the default so the signature becomes bool CheckAssetLockTx(const
CTransaction& tx, TxValidationState& state, bool is_v24_active); then update all
call sites that invoke CheckAssetLockTx(...) to pass the explicit
deployment/activation boolean (thread the v24 activation state from callers that
know consensus state), ensuring compilation fails if a caller neglects to
propagate the flag.

In `@src/evo/core_write.cpp`:
- Around line 103-105: The RPC JSON currently emits the new DIP-18 address as
creditOutputs[].address; change emission so the address is nested under
creditOutputs[].scriptPubKey.address for v2 asset-locks. Update the
RPCResult/JSON building in src/evo/core_write.cpp where the RPCResult::Type::STR
"address" entry is added (both occurrences around the two blocks noted) to
instead add "scriptPubKey" as an object containing the existing script fields
plus the "address" field when version >= 2, and ensure the produced JSON
structure uses creditOutputs[].scriptPubKey.address so consumers see the
documented path.

In `@src/wallet/rpc/spend.cpp`:
- Around line 27-57: The duplicate-recipient check only uses the CTxDestination
set `destinations`, so platform-decoded recipients never get inserted and
duplicates are allowed; after decoding a platform address in
`DecodePlatformDestination` and when handling `PlatformP2PKHDestination` or
`PlatformP2SHDestination`, construct the corresponding CTxDestination (e.g.,
`CTxDestination dest2 = PKHash(hash)` or `CTxDestination dest2 =
ScriptHash(hash)`), run the same duplicate check `if (destinations.count(dest2))
{ throw JSONRPCError(... duplicated address ...) }`, and then
`destinations.insert(dest2)` before creating `script_pub_key` (keep existing
`is_platform` handling and use the same error message and flow).

In `@src/wallet/spend.cpp`:
- Around line 792-808: The loop that moves platform recipients out of vecSend_in
(checking recipient.fPlatformTransfer) ignores recipient.fSubtractFeeFromAmount
so fee-subtraction is silently dropped; add a fail-fast guard inside that loop:
when iterating vecSend_in in the block that builds creditOutputs, detect if any
recipient has fPlatformTransfer == true AND fSubtractFeeFromAmount == true and
return an explicit RPC error (or propagate an error) explaining that
subtract-fee-from-amount is not supported for platform transfers; reference the
loop over vecSend_in, recipient.fPlatformTransfer,
recipient.fSubtractFeeFromAmount, creditOutputs, platform_total and the
vecSend.insert code path so the guard is placed before removing platform
recipients.
- Around line 801-805: When computing fees the extra payload size set via
opt_assetLockPayload.emplace(creditOutputs) and written into nExtraPayloadSize
is not re-applied when nBytes is recomputed during the second fee-pass, causing
underestimation; update the fee-recalculation path that recomputes nBytes (the
block that uses nBytes after the second pass) to include the payload overhead by
recomputing/adding nExtraPayloadSize (derived from serializing
*opt_assetLockPayload into CDataStream) to nBytes before the fee check, and
ensure opt_assetLockPayload is only cleared/dropped after the final fee decision
so nExtraPayloadSize remains available for the second pass.

In `@test/functional/feature_asset_locks.py`:
- Around line 776-777: The multiline calls to check_mempool_result (e.g.,
check_mempool_result(tx=lock_tx, result_expected={'allowed': False,
'reject-reason': 'bad-assetlocktx-version-2'}) and the similar call around lines
791-792) violate Flake8 E128 continuation indentation; fix by re-indenting the
continued lines so they align with the first element after the opening
parenthesis (or use a hanging indent) so the continuation line(s) are visually
aligned with the start of the arguments inside check_mempool_result.

---

Outside diff comments:
In `@src/evo/assetlocktx.cpp`:
- Around line 56-80: The code currently gates behavior using
CAssetLockPayload::CURRENT_VERSION and a relative check
(opt_assetLockTx->getVersion() >= 2), which will change historical rules when
CURRENT_VERSION increments; update the validation in the asset lock payload path
to dispatch on exact payload versions instead of relative comparisons: read the
payload version once (e.g., auto ver = opt_assetLockTx->getVersion()) and
replace the CURRENT_VERSION equality/inequality and the ">= 2" branch with
explicit version checks (e.g., ver == 1, ver == 2, etc.), ensuring
getCreditOutputs and the scriptPubKey checks (IsPayToPublicKeyHash /
IsPayToScriptHash) are applied only for the exact intended versions so past
rules remain fixed until intentionally modified.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

Run ID: bde9e45c-0174-4bee-84a1-f5087beff469

📥 Commits

Reviewing files that changed from the base of the PR and between beca567 and 303a71c.

📒 Files selected for processing (10)
  • src/evo/assetlocktx.cpp
  • src/evo/assetlocktx.h
  • src/evo/core_write.cpp
  • src/evo/specialtxman.cpp
  • src/rpc/output_script.cpp
  • src/test/evo_assetlocks_tests.cpp
  • src/wallet/rpc/spend.cpp
  • src/wallet/spend.cpp
  • src/wallet/wallet.h
  • test/functional/feature_asset_locks.py

Comment thread src/evo/assetlocktx.h
};

bool CheckAssetLockTx(const CTransaction& tx, TxValidationState& state);
bool CheckAssetLockTx(const CTransaction& tx, TxValidationState& state, bool is_v24_active = false);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion | 🟠 Major | ⚡ Quick win

Require the activation flag at every call site.

Defaulting is_v24_active to false makes a missed caller silently apply pre-v24 rules instead of failing to compile. For consensus validation, I'd drop the default so new call paths must thread deployment state explicitly.

Suggested change
-bool CheckAssetLockTx(const CTransaction& tx, TxValidationState& state, bool is_v24_active = false);
+bool CheckAssetLockTx(const CTransaction& tx, TxValidationState& state, bool is_v24_active);
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
bool CheckAssetLockTx(const CTransaction& tx, TxValidationState& state, bool is_v24_active = false);
bool CheckAssetLockTx(const CTransaction& tx, TxValidationState& state, bool is_v24_active);
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/evo/assetlocktx.h` at line 158, The declaration of CheckAssetLockTx
currently has a defaulted parameter is_v24_active = false which allows callers
to omit the activation flag; remove the default so the signature becomes bool
CheckAssetLockTx(const CTransaction& tx, TxValidationState& state, bool
is_v24_active); then update all call sites that invoke CheckAssetLockTx(...) to
pass the explicit deployment/activation boolean (thread the v24 activation state
from callers that know consensus state), ensuring compilation fails if a caller
neglects to propagate the flag.

Comment thread src/evo/core_write.cpp
Comment on lines +103 to +105
}},
{RPCResult::Type::STR, "address", /*optional=*/true, "DIP-18 Platform address (version >= 2 only)"},
}}}}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Nest platform address under scriptPubKey for v2 asset-lock JSON.

The new field is currently emitted as creditOutputs[].address instead of creditOutputs[].scriptPubKey.address, which can break consumers depending on the documented path.

Suggested patch
@@
                 {RPCResult::Type::OBJ, "scriptPubKey", "", {
                     {RPCResult::Type::STR, "asm", "The asm"},
                     {RPCResult::Type::STR_HEX, "hex", "The hex"},
                     {RPCResult::Type::STR, "type", "The type, eg 'pubkeyhash'"},
-                }},
-                {RPCResult::Type::STR, "address", /*optional=*/true, "DIP-18 Platform address (version >= 2 only)"},
+                    {RPCResult::Type::STR, "address", /*optional=*/true, "DIP-18 Platform address (version >= 2 only)"},
+                }},
         }}}}
@@
         UniValue spk(UniValue::VOBJ);
         ScriptToUniv(credit_output.scriptPubKey, spk, /*include_hex=*/true, /*include_address=*/false);
-        out.pushKV("scriptPubKey", spk);
         if (nVersion >= 2) {
             CTxDestination dest;
             if (ExtractDestination(credit_output.scriptPubKey, dest)) {
                 if (const auto* pkh = std::get_if<PKHash>(&dest)) {
-                    out.pushKV("address", EncodePlatformDestination(PlatformP2PKHDestination(uint160(*pkh))));
+                    spk.pushKV("address", EncodePlatformDestination(PlatformP2PKHDestination(uint160(*pkh))));
                 } else if (const auto* sh = std::get_if<ScriptHash>(&dest)) {
-                    out.pushKV("address", EncodePlatformDestination(PlatformP2SHDestination(uint160(*sh))));
+                    spk.pushKV("address", EncodePlatformDestination(PlatformP2SHDestination(uint160(*sh))));
                 }
             }
         }
+        out.pushKV("scriptPubKey", spk);

Also applies to: 117-128

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/evo/core_write.cpp` around lines 103 - 105, The RPC JSON currently emits
the new DIP-18 address as creditOutputs[].address; change emission so the
address is nested under creditOutputs[].scriptPubKey.address for v2 asset-locks.
Update the RPCResult/JSON building in src/evo/core_write.cpp where the
RPCResult::Type::STR "address" entry is added (both occurrences around the two
blocks noted) to instead add "scriptPubKey" as an object containing the existing
script fields plus the "address" field when version >= 2, and ensure the
produced JSON structure uses creditOutputs[].scriptPubKey.address so consumers
see the documented path.

Comment thread src/wallet/rpc/spend.cpp
Comment thread src/wallet/spend.cpp
Comment on lines +792 to +808
for (const auto& recipient : vecSend_in) {
if (recipient.fPlatformTransfer) {
creditOutputs.emplace_back(recipient.nAmount, recipient.scriptPubKey);
platform_total += recipient.nAmount;
} else {
vecSend.push_back(recipient);
}
}

opt_assetLockPayload.emplace(creditOutputs);
CDataStream ss(SER_NETWORK, PROTOCOL_VERSION);
ss << *opt_assetLockPayload;
nExtraPayloadSize = ss.size();

vecSend.insert(vecSend.begin(),
{CScript() << OP_RETURN << ParseHex(""), platform_total, /*fSubtractFeeFromAmount=*/false});
} else {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

subtractfeefromamount is ignored for platform recipients.

Platform recipients are removed from vecSend, so fee-subtraction logic never applies to them, which silently breaks RPC semantics.

Suggested guard (fail fast until full support is implemented)
         for (const auto& recipient : vecSend_in) {
             if (recipient.fPlatformTransfer) {
+                if (recipient.fSubtractFeeFromAmount) {
+                    return util::Error{_("subtractfeefromamount is not supported for platform recipients")};
+                }
                 creditOutputs.emplace_back(recipient.nAmount, recipient.scriptPubKey);
                 platform_total += recipient.nAmount;
             } else {
                 vecSend.push_back(recipient);
             }
         }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
for (const auto& recipient : vecSend_in) {
if (recipient.fPlatformTransfer) {
creditOutputs.emplace_back(recipient.nAmount, recipient.scriptPubKey);
platform_total += recipient.nAmount;
} else {
vecSend.push_back(recipient);
}
}
opt_assetLockPayload.emplace(creditOutputs);
CDataStream ss(SER_NETWORK, PROTOCOL_VERSION);
ss << *opt_assetLockPayload;
nExtraPayloadSize = ss.size();
vecSend.insert(vecSend.begin(),
{CScript() << OP_RETURN << ParseHex(""), platform_total, /*fSubtractFeeFromAmount=*/false});
} else {
for (const auto& recipient : vecSend_in) {
if (recipient.fPlatformTransfer) {
if (recipient.fSubtractFeeFromAmount) {
return util::Error{_("subtractfeefromamount is not supported for platform recipients")};
}
creditOutputs.emplace_back(recipient.nAmount, recipient.scriptPubKey);
platform_total += recipient.nAmount;
} else {
vecSend.push_back(recipient);
}
}
opt_assetLockPayload.emplace(creditOutputs);
CDataStream ss(SER_NETWORK, PROTOCOL_VERSION);
ss << *opt_assetLockPayload;
nExtraPayloadSize = ss.size();
vecSend.insert(vecSend.begin(),
{CScript() << OP_RETURN << ParseHex(""), platform_total, /*fSubtractFeeFromAmount=*/false});
} else {
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/wallet/spend.cpp` around lines 792 - 808, The loop that moves platform
recipients out of vecSend_in (checking recipient.fPlatformTransfer) ignores
recipient.fSubtractFeeFromAmount so fee-subtraction is silently dropped; add a
fail-fast guard inside that loop: when iterating vecSend_in in the block that
builds creditOutputs, detect if any recipient has fPlatformTransfer == true AND
fSubtractFeeFromAmount == true and return an explicit RPC error (or propagate an
error) explaining that subtract-fee-from-amount is not supported for platform
transfers; reference the loop over vecSend_in, recipient.fPlatformTransfer,
recipient.fSubtractFeeFromAmount, creditOutputs, platform_total and the
vecSend.insert code path so the guard is placed before removing platform
recipients.

Comment thread src/wallet/spend.cpp
Comment on lines +801 to +805
opt_assetLockPayload.emplace(creditOutputs);
CDataStream ss(SER_NETWORK, PROTOCOL_VERSION);
ss << *opt_assetLockPayload;
nExtraPayloadSize = ss.size();

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Extra payload bytes are missed in the second fee calculation pass.

You set nExtraPayloadSize here, but if change is dropped later, nBytes is recomputed without re-adding payload overhead, underestimating required fee.

Suggested fix
         nBytes = CalculateMaximumSignedTxSize(CTransaction(txNew), &wallet, &coin_control);
+        if (nExtraPayloadSize != 0) {
+            nBytes += GetSizeOfCompactSize(nExtraPayloadSize) + nExtraPayloadSize;
+        }
         fee_needed = coin_selection_params.m_effective_feerate.GetFee(nBytes);
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/wallet/spend.cpp` around lines 801 - 805, When computing fees the extra
payload size set via opt_assetLockPayload.emplace(creditOutputs) and written
into nExtraPayloadSize is not re-applied when nBytes is recomputed during the
second fee-pass, causing underestimation; update the fee-recalculation path that
recomputes nBytes (the block that uses nBytes after the second pass) to include
the payload overhead by recomputing/adding nExtraPayloadSize (derived from
serializing *opt_assetLockPayload into CDataStream) to nBytes before the fee
check, and ensure opt_assetLockPayload is only cleared/dropped after the final
fee decision so nExtraPayloadSize remains available for the second pass.

Comment on lines +776 to +777
self.check_mempool_result(tx=lock_tx,
result_expected={'allowed': False, 'reject-reason': 'bad-assetlocktx-version-2'})

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Fix Flake8 E128 continuation indentation on multiline check_mempool_result calls.

These lines are currently under-indented and will fail lint checks.

Proposed fix
-        self.check_mempool_result(tx=lock_tx,
-            result_expected={'allowed': False, 'reject-reason': 'bad-assetlocktx-version-2'})
+        self.check_mempool_result(
+            tx=lock_tx,
+            result_expected={'allowed': False, 'reject-reason': 'bad-assetlocktx-version-2'},
+        )
@@
-        self.check_mempool_result(tx=lock_tx_v2,
-            result_expected={'allowed': True, 'fees': {'base': Decimal(str(tiny_amount / COIN))}})
+        self.check_mempool_result(
+            tx=lock_tx_v2,
+            result_expected={'allowed': True, 'fees': {'base': Decimal(str(tiny_amount / COIN))}},
+        )

Also applies to: 791-792

🧰 Tools
🪛 Flake8 (7.3.0)

[error] 777-777: continuation line under-indented for visual indent

(E128)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@test/functional/feature_asset_locks.py` around lines 776 - 777, The multiline
calls to check_mempool_result (e.g., check_mempool_result(tx=lock_tx,
result_expected={'allowed': False, 'reject-reason':
'bad-assetlocktx-version-2'}) and the similar call around lines 791-792) violate
Flake8 E128 continuation indentation; fix by re-indenting the continued lines so
they align with the first element after the opening parenthesis (or use a
hanging indent) so the continuation line(s) are visually aligned with the start
of the arguments inside check_mempool_result.

@thepastaclaw thepastaclaw left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

Two blocking issues in the new wallet send path for platform addresses: the wallet always builds a v2 asset-lock payload even when DEPLOYMENT_V24 is inactive (the node will reject the tx but RPC reports success), and subtractfeefromamount is silently dropped because the platform recipients are replaced by a synthetic OP_RETURN with the flag hard-coded to false. A further suggestion: the duplicate-recipient guard in ParseRecipients does not track platform addresses, so the same destination can be specified twice through different encodings.

Reviewed commit: 303a71c

🔴 2 blocking | 🟡 1 suggestion(s) | 💬 1 nitpick(s)

4 finding(s)

🔴 blocking: Wallet builds a v2 asset-lock payload unconditionally; pre-V24 sends to platform addresses appear to succeed but are rejected

src/wallet/spend.cpp (lines 788-810)

opt_assetLockPayload.emplace(creditOutputs) uses the default CAssetLockPayload::CURRENT_VERSION, which is now 2 (src/evo/assetlocktx.h:32). Consensus rejects v2 asset locks with bad-assetlocktx-version-2 when DEPLOYMENT_V24 is inactive (src/evo/assetlocktx.cpp:59-61, gated in src/evo/specialtxman.cpp:140-141). Address decoding (DecodePlatformDestination) is not gated on V24, so a user sending to a dash1…/tdash1… address before V24 activates will have a syntactically valid tx constructed that the mempool always rejects. Because CommitTransaction() only logs broadcast failure and SendMoney() still returns a txid (src/wallet/wallet.cpp:2399-2402, src/wallet/rpc/spend.cpp:177-182), sendtoaddress/sendmany will report success for a wallet-tx that never enters the mempool. Either gate the platform-recipient path on DeploymentActiveAfter(..., DEPLOYMENT_V24) and return a friendly RPC error pre-V24, or fall back to building a v1 payload when only P2PKH credit outputs are required.

source: claude, codex

🔴 blocking: `subtractfeefromamount` is silently ignored for platform recipients

src/wallet/spend.cpp (lines 792-807)

Platform recipients are pulled out of vecSend_in and copied verbatim into creditOutputs at their original nAmount. They are then replaced in vecSend by a single synthetic OP_RETURN recipient whose fSubtractFeeFromAmount is hard-coded to false (line 807). The fee-subtraction loop later iterates only sortedVecSend and checks each recipient's flag (src/wallet/spend.cpp:1051-1083), so the flag set by the caller on a platform recipient is dropped completely. Result: sendtoaddress(tdash1..., amount, ..., subtractfeefromamount=true) still locks the full amount into the credit outputs and charges the fee from change/other recipients, violating the documented RPC contract and turning some valid calls into unexpected insufficient-funds failures. Either reject subtractfeefromamount on platform recipients in ParseRecipients with an explicit error, or implement consistent fee subtraction on both the credit output nAmount and the OP_RETURN platform_total.

source: claude, codex

🟡 suggestion: Duplicate-recipient protection is bypassed for platform addresses

src/wallet/rpc/spend.cpp (lines 33-58)

The duplicate-address guard tracks only decoded CTxDestinations in the destinations set. The platform-address branch derives a script_pub_key but never inserts anything into that set, so callers can specify the same destination twice — for example a regular yX… address and the equivalent tdash1… encoding, or two different platform encodings of the same hash — without tripping the duplicate check. This is inconsistent with the existing RPC behaviour and silently allows accidental duplicate payouts. Track an additional identifier (e.g. the derived script_pub_key or the underlying hash) in the dedup set for platform recipients and reject duplicates against both regular and platform encodings.

source: codex

💬 nitpick: Inserting the OP_RETURN at the front shifts caller-provided `change_pos` semantics

src/wallet/spend.cpp (lines 806-807)

When platform recipients are present, the OP_RETURN is unconditionally inserted at vecSend.begin(), but change_pos (passed in by the caller) is then interpreted relative to the post-insertion vector. A caller that passes change_pos = 0 intending 'before my first recipient' will now place change before the OP_RETURN. Likely benign for current RPC callers that leave change_pos = -1, but worth either documenting near the insertion or asserting that change_pos == -1 when has_platform is true to make the contract explicit.

source: claude

🤖 Prompt for all review comments with AI agents
These findings are from an automated code review. Verify each finding against the current code and only fix it if needed.

In `src/wallet/spend.cpp`:
- [BLOCKING] lines 788-810: Wallet builds a v2 asset-lock payload unconditionally; pre-V24 sends to platform addresses appear to succeed but are rejected
  `opt_assetLockPayload.emplace(creditOutputs)` uses the default `CAssetLockPayload::CURRENT_VERSION`, which is now 2 (`src/evo/assetlocktx.h:32`). Consensus rejects v2 asset locks with `bad-assetlocktx-version-2` when `DEPLOYMENT_V24` is inactive (`src/evo/assetlocktx.cpp:59-61`, gated in `src/evo/specialtxman.cpp:140-141`). Address decoding (`DecodePlatformDestination`) is not gated on V24, so a user sending to a `dash1…`/`tdash1…` address before V24 activates will have a syntactically valid tx constructed that the mempool always rejects. Because `CommitTransaction()` only logs broadcast failure and `SendMoney()` still returns a txid (`src/wallet/wallet.cpp:2399-2402`, `src/wallet/rpc/spend.cpp:177-182`), `sendtoaddress`/`sendmany` will report success for a wallet-tx that never enters the mempool. Either gate the platform-recipient path on `DeploymentActiveAfter(..., DEPLOYMENT_V24)` and return a friendly RPC error pre-V24, or fall back to building a v1 payload when only P2PKH credit outputs are required.

In `src/wallet/spend.cpp`:
- [BLOCKING] lines 792-807: `subtractfeefromamount` is silently ignored for platform recipients
  Platform recipients are pulled out of `vecSend_in` and copied verbatim into `creditOutputs` at their original `nAmount`. They are then replaced in `vecSend` by a single synthetic OP_RETURN recipient whose `fSubtractFeeFromAmount` is hard-coded to `false` (line 807). The fee-subtraction loop later iterates only `sortedVecSend` and checks each recipient's flag (`src/wallet/spend.cpp:1051-1083`), so the flag set by the caller on a platform recipient is dropped completely. Result: `sendtoaddress(tdash1..., amount, ..., subtractfeefromamount=true)` still locks the full `amount` into the credit outputs and charges the fee from change/other recipients, violating the documented RPC contract and turning some valid calls into unexpected insufficient-funds failures. Either reject `subtractfeefromamount` on platform recipients in `ParseRecipients` with an explicit error, or implement consistent fee subtraction on both the credit output `nAmount` and the OP_RETURN `platform_total`.

In `src/wallet/rpc/spend.cpp`:
- [SUGGESTION] lines 33-58: Duplicate-recipient protection is bypassed for platform addresses
  The duplicate-address guard tracks only decoded `CTxDestination`s in the `destinations` set. The platform-address branch derives a `script_pub_key` but never inserts anything into that set, so callers can specify the same destination twice — for example a regular `yX…` address and the equivalent `tdash1…` encoding, or two different platform encodings of the same hash — without tripping the duplicate check. This is inconsistent with the existing RPC behaviour and silently allows accidental duplicate payouts. Track an additional identifier (e.g. the derived `script_pub_key` or the underlying hash) in the dedup set for platform recipients and reject duplicates against both regular and platform encodings.

In `src/wallet/spend.cpp`:
- [NITPICK] lines 806-807: Inserting the OP_RETURN at the front shifts caller-provided `change_pos` semantics
  When platform recipients are present, the OP_RETURN is unconditionally inserted at `vecSend.begin()`, but `change_pos` (passed in by the caller) is then interpreted relative to the post-insertion vector. A caller that passes `change_pos = 0` intending 'before my first recipient' will now place change before the OP_RETURN. Likely benign for current RPC callers that leave `change_pos = -1`, but worth either documenting near the insertion or asserting that `change_pos == -1` when `has_platform` is true to make the contract explicit.

Inline posting hit GitHub HTTP 422, so I posted the verified findings as a top-level review body.

@knst knst requested review from PastaPastaPasta and UdjinM6 May 19, 2026 14:45
@knst knst added the RPC Some notable changes to RPC params/behaviour/descriptions label May 19, 2026
@knst knst requested a review from thephez May 19, 2026 14:47
@knst

knst commented May 25, 2026

Copy link
Copy Markdown
Collaborator Author

🔴 blocking: Wallet builds a v2 asset-lock payload unconditionally; pre-V24 sends to platform addresses appear to succeed but are rejected
src/wallet/spend.cpp (lines 788-810)

yes, it's expected. Wallet creates only v2 asset locks.
Asset locks v1 can be constructed externally by L2 (platform) or manually. There's no other mechanism to create v1 asset lock by Dash Core and it is expected.

@thepastaclaw

Copy link
Copy Markdown

Thanks for the clarification — I missed this top-level reply, not intentional.

You’re right that wallet-created asset locks being v2-only is expected, so the v1 fallback part of my review was wrong. I’m retracting that as a blocker.

The narrower thing I was trying to flag is only the pre-V24 RPC behavior: platform addresses appear to be accepted, a v2 payload is built, consensus rejects it with bad-assetlocktx-version-2, and CommitTransaction() logs broadcast failure while sendtoaddress/sendmany still return a txid. If the wallet path is intentionally post-V24-only, then a friendly pre-V24 RPC error (or explicit test/documentation) would be enough from my side.

I tried to edit the original review body to reflect this, but GitHub returned 404 for the review update endpoint, so leaving the correction here instead. The separate subtractfeefromamount blocker still looks valid to me.

Comment thread src/wallet/rpc/spend.cpp Outdated
@knst knst force-pushed the feat-asset-locks-v2 branch from ac311d1 to 834d56a Compare June 9, 2026 20:36

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/wallet/spend.cpp`:
- Around line 786-810: Detecting platform recipients (has_platform) currently
always builds a v2 asset-lock payload (opt_assetLockPayload) and inserts an
OP_RETURN output into vecSend even on pre-v24 nodes; change this so you first
check the v24 activation flag (use the existing consensus/activation helper
available in the codebase) and only construct opt_assetLockPayload, set
nExtraPayloadSize, and prepend the OP_RETURN output when v24 is active; if v24
is not active and has_platform is true, abort early with a clear RPC error
(return/throw the existing RPC error path) instead of building the payload so
the RPC fails fast and does not produce an invalid v2 asset-lock transaction.

In `@test/functional/feature_asset_locks.py`:
- Around line 800-803: Remove the unused local variables causing Flake8 F841:
either delete the assignment to fee_test_amount (and the unused COIN reference)
and/or remove the unused txid_subtract variable, or use the returned value;
specifically update the code around fee_test_amount and the call to
node_wallet.sendtoaddress (which assigns txid_subtract) — e.g., drop the
fee_test_amount line and replace txid_subtract = node_wallet.sendtoaddress(...)
with just node_wallet.sendtoaddress(address=platform_addr_from_key, ...) or
store the result in a used variable/underscore if you intend to ignore it.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

Run ID: 8ada7e55-42fe-48cb-bd18-38602116c367

📥 Commits

Reviewing files that changed from the base of the PR and between ac311d1 and 834d56a.

📒 Files selected for processing (10)
  • src/evo/assetlocktx.cpp
  • src/evo/assetlocktx.h
  • src/evo/core_write.cpp
  • src/evo/specialtxman.cpp
  • src/rpc/output_script.cpp
  • src/test/evo_assetlocks_tests.cpp
  • src/wallet/rpc/spend.cpp
  • src/wallet/spend.cpp
  • src/wallet/wallet.h
  • test/functional/feature_asset_locks.py
🚧 Files skipped from review as they are similar to previous changes (5)
  • src/wallet/wallet.h
  • src/evo/assetlocktx.cpp
  • src/evo/assetlocktx.h
  • src/evo/core_write.cpp
  • src/test/evo_assetlocks_tests.cpp

Comment thread src/wallet/spend.cpp
Comment on lines +786 to +810
const bool has_platform = std::any_of(vecSend_in.cbegin(), vecSend_in.cend(),
[](const auto& r) { return r.fPlatformTransfer; });
if (has_platform) {
std::vector<CTxOut> creditOutputs;
CAmount platform_total{0};

for (const auto& recipient : vecSend_in) {
if (recipient.fPlatformTransfer) {
creditOutputs.emplace_back(recipient.nAmount, recipient.scriptPubKey);
platform_total += recipient.nAmount;
} else {
vecSend.push_back(recipient);
}
}

opt_assetLockPayload.emplace(creditOutputs);
CDataStream ss(SER_NETWORK, PROTOCOL_VERSION);
ss << *opt_assetLockPayload;
nExtraPayloadSize = ss.size();

vecSend.insert(vecSend.begin(),
{CScript() << OP_RETURN << ParseHex(""), platform_total, /*fSubtractFeeFromAmount=*/false});
} else {
vecSend = vecSend_in;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Gate platform-asset-lock construction on v24 activation.

On Line 788, platform recipients always trigger v2 asset-lock payload creation. Pre-v24, that payload is consensus-invalid, so RPC send paths can still build/sign/return a txid even though relay will reject it (bad-assetlocktx-version-2). Add an explicit pre-v24 guard before payload creation and fail fast with a clear RPC error.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/wallet/spend.cpp` around lines 786 - 810, Detecting platform recipients
(has_platform) currently always builds a v2 asset-lock payload
(opt_assetLockPayload) and inserts an OP_RETURN output into vecSend even on
pre-v24 nodes; change this so you first check the v24 activation flag (use the
existing consensus/activation helper available in the codebase) and only
construct opt_assetLockPayload, set nExtraPayloadSize, and prepend the OP_RETURN
output when v24 is active; if v24 is not active and has_platform is true, abort
early with a clear RPC error (return/throw the existing RPC error path) instead
of building the payload so the RPC fails fast and does not produce an invalid v2
asset-lock transaction.

Comment on lines +800 to +803
fee_test_amount = COIN
try:
txid_subtract = node_wallet.sendtoaddress(
address=platform_addr_from_key,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Remove unused locals to satisfy Flake8 F841.

Line 800 (fee_test_amount) and Line 802 (txid_subtract) are assigned but never used.

Proposed fix
-        fee_test_amount = COIN
         try:
-            txid_subtract = node_wallet.sendtoaddress(
+            node_wallet.sendtoaddress(
                 address=platform_addr_from_key,
                 amount=Decimal("1.0"),
                 subtractfeefromamount=True,
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
fee_test_amount = COIN
try:
txid_subtract = node_wallet.sendtoaddress(
address=platform_addr_from_key,
try:
node_wallet.sendtoaddress(
address=platform_addr_from_key,
🧰 Tools
🪛 Flake8 (7.3.0)

[error] 800-800: local variable 'fee_test_amount' is assigned to but never used

(F841)


[error] 802-802: local variable 'txid_subtract' is assigned to but never used

(F841)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@test/functional/feature_asset_locks.py` around lines 800 - 803, Remove the
unused local variables causing Flake8 F841: either delete the assignment to
fee_test_amount (and the unused COIN reference) and/or remove the unused
txid_subtract variable, or use the returned value; specifically update the code
around fee_test_amount and the call to node_wallet.sendtoaddress (which assigns
txid_subtract) — e.g., drop the fee_test_amount line and replace txid_subtract =
node_wallet.sendtoaddress(...) with just
node_wallet.sendtoaddress(address=platform_addr_from_key, ...) or store the
result in a used variable/underscore if you intend to ignore it.

Source: Linters/SAST tools

@thepastaclaw

thepastaclaw commented Jun 10, 2026

Copy link
Copy Markdown

✅ Review complete (commit 834d56a)

@thepastaclaw thepastaclaw left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

Carried-forward prior findings: wallet still builds v2 asset-lock payloads unconditionally (pre-V24 platform sends will be silently mempool-rejected after RPC reports success), duplicate-recipient guard remains bypassable through Platform encodings, and the synthetic OP_RETURN insertion still shifts caller-supplied change_pos semantics. New findings in the latest delta: the new functional-test block introduces two unused locals (fee_test_amount, txid_subtract) which fail flake8 F841 that is enabled in this repo's Python lint, so CI will break. Fixed prior finding: subtractfeefromamount is now explicitly rejected for Platform recipients in ParseRecipients and has functional-test coverage.

🔴 2 blocking | 🟡 1 suggestion(s) | 💬 1 nitpick(s)

🤖 Prompt for all review comments with AI agents
These findings are from an automated code review. Verify each finding against the current code and only fix it if needed.

In `src/wallet/spend.cpp`:
- [BLOCKING] src/wallet/spend.cpp:788-810: Wallet builds a v2 asset-lock payload unconditionally; pre-V24 sends to platform addresses return a txid for a tx that can never enter the mempool
  `opt_assetLockPayload.emplace(creditOutputs)` (line 801) relies on `CAssetLockPayload`'s default ctor, which sets `nVersion = CURRENT_VERSION = 2` (`src/evo/assetlocktx.h:32,40`). Consensus rejects v2 asset-lock payloads with `bad-assetlocktx-version-2` while `DEPLOYMENT_V24` is inactive (`src/evo/assetlocktx.cpp` + `specialtxman`). Nothing in this code path or in `ParseRecipients` (`src/wallet/rpc/spend.cpp:25-76`) gates platform-recipient decoding on V24, and `CommitTransaction()` only logs broadcast failure (`src/wallet/wallet.cpp:2400-2403`). Net effect: pre-V24, `sendtoaddress`/`sendmany` to a `dash1…`/`tdash1…` address constructs and records a wallet transaction, returns a txid to the user, and the transaction is permanently mempool-rejected with no error surfaced. The functional test only exercises this path after V24 activation (`test/functional/feature_asset_locks.py:780-836`), so the regression has no coverage. Either gate platform-recipient decoding on `DeploymentActiveAfter(..., DEPLOYMENT_V24)` and return an explicit RPC error pre-V24, or fall back to a v1 payload when the credit outputs only contain P2PKH scripts.

In `test/functional/feature_asset_locks.py`:
- [BLOCKING] test/functional/feature_asset_locks.py:800-807: New test block introduces unused locals that fail enabled flake8 F841
  The new subtractfeefromamount sub-test assigns `fee_test_amount = COIN` (line 800) and `txid_subtract = node_wallet.sendtoaddress(...)` (line 802), but neither value is read. `test/lint/lint-python.py` enables flake8 `F841` (line 89), so the repository's Python lint will fail on this delta, blocking CI before functional tests run. Remove both unused assignments. (The assertion-message typo `subtracfeefromamount` on line 808 is cosmetic — only present in the local error message — and not worth blocking on by itself, but worth cleaning up in the same touch.)

In `src/wallet/rpc/spend.cpp`:
- [SUGGESTION] src/wallet/rpc/spend.cpp:33-58: Duplicate-recipient protection is bypassed for Platform addresses
  The duplicate-recipient guard only tracks decoded `CTxDestination`s in the `destinations` set (lines 35-38). The Platform branch (lines 40-58) derives a `script_pub_key` but inserts nothing into a dedup structure, so a caller can specify the same payout twice via different encodings — e.g. a regular base58 `yX…` address and the `tdash1…` encoding of the same hash, or two different Platform encodings of the same script — without tripping the duplicate check. This is inconsistent with the existing RPC contract and can silently allow accidental duplicate payouts into the credit pool. Track an additional identifier (the derived `script_pub_key` or the underlying hash) and reject duplicates across both regular and Platform encodings.

Comment thread src/wallet/spend.cpp
Comment on lines +788 to +810
if (has_platform) {
std::vector<CTxOut> creditOutputs;
CAmount platform_total{0};

for (const auto& recipient : vecSend_in) {
if (recipient.fPlatformTransfer) {
creditOutputs.emplace_back(recipient.nAmount, recipient.scriptPubKey);
platform_total += recipient.nAmount;
} else {
vecSend.push_back(recipient);
}
}

opt_assetLockPayload.emplace(creditOutputs);
CDataStream ss(SER_NETWORK, PROTOCOL_VERSION);
ss << *opt_assetLockPayload;
nExtraPayloadSize = ss.size();

vecSend.insert(vecSend.begin(),
{CScript() << OP_RETURN << ParseHex(""), platform_total, /*fSubtractFeeFromAmount=*/false});
} else {
vecSend = vecSend_in;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 Blocking: Wallet builds a v2 asset-lock payload unconditionally; pre-V24 sends to platform addresses return a txid for a tx that can never enter the mempool

opt_assetLockPayload.emplace(creditOutputs) (line 801) relies on CAssetLockPayload's default ctor, which sets nVersion = CURRENT_VERSION = 2 (src/evo/assetlocktx.h:32,40). Consensus rejects v2 asset-lock payloads with bad-assetlocktx-version-2 while DEPLOYMENT_V24 is inactive (src/evo/assetlocktx.cpp + specialtxman). Nothing in this code path or in ParseRecipients (src/wallet/rpc/spend.cpp:25-76) gates platform-recipient decoding on V24, and CommitTransaction() only logs broadcast failure (src/wallet/wallet.cpp:2400-2403). Net effect: pre-V24, sendtoaddress/sendmany to a dash1…/tdash1… address constructs and records a wallet transaction, returns a txid to the user, and the transaction is permanently mempool-rejected with no error surfaced. The functional test only exercises this path after V24 activation (test/functional/feature_asset_locks.py:780-836), so the regression has no coverage. Either gate platform-recipient decoding on DeploymentActiveAfter(..., DEPLOYMENT_V24) and return an explicit RPC error pre-V24, or fall back to a v1 payload when the credit outputs only contain P2PKH scripts.

source: ['claude', 'codex']

Comment on lines +800 to +807
fee_test_amount = COIN
try:
txid_subtract = node_wallet.sendtoaddress(
address=platform_addr_from_key,
amount=Decimal("1.0"),
subtractfeefromamount=True,
fee_rate=1000,
)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 Blocking: New test block introduces unused locals that fail enabled flake8 F841

The new subtractfeefromamount sub-test assigns fee_test_amount = COIN (line 800) and txid_subtract = node_wallet.sendtoaddress(...) (line 802), but neither value is read. test/lint/lint-python.py enables flake8 F841 (line 89), so the repository's Python lint will fail on this delta, blocking CI before functional tests run. Remove both unused assignments. (The assertion-message typo subtracfeefromamount on line 808 is cosmetic — only present in the local error message — and not worth blocking on by itself, but worth cleaning up in the same touch.)

Suggested change
fee_test_amount = COIN
try:
txid_subtract = node_wallet.sendtoaddress(
address=platform_addr_from_key,
amount=Decimal("1.0"),
subtractfeefromamount=True,
fee_rate=1000,
)
self.log.info("Test subtractfeefromamount for platform sendtoaddress")
try:
node_wallet.sendtoaddress(
address=platform_addr_from_key,
amount=Decimal("1.0"),
subtractfeefromamount=True,
fee_rate=1000,
)
raise AssertionError("subtractfeefromamount for platform address expected to generate error")
except JSONRPCException as e:
assert "subtractfeefromamount is not supported for Platform addresses" in e.error['message']

source: ['codex']

Comment thread src/wallet/rpc/spend.cpp
Comment on lines +33 to 58
CTxDestination dest = DecodeDestination(address);
if (IsValidDestination(dest)) {
if (destinations.count(dest)) {
throw JSONRPCError(RPC_INVALID_PARAMETER, std::string("Invalid parameter, duplicated address: ") + address);
}
destinations.insert(dest);
script_pub_key = GetScriptForDestination(dest);
} else {
std::string platform_error;
PlatformDestination platform_dest = DecodePlatformDestination(address, platform_error);
if (!IsValidPlatformDestination(platform_dest)) {
throw JSONRPCError(RPC_INVALID_ADDRESS_OR_KEY, std::string("Invalid Dash Platform address: ") + address);
}
is_platform = true;
if (const auto* pkh = std::get_if<PlatformP2PKHDestination>(&platform_dest)) {
uint160 hash;
std::copy(pkh->begin(), pkh->end(), hash.begin());
script_pub_key = GetScriptForDestination(PKHash(hash));
} else if (const auto* sh = std::get_if<PlatformP2SHDestination>(&platform_dest)) {
uint160 hash;
std::copy(sh->begin(), sh->end(), hash.begin());
script_pub_key = GetScriptForDestination(ScriptHash(hash));
} else {
throw JSONRPCError(RPC_INVALID_ADDRESS_OR_KEY, std::string("Unsupported Platform address type: ") + address);
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Suggestion: Duplicate-recipient protection is bypassed for Platform addresses

The duplicate-recipient guard only tracks decoded CTxDestinations in the destinations set (lines 35-38). The Platform branch (lines 40-58) derives a script_pub_key but inserts nothing into a dedup structure, so a caller can specify the same payout twice via different encodings — e.g. a regular base58 yX… address and the tdash1… encoding of the same hash, or two different Platform encodings of the same script — without tripping the duplicate check. This is inconsistent with the existing RPC contract and can silently allow accidental duplicate payouts into the credit pool. Track an additional identifier (the derived script_pub_key or the underlying hash) and reject duplicates across both regular and Platform encodings.

source: ['claude', 'codex']

Comment thread src/wallet/spend.cpp
Comment on lines +806 to +807
vecSend.insert(vecSend.begin(),
{CScript() << OP_RETURN << ParseHex(""), platform_total, /*fSubtractFeeFromAmount=*/false});

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💬 Nitpick: Inserting the synthetic OP_RETURN at vecSend.begin() shifts caller-provided change_pos semantics

When Platform recipients are present, the OP_RETURN is unconditionally inserted at vecSend.begin(), while change_pos (from the caller) is later interpreted relative to the post-insertion vector. A caller passing change_pos = 0 intending 'before my first recipient' will now place change before the OP_RETURN. Benign for current sendtoaddress/sendmany callers (they leave change_pos = -1), but CreateTransaction accepts explicit positions, so either document the shift or assert change_pos == -1 when has_platform is true to make the contract explicit.

source: ['claude', 'codex']

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

Labels

RPC Some notable changes to RPC params/behaviour/descriptions

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants