@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.
- package/LICENSE +21 -0
- package/README.md +42 -2
- package/dist/accounts.d.ts +23 -0
- package/dist/accounts.js +148 -0
- package/dist/cards/accounts.d.ts +156 -0
- package/dist/cards/accounts.js +346 -0
- package/dist/cards/api.d.ts +379 -0
- package/dist/cards/api.js +241 -0
- package/dist/cards/commitment.d.ts +86 -0
- package/dist/cards/commitment.js +288 -0
- package/dist/cards/constants.d.ts +160 -0
- package/dist/cards/constants.js +163 -0
- package/dist/cards/draft.d.ts +57 -0
- package/dist/cards/draft.js +131 -0
- package/dist/cards/evidence.d.ts +69 -0
- package/dist/cards/evidence.js +41 -0
- package/dist/cards/hash.d.ts +9 -0
- package/dist/cards/hash.js +47 -0
- package/dist/cards/index.d.ts +14 -0
- package/dist/cards/index.js +14 -0
- package/dist/cards/instructions.d.ts +302 -0
- package/dist/cards/instructions.js +474 -0
- package/dist/cards/layout.d.ts +33 -0
- package/dist/cards/layout.js +137 -0
- package/dist/cards/math.d.ts +67 -0
- package/dist/cards/math.js +138 -0
- package/dist/cards/merchants.d.ts +23 -0
- package/dist/cards/merchants.js +38 -0
- package/dist/cards/pda.d.ts +42 -0
- package/dist/cards/pda.js +93 -0
- package/dist/cards/private-repayment.d.ts +198 -0
- package/dist/cards/private-repayment.js +485 -0
- package/dist/cards/redact.d.ts +18 -0
- package/dist/cards/redact.js +103 -0
- package/dist/cards/tee.d.ts +253 -0
- package/dist/cards/tee.js +605 -0
- package/dist/cli.d.ts +31 -0
- package/dist/cli.js +351 -0
- package/dist/client.d.ts +80 -0
- package/dist/client.js +493 -0
- package/dist/constants.d.ts +44 -0
- package/dist/constants.js +43 -0
- package/dist/crossmint-adapt.d.ts +43 -0
- package/dist/crossmint-adapt.js +68 -0
- package/dist/crossmint-order.d.ts +220 -0
- package/dist/crossmint-order.js +638 -0
- package/dist/delivery.d.ts +66 -0
- package/dist/delivery.js +232 -0
- package/dist/encoding.d.ts +51 -0
- package/dist/encoding.js +128 -0
- package/dist/index.d.ts +27 -0
- package/dist/index.js +26 -0
- package/dist/known-assets.d.ts +30 -0
- package/dist/known-assets.js +49 -0
- package/dist/mandate-request.d.ts +123 -0
- package/dist/mandate-request.js +401 -0
- package/dist/mandate.d.ts +44 -0
- package/dist/mandate.js +157 -0
- package/dist/ops-snapshot.d.ts +157 -0
- package/dist/ops-snapshot.js +356 -0
- package/dist/payment-request.d.ts +37 -0
- package/dist/payment-request.js +218 -0
- package/dist/payment.d.ts +40 -0
- package/dist/payment.js +214 -0
- package/dist/pda.d.ts +13 -0
- package/dist/pda.js +36 -0
- package/dist/receipt-export.d.ts +55 -0
- package/dist/receipt-export.js +149 -0
- package/dist/receipt.d.ts +108 -0
- package/dist/receipt.js +213 -0
- package/dist/solana.d.ts +5 -0
- package/dist/solana.js +20 -0
- package/dist/token-capabilities.d.ts +9 -0
- package/dist/token-capabilities.js +139 -0
- package/dist/token.d.ts +18 -0
- package/dist/token.js +55 -0
- package/dist/transaction-reader.d.ts +10 -0
- package/dist/transaction-reader.js +18 -0
- package/dist/transaction-v1.d.ts +18 -0
- package/dist/transaction-v1.js +73 -0
- package/dist/types.d.ts +267 -0
- package/dist/types.js +1 -0
- package/dist/x402-adapt.d.ts +20 -0
- package/dist/x402-adapt.js +62 -0
- package/dist/x402-challenge.d.ts +116 -0
- package/dist/x402-challenge.js +385 -0
- package/dist/x402.d.ts +18 -0
- package/dist/x402.js +35 -0
- package/package.json +54 -4
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
import { bytesToHex, formatExactTokenAmount } from "./receipt.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 function receiptPolicy(receipt, relayObserved) {
|
|
7
|
+
const snapshot = receipt.policySnapshot;
|
|
8
|
+
if (snapshot) {
|
|
9
|
+
const { version: _version, ...limits } = snapshot;
|
|
10
|
+
return { source: "on-chain", limits };
|
|
11
|
+
}
|
|
12
|
+
if (relayObserved?.source === "relay-observed")
|
|
13
|
+
return relayObserved;
|
|
14
|
+
return { source: "not-recorded" };
|
|
15
|
+
}
|
|
16
|
+
function u64Field(value) {
|
|
17
|
+
if (typeof value !== "string" || !/^\d+$/.test(value))
|
|
18
|
+
return null;
|
|
19
|
+
const parsed = BigInt(value);
|
|
20
|
+
return parsed <= 18446744073709551615n ? parsed : null;
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Read the `policy` object the ChainPay relay returns from
|
|
24
|
+
* `GET /v1/receipts/{receipt}`. Only a well-formed relay observation is
|
|
25
|
+
* accepted; anything else is null, never a guessed limit. An "on-chain" value
|
|
26
|
+
* from the relay is ignored because the receipt account is read directly.
|
|
27
|
+
*/
|
|
28
|
+
export function relayObservedPolicy(value) {
|
|
29
|
+
if (!value || typeof value !== "object")
|
|
30
|
+
return null;
|
|
31
|
+
const policy = value;
|
|
32
|
+
if (policy.source !== "relay-observed")
|
|
33
|
+
return null;
|
|
34
|
+
const fields = {
|
|
35
|
+
maxPerPayment: u64Field(policy.max_per_payment),
|
|
36
|
+
totalLimit: u64Field(policy.total_limit),
|
|
37
|
+
amountSpentAfter: u64Field(policy.amount_spent_after),
|
|
38
|
+
paymentCountAfter: u64Field(policy.payment_count_after),
|
|
39
|
+
maxPaymentCount: u64Field(policy.max_payment_count),
|
|
40
|
+
expiresAtSlot: u64Field(policy.expires_at_slot),
|
|
41
|
+
cooldownSlots: u64Field(policy.cooldown_slots),
|
|
42
|
+
};
|
|
43
|
+
const observedAtSlot = u64Field(policy.observed_at_slot);
|
|
44
|
+
if (observedAtSlot === null || Object.values(fields).some((field) => field === null))
|
|
45
|
+
return null;
|
|
46
|
+
return {
|
|
47
|
+
source: "relay-observed",
|
|
48
|
+
limits: fields,
|
|
49
|
+
observedAtSlot,
|
|
50
|
+
includesLaterPayments: policy.includes_later_payments === true,
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
export function receiptExplorerUrl(receipt, cluster = "devnet") {
|
|
54
|
+
const suffix = cluster === "devnet" ? "?cluster=devnet" : "";
|
|
55
|
+
return receipt.transactionSignature
|
|
56
|
+
? `https://explorer.solana.com/tx/${receipt.transactionSignature}${suffix}`
|
|
57
|
+
: `https://explorer.solana.com/address/${receipt.address}${suffix}`;
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* Accounting tools read the first five columns; the rest let a reader trace
|
|
61
|
+
* every row back to Solana. New columns are appended so existing imports keep
|
|
62
|
+
* their column positions.
|
|
63
|
+
*/
|
|
64
|
+
export const RECEIPT_CSV_HEADERS = [
|
|
65
|
+
"Date",
|
|
66
|
+
"Description",
|
|
67
|
+
"Amount",
|
|
68
|
+
"Payee",
|
|
69
|
+
"Reference",
|
|
70
|
+
"Token",
|
|
71
|
+
"Agent",
|
|
72
|
+
"Spending permission",
|
|
73
|
+
"Per-payment limit",
|
|
74
|
+
"Total limit",
|
|
75
|
+
"Spent after",
|
|
76
|
+
"Limits source",
|
|
77
|
+
"Receipt",
|
|
78
|
+
"Verify URL",
|
|
79
|
+
"Explorer URL",
|
|
80
|
+
"PO number",
|
|
81
|
+
"Order match",
|
|
82
|
+
];
|
|
83
|
+
/** Exact decimal, trailing zeros trimmed. Unknown decimals stay labeled base units. */
|
|
84
|
+
function exactAmount(value, decimals) {
|
|
85
|
+
const exact = formatExactTokenAmount(value, decimals);
|
|
86
|
+
if (exact.displayKind === "base-units")
|
|
87
|
+
return `${exact.baseUnits} base units`;
|
|
88
|
+
return exact.display.includes(".") ? exact.display.replace(/0+$/, "").replace(/\.$/, "") : exact.display;
|
|
89
|
+
}
|
|
90
|
+
function isoDate(blockTime) {
|
|
91
|
+
if (blockTime === null || blockTime === undefined || !Number.isFinite(blockTime))
|
|
92
|
+
return "";
|
|
93
|
+
return new Date(blockTime * 1000).toISOString().slice(0, 10);
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* One CSV cell. A value a spreadsheet would run as a formula (leading =, +,
|
|
97
|
+
* -, @, tab, or carriage return) is prefixed with an apostrophe so it stays
|
|
98
|
+
* text. Then RFC 4180 quoting: wrap in quotes when the value holds a comma,
|
|
99
|
+
* quote, or line break, doubling inner quotes.
|
|
100
|
+
*/
|
|
101
|
+
export function csvCell(value) {
|
|
102
|
+
const safe = /^[=+\-@\t\r]/.test(value) ? `'${value}` : value;
|
|
103
|
+
return /[",\r\n]/.test(safe) ? `"${safe.replace(/"/g, '""')}"` : safe;
|
|
104
|
+
}
|
|
105
|
+
function description(purpose) {
|
|
106
|
+
if (purpose?.description)
|
|
107
|
+
return purpose.description;
|
|
108
|
+
if (purpose?.lineItems?.length)
|
|
109
|
+
return purpose.lineItems.map((item) => item.label).join("; ");
|
|
110
|
+
return "";
|
|
111
|
+
}
|
|
112
|
+
function rowCells(row) {
|
|
113
|
+
const { receipt, decimals } = row;
|
|
114
|
+
const policy = row.policy ?? receiptPolicy(receipt);
|
|
115
|
+
const limits = policy.source === "not-recorded" ? null : policy.limits;
|
|
116
|
+
// A relay read taken after a later payment no longer says what was spent
|
|
117
|
+
// after this one. Leave the cell empty rather than show a larger number.
|
|
118
|
+
const spentAfterKnown = limits && !(policy.source === "relay-observed" && policy.includesLaterPayments);
|
|
119
|
+
const payee = receipt.recipientTokenAccount;
|
|
120
|
+
return [
|
|
121
|
+
isoDate(row.blockTime),
|
|
122
|
+
description(row.purpose),
|
|
123
|
+
exactAmount(receipt.amount, decimals),
|
|
124
|
+
payee,
|
|
125
|
+
row.purpose?.invoice ?? bytesToHex(receipt.invoiceHash),
|
|
126
|
+
row.symbol ?? receipt.mint,
|
|
127
|
+
receipt.agent,
|
|
128
|
+
receipt.mandate,
|
|
129
|
+
limits ? exactAmount(limits.maxPerPayment, decimals) : "",
|
|
130
|
+
limits ? exactAmount(limits.totalLimit, decimals) : "",
|
|
131
|
+
spentAfterKnown ? exactAmount(limits.amountSpentAfter, decimals) : "",
|
|
132
|
+
policy.source,
|
|
133
|
+
receipt.address,
|
|
134
|
+
row.verifyUrl ?? "",
|
|
135
|
+
row.explorerUrl ?? receiptExplorerUrl(receipt),
|
|
136
|
+
row.poNumber ?? "",
|
|
137
|
+
row.orderMatch ?? "",
|
|
138
|
+
];
|
|
139
|
+
}
|
|
140
|
+
/**
|
|
141
|
+
* One CSV of receipts with QuickBooks-friendly leading columns (Date,
|
|
142
|
+
* Description, Amount, Payee, Reference), then the ChainPay columns. Amounts
|
|
143
|
+
* are exact decimals from base units, never floating point. CRLF line
|
|
144
|
+
* endings, header row always present.
|
|
145
|
+
*/
|
|
146
|
+
export function receiptsToCsv(rows) {
|
|
147
|
+
const lines = [RECEIPT_CSV_HEADERS.map(csvCell), ...rows.map((row) => rowCells(row).map(csvCell))];
|
|
148
|
+
return lines.map((cells) => cells.join(",")).join("\r\n") + "\r\n";
|
|
149
|
+
}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import type { Address, Mandate, PaymentReceipt, PaymentStatus } from "./types.js";
|
|
2
|
+
import { decodePaymentReceipt } from "./accounts.js";
|
|
3
|
+
export type ReceiptAccountView = {
|
|
4
|
+
address: Address;
|
|
5
|
+
owner: Address;
|
|
6
|
+
data: Uint8Array | Buffer;
|
|
7
|
+
};
|
|
8
|
+
export type ReceiptValidationCode = "wrong_owner" | "wrong_discriminator" | "truncated" | "pda_mismatch" | "unsettled" | "not_found";
|
|
9
|
+
export type ReceiptReadFailure = {
|
|
10
|
+
valid: false;
|
|
11
|
+
code: ReceiptValidationCode;
|
|
12
|
+
reason: string;
|
|
13
|
+
};
|
|
14
|
+
export type ReceiptReadSuccess = {
|
|
15
|
+
valid: true;
|
|
16
|
+
receipt: PaymentReceipt;
|
|
17
|
+
programId: Address;
|
|
18
|
+
derivedAddress: Address;
|
|
19
|
+
settled: boolean;
|
|
20
|
+
};
|
|
21
|
+
export type ReceiptReadResult = ReceiptReadSuccess | ReceiptReadFailure;
|
|
22
|
+
export type ReadReceiptOptions = {
|
|
23
|
+
programId?: Address;
|
|
24
|
+
requireSettled?: boolean;
|
|
25
|
+
transactionSignature?: string;
|
|
26
|
+
};
|
|
27
|
+
export type TokenAmountDisplay = {
|
|
28
|
+
/** Exact unsigned integer string of base units. Never a JS Number. */
|
|
29
|
+
baseUnits: string;
|
|
30
|
+
decimals: number | null;
|
|
31
|
+
/**
|
|
32
|
+
* Decimal-inserted UI amount when a supported mint owner and decimals
|
|
33
|
+
* are known. Otherwise the exact base-unit string. Never rounded.
|
|
34
|
+
*/
|
|
35
|
+
display: string;
|
|
36
|
+
displayKind: "ui-amount" | "base-units";
|
|
37
|
+
};
|
|
38
|
+
export type MintDecimalsRead = {
|
|
39
|
+
ok: true;
|
|
40
|
+
decimals: number;
|
|
41
|
+
} | {
|
|
42
|
+
ok: false;
|
|
43
|
+
reason: "unsupported_owner" | "truncated" | "not_a_mint" | "missing";
|
|
44
|
+
};
|
|
45
|
+
export type CurrentMandateRead = {
|
|
46
|
+
status: "present";
|
|
47
|
+
mandate: Mandate;
|
|
48
|
+
} | {
|
|
49
|
+
status: "absent";
|
|
50
|
+
} | {
|
|
51
|
+
status: "unavailable";
|
|
52
|
+
reason: string;
|
|
53
|
+
};
|
|
54
|
+
export type PublicReceiptProof = {
|
|
55
|
+
receipt: ReceiptReadResult;
|
|
56
|
+
amount: TokenAmountDisplay | null;
|
|
57
|
+
currentMandate: CurrentMandateRead;
|
|
58
|
+
};
|
|
59
|
+
export declare function createReceiptReference(address: Address, mandate: Address, invoiceHash: Uint8Array, status: PaymentStatus): Pick<PaymentReceipt, "address" | "mandate" | "invoiceHash" | "status">;
|
|
60
|
+
export declare function receiptAddress(mandate: Address, invoiceHash: Uint8Array, programId?: Address): Address;
|
|
61
|
+
export { decodePaymentReceipt };
|
|
62
|
+
export declare function bytesToHex(value: Uint8Array): string;
|
|
63
|
+
export declare function hexToBytes(value: string, name: string): Uint8Array;
|
|
64
|
+
export declare function readVerifiedMintDecimals(account: {
|
|
65
|
+
owner: Address;
|
|
66
|
+
data: Uint8Array | Buffer;
|
|
67
|
+
} | null | undefined): MintDecimalsRead;
|
|
68
|
+
/**
|
|
69
|
+
* Format a u64 token amount without Number conversion or rounding.
|
|
70
|
+
* Unknown decimals stay exact base units so a public card never invents
|
|
71
|
+
* a UI amount.
|
|
72
|
+
*/
|
|
73
|
+
export declare function formatExactTokenAmount(amount: bigint, decimals?: number | null): TokenAmountDisplay;
|
|
74
|
+
export declare function amountDisplayFromMint(amount: bigint, mintAccount?: {
|
|
75
|
+
owner: Address;
|
|
76
|
+
data: Uint8Array | Buffer;
|
|
77
|
+
} | null): TokenAmountDisplay;
|
|
78
|
+
/**
|
|
79
|
+
* Optional current-mandate enrichment. Independent of creation history
|
|
80
|
+
* and source token metadata. Missing or undecodable accounts never
|
|
81
|
+
* become fabricated limits.
|
|
82
|
+
*/
|
|
83
|
+
export declare function readCurrentMandateFields(account: ReceiptAccountView | null | undefined, options?: {
|
|
84
|
+
programId?: Address;
|
|
85
|
+
currentSlot?: bigint;
|
|
86
|
+
expectedAddress?: Address;
|
|
87
|
+
}): CurrentMandateRead;
|
|
88
|
+
/**
|
|
89
|
+
* Validating receipt reader for public `/verify` and shared ledger cards.
|
|
90
|
+
* Confirms program owner, discriminator, length, PDA seeds
|
|
91
|
+
* `["receipt", mandate, invoice_hash]`, and optional settled status.
|
|
92
|
+
* `signatureReference` is never treated as seller delivery.
|
|
93
|
+
*/
|
|
94
|
+
export declare function readPaymentReceiptAccount(account: ReceiptAccountView, options?: ReadReceiptOptions): ReceiptReadResult;
|
|
95
|
+
/** Public verify path: same checks plus settled status. */
|
|
96
|
+
export declare function readPublicSettledReceipt(account: ReceiptAccountView, options?: Omit<ReadReceiptOptions, "requireSettled">): ReceiptReadResult;
|
|
97
|
+
export declare function requirePaymentReceiptAccount(account: ReceiptAccountView, options?: ReadReceiptOptions): PaymentReceipt;
|
|
98
|
+
export declare function assemblePublicReceiptProof(input: {
|
|
99
|
+
receiptAccount: ReceiptAccountView;
|
|
100
|
+
programId?: Address;
|
|
101
|
+
mintAccount?: {
|
|
102
|
+
owner: Address;
|
|
103
|
+
data: Uint8Array | Buffer;
|
|
104
|
+
} | null;
|
|
105
|
+
mandateAccount?: ReceiptAccountView | null;
|
|
106
|
+
currentSlot?: bigint;
|
|
107
|
+
transactionSignature?: string;
|
|
108
|
+
}): PublicReceiptProof;
|
package/dist/receipt.js
ADDED
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
import { decodeCurrentMandateFields, decodePaymentReceipt } from "./accounts.js";
|
|
2
|
+
import { ACCOUNT_DISCRIMINATORS, DEFAULT_PROGRAM_ID, RECEIPT_ACCOUNT_LENGTH, RECEIPT_STATUS_SETTLED, SPL_TOKEN_PROGRAM_ID, TOKEN_2022_PROGRAM_ID, } from "./constants.js";
|
|
3
|
+
import { address } from "./encoding.js";
|
|
4
|
+
import { deriveReceiptAddress } from "./pda.js";
|
|
5
|
+
const MAX_U64 = 18446744073709551615n;
|
|
6
|
+
const ACCOUNT_DISCRIMINATOR_LENGTH = 8;
|
|
7
|
+
function accountBytes(data) {
|
|
8
|
+
return data instanceof Uint8Array ? data : new Uint8Array(data);
|
|
9
|
+
}
|
|
10
|
+
function discriminatorsEqual(data, expected) {
|
|
11
|
+
for (let index = 0; index < expected.length; index += 1) {
|
|
12
|
+
if (data[index] !== expected[index])
|
|
13
|
+
return false;
|
|
14
|
+
}
|
|
15
|
+
return true;
|
|
16
|
+
}
|
|
17
|
+
function failure(code, reason) {
|
|
18
|
+
return { valid: false, code, reason };
|
|
19
|
+
}
|
|
20
|
+
export function createReceiptReference(address, mandate, invoiceHash, status) {
|
|
21
|
+
return { address, mandate, invoiceHash, status };
|
|
22
|
+
}
|
|
23
|
+
export function receiptAddress(mandate, invoiceHash, programId = DEFAULT_PROGRAM_ID) {
|
|
24
|
+
return deriveReceiptAddress(mandate, invoiceHash, programId);
|
|
25
|
+
}
|
|
26
|
+
export { decodePaymentReceipt };
|
|
27
|
+
export function bytesToHex(value) {
|
|
28
|
+
return Array.from(value, (byte) => byte.toString(16).padStart(2, "0")).join("");
|
|
29
|
+
}
|
|
30
|
+
export function hexToBytes(value, name) {
|
|
31
|
+
const normalized = value.startsWith("0x") ? value.slice(2) : value;
|
|
32
|
+
if (!/^[0-9a-fA-F]+$/.test(normalized) || normalized.length !== 64) {
|
|
33
|
+
throw new Error(`${name} must be exactly 32 bytes encoded as hexadecimal`);
|
|
34
|
+
}
|
|
35
|
+
const bytes = new Uint8Array(32);
|
|
36
|
+
for (let index = 0; index < 32; index += 1) {
|
|
37
|
+
bytes[index] = Number.parseInt(normalized.slice(index * 2, index * 2 + 2), 16);
|
|
38
|
+
}
|
|
39
|
+
return bytes;
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Read mint decimals only after confirming the account owner is a supported
|
|
43
|
+
* token program. Callers must not inspect the decimals byte first.
|
|
44
|
+
*/
|
|
45
|
+
/** SPL Mint account length. Token-2022 mints pad to 165 and then tag themselves. */
|
|
46
|
+
const MINT_LEN = 82;
|
|
47
|
+
/** Offset of the Token-2022 `account_type` discriminator. */
|
|
48
|
+
const ACCOUNT_TYPE_OFFSET = 165;
|
|
49
|
+
/** `account_type` value meaning Mint. A token account is 2. */
|
|
50
|
+
const ACCOUNT_TYPE_MINT = 1;
|
|
51
|
+
export function readVerifiedMintDecimals(account) {
|
|
52
|
+
if (!account)
|
|
53
|
+
return { ok: false, reason: "missing" };
|
|
54
|
+
const owner = address(account.owner);
|
|
55
|
+
if (owner !== SPL_TOKEN_PROGRAM_ID && owner !== TOKEN_2022_PROGRAM_ID) {
|
|
56
|
+
return { ok: false, reason: "unsupported_owner" };
|
|
57
|
+
}
|
|
58
|
+
const data = accountBytes(account.data);
|
|
59
|
+
if (data.length <= 44)
|
|
60
|
+
return { ok: false, reason: "truncated" };
|
|
61
|
+
// `data[44]` is the decimals byte of an SPL Mint, but a 165-byte token account
|
|
62
|
+
// is owned by the same programs and would put part of its `owner` pubkey at
|
|
63
|
+
// that offset, yielding an arbitrary 0-255 as decimals and a confidently wrong
|
|
64
|
+
// amount. Accept only a base mint (exactly 82 bytes) or a Token-2022 mint,
|
|
65
|
+
// which pads to 165 and tags `account_type = 1` at offset 165.
|
|
66
|
+
const isBaseMint = data.length === MINT_LEN;
|
|
67
|
+
const isExtendedMint = data.length > ACCOUNT_TYPE_OFFSET && data[ACCOUNT_TYPE_OFFSET] === ACCOUNT_TYPE_MINT;
|
|
68
|
+
if (!isBaseMint && !isExtendedMint)
|
|
69
|
+
return { ok: false, reason: "not_a_mint" };
|
|
70
|
+
return { ok: true, decimals: data[44] };
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* Format a u64 token amount without Number conversion or rounding.
|
|
74
|
+
* Unknown decimals stay exact base units so a public card never invents
|
|
75
|
+
* a UI amount.
|
|
76
|
+
*/
|
|
77
|
+
export function formatExactTokenAmount(amount, decimals) {
|
|
78
|
+
if (amount < 0n || amount > MAX_U64) {
|
|
79
|
+
throw new Error("amount must fit in an unsigned 64-bit integer");
|
|
80
|
+
}
|
|
81
|
+
const baseUnits = amount.toString();
|
|
82
|
+
if (decimals === null
|
|
83
|
+
|| decimals === undefined
|
|
84
|
+
|| !Number.isInteger(decimals)
|
|
85
|
+
|| decimals < 0
|
|
86
|
+
|| decimals > 255) {
|
|
87
|
+
return { baseUnits, decimals: null, display: baseUnits, displayKind: "base-units" };
|
|
88
|
+
}
|
|
89
|
+
if (decimals === 0) {
|
|
90
|
+
return { baseUnits, decimals, display: baseUnits, displayKind: "ui-amount" };
|
|
91
|
+
}
|
|
92
|
+
const padded = baseUnits.padStart(decimals + 1, "0");
|
|
93
|
+
const whole = padded.slice(0, padded.length - decimals);
|
|
94
|
+
const fraction = padded.slice(padded.length - decimals);
|
|
95
|
+
return { baseUnits, decimals, display: `${whole}.${fraction}`, displayKind: "ui-amount" };
|
|
96
|
+
}
|
|
97
|
+
export function amountDisplayFromMint(amount, mintAccount) {
|
|
98
|
+
const mint = readVerifiedMintDecimals(mintAccount);
|
|
99
|
+
return formatExactTokenAmount(amount, mint.ok ? mint.decimals : null);
|
|
100
|
+
}
|
|
101
|
+
/**
|
|
102
|
+
* Optional current-mandate enrichment. Independent of creation history
|
|
103
|
+
* and source token metadata. Missing or undecodable accounts never
|
|
104
|
+
* become fabricated limits.
|
|
105
|
+
*/
|
|
106
|
+
export function readCurrentMandateFields(account, options = {}) {
|
|
107
|
+
if (!account)
|
|
108
|
+
return { status: "absent" };
|
|
109
|
+
const programId = address(options.programId ?? DEFAULT_PROGRAM_ID);
|
|
110
|
+
try {
|
|
111
|
+
if (address(account.owner) !== programId) {
|
|
112
|
+
return { status: "unavailable", reason: "Mandate account is not owned by the ChainPay program" };
|
|
113
|
+
}
|
|
114
|
+
if (options.expectedAddress !== undefined && address(account.address) !== address(options.expectedAddress)) {
|
|
115
|
+
return { status: "unavailable", reason: "Mandate account address does not match the receipt mandate" };
|
|
116
|
+
}
|
|
117
|
+
return {
|
|
118
|
+
status: "present",
|
|
119
|
+
mandate: decodeCurrentMandateFields(account.data, account.address, options.currentSlot),
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
catch (error) {
|
|
123
|
+
return {
|
|
124
|
+
status: "unavailable",
|
|
125
|
+
reason: error instanceof Error ? error.message : String(error),
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
/**
|
|
130
|
+
* Validating receipt reader for public `/verify` and shared ledger cards.
|
|
131
|
+
* Confirms program owner, discriminator, length, PDA seeds
|
|
132
|
+
* `["receipt", mandate, invoice_hash]`, and optional settled status.
|
|
133
|
+
* `signatureReference` is never treated as seller delivery.
|
|
134
|
+
*/
|
|
135
|
+
export function readPaymentReceiptAccount(account, options = {}) {
|
|
136
|
+
const programId = address(options.programId ?? DEFAULT_PROGRAM_ID);
|
|
137
|
+
let requestedAddress;
|
|
138
|
+
try {
|
|
139
|
+
requestedAddress = address(account.address);
|
|
140
|
+
}
|
|
141
|
+
catch {
|
|
142
|
+
return failure("not_found", "Receipt address is not a valid Solana public key");
|
|
143
|
+
}
|
|
144
|
+
try {
|
|
145
|
+
if (address(account.owner) !== programId) {
|
|
146
|
+
return failure("wrong_owner", "Receipt account is not owned by the ChainPay program");
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
catch {
|
|
150
|
+
return failure("wrong_owner", "Receipt account is not owned by the ChainPay program");
|
|
151
|
+
}
|
|
152
|
+
const data = accountBytes(account.data);
|
|
153
|
+
if (data.length < ACCOUNT_DISCRIMINATOR_LENGTH) {
|
|
154
|
+
return failure("truncated", "PaymentReceipt account data is truncated");
|
|
155
|
+
}
|
|
156
|
+
if (!discriminatorsEqual(data, ACCOUNT_DISCRIMINATORS.paymentReceipt)) {
|
|
157
|
+
return failure("wrong_discriminator", "Invalid PaymentReceipt account discriminator");
|
|
158
|
+
}
|
|
159
|
+
if (data.length < RECEIPT_ACCOUNT_LENGTH) {
|
|
160
|
+
return failure("truncated", "PaymentReceipt account data is truncated");
|
|
161
|
+
}
|
|
162
|
+
const receipt = decodePaymentReceipt(data, requestedAddress, options.transactionSignature);
|
|
163
|
+
const derivedAddress = deriveReceiptAddress(receipt.mandate, receipt.invoiceHash, programId);
|
|
164
|
+
if (derivedAddress !== requestedAddress) {
|
|
165
|
+
return failure("pda_mismatch", "Receipt address does not match PDA seeds [\"receipt\", mandate, invoice_hash]");
|
|
166
|
+
}
|
|
167
|
+
const settled = receipt.onChainStatus === RECEIPT_STATUS_SETTLED;
|
|
168
|
+
if ((options.requireSettled ?? false) && !settled) {
|
|
169
|
+
return failure("unsettled", "Receipt is not settled");
|
|
170
|
+
}
|
|
171
|
+
return {
|
|
172
|
+
valid: true,
|
|
173
|
+
receipt,
|
|
174
|
+
programId,
|
|
175
|
+
derivedAddress,
|
|
176
|
+
settled,
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
/** Public verify path: same checks plus settled status. */
|
|
180
|
+
export function readPublicSettledReceipt(account, options = {}) {
|
|
181
|
+
return readPaymentReceiptAccount(account, { ...options, requireSettled: true });
|
|
182
|
+
}
|
|
183
|
+
export function requirePaymentReceiptAccount(account, options = {}) {
|
|
184
|
+
const result = readPaymentReceiptAccount(account, options);
|
|
185
|
+
if (!result.valid)
|
|
186
|
+
throw new Error(result.reason);
|
|
187
|
+
return result.receipt;
|
|
188
|
+
}
|
|
189
|
+
export function assemblePublicReceiptProof(input) {
|
|
190
|
+
const receipt = readPublicSettledReceipt(input.receiptAccount, {
|
|
191
|
+
programId: input.programId,
|
|
192
|
+
transactionSignature: input.transactionSignature,
|
|
193
|
+
});
|
|
194
|
+
if (!receipt.valid) {
|
|
195
|
+
return {
|
|
196
|
+
receipt,
|
|
197
|
+
amount: null,
|
|
198
|
+
currentMandate: readCurrentMandateFields(input.mandateAccount, {
|
|
199
|
+
programId: input.programId,
|
|
200
|
+
currentSlot: input.currentSlot,
|
|
201
|
+
}),
|
|
202
|
+
};
|
|
203
|
+
}
|
|
204
|
+
return {
|
|
205
|
+
receipt,
|
|
206
|
+
amount: amountDisplayFromMint(receipt.receipt.amount, input.mintAccount),
|
|
207
|
+
currentMandate: readCurrentMandateFields(input.mandateAccount, {
|
|
208
|
+
programId: input.programId,
|
|
209
|
+
currentSlot: input.currentSlot,
|
|
210
|
+
expectedAddress: receipt.receipt.mandate,
|
|
211
|
+
}),
|
|
212
|
+
};
|
|
213
|
+
}
|
package/dist/solana.d.ts
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import { Connection, Transaction, TransactionInstruction } from "@solana/web3.js";
|
|
2
|
+
import type { ChainPayInstruction, PreparedTransaction } from "./types.js";
|
|
3
|
+
export declare function toWeb3Instruction(instruction: ChainPayInstruction): TransactionInstruction;
|
|
4
|
+
export declare function toWeb3Transaction(prepared: PreparedTransaction, recentBlockhash: string): Transaction;
|
|
5
|
+
export { Connection };
|
package/dist/solana.js
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { Connection, Transaction, TransactionInstruction, } from "@solana/web3.js";
|
|
2
|
+
import { publicKey } from "./encoding.js";
|
|
3
|
+
export function toWeb3Instruction(instruction) {
|
|
4
|
+
return new TransactionInstruction({
|
|
5
|
+
programId: publicKey(instruction.programId),
|
|
6
|
+
keys: instruction.keys.map((key) => ({
|
|
7
|
+
pubkey: publicKey(key.address),
|
|
8
|
+
isSigner: key.isSigner,
|
|
9
|
+
isWritable: key.isWritable,
|
|
10
|
+
})),
|
|
11
|
+
data: Buffer.from(instruction.data),
|
|
12
|
+
});
|
|
13
|
+
}
|
|
14
|
+
export function toWeb3Transaction(prepared, recentBlockhash) {
|
|
15
|
+
const feePayer = prepared.feePayer ?? prepared.requiredSigners[0];
|
|
16
|
+
if (!feePayer)
|
|
17
|
+
throw new Error("A prepared transaction needs a fee payer");
|
|
18
|
+
return new Transaction({ feePayer: publicKey(feePayer), recentBlockhash }).add(...prepared.instructions.map(toWeb3Instruction));
|
|
19
|
+
}
|
|
20
|
+
export { Connection };
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { type Commitment, type Connection } from "@solana/web3.js";
|
|
2
|
+
import type { Address, TokenCapabilityProfile, TokenProgram } from "./types.js";
|
|
3
|
+
export declare function inspectTokenCapabilities(connection: Connection, input: {
|
|
4
|
+
mint: Address;
|
|
5
|
+
sourceTokenAccount: Address;
|
|
6
|
+
recipientTokenAccount: Address;
|
|
7
|
+
tokenProgram: TokenProgram;
|
|
8
|
+
commitment?: Commitment;
|
|
9
|
+
}): Promise<TokenCapabilityProfile>;
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import { AccountState, ExtensionType, TOKEN_2022_PROGRAM_ID as SPL_TOKEN_2022_PROGRAM_ID, TOKEN_PROGRAM_ID as SPL_TOKEN_PROGRAM_ID, getAccount, getCpiGuard, getDefaultAccountState, getEpochFee, getExtensionTypes, getMemoTransfer, getMint, getPausableConfig, getTransferFeeConfig, getTransferHook, } from "@solana/spl-token";
|
|
2
|
+
import { PublicKey } from "@solana/web3.js";
|
|
3
|
+
import { publicKey } from "./encoding.js";
|
|
4
|
+
const CONFIDENTIAL_TRANSFER_FEE_CONFIG = 16;
|
|
5
|
+
const CONFIDENTIAL_TRANSFER_FEE_AMOUNT = 17;
|
|
6
|
+
const KNOWN_MINT_EXTENSIONS = new Set([
|
|
7
|
+
ExtensionType.TransferFeeConfig,
|
|
8
|
+
ExtensionType.MintCloseAuthority,
|
|
9
|
+
ExtensionType.ConfidentialTransferMint,
|
|
10
|
+
ExtensionType.DefaultAccountState,
|
|
11
|
+
ExtensionType.NonTransferable,
|
|
12
|
+
ExtensionType.InterestBearingConfig,
|
|
13
|
+
ExtensionType.PermanentDelegate,
|
|
14
|
+
ExtensionType.TransferHook,
|
|
15
|
+
CONFIDENTIAL_TRANSFER_FEE_CONFIG,
|
|
16
|
+
ExtensionType.MetadataPointer,
|
|
17
|
+
ExtensionType.TokenMetadata,
|
|
18
|
+
ExtensionType.GroupPointer,
|
|
19
|
+
ExtensionType.TokenGroup,
|
|
20
|
+
ExtensionType.GroupMemberPointer,
|
|
21
|
+
ExtensionType.TokenGroupMember,
|
|
22
|
+
ExtensionType.ScaledUiAmountConfig,
|
|
23
|
+
ExtensionType.PausableConfig,
|
|
24
|
+
ExtensionType.PermissionedBurn,
|
|
25
|
+
]);
|
|
26
|
+
const KNOWN_ACCOUNT_EXTENSIONS = new Set([
|
|
27
|
+
ExtensionType.TransferFeeAmount,
|
|
28
|
+
ExtensionType.ConfidentialTransferAccount,
|
|
29
|
+
ExtensionType.ImmutableOwner,
|
|
30
|
+
ExtensionType.MemoTransfer,
|
|
31
|
+
ExtensionType.CpiGuard,
|
|
32
|
+
ExtensionType.NonTransferableAccount,
|
|
33
|
+
ExtensionType.TransferHookAccount,
|
|
34
|
+
CONFIDENTIAL_TRANSFER_FEE_AMOUNT,
|
|
35
|
+
ExtensionType.PausableAccount,
|
|
36
|
+
]);
|
|
37
|
+
function extensionName(extension) {
|
|
38
|
+
if (extension === CONFIDENTIAL_TRANSFER_FEE_CONFIG)
|
|
39
|
+
return "ConfidentialTransferFeeConfig";
|
|
40
|
+
if (extension === CONFIDENTIAL_TRANSFER_FEE_AMOUNT)
|
|
41
|
+
return "ConfidentialTransferFeeAmount";
|
|
42
|
+
return ExtensionType[extension] ?? `UnknownExtension(${extension})`;
|
|
43
|
+
}
|
|
44
|
+
function unexpectedExtensions(extensions, known, scope) {
|
|
45
|
+
return extensions
|
|
46
|
+
.filter((extension) => !known.has(extension))
|
|
47
|
+
.map((extension) => `${scope} has unsupported ${extensionName(extension)}`);
|
|
48
|
+
}
|
|
49
|
+
export async function inspectTokenCapabilities(connection, input) {
|
|
50
|
+
const programId = input.tokenProgram === "token-2022"
|
|
51
|
+
? SPL_TOKEN_2022_PROGRAM_ID
|
|
52
|
+
: SPL_TOKEN_PROGRAM_ID;
|
|
53
|
+
const mintAddress = publicKey(input.mint);
|
|
54
|
+
const commitment = input.commitment ?? "confirmed";
|
|
55
|
+
const [mint, source, recipient] = await Promise.all([
|
|
56
|
+
getMint(connection, mintAddress, commitment, programId),
|
|
57
|
+
getAccount(connection, publicKey(input.sourceTokenAccount), commitment, programId),
|
|
58
|
+
getAccount(connection, publicKey(input.recipientTokenAccount), commitment, programId),
|
|
59
|
+
]);
|
|
60
|
+
const blockers = [];
|
|
61
|
+
const warnings = [];
|
|
62
|
+
if (!mint.isInitialized)
|
|
63
|
+
blockers.push("mint is not initialized");
|
|
64
|
+
if (!source.isInitialized)
|
|
65
|
+
blockers.push("source token account is not initialized");
|
|
66
|
+
if (!recipient.isInitialized)
|
|
67
|
+
blockers.push("recipient token account is not initialized");
|
|
68
|
+
if (!source.mint.equals(mintAddress))
|
|
69
|
+
blockers.push("source token account mint does not match payment mint");
|
|
70
|
+
if (!recipient.mint.equals(mintAddress))
|
|
71
|
+
blockers.push("recipient token account mint does not match payment mint");
|
|
72
|
+
if (source.isFrozen)
|
|
73
|
+
blockers.push("source token account is frozen");
|
|
74
|
+
if (recipient.isFrozen)
|
|
75
|
+
blockers.push("recipient token account is frozen");
|
|
76
|
+
const mintExtensions = input.tokenProgram === "token-2022" ? getExtensionTypes(mint.tlvData) : [];
|
|
77
|
+
const sourceExtensions = input.tokenProgram === "token-2022" ? getExtensionTypes(source.tlvData) : [];
|
|
78
|
+
const recipientExtensions = input.tokenProgram === "token-2022" ? getExtensionTypes(recipient.tlvData) : [];
|
|
79
|
+
let transferFee;
|
|
80
|
+
let transferHookProgram;
|
|
81
|
+
if (input.tokenProgram === "token-2022") {
|
|
82
|
+
blockers.push(...unexpectedExtensions(mintExtensions, KNOWN_MINT_EXTENSIONS, "mint"));
|
|
83
|
+
blockers.push(...unexpectedExtensions(sourceExtensions, KNOWN_ACCOUNT_EXTENSIONS, "source account"));
|
|
84
|
+
blockers.push(...unexpectedExtensions(recipientExtensions, KNOWN_ACCOUNT_EXTENSIONS, "recipient account"));
|
|
85
|
+
if (mintExtensions.includes(ExtensionType.NonTransferable)) {
|
|
86
|
+
blockers.push("mint is non-transferable");
|
|
87
|
+
}
|
|
88
|
+
if (sourceExtensions.includes(ExtensionType.NonTransferableAccount) || recipientExtensions.includes(ExtensionType.NonTransferableAccount)) {
|
|
89
|
+
blockers.push("payment uses a non-transferable token account");
|
|
90
|
+
}
|
|
91
|
+
const feeConfig = getTransferFeeConfig(mint);
|
|
92
|
+
if (feeConfig) {
|
|
93
|
+
const epoch = BigInt((await connection.getEpochInfo(commitment)).epoch);
|
|
94
|
+
const fee = getEpochFee(feeConfig, epoch);
|
|
95
|
+
transferFee = {
|
|
96
|
+
basisPoints: fee.transferFeeBasisPoints,
|
|
97
|
+
maximumFee: fee.maximumFee,
|
|
98
|
+
};
|
|
99
|
+
if (fee.transferFeeBasisPoints !== 0 || fee.maximumFee !== 0n) {
|
|
100
|
+
blockers.push("non-zero transfer fees require gross/net/withheld-fee receipt support");
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
const hook = getTransferHook(mint);
|
|
104
|
+
if (hook && !hook.programId.equals(PublicKey.default)) {
|
|
105
|
+
transferHookProgram = hook.programId.toBase58();
|
|
106
|
+
blockers.push("active transfer hook requires verified on-chain extra-account resolution and a Devnet-tested adapter");
|
|
107
|
+
}
|
|
108
|
+
const memo = getMemoTransfer(recipient);
|
|
109
|
+
if (memo?.requireIncomingTransferMemos) {
|
|
110
|
+
blockers.push("recipient requires an incoming memo, but the memo-aware settlement adapter is not enabled");
|
|
111
|
+
}
|
|
112
|
+
const cpiGuard = getCpiGuard(source);
|
|
113
|
+
if (cpiGuard?.lockCpi) {
|
|
114
|
+
blockers.push("source account CPI guard blocks the current Anchor delegate transfer path");
|
|
115
|
+
}
|
|
116
|
+
const pausable = getPausableConfig(mint);
|
|
117
|
+
if (pausable?.paused)
|
|
118
|
+
blockers.push("mint transfers are paused");
|
|
119
|
+
const defaultState = getDefaultAccountState(mint);
|
|
120
|
+
if (defaultState?.state === AccountState.Frozen) {
|
|
121
|
+
warnings.push("mint defaults new token accounts to frozen; current source and recipient were checked individually");
|
|
122
|
+
}
|
|
123
|
+
if (mintExtensions.includes(ExtensionType.ConfidentialTransferMint)) {
|
|
124
|
+
warnings.push("confidential extension is present; ChainPay uses only the transparent transfer_checked balance path");
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
return {
|
|
128
|
+
mint: mint.address.toBase58(),
|
|
129
|
+
tokenProgram: input.tokenProgram,
|
|
130
|
+
compatible: blockers.length === 0,
|
|
131
|
+
mintExtensions: mintExtensions.map(extensionName),
|
|
132
|
+
sourceAccountExtensions: sourceExtensions.map(extensionName),
|
|
133
|
+
recipientAccountExtensions: recipientExtensions.map(extensionName),
|
|
134
|
+
blockers,
|
|
135
|
+
warnings,
|
|
136
|
+
...(transferFee ? { transferFee } : {}),
|
|
137
|
+
...(transferHookProgram ? { transferHookProgram } : {}),
|
|
138
|
+
};
|
|
139
|
+
}
|
package/dist/token.d.ts
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { Address, ChainPayInstruction, PaymentPreflightContext, TokenProgram } from "./types.js";
|
|
2
|
+
export declare function deriveAssociatedTokenAddress(owner: Address, mint: Address, tokenProgram: TokenProgram): Address;
|
|
3
|
+
export declare function buildCreateAssociatedTokenAccountInstruction(input: {
|
|
4
|
+
payer: Address;
|
|
5
|
+
owner: Address;
|
|
6
|
+
mint: Address;
|
|
7
|
+
tokenProgram: TokenProgram;
|
|
8
|
+
}): ChainPayInstruction;
|
|
9
|
+
export declare function associatedTokenProgramAddress(): Address;
|
|
10
|
+
export type TokenAccountFields = {
|
|
11
|
+
mint: Address;
|
|
12
|
+
owner: Address;
|
|
13
|
+
balance: bigint;
|
|
14
|
+
delegate: Address | null;
|
|
15
|
+
delegatedAmount: bigint;
|
|
16
|
+
};
|
|
17
|
+
export declare function readTokenAccountFields(data: Uint8Array | Buffer): TokenAccountFields | null;
|
|
18
|
+
export declare function paymentPreflightContextFromTokenAccount(data: Uint8Array | Buffer): PaymentPreflightContext | null;
|