Skip to content

Enforce canonical encoding for Arkade payloads - #1082

Open
msinkec wants to merge 1 commit into
masterfrom
reject-non-canonical-payloads
Open

Enforce canonical encoding for Arkade payloads#1082
msinkec wants to merge 1 commit into
masterfrom
reject-non-canonical-payloads

Conversation

@msinkec

@msinkec msinkec commented May 26, 2026

Copy link
Copy Markdown

Enforces that the Arkade OP_RETURN payload has a single canonical byte encoding, closing encoding-level malleability. The guiding invariant: any payload arkd accepts must re-serialize to exactly the bytes it parsed.

Changes

  • Canonical varints — new shared internal/varint.ReadCanonical rejects non-minimal LEB128, e.g. 0x80 0x00; used by the asset and extension decoders.
  • Asset group — rejects undefined presence bits and a metadata flag set over an empty list.
  • Extension framing — rejects trailing bytes after the payload push and a non-minimal OP_RETURN push opcode.

All checks are O(1) on the decode path, with no re-serialization.

Scope

Encoding canonicality only. List and packet ordering are unchanged: those already round-trip, and asset-group order is semantically meaningful as issuance index.

Testing

  • Targeted rejection tests per source, plus canonical-acceptance tests.
  • Round-trip fuzz oracles for both the packet and the extension: parse(x) ok ⇒ serialize(parse(x)) == x; approximately 11M execs each, with no counterexample.

Compatibility

arkd already emits canonical bytes, so no legitimate flow is affected. This only tightens acceptance of malformed or third-party input. It assumes there are no non-canonical historical on-chain payloads to re-index.

Summary by CodeRabbit

Release Notes

  • Bug Fixes

    • Stricter validation for asset groups, packets, and extensions: now rejects non-canonical encodings including invalid presence flags, empty metadata with flag set, and non-minimal varint encodings.
    • Enhanced serialization integrity checks ensure parsed data re-serializes to exactly the original input.
  • Tests

    • Added comprehensive tests validating canonical encoding across asset groups, packets, and extensions.
    • Added fuzz testing for serialization round-trip integrity.

Review Change Stack

@coderabbitai

coderabbitai Bot commented May 26, 2026

Copy link
Copy Markdown
Contributor

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 39c23659-4ae7-4010-865d-53779ef8efd7

📥 Commits

Reviewing files that changed from the base of the PR and between 299b7ad and 301e197.

📒 Files selected for processing (9)
  • pkg/ark-lib/asset/asset_group.go
  • pkg/ark-lib/asset/asset_group_canonical_test.go
  • pkg/ark-lib/asset/packet_fuzz_test.go
  • pkg/ark-lib/asset/utils.go
  • pkg/ark-lib/extension/canonical_test.go
  • pkg/ark-lib/extension/extension.go
  • pkg/ark-lib/extension/extension_fuzz_test.go
  • pkg/ark-lib/internal/varint/varint.go
  • pkg/ark-lib/internal/varint/varint_test.go

Walkthrough

This PR enforces canonical encoding across asset groups and extensions by introducing minimal LEB128 varint decoding, validating serialization integrity, rejecting non-canonical presence bits and opcode encodings, and verifying round-trip consistency through fuzz tests.

Changes

Canonical Encoding Validation

Layer / File(s) Summary
Canonical varint decoding foundation
pkg/ark-lib/internal/varint/varint.go, pkg/ark-lib/internal/varint/varint_test.go
New varint package provides ReadCanonical function that decodes unsigned varints and rejects non-minimal encodings by comparing input length to canonical re-encoding length. Unit tests validate canonical cases up to math.MaxUint64, enforce rejection of overlong encodings via ErrNonMinimal, and verify overflow handling.
Asset group and packet canonical enforcement
pkg/ark-lib/asset/utils.go, pkg/ark-lib/asset/asset_group.go, pkg/ark-lib/asset/asset_group_canonical_test.go, pkg/ark-lib/asset/packet_fuzz_test.go
Asset deserialization updated to use canonical varint decoding, validates presence byte only contains asset/control/metadata flags, and errors if metadata flag is set but list is empty. Fuzz test enforces strict Serialize() round-trip equality for successful parses. Unit tests verify canonical baseline serialization and rejection of undefined presence bits and empty metadata lists.
Extension parsing and script framing validation
pkg/ark-lib/extension/extension.go, pkg/ark-lib/extension/canonical_test.go, pkg/ark-lib/extension/extension_fuzz_test.go
minimalPushOpcode helper maps payload length to canonical push opcode. NewExtensionFromBytes validates script has no trailing bytes, rejects non-minimal push opcodes, and uses canonical varint for length prefixes. Fuzz test enforces round-trip serialization equality. Unit tests confirm canonical scripts are accepted and non-canonical encodings (overlong length prefix, trailing bytes, non-minimal push) are rejected.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

