@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,37 @@
1
+ import type { PaymentReceipt, PaymentRequestPayload, PaymentRequestVerification, SignedPaymentRequest } from "./types.js";
2
+ /** Bounds for the optional "what was bought" fields. The relay enforces the same. */
3
+ export declare const MAX_PAYMENT_REQUEST_DESCRIPTION_LENGTH = 280;
4
+ export declare const MAX_PAYMENT_REQUEST_LINE_ITEMS = 20;
5
+ export declare const MAX_PAYMENT_REQUEST_LINE_ITEM_LABEL_LENGTH = 120;
6
+ /**
7
+ * Check the optional description and line items. Returns a reason when they
8
+ * are out of bounds, otherwise undefined.
9
+ */
10
+ export declare function paymentRequestPurposeError(payload: PaymentRequestPayload): string | undefined;
11
+ export declare function canonicalPaymentRequest(payload: PaymentRequestPayload): string;
12
+ export declare function verifyPaymentRequest(request: SignedPaymentRequest, currentSlot?: bigint): Promise<PaymentRequestVerification>;
13
+ export declare function paymentRequestTokenProgramAddress(request: PaymentRequestPayload): string;
14
+ export type ReceiptPurchaseMismatch = "amount" | "mint" | "recipient";
15
+ export type ReceiptPurchaseVerification = {
16
+ /** Merchant signature is valid and the request hashes to the receipt's invoice hash. */
17
+ valid: boolean;
18
+ /** `valid`, and the receipt paid the amount, mint, and recipient the request named. */
19
+ matched: boolean;
20
+ signatureValid: boolean;
21
+ hashMatches: boolean;
22
+ /** Fields the receipt paid differently from the request. Empty when matched. */
23
+ mismatches: ReceiptPurchaseMismatch[];
24
+ payload: PaymentRequestPayload;
25
+ invoiceHash: Uint8Array;
26
+ reason?: string;
27
+ };
28
+ /**
29
+ * Check that a merchant-signed request is the one a settled receipt paid:
30
+ * the signature verifies, and sha256 of its canonical form equals the
31
+ * receipt's invoice hash. Request expiry is not enforced, because a request
32
+ * that has since expired is still the request that was paid.
33
+ *
34
+ * The program binds the invoice hash, not the request's amount, mint, or
35
+ * recipient, so those are compared too and reported as mismatches.
36
+ */
37
+ export declare function verifyReceiptPurchase(receipt: Pick<PaymentReceipt, "invoiceHash" | "amount" | "mint" | "recipientTokenAccount">, signedRequest: SignedPaymentRequest): Promise<ReceiptPurchaseVerification>;
@@ -0,0 +1,218 @@
1
+ import { PublicKey } from "@solana/web3.js";
2
+ import { address, tokenProgramAddress } from "./encoding.js";
3
+ const MAX_U64 = 18446744073709551615n;
4
+ /** Bounds for the optional "what was bought" fields. The relay enforces the same. */
5
+ export const MAX_PAYMENT_REQUEST_DESCRIPTION_LENGTH = 280;
6
+ export const MAX_PAYMENT_REQUEST_LINE_ITEMS = 20;
7
+ export const MAX_PAYMENT_REQUEST_LINE_ITEM_LABEL_LENGTH = 120;
8
+ const MAX_QUANTITY_LENGTH = 32;
9
+ function orderedLineItem(item) {
10
+ // Canonical order is label, amount, quantity; absent fields are omitted.
11
+ if (!item || typeof item !== "object")
12
+ return item;
13
+ return {
14
+ label: item.label,
15
+ ...(item.amount === undefined ? {} : { amount: item.amount }),
16
+ ...(item.quantity === undefined ? {} : { quantity: item.quantity }),
17
+ };
18
+ }
19
+ function orderedPayload(payload) {
20
+ return {
21
+ version: 1,
22
+ cluster: payload.cluster,
23
+ merchant: address(payload.merchant),
24
+ invoice: payload.invoice,
25
+ mint: address(payload.mint),
26
+ tokenProgram: payload.tokenProgram,
27
+ recipient: address(payload.recipient),
28
+ amount: payload.amount,
29
+ decimals: payload.decimals,
30
+ nonce: payload.nonce,
31
+ ...(payload.expiresAtSlot === undefined ? {} : { expiresAtSlot: payload.expiresAtSlot }),
32
+ ...(payload.resource === undefined ? {} : { resource: payload.resource }),
33
+ // Included only when present, after resource, so a request without them
34
+ // keeps the exact canonical bytes and invoice hash it always had.
35
+ ...(payload.description === undefined ? {} : { description: payload.description }),
36
+ ...(payload.lineItems === undefined
37
+ ? {}
38
+ : { lineItems: Array.isArray(payload.lineItems) ? payload.lineItems.map(orderedLineItem) : payload.lineItems }),
39
+ };
40
+ }
41
+ /** Length in Unicode code points, the unit the relay counts in. */
42
+ function codePoints(value) {
43
+ return Array.from(value).length;
44
+ }
45
+ const CONTROL_CHARACTER = /[\u0000-\u001f\u007f-\u009f]/;
46
+ /**
47
+ * Check the optional description and line items. Returns a reason when they
48
+ * are out of bounds, otherwise undefined.
49
+ */
50
+ export function paymentRequestPurposeError(payload) {
51
+ if (payload.description !== undefined) {
52
+ const description = payload.description;
53
+ if (typeof description !== "string"
54
+ || !description.trim()
55
+ || codePoints(description) > MAX_PAYMENT_REQUEST_DESCRIPTION_LENGTH) {
56
+ return `Description must be 1 to ${MAX_PAYMENT_REQUEST_DESCRIPTION_LENGTH} characters`;
57
+ }
58
+ if (CONTROL_CHARACTER.test(description))
59
+ return "Description must be a single line of text";
60
+ }
61
+ if (payload.lineItems !== undefined) {
62
+ const items = payload.lineItems;
63
+ if (!Array.isArray(items) || items.length === 0 || items.length > MAX_PAYMENT_REQUEST_LINE_ITEMS) {
64
+ return `Line items must contain 1 to ${MAX_PAYMENT_REQUEST_LINE_ITEMS} items`;
65
+ }
66
+ for (const item of items) {
67
+ if (!item || typeof item !== "object")
68
+ return "Each line item must be an object";
69
+ if (typeof item.label !== "string"
70
+ || !item.label.trim()
71
+ || codePoints(item.label) > MAX_PAYMENT_REQUEST_LINE_ITEM_LABEL_LENGTH
72
+ || CONTROL_CHARACTER.test(item.label)) {
73
+ return `Each line item label must be one line of 1 to ${MAX_PAYMENT_REQUEST_LINE_ITEM_LABEL_LENGTH} characters`;
74
+ }
75
+ if (item.amount !== undefined) {
76
+ if (typeof item.amount !== "string" || !/^\d+$/.test(item.amount) || BigInt(item.amount) > MAX_U64) {
77
+ return "Line item amount must be an unsigned integer string in base units";
78
+ }
79
+ }
80
+ if (item.quantity !== undefined) {
81
+ if (typeof item.quantity !== "string"
82
+ || item.quantity.length > MAX_QUANTITY_LENGTH
83
+ || !/^\d+(\.\d+)?$/.test(item.quantity)) {
84
+ return "Line item quantity must be an unsigned decimal string";
85
+ }
86
+ }
87
+ }
88
+ }
89
+ return undefined;
90
+ }
91
+ export function canonicalPaymentRequest(payload) {
92
+ return JSON.stringify(orderedPayload(payload));
93
+ }
94
+ function base64Bytes(value) {
95
+ const normalized = value.replace(/-/g, "+").replace(/_/g, "/");
96
+ const binary = atob(normalized);
97
+ return Uint8Array.from(binary, (character) => character.charCodeAt(0));
98
+ }
99
+ async function sha256(value) {
100
+ const digest = await globalThis.crypto.subtle.digest("SHA-256", value.slice().buffer);
101
+ return new Uint8Array(digest);
102
+ }
103
+ function validationError(payload, reason) {
104
+ return { valid: false, payload, invoiceHash: new Uint8Array(32), reason };
105
+ }
106
+ export async function verifyPaymentRequest(request, currentSlot) {
107
+ if (request.payload.version !== 1)
108
+ return validationError(request.payload, "Unsupported payment request version");
109
+ const payload = orderedPayload(request.payload);
110
+ try {
111
+ if (payload.version !== 1)
112
+ return validationError(payload, "Unsupported payment request version");
113
+ if (payload.cluster !== "devnet" && payload.cluster !== "mainnet-beta") {
114
+ return validationError(payload, "Unsupported Solana cluster");
115
+ }
116
+ if (!payload.invoice.trim() || !payload.nonce.trim()) {
117
+ return validationError(payload, "Payment request invoice and nonce are required");
118
+ }
119
+ if (!/^\d+$/.test(payload.amount))
120
+ return validationError(payload, "Amount must be an unsigned integer string");
121
+ const amount = BigInt(payload.amount);
122
+ if (amount <= 0n || amount > MAX_U64)
123
+ return validationError(payload, "Amount must fit in u64 and be positive");
124
+ if (!Number.isInteger(payload.decimals) || payload.decimals < 0 || payload.decimals > 255) {
125
+ return validationError(payload, "Decimals must be between 0 and 255");
126
+ }
127
+ if (payload.tokenProgram !== "spl-token" && payload.tokenProgram !== "token-2022") {
128
+ return validationError(payload, "Unsupported token program");
129
+ }
130
+ address(payload.merchant);
131
+ address(payload.mint);
132
+ address(payload.recipient);
133
+ const purposeError = paymentRequestPurposeError(payload);
134
+ if (purposeError)
135
+ return validationError(payload, purposeError);
136
+ if (payload.expiresAtSlot !== undefined) {
137
+ if (!/^\d+$/.test(payload.expiresAtSlot))
138
+ return validationError(payload, "Expiry slot must be an unsigned integer");
139
+ if (currentSlot !== undefined && BigInt(payload.expiresAtSlot) <= currentSlot) {
140
+ return validationError(payload, "Payment request has expired");
141
+ }
142
+ }
143
+ const signature = base64Bytes(request.signature);
144
+ if (signature.length !== 64)
145
+ return validationError(payload, "Ed25519 signature must be 64 bytes");
146
+ const merchant = new PublicKey(payload.merchant).toBytes();
147
+ const key = await globalThis.crypto.subtle.importKey("raw", merchant.slice().buffer, { name: "Ed25519" }, false, ["verify"]);
148
+ const message = new TextEncoder().encode(canonicalPaymentRequest(payload));
149
+ const validSignature = await globalThis.crypto.subtle.verify("Ed25519", key, signature.slice().buffer, message.slice().buffer);
150
+ if (!validSignature)
151
+ return validationError(payload, "Payment request signature is invalid");
152
+ const invoiceHash = await sha256(message);
153
+ return { valid: true, payload, invoiceHash };
154
+ }
155
+ catch (error) {
156
+ return validationError(payload, error instanceof Error ? error.message : String(error));
157
+ }
158
+ }
159
+ export function paymentRequestTokenProgramAddress(request) {
160
+ return tokenProgramAddress(request.tokenProgram);
161
+ }
162
+ function bytesEqual(left, right) {
163
+ return left.length === right.length && left.every((byte, index) => byte === right[index]);
164
+ }
165
+ /**
166
+ * Check that a merchant-signed request is the one a settled receipt paid:
167
+ * the signature verifies, and sha256 of its canonical form equals the
168
+ * receipt's invoice hash. Request expiry is not enforced, because a request
169
+ * that has since expired is still the request that was paid.
170
+ *
171
+ * The program binds the invoice hash, not the request's amount, mint, or
172
+ * recipient, so those are compared too and reported as mismatches.
173
+ */
174
+ export async function verifyReceiptPurchase(receipt, signedRequest) {
175
+ const verification = await verifyPaymentRequest(signedRequest);
176
+ const base = {
177
+ payload: verification.payload,
178
+ invoiceHash: verification.invoiceHash,
179
+ mismatches: [],
180
+ };
181
+ if (!verification.valid) {
182
+ return {
183
+ ...base,
184
+ valid: false,
185
+ matched: false,
186
+ signatureValid: false,
187
+ hashMatches: false,
188
+ reason: verification.reason,
189
+ };
190
+ }
191
+ if (!bytesEqual(verification.invoiceHash, receipt.invoiceHash)) {
192
+ return {
193
+ ...base,
194
+ valid: false,
195
+ matched: false,
196
+ signatureValid: true,
197
+ hashMatches: false,
198
+ reason: "This request is not the one this receipt paid: its hash differs from the receipt's invoice hash",
199
+ };
200
+ }
201
+ const payload = verification.payload;
202
+ const mismatches = [];
203
+ if (BigInt(payload.amount) !== receipt.amount)
204
+ mismatches.push("amount");
205
+ if (payload.mint !== receipt.mint)
206
+ mismatches.push("mint");
207
+ if (payload.recipient !== receipt.recipientTokenAccount)
208
+ mismatches.push("recipient");
209
+ return {
210
+ ...base,
211
+ valid: true,
212
+ matched: mismatches.length === 0,
213
+ signatureValid: true,
214
+ hashMatches: true,
215
+ mismatches,
216
+ ...(mismatches.length ? { reason: `The receipt paid a different ${mismatches.join(", ")} than the request named` } : {}),
217
+ };
218
+ }
@@ -0,0 +1,40 @@
1
+ import type { Address, BatchPreflightEntry, ChainPayInstruction, Mandate, PaymentBatchPreflight, PaymentPreflight, PaymentPreflightContext, PaymentRequest, PreparedTransaction, TokenProgram } from "./types.js";
2
+ export declare const DUPLICATE_INVOICE_MESSAGE = "This invoice was already paid. Nothing new was submitted.";
3
+ /**
4
+ * The receipt for this mandate and invoice hash already exists, so the invoice
5
+ * was paid before. Raised before anything is built, signed, or sent. The
6
+ * program would refuse the payment anyway; this says why in plain words.
7
+ */
8
+ export declare class DuplicateInvoiceError extends Error {
9
+ readonly code: "DuplicateInvoice";
10
+ readonly receiptAddress: Address;
11
+ constructor(receiptAddress: Address);
12
+ }
13
+ export declare function isDuplicateInvoiceError(error: unknown): error is DuplicateInvoiceError;
14
+ /**
15
+ * True when a simulation or send error says the receipt account already
16
+ * exists ("Allocate: account ... already in use"). Matches only when the
17
+ * message names this payment's receipt, so an unrelated account collision is
18
+ * still reported as itself.
19
+ */
20
+ export declare function isReceiptAlreadyInUse(message: string, receiptAddress: Address): boolean;
21
+ export type PreparePaymentInput = {
22
+ mandate: Address;
23
+ invoiceHash: Uint8Array;
24
+ paymentId: Uint8Array;
25
+ signatureReference: Uint8Array;
26
+ mint: Address;
27
+ recipient: Address;
28
+ amount: bigint;
29
+ tokenProgram?: TokenProgram;
30
+ };
31
+ export declare function preparePayment(input: PreparePaymentInput): PaymentRequest;
32
+ export declare function buildExecutePaymentInstruction(request: PaymentRequest, agent: Address, mandate: Mandate, programId?: Address): ChainPayInstruction;
33
+ export declare function preflightPayment(request: PaymentRequest, mandate: Mandate, currentSlot: bigint, agent?: Address, receiptAlreadyExists?: boolean, sourceContext?: PaymentPreflightContext): PaymentPreflight;
34
+ /**
35
+ * Run per-payment preflight plus cumulative batch checks grouped by mandate.
36
+ * Dashboard batch import uses the same cumulative rules when source context
37
+ * is supplied for each row.
38
+ */
39
+ export declare function preflightPaymentBatch(entries: BatchPreflightEntry[], currentSlot: bigint): PaymentBatchPreflight;
40
+ export declare function preparedPaymentTransaction(instructionData: ChainPayInstruction, agent: Address): PreparedTransaction;
@@ -0,0 +1,214 @@
1
+ import { DEFAULT_PROGRAM_ID, SYSTEM_PROGRAM_ID } from "./constants.js";
2
+ import { address, bytes32, encodePayment, instruction, meta, publicKey, tokenProgramAddress, } from "./encoding.js";
3
+ import { deriveAssetAddress, deriveConfigAddress, deriveReceiptAddress } from "./pda.js";
4
+ export const DUPLICATE_INVOICE_MESSAGE = "This invoice was already paid. Nothing new was submitted.";
5
+ /**
6
+ * The receipt for this mandate and invoice hash already exists, so the invoice
7
+ * was paid before. Raised before anything is built, signed, or sent. The
8
+ * program would refuse the payment anyway; this says why in plain words.
9
+ */
10
+ export class DuplicateInvoiceError extends Error {
11
+ code = "DuplicateInvoice";
12
+ receiptAddress;
13
+ constructor(receiptAddress) {
14
+ super(DUPLICATE_INVOICE_MESSAGE);
15
+ this.name = "DuplicateInvoiceError";
16
+ this.receiptAddress = receiptAddress;
17
+ }
18
+ }
19
+ export function isDuplicateInvoiceError(error) {
20
+ return error instanceof DuplicateInvoiceError
21
+ || (typeof error === "object" && error !== null && error.code === "DuplicateInvoice");
22
+ }
23
+ /**
24
+ * True when a simulation or send error says the receipt account already
25
+ * exists ("Allocate: account ... already in use"). Matches only when the
26
+ * message names this payment's receipt, so an unrelated account collision is
27
+ * still reported as itself.
28
+ */
29
+ export function isReceiptAlreadyInUse(message, receiptAddress) {
30
+ return message.includes("already in use") && message.includes(receiptAddress);
31
+ }
32
+ export function preparePayment(input) {
33
+ publicKey(input.mandate);
34
+ publicKey(input.mint);
35
+ publicKey(input.recipient);
36
+ const untrustedRemainingAccounts = input.remainingAccounts;
37
+ if (untrustedRemainingAccounts?.length) {
38
+ throw new Error("Caller-supplied remainingAccounts are not accepted; ChainPay must resolve extension accounts from verified on-chain state");
39
+ }
40
+ bytes32(input.invoiceHash, "invoiceHash");
41
+ bytes32(input.paymentId, "paymentId");
42
+ bytes32(input.signatureReference, "signatureReference");
43
+ if (input.invoiceHash.length !== 32) {
44
+ throw new Error("invoiceHash must be 32 bytes");
45
+ }
46
+ if (input.paymentId.length !== 32) {
47
+ throw new Error("paymentId must be 32 bytes");
48
+ }
49
+ if (input.signatureReference.length !== 32) {
50
+ throw new Error("signatureReference must be 32 bytes");
51
+ }
52
+ if (input.amount <= 0n) {
53
+ throw new Error("amount must be positive");
54
+ }
55
+ return {
56
+ ...input,
57
+ invoiceHash: new Uint8Array(input.invoiceHash),
58
+ paymentId: new Uint8Array(input.paymentId),
59
+ signatureReference: new Uint8Array(input.signatureReference),
60
+ };
61
+ }
62
+ export function buildExecutePaymentInstruction(request, agent, mandate, programId = DEFAULT_PROGRAM_ID) {
63
+ publicKey(agent);
64
+ if (address(request.mandate) !== mandate.address) {
65
+ throw new Error("Payment request mandate does not match the loaded mandate");
66
+ }
67
+ const tokenProgram = request.tokenProgram ?? mandate.tokenProgram;
68
+ if (!tokenProgram) {
69
+ throw new Error("Token program is required to build an execute_payment instruction");
70
+ }
71
+ return instruction("execute_payment", programId, [
72
+ meta(deriveConfigAddress(programId)),
73
+ meta(deriveAssetAddress(mandate.allowedMint, programId)),
74
+ meta(mandate.address, true),
75
+ meta(deriveReceiptAddress(mandate.address, request.invoiceHash, programId), true),
76
+ meta(agent, true, true),
77
+ meta(request.mint),
78
+ meta(mandate.sourceTokenAccount, true),
79
+ meta(request.recipient, true),
80
+ meta(tokenProgramAddress(tokenProgram)),
81
+ meta(SYSTEM_PROGRAM_ID),
82
+ ], encodePayment(request));
83
+ }
84
+ function check(name, ok, message) {
85
+ return { name, ok, message };
86
+ }
87
+ function sourceAccountChecks(request, mandate, context) {
88
+ return [
89
+ check("source_owner", address(context.sourceOwner) === mandate.owner, address(context.sourceOwner) === mandate.owner
90
+ ? "Source token account owner matches the mandate owner"
91
+ : "Source token account owner does not match the mandate owner"),
92
+ check("source_balance", request.amount <= context.sourceBalance, request.amount <= context.sourceBalance
93
+ ? "Source token account balance covers this payment"
94
+ : "Source token account balance is insufficient for this payment"),
95
+ check("delegate_identity", context.delegate !== null && address(context.delegate) === mandate.address, context.delegate !== null && address(context.delegate) === mandate.address
96
+ ? "Source token account delegates spending authority to this mandate"
97
+ : "Source token account is not delegated to this mandate"),
98
+ check("delegated_amount", context.delegatedAmount > 0n && request.amount <= context.delegatedAmount, context.delegatedAmount > 0n && request.amount <= context.delegatedAmount
99
+ ? "Remaining delegated allowance covers this payment"
100
+ : context.delegatedAmount <= 0n
101
+ ? "Source token account has no remaining delegated allowance"
102
+ : "Payment exceeds the remaining delegated allowance"),
103
+ ];
104
+ }
105
+ export function preflightPayment(request, mandate, currentSlot, agent, receiptAlreadyExists = false, sourceContext) {
106
+ const checks = [
107
+ check("mandate_status", mandate.status === "active", mandate.status === "active"
108
+ ? "Mandate is active"
109
+ : `Mandate is ${mandate.status}`),
110
+ check("approved_agent", agent === undefined || address(agent) === mandate.approvedAgent, agent === undefined || address(agent) === mandate.approvedAgent
111
+ ? "Payment agent matches the mandate"
112
+ : "Payment agent does not match the mandate"),
113
+ check("mint", address(request.mint) === mandate.allowedMint, address(request.mint) === mandate.allowedMint
114
+ ? "Payment mint matches the mandate"
115
+ : "Payment mint does not match the mandate"),
116
+ check("recipient", address(request.recipient) !== SYSTEM_PROGRAM_ID, address(request.recipient) !== SYSTEM_PROGRAM_ID
117
+ ? "Payment recipient is specified for this request"
118
+ : "Payment recipient must be specified"),
119
+ check("amount_positive", request.amount > 0n, request.amount > 0n ? "Payment amount is positive" : "Payment amount must be positive"),
120
+ check("per_payment_limit", request.amount <= mandate.maxPerPayment, request.amount <= mandate.maxPerPayment
121
+ ? "Payment is within the per-payment limit"
122
+ : "Payment exceeds the per-payment limit"),
123
+ check("total_limit", request.amount >= 0n && mandate.amountSpent + request.amount <= mandate.totalLimit, request.amount >= 0n && mandate.amountSpent + request.amount <= mandate.totalLimit
124
+ ? "Payment is within the total spend limit"
125
+ : "Payment exceeds the total spend limit"),
126
+ check("payment_count_limit", mandate.maxPaymentCount === 0n || mandate.paymentCount + 1n <= mandate.maxPaymentCount, mandate.maxPaymentCount === 0n || mandate.paymentCount + 1n <= mandate.maxPaymentCount
127
+ ? "Payment is within the payment-count limit"
128
+ : "Payment exceeds the payment-count limit"),
129
+ check("cooldown", mandate.lastPaymentSlot === 0n || currentSlot >= mandate.lastPaymentSlot + mandate.cooldownSlots, mandate.lastPaymentSlot === 0n || currentSlot >= mandate.lastPaymentSlot + mandate.cooldownSlots
130
+ ? "Mandate cooldown has elapsed"
131
+ : "Mandate cooldown is still active"),
132
+ check("expiry", mandate.expiresAtSlot > currentSlot, mandate.expiresAtSlot > currentSlot ? "Mandate has not expired" : "Mandate has expired"),
133
+ check("invoice_hash", request.invoiceHash.some((byte) => byte !== 0), request.invoiceHash.some((byte) => byte !== 0)
134
+ ? "Invoice hash is non-zero"
135
+ : "Invoice hash must not be all zeroes"),
136
+ check("payment_id", request.paymentId.some((byte) => byte !== 0), request.paymentId.some((byte) => byte !== 0)
137
+ ? "Payment ID is non-zero"
138
+ : "Payment ID must not be all zeroes"),
139
+ check("signature_reference", request.signatureReference.some((byte) => byte !== 0), request.signatureReference.some((byte) => byte !== 0)
140
+ ? "Signature reference is non-zero"
141
+ : "Signature reference must not be all zeroes"),
142
+ check("duplicate_invoice", !receiptAlreadyExists, receiptAlreadyExists
143
+ ? "This invoice hash already has a receipt under the mandate"
144
+ : "No receipt exists for this invoice hash"),
145
+ check("token_program", request.tokenProgram === undefined ||
146
+ mandate.tokenProgram === undefined ||
147
+ request.tokenProgram === mandate.tokenProgram, request.tokenProgram === undefined ||
148
+ mandate.tokenProgram === undefined ||
149
+ request.tokenProgram === mandate.tokenProgram
150
+ ? "Token program matches the loaded mandate context"
151
+ : "Token program does not match the source token account"),
152
+ ...(sourceContext ? sourceAccountChecks(request, mandate, sourceContext) : []),
153
+ ];
154
+ return { valid: checks.every((item) => item.ok), currentSlot, checks };
155
+ }
156
+ function batchChecksForMandateGroup(group, mandate) {
157
+ const totalAmount = group.reduce((total, entry) => total + entry.request.amount, 0n);
158
+ const checks = [
159
+ check("batch_total_limit", mandate.amountSpent + totalAmount <= mandate.totalLimit, mandate.amountSpent + totalAmount <= mandate.totalLimit
160
+ ? "Batch total is within the mandate spending limit"
161
+ : "Together, these payments exceed the mandate total spending limit"),
162
+ check("batch_payment_count", mandate.maxPaymentCount === 0n || mandate.paymentCount + BigInt(group.length) <= mandate.maxPaymentCount, mandate.maxPaymentCount === 0n || mandate.paymentCount + BigInt(group.length) <= mandate.maxPaymentCount
163
+ ? "Batch size is within the mandate payment-count limit"
164
+ : "Together, these payments exceed the mandate payment-count limit"),
165
+ check("batch_cooldown", group.length <= 1 || mandate.cooldownSlots === 0n, group.length <= 1 || mandate.cooldownSlots === 0n
166
+ ? "Batch respects the mandate cooldown policy"
167
+ : "This mandate has a cooldown and can only settle once per atomic batch"),
168
+ ];
169
+ const context = group.find((entry) => entry.sourceContext)?.sourceContext;
170
+ if (context) {
171
+ checks.push(check("batch_source_balance", totalAmount <= context.sourceBalance, totalAmount <= context.sourceBalance
172
+ ? "Source token account balance covers the batch total"
173
+ : "Source token account balance is insufficient for the batch total"), check("batch_delegated_amount", context.delegatedAmount > 0n && totalAmount <= context.delegatedAmount, context.delegatedAmount > 0n && totalAmount <= context.delegatedAmount
174
+ ? "Remaining delegated allowance covers the batch total"
175
+ : context.delegatedAmount <= 0n
176
+ ? "Source token account has no remaining delegated allowance for this batch"
177
+ : "Batch total exceeds the remaining delegated allowance"));
178
+ }
179
+ return checks;
180
+ }
181
+ /**
182
+ * Run per-payment preflight plus cumulative batch checks grouped by mandate.
183
+ * Dashboard batch import uses the same cumulative rules when source context
184
+ * is supplied for each row.
185
+ */
186
+ export function preflightPaymentBatch(entries, currentSlot) {
187
+ const preparedEntries = entries.map((entry) => ({
188
+ request: entry.request,
189
+ mandate: address(entry.mandate.address),
190
+ preflight: preflightPayment(entry.request, entry.mandate, currentSlot, entry.agent, entry.receiptAlreadyExists ?? false, entry.sourceContext),
191
+ }));
192
+ const groups = new Map();
193
+ for (const entry of entries) {
194
+ const key = address(entry.mandate.address);
195
+ const group = groups.get(key) ?? [];
196
+ group.push(entry);
197
+ groups.set(key, group);
198
+ }
199
+ const batchChecks = [...groups.values()].flatMap((group) => batchChecksForMandateGroup(group, group[0].mandate));
200
+ const valid = preparedEntries.every((entry) => entry.preflight.valid) && batchChecks.every((item) => item.ok);
201
+ return {
202
+ valid,
203
+ currentSlot,
204
+ entries: preparedEntries,
205
+ batchChecks,
206
+ };
207
+ }
208
+ export function preparedPaymentTransaction(instructionData, agent) {
209
+ return {
210
+ instructions: [instructionData],
211
+ requiredSigners: [agent],
212
+ feePayer: agent,
213
+ };
214
+ }
package/dist/pda.d.ts ADDED
@@ -0,0 +1,13 @@
1
+ import type { Address } from "./types.js";
2
+ export declare function deriveConfigAddress(programId?: Address): Address;
3
+ export declare function deriveLegacyMandateAddress(owner: Address, programId?: Address): Address;
4
+ export declare function deriveMintMandateAddress(owner: Address, allowedMint: Address, programId?: Address): Address;
5
+ export declare function deriveVersionedMandateAddress(owner: Address, allowedMint: Address, mandateNonce: Address, programId?: Address): Address;
6
+ /**
7
+ * Derive a mandate address. Without a mint this returns the legacy
8
+ * owner-scoped address; with a mint it returns the production mint-scoped
9
+ * address used by new mandates.
10
+ */
11
+ export declare function deriveMandateAddress(owner: Address, programId?: Address, allowedMint?: Address): Address;
12
+ export declare function deriveReceiptAddress(mandate: Address, invoiceHash: Uint8Array, programId?: Address): Address;
13
+ export declare function deriveAssetAddress(mint: Address, programId?: Address): Address;
package/dist/pda.js ADDED
@@ -0,0 +1,36 @@
1
+ import { PublicKey } from "@solana/web3.js";
2
+ import { CONFIG_SEED, ASSET_SEED, DEFAULT_PROGRAM_ID, MANDATE_SEED, RECEIPT_SEED, } from "./constants.js";
3
+ import { bytes32, publicKey } from "./encoding.js";
4
+ export function deriveConfigAddress(programId = DEFAULT_PROGRAM_ID) {
5
+ return PublicKey.findProgramAddressSync([Buffer.from(CONFIG_SEED)], publicKey(programId))[0].toBase58();
6
+ }
7
+ export function deriveLegacyMandateAddress(owner, programId = DEFAULT_PROGRAM_ID) {
8
+ return PublicKey.findProgramAddressSync([Buffer.from(MANDATE_SEED), publicKey(owner).toBytes()], publicKey(programId))[0].toBase58();
9
+ }
10
+ export function deriveMintMandateAddress(owner, allowedMint, programId = DEFAULT_PROGRAM_ID) {
11
+ return PublicKey.findProgramAddressSync([Buffer.from(MANDATE_SEED), publicKey(owner).toBytes(), publicKey(allowedMint).toBytes()], publicKey(programId))[0].toBase58();
12
+ }
13
+ export function deriveVersionedMandateAddress(owner, allowedMint, mandateNonce, programId = DEFAULT_PROGRAM_ID) {
14
+ return PublicKey.findProgramAddressSync([
15
+ Buffer.from(MANDATE_SEED),
16
+ publicKey(owner).toBytes(),
17
+ publicKey(allowedMint).toBytes(),
18
+ publicKey(mandateNonce).toBytes(),
19
+ ], publicKey(programId))[0].toBase58();
20
+ }
21
+ /**
22
+ * Derive a mandate address. Without a mint this returns the legacy
23
+ * owner-scoped address; with a mint it returns the production mint-scoped
24
+ * address used by new mandates.
25
+ */
26
+ export function deriveMandateAddress(owner, programId = DEFAULT_PROGRAM_ID, allowedMint) {
27
+ return allowedMint === undefined
28
+ ? deriveLegacyMandateAddress(owner, programId)
29
+ : deriveMintMandateAddress(owner, allowedMint, programId);
30
+ }
31
+ export function deriveReceiptAddress(mandate, invoiceHash, programId = DEFAULT_PROGRAM_ID) {
32
+ return PublicKey.findProgramAddressSync([Buffer.from(RECEIPT_SEED), publicKey(mandate).toBytes(), bytes32(invoiceHash, "invoiceHash")], publicKey(programId))[0].toBase58();
33
+ }
34
+ export function deriveAssetAddress(mint, programId = DEFAULT_PROGRAM_ID) {
35
+ return PublicKey.findProgramAddressSync([Buffer.from(ASSET_SEED), publicKey(mint).toBytes()], publicKey(programId))[0].toBase58();
36
+ }
@@ -0,0 +1,55 @@
1
+ import type { PaymentReceipt, PaymentRequestPayload, ReceiptPolicy } from "./types.js";
2
+ /**
3
+ * The limits beside a receipt, preferring the snapshot the program wrote into
4
+ * the receipt itself. A relay observation is used only when there is none.
5
+ */
6
+ export declare function receiptPolicy(receipt: Pick<PaymentReceipt, "policySnapshot">, relayObserved?: ReceiptPolicy | null): ReceiptPolicy;
7
+ /**
8
+ * Read the `policy` object the ChainPay relay returns from
9
+ * `GET /v1/receipts/{receipt}`. Only a well-formed relay observation is
10
+ * accepted; anything else is null, never a guessed limit. An "on-chain" value
11
+ * from the relay is ignored because the receipt account is read directly.
12
+ */
13
+ export declare function relayObservedPolicy(value: unknown): ReceiptPolicy | null;
14
+ export declare function receiptExplorerUrl(receipt: Pick<PaymentReceipt, "address" | "transactionSignature">, cluster?: "devnet" | "mainnet-beta"): string;
15
+ export type ReceiptCsvRow = {
16
+ receipt: PaymentReceipt;
17
+ /** Verified mint decimals. Null keeps amounts in labeled base units. */
18
+ decimals: number | null;
19
+ symbol?: string;
20
+ /** Unix seconds from getBlockTime for the executed slot. Null when unknown. */
21
+ blockTime?: number | null;
22
+ /** Defaults to the receipt's own snapshot, else "not-recorded". */
23
+ policy?: ReceiptPolicy;
24
+ /** From the merchant-signed request, only after verifyReceiptPurchase. */
25
+ purpose?: Partial<Pick<PaymentRequestPayload, "invoice" | "description" | "lineItems">>;
26
+ verifyUrl?: string;
27
+ explorerUrl?: string;
28
+ /** From the mandate request the owner accepted, when one is linked. */
29
+ poNumber?: string;
30
+ /**
31
+ * The Order match pill as the reader saw it ("Matched", "Payee differs",
32
+ * "No order", ...). Empty when it was not checked. A check, not a guarantee.
33
+ */
34
+ orderMatch?: string;
35
+ };
36
+ /**
37
+ * Accounting tools read the first five columns; the rest let a reader trace
38
+ * every row back to Solana. New columns are appended so existing imports keep
39
+ * their column positions.
40
+ */
41
+ export declare const RECEIPT_CSV_HEADERS: readonly ["Date", "Description", "Amount", "Payee", "Reference", "Token", "Agent", "Spending permission", "Per-payment limit", "Total limit", "Spent after", "Limits source", "Receipt", "Verify URL", "Explorer URL", "PO number", "Order match"];
42
+ /**
43
+ * One CSV cell. A value a spreadsheet would run as a formula (leading =, +,
44
+ * -, @, tab, or carriage return) is prefixed with an apostrophe so it stays
45
+ * text. Then RFC 4180 quoting: wrap in quotes when the value holds a comma,
46
+ * quote, or line break, doubling inner quotes.
47
+ */
48
+ export declare function csvCell(value: string): string;
49
+ /**
50
+ * One CSV of receipts with QuickBooks-friendly leading columns (Date,
51
+ * Description, Amount, Payee, Reference), then the ChainPay columns. Amounts
52
+ * are exact decimals from base units, never floating point. CRLF line
53
+ * endings, header row always present.
54
+ */
55
+ export declare function receiptsToCsv(rows: readonly ReceiptCsvRow[]): string;