From 3f191505d06721014cec032d580b47e1874671b0 Mon Sep 17 00:00:00 2001 From: gianalarcon Date: Sat, 13 Jun 2026 08:50:28 +0000 Subject: [PATCH] Add support for agentic payment on Arbitrum --- .gitignore | 3 ++- CLAUDE.md | 24 +++++++++++++++++++ docs/arbitrum-stylus.md | 4 +++- .../backend/src/llms-txt/llms-txt.content.ts | 23 ++++++++++++++++-- .../relayer-wallet/relayer-wallet.service.ts | 7 +++++- packages/backend/src/x402/x402.controller.ts | 6 +++++ packages/backend/src/x402/x402.service.ts | 6 ++--- .../nextjs/components/modals/DepositModal.tsx | 16 ++++++++++--- packages/nextjs/hooks/api/useX402Deposit.ts | 7 +++--- .../shared/src/chains/facilitator-network.ts | 16 ++++++++++++- 10 files changed, 97 insertions(+), 15 deletions(-) diff --git a/.gitignore b/.gitignore index df6be3eb..3d60f15e 100644 --- a/.gitignore +++ b/.gitignore @@ -30,4 +30,5 @@ dist .env.example -.claude \ No newline at end of file +.claude +.CLAUDE.md \ No newline at end of file diff --git a/CLAUDE.md b/CLAUDE.md index d0aa307b..6ec1d4de 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -231,6 +231,30 @@ useSocketEvent("transaction:updated", (data) => { /* handle */ }); // hooks/app/useAppRouter.ts - type-safe navigation (goToDashboard, goToTransfer, etc.) ``` +## Deployment & CI/CD + +The app deploys to **GCP Cloud Run** in the Quantum3Labs org. Full operational details live in `.claude/agents/polypay-devops-expert.md`; this section is the at-a-glance summary every contributor needs. + +### Branch → environment +- `develop` → **staging** (`staging-apps-482206`, region `asia-southeast1`) +- `main` → **production** (`polypay-481601`, region `asia-southeast1`) + +### Workflows (`.github/workflows/`) +| File | Trigger | Pipeline | +|------|---------|----------| +| `backend-{staging,prod}.yaml` | push to develop/main + paths in `packages/backend/**`, `packages/shared/**`, `docker/backend.Dockerfile` | Build → Prisma migration (Cloud Run Job) → Cloud Run deploy → Slack | +| `frontend-{staging,prod}.yaml` | push to develop/main + paths in `packages/nextjs/**`, `packages/shared/**` | Build (with `NEXT_PUBLIC_*` build args) → Cloud Run deploy → Slack | +| `lint.yaml` | PR + push to main | `yarn format` + `yarn build` | +| `test.yaml` | PR + manual | Backend E2E tests against postgres:15 | + +### Auth & config +- **GCP auth**: Workload Identity Federation (no JSON keys). Config comes from GitHub Environment `vars` (`GCP_PROJECT_ID`, `GCP_LOCATION`, `GCP_BACKEND_NAME`, etc.). +- **Image tag**: 7-char commit SHA (`${GITHUB_SHA::7}`). No `latest` tag. +- **Slack notifications**: every deploy posts success/failure to `SLACK_CHANNEL_ID`. + +### Runtime env vars — critical gotcha +CI deploys use `gcloud run deploy ... --update-env-vars NETWORK=...`, which **only** sets the `NETWORK` variable. **Every other env var on the Cloud Run service is managed out-of-band** (manual `gcloud run services update` or Console) and **survives CI deploys**. The repo is not the source of truth for runtime env — the Cloud Run service is. Secrets must use Secret Manager bindings (`--set-secrets`), never plaintext values. + ## Environment Setup ### Prerequisites diff --git a/docs/arbitrum-stylus.md b/docs/arbitrum-stylus.md index 07da1748..56d313bb 100644 --- a/docs/arbitrum-stylus.md +++ b/docs/arbitrum-stylus.md @@ -15,7 +15,9 @@ invisible to users. No setup — pick "Arbitrum" in the network chooser when creating an account. Notes specific to Arbitrum: -- **Tokens**: ETH and Circle USDC. Gasless x402 deposit is Base-only. +- **Tokens**: ETH and Circle USDC. Gasless x402 deposits are supported on + Arbitrum (One 42161 and Sepolia 421614) via the PayAI facilitator, in + addition to Base. - **Approval time**: zkVerify publishes the aggregation receipt in ≈2 minutes, then the transaction is executable. - **MetaMask warning**: MetaMask's security checker can't read Stylus bytecode diff --git a/packages/backend/src/llms-txt/llms-txt.content.ts b/packages/backend/src/llms-txt/llms-txt.content.ts index 40ef655f..eaa2076d 100644 --- a/packages/backend/src/llms-txt/llms-txt.content.ts +++ b/packages/backend/src/llms-txt/llms-txt.content.ts @@ -34,6 +34,8 @@ Supported chains (use the matching \`chainId\` for account creation): | Horizen testnet | 2651420 | | Base mainnet | 8453 | | Base Sepolia | 84532 | +| Arbitrum One | 42161 | +| Arbitrum Sepolia | 421614 | `; const OVERVIEW_SECTION = `## Overview @@ -376,7 +378,20 @@ const FLOW_5_X402 = `## Flow 5 — Gasless USDC deposit (x402) This is the *one* write-side endpoint that is fully agent-friendly: no PolyPay account, no JWT, no ZK proof. Use it when an external agent (or -human) wants to fund an existing PolyPay multisig with USDC on Base. +human) wants to fund an existing PolyPay multisig with USDC. + +Supported chains for x402 deposits (the multisig's own \`chainId\` decides; +GET the discovery response in 5a to read the exact \`network\`/\`asset\`): + +| Chain | chainId | x402 network label | CAIP-2 | USDC asset | +|---|---|---|---|---| +| Base mainnet | 8453 | \`base\` | \`eip155:8453\` | \`0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913\` | +| Base Sepolia | 84532 | \`base-sepolia\` | \`eip155:84532\` | \`0x036CbD53842c5426634e7929541eC2318f3dCF7e\` | +| Arbitrum One | 42161 | \`arbitrum\` | \`eip155:42161\` | \`0xaf88d065e77c8cC2239327C5EDb3A432268e5831\` | +| Arbitrum Sepolia | 421614 | \`arbitrum-sepolia\` | \`eip155:421614\` | \`0x75faf114eafb1BDbe2F0316DF893fd58CE46AA4d\` | + +All chains route through the PayAI x402 facilitator; you do not choose it — +just sign for the chain the discovery response names. It implements the [x402 protocol](https://x402.org). @@ -427,6 +442,9 @@ const auth = { const signature = await agentWallet.signTypedData({ domain: { + // chainId + verifyingContract must match the chain from the 5a discovery + // response. Base mainnet shown; for Arbitrum One use chainId: 42161, + // verifyingContract: "0xaf88d065e77c8cC2239327C5EDb3A432268e5831". name: "USD Coin", version: "2", chainId: 8453, verifyingContract: "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913", }, @@ -444,7 +462,8 @@ const signature = await agentWallet.signTypedData({ message: auth, }); -// Build X-PAYMENT header per x402 v1 spec. +// Build X-PAYMENT header per x402 v1 spec. \`network\` is the label from the +// 5a discovery response ("base" / "base-sepolia" / "arbitrum" / "arbitrum-sepolia"). const payload = { x402Version: 1, scheme: "exact", diff --git a/packages/backend/src/relayer-wallet/relayer-wallet.service.ts b/packages/backend/src/relayer-wallet/relayer-wallet.service.ts index 128dcd44..62f6b136 100644 --- a/packages/backend/src/relayer-wallet/relayer-wallet.service.ts +++ b/packages/backend/src/relayer-wallet/relayer-wallet.service.ts @@ -192,7 +192,12 @@ export class RelayerService { throw new Error(`Stylus deployment reverted. TxHash: ${txHash}`); } - const address = deployedAddress as string; + // Normalize to lowercase to match the EVM deploy path (viem returns + // receipt.contractAddress lowercased) and the address lookups in x402 + // (assertAccount lowercases) and the rest of the system. simulateContract + // ABI-decodes the returned address to EIP-55 checksum, so without this the + // account would be stored checksummed and lowercasing lookups would 404. + const address = (deployedAddress as string).toLowerCase(); this.logger.log(`Stylus wallet (proxy) deployed at: ${address}`); return { address, txHash }; diff --git a/packages/backend/src/x402/x402.controller.ts b/packages/backend/src/x402/x402.controller.ts index d00bee59..74737060 100644 --- a/packages/backend/src/x402/x402.controller.ts +++ b/packages/backend/src/x402/x402.controller.ts @@ -40,6 +40,9 @@ export class X402Controller { @Req() req: Request, @Res() res: Response, ): Promise { + // Default deposit path uses PayAI (x402 v1) for all supported chains — + // Base and Arbitrum (One + Sepolia) are all on PayAI's v1 facilitator. The + // CDP/v2 path is reserved for the /bazaar routes below. const body = await this.x402Service.buildDiscoveryResponse( multisigAddress, resourceUrlFromRequest(req), @@ -56,6 +59,9 @@ export class X402Controller { @Body() body: DepositRequestDto, @Req() req: Request, ): Promise { + // All supported chains (Base, Arbitrum One + Sepolia) settle through PayAI's + // x402 v1 facilitator (network labels "base"/"arbitrum"/"arbitrum-sepolia"). + // The CDP/v2 path is used only by the /bazaar routes. return this.x402Service.processDeposit( multisigAddress, paymentHeader, diff --git a/packages/backend/src/x402/x402.service.ts b/packages/backend/src/x402/x402.service.ts index 8ef3b953..edba3a11 100644 --- a/packages/backend/src/x402/x402.service.ts +++ b/packages/backend/src/x402/x402.service.ts @@ -279,9 +279,9 @@ export class X402Service { if (!/^0x[a-fA-F0-9]{40}$/.test(multisigAddress)) { throw new BadRequestException('Invalid address format'); } - // x402 only supports Base chains, so we scope the lookup to those — this - // also disambiguates when the same address exists on multiple chains - // (Horizen + Base) due to wallet nonces colliding. + // x402 supports Base and Arbitrum chains; the isX402SupportedChain gate + // below rejects anything else (e.g. Horizen). The lookup itself is by + // address — disambiguation between chains relies on that gate. const account = await this.prisma.account.findFirst({ where: { address: multisigAddress.toLowerCase() }, }); diff --git a/packages/nextjs/components/modals/DepositModal.tsx b/packages/nextjs/components/modals/DepositModal.tsx index a5eb5c71..050fec76 100644 --- a/packages/nextjs/components/modals/DepositModal.tsx +++ b/packages/nextjs/components/modals/DepositModal.tsx @@ -3,7 +3,7 @@ import React from "react"; import Image from "next/image"; import ModalContainer from "./ModalContainer"; -import { USDC_TOKEN, formatTokenAmount, isX402SupportedChain } from "@polypay/shared"; +import { USDC_TOKEN, formatTokenAmount, getChainById, isX402SupportedChain } from "@polypay/shared"; import { ArrowLeft, Check, X } from "lucide-react"; import { formatUnits, parseUnits } from "viem"; import { useAccount, useChainId, useReadContract, useSwitchChain } from "wagmi"; @@ -40,7 +40,15 @@ export interface DepositModalProps extends ModalProps { } function txExplorerUrl(chainId: number, txHash: string): string { - return chainId === 84532 ? `https://sepolia.basescan.org/tx/${txHash}` : `https://basescan.org/tx/${txHash}`; + // Resolve the explorer from the viem chain def so each supported chain + // (Base -> Basescan, Arbitrum Sepolia -> Arbiscan, ...) links correctly. + try { + const base = getChainById(chainId).blockExplorers?.default.url; + if (base) return `${base.replace(/\/$/, "")}/tx/${txHash}`; + } catch { + // Unknown chain — fall through to a chain-neutral default. + } + return `https://basescan.org/tx/${txHash}`; } const DepositModal: React.FC = ({ isOpen, onClose, multisigAddress, multisigChainId }) => { @@ -162,7 +170,9 @@ const DepositModal: React.FC = ({ isOpen, onClose, multisigAd )} {unsupported && !wrongChain && ( -

