Skip to content
Merged
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
3 changes: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -30,4 +30,5 @@ dist
.env.example


.claude
.claude
.CLAUDE.md
24 changes: 24 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 3 additions & 1 deletion docs/arbitrum-stylus.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
23 changes: 21 additions & 2 deletions packages/backend/src/llms-txt/llms-txt.content.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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).

Expand Down Expand Up @@ -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",
},
Expand All @@ -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",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 };
Expand Down
6 changes: 6 additions & 0 deletions packages/backend/src/x402/x402.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,9 @@ export class X402Controller {
@Req() req: Request,
@Res() res: Response,
): Promise<void> {
// 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),
Expand All @@ -56,6 +59,9 @@ export class X402Controller {
@Body() body: DepositRequestDto,
@Req() req: Request,
): Promise<X402DepositResponse> {
// 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,
Expand Down
6 changes: 3 additions & 3 deletions packages/backend/src/x402/x402.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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() },
});
Expand Down
16 changes: 13 additions & 3 deletions packages/nextjs/components/modals/DepositModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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<DepositModalProps> = ({ isOpen, onClose, multisigAddress, multisigChainId }) => {
Expand Down Expand Up @@ -162,7 +170,9 @@ const DepositModal: React.FC<DepositModalProps> = ({ isOpen, onClose, multisigAd
</div>
)}
{unsupported && !wrongChain && (
<p className="mx-5 mt-3 text-sm text-red-600">Connect to Base or Base Sepolia to continue.</p>
<p className="mx-5 mt-3 text-sm text-red-600">
Connect to a network that supports gasless deposits to continue.
</p>
)}

{/* Amount input */}
Expand Down
7 changes: 4 additions & 3 deletions packages/nextjs/hooks/api/useX402Deposit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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),
});
Expand Down
16 changes: 15 additions & 1 deletion packages/shared/src/chains/facilitator-network.ts
Original file line number Diff line number Diff line change
@@ -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`,
Expand All @@ -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 {
Expand Down
Loading