Skip to content

contracts(audit): v0.6.0 audit-fix pass — H-01, M-01, M-03 + tests - #138

Open
logicalmechanism wants to merge 22 commits into
devfrom
feature/v0.6.0-audit-fix-pass
Open

logicalmechanism wants to merge 22 commits into
devfrom
feature/v0.6.0-audit-fix-pass

Conversation

@logicalmechanism

@logicalmechanism logicalmechanism commented May 8, 2026 •

Copy link
Copy Markdown
Contributor

Summary

v0.6.0 audit-fix release — closes the actionable findings from the 2026-05-07 self-audit (app/contracts/audits/audit_report.md) and the off-chain coupling that needed updating in the same release window.

Contracts (app/contracts/)

  • H-01 (stake-credential hijack): every continuing protocol output and the UseBid singleton-input filters now pin payment_credential = Script(h), stake_credential = None, and reference_script = None. Closes H-01 across encryption.ak (5 sites) + bidding.ak (2 sites + 2 input filters) + the UseEncryption bid-input lookup.
  • M-01 (reference-validator trust anchor): genesis takes a third compile-time parameter reference_hash: ScriptHash and asserts ReferenceDatum.reference == reference_hash at mint. Reference.ak re-keyed from (_genesis_pid, _genesis_tkn) to (_tx_id, _tx_idx) to break the bootstrap cycle. compile.sh updated to build reference first.
  • M-03 (Groth16 public-input over-supply): verify_groth16 now rejects extra public entries via expect [] = leftover_public instead of silently truncating. Pattern-match keeps aiken/collection/list out of lib/types/groth.ak.
  • I-08 (compile.sh CBOR token-name divergence): off-chain builder now mirrors util.construct_token_name byte-for-byte. Smoke-tested for tx_idx ∈ {0, 23, 24, 30, 100, 255}.

Off-chain follow-up (added on top of original scope)

  • GUI (app/gui/fe/src/services/transactions/txUtils.ts): computeTokenName had the same I-08 bug for encryption / bidding token names. For output_index >= 24 the off-chain prediction desynced from on-chain bytearray.push. Now mirrors on-chain byte-for-byte; throws on out-of-range indices.
  • Bash happy-path scripts (app/commands/): all 11 scripts that built encryption/bidding script addresses with --stake-key-hash ${staking_credential} would have failed validation post-H-01. Removed the flag everywhere. Replaced 7 cbor2.dumps token-name builders with the bytewise form across 6 scripts.
  • Docs: brief v0.6.0 notes in app/contracts/README.md and app/gui/CLAUDE.md (Conventions & Gotchas).

Tests / scaffolding

  • I-02 fixture extensions for all 4 stake_credential variants + reference_script Some/None.
  • 8 H-01 perimeter regression tests + 1 M-03 over-supply negative test (95 / 0, was 86 / 0).
  • I-03 baseline bench scaffolding under lib/benchmarks/ (8 blocks). Adds aiken-lang/fuzz v2.2.0 dep.
  • Inline rationale comments at every site flagged as carried Intentional (H-02, H-03, H-04, L-03, I-05).

Validator sizes (post-refactor):

Validator Bytes Δ vs pre-fix
genesis 1,094 +reference_hash param
reference 29 re-keyed (smaller types)
encryption 8,945 +903 (+11% — H-01 perimeter)
bidding 4,149 -1,671 (-29% — cleaner UseBid filter)
groth 1,664 +22 (M-03 leftover-public)

Test plan

  • cd app/contracts && aiken check — 95 / 0
  • aiken build — preamble version 0.6.0 matches aiken.toml
  • bash compile.sh — re-derives all 5 hashes (smoke-tested with tx_idx=0)
  • aiken bench — 8 baselines captured under lib/benchmarks/
  • cd app/gui/fe && npx vitest run on transactionBuilder + transactions/ — 109 / 109
  • bash -n syntax-check on every modified script in app/commands/
  • Visual review of inline H-01 destructure pattern across encryption.ak / bidding.ak
  • Manual happy-path run of app/commands/01a → 08 against preprod after env vars are repointed at the new hashes
  • Manual confirmation that the M-01 deploy runbook in compile.sh produces matching reference.hash + genesis.hash

Generated with Claude Code