Connect to Base or Base Sepolia to continue.

+

+ Connect to a network that supports gasless deposits to continue. +

)} {/* Amount input */} diff --git a/packages/nextjs/hooks/api/useX402Deposit.ts b/packages/nextjs/hooks/api/useX402Deposit.ts index 7579d2fb..36c61233 100644 --- a/packages/nextjs/hooks/api/useX402Deposit.ts +++ b/packages/nextjs/hooks/api/useX402Deposit.ts @@ -27,7 +27,7 @@ export function useX402Deposit() { throw new Error("RPC client not ready"); } if (!isX402SupportedChain(chainId)) { - throw new Error("Connect to Base or Base Sepolia to deposit via x402"); + throw new Error("Connect to a network that supports x402 gasless deposits"); } const usdc = USDC_TOKEN.addresses[chainId] as `0x${string}` | undefined; @@ -62,8 +62,9 @@ export function useX402Deposit() { }, onSuccess: (_data, params) => { notification.success("Deposit submitted"); - // x402 only operates on Base, so the chainId in the query key is the - // wallet's current connected chain. + // The deposit is signed on the wallet's connected chain, which (after the + // wrong-chain guard in the modal) matches the multisig's chain — so the + // connected chainId is the right key to invalidate. void queryClient.invalidateQueries({ queryKey: accountKeys.byAddress(params.multisigAddress, chainId), }); diff --git a/packages/shared/src/chains/facilitator-network.ts b/packages/shared/src/chains/facilitator-network.ts index 86906ac8..d50375af 100644 --- a/packages/shared/src/chains/facilitator-network.ts +++ b/packages/shared/src/chains/facilitator-network.ts @@ -1,19 +1,31 @@ import { baseMainnet } from "./baseMainnet"; import { baseSepolia } from "./baseSepolia"; +import { arbitrumOne } from "./arbitrumOne"; +import { arbitrumSepolia } from "./arbitrumSepolia"; /** * Map a chainId to the label the x402 facilitator expects in its payment * payload and requirements. Throws for unsupported chains so callers never * silently send a mainnet payment to a testnet facilitator. + * + * These labels are the x402 v1 `network` field sent to PayAI's facilitator + * (PayAI's /supported lists "base", "base-sepolia", "arbitrum", and + * "arbitrum-sepolia" for the v1 "exact" scheme). They also satisfy the internal + * frontend<->backend equality check + * (`payload.network === chainIdToFacilitatorNetwork(chainId)`). */ export function chainIdToFacilitatorNetwork( chainId: number, -): "base" | "base-sepolia" { +): "base" | "base-sepolia" | "arbitrum" | "arbitrum-sepolia" { switch (chainId) { case baseMainnet.id: return "base"; case baseSepolia.id: return "base-sepolia"; + case arbitrumOne.id: + return "arbitrum"; + case arbitrumSepolia.id: + return "arbitrum-sepolia"; default: throw new Error( `Chain ${chainId} is not supported by the x402 facilitator`, @@ -24,6 +36,8 @@ export function chainIdToFacilitatorNetwork( export const X402_SUPPORTED_CHAIN_IDS: readonly number[] = [ baseMainnet.id, baseSepolia.id, + arbitrumOne.id, + arbitrumSepolia.id, ]; export function isX402SupportedChain(chainId: number): boolean {