sememe 0.4.22 → 0.4.24

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -69,7 +69,7 @@ What this changes about the thesis, stated plainly: sememe's decode path is stro
69
69
 
70
70
  **HADRo's 195 refusals were NOT source lag — stored discriminators are stored at ANY length (fixed and measured 2026-11-12, `tools/measure-hadro-short-discriminator.mjs` -> `research/hadro-short-discriminator-2026-11-12.json`, pinned by `test/hadro-short-discriminator.test.mjs` 7 tests + `test/hadro-short-discriminator-measurement.test.mjs` 5 tests).** The ecosystem measurement had labelled HADRo (`HADRo…VDQ8`) a "total source-lag face" at 0/195. It was wrong, and the PULS V two-tree measurement (`git archive c304e80` for the pre-fix tree, a byte-identical probe in both) proved why: the program's published IDL stores **38 one-byte instruction discriminators** — a real, chain-accepted wire table (unique values 0x00..0x26) — and `withDerivedDiscriminators` treated only 8-byte arrays as "stored", so every stored byte was OVERWRITTEN with Anchor's 8-byte sighash, against that module's own header rule ("stored discriminators are never changed"). The replaced table matches no payload: pre-fix 0/195 decoded; post-fix **123/195 decode through the product path** (51 full byte-exact, 72 `setCurve` described-exact with reported trailing bytes), and the remaining 72 (`setRiskCurve`) stay **honestly refused and NAMED** — that IDL declares a vector length the wire bytes do not carry. The same `length === 8` assumption in `src/union.mjs`'s rename-evidence map was fixed the same way (stored means any non-empty byte array). The fix is surgical and the measurement pins that: pump.fun (400/842, 249 byte-exact), pfeeU (5/464) and Eternal (227/242 byte-exact) verdicts are BYTE-IDENTICAL between the trees; Eternal's 76 stored 1-byte EVENT discriminators are now preserved too (the event dispatch path reads 8-byte heads, so that preservation changes no decode in this window — stated, not smoothed). Honest limits ride in the artifact; the unit face flips `false -> true` on both instruction and event preservation, and the store face `0/38 -> 38/38`.
71
71
 
