@agenticstechnologies/verify 0.0.0-stage → 1.0.0

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/src/record.mjs ADDED
@@ -0,0 +1,440 @@
1
+ // Offline check for a signed agent record (GET /api/record/:handle/export).
2
+ //
3
+ // The 0–100 formula adapts lib/bureau/scoring.ts and matches lib/record/score.ts.
4
+ // Good = paid on time, an approval, or a recorded outcome.
5
+ // Adverse = claim paid, or a denial. A filed claim that was not paid is in
6
+ // the total only. Windows are 7-day buckets of the events themselves, newest
7
+ // first, weighted 1, 1/2, 1/4, … Stability is 1/(1+variance) of the per-window
8
+ // good rate, and it applies only when two or more windows exist.
9
+ //
10
+ // base = reliability * (1 - incident rate)
11
+ // score = round(100 * base) when one window
12
+ // score = round(100 * base * (0.5 + 0.5 * stability)) when two or more
13
+ //
14
+ // score_version 2. A self-reported event weighs SELF_REPORTED_WEIGHT. An agent
15
+ // with only self-reported scoring events cannot score above SELF_REPORTED_CAP.
16
+ // Missing provenance weighs 1, so a version 1 set of settlements and claims
17
+ // still produces the same number. Exports that omit score_version are read
18
+ // with agentRecordV1. Written form: docs/record/score.md.
19
+ //
20
+ // No scoring event → score is null, label "New, no record yet".
21
+ // The signature is EdDSA with the Bond key published at /.well-known/jwks.json.
22
+ // This file does not fetch that URL and does not trust a key embedded in the file.
23
+
24
+ import { canonicalize } from "./evidence.mjs";
25
+
26
+ export const NEW_RECORD_LABEL = "New, no record yet";
27
+ export const SCORE_VERSION = 2;
28
+ export const SELF_REPORTED_WEIGHT = 0.25;
29
+ export const SELF_REPORTED_CAP = 40;
30
+ export const RECORD_TYP = "agentics.record.v1";
31
+ export const RECORD_ISS = "https://agentics.you";
32
+ export const RECORD_JWT_TYP = "agentics-record+jwt";
33
+ const WEEK_MS = 7 * 24 * 60 * 60 * 1000;
34
+ const DAY_MS = 24 * 60 * 60 * 1000;
35
+ const HEX64 = /^[0-9a-f]{64}$/;
36
+ const HANDLE = /^[a-z0-9][a-z0-9-]{0,29}$/;
37
+ const V1_KINDS = new Set(["paid_on_time", "claim_filed", "claim_paid"]);
38
+ const KINDS = new Set([
39
+ "paid_on_time",
40
+ "claim_filed",
41
+ "claim_paid",
42
+ "denial",
43
+ "approval",
44
+ "outcome",
45
+ "outcome_adverse",
46
+ ]);
47
+ const PROVENANCE = new Set(["verified_by_agentics", "self_reported", "imported"]);
48
+
49
+ function clamp01(x) {
50
+ if (!Number.isFinite(x)) return 0;
51
+ if (x < 0) return 0;
52
+ if (x > 1) return 1;
53
+ return x;
54
+ }
55
+
56
+ function recencyWeight(windowIndex) {
57
+ return Math.pow(0.5, Math.max(0, windowIndex));
58
+ }
59
+
60
+ function iso(v) {
61
+ if (v instanceof Date) return Number.isFinite(v.getTime()) ? v.toISOString() : null;
62
+ if (typeof v === "string" && v.trim()) {
63
+ const t = Date.parse(v);
64
+ return Number.isFinite(t) ? new Date(t).toISOString() : null;
65
+ }
66
+ return null;
67
+ }
68
+
69
+ export function publicSeller(value) {
70
+ if (value == null) return null;
71
+ const s = String(value).trim().toLowerCase();
72
+ if (!s || s.includes("@") || /\s/.test(s) || s.length > 253) return null;
73
+ return s;
74
+ }
75
+
76
+ export function normalizeEvents(events) {
77
+ const seen = new Set();
78
+ const out = [];
79
+ for (const raw of events ?? []) {
80
+ if (!raw || typeof raw !== "object") continue;
81
+ const kind = String(raw.kind ?? "");
82
+ if (!KINDS.has(kind)) continue;
83
+ const at = iso(raw.at);
84
+ if (!at) continue;
85
+ const hash = raw.receipt_hash == null ? "" : String(raw.receipt_hash).trim().toLowerCase();
86
+ if (hash && !HEX64.test(hash)) continue;
87
+ if (hash) {
88
+ const key = `${kind}:${hash}`;
89
+ if (seen.has(key)) continue;
90
+ seen.add(key);
91
+ }
92
+ const seller = publicSeller(raw.seller);
93
+ const event = { kind, at };
94
+ if (seller) event.seller = seller;
95
+ if (hash) event.receipt_hash = hash;
96
+ const provenance = provenanceOf(raw.provenance);
97
+ if (provenance) event.provenance = provenance;
98
+ out.push(event);
99
+ }
100
+ return out;
101
+ }
102
+
103
+ function provenanceOf(value) {
104
+ return typeof value === "string" && PROVENANCE.has(value) ? value : undefined;
105
+ }
106
+
107
+ function eventWeight(event) {
108
+ return event.provenance === "self_reported" ? SELF_REPORTED_WEIGHT : 1;
109
+ }
110
+
111
+ export function recordWindows(events) {
112
+ const buckets = new Map();
113
+ for (const event of events) {
114
+ const t = Date.parse(event.at);
115
+ if (!Number.isFinite(t)) continue;
116
+ const key = Math.floor(t / WEEK_MS);
117
+ const bucket = buckets.get(key) ?? { total: 0, good: 0, adverse: 0 };
118
+ const weight = eventWeight(event);
119
+ bucket.total += weight;
120
+ if (event.kind === "paid_on_time" || event.kind === "approval" || event.kind === "outcome") bucket.good += weight;
121
+ if (event.kind === "claim_paid" || event.kind === "denial" || event.kind === "outcome_adverse") bucket.adverse += weight;
122
+ buckets.set(key, bucket);
123
+ }
124
+ return [...buckets.keys()]
125
+ .sort((a, b) => b - a)
126
+ .map((key, index) => {
127
+ const bucket = buckets.get(key);
128
+ return { window_index: index, total: bucket.total, good: bucket.good, adverse: bucket.adverse };
129
+ });
130
+ }
131
+
132
+ function recordReliability(windows) {
133
+ let num = 0;
134
+ let den = 0;
135
+ for (const w of windows) {
136
+ const weight = recencyWeight(w.window_index);
137
+ const total = Math.max(0, Number(w.total) || 0);
138
+ num += weight * Math.max(0, Number(w.good) || 0);
139
+ den += weight * total;
140
+ }
141
+ if (den <= 0) return null;
142
+ return clamp01(num / den);
143
+ }
144
+
145
+ function recordIncidentRate(windows) {
146
+ let num = 0;
147
+ let den = 0;
148
+ for (const w of windows) {
149
+ const weight = recencyWeight(w.window_index);
150
+ const total = Math.max(0, Number(w.total) || 0);
151
+ num += weight * Math.max(0, Number(w.adverse) || 0);
152
+ den += weight * total;
153
+ }
154
+ if (den <= 0) return null;
155
+ return clamp01(num / den);
156
+ }
157
+
158
+ function recordStability(windows) {
159
+ const rates = [];
160
+ for (const w of windows) {
161
+ const total = Math.max(0, Number(w.total) || 0);
162
+ if (total <= 0) continue;
163
+ rates.push(clamp01(Math.max(0, Number(w.good) || 0) / total));
164
+ }
165
+ if (rates.length < 2) return null;
166
+ const mean = rates.reduce((a, b) => a + b, 0) / rates.length;
167
+ const ss = rates.reduce((a, b) => a + (b - mean) * (b - mean), 0);
168
+ return clamp01(1 / (1 + ss / rates.length));
169
+ }
170
+
171
+ function scoreFromWindows(windows) {
172
+ const reliability = recordReliability(windows);
173
+ const incident = recordIncidentRate(windows);
174
+ if (reliability == null || incident == null) return null;
175
+ const stability = recordStability(windows);
176
+ const base = reliability * (1 - incident);
177
+ const scaled = stability == null ? base : base * (0.5 + 0.5 * stability);
178
+ if (!Number.isFinite(scaled)) return null;
179
+ return Math.round(clamp01(scaled) * 100);
180
+ }
181
+
182
+ function selfReportedOnly(events) {
183
+ return events.length > 0 && events.every((event) => event.provenance === "self_reported");
184
+ }
185
+
186
+ export function recordScore(events) {
187
+ const kept = normalizeEvents(events);
188
+ const score = scoreFromWindows(recordWindows(kept));
189
+ if (score == null) return null;
190
+ if (selfReportedOnly(kept)) return Math.min(score, SELF_REPORTED_CAP);
191
+ return score;
192
+ }
193
+
194
+ export function agentRecord(events, asOf) {
195
+ const kept = normalizeEvents(events);
196
+ const score = kept.length ? recordScore(kept) : null;
197
+ const sellers = new Set();
198
+ let first = Infinity;
199
+ for (const event of kept) {
200
+ if (event.seller) sellers.add(event.seller);
201
+ const t = Date.parse(event.at);
202
+ if (t < first) first = t;
203
+ }
204
+ const asOfMs = asOf instanceof Date ? asOf.getTime() : Date.parse(String(asOf));
205
+ const age = kept.length && Number.isFinite(first) && Number.isFinite(asOfMs)
206
+ ? Math.max(0, Math.floor((asOfMs - first) / DAY_MS))
207
+ : null;
208
+ return {
209
+ paid_on_time: kept.filter((event) => event.kind === "paid_on_time").length,
210
+ claims_filed: kept.filter((event) => event.kind === "claim_filed").length,
211
+ claims_paid: kept.filter((event) => event.kind === "claim_paid").length,
212
+ denials: kept.filter((event) => event.kind === "denial").length,
213
+ approvals: kept.filter((event) => event.kind === "approval").length,
214
+ outcomes: kept.filter((event) => event.kind === "outcome" || event.kind === "outcome_adverse").length,
215
+ distinct_sellers: sellers.size,
216
+ age_days: age,
217
+ verified_actions: kept.filter((event) => event.provenance !== "self_reported").length,
218
+ score,
219
+ score_version: SCORE_VERSION,
220
+ label: kept.length === 0 || score == null ? NEW_RECORD_LABEL : String(score),
221
+ };
222
+ }
223
+
224
+ /** Version 1 shape. Denials, approvals, outcomes, and provenance do not count. */
225
+ export function agentRecordV1(events, asOf) {
226
+ const classic = normalizeEvents(events)
227
+ .filter((event) => V1_KINDS.has(event.kind))
228
+ .map((event) => {
229
+ const next = { kind: event.kind, at: event.at };
230
+ if (event.seller) next.seller = event.seller;
231
+ if (event.receipt_hash) next.receipt_hash = event.receipt_hash;
232
+ return next;
233
+ });
234
+ const score = classic.length ? scoreFromWindows(recordWindows(classic)) : null;
235
+ const sellers = new Set();
236
+ let first = Infinity;
237
+ for (const event of classic) {
238
+ if (event.seller) sellers.add(event.seller);
239
+ const t = Date.parse(event.at);
240
+ if (t < first) first = t;
241
+ }
242
+ const asOfMs = asOf instanceof Date ? asOf.getTime() : Date.parse(String(asOf));
243
+ const age = classic.length && Number.isFinite(first) && Number.isFinite(asOfMs)
244
+ ? Math.max(0, Math.floor((asOfMs - first) / DAY_MS))
245
+ : null;
246
+ return {
247
+ paid_on_time: classic.filter((event) => event.kind === "paid_on_time").length,
248
+ claims_filed: classic.filter((event) => event.kind === "claim_filed").length,
249
+ claims_paid: classic.filter((event) => event.kind === "claim_paid").length,
250
+ distinct_sellers: sellers.size,
251
+ age_days: age,
252
+ score,
253
+ label: classic.length === 0 || score == null ? NEW_RECORD_LABEL : String(score),
254
+ };
255
+ }
256
+
257
+ export function recordOutcomes(events) {
258
+ return normalizeEvents(events).sort((a, b) => {
259
+ if (a.at < b.at) return -1;
260
+ if (a.at > b.at) return 1;
261
+ const ah = a.receipt_hash ?? "";
262
+ const bh = b.receipt_hash ?? "";
263
+ if (ah < bh) return -1;
264
+ if (ah > bh) return 1;
265
+ if (a.kind < b.kind) return -1;
266
+ if (a.kind > b.kind) return 1;
267
+ return 0;
268
+ });
269
+ }
270
+
271
+ export function canonicalJson(value) {
272
+ return JSON.stringify(canonicalize(value));
273
+ }
274
+
275
+ function b64urlEncode(bytes) {
276
+ const u8 = bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes);
277
+ let str = "";
278
+ for (let i = 0; i < u8.length; i++) str += String.fromCharCode(u8[i]);
279
+ return btoa(str).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
280
+ }
281
+
282
+ function b64urlDecode(s) {
283
+ const pad = s.length % 4 === 0 ? "" : "=".repeat(4 - (s.length % 4));
284
+ const b64 = String(s).replace(/-/g, "+").replace(/_/g, "/") + pad;
285
+ const bin = atob(b64);
286
+ const out = new Uint8Array(bin.length);
287
+ for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
288
+ return out;
289
+ }
290
+
291
+ async function sha256Hex(text) {
292
+ const buf = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(text));
293
+ return [...new Uint8Array(buf)].map((b) => b.toString(16).padStart(2, "0")).join("");
294
+ }
295
+
296
+ async function publicParts(privateJwk) {
297
+ if (!privateJwk || privateJwk.kty !== "OKP" || privateJwk.crv !== "Ed25519" || !privateJwk.x || !privateJwk.d) {
298
+ throw new Error("bond_signing_jwk_invalid");
299
+ }
300
+ const kid = privateJwk.kid || (await sha256Hex(privateJwk.x)).slice(0, 16);
301
+ return { kid, x: privateJwk.x };
302
+ }
303
+
304
+ export function recordBundle({ handle, as_of, outcomes }) {
305
+ const list = recordOutcomes(outcomes);
306
+ const asOf = typeof as_of === "string" ? new Date(as_of).toISOString() : new Date(as_of).toISOString();
307
+ return {
308
+ v: 1,
309
+ typ: RECORD_TYP,
310
+ iss: RECORD_ISS,
311
+ handle: String(handle || "").trim().toLowerCase(),
312
+ as_of: asOf,
313
+ record: agentRecord(list, asOf),
314
+ outcomes: list,
315
+ };
316
+ }
317
+
318
+ function bundleError(bundle) {
319
+ if (!bundle || typeof bundle !== "object") return "malformed";
320
+ if (bundle.v !== 1 || bundle.typ !== RECORD_TYP) return "malformed";
321
+ if (bundle.iss !== RECORD_ISS) return "bad_iss";
322
+ if (typeof bundle.handle !== "string" || !HANDLE.test(bundle.handle)) return "bad_handle";
323
+ if (typeof bundle.as_of !== "string" || !Number.isFinite(Date.parse(bundle.as_of))) return "bad_time";
324
+ if (!bundle.record || typeof bundle.record !== "object" || !Array.isArray(bundle.outcomes)) return "malformed";
325
+ return null;
326
+ }
327
+
328
+ function recordVersion(record) {
329
+ return Number(record?.score_version) === SCORE_VERSION ? SCORE_VERSION : 1;
330
+ }
331
+
332
+ /** Version 2 records use the current formula. Anything else is read as version 1. */
333
+ function expectedRecord(bundle) {
334
+ return recordVersion(bundle?.record) === SCORE_VERSION
335
+ ? agentRecord(bundle.outcomes, bundle.as_of)
336
+ : agentRecordV1(bundle.outcomes, bundle.as_of);
337
+ }
338
+
339
+ /** Sign the canonical bundle. Refuses a record that the outcomes do not produce. */
340
+ export async function signRecord(privateJwk, bundle) {
341
+ const reason = bundleError(bundle);
342
+ if (reason) throw new Error(reason);
343
+ const expected = expectedRecord(bundle);
344
+ if (canonicalJson(expected) !== canonicalJson(bundle.record)) throw new Error("record_mismatch");
345
+ return signRecordPayload(privateJwk, bundle);
346
+ }
347
+
348
+ /** Sign whatever object is passed. Used to prove a lying payload still fails. */
349
+ export async function signRecordPayload(privateJwk, bundle) {
350
+ const pub = await publicParts(privateJwk);
351
+ const header = { alg: "EdDSA", typ: RECORD_JWT_TYP, kid: pub.kid };
352
+ const payload = canonicalJson(bundle);
353
+ const signingInput = `${b64urlEncode(new TextEncoder().encode(canonicalJson(header)))}.${b64urlEncode(new TextEncoder().encode(payload))}`;
354
+ const key = await crypto.subtle.importKey(
355
+ "jwk",
356
+ { ...privateJwk, kid: pub.kid, alg: "EdDSA" },
357
+ { name: "Ed25519" },
358
+ false,
359
+ ["sign"],
360
+ );
361
+ const sig = await crypto.subtle.sign({ name: "Ed25519" }, key, new TextEncoder().encode(signingInput));
362
+ return `${signingInput}.${b64urlEncode(sig)}`;
363
+ }
364
+
365
+ // Stable `reason` codes stay as they are. `message` is the sentence a person
366
+ // can read. Clock time does not expire a record: a key verifies old exports
367
+ // for as long as it is still published.
368
+ const RECORD_MESSAGES = {
369
+ malformed: "That doesn't look like a signed record.",
370
+ bad_header: "The signature header is not an EdDSA record.",
371
+ no_key: "No published key is available to check this record.",
372
+ unknown_kid: "This record was signed by a key that is not in the published set.",
373
+ bad_iss: "This record was not issued by Agentics.",
374
+ bad_handle: "The agent handle on this record is not valid.",
375
+ bad_time: "The record time is not a real timestamp.",
376
+ bad_sig: "The signature does not match this record.",
377
+ body_mismatch: "A field in this record was changed after it was signed.",
378
+ record_mismatch: "The score does not match the outcomes in this record.",
379
+ };
380
+
381
+ function fail(reason) {
382
+ return { ok: false, reason, message: RECORD_MESSAGES[reason] || "This record did not check out." };
383
+ }
384
+
385
+ /**
386
+ * Verify a record export offline.
387
+ * `jwks` is the caller's copy of /.well-known/jwks.json. A key inside the file is ignored.
388
+ */
389
+ export async function verifyRecord(bundle, jwks) {
390
+ if (!bundle || typeof bundle !== "object") return fail("malformed");
391
+ const jws = bundle.jws;
392
+ if (typeof jws !== "string" || !jws) return fail("malformed");
393
+ const parts = jws.split(".");
394
+ if (parts.length !== 3 || parts.some((part) => !part)) return fail("malformed");
395
+ let header;
396
+ let payload;
397
+ try {
398
+ header = JSON.parse(new TextDecoder().decode(b64urlDecode(parts[0])));
399
+ payload = JSON.parse(new TextDecoder().decode(b64urlDecode(parts[1])));
400
+ } catch {
401
+ return fail("malformed");
402
+ }
403
+ if (!header || header.alg !== "EdDSA" || header.typ !== RECORD_JWT_TYP || !header.kid) return fail("bad_header");
404
+ const keys = Array.isArray(jwks?.keys) ? jwks.keys : [];
405
+ if (!keys.length) return fail("no_key");
406
+ const match = keys.find((key) => key && key.kid === header.kid && key.kty === "OKP" && key.crv === "Ed25519" && key.x);
407
+ if (!match) return fail("unknown_kid");
408
+ const reason = bundleError(payload);
409
+ if (reason) return fail(reason);
410
+ try {
411
+ const key = await crypto.subtle.importKey(
412
+ "jwk",
413
+ { kty: "OKP", crv: "Ed25519", x: match.x, alg: "EdDSA" },
414
+ { name: "Ed25519" },
415
+ false,
416
+ ["verify"],
417
+ );
418
+ const ok = await crypto.subtle.verify(
419
+ { name: "Ed25519" },
420
+ key,
421
+ b64urlDecode(parts[2]),
422
+ new TextEncoder().encode(`${parts[0]}.${parts[1]}`),
423
+ );
424
+ if (!ok) return fail("bad_sig");
425
+ } catch {
426
+ return fail("bad_sig");
427
+ }
428
+ const body = { ...bundle };
429
+ delete body.jws;
430
+ if (canonicalJson(body) !== canonicalJson(payload)) return fail("body_mismatch");
431
+ const expected = expectedRecord(payload);
432
+ if (canonicalJson(expected) !== canonicalJson(payload.record)) return fail("record_mismatch");
433
+ return { ok: true, handle: payload.handle, record: payload.record };
434
+ }
435
+
436
+ export function isRecordBundle(bundle) {
437
+ return !!bundle && typeof bundle === "object" && (
438
+ bundle.typ === RECORD_TYP || (typeof bundle.jws === "string" && Array.isArray(bundle.outcomes))
439
+ );
440
+ }
package/src/solana.mjs ADDED
@@ -0,0 +1,235 @@
1
+ // Public Solana memo read. Devnet and testnet only. No key, no signature,
2
+ // no mainnet RPC. A timeout or an unreachable RPC is a warning, not a hang
3
+ // and not a failed bundle.
4
+
5
+ export const MEMO_PROGRAM = "MemoSq4gqABAXKb96qnH8TysNcWxMyWCqXgDLGmfcHr";
6
+
7
+ export const PUBLIC_RPC = {
8
+ devnet: "https://api.devnet.solana.com",
9
+ testnet: "https://api.testnet.solana.com",
10
+ };
11
+
12
+ /** Free public Base Sepolia RPC. Testnet only. Never a mainnet endpoint. */
13
+ export const PUBLIC_EVM_RPC = {
14
+ baseSepolia: "https://sepolia.base.org",
15
+ };
16
+
17
+ export const DEFAULT_RPC_TIMEOUT_MS = 8000;
18
+
19
+ const B58 = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz";
20
+
21
+ /** @param {string} input */
22
+ export function base58Decode(input) {
23
+ if (typeof input !== "string" || input.length === 0) return null;
24
+ const bytes = [];
25
+ for (const char of input) {
26
+ let carry = B58.indexOf(char);
27
+ if (carry < 0) return null;
28
+ for (let j = 0; j < bytes.length; j++) {
29
+ carry += bytes[j] * 58;
30
+ bytes[j] = carry & 0xff;
31
+ carry >>= 8;
32
+ }
33
+ while (carry > 0) {
34
+ bytes.push(carry & 0xff);
35
+ carry >>= 8;
36
+ }
37
+ }
38
+ for (const char of input) {
39
+ if (char !== "1") break;
40
+ bytes.push(0);
41
+ }
42
+ return Uint8Array.from(bytes.reverse());
43
+ }
44
+
45
+ /**
46
+ * @param {string | null | undefined} hint anchor.chain or anchor.cluster
47
+ * @param {string | null | undefined} explorer
48
+ * @returns {"devnet" | "testnet" | "mainnet" | null}
49
+ */
50
+ export function classifyCluster(hint, explorer) {
51
+ const blob = `${hint || ""} ${explorer || ""}`.toLowerCase();
52
+ if (!blob.trim()) return null;
53
+ if (blob.includes("devnet")) return "devnet";
54
+ if (blob.includes("testnet")) return "testnet";
55
+ if (blob.includes("mainnet") || /(^|[^a-z])solana([^a-z]|$)/.test(blob)) return "mainnet";
56
+ return null;
57
+ }
58
+
59
+ function memoBytes(data) {
60
+ if (typeof data === "string") return base58Decode(data);
61
+ if (data instanceof Uint8Array) return data;
62
+ if (Array.isArray(data) && data.every((n) => Number.isInteger(n) && n >= 0 && n <= 255)) {
63
+ return Uint8Array.from(data);
64
+ }
65
+ return null;
66
+ }
67
+
68
+ /** Signers on the transaction message. jsonParsed keys carry `signer`. */
69
+ export function signersFromTransaction(tx) {
70
+ const msg = tx && tx.transaction && tx.transaction.message;
71
+ if (!msg) return [];
72
+ const keys = msg.accountKeys || msg.staticAccountKeys || [];
73
+ const flagged = [];
74
+ for (const key of keys) {
75
+ if (key && typeof key === "object" && key.signer === true && key.pubkey) {
76
+ flagged.push(String(key.pubkey));
77
+ }
78
+ }
79
+ if (flagged.length) return flagged;
80
+ const count = msg.header && Number(msg.header.numRequiredSignatures);
81
+ if (!Number.isInteger(count) || count <= 0) return [];
82
+ const signers = [];
83
+ for (let i = 0; i < count && i < keys.length; i++) {
84
+ const key = keys[i];
85
+ const pubkey = typeof key === "string" ? key : key && key.pubkey;
86
+ if (pubkey) signers.push(String(pubkey));
87
+ }
88
+ return signers;
89
+ }
90
+
91
+ /**
92
+ * A memo counts only when the transaction succeeded and its one signer is
93
+ * `authority`. A non-null meta.err is `anchor_tx_failed`. Any other signer
94
+ * is `anchor_wrong_signer`.
95
+ * @param {unknown} tx
96
+ * @param {string} authority
97
+ * @returns {{ ok: true, memos: string[] } | { ok: false, reason: "anchor_tx_failed" | "anchor_wrong_signer" }}
98
+ */
99
+ export function anchorMemoVerdict(tx, authority) {
100
+ const err = tx && tx.meta ? tx.meta.err : undefined;
101
+ if (err != null) return { ok: false, reason: "anchor_tx_failed" };
102
+ const expected = String(authority || "").trim();
103
+ const signers = signersFromTransaction(tx);
104
+ if (!expected || signers.length !== 1 || signers[0] !== expected) {
105
+ return { ok: false, reason: "anchor_wrong_signer" };
106
+ }
107
+ return { ok: true, memos: memosFromTransaction(tx) };
108
+ }
109
+
110
+ /** Memo strings on the transaction message. Inner instructions are ignored. */
111
+ export function memosFromTransaction(tx) {
112
+ const msg = tx && tx.transaction && tx.transaction.message;
113
+ if (!msg) return [];
114
+ const instructions = msg.instructions || msg.compiledInstructions || [];
115
+ const keys = msg.accountKeys || msg.staticAccountKeys || [];
116
+ const memos = [];
117
+ for (const ix of instructions) {
118
+ if (!ix || typeof ix !== "object") continue;
119
+ const parsedMemo = typeof ix.parsed === "string"
120
+ ? ix.parsed
121
+ : (ix.parsed && typeof ix.parsed.memo === "string" ? ix.parsed.memo : null);
122
+ if (ix.programId === MEMO_PROGRAM || ix.program === "spl-memo") {
123
+ if (parsedMemo != null) memos.push(parsedMemo);
124
+ continue;
125
+ }
126
+ if (ix.programIdIndex == null) continue;
127
+ const key = keys[ix.programIdIndex];
128
+ const pubkey = typeof key === "string" ? key : key && key.pubkey;
129
+ if (pubkey !== MEMO_PROGRAM) continue;
130
+ if (parsedMemo != null) {
131
+ memos.push(parsedMemo);
132
+ continue;
133
+ }
134
+ const bytes = memoBytes(ix.data);
135
+ if (!bytes) continue;
136
+ memos.push(new TextDecoder().decode(bytes));
137
+ }
138
+ return memos;
139
+ }
140
+
141
+ function unreachableKind(err) {
142
+ if (!err) return "unreachable";
143
+ if (err.code === "timeout" || err.name === "AbortError" || err.name === "TimeoutError") return "timeout";
144
+ const msg = String(err.message || err);
145
+ if (/abort|timeout|timed out/i.test(msg)) return "timeout";
146
+ return "unreachable";
147
+ }
148
+
149
+ /**
150
+ * @param {{ txid: string, cluster: "devnet" | "testnet", expected: string, timeoutMs?: number, fetchImpl?: typeof fetch }} opts
151
+ * @returns {Promise<{ ok: boolean, warn: boolean, reason: string | null }>}
152
+ */
153
+ export async function checkSolanaMemo(opts) {
154
+ const rpc = PUBLIC_RPC[opts.cluster];
155
+ if (!rpc) {
156
+ return { ok: false, warn: true, reason: `cluster ${opts.cluster} is not queried` };
157
+ }
158
+ const timeoutMs = opts.timeoutMs ?? DEFAULT_RPC_TIMEOUT_MS;
159
+ const fetchImpl = opts.fetchImpl ?? fetch;
160
+ const ctrl = new AbortController();
161
+ let timer;
162
+ const timedOut = new Promise((_, reject) => {
163
+ timer = setTimeout(() => {
164
+ ctrl.abort();
165
+ const err = new Error("solana rpc timed out");
166
+ err.code = "timeout";
167
+ reject(err);
168
+ }, timeoutMs);
169
+ });
170
+ let pending;
171
+ try {
172
+ pending = Promise.resolve().then(() => fetchImpl(rpc, {
173
+ method: "POST",
174
+ headers: { "content-type": "application/json", accept: "application/json" },
175
+ body: JSON.stringify({
176
+ jsonrpc: "2.0",
177
+ id: 1,
178
+ method: "getTransaction",
179
+ params: [opts.txid, {
180
+ encoding: "jsonParsed",
181
+ maxSupportedTransactionVersion: 0,
182
+ commitment: "confirmed",
183
+ }],
184
+ }),
185
+ signal: ctrl.signal,
186
+ })).then((res) => res, (err) => {
187
+ if (err && (err.name === "AbortError" || err.name === "TimeoutError")) {
188
+ const wrapped = new Error("solana rpc timed out");
189
+ wrapped.code = "timeout";
190
+ throw wrapped;
191
+ }
192
+ throw err;
193
+ });
194
+ pending.catch(() => {});
195
+ const res = await Promise.race([pending, timedOut]);
196
+ if (!res || !res.ok) {
197
+ const status = res && res.status ? res.status : "no response";
198
+ return { ok: false, warn: true, reason: `solana rpc unreachable (http ${status})` };
199
+ }
200
+ const body = await res.json();
201
+ if (body && body.error) {
202
+ const msg = body.error.message || "rpc error";
203
+ return { ok: false, warn: true, reason: `solana rpc unreachable (${msg})` };
204
+ }
205
+ if (!body || body.result == null) {
206
+ return { ok: false, warn: false, reason: `transaction not found on ${opts.cluster}` };
207
+ }
208
+ if (opts.authority) {
209
+ const verdict = anchorMemoVerdict(body.result, opts.authority);
210
+ if (!verdict.ok) return { ok: false, warn: false, reason: verdict.reason };
211
+ if (verdict.memos.includes(opts.expected)) return { ok: true, warn: false, reason: null };
212
+ return {
213
+ ok: false,
214
+ warn: false,
215
+ reason: `solana memo does not equal ${opts.expected}`,
216
+ };
217
+ }
218
+ if (body.result && body.result.meta && body.result.meta.err != null) {
219
+ return { ok: false, warn: false, reason: "anchor_tx_failed" };
220
+ }
221
+ const memos = memosFromTransaction(body.result);
222
+ if (memos.includes(opts.expected)) return { ok: true, warn: false, reason: null };
223
+ return {
224
+ ok: false,
225
+ warn: false,
226
+ reason: `solana memo does not equal ${opts.expected}`,
227
+ };
228
+ } catch (err) {
229
+ const kind = unreachableKind(err);
230
+ const why = kind === "timeout" ? "solana rpc timed out" : "solana rpc unreachable";
231
+ return { ok: false, warn: true, reason: why };
232
+ } finally {
233
+ clearTimeout(timer);
234
+ }
235
+ }