@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,123 @@
|
|
|
1
|
+
import type { Address, TokenProgram } from "./types.js";
|
|
2
|
+
/**
|
|
3
|
+
* A signed ask for a spending permission. A vendor sends one to say "let your
|
|
4
|
+
* agent pay me up to these limits"; a builder sends one to say "fund my agent".
|
|
5
|
+
*
|
|
6
|
+
* The request is a proposal only. It moves no money and creates nothing on
|
|
7
|
+
* chain. The owner reviews it, may change every limit, and signs the mandate in
|
|
8
|
+
* their own wallet. The requester signature proves which key asked; the stated
|
|
9
|
+
* name is not verified by anything.
|
|
10
|
+
*/
|
|
11
|
+
export type MandateRequestRole = "vendor" | "grantee";
|
|
12
|
+
export type MandateRequestPayload = {
|
|
13
|
+
version: 1;
|
|
14
|
+
cluster: "devnet" | "mainnet-beta";
|
|
15
|
+
role: MandateRequestRole;
|
|
16
|
+
/** Ed25519 public key that signs the request. */
|
|
17
|
+
requester: Address;
|
|
18
|
+
/** Stated by the requester, not verified. At most 64 characters. */
|
|
19
|
+
requesterName?: string;
|
|
20
|
+
/** Grantee only: the agent key that will sign payments. */
|
|
21
|
+
agent?: Address;
|
|
22
|
+
mint: Address;
|
|
23
|
+
tokenProgram: TokenProgram;
|
|
24
|
+
/** Vendor only: the expected payee. Not enforced on chain for nonce mandates. */
|
|
25
|
+
recipient?: Address;
|
|
26
|
+
/** Base units, u64. */
|
|
27
|
+
suggestedMaxPerPayment: string;
|
|
28
|
+
/** Base units, u64, at least suggestedMaxPerPayment. */
|
|
29
|
+
suggestedTotal: string;
|
|
30
|
+
decimals: number;
|
|
31
|
+
suggestedExpirySlot?: string;
|
|
32
|
+
/** The slot after which the link should no longer be accepted. */
|
|
33
|
+
validUntilSlot?: string;
|
|
34
|
+
/** At most 280 characters. */
|
|
35
|
+
description: string;
|
|
36
|
+
/** At most 64 characters. */
|
|
37
|
+
poNumber?: string;
|
|
38
|
+
nonce: string;
|
|
39
|
+
};
|
|
40
|
+
export type SignedMandateRequest = {
|
|
41
|
+
payload: MandateRequestPayload;
|
|
42
|
+
/** Ed25519 signature over the canonical payload, base64url without padding. */
|
|
43
|
+
signature: string;
|
|
44
|
+
};
|
|
45
|
+
export type MandateRequestVerification = {
|
|
46
|
+
valid: boolean;
|
|
47
|
+
payload: MandateRequestPayload;
|
|
48
|
+
/** Lowercase hex SHA-256 of the canonical payload. Empty when the payload could not be read. */
|
|
49
|
+
requestHash: string;
|
|
50
|
+
reason?: string;
|
|
51
|
+
};
|
|
52
|
+
export declare const MANDATE_REQUEST_PATH = "/app/requests/permission";
|
|
53
|
+
export declare const MANDATE_REQUEST_FRAGMENT_KEY = "req";
|
|
54
|
+
export declare const MAX_REQUESTER_NAME_LENGTH = 64;
|
|
55
|
+
export declare const MAX_REQUEST_DESCRIPTION_LENGTH = 280;
|
|
56
|
+
export declare const MAX_PO_NUMBER_LENGTH = 64;
|
|
57
|
+
export declare const MAX_REQUEST_NONCE_LENGTH = 128;
|
|
58
|
+
/**
|
|
59
|
+
* Nominal slots per day at 400 ms a slot. Real slot times drift, so durations
|
|
60
|
+
* built from it are estimates and are worded as such.
|
|
61
|
+
*/
|
|
62
|
+
export declare const NOMINAL_SLOTS_PER_DAY = 216000n;
|
|
63
|
+
export declare function canonicalMandateRequest(payload: MandateRequestPayload): string;
|
|
64
|
+
/**
|
|
65
|
+
* Check every field and every role rule, without the signature or the clock.
|
|
66
|
+
* Throws with a plain reason. Unknown fields are rejected so that nothing a
|
|
67
|
+
* reader sees can sit outside what the requester signed.
|
|
68
|
+
*/
|
|
69
|
+
export declare function parseMandateRequestPayload(value: unknown): MandateRequestPayload;
|
|
70
|
+
export declare function parseSignedMandateRequest(value: unknown): SignedMandateRequest;
|
|
71
|
+
export declare function bytesToBase64Url(bytes: Uint8Array): string;
|
|
72
|
+
export declare function base64UrlToBytes(value: string): Uint8Array;
|
|
73
|
+
/**
|
|
74
|
+
* Verify a signed request. With `currentSlot`, an expired link or a suggested
|
|
75
|
+
* expiry already in the past is refused.
|
|
76
|
+
*/
|
|
77
|
+
export declare function verifyMandateRequest(request: SignedMandateRequest | unknown, currentSlot?: bigint): Promise<MandateRequestVerification>;
|
|
78
|
+
/**
|
|
79
|
+
* Sign a request with the requester key. For the CLI and the demo merchant;
|
|
80
|
+
* the key never leaves the caller. Refuses a key that is not the requester.
|
|
81
|
+
*/
|
|
82
|
+
export declare function signMandateRequest(payload: MandateRequestPayload, secretKey: Uint8Array): Promise<SignedMandateRequest>;
|
|
83
|
+
/** `${appBaseUrl}/app/requests/permission#req=<base64url(JSON)>`. The fragment never reaches a server. */
|
|
84
|
+
export declare function encodeMandateRequestLink(signed: SignedMandateRequest, appBaseUrl: string): string;
|
|
85
|
+
/**
|
|
86
|
+
* Read a request from a full link, a `#req=…` fragment, `req=…`, or the bare
|
|
87
|
+
* encoded value. Checks the shape only; call {@link verifyMandateRequest} next.
|
|
88
|
+
*/
|
|
89
|
+
export declare function decodeMandateRequestLink(urlOrFragment: string): SignedMandateRequest;
|
|
90
|
+
/** "5" or "5.25" in whole tokens to exact base units, without floating point. */
|
|
91
|
+
export declare function parseHumanTokenAmount(value: string, decimals: number): string;
|
|
92
|
+
/** Exact base units to whole tokens with trailing zeros dropped: "5000000", 6 → "5". */
|
|
93
|
+
export declare function formatHumanTokenAmount(baseUnits: string, decimals: number): string;
|
|
94
|
+
export type BuildMandateRequestInput = {
|
|
95
|
+
role: MandateRequestRole;
|
|
96
|
+
cluster?: "devnet" | "mainnet-beta";
|
|
97
|
+
requester: Address;
|
|
98
|
+
requesterName?: string;
|
|
99
|
+
agent?: Address;
|
|
100
|
+
mint: Address;
|
|
101
|
+
tokenProgram: TokenProgram;
|
|
102
|
+
recipient?: Address;
|
|
103
|
+
/** Base units. Defaults to the total for a budget request. */
|
|
104
|
+
maxPerPayment?: string;
|
|
105
|
+
/** Base units. */
|
|
106
|
+
total: string;
|
|
107
|
+
decimals: number;
|
|
108
|
+
currentSlot: bigint;
|
|
109
|
+
/** Suggested mandate lifetime in days, from the current slot. */
|
|
110
|
+
days: number;
|
|
111
|
+
/** How long the link stays acceptable. Defaults to 7 days. */
|
|
112
|
+
linkValidDays?: number;
|
|
113
|
+
description: string;
|
|
114
|
+
poNumber?: string;
|
|
115
|
+
nonce?: string;
|
|
116
|
+
};
|
|
117
|
+
/** Build and check an unsigned payload. Slots come from nominal 400 ms slots. */
|
|
118
|
+
export declare function buildMandateRequestPayload(input: BuildMandateRequestInput): MandateRequestPayload;
|
|
119
|
+
/**
|
|
120
|
+
* One plain sentence pair for the requester, e.g.
|
|
121
|
+
* "Asks for up to 5 USDC per payment, 50 USDC total, 30 days. Payee 9abc…wxyz. Link valid 7 days."
|
|
122
|
+
*/
|
|
123
|
+
export declare function mandateRequestSummary(payload: MandateRequestPayload, currentSlot: bigint): string;
|
|
@@ -0,0 +1,401 @@
|
|
|
1
|
+
import { PublicKey } from "@solana/web3.js";
|
|
2
|
+
import { address } from "./encoding.js";
|
|
3
|
+
import { assetLabel } from "./known-assets.js";
|
|
4
|
+
export const MANDATE_REQUEST_PATH = "/app/requests/permission";
|
|
5
|
+
export const MANDATE_REQUEST_FRAGMENT_KEY = "req";
|
|
6
|
+
export const MAX_REQUESTER_NAME_LENGTH = 64;
|
|
7
|
+
export const MAX_REQUEST_DESCRIPTION_LENGTH = 280;
|
|
8
|
+
export const MAX_PO_NUMBER_LENGTH = 64;
|
|
9
|
+
export const MAX_REQUEST_NONCE_LENGTH = 128;
|
|
10
|
+
/**
|
|
11
|
+
* Nominal slots per day at 400 ms a slot. Real slot times drift, so durations
|
|
12
|
+
* built from it are estimates and are worded as such.
|
|
13
|
+
*/
|
|
14
|
+
export const NOMINAL_SLOTS_PER_DAY = 216000n;
|
|
15
|
+
const MAX_U64 = 18446744073709551615n;
|
|
16
|
+
const CANONICAL_UINT = /^(0|[1-9]\d*)$/;
|
|
17
|
+
const BASE64URL = /^[A-Za-z0-9_-]*$/;
|
|
18
|
+
const CONTROL_CHARACTER = /[\u0000-\u001f\u007f]/;
|
|
19
|
+
const PAYLOAD_KEYS = [
|
|
20
|
+
"version",
|
|
21
|
+
"cluster",
|
|
22
|
+
"role",
|
|
23
|
+
"requester",
|
|
24
|
+
"requesterName",
|
|
25
|
+
"agent",
|
|
26
|
+
"mint",
|
|
27
|
+
"tokenProgram",
|
|
28
|
+
"recipient",
|
|
29
|
+
"suggestedMaxPerPayment",
|
|
30
|
+
"suggestedTotal",
|
|
31
|
+
"decimals",
|
|
32
|
+
"suggestedExpirySlot",
|
|
33
|
+
"validUntilSlot",
|
|
34
|
+
"description",
|
|
35
|
+
"poNumber",
|
|
36
|
+
"nonce",
|
|
37
|
+
];
|
|
38
|
+
const ENVELOPE_KEYS = ["payload", "signature"];
|
|
39
|
+
const ED25519_PKCS8_PREFIX = Uint8Array.from([
|
|
40
|
+
0x30, 0x2e, 0x02, 0x01, 0x00, 0x30, 0x05, 0x06, 0x03, 0x2b, 0x65, 0x70, 0x04, 0x22, 0x04, 0x20,
|
|
41
|
+
]);
|
|
42
|
+
function isPlainObject(value) {
|
|
43
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
44
|
+
}
|
|
45
|
+
function present(value) {
|
|
46
|
+
return value !== undefined;
|
|
47
|
+
}
|
|
48
|
+
/** Fixed key order. Optional keys appear only when present. */
|
|
49
|
+
function orderedPayload(payload) {
|
|
50
|
+
return {
|
|
51
|
+
version: payload.version,
|
|
52
|
+
cluster: payload.cluster,
|
|
53
|
+
role: payload.role,
|
|
54
|
+
requester: payload.requester,
|
|
55
|
+
...(present(payload.requesterName) ? { requesterName: payload.requesterName } : {}),
|
|
56
|
+
...(present(payload.agent) ? { agent: payload.agent } : {}),
|
|
57
|
+
mint: payload.mint,
|
|
58
|
+
tokenProgram: payload.tokenProgram,
|
|
59
|
+
...(present(payload.recipient) ? { recipient: payload.recipient } : {}),
|
|
60
|
+
suggestedMaxPerPayment: payload.suggestedMaxPerPayment,
|
|
61
|
+
suggestedTotal: payload.suggestedTotal,
|
|
62
|
+
decimals: payload.decimals,
|
|
63
|
+
...(present(payload.suggestedExpirySlot) ? { suggestedExpirySlot: payload.suggestedExpirySlot } : {}),
|
|
64
|
+
...(present(payload.validUntilSlot) ? { validUntilSlot: payload.validUntilSlot } : {}),
|
|
65
|
+
description: payload.description,
|
|
66
|
+
...(present(payload.poNumber) ? { poNumber: payload.poNumber } : {}),
|
|
67
|
+
nonce: payload.nonce,
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
export function canonicalMandateRequest(payload) {
|
|
71
|
+
return JSON.stringify(orderedPayload(payload));
|
|
72
|
+
}
|
|
73
|
+
function characterCount(value) {
|
|
74
|
+
return [...value].length;
|
|
75
|
+
}
|
|
76
|
+
function canonicalAddress(value, name) {
|
|
77
|
+
if (typeof value !== "string")
|
|
78
|
+
throw new Error(`${name} must be a Solana address`);
|
|
79
|
+
let parsed;
|
|
80
|
+
try {
|
|
81
|
+
parsed = address(value);
|
|
82
|
+
}
|
|
83
|
+
catch {
|
|
84
|
+
throw new Error(`${name} must be a Solana address`);
|
|
85
|
+
}
|
|
86
|
+
if (parsed !== value)
|
|
87
|
+
throw new Error(`${name} must be a canonical Solana address`);
|
|
88
|
+
return parsed;
|
|
89
|
+
}
|
|
90
|
+
function u64String(value, name, positive) {
|
|
91
|
+
if (typeof value !== "string" || !CANONICAL_UINT.test(value)) {
|
|
92
|
+
throw new Error(`${name} must be an unsigned integer string`);
|
|
93
|
+
}
|
|
94
|
+
const parsed = BigInt(value);
|
|
95
|
+
if (parsed > MAX_U64)
|
|
96
|
+
throw new Error(`${name} must fit in u64`);
|
|
97
|
+
if (positive && parsed === 0n)
|
|
98
|
+
throw new Error(`${name} must be greater than zero`);
|
|
99
|
+
return parsed;
|
|
100
|
+
}
|
|
101
|
+
function boundedText(value, name, max, required) {
|
|
102
|
+
if (value === undefined && !required)
|
|
103
|
+
return;
|
|
104
|
+
if (typeof value !== "string")
|
|
105
|
+
throw new Error(`${name} must be text`);
|
|
106
|
+
if (!value.trim())
|
|
107
|
+
throw new Error(`${name} must not be empty`);
|
|
108
|
+
if (value !== value.trim())
|
|
109
|
+
throw new Error(`${name} must not start or end with spaces`);
|
|
110
|
+
if (characterCount(value) > max)
|
|
111
|
+
throw new Error(`${name} must be at most ${max} characters`);
|
|
112
|
+
if (CONTROL_CHARACTER.test(value))
|
|
113
|
+
throw new Error(`${name} must not contain control characters`);
|
|
114
|
+
}
|
|
115
|
+
/**
|
|
116
|
+
* Check every field and every role rule, without the signature or the clock.
|
|
117
|
+
* Throws with a plain reason. Unknown fields are rejected so that nothing a
|
|
118
|
+
* reader sees can sit outside what the requester signed.
|
|
119
|
+
*/
|
|
120
|
+
export function parseMandateRequestPayload(value) {
|
|
121
|
+
if (!isPlainObject(value))
|
|
122
|
+
throw new Error("Mandate request payload must be an object");
|
|
123
|
+
const extra = Object.keys(value).filter((key) => !PAYLOAD_KEYS.includes(key));
|
|
124
|
+
if (extra.length > 0)
|
|
125
|
+
throw new Error(`Unknown mandate request field: ${extra[0]}`);
|
|
126
|
+
if (value.version !== 1)
|
|
127
|
+
throw new Error("Unsupported mandate request version");
|
|
128
|
+
if (value.cluster !== "devnet" && value.cluster !== "mainnet-beta")
|
|
129
|
+
throw new Error("Unsupported Solana cluster");
|
|
130
|
+
if (value.role !== "vendor" && value.role !== "grantee")
|
|
131
|
+
throw new Error("Role must be vendor or grantee");
|
|
132
|
+
if (value.tokenProgram !== "spl-token" && value.tokenProgram !== "token-2022") {
|
|
133
|
+
throw new Error("Unsupported token program");
|
|
134
|
+
}
|
|
135
|
+
canonicalAddress(value.requester, "requester");
|
|
136
|
+
canonicalAddress(value.mint, "mint");
|
|
137
|
+
if (value.role === "vendor") {
|
|
138
|
+
canonicalAddress(value.recipient, "recipient");
|
|
139
|
+
if (value.agent !== undefined)
|
|
140
|
+
throw new Error("A vendor request must not name an agent");
|
|
141
|
+
}
|
|
142
|
+
else {
|
|
143
|
+
canonicalAddress(value.agent, "agent");
|
|
144
|
+
if (value.recipient !== undefined)
|
|
145
|
+
throw new Error("A budget request must not name a recipient");
|
|
146
|
+
}
|
|
147
|
+
boundedText(value.requesterName, "requesterName", MAX_REQUESTER_NAME_LENGTH, false);
|
|
148
|
+
boundedText(value.description, "description", MAX_REQUEST_DESCRIPTION_LENGTH, true);
|
|
149
|
+
boundedText(value.poNumber, "poNumber", MAX_PO_NUMBER_LENGTH, false);
|
|
150
|
+
boundedText(value.nonce, "nonce", MAX_REQUEST_NONCE_LENGTH, true);
|
|
151
|
+
const perPayment = u64String(value.suggestedMaxPerPayment, "suggestedMaxPerPayment", true);
|
|
152
|
+
const total = u64String(value.suggestedTotal, "suggestedTotal", true);
|
|
153
|
+
if (total < perPayment)
|
|
154
|
+
throw new Error("suggestedTotal must be at least suggestedMaxPerPayment");
|
|
155
|
+
if (typeof value.decimals !== "number" || !Number.isInteger(value.decimals) || value.decimals < 0 || value.decimals > 255) {
|
|
156
|
+
throw new Error("decimals must be an integer between 0 and 255");
|
|
157
|
+
}
|
|
158
|
+
if (value.suggestedExpirySlot !== undefined)
|
|
159
|
+
u64String(value.suggestedExpirySlot, "suggestedExpirySlot", true);
|
|
160
|
+
if (value.validUntilSlot !== undefined)
|
|
161
|
+
u64String(value.validUntilSlot, "validUntilSlot", true);
|
|
162
|
+
return orderedPayload(value);
|
|
163
|
+
}
|
|
164
|
+
export function parseSignedMandateRequest(value) {
|
|
165
|
+
if (!isPlainObject(value))
|
|
166
|
+
throw new Error("Mandate request must be an object");
|
|
167
|
+
const extra = Object.keys(value).filter((key) => !ENVELOPE_KEYS.includes(key));
|
|
168
|
+
if (extra.length > 0)
|
|
169
|
+
throw new Error(`Unknown mandate request envelope field: ${extra[0]}`);
|
|
170
|
+
if (typeof value.signature !== "string")
|
|
171
|
+
throw new Error("Mandate request signature is required");
|
|
172
|
+
return { payload: parseMandateRequestPayload(value.payload), signature: value.signature };
|
|
173
|
+
}
|
|
174
|
+
function utf8(value) {
|
|
175
|
+
return new TextEncoder().encode(value);
|
|
176
|
+
}
|
|
177
|
+
export function bytesToBase64Url(bytes) {
|
|
178
|
+
let binary = "";
|
|
179
|
+
for (const byte of bytes)
|
|
180
|
+
binary += String.fromCharCode(byte);
|
|
181
|
+
return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
182
|
+
}
|
|
183
|
+
export function base64UrlToBytes(value) {
|
|
184
|
+
if (!BASE64URL.test(value) || value.length % 4 === 1)
|
|
185
|
+
throw new Error("Value must be base64url");
|
|
186
|
+
const padded = value.replace(/-/g, "+").replace(/_/g, "/").padEnd(Math.ceil(value.length / 4) * 4, "=");
|
|
187
|
+
const binary = atob(padded);
|
|
188
|
+
return Uint8Array.from(binary, (character) => character.charCodeAt(0));
|
|
189
|
+
}
|
|
190
|
+
function hex(bytes) {
|
|
191
|
+
return Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join("");
|
|
192
|
+
}
|
|
193
|
+
async function sha256Hex(bytes) {
|
|
194
|
+
const digest = await globalThis.crypto.subtle.digest("SHA-256", bytes.slice().buffer);
|
|
195
|
+
return hex(new Uint8Array(digest));
|
|
196
|
+
}
|
|
197
|
+
function invalid(payload, reason, requestHash = "") {
|
|
198
|
+
return { valid: false, payload, requestHash, reason };
|
|
199
|
+
}
|
|
200
|
+
/**
|
|
201
|
+
* Verify a signed request. With `currentSlot`, an expired link or a suggested
|
|
202
|
+
* expiry already in the past is refused.
|
|
203
|
+
*/
|
|
204
|
+
export async function verifyMandateRequest(request, currentSlot) {
|
|
205
|
+
const raw = isPlainObject(request) ? request : {};
|
|
206
|
+
const rawPayload = (isPlainObject(raw.payload) ? raw.payload : {});
|
|
207
|
+
let signed;
|
|
208
|
+
try {
|
|
209
|
+
signed = parseSignedMandateRequest(request);
|
|
210
|
+
}
|
|
211
|
+
catch (error) {
|
|
212
|
+
return invalid(rawPayload, error instanceof Error ? error.message : String(error));
|
|
213
|
+
}
|
|
214
|
+
const payload = signed.payload;
|
|
215
|
+
const message = utf8(canonicalMandateRequest(payload));
|
|
216
|
+
const requestHash = await sha256Hex(message);
|
|
217
|
+
try {
|
|
218
|
+
if (currentSlot !== undefined) {
|
|
219
|
+
if (payload.validUntilSlot !== undefined && BigInt(payload.validUntilSlot) <= currentSlot) {
|
|
220
|
+
return invalid(payload, "This request link has expired", requestHash);
|
|
221
|
+
}
|
|
222
|
+
if (payload.suggestedExpirySlot !== undefined && BigInt(payload.suggestedExpirySlot) <= currentSlot) {
|
|
223
|
+
return invalid(payload, "The suggested expiry has already passed", requestHash);
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
let signature;
|
|
227
|
+
try {
|
|
228
|
+
signature = base64UrlToBytes(signed.signature);
|
|
229
|
+
}
|
|
230
|
+
catch {
|
|
231
|
+
return invalid(payload, "Signature must be base64url", requestHash);
|
|
232
|
+
}
|
|
233
|
+
if (signature.length !== 64)
|
|
234
|
+
return invalid(payload, "Ed25519 signature must be 64 bytes", requestHash);
|
|
235
|
+
const key = await globalThis.crypto.subtle.importKey("raw", new PublicKey(payload.requester).toBytes().slice().buffer, { name: "Ed25519" }, false, ["verify"]);
|
|
236
|
+
const ok = await globalThis.crypto.subtle.verify("Ed25519", key, signature.slice().buffer, message.slice().buffer);
|
|
237
|
+
if (!ok)
|
|
238
|
+
return invalid(payload, "Mandate request signature is invalid", requestHash);
|
|
239
|
+
return { valid: true, payload, requestHash };
|
|
240
|
+
}
|
|
241
|
+
catch (error) {
|
|
242
|
+
return invalid(payload, error instanceof Error ? error.message : String(error), requestHash);
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
function ed25519Seed(secretKey) {
|
|
246
|
+
if (secretKey.length === 32)
|
|
247
|
+
return new Uint8Array(secretKey);
|
|
248
|
+
if (secretKey.length === 64)
|
|
249
|
+
return new Uint8Array(secretKey.subarray(0, 32));
|
|
250
|
+
throw new Error("Ed25519 secret key must be 32 or 64 bytes");
|
|
251
|
+
}
|
|
252
|
+
/**
|
|
253
|
+
* Sign a request with the requester key. For the CLI and the demo merchant;
|
|
254
|
+
* the key never leaves the caller. Refuses a key that is not the requester.
|
|
255
|
+
*/
|
|
256
|
+
export async function signMandateRequest(payload, secretKey) {
|
|
257
|
+
const parsed = parseMandateRequestPayload(payload);
|
|
258
|
+
const seed = ed25519Seed(secretKey);
|
|
259
|
+
const pkcs8 = new Uint8Array(ED25519_PKCS8_PREFIX.length + 32);
|
|
260
|
+
pkcs8.set(ED25519_PKCS8_PREFIX);
|
|
261
|
+
pkcs8.set(seed, ED25519_PKCS8_PREFIX.length);
|
|
262
|
+
const key = await globalThis.crypto.subtle.importKey("pkcs8", pkcs8.buffer, { name: "Ed25519" }, true, ["sign"]);
|
|
263
|
+
const jwk = await globalThis.crypto.subtle.exportKey("jwk", key);
|
|
264
|
+
const signer = new PublicKey(base64UrlToBytes(jwk.x ?? "")).toBase58();
|
|
265
|
+
if (signer !== parsed.requester)
|
|
266
|
+
throw new Error("Signing key does not match the requester");
|
|
267
|
+
const message = utf8(canonicalMandateRequest(parsed));
|
|
268
|
+
const signature = new Uint8Array(await globalThis.crypto.subtle.sign("Ed25519", key, message.slice().buffer));
|
|
269
|
+
return { payload: parsed, signature: bytesToBase64Url(signature) };
|
|
270
|
+
}
|
|
271
|
+
/** `${appBaseUrl}/app/requests/permission#req=<base64url(JSON)>`. The fragment never reaches a server. */
|
|
272
|
+
export function encodeMandateRequestLink(signed, appBaseUrl) {
|
|
273
|
+
const origin = appBaseUrl.trim().replace(/\/+$/, "");
|
|
274
|
+
if (!origin)
|
|
275
|
+
throw new Error("App URL is required");
|
|
276
|
+
const body = JSON.stringify({ payload: orderedPayload(signed.payload), signature: signed.signature });
|
|
277
|
+
return `${origin}${MANDATE_REQUEST_PATH}#${MANDATE_REQUEST_FRAGMENT_KEY}=${bytesToBase64Url(utf8(body))}`;
|
|
278
|
+
}
|
|
279
|
+
/**
|
|
280
|
+
* Read a request from a full link, a `#req=…` fragment, `req=…`, or the bare
|
|
281
|
+
* encoded value. Checks the shape only; call {@link verifyMandateRequest} next.
|
|
282
|
+
*/
|
|
283
|
+
export function decodeMandateRequestLink(urlOrFragment) {
|
|
284
|
+
const value = urlOrFragment.trim();
|
|
285
|
+
const hashIndex = value.indexOf("#");
|
|
286
|
+
const fragment = hashIndex >= 0 ? value.slice(hashIndex + 1) : value;
|
|
287
|
+
let encoded = fragment;
|
|
288
|
+
if (fragment.includes("=")) {
|
|
289
|
+
const params = new URLSearchParams(fragment);
|
|
290
|
+
encoded = params.get(MANDATE_REQUEST_FRAGMENT_KEY) ?? "";
|
|
291
|
+
}
|
|
292
|
+
if (!encoded)
|
|
293
|
+
throw new Error("No mandate request in this link");
|
|
294
|
+
let parsed;
|
|
295
|
+
try {
|
|
296
|
+
parsed = JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(base64UrlToBytes(encoded)));
|
|
297
|
+
}
|
|
298
|
+
catch {
|
|
299
|
+
throw new Error("This mandate request link is damaged");
|
|
300
|
+
}
|
|
301
|
+
return parseSignedMandateRequest(parsed);
|
|
302
|
+
}
|
|
303
|
+
/** "5" or "5.25" in whole tokens to exact base units, without floating point. */
|
|
304
|
+
export function parseHumanTokenAmount(value, decimals) {
|
|
305
|
+
const normalized = value.trim();
|
|
306
|
+
if (!Number.isInteger(decimals) || decimals < 0 || decimals > 255) {
|
|
307
|
+
throw new Error("decimals must be an integer between 0 and 255");
|
|
308
|
+
}
|
|
309
|
+
if (!/^\d+(\.\d+)?$/.test(normalized))
|
|
310
|
+
throw new Error(`Not a token amount: ${value}`);
|
|
311
|
+
const [whole, fraction = ""] = normalized.split(".");
|
|
312
|
+
if (fraction.length > decimals)
|
|
313
|
+
throw new Error(`This mint allows ${decimals} decimal places`);
|
|
314
|
+
const units = BigInt(whole) * 10n ** BigInt(decimals) + BigInt(fraction.padEnd(decimals, "0") || "0");
|
|
315
|
+
if (units > MAX_U64)
|
|
316
|
+
throw new Error("Amount is too large for a token account");
|
|
317
|
+
return units.toString();
|
|
318
|
+
}
|
|
319
|
+
/** Exact base units to whole tokens with trailing zeros dropped: "5000000", 6 → "5". */
|
|
320
|
+
export function formatHumanTokenAmount(baseUnits, decimals) {
|
|
321
|
+
const amount = BigInt(baseUnits);
|
|
322
|
+
if (decimals === 0)
|
|
323
|
+
return amount.toString();
|
|
324
|
+
const scale = 10n ** BigInt(decimals);
|
|
325
|
+
const whole = amount / scale;
|
|
326
|
+
const fraction = amount % scale;
|
|
327
|
+
if (fraction === 0n)
|
|
328
|
+
return whole.toString();
|
|
329
|
+
return `${whole}.${fraction.toString().padStart(decimals, "0").replace(/0+$/, "")}`;
|
|
330
|
+
}
|
|
331
|
+
function randomNonce() {
|
|
332
|
+
const bytes = new Uint8Array(16);
|
|
333
|
+
globalThis.crypto.getRandomValues(bytes);
|
|
334
|
+
return bytesToBase64Url(bytes);
|
|
335
|
+
}
|
|
336
|
+
function wholeDays(value, name) {
|
|
337
|
+
if (!Number.isInteger(value) || value < 1 || value > 3650)
|
|
338
|
+
throw new Error(`${name} must be a whole number of days from 1 to 3650`);
|
|
339
|
+
return BigInt(value);
|
|
340
|
+
}
|
|
341
|
+
/** Build and check an unsigned payload. Slots come from nominal 400 ms slots. */
|
|
342
|
+
export function buildMandateRequestPayload(input) {
|
|
343
|
+
const days = wholeDays(input.days, "days");
|
|
344
|
+
const linkDays = wholeDays(input.linkValidDays ?? 7, "link validity");
|
|
345
|
+
const total = input.total;
|
|
346
|
+
return parseMandateRequestPayload({
|
|
347
|
+
version: 1,
|
|
348
|
+
cluster: input.cluster ?? "devnet",
|
|
349
|
+
role: input.role,
|
|
350
|
+
requester: input.requester,
|
|
351
|
+
...(input.requesterName ? { requesterName: input.requesterName.trim() } : {}),
|
|
352
|
+
...(input.role === "grantee" ? { agent: input.agent } : {}),
|
|
353
|
+
mint: input.mint,
|
|
354
|
+
tokenProgram: input.tokenProgram,
|
|
355
|
+
...(input.role === "vendor" ? { recipient: input.recipient } : {}),
|
|
356
|
+
suggestedMaxPerPayment: input.maxPerPayment ?? total,
|
|
357
|
+
suggestedTotal: total,
|
|
358
|
+
decimals: input.decimals,
|
|
359
|
+
suggestedExpirySlot: (input.currentSlot + days * NOMINAL_SLOTS_PER_DAY).toString(),
|
|
360
|
+
validUntilSlot: (input.currentSlot + linkDays * NOMINAL_SLOTS_PER_DAY).toString(),
|
|
361
|
+
description: input.description.trim(),
|
|
362
|
+
...(input.poNumber ? { poNumber: input.poNumber.trim() } : {}),
|
|
363
|
+
nonce: input.nonce ?? randomNonce(),
|
|
364
|
+
});
|
|
365
|
+
}
|
|
366
|
+
function shortKey(value) {
|
|
367
|
+
return value.length < 12 ? value : `${value.slice(0, 4)}…${value.slice(-4)}`;
|
|
368
|
+
}
|
|
369
|
+
function daysFrom(slot, currentSlot) {
|
|
370
|
+
if (slot === undefined)
|
|
371
|
+
return undefined;
|
|
372
|
+
const remaining = BigInt(slot) - currentSlot;
|
|
373
|
+
if (remaining <= 0n)
|
|
374
|
+
return "0 days";
|
|
375
|
+
const days = (remaining + NOMINAL_SLOTS_PER_DAY / 2n) / NOMINAL_SLOTS_PER_DAY;
|
|
376
|
+
return days === 1n ? "1 day" : `${days} days`;
|
|
377
|
+
}
|
|
378
|
+
/**
|
|
379
|
+
* One plain sentence pair for the requester, e.g.
|
|
380
|
+
* "Asks for up to 5 USDC per payment, 50 USDC total, 30 days. Payee 9abc…wxyz. Link valid 7 days."
|
|
381
|
+
*/
|
|
382
|
+
export function mandateRequestSummary(payload, currentSlot) {
|
|
383
|
+
const symbol = assetLabel(payload.mint);
|
|
384
|
+
const amount = (value) => `${formatHumanTokenAmount(value, payload.decimals)} ${symbol}`;
|
|
385
|
+
const terms = [];
|
|
386
|
+
if (payload.role === "vendor" || payload.suggestedMaxPerPayment !== payload.suggestedTotal) {
|
|
387
|
+
terms.push(`up to ${amount(payload.suggestedMaxPerPayment)} per payment`);
|
|
388
|
+
terms.push(`${amount(payload.suggestedTotal)} total`);
|
|
389
|
+
}
|
|
390
|
+
else {
|
|
391
|
+
terms.push(`up to ${amount(payload.suggestedTotal)} total`);
|
|
392
|
+
}
|
|
393
|
+
const lifetime = daysFrom(payload.suggestedExpirySlot, currentSlot);
|
|
394
|
+
if (lifetime)
|
|
395
|
+
terms.push(lifetime);
|
|
396
|
+
const who = payload.role === "vendor"
|
|
397
|
+
? `Payee ${shortKey(payload.recipient ?? "")}.`
|
|
398
|
+
: `Agent ${shortKey(payload.agent ?? "")} signs the payments.`;
|
|
399
|
+
const link = daysFrom(payload.validUntilSlot, currentSlot);
|
|
400
|
+
return [`Asks for ${terms.join(", ")}.`, who, ...(link ? [`Link valid ${link}.`] : [])].join(" ");
|
|
401
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import type { Address, ChainPayInstruction, PreparedMandate, TokenProgram } from "./types.js";
|
|
2
|
+
export type CreateMandateInput = {
|
|
3
|
+
approvedAgent: Address;
|
|
4
|
+
sourceTokenAccount: Address;
|
|
5
|
+
allowedMint: Address;
|
|
6
|
+
maxPerPayment: bigint;
|
|
7
|
+
totalLimit: bigint;
|
|
8
|
+
expiresAtSlot: bigint;
|
|
9
|
+
maxPaymentCount: bigint;
|
|
10
|
+
cooldownSlots: bigint;
|
|
11
|
+
tokenProgram: TokenProgram;
|
|
12
|
+
delegateAmount?: bigint;
|
|
13
|
+
mandateNonce?: Address;
|
|
14
|
+
};
|
|
15
|
+
export type UpdateMandateInput = {
|
|
16
|
+
approvedAgent: Address;
|
|
17
|
+
maxPerPayment: bigint;
|
|
18
|
+
totalLimit: bigint;
|
|
19
|
+
expiresAtSlot: bigint;
|
|
20
|
+
maxPaymentCount: bigint;
|
|
21
|
+
cooldownSlots: bigint;
|
|
22
|
+
paused: boolean;
|
|
23
|
+
};
|
|
24
|
+
export type RegisterAssetInput = {
|
|
25
|
+
mint: Address;
|
|
26
|
+
tokenProgram: TokenProgram;
|
|
27
|
+
};
|
|
28
|
+
export declare function createMandateNonce(): Address;
|
|
29
|
+
export declare function buildInitializeConfigInstruction(supportedMints: readonly Address[], authority: Address, programId?: Address): ChainPayInstruction;
|
|
30
|
+
export declare function validateMandateInput(input: CreateMandateInput): void;
|
|
31
|
+
export declare function buildCreateMandateInstruction(input: CreateMandateInput, owner: Address, programId?: Address): ChainPayInstruction;
|
|
32
|
+
export declare function buildRegisterAssetInstruction(input: RegisterAssetInput, authority: Address, programId?: Address): ChainPayInstruction;
|
|
33
|
+
export declare function buildSetAssetStatusInstruction(mint: Address, authority: Address, enabled: boolean, programId?: Address): ChainPayInstruction;
|
|
34
|
+
export declare function buildApproveDelegateInstruction(input: Pick<CreateMandateInput, "sourceTokenAccount" | "allowedMint" | "tokenProgram" | "totalLimit"> & {
|
|
35
|
+
owner: Address;
|
|
36
|
+
mandate?: Address;
|
|
37
|
+
delegateAmount?: bigint;
|
|
38
|
+
decimals: number;
|
|
39
|
+
}): ChainPayInstruction;
|
|
40
|
+
export declare function buildRevokeDelegateInstruction(sourceTokenAccount: Address, owner: Address, tokenProgram: TokenProgram): ChainPayInstruction;
|
|
41
|
+
export declare function buildCreateMandateTransaction(input: CreateMandateInput, owner: Address, programId?: Address, decimals?: number): PreparedMandate;
|
|
42
|
+
export declare function buildUpdateMandateInstruction(input: UpdateMandateInput, owner: Address, programId?: Address, mandateAddress?: Address): ChainPayInstruction;
|
|
43
|
+
export declare function buildPauseMandateInstruction(owner: Address, programId?: Address, mandateAddress?: Address): ChainPayInstruction;
|
|
44
|
+
export declare function buildRevokeMandateInstruction(owner: Address, programId?: Address, mandateAddress?: Address): ChainPayInstruction;
|