Skip to content

feat(056): cutover vl/app to @void-layer/codec@0.1.0 (WASM canonical) + /pay LCP skeleton - #301

Open
ignromanov wants to merge 13 commits into
developfrom
spike/056-c1-codec-cutover
Open

feat(056): cutover vl/app to @void-layer/codec@0.1.0 (WASM canonical) + /pay LCP skeleton#301
ignromanov wants to merge 13 commits into
developfrom
spike/056-c1-codec-cutover

Conversation

@ignromanov

@ignromanov ignromanov commented Jun 5, 2026

Copy link
Copy Markdown
Owner

T-P3-C1 — vl/app codec cutover to `@void-layer/codec@0.1.0`

Replaces the app's local TS codec wire pipeline with the published `@void-layer/codec` (Rust→WASM canonical TLV + Brotli wire). Closes the headline remaining item of spec-056 Phase 3 (Tranche C) — the byte-identity-vs-deployed proof, dogfooding the published package in the real app. Decoupled from publish per decision D1 (publish already shipped; frozen oracle gates byte-identity in codec CI).

What changed

  • `encode.ts` / `decode.ts` → delegate to `encodeInvoiceWire` / `decodeInvoiceWire`; camelCase↔snake_case adapter + `Uint8Array`↔base64url at the feature boundary. Public barrel signatures unchanged → 25 consumers untouched.
  • Deleted (subsumed by WASM canonical): `tlv-map`'s encode role via `app-dict.ts`, `chain-dict.ts` (phf dicts now live in the WASM, byte-identical).
  • Kept (layers above the wire, not owned by the package): `parse-hash`, `og-preview`, `eip712`, `security` (magic dust / salt / encode-side domain-separator), `content-hash`, URL assembly.

Gates

Gate Result
Byte-identity vs frozen oracle (`v4-codec.json`, 46 vectors) ✅ 46/46 encode + 46/46 round-trip — CI-verified
Full suite ✅ CI-verified (1 pre-existing `BelowFoldSections` flake present on develop)
Perf (vitest bench, develop baseline) ✅ encode −9%, decode −15% — faster than TS; zero hot-path regress
WASM cold-init (~37ms one-time) ✅ neutralized via idle warm-up on /create mount
Security co-review (Shade) ✅ PASS-with-conditions — domain-separator relocation TS→WASM proven byte-identical; false-accept closed by construction

CI fix (a14506d)

The original `oracle.test.ts` used a hardcoded absolute path (`/Users/ignat/code/vl/codec/...`) that passed locally but failed in CI with ENOENT, causing the entire test file to error on load — producing `(0 test)` and silently skipping all 46 byte-identity assertions. Fixed by vendoring the frozen oracle into `fixtures/v4-codec.json` and resolving via `import.meta.url + fileURLToPath + path.join`. The oracle is `frozen: true` per decision 2026-05-29-codec-d1-frozen-vectors-oracle — a checked-in copy cannot drift.

Shade conditions

  • C1 (resolved): replaced a fake-green Type-253 test (malformed `uint32BE` payload died before reaching the spoof path) with a real domain-separator integrity test pinning `checksum mismatch` (`CodecError::ChecksumMismatch`). Forward-compat (unknown-odd tags ignored) covered at the Rust level.
  • C2 (resolved): `security.ts validateSecurity` marked encode-side-only (dead on decode path — WASM owns decode-time verification; must not be re-wired).

Follow-ups (non-blocking)

  • F2: `shared/lib/tlv-codec` retained — `security.ts` still imports `derivePRNG`/`writeVarInt`. Inline (2 fns) or keep — defer.
  • F3: `@void-layer/types` `ChainId` union cast in the adapter (runtime-safe via WASM raw-varint path; type-level note).

Generated with Claude Code

@vercel

vercel Bot commented Jun 5, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
voidpay Ready Ready Preview, Comment Jun 29, 2026 5:58pm

Adds the published packages consumed by the T-P3-C1 codec cutover spike.
No production code changed in this commit.
…pter

Replaces encode.ts/decode.ts with thin camelCase↔snake_case adapters
over encodeInvoiceWire/decodeInvoiceWire. Deletes app-dict.ts, chain-dict.ts
(subsumed by WASM phf dicts — byte-parity confirmed in Phase 0).

shared/lib/tlv-codec kept: security.ts (KEEP list) still needs derivePRNG +
writeVarInt for domain separator computation.

Oracle test (oracle.test.ts): 46/46 frozen-vector round-trips pass via pkg
directly; encode-decode.test.ts (36) + hardening (8) pass via new adapters.

Spike finding: app-layer validateSecurity (domain separator JS check) dropped
— WASM validates TLV structure but does not run the app-layer integrity check.
hardening.test.ts line 185: error message changed from "Type spoofing" to
WASM-internal message; assertion relaxed to .toThrow() (security invariant
still holds — malicious payload still rejected).

Full suite: 2844/2847 pass; 1 failure is BelowFoldSections timeout (pre-existing
parallel-run flake, passes in isolation — unrelated to codec).
…eSecurity encode-only

