@primitivedotdev/sdk 1.3.0 → 1.5.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/README.md +44 -0
- package/dist/api/index.d.ts +4 -4
- package/dist/api/index.js +3 -3
- package/dist/{api-DacG4JSU.js → api-DrrmrNDe.js} +257 -22
- package/dist/contract/index.d.ts +2 -2
- package/dist/contract/index.js +1 -1
- package/dist/{errors-7E9sW9eX.d.ts → errors-DyuAXctD.d.ts} +1 -1
- package/dist/{index-X7RsDaVi.d.ts → index--2RzTxVl.d.ts} +361 -7
- package/dist/{index-DR978rq5.d.ts → index-iZWfb98V.d.ts} +275 -4
- package/dist/index.d.ts +6 -184
- package/dist/index.js +4 -194
- package/dist/openapi/index.js +1 -1
- package/dist/{operations.generated-DSMTMcWh.js → operations.generated-CA3-eorF.js} +453 -3
- package/dist/parser/index.d.ts +1 -1
- package/dist/{types-yNU-Oiea.d.ts → types-QT2ss9ho.d.ts} +207 -3
- package/dist/webhook/index.d.ts +4 -4
- package/dist/webhook/index.js +1 -1
- package/dist/{webhook-BAwK8EOG.js → webhook-CwjCyFv-.js} +2559 -224
- package/dist/x402/index.d.ts +263 -0
- package/dist/x402/index.js +320 -0
- package/package.json +6 -1
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
import { Address, Hex } from "viem";
|
|
2
|
+
|
|
3
|
+
//#region src/x402/sign.d.ts
|
|
4
|
+
interface NonceBinding {
|
|
5
|
+
/** The interaction id, including its `@domain`. Lowercased before hashing. */
|
|
6
|
+
interactionId: string;
|
|
7
|
+
/** The challenge step id (a UUID). Lowercased before hashing. */
|
|
8
|
+
challengeStepId: string;
|
|
9
|
+
/** The challenger's per-challenge random nonce: 64 lowercase hex chars. */
|
|
10
|
+
challengeNonce: string;
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* Derive the EIP-3009 nonce bound to a specific interaction step:
|
|
14
|
+
*
|
|
15
|
+
* keccak256( utf8(lower(interaction_id)) || 0x00
|
|
16
|
+
* || utf8(lower(challenge_step_id)) || 0x00
|
|
17
|
+
* || hexdecode(challenge_nonce) )
|
|
18
|
+
*
|
|
19
|
+
* The `0x00` separators pin the field boundaries (undelimited concatenation of
|
|
20
|
+
* variable-length strings is collision-ambiguous), and the challenge nonce is
|
|
21
|
+
* decoded to its 32 raw bytes before hashing. The platform recomputes this and
|
|
22
|
+
* rejects a mismatch.
|
|
23
|
+
*/
|
|
24
|
+
declare function deriveEip3009Nonce(input: NonceBinding): Hex;
|
|
25
|
+
/**
|
|
26
|
+
* The EIP-3009 `TransferWithAuthorization` EIP-712 type. The field order and
|
|
27
|
+
* types are part of the on-chain contract and MUST NOT change.
|
|
28
|
+
*/
|
|
29
|
+
declare const TRANSFER_WITH_AUTHORIZATION_TYPES: {
|
|
30
|
+
readonly TransferWithAuthorization: readonly [{
|
|
31
|
+
readonly name: "from";
|
|
32
|
+
readonly type: "address";
|
|
33
|
+
}, {
|
|
34
|
+
readonly name: "to";
|
|
35
|
+
readonly type: "address";
|
|
36
|
+
}, {
|
|
37
|
+
readonly name: "value";
|
|
38
|
+
readonly type: "uint256";
|
|
39
|
+
}, {
|
|
40
|
+
readonly name: "validAfter";
|
|
41
|
+
readonly type: "uint256";
|
|
42
|
+
}, {
|
|
43
|
+
readonly name: "validBefore";
|
|
44
|
+
readonly type: "uint256";
|
|
45
|
+
}, {
|
|
46
|
+
readonly name: "nonce";
|
|
47
|
+
readonly type: "bytes32";
|
|
48
|
+
}];
|
|
49
|
+
};
|
|
50
|
+
/**
|
|
51
|
+
* The token's EIP-712 domain. `name`/`version` MUST be the actual token's domain
|
|
52
|
+
* params (Base mainnet USDC reports `name: "USD Coin"`, Base Sepolia `"USDC"`;
|
|
53
|
+
* both `version: "2"`); they come from the challenge's payment requirements
|
|
54
|
+
* `extra`. A wrong name/version produces a signature the verifier rejects.
|
|
55
|
+
*/
|
|
56
|
+
interface TokenDomain {
|
|
57
|
+
name: string;
|
|
58
|
+
version: string;
|
|
59
|
+
chainId: number;
|
|
60
|
+
verifyingContract: Address;
|
|
61
|
+
}
|
|
62
|
+
interface TransferAuthorization {
|
|
63
|
+
from: Address;
|
|
64
|
+
to: Address;
|
|
65
|
+
/** Token base units (USDC has 6 decimals), as a bigint. */
|
|
66
|
+
value: bigint;
|
|
67
|
+
validAfter: bigint;
|
|
68
|
+
validBefore: bigint;
|
|
69
|
+
nonce: Hex;
|
|
70
|
+
}
|
|
71
|
+
interface TransferWithAuthorizationTypedData {
|
|
72
|
+
domain: {
|
|
73
|
+
name: string;
|
|
74
|
+
version: string;
|
|
75
|
+
chainId: number;
|
|
76
|
+
verifyingContract: Address;
|
|
77
|
+
};
|
|
78
|
+
types: typeof TRANSFER_WITH_AUTHORIZATION_TYPES;
|
|
79
|
+
primaryType: "TransferWithAuthorization";
|
|
80
|
+
message: TransferAuthorization;
|
|
81
|
+
}
|
|
82
|
+
declare function transferWithAuthorizationTypedData(domain: TokenDomain, auth: TransferAuthorization): TransferWithAuthorizationTypedData;
|
|
83
|
+
/**
|
|
84
|
+
* A customer-held signer. A viem `LocalAccount` satisfies this directly; any
|
|
85
|
+
* key source (hardware wallet, injected provider) can be adapted. The key never
|
|
86
|
+
* leaves the caller.
|
|
87
|
+
*/
|
|
88
|
+
interface X402Signer {
|
|
89
|
+
address: Address;
|
|
90
|
+
signTypedData(typedData: TransferWithAuthorizationTypedData): Promise<Hex>;
|
|
91
|
+
/**
|
|
92
|
+
* `personal_sign` over a UTF-8 string. Only needed for
|
|
93
|
+
* `registerPayoutAddress()` (the ownership proof); a viem `LocalAccount`
|
|
94
|
+
* provides it directly.
|
|
95
|
+
*/
|
|
96
|
+
signMessage?(args: {
|
|
97
|
+
message: string;
|
|
98
|
+
}): Promise<Hex>;
|
|
99
|
+
}
|
|
100
|
+
interface PayoutRegistrationMessageInput {
|
|
101
|
+
/** The org id the address is being authorized for. Bound into the signature. */
|
|
102
|
+
org: string;
|
|
103
|
+
/** The payout address (the signer's own address). Lowercased in the message. */
|
|
104
|
+
address: string;
|
|
105
|
+
network: string;
|
|
106
|
+
/** ISO-8601 timestamp; the server enforces a freshness window against replay. */
|
|
107
|
+
issuedAt: string;
|
|
108
|
+
}
|
|
109
|
+
/**
|
|
110
|
+
* Build the payout-address ownership message. This MUST be byte-identical to the
|
|
111
|
+
* platform's `buildPayoutRegistrationMessage`, or registration fails the
|
|
112
|
+
* ownership proof. The org id is in the signed bytes, so a captured signature
|
|
113
|
+
* can never register the address under a different org.
|
|
114
|
+
*/
|
|
115
|
+
declare function buildPayoutRegistrationMessage(input: PayoutRegistrationMessageInput): string;
|
|
116
|
+
/** The x402 wire payload (validated server-side against the x402 schema). */
|
|
117
|
+
interface X402PaymentPayload {
|
|
118
|
+
x402Version: 1;
|
|
119
|
+
scheme: "exact";
|
|
120
|
+
network: string;
|
|
121
|
+
payload: {
|
|
122
|
+
signature: Hex;
|
|
123
|
+
authorization: {
|
|
124
|
+
from: Address;
|
|
125
|
+
to: Address;
|
|
126
|
+
value: string;
|
|
127
|
+
validAfter: string;
|
|
128
|
+
validBefore: string;
|
|
129
|
+
nonce: Hex;
|
|
130
|
+
};
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
/** Assemble the wire payload from a signed authorization. */
|
|
134
|
+
declare function toPaymentPayload(network: string, auth: TransferAuthorization, signature: Hex): X402PaymentPayload;
|
|
135
|
+
//#endregion
|
|
136
|
+
//#region src/x402/client.d.ts
|
|
137
|
+
interface X402PaymentRequirements {
|
|
138
|
+
scheme: string;
|
|
139
|
+
network: string;
|
|
140
|
+
maxAmountRequired: string;
|
|
141
|
+
payTo: string;
|
|
142
|
+
asset: string;
|
|
143
|
+
extra: {
|
|
144
|
+
name: string;
|
|
145
|
+
version: string;
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
/** A request for payment, as returned by `charge()` / the platform. */
|
|
149
|
+
interface X402Challenge {
|
|
150
|
+
id: string;
|
|
151
|
+
network: string;
|
|
152
|
+
amount: string;
|
|
153
|
+
pay_to: string;
|
|
154
|
+
nonce_binding: {
|
|
155
|
+
interaction_id: string;
|
|
156
|
+
challenge_step_id: string;
|
|
157
|
+
challenge_nonce: string;
|
|
158
|
+
};
|
|
159
|
+
payment_requirements: X402PaymentRequirements;
|
|
160
|
+
expires_at: string;
|
|
161
|
+
}
|
|
162
|
+
interface X402Receipt {
|
|
163
|
+
id: string;
|
|
164
|
+
status: string;
|
|
165
|
+
settle_tx: string | null;
|
|
166
|
+
}
|
|
167
|
+
/** A registered payout address (read shape; mirrors the platform response). */
|
|
168
|
+
interface X402PayoutAddress {
|
|
169
|
+
id: string;
|
|
170
|
+
address: string;
|
|
171
|
+
network: string;
|
|
172
|
+
label: string | null;
|
|
173
|
+
is_default: boolean;
|
|
174
|
+
verified_at: string | null;
|
|
175
|
+
}
|
|
176
|
+
/** The org's spend policy (read shape; also accepted by `setSpendPolicy`). */
|
|
177
|
+
interface X402SpendPolicy {
|
|
178
|
+
/** Kill-switch: when true, all outbound payments are refused. */
|
|
179
|
+
paused: boolean;
|
|
180
|
+
/** Per-payment cap in token base units, or null for no cap. */
|
|
181
|
+
max_per_payment: string | null;
|
|
182
|
+
/** Daily cap in token base units, or null for no cap. */
|
|
183
|
+
max_per_day: string | null;
|
|
184
|
+
/** Allowed payee org ids; null = any on-net payee, [] = deny all. */
|
|
185
|
+
allowlist: string[] | null;
|
|
186
|
+
}
|
|
187
|
+
interface X402ChargeInput {
|
|
188
|
+
/** Amount in token base units (USDC has 6 decimals, so "10000" = 0.01). */
|
|
189
|
+
amount: string;
|
|
190
|
+
/** Defaults to "base-sepolia". */
|
|
191
|
+
network?: string;
|
|
192
|
+
/** The org id allowed to pay this challenge (on-net binding). */
|
|
193
|
+
payerOrg?: string;
|
|
194
|
+
description?: string;
|
|
195
|
+
/** A URL identifying the thing being paid for. */
|
|
196
|
+
resource?: string;
|
|
197
|
+
/** Seconds until the challenge expires (default 1h). */
|
|
198
|
+
expiresIn?: number;
|
|
199
|
+
}
|
|
200
|
+
declare class X402Error extends Error {
|
|
201
|
+
/** HTTP status, or 0 for a client-side / transport error that never reached the server. */
|
|
202
|
+
readonly status: number;
|
|
203
|
+
readonly body: unknown;
|
|
204
|
+
/** The `Retry-After` response header, if the server sent one. */
|
|
205
|
+
readonly retryAfter: string | null;
|
|
206
|
+
constructor(message: string, status: number, body?: unknown, options?: {
|
|
207
|
+
cause?: unknown;
|
|
208
|
+
retryAfter?: string | null;
|
|
209
|
+
});
|
|
210
|
+
}
|
|
211
|
+
interface X402ClientOptions {
|
|
212
|
+
/** API key. Defaults to `process.env.PRIMITIVE_API_KEY`. */
|
|
213
|
+
apiKey?: string;
|
|
214
|
+
/** API base URL. Defaults to the production host. */
|
|
215
|
+
baseUrl?: string;
|
|
216
|
+
/** Override the fetch implementation (e.g. for testing). */
|
|
217
|
+
fetch?: typeof fetch;
|
|
218
|
+
/** Per-request timeout in milliseconds. Defaults to 30000. */
|
|
219
|
+
timeoutMs?: number;
|
|
220
|
+
}
|
|
221
|
+
declare class X402Client {
|
|
222
|
+
#private;
|
|
223
|
+
constructor(options?: X402ClientOptions);
|
|
224
|
+
/** Request a payment (payee side). Returns the challenge to hand to the payer. */
|
|
225
|
+
charge(input: X402ChargeInput): Promise<X402Challenge>;
|
|
226
|
+
/**
|
|
227
|
+
* Pay a challenge (payer side). Derives the interaction-bound authorization,
|
|
228
|
+
* signs it locally with the caller's key, and submits it for settlement.
|
|
229
|
+
*/
|
|
230
|
+
pay(challenge: X402Challenge, options: {
|
|
231
|
+
signer: X402Signer;
|
|
232
|
+
}): Promise<X402Receipt>;
|
|
233
|
+
/** Fetch a challenge by id (scoped to the challenger org that created it). */
|
|
234
|
+
getChallenge(id: string): Promise<X402Challenge>;
|
|
235
|
+
/**
|
|
236
|
+
* Register a payout address for your org (payee side). The signer proves
|
|
237
|
+
* control of its own address with an org-bound `personal_sign`; the proven
|
|
238
|
+
* address becomes (or updates to) the default payout destination for the
|
|
239
|
+
* network. `charge()` resolves its `pay_to` from this directory, so a payee
|
|
240
|
+
* must register before requesting payments.
|
|
241
|
+
*/
|
|
242
|
+
registerPayoutAddress(input: {
|
|
243
|
+
org: string;
|
|
244
|
+
network?: string;
|
|
245
|
+
issuedAt?: string;
|
|
246
|
+
}, options: {
|
|
247
|
+
signer: X402Signer;
|
|
248
|
+
}): Promise<X402PayoutAddress>;
|
|
249
|
+
/** List your org's registered payout addresses. */
|
|
250
|
+
listPayoutAddresses(): Promise<X402PayoutAddress[]>;
|
|
251
|
+
/** Read your org's spend policy (kill-switch + caps + allowlist). */
|
|
252
|
+
getSpendPolicy(): Promise<X402SpendPolicy>;
|
|
253
|
+
/**
|
|
254
|
+
* Update your org's spend policy. The endpoint is a PUT, but the server
|
|
255
|
+
* applies it as a merge: only the fields you include are changed and omitted
|
|
256
|
+
* fields keep their current value, so a partial update can't silently reset
|
|
257
|
+
* the kill-switch. Pass `null` to clear a cap.
|
|
258
|
+
*/
|
|
259
|
+
setSpendPolicy(update: Partial<X402SpendPolicy>): Promise<X402SpendPolicy>;
|
|
260
|
+
}
|
|
261
|
+
declare function createX402Client(options?: X402ClientOptions): X402Client;
|
|
262
|
+
//#endregion
|
|
263
|
+
export { NonceBinding, PayoutRegistrationMessageInput, TRANSFER_WITH_AUTHORIZATION_TYPES, TokenDomain, TransferAuthorization, TransferWithAuthorizationTypedData, X402Challenge, X402ChargeInput, X402Client, X402ClientOptions, X402Error, X402PaymentPayload, X402PaymentRequirements, X402PayoutAddress, X402Receipt, X402Signer, X402SpendPolicy, buildPayoutRegistrationMessage, createX402Client, deriveEip3009Nonce, toPaymentPayload, transferWithAuthorizationTypedData };
|
|
@@ -0,0 +1,320 @@
|
|
|
1
|
+
import { concat, hexToBytes, keccak256, stringToBytes } from "viem";
|
|
2
|
+
//#region src/x402/sign.ts
|
|
3
|
+
/**
|
|
4
|
+
* x402 client-side signing.
|
|
5
|
+
*
|
|
6
|
+
* The payer signs an EIP-3009 `transferWithAuthorization` over the customer's
|
|
7
|
+
* own key; the key never leaves them. This module derives the interaction-bound
|
|
8
|
+
* nonce and assembles the EIP-712 typed data and the wire payload. The byte
|
|
9
|
+
* layout here MUST match the platform verifier exactly; a normative test vector
|
|
10
|
+
* (see sign.test.ts) locks the nonce derivation to the same value the server
|
|
11
|
+
* recomputes.
|
|
12
|
+
*/
|
|
13
|
+
/** A challenge nonce is 32 bytes rendered as 64 lowercase hex chars, no 0x. */
|
|
14
|
+
const CHALLENGE_NONCE_RE = /^[0-9a-f]{64}$/;
|
|
15
|
+
/** Single-byte domain separator between the variable-length string fields. */
|
|
16
|
+
const FIELD_SEPARATOR = new Uint8Array([0]);
|
|
17
|
+
/**
|
|
18
|
+
* Derive the EIP-3009 nonce bound to a specific interaction step:
|
|
19
|
+
*
|
|
20
|
+
* keccak256( utf8(lower(interaction_id)) || 0x00
|
|
21
|
+
* || utf8(lower(challenge_step_id)) || 0x00
|
|
22
|
+
* || hexdecode(challenge_nonce) )
|
|
23
|
+
*
|
|
24
|
+
* The `0x00` separators pin the field boundaries (undelimited concatenation of
|
|
25
|
+
* variable-length strings is collision-ambiguous), and the challenge nonce is
|
|
26
|
+
* decoded to its 32 raw bytes before hashing. The platform recomputes this and
|
|
27
|
+
* rejects a mismatch.
|
|
28
|
+
*/
|
|
29
|
+
function deriveEip3009Nonce(input) {
|
|
30
|
+
if (!CHALLENGE_NONCE_RE.test(input.challengeNonce)) throw new Error("challengeNonce must be exactly 64 lowercase hex chars (32 bytes), no 0x prefix");
|
|
31
|
+
return keccak256(concat([
|
|
32
|
+
stringToBytes(input.interactionId.toLowerCase()),
|
|
33
|
+
FIELD_SEPARATOR,
|
|
34
|
+
stringToBytes(input.challengeStepId.toLowerCase()),
|
|
35
|
+
FIELD_SEPARATOR,
|
|
36
|
+
hexToBytes(`0x${input.challengeNonce}`)
|
|
37
|
+
]));
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* The EIP-3009 `TransferWithAuthorization` EIP-712 type. The field order and
|
|
41
|
+
* types are part of the on-chain contract and MUST NOT change.
|
|
42
|
+
*/
|
|
43
|
+
const TRANSFER_WITH_AUTHORIZATION_TYPES = { TransferWithAuthorization: [
|
|
44
|
+
{
|
|
45
|
+
name: "from",
|
|
46
|
+
type: "address"
|
|
47
|
+
},
|
|
48
|
+
{
|
|
49
|
+
name: "to",
|
|
50
|
+
type: "address"
|
|
51
|
+
},
|
|
52
|
+
{
|
|
53
|
+
name: "value",
|
|
54
|
+
type: "uint256"
|
|
55
|
+
},
|
|
56
|
+
{
|
|
57
|
+
name: "validAfter",
|
|
58
|
+
type: "uint256"
|
|
59
|
+
},
|
|
60
|
+
{
|
|
61
|
+
name: "validBefore",
|
|
62
|
+
type: "uint256"
|
|
63
|
+
},
|
|
64
|
+
{
|
|
65
|
+
name: "nonce",
|
|
66
|
+
type: "bytes32"
|
|
67
|
+
}
|
|
68
|
+
] };
|
|
69
|
+
function transferWithAuthorizationTypedData(domain, auth) {
|
|
70
|
+
return {
|
|
71
|
+
domain: {
|
|
72
|
+
name: domain.name,
|
|
73
|
+
version: domain.version,
|
|
74
|
+
chainId: domain.chainId,
|
|
75
|
+
verifyingContract: domain.verifyingContract
|
|
76
|
+
},
|
|
77
|
+
types: TRANSFER_WITH_AUTHORIZATION_TYPES,
|
|
78
|
+
primaryType: "TransferWithAuthorization",
|
|
79
|
+
message: auth
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* Build the payout-address ownership message. This MUST be byte-identical to the
|
|
84
|
+
* platform's `buildPayoutRegistrationMessage`, or registration fails the
|
|
85
|
+
* ownership proof. The org id is in the signed bytes, so a captured signature
|
|
86
|
+
* can never register the address under a different org.
|
|
87
|
+
*/
|
|
88
|
+
function buildPayoutRegistrationMessage(input) {
|
|
89
|
+
return [
|
|
90
|
+
"Primitive x402 payout address authorization",
|
|
91
|
+
"",
|
|
92
|
+
"I authorize this address as a payout destination for my Primitive organization.",
|
|
93
|
+
"",
|
|
94
|
+
`org: ${input.org}`,
|
|
95
|
+
`address: ${input.address.toLowerCase()}`,
|
|
96
|
+
`network: ${input.network}`,
|
|
97
|
+
`issued: ${input.issuedAt}`
|
|
98
|
+
].join("\n");
|
|
99
|
+
}
|
|
100
|
+
/** Assemble the wire payload from a signed authorization. */
|
|
101
|
+
function toPaymentPayload(network, auth, signature) {
|
|
102
|
+
return {
|
|
103
|
+
x402Version: 1,
|
|
104
|
+
scheme: "exact",
|
|
105
|
+
network,
|
|
106
|
+
payload: {
|
|
107
|
+
signature,
|
|
108
|
+
authorization: {
|
|
109
|
+
from: auth.from,
|
|
110
|
+
to: auth.to,
|
|
111
|
+
value: auth.value.toString(),
|
|
112
|
+
validAfter: auth.validAfter.toString(),
|
|
113
|
+
validBefore: auth.validBefore.toString(),
|
|
114
|
+
nonce: auth.nonce
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
//#endregion
|
|
120
|
+
//#region src/x402/client.ts
|
|
121
|
+
const CHAIN_IDS = {
|
|
122
|
+
"base-sepolia": 84532,
|
|
123
|
+
base: 8453
|
|
124
|
+
};
|
|
125
|
+
const CLOCK_SKEW_SEC = 300;
|
|
126
|
+
const SETTLEMENT_MARGIN_SEC = 300;
|
|
127
|
+
const DEFAULT_BASE_URL = "https://api.primitive.dev";
|
|
128
|
+
const CHARGE_INPUT_KEYS = {
|
|
129
|
+
amount: true,
|
|
130
|
+
network: true,
|
|
131
|
+
payerOrg: true,
|
|
132
|
+
description: true,
|
|
133
|
+
resource: true,
|
|
134
|
+
expiresIn: true
|
|
135
|
+
};
|
|
136
|
+
var X402Error = class extends Error {
|
|
137
|
+
/** HTTP status, or 0 for a client-side / transport error that never reached the server. */
|
|
138
|
+
status;
|
|
139
|
+
body;
|
|
140
|
+
/** The `Retry-After` response header, if the server sent one. */
|
|
141
|
+
retryAfter;
|
|
142
|
+
constructor(message, status, body, options) {
|
|
143
|
+
super(message, options?.cause !== void 0 ? { cause: options.cause } : void 0);
|
|
144
|
+
this.name = "X402Error";
|
|
145
|
+
this.status = status;
|
|
146
|
+
this.body = body;
|
|
147
|
+
this.retryAfter = options?.retryAfter ?? null;
|
|
148
|
+
}
|
|
149
|
+
};
|
|
150
|
+
/**
|
|
151
|
+
* Assert a challenge is fully hydrated before signing, so a missing field fails
|
|
152
|
+
* with a named X402Error instead of an opaque viem/BigInt error mid-sign.
|
|
153
|
+
*/
|
|
154
|
+
function validateChallenge(c) {
|
|
155
|
+
const bad = (field) => {
|
|
156
|
+
throw new X402Error(`challenge is missing or malformed: ${field}`, 0);
|
|
157
|
+
};
|
|
158
|
+
if (!c || typeof c !== "object") bad("challenge");
|
|
159
|
+
if (!c.id) bad("id");
|
|
160
|
+
if (!c.network) bad("network");
|
|
161
|
+
if (!c.expires_at) bad("expires_at");
|
|
162
|
+
const nb = c.nonce_binding;
|
|
163
|
+
if (!nb?.interaction_id || !nb.challenge_step_id || !nb.challenge_nonce) bad("nonce_binding");
|
|
164
|
+
const pr = c.payment_requirements;
|
|
165
|
+
if (!pr) bad("payment_requirements");
|
|
166
|
+
if (!pr.maxAmountRequired) bad("payment_requirements.maxAmountRequired");
|
|
167
|
+
if (!/^0x[0-9a-fA-F]{40}$/.test(pr.payTo ?? "")) bad("payment_requirements.payTo (expected a 0x address)");
|
|
168
|
+
if (!/^0x[0-9a-fA-F]{40}$/.test(pr.asset ?? "")) bad("payment_requirements.asset (expected a 0x address)");
|
|
169
|
+
if (!pr.extra?.name || !pr.extra.version) bad("payment_requirements.extra (name/version)");
|
|
170
|
+
}
|
|
171
|
+
var X402Client = class {
|
|
172
|
+
#apiKey;
|
|
173
|
+
#baseUrl;
|
|
174
|
+
#fetch;
|
|
175
|
+
#timeoutMs;
|
|
176
|
+
constructor(options = {}) {
|
|
177
|
+
this.#apiKey = options.apiKey ?? process.env.PRIMITIVE_API_KEY ?? "";
|
|
178
|
+
this.#baseUrl = (options.baseUrl ?? DEFAULT_BASE_URL).replace(/\/+$/, "");
|
|
179
|
+
this.#fetch = options.fetch ?? fetch;
|
|
180
|
+
this.#timeoutMs = options.timeoutMs ?? 3e4;
|
|
181
|
+
}
|
|
182
|
+
async #request(method, path, body, init) {
|
|
183
|
+
if (!this.#apiKey) throw new X402Error("no API key configured; set PRIMITIVE_API_KEY or pass { apiKey } to the client", 0);
|
|
184
|
+
const timeout = AbortSignal.timeout(this.#timeoutMs);
|
|
185
|
+
const signal = init?.signal ? AbortSignal.any([timeout, init.signal]) : timeout;
|
|
186
|
+
let res;
|
|
187
|
+
try {
|
|
188
|
+
res = await this.#fetch(`${this.#baseUrl}${path}`, {
|
|
189
|
+
method,
|
|
190
|
+
headers: {
|
|
191
|
+
authorization: `Bearer ${this.#apiKey}`,
|
|
192
|
+
"content-type": "application/json"
|
|
193
|
+
},
|
|
194
|
+
body: body === void 0 ? void 0 : JSON.stringify(body),
|
|
195
|
+
signal
|
|
196
|
+
});
|
|
197
|
+
} catch (cause) {
|
|
198
|
+
throw new X402Error(cause instanceof Error && cause.name === "TimeoutError" ? `request to ${path} timed out after ${this.#timeoutMs}ms` : `request to ${path} failed: ${cause instanceof Error ? cause.message : String(cause)}`, 0, void 0, { cause });
|
|
199
|
+
}
|
|
200
|
+
const retryAfter = res.headers.get("retry-after");
|
|
201
|
+
const text = await res.text().catch(() => "");
|
|
202
|
+
let json;
|
|
203
|
+
if (text) try {
|
|
204
|
+
json = JSON.parse(text);
|
|
205
|
+
} catch {
|
|
206
|
+
throw new X402Error(`non-JSON response (${res.status}) from ${path}: ${text.slice(0, 200)}`, res.status, text.slice(0, 500), { retryAfter });
|
|
207
|
+
}
|
|
208
|
+
if (!res.ok || json?.success === false) throw new X402Error(json?.error?.message ?? `request failed with ${res.status}`, res.status, json ?? text.slice(0, 500), { retryAfter });
|
|
209
|
+
if (!json || json.success !== true || json.data === void 0) throw new X402Error(`unexpected response shape (${res.status}) from ${path}: missing success/data envelope`, res.status, json ?? text.slice(0, 500), { retryAfter });
|
|
210
|
+
return json.data;
|
|
211
|
+
}
|
|
212
|
+
/** Request a payment (payee side). Returns the challenge to hand to the payer. */
|
|
213
|
+
async charge(input) {
|
|
214
|
+
for (const key of Object.keys(input)) if (!(key in CHARGE_INPUT_KEYS)) throw new X402Error(`unknown charge() option "${key}"; expected one of: ${Object.keys(CHARGE_INPUT_KEYS).join(", ")}`, 0);
|
|
215
|
+
if (!input.amount || !/^[1-9][0-9]{0,38}$/.test(input.amount)) throw new X402Error("charge() requires `amount` as a positive integer string in token base units, e.g. \"10000\"", 0);
|
|
216
|
+
const body = {
|
|
217
|
+
amount: input.amount,
|
|
218
|
+
network: input.network ?? "base-sepolia"
|
|
219
|
+
};
|
|
220
|
+
if (input.payerOrg) body.payer_org = input.payerOrg;
|
|
221
|
+
if (input.description) body.description = input.description;
|
|
222
|
+
if (input.resource) body.resource = input.resource;
|
|
223
|
+
if (input.expiresIn !== void 0) body.expires_in = input.expiresIn;
|
|
224
|
+
return this.#request("POST", "/v1/x402/challenges", body);
|
|
225
|
+
}
|
|
226
|
+
/**
|
|
227
|
+
* Pay a challenge (payer side). Derives the interaction-bound authorization,
|
|
228
|
+
* signs it locally with the caller's key, and submits it for settlement.
|
|
229
|
+
*/
|
|
230
|
+
async pay(challenge, options) {
|
|
231
|
+
if (!options?.signer?.address || typeof options.signer.signTypedData !== "function") throw new X402Error("pay() requires options.signer with { address, signTypedData } (e.g. a viem LocalAccount)", 0);
|
|
232
|
+
validateChallenge(challenge);
|
|
233
|
+
const chainId = CHAIN_IDS[challenge.network];
|
|
234
|
+
if (chainId === void 0) throw new X402Error(`unsupported network: ${challenge.network}`, 0);
|
|
235
|
+
const pr = challenge.payment_requirements;
|
|
236
|
+
if (pr.network !== challenge.network) throw new X402Error(`challenge network mismatch: ${challenge.network} vs payment_requirements ${pr.network}`, 0);
|
|
237
|
+
if (pr.scheme !== "exact") throw new X402Error(`unsupported payment scheme: ${pr.scheme}`, 0);
|
|
238
|
+
const nonce = deriveEip3009Nonce({
|
|
239
|
+
interactionId: challenge.nonce_binding.interaction_id,
|
|
240
|
+
challengeStepId: challenge.nonce_binding.challenge_step_id,
|
|
241
|
+
challengeNonce: challenge.nonce_binding.challenge_nonce
|
|
242
|
+
});
|
|
243
|
+
const nowSec = Math.floor(Date.now() / 1e3);
|
|
244
|
+
const expiresAtMs = Date.parse(challenge.expires_at);
|
|
245
|
+
if (Number.isNaN(expiresAtMs)) throw new X402Error(`challenge has an invalid expires_at: ${challenge.expires_at}`, 0);
|
|
246
|
+
const expiresAtSec = Math.floor(expiresAtMs / 1e3);
|
|
247
|
+
if (expiresAtSec <= nowSec) throw new X402Error(`challenge has already expired (expires_at ${challenge.expires_at}); not signing`, 0);
|
|
248
|
+
const validAfter = BigInt(nowSec - CLOCK_SKEW_SEC);
|
|
249
|
+
const validBefore = BigInt(expiresAtSec + SETTLEMENT_MARGIN_SEC);
|
|
250
|
+
const auth = {
|
|
251
|
+
from: options.signer.address,
|
|
252
|
+
to: pr.payTo,
|
|
253
|
+
value: BigInt(pr.maxAmountRequired),
|
|
254
|
+
validAfter,
|
|
255
|
+
validBefore,
|
|
256
|
+
nonce
|
|
257
|
+
};
|
|
258
|
+
const signature = await options.signer.signTypedData(transferWithAuthorizationTypedData({
|
|
259
|
+
name: pr.extra.name,
|
|
260
|
+
version: pr.extra.version,
|
|
261
|
+
chainId,
|
|
262
|
+
verifyingContract: pr.asset
|
|
263
|
+
}, auth));
|
|
264
|
+
return this.#request("POST", `/v1/x402/challenges/${challenge.id}/pay`, { payment: toPaymentPayload(challenge.network, auth, signature) });
|
|
265
|
+
}
|
|
266
|
+
/** Fetch a challenge by id (scoped to the challenger org that created it). */
|
|
267
|
+
async getChallenge(id) {
|
|
268
|
+
if (!id) throw new X402Error("getChallenge() requires a challenge id", 0);
|
|
269
|
+
return this.#request("GET", `/v1/x402/challenges/${encodeURIComponent(id)}`);
|
|
270
|
+
}
|
|
271
|
+
/**
|
|
272
|
+
* Register a payout address for your org (payee side). The signer proves
|
|
273
|
+
* control of its own address with an org-bound `personal_sign`; the proven
|
|
274
|
+
* address becomes (or updates to) the default payout destination for the
|
|
275
|
+
* network. `charge()` resolves its `pay_to` from this directory, so a payee
|
|
276
|
+
* must register before requesting payments.
|
|
277
|
+
*/
|
|
278
|
+
async registerPayoutAddress(input, options) {
|
|
279
|
+
if (!input?.org) throw new X402Error("registerPayoutAddress() requires an org id", 0);
|
|
280
|
+
if (typeof options?.signer?.signMessage !== "function") throw new X402Error("registerPayoutAddress() requires a signer with signMessage (e.g. a viem LocalAccount)", 0);
|
|
281
|
+
const network = input.network ?? "base-sepolia";
|
|
282
|
+
const issuedAt = input.issuedAt ?? (/* @__PURE__ */ new Date()).toISOString();
|
|
283
|
+
const address = options.signer.address;
|
|
284
|
+
const message = buildPayoutRegistrationMessage({
|
|
285
|
+
org: input.org,
|
|
286
|
+
address,
|
|
287
|
+
network,
|
|
288
|
+
issuedAt
|
|
289
|
+
});
|
|
290
|
+
const signature = await options.signer.signMessage({ message });
|
|
291
|
+
return this.#request("POST", "/v1/x402/payout-addresses", {
|
|
292
|
+
address,
|
|
293
|
+
network,
|
|
294
|
+
signature,
|
|
295
|
+
issued_at: issuedAt
|
|
296
|
+
});
|
|
297
|
+
}
|
|
298
|
+
/** List your org's registered payout addresses. */
|
|
299
|
+
async listPayoutAddresses() {
|
|
300
|
+
return this.#request("GET", "/v1/x402/payout-addresses");
|
|
301
|
+
}
|
|
302
|
+
/** Read your org's spend policy (kill-switch + caps + allowlist). */
|
|
303
|
+
async getSpendPolicy() {
|
|
304
|
+
return this.#request("GET", "/v1/x402/spend-policy");
|
|
305
|
+
}
|
|
306
|
+
/**
|
|
307
|
+
* Update your org's spend policy. The endpoint is a PUT, but the server
|
|
308
|
+
* applies it as a merge: only the fields you include are changed and omitted
|
|
309
|
+
* fields keep their current value, so a partial update can't silently reset
|
|
310
|
+
* the kill-switch. Pass `null` to clear a cap.
|
|
311
|
+
*/
|
|
312
|
+
async setSpendPolicy(update) {
|
|
313
|
+
return this.#request("PUT", "/v1/x402/spend-policy", update);
|
|
314
|
+
}
|
|
315
|
+
};
|
|
316
|
+
function createX402Client(options = {}) {
|
|
317
|
+
return new X402Client(options);
|
|
318
|
+
}
|
|
319
|
+
//#endregion
|
|
320
|
+
export { TRANSFER_WITH_AUTHORIZATION_TYPES, X402Client, X402Error, buildPayoutRegistrationMessage, createX402Client, deriveEip3009Nonce, toPaymentPayload, transferWithAuthorizationTypedData };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@primitivedotdev/sdk",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.5.0",
|
|
4
4
|
"description": "Official Primitive Node.js SDK: webhook, api, openapi, contract, and parser runtime modules.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"module": "./dist/index.js",
|
|
@@ -40,6 +40,11 @@
|
|
|
40
40
|
"types": "./dist/parser/address.d.ts",
|
|
41
41
|
"import": "./dist/parser/address.js",
|
|
42
42
|
"default": "./dist/parser/address.js"
|
|
43
|
+
},
|
|
44
|
+
"./x402": {
|
|
45
|
+
"types": "./dist/x402/index.d.ts",
|
|
46
|
+
"import": "./dist/x402/index.js",
|
|
47
|
+
"default": "./dist/x402/index.js"
|
|
43
48
|
}
|
|
44
49
|
},
|
|
45
50
|
"sideEffects": false,
|