@chainpayhq/sdk 0.0.0-stage → 0.1.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.
Files changed (89) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +42 -2
  3. package/dist/accounts.d.ts +23 -0
  4. package/dist/accounts.js +148 -0
  5. package/dist/cards/accounts.d.ts +156 -0
  6. package/dist/cards/accounts.js +346 -0
  7. package/dist/cards/api.d.ts +379 -0
  8. package/dist/cards/api.js +241 -0
  9. package/dist/cards/commitment.d.ts +86 -0
  10. package/dist/cards/commitment.js +288 -0
  11. package/dist/cards/constants.d.ts +160 -0
  12. package/dist/cards/constants.js +163 -0
  13. package/dist/cards/draft.d.ts +57 -0
  14. package/dist/cards/draft.js +131 -0
  15. package/dist/cards/evidence.d.ts +69 -0
  16. package/dist/cards/evidence.js +41 -0
  17. package/dist/cards/hash.d.ts +9 -0
  18. package/dist/cards/hash.js +47 -0
  19. package/dist/cards/index.d.ts +14 -0
  20. package/dist/cards/index.js +14 -0
  21. package/dist/cards/instructions.d.ts +302 -0
  22. package/dist/cards/instructions.js +474 -0
  23. package/dist/cards/layout.d.ts +33 -0
  24. package/dist/cards/layout.js +137 -0
  25. package/dist/cards/math.d.ts +67 -0
  26. package/dist/cards/math.js +138 -0
  27. package/dist/cards/merchants.d.ts +23 -0
  28. package/dist/cards/merchants.js +38 -0
  29. package/dist/cards/pda.d.ts +42 -0
  30. package/dist/cards/pda.js +93 -0
  31. package/dist/cards/private-repayment.d.ts +198 -0
  32. package/dist/cards/private-repayment.js +485 -0
  33. package/dist/cards/redact.d.ts +18 -0
  34. package/dist/cards/redact.js +103 -0
  35. package/dist/cards/tee.d.ts +253 -0
  36. package/dist/cards/tee.js +605 -0
  37. package/dist/cli.d.ts +31 -0
  38. package/dist/cli.js +351 -0
  39. package/dist/client.d.ts +80 -0
  40. package/dist/client.js +493 -0
  41. package/dist/constants.d.ts +44 -0
  42. package/dist/constants.js +43 -0
  43. package/dist/crossmint-adapt.d.ts +43 -0
  44. package/dist/crossmint-adapt.js +68 -0
  45. package/dist/crossmint-order.d.ts +220 -0
  46. package/dist/crossmint-order.js +638 -0
  47. package/dist/delivery.d.ts +66 -0
  48. package/dist/delivery.js +232 -0
  49. package/dist/encoding.d.ts +51 -0
  50. package/dist/encoding.js +128 -0
  51. package/dist/index.d.ts +27 -0
  52. package/dist/index.js +26 -0
  53. package/dist/known-assets.d.ts +30 -0
  54. package/dist/known-assets.js +49 -0
  55. package/dist/mandate-request.d.ts +123 -0
  56. package/dist/mandate-request.js +401 -0
  57. package/dist/mandate.d.ts +44 -0
  58. package/dist/mandate.js +157 -0
  59. package/dist/ops-snapshot.d.ts +157 -0
  60. package/dist/ops-snapshot.js +356 -0
  61. package/dist/payment-request.d.ts +37 -0
  62. package/dist/payment-request.js +218 -0
  63. package/dist/payment.d.ts +40 -0
  64. package/dist/payment.js +214 -0
  65. package/dist/pda.d.ts +13 -0
  66. package/dist/pda.js +36 -0
  67. package/dist/receipt-export.d.ts +55 -0
  68. package/dist/receipt-export.js +149 -0
  69. package/dist/receipt.d.ts +108 -0
  70. package/dist/receipt.js +213 -0
  71. package/dist/solana.d.ts +5 -0
  72. package/dist/solana.js +20 -0
  73. package/dist/token-capabilities.d.ts +9 -0
  74. package/dist/token-capabilities.js +139 -0
  75. package/dist/token.d.ts +18 -0
  76. package/dist/token.js +55 -0
  77. package/dist/transaction-reader.d.ts +10 -0
  78. package/dist/transaction-reader.js +18 -0
  79. package/dist/transaction-v1.d.ts +18 -0
  80. package/dist/transaction-v1.js +73 -0
  81. package/dist/types.d.ts +267 -0
  82. package/dist/types.js +1 -0
  83. package/dist/x402-adapt.d.ts +20 -0
  84. package/dist/x402-adapt.js +62 -0
  85. package/dist/x402-challenge.d.ts +116 -0
  86. package/dist/x402-challenge.js +385 -0
  87. package/dist/x402.d.ts +18 -0
  88. package/dist/x402.js +35 -0
  89. package/package.json +54 -4