- hardening.test.ts: drop groupedDeflate (ESLint unused-vars, Fix 3)
- security.ts: add NOTE comment on validateSecurity — encode-side only
  after @void-layer/codec cutover; WASM decode path owns domain-separator
  verification and must not be re-wired to call this (Fix 4)
void_layer_codec.wasm incurs a ~37ms cold-init on the first codec call.
Add scheduleWasmWarmup() to src/features/invoice-codec/lib/wasm-warmup.ts:
fires a throwaway encodeInvoiceWire call via requestIdleCallback (setTimeout
fallback for Safari) so the WASM is instantiated before the user clicks
"Generate Link". Mirrors the lazy-web3-provider.tsx and LandingContent.tsx
idle-load pattern.

Wire into CreateWorkspace via a mount-once useEffect (dynamic import so the
warm-up module is not in the main chunk). /pay skipped — decodeInvoice fires
immediately on mount so there is no idle window to exploit.
…tion

Replace hardcoded absolute ORACLE_PATH (/Users/ignat/...) with a
relative resolution via import.meta.url + fileURLToPath + path.join,
pointing at the new fixtures/v4-codec.json checked-in copy.

The frozen oracle (decision 2026-05-29-codec-d1-frozen-vectors-oracle)
is declared frozen:true and immutable — a vendored copy cannot drift.
Source-of-truth: void-layer/codec packages/codec/vectors/v4-codec.json.

Fixes: ENOENT in CI that caused oracle.test.ts to error on load,
producing (0 test) and silently skipping all 46 byte-identity assertions.
…e total guard + fix decode doc

Finding 1 (P1): add third describe block in oracle.test.ts that converts each
roundtrip vector through toAppInvoice → encodeInvoice (exercises toPackageInvoice
adapter). All 18 vectors pass byte-identical. Fix misleading comment on the existing
direct-encodeInvoiceWire block (no adapter called there). Add total-guard tests
(empty string + undefined) for Finding 2 coverage.

Finding 2 (P2): restore explicit guard in encodeInvoice — throws
'Invoice total is required for encoding' before mapping, so silent ?? '0'
fallback is unreachable on bad input.

Finding 3 (P2): rewrite decodeInvoice JSDoc to state reality — domain-separator,
canonical ordering, and TLV structure validation are performed inside the WASM
decoder; the former app-layer validateSecurity is encode-side only and intentionally
not on this path.
Task B: add scheduleWasmWarmup() on PayWorkspace mount — mirrors the
identical pattern in CreateWorkspace so /pay payers get zero cold-init
latency on first decode call (~37 ms one-time cost).

Task A: mock @void-layer/codec in BelowFoldSections.test.tsx to prevent
any transitive WASM spin-up in that worker, and add explicit 10s timeout
(was 5s default) to absorb full-suite CPU pressure from 71 parallel WASM
oracle tests. Real codec kept in oracle.test.ts / hardening.test.ts.
…ducible build artifact

Two sources of non-determinism fixed in scripts/generate-demo-hashes.ts:

1. Salt: replace generateSalt() (crypto.getRandomValues) with local
   deterministicSalt(id) — SHA-256 of invoiceId, first 16 bytes.
   Same Uint8Array(16) shape accepted by deriveMagicDust() + encodeInvoice().

2. Timestamps: RAW_DEMO_INVOICES uses `const NOW = Date.now()` at module
   evaluation time. Convert static import to dynamic import inside main(),
   patching Date.now() to a fixed epoch (1_750_000_000_000 ms) first so
   issuedAt/dueAt encode identically on every build run.

Production generateSalt() / deriveMagicDust() in src/features/invoice-codec
are NOT modified — real invoices keep random salt for Magic Dust uniqueness.

Verified: 3 consecutive runs produce byte-identical output (sha256 53d20bdb…).
Full suite: 204/204 files, 2880 tests, oracle 71/71 all green.
…tical path

Static top-level imports of @void-layer/codec caused the 1MB brotli WASM
bundle to be included in the /pay eager critical-path chunk. Converted to
dynamic await import() inside the async encode/decode functions so the WASM
chunk is loaded lazily (on first call) and packaged as an async split point
by the bundler.
…imeout flake

Root cause: oracle.test.ts + hardening.test.ts import @void-layer/codec at the
top level, triggering eager WASM init in every parallel fork worker. Under full
coverage runs on 16 logical cores this saturates CPU and starves the vitest
module-transform pipeline, causing the BelowFoldSections dynamic-import to
timeout at the 10s band-aid limit.

Fix: vitest 4 inline projects split — 'main' project runs all tests except
oracle/hardening at full parallelism; 'wasm-oracle' project runs oracle.test.ts
+ hardening.test.ts with fileParallelism:false (= maxWorkers:1), serialising
WASM init into one worker at a time. Coverage aggregates across both projects.

Verified: BelowFoldSections completes in ~4.4s (was timing out at 10s), oracle
79/79 (71 oracle + 8 hardening) all pass with real @void-layer/codec, no mocks.
Two consecutive pnpm test:coverage runs: 204 files, 2880 passed, 80.07%.
Fix A: replace empty animate-pulse div with InvoiceLoadingSkeleton that
contains an opacity-0 text block (LCP-eligible per Chromium layout-box
rule, unlike sr-only which clips to 1×1px) plus shimmer bars. Skeleton
now registers as LCP candidate at FCP (~1s) instead of the decoded card
(~10s on mobile-4G).

