Skip to content

guards for subdust in opreturn#925

Merged
altafan merged 6 commits intomasterfrom
bob/ensure-subdust-guards
Mar 24, 2026
Merged

guards for subdust in opreturn#925
altafan merged 6 commits intomasterfrom
bob/ensure-subdust-guards

Conversation

@bitcoin-coder-bob
Copy link
Copy Markdown
Collaborator

@bitcoin-coder-bob bitcoin-coder-bob commented Feb 23, 2026

Closes #841

To be rebased off of #923 after it is merged first

Created a functions validateOffchainTxOutputs called from within existing SubmitOffchainTx to separate out the validation logic. Within this logic is the addition of the 2 new guards outlined in the issue.

Tests were added against this new function in the new test file: internal/core/application/validate_outputs_test.go

Summary by CodeRabbit

  • Refactor

    • Centralized and streamlined offchain transaction output validation, consolidating checks for anchors, OP_RETURN, dust, and per-output value limits while preserving existing behavior.
  • Tests

    • Added comprehensive tests covering valid flows, error conditions, boundary values, and mixed scenarios to reduce regressions.

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented Feb 23, 2026

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review

Walkthrough

The PR refactors SubmitOffchainTx by extracting output validation logic into a new validateOffchainTxOutputs helper function. This centralizes validation for anchor, OP_RETURN, dust, and value checks on Ark offchain transaction outputs, replacing inline validation code with a reusable helper function.

Changes

Cohort / File(s) Summary
Validation Refactoring
internal/core/application/service.go
Extracts per-output validation logic (anchor, OP_RETURN, dust, value checks) from SubmitOffchainTx into new private helper validateOffchainTxOutputs, centralizing subdust guard enforcement, vtxo amount bounds checking, and anchor/OP_RETURN presence validation.
Test Coverage
internal/core/application/service_test.go
Adds comprehensive unit tests for validateOffchainTxOutputs with table-driven test cases covering anchor presence, subdust/bare OP_RETURN outputs, extension OP_RETURN handling, max/min vtxo amount boundaries, and error conditions; includes test helpers for script and key generation.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Possibly related PRs

Suggested reviewers

  • louisinger
  • altafan
🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 14.29% 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
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title 'guards for subdust in opreturn' directly reflects the main changes: adding validation guards for subdust rules in OP_RETURN outputs as described in issue #841.
Linked Issues check ✅ Passed The PR implements both requirements from issue #841: disallowing OP_RETURNs with value unless in subdust format, and rejecting OP_RETURN values >= subdust threshold through the new validateOffchainTxOutputs helper.
Out of Scope Changes check ✅ Passed All changes are scoped to the validation requirements: centralizing validation logic in validateOffchainTxOutputs, adding subdust guards, and including comprehensive unit tests directly supporting issue #841 objectives.

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

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch bob/ensure-subdust-guards

Warning

Review ran into problems

🔥 Problems

Git: Failed to clone repository. Please run the @coderabbitai full review command to re-trigger a full review. If the issue persists, set path_filters to include or exclude specific files.


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.

@bitcoin-coder-bob bitcoin-coder-bob marked this pull request as ready for review February 23, 2026 19:00
@bitcoin-coder-bob
Copy link
Copy Markdown
Collaborator Author

@coderabbitai review

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented Feb 23, 2026

✅ Actions performed

Review triggered.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 2

🧹 Nitpick comments (1)
internal/core/application/service.go (1)

4159-4171: script.IsSubDustScript is dead code in this branch — the inner condition is always true.

IsSubDustScript requires an OP_RETURN prefix (per its implementation in internal/ark-lib/script/script.go Lines 125-137). Since this block is only reached when bytes.HasPrefix(out.PkScript, []byte{txscript.OP_RETURN}) returned false, IsSubDustScript will always return false here. The comment "it must be using OP_RETURN-style vtxo pkscript" is therefore unreachable as a passing branch. The guard can be simplified.

