Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,7 @@ Circle Gateway batches many signed offchain authorizations into a single onchain
Requires a [Supabase](https://supabase.com/) account and project.

```bash
npx supabase link --project-ref <your-project-ref>
npx supabase link --project-ref tjjptretakkntnqyooal
npx supabase db push
```

Expand Down
29 changes: 26 additions & 3 deletions agent.mts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,27 @@ let { spendingLimit } = parseArgs();
let totalSpent = 0;
let paused = false;

function formatPaymentError(err: unknown) {
if (err instanceof Error) {
const cause = err.cause as
| { shortMessage?: string; details?: string }
| undefined;
const parts = [err.message];

if (cause?.shortMessage && !parts.includes(cause.shortMessage)) {
parts.push(cause.shortMessage);
}

if (cause?.details && !parts.includes(cause.details)) {
parts.push(cause.details);
}

return parts.join(" | ");
}

return String(err);
}

if (spendingLimit !== null) {
console.log(`Spending limit: ${spendingLimit} USDC`);
}
Expand Down Expand Up @@ -260,7 +281,8 @@ function startPaymentLoop() {
if (paused) return;

const ep = endpoints[index % endpoints.length];
index++;
const requestId = ++index;
const endpointName = ep.url.split("/").pop();
inFlight++;

const start = Date.now();
Expand All @@ -276,7 +298,7 @@ function startPaymentLoop() {
? ` [spent: ${totalSpent.toFixed(6)}/${spendingLimit.toFixed(6)} USDC]`
: "";
console.log(
`#${index} ${ep.method} ${ep.url.split("/").pop()} -> ${result.formattedAmount} USDC (${ms}ms) [in-flight: ${inFlight}]${limitInfo}`,
`#${requestId} ${ep.method} ${endpointName} -> ${result.formattedAmount} USDC (${ms}ms) [in-flight: ${inFlight}]${limitInfo}`,
);

if (spendingLimit !== null && totalSpent >= spendingLimit) {
Expand All @@ -286,8 +308,9 @@ function startPaymentLoop() {
.catch((err) => {
inFlight--;
const ms = Date.now() - start;
const errorDetails = formatPaymentError(err);
console.error(
`#${index} ${ep.url.split("/").pop()} FAILED (${ms}ms): ${err.message} [in-flight: ${inFlight}]`,
`#${requestId} ${ep.method} ${endpointName} FAILED (${ms}ms): ${errorDetails} [in-flight: ${inFlight}]`,
);
});
}, 1000);
Expand Down
69 changes: 63 additions & 6 deletions lib/x402.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,8 +42,45 @@ interface PaymentPayload {
extensions?: Record<string, unknown>;
}

function buildPaymentRequirements(price: string) {
// Parse dollar amount to USDC atomic units (6 decimals)
interface SupportedKind {
scheme: string;
network: string;
extra?: {
verifyingContract?: string;
};
}

let cachedArcVerifyingContract: string | null = null;

async function getArcVerifyingContract() {
if (cachedArcVerifyingContract) {
return cachedArcVerifyingContract;
}

try {
const supported = await facilitator.getSupported();
const arcKind = (supported.kinds as SupportedKind[]).find(
(kind) =>
kind.scheme === "exact" &&
kind.network === ARC_TESTNET_NETWORK &&
typeof kind.extra?.verifyingContract === "string",
);

if (arcKind?.extra?.verifyingContract) {
cachedArcVerifyingContract = arcKind.extra.verifyingContract;
return cachedArcVerifyingContract;
}
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
console.error("[x402] Failed to fetch supported kinds:", message);
}

// Fallback keeps the route operational if supported lookup is transiently unavailable.
return ARC_TESTNET_GATEWAY_WALLET;
}

async function buildPaymentRequirements(price: string) {
const verifyingContract = await getArcVerifyingContract();
const amount = Math.round(parseFloat(price.replace("$", "")) * 1_000_000);

return {
Expand All @@ -52,11 +89,13 @@ function buildPaymentRequirements(price: string) {
asset: ARC_TESTNET_USDC,
amount: amount.toString(),
payTo: sellerAddress,
maxTimeoutSeconds: 345600,
// Gateway currently rejects short authorization windows.
// Use a longer validity period to avoid authorization_validity_too_short.
maxTimeoutSeconds: 31536000,
extra: {
name: "GatewayWalletBatched",
version: "1",
verifyingContract: ARC_TESTNET_GATEWAY_WALLET,
verifyingContract,
},
};
}
Expand All @@ -72,9 +111,8 @@ export function withGateway(
price: string,
endpoint: string,
) {
const requirements = buildPaymentRequirements(price);

return async (req: NextRequest) => {
const requirements = await buildPaymentRequirements(price);
const paymentSignature = req.headers.get("payment-signature");

// No payment — return 402 with Gateway batching payment requirements
Expand Down Expand Up @@ -108,12 +146,31 @@ export function withGateway(
Buffer.from(paymentSignature, "base64").toString("utf-8"),
);

const acceptedNetwork =
typeof paymentPayload.accepted?.network === "string"
? paymentPayload.accepted.network
: null;

if (acceptedNetwork && acceptedNetwork !== requirements.network) {
return NextResponse.json(
{
error: "Unsupported payment network",
expected: requirements.network,
received: acceptedNetwork,
},
{ status: 400 },
);
}

const verifyResult = await facilitator.verify(
paymentPayload,
requirements,
);

if (!verifyResult.isValid) {
console.error(
`[x402] Verification failed for ${endpoint}: ${verifyResult.invalidReason ?? "unknown"}`,
);
return NextResponse.json(
{
error: "Payment verification failed",
Expand Down
2 changes: 1 addition & 1 deletion package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.