72
- The stranger-facing truth this pins down: **the 1,000-transaction decode is one command, offline, and fails loudly.** What it does not prove is that an outside person has run it — and that is now **measured, not asserted** (`tools/measure-foreign-run.mjs` -> `research/foreign-run-2026-11-05.json`): verdict `not-distributed-not-consumed`, i.e. zero non-owner signals on every channel that was read — 0 non-owner stars/forks/issues/PRs on this repo, 0 non-owner comments on the two outbound threads (`step-finance-oss/step-ingestooor#1` and `jup-ag/rfq-webhook-toolkit#96`, both open), 0 foreign-authored threads containing this project's slug. The first external run therefore remains the open gate. The measurement has limits and names them: the broad mention sweep covers 92 of 93 results (GitHub's search index hides one result in every ordering — measured, not assumed), a registry `404` for the bare name `sememe` on npm/crates.io/PyPI means **no install channel is published yet**, never "nobody used it", and the code-index absence is only claimed for the indexed part of GitHub (the sweep's self-reference control passes). Reproduce with `npm test` (includes `test/stranger-decode.test.mjs`, which runs this tool against both fixtures and falsifies it three ways: tampered payload, missing store, and a mutation of the pass/fail logic). The funnel a stranger walks before that first run is measured separately (2026-11-05, `tools/measure-stranger-funnel.mjs` -> `research/stranger-funnel-2026-11-05.json`): a fresh GitHub clone ran both quickstart commands verbatim (1,089 and 6,337 payloads, all decoded byte-exactly, exit 0), the published tag `v0.3.0` installed into an empty app with no npm account, and its entry decoded 1,119/1,119 real payloads byte-exactly from `node_modules/sememe` — the harness (tools/test/research) does not travel into the install. The quickstart block itself is pinned by `test/quickstart-funnel.test.mjs`, which caught and fixed one defect on 2026-11-05: the documented output line was the pre-2026-11-04 schema (missing `rpcEncoding`, `chainParsedInstructions`, `missingBytes`) — a stranger comparing her terminal to the README saw two different products. The README now documents the line the tool actually prints, and a hand-edited documented line goes red.
72
+ The stranger-facing truth this pins down: **the 1,000-transaction decode is one command, offline, and fails loudly.** What it does not prove is that an outside person has run it — and that is now **measured, not asserted** (`tools/measure-foreign-run.mjs` -> `research/foreign-run-2026-11-05.json`): verdict `not-distributed-not-consumed`, i.e. zero non-owner signals on every channel that was read — 0 non-owner stars/forks/issues/PRs on this repo, 0 non-owner comments on the two outbound threads (`step-finance-oss/step-ingestooor#1` and `jup-ag/rfq-webhook-toolkit#96`, both open), 0 foreign-authored threads containing this project's slug. The first external run therefore remains the open gate. The measurement has limits and names them: the broad mention sweep covers 92 of 93 results (GitHub's search index hides one result in every ordering — measured, not assumed), a registry `404` for the bare name `sememe` on npm/crates.io/PyPI meant **no install channel was published yet** at that time, never "nobody used it" — npm published the name on 2026-11-12 (crates.io/PyPI still have no `sememe`; both are aliasing checks, not consumption evidence), and the code-index absence is only claimed for the indexed part of GitHub (the sweep's self-reference control passes). Reproduce with `npm test` (includes `test/stranger-decode.test.mjs`, which runs this tool against both fixtures and falsifies it three ways: tampered payload, missing store, and a mutation of the pass/fail logic). The funnel a stranger walks before that first run is measured separately (2026-11-05, `tools/measure-stranger-funnel.mjs` -> `research/stranger-funnel-2026-11-05.json`): a fresh GitHub clone ran both quickstart commands verbatim (1,089 and 6,337 payloads, all decoded byte-exactly, exit 0), the published tag `v0.3.0` installed into an empty app with no npm account, and its entry decoded 1,119/1,119 real payloads byte-exactly from `node_modules/sememe` — the harness (tools/test/research) does not travel into the install. The quickstart block itself is pinned by `test/quickstart-funnel.test.mjs`, which caught and fixed one defect on 2026-11-05: the documented output line was the pre-2026-11-04 schema (missing `rpcEncoding`, `chainParsedInstructions`, `missingBytes`) — a stranger comparing her terminal to the README saw two different products. The README now documents the line the tool actually prints, and a hand-edited documented line goes red. Re-measured 2026-11-13 after the registry channel opened (`research/foreign-run-2026-11-12.json`): the registry check reads `npm: found` for the bare name while the verdict stays `not-distributed-not-consumed` — publish is not consumption, and the first external run remains the open gate.
73
73
 
74
74
  ## Your own transactions: raw RPC responses (the shape you already hold)
75
75
 
@@ -132,7 +132,7 @@ sememe is a local, content-addressed schema store with a verification contract:
132
132
  ## Install (as a consumer, not a contributor)
133
133
 
134
134
  ```bash
135
- npm install github:auroraxo/sememe#v0.4.22 # tagged release; no npm account needed
135
+ npm install github:auroraxo/sememe#v0.4.24 # tagged release; no npm account needed
136
136
  ```
137
137
 
138
138
  ```js
@@ -151,14 +151,14 @@ The **library** refuses the same way: importing `sememe/pull` or `sememe/gap` wi
151
151
  **From an install (not a clone), the CLI is `npx sememe …`** — npm exec resolves the package's own `bin` from `node_modules`; the bare command `sememe` is not on your `PATH` after a plain `npm install`, and `node bin/sememe.mjs` works only inside a clone of the repository (both measured 2026-11-05 from a real tarball install). The consumer journey from the install, verbatim:
152
152
 
153
153
  ```bash
154
- npm install github:auroraxo/sememe#v0.4.22 @solana/web3.js # acquisition needs the peer
154
+ npm install github:auroraxo/sememe#v0.4.24 @solana/web3.js # acquisition needs the peer
155
155
  npx sememe pull whirLbMiicVdio4qvUfM5KAg6Ct8VwpYzGff3uctyCc --store ./store
156
156
  npx sememe decode whirLbMiicVdio4qvUfM5KAg6Ct8VwpYzGff3uctyCc 2b04ed0b1ac91e62a40d0100000000000100000000000000af331ba8327fbb35b1c4feff00000000010001010000000601 --store ./store
157
157
  ```
158
158
 
159
159
  `./store` is the local content-addressed store the error `no sememe store at ./store: run "sememe pull" first` tells you to create — the `pull` above is that creation step; the vendored programs (System, ComputeBudget, SPL Token, Token-2022, Raydium AMM v4, SPL Associated Token Account) need no store at all: `npx sememe decode-native 11111111111111111111111111111111 <payload>` answers offline from the pinned upstream tables. If you ever point the decoder at a store directory that does not exist yet, the refusal names the fix; `npx sememe` is the form of that fix that runs where you are. Measured 2026-11-05 from the packed tarball of this tree: install (zero deps) → `npx sememe pull` (live mainnet) → `npx sememe decode` (byte-exact, live-pulled object byte-identical to the fixture store object `0b886e37…660f`).
160
160
 
161
- Publishing to the npm registry itself is deliberately not done: the files allowlist already ships none of this repository's tests, fixtures or qualification harness (pinned by `test/package.test.mjs`), and no registry name squatting is part of the plan. The git tag is the release artifact; `test/package.test.mjs` (the contract above), `tools/e2e-package-consumer.mjs` (a real install into an empty directory) and `test/install-surface.test.mjs` (the shipped README's install commands run verbatim from the packed tarball) keep it honest.
161
+ Publishing to the npm registry changed on 2026-11-12: the `sememe` name is now live on registry.npmjs.org (first shipped as 0.4.21; the measured registry install of `sememe@0.4.22` into an empty app decoded the Whirlpool fixture payload `swap_v2`, `roundTripByteExact: true`). `npm install sememe` and `npm install github:auroraxo/sememe#v0.4.24` deliver the same product files from the same code: the files allowlist already ships none of this repository's tests, fixtures or qualification harness (pinned by `test/package.test.mjs`), so the git tag remains the release artifact — `test/package.test.mjs` (the contract above), `tools/e2e-package-consumer.mjs` (a real install into an empty directory) and `test/install-surface.test.mjs` (the shipped README's install commands run verbatim from the packed tarball) keep both channels honest.
162
162
 
163
163
  ## Working now: pin the answers (`sememe lock` + `sememe verify`, 2026-11-05)
164
164
 
@@ -271,7 +271,8 @@ freshness against the chain; `scope: "extra"` tells you *where* the damage
271
271
  is, not *who* wrote it; and the reported extra is an unpinned program, so a
272
272
  store can still gain a program the lock has never seen (by design — that is
273
273
  the minimums rule). No third party has run this; there are 0 external users,
274
- 0 stars, and `npm publish` remains credentials-blocked. The published
274
+ 0 stars (`npm publish` was credentials-blocked until 2026-11-12, when the
275
+ registry name shipped — see the Install section). The published
275
276
  `v0.4.10` tag keeps this defect (a tag is immutable); `v0.4.11` carries
276
277
  the fix.
277
278
 
@@ -2636,7 +2637,7 @@ the next release.
2636
2637
 
2637
2638
  This release carries the raw-LE wiring above to the install channel: the
2638
2639
  pinned tag now contains `src/rawle.mjs` and every wired boundary, so
2639
- `npm install github:auroraxo/sememe#v0.4.22` degrades nobody (Whirlpool,
2640
+ the v0.4.22 install degrades nobody (Whirlpool,
2640
2641
  shank, name-only anchor controls pin unchanged) and answers the diabloarb
2641
2642
  dialect where v0.4.21 refused it by name.
2642
2643
 
@@ -2680,6 +2681,68 @@ Suite after this release: **954 tests / 950 pass / 4 named network skips /
2680
2681
  surface re-measured against that tag is clean (newest = latest = pinned,
2681
2682
  0 defects — `research/release-surface-2026-11-12b.json`).
2682
2683
 
2684
+ ## v0.4.23 / v0.4.24: struct-internal absent fields decode under the evidence gate — and the registry stays truthful (2026-11-13)
2685
+
2686
+ The absent-field policy (`absent: "report"`) only fired at TOP-LEVEL argument
2687
+ boundaries, so a payload that ends on the boundary of the LAST FIELD INSIDE a
2688
+ struct argument was refused under every policy. Measured on mainnet: Archer's
2689
+ legacy Swap form (tag `0f`, 19 bytes) omits `SwapParams.builderFeePpm`
2690
+ INSIDE `swapParams` and is chain-executed — witness `5Kpdai4oRG…`, slot
2691
+ 468261978, `err: null`, instruction bytes byte-identical to the payload
2692
+ (`research/archer-legacy-swap-witness-2026-11-12.json`; the pinned IDL's own
2693
+ `SwapParams` docs describe the shorter form as supported and read as a 0 fee,
2694
+ with the program's own `success` log line). One of the 83 payloads in
2695
+ `research/archer-second-source-2026-11-12.json` hit exactly this and v0.4.22
2696
+ refused it with `truncated input: need 4 byte(s) at offset 19`.
2697
+
2698
+ This release decodes it and re-encodes it byte-exactly: inside a named struct,
2699
+ under the same decode-only evidence gate (`ctx.__absentPolicy`), a boundary
2700
+ ending mid-struct makes the remaining fields ABSENT, named by their dotted path
2701
+ (`swapParams.builderFeePpm`), never invented; cuts INSIDE a field and boundary
2702
+ cuts inside vec/array elements still refuse; the strict default policy is
2703
+ untouched; `encodeInstructionData` accepts the dotted absent names (trailing
2704
+ run per struct). `verifiedShortForm` now matches short (sub-8-byte) shank
2705
+ discriminators by prefix — the rule the codec's instruction index already used
2706
+ — so the 1-byte Archer tag reaches the chain-verified short-form table. Pinned
2707
+ by `test/struct-absent-short-form.test.mjs` (10 tests: the falsification
2708
+ ladder is 6 red on the v0.4.22 tree, green here).
2709
+
2710
+ The registry channel is live and carries this release with the tag: the
2711
+ foreign-run sweep measured `npm: found` for the bare name with the verdict
2712
+ still `not-distributed-not-consumed`
2713
+ (`research/foreign-run-2026-11-12.json`) — publish is not consumption, and
2714
+ the first external run stays the open gate.
2715
+
2716
+ Install — the registry and the tag ship the same product files (`npm install
2717
+ sememe` resolves to 0.4.23, the one tag this README pins above):
2718
+
2719
+ ```bash
2720
+ npm install sememe # registry: 0.4.23, zero runtime dependencies
2721
+ ```
2722
+
2723
+ Limits, stated plainly: the Archer interface still does not ship inside the
2724
+ store (no on-chain IDL; the Apache-2.0 first-party copy stays adopt-only and
2725
+ hash-gated — unchanged); enum- and vec-body boundary cuts still refuse until a
2726
+ chain witness names them (the rule stays evidence-gated; no speculative wire
2727
+ form is accepted); tags <= v0.4.22 keep the truncation refusal (a tag is
2728
+ immutable); one owner account, zero external users, no audit.
2729
+
2730
+ Suite for this release: **964 tests / 960 pass / 4 named network skips /
2731
+ 0 fail** (954 → 964: the 10 new pin tests). CI green on `main`
2732
+ (43332583023); the release surface re-measured after publishing is CLEAN —
2733
+ newest = latest = pinned, 0 defects (`research/release-surface-2026-11-13.json`),
2734
+ and the registry carries the release as `latest` (measured after propagation).
2735
+
2736
+ v0.4.23 (published 2026-11-13 00:49 UTC) carried this release to both channels
2737
+ but its README held TWO install pins — the front page plus the historical
2738
+ v0.4.22 section line — and the journey contract
2739
+ (`test/base58-journey.test.mjs` and five siblings) requires exactly one:
2740
+ tag CI 43340061132 RED with `README must pin exactly one tag, found v0.4.23,
2741
+ v0.4.22` (394b37b is the fix). The v0.4.23 tag and its npm tarball are
2742
+ published and immutable, so they keep the two-pin README; **v0.4.24 is the
2743
+ corrected release** — same product bytes as v0.4.23 (no src/ change since
2744
+ 3fa9627), one pin, journey suite green (27/27), registry `latest` = 0.4.24.
2745
+
2683
2746
  ## The remaining 35 publish-nothing programs, classified against foreign sources (2026-11-04)
2684
2747
 
2685
2748
  With Lighthouse decoded under shank, exactly **35 programs (87 payloads)** remained
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sememe",
3
- "version": "0.4.22",
3
+ "version": "0.4.24",
4
4
  "description": "Local, fingerprint-verified Solana program interfaces: pull an IDL once, then decode instructions, accounts and events offline. Zero dependencies on the decode path. Ships derived wire tables for System, ComputeBudget, SPL Token, Token-2022 (incl. its token-metadata fallback), Raydium AMM v4 and the SPL Associated Token Account, pinned to upstream Rust.",
5
5
  "license": "MIT",
6
6
  "type": "module",
package/src/codec.mjs CHANGED
@@ -365,8 +365,14 @@ function decodeAt(type, cur, ctx, path, depth) {
365
365
  throw new CodecError(`vector length ${length} needs ${length * minimum} byte(s) but ${cur.remaining} remain`, path);
366
366
  }
367
367
  const out = [];
368
- for (let index = 0; index < length; index++) {
369
- out.push(decodeAt(type.vec, cur, ctx, `${path}[${index}]`, depth + 1));
368
+ const insideCollection = ctx.__insideCollection;
369
+ ctx.__insideCollection = true;
370
+ try {
371
+ for (let index = 0; index < length; index++) {
372
+ out.push(decodeAt(type.vec, cur, ctx, `${path}[${index}]`, depth + 1));
373
+ }
374
+ } finally {
375
+ ctx.__insideCollection = insideCollection;
370
376
  }
371
377
  return out;
372
378
  }
@@ -428,8 +434,40 @@ function decodeAt(type, cur, ctx, path, depth) {
428
434
  throw new CodecError('malformed struct field', path);
429
435
  }
430
436
  const out = {};
431
- for (const field of fields) {
432
- assign(out, field.name, decodeAt(field.type, cur, ctx, `${path}.${field.name}`, depth + 1), path);
437
+ for (const [index, field] of fields.entries()) {
438
+ // The payload ends exactly where this struct's next field would begin.
439
+ // Under the caller-risk 'report' policy (reached only through
440
+ // ctx.__absentPolicy — decodeInstructionData sets it, and nothing else
441
+ // in the codec opts in), the rest of THIS struct is ABSENT, not
442
+ // truncated: a field boundary is the only place a payload can end and
443
+ // still carry a complete value for what came before. The marker names
444
+ // the missing fields by their path RELATIVE TO THE ARGUMENT (e.g.
445
+ // builderFeePpm inside swapParams), so decodeInstructionData can report
446
+ // `swapParams.builderFeePpm` and tell a struct-internal absence from a
447
+ // missing top-level argument. Under the strict default this branch
448
+ // never engages and the field decode refuses as truncated.
449
+ if (ctx.__absentPolicy === 'report' && cur.remaining === 0) {
450
+ if (ctx.__insideCollection) {
451
+ // A struct inside a vec/array: the wire stopped inside a repeated
452
+ // element, and the element INDEX is not recoverable from the
453
+ // bytes. Naming an absence there would be a guess, so it refuses
454
+ // like a mid-field cut until a witness says what to call it.
455
+ throw new CodecError(
456
+ `truncated input: the payload ends inside a repeated struct field (${path})`,
457
+ path);
458
+ }
459
+ const missing = fields.slice(index).map((rest) => rest.name);
460
+ ctx.__absentCollected?.push(...missing.map((name) => [...(ctx.__absentTrail ?? []), name].join('.')));
461
+ out.__absentFields = missing;
462
+ return out;
463
+ }
464
+ const trail = ctx.__absentTrail;
465
+ trail?.push(field.name);
466
+ try {
467
+ assign(out, field.name, decodeAt(field.type, cur, ctx, `${path}.${field.name}`, depth + 1), path);
468
+ } finally {
469
+ trail?.pop();
470
+ }
433
471
  }
434
472
  return out;
435
473
  }
@@ -445,6 +483,10 @@ function decodeAt(type, cur, ctx, path, depth) {
445
483
  if (!shape) throw new CodecError(`malformed variant ${JSON.stringify(variant.name)}`, path);
446
484
  const out = { __kind: variant.name };
447
485
  if (shape.kind === 'unit') return out;
486
+ // Note: a payload cut inside an enum variant's fields refuses even under
487
+ // the absent:'report' policy — the struct-boundary absence rule covers
488
+ // struct arguments (the shape every measured witness used), not variant
489
+ // bodies. Widening it needs its own chain witness first.
448
490
  if (shape.kind === 'tuple') {
449
491
  out.fields = shape.fields.map((fieldType, position) =>
450
492
  decodeAt(fieldType, cur, ctx, `${path}.fields[${position}]`, depth + 1));
@@ -461,14 +503,46 @@ function decodeAt(type, cur, ctx, path, depth) {
461
503
 
462
504
  function decodeArray(inner, length, cur, ctx, path, depth) {
463
505
  const out = [];
464
- for (let index = 0; index < length; index++) {
465
- out.push(decodeAt(inner, cur, ctx, `${path}[${index}]`, depth + 1));
506
+ const insideCollection = ctx.__insideCollection;
507
+ ctx.__insideCollection = true;
508
+ try {
509
+ for (let index = 0; index < length; index++) {
510
+ out.push(decodeAt(inner, cur, ctx, `${path}[${index}]`, depth + 1));
511
+ }
512
+ } finally {
513
+ ctx.__insideCollection = insideCollection;
466
514
  }
467
515
  return out;
468
516
  }
469
517
 
470
518
  // --- encoder ----------------------------------------------------------------
471
519
 
520
+ /**
521
+ * Struct fields of a type that an absent PATH may descend through. Only
522
+ * named structs qualify: a dotted absent name like swapParams.builderFeePpm
523
+ * describes where the wire stopped, and only a named struct's fields can
524
+ * name that place. Anything else (tuple struct, enum, array, primitive) is a
525
+ * refusal — inventing an absence inside a shape that cannot name its fields
526
+ * would be exactly the guesswork the policy exists to avoid.
527
+ */
528
+ function structFieldsForAbsentPath(type, ctx, path) {
529
+ let current = type;
530
+ if (current && typeof current === 'object' && 'defined' in current) {
531
+ const name = typeof current.defined === 'string' ? current.defined : current.defined?.name;
532
+ const typedef = ctx.types.get(name);
533
+ if (!typedef) throw new CodecError(`absent path crosses unknown type ${JSON.stringify(name)}`, path);
534
+ current = typedef.type;
535
+ }
536
+ if (current && typeof current === 'object' && current.kind === 'type' && 'type' in current) {
537
+ current = current.type;
538
+ }
539
+ const fields = structShape(current);
540
+ if (!fields || !isNamedStructFields(fields)) {
541
+ throw new CodecError(`absent path crosses a type that is not a named struct`, path);
542
+ }
543
+ return fields;
544
+ }
545
+
472
546
  function encodeAt(type, value, ctx, path, depth) {
473
547
  if (depth > MAX_DEPTH) throw new CodecError('type nesting too deep (recursive type?)', path);
474
548
 
@@ -562,8 +636,31 @@ function encodeAt(type, value, ctx, path, depth) {
562
636
  const expected = new Set(fields.map((field) => field.name));
563
637
  const unknown = Object.keys(value).filter((key) => !expected.has(key));
564
638
  if (unknown.length) throw new CodecError(`unexpected field(s): ${unknown.join(', ')}`, path);
639
+ // Struct-internal absent fields: encodeInstructionData resolves dotted
640
+ // absent names (swapParams.builderFeePpm) to the VALUE object that owns
641
+ // the field and records the omission on ctx. Same rule as the top level:
642
+ // within this struct only a trailing run may be omitted, every omitted
643
+ // name must be declared, and what remains must still carry a value —
644
+ // byte-exactness is the only evidence this encoder offers.
645
+ const skip = ctx.__absentFields?.get(value);
646
+ if (skip) {
647
+ const unknownSkip = [...skip].filter((name) => !expected.has(name));
648
+ if (unknownSkip.length) {
649
+ throw new CodecError(`unexpected absent field(s): ${unknownSkip.join(', ')}`, path);
650
+ }
651
+ let seenSkip = false;
652
+ for (const field of fields) {
653
+ if (skip.has(field.name)) { seenSkip = true; continue; }
654
+ if (seenSkip) {
655
+ throw new CodecError(
656
+ `field ${JSON.stringify(field.name)} follows an absent field — only a trailing run may be omitted`,
657
+ path);
658
+ }
659
+ }
660
+ }
565
661
  const parts = [];
566
662
  for (const field of fields) {
663
+ if (skip?.has(field.name)) continue;
567
664
  if (!(field.name in value)) throw new CodecError(`missing field ${JSON.stringify(field.name)}`, path);
568
665
  parts.push(encodeAt(field.type, value[field.name], ctx, `${path}.${field.name}`, depth + 1));
569
666
  }
@@ -986,6 +1083,14 @@ export function decodeInstructionData(idl, data, { allowTrailing, trailing, abse
986
1083
  const args = {};
987
1084
  const absentArgs = [];
988
1085
  const declaredArgs = instruction.args ?? [];
1086
+ // Struct-internal absences (the payload ends INSIDE a declared struct
1087
+ // argument, on that struct's own field boundary) surface as a marker the
1088
+ // struct decoder leaves on the decoded value. The struct decoder only
1089
+ // produces that marker under ctx.__absentPolicy === 'report' — every other
1090
+ // entry into the codec leaves these inert.
1091
+ ctx.__absentPolicy = absentPolicy;
1092
+ ctx.__absentCollected = absentArgs;
1093
+ ctx.__absentTrail = [];
989
1094
  for (const [position, argument] of declaredArgs.entries()) {
990
1095
  if (!argument || typeof argument.name !== 'string' || !('type' in argument)) {
991
1096
  throw new CodecError(`instruction ${instruction.name} has a malformed argument`, null);
@@ -1000,8 +1105,23 @@ export function decodeInstructionData(idl, data, { allowTrailing, trailing, abse
1000
1105
  }
1001
1106
  // With absent:'error' (default) this falls through to decodeAt, which
1002
1107
  // reports `truncated input` — the behaviour every existing caller pins.
1003
- assign(args, argument.name, decodeAt(argument.type, cursor, ctx, `${instruction.name}.${argument.name}`, 0), instruction.name);
1108
+ ctx.__absentTrail.push(argument.name);
1109
+ let value;
1110
+ try {
1111
+ value = decodeAt(argument.type, cursor, ctx, `${instruction.name}.${argument.name}`, 0);
1112
+ } finally {
1113
+ ctx.__absentTrail.pop();
1114
+ }
1115
+ // A struct the wire stopped inside carries the `__absentFields` marker;
1116
+ // peel it off so the decoded args never show codec-internal keys. The
1117
+ // dotted names were already collected into absentArgs. The strip is
1118
+ // recursive: a struct nested in a struct carries its own marker.
1119
+ stripAbsentMarkers(value);
1120
+ assign(args, argument.name, value, instruction.name);
1004
1121
  }
1122
+ delete ctx.__absentPolicy;
1123
+ delete ctx.__absentCollected;
1124
+ delete ctx.__absentTrail;
1005
1125
  const trailingCount = cursor.remaining;
1006
1126
  const trailingHex = trailingCount > 0
1007
1127
  ? Buffer.from(data.subarray(cursor.offset)).toString('hex')
@@ -1027,6 +1147,26 @@ export function decodeInstructionData(idl, data, { allowTrailing, trailing, abse
1027
1147
  };
1028
1148
  }
1029
1149
 
1150
+ /**
1151
+ * Remove the codec-internal `__absentFields` markers from a decoded value.
1152
+ *
1153
+ * The marker is how a struct tells the instruction level "the wire ended on
1154
+ * my field boundary, and THESE fields are the ones it did not carry". It is
1155
+ * bookkeeping, not data: a consumer must never see it inside the decoded
1156
+ * args. Recursive, because a struct nested in a struct carries its own
1157
+ * marker.
1158
+ */
1159
+ function stripAbsentMarkers(value) {
1160
+ if (!value || typeof value !== 'object') return value;
1161
+ if (Array.isArray(value)) {
1162
+ for (const item of value) stripAbsentMarkers(item);
1163
+ return value;
1164
+ }
1165
+ delete value.__absentFields;
1166
+ for (const key of Object.keys(value)) stripAbsentMarkers(value[key]);
1167
+ return value;
1168
+ }
1169
+
1030
1170
  /**
1031
1171
  * Encode one instruction payload (discriminator + args) using the IDL.
1032
1172
  *
@@ -1058,6 +1198,10 @@ export function encodeInstructionData(idl, name, args, { absent } = {}) {
1058
1198
  const known = new Set(declared.map((argument) => argument.name));
1059
1199
  const absentSet = new Set(Array.isArray(absent) ? absent : []);
1060
1200
  for (const fieldName of absentSet) {
1201
+ // A dotted name names a field INSIDE a declared struct argument; it is
1202
+ // validated against that struct further down, where the struct's own
1203
+ // field list is known.
1204
+ if (fieldName.includes('.')) continue;
1061
1205
  if (!known.has(fieldName)) {
1062
1206
  throw new CodecError(`unexpected argument(s) for ${instruction.name}: ${fieldName}`, instruction.name);
1063
1207
  }
@@ -1079,13 +1223,68 @@ export function encodeInstructionData(idl, name, args, { absent } = {}) {
1079
1223
  if (unknown.length) {
1080
1224
  throw new CodecError(`unexpected argument(s) for ${instruction.name}: ${unknown.join(', ')}`, null);
1081
1225
  }
1082
- const parts = [bytes];
1083
- for (const argument of declared) {
1084
- if (absentSet.has(argument.name)) continue;
1085
- if (!(argument.name in args)) throw new CodecError(`missing argument ${JSON.stringify(argument.name)}`, instruction.name);
1086
- parts.push(encodeAt(argument.type, args[argument.name], ctx, `${instruction.name}.${argument.name}`, 0));
1226
+ // Struct-internal absent fields: an absent name with a dot names a field
1227
+ // INSIDE a declared struct argument (swapParams.builderFeePpm). Resolve
1228
+ // each dotted path to the value object that owns the field, and record the
1229
+ // omission on the encode context so encodeAt's struct branch skips it —
1230
+ // with the same rules the top level enforces: only a trailing run of that
1231
+ // struct's fields may be omitted, and the value must not carry the field.
1232
+ // The encoder is the inverse of the decoder's struct-boundary marker: the
1233
+ // dotted absent list is exactly what the decode reported.
1234
+ const nestedAbsent = new Map(); // value object -> Set(struct-relative field names)
1235
+ for (const fieldName of absentSet) {
1236
+ if (!fieldName.includes('.')) continue;
1237
+ const parts = fieldName.split('.');
1238
+ if (!known.has(parts[0])) {
1239
+ throw new CodecError(`unexpected argument(s) for ${instruction.name}: ${parts[0]}`, instruction.name);
1240
+ }
1241
+ const ownerName = parts[0];
1242
+ const ownerType = declared.find((argument) => argument.name === ownerName)?.type;
1243
+ // Walk the INTERMEDIATE segments through named structs; the LAST segment
1244
+ // is the omitted field itself, validated against the struct it lives in.
1245
+ let containerType = ownerType;
1246
+ let containerValue = args[ownerName];
1247
+ for (const part of parts.slice(1, -1)) {
1248
+ const fields = structFieldsForAbsentPath(containerType, ctx, fieldName);
1249
+ const nextField = fields.find((field) => field.name === part);
1250
+ if (!nextField) {
1251
+ throw new CodecError(`unexpected absent field(s) for ${instruction.name}: ${fieldName}`, instruction.name);
1252
+ }
1253
+ containerType = nextField.type;
1254
+ if (!containerValue || typeof containerValue !== 'object' || !(part in containerValue)) {
1255
+ throw new CodecError(
1256
+ `absent path ${JSON.stringify(fieldName)} crosses a field the value does not carry`, instruction.name);
1257
+ }
1258
+ containerValue = containerValue[part];
1259
+ }
1260
+ const fields = structFieldsForAbsentPath(containerType, ctx, fieldName);
1261
+ const leaf = parts[parts.length - 1];
1262
+ if (!fields.some((field) => field.name === leaf)) {
1263
+ throw new CodecError(`unexpected absent field(s) for ${instruction.name}: ${fieldName}`, instruction.name);
1264
+ }
1265
+ if (!containerValue || typeof containerValue !== 'object' || Array.isArray(containerValue)) {
1266
+ throw new CodecError(`expected a struct object for ${JSON.stringify(fieldName)}, got ${describe(containerValue)}`, instruction.name);
1267
+ }
1268
+ if (leaf in containerValue) {
1269
+ throw new CodecError(`argument ${JSON.stringify(fieldName)} is both absent and present`, instruction.name);
1270
+ }
1271
+ if (!nestedAbsent.has(containerValue)) nestedAbsent.set(containerValue, new Set());
1272
+ nestedAbsent.get(containerValue).add(leaf);
1273
+ }
1274
+ if (nestedAbsent.size) {
1275
+ ctx.__absentFields = nestedAbsent;
1276
+ }
1277
+ try {
1278
+ const parts = [bytes];
1279
+ for (const argument of declared) {
1280
+ if (absentSet.has(argument.name)) continue;
1281
+ if (!(argument.name in args)) throw new CodecError(`missing argument ${JSON.stringify(argument.name)}`, instruction.name);
1282
+ parts.push(encodeAt(argument.type, args[argument.name], ctx, `${instruction.name}.${argument.name}`, 0));
1283
+ }
1284
+ return Buffer.concat(parts);
1285
+ } finally {
1286
+ delete ctx.__absentFields;
1087
1287
  }
1088
- return Buffer.concat(parts);
1089
1288
  }
1090
1289
 
1091
1290
  /**
@@ -114,23 +114,51 @@ const VERIFIED_SHORT_FORMS = [
114
114
  absent: ['is_cashback_enabled', 'creator_fee_bps', 'is_holder_reward'],
115
115
  witnesses: 1,
116
116
  },
117
+ // --- 2026-11-12: the first short form whose missing field sits INSIDE a
118
+ // struct argument (research/archer-legacy-swap-witness-2026-11-12.json).
119
+ // Every form above ends at a TOP-LEVEL argument boundary; this one ends on
120
+ // the boundary of the LAST field inside `swapParams`, so the absent name
121
+ // is dotted (`swapParams.builderFeePpm`) and the codec's struct-boundary
122
+ // rule is what makes it decodable. Evidence: a Swap executed at slot
123
+ // 468261978 with err:null whose instruction bytes are byte-identical to
124
+ // the 19-byte payload (checked against the RPC witness twice: `json`
125
+ // encoding base58 -> 0f430000000000000000000000000000000100), the Archer
126
+ // CPI's own `success` line, and the pinned IDL's SwapParams docs which
127
+ // describe the shorter legacy payload as a supported form read as a 0 fee.
128
+ {
129
+ program: 'Archer8kgiavM61GyusMzaaS2ft5sALtNsD1HxkUPMhy',
130
+ discriminator: '0f',
131
+ bytes: 19,
132
+ instruction: 'Swap',
133
+ absent: ['swapParams.builderFeePpm'],
134
+ witnesses: 1,
135
+ missingFieldInsideStruct: true,
136
+ },
117
137
  ];
118
138
 
119
139
  export const SHORT_FORM_SOURCE =
120
140
  'research/short-form-wire-forms-2026-11-03.json + research/short-form-wire-forms-2026-11-04.json '
121
- + '(chain witnesses: err:null, byte-identical payload, matching program log line)';
141
+ + '(chain witnesses: err:null, byte-identical payload, matching program log line) '
142
+ + 'research/archer-legacy-swap-witness-2026-11-12.json (chain witness: err:null, byte-identical payload)';
122
143
 
123
144
  export const VERIFIED = VERIFIED_SHORT_FORMS;
124
145
  export { VERIFIED_SHORT_FORMS };
125
146
 
126
147
  /** Look up a payload in the chain-verified short-form table; null when absent. */
127
148
  export function verifiedShortForm(program, data) {
149
+ // Struct-internal short forms may carry a discriminator SHORTER than 8
150
+ // bytes (measured: Archer's shank tag, 1 byte). The old `data.length < 8`
151
+ // guard refused any lookup that could not yield such a match: a 19-byte
152
+ // payload with a 1-byte tag reaches the table fine once the prefix is
153
+ // clipped to min(8, len) — exactly the rule the codec's instruction index
154
+ // already uses for short stored discriminators.
128
155
  if (!program || !(data instanceof Uint8Array)) return null;
129
- if (data.length < 8) return null;
130
- const discriminator = Buffer.from(data.subarray(0, 8)).toString('hex');
156
+ if (data.length === 0) return null;
157
+ const prefixLength = Math.min(8, data.length);
158
+ const discriminator = Buffer.from(data.subarray(0, prefixLength)).toString('hex');
131
159
  return VERIFIED_SHORT_FORMS.find((entry) =>
132
160
  entry.program === program
133
- && entry.discriminator === discriminator
161
+ && discriminator.startsWith(entry.discriminator)
134
162
  && entry.bytes === data.length) ?? null;
135
163
  }
136
164