♻️ Proposed simplification
  if out.Value < int64(dust) {
-     // if the output is below dust limit, it must be using OP_RETURN-style vtxo pkscript
-     if !script.IsSubDustScript(out.PkScript) {
-         return nil, errors.AMOUNT_TOO_LOW.New(
-             "output #%d amount is below dust limit (%d < %d) but is not using "+
-                 "OP_RETURN output script", outIndex, out.Value, dust,
-         ).WithMetadata(errors.AmountTooLowMetadata{
-             OutputIndex: outIndex,
-             Amount:      int(out.Value),
-             MinAmount:   int(dust),
-         })
-     }
+     // non-OP_RETURN outputs (not subdust, not asset-packet) must meet the dust floor
+     return nil, errors.AMOUNT_TOO_LOW.New(
+         "output #%d amount is below dust limit (%d < %d) but is not using "+
+             "OP_RETURN output script", outIndex, out.Value, dust,
+     ).WithMetadata(errors.AmountTooLowMetadata{
+         OutputIndex: outIndex,
+         Amount:      int(out.Value),
+         MinAmount:   int(dust),
+     })
  }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@internal/core/application/service.go` around lines 4159 - 4171, The branch's
inner script.IsSubDustScript(out.PkScript) check is unreachable because this
code path is only entered when bytes.HasPrefix(out.PkScript,
[]byte{txscript.OP_RETURN}) was false, so IsSubDustScript will always be false;
remove the dead check and simplify by directly returning the AMOUNT_TOO_LOW
error (update the comment to reflect that non-OP_RETURN outputs below dust are
invalid), referencing the symbols out.PkScript, script.IsSubDustScript (to
remove), bytes.HasPrefix (reason), and the AMOUNT_TOO_LOW error construction in
this block.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@internal/core/application/service.go`:
- Around line 4148-4156: The AmountTooLow error metadata is incorrectly setting
Amount to the minimum threshold (vtxoMinOffchainTxAmount) instead of the actual
output value; in the block that constructs errors.AMOUNT_TOO_LOW.New for the
failing output (referencing out, outIndex, vtxoMinOffchainTxAmount and
AmountTooLowMetadata), change the Amount field to reflect the actual output
value (use out.Value cast to int) while leaving OutputIndex and MinAmount as
they are so the metadata shows the real amount, the min amount, and the output
index.

