sememe 0.0.0-stage → 0.4.22
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/LICENSE +21 -0
- package/README.md +2777 -2
- package/bin/sememe.mjs +556 -0
- package/index.mjs +48 -0
- package/interface/ata-ATokenGPvbdGVxr1b2hvZbsiqW5xWH25efTNsLJA8knL.json +62 -0
- package/interface/native-11111111111111111111111111111111.json +226 -0
- package/interface/native-ComputeBudget111111111111111111111111111111.json +72 -0
- package/interface/raydium-675kPX9MHTjS2zt1qfr1NYHuzeLXfQM9H24wFSUt1Mp8.json +352 -0
- package/interface/token-TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA.json +334 -0
- package/interface/token-TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb.json +535 -0
- package/interface/token-metadata.json +132 -0
- package/package.json +71 -4
- package/src/account-list.mjs +308 -0
- package/src/account-union.mjs +523 -0
- package/src/account.mjs +199 -0
- package/src/acquire.mjs +145 -0
- package/src/adopt.mjs +206 -0
- package/src/ata-interface.mjs +276 -0
- package/src/ata.mjs +223 -0
- package/src/base58.mjs +96 -0
- package/src/canonical.mjs +24 -0
- package/src/cli-surface.mjs +300 -0
- package/src/codec.mjs +1118 -0
- package/src/conformance.mjs +431 -0
- package/src/corpus/harvest-compute-budget-2026-10-31.json +19122 -0
- package/src/corpus/harvest-system-2026-10-31.json +26090 -0
- package/src/decode.mjs +286 -0
- package/src/dialect.mjs +180 -0
- package/src/discriminators.mjs +192 -0
- package/src/drift.mjs +172 -0
- package/src/gap.mjs +205 -0
- package/src/lighthouse.mjs +168 -0
- package/src/lock.mjs +440 -0
- package/src/native-audit.mjs +56 -0
- package/src/native-corpus.mjs +57 -0
- package/src/native-interface.mjs +252 -0
- package/src/native.mjs +324 -0
- package/src/peer.mjs +95 -0
- package/src/program-id.mjs +74 -0
- package/src/pull.mjs +117 -0
- package/src/rawle.mjs +454 -0
- package/src/raydium-interface.mjs +747 -0
- package/src/raydium.mjs +391 -0
- package/src/receipt-digest.mjs +84 -0
- package/src/receipt-name.mjs +128 -0
- package/src/receipt-order.mjs +56 -0
- package/src/shank.mjs +73 -0
- package/src/short-forms.mjs +209 -0
- package/src/store-read.mjs +253 -0
- package/src/store-write.mjs +46 -0
- package/src/token-interface.mjs +394 -0
- package/src/token-metadata-interface.mjs +371 -0
- package/src/token-metadata.mjs +317 -0
- package/src/token.mjs +443 -0
- package/src/union.mjs +428 -0
- package/vendor/anza-solana-sdk/8db406e2edfafa25939ec782b77b5b6f30de164d/LICENSE.txt +201 -0
- package/vendor/anza-solana-sdk/8db406e2edfafa25939ec782b77b5b6f30de164d/compute-budget-interface/src/lib.rs +93 -0
- package/vendor/anza-solana-sdk/8db406e2edfafa25939ec782b77b5b6f30de164d/system-interface/src/instruction.rs +1899 -0
- package/vendor/lighthouse/4c579479c98635e419b1b167f08be02a71604a71/LICENSE +21 -0
- package/vendor/lighthouse/4c579479c98635e419b1b167f08be02a71604a71/lighthouse.json +2769 -0
- package/vendor/raydium-amm/91739d8d9f5cf347e0ce14ebe4d2bb7069fb31b6/LICENSE +201 -0
- package/vendor/raydium-amm/91739d8d9f5cf347e0ce14ebe4d2bb7069fb31b6/program/src/instruction.rs +1189 -0
- package/vendor/raydium-amm/91739d8d9f5cf347e0ce14ebe4d2bb7069fb31b6/program/src/state.rs +1348 -0
- package/vendor/spl-associated-token-account/fbf5379e7398ac8b231c73ee8a9ff677b43dd0ec/LICENSE +201 -0
- package/vendor/spl-associated-token-account/fbf5379e7398ac8b231c73ee8a9ff677b43dd0ec/interface/src/instruction.rs +169 -0
- package/vendor/spl-associated-token-account/fbf5379e7398ac8b231c73ee8a9ff677b43dd0ec/interface/src/lib.rs +12 -0
- package/vendor/spl-associated-token-account/fbf5379e7398ac8b231c73ee8a9ff677b43dd0ec/program/src/processor.rs +308 -0
- package/vendor/spl-token/737788ff5bcc3f1f44cfb222f25483187650be76/LICENSE.txt +176 -0
- package/vendor/spl-token/737788ff5bcc3f1f44cfb222f25483187650be76/interface/src/instruction.rs +1963 -0
- package/vendor/spl-token-2022/5251dcc07ecbcb721d6c4f0231090defadf00311/LICENSE.txt +176 -0
- package/vendor/spl-token-2022/5251dcc07ecbcb721d6c4f0231090defadf00311/interface/src/instruction.rs +2868 -0
- package/vendor/spl-token-metadata-interface/7038969ec74a89f1afdb9bc97c112d4d26fead9a/LICENSE +201 -0
- package/vendor/spl-token-metadata-interface/7038969ec74a89f1afdb9bc97c112d4d26fead9a/interface/src/error.rs +58 -0
- package/vendor/spl-token-metadata-interface/7038969ec74a89f1afdb9bc97c112d4d26fead9a/interface/src/instruction.rs +596 -0
- package/vendor/spl-token-metadata-interface/7038969ec74a89f1afdb9bc97c112d4d26fead9a/interface/src/lib.rs +21 -0
- package/vendor/spl-token-metadata-interface/7038969ec74a89f1afdb9bc97c112d4d26fead9a/interface/src/state.rs +229 -0
package/src/raydium.mjs
ADDED
|
@@ -0,0 +1,391 @@
|
|
|
1
|
+
// The Raydium AMM v4 decoder: the vendored-upstream path for a LEGACY
|
|
2
|
+
// (pre-Anchor) program that publishes no interface at all.
|
|
3
|
+
//
|
|
4
|
+
// Why this file exists: measured 2026-11-02 on the unbiased four-block mainnet
|
|
5
|
+
// sample, Raydium AMM v4 was the LAST program with no interface sememe could
|
|
6
|
+
// acquire — 9 payloads (the whole remaining acquisition gap; every other
|
|
7
|
+
// program in the sample was already named). Neither of the two pullable IDL
|
|
8
|
+
// addresses answers for it (re-probed live at capture time), and its wire is
|
|
9
|
+
// not an Anchor 8-byte discriminator: it is a hand-written `pack()`/`unpack()`
|
|
10
|
+
// pair over a SPARSE u8 tag set. So, exactly like the System/ComputeBudget and
|
|
11
|
+
// SPL Token tables, the interface is DERIVED from the vendored upstream Rust
|
|
12
|
+
// program source by `src/raydium-interface.mjs` and committed as a reviewable
|
|
13
|
+
// artifact (`interface/raydium-<program>.json`).
|
|
14
|
+
//
|
|
15
|
+
// Ground truth chain, every row derived and then VERIFIED, not typed:
|
|
16
|
+
// 1. INTERFACE raydium-io/raydium-amm@91739d8d (Apache-2.0)
|
|
17
|
+
// program/src/instruction.rs AmmInstruction / pack() / unpack()
|
|
18
|
+
// program/src/state.rs AmmParams, Fees (LEN 64)
|
|
19
|
+
// DERIVED by tools/raydium-interface.mjs into
|
|
20
|
+
// interface/raydium-<program>.json. This module reads the
|
|
21
|
+
// artifact at load and re-hashes BOTH vendored files against
|
|
22
|
+
// the sha256 the artifact claims; a mismatch REFUSES the load
|
|
23
|
+
// instead of decoding from it — the two-check contract
|
|
24
|
+
// src/native.mjs and src/token.mjs already proved.
|
|
25
|
+
// 2. WIRE the pack() arm's literal tag push cross-checked against the
|
|
26
|
+
// unpack() arm that binds the same integer; the arm's byte-write
|
|
27
|
+
// sequence cross-checked against unpack()'s reads by count and
|
|
28
|
+
// width; optional groups carry upstream's own `rest.len() >= N`
|
|
29
|
+
// threshold (a trailing group, NOT a 1-byte option tag);
|
|
30
|
+
// branches carry upstream's own discriminator values
|
|
31
|
+
// (AmmParams enum text or numeric literal).
|
|
32
|
+
// 3. RUNTIME at this pin unpack() binds an explicit sparse u8 tag and an
|
|
33
|
+
// unknown tag is InvalidInstructionData; the seven variants
|
|
34
|
+
// upstream marks unsupported PANIC (unimplemented!()).
|
|
35
|
+
//
|
|
36
|
+
// What is deliberately NOT claimed: this table describes the program AT THE
|
|
37
|
+
// PIN. It carries no account list, no invariants and no state layout — only
|
|
38
|
+
// instruction data. Program ids 675kPX9… is the mainnet AMM v4 id; the table
|
|
39
|
+
// makes no claim about any other Raydium deployment.
|
|
40
|
+
//
|
|
41
|
+
// Strictness mirrors src/token.mjs: an unknown selector, a wire-rejected
|
|
42
|
+
// variant, an unmatched branch discriminator, a truncated payload or a
|
|
43
|
+
// truncated optional group is a refusal with a named reason — never a
|
|
44
|
+
// confident wrong label.
|
|
45
|
+
|
|
46
|
+
import { readFileSync } from 'node:fs';
|
|
47
|
+
import path from 'node:path';
|
|
48
|
+
import { fileURLToPath } from 'node:url';
|
|
49
|
+
import { decodeBase58, encodeBase58 } from './base58.mjs';
|
|
50
|
+
import {
|
|
51
|
+
deriveRaydiumInterface, RAYDIUM_SPECS, vendoredRaydiumHash,
|
|
52
|
+
} from './raydium-interface.mjs';
|
|
53
|
+
|
|
54
|
+
const HERE = path.dirname(fileURLToPath(import.meta.url));
|
|
55
|
+
const ROOT = path.resolve(HERE, '..');
|
|
56
|
+
|
|
57
|
+
export { RAYDIUM_AMM_V4 } from './raydium-interface.mjs';
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Load one Raydium interface: DERIVE it from the vendored upstream Rust, then
|
|
61
|
+
* require the committed artifact to agree with the derivation byte for byte.
|
|
62
|
+
*
|
|
63
|
+
* Two checks, deliberately (the contract src/native.mjs already proved):
|
|
64
|
+
* - the derivation runs against the vendored bytes themselves, so a swapped
|
|
65
|
+
* or edited source file cannot silently change the table it describes;
|
|
66
|
+
* - the committed artifact must equal that derivation, so a tampered or
|
|
67
|
+
* stale artifact refuses the load instead of decoding from it.
|
|
68
|
+
*/
|
|
69
|
+
function loadInterface(spec) {
|
|
70
|
+
const artifactPath = path.join(ROOT, 'interface', `raydium-${spec.program}.json`);
|
|
71
|
+
const derived = deriveRaydiumInterface(spec);
|
|
72
|
+
let current;
|
|
73
|
+
try {
|
|
74
|
+
current = readFileSync(artifactPath, 'utf8');
|
|
75
|
+
} catch (error) {
|
|
76
|
+
throw new Error(`raydium interface artifact for ${spec.label} is missing: ${error.message}`);
|
|
77
|
+
}
|
|
78
|
+
const expected = `${JSON.stringify(derived, null, 2)}\n`;
|
|
79
|
+
if (current !== expected) {
|
|
80
|
+
const claimed = derived.source.files.map((entry) => entry.sha256).join(', ');
|
|
81
|
+
throw new Error(
|
|
82
|
+
`raydium interface for ${spec.label} refuses to load: interface/raydium-${spec.program}.json does not ` +
|
|
83
|
+
`equal a fresh derivation of ${derived.source.repo}@${derived.source.pin} (${claimed}, ` +
|
|
84
|
+
`hashed now: ${vendoredRaydiumHash(spec)})`,
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
if (!derived.instructions.length || !derived.selector?.width) {
|
|
88
|
+
throw new Error(`raydium interface artifact for ${spec.label} is malformed`);
|
|
89
|
+
}
|
|
90
|
+
return derived;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const ARTIFACTS = new Map(RAYDIUM_SPECS.map((spec) => [spec.program, loadInterface(spec)]));
|
|
94
|
+
const BY_TAG = new Map(RAYDIUM_SPECS.map((spec) => [
|
|
95
|
+
spec.program,
|
|
96
|
+
new Map(ARTIFACTS.get(spec.program).instructions.map((entry) => [entry.tag, entry])),
|
|
97
|
+
]));
|
|
98
|
+
const BY_NAME = new Map(RAYDIUM_SPECS.map((spec) => [
|
|
99
|
+
spec.program,
|
|
100
|
+
new Map(ARTIFACTS.get(spec.program).instructions.map((entry) => [entry.name, entry])),
|
|
101
|
+
]));
|
|
102
|
+
|
|
103
|
+
export class RaydiumCodecError extends Error {
|
|
104
|
+
constructor(reason, detail) {
|
|
105
|
+
super(detail ? `${reason}: ${detail}` : reason);
|
|
106
|
+
this.name = 'RaydiumCodecError';
|
|
107
|
+
this.reason = reason;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** Whether `program` is a Raydium program this module owns. */
|
|
112
|
+
export function isRaydiumProgram(program) {
|
|
113
|
+
return ARTIFACTS.has(program);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** Program ids this module can decode, for callers that enumerate. */
|
|
117
|
+
export function raydiumPrograms() {
|
|
118
|
+
return [...ARTIFACTS.keys()];
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** Provenance for one Raydium program, or null. */
|
|
122
|
+
export function raydiumProvenance(program) {
|
|
123
|
+
const artifact = ARTIFACTS.get(program);
|
|
124
|
+
if (!artifact) return null;
|
|
125
|
+
const reachable = artifact.instructions.filter((entry) => entry.wire !== 'rejected');
|
|
126
|
+
return {
|
|
127
|
+
label: artifact.label,
|
|
128
|
+
interface: `${artifact.source.repo}@${artifact.source.pin.slice(0, 8)} ` +
|
|
129
|
+
`${artifact.source.files.map((entry) => entry.path).join(' + ')} AmmInstruction (explicit sparse tags)`,
|
|
130
|
+
encoding: artifact.argEncoding,
|
|
131
|
+
runtime: artifact.runtime,
|
|
132
|
+
variants: artifact.instructions.length,
|
|
133
|
+
reachable: reachable.length,
|
|
134
|
+
refused: artifact.instructions.length - reachable.length,
|
|
135
|
+
scheme: artifact.scheme,
|
|
136
|
+
source: artifact.source,
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function readLe(bytes, offset, size) {
|
|
141
|
+
let value = 0n;
|
|
142
|
+
for (let index = size - 1; index >= 0; index--) value = (value << 8n) | BigInt(bytes[offset + index]);
|
|
143
|
+
return value;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const INT_WIDTH = { u8: 1, u16: 2, u32: 4, u64: 8 };
|
|
147
|
+
|
|
148
|
+
/** Decode one arg. Returns the value plus the bytes it consumed. */
|
|
149
|
+
function decodeArg(spec, bytes, offset, artifact, what) {
|
|
150
|
+
const type = spec.type;
|
|
151
|
+
if (type in INT_WIDTH) {
|
|
152
|
+
const width = INT_WIDTH[type];
|
|
153
|
+
if (offset + width > bytes.length) {
|
|
154
|
+
throw new RaydiumCodecError('truncated', `${what}: truncated ${type} at ${offset} of ${bytes.length} byte(s)`);
|
|
155
|
+
}
|
|
156
|
+
const value = readLe(bytes, offset, width);
|
|
157
|
+
// u8/u16/u32 are safe as Numbers (native.mjs does the same); u64 is not.
|
|
158
|
+
return { value: width < 8 ? Number(value) : value, size: width };
|
|
159
|
+
}
|
|
160
|
+
if (type === 'pubkey') {
|
|
161
|
+
if (offset + 32 > bytes.length) {
|
|
162
|
+
throw new RaydiumCodecError('truncated', `${what}: truncated pubkey at ${offset} of ${bytes.length} byte(s)`);
|
|
163
|
+
}
|
|
164
|
+
return { value: encodeBase58(bytes.subarray(offset, offset + 32)), size: 32 };
|
|
165
|
+
}
|
|
166
|
+
if (type === 'amm_fees') {
|
|
167
|
+
const len = artifact.ammFees.len;
|
|
168
|
+
if (offset + len > bytes.length) {
|
|
169
|
+
throw new RaydiumCodecError('truncated', `${what}: truncated fees struct (${len} bytes) at ${offset} of ${bytes.length} byte(s)`);
|
|
170
|
+
}
|
|
171
|
+
const value = {};
|
|
172
|
+
let at = offset;
|
|
173
|
+
for (const field of artifact.ammFees.fields) {
|
|
174
|
+
value[field.name] = readLe(bytes, at, INT_WIDTH[field.type]);
|
|
175
|
+
at += INT_WIDTH[field.type];
|
|
176
|
+
}
|
|
177
|
+
if (at - offset !== len) {
|
|
178
|
+
throw new RaydiumCodecError('malformed-artifact',
|
|
179
|
+
`${what}: amm_fees fields sum to ${at - offset} byte(s), artifact declares ${len}`);
|
|
180
|
+
}
|
|
181
|
+
return { value, size: len };
|
|
182
|
+
}
|
|
183
|
+
throw new RaydiumCodecError('unknown-arg-type', `${what}: arg type ${type} is not in the pinned table`);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/** Encode one arg back to bytes. Throws on any value that cannot be written. */
|
|
187
|
+
function encodeArg(spec, value, artifact, what) {
|
|
188
|
+
const type = spec.type;
|
|
189
|
+
if (type in INT_WIDTH) {
|
|
190
|
+
const width = INT_WIDTH[type];
|
|
191
|
+
const v = typeof value === 'string' && /^-?\d+$/.test(value) ? BigInt(value)
|
|
192
|
+
: typeof value === 'number' ? BigInt(value)
|
|
193
|
+
: typeof value === 'bigint' ? value
|
|
194
|
+
: (() => { throw new RaydiumCodecError('bad-arg', `${what}: not an integer`); })();
|
|
195
|
+
if (v < 0n || v >= (1n << BigInt(width * 8))) {
|
|
196
|
+
throw new RaydiumCodecError('bad-arg', `${what}: out of ${type} range`);
|
|
197
|
+
}
|
|
198
|
+
const out = Buffer.alloc(width);
|
|
199
|
+
for (let index = 0; index < width; index++) out[index] = Number((v >> BigInt(index * 8)) & 0xffn);
|
|
200
|
+
return out;
|
|
201
|
+
}
|
|
202
|
+
if (type === 'pubkey') {
|
|
203
|
+
const raw = Buffer.from(decodeBase58(String(value)));
|
|
204
|
+
if (raw.length !== 32) throw new RaydiumCodecError('bad-arg', `${what}: not a 32-byte pubkey`);
|
|
205
|
+
return raw;
|
|
206
|
+
}
|
|
207
|
+
if (type === 'amm_fees') {
|
|
208
|
+
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
|
|
209
|
+
throw new RaydiumCodecError('bad-arg', `${what}: amm_fees must be an object of named fields`);
|
|
210
|
+
}
|
|
211
|
+
const chunks = [];
|
|
212
|
+
for (const field of artifact.ammFees.fields) {
|
|
213
|
+
if (value[field.name] === undefined) {
|
|
214
|
+
throw new RaydiumCodecError('bad-arg', `${what}: missing fee field ${field.name}`);
|
|
215
|
+
}
|
|
216
|
+
chunks.push(encodeArg({ name: field.name, type: field.type }, value[field.name], artifact, `${what}.${field.name}`));
|
|
217
|
+
}
|
|
218
|
+
return Buffer.concat(chunks);
|
|
219
|
+
}
|
|
220
|
+
throw new RaydiumCodecError('unknown-arg-type', `${what}: arg type ${type} is not in the pinned table`);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/** The branch of `entry` that its discriminator selects, or null. */
|
|
224
|
+
function branchFor(entry, discriminator, artifact, what) {
|
|
225
|
+
for (const branch of entry.branches) {
|
|
226
|
+
if (Array.isArray(branch.values) && branch.values.includes(discriminator)) return branch;
|
|
227
|
+
}
|
|
228
|
+
// A wildcard branch (`values: null`) is upstream's own `match ... { _ => Err }`
|
|
229
|
+
// arm: it is a refusal, not a catch-all decode.
|
|
230
|
+
const wildcard = entry.branches.find((branch) => branch.values === null);
|
|
231
|
+
if (wildcard) return wildcard;
|
|
232
|
+
throw new RaydiumCodecError('unknown-branch',
|
|
233
|
+
`${what}: discriminator ${JSON.stringify(discriminator)} selects no branch of the pinned ${entry.name}`);
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* Decode one Raydium AMM v4 instruction payload.
|
|
238
|
+
*
|
|
239
|
+
* Returns `{refused: 'not-raydium'}` when the caller was never supposed to ask,
|
|
240
|
+
* throws a `RaydiumCodecError` (named reason) when this program's own table
|
|
241
|
+
* refuses, and returns a result — named AND re-encodable — otherwise.
|
|
242
|
+
*
|
|
243
|
+
* `trailing: 'report'` (default) returns the decoded instruction plus the byte
|
|
244
|
+
* count the table does not explain; 'error' refuses instead.
|
|
245
|
+
*/
|
|
246
|
+
export function decodeRaydiumInstruction(program, bytes, { trailing = 'report' } = {}) {
|
|
247
|
+
const artifact = ARTIFACTS.get(program);
|
|
248
|
+
if (!artifact) return { refused: 'not-raydium' };
|
|
249
|
+
if (!(bytes instanceof Uint8Array) || bytes.length === 0) {
|
|
250
|
+
throw new RaydiumCodecError('empty-payload', `${program}: no instruction data`);
|
|
251
|
+
}
|
|
252
|
+
const tag = bytes[0];
|
|
253
|
+
const entry = BY_TAG.get(program).get(tag);
|
|
254
|
+
if (!entry) {
|
|
255
|
+
throw new RaydiumCodecError('unknown-selector', `${program}: tag ${tag} is not in the pinned ${artifact.label} interface`);
|
|
256
|
+
}
|
|
257
|
+
if (entry.wire === 'rejected') {
|
|
258
|
+
throw new RaydiumCodecError('wire-rejected',
|
|
259
|
+
`${program}: ${entry.name} (tag ${tag}) is refused by upstream itself — ${entry.reason}`);
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
const args = {};
|
|
263
|
+
const absent = [];
|
|
264
|
+
let offset = 1;
|
|
265
|
+
for (const spec of entry.args) {
|
|
266
|
+
const decoded = decodeArg(spec, bytes, offset, artifact, `${entry.name}.${spec.name}`);
|
|
267
|
+
args[spec.name] = decoded.value;
|
|
268
|
+
offset += decoded.size;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
if (entry.optional) {
|
|
272
|
+
// Upstream's rule is a LENGTH GUARD, not an option tag: the group is there
|
|
273
|
+
// only when at least `presentWhenRemainingAtLeast` bytes remain. Fewer
|
|
274
|
+
// bytes than that are NOT the group, and are reported as trailing.
|
|
275
|
+
const threshold = entry.optional.presentWhenRemainingAtLeast;
|
|
276
|
+
const remaining = bytes.length - offset;
|
|
277
|
+
if (remaining >= threshold) {
|
|
278
|
+
for (const spec of entry.optional.args) {
|
|
279
|
+
const decoded = decodeArg(spec, bytes, offset, artifact, `${entry.name}.${spec.name}`);
|
|
280
|
+
args[spec.name] = decoded.value;
|
|
281
|
+
offset += decoded.size;
|
|
282
|
+
}
|
|
283
|
+
} else {
|
|
284
|
+
for (const spec of entry.optional.args) absent.push(spec.name);
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
if (entry.branches) {
|
|
289
|
+
const branch = branchFor(entry, args[entry.branches[0].on], artifact, `${entry.name}.${entry.branches[0].on}`);
|
|
290
|
+
if (branch.wire === 'rejected') {
|
|
291
|
+
throw new RaydiumCodecError('wire-rejected',
|
|
292
|
+
`${program}: ${entry.name} with ${entry.branches[0].on}=${JSON.stringify(args[entry.branches[0].on])} ` +
|
|
293
|
+
`is refused by upstream itself — ${branch.reason}`);
|
|
294
|
+
}
|
|
295
|
+
for (const spec of branch.args) {
|
|
296
|
+
const decoded = decodeArg(spec, bytes, offset, artifact, `${entry.name}.${spec.name}`);
|
|
297
|
+
args[spec.name] = decoded.value;
|
|
298
|
+
offset += decoded.size;
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
const trailingBytes = bytes.length - offset;
|
|
303
|
+
if (trailingBytes > 0 && trailing === 'error') {
|
|
304
|
+
throw new RaydiumCodecError('trailing-bytes', `${program}: ${entry.name} explains ${offset} of ${bytes.length} byte(s)`);
|
|
305
|
+
}
|
|
306
|
+
return {
|
|
307
|
+
name: entry.name,
|
|
308
|
+
tag,
|
|
309
|
+
selectorWidth: 1,
|
|
310
|
+
args,
|
|
311
|
+
// Optional fields the payload did NOT carry: the group is absent because
|
|
312
|
+
// the payload was shorter than upstream's own length threshold.
|
|
313
|
+
absent,
|
|
314
|
+
consumed: offset,
|
|
315
|
+
bytes: bytes.length,
|
|
316
|
+
trailing: { count: trailingBytes },
|
|
317
|
+
roundTripByteExact: encodeRaydiumInstruction(program, entry.name, args).equals(Buffer.from(bytes)),
|
|
318
|
+
scheme: artifact.scheme,
|
|
319
|
+
source: 'pinned-vendored-upstream',
|
|
320
|
+
};
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/** Why the last `decodeRaydiumInstruction` refused, or null if it did not. */
|
|
324
|
+
export function raydiumRefusal(program, bytes) {
|
|
325
|
+
try {
|
|
326
|
+
const result = decodeRaydiumInstruction(program, bytes);
|
|
327
|
+
return result.refused ? result.refused : null;
|
|
328
|
+
} catch (error) {
|
|
329
|
+
return error instanceof RaydiumCodecError ? error.reason : `thrown: ${error.message}`;
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
/**
|
|
334
|
+
* Re-encode one Raydium instruction from a name + args. Throws on mismatch.
|
|
335
|
+
*
|
|
336
|
+
* An optional group is written only when EVERY one of its args is supplied, so
|
|
337
|
+
* a decode that found the group absent re-encodes to exactly the shorter wire;
|
|
338
|
+
* supplying only part of the group is refused rather than guessed.
|
|
339
|
+
*/
|
|
340
|
+
export function encodeRaydiumInstruction(program, name, values = {}) {
|
|
341
|
+
const artifact = ARTIFACTS.get(program);
|
|
342
|
+
if (!artifact) throw new RaydiumCodecError('not-raydium', program);
|
|
343
|
+
const entry = BY_NAME.get(program).get(name);
|
|
344
|
+
if (!entry) throw new RaydiumCodecError('unknown-variant', `${program}: ${name}`);
|
|
345
|
+
if (entry.wire === 'rejected') {
|
|
346
|
+
throw new RaydiumCodecError('wire-rejected', `${program}: ${name} is refused by upstream itself — ${entry.reason}`);
|
|
347
|
+
}
|
|
348
|
+
const chunks = [Buffer.from([entry.tag])];
|
|
349
|
+
const write = (spec) => {
|
|
350
|
+
const value = values[spec.name];
|
|
351
|
+
if (value === undefined || value === null) {
|
|
352
|
+
throw new RaydiumCodecError('missing-arg', `${name}.${spec.name}`);
|
|
353
|
+
}
|
|
354
|
+
chunks.push(encodeArg(spec, value, artifact, `${name}.${spec.name}`));
|
|
355
|
+
};
|
|
356
|
+
for (const spec of entry.args) write(spec);
|
|
357
|
+
if (entry.optional) {
|
|
358
|
+
const supplied = entry.optional.args.map((spec) => values[spec.name] ?? null);
|
|
359
|
+
const present = supplied.filter((value) => value !== null).length;
|
|
360
|
+
if (present > 0 && present < supplied.length) {
|
|
361
|
+
const missing = entry.optional.args.filter((spec) => values[spec.name] == null).map((spec) => spec.name);
|
|
362
|
+
throw new RaydiumCodecError('partial-optional',
|
|
363
|
+
`${name}: optional group ${entry.optional.args.map((spec) => spec.name).join('+')} is all-or-none; missing ${missing.join(', ')}`);
|
|
364
|
+
}
|
|
365
|
+
if (present === supplied.length) for (const spec of entry.optional.args) write(spec);
|
|
366
|
+
}
|
|
367
|
+
if (entry.branches) {
|
|
368
|
+
const discriminator = values[entry.branches[0].on];
|
|
369
|
+
if (discriminator === undefined || discriminator === null) {
|
|
370
|
+
throw new RaydiumCodecError('missing-arg', `${name}.${entry.branches[0].on}`);
|
|
371
|
+
}
|
|
372
|
+
const branch = branchFor(entry, discriminator, artifact, `${name}.${entry.branches[0].on}`);
|
|
373
|
+
if (branch.wire === 'rejected') {
|
|
374
|
+
throw new RaydiumCodecError('wire-rejected',
|
|
375
|
+
`${name}: ${entry.branches[0].on}=${JSON.stringify(discriminator)} is refused by upstream itself — ${branch.reason}`);
|
|
376
|
+
}
|
|
377
|
+
for (const spec of branch.args) write(spec);
|
|
378
|
+
}
|
|
379
|
+
return Buffer.concat(chunks);
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
/** Names the table can claim for `program` (coverage callers). */
|
|
383
|
+
export function raydiumNames(program) {
|
|
384
|
+
const artifact = ARTIFACTS.get(program);
|
|
385
|
+
return artifact ? artifact.instructions.filter((entry) => entry.wire !== 'rejected').map((entry) => entry.name) : [];
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
/** Whether `name` belongs to `program`'s Raydium table. */
|
|
389
|
+
export function isRaydiumName(program, name) {
|
|
390
|
+
return BY_NAME.get(program)?.has(name) ?? false;
|
|
391
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
// The receipt's own digest — so a DECLARED field cannot be rewritten silently.
|
|
2
|
+
//
|
|
3
|
+
// The store binds BYTES by content address: the object's fingerprint is
|
|
4
|
+
// sha256(canonicalJson(idl) + '\n'), and every reader re-hashes the object
|
|
5
|
+
// before it decodes. The RECEIPT was never covered by anything: it is a JSON
|
|
6
|
+
// file next to the object, and every field in it is free text after the fact.
|
|
7
|
+
//
|
|
8
|
+
// Measured 2026-11-07 (this host, the published v0.4.10 tree, main 939b320):
|
|
9
|
+
// rewriting `origin` in a receipt to `attacker/rewritten-origin` and running
|
|
10
|
+
// `sememe verify --mode warn` reported
|
|
11
|
+
// violations: 0 verified: ["6EF8rrecthR5Dkzon8Nwu78hRvfCKubJ14M5uBEwF6P"]
|
|
12
|
+
// The fingerprint covers the object; the ORIGIN next to it was covered by
|
|
13
|
+
// nothing, so the CI gate stayed green over a rewritten provenance label.
|
|
14
|
+
//
|
|
15
|
+
// The fix mirrors the object pattern one level up: the receipt carries a
|
|
16
|
+
// digest over ITS OWN BODY, so a reader can tell "this receipt was written by
|
|
17
|
+
// sememe" from "this file was edited afterwards" — exactly the question the
|
|
18
|
+
// object fingerprint already answers for the bytes.
|
|
19
|
+
//
|
|
20
|
+
// receiptDigest sha256(canonicalJson(body without receiptDigest) + '\n')
|
|
21
|
+
// originDigest sha256(origin) — binds the DECLARED label on its own,
|
|
22
|
+
// so a rewrite of the label ALONE is
|
|
23
|
+
// named as a label rewrite and not
|
|
24
|
+
// as an anonymous body change.
|
|
25
|
+
//
|
|
26
|
+
// The digest is over the CANONICAL serialization, the same one the store
|
|
27
|
+
// fingerprints objects with, so a receipt is reproducible from its body alone.
|
|
28
|
+
//
|
|
29
|
+
// COMPATIBILITY: receipts written before this field existed carry NO digest.
|
|
30
|
+
// They are read as they always were (the origin is then treated as DECLARED
|
|
31
|
+
// without a binding). A legacy receipt is never refused for lacking a digest —
|
|
32
|
+
// only a receipt whose digest DISAGREES is refused.
|
|
33
|
+
|
|
34
|
+
import { createHash } from 'node:crypto';
|
|
35
|
+
import { canonicalJson } from './canonical.mjs';
|
|
36
|
+
|
|
37
|
+
const sha256Hex = (data) => createHash('sha256').update(data).digest('hex');
|
|
38
|
+
|
|
39
|
+
/** The receipt body WITHOUT its own digest field. */
|
|
40
|
+
export function receiptBody(receipt) {
|
|
41
|
+
const { receiptDigest, ...body } = receipt ?? {};
|
|
42
|
+
return body;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** The digest a receipt body must carry. */
|
|
46
|
+
export function receiptDigestOf(receipt) {
|
|
47
|
+
return `sha256:${sha256Hex(`${canonicalJson(receiptBody(receipt))}\n`)}`;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** The digest a declared origin must carry. */
|
|
51
|
+
export function originDigestOf(origin) {
|
|
52
|
+
return `sha256:${sha256Hex(String(origin))}`;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** The two checks a reader runs on a receipt that carries the new fields.
|
|
56
|
+
* Returns null when the receipt is consistent, or a named reason when it is
|
|
57
|
+
* not. `relabelled` distinguishes "the declared origin was rewritten" from
|
|
58
|
+
* "some other field of the receipt was rewritten" — the lock reports the two
|
|
59
|
+
* differently, because they are different failures. */
|
|
60
|
+
export function receiptIntegrity(receipt) {
|
|
61
|
+
const origin = receipt?.origin;
|
|
62
|
+
if (typeof receipt?.originDigest === 'string' && origin !== undefined && origin !== null) {
|
|
63
|
+
if (originDigestOf(origin) !== receipt.originDigest) {
|
|
64
|
+
return {
|
|
65
|
+
code: 'receipt-relabelled',
|
|
66
|
+
reason: `the declared origin was rewritten after the receipt was written `
|
|
67
|
+
+ `(the receipt still binds "${originDigestOf(origin)}" but the receipt claims `
|
|
68
|
+
+ `${receipt.originDigest}) — refusing to read it as provenance`,
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
if (typeof receipt?.receiptDigest === 'string') {
|
|
73
|
+
const actual = receiptDigestOf(receipt);
|
|
74
|
+
if (actual !== receipt.receiptDigest) {
|
|
75
|
+
return {
|
|
76
|
+
code: 'receipt-tampered',
|
|
77
|
+
reason: `the receipt was modified after it was written `
|
|
78
|
+
+ `(body digest ${actual}, receipt claims ${receipt.receiptDigest}) — `
|
|
79
|
+
+ `refusing to read it as provenance`,
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
return null;
|
|
84
|
+
}
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
// ONE classifier for what a file in `receipts/` actually IS.
|
|
2
|
+
//
|
|
3
|
+
// The store's receipt directory holds two kinds of things, and until now the
|
|
4
|
+
// readers treated EVERY file in it as a receipt:
|
|
5
|
+
//
|
|
6
|
+
// 1. a RECEIPT — `<program>-<slot>-<source>-<fingerprint>.json`, written by
|
|
7
|
+
// `sememe pull` (src/pull.mjs) or `sememe adopt` (src/adopt.mjs). The
|
|
8
|
+
// fingerprint is the sha256 of the stored object; the slot is a decimal
|
|
9
|
+
// number (0 for an adopted copy); the source is a short label; an adopted
|
|
10
|
+
// copy may carry an extra `<originKey>` segment.
|
|
11
|
+
// 2. DEBRIS — a file nobody committed. The most common source is sememe
|
|
12
|
+
// ITSELF: src/store-write.mjs writes every receipt and object through a
|
|
13
|
+
// temporary file `<name>.tmp-<pid>-<random>` in the SAME directory and
|
|
14
|
+
// removes it in a `finally`. A crash between the write and the cleanup
|
|
15
|
+
// (SIGKILL, power loss, OOM) leaves that file behind FOREVER.
|
|
16
|
+
//
|
|
17
|
+
// MEASURED 2026-11-07 (this host, pre-fix tree a7848d4): one leftover
|
|
18
|
+
// `.tmp-4711-9f2c1a` file in `receipts/` broke EVERY surface of the product:
|
|
19
|
+
//
|
|
20
|
+
// sememe decode <program> <payload> -> EXIT 1 "store receipt <tmp file>
|
|
21
|
+
// is unreadable: Unexpected token"
|
|
22
|
+
// sememe decode --union <program> ... -> EXIT 1, same refusal
|
|
23
|
+
// sememe lock -> EXIT 1, "Cannot destructure
|
|
24
|
+
// property 'idl' of (intermediate
|
|
25
|
+
// value) as it is null"
|
|
26
|
+
// sememe verify --mode error -> EXIT 1, "unverified: <program>"
|
|
27
|
+
//
|
|
28
|
+
// The last one is the worst: the gate went RED and named a program that was
|
|
29
|
+
// perfectly intact, and the repair was never named — the user is told to
|
|
30
|
+
// re-pull a healthy program. One crashed write permanently bricked the store.
|
|
31
|
+
//
|
|
32
|
+
// WHY THE DEBRIS IS SAFE TO IGNORE (this is the reason, not a convenience):
|
|
33
|
+
// store-write.mjs installs a receipt by HARD-LINKING the temporary file to its
|
|
34
|
+
// final name (`link(temporary, destination)`) and only then removes the
|
|
35
|
+
// temporary. So the temporary file never holds anything the final name does
|
|
36
|
+
// not also hold:
|
|
37
|
+
// - died BEFORE the link -> only the temp exists; the receipt was never
|
|
38
|
+
// committed, so there is nothing to read;
|
|
39
|
+
// - died AFTER the link -> the final name holds the receipt AND the temp
|
|
40
|
+
// holds a byte-identical copy of it, so the temp carries no information
|
|
41
|
+
// the committed receipt does not.
|
|
42
|
+
// Either way the debris is an UNCOMMITTED write, and ignoring it cannot hide
|
|
43
|
+
// an answer.
|
|
44
|
+
//
|
|
45
|
+
// THE OTHER TWO KINDS, and why they are not debris:
|
|
46
|
+
// - MALFORMED — a name that LOOKS like a receipt (it has a numeric slot
|
|
47
|
+
// segment) but is not well-formed (the fingerprint is not 64 hex, the
|
|
48
|
+
// name was truncated, an editor left a suffix). This is DAMAGE to the
|
|
49
|
+
// store: it may be a receipt that was renamed or corrupted. It stays in
|
|
50
|
+
// the verified set, so the reader refuses it by name — exactly as it did
|
|
51
|
+
// before. Classifying it as ignorable junk would be a NEW green lie: a
|
|
52
|
+
// renamed receipt would vanish from the store's answers without a word.
|
|
53
|
+
// - FOREIGN — anything else in the directory (`.DS_Store`, `README`, a
|
|
54
|
+
// user's own note). It names no program and carries no slot, so it is
|
|
55
|
+
// not a receipt and never was one. Today it was read as one: `.DS_Store`
|
|
56
|
+
// became a PHANTOM program in `sememe verify` ("extra: [.DS_Store]"), and
|
|
57
|
+
// `my-file.json` became a phantom program "my" that FAILED the gate
|
|
58
|
+
// ("unverified: my") — a false red over a file that was never a receipt.
|
|
59
|
+
//
|
|
60
|
+
// So the rule is narrow on purpose: only what can be PROVEN to be an
|
|
61
|
+
// uncommitted write is ignored. Everything else keeps its previous verdict.
|
|
62
|
+
//
|
|
63
|
+
// A receipt whose name matches the grammar is a receipt WHATEVER its program
|
|
64
|
+
// id looks like — the grammar says nothing about base58, because a store may
|
|
65
|
+
// hold a synthetic program id and a reader that dropped such a receipt would
|
|
66
|
+
// be the green lie this repo keeps killing.
|
|
67
|
+
|
|
68
|
+
/** A receipt filename: `<program>-<slot>-<middle>-<64 hex>.json`.
|
|
69
|
+
* The middle segment may itself contain dashes (an adopted copy carries an
|
|
70
|
+
* extra `<originKey>`), so the fingerprint tail is anchored at the end. */
|
|
71
|
+
const RECEIPT_GRAMMAR = /^[^-]+-\d+-.+-[a-f0-9]{64}\.json$/;
|
|
72
|
+
|
|
73
|
+
/** sememe's own temporary file: `<something>.tmp-<pid>-<random hex>`. */
|
|
74
|
+
const DEBRIS_GRAMMAR = /\.tmp-\d+-[0-9a-f]+$/;
|
|
75
|
+
|
|
76
|
+
/** A name that looks like a receipt: it carries a numeric slot segment.
|
|
77
|
+
* `<program>-<slot>-...` — the shape every receipt has, whether or not the
|
|
78
|
+
* rest of the name is well-formed. */
|
|
79
|
+
const RECEIPT_SHAPED = /^[^-]+-\d+-/;
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* What one file in `receipts/` is.
|
|
83
|
+
*
|
|
84
|
+
* Returns one of:
|
|
85
|
+
* 'receipt' a well-formed receipt — read it, verify it, bind it.
|
|
86
|
+
* 'malformed' receipt-shaped but not well-formed — DAMAGE: read it so the
|
|
87
|
+
* refusal names it; never ignore it.
|
|
88
|
+
* 'debris' sememe's own uncommitted temporary file — ignore it, report
|
|
89
|
+
* it by name.
|
|
90
|
+
* 'foreign' a file that is not a receipt at all — ignore it, report it
|
|
91
|
+
* by name.
|
|
92
|
+
*
|
|
93
|
+
* The order matters: the debris rule is checked FIRST, because a debris file
|
|
94
|
+
* of a receipt-shaped name (`<receipt>.tmp-<pid>-<hex>`) is still debris —
|
|
95
|
+
* sememe's own leftovers are exactly the ones that carry a slot segment.
|
|
96
|
+
*/
|
|
97
|
+
export function classifyReceiptFile(name) {
|
|
98
|
+
if (DEBRIS_GRAMMAR.test(name)) return 'debris';
|
|
99
|
+
if (RECEIPT_GRAMMAR.test(name)) return 'receipt';
|
|
100
|
+
if (RECEIPT_SHAPED.test(name)) return 'malformed';
|
|
101
|
+
return 'foreign';
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** True for the kinds that are receipts for the purposes of reading a store:
|
|
105
|
+
* well-formed receipts, and malformed ones (which must still be refused by
|
|
106
|
+
* name rather than skipped in silence). */
|
|
107
|
+
export function isReceiptFile(name) {
|
|
108
|
+
const kind = classifyReceiptFile(name);
|
|
109
|
+
return kind === 'receipt' || kind === 'malformed';
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Partition a directory listing into the four kinds.
|
|
114
|
+
*
|
|
115
|
+
* `receipts` are the names to read and verify; `stray` are the names to
|
|
116
|
+
* report but not read (each with the kind that excluded it, so the report
|
|
117
|
+
* can say WHY a file was ignored).
|
|
118
|
+
*/
|
|
119
|
+
export function classifyReceiptFiles(names) {
|
|
120
|
+
const receipts = [];
|
|
121
|
+
const stray = [];
|
|
122
|
+
for (const name of names) {
|
|
123
|
+
const kind = classifyReceiptFile(name);
|
|
124
|
+
if (kind === 'receipt' || kind === 'malformed') receipts.push(name);
|
|
125
|
+
else stray.push({ name, kind });
|
|
126
|
+
}
|
|
127
|
+
return { receipts, stray };
|
|
128
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
// ONE ordering for store receipts — because two orderings are two answers.
|
|
2
|
+
//
|
|
3
|
+
// The receipt filename is `<program>-<slot>-<source>-<fingerprint>.json`, and
|
|
4
|
+
// the SLOT inside it is a decimal number stored as a string. Sorting receipt
|
|
5
|
+
// names with a plain string comparison therefore orders by DIGITS, not by
|
|
6
|
+
// value: `…-99999999-…` sorts AFTER `…-123456789-…`, so the OLDER receipt wins
|
|
7
|
+
// "last position" — and `findStoredIdl` takes the last position as "newest".
|
|
8
|
+
//
|
|
9
|
+
// Measured 2026-11-07 (this host, pre-fix tree 00ae18c, the code the published
|
|
10
|
+
// v0.4.9 carries): with one program's receipts at slots 99999999 and 123456789
|
|
11
|
+
// in one store, `sememe decode` answered from slot 99999999 while
|
|
12
|
+
// `sememe decode --union` answered from 123456789 — the union path has always
|
|
13
|
+
// sorted on-chain receipts by NUMERIC slot (`onChain.sort((a, b) => a.slot -
|
|
14
|
+
// b.slot)`), while the plain reader and src/lock.mjs sorted by filename. Two
|
|
15
|
+
// sorts, two winners: the same defect shape 08eff55 fixed for labels, one
|
|
16
|
+
// dimension over, and the reason "the rule is now ONE ordering everywhere"
|
|
17
|
+
// was not yet true.
|
|
18
|
+
//
|
|
19
|
+
// The rule here:
|
|
20
|
+
// 1. by SLOT, numerically (adopted copies carry filename segment 0, so they
|
|
21
|
+
// sort before every real slot — adoption never switches the default
|
|
22
|
+
// source, the selection guard the union tests protect);
|
|
23
|
+
// 2. ties broken by the REST of the filename, byte-wise — so two receipts of
|
|
24
|
+
// the same slot (different sources) keep a deterministic, reproducible
|
|
25
|
+
// order instead of depending on readdir() order.
|
|
26
|
+
//
|
|
27
|
+
// Why parse the filename instead of reading each receipt: the selection rule
|
|
28
|
+
// must be computable before opening files (findStoredIdl filters and sorts
|
|
29
|
+
// first, and lock.mjs does the same), and it must not depend on which receipts
|
|
30
|
+
// are readable — an unreadable receipt is named by readVerified, not silently
|
|
31
|
+
// re-ordered around.
|
|
32
|
+
|
|
33
|
+
const RECEIPT_NAME = /^(?<program>[^-]+)-(?<slot>\d+)-(?<rest>.*)$/;
|
|
34
|
+
|
|
35
|
+
/** The slot a receipt filename declares, or null when the name is not a
|
|
36
|
+
* receipt name this rule understands (never happens for pull/adopt output). */
|
|
37
|
+
export function slotOfReceiptName(name) {
|
|
38
|
+
const match = RECEIPT_NAME.exec(name);
|
|
39
|
+
if (!match) return null;
|
|
40
|
+
const slot = Number(match.groups.slot);
|
|
41
|
+
return Number.isSafeInteger(slot) ? slot : null;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Compare two receipt filenames: numeric slot first, filename as tiebreak.
|
|
46
|
+
* Names this rule cannot parse sort before parsed ones (stable, deterministic)
|
|
47
|
+
* so an unexpected name can never displace a real receipt's position silently.
|
|
48
|
+
*/
|
|
49
|
+
export function compareReceiptNames(a, b) {
|
|
50
|
+
const slotA = slotOfReceiptName(a);
|
|
51
|
+
const slotB = slotOfReceiptName(b);
|
|
52
|
+
if (slotA !== null && slotB !== null && slotA !== slotB) return slotA - slotB;
|
|
53
|
+
if (slotA === null && slotB !== null) return -1;
|
|
54
|
+
if (slotA !== null && slotB === null) return 1;
|
|
55
|
+
return a < b ? -1 : a > b ? 1 : 0;
|
|
56
|
+
}
|