@@ -0,0 +1,485 @@
1
+ /*
2
+ * Private statement repayment through MagicBlock Private Payments
3
+ * (contracts.md §7.3). Devnet USDC only.
4
+ *
5
+ * The flow, in the owner's browser or CLI:
6
+ * 1. `prepare` asks ChainPay for an attempt: the exact amount, the simulated
7
+ * partner, and an opaque `clientRefId`.
8
+ * 2. The owner reads the vault model (`privateRepaymentDisclosure`) and opts in.
9
+ * 3. `payStatementPrivately` signs in to MagicBlock with the wallet, deposits
10
+ * any shortfall into MagicBlock's vault (a private balance on the TEE
11
+ * rollup), then sends one private transfer to the partner tagged with the
12
+ * reference. The owner's wallet signs everything; ChainPay never holds
13
+ * the MagicBlock token or a key.
14
+ * 4. `submit` asks ChainPay to verify the partner's settlement on Solana.
15
+ *
16
+ * What ChainPay can verify: a finalized MagicBlock settlement paid exactly the
17
+ * amount due, in Devnet USDC, from MagicBlock's vault to the partner, tagged
18
+ * with this attempt's reference. What it cannot: who paid. The vault hides
19
+ * that link by design, so the method never claims "paid by your wallet".
20
+ */
21
+ export const MAGICBLOCK_PAYMENTS_API = "https://payments.magicblock.app";
22
+ export const PRIVATE_REPAYMENT_METHOD = "magicblock_private_payments";
23
+ export const PRIVATE_REPAYMENT_DEVNET_USDC = "4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU";
24
+ export const EPHEMERAL_SPL_PROGRAM = "SPLxh1LVZzEkX99H6rqYizhytLWPZVV296zyYDPagv2";
25
+ export class PrivateRepaymentError extends Error {
26
+ status;
27
+ code;
28
+ retryable;
29
+ constructor(status, code, message, retryable = false) {
30
+ super(message);
31
+ this.name = "PrivateRepaymentError";
32
+ this.status = status;
33
+ this.code = code;
34
+ this.retryable = retryable;
35
+ }
36
+ }
37
+ /** Refuse anything but the Devnet USDC private route ChainPay verifies. */
38
+ export function assertPayableAttempt(attempt) {
39
+ const problems = [];
40
+ if (attempt.method !== PRIVATE_REPAYMENT_METHOD)
41
+ problems.push("method");
42
+ if (attempt.cluster !== "devnet" || attempt.apiCluster !== "devnet-private")
43
+ problems.push("network");
44
+ if (attempt.mint !== PRIVATE_REPAYMENT_DEVNET_USDC)
45
+ problems.push("mint (Devnet USDC only)");
46
+ if (attempt.state !== "awaiting_settlement")
47
+ problems.push("attempt state");
48
+ const t = attempt.transfer;
49
+ if (!t || t.visibility !== "private" || t.fromBalance !== "ephemeral" || t.toBalance !== "base" || t.split !== 1 || t.exactOut !== true || t.memo !== null) {
50
+ problems.push("transfer route");
51
+ }
52
+ if (!/^[1-9][0-9]{0,11}$/.test(attempt.clientRefId))
53
+ problems.push("reference");
54
+ // The two amounts must be the same money: USDC has 6 decimals, so cents x 10 000.
55
+ // The disclosure shows amountCents; the wallet would sign amountBaseUnits.
56
+ if (!/^[1-9][0-9]{0,18}$/.test(attempt.amountBaseUnits) || !/^[1-9][0-9]{0,14}$/.test(attempt.amountCents ?? "") || BigInt(attempt.amountBaseUnits) !== BigInt(attempt.amountCents) * 10000n)
57
+ problems.push("amount");
58
+ if (!isAddress(attempt.recipientWallet))
59
+ problems.push("recipient");
60
+ if (problems.length) {
61
+ throw new PrivateRepaymentError(400, "attempt_not_payable", `This attempt can't be paid privately: ${problems.join(", ")}`);
62
+ }
63
+ }
64
+ /**
65
+ * The vault and deposit model in plain words, for the opt-in step. Every line
66
+ * is true of the route this module pays through; none claims more than
67
+ * ChainPay verifies.
68
+ */
69
+ export function privateRepaymentDisclosure(attempt) {
70
+ const dollars = formatCents(attempt.amountCents);
71
+ return [
72
+ `Your USDC moves into MagicBlock's shared vault first. Usually ChainPay leaves tokens in your account until a payment, but this one is different.`,
73
+ `From the vault, ${dollars} goes privately to the card partner. The link between your wallet and that payout stays off the public chain.`,
74
+ `What's still public: the deposit, the amount the partner receives, and when it lands. Matching amounts and times can still connect them.`,
75
+ `ChainPay checks that the partner got exactly ${dollars} with this statement's reference. It can't check who paid, so it won't say "paid by your wallet."`,
76
+ `Anything left in your private balance stays yours. You can withdraw it back to your wallet anytime.`,
77
+ `Devnet test USDC only. No real money.`,
78
+ ];
79
+ }
80
+ function formatCents(cents) {
81
+ if (!/^(0|[1-9][0-9]*)$/.test(cents))
82
+ return `${cents} cents`;
83
+ const padded = cents.padStart(3, "0");
84
+ return `$${padded.slice(0, -2)}.${padded.slice(-2)}`;
85
+ }
86
+ async function chainpay(opts, path, body) {
87
+ const f = opts.fetch ?? fetch;
88
+ const response = await f(`${opts.baseUrl.replace(/\/$/, "")}${path}`, {
89
+ method: "POST",
90
+ headers: { "content-type": "application/json", authorization: `Bearer ${opts.ownerSession}` },
91
+ body: JSON.stringify(body),
92
+ });
93
+ const text = await response.text();
94
+ let json = null;
95
+ try {
96
+ json = JSON.parse(text);
97
+ }
98
+ catch { /* not json */ }
99
+ if (!response.ok) {
100
+ throw new PrivateRepaymentError(response.status, json?.code ?? "chainpay_error", json?.message ?? `ChainPay request failed (${response.status})`, json?.retryable === true);
101
+ }
102
+ return json;
103
+ }
104
+ function statementPath(cardId, statementId) {
105
+ return `/v1/cards/${encodeURIComponent(cardId)}/statements/${encodeURIComponent(statementId)}`;
106
+ }
107
+ /** Get or create the open attempt (idempotent). */
108
+ export function preparePrivateRepayment(opts, cardId, statementId, clientOperationId) {
109
+ return chainpay(opts, `${statementPath(cardId, statementId)}/repayment/private`, { clientOperationId });
110
+ }
111
+ /**
112
+ * Ask ChainPay to verify the settlement. A `settlement_pending` error is
113
+ * retryable: MagicBlock's queue pays out after the transfer, usually within
114
+ * seconds.
115
+ */
116
+ export function submitPrivateRepayment(opts, cardId, statementId, attemptId) {
117
+ return chainpay(opts, `${statementPath(cardId, statementId)}/repayment`, { method: PRIVATE_REPAYMENT_METHOD, attemptId, cluster: "devnet" });
118
+ }
119
+ /** Poll `submit` until the settlement is found (or `timeoutMs` passes). */
120
+ export async function waitForPrivateRepayment(opts, cardId, statementId, attemptId, { timeoutMs = 90_000, intervalMs = 4_000, sleep = (ms) => new Promise((r) => setTimeout(r, ms)) } = {}) {
121
+ const deadline = Date.now() + timeoutMs;
122
+ for (;;) {
123
+ try {
124
+ return await submitPrivateRepayment(opts, cardId, statementId, attemptId);
125
+ }
126
+ catch (error) {
127
+ const pending = error instanceof PrivateRepaymentError && error.code === "settlement_pending";
128
+ if (!pending || Date.now() + intervalMs > deadline)
129
+ throw error;
130
+ await sleep(intervalMs);
131
+ }
132
+ }
133
+ }
134
+ async function magicblock(opts, method, path, body, token) {
135
+ const f = opts.fetch ?? fetch;
136
+ const headers = { "content-type": "application/json" };
137
+ if (token)
138
+ headers.authorization = `Bearer ${token}`;
139
+ const response = await f(`${(opts.api ?? MAGICBLOCK_PAYMENTS_API).replace(/\/$/, "")}${path}`, {
140
+ method,
141
+ headers,
142
+ body: body === undefined ? undefined : JSON.stringify(body),
143
+ });
144
+ const text = await response.text();
145
+ let json = null;
146
+ try {
147
+ json = JSON.parse(text);
148
+ }
149
+ catch { /* not json */ }
150
+ if (!response.ok) {
151
+ // Never echo URLs or headers: a tokenized RPC URL must not reach logs.
152
+ throw new PrivateRepaymentError(response.status, json?.error?.code ?? "magicblock_error", json?.error?.message ?? `MagicBlock request failed (${response.status})`, response.status >= 500);
153
+ }
154
+ return json;
155
+ }
156
+ function base58(bytes) {
157
+ const alphabet = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz";
158
+ let n = 0n;
159
+ for (const b of bytes)
160
+ n = n * 256n + BigInt(b);
161
+ let out = "";
162
+ while (n > 0n) {
163
+ out = alphabet[Number(n % 58n)] + out;
164
+ n /= 58n;
165
+ }
166
+ for (const b of bytes) {
167
+ if (b !== 0)
168
+ break;
169
+ out = "1" + out;
170
+ }
171
+ return out;
172
+ }
173
+ /**
174
+ * Wallet challenge login for private reads and rollup sends. The token lives
175
+ * in memory only: never store it, log it or send it to ChainPay.
176
+ */
177
+ export async function magicblockLogin(signer, opts = {}) {
178
+ const q = new URLSearchParams({ pubkey: signer.publicKey, cluster: "devnet-private" });
179
+ const { challenge } = await magicblock(opts, "GET", `/v1/spl/challenge?${q}`);
180
+ const signature = base58(await signer.signMessage(new TextEncoder().encode(challenge)));
181
+ const { token } = await magicblock(opts, "POST", "/v1/spl/login", { pubkey: signer.publicKey, challenge, signature, cluster: "devnet-private" });
182
+ return token;
183
+ }
184
+ export async function privateBalance(owner, mint, token, opts = {}) {
185
+ const q = new URLSearchParams({ address: owner, mint, cluster: "devnet-private" });
186
+ const row = await magicblock(opts, "GET", `/v1/spl/private-balance?${q}`, undefined, token);
187
+ return BigInt(row.balance);
188
+ }
189
+ function checkBuilt(built, owner, kind) {
190
+ if (built.kind !== kind || built.requiredSigners.length !== 1 || built.requiredSigners[0] !== owner) {
191
+ throw new PrivateRepaymentError(502, "unexpected_transaction", `MagicBlock returned an unexpected ${kind} transaction; nothing was signed`);
192
+ }
193
+ }
194
+ // ------------------------------------------ what the owner is asked to sign
195
+ const SYSTEM_PROGRAM = "11111111111111111111111111111111";
196
+ const COMPUTE_BUDGET_PROGRAM = "ComputeBudget111111111111111111111111111111";
197
+ const SPL_TOKEN_PROGRAM = "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA";
198
+ const ASSOCIATED_TOKEN_PROGRAM = "ATokenGPvbdGVxr1b2hvZbsiqW5xWH25efTNsLJA8knL";
199
+ const DELEGATION_PROGRAM = "DELeGGvXpWV2fqJUhqcF5ZSYMS4JTLjteaAMARRSaeSh";
200
+ const PERMISSION_PROGRAM = "ACLseoPoyC3cBqoUtkbjZ4aDrkurZW86v19pXz2XQnp1";
201
+ const MAGIC_PROGRAM = "Magic11111111111111111111111111111111111111";
202
+ /** Rent and fees only: a built transaction may move at most this much SOL. */
203
+ const MAX_LAMPORTS = 50000000n;
204
+ /**
205
+ * Ephemeral SPL Token instruction tags (magicblock-labs/ephemeral-spl-token,
206
+ * e-token-api/src/instruction.rs). Anything else in a built transaction is
207
+ * refused: withdrawals, shuttles to a cleartext destination, stealth pools.
208
+ */
209
+ const ESPL = {
210
+ initializeEphemeralAta: 0,
211
+ initializeGlobalVault: 1,
212
+ depositSplTokens: 2,
213
+ delegateEphemeralAta: 4,
214
+ createEphemeralAtaPermission: 6,
215
+ delegateEphemeralAtaPermission: 7,
216
+ initializeShuttleEphemeralAta: 11,
217
+ delegateShuttleEphemeralAta: 13,
218
+ depositAndQueueTransfer: 16,
219
+ ensureTransferQueueCrank: 17,
220
+ depositAndDelegateShuttleWithMergeToEncryptedDestination: 34,
221
+ };
222
+ const DEPOSIT_TAGS = [0, 1, 2, 4, 6, 7, 11, 13, 34];
223
+ const TRANSFER_TAGS = [0, 16, 17];
224
+ const BASE58 = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz";
225
+ function base58Decode(text) {
226
+ let n = 0n;
227
+ for (const char of text) {
228
+ const digit = BASE58.indexOf(char);
229
+ if (digit < 0)
230
+ return null;
231
+ n = n * 58n + BigInt(digit);
232
+ }
233
+ const bytes = [];
234
+ while (n > 0n) {
235
+ bytes.unshift(Number(n % 256n));
236
+ n /= 256n;
237
+ }
238
+ for (const char of text) {
239
+ if (char !== "1")
240
+ break;
241
+ bytes.unshift(0);
242
+ }
243
+ return Uint8Array.from(bytes);
244
+ }
245
+ function isAddress(value) {
246
+ return typeof value === "string" && base58Decode(value)?.length === 32;
247
+ }
248
+ function fromBase64(text) {
249
+ const binary = globalThis.atob(text);
250
+ return Uint8Array.from(binary, (char) => char.charCodeAt(0));
251
+ }
252
+ function u64(data, at) {
253
+ if (data.length < at + 8)
254
+ throw new Error("truncated");
255
+ let out = 0n;
256
+ for (let i = 7; i >= 0; i -= 1)
257
+ out = (out << 8n) | BigInt(data[at + i]);
258
+ return out;
259
+ }
260
+ /**
261
+ * Decode a serialized legacy (or v0 without lookup tables) transaction: its
262
+ * signers and every instruction with resolved account keys. Lookup tables
263
+ * can't be checked offline, so they are refused.
264
+ */
265
+ export function decodeBuiltTransaction(base64) {
266
+ const bytes = fromBase64(base64);
267
+ let at = 0;
268
+ const byte = () => { if (at >= bytes.length)
269
+ throw new Error("truncated"); return bytes[at++]; };
270
+ const compact = () => { let value = 0; for (let shift = 0; shift < 21; shift += 7) {
271
+ const b = byte();
272
+ value |= (b & 0x7f) << shift;
273
+ if (!(b & 0x80))
274
+ return value;
275
+ } throw new Error("bad length"); };
276
+ const take = (n) => { if (at + n > bytes.length)
277
+ throw new Error("truncated"); const out = bytes.slice(at, at + n); at += n; return out; };
278
+ take(compact() * 64);
279
+ const versioned = (bytes[at] & 0x80) !== 0;
280
+ if (versioned && byte() !== 0x80)
281
+ throw new Error("unsupported version");
282
+ const [required] = [byte(), byte(), byte()];
283
+ const keys = Array.from({ length: compact() }, () => base58(take(32)));
284
+ take(32); // recent blockhash
285
+ const instructions = Array.from({ length: compact() }, () => {
286
+ const program = keys[byte()];
287
+ const accounts = Array.from({ length: compact() }, () => keys[byte()]);
288
+ const data = take(compact());
289
+ if (!program || accounts.some((key) => key === undefined))
290
+ throw new Error("account index out of range");
291
+ return { programId: program, accounts, data };
292
+ });
293
+ if (versioned && compact() !== 0)
294
+ throw new Error("address lookup tables can't be checked");
295
+ if (at !== bytes.length)
296
+ throw new Error("trailing bytes");
297
+ return { signers: keys.slice(0, required), instructions };
298
+ }
299
+ function refuse(kind, why) {
300
+ throw new PrivateRepaymentError(502, "unexpected_transaction", `MagicBlock's ${kind} transaction ${why}; nothing was signed`);
301
+ }
302
+ /**
303
+ * Before the owner signs a MagicBlock-built transaction, check it does
304
+ * exactly what the statement says (review F1): only the expected programs,
305
+ * the owner as the only signer, and the one amount-bearing instruction moving
306
+ * the expected amount of the attempt's mint (to the attempt's recipient, for
307
+ * the transfer).
308
+ */
309
+ export function verifyBuiltTransaction(built, expect) {
310
+ const { kind } = expect;
311
+ let tx;
312
+ try {
313
+ tx = decodeBuiltTransaction(built.transactionBase64);
314
+ }
315
+ catch {
316
+ refuse(kind, "could not be read");
317
+ }
318
+ if (tx.signers.length !== 1 || tx.signers[0] !== expect.owner)
319
+ refuse(kind, "asks for a signer other than your wallet");
320
+ const allowed = new Set([SYSTEM_PROGRAM, COMPUTE_BUDGET_PROGRAM, ASSOCIATED_TOKEN_PROGRAM, EPHEMERAL_SPL_PROGRAM, DELEGATION_PROGRAM, PERMISSION_PROGRAM, MAGIC_PROGRAM]);
321
+ const tags = kind === "deposit" ? DEPOSIT_TAGS : TRANSFER_TAGS;
322
+ let lamports = 0n;
323
+ const moves = [];
324
+ for (const ix of tx.instructions) {
325
+ // SPL Token at the top level could transfer or approve anything: never.
326
+ if (!allowed.has(ix.programId))
327
+ refuse(kind, "calls a program ChainPay doesn't expect");
328
+ if (ix.programId === SYSTEM_PROGRAM && ix.data.length >= 12 && ix.data[0] === 2 && ix.data[1] === 0 && ix.data[2] === 0 && ix.data[3] === 0)
329
+ lamports += u64(ix.data, 4);
330
+ if (ix.programId !== EPHEMERAL_SPL_PROGRAM)
331
+ continue;
332
+ if (!ix.data.length || !tags.includes(ix.data[0]))
333
+ refuse(kind, "includes a token instruction ChainPay doesn't expect");
334
+ if ([ESPL.depositSplTokens, ESPL.depositAndQueueTransfer, ESPL.depositAndDelegateShuttleWithMergeToEncryptedDestination].includes(ix.data[0]))
335
+ moves.push(ix);
336
+ }
337
+ if (lamports > MAX_LAMPORTS)
338
+ refuse(kind, "moves more SOL than rent needs");
339
+ if (moves.length !== 1)
340
+ refuse(kind, "doesn't move exactly one amount");
341
+ const [move] = moves;
342
+ try {
343
+ if (move.data[0] === ESPL.depositSplTokens) {
344
+ // [tag][amount u64]; accounts: eata, vault, mint, source, vault token, authority, token program
345
+ if (move.data.length !== 9 || move.accounts[2] !== expect.mint || move.accounts[5] !== expect.owner)
346
+ refuse(kind, "deposits from the wrong account or mint");
347
+ if (u64(move.data, 1) !== expect.amountBaseUnits)
348
+ refuse(kind, "deposits a different amount");
349
+ }
350
+ else if (move.data[0] === ESPL.depositAndDelegateShuttleWithMergeToEncryptedDestination) {
351
+ // [tag][shuttle u32][amount u64]...; accounts: payer .. shuttle owner(5) .. mint(13)
352
+ if (move.accounts[0] !== expect.owner || move.accounts[5] !== expect.owner || move.accounts[13] !== expect.mint)
353
+ refuse(kind, "deposits from the wrong account or mint");
354
+ if (u64(move.data, 5) !== expect.amountBaseUnits)
355
+ refuse(kind, "deposits a different amount");
356
+ }
357
+ else {
358
+ // [tag][amount u64][group 3][min u64][max u64][split u32][flags u8?][clientRefId u64?]
359
+ // accounts: queue, vault, mint(2), source, vault token, destination owner(5), sender(6), ...
360
+ if (kind !== "transfer")
361
+ refuse(kind, "is not a deposit");
362
+ if (move.accounts[2] !== expect.mint || move.accounts[5] !== expect.recipientWallet || move.accounts[6] !== expect.owner)
363
+ refuse(kind, "pays a different recipient or mint");
364
+ if (u64(move.data, 1) !== expect.amountBaseUnits)
365
+ refuse(kind, "pays a different amount");
366
+ const split = move.data[28] | (move.data[29] << 8) | (move.data[30] << 16) | (move.data[31] << 24);
367
+ if (split !== 1)
368
+ refuse(kind, "splits the payment");
369
+ if (expect.minDelayMs !== undefined && u64(move.data, 12) !== BigInt(expect.minDelayMs))
370
+ refuse(kind, "uses a different delay");
371
+ if (expect.maxDelayMs !== undefined && u64(move.data, 20) !== BigInt(expect.maxDelayMs))
372
+ refuse(kind, "uses a different delay");
373
+ if (move.data.length !== 40 && move.data.length !== 41)
374
+ refuse(kind, "carries no statement reference");
375
+ if (u64(move.data, move.data.length - 8).toString() !== expect.clientRefId)
376
+ refuse(kind, "carries a different statement reference");
377
+ }
378
+ }
379
+ catch (error) {
380
+ if (error instanceof PrivateRepaymentError)
381
+ throw error;
382
+ refuse(kind, "could not be read");
383
+ }
384
+ }
385
+ async function sendViaApi(opts, built, signed, token) {
386
+ const result = await magicblock(opts, "POST", "/v1/transaction/send", {
387
+ transactionBase64: signed,
388
+ sendTo: built.sendTo,
389
+ sendRpcEndpoint: built.sendRpcEndpoint,
390
+ confirm: true,
391
+ recentBlockhash: built.recentBlockhash,
392
+ lastValidBlockHeight: built.lastValidBlockHeight,
393
+ }, token);
394
+ return result.signature;
395
+ }
396
+ /**
397
+ * Pay the attempt privately: deposit any shortfall into MagicBlock's vault,
398
+ * then one private transfer to the partner tagged with the attempt reference.
399
+ * Call only after the owner opted in on `privateRepaymentDisclosure`.
400
+ *
401
+ * A rollup send can report an error even though the transfer landed (seen
402
+ * live on Devnet: "block height exceeded" for a transfer that settled), so a
403
+ * failed transfer send resolves as `outcome: "unknown"`; ask ChainPay
404
+ * (`waitForPrivateRepayment`) before paying again.
405
+ */
406
+ export async function payStatementPrivately(input) {
407
+ const { attempt, signer, onStep } = input;
408
+ assertPayableAttempt(attempt);
409
+ const opts = input.magicblock ?? {};
410
+ const sleep = input.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
411
+ const owner = signer.publicKey;
412
+ const amount = BigInt(attempt.amountBaseUnits);
413
+ onStep?.({ step: "login" });
414
+ const token = await magicblockLogin(signer, opts);
415
+ let balance = await privateBalance(owner, attempt.mint, token, opts);
416
+ onStep?.({ step: "balance", privateBalance: balance.toString() });
417
+ let depositSignature;
418
+ if (balance < amount) {
419
+ const shortfall = amount - balance;
420
+ const built = await magicblock(opts, "POST", "/v1/spl/deposit", {
421
+ owner,
422
+ amount: Number(shortfall),
423
+ mint: attempt.mint,
424
+ cluster: "devnet-private",
425
+ initIfMissing: true,
426
+ initVaultIfMissing: true,
427
+ initAtasIfMissing: true,
428
+ idempotent: true,
429
+ private: true,
430
+ }, token);
431
+ checkBuilt(built, owner, "deposit");
432
+ verifyBuiltTransaction(built, { kind: "deposit", owner, mint: attempt.mint, amountBaseUnits: shortfall });
433
+ onStep?.({ step: "deposit", amountBaseUnits: shortfall.toString() });
434
+ const signed = await signer.signTransaction(built.transactionBase64);
435
+ depositSignature = built.sendTo === "base" && input.sendBase
436
+ ? await input.sendBase(signed, built)
437
+ : await sendViaApi(opts, built, signed, token);
438
+ onStep?.({ step: "deposit", amountBaseUnits: shortfall.toString(), signature: depositSignature });
439
+ const deadline = Date.now() + (input.waitMs ?? 60_000);
440
+ while (balance < amount) {
441
+ if (Date.now() > deadline) {
442
+ throw new PrivateRepaymentError(504, "deposit_pending", "Your deposit hasn't reached your private balance yet. Nothing was paid; try again in a minute.", true);
443
+ }
444
+ await sleep(2_000);
445
+ balance = await privateBalance(owner, attempt.mint, token, opts);
446
+ }
447
+ }
448
+ const t = attempt.transfer;
449
+ const built = await magicblock(opts, "POST", "/v1/spl/transfer", {
450
+ from: owner,
451
+ to: attempt.recipientWallet,
452
+ mint: attempt.mint,
453
+ amount: Number(amount),
454
+ visibility: t.visibility,
455
+ fromBalance: t.fromBalance,
456
+ toBalance: t.toBalance,
457
+ cluster: "devnet-private",
458
+ initIfMissing: true,
459
+ initAtasIfMissing: true,
460
+ initVaultIfMissing: false,
461
+ clientRefId: attempt.clientRefId,
462
+ exactOut: t.exactOut,
463
+ split: t.split,
464
+ minDelayMs: t.minDelayMs,
465
+ maxDelayMs: t.maxDelayMs,
466
+ legacy: true,
467
+ }, token);
468
+ checkBuilt(built, owner, "transfer");
469
+ verifyBuiltTransaction(built, { kind: "transfer", owner, mint: attempt.mint, amountBaseUnits: amount, recipientWallet: attempt.recipientWallet, clientRefId: attempt.clientRefId, minDelayMs: t.minDelayMs, maxDelayMs: t.maxDelayMs });
470
+ if (built.fees && built.fees.tokens !== "0") {
471
+ throw new PrivateRepaymentError(502, "unexpected_fee", "MagicBlock quoted a token fee for this transfer; nothing was signed");
472
+ }
473
+ const signed = await signer.signTransaction(built.transactionBase64);
474
+ try {
475
+ const transferSignature = built.sendTo === "base" && input.sendBase
476
+ ? await input.sendBase(signed, built)
477
+ : await sendViaApi(opts, built, signed, token);
478
+ onStep?.({ step: "transfer", signature: transferSignature, outcome: "sent" });
479
+ return { depositSignature, transferSignature, transferOutcome: "sent" };
480
+ }
481
+ catch {
482
+ onStep?.({ step: "transfer", outcome: "unknown" });
483
+ return { depositSignature, transferOutcome: "unknown" };
484
+ }
485
+ }
@@ -0,0 +1,18 @@
1
+ export declare const CARD_DATA_REDACTION = "[redacted]";
2
+ export declare function isForbiddenCardKey(key: string): boolean;
3
+ /** Every 13 to 19 digit run that could be a card number. */
4
+ export declare function findCardNumberLike(text: string): string[];
5
+ /** Input-side scan: also catches digits separated by dots or slashes. */
6
+ export declare function findCardNumberLikeInInput(text: string): string[];
7
+ /** Input-side redaction, for anything that echoes caller text (errors). */
8
+ export declare function redactCardNumbersInInput(text: string): string;
9
+ export declare function luhnValid(digits: string): boolean;
10
+ export declare function redactCardNumbers(text: string): string;
11
+ export type RedactionResult<T> = {
12
+ value: T;
13
+ redactions: number;
14
+ };
15
+ /** Deep copy with forbidden keys removed and card-number-like runs replaced in every string. */
16
+ export declare function redactCardData<T>(input: T): RedactionResult<T>;
17
+ /** Throw instead of redacting. Use at trust boundaries where a match means a bug upstream. */
18
+ export declare function assertNoCardData(value: unknown, where?: string): void;
@@ -0,0 +1,103 @@
1
+ /*
2
+ * Card-data hygiene for anything that leaves a ChainPay process toward a
3
+ * model, a log or a screen (contracts.md §5 "never stored anywhere", §10
4
+ * forbidden list). Two layers:
5
+ * 1. Forbidden keys are dropped wherever they appear.
6
+ * 2. Any free-standing run of 13 to 19 digits (spaces or dashes allowed
7
+ * between digits) is replaced, Luhn-valid or not. ChainPay never needs
8
+ * to show such a number: amounts are cents (≤ 7 digits under the sandbox
9
+ * cap) and times are ISO strings.
10
+ * Digits glued to letters, `_` or `-` (base58 addresses, hex digests, UUIDs,
11
+ * base64url tokens) are not a match. A bare 13-digit epoch-ms value IS
12
+ * redacted, which is why card outputs use ISO times.
13
+ */
14
+ export const CARD_DATA_REDACTION = "[redacted]";
15
+ const FORBIDDEN_KEY = /^(?:pan|card_?number|cardnum|primary_?account_?number|cvv2?|cvc2?|csc|security_?code|exp(?:iry|iration)?(?:_?(?:date|month|year))?|exp_?(?:month|year)|pin|embed_?url|embed_?session|track_?data|tee_?token|auth_?token|access_?token)$/i;
16
+ // `_` and `-` count as part of an identifier, so UUIDs, base64url capabilities
17
+ // and dashed ids are never touched; a dashed PAN still matches when it stands alone.
18
+ const CARD_NUMBER_RUN = /(?<![0-9A-Za-z_-])\d(?:[ -]?\d){12,18}(?![0-9A-Za-z_-])/g;
19
+ // Inputs (tool arguments, keys included) are scanned more loosely: dots and
20
+ // slashes between digits too, so `4111.1111.1111.1111` or `4111/1111/...`
21
+ // never reaches Axum (review F7). Outputs keep the strict pattern above.
22
+ const CARD_NUMBER_RUN_INPUT = /(?<![0-9A-Za-z_-])\d(?:[ ./-]?\d){12,18}(?![0-9A-Za-z_-])/g;
23
+ export function isForbiddenCardKey(key) {
24
+ return FORBIDDEN_KEY.test(key);
25
+ }
26
+ /** Every 13 to 19 digit run that could be a card number. */
27
+ export function findCardNumberLike(text) {
28
+ return text.match(CARD_NUMBER_RUN) ?? [];
29
+ }
30
+ /** Input-side scan: also catches digits separated by dots or slashes. */
31
+ export function findCardNumberLikeInInput(text) {
32
+ return text.match(CARD_NUMBER_RUN_INPUT) ?? [];
33
+ }
34
+ /** Input-side redaction, for anything that echoes caller text (errors). */
35
+ export function redactCardNumbersInInput(text) {
36
+ return text.replace(CARD_NUMBER_RUN_INPUT, CARD_DATA_REDACTION);
37
+ }
38
+ export function luhnValid(digits) {
39
+ const clean = digits.replace(/[ -]/g, "");
40
+ if (!/^\d+$/.test(clean))
41
+ return false;
42
+ let sum = 0;
43
+ let double = false;
44
+ for (let i = clean.length - 1; i >= 0; i -= 1) {
45
+ let digit = clean.charCodeAt(i) - 48;
46
+ if (double) {
47
+ digit *= 2;
48
+ if (digit > 9)
49
+ digit -= 9;
50
+ }
51
+ sum += digit;
52
+ double = !double;
53
+ }
54
+ return sum % 10 === 0;
55
+ }
56
+ export function redactCardNumbers(text) {
57
+ return text.replace(CARD_NUMBER_RUN, CARD_DATA_REDACTION);
58
+ }
59
+ /** Deep copy with forbidden keys removed and card-number-like runs replaced in every string. */
60
+ export function redactCardData(input) {
61
+ let redactions = 0;
62
+ const walk = (value, depth) => {
63
+ if (depth > 32)
64
+ throw new Error("Value is nested too deeply to check for card data");
65
+ if (typeof value === "string") {
66
+ const next = redactCardNumbers(value);
67
+ if (next !== value)
68
+ redactions += 1;
69
+ return next;
70
+ }
71
+ if (typeof value === "number" || typeof value === "bigint") {
72
+ const text = value.toString();
73
+ if (findCardNumberLike(text).length) {
74
+ redactions += 1;
75
+ return CARD_DATA_REDACTION;
76
+ }
77
+ return value;
78
+ }
79
+ if (Array.isArray(value))
80
+ return value.map((item) => walk(item, depth + 1));
81
+ if (value instanceof Uint8Array)
82
+ return value;
83
+ if (value && typeof value === "object") {
84
+ const out = {};
85
+ for (const [key, item] of Object.entries(value)) {
86
+ if (isForbiddenCardKey(key)) {
87
+ redactions += 1;
88
+ continue;
89
+ }
90
+ out[key] = walk(item, depth + 1);
91
+ }
92
+ return out;
93
+ }
94
+ return value;
95
+ };
96
+ return { value: walk(input, 0), redactions };
97
+ }
98
+ /** Throw instead of redacting. Use at trust boundaries where a match means a bug upstream. */
99
+ export function assertNoCardData(value, where = "value") {
100
+ const { redactions } = redactCardData(value);
101
+ if (redactions > 0)
102
+ throw new Error(`Card data is never allowed in ${where}`);
103
+ }