@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,605 @@
1
+ import { PublicKey } from "@solana/web3.js";
2
+ import { DEVNET_TEE_URL } from "./constants.js";
3
+ import { decodeCardPeriod, decodeCardPolicy, decodeCheckoutIntent, decodeReservation, } from "./accounts.js";
4
+ import { availableCents, centsToString, maxObligationCents } from "./math.js";
5
+ /*
6
+ * MagicBlock Private Ephemeral Rollup access (contracts.md §2, CD-4, CD-6).
7
+ *
8
+ * - Each party gets its own token through a wallet-signed challenge. Tokens
9
+ * are secrets: never logged, never in errors, never serialized.
10
+ * - A permissioned read that comes back `value: null` means "not visible to
11
+ * you". It is indistinguishable from "does not exist" on the wire, so it
12
+ * is never reported as missing.
13
+ * - `verifyTeeRpcIntegrity` proves genuine TDX hardware, not which code runs.
14
+ * `verifyTee` adds the workload check: MRTD/RTMR0-3/MROWNER of the same
15
+ * fresh quote against MAGICBLOCK_DEVNET_TEE_MEASUREMENTS (pinned below).
16
+ */
17
+ const DAY_MS = 86_400_000;
18
+ const SESSION_FALLBACK_MS = 30 * DAY_MS;
19
+ const REFRESH_WINDOW_MS = DAY_MS;
20
+ const ED25519_PKCS8_PREFIX = Uint8Array.from([
21
+ 0x30, 0x2e, 0x02, 0x01, 0x00, 0x30, 0x05, 0x06, 0x03, 0x2b, 0x65, 0x70, 0x04, 0x22, 0x04, 0x20,
22
+ ]);
23
+ /** Signer for a server keypair (Axum authorizer, scripts). The secret stays in this closure. */
24
+ export async function keypairSigner(secretKey) {
25
+ if (secretKey.length !== 64)
26
+ throw new Error("Expected a 64-byte Solana secret key");
27
+ const seed = secretKey.slice(0, 32);
28
+ const publicKey = new PublicKey(secretKey.slice(32)).toBase58();
29
+ const pkcs8 = new Uint8Array(ED25519_PKCS8_PREFIX.length + 32);
30
+ pkcs8.set(ED25519_PKCS8_PREFIX);
31
+ pkcs8.set(seed, ED25519_PKCS8_PREFIX.length);
32
+ const key = await globalThis.crypto.subtle.importKey("pkcs8", pkcs8.buffer, { name: "Ed25519" }, false, ["sign"]);
33
+ pkcs8.fill(0);
34
+ seed.fill(0);
35
+ return {
36
+ publicKey,
37
+ async signMessage(message) {
38
+ return new Uint8Array(await globalThis.crypto.subtle.sign("Ed25519", key, message.slice().buffer));
39
+ },
40
+ };
41
+ }
42
+ /** Signer for a browser wallet (Phantom via wallet adapter, or wallet-standard). */
43
+ export function walletAdapterSigner(adapter) {
44
+ const raw = adapter.publicKey;
45
+ if (!raw)
46
+ throw new Error("Connect a wallet first");
47
+ const publicKey = new PublicKey(typeof raw === "string" ? raw : raw.toBase58()).toBase58();
48
+ if (typeof adapter.signMessage !== "function")
49
+ throw new Error("This wallet can't sign messages, so it can't open a private card session");
50
+ const sign = adapter.signMessage.bind(adapter);
51
+ return {
52
+ publicKey,
53
+ async signMessage(message) {
54
+ const result = await sign(message);
55
+ const signature = result instanceof Uint8Array ? result : Array.isArray(result) ? result[0]?.signature : result?.signature;
56
+ if (!(signature instanceof Uint8Array) || signature.length !== 64)
57
+ throw new Error("Wallet returned an invalid signature");
58
+ return new Uint8Array(signature);
59
+ },
60
+ };
61
+ }
62
+ const BASE58 = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz";
63
+ export function base58Encode(bytes) {
64
+ let value = 0n;
65
+ for (const byte of bytes)
66
+ value = (value << 8n) | BigInt(byte);
67
+ let out = "";
68
+ while (value > 0n) {
69
+ out = BASE58[Number(value % 58n)] + out;
70
+ value /= 58n;
71
+ }
72
+ for (const byte of bytes) {
73
+ if (byte !== 0)
74
+ break;
75
+ out = "1" + out;
76
+ }
77
+ return out;
78
+ }
79
+ async function verifyEd25519(publicKey, message, signature) {
80
+ try {
81
+ const key = await globalThis.crypto.subtle.importKey("raw", new PublicKey(publicKey).toBytes().slice().buffer, { name: "Ed25519" }, false, ["verify"]);
82
+ return await globalThis.crypto.subtle.verify("Ed25519", key, signature.slice().buffer, message.slice().buffer);
83
+ }
84
+ catch {
85
+ return false;
86
+ }
87
+ }
88
+ export class TeeAuthError extends Error {
89
+ constructor(message) {
90
+ super(message);
91
+ this.name = "TeeAuthError";
92
+ }
93
+ }
94
+ async function acquireToken(wallet, teeUrl, fetchImpl, now, timeoutMs) {
95
+ let challenge;
96
+ try {
97
+ const response = await fetchImpl(`${teeUrl}/auth/challenge?${new URLSearchParams({ pubkey: wallet.publicKey })}`, { signal: AbortSignal.timeout(timeoutMs) });
98
+ const body = await response.json();
99
+ if (typeof body.error === "string" && body.error)
100
+ throw new TeeAuthError("The private rollup refused to issue a sign-in challenge");
101
+ if (typeof body.challenge !== "string" || !body.challenge)
102
+ throw new TeeAuthError("The private rollup sent no sign-in challenge");
103
+ challenge = body.challenge;
104
+ }
105
+ catch (error) {
106
+ if (error instanceof TeeAuthError)
107
+ throw error;
108
+ throw new TeeAuthError("Couldn't reach the private rollup to sign in");
109
+ }
110
+ const message = new TextEncoder().encode(challenge);
111
+ const signature = await wallet.signMessage(message);
112
+ if (signature.length !== 64 || !await verifyEd25519(wallet.publicKey, message, signature)) {
113
+ throw new TeeAuthError("The wallet's signature doesn't match its address");
114
+ }
115
+ let response;
116
+ try {
117
+ response = await fetchImpl(`${teeUrl}/auth/login`, {
118
+ method: "POST",
119
+ headers: { "Content-Type": "application/json" },
120
+ body: JSON.stringify({ pubkey: wallet.publicKey, challenge, signature: base58Encode(signature) }),
121
+ signal: AbortSignal.timeout(timeoutMs),
122
+ });
123
+ }
124
+ catch {
125
+ throw new TeeAuthError("Couldn't reach the private rollup to sign in");
126
+ }
127
+ const body = await response.json().catch(() => ({}));
128
+ if (response.status !== 200)
129
+ throw new TeeAuthError("The private rollup rejected the sign-in");
130
+ if (typeof body.token !== "string" || !body.token)
131
+ throw new TeeAuthError("The private rollup returned no session");
132
+ const expiresAt = typeof body.expiresAt === "number" && Number.isFinite(body.expiresAt) ? body.expiresAt : now + SESSION_FALLBACK_MS;
133
+ return { token: body.token, expiresAt };
134
+ }
135
+ /**
136
+ * One wallet's authenticated connection to the private rollup. Refreshes the
137
+ * token when it is within 24 h of expiry or after any 401. The token is held
138
+ * in a private field and never leaves this object except through
139
+ * `endpoint()`, which exists only to build a Connection for sending
140
+ * owner-signed transactions.
141
+ */
142
+ export class TeeSession {
143
+ wallet;
144
+ teeUrl;
145
+ #grant;
146
+ #signer;
147
+ #fetch;
148
+ #now;
149
+ #timeoutMs;
150
+ #refreshing = null;
151
+ constructor(signer, grant, teeUrl, fetchImpl, now, timeoutMs) {
152
+ this.wallet = signer.publicKey;
153
+ this.teeUrl = teeUrl;
154
+ this.#grant = grant;
155
+ this.#signer = signer;
156
+ this.#fetch = fetchImpl;
157
+ this.#now = now;
158
+ this.#timeoutMs = timeoutMs;
159
+ }
160
+ static async open(signer, options = {}) {
161
+ const teeUrl = (options.teeUrl ?? DEVNET_TEE_URL).replace(/\/+$/, "");
162
+ const fetchImpl = options.fetch ?? globalThis.fetch.bind(globalThis);
163
+ const now = options.now ?? Date.now;
164
+ const timeoutMs = options.timeoutMs ?? 15_000;
165
+ const grant = await acquireToken(signer, teeUrl, fetchImpl, now(), timeoutMs);
166
+ return new TeeSession(signer, grant, teeUrl, fetchImpl, now, timeoutMs);
167
+ }
168
+ get expiresAt() {
169
+ return this.#grant.expiresAt;
170
+ }
171
+ needsRefresh() {
172
+ return this.#grant.expiresAt - this.#now() < REFRESH_WINDOW_MS;
173
+ }
174
+ async refresh() {
175
+ if (!this.#refreshing) {
176
+ this.#refreshing = acquireToken(this.#signer, this.teeUrl, this.#fetch, this.#now(), this.#timeoutMs)
177
+ .then((grant) => { this.#grant = grant; })
178
+ .finally(() => { this.#refreshing = null; });
179
+ }
180
+ return this.#refreshing;
181
+ }
182
+ async ensureFresh() {
183
+ if (this.needsRefresh())
184
+ await this.refresh();
185
+ }
186
+ /** SECRET: the tokenized RPC URL. Use only to construct a Connection; never log or display it. */
187
+ async endpoint() {
188
+ await this.ensureFresh();
189
+ return `${this.teeUrl}?token=${encodeURIComponent(this.#grant.token)}`;
190
+ }
191
+ /** Raw JSON-RPC over the session. Retries once after a 401 with a fresh token. */
192
+ async rpc(method, params) {
193
+ await this.ensureFresh();
194
+ const send = async () => {
195
+ try {
196
+ return await this.#fetch(`${this.teeUrl}?token=${encodeURIComponent(this.#grant.token)}`, {
197
+ method: "POST",
198
+ headers: { "Content-Type": "application/json" },
199
+ body: JSON.stringify({ jsonrpc: "2.0", id: 1, method, params }),
200
+ signal: AbortSignal.timeout(this.#timeoutMs),
201
+ });
202
+ }
203
+ catch {
204
+ // Never rethrow the platform error: it can carry the tokenized URL.
205
+ return null;
206
+ }
207
+ };
208
+ let response = await send();
209
+ if (response?.status === 401) {
210
+ await this.refresh();
211
+ response = await send();
212
+ }
213
+ if (!response)
214
+ return { httpStatus: 0, body: null };
215
+ const body = await response.json().catch(() => null);
216
+ return { httpStatus: response.status, body };
217
+ }
218
+ toJSON() {
219
+ return { wallet: this.wallet, teeUrl: this.teeUrl, expiresAt: this.#grant.expiresAt, token: "[redacted]" };
220
+ }
221
+ [Symbol.for("nodejs.util.inspect.custom")]() {
222
+ return `TeeSession(${this.wallet}, token [redacted])`;
223
+ }
224
+ }
225
+ /** Contract name (§10): open a TEE session for a keypair- or wallet-backed signer. */
226
+ export function getTeeSession(wallet, options) {
227
+ return TeeSession.open(wallet, options);
228
+ }
229
+ function slotOf(body) {
230
+ const slot = body.result?.context?.slot;
231
+ return typeof slot === "number" && Number.isSafeInteger(slot) ? BigInt(slot) : null;
232
+ }
233
+ function base64Bytes(value) {
234
+ const binary = globalThis.atob(value);
235
+ return Uint8Array.from(binary, (char) => char.charCodeAt(0));
236
+ }
237
+ export async function readTeeAccount(session, address, commitment = "confirmed") {
238
+ const target = new PublicKey(address).toBase58();
239
+ const { httpStatus, body } = await session.rpc("getAccountInfo", [target, { encoding: "base64", commitment }]);
240
+ if (httpStatus === 0)
241
+ return { state: "rpc_error", code: "network" };
242
+ if (httpStatus !== 200)
243
+ return { state: "rpc_error", code: `http_${httpStatus}` };
244
+ const json = (body ?? {});
245
+ if (json.error)
246
+ return { state: "rpc_error", code: `rpc_${typeof json.error.code === "number" ? json.error.code : "error"}` };
247
+ if (!json.result || typeof json.result !== "object")
248
+ return { state: "rpc_error", code: "malformed_response" };
249
+ const value = json.result.value;
250
+ const slot = slotOf(json);
251
+ if (value === null || value === undefined)
252
+ return { state: "not_visible", slot };
253
+ if (typeof value.owner !== "string" || !Array.isArray(value.data) || typeof value.data[0] !== "string" || typeof value.lamports !== "number") {
254
+ return { state: "rpc_error", code: "malformed_account" };
255
+ }
256
+ return {
257
+ state: "visible",
258
+ slot: slot ?? 0n,
259
+ account: { owner: value.owner, lamports: BigInt(value.lamports), data: base64Bytes(value.data[0]), executable: value.executable === true },
260
+ };
261
+ }
262
+ async function readDecoded(session, address, programId, decode) {
263
+ const read = await readTeeAccount(session, address);
264
+ if (read.state !== "visible")
265
+ return read;
266
+ if (programId && read.account.owner !== programId)
267
+ return { state: "rpc_error", code: "wrong_owner_program" };
268
+ try {
269
+ return { state: "visible", slot: read.slot, account: decode(read.account.data) };
270
+ }
271
+ catch {
272
+ return { state: "rpc_error", code: "invalid_account_data" };
273
+ }
274
+ }
275
+ const hex = (bytes) => Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join("");
276
+ /** 0 = no expiry. Out-of-range seconds still render (as raw seconds) instead of throwing. */
277
+ function expiryIso(seconds) {
278
+ if (seconds === 0n)
279
+ return null;
280
+ const ms = Number(seconds) * 1000;
281
+ return Number.isFinite(ms) && Math.abs(ms) <= 8.64e15 ? new Date(ms).toISOString() : `unix:${seconds.toString()}`;
282
+ }
283
+ export function cardPolicyView(policy) {
284
+ return {
285
+ policyVersion: policy.policyVersion,
286
+ authorizer: policy.authorizer,
287
+ budgetCents: centsToString(policy.budgetCents),
288
+ maxPurchaseCents: centsToString(policy.maxPurchaseCents),
289
+ maxPurchasesPerPeriod: policy.maxPurchasesPerPeriod,
290
+ periodSeconds: policy.periodSeconds,
291
+ currency: policy.currency,
292
+ merchantIdHashes: policy.merchantIdHashes.map(hex),
293
+ mccs: [...policy.mccs],
294
+ expiresAt: expiryIso(policy.expiresAt),
295
+ recurringAllowed: policy.recurringAllowed,
296
+ feeBps: policy.feeBps,
297
+ maxObligationCents: centsToString(maxObligationCents(policy.budgetCents, Math.min(policy.feeBps, 1_000))),
298
+ frozen: policy.frozen,
299
+ freezeReason: policy.freezeReason,
300
+ recoveryState: policy.recoveryState,
301
+ statementOutstandingCents: centsToString(policy.statementOutstandingCents),
302
+ exceptionsOpen: policy.exceptionsOpen,
303
+ members: policy.members.map((member) => ({ ...member })),
304
+ ledgerSeq: policy.ledgerSeq.toString(),
305
+ commitSeq: policy.commitSeq.toString(),
306
+ };
307
+ }
308
+ export async function readCardPolicy(session, policyAddress, programId) {
309
+ return readDecoded(session, policyAddress, programId, (data) => cardPolicyView(decodeCardPolicy(data)));
310
+ }
311
+ export function readCardPeriod(session, periodAddress, programId) {
312
+ return readDecoded(session, periodAddress, programId, decodeCardPeriod);
313
+ }
314
+ export function readReservation(session, address, programId) {
315
+ return readDecoded(session, address, programId, decodeReservation);
316
+ }
317
+ export function readCheckoutIntent(session, address, programId) {
318
+ return readDecoded(session, address, programId, decodeCheckoutIntent);
319
+ }
320
+ /** Budget left this period, only when both accounts are visible to this wallet. */
321
+ export async function readAvailableCents(session, accounts, programId) {
322
+ const [policy, period] = await Promise.all([
323
+ readDecoded(session, accounts.policy, programId, decodeCardPolicy),
324
+ readCardPeriod(session, accounts.period, programId),
325
+ ]);
326
+ if (policy.state !== "visible")
327
+ return policy;
328
+ if (period.state !== "visible")
329
+ return period;
330
+ return {
331
+ state: "visible",
332
+ slot: period.slot > policy.slot ? period.slot : policy.slot,
333
+ account: {
334
+ availableCents: centsToString(availableCents(policy.account.budgetCents, period.account.capturedCents, period.account.reservedCents)),
335
+ periodIndex: period.account.periodIndex,
336
+ },
337
+ };
338
+ }
339
+ /** Plain-language status for any read. Never says "missing" for `not_visible`. */
340
+ export function describeTeeRead(read) {
341
+ switch (read.state) {
342
+ case "visible":
343
+ return "Visible to this wallet.";
344
+ case "not_visible":
345
+ return "Not visible to this wallet. The card's rules are private, and this wallet isn't on the list of people who can read them.";
346
+ default:
347
+ return `The private rollup didn't answer cleanly (${read.code}). Nothing was assumed.`;
348
+ }
349
+ }
350
+ /** Authorizer path (CD-4): anything but a visible read means stop. */
351
+ export function requireVisible(read, what) {
352
+ if (read.state === "visible")
353
+ return read.account;
354
+ throw new Error(read.state === "not_visible" ? `${what} is not visible to this wallet; failing closed` : `${what} could not be read (${read.code}); failing closed`);
355
+ }
356
+ const MEASUREMENT_HEX = /^[0-9a-f]{96}$/;
357
+ /**
358
+ * MagicBlock Devnet TEE validator measurements (gate G-MB).
359
+ *
360
+ * Observed by ChainPay in a fresh quote from devnet-tee.magicblock.app on
361
+ * 2026-10-03 (and again 2026-10-04), then confirmed by MagicBlock directly to
362
+ * Dre on 2026-10-04. That confirmation is not a signed or published source, so
363
+ * the provenance label says so.
364
+ *
365
+ * Rotation: when MagicBlock upgrades the validator, move `current` to
366
+ * `previous`, pin the new values as `current`, and drop `previous` once the
367
+ * old build is gone. Deployments can also pass an extra entry through
368
+ * `teeMeasurementAllowlist(extra)` (Axum: `CARDS_TEE_MEASUREMENTS`; frontend:
369
+ * `VITE_CHAINPAY_TEE_MEASUREMENTS_PREVIOUS`) without a release.
370
+ * Mirrored for Axum in shared/cards/tee-measurements.json (a test keeps them equal).
371
+ */
372
+ export const MAGICBLOCK_DEVNET_TEE_MEASUREMENTS = {
373
+ teeUrl: DEVNET_TEE_URL,
374
+ validator: "MTEWGuqxUpYZGFJQcp8tLN7x5v9BSeoFHYWQQ3n3xzo",
375
+ provenance: "confirmed by MagicBlock to ChainPay, 2026-10-04 (direct, unsigned)",
376
+ current: {
377
+ label: "current",
378
+ mrTd: "c1ee9c16e3afc506cfe042c5b846a368528f3b37618eafb27469bc114cf914e9222c91618470e7f2b28ac360968270a5",
379
+ rtMr0: "c1f1f2bff33b16d1469134987fcdf0ea3bd9707350cc70e73f086c635a5fc4e96e0d214a7f775608e64d251382f20564",
380
+ rtMr1: "b4fe8751e5d96b726a0dabb86ba821783626d80638caf6ca0045f31f5e4c4e90e20aee96fca711421db5bdc60e5c91ed",
381
+ rtMr2: "51c09b72276c8bcd0e274865a7e0a408ee956a6ccfae8b4edd43467d7585ff352bb89e28f5cb62105114cad7225e11ef",
382
+ rtMr3: "0".repeat(96),
383
+ mrOwner: "44eeaee2768dd9b3a52b41747367e5751b18f7653f087febd7de11bffabbd1d75d4ff353a68154541215863ee7fc2903",
384
+ },
385
+ previous: null,
386
+ };
387
+ /** The pinned Devnet allowlist: `current`, then `previous` if a rotation is in progress, then any `extra` entries. */
388
+ export function teeMeasurementAllowlist(extra = []) {
389
+ const pinned = MAGICBLOCK_DEVNET_TEE_MEASUREMENTS;
390
+ return [pinned.current, ...(pinned.previous ? [pinned.previous] : []), ...extra];
391
+ }
392
+ function isDevnetTee(teeUrl) {
393
+ return teeUrl.replace(/\/+$/, "") === DEVNET_TEE_URL;
394
+ }
395
+ /**
396
+ * Parse an allowlist (JSON list). Accepts the SDK keys (`mrTd`, `rtMr0`…) and
397
+ * Axum's `CARDS_TEE_MEASUREMENTS` keys (`mrtd`, `rtmr0`…). Rejects anything
398
+ * that isn't 48-byte hex.
399
+ */
400
+ export function parseMeasurementAllowlist(json) {
401
+ if (!json || !json.trim())
402
+ return [];
403
+ const parsed = JSON.parse(json);
404
+ if (!Array.isArray(parsed))
405
+ throw new Error("CARDS_TEE_MEASUREMENTS must be a JSON list");
406
+ return parsed.map((entry, i) => {
407
+ const m = (entry ?? {});
408
+ const field = (name, alias, required) => {
409
+ const raw = m[name] ?? m[alias];
410
+ if (raw === undefined && !required)
411
+ return undefined;
412
+ const value = typeof raw === "string" ? raw.toLowerCase() : "";
413
+ if (!MEASUREMENT_HEX.test(value))
414
+ throw new Error(`CARDS_TEE_MEASUREMENTS[${i}].${name} must be 48 bytes of hex`);
415
+ return value;
416
+ };
417
+ const rtMr3 = field("rtMr3", "rtmr3", false);
418
+ const mrOwner = field("mrOwner", "mrowner", false);
419
+ return {
420
+ mrTd: field("mrTd", "mrtd", true),
421
+ rtMr0: field("rtMr0", "rtmr0", true),
422
+ rtMr1: field("rtMr1", "rtmr1", true),
423
+ rtMr2: field("rtMr2", "rtmr2", true),
424
+ ...(rtMr3 ? { rtMr3 } : {}),
425
+ ...(mrOwner ? { mrOwner } : {}),
426
+ ...(m.label ? { label: String(m.label) } : {}),
427
+ };
428
+ });
429
+ }
430
+ function measurementMatches(actual, allowed) {
431
+ const required = ["mrTd", "rtMr0", "rtMr1", "rtMr2"].every((field) => actual[field].toLowerCase() === allowed[field].toLowerCase());
432
+ const pinned = ["rtMr3", "mrOwner"].every((field) => allowed[field] === undefined || actual[field]?.toLowerCase() === allowed[field].toLowerCase());
433
+ return required && pinned;
434
+ }
435
+ // ------------------------------------------------------------ TDX quotes
436
+ const TDX_QUOTE_HEADER = 48;
437
+ const TD10_REPORT_LENGTH = 584;
438
+ const TDX_TEE_TYPE = 0x81;
439
+ function hexOf(bytes) {
440
+ return Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join("");
441
+ }
442
+ /**
443
+ * Read the TD 1.0 report of a TDX quote (v4 or v5 with a TD10 body): MRTD,
444
+ * MROWNER, RTMR0-3 and the 64-byte report data. Parsing only; it proves
445
+ * nothing about who signed the quote.
446
+ */
447
+ export function parseTdxQuote(raw) {
448
+ if (raw.length < TDX_QUOTE_HEADER + TD10_REPORT_LENGTH)
449
+ throw new Error("TDX quote is truncated");
450
+ const view = new DataView(raw.buffer, raw.byteOffset, raw.byteLength);
451
+ const version = view.getUint16(0, true);
452
+ const teeType = view.getUint32(4, true);
453
+ if (teeType !== TDX_TEE_TYPE)
454
+ throw new Error("Not a TDX quote");
455
+ // v5 inserts a 6-byte body descriptor (type u16 + size u32) before the report.
456
+ let offset = TDX_QUOTE_HEADER;
457
+ if (version === 5) {
458
+ if (view.getUint16(TDX_QUOTE_HEADER, true) !== 2)
459
+ throw new Error("Unsupported TDX quote body");
460
+ offset += 6;
461
+ }
462
+ else if (version !== 4) {
463
+ throw new Error(`Unsupported TDX quote version ${version}`);
464
+ }
465
+ const body = raw.subarray(offset, offset + TD10_REPORT_LENGTH);
466
+ // TEE_TCB_SVN 16, MRSEAM 48, MRSIGNERSEAM 48, SEAMATTRIBUTES 8, TDATTRIBUTES 8, XFAM 8,
467
+ // MRTD 48, MRCONFIGID 48, MROWNER 48, MROWNERCONFIG 48, RTMR0..3 48 each, REPORTDATA 64.
468
+ const mrtd = 16 + 48 + 48 + 8 + 8 + 8;
469
+ const mrowner = mrtd + 96;
470
+ const rtmr0 = mrtd + 48 * 4;
471
+ const reportData = rtmr0 + 48 * 4;
472
+ const at = (start) => hexOf(body.subarray(start, start + 48));
473
+ return {
474
+ measurements: { mrTd: at(mrtd), mrOwner: at(mrowner), rtMr0: at(rtmr0), rtMr1: at(rtmr0 + 48), rtMr2: at(rtmr0 + 96), rtMr3: at(rtmr0 + 144) },
475
+ reportData: body.slice(reportData, reportData + 64),
476
+ };
477
+ }
478
+ function base64Encode(bytes) {
479
+ let binary = "";
480
+ for (const byte of bytes)
481
+ binary += String.fromCharCode(byte);
482
+ return btoa(binary);
483
+ }
484
+ function base64Decode(value) {
485
+ return Uint8Array.from(atob(value), (char) => char.charCodeAt(0));
486
+ }
487
+ /**
488
+ * Attestation from one fresh quote: GET `{tee}/quote?challenge=<base64 64 bytes>`
489
+ * (the endpoint MagicBlock's verifyTeeRpcIntegrity uses), require the report
490
+ * data to equal our challenge, optionally verify the DCAP chain, and return
491
+ * the measurements of that same quote.
492
+ */
493
+ export function createTdxQuoteProvider(options = {}) {
494
+ const doFetch = options.fetch ?? globalThis.fetch.bind(globalThis);
495
+ const random = options.randomBytes ?? ((length) => globalThis.crypto.getRandomValues(new Uint8Array(length)));
496
+ const attestQuote = async (teeUrl) => {
497
+ const challenge = random(64);
498
+ const response = await doFetch(`${teeUrl.replace(/\/+$/, "")}/quote?challenge=${encodeURIComponent(base64Encode(challenge))}`);
499
+ const body = await response.json().catch(() => null);
500
+ if (!response.ok || typeof body?.quote !== "string")
501
+ throw new Error("The TEE didn't return a quote");
502
+ const raw = base64Decode(body.quote);
503
+ const { measurements, reportData } = parseTdxQuote(raw);
504
+ if (reportData.length !== challenge.length || reportData.some((byte, i) => byte !== challenge[i]))
505
+ throw new Error("Quote doesn't answer our challenge");
506
+ if (options.verifyQuote) {
507
+ await options.verifyQuote(raw);
508
+ return { hardware: "verified", measurements };
509
+ }
510
+ return { hardware: "challenge_bound", measurements };
511
+ };
512
+ return {
513
+ attestQuote,
514
+ async verifyRpcIntegrity(teeUrl) {
515
+ if ((await attestQuote(teeUrl)).hardware !== "verified")
516
+ throw new Error("Quote signature wasn't verified");
517
+ },
518
+ readMeasurements: async (teeUrl) => (await attestQuote(teeUrl)).measurements,
519
+ };
520
+ }
521
+ /**
522
+ * Default provider: MagicBlock's own verifiers, loaded only when installed
523
+ * (`@magicblock-labs/ephemeral-rollups-sdk` is optional, not an SDK dependency).
524
+ */
525
+ export async function loadMagicBlockIntegrityProvider() {
526
+ const specifier = "@magicblock-labs/ephemeral-rollups-sdk";
527
+ let mod;
528
+ try {
529
+ mod = await import(specifier);
530
+ }
531
+ catch {
532
+ throw new Error("Install @magicblock-labs/ephemeral-rollups-sdk or pass an integrity provider to check the TEE");
533
+ }
534
+ if (typeof mod.verifyTeeRpcIntegrity !== "function")
535
+ throw new Error("MagicBlock SDK has no verifyTeeRpcIntegrity");
536
+ const verifyIntegrity = mod.verifyTeeIntegrity;
537
+ return {
538
+ verifyRpcIntegrity: async (url) => { await mod.verifyTeeRpcIntegrity(url); },
539
+ ...(typeof verifyIntegrity === "function" ? { verifyIntegrity: async (url) => { await verifyIntegrity(url); } } : {}),
540
+ };
541
+ }
542
+ /** Plain-language result. Claims only what the check established. */
543
+ function attestationLabel(hardware, measurements, mode, devnet) {
544
+ const build = devnet ? "MagicBlock's confirmed Devnet build" : "an allowlisted build";
545
+ const paused = mode === "enforce" ? ", so approvals are paused" : " (report only)";
546
+ if (measurements === "pending")
547
+ return mode === "enforce" ? "Enforce mode has no measurement allowlist, so approvals are paused" : "Hardware verified, measurements pending";
548
+ if (measurements === "unavailable")
549
+ return `Hardware verified, but the workload couldn't be checked${paused}`;
550
+ if (measurements === "mismatch")
551
+ return `The private rollup is running a build that isn't ${build}${paused}`;
552
+ if (hardware === "verified")
553
+ return devnet ? "Genuine TDX hardware and expected MagicBlock build verified (Devnet)" : "Genuine TDX hardware and allowlisted workload verified";
554
+ return devnet
555
+ ? "The private rollup answered a fresh challenge with MagicBlock's expected Devnet build. Intel's hardware signature wasn't checked here."
556
+ : "The TEE answered a fresh challenge with an allowlisted build. Intel's hardware signature wasn't checked here.";
557
+ }
558
+ export async function verifyTee(options) {
559
+ const teeUrl = (options.teeUrl ?? DEVNET_TEE_URL).replace(/\/+$/, "");
560
+ const devnet = isDevnetTee(teeUrl);
561
+ const checkedAt = new Date((options.now ?? Date.now)()).toISOString();
562
+ const allowlist = options.allowlist ?? (devnet ? teeMeasurementAllowlist() : []);
563
+ const base = { mode: options.mode, checkedAt };
564
+ const hardwareFailed = { ...base, hardware: "failed", measurements: "unavailable", ok: false, label: "Couldn't confirm the private rollup runs on genuine secure hardware." };
565
+ let provider;
566
+ let hardware;
567
+ let actual;
568
+ try {
569
+ provider = options.provider ?? await loadMagicBlockIntegrityProvider();
570
+ if (provider.attestQuote) {
571
+ const attested = await provider.attestQuote(teeUrl);
572
+ hardware = attested.hardware;
573
+ actual = attested.measurements;
574
+ }
575
+ else {
576
+ await provider.verifyRpcIntegrity(teeUrl);
577
+ if (provider.verifyIntegrity)
578
+ await provider.verifyIntegrity(teeUrl);
579
+ hardware = "verified";
580
+ }
581
+ }
582
+ catch {
583
+ return hardwareFailed;
584
+ }
585
+ if (allowlist.length === 0) {
586
+ // Enforce without an allowlist is a misconfiguration: fail closed.
587
+ return { ...base, hardware, measurements: "pending", ok: options.mode === "report", label: attestationLabel(hardware, "pending", options.mode, devnet) };
588
+ }
589
+ if (!actual) {
590
+ try {
591
+ if (!provider.readMeasurements)
592
+ throw new Error("no measurement reader");
593
+ actual = await provider.readMeasurements(teeUrl);
594
+ }
595
+ catch {
596
+ return { ...base, hardware, measurements: "unavailable", ok: options.mode === "report", label: attestationLabel(hardware, "unavailable", options.mode, devnet) };
597
+ }
598
+ }
599
+ const matched = allowlist.find((allowed) => measurementMatches(actual, allowed));
600
+ if (matched) {
601
+ // Enforce needs Intel's signature chain: an unsigned quote is just bytes the endpoint chose.
602
+ return { ...base, hardware, measurements: "matched", ok: options.mode === "report" || hardware === "verified", label: attestationLabel(hardware, "matched", options.mode, devnet), ...(matched.label ? { matchedLabel: matched.label } : {}) };
603
+ }
604
+ return { ...base, hardware, measurements: "mismatch", ok: options.mode === "report", label: attestationLabel(hardware, "mismatch", options.mode, devnet) };
605
+ }
package/dist/cli.d.ts ADDED
@@ -0,0 +1,31 @@
1
+ #!/usr/bin/env node
2
+ import { ChainPayClient } from "./client.js";
3
+ import type { Mandate, TokenProgram } from "./types.js";
4
+ /** The default public app, the same one the MCP server links to. */
5
+ export declare const DEFAULT_APP_URL = "https://chainpay-frontend.onrender.com";
6
+ type Env = Record<string, string | undefined>;
7
+ type ExportClient = Pick<ChainPayClient, "getMandatesByOwner" | "getPaymentsByMandate" | "getMintDecimals"> & {
8
+ connection: Pick<ChainPayClient["connection"], "getBlockTime">;
9
+ };
10
+ /**
11
+ * Every receipt under the owner's mandates as one CSV, newest first. Limits
12
+ * come from each receipt's on-chain snapshot or read "not-recorded": the CLI
13
+ * has no relay session, so it never shows a relay observation. Dates come from
14
+ * block time and stay empty when unknown.
15
+ */
16
+ export declare function exportReceiptsCsv(client: ExportClient, options: {
17
+ owner: string;
18
+ mandateFilter?: (mandate: Mandate) => boolean;
19
+ appUrl?: string;
20
+ }): Promise<{
21
+ csv: string;
22
+ rowCount: number;
23
+ }>;
24
+ /** What a request command reads from the network. Injected in tests. */
25
+ export type RequestCommandDependencies = {
26
+ getCurrentSlot(): Promise<bigint>;
27
+ getMintDecimals(mint: string): Promise<number>;
28
+ getTokenProgram(mint: string): Promise<TokenProgram>;
29
+ };
30
+ export declare function runChainPayCli(argv: string[], env?: Env, requestDeps?: RequestCommandDependencies): Promise<number>;
31
+ export {};