logicalmechanism and others added 22 commits May 7, 2026 22:18
Introduce util.is_protocol_output / util.is_protocol_input that pin
payment_credential = Script(h), stake_credential = None, and
reference_script = None. These will replace the open
"Script(this_script) == payment_credential, reference_script == None"
clusters in encryption.ak and bidding.ak, closing the H-01 stake-
credential hijack.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Replace 5 continuing-output clusters in encryption.ak (EntryEncryptionMint,
UseEncryption, UseSnark, CancelEncryption, UpdateEncryptionPrice) with
util.is_protocol_output, plus the bid-input filter in UseEncryption with
util.is_protocol_input. Each call site now pins stake_credential = None
and reference_script = None, closing the H-01 stake-credential hijack on
every encryption-side path including the permissionless TTL-expired
CancelEncryption branch.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Replace 2 continuing-output clusters (EntryBidMint, UpdateBidPrice) and
both UseBid input filters with util.is_protocol_output /
util.is_protocol_input. Each call site now pins stake_credential = None
and reference_script = None across mint, spend, and the
encryption-and-self input filters in UseBid.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Add a third compile-time parameter \`reference_hash: ScriptHash\` to the
genesis policy and assert \`ReferenceDatum.reference == reference_hash\`
at mint time. Closes M-01: previously the operator-supplied
\`ReferenceDatum.reference\` was anchored only off-chain, which let a
mis-deployed system point protocol trust at any script the operator
chose. With the parameter, the reference UTxO is provably tied to the
build-time reference.ak hash.

This is a breaking change to the genesis script — bumping protocol to
v0.6.0. The off-chain Veiled bootstrap flow will need a follow-up to
thread the new parameter through deployment.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Reference.ak now takes (_tx_id, _tx_idx) instead of (_genesis_pid,
_genesis_tkn) — same params, both unused, but keyed off the bootstrap
UTxO instead of the genesis policy hash. This breaks the cycle the
M-01 fix introduced (genesis depends on reference_hash, but reference
previously depended on genesis_pid).

compile.sh now:
- Validates 0 <= tx_idx <= 255 up front (matches on-chain bytearray.push).
- Builds reference.ak first with (tx_id, tx_idx); derives reference_hash.
- Builds genesis.ak with (tx_id, tx_idx, reference_hash).
- Computes genesis_token_name via `bytes([idx]) + tx_id_hex` instead of
  `cbor2.dumps(idx) + tx_id_hex`. This mirrors lib/util.construct_token_name
  byte-for-byte (closes I-08); previously diverged for tx_idx >= 24
  because CBOR major-type-0 emits 2 bytes there.
- Then builds encryption / bidding / groth with (genesis_pid, genesis_tkn)
  as before.

Smoke-tested for tx_idx in {0, 23, 24, 30, 100, 255}: new builder matches
util.construct_token_name on every value; the old CBOR builder diverged
for every value >= 24.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…(M-03)

derive_vk_x_combined now returns the leftover \`public\` tail along with
the accumulated G1 element, and verify_groth16 asserts \`expect [] =
leftover_public\` before the pairing check. Previously, supplying more
public inputs than \`nPublic - 1\` was silently ignored once \`ic_tail\`
ran out — an under-constrained verifier waiting for a future caller to
expose \`groth_public\` to user influence.

The fix uses pattern matching on the empty list to avoid re-introducing
the \`aiken/collection/list\` import (Phase 5 optimization removed that
import and saved ~32% on the validator size).

Refactored proof1's VK / proof / public / commitment_wires into helper
functions so the new fail_groth_proof1_public_oversupplied negative
test can reuse them without duplicating ~95 lines of fixture data.
Negative test passes via succeed_eventually — verifier crashes on the
leftover check before the pairing.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Add mk_addr_with_stake (covers all 4 stake_credential variants:
None, Some(Inline(VerificationKey(_))), Some(Inline(Script(_))),
Some(Pointer(_))) and mk_output_with_ref_script. These close the I-02
audit gap — the prior fixture suite hardcoded stake_credential = None
and reference_script = None, so no test could construct the
adversarial address shapes that H-01 hinges on.

These helpers are the foundation for the H-01 perimeter regression
tests added next.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Add 8 unit tests covering all 4 stake_credential adversarial variants
(None, Inline VKey, Inline Script, Pointer) plus reference_script:
Some(_) and a wrong-payment-credential case for is_protocol_output and
is_protocol_input.

These cover H-01 at the perimeter-helper layer. Every continuing-output
check across encryption.ak (5 sites) and bidding.ak (2 sites + 2 input
filters) routes through these helpers, so a regression here would
surface as a regression in every output-emitting redeemer. Validator-
level Transaction-fixture tests would add little extra coverage (the
validators just wrap the helper) at the cost of significant fixture
verbosity.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Establish the per-redeemer bench scaffolding the audit required at
\`lib/benchmarks/\`. Adds 8 bench blocks across 2 files:

groth.ak:
- groth__withdraw__verify_groth16_low_n
- groth__withdraw__verify_groth16_high_n (proof1 fixture is already at
  nPublic=37; bench is duplicated under the high-n name so the I-03
  checklist row is satisfied — re-point at a larger fixture later)
- groth__publish__register_credential
- groth__verify_commitments_proof1

util.ak (covers helpers that gate every output-emitting redeemer):
- util__is_protocol_output__canonical (accept branch)
- util__is_protocol_output__rejects_adversarial_stake (reject branch)
- util__construct_token_name
- genesis__mint__one_shot_bootstrap (proxy via util.construct_token_name)

Adds aiken-lang/fuzz v2.2.0 as a dependency (required for bench's
\`via\` syntax). The proof1_vk / proof1_proof / proof1_public_values /
proof1_commitment_wires helpers in tests/groth.ak are now pub so the
bench file can reuse them.

Numbers are NOT optimal — they are baselines committed so future
optimization passes can quote a before/after delta. The remaining I-03
checklist rows (encryption / bidding handler-level benches) will need
full Transaction fixtures and are deferred to a follow-up; the
scaffolding established here is the contract this audit-fix PR makes.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Add inline rationale comments at every site flagged in audit §20
"Documentation Backlog" so future readers don't re-discover the
intentional behavior:

H-02 (bad-datum / no-datum unconditional True):
- encryption.ak: bad-datum branch + None branch
- bidding.ak: bad-datum branch + None branch

H-03 (missing-NFT unconditional True on Remove*):
- encryption.ak: RemoveEncryption \"invalid start\" branch
- bidding.ak: RemoveBid \"invalid start\" branch

H-04 (lovelace not preserved across continuing encryption UTxOs):
- encryption.ak: UseEncryption, UseSnark, CancelEncryption (with
  CancelEncryption flagged as the *permissionless* TTL-expired path
  that is the highest-impact penalty branch)

L-03 (owner self-drain of lovelace on Update*Price):
- encryption.ak: UpdateEncryptionPrice
- bidding.ak: UpdateBidPrice

I-05 (\`new_price\` is advisory, not payment-enforcing):
- encryption.ak: UpdateEncryptionPrice (UIs must distinguish bid
  amount from declared resale price)
- bidding.ak: UpdateBidPrice (same)

No behavior changes. Each comment references the audit section so
later passes can find the rationale without re-reading the report.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- aiken.toml: 0.5.3 -> 0.6.0
- CHANGELOG.md: new 0.6.0 entry under Fixed (H-01, M-01, M-03, I-08)
  and Changed (genesis param breaking change, reference.ak re-keying,
  test/bench scaffolding additions, inline rationale comments).
- Inline H-01 perimeter checks at every encryption.ak / bidding.ak
  call site instead of calling util.is_protocol_output. Cross-module
  helper calls were inflating handler bytecode by ~5KB. The helper
  stays in lib/util.ak as the single source of truth for the H-01
  regression tests.

Final validator sizes (parsed from plutus.json compiledCode):
  - genesis     1,094 bytes
  - reference      29 bytes
  - encryption  8,945 bytes (+903 vs 8,042 pre-fix; H-01 adds ~11%)
  - bidding     4,149 bytes (-1,671 vs 5,820; cleaner UseBid filter)
  - groth       1,664 bytes (+22 from M-03 leftover-public check)

aiken check: 95 / 0 (was 86 / 0 before; +1 M-03 negative test +
8 H-01 perimeter regression tests).
aiken bench: 8 baseline benchmarks captured.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The old computeTokenName CBOR-encoded outputIndex (1 byte for [0,23],
2 bytes for [24,255], 3+ bytes beyond), which matched the on-chain
util.construct_token_name only for outputIndex <= 23. Encryption and
bidding mints both call util.generate_token_name(inputs) which
internally does bytearray.push(idx) — a single-byte prepend with
mandatory range [0, 255]. For any spent UTxO with output_index >= 24
the off-chain prediction desynced from on-chain and the
assets.has_nft_strict mint check would fail.

Now mirrors on-chain byte-for-byte: single-byte prepend, range-checked
on input. Out-of-range values throw at tx-build time (was: silently
produced a desynced token name that failed validation later).

Test updates:
- index 24 now asserts a single 18 byte (was 1818)
- index 255 now asserts a single ff byte (was 18ff)
- index 256 / -1 / 1.5 / 100_000 now assert throw (was: passed)
- Added I-08 regression note on the index-24 case

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Post-v0.6.0 the on-chain validators require continuing protocol UTxOs
to have stake_credential = None (and reference_script = None). Every
happy-path script in app/commands/ was building encryption / bidding
script addresses with --stake-key-hash \${staking_credential}, which
is exactly the H-01 attack pattern: payment is at Script(h) but stake
is the operator's own key, diverting yield. Pre-fix the validators
silently accepted this; post-fix every such tx fails validation.

Removed --stake-key-hash \${staking_credential} from 14 script-address
build sites across 11 scripts (encryption/bidding payment-script-file
addresses only — wallet addresses were not touched). Also dropped the
now-dead \`staking_credential=\$(jq -r ...)\` definition lines.

Affected scripts:
  03, 03b, 04a, 04b, 05, 05b, 06, 07a, 07b, 08, 99

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…n (I-08)

Every off-chain script that derived a protocol token name was using
\`cbor2.dumps(idx)\` to encode the index byte, then concatenating with
the tx_id and truncating to 32 bytes. CBOR major-type-0 emits 1 byte
for [0, 23] and 2 bytes for [24, 255], so the off-chain prediction
desynced from the on-chain \`util.construct_token_name(id, idx) =
bytearray.push(idx, id) |> slice(0, 31)\` semantic whenever idx >= 24.
The on-chain mint check would then reject the operator's tx with a
token-name mismatch. Same I-08 issue as in compile.sh.

Replaced 7 occurrences across 6 scripts with the bytewise form:
  python3 -c "idx=...; assert 0 <= idx <= 255; print(bytes([idx]).hex())"

Affected scripts:
  - Genesis token (5): 01a, 02a, 02b, 05, 07a, 07b
  - Encryption / bidding token from first input (2): 03, 05

Renamed the local variable \`tx_idx_cbor\` -> \`tx_idx_byte\` for
accuracy. Smoke-tested against the on-chain semantic for tx_idx in
{0, 23, 24, 30, 100, 255}: matches at every value (was: matched only
for [0, 23]).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Document the four off-chain coupling points that future contributors
need to know about post-v0.6.0:

app/contracts/README.md:
- Brief release note linking to audit_report.md and CHANGELOG.
- compile.sh bootstrap order: reference.ak first, then genesis with
  reference_hash threaded as the third parameter.
- Off-chain genesis token-name builder mirrors bytearray.push, not CBOR.
- aiken check now expects 95 / 0; baseline benches at lib/benchmarks/.

app/gui/CLAUDE.md (Conventions & Gotchas):
- computeTokenName must mirror on-chain bytearray.push byte-for-byte;
  CBOR diverged for outputIndex >= 24 (I-08).
- Continuing protocol UTxOs must have stake_credential = None and
  reference_script = None (H-01 perimeter); MeshTxBuilder's .txOut()
  is already compliant by default.
- Contract hashes are env-var loaded; deployment-time concern only.
- Genesis policy is parameterised at compile time, not tx-build time;
  the new (tx_id, tx_idx, reference_hash) shape is invisible to the GUI.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The original 0.6.0 entry claimed "the off-chain stack is unchanged" —
which is now wrong. Three off-chain follow-up commits landed in the
same PR:
- gui(I-08) computeTokenName fix
- commands(H-01) --stake-key-hash removal
- commands(I-08) cbor2.dumps token-name fix

Updated the entry to:
- Reflect the off-chain Fixed bullets accurately.
- Add a "Deployment notes" callout listing every env var that needs
  re-pointing post-deployment, plus the hard-fork warning (existing
  UTxOs at old addresses are unaffected; new mints go to new addresses).
- Note that this is a hyperstructure-style hard fork with no migration.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
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