@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,638 @@
1
+ import { PublicKey, VersionedTransaction } from "@solana/web3.js";
2
+ import { MEMO_PROGRAM_ID, SPL_TOKEN_PROGRAM_ID, TOKEN_2022_PROGRAM_ID } from "./constants.js";
3
+ export const CROSSMINT_CONNECTOR = "crossmint-checkout/1.0";
4
+ export const CROSSMINT_CONNECTOR_LABEL = "Crossmint checkout order settled through a ChainPay mandate. Proof is the settled signature and receipt PDA; Crossmint order state is read back from its Orders API.";
5
+ export const CROSSMINT_STAGING_BASE_URL = "https://staging.crossmint.com";
6
+ export const CROSSMINT_PRODUCTION_BASE_URL = "https://www.crossmint.com";
7
+ export const CROSSMINT_ORDERS_PATH = "/api/2022-06-09/orders";
8
+ /** Crossmint advances an order through these phases; only `payment` still owes money. */
9
+ export const CROSSMINT_PAYABLE_PHASE = "payment";
10
+ export const CROSSMINT_SETTLED_PHASES = ["delivery", "completed"];
11
+ const MAX_ORDER_ID_CHARS = 128;
12
+ const MAX_SERIALIZED_TRANSACTION_CHARS = 16 * 1024;
13
+ const MAX_LINE_ITEM_LOCATORS = 32;
14
+ const MAX_LOCATOR_CHARS = 256;
15
+ const MAX_ITEM_NAME_CHARS = 200;
16
+ const MAX_ITEM_DESCRIPTION_CHARS = 2_000;
17
+ const MAX_ITEM_IMAGE_URL_CHARS = 1_024;
18
+ /**
19
+ * Crossmint's Solana memo is `------BEGIN MEMO------<JWT>------END MEMO------`,
20
+ * about 300 bytes. The bound keeps the memo plus `execute_payment` well inside
21
+ * one legacy transaction.
22
+ */
23
+ export const MAX_CROSSMINT_MEMO_BYTES = 512;
24
+ const CROSSMINT_MEMO_PATTERN = /^------BEGIN MEMO------([A-Za-z0-9_-]+)\.([A-Za-z0-9_-]+)\.([A-Za-z0-9_-]+)------END MEMO------$/;
25
+ const MAX_U64 = 18446744073709551615n;
26
+ const SPL_TRANSFER_TAG = 3;
27
+ const SPL_TRANSFER_CHECKED_TAG = 12;
28
+ export class CrossmintOrderError extends Error {
29
+ code;
30
+ constructor(code, message) {
31
+ super(message);
32
+ this.name = "CrossmintOrderError";
33
+ this.code = code;
34
+ }
35
+ }
36
+ function fail(code, message) {
37
+ throw new CrossmintOrderError(code, message);
38
+ }
39
+ function isRecord(value) {
40
+ return Boolean(value) && typeof value === "object" && !Array.isArray(value);
41
+ }
42
+ function optionalRecord(value) {
43
+ return isRecord(value) ? value : undefined;
44
+ }
45
+ function optionalString(value, max) {
46
+ if (typeof value !== "string")
47
+ return undefined;
48
+ const trimmed = value.trim();
49
+ if (trimmed === "" || trimmed.length > max)
50
+ return undefined;
51
+ return trimmed;
52
+ }
53
+ function boundedString(value, name, max) {
54
+ if (typeof value !== "string" || value.trim() === "")
55
+ fail("malformed", `${name} is required`);
56
+ const trimmed = value.trim();
57
+ if (trimmed.length > max)
58
+ fail("malformed", `${name} is too long`);
59
+ return trimmed;
60
+ }
61
+ function canonicalAddress(value, name) {
62
+ const encoded = value.toBase58();
63
+ if (encoded === PublicKey.default.toBase58())
64
+ fail("malformed", `${name} must not be the default address`);
65
+ return encoded;
66
+ }
67
+ function tokenProgramFrom(programId) {
68
+ if (programId === SPL_TOKEN_PROGRAM_ID)
69
+ return "spl-token";
70
+ if (programId === TOKEN_2022_PROGRAM_ID)
71
+ return "token-2022";
72
+ fail("unsupported_token_program", `Crossmint prepared a transfer on ${programId}, which is neither SPL Token nor Token-2022`);
73
+ }
74
+ async function sha256Hex(value) {
75
+ const digest = await globalThis.crypto.subtle.digest("SHA-256", new TextEncoder().encode(value).slice().buffer);
76
+ return Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, "0")).join("");
77
+ }
78
+ /**
79
+ * Scale a decimal display amount to base units without floating point.
80
+ * Returns undefined when the string is not a plain decimal number, because a
81
+ * quote ChainPay cannot read must be reported as unchecked, never as agreeing.
82
+ */
83
+ export function scaleDecimalString(value, decimals) {
84
+ if (!Number.isInteger(decimals) || decimals < 0 || decimals > 20)
85
+ return undefined;
86
+ const match = /^(\d+)(?:\.(\d+))?$/.exec(value.trim());
87
+ if (!match)
88
+ return undefined;
89
+ const whole = match[1];
90
+ const fraction = match[2] ?? "";
91
+ if (fraction.length > decimals) {
92
+ // More precision than the mint can hold: only a trailing-zero tail is safe
93
+ // to drop, anything else would silently round the merchant's price.
94
+ if (!/^0*$/.test(fraction.slice(decimals)))
95
+ return undefined;
96
+ }
97
+ const padded = (fraction + "0".repeat(decimals)).slice(0, decimals);
98
+ return BigInt(whole + padded);
99
+ }
100
+ function lineItemLocator(record) {
101
+ return (optionalString(record.tokenLocator, MAX_LOCATOR_CHARS) ??
102
+ optionalString(record.collectionLocator, MAX_LOCATOR_CHARS));
103
+ }
104
+ function lineItemLocators(value) {
105
+ if (!Array.isArray(value))
106
+ return [];
107
+ const locators = [];
108
+ for (const item of value.slice(0, MAX_LINE_ITEM_LOCATORS)) {
109
+ const record = optionalRecord(item);
110
+ if (!record)
111
+ continue;
112
+ const locator = lineItemLocator(record);
113
+ if (locator)
114
+ locators.push(locator);
115
+ }
116
+ return locators;
117
+ }
118
+ function positiveInteger(value) {
119
+ const parsed = typeof value === "string" && /^\d{1,9}$/.test(value.trim()) ? Number(value.trim()) : value;
120
+ return typeof parsed === "number" && Number.isSafeInteger(parsed) && parsed > 0 ? parsed : undefined;
121
+ }
122
+ /** Normalize a delivery wallet. Only a Solana address counts; an email is not a wallet. */
123
+ function solanaWallet(value) {
124
+ const text = optionalString(value, MAX_LOCATOR_CHARS);
125
+ if (!text)
126
+ return undefined;
127
+ const separator = text.indexOf(":");
128
+ const candidate = separator < 0 ? text : text.slice(0, separator) === "solana" ? text.slice(separator + 1) : undefined;
129
+ if (!candidate)
130
+ return undefined;
131
+ try {
132
+ const key = new PublicKey(candidate);
133
+ return key.toBase58() === candidate && candidate !== PublicKey.default.toBase58() ? candidate : undefined;
134
+ }
135
+ catch {
136
+ return undefined;
137
+ }
138
+ }
139
+ /** Every stated value must agree; disagreement is a conflict, never a choice. */
140
+ function agreed(values) {
141
+ const present = values.filter((value) => value !== undefined);
142
+ if (present.length === 0)
143
+ return {};
144
+ return present.every((value) => value === present[0]) ? { value: present[0] } : { conflict: true };
145
+ }
146
+ const UNREADABLE = Symbol("unreadable");
147
+ /**
148
+ * A string Crossmint may state. Absent stays absent; stated but not a bounded,
149
+ * non-empty string is reported as unreadable, so a field cannot silently vanish
150
+ * from what the owner reviews because it grew too long.
151
+ */
152
+ function statedString(value, max) {
153
+ if (value === undefined || value === null)
154
+ return undefined;
155
+ return optionalString(value, max) ?? UNREADABLE;
156
+ }
157
+ function lineItem(value) {
158
+ const record = optionalRecord(value);
159
+ if (!record)
160
+ return undefined;
161
+ const callData = optionalRecord(record.callData);
162
+ const executionParams = optionalRecord(record.executionParams);
163
+ const recipient = optionalRecord(optionalRecord(record.delivery)?.recipient);
164
+ const rawQuantities = [record.quantity, callData?.quantity, executionParams?.quantity].filter((q) => q !== undefined && q !== null);
165
+ const quantities = rawQuantities.map(positiveInteger);
166
+ // A quantity Crossmint states but ChainPay cannot read is a conflict, not an absence.
167
+ const quantity = quantities.includes(undefined) ? { conflict: true } : agreed(quantities);
168
+ const rawRecipients = [recipient?.walletAddress, recipient?.locator].filter((r) => r !== undefined && r !== null);
169
+ const wallets = rawRecipients.map(solanaWallet);
170
+ const delivery = wallets.includes(undefined) ? { conflict: true } : agreed(wallets);
171
+ const price = optionalRecord(optionalRecord(record.quote)?.totalPrice);
172
+ const amount = optionalString(price?.amount, 64);
173
+ const currency = optionalString(price?.currency, 32);
174
+ const locator = lineItemLocator(record);
175
+ const chain = optionalString(record.chain, 64);
176
+ const executionMode = optionalString(record.executionMode, 32);
177
+ const metadata = optionalRecord(record.metadata);
178
+ const name = statedString(metadata?.name, MAX_ITEM_NAME_CHARS);
179
+ const description = statedString(metadata?.description, MAX_ITEM_DESCRIPTION_CHARS);
180
+ const imageUrl = statedString(metadata?.imageUrl, MAX_ITEM_IMAGE_URL_CHARS);
181
+ const metadataUnreadable = (record.metadata !== undefined && record.metadata !== null && !metadata)
182
+ || [name, description, imageUrl].some((field) => field === UNREADABLE);
183
+ return {
184
+ ...(locator ? { locator } : {}),
185
+ ...(typeof name === "string" ? { name } : {}),
186
+ ...(typeof description === "string" ? { description } : {}),
187
+ ...(typeof imageUrl === "string" ? { imageUrl } : {}),
188
+ ...(metadataUnreadable ? { metadataUnreadable: true } : {}),
189
+ ...(chain ? { chain } : {}),
190
+ ...(executionMode ? { executionMode } : {}),
191
+ ...(quantity.value !== undefined ? { quantity: quantity.value } : {}),
192
+ ...(quantity.conflict ? { quantityConflict: true } : {}),
193
+ ...(delivery.value !== undefined ? { deliveryRecipient: delivery.value } : {}),
194
+ ...(delivery.conflict ? { deliveryRecipientConflict: true } : {}),
195
+ ...(amount ? { totalPrice: { amount, ...(currency ? { currency } : {}) } } : {}),
196
+ };
197
+ }
198
+ function lineItems(value) {
199
+ if (!Array.isArray(value))
200
+ return { items: [], count: 0 };
201
+ const items = [];
202
+ for (const raw of value.slice(0, MAX_LINE_ITEM_LOCATORS)) {
203
+ const item = lineItem(raw);
204
+ if (item)
205
+ items.push(item);
206
+ }
207
+ return { items, count: value.length };
208
+ }
209
+ function boundItem(item) {
210
+ return {
211
+ locator: item.locator ?? null,
212
+ name: item.name ?? null,
213
+ description: item.description ?? null,
214
+ imageUrl: item.imageUrl ?? null,
215
+ chain: item.chain ?? null,
216
+ executionMode: item.executionMode ?? null,
217
+ quantity: item.quantityConflict ? null : item.quantity ?? null,
218
+ deliveryRecipient: item.deliveryRecipientConflict ? null : item.deliveryRecipient ?? null,
219
+ totalPrice: item.totalPrice ? { amount: item.totalPrice.amount, currency: item.totalPrice.currency ?? null } : null,
220
+ };
221
+ }
222
+ /**
223
+ * Checkout pays only for items whose delivery is fully stated: a readable item
224
+ * name, one quantity and one Solana delivery wallet per line. Crossmint's live
225
+ * order responses name the item only through `metadata`, not a collection or
226
+ * template locator, so the name is what the owner reviews. Anything absent or
227
+ * ambiguous stops before a payment is prepared.
228
+ */
229
+ function requireBindableItems(order) {
230
+ if (order.lineItemCount === 0 || order.lineItemCount > MAX_LINE_ITEM_LOCATORS || order.lineItems.length !== order.lineItemCount) {
231
+ fail("terms_unavailable", "Crossmint order line items are missing or unreadable, so ChainPay cannot show what is being bought");
232
+ }
233
+ for (const item of order.lineItems) {
234
+ if (!item.name || item.metadataUnreadable)
235
+ fail("terms_unavailable", "A Crossmint line item does not name the item being bought");
236
+ if (item.chain !== undefined && item.chain !== "solana")
237
+ fail("terms_unavailable", "A Crossmint line item delivers on a chain other than Solana");
238
+ if (item.quantityConflict || item.quantity === undefined) {
239
+ fail("terms_unavailable", "A Crossmint line item has a missing or ambiguous quantity");
240
+ }
241
+ if (item.deliveryRecipientConflict || item.deliveryRecipient === undefined) {
242
+ fail("terms_unavailable", "A Crossmint line item has a missing or ambiguous delivery wallet");
243
+ }
244
+ }
245
+ }
246
+ function quotedTotal(order) {
247
+ const total = optionalRecord(optionalRecord(order.quote)?.totalPrice);
248
+ if (!total)
249
+ return undefined;
250
+ const amount = optionalString(total.amount, 64);
251
+ if (!amount)
252
+ return undefined;
253
+ return { amount, ...(optionalString(total.currency, 32) ? { currency: optionalString(total.currency, 32) } : {}) };
254
+ }
255
+ /**
256
+ * Read a Crossmint create-order or get-order response. Accepts the documented
257
+ * `{ order }` envelope and a bare order object, because the same order shape is
258
+ * returned by both endpoints.
259
+ */
260
+ export function parseCrossmintOrder(value) {
261
+ const envelope = optionalRecord(value);
262
+ if (!envelope)
263
+ fail("malformed", "Crossmint order must be a JSON object");
264
+ const order = optionalRecord(envelope.order) ?? envelope;
265
+ const orderId = boundedString(order.orderId ?? order.id, "Crossmint orderId", MAX_ORDER_ID_CHARS);
266
+ const phase = boundedString(order.phase, "Crossmint order phase", 64);
267
+ const payment = optionalRecord(order.payment);
268
+ const preparation = optionalRecord(payment?.preparation);
269
+ const serializedTransaction = optionalString(preparation?.serializedTransaction, MAX_SERIALIZED_TRANSACTION_CHARS);
270
+ const currency = optionalString(payment?.currency, 32);
271
+ const payerAddress = optionalString(preparation?.payerAddress, 64);
272
+ const parameters = optionalRecord(preparation?.transactionParameters);
273
+ // Kept exactly as sent: the memo must match the prepared instruction byte for byte.
274
+ const transactionMemo = typeof parameters?.memo === "string"
275
+ && parameters.memo.length > 0
276
+ && new TextEncoder().encode(parameters.memo).length <= MAX_CROSSMINT_MEMO_BYTES
277
+ ? parameters.memo
278
+ : undefined;
279
+ const transactionAmount = typeof parameters?.amount === "string" && /^\d{1,20}$/.test(parameters.amount) ? parameters.amount : undefined;
280
+ const total = quotedTotal(order);
281
+ const items = lineItems(order.lineItems);
282
+ return {
283
+ orderId,
284
+ phase,
285
+ lineItemLocators: lineItemLocators(order.lineItems),
286
+ lineItems: items.items,
287
+ lineItemCount: items.count,
288
+ ...(optionalString(payment?.status, 64) ? { paymentStatus: optionalString(payment?.status, 64) } : {}),
289
+ ...(optionalString(payment?.method, 64) ? { paymentMethod: optionalString(payment?.method, 64) } : {}),
290
+ ...(currency ? { currency } : {}),
291
+ ...(total ? { quotedTotal: total } : {}),
292
+ ...(serializedTransaction ? { serializedTransaction } : {}),
293
+ ...(transactionMemo ? { transactionMemo } : {}),
294
+ ...(transactionAmount ? { transactionAmount } : {}),
295
+ ...(payerAddress ? { payerAddress } : {}),
296
+ preparationChain: optionalString(preparation?.chain, 64),
297
+ quoteExpiresAt: optionalString(optionalRecord(order.quote)?.expiresAt, 64),
298
+ quoteStatus: optionalString(optionalRecord(order.quote)?.status, 32),
299
+ };
300
+ }
301
+ function normalizedInstructions(message) {
302
+ const candidate = message;
303
+ if (!Array.isArray(candidate.compiledInstructions)) {
304
+ fail("terms_unavailable", "Crossmint transaction message could not be decoded into instructions");
305
+ }
306
+ return candidate.compiledInstructions.map((item) => ({
307
+ programIdIndex: item.programIdIndex,
308
+ accountKeyIndexes: [...item.accountKeyIndexes],
309
+ data: item.data instanceof Uint8Array ? item.data : new Uint8Array(item.data),
310
+ }));
311
+ }
312
+ function accountKeyResolver(message) {
313
+ const candidate = message;
314
+ if (Array.isArray(candidate.addressTableLookups) && candidate.addressTableLookups.length > 0) {
315
+ fail("terms_unavailable", "Crossmint transaction resolves accounts through address lookup tables, so its transfer terms cannot be read offline");
316
+ }
317
+ const keys = candidate.staticAccountKeys;
318
+ if (!Array.isArray(keys) || keys.length === 0) {
319
+ fail("terms_unavailable", "Crossmint transaction message has no static account keys");
320
+ }
321
+ return (index) => {
322
+ const key = keys[index];
323
+ if (!key)
324
+ fail("terms_unavailable", "Crossmint transaction references an account index it does not define");
325
+ return key;
326
+ };
327
+ }
328
+ const BASE58_ALPHABET = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz";
329
+ function base58Decode(value) {
330
+ const bytes = [];
331
+ for (const char of value) {
332
+ let carry = BASE58_ALPHABET.indexOf(char);
333
+ if (carry < 0)
334
+ return undefined;
335
+ for (let index = 0; index < bytes.length; index += 1) {
336
+ carry += bytes[index] * 58;
337
+ bytes[index] = carry & 0xff;
338
+ carry >>= 8;
339
+ }
340
+ while (carry > 0) {
341
+ bytes.push(carry & 0xff);
342
+ carry >>= 8;
343
+ }
344
+ }
345
+ for (const char of value) {
346
+ if (char !== "1")
347
+ break;
348
+ bytes.push(0);
349
+ }
350
+ return new Uint8Array(bytes.reverse());
351
+ }
352
+ function base64Decode(value) {
353
+ if (!/^[A-Za-z0-9+/]+={0,2}$/.test(value))
354
+ return undefined;
355
+ return new Uint8Array(Buffer.from(value, "base64"));
356
+ }
357
+ /**
358
+ * Crossmint documents its Solana `serializedTransaction` as base58 and parses
359
+ * it as a legacy transaction. Base64 is still accepted because a payload that
360
+ * decodes as a valid transaction under either encoding is unambiguous, and
361
+ * fixtures and other tooling commonly use base64.
362
+ */
363
+ function deserializeCrossmintTransaction(wire) {
364
+ let lastError;
365
+ for (const decode of [base58Decode, base64Decode]) {
366
+ const bytes = decode(wire);
367
+ if (!bytes || bytes.length === 0)
368
+ continue;
369
+ try {
370
+ return VersionedTransaction.deserialize(bytes);
371
+ }
372
+ catch (error) {
373
+ lastError = error;
374
+ }
375
+ }
376
+ fail("malformed", lastError === undefined
377
+ ? "Crossmint serializedTransaction is neither base58 nor base64"
378
+ : `Crossmint serializedTransaction is not a Solana transaction: ${lastError instanceof Error ? lastError.message : String(lastError)}`);
379
+ }
380
+ /**
381
+ * Derive what a Crossmint order actually charges from the transaction Crossmint
382
+ * prepared for its own payer. Exactly one SPL Token or Token-2022 transfer must
383
+ * be present: an order that moves money in more than one transfer has no single
384
+ * set of terms a mandate can be checked against.
385
+ */
386
+ export function decodeCrossmintTransferTerms(serializedTransaction, expected = {}) {
387
+ const wire = serializedTransaction.trim();
388
+ if (wire === "" || wire.length > MAX_SERIALIZED_TRANSACTION_CHARS) {
389
+ fail("malformed", "Crossmint serializedTransaction is missing or too large");
390
+ }
391
+ const transaction = deserializeCrossmintTransaction(wire);
392
+ const message = transaction.message;
393
+ const keyAt = accountKeyResolver(message);
394
+ const transfers = [];
395
+ const memos = [];
396
+ const signerCount = message.header.numRequiredSignatures;
397
+ for (const compiled of normalizedInstructions(message)) {
398
+ let programId;
399
+ try {
400
+ programId = keyAt(compiled.programIdIndex).toBase58();
401
+ }
402
+ catch (error) {
403
+ if (error instanceof CrossmintOrderError)
404
+ throw error;
405
+ continue;
406
+ }
407
+ if (expected.strict && programId === MEMO_PROGRAM_ID) {
408
+ // The memo program checks that every account it names signed. Only a
409
+ // signer may appear here; anything else is not Crossmint's memo shape.
410
+ if (compiled.accountKeyIndexes.some((index) => index >= signerCount)) {
411
+ fail("terms_unavailable", "Crossmint memo names an account that is not a signer");
412
+ }
413
+ memos.push({
414
+ text: decodeMemoText(compiled.data),
415
+ signers: compiled.accountKeyIndexes.map((index) => canonicalAddress(keyAt(index), "Crossmint memo signer")),
416
+ });
417
+ continue;
418
+ }
419
+ if (programId !== SPL_TOKEN_PROGRAM_ID && programId !== TOKEN_2022_PROGRAM_ID) {
420
+ if (expected.strict)
421
+ fail("terms_unavailable", "Crossmint preparation requires instructions the mandate adapter cannot preserve");
422
+ continue;
423
+ }
424
+ const tag = compiled.data[0];
425
+ if (tag !== SPL_TRANSFER_TAG && tag !== SPL_TRANSFER_CHECKED_TAG) {
426
+ if (expected.strict)
427
+ fail("terms_unavailable", "Crossmint preparation contains unsupported token instructions");
428
+ continue;
429
+ }
430
+ const checked = tag === SPL_TRANSFER_CHECKED_TAG;
431
+ const expectedLength = checked ? 10 : 9;
432
+ if (compiled.data.length !== expectedLength) {
433
+ fail("malformed", "Crossmint transfer instruction has an unexpected data length");
434
+ }
435
+ const minimumAccounts = checked ? 4 : 3;
436
+ if (compiled.accountKeyIndexes.length < minimumAccounts) {
437
+ fail("malformed", "Crossmint transfer instruction is missing required accounts");
438
+ }
439
+ if (expected.strict && (!checked || compiled.accountKeyIndexes.length !== minimumAccounts)) {
440
+ fail("terms_unavailable", "Checkout requires exactly one TransferChecked without extra reference accounts");
441
+ }
442
+ const view = new DataView(compiled.data.buffer, compiled.data.byteOffset, compiled.data.byteLength);
443
+ const amount = view.getBigUint64(1, true);
444
+ if (amount <= 0n || amount > MAX_U64) {
445
+ fail("malformed", "Crossmint transfer amount must be a positive unsigned 64-bit integer");
446
+ }
447
+ const source = canonicalAddress(keyAt(compiled.accountKeyIndexes[0]), "Crossmint transfer source");
448
+ const recipient = canonicalAddress(keyAt(compiled.accountKeyIndexes[checked ? 2 : 1]), "Crossmint transfer destination");
449
+ const tokenProgram = tokenProgramFrom(programId);
450
+ if (!checked) {
451
+ if (!expected.mint) {
452
+ fail("terms_unavailable", "Crossmint prepared an unchecked SPL transfer, which does not name a mint; supply the order's mint explicitly before settling");
453
+ }
454
+ transfers.push({
455
+ mint: expected.mint,
456
+ recipient,
457
+ source,
458
+ amount: amount.toString(),
459
+ tokenProgram,
460
+ });
461
+ continue;
462
+ }
463
+ transfers.push({
464
+ mint: canonicalAddress(keyAt(compiled.accountKeyIndexes[1]), "Crossmint transfer mint"),
465
+ recipient,
466
+ source,
467
+ amount: amount.toString(),
468
+ decimals: compiled.data[9],
469
+ tokenProgram,
470
+ });
471
+ }
472
+ if (transfers.length === 0) {
473
+ fail("terms_unavailable", "Crossmint transaction contains no SPL Token or Token-2022 transfer to read terms from");
474
+ }
475
+ if (transfers.length > 1) {
476
+ fail("terms_unavailable", "Crossmint transaction contains more than one token transfer, so it has no single amount a mandate can authorize");
477
+ }
478
+ if (memos.length > 1) {
479
+ fail("terms_unavailable", "Crossmint preparation carries more than one memo, so it has no single order reference");
480
+ }
481
+ const terms = memos.length === 1 ? { ...transfers[0], memo: memos[0] } : transfers[0];
482
+ if (expected.mint && expected.mint !== terms.mint) {
483
+ fail("terms_mismatch", `Crossmint transfer pays mint ${terms.mint}, which is not the expected ${expected.mint}`);
484
+ }
485
+ if (expected.tokenProgram && expected.tokenProgram !== terms.tokenProgram) {
486
+ fail("terms_mismatch", `Crossmint transfer uses ${terms.tokenProgram}, which is not the expected ${expected.tokenProgram}`);
487
+ }
488
+ return terms;
489
+ }
490
+ function decodeMemoText(data) {
491
+ if (data.length === 0 || data.length > MAX_CROSSMINT_MEMO_BYTES) {
492
+ fail("terms_unavailable", "Crossmint memo is empty or too long");
493
+ }
494
+ try {
495
+ return new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode(data);
496
+ }
497
+ catch {
498
+ fail("terms_unavailable", "Crossmint memo is not valid UTF-8");
499
+ }
500
+ }
501
+ function base64UrlJson(segment) {
502
+ try {
503
+ return JSON.parse(Buffer.from(segment, "base64url").toString("utf8"));
504
+ }
505
+ catch {
506
+ return undefined;
507
+ }
508
+ }
509
+ /**
510
+ * Check Crossmint's order memo and return it unchanged.
511
+ *
512
+ * The memo is `------BEGIN MEMO------<JWT>------END MEMO------`. Its JWT payload
513
+ * names the order (`orderIdentifier`). ChainPay cannot check the JWT signature,
514
+ * which is Crossmint's own; it only checks that the memo belongs to this order,
515
+ * so a memo copied from a different order is refused.
516
+ */
517
+ export function crossmintOrderMemo(memo, orderId) {
518
+ if (new TextEncoder().encode(memo).length > MAX_CROSSMINT_MEMO_BYTES) {
519
+ fail("terms_unavailable", "Crossmint order memo is too long");
520
+ }
521
+ const match = CROSSMINT_MEMO_PATTERN.exec(memo);
522
+ if (!match)
523
+ fail("terms_unavailable", "Crossmint order memo is not in the expected format");
524
+ const header = base64UrlJson(match[1]);
525
+ const payload = base64UrlJson(match[2]);
526
+ if (!isRecord(header) || !isRecord(payload))
527
+ fail("terms_unavailable", "Crossmint order memo is not a readable token");
528
+ if (payload.orderIdentifier !== orderId)
529
+ fail("terms_mismatch", "Crossmint order memo names a different order");
530
+ return memo;
531
+ }
532
+ function quoteCheckFor(order, terms) {
533
+ if (!order.quotedTotal || terms.decimals === undefined)
534
+ return "unavailable";
535
+ if (!order.currency || order.quotedTotal.currency !== order.currency)
536
+ return "unavailable";
537
+ const scaled = scaleDecimalString(order.quotedTotal.amount, terms.decimals);
538
+ if (scaled === undefined)
539
+ return "unavailable";
540
+ return scaled === BigInt(terms.amount) ? "match" : "mismatch";
541
+ }
542
+ /** Narrow, fail-closed staging checkout contract; generic decoding is not checkout authorization. */
543
+ export function validateCrossmintCheckoutOrder(order, expected) {
544
+ if (order.orderId !== expected.orderId || order.payerAddress !== expected.owner)
545
+ fail("terms_mismatch", "Order or payer differs from the authenticated owner");
546
+ if (order.paymentMethod !== "solana" || order.preparationChain !== "solana")
547
+ fail("terms_unavailable", "Only Solana staging preparation is supported");
548
+ if (order.currency !== "usdc" || expected.mint !== "4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU")
549
+ fail("terms_unavailable", "Checkout currently supports only canonical Devnet USDC");
550
+ const expires = Date.parse(order.quoteExpiresAt ?? "");
551
+ if (order.quoteStatus !== "valid" || !Number.isFinite(expires) || expires <= (expected.now ?? Date.now()))
552
+ fail("order_not_payable", "Crossmint quote is expired or unavailable");
553
+ if (order.paymentStatus !== "awaiting-payment")
554
+ fail("order_not_payable", "Crossmint order is not awaiting payment");
555
+ requireBindableItems(order);
556
+ const transfer = decodeCrossmintTransferTerms(order.serializedTransaction ?? "", { mint: expected.mint, tokenProgram: "spl-token", strict: true });
557
+ if (transfer.source !== expected.source || transfer.decimals !== 6)
558
+ fail("terms_mismatch", "Prepared transfer differs from the mandate source or USDC decimals");
559
+ if (order.transactionAmount !== undefined && order.transactionAmount !== transfer.amount) {
560
+ fail("terms_mismatch", "Crossmint's stated payment amount differs from its prepared transfer");
561
+ }
562
+ // Crossmint pays every order into one shared treasury account. Without the
563
+ // memo a payment cannot be matched to this order, so checkout requires it in
564
+ // both places Crossmint states it, identical, and naming this order.
565
+ if (!order.transactionMemo || !transfer.memo) {
566
+ fail("terms_unavailable", "Crossmint preparation carries no order memo, so a payment could not be matched to this order");
567
+ }
568
+ if (transfer.memo.text !== order.transactionMemo)
569
+ fail("terms_mismatch", "Crossmint's prepared memo differs from its stated memo");
570
+ if (transfer.memo.signers.length > 1 || transfer.memo.signers.some((signer) => signer !== expected.owner)) {
571
+ fail("terms_mismatch", "Crossmint memo is signed by an account other than the payer");
572
+ }
573
+ const memo = crossmintOrderMemo(order.transactionMemo, order.orderId);
574
+ const terms = crossmintPaymentTerms(order, { mint: expected.mint, tokenProgram: "spl-token" });
575
+ if (terms.quoteCheck !== "match")
576
+ fail("terms_mismatch", "Quote amount or currency does not match the prepared transfer");
577
+ return { ...terms, memo };
578
+ }
579
+ /**
580
+ * Turn a Crossmint order into the terms ChainPay will check against a mandate.
581
+ * A phase that no longer owes money is rejected here rather than at settlement,
582
+ * so an already-paid order cannot be charged a second time by mistake.
583
+ */
584
+ export function crossmintPaymentTerms(order, expected = {}) {
585
+ if (order.phase !== CROSSMINT_PAYABLE_PHASE) {
586
+ const settled = CROSSMINT_SETTLED_PHASES.includes(order.phase);
587
+ fail("order_not_payable", settled
588
+ ? `Crossmint order ${order.orderId} is already in the ${order.phase} phase and owes nothing`
589
+ : `Crossmint order ${order.orderId} is in the ${order.phase} phase, which does not accept payment`);
590
+ }
591
+ if (!order.serializedTransaction) {
592
+ fail("terms_unavailable", `Crossmint order ${order.orderId} carries no prepared transaction, so ChainPay cannot read what it charges`);
593
+ }
594
+ const terms = decodeCrossmintTransferTerms(order.serializedTransaction, expected);
595
+ return {
596
+ connector: CROSSMINT_CONNECTOR,
597
+ connectorLabel: CROSSMINT_CONNECTOR_LABEL,
598
+ orderId: order.orderId,
599
+ phase: order.phase,
600
+ mint: terms.mint,
601
+ recipient: terms.recipient,
602
+ amount: terms.amount,
603
+ ...(terms.decimals === undefined ? {} : { decimals: terms.decimals }),
604
+ tokenProgram: terms.tokenProgram,
605
+ termsSource: "serialized-transaction",
606
+ quoteCheck: quoteCheckFor(order, terms),
607
+ ...(order.quotedTotal ? { quotedTotal: order.quotedTotal } : {}),
608
+ lineItemLocators: order.lineItemLocators,
609
+ items: (order.lineItems ?? []).map(boundItem),
610
+ ...(order.payerAddress ? { payerAddress: order.payerAddress } : {}),
611
+ crossmintSourceTokenAccount: terms.source,
612
+ };
613
+ }
614
+ /**
615
+ * Payment references for a Crossmint order.
616
+ *
617
+ * The invoice hash commits to the connector and the order id and nothing else.
618
+ * The receipt PDA is derived from it, so one Crossmint order can be settled at
619
+ * most once under a given mandate however its price or recipient later change —
620
+ * a repriced order cannot become a second charge.
621
+ */
622
+ export async function deriveCrossmintPaymentReferences(orderId) {
623
+ const canonical = JSON.stringify({ connector: CROSSMINT_CONNECTOR, orderId: boundedString(orderId, "orderId", MAX_ORDER_ID_CHARS) });
624
+ const invoiceHash = await sha256Hex(canonical);
625
+ return {
626
+ invoiceHash,
627
+ paymentId: await sha256Hex(`payment:${invoiceHash}`),
628
+ signatureReference: await sha256Hex(`crossmint:${invoiceHash}`),
629
+ };
630
+ }
631
+ /** Orders API URL for one order id, against the staging or production host. */
632
+ export function crossmintOrderUrl(baseUrl, orderId) {
633
+ const base = baseUrl.replace(/\/$/, "");
634
+ const url = new URL(`${base}${CROSSMINT_ORDERS_PATH}/${encodeURIComponent(boundedString(orderId, "orderId", MAX_ORDER_ID_CHARS))}`);
635
+ if (url.protocol !== "https:")
636
+ fail("malformed", "Crossmint base URL must use HTTPS");
637
+ return url.toString();
638
+ }