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 +69 -6
- package/package.json +1 -1
- package/src/codec.mjs +212 -13
- package/src/short-forms.mjs +32 -4
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
|
|
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.
|
|
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.
|
|
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
|
|
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
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
369
|
-
|
|
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
|
-
|
|
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
|
-
|
|
465
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
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
|
/**
|
package/src/short-forms.mjs
CHANGED
|
@@ -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
|
|
130
|
-
const
|
|
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
|
|
161
|
+
&& discriminator.startsWith(entry.discriminator)
|
|
134
162
|
&& entry.bytes === data.length) ?? null;
|
|
135
163
|
}
|
|
136
164
|
|