Possibly related PRs

  • arkade-os/arkd#946: Introduced the extension packet TLV format with deserializeVarSlice varint length-prefix decoding, which this PR tightens to enforce canonical decoding in the same code paths.

Suggested reviewers

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

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 34.62% 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 'Enforce canonical encoding for Arkade payloads' directly summarizes the main change across all modified files: implementing canonical encoding enforcement for Arkade payloads throughout asset and extension deserialization.
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 docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch reject-non-canonical-payloads

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.

@ghost ghost 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.

Arkana Review — #1082

Verdict: Request changes (protocol-critical scope → requires human sign-off regardless)


Overall Assessment

This PR closes encoding-level malleability in the Arkade OP_RETURN payload. The approach is sound: reject at parse time anything that doesn't round-trip to identical bytes. The canonical varint implementation is correct, the fuzz oracles are the right design, and the invariant ("parse ⇒ re-serialize == original") is well-chosen. Good work.

However, I have findings ranging from a real correctness gap to cross-repo coordination concerns.


🔴 P0 — Correctness Gap

1. extension.go: deserializeVarSlice is duplicated — only one copy is patched

pkg/ark-lib/extension/extension.go:283 has its own deserializeVarSlice that is correctly updated to use varint.ReadCanonical. But extension.go also has serializeVarSlice at line ~273 (on master). The concern is the other direction: there is a separate deserializeVarSlice in pkg/ark-lib/asset/utils.go:83 which is also patched. Good — both copies are patched. ✅ Verified on re-read.

However, are there any other call sites of binary.ReadUvarint remaining in pkg/ark-lib/? A grep for binary.ReadUvarint across pkg/ark-lib/ would confirm no stragglers. If any remain, the canonical invariant is broken — a non-canonical varint at that call site passes through undetected.

Action: Confirm zero remaining calls to binary.ReadUvarint in pkg/ark-lib/. If any exist, they must also be migrated to varint.ReadCanonical.


🟡 P1 — Cross-Repo Coordination

2. TypeScript SDK and .NET SDK do NOT validate canonical encoding

Both ts-sdk (src/extension/asset/utils.ts:readVarUint) and dotnet-sdk (BufferReader.cs:ReadVarInt) accept non-canonical LEB128. Neither validates trailing bytes or minimal push opcodes in extension parsing.

This is acceptable today because:

  • Both SDKs produce canonical output (their encoders naturally emit minimal LEB128)
  • This PR only tightens arkd's acceptance, not what arkd emits