In `@internal/core/application/validate_outputs_test.go`:
- Around line 214-226: Add an assertion to the failing test that validates the
Amount field in AmountTooLowMetadata so the metadata matches the actual output
value: extend the test case struct with wantAmount (set to 600 for the "reject:
regular output below min amount" scenario) and, when wantErrCode ==
errors.AMOUNT_TOO_LOW.Code, cast the returned error metadata to
AmountTooLowMetadata and assert metadata.Amount == tc.wantAmount (i.e., equals
the out.Value) to catch the bug where Amount was incorrectly set to
vtxoMinOffchainTxAmount.

---

Nitpick comments:
In `@internal/core/application/service.go`:
- Around line 4159-4171: The branch's inner script.IsSubDustScript(out.PkScript)
check is unreachable because this code path is only entered when
bytes.HasPrefix(out.PkScript, []byte{txscript.OP_RETURN}) was false, so
IsSubDustScript will always be false; remove the dead check and simplify by
directly returning the AMOUNT_TOO_LOW error (update the comment to reflect that
non-OP_RETURN outputs below dust are invalid), referencing the symbols
out.PkScript, script.IsSubDustScript (to remove), bytes.HasPrefix (reason), and
the AMOUNT_TOO_LOW error construction in this block.

ℹ️ Review info

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 76f077b and 82fedf2.

📒 Files selected for processing (2)
  • internal/core/application/service.go
  • internal/core/application/validate_outputs_test.go

Comment thread internal/core/application/service.go
Comment thread internal/core/application/validate_outputs_test.go Outdated
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

♻️ Duplicate comments (2)
internal/core/application/service.go (1)

4148-4156: ⚠️ Potential issue | 🟡 Minor

Amount in AmountTooLowMetadata is still set to the minimum threshold, not the actual output value.

Amount: int(vtxoMinOffchainTxAmount) makes Amount == MinAmount, which is meaningless. It should be int(out.Value) — the same pattern used correctly in RegisterIntent (Lines 1748-1757).

🐛 Proposed fix
  ).WithMetadata(errors.AmountTooLowMetadata{
      OutputIndex: outIndex,
-     Amount:      int(vtxoMinOffchainTxAmount),
+     Amount:      int(out.Value),
      MinAmount:   int(vtxoMinOffchainTxAmount),
  })
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@internal/core/application/service.go` around lines 4148 - 4156, The metadata
for the AMOUNT_TOO_LOW error is using the minimum threshold instead of the
actual output value; update the AmountTooLowMetadata in the error returned by
errors.AMOUNT_TOO_LOW.New (the block that checks if out.Value <
vtxoMinOffchainTxAmount) so that Amount is set to int(out.Value) (keep MinAmount
as int(vtxoMinOffchainTxAmount) and OutputIndex as outIndex) to mirror the
correct pattern used in RegisterIntent.
internal/core/application/validate_outputs_test.go (1)

214-226: ⚠️ Potential issue | 🟡 Minor

The AMOUNT_TOO_LOW test case still doesn't assert the Amount metadata field, so the bug at service.go line 4154 remains undetected by tests.

Adding a wantAmount field to the struct and asserting it would have caught the Amount: int(vtxoMinOffchainTxAmount) bug, which sets Amount == MinAmount rather than the actual output value.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@internal/core/application/validate_outputs_test.go` around lines 214 - 226,
Add a new wantAmount field to the test case struct in validate_outputs_test.go
for the AMOUNT_TOO_LOW case and set it to the actual output value (e.g., 600);
update the test assertion in the test loop to compare the returned
metadata.Amount against wantAmount when wantErr is true/false as appropriate so
the test will fail if metadata.Amount is incorrectly set to
vtxoMinOffchainTxAmount; this will surface the bug at service.go (around line
4154) where Amount is being assigned int(vtxoMinOffchainTxAmount) instead of the
actual output value.
🧹 Nitpick comments (2)
internal/core/application/service.go (1)

4159-4171: script.IsSubDustScript check at line 4161 is dead code — simplify the guard.

Any script for which IsSubDustScript returns true starts with OP_RETURN and is therefore caught by the bytes.HasPrefix branch at line 4099 (which continues, never reaching this point). By the time execution arrives at line 4159, out.PkScript is guaranteed to be a non-OP_RETURN, non-anchor script, so IsSubDustScript always returns false and !IsSubDustScript is always true. The check is vacuous; the error is unconditionally returned whenever out.Value < int64(dust). The comment "it must be using OP_RETURN-style vtxo pkscript" is also confusing because such outputs have already been handled.

