diff --git a/.gitignore b/.gitignore index 3d60f15e..df6be3eb 100644 --- a/.gitignore +++ b/.gitignore @@ -30,5 +30,4 @@ dist .env.example -.claude -.CLAUDE.md \ No newline at end of file +.claude \ No newline at end of file diff --git a/CLAUDE.md b/CLAUDE.md index 6ec1d4de..d2540bd2 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -2,7 +2,7 @@ ## Project Overview -PolyPay is a privacy-preserving payroll platform built on Horizen blockchain. It enables organizations, DAOs, and global teams to run payroll privately using zero-knowledge proofs (Noir circuits). Key features: private payments, private multisig approvals, escrow/milestone-based transfers, real-time notifications via WebSocket, and JWT authentication. +PolyPay is a privacy-preserving payroll & multisig platform built on Horizen (primary), Base, and Arbitrum (Stylus). It enables organizations, DAOs, and global teams to run payroll while keeping signer identities private, using zero-knowledge proofs (Noir circuits). Key shipped features: private multisig approvals (signer identities hidden — only a relayer appears on-chain), ZK authentication, single + batch transfers, gasless USDC deposits (x402), real-time notifications via WebSocket, and JWT auth. Note: confidential payment **amounts and recipients** ("private payments") are roadmap, **not yet implemented** — today's privacy covers signer identities only. ## Tech Stack diff --git a/README.md b/README.md index 97908552..6058a033 100644 --- a/README.md +++ b/README.md @@ -10,9 +10,11 @@ A privacy-preserving payroll platform built on Horizen. PolyPay enables organiza ## Features -- **Private Payments**: Salary amounts and recipients stay confidential -- **Private Multisig**: Team approvals without exposing signer identities -- **Flexible Payment Logic**: Escrow, milestone-based, and recurring transfers +- **Private Multisig**: Team approvals without exposing signer identities — only a relayer wallet appears on-chain +- **ZK Authentication**: Login via zero-knowledge proof; secrets never leave your device +- **Batch Payroll**: Single and batch transfers + +> **Roadmap:** Confidential payment amounts and recipients ("private payments") are in active development — not yet live. Today's privacy guarantee covers signer identities. ## Quick Start diff --git a/REVIEW.md b/REVIEW.md new file mode 100644 index 00000000..bc37e51b --- /dev/null +++ b/REVIEW.md @@ -0,0 +1,54 @@ +# PolyPay PR Review Checklist + +Shared standard for reviewing pull requests — used by human reviewers and by the +automated review in `.github/workflows/claude-review.yaml`. Read `CLAUDE.md` for +the repo's architecture and conventions. + +## Priority order + +Report findings in this order; correctness always outranks cleanup. + +1. **Correctness bugs** +2. **Security** +3. **Convention adherence** (`CLAUDE.md`) +4. **Reuse / simplification / efficiency cleanup** + +## Security + +PolyPay moves USDC and handles multisig approvals, ZK proofs, and auth. Scrutinise +any change that touches: + +- **Signatures & payments** — EIP-3009 / x402 payloads, signature assembly and + the `v` recovery byte, nonce generation and replay protection, amount bounds. +- **Key material** — anything that could log, leak, or widen access to private + keys, tokens, or OWS vault flows. +- **Auth & access control** — JWT handling, route guards, `useAuthenticatedQuery`, + multisig signer/relayer logic, ZK proof verification. +- **Secrets** — never approve committed secrets, hardcoded credentials, or + plaintext keys. Secret Manager bindings only (see `CLAUDE.md` deployment notes). + +## Convention adherence (see `CLAUDE.md`) + +- API contracts via `@polypay/shared` DTOs; all HTTP through `apiClient`. +- Zod schemas for every form; `useAuthenticatedQuery` for authenticated queries. +- Business logic in `hooks/app/`, not components; no hardcoded API URLs + (use `API_BASE_URL`). +- Notifications via the existing `notification` utility / Sonner. +- Zustand stores use `persist` unless state is truly ephemeral. + +## Correctness + +Bugs a careful reviewer catches in one sitting: + +- Inverted or wrong conditions, off-by-one on boundaries. +- Missing `await`, unhandled promise rejections. +- Null / undefined dereferences on reachable paths (error handlers, cold cache, + missing optional fields), falsy-zero treated as missing. +- Errors swallowed in `catch`, copy-paste of the wrong variable. +- Call sites broken by a changed signature, return shape, or new precondition. + +## Output + +- Top-level summary via `gh pr comment`; line-level issues as inline comments. +- Cite `file:line`. Be specific and actionable. +- If the diff is clean, say so briefly — do not invent issues. diff --git a/docs/README.md b/docs/README.md index 4126a6bf..1a4ea46b 100644 --- a/docs/README.md +++ b/docs/README.md @@ -16,12 +16,14 @@ This lack of privacy prevents businesses from adopting crypto payroll. ### Our Solution -PolyPay uses **zero-knowledge proofs** and **multi-chain deployment** (Horizen and Base) to provide: +PolyPay uses **zero-knowledge proofs** and **multi-chain deployment** (Horizen, Base, and Arbitrum) to provide: -* **Private Payments**: Salary amounts and recipients stay confidential -* **Private Multisig**: Team approvals without exposing signer identities +* **Private Multisig**: Team approvals without exposing signer identities — only a relayer wallet appears on-chain +* **ZK Authentication**: Login via zero-knowledge proof; secrets never leave your device * **Flexible Payment Logic**: Single and batch transfers -* **Gasless USDC Deposit (x402)**: Fund a multisig on Base with one signature, no ETH required — works for human users and AI agents via the [x402 protocol](https://www.x402.org/) +* **Gasless USDC Deposit (x402)**: Fund a multisig with one signature, no ETH required — works for human users and AI agents via the [x402 protocol](https://www.x402.org/) + +> **On the roadmap:** confidential payment amounts and recipients ("private payments"). Today's ZK privacy covers signer identities and authentication; we're actively building toward fully private transfers. ### Who Is It For? diff --git a/usdc-deposit-agent/.gitignore b/usdc-deposit-agent/.gitignore new file mode 100644 index 00000000..72a66c03 --- /dev/null +++ b/usdc-deposit-agent/.gitignore @@ -0,0 +1,5 @@ +node_modules/ +.env +dist/ +# Rendered policy contains your multisig + caps; keep the template, ignore the rendered copy. +policy/arbitrum-usdc-deposit.policy.json diff --git a/usdc-deposit-agent/README.md b/usdc-deposit-agent/README.md new file mode 100644 index 00000000..eeb3799a --- /dev/null +++ b/usdc-deposit-agent/README.md @@ -0,0 +1,139 @@ +# PolyPay USDC Deposit Agent — Arbitrum One (OWS-secured) + +An autonomous agent that funds an **already-created PolyPay multisig** with USDC on +**Arbitrum One**, gaslessly, via PolyPay's **x402** deposit flow — while keeping the +signing key out of the agent process entirely. + +``` +agent process ──spawn──▶ OWS signer subprocess ──▶ OWS policy engine ──▶ vault key +(holds token) (holds token) (allow/deny) (~/.ows, encrypted) + │ │ + └────────── builds x402 payload, POSTs to PolyPay ◀── signature ────────────┘ +``` + +## Why this design + +- **Not Coinbase `awal` / agentic wallet** — it only supports Base/Polygon/Solana, **not Arbitrum**. +- **Not a raw `PRIVATE_KEY` in `.env`** — that key would sit in the agent's heap, reachable by any + tool it runs and one prompt-injection away from signing an attacker's transfer. +- **OWS ([Open Wallet Standard](https://github.com/open-wallet-standard/core))** — key encrypted at + rest (AES-256-GCM / scrypt), the agent holds only a **scoped token**, and a **policy engine** + decides what may be signed *before* the key is ever decrypted. + +### What the policy guarantees + +Even if this agent is fully compromised or prompt-injected, the scoped token can **only** produce: + +- an **EIP-3009 `TransferWithAuthorization`** (not arbitrary transactions), +- for **USDC** (`0xaf88…5831`) — no other token, +- on **Arbitrum One** (`eip155:42161`) — no other chain, +- to **your multisig** — no other recipient, +- **≤ your cap** per signature, until the token **expires**. + +It cannot drain the wallet or redirect funds. (`policy/check-deposit.mjs` enforces this; chain + +expiry are also enforced declaratively by OWS.) + +## Prerequisites + +- Node.js ≥ 20.18.3 +- The OWS CLI: `curl -fsSL https://docs.openwallet.sh/install.sh | bash` +- An EOA **funded with USDC on Arbitrum One** (the facilitator sponsors gas; the agent spends USDC). +- The target PolyPay multisig address (must be an **Arbitrum One** multisig). + +## Setup + +```bash +cd usdc-deposit-agent +npm install # or: yarn +``` + +### 1. Import your funded key into the OWS vault (one time) + +> ⚠️ Run this yourself in your terminal. **Never paste the private key into a chat or commit it.** +> It is read from stdin and encrypted into `~/.ows` — it never enters this project or the agent. + +```bash +# you will paste the key at the prompt +ows wallet import --name agent-treasury --private-key +# (or import a mnemonic: ows wallet import --name agent-treasury --mnemonic) +``` + +Confirm the imported address: `ows wallet list`. + +### 2. Render the policy + mint the scoped token + +```bash +MULTISIG_ADDRESS=0xYourArbitrumMultisig MAX_USDC=100 EXPIRES_DAYS=30 ./setup.sh +``` + +This renders the policy for *your* multisig and cap, registers it, and prints a one-time +`ows_key_...` token. + +### 3. Configure `.env` + +```bash +cp .env.example .env +# set OWS_API_KEY (the ows_key_... token), OWS_WALLET, MULTISIG_ADDRESS, AMOUNT_USDC +``` + +## Verify the policy (no funds, no setup) + +```bash +npm run test:policy +``` + +Spins up an isolated temp vault and proves the gate: a correct deposit signs, while a wrong +recipient, an over-cap amount, and the wrong chain are all denied. + +## Run + +```bash +npm run deposit # or: yarn deposit +``` + +Output: + +``` +→ PolyPay USDC deposit agent (Arbitrum One, gasless x402) + agent wallet : 0x… + multisig : 0x… + amount : 1 USDC (1000000 base units) + signing : via OWS vault (policy-gated)… + submitting : POST x402 deposit… +✓ Deposit settled + tx hash : 0x… +``` + +## How it works (flow) + +1. **Discover** — `GET /api/x402/deposit/:multisig` returns `402` with the payment requirements + (asset, `payTo`, network `arbitrum`, min/max, EIP-712 domain). +2. **Build** — assembles the EIP-3009 `TransferWithAuthorization` typed data (byte-compatible with + PolyPay's frontend `eip3009.ts`). +3. **Sign** — spawns `signer/ows-signer.mjs`, which calls OWS `signTypedData`. The policy engine + evaluates the typed data; only then is the key decrypted, used, and wiped. The agent's main + process never imports the OWS SDK and never sees the key. +4. **Submit** — base64-encodes the x402 v1 payload, `POST`s it with the `X-PAYMENT` header. PolyPay + settles via the Coinbase CDP facilitator (gasless). Returns `principalTxHash`. + +## Operations + +- **Rotate / revoke** the agent: `ows key revoke --id --confirm` (find it via `ows key list`). + The token instantly becomes useless; the wallet is untouched. +- **Change the cap or expiry**: re-run `setup.sh` with new values, then mint a fresh token. +- **Audit**: every signing request is logged to `~/.ows/logs/audit.jsonl`. + +## Troubleshooting + +| Symptom | Cause / fix | +|---|---| +| `OWS signer exited … POLICY_DENIED` | The request violated the policy (wrong recipient/amount/chain/expired). Check the deny reason. | +| `Expected HTTP 402 … got 4xx` | Wrong multisig address, or it isn't an Arbitrum One x402 multisig. | +| `Insufficient USDC` | Fund the agent EOA with USDC on Arbitrum One. | +| `Signature check failed` | Domain name/version mismatch — set `ARBITRUM_RPC_URL` so the agent reads them on-chain. | + +## Hardening (optional, Layer 2) + +This is Layer 1 (single host, key isolated behind a subprocess + policy). To fully isolate the +signer, run OWS in its own container exposing signing over a local socket, with the agent in a +separate container holding only `OWS_API_KEY` — the "two containers, one socket" KMS pattern. diff --git a/usdc-deposit-agent/package-lock.json b/usdc-deposit-agent/package-lock.json new file mode 100644 index 00000000..e2cff278 --- /dev/null +++ b/usdc-deposit-agent/package-lock.json @@ -0,0 +1,844 @@ +{ + "name": "polypay-usdc-deposit-agent", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "polypay-usdc-deposit-agent", + "version": "0.1.0", + "dependencies": { + "@open-wallet-standard/core": "^1.3.2", + "viem": "2.31.1" + }, + "devDependencies": { + "@types/node": "^22.10.0", + "tsx": "^4.22.4", + "typescript": "^5.8.2" + }, + "engines": { + "node": ">=20.18.3" + } + }, + "node_modules/@adraffy/ens-normalize": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@adraffy/ens-normalize/-/ens-normalize-1.11.1.tgz", + "integrity": "sha512-nhCBV3quEgesuf7c7KYfperqSS14T8bYuvJ8PcLJp6znkZpFc0AuW4qBtr8eKVyPPe/8RSr7sglCWPU5eaxwKQ==", + "license": "MIT" + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz", + "integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz", + "integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz", + "integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz", + "integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz", + "integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz", + "integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz", + "integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz", + "integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz", + "integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz", + "integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz", + "integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz", + "integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz", + "integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz", + "integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz", + "integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz", + "integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz", + "integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz", + "integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz", + "integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz", + "integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz", + "integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz", + "integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz", + "integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz", + "integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz", + "integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz", + "integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@noble/ciphers": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-1.3.0.tgz", + "integrity": "sha512-2I0gnIVPtfnMw9ee9h1dJG7tp81+8Ob3OJb3Mv37rx5L40/b0i7djjCVvGOVqc9AEIQyvyu1i6ypKdFw8R8gQw==", + "license": "MIT", + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@noble/curves": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.9.1.tgz", + "integrity": "sha512-k11yZxZg+t+gWvBbIswW0yoJlu8cHOC7dhunwOzoWH/mXGBiYyR4YY6hAEK/3EUs4UpB8la1RfdRpeGsFHkWsA==", + "license": "MIT", + "dependencies": { + "@noble/hashes": "1.8.0" + }, + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@noble/hashes": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz", + "integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==", + "license": "MIT", + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@open-wallet-standard/core": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@open-wallet-standard/core/-/core-1.3.2.tgz", + "integrity": "sha512-Dk8bB9G5PjJfNzAqJd9WT9OfqrrSXRc3gA9+BCaY2qirlNrCkIAZCHaDIE0LnGOAaoSZizR6qnOFbukj+DTzJg==", + "license": "MIT", + "bin": { + "ows": "bin/ows" + }, + "optionalDependencies": { + "@open-wallet-standard/core-darwin-arm64": "1.3.2", + "@open-wallet-standard/core-darwin-x64": "1.3.2", + "@open-wallet-standard/core-linux-arm64-gnu": "1.3.2", + "@open-wallet-standard/core-linux-x64-gnu": "1.3.2" + } + }, + "node_modules/@open-wallet-standard/core-darwin-arm64": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@open-wallet-standard/core-darwin-arm64/-/core-darwin-arm64-1.3.2.tgz", + "integrity": "sha512-rvUsGU8eZtIMYFJ047nFKS79ajJeYCydvvh2B6bX0UUGf2NJZ4wUnxGqijxPiflz2xqzvbHXYZNzy3MIO4+fDQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@open-wallet-standard/core-darwin-x64": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@open-wallet-standard/core-darwin-x64/-/core-darwin-x64-1.3.2.tgz", + "integrity": "sha512-a6u3CvWZY8sC1S92AMGnO1WFlMW3m38+3/IoKzhxcFvD8rVY0OA0ebH6qzYLT96/TaouXDwmMQV6yQmb1IjfzQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@open-wallet-standard/core-linux-arm64-gnu": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@open-wallet-standard/core-linux-arm64-gnu/-/core-linux-arm64-gnu-1.3.2.tgz", + "integrity": "sha512-wzyd7euZ5qvDr8dHf3eG2XP7iFgMmBUo35U7cWMLs6Yqjqz103SEjtMfNJEedZRb62f8P3Xhp9jdRLzaJvjk5g==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@open-wallet-standard/core-linux-x64-gnu": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@open-wallet-standard/core-linux-x64-gnu/-/core-linux-x64-gnu-1.3.2.tgz", + "integrity": "sha512-pVtyEP8EqR3rjqdBDCRuB9Rd4xs8+11gRVtWidwPeFz5QPq3c9VGZjGQtTZoiZBHCxKaq5pTQjC78J5lVuA0hg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@scure/base": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/@scure/base/-/base-1.2.6.tgz", + "integrity": "sha512-g/nm5FgUa//MCj1gV09zTJTaM6KBAHqLN907YVQqf7zC49+DcO4B1so4ZX07Ef10Twr6nuqYEH9GEggFXA4Fmg==", + "license": "MIT", + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@scure/bip32": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/@scure/bip32/-/bip32-1.7.0.tgz", + "integrity": "sha512-E4FFX/N3f4B80AKWp5dP6ow+flD1LQZo/w8UnLGYZO674jS6YnYeepycOOksv+vLPSpgN35wgKgy+ybfTb2SMw==", + "license": "MIT", + "dependencies": { + "@noble/curves": "~1.9.0", + "@noble/hashes": "~1.8.0", + "@scure/base": "~1.2.5" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@scure/bip39": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@scure/bip39/-/bip39-1.6.0.tgz", + "integrity": "sha512-+lF0BbLiJNwVlev4eKelw1WWLaiKXw7sSl8T6FvBlWkdX+94aGJ4o8XjUdlyhTCjd8c+B3KT3JfS8P0bLRNU6A==", + "license": "MIT", + "dependencies": { + "@noble/hashes": "~1.8.0", + "@scure/base": "~1.2.5" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@types/node": { + "version": "22.19.21", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.21.tgz", + "integrity": "sha512-VMeFBSCKQKmm2swI2kW51SFusDqekC6q9trBCvJ/JliDchFSuoYYKN7yVNjPthP1HKZcx3U1gI/wTcEBjEFKTA==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/abitype": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/abitype/-/abitype-1.0.8.tgz", + "integrity": "sha512-ZeiI6h3GnW06uYDLx0etQtX/p8E24UaHHBj57RSjK7YBFe7iuVn07EDpOeP451D06sF27VOz9JJPlIKJmXgkEg==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/wevm" + }, + "peerDependencies": { + "typescript": ">=5.0.4", + "zod": "^3 >=3.22.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + }, + "zod": { + "optional": true + } + } + }, + "node_modules/esbuild": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz", + "integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.1", + "@esbuild/android-arm": "0.28.1", + "@esbuild/android-arm64": "0.28.1", + "@esbuild/android-x64": "0.28.1", + "@esbuild/darwin-arm64": "0.28.1", + "@esbuild/darwin-x64": "0.28.1", + "@esbuild/freebsd-arm64": "0.28.1", + "@esbuild/freebsd-x64": "0.28.1", + "@esbuild/linux-arm": "0.28.1", + "@esbuild/linux-arm64": "0.28.1", + "@esbuild/linux-ia32": "0.28.1", + "@esbuild/linux-loong64": "0.28.1", + "@esbuild/linux-mips64el": "0.28.1", + "@esbuild/linux-ppc64": "0.28.1", + "@esbuild/linux-riscv64": "0.28.1", + "@esbuild/linux-s390x": "0.28.1", + "@esbuild/linux-x64": "0.28.1", + "@esbuild/netbsd-arm64": "0.28.1", + "@esbuild/netbsd-x64": "0.28.1", + "@esbuild/openbsd-arm64": "0.28.1", + "@esbuild/openbsd-x64": "0.28.1", + "@esbuild/openharmony-arm64": "0.28.1", + "@esbuild/sunos-x64": "0.28.1", + "@esbuild/win32-arm64": "0.28.1", + "@esbuild/win32-ia32": "0.28.1", + "@esbuild/win32-x64": "0.28.1" + } + }, + "node_modules/eventemitter3": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.1.tgz", + "integrity": "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==", + "license": "MIT" + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/isows": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/isows/-/isows-1.0.7.tgz", + "integrity": "sha512-I1fSfDCZL5P0v33sVqeTDSpcstAg/N+wF5HS033mogOVIp4B+oHC7oOCsA3axAbBSGTJ8QubbNmnIRN/h8U7hg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/wevm" + } + ], + "license": "MIT", + "peerDependencies": { + "ws": "*" + } + }, + "node_modules/ox": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/ox/-/ox-0.7.1.tgz", + "integrity": "sha512-+k9fY9PRNuAMHRFIUbiK9Nt5seYHHzSQs9Bj+iMETcGtlpS7SmBzcGSVUQO3+nqGLEiNK4598pHNFlVRaZbRsg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/wevm" + } + ], + "license": "MIT", + "dependencies": { + "@adraffy/ens-normalize": "^1.10.1", + "@noble/ciphers": "^1.3.0", + "@noble/curves": "^1.6.0", + "@noble/hashes": "^1.5.0", + "@scure/bip32": "^1.5.0", + "@scure/bip39": "^1.4.0", + "abitype": "^1.0.6", + "eventemitter3": "5.0.1" + }, + "peerDependencies": { + "typescript": ">=5.4.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/tsx": { + "version": "4.22.4", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.22.4.tgz", + "integrity": "sha512-X8EX+XV4QR5xCsrgxaED954zTDfY8KqlDtskKEL0cHhyS/P8b4IFOvGDQpsC9Q1XnLq915wEfwwY/zzskCtmhg==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.28.0" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "devOptional": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/viem": { + "version": "2.31.1", + "resolved": "https://registry.npmjs.org/viem/-/viem-2.31.1.tgz", + "integrity": "sha512-w7P+glrmZm4xd3wugH31lXy2J98DAWlEKW+legUgiADvfOT2wjDIGywsEtEWk44auQLykTiijtNz8pEnINa/8A==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/wevm" + } + ], + "license": "MIT", + "dependencies": { + "@noble/curves": "1.9.1", + "@noble/hashes": "1.8.0", + "@scure/bip32": "1.7.0", + "@scure/bip39": "1.6.0", + "abitype": "1.0.8", + "isows": "1.0.7", + "ox": "0.7.1", + "ws": "8.18.2" + }, + "peerDependencies": { + "typescript": ">=5.0.4" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/ws": { + "version": "8.18.2", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.2.tgz", + "integrity": "sha512-DMricUmwGZUVr++AEAe2uiVM7UoO9MAVZMDu05UQOaUII0lp+zOzLLU4Xqh/JvTqklB1T4uELaaPBKyjE1r4fQ==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + } + } +} diff --git a/usdc-deposit-agent/package.json b/usdc-deposit-agent/package.json new file mode 100644 index 00000000..2d308751 --- /dev/null +++ b/usdc-deposit-agent/package.json @@ -0,0 +1,24 @@ +{ + "name": "polypay-usdc-deposit-agent", + "version": "0.1.0", + "private": true, + "type": "module", + "description": "Autonomous agent that deposits USDC into a PolyPay multisig on Arbitrum One via gasless x402, signing through an OWS policy-gated vault (no private key in the agent process).", + "engines": { + "node": ">=20.18.3" + }, + "scripts": { + "deposit": "node --env-file-if-exists=.env --import tsx src/index.ts", + "test:policy": "node test/policy-gate.mjs", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@open-wallet-standard/core": "^1.3.2", + "viem": "2.31.1" + }, + "devDependencies": { + "@types/node": "^22.10.0", + "tsx": "^4.22.4", + "typescript": "^5.8.2" + } +} diff --git a/usdc-deposit-agent/policy/arbitrum-usdc-deposit.policy.template.json b/usdc-deposit-agent/policy/arbitrum-usdc-deposit.policy.template.json new file mode 100644 index 00000000..7720803d --- /dev/null +++ b/usdc-deposit-agent/policy/arbitrum-usdc-deposit.policy.template.json @@ -0,0 +1,17 @@ +{ + "id": "arbitrum-usdc-deposit", + "name": "Arbitrum One USDC deposit to a single PolyPay multisig", + "version": 1, + "created_at": "__CREATED_AT__", + "rules": [ + { "type": "allowed_chains", "chain_ids": ["eip155:42161"] }, + { "type": "expires_at", "timestamp": "__EXPIRES_AT__" } + ], + "executable": "__CHECK_PATH__", + "config": { + "multisig": "__MULTISIG__", + "usdc": "0xaf88d065e77c8cC2239327C5EDb3A432268e5831", + "max_value": "__MAX_VALUE__" + }, + "action": "deny" +} diff --git a/usdc-deposit-agent/policy/check-deposit.mjs b/usdc-deposit-agent/policy/check-deposit.mjs new file mode 100755 index 00000000..76b21951 --- /dev/null +++ b/usdc-deposit-agent/policy/check-deposit.mjs @@ -0,0 +1,68 @@ +#!/usr/bin/env node +// OWS executable policy — the security core of the agent. +// +// OWS pipes a PolicyContext JSON on stdin before any key is decrypted. We allow +// the signature ONLY if it is an EIP-3009 USDC TransferWithAuthorization, on +// Arbitrum One, to the configured multisig, at or below the configured cap. +// Anything else (different recipient, different token, oversized amount, a plain +// transaction, a different chain) is denied — so a prompt-injected agent still +// cannot move funds anywhere except into your multisig, up to the cap. +// +// Config (multisig / usdc / max_value) comes from the policy file's `config` +// block, injected as ctx.policy_config. + +let input = ""; +process.stdin.on("data", c => (input += c)); +process.stdin.on("end", () => { + try { + const ctx = JSON.parse(input); + const cfg = ctx.policy_config || {}; + const multisig = String(cfg.multisig || "").toLowerCase(); + const usdc = String(cfg.usdc || "").toLowerCase(); + const maxValue = cfg.max_value != null ? BigInt(cfg.max_value) : null; + + if (ctx.chain_id !== "eip155:42161") return deny(`chain ${ctx.chain_id} not allowed`); + + const td = ctx.typed_data; + if (!td) return deny("refusing: request is not EIP-712 typed data"); + if (td.primary_type !== "TransferWithAuthorization") + return deny(`primaryType ${td.primary_type} not allowed`); + + const vc = String(td.verifying_contract || "").toLowerCase(); + if (usdc && vc !== usdc) return deny(`verifyingContract ${vc || "(none)"} != USDC ${usdc}`); + + let message; + try { + message = JSON.parse(td.raw_json).message; + } catch { + return deny("cannot parse typed_data.raw_json"); + } + + const to = String(message.to || "").toLowerCase(); + if (multisig && to !== multisig) return deny(`recipient ${to || "(none)"} != multisig ${multisig}`); + + if (maxValue != null) { + let val; + try { + val = BigInt(message.value); + } catch { + return deny("message.value is not an integer"); + } + if (val <= 0n) return deny("message.value must be positive"); + if (val > maxValue) return deny(`value ${val} exceeds cap ${maxValue}`); + } + + return allow(); + } catch (e) { + return deny(`policy error: ${e && e.message ? e.message : String(e)}`); + } +}); + +function allow() { + process.stdout.write(JSON.stringify({ allow: true })); + process.exit(0); +} +function deny(reason) { + process.stdout.write(JSON.stringify({ allow: false, reason })); + process.exit(0); +} diff --git a/usdc-deposit-agent/setup.sh b/usdc-deposit-agent/setup.sh new file mode 100755 index 00000000..54ebfbe1 --- /dev/null +++ b/usdc-deposit-agent/setup.sh @@ -0,0 +1,60 @@ +#!/usr/bin/env bash +# One-time setup: render the policy for YOUR multisig + cap, register it in OWS, +# and mint a scoped agent token. Does NOT touch your private key — you import +# that yourself (see step 1 in the README) so the key never passes through here. +set -euo pipefail + +cd "$(dirname "$0")" + +# ── Config (override via env) ────────────────────────────────────────────── +WALLET="${OWS_WALLET:-agent-treasury}" # vault wallet name (must already exist) +MULTISIG="${MULTISIG_ADDRESS:?set MULTISIG_ADDRESS=0x... (your Arbitrum One multisig)}" +MAX_USDC="${MAX_USDC:-100}" # per-signature cap, human USDC +EXPIRES_DAYS="${EXPIRES_DAYS:-30}" # token/policy lifetime in days +POLICY_ID="arbitrum-usdc-deposit" +KEY_NAME="${KEY_NAME:-polypay-deposit-agent}" + +command -v ows >/dev/null || { echo "✖ 'ows' CLI not found. Install: curl -fsSL https://docs.openwallet.sh/install.sh | bash"; exit 1; } + +# ── Guard: wallet must exist (import it first — see README step 1) ────────── +if ! ows wallet list 2>/dev/null | grep -q "$WALLET"; then + echo "✖ Wallet '$WALLET' not found in the vault." + echo " Import your funded EOA first (key read from stdin, never stored in shell history):" + echo " ows wallet import --name \"$WALLET\" --private-key" + exit 1 +fi + +# ── Render the policy template ───────────────────────────────────────────── +CHECK_PATH="$(cd policy && pwd)/check-deposit.mjs" +chmod +x "$CHECK_PATH" +CREATED_AT="$(node -e "process.stdout.write(new Date().toISOString())")" +EXPIRES_AT="$(node -e "process.stdout.write(new Date(Date.now()+${EXPIRES_DAYS}*864e5).toISOString())")" +# Convert human USDC -> 6-decimal base units without float error. +MAX_VALUE="$(node -e 'const a=process.argv[1];const[i,f=""]=a.split(".");process.stdout.write((BigInt(i||"0")*1000000n+BigInt((f+"000000").slice(0,6))).toString())' "$MAX_USDC")" + +RENDERED="policy/${POLICY_ID}.policy.json" +sed -e "s|__CREATED_AT__|$CREATED_AT|g" \ + -e "s|__EXPIRES_AT__|$EXPIRES_AT|g" \ + -e "s|__CHECK_PATH__|$CHECK_PATH|g" \ + -e "s|__MULTISIG__|$MULTISIG|g" \ + -e "s|__MAX_VALUE__|$MAX_VALUE|g" \ + policy/arbitrum-usdc-deposit.policy.template.json > "$RENDERED" + +echo "→ Rendered policy: $RENDERED" +echo " chain : eip155:42161 (Arbitrum One)" +echo " to : $MULTISIG (only)" +echo " token : USDC 0xaf88d065e77c8cC2239327C5EDb3A432268e5831 (only)" +echo " cap : $MAX_USDC USDC ($MAX_VALUE base units) per signature" +echo " expires : $EXPIRES_AT" + +# ── Register policy (re-create cleanly) ──────────────────────────────────── +ows policy delete --id "$POLICY_ID" --confirm >/dev/null 2>&1 || true +ows policy create --file "$RENDERED" + +# ── Mint the scoped agent token ──────────────────────────────────────────── +echo +echo "→ Creating scoped token (you'll be prompted for the vault passphrase)…" +ows key create --name "$KEY_NAME" --wallet "$WALLET" --policy "$POLICY_ID" --expires-at "$EXPIRES_AT" +echo +echo "✓ Copy the ows_key_... token above into .env as OWS_API_KEY (it is shown ONCE)." +echo " Then: yarn deposit (or: npm run deposit)" diff --git a/usdc-deposit-agent/signer/ows-signer.mjs b/usdc-deposit-agent/signer/ows-signer.mjs new file mode 100755 index 00000000..6709fea7 --- /dev/null +++ b/usdc-deposit-agent/signer/ows-signer.mjs @@ -0,0 +1,60 @@ +#!/usr/bin/env node +// OWS signer subprocess. +// +// The agent's main process never imports the OWS SDK and never sees key +// material. It spawns THIS process, which asks OWS to sign — the private key is +// decrypted inside the vault, used, and wiped, all behind the policy engine. +// +// node ows-signer.mjs address -> { "address": "0x..." } (no token needed) +// node ows-signer.mjs sign < stdin -> { "signature": "0x...", "recoveryId": 27|28 } +// +// stdin for `sign`: { "typedDataJson": "" } +// env: OWS_WALLET (required), OWS_API_KEY (required for `sign`) + +import { getWallet, signTypedData } from "@open-wallet-standard/core"; + +const ARBITRUM = "arbitrum"; // OWS alias for eip155:42161 + +function fail(msg) { + process.stderr.write(`${msg}\n`); + process.exit(1); +} + +function readStdin() { + return new Promise(resolve => { + let data = ""; + process.stdin.on("data", chunk => (data += chunk)); + process.stdin.on("end", () => resolve(data)); + }); +} + +const wallet = process.env.OWS_WALLET; +if (!wallet) fail("OWS_WALLET is not set"); + +const action = process.argv[2]; + +if (action === "address") { + const info = getWallet(wallet); + const evm = info.accounts.find(a => a.chainId.startsWith("eip155:")); + if (!evm) fail("wallet has no EVM (eip155) account"); + process.stdout.write(JSON.stringify({ address: evm.address })); + process.exit(0); +} else if (action === "sign") { + const token = process.env.OWS_API_KEY; + if (!token) fail("OWS_API_KEY is not set"); + const raw = await readStdin(); + let typedDataJson; + try { + ({ typedDataJson } = JSON.parse(raw)); + } catch { + fail("invalid signer stdin: expected JSON { typedDataJson }"); + } + if (typeof typedDataJson !== "string") fail("typedDataJson must be a JSON string"); + // Passing the ows_key_ token where the passphrase goes triggers agent mode: + // OWS evaluates every attached policy BEFORE the key is decrypted. + const res = signTypedData(wallet, ARBITRUM, typedDataJson, token); + process.stdout.write(JSON.stringify({ signature: res.signature, recoveryId: res.recoveryId })); + process.exit(0); +} else { + fail(`unknown action: ${action ?? "(none)"} — expected "address" or "sign"`); +} diff --git a/usdc-deposit-agent/src/index.ts b/usdc-deposit-agent/src/index.ts new file mode 100644 index 00000000..f5fb19e0 --- /dev/null +++ b/usdc-deposit-agent/src/index.ts @@ -0,0 +1,215 @@ +import { randomBytes } from "node:crypto"; +import { + createPublicClient, + formatUnits, + getAddress, + http, + parseUnits, + recoverTypedDataAddress, + type Hex, +} from "viem"; +import { arbitrum } from "viem/chains"; +import { OwsSigner } from "./owsSigner.js"; + +const CHAIN_ID = 42161; +const USDC_FALLBACK = "0xaf88d065e77c8cC2239327C5EDb3A432268e5831"; +// The v1 X-PAYMENT payload `network` the backend checks is the facilitator label +// (`chainIdToFacilitatorNetwork(42161)` === "arbitrum"), NOT discovery's CAIP-2 +// `network` ("eip155:42161"). Sending the CAIP-2 form fails the equality check. +const FACILITATOR_NETWORK = "arbitrum"; + +// EIP-3009 / EIP-712 type definitions (must match the USDC contract + PolyPay). +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; + +const EIP712_DOMAIN_TYPE = [ + { name: "name", type: "string" }, + { name: "version", type: "string" }, + { name: "chainId", type: "uint256" }, + { name: "verifyingContract", type: "address" }, +]; + +const USDC_READ_ABI = [ + { name: "name", type: "function", stateMutability: "view", inputs: [], outputs: [{ type: "string" }] }, + { name: "version", type: "function", stateMutability: "view", inputs: [], outputs: [{ type: "string" }] }, + { + name: "balanceOf", + type: "function", + stateMutability: "view", + inputs: [{ name: "account", type: "address" }], + outputs: [{ type: "uint256" }], + }, +] as const; + +function env(name: string, required = true): string { + const v = process.env[name]?.trim(); + if (!v && required) { + console.error(`✖ Missing required env var: ${name}`); + process.exit(1); + } + return v ?? ""; +} + +function assembleSignature(signature: string, recoveryId: number): Hex { + const hex = signature.startsWith("0x") ? signature.slice(2) : signature; + if (hex.length === 130) return `0x${hex}`; // already r||s||v (65 bytes) + if (hex.length === 128) { + // r||s (64 bytes) — append v in {27,28} + const v = recoveryId < 27 ? recoveryId + 27 : recoveryId; + return `0x${hex}${v.toString(16).padStart(2, "0")}`; + } + throw new Error(`Unexpected signature length: ${hex.length} hex chars`); +} + +async function main() { + const apiBase = (process.env.POLYPAY_API_URL?.trim() || "https://api.polypay.pro").replace(/\/$/, ""); + const multisig = getAddress(env("MULTISIG_ADDRESS")); + const amountStr = env("AMOUNT_USDC"); + const memo = process.env.MEMO?.trim() || ""; + const rpcUrl = process.env.ARBITRUM_RPC_URL?.trim() || ""; + + const signer = new OwsSigner({ wallet: env("OWS_WALLET"), apiKey: env("OWS_API_KEY") }); + + console.log("→ PolyPay USDC deposit agent (Arbitrum One, gasless x402)"); + const from = getAddress(await signer.getAddress()); + console.log(` agent wallet : ${from}`); + console.log(` multisig : ${multisig}`); + + // ── 1. Discover payment requirements (HTTP 402) ─────────────────────────── + const depositUrl = `${apiBase}/api/x402/deposit/${multisig}`; + const disco = await fetch(depositUrl, { headers: { Accept: "application/json" } }); + if (disco.status !== 402) { + throw new Error(`Expected HTTP 402 from discovery, got ${disco.status}: ${await disco.text()}`); + } + const accept = ((await disco.json()) as { accepts?: any[] }).accepts?.[0]; + if (!accept) throw new Error("Discovery response had no `accepts[0]`"); + + const usdc = getAddress(accept.asset ?? USDC_FALLBACK); + const network = FACILITATOR_NETWORK; // payload label the backend validates against + const payTo = getAddress(accept.payTo ?? multisig); + if (payTo !== multisig) throw new Error(`payTo ${payTo} != requested multisig ${multisig}`); + + const minDeposit = BigInt(accept.extra?.minDeposit ?? "0"); + const maxDeposit = BigInt(accept.extra?.maxDeposit ?? accept.maxAmountRequired ?? "0"); + const maxTimeout = Number(accept.maxTimeoutSeconds ?? 0); + + // ── 2. Resolve EIP-712 domain (name/version) + amount ───────────────────── + const publicClient = rpcUrl + ? createPublicClient({ chain: arbitrum, transport: http(rpcUrl) }) + : undefined; + + let domainName: string | undefined = accept.extra?.name; + let domainVersion: string | undefined = accept.extra?.version; + if ((!domainName || !domainVersion) && publicClient) { + const [n, v] = await Promise.all([ + publicClient.readContract({ address: usdc, abi: USDC_READ_ABI, functionName: "name" }), + publicClient.readContract({ address: usdc, abi: USDC_READ_ABI, functionName: "version" }), + ]); + domainName ??= n as string; + domainVersion ??= v as string; + } + domainName ??= "USD Coin"; + domainVersion ??= "2"; + + const value = parseUnits(amountStr, 6); + if (value <= 0n) throw new Error("AMOUNT_USDC must be positive"); + if (minDeposit > 0n && value < minDeposit) + throw new Error(`Amount ${formatUnits(value, 6)} below minDeposit ${formatUnits(minDeposit, 6)} USDC`); + if (maxDeposit > 0n && value > maxDeposit) + throw new Error(`Amount ${formatUnits(value, 6)} above maxDeposit ${formatUnits(maxDeposit, 6)} USDC`); + console.log(` amount : ${formatUnits(value, 6)} USDC (${value} base units)`); + + // ── 2b. Optional balance pre-flight ─────────────────────────────────────── + if (publicClient) { + const balance = (await publicClient.readContract({ + address: usdc, + abi: USDC_READ_ABI, + functionName: "balanceOf", + args: [from], + })) as bigint; + if (balance < value) { + throw new Error( + `Insufficient USDC: wallet holds ${formatUnits(balance, 6)}, needs ${formatUnits(value, 6)} on Arbitrum One`, + ); + } + } + + // ── 3. Build EIP-3009 authorization typed data ──────────────────────────── + const validBefore = Math.floor(Date.now() / 1000) + Math.max(maxTimeout, 300); + const nonce = `0x${randomBytes(32).toString("hex")}` as Hex; + const message = { + from, + to: multisig, + value: value.toString(), + validAfter: "0", + validBefore: String(validBefore), + nonce, + }; + const typedData = { + types: { EIP712Domain: EIP712_DOMAIN_TYPE, ...TRANSFER_WITH_AUTHORIZATION_TYPES }, + primaryType: "TransferWithAuthorization", + domain: { name: domainName, version: domainVersion, chainId: String(CHAIN_ID), verifyingContract: usdc }, + message, + }; + + // ── 4. Sign via OWS (policy-gated; key never enters this process) ────────── + console.log(" signing : via OWS vault (policy-gated)…"); + const { signature, recoveryId } = await signer.signTypedData(JSON.stringify(typedData)); + const sig = assembleSignature(signature, recoveryId); + + // Sanity: recover the signer locally and confirm it matches the vault wallet. + const recovered = await recoverTypedDataAddress({ + domain: { name: domainName, version: domainVersion, chainId: CHAIN_ID, verifyingContract: usdc }, + types: TRANSFER_WITH_AUTHORIZATION_TYPES, + primaryType: "TransferWithAuthorization", + message: { + from, + to: multisig, + value, + validAfter: 0n, + validBefore: BigInt(validBefore), + nonce, + }, + signature: sig, + }); + if (getAddress(recovered) !== from) { + throw new Error(`Signature check failed: recovered ${recovered} != wallet ${from}`); + } + + // ── 5. Submit the x402 v1 payment ───────────────────────────────────────── + const payload = { + x402Version: 1, + scheme: "exact", + network, + payload: { signature: sig, authorization: message }, + }; + const xPayment = Buffer.from(JSON.stringify(payload)).toString("base64"); + + console.log(" submitting : POST x402 deposit…"); + const submit = await fetch(depositUrl, { + method: "POST", + headers: { "Content-Type": "application/json", "X-PAYMENT": xPayment }, + body: JSON.stringify(memo ? { memo } : {}), + }); + const text = await submit.text(); + if (!submit.ok) throw new Error(`Deposit failed (${submit.status}): ${text}`); + + const result = JSON.parse(text); + console.log("\n✓ Deposit settled"); + console.log(` tx hash : ${result.principalTxHash ?? "(none returned)"}`); + console.log(` deposited : ${result.depositedAmount ? formatUnits(BigInt(result.depositedAmount), 6) : amountStr} USDC`); + console.log(` multisig : ${result.multisigAddress ?? multisig}`); +} + +main().catch(err => { + console.error(`\n✖ ${err instanceof Error ? err.message : String(err)}`); + process.exit(1); +}); diff --git a/usdc-deposit-agent/src/owsSigner.ts b/usdc-deposit-agent/src/owsSigner.ts new file mode 100644 index 00000000..81518865 --- /dev/null +++ b/usdc-deposit-agent/src/owsSigner.ts @@ -0,0 +1,54 @@ +import { spawn } from "node:child_process"; +import { fileURLToPath } from "node:url"; +import path from "node:path"; + +const SIGNER_PATH = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../signer/ows-signer.mjs"); + +export interface OwsSignResult { + signature: string; + recoveryId: number; +} + +/** + * Thin client over the OWS signer subprocess. The agent never imports the OWS + * SDK directly and never holds key material — it only passes the scoped token + * (`ows_key_...`) through to a short-lived child process. + */ +export class OwsSigner { + constructor(private readonly opts: { wallet: string; apiKey: string }) {} + + /** EVM address of the vault wallet. Read-only — does not need the token. */ + async getAddress(): Promise { + const out = await this.run(["address"], undefined, { OWS_WALLET: this.opts.wallet }); + return JSON.parse(out).address as string; + } + + /** Policy-gated EIP-712 signature. Throws POLICY_DENIED if the policy rejects it. */ + async signTypedData(typedDataJson: string): Promise { + const out = await this.run(["sign"], JSON.stringify({ typedDataJson }), { + OWS_WALLET: this.opts.wallet, + OWS_API_KEY: this.opts.apiKey, + }); + return JSON.parse(out) as OwsSignResult; + } + + private run(args: string[], stdin: string | undefined, env: Record): Promise { + return new Promise((resolve, reject) => { + const child = spawn("node", [SIGNER_PATH, ...args], { + env: { ...process.env, ...env }, + stdio: ["pipe", "pipe", "pipe"], + }); + let stdout = ""; + let stderr = ""; + child.stdout.on("data", d => (stdout += d)); + child.stderr.on("data", d => (stderr += d)); + child.on("error", reject); + child.on("close", code => { + if (code === 0) resolve(stdout); + else reject(new Error(`OWS signer exited ${code}: ${stderr.trim() || stdout.trim() || "no output"}`)); + }); + if (stdin) child.stdin.write(stdin); + child.stdin.end(); + }); + } +} diff --git a/usdc-deposit-agent/test/policy-gate.mjs b/usdc-deposit-agent/test/policy-gate.mjs new file mode 100644 index 00000000..923332cd --- /dev/null +++ b/usdc-deposit-agent/test/policy-gate.mjs @@ -0,0 +1,91 @@ +// Isolated end-to-end test of the OWS vault → policy → sign path. No real funds. +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import { importWalletPrivateKey, createPolicy, createApiKey, signTypedData } from "@open-wallet-standard/core"; + +const vault = mkdtempSync(join(tmpdir(), "ows-smoke-")); +const pass = "testpass"; +const USDC = "0xaf88d065e77c8cC2239327C5EDb3A432268e5831"; +const MULTISIG = "0x1111111111111111111111111111111111111111"; +const CHECK = resolve("policy/check-deposit.mjs"); +// anvil account #0 +const PK = "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80"; +const FROM = "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266"; + +function td(to, value, chainId = "42161") { + return JSON.stringify({ + types: { + EIP712Domain: [ + { name: "name", type: "string" }, + { name: "version", type: "string" }, + { name: "chainId", type: "uint256" }, + { name: "verifyingContract", type: "address" }, + ], + 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" }, + ], + }, + primaryType: "TransferWithAuthorization", + domain: { name: "USD Coin", version: "2", chainId, verifyingContract: USDC }, + message: { + from: FROM, + to, + value, + validAfter: "0", + validBefore: "99999999999", + nonce: "0x" + "ab".repeat(32), + }, + }); +} + +const results = []; +function run(label, chain, typedData, expectAllow) { + try { + const r = signTypedData("smoke-wallet", chain, typedData, token, undefined, vault); + results.push([label, expectAllow ? "PASS" : "FAIL (should have denied)", r.signature.slice(0, 14) + "…"]); + } catch (e) { + const denied = String(e.message || e); + results.push([label, !expectAllow ? "PASS" : "FAIL (should have signed)", denied.slice(0, 80)]); + } +} + +let token; +try { + importWalletPrivateKey("smoke-wallet", PK, pass, vault); + createPolicy( + JSON.stringify({ + id: "arbitrum-usdc-deposit", + name: "smoke", + version: 1, + created_at: "2026-06-13T00:00:00Z", + rules: [ + { type: "allowed_chains", chain_ids: ["eip155:42161"] }, + { type: "expires_at", timestamp: "2030-01-01T00:00:00Z" }, + ], + executable: CHECK, + config: { multisig: MULTISIG, usdc: USDC, max_value: "100000000" }, + action: "deny", + }), + vault, + ); + const key = createApiKey("agent", ["smoke-wallet"], ["arbitrum-usdc-deposit"], pass, undefined, vault); + token = key.token; + + run("✔ correct deposit (1 USDC → multisig)", "arbitrum", td(MULTISIG, "1000000"), true); + run("✘ wrong recipient", "arbitrum", td("0x2222222222222222222222222222222222222222", "1000000"), false); + run("✘ over the cap (200 USDC)", "arbitrum", td(MULTISIG, "200000000"), false); + run("✘ wrong chain (base)", "base", td(MULTISIG, "1000000"), false); +} finally { + rmSync(vault, { recursive: true, force: true }); +} + +console.log("\nPolicy gate results:"); +for (const [label, verdict, detail] of results) console.log(` [${verdict}] ${label}\n ${detail}`); +const failed = results.filter(r => r[1].startsWith("FAIL")); +process.exit(failed.length ? 1 : 0); diff --git a/usdc-deposit-agent/tsconfig.json b/usdc-deposit-agent/tsconfig.json new file mode 100644 index 00000000..7c95b8a7 --- /dev/null +++ b/usdc-deposit-agent/tsconfig.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "lib": ["ES2022"], + "strict": true, + "esModuleInterop": true, + "resolveJsonModule": true, + "skipLibCheck": true, + "noEmit": true, + "types": ["node"] + }, + "include": ["src/**/*.ts"] +}