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
23 changes: 23 additions & 0 deletions node/src/agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { sha256 } from "./crypto.js";
import { AiFinPayError, FundingTimeoutError, X402Error } from "./errors.js";
import { detectFacilitator } from "./facilitators/detect.js";
import type { PayOptions } from "./facilitators/base.js";
import { privateKeyToAccount, type PrivateKeyAccount } from "viem/accounts";

// Canonical domain is aifinpay.io. The legacy aifinpay.company host is
// fully retired (DNS removed) — do not use it.
Expand Down Expand Up @@ -107,6 +108,28 @@ export class Agent {
return bs58.encode(this.secretKey);
}

private _evm?: PrivateKeyAccount;
/**
* The agent's EVM account (viem), derived from the same 32-byte seed as the
* Solana key via domain-separated SHA-256 ("aifinpay:evm:v1\0" || seed) —
* byte-for-byte identical to AiFinPayAgent's EVM address. Used by the
* standard x402 (EIP-3009) facilitator. Node-only (sync SHA-256).
*/
async evmAccount(): Promise<PrivateKeyAccount> {
if (this._evm) return this._evm;
const { createHash } = await import("node:crypto");
const h = createHash("sha256");
h.update("aifinpay:evm:v1\0");
h.update(this.secretKey.subarray(0, 32));
this._evm = privateKeyToAccount(`0x${h.digest("hex")}` as `0x${string}`);
return this._evm;
}

/** The agent's EVM (Polygon / Base / …) address. */
async evmAddress(): Promise<string> {
return (await this.evmAccount()).address;
}

// ── Discovery ──────────────────────────────────────────────────────────

