repositories
loading repo index
repositories
loading repo index
repository
loading code, commits, and activity
public Clawd ADK gateway launch mirror
stars
latest
clone command
git clone gitlawb://did:key:z6Mkq5mY...iFZ5/my-project-publ...git clone gitlawb://did:key:z6Mkq5mY.../my-project-publ...2fa351d6docs: add automaton and perps launch sources16d ago| #1 | /** |
| #2 | * x402 Payment Protocol |
| #3 | * |
| #4 | * Enables the automaton to make USDC micropayments via HTTP 402. |
| #5 | * Adapted from runtime-mcp/src/x402/index.ts |
| #6 | */ |
| #7 | |
| #8 | import { |
| #9 | createPublicClient, |
| #10 | http, |
| #11 | parseUnits, |
| #12 | type Address, |
| #13 | type PrivateKeyAccount, |
| #14 | } from "viem"; |
| #15 | import { base, baseSepolia } from "viem/chains"; |
| #16 | |
| #17 | // USDC contract addresses |
| #18 | const USDC_ADDRESSES: Record<string, Address> = { |
| #19 | "eip155:8453": "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913", // Base mainnet |
| #20 | "eip155:84532": "0x036CbD53842c5426634e7929541eC2318f3dCF7e", // Base Sepolia |
| #21 | }; |
| #22 | |
| #23 | const CHAINS: Record<string, any> = { |
| #24 | "eip155:8453": base, |
| #25 | "eip155:84532": baseSepolia, |
| #26 | }; |
| #27 | type NetworkId = keyof typeof USDC_ADDRESSES; |
| #28 | |
| #29 | const BALANCE_OF_ABI = [ |
| #30 | { |
| #31 | inputs: [{ name: "account", type: "address" }], |
| #32 | name: "balanceOf", |
| #33 | outputs: [{ name: "", type: "uint256" }], |
| #34 | stateMutability: "view", |
| #35 | type: "function", |
| #36 | }, |
| #37 | ] as const; |
| #38 | |
| #39 | interface PaymentRequirement { |
| #40 | scheme: string; |
| #41 | network: NetworkId; |
| #42 | maxAmountRequired: string; |
| #43 | payToAddress: Address; |
| #44 | requiredDeadlineSeconds: number; |
| #45 | usdcAddress: Address; |
| #46 | } |
| #47 | |
| #48 | interface PaymentRequiredResponse { |
| #49 | x402Version: number; |
| #50 | accepts: PaymentRequirement[]; |
| #51 | } |
| #52 | |
| #53 | interface ParsedPaymentRequirement { |
| #54 | x402Version: number; |
| #55 | requirement: PaymentRequirement; |
| #56 | } |
| #57 | |
| #58 | interface X402PaymentResult { |
| #59 | success: boolean; |
| #60 | response?: any; |
| #61 | error?: string; |
| #62 | status?: number; |
| #63 | } |
| #64 | |
| #65 | export interface UsdcBalanceResult { |
| #66 | balance: number; |
| #67 | network: string; |
| #68 | ok: boolean; |
| #69 | error?: string; |
| #70 | } |
| #71 | |
| #72 | function safeJsonParse(value: string): unknown | null { |
| #73 | try { |
| #74 | return JSON.parse(value); |
| #75 | } catch { |
| #76 | return null; |
| #77 | } |
| #78 | } |
| #79 | |
| #80 | function parsePositiveInt(value: unknown): number | null { |
| #81 | if (typeof value === "number" && Number.isFinite(value) && value > 0) { |
| #82 | return Math.floor(value); |
| #83 | } |
| #84 | if (typeof value === "string" && value.trim()) { |
| #85 | const parsed = Number(value); |
| #86 | if (Number.isFinite(parsed) && parsed > 0) { |
| #87 | return Math.floor(parsed); |
| #88 | } |
| #89 | } |
| #90 | return null; |
| #91 | } |
| #92 | |
| #93 | function normalizeNetwork(raw: unknown): NetworkId | null { |
| #94 | if (typeof raw !== "string") return null; |
| #95 | const normalized = raw.trim().toLowerCase(); |
| #96 | if (normalized === "base") return "eip155:8453"; |
| #97 | if (normalized === "base-sepolia") return "eip155:84532"; |
| #98 | if (normalized === "eip155:8453" || normalized === "eip155:84532") { |
| #99 | return normalized; |
| #100 | } |
| #101 | return null; |
| #102 | } |
| #103 | |
| #104 | function normalizePaymentRequirement(raw: unknown): PaymentRequirement | null { |
| #105 | if (typeof raw !== "object" || raw === null) return null; |
| #106 | const value = raw as Record<string, unknown>; |
| #107 | const network = normalizeNetwork(value.network); |
| #108 | if (!network) return null; |
| #109 | |
| #110 | const scheme = typeof value.scheme === "string" ? value.scheme : null; |
| #111 | const maxAmountRequired = typeof value.maxAmountRequired === "string" |
| #112 | ? value.maxAmountRequired |
| #113 | : typeof value.maxAmountRequired === "number" && |
| #114 | Number.isFinite(value.maxAmountRequired) |
| #115 | ? String(value.maxAmountRequired) |
| #116 | : null; |
| #117 | const payToAddress = typeof value.payToAddress === "string" |
| #118 | ? value.payToAddress |
| #119 | : typeof value.payTo === "string" |
| #120 | ? value.payTo |
| #121 | : null; |
| #122 | const usdcAddress = typeof value.usdcAddress === "string" |
| #123 | ? value.usdcAddress |
| #124 | : typeof value.asset === "string" |
| #125 | ? value.asset |
| #126 | : USDC_ADDRESSES[network]; |
| #127 | const requiredDeadlineSeconds = |
| #128 | parsePositiveInt(value.requiredDeadlineSeconds) ?? |
| #129 | parsePositiveInt(value.maxTimeoutSeconds) ?? |
| #130 | 300; |
| #131 | |
| #132 | if (!scheme || !maxAmountRequired || !payToAddress || !usdcAddress) { |
| #133 | return null; |
| #134 | } |
| #135 | |
| #136 | return { |
| #137 | scheme, |
| #138 | network, |
| #139 | maxAmountRequired, |
| #140 | payToAddress: payToAddress as Address, |
| #141 | requiredDeadlineSeconds, |
| #142 | usdcAddress: usdcAddress as Address, |
| #143 | }; |
| #144 | } |
| #145 | |
| #146 | function normalizePaymentRequired(raw: unknown): PaymentRequiredResponse | null { |
| #147 | if (typeof raw !== "object" || raw === null) return null; |
| #148 | const value = raw as Record<string, unknown>; |
| #149 | if (!Array.isArray(value.accepts)) return null; |
| #150 | |
| #151 | const accepts = value.accepts |
| #152 | .map(normalizePaymentRequirement) |
| #153 | .filter((v): v is PaymentRequirement => v !== null); |
| #154 | if (!accepts.length) return null; |
| #155 | |
| #156 | const x402Version = parsePositiveInt(value.x402Version) ?? 1; |
| #157 | return { x402Version, accepts }; |
| #158 | } |
| #159 | |
| #160 | function parseMaxAmountRequired(maxAmountRequired: string, x402Version: number): bigint { |
| #161 | const amount = maxAmountRequired.trim(); |
| #162 | if (!/^\d+(\.\d+)?$/.test(amount)) { |
| #163 | throw new Error(`Invalid maxAmountRequired: ${maxAmountRequired}`); |
| #164 | } |
| #165 | |
| #166 | if (amount.includes(".")) { |
| #167 | return parseUnits(amount, 6); |
| #168 | } |
| #169 | if (x402Version >= 2 || amount.length > 6) { |
| #170 | return BigInt(amount); |
| #171 | } |
| #172 | return parseUnits(amount, 6); |
| #173 | } |
| #174 | |
| #175 | function selectRequirement(parsed: PaymentRequiredResponse): PaymentRequirement { |
| #176 | const exactSupported = parsed.accepts.find( |
| #177 | (r) => r.scheme === "exact" && !!CHAINS[r.network], |
| #178 | ); |
| #179 | if (exactSupported) return exactSupported; |
| #180 | return parsed.accepts[0]; |
| #181 | } |
| #182 | |
| #183 | /** |
| #184 | * Get the USDC balance for the automaton's wallet on a given network. |
| #185 | */ |
| #186 | export async function getUsdcBalance( |
| #187 | address: Address, |
| #188 | network: string = "eip155:8453", |
| #189 | ): Promise<number> { |
| #190 | const result = await getUsdcBalanceDetailed(address, network); |
| #191 | return result.balance; |
| #192 | } |
| #193 | |
| #194 | /** |
| #195 | * Get the USDC balance and read status details for diagnostics. |
| #196 | */ |
| #197 | export async function getUsdcBalanceDetailed( |
| #198 | address: Address, |
| #199 | network: string = "eip155:8453", |
| #200 | ): Promise<UsdcBalanceResult> { |
| #201 | const chain = CHAINS[network]; |
| #202 | const usdcAddress = USDC_ADDRESSES[network]; |
| #203 | if (!chain || !usdcAddress) { |
| #204 | return { |
| #205 | balance: 0, |
| #206 | network, |
| #207 | ok: false, |
| #208 | error: `Unsupported USDC network: ${network}`, |
| #209 | }; |
| #210 | } |
| #211 | |
| #212 | try { |
| #213 | const client = createPublicClient({ |
| #214 | chain, |
| #215 | transport: http(), |
| #216 | }); |
| #217 | |
| #218 | const balance = await client.readContract({ |
| #219 | address: usdcAddress, |
| #220 | abi: BALANCE_OF_ABI, |
| #221 | functionName: "balanceOf", |
| #222 | args: [address], |
| #223 | }); |
| #224 | |
| #225 | // USDC has 6 decimals |
| #226 | return { |
| #227 | balance: Number(balance) / 1_000_000, |
| #228 | network, |
| #229 | ok: true, |
| #230 | }; |
| #231 | } catch (err: any) { |
| #232 | return { |
| #233 | balance: 0, |
| #234 | network, |
| #235 | ok: false, |
| #236 | error: err?.message || String(err), |
| #237 | }; |
| #238 | } |
| #239 | } |
| #240 | |
| #241 | /** |
| #242 | * Check if a URL requires x402 payment. |
| #243 | */ |
| #244 | export async function checkX402( |
| #245 | url: string, |
| #246 | ): Promise<PaymentRequirement | null> { |
| #247 | try { |
| #248 | const resp = await fetch(url, { method: "GET" }); |
| #249 | if (resp.status !== 402) { |
| #250 | return null; |
| #251 | } |
| #252 | const parsed = await parsePaymentRequired(resp); |
| #253 | return parsed?.requirement ?? null; |
| #254 | } catch { |
| #255 | return null; |
| #256 | } |
| #257 | } |
| #258 | |
| #259 | /** |
| #260 | * Fetch a URL with automatic x402 payment. |
| #261 | * If the endpoint returns 402, sign and pay, then retry. |
| #262 | */ |
| #263 | export async function x402Fetch( |
| #264 | url: string, |
| #265 | account: PrivateKeyAccount, |
| #266 | method: string = "GET", |
| #267 | body?: string, |
| #268 | headers?: Record<string, string>, |
| #269 | ): Promise<X402PaymentResult> { |
| #270 | try { |
| #271 | // Initial request |
| #272 | const initialResp = await fetch(url, { |
| #273 | method, |
| #274 | headers: { ...headers, "Content-Type": "application/json" }, |
| #275 | body, |
| #276 | }); |
| #277 | |
| #278 | if (initialResp.status !== 402) { |
| #279 | const data = await initialResp |
| #280 | .json() |
| #281 | .catch(() => initialResp.text()); |
| #282 | return { success: initialResp.ok, response: data, status: initialResp.status }; |
| #283 | } |
| #284 | |
| #285 | // Parse payment requirements |
| #286 | const parsed = await parsePaymentRequired(initialResp); |
| #287 | if (!parsed) { |
| #288 | return { |
| #289 | success: false, |
| #290 | error: "Could not parse payment requirements", |
| #291 | status: initialResp.status, |
| #292 | }; |
| #293 | } |
| #294 | |
| #295 | // Sign payment |
| #296 | let payment: any; |
| #297 | try { |
| #298 | payment = await signPayment( |
| #299 | account, |
| #300 | parsed.requirement, |
| #301 | parsed.x402Version, |
| #302 | ); |
| #303 | } catch (err: any) { |
| #304 | return { |
| #305 | success: false, |
| #306 | error: `Failed to sign payment: ${err?.message || String(err)}`, |
| #307 | status: initialResp.status, |
| #308 | }; |
| #309 | } |
| #310 | |
| #311 | // Retry with payment |
| #312 | const paymentHeader = Buffer.from( |
| #313 | JSON.stringify(payment), |
| #314 | ).toString("base64"); |
| #315 | |
| #316 | const paidResp = await fetch(url, { |
| #317 | method, |
| #318 | headers: { |
| #319 | ...headers, |
| #320 | "Content-Type": "application/json", |
| #321 | "X-Payment": paymentHeader, |
| #322 | }, |
| #323 | body, |
| #324 | }); |
| #325 | |
| #326 | const data = await paidResp.json().catch(() => paidResp.text()); |
| #327 | return { success: paidResp.ok, response: data, status: paidResp.status }; |
| #328 | } catch (err: any) { |
| #329 | return { success: false, error: err.message }; |
| #330 | } |
| #331 | } |
| #332 | |
| #333 | async function parsePaymentRequired( |
| #334 | resp: Response, |
| #335 | ): Promise<ParsedPaymentRequirement | null> { |
| #336 | const header = resp.headers.get("X-Payment-Required"); |
| #337 | if (header) { |
| #338 | const rawHeader = safeJsonParse(header); |
| #339 | const normalizedRaw = normalizePaymentRequired(rawHeader); |
| #340 | if (normalizedRaw) { |
| #341 | return { |
| #342 | x402Version: normalizedRaw.x402Version, |
| #343 | requirement: selectRequirement(normalizedRaw), |
| #344 | }; |
| #345 | } |
| #346 | |
| #347 | try { |
| #348 | const decoded = Buffer.from(header, "base64").toString("utf-8"); |
| #349 | const parsedDecoded = normalizePaymentRequired(safeJsonParse(decoded)); |
| #350 | if (parsedDecoded) { |
| #351 | return { |
| #352 | x402Version: parsedDecoded.x402Version, |
| #353 | requirement: selectRequirement(parsedDecoded), |
| #354 | }; |
| #355 | } |
| #356 | } catch { |
| #357 | // Ignore header decode errors and continue with body parsing. |
| #358 | } |
| #359 | } |
| #360 | |
| #361 | try { |
| #362 | const body = await resp.json(); |
| #363 | const parsedBody = normalizePaymentRequired(body); |
| #364 | if (!parsedBody) return null; |
| #365 | return { |
| #366 | x402Version: parsedBody.x402Version, |
| #367 | requirement: selectRequirement(parsedBody), |
| #368 | }; |
| #369 | } catch { |
| #370 | return null; |
| #371 | } |
| #372 | } |
| #373 | |
| #374 | async function signPayment( |
| #375 | account: PrivateKeyAccount, |
| #376 | requirement: PaymentRequirement, |
| #377 | x402Version: number, |
| #378 | ): Promise<any> { |
| #379 | const chain = CHAINS[requirement.network]; |
| #380 | if (!chain) { |
| #381 | throw new Error(`Unsupported network: ${requirement.network}`); |
| #382 | } |
| #383 | |
| #384 | const nonce = `0x${Buffer.from( |
| #385 | crypto.getRandomValues(new Uint8Array(32)), |
| #386 | ).toString("hex")}`; |
| #387 | |
| #388 | const now = Math.floor(Date.now() / 1000); |
| #389 | const validAfter = now - 60; |
| #390 | const validBefore = now + requirement.requiredDeadlineSeconds; |
| #391 | const amount = parseMaxAmountRequired( |
| #392 | requirement.maxAmountRequired, |
| #393 | x402Version, |
| #394 | ); |
| #395 | |
| #396 | // EIP-712 typed data for TransferWithAuthorization |
| #397 | const domain = { |
| #398 | name: "USD Coin", |
| #399 | version: "2", |
| #400 | chainId: chain.id, |
| #401 | verifyingContract: requirement.usdcAddress, |
| #402 | } as const; |
| #403 | |
| #404 | const types = { |
| #405 | TransferWithAuthorization: [ |
| #406 | { name: "from", type: "address" }, |
| #407 | { name: "to", type: "address" }, |
| #408 | { name: "value", type: "uint256" }, |
| #409 | { name: "validAfter", type: "uint256" }, |
| #410 | { name: "validBefore", type: "uint256" }, |
| #411 | { name: "nonce", type: "bytes32" }, |
| #412 | ], |
| #413 | } as const; |
| #414 | |
| #415 | const message = { |
| #416 | from: account.address, |
| #417 | to: requirement.payToAddress, |
| #418 | value: amount, |
| #419 | validAfter: BigInt(validAfter), |
| #420 | validBefore: BigInt(validBefore), |
| #421 | nonce: nonce as `0x${string}`, |
| #422 | }; |
| #423 | |
| #424 | const signature = await account.signTypedData({ |
| #425 | domain, |
| #426 | types, |
| #427 | primaryType: "TransferWithAuthorization", |
| #428 | message, |
| #429 | }); |
| #430 | |
| #431 | return { |
| #432 | x402Version, |
| #433 | scheme: requirement.scheme, |
| #434 | network: requirement.network, |
| #435 | payload: { |
| #436 | signature, |
| #437 | authorization: { |
| #438 | from: account.address, |
| #439 | to: requirement.payToAddress, |
| #440 | value: amount.toString(), |
| #441 | validAfter: validAfter.toString(), |
| #442 | validBefore: validBefore.toString(), |
| #443 | nonce, |
| #444 | }, |
| #445 | }, |
| #446 | }; |
| #447 | } |
| #448 |