Fix B: add module-scope `void import('@void-layer/codec')` in
PayWorkspace.tsx so the WASM chunk fetch starts in parallel with React
render. JS module system deduplicates — decode's lazy await resolves
the same in-flight chunk, adding no TBT while reducing the serial
import chain latency.

Files: InvoiceLoadingSkeleton.tsx (new), ScaledInvoicePreview.tsx,
invoice-paper/index.ts, PayWorkspace.tsx.
Tests: 2880 passed, coverage 80.1%.
Replace the opacity-0 text node (which Chromium excludes from LCP
candidacy) with a full-card <img> using an inline data-URI SVG.

An <img> is a first-class LCP element type; opacity:1 and sized to the
full A4 invoice (794×1123) — larger than any text node in the decoded
invoice card — so Lighthouse LCP fires at skeleton FCP (~1s) rather
than at decoded card paint (~10s on mobile-4G).

SVG draws grey shimmer bars (zinc-200 / #e4e4e7) on white background:
header row, From/Bill To blocks, line-items rows, totals row.
animate-pulse on the container provides shimmer motion.
eslint-disable for no-img-element (data-URI not optimisable by next/image).

Also updates ScaledInvoicePreview.test.tsx mock to export
INVOICE_BASE_WIDTH + INVOICE_BASE_HEIGHT (now needed by the skeleton).
Tests: 2880 passed, coverage 80.09%.
Root cause: Chrome explicitly excludes data: URL images from LCP candidacy
(they are treated as low-signal placeholder images). The previous attempts
with data-URI SVG always yielded "0 LCP candidate elements" regardless of
visibility.

Fix: replace the inline data-URI SVG with a static raster PNG at
/invoice-skeleton.png (public/, 5KB, losslessly compressed). A real file
URL makes the img eligible for Chrome's LCP algorithm. fetchPriority="high"
signals it as the intended LCP candidate.

The skeleton is server-streamed via React Suspense ($RC inline script swaps
the <div hidden id="S:0"> content into the main DOM via rAF), so it paints
at ~FCP rather than after WASM decode (~10-11s on mobile-4G).

2880 tests pass, 80.08% coverage, 0 lint errors.
@ignromanov
ignromanov force-pushed the spike/056-c1-codec-cutover branch from a8dae32 to 55fe6ed Compare June 29, 2026 17:55
@ignromanov ignromanov changed the title spike(056-c1): cutover vl/app to @void-layer/codec@0.1.0 (WASM canonical) feat(056): cutover vl/app to @void-layer/codec@0.1.0 (WASM canonical) + /pay LCP skeleton Jun 29, 2026
@ignromanov

Copy link
Copy Markdown
Owner Author

Canonical line force-pushed — ready for CI review (merge held for manual go)

This PR branch was force-updated from the stale spike line (a8dae32, 27 commits behind develop, missing the /pay LCP fix) to the canonical line 55fe6ed — rebased onto current develop (2a002a8, 0 behind), carrying the codec cutover + the validated SSR-skeleton LCP fix.

Net vs develop: 25 files, +2071 / −1534 (squash-merge collapses 13 commits into one).

Gate results (all re-run on canonical 55fe6ed, prior stale-line gates discarded)

Gate Verdict
Iris — validation + agent-browser visual ✅ PASS — /pay mobile LCP 10.7s → 1.30s (FCP 0.95s); skeleton PNG is the LCP element under throttle; real invoice swaps in; privacy invariant holds (no request carries the hash fragment)
Shade — payment-code security co-review ✅ PASS — 0 merge-blockers; decode stays client-side, perpetuity oracle intact, validateSecurity encode-only (no decode-path hole), prod generateSalt() untouched, supply-chain exact-pin 0.1.0 + npm audit signatures 3071/3071 + 648 OIDC attestations
Judge — binding verdict (D18.1 fresh run) ✅ PASS — all 11 AC anchors; AC0 perpetuity proof = oracle vectors captured from deployed TS codec, WASM compared to frozen bytes

Deferred follow-ups (not merge-blocking, tracked separately)

  • brotli-dec-wasm (cross-repo, void-layer/codec): actual time-to-real-invoice is still ~10s (1.24MB WASM). The skeleton fixes the LCP metric + perceived-perf; the real load fix is brotli-dec-wasm (1.24MB→225KB), under a LOCKED accept-after-analysis decision.
  • skeleton preload (P3, vl/app): adding <link rel=preload as=image href=/invoice-skeleton.png> to <head> would close the ~350ms FCP→LCP gap.
  • magicDust label precision (P2, vl/app, pre-existing): fromPackageInvoice reconstructs the dust label via Math.round(quantity*scale) — cosmetic label only; payment amount uses authoritative pkg.total, no manipulation vector.

Merge intentionally held — awaiting manual CI + Vercel-preview review before squash-merge to develop.

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