async manifesto(): Promise<Record<string, unknown>> {
Expand Down
2 changes: 2 additions & 0 deletions node/src/facilitators/detect.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { UnsupportedFacilitatorError } from "../errors.js";
import { AiFinPayFacilitator } from "./aifinpay.js";
import type { Facilitator, FacilitatorClass } from "./base.js";
import { CoinbaseX402Facilitator } from "./coinbase.js";
import { StandardX402Facilitator } from "./standard-x402.js";

/**
* Order matters: most-specific detector first. A response that matches
Expand All @@ -10,6 +11,7 @@ import { CoinbaseX402Facilitator } from "./coinbase.js";
*/
export const REGISTERED: FacilitatorClass[] = [
AiFinPayFacilitator,
StandardX402Facilitator,
CoinbaseX402Facilitator,
];

Expand Down
166 changes: 166 additions & 0 deletions node/src/facilitators/standard-x402.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,166 @@
import type { Agent } from "../agent.js";
import {
PaymentTooExpensiveError,
UnsupportedFacilitatorError,
} from "../errors.js";
import type { AuthPayload, Facilitator, PayOptions } from "./base.js";

/**
* Standard x402 — the `X-PAYMENT` header flow (x402 Foundation / Linux
* Foundation standard, donated by Coinbase 2026-04). This is what makes
* AiFinPay agents interoperable with the wider x402 economy (Coinbase, Dexter,
* 69k+ agents) — distinct from our native `aifinpay` flavor.
*
* Wire format:
* - 402 body: { x402Version, accepts: [ { scheme:"exact", network,
* maxAmountRequired, payTo, asset, maxTimeoutSeconds, extra:{name,version} }, … ] }
* - Client signs an EIP-3009 TransferWithAuthorization (gasless) and retries with
* X-PAYMENT: base64(JSON({ x402Version, scheme, network, payload }))
*
* EVM `exact` only for now; Solana `exact` is a follow-up.
*/

const CHAIN_IDS: Record<string, number> = {
base: 8453,
"base-sepolia": 84532,
ethereum: 1,
mainnet: 1,
polygon: 137,
"polygon-amoy": 80002,
arbitrum: 42161,
optimism: 10,
avalanche: 43114,
bsc: 56,
};

const TRANSFER_WITH_AUTHORIZATION_TYPES = {
TransferWithAuthorization: [
{ name: "from", type: "address" },
{ name: "to", type: "address" },
{ name: "value", type: "uint256" },
{ name: "validAfter", type: "uint256" },
{ name: "validBefore", type: "uint256" },
{ name: "nonce", type: "bytes32" },
],
} as const;

export class StandardX402Facilitator implements Facilitator {
static readonly name = "x402";
readonly name = "x402";

static async detect(resp: Response): Promise<boolean> {
if (resp.status !== 402) return false;
let body: unknown;
try {
body = await resp.clone().json();
} catch {
return false;
}
if (typeof body !== "object" || body === null) return false;
const b = body as Record<string, unknown>;
return "x402Version" in b && Array.isArray(b.accepts);
}

async buildAuth(
resp: Response,
agent: Agent,
opts: PayOptions,
): Promise<AuthPayload> {
const body = (await resp.clone().json()) as {
x402Version?: number;
accepts?: Array<Record<string, unknown>>;
};
const accepts = body.accepts ?? [];

// Pick the first `exact` requirement on an EVM chain we know how to pay.
const req = accepts.find(
(a) =>
a.scheme === "exact" &&
typeof a.network === "string" &&
CHAIN_IDS[a.network] !== undefined,
);
if (!req) {
throw new UnsupportedFacilitatorError(
"standard x402 detected but no payable EVM `exact` requirement " +
`(offered: ${accepts.map((a) => `${a.scheme}/${a.network}`).join(", ") || "none"})`,
);
}

const network = req.network as string;
const asset = String(req.asset ?? "");
const payTo = String(req.payTo ?? "");
const value = String(req.maxAmountRequired ?? "0");
if (!asset || !payTo) {
throw new UnsupportedFacilitatorError(
"standard x402 requirement is missing `asset` or `payTo`",
);
}

// Best-effort USD cap (assumes 6-decimal USDC/EURC pricing).
if (opts.maxAmountUsd !== undefined) {
const approxUsd = Number(value) / 1e6;
if (Number.isFinite(approxUsd) && approxUsd > opts.maxAmountUsd) {
throw new PaymentTooExpensiveError(
`x402 wants ~$${approxUsd.toFixed(4)}, caller cap is $${opts.maxAmountUsd.toFixed(4)}`,
);
}
}

const account = await agent.evmAccount();
const extra = (req.extra ?? {}) as Record<string, unknown>;
const timeout = Number(req.maxTimeoutSeconds ?? 600);
const now = Math.floor(Date.now() / 1000);
const validBefore = String(now + (Number.isFinite(timeout) ? timeout : 600));
const nonce = randomNonce();

const authorization = {
from: account.address,
to: payTo as `0x${string}`,
value,
validAfter: "0",
validBefore,
nonce,
};

const signature = await account.signTypedData({
domain: {
name: String(extra.name ?? "USD Coin"),
version: String(extra.version ?? "2"),
chainId: CHAIN_IDS[network],
verifyingContract: asset as `0x${string}`,
},
types: TRANSFER_WITH_AUTHORIZATION_TYPES,
primaryType: "TransferWithAuthorization",
message: {
from: authorization.from,
to: authorization.to,
value: BigInt(value),
validAfter: 0n,
validBefore: BigInt(validBefore),
nonce,
},
});

const paymentPayload = {
x402Version: body.x402Version ?? 1,
scheme: "exact",
network,
payload: { signature, authorization },
};

const header =
typeof Buffer !== "undefined"
? Buffer.from(JSON.stringify(paymentPayload)).toString("base64")
: btoa(JSON.stringify(paymentPayload));

return { headers: { "X-PAYMENT": header } };
}
}

function randomNonce(): `0x${string}` {
const b = new Uint8Array(32);
(globalThis.crypto ?? crypto).getRandomValues(b);
let s = "0x";
for (const x of b) s += x.toString(16).padStart(2, "0");
return s as `0x${string}`;
}