@void-layer/codec 0.1.0 — pre-release: invoice codec, types & networks (Phase 1-3)#7
Conversation
ChainId union (5 EVM chains), NetworkConfig, PaymentProof, PaymentRequiredResponse, FrameContext, FrameState. Zero runtime deps. pnpm build produces dist/ with .js + .d.ts. npm pack --dry-run clean.
… (T-3) Pins rust-toolchain to 1.85.0 (minimum for edition 2024; 1.84.1 from previous dispatch was incompatible). cargo build --release + cargo test (1/1) both green. npm pack --dry-run succeeds.
…confirmed Corpus: 20 synthetic invoices via TS reference codec (140–564 B uncompressed, median 193 B). Brotli-wasm q=11 median compressed: 185 B — Plan-C NOT triggered. WASM blob measurements (wasm-pack 0.13.1 + wasm-opt -Oz, Rust 1.85.0): A (brotli-decompressor decoder-only): ~196 KB blob / ~201 KB pkg total B (brotli v7 full encoder+decoder): ~953 KB blob / ~959 KB pkg total C (brotli v7 no-stdlib): ≈ B — no decoder-only feature gate in v7 Verdict: B-i RULED OUT. B-iv CONFIRMED — Rust ships brotli-decompressor only; encode-wire is native JS-side. Matches Ignat pre-decision. Cargo.toml unchanged (T-P2-1 owns deps).
…th (T-P2-0b follow-up)
…eplan T-P2-7 deleted. B-iv (decode in Rust) measured at ~196 KB WASM blob vs the 80 KB hard cap. brotli-decompressor mandates a ~120 KB static dictionary with no decoder-only feature gate. B-v LOCKED: WASM ships TLV+keccak core only. Both compress and decompress live in the JS shim layer over brotli-wasm peerDep. Wire bytes unchanged (brotli-wasm q11 = same compressor as TS codec). Removes: brotli-decompressor v4.0.3, alloc-stdlib v0.2.2, alloc-no-stdlib v2.0.4 Cargo.lock staged with Cargo.toml per Phase 1 rule F-3.
Replace `map(|b| format!("{b:02x}")).collect::<String>()` with
`fold + write!` pattern in arb_wallet_address and arb_invoice.
cargo clippy --all-targets --all-features -- -D warnings now exits 0.
Implements the canonical-only Rust surface per B-v replan: - encode_invoice_canonical → TLV bytes, COMPRESSED_FLAG never set - decode_invoice_canonical → Invoice from canonical bytes, rejects 0x80 - lib.rs: exports 2 canonical fns + compute_content_hash; no wire variants - wasm.rs: exactly 2 #[wasm_bindgen] exports (encodeInvoiceCanonical / decodeInvoiceCanonical); BigInt-safe via serde_large_number_types_as_bigints - invoice.rs: Invoice/InvoiceFrom/InvoiceClient/InvoiceItem with Tsify + serde - encode.rs: TLV type registry, phf dict, mantissa/LEB128 encoding, domain sep - decode.rs: full TLV decode, domain separator verify, BigInt-safe mantissa No brotli dep in Rust. Wire compression lives in JS shim (src/index.ts).
Removed tsify (gloo-utils, web-sys, serde_json) and serde_json from [dependencies] — not needed since wasm.rs uses JsValue + serde_wasm_bindgen directly. Invoice structs keep Serialize+Deserialize for serde_wasm_bindgen. WASM blob after wasm-pack release build: 163 KB (hard cap 80 KB exceeded). Size checkpoint FAILED — see T-P2-7-alt final report for Kai escalation.
…7-alt) - src/index.ts: 4-name public API — encodeInvoiceCanonical/decodeInvoiceCanonical re-exported from WASM, encodeInvoiceWire/decodeInvoiceWire over brotli-wasm peerDep (COMPRESSED_FLAG + expand-fallback mirror reference compressPayload) - vitest.config.ts: vite-plugin-wasm + top-level-await; brotli-wasm aliased to the CJS node build for the Node test env - package.json: dist/ shim is the main entry (was raw WASM pkg); build runs wasm-pack then tsc, strips wasm-pack's pkg/.gitignore so pkg/ ships - 6 shim tests green (canonical + wire roundtrip, COMPRESSED_FLAG set/clear)
Iris Gate A2 flagged cargo fmt --check failures across 5 files (purely stylistic — line wrapping, import ordering, no logic change). fmt now clean; 81 Rust tests still green.
- wasm.rs: #[wasm_bindgen(js_name = receiptHash)] export - wasm_boundary.rs: boundary test (32-byte digest, deterministic) - index.ts: re-export receiptHash - Decision: receipt_hash ships in Phase 2 (plan-2c C6, Ignat 2026-05-20)
… (T-P2-9c) - proptest -> [target.'cfg(not(target_arch = "wasm32"))'.dev-dependencies] - getrandom 0.3 / wait-timeout don't build for wasm32; proptest must leave the wasm32 test graph - cfg-gate proptest-using test files to cfg(not(wasm32)) - wasm-pack test --node now compiles -> AC-9 boundary tests executable - see spec 056 plan-2c C8
…2-9b-fix) - drop #[wasm_bindgen(module = "/pkg/...")] extern block — a wasm_bindgen_test must not re-import the built JS bundle - call void_layer_codec::compute_content_hash directly, like bigint_boundary.rs - add receiptHash JS-export coverage to index.test.ts - fix pre-existing TS18046 errors on decoded: unknown (add DecodedInvoice cast) - fixes ERR_MODULE_NOT_FOUND under wasm-pack test --node
- codec build script invokes `wasm-pack build` since B-v (a1e6753); lint-and-build ran `pnpm -r build` without wasm-pack on the runner - pin wasm-pack 0.14.1 per Phase 1 D-A5 - add explicit `rustup target add wasm32-unknown-unknown` before install - fixes CI run 26179858633 'wasm-pack: not found'
… (1306→0 LOC monolith)
…-forward-compat)
Replace blanket KNOWN_TAGS reject with BOLT-12 parity rule:
- Unknown odd tags (bit 0 = 1) are silently ignored; bytes remain in
records and flow through compute_domain_separator unchanged — content_hash
stable across readers with different tag sets.
- Unknown even tags (bit 0 = 0) are rejected with UnknownExtension(tag) —
mandatory schema change, decoder cannot safely skip.
AC-2 verified: compute_domain_separator already iterates the full BTreeMap
minus type 31 with no KNOWN_TAGS filtering — no code change needed there.
Tests (RED then GREEN):
- y1_unknown_odd_tag_ignored: injects tag 39 via inject_extra_tag_and_recompute
helper; decode succeeds with correct Invoice fields
- y1_unknown_even_tag_rejected: injects tag 26; decode returns UnknownExtension(26)
Golden vectors added to v4-codec.json (additive, schema_version=1 unchanged):
- decode_unknown_odd_tag_ignored (roundtrip: false)
- decode_unknown_even_tag_rejected (roundtrip: false, expected_error set)
…ict-monotone-decode)
Add prev_type cursor in read_tlv_stream; reject any tag <= previous with
InvalidData("non-monotone TLV stream"). Pre-fix: BTreeMap silently
re-canonicalized out-of-order tags, allowing two wire representations of
the same logical invoice.
Duplicate-tag check preserved as defensive depth — structurally unreachable
under strict-monotone (duplicate implies tag == prev, caught first) but
costs only one branch.
All 33 existing vectors remain valid — they were produced by the
BTreeMap-emitting encoder which is key-ascending by construction.
Tests (RED then GREEN):
- y2_non_monotone_stream_rejected: tags [5,3,7] — FAILED before fix
(BTreeMap re-ordered silently), passes after
- y2_duplicate_tag_still_rejected: tags [1,1] — passes before and after
- y2_monotone_stream_accepted: tags [1,3,5] — passes before and after
Golden vector added to v4-codec.json:
- decode_non_monotone_rejected (roundtrip: false, expected_error set)
…ental) Add packages/codec/docs/tlv-type-ranges.md documenting: - Range partition: 0=forbidden, 1-127=spec-allocated, 128-255=experimental/vendor - Odd/even parity rule applied across both ranges - Full table of 26 currently allocated spec tags with parity + required flags - Allocation process for each range - Cross-references to decisions and spec 067 governance (AI#117)
…-even-forward-compat P1 fix)
… not deferred SECURITY.md stated the decoder "hard-rejects ALL unknown TLV tags" and odd-tag forward-compat was "NOT implemented … deferred to v1.2". Both claims were false: decode/mod.rs:172-181 ships odd-tag-ignore since the codec-bolt12-odd-even-forward-compat commit. Changes: - Split the single "Unknown TLV tag" row into EVEN (UnknownExtension, mandatory reject) and ODD (silently ignored per BOLT-12, bytes retained in TLV map + domain separator). - Replace the "Known limitations (v1.0–v1.1) / deferred" section with a LIVE-HAZARD warning: decode→re-encode is lossy for unknown odd tags (drops them → different canonical_bytes → different ERC-3009 nonce). Integrators MUST use originally-received canonical bytes as identity.
… TLV bytes (T2-1, T2-2) T2-1 — mantissa scale-aliasing reject (two call-sites): - decode_mantissa: rejects mantissa%10==0 when mantissa!=0 (trailing decimal zero must live in the zeros byte, not the mantissa). - decode_mantissa: rejects mantissa==0 with zeros!=0 (canonical zero is [0x00, 0x00]). Applied at both total (decode_mantissa) and item-rate (unpack_items) call-sites. - Error variant: InvalidData (matches scale>9 reject at ~line 108). T2-2 — trailing bytes inside TLV value (4 call-sites): - decode_mantissa: requires zeros_offset+1 == bytes.len() after reading. - unpack_items: requires offset == data.len() after the item loop. - decode_chain_id raw branch: requires consumed == raw.len(). - due_at in decode_invoice_canonical: requires consumed == due_at_bytes.len(). - Error variant: InvalidData, message names the field. - Chosen: 4 inline checks (no shared helper) — each field has unique context string and the patterns are not identical enough to abstract. Tests: 3 new unit tests in decode/tests.rs (TDD red→green verified). SAFETY: all 133+ existing frozen vectors pass unchanged.
…ecompression (T2-3, T2-6)
T2-3 — src/index.ts decodeInvoiceWire:
The old guard called brotli.decompress() (allocates full output) THEN
checked length — the bomb was already in memory before the check fired.
Replace with decompressBounded() using DecompressStream: feeds input in
chunks of MAX_DECOMPRESSED_BYTES, checks accumulated total BEFORE
appending each chunk, so the bomb never fully allocates. Corrupt input
still throws synchronously from DecompressStream.decompress().
brotli-wasm API confirmed from pkg.node/brotli_wasm.d.ts:
DecompressStream.decompress(input, output_size): BrotliStreamResult
BrotliStreamResult: { buf, code, input_offset }
code=0 ResultSuccess, code=1 NeedsMoreInput (terminal on full input),
code=2 NeedsMoreOutput (loop to drain).
T2-6 — scripts/lib/wire-codec.ts (dev-only, not published):
Same streaming pattern applied for defense-in-depth so a bomb vector
in the parity corpus cannot OOM CI.
tests/parity.test.ts: updated ERROR_SUBSTRINGS CompressionFailed
substring from "Brotli decompress failed" to "decompress failed" —
the streaming error is "Brotli streaming decompress failed: Error code N"
which does not contain the old substring.
All 365 TS tests pass; 133+ Rust tests pass; ZERO frozen-vector regressions.
…(DoS) A truncated brotli stream returns code=2 (NeedsMoreOutput) with buf.length===0 and input_offset===0 indefinitely, causing the DecompressStream loop to spin forever (verified: 499,999+ iterations before timeout on brotli-wasm@3.0.1). Added a no-progress guard (both buf.length===0 AND input_offset===0) inside the loop in src/index.ts and scripts/lib/wire-codec.ts. Both-zero is the precise stuck/truncation signal; buf.length===0 alone is a normal transient. Regression test added with vitest timeout:2000 — confirmed RED (hang) without the guard, GREEN (fast throw) with it.
…cit len guard The U256 mod-10 scale-aliasing check was copy-pasted verbatim in decode_mantissa and unpack_items (item rate path). Extract to check_mantissa_canonical(mantissa_bytes: &[u8]) with an explicit len <= 32 precondition guard (Shade concern: makes the check's own boundary structurally explicit instead of relying on downstream mantissa_to_decimal_string's len > 32 rejection). Behavior is unchanged for all valid inputs — 133 Rust tests + 18 frozen vectors still pass.
CTO audit + fix-pass (2026-05-29)43-agent workflow audit (6 dimensions + adversarial verify + completeness critic) found the branch was CI-RED and structurally un-greenable — not the inherited "merge-ready" state. Fixed in this push (
Reviewed MERGE-CLEAN by security (Shade) + quality gate (Iris): 201 Rust + 366 TS tests pass, 100% index.ts coverage, 0 frozen-vector regressions. Follow-ups (pre-publish, NOT merge blockers): #16 (byte-identity freeze-gate / D1), #17 (Node-runnable artifact + networks barrel + release.yml + Rust coverage gate), #18 (hygiene). Merge with a merge commit, not squash (intentional audit trail). |
… ignored per BOLT-12; covered by even-reject + odd-ignore vectors)
…n (coverage enforced by full suite)
✅ CI GREEN — merge-clean (run 26661066037)Two further masked-failure layers surfaced after the initial push (each fix let the next previously-
Final tip Merge with a merge commit, not squash. |
@void-layer/codec0.1.0 — pre-releasePre-1.0 release of the @void-layer invoice codec standard: the open, versioned wire format that powers privacy-first crypto invoicing. This PR brings the entire project (149 commits, Phase 1 → Phase 3 + BOLT-12 forward-compat + Tranche A/B decoder hardening +
types/networksenrichment) ontomainas a single reviewable unit before the first npm publish.Pre-release status
0.1.0— pre-1.0, public API may still change@void-layer/{codec,types,networks}— first npm publish pending (manual0.1.0, OIDC for0.2.0+)types+networksnow carry real vitest suites (no longer Phase-1 stubs)mergeable,mergeStateStatus: CLEANMonorepo layout
@void-layer/codecwasm-pack) + TS shim@void-layer/typesinvoice,network,frame,x402@void-layer/networksdefineChain— no RPC keys (Constitution VI)pnpm workspace, Node ≥24, pnpm ≥10. Changesets for versioning (
access: public).Implementation detail
Phase 1 — scaffold
3-package skeleton; CI scaffold with the LOCKED
release.ymlfilename; ESLint 9 flat config carrying a Constitution VI regex gate that fails the build on any inlined RPC key; Prettier + rustfmt + clippy; docs foundation (README, SECURITY, architecture-overview, TLV-registry contributing guide).Phase 2 — codec implementation
Rust primitives:
CodecError(14-variant after R-tranche split) · LEB128 varint withMAX_BYTES=37overflow guard · TLV encoded over aBTreeMapfor deterministic byte-stable ordering ·phfcompile-time perfect-hash dictionaries for app/chain tags ·keccak256content-hash.B-v codec: canonical
encode/decodecompiled to WASM; the JS wire layer is a thin shim overbrotli-wasm(whole-payload Brotli compression per Constitution IV); amounts handled in the U256 domain to avoid float drift.The codec is dual-surface: the same canonical bytes are produced by the Rust path and the TS path.
Phase 2.5 — BOLT-12 forward-compat & hardening (2026-05-26)
Pre-publish prior-art review of the only other production TLV + tagged-field + content-hash codec (Lightning BOLT-12). Three Y-statements filed under
.ai/ops/decisions/and shipped before the reversibility window closes at npm publish:UnknownExtensionreject (mandatory schema bump).read_tlv_streamrejects non-monotone wire ([5,3,7]→InvalidData("non-monotone TLV stream")).Companion:
packages/codec/docs/tlv-type-ranges.md(registry table). Audit C-2 rationale (decode/mod.rs) is now inverted: preserving unknown-odd bytes IS the determinism mechanism (not the leak it would be in the old projection-based hash). Comment rewritten accordingly.Phase 2.6 — Tranche A/B decoder hardening + corpus expansion
Adversarial pre-publish hardening of the attacker-facing decode path (a two-lens review — coverage + adversarial — caught a truncation-HANG DoS that the first bomb-guard fix itself introduced):
MAX_DECOMPRESSED_BYTES); truncated-stream hang guard so a malformed Brotli frame can't spin the decoder.0x01prefix; enforce exactTLV_DECIMALSlength == 1; reject mantissa scale-aliasing, trailing TLV bytes, and quantity scale > 9.due_atordering, zero-amount, precision-loss past 9 decimals.decode_unknown_odd_tag_ignored,decode_unknown_even_tag_rejected,decode_non_monotone_rejected). Original behavioural vectors untouched.encode.rs/decode.rssplit into submodules; dedicated error variants + sharedlimitsmodule; zero-allocapply_dict; test monoliths (1306-LOCedge_cases.rs,parity.rs) split into category-focused files. Behaviour-preserving.types/networksenrichmentNo longer Phase-1
echostubs:types—Invoice/InvoiceFrom/InvoiceClient,network,frame,x402declarations + real vitest suite.networks—chains, curatedtokenslist with metadata, wagmidefineChainconfigs,explorer/rpc/get-chainhelpers; 81 tests, 100% coverage; still zero RPC keys (Constitution VI gate holds).Phase 3 — publish-prep
Real
release.ymlpublish job —workflow_dispatch, OIDC trusted publishing (id-token: write, provenance attestations), Rust +wasm-pack 0.14.0toolchain,pnpm changeset publish. Per-packageLICENSE(MIT),publishConfig { access: public, provenance: true }, npm metadata, per-packageCHANGELOG.md. All third-party GitHub Actions SHA-pinned;wasm-packcurl-pipe install replaced.Validation & CI
v4-codec.json,schema_version=1) — behavioural + unicode + parametric + demo + BOLT-12 boundary cases.vector-parityjob); plus ats-rust-parityjob againstvl/apppinned SHA, gated by aci-gatemeta-job so a skipped parity job can't be mistaken for a pass.wasm-pack test --nodeboundary tests; macOS sanity job.npm pack --dry-runper package confirms tarball contents (no Rust source,target/, or vectors leak —filesallowlist).Reviewer notes
9dba355(Gate B hotfix) was never onorigin/main— first review here. Phase 2 Gate B ran 2 rounds: R1fail(4×P1) → hotfix → R2pass.schema_versionbump. Iris validation: PASS (BOLT-12 tranche 2026-05-26, 10/10 AC; Tranche B hardening re-audited 2026-05-29, CI green).vl/app— Phase 3 cutover wholesale-replacessrc/shared/lib/tlv-codec/with@void-layer/codec, so TS reader changes would be discarded.release.yml(andci.yml) are now pinned to commit SHAs, not mutable tags. No@vNtags remain.assert-size.shhalts CI on any change that breaches the cap. Pre-decided swap criterion: if headroom <1% on a future golden-vector wave, swap to zstd-with-dictionary (P1 follow-up Y-statement not yet authored). Seepackages/codec/docs/bundle-budget.md.Review checklist
MAX_BYTESguard,keccak256content-hash[5,3,7]rejected; behavioural vectors monotonetypes/networksenriched suites — Invoice types, wagmi configs, RPC-key gate holdsrelease.ymlOIDC publish job + SHA-pinned actionspublishConfig/ metadata across the 3package.jsonfilesCloses
2026-05-20-codec-compression-strategy-b-vdecision)Rollback
Previous
origin/mainwas2cf3ba8. Restore withgit push --force origin 2cf3ba8:main.🤖 Generated with Claude Code