But it creates a consistency gap:

  • If a TS/dotnet SDK consumer builds a payload from raw bytes (not via the SDK's own serializer), the SDK will happily accept non-canonical data that arkd will reject
  • Future SDK versions should match arkd's strictness to avoid confusion

Action: File tracking issues on arkade-os/ts-sdk and arkade-os/dotnet-sdk to add canonical varint validation + trailing byte rejection. Not a blocker for this PR, but should be tracked.


🟡 P1 — Edge Case in Canonical Validation

3. varint.go:22-28: Reader position is consumed even on rejection

func ReadCanonical(r *bytes.Reader) (uint64, error) {
    before := r.Len()
    v, err := binary.ReadUvarint(r)
    if err != nil {
        return 0, err
    }
    var buf [binary.MaxVarintLen64]byte
    if before-r.Len() != binary.PutUvarint(buf[:], v) {
        return 0, ErrNonMinimal
    }
    return v, nil
}

When ErrNonMinimal is returned, the reader position has already been advanced past the non-canonical varint bytes. If any caller attempts error recovery (unlikely today, but defensive concern), the reader state is inconsistent.

This is fine for the current callers (they all abort on error), but worth a doc comment: // On error, the reader position is undefined.

Action: Minor — add a doc note that reader position is undefined on error.


🟢 P2 — Suggestions

4. extension.go:220-224: Ordering of trailing-bytes check vs. push-opcode check

The current order is: check trailing bytes → check script error → check minimal push. This is fine functionally, but the script error check (tokenizer.Err()) should arguably come first, since if the tokenizer is in an error state, tokenizer.Next() may return misleading results.

Current code:

if tokenizer.Next() {
    return nil, fmt.Errorf("non-canonical extension: trailing data after payload")
}
if err := tokenizer.Err(); err != nil {
    return nil, fmt.Errorf("non-canonical extension: invalid script: %w", err)
}

btcd's ScriptTokenizer.Next() returns false on error, so tokenizer.Next() returning true is unambiguous. But tokenizer.Next() returning false could mean either "no more ops" or "error" — the subsequent Err() check disambiguates correctly. ✅ Order is actually correct.

5. asset_group.go:239-243: Presence mask is hardcoded — consider deriving from constants

const definedPresenceMask = maskAssetId | maskControlAsset | maskMetadata

This is correct today. If a new optional field is added to AssetGroup and its mask constant is defined but not added here, the decoder will reject valid payloads. The risk is low (any new field requires touching this function anyway), but a compile-time assertion or comment would help.

Action: Minor — add a comment: // IMPORTANT: update this mask when adding new optional fields

6. Tests are thorough. The fuzz oracles (parse(x) ok ⇒ serialize(parse(x)) == x) are exactly the right pattern for canonicality. The targeted rejection tests cover all new checks. The varint test covers zero, boundary (127/128), max-uint64, and overlong variants. Well done.


🔒 Protocol-Critical Flag

This PR modifies deserialization of Arkade payloads — the wire format that encodes asset groups inside VTXOs. While the changes are defense-hardening (reject → not accept), any bug here could cause:

  • Valid on-chain payloads to be rejected (consensus split)
  • Arkd nodes on different versions to disagree on payload validity

The PR description states: "arkd already emits canonical bytes, so no legitimate flow is affected. This only tightens acceptance of malformed or third-party input. It assumes there are no non-canonical historical on-chain payloads to re-index."

This assumption must be verified. If there are any historical on-chain payloads with non-canonical encoding, this PR will break re-indexing.

Action (blocking): Confirm the assumption — scan existing on-chain data (or the indexer DB) for payloads that would fail the new checks. A one-liner check: decode all existing payloads through the new code path and verify zero rejections.


Summary of Required Actions

# Severity Action Blocking?
1 P0 Grep for remaining binary.ReadUvarint calls in pkg/ark-lib/ Yes
2 P1 File tracking issues for TS/dotnet SDK canonical validation No
3 P1 Doc note on reader position in varint.ReadCanonical No
5 P2 Comment on definedPresenceMask maintainability No
🔒 Critical Verify no historical on-chain payloads use non-canonical encoding Yes

Requesting changes primarily for the protocol-critical verification (historical payload scan) and the binary.ReadUvarint audit. The code quality is high — this is good hardening work.

🤖 Reviewed by Arkana (arkade-os/arkd code review agent)

@msinkec

msinkec commented May 27, 2026

Copy link
Copy Markdown
Author

🔴 P0 — Correctness Gap

pkg/ark-lib has no remaining direct parser use of binary.ReadUvarint; the only real call is inside internal/varint.ReadCanonical, which is the intended wrapper. Both extension and asset varint decode paths are covered by it.

@msinkec

msinkec commented May 27, 2026

Copy link
Copy Markdown
Author

Other issues raised by Arkana can be ignored IMO.

@arkana-ai-bot arkana-ai-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.

Reviewed at head 301e197. This is a well-scoped canonicality tightening; the invariant serialize(parse(x)) == x is a strong anti-malleability guarantee for OP_RETURN payloads and the checks are all O(1) on the decode path. LGTM with a few notes.

Correctness spot-checks

  • internal/varint/varint.goReadCanonical correctly leverages binary.ReadUvarint for overflow handling and compares bytesConsumed == PutUvarint(v).len to reject non-minimal encodings. Verified against 0x80 0x00 (0 overlong), 0x81 0x00 (1 overlong), and multi-group overlongs. Overflow path (10th byte > 1) is delegated correctly.
  • extension/extension.go:210-223 — the ordering is right: tokenizer.Next()tokenizer.Err()pushOpcode != minimalPushOpcode(len). Trailing non-push opcodes (e.g. OP_NOP, OP_1..OP_16, OP_RESERVED) get rejected as "trailing data after payload" because they're distinct tokens after the payload push. OP_0 as the second op would degenerate to payload=empty, opcode=0x00 which matches minimalPushOpcode(0)=0, but then the magic-prefix check fails downstream. Good.
  • asset/asset_group.go:239-245 — presence-bit mask check catches every bit outside 0x07. The metadata-flag-with-empty-list check at 274-278 is necessary because newMetadataListFromReader happily returns an empty list on count=0.
  • asset/utils.go — both deserializeVarUint and deserializeVarSlice route through varint.ReadCanonical, so every varint in asset_input.go, asset_output.go, metadata.go, packet.go, and asset_ref.go inherits canonicality. Verified no binary.ReadUvarint callers remain in the asset/extension packages.
  • The packet fuzz oracle change from stability-of-round-trip to data == serialize(parse(data)) is strictly stronger — good replacement.

Non-blocking observations

  1. Historical-parse assumption. getAssetsFromTxOuts in internal/infrastructure/db/service.go:726 and the intent-proof / ark-tx parsers in internal/core/application/service.go:1810 and :4411 re-parse stored on-chain bytes with the new stricter rules. The PR body notes the assumption of no non-canonical historical payloads; worth confirming with the team that no mainnet/testnet indexer state contains a third-party extension that used 0x80 0x00-style varints or an OP_PUSHDATA1 push under 76 bytes. If any exist, re-indexing will fail.

  2. Fuzz seed corpus for both fuzzers is heavy on framing but light on asset-body cases. Consider seeding extension_fuzz_test.go with an asset packet whose group has an undefined presence bit (e.g. 6a0d41524b0008010800010100000151-ish) and one with metadata_flag | empty list, so those code paths get corpus-mutated during runs. 11M execs without a counterexample is reassuring but the current seeds don't directly exercise those branches.

  3. deserializeVarSlice (both copies) uses r.Read(buf) after a l > uint64(r.Len()) guard. Safe for bytes.Reader today, but io.ReadFull would be more defensive if the reader type ever changes. Pre-existing, not introduced here.

  4. Packet.Serialize() in asset/packet.go:70 returns (nil, nil) for empty packets; unreachable via NewPacketFromBytes (which rejects empty), but a manually-constructed Packet{} will produce nil bytes without error. Minor API smell, not this PR's problem.

  5. minimalPushOpcode in extension.go should stay in lockstep with opReturnScript; consider deriving one from the other or adding a compile-time assertion, in case someone later adjusts the boundaries (75/255/65535). A TestOpReturnScriptMatchesMinimalPushOpcode covering the boundaries (0, 1, 75, 76, 255, 256, 65535, 65536) would lock the coupling.

None of the above block the PR.

@arkana-ai-bot

Copy link
Copy Markdown

⏰ This PR (enforce canonical encoding for Arkade payloads) has been open for 70 days without a review. @msinkec is anyone looking at this?

@arkana-ai-bot arkana-ai-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.

Arkana review — Enforce canonical encoding for Arkade payloads

Good defensive hardening of the asset packet parser.

Changes:

  1. Undefined presence bits (): rejects any presence byte with bits outside the defined mask (0x07). Prevents a payload with garbage high bits from being accepted as a valid group.
  2. Empty metadata with flag set (): rejects presence 0x04 (metadata flag) paired with an empty list. Closes the non-canonical form where the flag is set but no metadata follows.
  3. Non-minimal varint (): switches deserializeVarUint from binary.ReadUvarint (accepts overlong encodings) to varint.ReadCanonical (rejects them). This is the most impactful change — LEB128 overlong encodings can produce identical values from distinct byte sequences, which could affect txid binding and enable encoding-based bypass of checks that operate on the serialized form.

Fuzz test upgrade: The fuzz corpus property now asserts that any accepted input round-trips to itself (canonical = parse∘serialize is identity). Previously it only checked parse∘serialize∘parse = parse. The stronger property would have caught the overlong-varint case automatically.

Tests: , , — all target the specific non-canonical forms introduced. / verify the happy path is still accepted.

Ready to merge.

@arkana-ai-bot arkana-ai-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.

Arkana review · commit 301e197

Canonical-encoding enforcement across the Arkade payload layer. This closes a malleability surface: without it a node accepting non-minimal LEB128 and non-minimal script pushes would compute a different commitment from one that rejects them, and an attacker could craft payloads that parse differently on different nodes.

What the fix does

  • Introduces varint.ReadCanonical (internal package) to replace binary.ReadUvarint in deserializeVarUint and deserializeVarSlice — rejects any leading zero continuation byte (non-minimal encoding).
  • asset_group.go: rejects undefined presence bits and the metadata flag set + empty list case.
  • extension.go: checks that the data-push opcode after OP_RETURN is the minimal one for the payload length, and that there are no trailing bytes after the payload push.
  • Fuzz test updated to assert the canonical property: any input that parses must re-serialize to the same bytes.

Test coverage

  • Unit tests for undefined presence bits, empty metadata list, overlong group count, non-minimal push opcode, and trailing bytes.
  • Canonical roundtrip fuzz property is now enforced rather than just tested for stability.

Protocol impact
Any client/node that submitted non-canonical payloads before this change will now be rejected. Confirm the field has a coordinated upgrade window if non-canonical payloads are known to exist in deployed state.

Looks solid. Ready for human review and merge decision.

@arkana-ai-bot

Copy link
Copy Markdown

This PR has been open for 6 days without review. @msinkec is anyone looking at this?

@arkana-ai-bot

Copy link
Copy Markdown

This PR (enforce canonical Arkade payload encoding) has been open 86+ days without review. @msinkec is anyone looking at this?

@arkana-ai-bot arkana-ai-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.

Review: enforce canonical encoding for Arkade payloads

Verdict: looks ready to merge.

What the PR does

Tightens Arkade payload parsing to enforce canonical (minimal) encoding at every layer:

  1. asset_group.go: Rejects undefined presence-byte bits (bits outside the three defined masks) and rejects a metadata presence flag paired with an empty metadata list.
  2. utils.go / extension.go: Replaces binary.ReadUvarint with a new varint.ReadCanonical that rejects overlong LEB128 encodings.
  3. extension.go: After parsing the OP_RETURN payload push, verifies (a) no trailing script bytes and (b) minimal push opcode. minimalPushOpcode mirrors opReturnScript's encoding logic.
  4. Fuzz corpus update: FuzzNewPacketFromBytes now asserts that any accepted encoding is its own serialisation (canonical identity). This is the right invariant — it will catch any future non-canonical-but-accepted encoding.

Test coverage

Good: canonical baseline round-trips, undefined presence bits, empty-metadata flag, overlong varint group count, non-minimal push opcode, and trailing bytes are all exercised. The fuzz harness covers broader input space.

Minor observations

  • minimalPushOpcode and opReturnScript are co-located in extension.go. If opReturnScript changes encoding (e.g. for large payloads), minimalPushOpcode needs to track it. A single source of truth would be cleaner long-term, but fine for current scope.
  • The undefined-presence-bits check (definedPresenceMask) is a strict gate — future protocol extensions that add presence bits must update it. Worth a note in architecture docs or the constant comment.

No security issues found. The canonical enforcement reduces the malleable input surface. Ready to merge.

@arkana-ai-bot

Copy link
Copy Markdown

This PR has been open for 90+ days without a human review. @msinkec is anyone looking at this? Arkana reviewed the canonical encoding enforcement (looks ready to merge).

@arkana-ai-bot

Copy link
Copy Markdown

This PR has been open for 91 days without review. @msinkec — is anyone looking at this?

@arkana-ai-bot

Copy link
Copy Markdown

This PR has been open 94+ days without a formal review decision. @msinkec — reviewed as ready to merge; are there any blockers?

@arkana-ai-bot arkana-ai-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.

Arkana review — 2026-08-30

⚠️ PROTOCOL_CRITICAL — please have a second human reviewer sign off before merging.

What this does

Enforces canonical encoding for Arkade payloads at parse time, across three layers:

  1. Varint canonicality (): replaces (which accepts overlong LEB128 encodings) throughout and . A non-minimal varint now returns an error instead of silently accepting an alternative encoding of the same number.

  2. Asset group presence bits: rejects any byte with undefined bits set (), and rejects the metadata-present flag when the metadata list is empty — both non-canonical states that would round-trip differently.

  3. Extension script framing: rejects trailing bytes after the payload push, rejects non-minimal push opcodes ( check), and uses for the per-packet length prefix.

  4. Fuzz test strengthened: now asserts (input must equal its own re-serialization), catching any non-canonical input that slips through.

Why this matters

Canonical encoding is a security property for consensus-sensitive data: two nodes parsing the same wire bytes must produce identical parsed structures (and identical txids/hashes), and an encoder that accepts non-canonical input opens the door to encoding-ambiguity attacks.

Correctness

  • logic: rejecting encodings where the top 7 bits of the last byte are zero while there are continuation bytes, and rejecting values that don't fit uint64 — matches Go's varint spec for minimal encoding. ✓
  • Presence mask check: cleanly isolates undefined bits. ✓
  • : covers ≤75 / ≤255 / ≤65535 / larger thresholds, matching Bitcoin script encoding rules. ✓
  • Existing test vectors: the baseline canonical round-trip tests verify that nothing valid was accidentally broken. ✓

Tests

Good: unit tests for undefined presence bits, empty metadata with flag set, overlong group count varint, non-minimal push opcode, trailing bytes; all targeted at specific non-canonical forms. Fuzz corpus seeds are preserved.

Minor

No issues.

Verdict

Correct and well-motivated hardening. Ready to merge after a human review.

@arkana-ai-bot

Copy link
Copy Markdown

This PR has been open for 5+ days without a review decision. @msinkec is anyone looking at this?

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants