/** * WAKE402 Agent Quickstart client for Node 22. * * Required packages: * @x402/core@2.23.0 @x402/evm@2.23.0 * @x402/extensions@2.23.0 viem@2.55.19 * * This is a reusable library module. Importing or bundling it never executes a * purchase or CLI path; callers must invoke an exported function explicitly. */ import { x402Client, type ClientExtension } from "@x402/core/client"; import { decodePaymentRequiredHeader, encodePaymentSignatureHeader, } from "@x402/core/http"; import type { PaymentPayload, PaymentRequired, } from "@x402/core/types"; import type { ClientEvmSigner } from "@x402/evm"; import { ExactEvmScheme } from "@x402/evm/exact/client"; import { appendPaymentIdentifierToExtensions, extractPaymentIdentifier, generatePaymentId, isPaymentIdentifierExtension, isPaymentIdentifierRequired, validatePaymentIdentifierRequirement, } from "@x402/extensions/payment-identifier"; import { getAddress, isAddressEqual, type Hex } from "viem"; import { privateKeyToAccount } from "viem/accounts"; // WAKE402_BUYER_PRIVATE_KEY is user-owned secret material. Never send it to // WAKE402, commit it, print it, or use a primary personal wallet here. export const WAKE402_ORIGIN = "https://wake402.agentwake.workers.dev"; export const WAKE402_ENDPOINT = `${WAKE402_ORIGIN}/v1/wake`; export const WAKE402_RELATIVE_ENDPOINT = `${WAKE402_ORIGIN}/v1/wake-after`; export const EXPECTED_NETWORK = "eip155:8453"; export const EXPECTED_AMOUNT = "2000"; export const EXPECTED_USDC = getAddress( "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913", ); export const EXPECTED_PAY_TO = getAddress( "0x4e1D6C25E1E89BD1e17654FA8Ca3c4d0Cfd3b5d6", ); export const WAKE402_REQUEST_COMMITMENT = "wake402-request-commitment"; const PAYMENT_IDENTIFIER = "payment-identifier"; const REQUEST_COMMITMENT_VERSION = "1"; const REQUEST_COMMITMENT_PROTOCOL = "WAKE402_REQUEST_V1"; const REQUEST_COMMITMENT_PRIMARY_TYPE = "WakeRequest"; const RELATIVE_REQUEST_COMMITMENT_VERSION = "2"; const RELATIVE_REQUEST_COMMITMENT_PROTOCOL = "WAKE402_REQUEST_RELATIVE_V2"; const RELATIVE_REQUEST_COMMITMENT_PRIMARY_TYPE = "WakeAfterRequest"; const RELATIVE_REQUEST_FINGERPRINT_PROTOCOL = "WAKE402_RELATIVE_REQUEST_V1"; const MIN_DELAY_MS = 60_000; const MAX_DELAY_MS = 24 * 60 * 60_000; const MAX_CALLBACK_URL_LENGTH = 2_048; const UTC_TIMESTAMP = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})(?:\.(\d{1,3}))?Z$/; const IPV4_LITERAL = /^\d{1,3}(?:\.\d{1,3}){3}$/; const FORBIDDEN_SUFFIXES = [ ".localhost", ".local", ".localdomain", ".internal", ".intranet", ".home", ".lan", ".corp", ] as const; const FORBIDDEN_HOSTS = new Set([ "localhost", "local", "internal", "intranet", "metadata", "metadata.google.internal", "kubernetes.default", "host.docker.internal", ]); export const WAKE402_SUPPORTED_DELIVERY_CONTRACT = { version: "1", delivery_semantics: "at-least-once", successful_delivery_guaranteed: false, exact_second_timing_guaranteed: false, timing: { expectation: "for or shortly after the requested scheduled time", successful_delivery_sla_guaranteed: false, }, schedule: { min_delay_seconds: 60, max_delay_seconds: 86_400, }, callback: { method: "POST", content_type: "application/json", redirects_followed: false, timeout_ms: 5_000, authentication: { mode: "none", }, idempotency_key: { header: "Idempotency-Key", present: true, value_semantics: "wake_id", }, body: { format: "fixed", additional_properties: false, fields: { type: { const: "wake402.wake" }, wake_id: { value_semantics: "wake_id" }, scheduled_at: { value_semantics: "scheduled wake time" }, fired_at: { value_semantics: "delivery attempt time" }, }, }, }, retry: { first_delivery_attempt: "at-or-after-scheduled-time", retry_limit: 3, maximum_total_attempts: 4, initial_retry_delay: "5 seconds", backoff: "exponential", network_failures_retryable: true, callback_timeout_retryable: true, retryable_http_statuses: [ 408, 425, 429, 500, 501, 502, 503, 504, 505, 506, 507, 508, 510, 511, ], other_http_failures_retryable: false, }, failure: { terminal_state: "delivery_failed", successful_delivery_sla_guaranteed: false, callback_response_body_retained: false, automatic_refund_on_delivery_failure: false, }, expiry: { absolute_wall_clock_delivery_expiry_guaranteed: false, semantics: "delivery processing ends on success, non-retryable failure, or bounded retry exhaustion; no absolute wall-clock expiry is promised", }, } as const; export type Wake402DeliveryContract = typeof WAKE402_SUPPORTED_DELIVERY_CONTRACT; const ABSOLUTE_SIGNED_FIELDS = [ "protocol", "paymentIdentifier", "requestFingerprint", "callback", "at", "network", "asset", "amount", "payTo", ] as const; const RELATIVE_SIGNED_FIELDS = [ "protocol", "paymentIdentifier", "requestFingerprint", "callback", "delaySeconds", "network", "asset", "amount", "payTo", ] as const; const CALLBACK_CANONICALIZATION = "validated WHATWG HTTPS URL serialized with URL.toString()"; const PAYER_BINDING = "recovered commitment signer must equal the payer returned by official x402 verification and the verified EIP-3009 authorization.from"; const COMMITMENT_PROOF_PATH = `extensions.${WAKE402_REQUEST_COMMITMENT}.proof.signature`; const COMMITMENT_PROOF_SCHEMA = { $schema: "https://json-schema.org/draft/2020-12/schema", type: "object", additionalProperties: false, properties: { signature: { type: "string", pattern: "^0x[0-9a-fA-F]{128}(?:[0-9a-fA-F]{2})?$", description: "EIP-712 signature by the same EVM payer as the x402 authorization.", }, }, required: ["signature"], } as const; const wakeRequestTypes = { WakeRequest: [ { name: "protocol", type: "string" }, { name: "paymentIdentifier", type: "string" }, { name: "requestFingerprint", type: "bytes32" }, { name: "callback", type: "string" }, { name: "at", type: "string" }, { name: "network", type: "string" }, { name: "asset", type: "address" }, { name: "amount", type: "uint256" }, { name: "payTo", type: "address" }, ], } as const; const wakeAfterRequestTypes = { WakeAfterRequest: [ { name: "protocol", type: "string" }, { name: "paymentIdentifier", type: "string" }, { name: "requestFingerprint", type: "bytes32" }, { name: "callback", type: "string" }, { name: "delaySeconds", type: "uint256" }, { name: "network", type: "string" }, { name: "asset", type: "address" }, { name: "amount", type: "uint256" }, { name: "payTo", type: "address" }, ], } as const; export interface WakeRequestBody { callback: string; at: string; } export interface WakeAfterRequestBody { callback: string; delay_seconds: number; } interface CanonicalWakeRequest { callback: string; at: string; } interface CanonicalWakeAfterRequest { callback: string; delaySeconds: number; } export interface WakeCreated { wake_id: string; status: "scheduled"; scheduled_for: string; delivery_semantics: "at-least-once"; status_url: string; } export interface RequirementSummary { x402Version: 2; scheme: "exact"; network: typeof EXPECTED_NETWORK; amount: typeof EXPECTED_AMOUNT; asset: typeof EXPECTED_USDC; payTo: typeof EXPECTED_PAY_TO; paymentIdentifierRequired: true; requestCommitmentRequired: true; requestCommitmentVersion: "1" | "2"; } export type Wake402ClientResult = | { dryRun: true; requirements: RequirementSummary; deliveryContract: Wake402DeliveryContract; } | { dryRun: false; wake_id: string; scheduled_for: string; status_url: string; paymentIdentifier: string; deliveryContract: Wake402DeliveryContract; observation?: Record; }; export interface Wake402ClientOptions { callbackUrl: string; at?: string; dryRun?: boolean; pollStatus?: boolean; buyerPrivateKey?: string; signer?: ClientEvmSigner; origin?: string; fetchImpl?: typeof fetch; now?: () => number; log?: (line: string) => void; paymentIdFactory?: () => string; } export interface Wake402BuyerOptions { request: WakeAfterRequestBody; signer?: ClientEvmSigner; dryRun?: boolean; origin?: string; fetchImpl?: typeof fetch; paymentIdFactory?: () => string; } export type Wake402BuyerResult = | { dryRun: true; requirements: RequirementSummary; deliveryContract: Wake402DeliveryContract; } | { dryRun: false; wake_id: string; scheduled_for: string; status_url: string; paymentIdentifier: string; deliveryContract: Wake402DeliveryContract; }; interface RequirementExpectation { path: "/v1/wake" | "/v1/wake-after"; commitmentVersion: "1" | "2"; commitmentPrimaryType: "WakeRequest" | "WakeAfterRequest"; signedFields: readonly string[]; } const ABSOLUTE_REQUIREMENT: RequirementExpectation = { path: "/v1/wake", commitmentVersion: REQUEST_COMMITMENT_VERSION, commitmentPrimaryType: REQUEST_COMMITMENT_PRIMARY_TYPE, signedFields: ABSOLUTE_SIGNED_FIELDS, }; const RELATIVE_REQUIREMENT: RequirementExpectation = { path: "/v1/wake-after", commitmentVersion: RELATIVE_REQUEST_COMMITMENT_VERSION, commitmentPrimaryType: RELATIVE_REQUEST_COMMITMENT_PRIMARY_TYPE, signedFields: RELATIVE_SIGNED_FIELDS, }; function isRecord(value: unknown): value is Record { return value !== null && typeof value === "object" && !Array.isArray(value); } function fail(message: string): never { throw new Error(`WAKE402 requirements rejected: ${message}`); } function requireEqual(actual: unknown, expected: unknown, field: string): void { if (actual !== expected) fail(`${field} is not the expected value`); } function requireJsonEqual( actual: unknown, expected: unknown, field: string, ): void { if (Array.isArray(expected)) { if (!Array.isArray(actual) || actual.length !== expected.length) { fail(`${field} does not match the supported contract`); } expected.forEach((item, index) => requireJsonEqual(actual[index], item, `${field}[${index}]`), ); return; } if (isRecord(expected)) { if (!isRecord(actual)) { fail(`${field} does not match the supported contract`); } const actualKeys = Object.keys(actual).sort(); const expectedKeys = Object.keys(expected).sort(); if ( actualKeys.length !== expectedKeys.length || actualKeys.some((key, index) => key !== expectedKeys[index]) ) { fail(`${field} does not match the supported contract`); } for (const key of expectedKeys) { requireJsonEqual(actual[key], expected[key], `${field}.${key}`); } return; } requireEqual(actual, expected, field); } function expectedEndpoint( origin: string, path: RequirementExpectation["path"] = "/v1/wake", ): string { const parsed = new URL(origin); if (parsed.protocol !== "https:" || parsed.username || parsed.password) { throw new Error("WAKE402 origin must be HTTPS without credentials"); } return new URL(path, parsed).toString(); } function invalidCallback(): never { throw new Error( "WAKE402_CALLBACK_URL must be a public HTTPS URL on the standard port", ); } /** * Standalone mirror of the server's callback validator. Differential tests * lock this public artifact to src/validation.ts over positive and negative * callback corpora. */ export function canonicalizeWake402Callback(raw: unknown): string { if ( typeof raw !== "string" || raw.length === 0 || raw.length > MAX_CALLBACK_URL_LENGTH || /[\u0000-\u001f\u007f\\]/.test(raw) || raw.includes("#") ) { return invalidCallback(); } const authority = raw.match(/^https:\/\/([^/?#]*)/i)?.[1]; if (!authority || authority.includes("%")) return invalidCallback(); let callback: URL; try { callback = new URL(raw); } catch { return invalidCallback(); } if ( callback.protocol !== "https:" || callback.username !== "" || callback.password !== "" || callback.hash !== "" || (callback.port !== "" && callback.port !== "443") ) { return invalidCallback(); } const hostname = callback.hostname.toLowerCase().replace(/\.$/, ""); if ( hostname.length === 0 || hostname.length > 253 || !hostname.includes(".") || hostname.includes(":") || hostname.startsWith("[") || hostname.endsWith("]") || IPV4_LITERAL.test(hostname) || FORBIDDEN_HOSTS.has(hostname) || FORBIDDEN_SUFFIXES.some(suffix => hostname.endsWith(suffix)) ) { return invalidCallback(); } const labels = hostname.split("."); if ( labels.some( label => label.length === 0 || label.length > 63 || !/^[a-z0-9-]+$/.test(label) || label.startsWith("-") || label.endsWith("-"), ) ) { return invalidCallback(); } callback.hostname = hostname; return callback.toString(); } function parseUtcTimestamp(raw: unknown): number { if (typeof raw !== "string") { throw new Error("WAKE402_AT must be an RFC3339 UTC timestamp ending in Z"); } const match = UTC_TIMESTAMP.exec(raw); if (!match) { throw new Error("WAKE402_AT must be an RFC3339 UTC timestamp ending in Z"); } const [, y, mo, d, h, mi, s, ms = "0"] = match; const values = [y, mo, d, h, mi, s].map(Number); const [year, month, day, hour, minute, second] = values; if ( year === undefined || month === undefined || day === undefined || hour === undefined || minute === undefined || second === undefined || month < 1 || month > 12 || day < 1 || day > 31 || hour > 23 || minute > 59 || second > 59 ) { throw new Error("WAKE402_AT must be a real UTC timestamp"); } const millis = Number(ms.padEnd(3, "0")); const timestamp = Date.UTC(year, month - 1, day, hour, minute, second, millis); const check = new Date(timestamp); if ( check.getUTCFullYear() !== year || check.getUTCMonth() !== month - 1 || check.getUTCDate() !== day || check.getUTCHours() !== hour || check.getUTCMinutes() !== minute || check.getUTCSeconds() !== second ) { throw new Error("WAKE402_AT must be a real UTC timestamp"); } return timestamp; } function canonicalizeWakeRequest( body: WakeRequestBody, nowMs: number, ): CanonicalWakeRequest { if ( !isRecord(body) || Object.keys(body).some(key => key !== "callback" && key !== "at") ) { throw new Error("request body may contain only callback and at"); } const callback = canonicalizeWake402Callback(body.callback); const scheduledMs = parseUtcTimestamp(body.at); const delay = scheduledMs - nowMs; if (delay < MIN_DELAY_MS || delay > MAX_DELAY_MS) { throw new Error("WAKE402_AT must be 60 seconds to 24 hours ahead"); } return { callback, at: new Date(scheduledMs).toISOString(), }; } function canonicalizeWakeAfterRequest( body: WakeAfterRequestBody, ): CanonicalWakeAfterRequest { if ( !isRecord(body) || Object.keys(body).some( key => key !== "callback" && key !== "delay_seconds", ) ) { throw new Error("request body may contain only callback and delay_seconds"); } const delaySeconds = body.delay_seconds; if ( typeof delaySeconds !== "number" || !Number.isSafeInteger(delaySeconds) || delaySeconds < MIN_DELAY_MS / 1_000 || delaySeconds > MAX_DELAY_MS / 1_000 ) { throw new Error("delay_seconds must be an integer from 60 through 86400"); } return { callback: canonicalizeWake402Callback(body.callback), delaySeconds, }; } async function sha256Hex(value: string): Promise { const digest = await crypto.subtle.digest( "SHA-256", new TextEncoder().encode(value), ); return [...new Uint8Array(digest)] .map(byte => byte.toString(16).padStart(2, "0")) .join(""); } async function requestFingerprint(request: CanonicalWakeRequest): Promise { const canonical = JSON.stringify({ callback: request.callback, at: request.at, }); return `0x${await sha256Hex(canonical)}`; } async function relativeRequestFingerprint( request: CanonicalWakeAfterRequest, ): Promise { const canonical = JSON.stringify({ protocol: RELATIVE_REQUEST_FINGERPRINT_PROTOCOL, callback: request.callback, delay_seconds: request.delaySeconds, }); return `0x${await sha256Hex(canonical)}`; } function expectedCommitmentInfo(expectation: RequirementExpectation) { const common = { required: true, version: expectation.commitmentVersion, signatureScheme: "eip712", domain: { name: "WAKE402", version: expectation.commitmentVersion, chainId: "derived from accepted.network", verifyingContract: "accepted.asset", }, primaryType: expectation.commitmentPrimaryType, signedFields: [...expectation.signedFields], }; if (expectation.path === "/v1/wake-after") { return { ...common, requestCanonicalization: { callback: CALLBACK_CANONICALIZATION, delaySeconds: "validated request delay_seconds integer from 60 through 86400", requestFingerprint: 'sha256(utf8(JSON.stringify({protocol:"WAKE402_RELATIVE_REQUEST_V1",callback:,delay_seconds:})))', }, scheduledTimeDerivation: { anchor: "immutable payment_attempts.first_verified_at", formula: "scheduled_at = first_verified_at + delay_seconds", recomputeFromRetryTime: false, }, payerBinding: PAYER_BINDING, proofPath: COMMITMENT_PROOF_PATH, }; } return { ...common, requestCanonicalization: { callback: CALLBACK_CANONICALIZATION, at: "validated UTC timestamp serialized with Date.toISOString()", requestFingerprint: "sha256(utf8(JSON.stringify({callback:,at:})))", }, payerBinding: PAYER_BINDING, proofPath: COMMITMENT_PROOF_PATH, }; } function requirementSummary( paymentRequired: PaymentRequired, origin: string, expectation: RequirementExpectation = ABSOLUTE_REQUIREMENT, ): RequirementSummary { requireEqual(paymentRequired.x402Version, 2, "x402Version"); requireEqual( paymentRequired.resource?.url, expectedEndpoint(origin, expectation.path), "resource.url", ); requireEqual(paymentRequired.resource?.mimeType, "application/json", "resource.mimeType"); if (!Array.isArray(paymentRequired.accepts) || paymentRequired.accepts.length !== 1) { fail("accepts must contain exactly one payment option"); } const accepted = paymentRequired.accepts[0]!; requireEqual(accepted.scheme, "exact", "scheme"); requireEqual(accepted.network, EXPECTED_NETWORK, "network"); requireEqual(accepted.amount, EXPECTED_AMOUNT, "amount"); requireEqual(accepted.maxTimeoutSeconds, 60, "maxTimeoutSeconds"); try { if (!isAddressEqual(getAddress(accepted.asset), EXPECTED_USDC)) { fail("asset is not Base Mainnet USDC"); } if (!isAddressEqual(getAddress(accepted.payTo), EXPECTED_PAY_TO)) { fail("payTo is not the expected WAKE402 recipient"); } } catch { fail("asset or payTo is not a valid EVM address"); } requireEqual(accepted.extra?.name, "USD Coin", "extra.name"); requireEqual(accepted.extra?.version, "2", "extra.version"); if ( accepted.extra?.assetTransferMethod !== undefined && accepted.extra.assetTransferMethod !== "eip3009" ) { fail("assetTransferMethod is not EIP-3009 compatible"); } const paymentIdentifier = paymentRequired.extensions?.[PAYMENT_IDENTIFIER]; if ( !isPaymentIdentifierExtension(paymentIdentifier) || !isPaymentIdentifierRequired(paymentIdentifier) ) { fail("required Payment Identifier declaration is missing"); } if ( isRecord(paymentIdentifier) && isRecord(paymentIdentifier.info) && paymentIdentifier.info.id !== undefined ) { fail("Payment Identifier declaration must not preselect a buyer identifier"); } const commitment = paymentRequired.extensions?.[WAKE402_REQUEST_COMMITMENT]; if (!isRecord(commitment) || !isRecord(commitment.info)) { fail("wake402-request-commitment declaration is missing"); } requireJsonEqual( commitment.info, expectedCommitmentInfo(expectation), "request commitment metadata", ); requireJsonEqual( commitment.schema, COMMITMENT_PROOF_SCHEMA, "request commitment proof schema", ); return { x402Version: 2, scheme: "exact", network: EXPECTED_NETWORK, amount: EXPECTED_AMOUNT, asset: EXPECTED_USDC, payTo: EXPECTED_PAY_TO, paymentIdentifierRequired: true, requestCommitmentRequired: true, requestCommitmentVersion: expectation.commitmentVersion, }; } function validateDeliveryContract(value: unknown): Wake402DeliveryContract { requireJsonEqual( value, WAKE402_SUPPORTED_DELIVERY_CONTRACT, "wake402 delivery contract", ); return value as Wake402DeliveryContract; } async function parsePaymentRequired( response: Response, origin: string, expectation: RequirementExpectation = ABSOLUTE_REQUIREMENT, ): Promise<{ paymentRequired: PaymentRequired; summary: RequirementSummary; deliveryContract: Wake402DeliveryContract; }> { const header = response.headers.get("PAYMENT-REQUIRED"); if (!header) fail("HTTP 402 did not include PAYMENT-REQUIRED"); let paymentRequired: PaymentRequired; try { paymentRequired = decodePaymentRequiredHeader(header); } catch { fail("PAYMENT-REQUIRED is malformed"); } const body = await safeJson(response); if (!isRecord(body)) fail("HTTP 402 body is not an object"); const deliveryContract = validateDeliveryContract( body.wake402_delivery_contract, ); return { paymentRequired, summary: requirementSummary(paymentRequired!, origin, expectation), deliveryContract, }; } export function createWake402RequestCommitmentClientExtension(options: { signer: ClientEvmSigner; request: WakeRequestBody; now?: () => number; }): ClientExtension { return { key: WAKE402_REQUEST_COMMITMENT, enrichPaymentPayload: async ( paymentPayload: PaymentPayload, paymentRequired: PaymentRequired, ) => { if (!paymentRequired.extensions?.[WAKE402_REQUEST_COMMITMENT]) { return paymentPayload; } const identifierValidation = validatePaymentIdentifierRequirement( paymentPayload, true, ); const paymentIdentifier = extractPaymentIdentifier(paymentPayload); if (!identifierValidation.valid || !paymentIdentifier) { throw new Error("valid Payment Identifier is required before commitment signing"); } const request = canonicalizeWakeRequest( options.request, options.now?.() ?? Date.now(), ); const accepted = paymentPayload.accepted; const chainId = BigInt(accepted.network.split(":")[1]!); const signature = await options.signer.signTypedData({ domain: { name: "WAKE402", version: REQUEST_COMMITMENT_VERSION, chainId, verifyingContract: getAddress(accepted.asset), }, types: wakeRequestTypes, primaryType: REQUEST_COMMITMENT_PRIMARY_TYPE, message: { protocol: REQUEST_COMMITMENT_PROTOCOL, paymentIdentifier, requestFingerprint: await requestFingerprint(request), callback: request.callback, at: request.at, network: accepted.network, asset: getAddress(accepted.asset), amount: BigInt(accepted.amount), payTo: getAddress(accepted.payTo), }, }); const current = paymentPayload.extensions?.[WAKE402_REQUEST_COMMITMENT]; const extension = isRecord(current) ? current : {}; return { ...paymentPayload, extensions: { ...paymentPayload.extensions, [WAKE402_REQUEST_COMMITMENT]: { ...extension, proof: { signature }, }, }, }; }, }; } /** * Register this helper when purchasing POST /v1/wake-after. The server anchors * delay_seconds once to its immutable first_verified_at value; retries never * derive a later scheduled time from the retry clock. */ export function createWake402RelativeRequestCommitmentClientExtension(options: { signer: ClientEvmSigner; request: WakeAfterRequestBody; }): ClientExtension { return { key: WAKE402_REQUEST_COMMITMENT, enrichPaymentPayload: async ( paymentPayload: PaymentPayload, paymentRequired: PaymentRequired, ) => { if (!paymentRequired.extensions?.[WAKE402_REQUEST_COMMITMENT]) { return paymentPayload; } const identifierValidation = validatePaymentIdentifierRequirement( paymentPayload, true, ); const paymentIdentifier = extractPaymentIdentifier(paymentPayload); if (!identifierValidation.valid || !paymentIdentifier) { throw new Error("valid Payment Identifier is required before commitment signing"); } const request = canonicalizeWakeAfterRequest(options.request); const accepted = paymentPayload.accepted; const chainId = BigInt(accepted.network.split(":")[1]!); const signature = await options.signer.signTypedData({ domain: { name: "WAKE402", version: RELATIVE_REQUEST_COMMITMENT_VERSION, chainId, verifyingContract: getAddress(accepted.asset), }, types: wakeAfterRequestTypes, primaryType: RELATIVE_REQUEST_COMMITMENT_PRIMARY_TYPE, message: { protocol: RELATIVE_REQUEST_COMMITMENT_PROTOCOL, paymentIdentifier, requestFingerprint: await relativeRequestFingerprint(request), callback: request.callback, delaySeconds: BigInt(request.delaySeconds), network: accepted.network, asset: getAddress(accepted.asset), amount: BigInt(accepted.amount), payTo: getAddress(accepted.payTo), }, }); const current = paymentPayload.extensions?.[WAKE402_REQUEST_COMMITMENT]; const extension = isRecord(current) ? current : {}; return { ...paymentPayload, extensions: { ...paymentPayload.extensions, [WAKE402_REQUEST_COMMITMENT]: { ...extension, proof: { signature }, }, }, }; }, }; } function assertCreatedPaymentPayload( paymentPayload: PaymentPayload, paymentIdentifier: string, signer: ClientEvmSigner, ): void { const identifierValidation = validatePaymentIdentifierRequirement( paymentPayload, true, ); if ( !identifierValidation.valid || extractPaymentIdentifier(paymentPayload) !== paymentIdentifier ) { throw new Error("created payment payload lost its unique Payment Identifier"); } const commitment = paymentPayload.extensions?.[WAKE402_REQUEST_COMMITMENT]; if ( !isRecord(commitment) || !isRecord(commitment.proof) || typeof commitment.proof.signature !== "string" || !/^0x[0-9a-fA-F]{128}(?:[0-9a-fA-F]{2})?$/.test( commitment.proof.signature, ) ) { throw new Error("created payment payload is missing the commitment proof"); } const authorization = isRecord(paymentPayload.payload) ? paymentPayload.payload.authorization : undefined; if (!isRecord(authorization) || typeof authorization.from !== "string") { throw new Error("created payment payload is missing the Exact-EVM payer"); } try { if ( !isAddressEqual( getAddress(authorization.from), getAddress(signer.address), ) ) { throw new Error("created payment payload payer does not match the injected signer"); } } catch (error) { if ( error instanceof Error && error.message === "created payment payload payer does not match the injected signer" ) { throw error; } throw new Error("created payment payload contains an invalid Exact-EVM payer"); } } function ambiguousPaidResult(): Error { return new Error( "paid request result is ambiguous; do not create a new Payment Identifier or authorization; retry only the identical body and exact PAYMENT-SIGNATURE", ); } /** * Reusable buyer entry point for the Bazaar-capable POST /v1/wake-after route. * It creates one authorization pair, sends one paid attempt, and never retries * by creating fresh buyer signatures after an ambiguous result. */ export async function buyWake402WakeAfter( options: Wake402BuyerOptions, ): Promise { const origin = options.origin ?? WAKE402_ORIGIN; const endpoint = expectedEndpoint(origin, RELATIVE_REQUIREMENT.path); const fetchImpl = options.fetchImpl ?? fetch; if ( !isRecord(options.request) || Object.keys(options.request).some( key => key !== "callback" && key !== "delay_seconds", ) ) { throw new Error("request body may contain only callback and delay_seconds"); } const request: WakeAfterRequestBody = { callback: options.request.callback as string, delay_seconds: options.request.delay_seconds as number, }; canonicalizeWakeAfterRequest(request); const exactRequestBody = JSON.stringify(request); const unpaid = await fetchImpl(endpoint, { method: "POST", headers: { Accept: "application/json", "Content-Type": "application/json", }, body: exactRequestBody, }); if (unpaid.status !== 402) { throw new Error(`expected initial HTTP 402, received HTTP ${unpaid.status}`); } const { paymentRequired, summary, deliveryContract } = await parsePaymentRequired(unpaid, origin, RELATIVE_REQUIREMENT); if (options.dryRun) { return { dryRun: true, requirements: summary, deliveryContract }; } if (!options.signer) { throw new Error("an injected ClientEvmSigner is required outside dry-run mode"); } // Requirements are fully validated before either signature is requested. const signer = options.signer; const paymentIdentifier = options.paymentIdFactory?.() ?? generatePaymentId("pay_wake402_"); const extensions = structuredClone(paymentRequired.extensions ?? {}); appendPaymentIdentifierToExtensions(extensions, paymentIdentifier); // The official Exact-EVM payment and existing WAKE402 commitment helper use // the same injected signer. No buyer secret is sent to or stored by WAKE402. const client = new x402Client() .register(EXPECTED_NETWORK, new ExactEvmScheme(signer)) .registerExtension( createWake402RelativeRequestCommitmentClientExtension({ signer, request, }), ); const paymentPayload = await client.createPaymentPayload({ ...paymentRequired, extensions, }); assertCreatedPaymentPayload(paymentPayload, paymentIdentifier, signer); const paymentSignature = encodePaymentSignatureHeader(paymentPayload); let paid: Response; try { paid = await fetchImpl(endpoint, { method: "POST", headers: { Accept: "application/json", "Content-Type": "application/json", "PAYMENT-SIGNATURE": paymentSignature, }, body: exactRequestBody, }); } catch { throw ambiguousPaidResult(); } if (paid.status !== 201) { throw ambiguousPaidResult(); } let created: WakeCreated; try { created = assertCreated(await safeJson(paid), origin); } catch { throw ambiguousPaidResult(); } return { dryRun: false, wake_id: created.wake_id, scheduled_for: created.scheduled_for, status_url: created.status_url, paymentIdentifier, deliveryContract, }; } function resolveSigner(options: Wake402ClientOptions): ClientEvmSigner { if (options.signer && options.buyerPrivateKey) { throw new Error("provide either an injected signer or WAKE402_BUYER_PRIVATE_KEY, not both"); } if (options.signer) return options.signer; if (!options.buyerPrivateKey) { throw new Error("WAKE402_BUYER_PRIVATE_KEY is required outside dry-run mode"); } if (!/^0x[0-9a-fA-F]{64}$/.test(options.buyerPrivateKey)) { throw new Error("WAKE402_BUYER_PRIVATE_KEY must be a 32-byte 0x-prefixed key"); } return privateKeyToAccount(options.buyerPrivateKey as Hex); } function isCanonicalIsoDateTime(value: unknown): value is string { if ( typeof value !== "string" || !/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/.test(value) ) { return false; } const timestamp = Date.parse(value); return ( Number.isFinite(timestamp) && new Date(timestamp).toISOString() === value ); } function assertCreated(value: unknown, origin: string): WakeCreated { if (!isRecord(value)) throw new Error("HTTP 201 body is not an object"); const expectedKeys = [ "delivery_semantics", "scheduled_for", "status", "status_url", "wake_id", ]; if ( Object.keys(value).sort().some((key, index) => key !== expectedKeys[index]) || Object.keys(value).length !== expectedKeys.length || typeof value.wake_id !== "string" || !/^wk_[a-f0-9]{32}$/.test(value.wake_id) || value.status !== "scheduled" || !isCanonicalIsoDateTime(value.scheduled_for) || value.delivery_semantics !== "at-least-once" || typeof value.status_url !== "string" ) { throw new Error("HTTP 201 body is not a strict WAKE402 WakeCreated response"); } let statusUrl: URL; try { statusUrl = new URL(value.status_url); } catch { throw new Error("HTTP 201 status_url is invalid"); } const expectedOrigin = new URL(origin).origin; if ( statusUrl.protocol !== "https:" || statusUrl.origin !== expectedOrigin || statusUrl.username !== "" || statusUrl.password !== "" || statusUrl.hash !== "" || statusUrl.search !== "" || statusUrl.pathname !== `/v1/wakes/${value.wake_id}` ) { throw new Error("HTTP 201 status_url does not match the created wake"); } return value as unknown as WakeCreated; } async function safeJson(response: Response): Promise { try { return await response.json(); } catch { throw new Error(`HTTP ${response.status} response was not valid JSON`); } } export async function runWake402Client( options: Wake402ClientOptions, ): Promise { const origin = options.origin ?? WAKE402_ORIGIN; const endpoint = expectedEndpoint(origin); const fetchImpl = options.fetchImpl ?? fetch; const now = options.now ?? Date.now; const request: WakeRequestBody = { callback: options.callbackUrl, at: options.at ?? new Date(now() + 5 * 60_000).toISOString(), }; canonicalizeWakeRequest(request, now()); const exactRequestBody = JSON.stringify(request); const unpaid = await fetchImpl(endpoint, { method: "POST", headers: { Accept: "application/json", "Content-Type": "application/json", }, body: exactRequestBody, }); if (unpaid.status !== 402) { throw new Error(`expected initial HTTP 402, received HTTP ${unpaid.status}`); } const { paymentRequired, summary, deliveryContract } = await parsePaymentRequired(unpaid, origin); if (options.dryRun) { options.log?.( JSON.stringify( { dry_run: true, requirements: summary, delivery_contract: deliveryContract, }, null, 2, ), ); return { dryRun: true, requirements: summary, deliveryContract }; } // Requirements are fully validated before the signer is created or called. const signer = resolveSigner(options); const paymentIdentifier = options.paymentIdFactory?.() ?? generatePaymentId("pay_wake402_"); const extensions = structuredClone(paymentRequired.extensions ?? {}); appendPaymentIdentifierToExtensions(extensions, paymentIdentifier); const clientRequired: PaymentRequired = { ...paymentRequired, extensions }; // The exact same signer object is used by both signature paths. const client = new x402Client() .register(EXPECTED_NETWORK, new ExactEvmScheme(signer)) .registerExtension( createWake402RequestCommitmentClientExtension({ signer, request, now, }), ); const paymentPayload = await client.createPaymentPayload(clientRequired); assertCreatedPaymentPayload(paymentPayload, paymentIdentifier, signer); const paid = await fetchImpl(endpoint, { method: "POST", headers: { Accept: "application/json", "Content-Type": "application/json", "PAYMENT-SIGNATURE": encodePaymentSignatureHeader(paymentPayload), }, body: exactRequestBody, }); if (paid.status !== 201) { throw new Error( `paid retry returned HTTP ${paid.status}; do not create a fresh authorization automatically`, ); } const created = assertCreated(await safeJson(paid), origin); let observation: Record | undefined; if (options.pollStatus) { const observed = await fetchImpl(created.status_url, { headers: { Accept: "application/json" }, }); if (!observed.ok) { throw new Error(`status_url returned HTTP ${observed.status}`); } const value = await safeJson(observed); if (!isRecord(value)) throw new Error("status_url response is not an object"); observation = Object.fromEntries( ["wake_id", "status", "scheduled_at", "attempt_count", "delivered_at"] .filter(key => key in value) .map(key => [key, value[key]]), ); } const result: Wake402ClientResult = { dryRun: false, wake_id: created.wake_id, scheduled_for: created.scheduled_for, status_url: created.status_url, paymentIdentifier, deliveryContract, ...(observation ? { observation } : {}), }; options.log?.( JSON.stringify( { wake_id: result.wake_id, scheduled_for: result.scheduled_for, status_url: result.status_url, ...(observation ? { observation } : {}), }, null, 2, ), ); return result; }