@primitivedotdev/sdk 1.6.0 → 1.7.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 +54 -0
- package/dist/x402/index.d.ts +60 -1
- package/dist/x402/index.js +102 -26
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -266,6 +266,60 @@ const receipt = await x402.pay(challenge, { signer: payer });
|
|
|
266
266
|
console.log(receipt.status, receipt.settle_tx); // settled, on-chain tx hash
|
|
267
267
|
```
|
|
268
268
|
|
|
269
|
+
### Signing primitives (lower level)
|
|
270
|
+
|
|
271
|
+
`pay()` builds and signs the payment for you. When you need to drive the signing yourself, for example to sign a challenge carried in an email reply and submit the payment separately, the same building blocks are exported directly:
|
|
272
|
+
|
|
273
|
+
- `deriveEip3009Nonce(binding)` derives the interaction-bound EIP-3009 nonce. The byte layout (`keccak256` over the lowercased `interaction_id`, a `0x00` separator, the lowercased `challenge_step_id`, a `0x00` separator, and the 32 raw bytes of the challenge nonce) is locked to a normative vector the platform recomputes.
|
|
274
|
+
- `computePaymentValidityWindow({ challengeExpiresAtSec, nowSec })` returns the `{ validAfter, validBefore }` window. `validBefore` covers the challenge expiry plus a settlement margin; the total window is hard-capped (24h) so an over-wide authorization can never be produced.
|
|
275
|
+
- `signInteractionPayment({ sign, payer, domain, payTo, amount, nonceBinding, validAfter, validBefore })` derives the bound nonce, assembles the authorization, and signs it with your `sign` callback. Returns `{ authorization, signature }`. The key never leaves the caller.
|
|
276
|
+
- `buildExactEvmPaymentPayload({ network, authorization, signature })` assembles the exact-EVM x402 wire payload, validating the nonce and signature shape.
|
|
277
|
+
|
|
278
|
+
```ts
|
|
279
|
+
import {
|
|
280
|
+
buildExactEvmPaymentPayload,
|
|
281
|
+
computePaymentValidityWindow,
|
|
282
|
+
signInteractionPayment,
|
|
283
|
+
} from "@primitivedotdev/sdk/x402";
|
|
284
|
+
import { privateKeyToAccount } from "viem/accounts";
|
|
285
|
+
|
|
286
|
+
const payer = privateKeyToAccount(process.env.PAYER_KEY as `0x${string}`);
|
|
287
|
+
const pr = challenge.payment_requirements;
|
|
288
|
+
const nowSec = Math.floor(Date.now() / 1000);
|
|
289
|
+
|
|
290
|
+
const { validAfter, validBefore } = computePaymentValidityWindow({
|
|
291
|
+
challengeExpiresAtSec: Math.floor(Date.parse(challenge.expires_at) / 1000),
|
|
292
|
+
nowSec,
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
const { authorization, signature } = await signInteractionPayment({
|
|
296
|
+
sign: (typedData) => payer.signTypedData(typedData),
|
|
297
|
+
payer: payer.address,
|
|
298
|
+
domain: {
|
|
299
|
+
name: pr.extra.name,
|
|
300
|
+
version: pr.extra.version,
|
|
301
|
+
chainId: 84532, // base-sepolia
|
|
302
|
+
verifyingContract: pr.asset as `0x${string}`,
|
|
303
|
+
},
|
|
304
|
+
payTo: pr.payTo as `0x${string}`,
|
|
305
|
+
amount: BigInt(pr.maxAmountRequired),
|
|
306
|
+
nonceBinding: {
|
|
307
|
+
interactionId: challenge.nonce_binding.interaction_id,
|
|
308
|
+
challengeStepId: challenge.nonce_binding.challenge_step_id,
|
|
309
|
+
challengeNonce: challenge.nonce_binding.challenge_nonce,
|
|
310
|
+
},
|
|
311
|
+
validAfter,
|
|
312
|
+
validBefore,
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
const payment = buildExactEvmPaymentPayload({
|
|
316
|
+
network: "base-sepolia",
|
|
317
|
+
authorization,
|
|
318
|
+
signature,
|
|
319
|
+
});
|
|
320
|
+
// submit `payment` to /v1/x402/challenges/{id}/pay
|
|
321
|
+
```
|
|
322
|
+
|
|
269
323
|
### Read and set the spend policy
|
|
270
324
|
|
|
271
325
|
The spend policy guards outbound payments: a `paused` kill-switch, per-payment and daily caps (token base units, or `null` for no cap), and a payee `allowlist` (`null` means any on-net payee, `[]` denies all). `setSpendPolicy` merges: only the fields you pass change, and omitted fields keep their current value. Pass `null` to clear a cap.
|
package/dist/x402/index.d.ts
CHANGED
|
@@ -132,6 +132,65 @@ interface X402PaymentPayload {
|
|
|
132
132
|
}
|
|
133
133
|
/** Assemble the wire payload from a signed authorization. */
|
|
134
134
|
declare function toPaymentPayload(network: string, auth: TransferAuthorization, signature: Hex): X402PaymentPayload;
|
|
135
|
+
/**
|
|
136
|
+
* Absolute ceiling on the total signed window (validBefore - validAfter). A
|
|
137
|
+
* signed EIP-3009 authorization stays settleable on-chain until validBefore
|
|
138
|
+
* regardless of the interaction state, so an unbounded window is a standing
|
|
139
|
+
* "funds committed" risk. The real window is minutes; this 24h cap is the hard
|
|
140
|
+
* safety ceiling, enforced so a caller-supplied window cannot bypass it.
|
|
141
|
+
*/
|
|
142
|
+
declare const DEFAULT_MAX_WINDOW_SEC: number;
|
|
143
|
+
/**
|
|
144
|
+
* Compute the EIP-3009 validity window for a payment. `validBefore` is the
|
|
145
|
+
* value that governs on-chain validity, so it MUST cover the challenge's
|
|
146
|
+
* `expires_at` plus a settlement margin; `validAfter` is set generously in the
|
|
147
|
+
* past for clock skew. The total window is hard-capped at `maxWindowSec` so
|
|
148
|
+
* neither a far-future `challengeExpiresAtSec` nor a widened margin can produce
|
|
149
|
+
* a window the platform verifier would later reject.
|
|
150
|
+
*/
|
|
151
|
+
declare function computePaymentValidityWindow(params: {
|
|
152
|
+
/** The challenge's expires_at, unix seconds. */challengeExpiresAtSec: number; /** Current time, unix seconds. */
|
|
153
|
+
nowSec: number; /** Headroom past expiry for verify+settle to complete. Default 5 min. */
|
|
154
|
+
settlementMarginSec?: number; /** How far in the past to set validAfter for clock skew. Default 5 min. */
|
|
155
|
+
clockSkewSec?: number; /** Hard ceiling on validBefore - validAfter. Default 24h. */
|
|
156
|
+
maxWindowSec?: number;
|
|
157
|
+
}): {
|
|
158
|
+
validAfter: bigint;
|
|
159
|
+
validBefore: bigint;
|
|
160
|
+
};
|
|
161
|
+
/** The x402 named networks supported in v1 (testnet first). */
|
|
162
|
+
type X402Network = "base-sepolia" | "base";
|
|
163
|
+
/**
|
|
164
|
+
* The interaction-aware signer: derive the bound nonce, assemble the
|
|
165
|
+
* authorization, and sign it. This is the one piece a stock x402 signer cannot
|
|
166
|
+
* do (it generates the nonce internally with no injection point), so the payer
|
|
167
|
+
* side needs this Primitive-provided helper. The key never leaves the caller.
|
|
168
|
+
*/
|
|
169
|
+
declare function signInteractionPayment(params: {
|
|
170
|
+
/** Sign EIP-712 typed data with the caller's own key. */sign: (typedData: TransferWithAuthorizationTypedData) => Promise<Hex>; /** Payer (from) address. */
|
|
171
|
+
payer: Address;
|
|
172
|
+
domain: TokenDomain; /** Recipient (the challenger's payTo). */
|
|
173
|
+
payTo: Address; /** Amount in token base units. */
|
|
174
|
+
amount: bigint; /** Inputs that derive the interaction-bound EIP-3009 nonce. */
|
|
175
|
+
nonceBinding: NonceBinding;
|
|
176
|
+
validAfter: bigint;
|
|
177
|
+
validBefore: bigint;
|
|
178
|
+
}): Promise<{
|
|
179
|
+
authorization: TransferAuthorization;
|
|
180
|
+
signature: Hex;
|
|
181
|
+
}>;
|
|
182
|
+
/**
|
|
183
|
+
* Assemble (and validate) the exact-EVM x402 wire payload from an
|
|
184
|
+
* interaction-bound, locally-signed authorization. The numeric authorization
|
|
185
|
+
* fields are decimal strings in the wire schema, so the bigints are stringified
|
|
186
|
+
* here; the nonce passes through as hex. Validation rejects a malformed nonce or
|
|
187
|
+
* signature loudly rather than emitting a payload the platform will reject.
|
|
188
|
+
*/
|
|
189
|
+
declare function buildExactEvmPaymentPayload(params: {
|
|
190
|
+
network: X402Network;
|
|
191
|
+
authorization: TransferAuthorization;
|
|
192
|
+
signature: Hex;
|
|
193
|
+
}): X402PaymentPayload;
|
|
135
194
|
//#endregion
|
|
136
195
|
//#region src/x402/client.d.ts
|
|
137
196
|
interface X402PaymentRequirements {
|
|
@@ -292,4 +351,4 @@ declare class X402Client {
|
|
|
292
351
|
}
|
|
293
352
|
declare function createX402Client(options?: X402ClientOptions): X402Client;
|
|
294
353
|
//#endregion
|
|
295
|
-
export { NonceBinding, PayoutRegistrationMessageInput, TRANSFER_WITH_AUTHORIZATION_TYPES, TokenDomain, TransferAuthorization, TransferWithAuthorizationTypedData, X402Challenge, X402ChargeInput, X402Client, X402ClientOptions, X402DeclinedPayment, X402Error, X402PaymentPayload, X402PaymentRequirements, X402PayoutAddress, X402Receipt, X402Signer, X402SpendPolicy, buildPayoutRegistrationMessage, createX402Client, deriveEip3009Nonce, toPaymentPayload, transferWithAuthorizationTypedData };
|
|
354
|
+
export { DEFAULT_MAX_WINDOW_SEC, NonceBinding, PayoutRegistrationMessageInput, TRANSFER_WITH_AUTHORIZATION_TYPES, TokenDomain, TransferAuthorization, TransferWithAuthorizationTypedData, X402Challenge, X402ChargeInput, X402Client, X402ClientOptions, X402DeclinedPayment, X402Error, X402Network, X402PaymentPayload, X402PaymentRequirements, X402PayoutAddress, X402Receipt, X402Signer, X402SpendPolicy, buildExactEvmPaymentPayload, buildPayoutRegistrationMessage, computePaymentValidityWindow, createX402Client, deriveEip3009Nonce, signInteractionPayment, toPaymentPayload, transferWithAuthorizationTypedData };
|
package/dist/x402/index.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { concat, hexToBytes, keccak256, stringToBytes } from "viem";
|
|
1
|
+
import { concat, getAddress, hexToBytes, keccak256, stringToBytes } from "viem";
|
|
2
2
|
//#region src/x402/sign.ts
|
|
3
3
|
/**
|
|
4
4
|
* x402 client-side signing.
|
|
@@ -116,14 +116,78 @@ function toPaymentPayload(network, auth, signature) {
|
|
|
116
116
|
}
|
|
117
117
|
};
|
|
118
118
|
}
|
|
119
|
+
/**
|
|
120
|
+
* Absolute ceiling on the total signed window (validBefore - validAfter). A
|
|
121
|
+
* signed EIP-3009 authorization stays settleable on-chain until validBefore
|
|
122
|
+
* regardless of the interaction state, so an unbounded window is a standing
|
|
123
|
+
* "funds committed" risk. The real window is minutes; this 24h cap is the hard
|
|
124
|
+
* safety ceiling, enforced so a caller-supplied window cannot bypass it.
|
|
125
|
+
*/
|
|
126
|
+
const DEFAULT_MAX_WINDOW_SEC = 1440 * 60;
|
|
127
|
+
/**
|
|
128
|
+
* Compute the EIP-3009 validity window for a payment. `validBefore` is the
|
|
129
|
+
* value that governs on-chain validity, so it MUST cover the challenge's
|
|
130
|
+
* `expires_at` plus a settlement margin; `validAfter` is set generously in the
|
|
131
|
+
* past for clock skew. The total window is hard-capped at `maxWindowSec` so
|
|
132
|
+
* neither a far-future `challengeExpiresAtSec` nor a widened margin can produce
|
|
133
|
+
* a window the platform verifier would later reject.
|
|
134
|
+
*/
|
|
135
|
+
function computePaymentValidityWindow(params) {
|
|
136
|
+
const margin = params.settlementMarginSec ?? 300;
|
|
137
|
+
const skew = params.clockSkewSec ?? 300;
|
|
138
|
+
const validBefore = BigInt(params.challengeExpiresAtSec + margin);
|
|
139
|
+
const validAfter = BigInt(params.nowSec - skew);
|
|
140
|
+
if (validBefore <= validAfter) throw new Error("invalid validity window: validBefore must be after validAfter (challenge already expired?)");
|
|
141
|
+
const maxWindow = BigInt(params.maxWindowSec ?? 86400);
|
|
142
|
+
if (validBefore - validAfter > maxWindow) throw new Error(`invalid validity window: total window exceeds the ${maxWindow}s cap (challenge expiry too far out?)`);
|
|
143
|
+
return {
|
|
144
|
+
validAfter,
|
|
145
|
+
validBefore
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
/**
|
|
149
|
+
* The interaction-aware signer: derive the bound nonce, assemble the
|
|
150
|
+
* authorization, and sign it. This is the one piece a stock x402 signer cannot
|
|
151
|
+
* do (it generates the nonce internally with no injection point), so the payer
|
|
152
|
+
* side needs this Primitive-provided helper. The key never leaves the caller.
|
|
153
|
+
*/
|
|
154
|
+
async function signInteractionPayment(params) {
|
|
155
|
+
const authorization = {
|
|
156
|
+
from: getAddress(params.payer),
|
|
157
|
+
to: getAddress(params.payTo),
|
|
158
|
+
value: params.amount,
|
|
159
|
+
validAfter: params.validAfter,
|
|
160
|
+
validBefore: params.validBefore,
|
|
161
|
+
nonce: deriveEip3009Nonce(params.nonceBinding)
|
|
162
|
+
};
|
|
163
|
+
return {
|
|
164
|
+
authorization,
|
|
165
|
+
signature: await params.sign(transferWithAuthorizationTypedData(params.domain, authorization))
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
/** The authorization nonce is 32 bytes rendered as a 0x-prefixed 64-char hex string. */
|
|
169
|
+
const NONCE_HEX_RE = /^0x[0-9a-fA-F]{64}$/;
|
|
170
|
+
/** A shape-valid EIP signature is 65 bytes (r,s,v) rendered as 130 hex chars. */
|
|
171
|
+
const SIGNATURE_HEX_RE = /^0x[0-9a-fA-F]{130}$/;
|
|
172
|
+
/**
|
|
173
|
+
* Assemble (and validate) the exact-EVM x402 wire payload from an
|
|
174
|
+
* interaction-bound, locally-signed authorization. The numeric authorization
|
|
175
|
+
* fields are decimal strings in the wire schema, so the bigints are stringified
|
|
176
|
+
* here; the nonce passes through as hex. Validation rejects a malformed nonce or
|
|
177
|
+
* signature loudly rather than emitting a payload the platform will reject.
|
|
178
|
+
*/
|
|
179
|
+
function buildExactEvmPaymentPayload(params) {
|
|
180
|
+
if (params.network !== "base" && params.network !== "base-sepolia") throw new Error(`buildExactEvmPaymentPayload: unsupported network ${params.network}`);
|
|
181
|
+
if (!SIGNATURE_HEX_RE.test(params.signature)) throw new Error("buildExactEvmPaymentPayload: signature must be a 0x-prefixed 65-byte (130 hex char) EIP signature");
|
|
182
|
+
if (!NONCE_HEX_RE.test(params.authorization.nonce)) throw new Error("buildExactEvmPaymentPayload: authorization.nonce must be a 0x-prefixed 32-byte (64 hex char) value");
|
|
183
|
+
return toPaymentPayload(params.network, params.authorization, params.signature);
|
|
184
|
+
}
|
|
119
185
|
//#endregion
|
|
120
186
|
//#region src/x402/client.ts
|
|
121
187
|
const CHAIN_IDS = {
|
|
122
188
|
"base-sepolia": 84532,
|
|
123
189
|
base: 8453
|
|
124
190
|
};
|
|
125
|
-
const CLOCK_SKEW_SEC = 300;
|
|
126
|
-
const SETTLEMENT_MARGIN_SEC = 300;
|
|
127
191
|
const DEFAULT_BASE_URL = "https://api.primitive.dev";
|
|
128
192
|
const CHARGE_INPUT_KEYS = {
|
|
129
193
|
amount: true,
|
|
@@ -173,7 +237,7 @@ function validateChallenge(c) {
|
|
|
173
237
|
if (!nb?.interaction_id || !nb.challenge_step_id || !nb.challenge_nonce) bad("nonce_binding");
|
|
174
238
|
const pr = c.payment_requirements;
|
|
175
239
|
if (!pr) bad("payment_requirements");
|
|
176
|
-
if (
|
|
240
|
+
if (!/^[1-9][0-9]{0,38}$/.test(pr.maxAmountRequired ?? "")) bad("payment_requirements.maxAmountRequired (expected a positive integer string in token base units)");
|
|
177
241
|
if (!/^0x[0-9a-fA-F]{40}$/.test(pr.payTo ?? "")) bad("payment_requirements.payTo (expected a 0x address)");
|
|
178
242
|
if (!/^0x[0-9a-fA-F]{40}$/.test(pr.asset ?? "")) bad("payment_requirements.asset (expected a 0x address)");
|
|
179
243
|
if (!pr.extra?.name || !pr.extra.version) bad("payment_requirements.extra (name/version)");
|
|
@@ -248,33 +312,45 @@ var X402Client = class {
|
|
|
248
312
|
const pr = challenge.payment_requirements;
|
|
249
313
|
if (pr.network !== challenge.network) throw new X402Error(`challenge network mismatch: ${challenge.network} vs payment_requirements ${pr.network}`, 0);
|
|
250
314
|
if (pr.scheme !== "exact") throw new X402Error(`unsupported payment scheme: ${pr.scheme}`, 0);
|
|
251
|
-
const nonce = deriveEip3009Nonce({
|
|
252
|
-
interactionId: challenge.nonce_binding.interaction_id,
|
|
253
|
-
challengeStepId: challenge.nonce_binding.challenge_step_id,
|
|
254
|
-
challengeNonce: challenge.nonce_binding.challenge_nonce
|
|
255
|
-
});
|
|
256
315
|
const nowSec = Math.floor(Date.now() / 1e3);
|
|
257
316
|
const expiresAtMs = Date.parse(challenge.expires_at);
|
|
258
317
|
if (Number.isNaN(expiresAtMs)) throw new X402Error(`challenge has an invalid expires_at: ${challenge.expires_at}`, 0);
|
|
259
318
|
const expiresAtSec = Math.floor(expiresAtMs / 1e3);
|
|
260
319
|
if (expiresAtSec <= nowSec) throw new X402Error(`challenge has already expired (expires_at ${challenge.expires_at}); not signing`, 0);
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
320
|
+
let validAfter;
|
|
321
|
+
let validBefore;
|
|
322
|
+
try {
|
|
323
|
+
({validAfter, validBefore} = computePaymentValidityWindow({
|
|
324
|
+
challengeExpiresAtSec: expiresAtSec,
|
|
325
|
+
nowSec
|
|
326
|
+
}));
|
|
327
|
+
} catch (cause) {
|
|
328
|
+
throw new X402Error(cause instanceof Error ? cause.message : String(cause), 0, void 0, { cause });
|
|
329
|
+
}
|
|
330
|
+
const { authorization, signature } = await signInteractionPayment({
|
|
331
|
+
sign: (typedData) => options.signer.signTypedData(typedData),
|
|
332
|
+
payer: options.signer.address,
|
|
333
|
+
domain: {
|
|
334
|
+
name: pr.extra.name,
|
|
335
|
+
version: pr.extra.version,
|
|
336
|
+
chainId,
|
|
337
|
+
verifyingContract: pr.asset
|
|
338
|
+
},
|
|
339
|
+
payTo: pr.payTo,
|
|
340
|
+
amount: BigInt(pr.maxAmountRequired),
|
|
341
|
+
nonceBinding: {
|
|
342
|
+
interactionId: challenge.nonce_binding.interaction_id,
|
|
343
|
+
challengeStepId: challenge.nonce_binding.challenge_step_id,
|
|
344
|
+
challengeNonce: challenge.nonce_binding.challenge_nonce
|
|
345
|
+
},
|
|
267
346
|
validAfter,
|
|
268
|
-
validBefore
|
|
269
|
-
|
|
270
|
-
}
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
verifyingContract: pr.asset
|
|
276
|
-
}, auth));
|
|
277
|
-
return this.#request("POST", `/v1/x402/challenges/${challenge.id}/pay`, { payment: toPaymentPayload(challenge.network, auth, signature) });
|
|
347
|
+
validBefore
|
|
348
|
+
});
|
|
349
|
+
return this.#request("POST", `/v1/x402/challenges/${challenge.id}/pay`, { payment: buildExactEvmPaymentPayload({
|
|
350
|
+
network: challenge.network,
|
|
351
|
+
authorization,
|
|
352
|
+
signature
|
|
353
|
+
}) });
|
|
278
354
|
}
|
|
279
355
|
/** Fetch a challenge by id (scoped to the challenger org that created it). */
|
|
280
356
|
async getChallenge(id) {
|
|
@@ -347,4 +423,4 @@ function createX402Client(options = {}) {
|
|
|
347
423
|
return new X402Client(options);
|
|
348
424
|
}
|
|
349
425
|
//#endregion
|
|
350
|
-
export { TRANSFER_WITH_AUTHORIZATION_TYPES, X402Client, X402Error, buildPayoutRegistrationMessage, createX402Client, deriveEip3009Nonce, toPaymentPayload, transferWithAuthorizationTypedData };
|
|
426
|
+
export { DEFAULT_MAX_WINDOW_SEC, TRANSFER_WITH_AUTHORIZATION_TYPES, X402Client, X402Error, buildExactEvmPaymentPayload, buildPayoutRegistrationMessage, computePaymentValidityWindow, createX402Client, deriveEip3009Nonce, signInteractionPayment, toPaymentPayload, transferWithAuthorizationTypedData };
|
package/package.json
CHANGED