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
Copy link
Copy Markdown

@msinkec msinkec commented May 26, 2026

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
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented May 26, 2026

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.

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.

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
Copy link
Copy Markdown
Author

msinkec commented May 27, 2026

🔴 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
Copy link
Copy Markdown
Author

msinkec commented May 27, 2026

Other issues raised by Arkana can be ignored IMO.

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.

1 participant