♻️ Suggested simplification
-       if out.Value < int64(dust) {
-           // if the output is below dust limit, it must be using OP_RETURN-style vtxo pkscript
-           if !script.IsSubDustScript(out.PkScript) {
-               return nil, errors.AMOUNT_TOO_LOW.New(
-                   "output #%d amount is below dust limit (%d < %d) but is not using "+
-                       "OP_RETURN output script", outIndex, out.Value, dust,
-               ).WithMetadata(errors.AmountTooLowMetadata{
-                   OutputIndex: outIndex,
-                   Amount:      int(out.Value),
-                   MinAmount:   int(dust),
-               })
-           }
-       }
+       if out.Value < int64(dust) {
+           return nil, errors.AMOUNT_TOO_LOW.New(
+               "output #%d amount is below dust limit (%d < %d)",
+               outIndex, out.Value, dust,
+           ).WithMetadata(errors.AmountTooLowMetadata{
+               OutputIndex: outIndex,
+               Amount:      int(out.Value),
+               MinAmount:   int(dust),
+           })
+       }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@internal/core/application/service.go` around lines 4159 - 4171, The
IsSubDustScript check is redundant because any OP_RETURN/anchor scripts are
already skipped by the earlier bytes.HasPrefix branch, so when execution reaches
the block guarded by out.Value < int64(dust) the output script cannot be
OP_RETURN and the script.IsSubDustScript call is dead; remove the
script.IsSubDustScript condition and its negation, always return the
AMOUNT_TOO_LOW error when out.Value < int64(dust), and update the surrounding
comment to state that non-OP_RETURN outputs below dust are invalid; search for
symbols out.Value, out.PkScript, script.IsSubDustScript and the earlier
bytes.HasPrefix handling to locate and simplify the guard and message.
internal/core/application/validate_outputs_test.go (1)

50-421: Missing coverage for the asset.IsAssetPacket branch.

The asset.IsAssetPacket(out.PkScript) branch at service.go line 4108 — which adds the output directly to the result list and skips all amount/dust checks — has no test case. A test with an asset-packet OP_RETURN output (possibly also testing that it is not blocked by the amount guards and that foundOpReturn is set so a second OP_RETURN is rejected) would close this gap.

Would you like me to draft the missing test cases for the asset packet path?

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@internal/core/application/validate_outputs_test.go` around lines 50 - 421,
Add tests to TestValidateOffchainTxOutputs that cover the asset packet OP_RETURN
branch: create a txOut with a PkScript that asset.IsAssetPacket would return
true for (e.g., add a helper like testAssetPacketScript and use it), call
validateOffchainTxOutputs with vtxoMaxAmount/vtxoMinOffchainTxAmount set to
values that would normally reject a regular output, and assert the call succeeds
and includes the asset output in outputs (wantOutputCount). Also add a second
test that includes an asset packet OP_RETURN plus another OP_RETURN to verify
the duplicate OP_RETURN path still triggers the MALFORMED_ARK_TX error (ensure
you check err.Code() and err.Error() contains "multiple op return"). Ensure
tests reference TestValidateOffchainTxOutputs, validateOffchainTxOutputs, and
asset.IsAssetPacket to locate the logic.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Duplicate comments:
In `@internal/core/application/service.go`:
- Around line 4148-4156: The metadata for the AMOUNT_TOO_LOW error is using the
minimum threshold instead of the actual output value; update the
AmountTooLowMetadata in the error returned by errors.AMOUNT_TOO_LOW.New (the
block that checks if out.Value < vtxoMinOffchainTxAmount) so that Amount is set
to int(out.Value) (keep MinAmount as int(vtxoMinOffchainTxAmount) and
OutputIndex as outIndex) to mirror the correct pattern used in RegisterIntent.

In `@internal/core/application/validate_outputs_test.go`:
- Around line 214-226: Add a new wantAmount field to the test case struct in
validate_outputs_test.go for the AMOUNT_TOO_LOW case and set it to the actual
output value (e.g., 600); update the test assertion in the test loop to compare
the returned metadata.Amount against wantAmount when wantErr is true/false as
appropriate so the test will fail if metadata.Amount is incorrectly set to
vtxoMinOffchainTxAmount; this will surface the bug at service.go (around line
4154) where Amount is being assigned int(vtxoMinOffchainTxAmount) instead of the
actual output value.

---

Nitpick comments:
In `@internal/core/application/service.go`:
- Around line 4159-4171: The IsSubDustScript check is redundant because any
OP_RETURN/anchor scripts are already skipped by the earlier bytes.HasPrefix
branch, so when execution reaches the block guarded by out.Value < int64(dust)
the output script cannot be OP_RETURN and the script.IsSubDustScript call is
dead; remove the script.IsSubDustScript condition and its negation, always
return the AMOUNT_TOO_LOW error when out.Value < int64(dust), and update the
surrounding comment to state that non-OP_RETURN outputs below dust are invalid;
search for symbols out.Value, out.PkScript, script.IsSubDustScript and the
earlier bytes.HasPrefix handling to locate and simplify the guard and message.

In `@internal/core/application/validate_outputs_test.go`:
- Around line 50-421: Add tests to TestValidateOffchainTxOutputs that cover the
asset packet OP_RETURN branch: create a txOut with a PkScript that
asset.IsAssetPacket would return true for (e.g., add a helper like
testAssetPacketScript and use it), call validateOffchainTxOutputs with
vtxoMaxAmount/vtxoMinOffchainTxAmount set to values that would normally reject a
regular output, and assert the call succeeds and includes the asset output in
outputs (wantOutputCount). Also add a second test that includes an asset packet
OP_RETURN plus another OP_RETURN to verify the duplicate OP_RETURN path still
triggers the MALFORMED_ARK_TX error (ensure you check err.Code() and err.Error()
contains "multiple op return"). Ensure tests reference
TestValidateOffchainTxOutputs, validateOffchainTxOutputs, and
asset.IsAssetPacket to locate the logic.

ℹ️ Review info

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 76f077b and 82fedf2.

📒 Files selected for processing (2)
  • internal/core/application/service.go
  • internal/core/application/validate_outputs_test.go

Copy link
Copy Markdown
Contributor

@arkanaai arkanaai Bot left a comment

Choose a reason for hiding this comment

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

arkanaai[bot] review — arkd #925

Solid security hardening. Extracts validateOffchainTxOutputs into a testable standalone function and adds the critical missing guards on OP_RETURN outputs.

New guards — correct ✓

  1. Subdust OP_RETURN with value ≥ dust → MALFORMED_ARK_TX: Previously, a malicious client could craft a subdust-format OP_RETURN with dust-or-higher satoshis and bypass the vtxoMinOffchainTxAmount floor check (the code only checked the < dust path for non-OP_RETURN outputs). Now explicitly blocked. ✓

  2. Non-subdust OP_RETURN with non-zero value → MALFORMED_ARK_TX: Standard OP_RETURN outputs in Bitcoin carry 0 sats; a non-zero value here indicates a malformed or adversarial transaction. ✓

  3. Asset packet OP_RETURN outputs are correctly passed through without the above value checks (asset packets carry their own semantics). ✓

Test coverage is comprehensive — 421 lines covering all cases including anchor duplicate, OP_RETURN duplicate, all the new subdust guards, and the standard amount bounds.

⚠️ Merge conflict with arkd #923

arkd #923 (also open, reviewed this cycle) added commit 23a65815 which renames vtxoMinOffchainTxAmountvtxoMinAmount and removes vtxoMinSettlementAmount from the struct. This PR's call site still uses s.vtxoMinOffchainTxAmount:

outputs, outputsErr := validateOffchainTxOutputs(
    ..., s.vtxoMinOffchainTxAmount, ...
)

These two PRs will conflict when either is merged. Whichever merges second will need to rebase and update the field reference. The extracted function signature (vtxoMinOffchainTxAmount int64 param) should also be renamed to vtxoMinAmount after #923 lands.

Minor — foundAnchor check moved to end

The anchor check is now at the bottom of the loop (after all output validation). Previously it short-circuited early. The behavior is equivalent — we still reject if no anchor is found — but a transaction with a missing anchor AND a malformed OP_RETURN output will now return the OP_RETURN error, not the missing-anchor error. This changes the error priority order; not a security issue but worth being aware of for client error message handling.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@internal/core/application/service.go`:
- Around line 4230-4264: The extension OP_RETURN path currently skips the
non-zero-value check because extension.IsExtension(out.PkScript) runs before
value validation; modify the logic in the output-validation block (where
extension.IsExtension, extension.NewExtensionFromBytes, script.IsSubDustScript
and errors.MALFORMED_ARK_TX are used) to enforce out.Value == 0 for non-subdust
OP_RETURNs before or alongside parsing extensions (i.e., if not
script.IsSubDustScript(out.PkScript) and out.Value > 0 return MALFORMED_ARK_TX),
and only then call extension.NewExtensionFromBytes for extension scripts; also
add a regression test in validate_outputs_test.go that constructs an extension
OP_RETURN with a non-zero value and asserts the MALFORMED_ARK_TX error is
returned.

ℹ️ Review info

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between f7265cc and 5730a02.

📒 Files selected for processing (2)
  • internal/core/application/service.go
  • internal/core/application/validate_outputs_test.go

Comment thread internal/core/application/service.go Outdated
@@ -0,0 +1,428 @@
package application
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

let's move this test to service_test.go

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

done in: 1b79621

@bitcoin-coder-bob bitcoin-coder-bob force-pushed the bob/ensure-subdust-guards branch from ce6c349 to ef652cd Compare March 23, 2026 12:14
@arkanaai
Copy link
Copy Markdown
Contributor

arkanaai Bot commented Mar 23, 2026

🔍 Arkana PR Review — arkd#925 (updated)

Guards for subdust in OP_RETURN by @bitcoin-coder-bob | Closes #841

Summary

Extracts offchain tx output validation into a standalone validateOffchainTxOutputs function and adds two new security guards for OP_RETURN outputs:

  1. Subdust OP_RETURN outputs must have value < dust — prevents locking funds in unspendable subdust scripts
  2. Non-subdust OP_RETURN outputs must have value == 0 — prevents value being burned in generic OP_RETURN data outputs
  3. Extension OP_RETURN outputs must have value == 0 — enforced in both SubmitOffchainTx and RegisterIntent

What Changed Since Last Review

  • Fixed: AmountTooLowMetadata.Amount now correctly uses int(out.Value) instead of int(vtxoMinOffchainTxAmount) — previous review feedback addressed.
  • New commit(s) pushed (SHA changed from ce6c349cef652cde).

Security Analysis

Validation flow is sound. The output classification follows a clear priority:

  1. Anchor → single-check, skip
  2. Extension OP_RETURN → single-check, zero-value enforced, parse
  3. Subdust OP_RETURN → value < dust enforced
  4. Bare OP_RETURN → value == 0 enforced
  5. Regular outputs → min/max amount, dust threshold, taproot script type

Intentional behavior change worth noting: In the old code, a bare (non-subdust, non-extension) OP_RETURN with value 0 would be rejected because it fell through to the regular output checks and failed either the dust check or the taproot script check. In the new code, zero-value bare OP_RETURN outputs are explicitly allowed. This is correct Bitcoin behavior (zero-value OP_RETURN is standard for data embedding) but it's a semantic change reviewers should be aware of.

No double-spend vectors introduced. The validation strictly tightens what was previously accepted (except the zero-value bare OP_RETURN case above, which is benign).

RegisterIntent consistency. The extension zero-value check is correctly mirrored in RegisterIntent (line ~1749), ensuring both the offchain tx submission and intent registration paths enforce the same constraint.

Test Coverage

Excellent — 25+ test cases covering:

  • Valid flows (anchor + regular, subdust, bare OP_RETURN, extension, mixed)
  • Boundary values (dust-1, exact dust, exact min/max amounts)
  • Error conditions (missing anchor, duplicates, value violations)
  • Metadata assertions (checking Amount field in AmountTooLowMetadata)
  • Subdust outputs correctly bypass min/max amount checks

Minor Observations

  • The maxOpReturnOutputs default in tests (if maxOpRet == 0 { maxOpRet = 3 }) is a reasonable test default but could be documented to avoid confusion with production defaults.

Verdict

Previous feedback addressed. The refactoring is clean, the new guards close real attack surfaces (value locked in unspendable scripts), and test coverage is thorough. Ready for human approval.

@arkanaai
Copy link
Copy Markdown
Contributor

arkanaai Bot commented Mar 23, 2026

🔍 Arkana PR Review

PR: guards for subdust in opreturn (closes #841)
Scope: internal/core/application/service.go refactor + new tests — +623/-101
Depends on: #923 (must merge first)

Summary

Extracts output validation from SubmitOffchainTx into a standalone validateOffchainTxOutputs function and adds two new guards:

  1. Subdust OP_RETURN outputs must have value < dust — rejects if IsSubDustScript(pkScript) but value >= dust
  2. Non-subdust OP_RETURN outputs must have zero value — rejects if OP_RETURN (not subdust format) but value > 0

Security Analysis

Positive:

  • These guards close a real gap. Without them, a malicious submitter could attach value to bare OP_RETURN outputs (value burned on-chain) or pass off a dust-or-above value with subdust script format, bypassing the taproot-script requirement for above-dust outputs.
  • The refactor is clean — behavior-preserving for existing paths plus the two new guards.

Observations:

  1. OP_RETURN early-continue logic is well structured. Subdust OP_RETURN outputs now get their own branch with continue, so they skip the taproot check below. Non-subdust OP_RETURN with zero value also continue. This correctly ensures only taproot outputs reach the script-class check.

  2. Extension outputs bypass OP_RETURN guards. Extension outputs are checked via extension.IsExtension() before the OP_RETURN guard block. This is correct — extensions have their own validation path.

  3. maxOpReturnOutputs counting is consistent. The opRetCount increment happens before the extension check, so extension OP_RETURN outputs still count toward the limit. This matches the original behavior.

Test Coverage

Excellent coverage in service_test.go — 20+ test cases covering:

  • Valid flows (anchor + regular, subdust below dust, bare OP_RETURN with zero value)
  • Boundary values (subdust at dust-1, regular at exact max/min/dust)
  • Rejection cases (subdust >= dust, bare OP_RETURN with value, missing anchor, multiple anchors, etc.)
  • Mixed scenarios (regular + subdust, regular + bare OP_RETURN)

Nit

  • The maxOpReturnOutputs field in test structs defaults to 0 (Go zero value) which means when not explicitly set, it defaults to allowing 0 OP_RETURN outputs. The tests work because most cases only have 1 OP_RETURN and the default maxOpReturnOutputs in the validateOffchainTxOutputs call uses the test struct value. Just worth noting that the default is restrictive (0 = no OP_RETURN allowed) but this is handled correctly in production via max(1, ...) in config.

Verdict: Solid security improvement with thorough tests. The guards are correctly positioned in the validation flow. Ready for human approval once #923 merges. 👍

@arkanaai
Copy link
Copy Markdown
Contributor

arkanaai Bot commented Mar 23, 2026

🔍 Arkana PR Review (incremental — new commits since last review)

Changes since last review: Added validation that extension OP_RETURN outputs must have Value == 0 in both RegisterIntent and validateOffchainTxOutputs. Tests updated accordingly — previous "valid with non-zero value" cases now correctly reject.

Assessment: ✅ Solid

The new guards are well-placed and correctly enforce the subdust constraint at both entry points:

  1. RegisterIntent — rejects intent PSBTs with non-zero-value extension outputs upfront, before any state mutation. Good.
  2. validateOffchainTxOutputs — catches the same violation during offchain tx submission with proper error metadata (includes the PSBT for debugging).

Security notes:

  • These guards close the vector described in Ensure subdust guards #841 where an attacker could embed dust/subdust value in OP_RETURN extension outputs. Since OP_RETURN outputs are unspendable, any value assigned is effectively burned — this validation prevents accidental or malicious fund loss.
  • Both code paths return early on violation, no partial state corruption risk.

Tests: Good coverage — boundary values (0, 1, 1000) all tested, with correct error code and message substring assertions.

Nit: The test "reject: extension OP_RETURN with value == 1" is a nice boundary check. Consider whether you also want a test with Value: math.MaxInt64 to cover overflow edge cases, though the current guards would catch it regardless.

@altafan altafan merged commit db9b5de into master Mar 24, 2026
6 checks passed
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.

Ensure subdust guards

3 participants