@loopprotocol/sdk-byoaa 0.1.0-alpha.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/dist/index.mjs ADDED
@@ -0,0 +1,1068 @@
1
+ // src/types.ts
2
+ import { createHash } from "crypto";
3
+ var MAX_RECEIPT_CENTS = 10000000000n;
4
+ var MAX_MERCHANT_NAME_RAW_LEN = 64;
5
+ var MAX_RECEIPT_AGE_SECONDS = 365n * 24n * 60n * 60n;
6
+ function normalizeMerchantName(raw) {
7
+ const ascii = raw.replace(/[^\x20-\x7E]/g, "");
8
+ return ascii.trim().replace(/\s+/g, " ").toUpperCase();
9
+ }
10
+ function deriveBankTxnId(input) {
11
+ const normalized = normalizeMerchantName(input.merchantNameRaw);
12
+ const payload = [
13
+ normalized,
14
+ input.amountCents.toString(),
15
+ input.postedAt.toString(),
16
+ input.accountLast4.toString()
17
+ ].join("|");
18
+ const hash = createHash("sha256").update(payload, "utf8").digest();
19
+ return new Uint8Array(hash);
20
+ }
21
+ function validateReceipt(receipt, nowSeconds = BigInt(Math.floor(Date.now() / 1e3))) {
22
+ if (receipt.bankTxnId.length !== 32) {
23
+ return { ok: false, error: "bank_txn_id_wrong_length" };
24
+ }
25
+ if (receipt.merchantNameRaw.length === 0) {
26
+ return { ok: false, error: "merchant_name_empty" };
27
+ }
28
+ if (receipt.merchantNameRaw.length > MAX_MERCHANT_NAME_RAW_LEN) {
29
+ return { ok: false, error: "merchant_name_too_long" };
30
+ }
31
+ if (!/^[\x20-\x7E]+$/.test(receipt.merchantNameRaw)) {
32
+ return { ok: false, error: "merchant_name_not_ascii" };
33
+ }
34
+ if (receipt.amountCents <= 0n) {
35
+ return { ok: false, error: "amount_zero" };
36
+ }
37
+ if (receipt.amountCents > MAX_RECEIPT_CENTS) {
38
+ return { ok: false, error: "amount_too_large" };
39
+ }
40
+ if (receipt.postedAt > nowSeconds) {
41
+ return { ok: false, error: "posted_at_in_future" };
42
+ }
43
+ if (nowSeconds - receipt.postedAt > MAX_RECEIPT_AGE_SECONDS) {
44
+ return { ok: false, error: "posted_at_too_old" };
45
+ }
46
+ return { ok: true };
47
+ }
48
+
49
+ // src/config.ts
50
+ import {
51
+ Connection,
52
+ PublicKey,
53
+ clusterApiUrl
54
+ } from "@solana/web3.js";
55
+ var DEVNET_PROGRAM_IDS = {
56
+ SHOPPING: new PublicKey("FSqRkH7nkGHP3VpHwFE667PVAfLKfSGaPMgTrXpJZJoJ"),
57
+ VAULT: new PublicKey("9gKRCrUpHv9CRHwYMmm2zP5bN1bUKgyi7BxuS5jifX4x")
58
+ };
59
+ var MAINNET_PROGRAM_IDS = {
60
+ // TODO(post-Bundle-2-deploy): replace with the live mainnet id.
61
+ SHOPPING: new PublicKey("11111111111111111111111111111111"),
62
+ // Live mainnet vault id (already deployed). Fixed.
63
+ VAULT: new PublicKey("J8HhLeRv5iQaSyYQBXJoDwDKbw4V8uA84WN93YrVSWQT")
64
+ };
65
+ function resolveConfig(input) {
66
+ const cfg = typeof input === "string" ? { network: input } : input;
67
+ const programIds = getProgramIds(cfg.network);
68
+ const rpcUrl = cfg.rpcUrl ?? defaultRpcUrl(cfg.network);
69
+ const commitment = cfg.commitment ?? "confirmed";
70
+ const connection = new Connection(
71
+ rpcUrl,
72
+ cfg.connectionConfig ?? { commitment }
73
+ );
74
+ return {
75
+ network: cfg.network,
76
+ connection,
77
+ shoppingProgramId: cfg.shoppingProgramId ?? programIds.SHOPPING,
78
+ vaultProgramId: cfg.vaultProgramId ?? programIds.VAULT
79
+ };
80
+ }
81
+ function getProgramIds(network) {
82
+ switch (network) {
83
+ case "devnet":
84
+ case "localnet":
85
+ return DEVNET_PROGRAM_IDS;
86
+ case "mainnet-beta":
87
+ return MAINNET_PROGRAM_IDS;
88
+ default: {
89
+ const _exhaustive = network;
90
+ throw new Error(`Unknown network: ${_exhaustive}`);
91
+ }
92
+ }
93
+ }
94
+ function defaultRpcUrl(network) {
95
+ if (network === "localnet") return "http://127.0.0.1:8899";
96
+ return clusterApiUrl(network);
97
+ }
98
+ function isMainnet(network) {
99
+ return network === "mainnet-beta";
100
+ }
101
+
102
+ // src/pda.ts
103
+ import { PublicKey as PublicKey2 } from "@solana/web3.js";
104
+ function deriveBankAttestedReceiptPda(args) {
105
+ if (args.bankTxnId.length !== 32) {
106
+ throw new Error(
107
+ `bankTxnId must be exactly 32 bytes; got ${args.bankTxnId.length}`
108
+ );
109
+ }
110
+ const [pda, bump] = PublicKey2.findProgramAddressSync(
111
+ [
112
+ Buffer.from("attested_receipt"),
113
+ args.vault.toBuffer(),
114
+ Buffer.from(args.bankTxnId)
115
+ ],
116
+ args.shoppingProgramId
117
+ );
118
+ return { pda, bump };
119
+ }
120
+ function deriveShoppingStatePda(args) {
121
+ const [pda, bump] = PublicKey2.findProgramAddressSync(
122
+ [Buffer.from("state")],
123
+ args.shoppingProgramId
124
+ );
125
+ return { pda, bump };
126
+ }
127
+ function deriveAgentSessionPda(args) {
128
+ const [pda, bump] = PublicKey2.findProgramAddressSync(
129
+ [
130
+ Buffer.from("agent_session"),
131
+ args.vault.toBuffer(),
132
+ args.sessionPubkey.toBuffer()
133
+ ],
134
+ args.vaultProgramId
135
+ );
136
+ return { pda, bump };
137
+ }
138
+
139
+ // src/anchor-codec.ts
140
+ import { createHash as createHash2 } from "crypto";
141
+ function instructionDiscriminator(snakeName) {
142
+ const h = createHash2("sha256").update(`global:${snakeName}`).digest();
143
+ return new Uint8Array(h.subarray(0, 8));
144
+ }
145
+ function encodeAttestedReceiptArgs(args) {
146
+ if (args.bankTxnId.length !== 32) {
147
+ throw new Error(
148
+ `bankTxnId must be exactly 32 bytes; got ${args.bankTxnId.length}`
149
+ );
150
+ }
151
+ const nameBytes = new TextEncoder().encode(args.merchantNameRaw);
152
+ const buf = new Uint8Array(32 + 4 + nameBytes.length + 2 + 8 + 8 + 2);
153
+ const view = new DataView(buf.buffer);
154
+ let off = 0;
155
+ buf.set(args.bankTxnId, off);
156
+ off += 32;
157
+ view.setUint32(
158
+ off,
159
+ nameBytes.length,
160
+ true
161
+ /* little-endian */
162
+ );
163
+ off += 4;
164
+ buf.set(nameBytes, off);
165
+ off += nameBytes.length;
166
+ view.setUint16(off, args.mcc & 65535, true);
167
+ off += 2;
168
+ setU64LE(view, off, args.amountCents);
169
+ off += 8;
170
+ setI64LE(view, off, args.postedAt);
171
+ off += 8;
172
+ view.setUint16(off, args.accountLast4 & 65535, true);
173
+ off += 2;
174
+ return buf;
175
+ }
176
+ function encodeSubmitAttestedReceiptIxData(args) {
177
+ const disc = instructionDiscriminator("submit_attested_receipt");
178
+ const argBytes = encodeAttestedReceiptArgs(args);
179
+ const out = new Uint8Array(disc.length + argBytes.length);
180
+ out.set(disc, 0);
181
+ out.set(argBytes, disc.length);
182
+ return out;
183
+ }
184
+ function setU64LE(view, offset, value) {
185
+ if (value < 0n || value > 0xffffffffffffffffn) {
186
+ throw new RangeError(`u64 out of range: ${value}`);
187
+ }
188
+ view.setBigUint64(offset, value, true);
189
+ }
190
+ function setI64LE(view, offset, value) {
191
+ const min = -(1n << 63n);
192
+ const max = (1n << 63n) - 1n;
193
+ if (value < min || value > max) {
194
+ throw new RangeError(`i64 out of range: ${value}`);
195
+ }
196
+ view.setBigInt64(offset, value, true);
197
+ }
198
+
199
+ // src/enclave.ts
200
+ import {
201
+ SystemProgram,
202
+ Transaction,
203
+ TransactionInstruction
204
+ } from "@solana/web3.js";
205
+ var SESSION_INSTRUCTION_INDEX_SUBMIT_ATTESTED_RECEIPT = 5;
206
+ var AttestedReceiptSubmitter = class {
207
+ constructor(opts) {
208
+ /** Cached so we don't round-trip the enclave on every submit. */
209
+ this.cachedSessionPubkey = null;
210
+ this.resolvedConfig = resolveConfig(opts.network ?? "devnet");
211
+ this.enclaveSigner = opts.enclaveSigner;
212
+ this.vault = opts.vault;
213
+ this.sessionRegistrationPda = deriveAgentSessionPda({
214
+ vaultProgramId: this.resolvedConfig.vaultProgramId,
215
+ vault: opts.vault,
216
+ sessionPubkey: opts.sessionPubkey
217
+ }).pda;
218
+ this.commitment = opts.commitment ?? "confirmed";
219
+ }
220
+ /** Network selector this submitter was constructed against. */
221
+ get network() {
222
+ return this.resolvedConfig.network;
223
+ }
224
+ /**
225
+ * Resolve the receipt PDA for a given txn id without submitting.
226
+ * Useful for off-chain state checks ("have we already submitted X?")
227
+ * before paying RPC fees.
228
+ */
229
+ deriveReceiptPda(bankTxnId) {
230
+ return deriveBankAttestedReceiptPda({
231
+ shoppingProgramId: this.resolvedConfig.shoppingProgramId,
232
+ vault: this.vault,
233
+ bankTxnId
234
+ }).pda;
235
+ }
236
+ /**
237
+ * Sign + submit one receipt. Returns the on-chain signature + the
238
+ * receipt's PDA on success. Failures are returned as discriminated
239
+ * unions rather than thrown — batch consumers want fail-isolation.
240
+ */
241
+ async submit(receipt) {
242
+ const validation = validateReceipt(receipt);
243
+ if (!validation.ok) {
244
+ return failV(validation.error);
245
+ }
246
+ let sessionSigner;
247
+ try {
248
+ sessionSigner = await this.getSessionPubkey();
249
+ } catch (err) {
250
+ return fail("enclave_sign_failed", errorMessage(err));
251
+ }
252
+ const receiptPda = this.deriveReceiptPda(receipt.bankTxnId);
253
+ const ix = this.buildSubmitIx({
254
+ receipt,
255
+ receiptPda,
256
+ sessionSigner
257
+ });
258
+ let blockhashCtx;
259
+ try {
260
+ blockhashCtx = await this.resolvedConfig.connection.getLatestBlockhash(
261
+ this.commitment
262
+ );
263
+ } catch (err) {
264
+ return fail("blockhash_fetch_failed", errorMessage(err));
265
+ }
266
+ const tx = new Transaction({
267
+ feePayer: sessionSigner,
268
+ blockhash: blockhashCtx.blockhash,
269
+ lastValidBlockHeight: blockhashCtx.lastValidBlockHeight
270
+ }).add(ix);
271
+ const message = tx.serializeMessage();
272
+ let signature;
273
+ try {
274
+ signature = await this.enclaveSigner.signInstruction({
275
+ instructionIndex: SESSION_INSTRUCTION_INDEX_SUBMIT_ATTESTED_RECEIPT,
276
+ message,
277
+ policyInputs: {
278
+ amountCents: receipt.amountCents,
279
+ merchantNameRaw: receipt.merchantNameRaw,
280
+ postedAt: receipt.postedAt,
281
+ bankTxnId: receipt.bankTxnId
282
+ }
283
+ });
284
+ } catch (err) {
285
+ return fail("enclave_sign_failed", errorMessage(err));
286
+ }
287
+ if (signature.length !== 64) {
288
+ return fail(
289
+ "enclave_sign_failed",
290
+ `expected 64-byte ed25519 signature; got ${signature.length}`
291
+ );
292
+ }
293
+ tx.addSignature(sessionSigner, Buffer.from(signature));
294
+ if (!tx.verifySignatures()) {
295
+ return fail(
296
+ "session_pubkey_mismatch",
297
+ "enclave signature does not verify against session_signer pubkey"
298
+ );
299
+ }
300
+ let txSig;
301
+ try {
302
+ const sendOpts = {
303
+ skipPreflight: false,
304
+ preflightCommitment: this.commitment
305
+ };
306
+ txSig = await this.resolvedConfig.connection.sendRawTransaction(
307
+ tx.serialize(),
308
+ sendOpts
309
+ );
310
+ } catch (err) {
311
+ return fail("transaction_send_failed", errorMessage(err));
312
+ }
313
+ try {
314
+ await this.resolvedConfig.connection.confirmTransaction(
315
+ {
316
+ signature: txSig,
317
+ blockhash: blockhashCtx.blockhash,
318
+ lastValidBlockHeight: blockhashCtx.lastValidBlockHeight
319
+ },
320
+ this.commitment
321
+ );
322
+ } catch (err) {
323
+ return fail("transaction_confirm_failed", errorMessage(err));
324
+ }
325
+ return { ok: true, signature: txSig, receiptPda };
326
+ }
327
+ /**
328
+ * Submit N receipts with bounded concurrency + optional inter-request
329
+ * spacing. Fail-isolated: one bad receipt does not poison the rest.
330
+ *
331
+ * Order of `results` matches the order of `receipts`.
332
+ */
333
+ async submitBatch(receipts, opts = {}) {
334
+ const concurrency = Math.max(1, opts.concurrency ?? 4);
335
+ const rateLimitMs = Math.max(0, opts.rateLimitMs ?? 0);
336
+ const total = receipts.length;
337
+ const results = new Array(total);
338
+ let nextIndex = 0;
339
+ const worker = async () => {
340
+ while (true) {
341
+ const i = nextIndex++;
342
+ if (i >= total) return;
343
+ if (rateLimitMs > 0 && i > 0) {
344
+ await sleep(rateLimitMs);
345
+ }
346
+ const r = await this.submit(receipts[i]);
347
+ results[i] = r;
348
+ opts.onProgress?.({ index: i, total, result: r });
349
+ }
350
+ };
351
+ await Promise.all(
352
+ Array.from({ length: Math.min(concurrency, total) }, () => worker())
353
+ );
354
+ return results;
355
+ }
356
+ // ────────────────────────────────────────────────────────────────────
357
+ // Internal
358
+ // ────────────────────────────────────────────────────────────────────
359
+ async getSessionPubkey() {
360
+ if (this.cachedSessionPubkey) return this.cachedSessionPubkey;
361
+ const pk = await this.enclaveSigner.getSessionPubkey();
362
+ this.cachedSessionPubkey = pk;
363
+ return pk;
364
+ }
365
+ buildSubmitIx(args) {
366
+ const data = encodeSubmitAttestedReceiptIxData({
367
+ bankTxnId: args.receipt.bankTxnId,
368
+ merchantNameRaw: args.receipt.merchantNameRaw,
369
+ mcc: args.receipt.mcc,
370
+ amountCents: args.receipt.amountCents,
371
+ postedAt: args.receipt.postedAt,
372
+ accountLast4: args.receipt.accountLast4
373
+ });
374
+ const statePda = deriveShoppingStatePda({
375
+ shoppingProgramId: this.resolvedConfig.shoppingProgramId
376
+ }).pda;
377
+ return new TransactionInstruction({
378
+ programId: this.resolvedConfig.shoppingProgramId,
379
+ keys: [
380
+ { pubkey: args.sessionSigner, isSigner: true, isWritable: true },
381
+ { pubkey: statePda, isSigner: false, isWritable: false },
382
+ { pubkey: this.vault, isSigner: false, isWritable: false },
383
+ { pubkey: this.sessionRegistrationPda, isSigner: false, isWritable: false },
384
+ { pubkey: args.receiptPda, isSigner: false, isWritable: true },
385
+ { pubkey: SystemProgram.programId, isSigner: false, isWritable: false }
386
+ ],
387
+ data: Buffer.from(data)
388
+ });
389
+ }
390
+ };
391
+ function fail(error, detail) {
392
+ return detail !== void 0 ? { ok: false, error, detail } : { ok: false, error };
393
+ }
394
+ function failV(error) {
395
+ return { ok: false, error: "validation_failed", detail: error };
396
+ }
397
+ function errorMessage(err) {
398
+ if (err instanceof Error) return err.message;
399
+ return String(err);
400
+ }
401
+ function sleep(ms) {
402
+ return new Promise((r) => setTimeout(r, ms));
403
+ }
404
+
405
+ // src/verifier.ts
406
+ import { PublicKey as PublicKey4 } from "@solana/web3.js";
407
+ import { createHash as createHash3 } from "crypto";
408
+ var ReceiptNotAuditedError = class extends Error {
409
+ constructor(receipt) {
410
+ super(
411
+ `Receipt ${receipt.pda.toBase58()} produced by an unaudited enclave image (pcr0 not in EnclaveImageRegistry)`
412
+ );
413
+ this.receipt = receipt;
414
+ this.name = "ReceiptNotAuditedError";
415
+ }
416
+ };
417
+ var ReceiptNotFoundError = class extends Error {
418
+ constructor(pda) {
419
+ super(`No BankAttestedReceipt account at ${pda.toBase58()}`);
420
+ this.pda = pda;
421
+ this.name = "ReceiptNotFoundError";
422
+ }
423
+ };
424
+ var UnsupportedProofTypeError = class extends Error {
425
+ constructor(proofType) {
426
+ super(`Unsupported receipt proof type: ${proofType}`);
427
+ this.proofType = proofType;
428
+ this.name = "UnsupportedProofTypeError";
429
+ }
430
+ };
431
+ function verifyAttestedReceiptProof(receipt) {
432
+ switch (receipt.proof.proof_type) {
433
+ case "cose_sign1_x509":
434
+ verifyCoseSign1X509ProofShape(receipt);
435
+ return { ok: true, proof_type: "cose_sign1_x509" };
436
+ case "risc0_v1":
437
+ case "groth16_alt_bn128":
438
+ throw new UnsupportedProofTypeError(receipt.proof.proof_type);
439
+ default: {
440
+ const unreachable = receipt.proof;
441
+ throw new UnsupportedProofTypeError(
442
+ unreachable.proof_type
443
+ );
444
+ }
445
+ }
446
+ }
447
+ function verifyCoseSign1X509ProofShape(receipt) {
448
+ if (receipt.proof.proof_type !== "cose_sign1_x509") return;
449
+ if (!(receipt.proof.signature instanceof Uint8Array)) {
450
+ throw new Error("cose_sign1_x509 signature must be bytes");
451
+ }
452
+ if (!Array.isArray(receipt.proof.cert_chain)) {
453
+ throw new Error("cose_sign1_x509 cert_chain must be an array");
454
+ }
455
+ for (const cert of receipt.proof.cert_chain) {
456
+ if (!(cert instanceof Uint8Array)) {
457
+ throw new Error("cose_sign1_x509 cert_chain entries must be bytes");
458
+ }
459
+ }
460
+ }
461
+ var BANK_ATTESTED_RECEIPT_DISC = anchorAccountDiscriminator(
462
+ "BankAttestedReceipt"
463
+ );
464
+ var ENCLAVE_IMAGE_REGISTRY_DISC = anchorAccountDiscriminator(
465
+ "EnclaveImageRegistry"
466
+ );
467
+ function anchorAccountDiscriminator(typeName) {
468
+ const h = createHash3("sha256").update(`account:${typeName}`).digest();
469
+ return new Uint8Array(h.subarray(0, 8));
470
+ }
471
+ var AttestedReceiptVerifier = class {
472
+ constructor(opts = {}) {
473
+ /** Cache of approved PCR0s. */
474
+ this.approvedPcr0s = null;
475
+ this.approvedPcr0sExpiresAt = 0;
476
+ this.resolvedConfig = resolveConfig(opts.network ?? "devnet");
477
+ this.registryCacheMs = opts.registryCacheMs ?? 6e4;
478
+ this.skipImageAudit = opts.skipImageAudit ?? false;
479
+ }
480
+ get network() {
481
+ return this.resolvedConfig.network;
482
+ }
483
+ /**
484
+ * Fetch one receipt by PDA. Throws `ReceiptNotFoundError` if the
485
+ * account is missing or `ReceiptNotAuditedError` when
486
+ * `requireAuditedImage` is set and the PCR0 doesn't match the registry.
487
+ */
488
+ async fetch(pda, opts = {}) {
489
+ const account = await this.resolvedConfig.connection.getAccountInfo(
490
+ pda,
491
+ this.resolvedConfig.connection.commitment ?? "confirmed"
492
+ );
493
+ if (!account) throw new ReceiptNotFoundError(pda);
494
+ const decoded = decodeBankAttestedReceipt(pda, account);
495
+ const audited = await this.auditPcr0(decoded.pcr0);
496
+ decoded.imageAudited = audited;
497
+ if (opts.requireAuditedImage && audited === false) {
498
+ throw new ReceiptNotAuditedError(decoded);
499
+ }
500
+ return decoded;
501
+ }
502
+ /**
503
+ * Stream every receipt for a given vault. Uses `getProgramAccounts`
504
+ * with a memcmp filter on the `vault` field at fixed offset 8 (right
505
+ * after the 8-byte Anchor discriminator).
506
+ *
507
+ * Order is undefined — sort client-side if you need deterministic
508
+ * ordering (e.g. by `submittedAt` or `postedAt`).
509
+ */
510
+ async *forVault(vault) {
511
+ const filters = [
512
+ // Discriminator filter — only BankAttestedReceipt accounts.
513
+ {
514
+ memcmp: {
515
+ offset: 0,
516
+ bytes: bs58Encode(BANK_ATTESTED_RECEIPT_DISC)
517
+ }
518
+ },
519
+ // Vault filter at offset 8 (immediately after discriminator).
520
+ {
521
+ memcmp: {
522
+ offset: 8,
523
+ bytes: bs58Encode(vault.toBytes())
524
+ }
525
+ }
526
+ ];
527
+ const accounts = await this.resolvedConfig.connection.getProgramAccounts(
528
+ this.resolvedConfig.shoppingProgramId,
529
+ { filters }
530
+ );
531
+ for (const { pubkey, account } of accounts) {
532
+ const decoded = decodeBankAttestedReceipt(pubkey, account);
533
+ decoded.imageAudited = await this.auditPcr0(decoded.pcr0);
534
+ yield decoded;
535
+ }
536
+ }
537
+ /**
538
+ * Stream receipts whose `merchantNameRaw` (after `normalizeMerchantName`)
539
+ * matches the given normalized name.
540
+ *
541
+ * **Implementation note:** `merchant_name_raw` lives at variable byte
542
+ * offset (it follows a 4-byte length prefix that varies per receipt),
543
+ * so memcmp filtering on-chain is impractical. This method therefore
544
+ * fetches every `BankAttestedReceipt` account from the program and
545
+ * filters client-side. **Use the off-chain `attested_receipt_index`
546
+ * Supabase mirror (loop-site-v2 PR #27) for production-scale
547
+ * merchant queries.** This SDK method is fine for low-volume audits
548
+ * + correctness testing.
549
+ */
550
+ async *forMerchant(merchantNameNormalized) {
551
+ const target = normalizeMerchantName(merchantNameNormalized);
552
+ const filters = [
553
+ {
554
+ memcmp: {
555
+ offset: 0,
556
+ bytes: bs58Encode(BANK_ATTESTED_RECEIPT_DISC)
557
+ }
558
+ }
559
+ ];
560
+ const accounts = await this.resolvedConfig.connection.getProgramAccounts(
561
+ this.resolvedConfig.shoppingProgramId,
562
+ { filters }
563
+ );
564
+ for (const { pubkey, account } of accounts) {
565
+ const decoded = decodeBankAttestedReceipt(pubkey, account);
566
+ if (normalizeMerchantName(decoded.merchantNameRaw) !== target) continue;
567
+ decoded.imageAudited = await this.auditPcr0(decoded.pcr0);
568
+ yield decoded;
569
+ }
570
+ }
571
+ /**
572
+ * Force a refresh of the EnclaveImageRegistry cache. Useful in long-
573
+ * running daemons that want to pick up admin updates immediately.
574
+ */
575
+ async refreshImageRegistry() {
576
+ this.approvedPcr0sExpiresAt = 0;
577
+ await this.loadApprovedPcr0s();
578
+ }
579
+ // ────────────────────────────────────────────────────────────────────
580
+ // Internal
581
+ // ────────────────────────────────────────────────────────────────────
582
+ async auditPcr0(pcr0) {
583
+ if (this.skipImageAudit) return null;
584
+ const set = await this.loadApprovedPcr0s();
585
+ return set.has(toHex(pcr0));
586
+ }
587
+ async loadApprovedPcr0s() {
588
+ if (this.approvedPcr0s && Date.now() < this.approvedPcr0sExpiresAt) {
589
+ return this.approvedPcr0s;
590
+ }
591
+ const [registryPda] = PublicKey4.findProgramAddressSync(
592
+ [Buffer.from("enclave_image_registry")],
593
+ this.resolvedConfig.vaultProgramId
594
+ );
595
+ const account = await this.resolvedConfig.connection.getAccountInfo(
596
+ registryPda
597
+ );
598
+ const set = account ? decodeRegistryPcr0Set(account) : /* @__PURE__ */ new Set();
599
+ this.approvedPcr0s = set;
600
+ this.approvedPcr0sExpiresAt = Date.now() + this.registryCacheMs;
601
+ return set;
602
+ }
603
+ };
604
+ function decodeBankAttestedReceipt(pda, account) {
605
+ const data = account.data;
606
+ if (data.length < 8) {
607
+ throw new Error(`Account too small to be a BankAttestedReceipt: ${data.length} bytes`);
608
+ }
609
+ if (!bytesEqual(data.subarray(0, 8), BANK_ATTESTED_RECEIPT_DISC)) {
610
+ throw new Error(
611
+ `Discriminator mismatch at ${pda.toBase58()} \u2014 not a BankAttestedReceipt`
612
+ );
613
+ }
614
+ let off = 8;
615
+ const vault = new PublicKey4(data.subarray(off, off + 32));
616
+ off += 32;
617
+ const sessionPubkey = new PublicKey4(data.subarray(off, off + 32));
618
+ off += 32;
619
+ const pcr0 = new Uint8Array(data.subarray(off, off + 48));
620
+ off += 48;
621
+ const bankTxnId = new Uint8Array(data.subarray(off, off + 32));
622
+ off += 32;
623
+ const view = new DataView(data.buffer, data.byteOffset, data.byteLength);
624
+ const nameLen = view.getUint32(off, true);
625
+ off += 4;
626
+ if (nameLen > 64) {
627
+ throw new Error(
628
+ `merchant_name_raw length ${nameLen} exceeds on-chain cap of 64`
629
+ );
630
+ }
631
+ const merchantNameRaw = new TextDecoder().decode(
632
+ data.subarray(off, off + nameLen)
633
+ );
634
+ off += nameLen;
635
+ const mcc = view.getUint16(off, true);
636
+ off += 2;
637
+ const amountCents = view.getBigUint64(off, true);
638
+ off += 8;
639
+ const postedAt = view.getBigInt64(off, true);
640
+ off += 8;
641
+ const accountLast4 = view.getUint16(off, true);
642
+ off += 2;
643
+ const submittedAt = view.getBigInt64(off, true);
644
+ off += 8;
645
+ const statusRaw = view.getUint8(off);
646
+ off += 1;
647
+ return {
648
+ pda,
649
+ vault,
650
+ sessionPubkey,
651
+ pcr0,
652
+ bankTxnId,
653
+ merchantNameRaw,
654
+ mcc,
655
+ amountCents,
656
+ postedAt,
657
+ accountLast4,
658
+ submittedAt,
659
+ status: statusFromByte(statusRaw),
660
+ imageAudited: null
661
+ // populated by the verifier
662
+ };
663
+ }
664
+ function statusFromByte(byte) {
665
+ switch (byte) {
666
+ case 0:
667
+ return "recorded";
668
+ case 1:
669
+ return "honored";
670
+ case 2:
671
+ return "invalidated";
672
+ default:
673
+ throw new Error(`Unknown receipt status byte: ${byte}`);
674
+ }
675
+ }
676
+ function decodeRegistryPcr0Set(account) {
677
+ const data = account.data;
678
+ if (data.length < 8) return /* @__PURE__ */ new Set();
679
+ if (!bytesEqual(data.subarray(0, 8), ENCLAVE_IMAGE_REGISTRY_DISC)) {
680
+ return /* @__PURE__ */ new Set();
681
+ }
682
+ const view = new DataView(data.buffer, data.byteOffset, data.byteLength);
683
+ let off = 8;
684
+ off += 32;
685
+ const vecLen = view.getUint32(off, true);
686
+ off += 4;
687
+ const set = /* @__PURE__ */ new Set();
688
+ const ENTRY_SIZE = 48 + 48 + 48 + 32 + 8;
689
+ for (let i = 0; i < vecLen; i++) {
690
+ const pcr0 = data.subarray(off, off + 48);
691
+ set.add(toHex(pcr0));
692
+ off += ENTRY_SIZE;
693
+ }
694
+ return set;
695
+ }
696
+ function toHex(bytes) {
697
+ let s = "";
698
+ for (let i = 0; i < bytes.length; i++) {
699
+ s += bytes[i].toString(16).padStart(2, "0");
700
+ }
701
+ return s;
702
+ }
703
+ function bytesEqual(a, b) {
704
+ if (a.length !== b.length) return false;
705
+ for (let i = 0; i < a.length; i++) if (a[i] !== b[i]) return false;
706
+ return true;
707
+ }
708
+ function bs58Encode(bytes) {
709
+ const ALPHABET = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz";
710
+ let n = 0n;
711
+ for (let i = 0; i < bytes.length; i++) {
712
+ n = (n << 8n) + BigInt(bytes[i]);
713
+ }
714
+ let out = "";
715
+ while (n > 0n) {
716
+ const rem = Number(n % 58n);
717
+ n = n / 58n;
718
+ out = ALPHABET[rem] + out;
719
+ }
720
+ for (let i = 0; i < bytes.length && bytes[i] === 0; i++) {
721
+ out = "1" + out;
722
+ }
723
+ return out;
724
+ }
725
+
726
+ // src/client/LoopByoaaClient.ts
727
+ var LoopByoaaError = class extends Error {
728
+ constructor(code, message, context = {}) {
729
+ super(message);
730
+ this.name = "LoopByoaaError";
731
+ this.code = code;
732
+ this.status = context.status;
733
+ this.request_id = context.request_id;
734
+ this.documentation_url = context.documentation_url;
735
+ this.details = context.details;
736
+ }
737
+ };
738
+ var LoopByoaaApiKeyError = class extends LoopByoaaError {
739
+ constructor(code, message, context = {}) {
740
+ super(code, message, context);
741
+ this.name = "LoopByoaaApiKeyError";
742
+ }
743
+ };
744
+ var LoopByoaaPermissionError = class extends LoopByoaaError {
745
+ constructor(code, message, context = {}) {
746
+ super(code, message, context);
747
+ this.name = "LoopByoaaPermissionError";
748
+ }
749
+ };
750
+ var LoopByoaaRateLimitError = class extends LoopByoaaError {
751
+ constructor(code, message, context = {}) {
752
+ super(code, message, context);
753
+ this.name = "LoopByoaaRateLimitError";
754
+ this.retry_after_seconds = context.retry_after_seconds;
755
+ }
756
+ };
757
+ var LoopByoaaQuotaError = class extends LoopByoaaError {
758
+ constructor(code, message, context = {}) {
759
+ super(code, message, context);
760
+ this.name = "LoopByoaaQuotaError";
761
+ this.meter_name = context.meter_name;
762
+ this.upgrade_url = context.upgrade_url;
763
+ }
764
+ };
765
+ var LoopByoaaValidationError = class extends LoopByoaaError {
766
+ constructor(code, message, context = {}) {
767
+ super(code, message, context);
768
+ this.name = "LoopByoaaValidationError";
769
+ }
770
+ };
771
+ var LoopByoaaServerError = class extends LoopByoaaError {
772
+ constructor(code, message, context = {}) {
773
+ super(code, message, context);
774
+ this.name = "LoopByoaaServerError";
775
+ }
776
+ };
777
+ var LoopByoaaNetworkError = class extends LoopByoaaError {
778
+ constructor(message, context = {}) {
779
+ super("network_error", message, context);
780
+ this.name = "LoopByoaaNetworkError";
781
+ }
782
+ };
783
+ var ENV_BASE_URLS = {
784
+ dev: "https://dev-api.kya.looplocal.io",
785
+ staging: "https://staging-api.kya.looplocal.io",
786
+ prod: "https://prod-api.kya.loopprotocol.com"
787
+ };
788
+ function baseUrlForEnv(env) {
789
+ return ENV_BASE_URLS[env];
790
+ }
791
+ function parseLoopByoaaSdkKey(apiKey) {
792
+ const match = /^loop_byoaa_(dev|staging|prod)_([A-Za-z0-9]{32})$/.exec(apiKey);
793
+ if (!match) {
794
+ throw new LoopByoaaApiKeyError(
795
+ "invalid_api_key",
796
+ "Loop BYOAA SDK keys must match loop_byoaa_<dev|staging|prod>_<32 base62 characters>"
797
+ );
798
+ }
799
+ const env = match[1];
800
+ return {
801
+ env,
802
+ prefix: `loop_byoaa_${env}`,
803
+ hashable: apiKey
804
+ };
805
+ }
806
+ function envForKnownBaseUrl(baseUrl) {
807
+ const cleaned = cleanBaseUrl(baseUrl);
808
+ return Object.entries(ENV_BASE_URLS).find(
809
+ ([, knownUrl]) => cleanBaseUrl(knownUrl) === cleaned
810
+ )?.[0];
811
+ }
812
+ function cleanBaseUrl(baseUrl) {
813
+ return baseUrl.replace(/\/+$/, "");
814
+ }
815
+ function encodeValue(value) {
816
+ if (value instanceof Date) return value.toISOString();
817
+ return String(value);
818
+ }
819
+ function toQuery(params) {
820
+ if (!params) return "";
821
+ const query = new URLSearchParams();
822
+ for (const [key, value] of Object.entries(params)) {
823
+ if (value === void 0 || value === null) continue;
824
+ query.set(key, encodeValue(value));
825
+ }
826
+ const encoded = query.toString();
827
+ return encoded ? `?${encoded}` : "";
828
+ }
829
+ function normalizeBody(value) {
830
+ if (value instanceof Date) return value.toISOString();
831
+ if (Array.isArray(value)) return value.map(normalizeBody);
832
+ if (value && typeof value === "object") {
833
+ return Object.fromEntries(
834
+ Object.entries(value).map(([key, entry]) => [key, normalizeBody(entry)])
835
+ );
836
+ }
837
+ return value;
838
+ }
839
+ function readHeader(headers, name) {
840
+ return headers.get(name) ?? headers.get(name.toLowerCase()) ?? void 0;
841
+ }
842
+ function errorFromResponse(status, payload, headers) {
843
+ const nested = payload.error && typeof payload.error === "object" && !Array.isArray(payload.error) ? payload.error : void 0;
844
+ const envelope = nested ?? payload;
845
+ const details = envelope.details && typeof envelope.details === "object" && !Array.isArray(envelope.details) ? envelope.details : void 0;
846
+ const code = String(envelope.code ?? (status >= 500 ? "server_error" : "unknown"));
847
+ const message = String(envelope.message ?? `Loop BYOAA request failed with HTTP ${status}`);
848
+ const retryAfterSeconds = typeof details?.retry_after_seconds === "number" ? details.retry_after_seconds : typeof envelope.retry_after_seconds === "number" ? envelope.retry_after_seconds : Number(readHeader(headers, "retry-after")) || void 0;
849
+ const context = {
850
+ status,
851
+ request_id: typeof envelope.request_id === "string" ? envelope.request_id : readHeader(headers, "x-request-id"),
852
+ documentation_url: typeof envelope.documentation_url === "string" ? envelope.documentation_url : void 0,
853
+ retry_after_seconds: retryAfterSeconds,
854
+ meter_name: typeof envelope.meter_name === "string" ? envelope.meter_name : void 0,
855
+ upgrade_url: typeof envelope.upgrade_url === "string" ? envelope.upgrade_url : void 0,
856
+ details: envelope.details
857
+ };
858
+ if (status === 401 || status === 403 || code === "auth" || code === "invalid_api_key") {
859
+ return status === 403 || code === "permission_denied" ? new LoopByoaaPermissionError(code, message, context) : new LoopByoaaApiKeyError(code, message, context);
860
+ }
861
+ if (status === 400 || status === 422 || code === "validation") return new LoopByoaaValidationError(code, message, context);
862
+ if (status === 429 || code === "rate_limit") return new LoopByoaaRateLimitError(code, message, context);
863
+ if (status === 402 || code === "quota") return new LoopByoaaQuotaError(code, message, context);
864
+ if (status >= 500) return new LoopByoaaServerError(code, message, context);
865
+ return new LoopByoaaError(code, message, context);
866
+ }
867
+ var HttpClient = class {
868
+ constructor(apiKey, baseUrl, fetchImpl, timeout) {
869
+ this.apiKey = apiKey;
870
+ this.baseUrl = baseUrl;
871
+ this.fetchImpl = fetchImpl;
872
+ this.timeout = timeout;
873
+ }
874
+ async request(method, path, body, query) {
875
+ const controller = typeof AbortController !== "undefined" ? new AbortController() : void 0;
876
+ const timer = controller ? setTimeout(() => controller.abort(), this.timeout) : void 0;
877
+ const hasBody = body !== void 0;
878
+ const headers = {
879
+ Authorization: `Bearer ${this.apiKey}`,
880
+ Accept: "application/json"
881
+ };
882
+ if (hasBody) headers["Content-Type"] = "application/json";
883
+ try {
884
+ const response = await this.fetchImpl(`${this.baseUrl}${path}${toQuery(query)}`, {
885
+ method,
886
+ headers,
887
+ body: hasBody ? JSON.stringify(normalizeBody(body)) : void 0,
888
+ signal: controller?.signal
889
+ });
890
+ if (timer) clearTimeout(timer);
891
+ const text = await response.text();
892
+ const payload = text ? JSON.parse(text) : void 0;
893
+ if (!response.ok) {
894
+ throw errorFromResponse(response.status, payload ?? {}, response.headers);
895
+ }
896
+ return payload;
897
+ } catch (error) {
898
+ if (timer) clearTimeout(timer);
899
+ if (error instanceof LoopByoaaError) throw error;
900
+ throw new LoopByoaaNetworkError(error instanceof Error ? error.message : "Loop BYOAA network request failed");
901
+ }
902
+ }
903
+ };
904
+ function unwrapGatewayEnvelope(payload, key) {
905
+ if (payload && typeof payload === "object" && !Array.isArray(payload) && key in payload) {
906
+ return payload[key];
907
+ }
908
+ return payload;
909
+ }
910
+ function unwrapGatewayList(payload, key) {
911
+ if (payload && typeof payload === "object" && !Array.isArray(payload)) {
912
+ const record = payload;
913
+ if (Array.isArray(record[key])) return { items: record[key] };
914
+ if (Array.isArray(record.items)) return record;
915
+ }
916
+ return payload;
917
+ }
918
+ var LoopByoaaClient = class {
919
+ constructor(options) {
920
+ if (!options.apiKey) {
921
+ throw new LoopByoaaApiKeyError("auth", "Loop BYOAA apiKey is required");
922
+ }
923
+ const fetchImpl = options.fetch ?? globalThis.fetch;
924
+ if (!fetchImpl) {
925
+ throw new LoopByoaaNetworkError("Loop BYOAA requires a fetch implementation in this runtime");
926
+ }
927
+ const parsedKey = parseLoopByoaaSdkKey(options.apiKey);
928
+ const requestedBaseUrl = options.baseUrl ?? baseUrlForEnv(parsedKey.env);
929
+ const knownEnv = envForKnownBaseUrl(requestedBaseUrl);
930
+ if (knownEnv && knownEnv !== parsedKey.env) {
931
+ throw new LoopByoaaApiKeyError(
932
+ "cross_env_key_use",
933
+ `Loop BYOAA ${parsedKey.env} keys cannot call ${knownEnv} endpoints`,
934
+ { details: { key_env: parsedKey.env, endpoint_env: knownEnv } }
935
+ );
936
+ }
937
+ this.apiKey = options.apiKey;
938
+ this.env = parsedKey.env;
939
+ this.parsedKey = parsedKey;
940
+ this.baseUrl = cleanBaseUrl(requestedBaseUrl);
941
+ const http = new HttpClient(this.apiKey, this.baseUrl, fetchImpl, options.timeout ?? 1e4);
942
+ this.agents = new AgentsNamespace(http);
943
+ this.permissions = new PermissionsNamespace(http);
944
+ this.decisions = new DecisionsNamespace(http);
945
+ this.audit = new AuditNamespace(http);
946
+ }
947
+ };
948
+ var AgentsNamespace = class {
949
+ constructor(http) {
950
+ this.http = http;
951
+ }
952
+ register(input) {
953
+ return this.http.request("POST", "/api/v1/kya/agents/register", input).then((payload) => unwrapGatewayEnvelope(payload, "agent"));
954
+ }
955
+ list(params = {}) {
956
+ return this.http.request("GET", "/api/v1/kya/agents", void 0, params).then((payload) => unwrapGatewayList(payload, "agents"));
957
+ }
958
+ get(agentIdOrExternalId) {
959
+ return this.http.request("GET", `/api/v1/kya/agents/${encodeURIComponent(agentIdOrExternalId)}`).then((payload) => unwrapGatewayEnvelope(payload, "agent"));
960
+ }
961
+ update(id, input) {
962
+ return this.http.request("PATCH", `/api/v1/kya/agents/${encodeURIComponent(id)}`, input);
963
+ }
964
+ suspend(id, reason) {
965
+ return this.http.request("POST", `/api/v1/kya/agents/${encodeURIComponent(id)}/suspend`, { reason });
966
+ }
967
+ revoke(id, reason) {
968
+ return this.http.request("POST", `/api/v1/kya/agents/${encodeURIComponent(id)}/revoke`, { reason });
969
+ }
970
+ };
971
+ var PermissionsNamespace = class {
972
+ constructor(http) {
973
+ this.http = http;
974
+ }
975
+ grant(input) {
976
+ return this.http.request("POST", "/api/v1/kya/permissions/grant", input).then((payload) => unwrapGatewayEnvelope(payload, "permission"));
977
+ }
978
+ list(params = {}) {
979
+ return this.http.request("GET", "/api/v1/kya/permissions", void 0, params).then((payload) => unwrapGatewayList(payload, "permissions"));
980
+ }
981
+ get(grantId) {
982
+ return this.http.request("GET", `/api/v1/kya/permissions/${encodeURIComponent(grantId)}`).then((payload) => unwrapGatewayEnvelope(payload, "permission"));
983
+ }
984
+ revoke(grantId, input) {
985
+ return this.http.request("POST", `/api/v1/kya/permissions/${encodeURIComponent(grantId)}/revoke`, input);
986
+ }
987
+ };
988
+ var DecisionsNamespace = class {
989
+ constructor(http) {
990
+ this.http = http;
991
+ }
992
+ request(input) {
993
+ return this.http.request("POST", "/api/v1/kya/decisions/request", input);
994
+ }
995
+ get(envelopeId) {
996
+ return this.http.request("GET", `/api/v1/kya/decisions/${encodeURIComponent(envelopeId)}`).then((payload) => unwrapGatewayEnvelope(payload, "decision"));
997
+ }
998
+ list(params = {}) {
999
+ return this.http.request("GET", "/api/v1/kya/decisions", void 0, params).then((payload) => unwrapGatewayList(payload, "decisions"));
1000
+ }
1001
+ };
1002
+ var AuditNamespace = class {
1003
+ constructor(http) {
1004
+ this.http = http;
1005
+ }
1006
+ /**
1007
+ * Export a tamper-evident KYA audit pack.
1008
+ *
1009
+ * TODO(F.5): switch this to the public audit-pack REST route once the
1010
+ * customer-facing endpoint ships. E.1 intentionally points at the current
1011
+ * internal export bridge so the method shape is stable for quickstart code.
1012
+ */
1013
+ exportPack(input) {
1014
+ return this.http.request("GET", "/api/v1/kya/audit/export-pack", void 0, { limit: 200 }).then((payload) => unwrapGatewayEnvelope(payload, "export_pack"));
1015
+ }
1016
+ export(input) {
1017
+ return this.exportPack(input);
1018
+ }
1019
+ list(params = {}) {
1020
+ return this.http.request("GET", "/api/v1/kya/audit/events", void 0, params).then((payload) => unwrapGatewayList(payload, "events"));
1021
+ }
1022
+ get(eventId) {
1023
+ return this.http.request("GET", `/api/v1/kya/audit/events/${encodeURIComponent(eventId)}`).then((payload) => unwrapGatewayEnvelope(payload, "event"));
1024
+ }
1025
+ };
1026
+ export {
1027
+ AgentsNamespace,
1028
+ AttestedReceiptSubmitter,
1029
+ AttestedReceiptVerifier,
1030
+ AuditNamespace,
1031
+ DEVNET_PROGRAM_IDS,
1032
+ DecisionsNamespace,
1033
+ LoopByoaaApiKeyError,
1034
+ LoopByoaaClient,
1035
+ LoopByoaaError,
1036
+ LoopByoaaNetworkError,
1037
+ LoopByoaaPermissionError,
1038
+ LoopByoaaQuotaError,
1039
+ LoopByoaaRateLimitError,
1040
+ LoopByoaaServerError,
1041
+ LoopByoaaValidationError,
1042
+ MAINNET_PROGRAM_IDS,
1043
+ MAX_MERCHANT_NAME_RAW_LEN,
1044
+ MAX_RECEIPT_AGE_SECONDS,
1045
+ MAX_RECEIPT_CENTS,
1046
+ PermissionsNamespace,
1047
+ ReceiptNotAuditedError,
1048
+ ReceiptNotFoundError,
1049
+ SESSION_INSTRUCTION_INDEX_SUBMIT_ATTESTED_RECEIPT,
1050
+ UnsupportedProofTypeError,
1051
+ decodeBankAttestedReceipt,
1052
+ decodeRegistryPcr0Set,
1053
+ defaultRpcUrl,
1054
+ deriveAgentSessionPda,
1055
+ deriveBankAttestedReceiptPda,
1056
+ deriveBankTxnId,
1057
+ deriveShoppingStatePda,
1058
+ encodeAttestedReceiptArgs,
1059
+ encodeSubmitAttestedReceiptIxData,
1060
+ getProgramIds,
1061
+ instructionDiscriminator,
1062
+ isMainnet,
1063
+ normalizeMerchantName,
1064
+ parseLoopByoaaSdkKey,
1065
+ resolveConfig,
1066
+ validateReceipt,
1067
+ verifyAttestedReceiptProof
1068
+ };