diff --git a/.gitignore b/.gitignore index de1b8de..6aaf867 100644 --- a/.gitignore +++ b/.gitignore @@ -7,3 +7,4 @@ dist/ /.idea/workspace.xml .nx/ */**/.nx +.gstack/ diff --git a/CLAUDE.md b/CLAUDE.md index 8a66d36..73045df 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -19,7 +19,7 @@ published with the CLI package but are not source code. ## Development - Dev environment requires Node 24+ (`.nvmrc`). The published CLI supports Node 20+. -- `node packages/polygon-agent-cli/src/index.ts` runs the CLI directly from source. +- `tsx packages/polygon-agent-cli/src/index.ts` runs the CLI directly from source (tsx handles `.js`→`.ts` remapping for workspace packages). - `pnpm run build` compiles TypeScript to `dist/` (targeting es2023 for Node 20 compat). - The CLI uses yargs with the `CommandModule` builder/handler pattern. diff --git a/docs/superpowers/plans/2026-03-27-v2-relay-session.md b/docs/superpowers/plans/2026-03-27-v2-relay-session.md new file mode 100644 index 0000000..bcefda7 --- /dev/null +++ b/docs/superpowers/plans/2026-03-27-v2-relay-session.md @@ -0,0 +1,1381 @@ +# Polygon Agent Kit v2 — Relay Session Handoff + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Replace the cloudflared-tunnel session handoff with a Cloudflare Durable Object relay + 6-digit out-of-band code, keeping all existing CLI/UI styles and adding only the necessary new screens. + +**Architecture:** A new `packages/shared` workspace package provides the pure-JS crypto protocol (X25519 ECDH + HKDF-SHA256 + XChaCha20-Poly1305) usable in both Node.js and Cloudflare Workers. The existing `connector-ui` Worker gains a `/api/relay/*` API backed by a `SessionRelay` Durable Object; the SPA adds a code-display screen. The CLI replaces its cloudflared + local HTTP server with a relay HTTP client + readline code prompt. + +**Tech Stack:** `@noble/curves` (X25519), `@noble/hashes` (HKDF/SHA-256), `@noble/ciphers` (XChaCha20-Poly1305), Cloudflare Durable Objects, pnpm workspaces, Vite + React + Tailwind (connector-ui), yargs (CLI). + +**Branch:** `feat/v2-relay-session` + +--- + +## File Map + +### New +| File | Responsibility | +|------|---------------| +| `packages/shared/package.json` | Workspace package declaration, @noble/* deps | +| `packages/shared/src/constants.ts` | Protocol constants (TTL, code length, max attempts) | +| `packages/shared/src/types.ts` | `SessionPayload`, `EncryptedPayload`, relay request/response shapes | +| `packages/shared/src/encoding.ts` | Hex ↔ bytes, base64url ↔ bytes helpers | +| `packages/shared/src/crypto.ts` | X25519 keypair gen, encrypt, decrypt | +| `packages/shared/src/index.ts` | Re-exports | +| `packages/shared/crypto.test.ts` | Round-trip encrypt/decrypt test | +| `packages/shared/vitest.config.ts` | Vitest config | +| `packages/connector-ui/src/relay.ts` | `SessionRelay` Durable Object + relay route handlers | +| `packages/connector-ui/src/components/CodeDisplay.tsx` | "Enter this code" screen (existing Tailwind style) | +| `packages/polygon-agent-cli/src/lib/relay-client.ts` | HTTP client to relay (createRequest, getStatus, retrieve) | + +### Modified +| File | What changes | +|------|-------------| +| `packages/connector-ui/worker.mjs` | Route `/api/relay/*` to DO; export `SessionRelay` | +| `packages/connector-ui/wrangler.toml` | Add `[durable_objects]` binding + migration | +| `packages/connector-ui/package.json` | Add `@polygonlabs/agent-shared` workspace dep | +| `packages/connector-ui/src/App.tsx` | New state machine; replace sealed-box with shared crypto; add code-display screen | +| `packages/connector-ui/src/config.ts` | Add `relayUrl` export | +| `packages/polygon-agent-cli/src/commands/wallet.ts` | Replace tunnel/local-server with relay-client + readline prompt | +| `packages/polygon-agent-cli/package.json` | Add `@polygonlabs/agent-shared`, `@noble/*` deps; remove `tweetnacl` | +| `pnpm-workspace.yaml` | Already covers `packages/*`; no change needed | + +--- + +## Task 1: packages/shared — crypto protocol package + +**Files:** +- Create: `packages/shared/package.json` +- Create: `packages/shared/src/constants.ts` +- Create: `packages/shared/src/types.ts` +- Create: `packages/shared/src/encoding.ts` +- Create: `packages/shared/src/crypto.ts` +- Create: `packages/shared/src/index.ts` +- Create: `packages/shared/crypto.test.ts` +- Create: `packages/shared/vitest.config.ts` + +- [ ] **Step 1: Create package.json** + +```json +// packages/shared/package.json +{ + "name": "@polygonlabs/agent-shared", + "version": "0.1.0", + "private": true, + "type": "module", + "main": "./src/index.ts", + "exports": { + ".": "./src/index.ts" + }, + "scripts": { + "test": "vitest run" + }, + "dependencies": { + "@noble/ciphers": "^1.2.1", + "@noble/curves": "^1.8.1", + "@noble/hashes": "^1.7.2" + }, + "devDependencies": { + "vitest": "^3.1.1" + } +} +``` + +- [ ] **Step 2: Create constants.ts** + +```typescript +// packages/shared/src/constants.ts +export const PROTOCOL_VERSION = 'polygon-agent-session-v1'; +export const CODE_LENGTH = 6; +export const MAX_CODE_ATTEMPTS = 3; +export const REQUEST_TTL_SECONDS = 300; +export const REQUEST_ID_LENGTH = 8; +``` + +- [ ] **Step 3: Create types.ts** + +```typescript +// packages/shared/src/types.ts + +export interface ImplicitSession { + pk: string; + attestation: string; + identity_sig: string; +} + +export interface SessionPermissions { + /** Max native token spend, as wei string */ + native_limit?: string; + erc20_limits?: Array<{ token_address: string; limit: string }>; + contract_calls?: Array<{ address: string; functions: string[] }>; +} + +export interface SessionPayload { + version: 1; + wallet_address: string; + chain_id: number; + /** Hex-encoded explicit session private key */ + session_private_key: string; + /** Explicit session signer address */ + session_address: string; + permissions: SessionPermissions; + /** Unix timestamp — expiry of explicit session */ + expiry: number; + ecosystem_wallet_url: string; + dapp_origin: string; + project_access_key: string; + relayer_url?: string; + /** Full explicit session config, JSON-stringified (for dapp-client reconstruction) */ + session_config?: string; + implicit_session?: ImplicitSession; +} + +export interface EncryptedPayload { + wallet_pk_hex: string; + nonce_hex: string; + ciphertext_b64url: string; + code_hash_hex: string; +} + +export interface RelayCreateResponse { + request_id: string; +} + +export interface RelayStatusResponse { + status: 'pending' | 'ready'; +} +``` + +- [ ] **Step 4: Create encoding.ts** + +```typescript +// packages/shared/src/encoding.ts + +export function bytesToHex(bytes: Uint8Array): string { + return Array.from(bytes) + .map((b) => b.toString(16).padStart(2, '0')) + .join(''); +} + +export function hexToBytes(hex: string): Uint8Array { + if (hex.length % 2 !== 0) throw new Error('Invalid hex string'); + const bytes = new Uint8Array(hex.length / 2); + for (let i = 0; i < bytes.length; i++) { + bytes[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16); + } + return bytes; +} + +export function b64urlEncode(bytes: Uint8Array): string { + // Works in Node.js and Cloudflare Workers + let bin = ''; + for (const b of bytes) bin += String.fromCharCode(b); + return btoa(bin).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); +} + +export function b64urlDecode(str: string): Uint8Array { + const norm = str.replace(/-/g, '+').replace(/_/g, '/'); + const pad = norm.length % 4 === 0 ? '' : '='.repeat(4 - (norm.length % 4)); + const bin = atob(norm + pad); + const bytes = new Uint8Array(bin.length); + for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i); + return bytes; +} +``` + +- [ ] **Step 5: Create crypto.ts** + +```typescript +// packages/shared/src/crypto.ts +import { x25519 } from '@noble/curves/ed25519'; +import { hkdf } from '@noble/hashes/hkdf'; +import { sha256 } from '@noble/hashes/sha2'; +import { xchacha20poly1305 } from '@noble/ciphers/chacha'; +import { randomBytes } from '@noble/hashes/utils'; +import { PROTOCOL_VERSION, CODE_LENGTH } from './constants.js'; +import { bytesToHex, hexToBytes, b64urlEncode, b64urlDecode } from './encoding.js'; +import type { EncryptedPayload, SessionPayload } from './types.js'; + +export interface X25519Keypair { + secretKey: Uint8Array; + publicKey: Uint8Array; +} + +export function generateX25519Keypair(): X25519Keypair { + const secretKey = randomBytes(32); + const publicKey = x25519.getPublicKey(secretKey); + return { secretKey, publicKey }; +} + +/** Generates a random 6-digit code string, zero-padded. */ +export function generateCode(): string { + // Use 4 random bytes, take mod 1_000_000 to get 0–999999 + const bytes = randomBytes(4); + const n = new DataView(bytes.buffer).getUint32(0) % 1_000_000; + return n.toString().padStart(CODE_LENGTH, '0'); +} + +/** SHA-256(requestId + code). Used as the code_hash sent to the relay. */ +export function computeCodeHash(requestId: string, code: string): Uint8Array { + return sha256(new TextEncoder().encode(requestId + code)); +} + +function deriveEncKey( + shared: Uint8Array, + code: string, + cliPkHex: string, + walletPkHex: string +): Uint8Array { + const salt = sha256(new TextEncoder().encode(code)); + const info = new TextEncoder().encode(cliPkHex + walletPkHex + PROTOCOL_VERSION); + return hkdf(sha256, shared, salt, info, 32); +} + +/** + * Encrypt a SessionPayload for the CLI to decrypt. + * Returns the EncryptedPayload (to POST to relay) and the plaintext code (to display to user). + */ +export function encryptSession( + payload: SessionPayload, + cliPkHex: string, + requestId: string +): { encrypted: EncryptedPayload; code: string } { + const cliPk = hexToBytes(cliPkHex); + const { secretKey: walletSk, publicKey: walletPk } = generateX25519Keypair(); + const shared = x25519.getSharedSecret(walletSk, cliPk); + + const walletPkHex = bytesToHex(walletPk); + const code = generateCode(); + const encKey = deriveEncKey(shared, code, cliPkHex, walletPkHex); + + const nonce = randomBytes(24); + const aad = new Uint8Array([...cliPk, ...walletPk]); + const plaintext = new TextEncoder().encode(JSON.stringify(payload)); + + const cipher = xchacha20poly1305(encKey, nonce, aad); + const ciphertext = cipher.encrypt(plaintext); + + const encrypted: EncryptedPayload = { + wallet_pk_hex: walletPkHex, + nonce_hex: bytesToHex(nonce), + ciphertext_b64url: b64urlEncode(ciphertext), + code_hash_hex: bytesToHex(computeCodeHash(requestId, code)) + }; + + return { encrypted, code }; +} + +/** + * Decrypt a session payload received from the relay. + * The code is provided by the user out-of-band. + */ +export function decryptSession( + encrypted: EncryptedPayload, + cliSk: Uint8Array, + code: string, + requestId: string +): SessionPayload { + const cliPk = x25519.getPublicKey(cliSk); + const walletPk = hexToBytes(encrypted.wallet_pk_hex); + const shared = x25519.getSharedSecret(cliSk, walletPk); + + const cliPkHex = bytesToHex(cliPk); + const walletPkHex = encrypted.wallet_pk_hex; + const encKey = deriveEncKey(shared, code, cliPkHex, walletPkHex); + + const nonce = hexToBytes(encrypted.nonce_hex); + const aad = new Uint8Array([...cliPk, ...walletPk]); + const ciphertext = b64urlDecode(encrypted.ciphertext_b64url); + + const cipher = xchacha20poly1305(encKey, nonce, aad); + // xchacha20poly1305.decrypt throws if auth tag fails + const plaintext = cipher.decrypt(ciphertext); + return JSON.parse(new TextDecoder().decode(plaintext)) as SessionPayload; +} +``` + +- [ ] **Step 6: Create index.ts** + +```typescript +// packages/shared/src/index.ts +export * from './constants.js'; +export * from './types.js'; +export * from './encoding.js'; +export * from './crypto.js'; +``` + +- [ ] **Step 7: Write failing test** + +```typescript +// packages/shared/crypto.test.ts +import { describe, it, expect } from 'vitest'; +import { + generateX25519Keypair, + encryptSession, + decryptSession, + generateCode, + computeCodeHash +} from './src/crypto.js'; +import { bytesToHex } from './src/encoding.js'; +import type { SessionPayload } from './src/types.js'; + +const SAMPLE_PAYLOAD: SessionPayload = { + version: 1, + wallet_address: '0xc448e20a23d9ca5b0f9d667c6676f64c73cff8b7', + chain_id: 137, + session_private_key: '0x' + 'ab'.repeat(32), + session_address: '0x' + 'cd'.repeat(20), + permissions: { native_limit: '2000000000000000000', erc20_limits: [] }, + expiry: Math.floor(Date.now() / 1000) + 86400 * 183, + ecosystem_wallet_url: 'https://wallet.sequence.app', + dapp_origin: 'https://agentconnect.polygon.technology', + project_access_key: 'AQAAAAAAAAAAAAAAAAAAAAAAAAAtest' +}; + +describe('session encrypt/decrypt round-trip', () => { + it('decrypts to original payload', () => { + const { secretKey: cliSk, publicKey: cliPk } = generateX25519Keypair(); + const requestId = 'abc12345'; + const cliPkHex = bytesToHex(cliPk); + + const { encrypted, code } = encryptSession(SAMPLE_PAYLOAD, cliPkHex, requestId); + + expect(code).toMatch(/^\d{6}$/); + expect(encrypted.wallet_pk_hex).toHaveLength(64); + expect(encrypted.nonce_hex).toHaveLength(48); + + const decrypted = decryptSession(encrypted, cliSk, code, requestId); + expect(decrypted.wallet_address).toBe(SAMPLE_PAYLOAD.wallet_address); + expect(decrypted.chain_id).toBe(137); + expect(decrypted.session_private_key).toBe(SAMPLE_PAYLOAD.session_private_key); + }); + + it('throws on wrong code', () => { + const { secretKey: cliSk, publicKey: cliPk } = generateX25519Keypair(); + const requestId = 'abc12345'; + const { encrypted } = encryptSession(SAMPLE_PAYLOAD, bytesToHex(cliPk), requestId); + expect(() => decryptSession(encrypted, cliSk, '000000', requestId)).toThrow(); + }); + + it('generates 6-digit codes', () => { + for (let i = 0; i < 20; i++) { + const code = generateCode(); + expect(code).toMatch(/^\d{6}$/); + expect(parseInt(code)).toBeGreaterThanOrEqual(0); + expect(parseInt(code)).toBeLessThan(1_000_000); + } + }); + + it('computeCodeHash is deterministic', () => { + const h1 = computeCodeHash('req123', '847291'); + const h2 = computeCodeHash('req123', '847291'); + expect(bytesToHex(h1)).toBe(bytesToHex(h2)); + }); +}); +``` + +- [ ] **Step 8: Create vitest.config.ts** + +```typescript +// packages/shared/vitest.config.ts +import { defineConfig } from 'vitest/config'; +export default defineConfig({ + test: { environment: 'node' } +}); +``` + +- [ ] **Step 9: Install deps and run test (expect FAIL — package not yet built)** + +```bash +cd /path/to/polygon-agent-kit +pnpm install +cd packages/shared && pnpm test +``` + +Expected: FAIL — `Cannot find module '@noble/curves/ed25519'` or similar (deps not installed yet). If `pnpm install` ran, it should FAIL with test errors, not module-not-found errors. + +After `pnpm install`, run again: + +```bash +pnpm test +``` + +Expected: All 4 tests PASS. + +- [ ] **Step 10: Commit** + +```bash +git add packages/shared +git commit -m "feat(shared): add v2 crypto protocol package (X25519+HKDF+XChaCha20)" +``` + +--- + +## Task 2: connector-ui — Durable Object relay API + +**Files:** +- Create: `packages/connector-ui/src/relay.ts` +- Modify: `packages/connector-ui/worker.mjs` +- Modify: `packages/connector-ui/wrangler.toml` + +The relay runs inside the same Worker that serves the SPA. Requests to `/api/relay/*` are forwarded to `SessionRelay` Durable Objects (one per request ID). All other paths serve the SPA as before. + +- [ ] **Step 1: Create src/relay.ts (Durable Object + route handlers)** + +```typescript +// packages/connector-ui/src/relay.ts +import { MAX_CODE_ATTEMPTS, REQUEST_TTL_SECONDS } from '@polygonlabs/agent-shared'; + +// --- Validation helpers --- + +function isHex(s: unknown, len: number): s is string { + return typeof s === 'string' && s.length === len && /^[0-9a-f]+$/.test(s); +} + +function isB64url(s: unknown): s is string { + return typeof s === 'string' && s.length > 0 && /^[A-Za-z0-9_-]+$/.test(s); +} + +/** Constant-time hex string comparison (avoids timing attacks). */ +function constantTimeEqual(a: string, b: string): boolean { + if (a.length !== b.length) return false; + let diff = 0; + for (let i = 0; i < a.length; i++) { + diff |= a.charCodeAt(i) ^ b.charCodeAt(i); + } + return diff === 0; +} + +function cors(response: Response): Response { + const h = new Headers(response.headers); + h.set('Access-Control-Allow-Origin', '*'); + h.set('Access-Control-Allow-Methods', 'GET,POST,OPTIONS'); + h.set('Access-Control-Allow-Headers', 'Content-Type'); + return new Response(response.body, { status: response.status, headers: h }); +} + +function json(data: unknown, status = 200): Response { + return cors(Response.json(data, { status })); +} + +function err(msg: string, status: number): Response { + return cors(new Response(JSON.stringify({ error: msg }), { + status, + headers: { 'Content-Type': 'application/json' } + })); +} + +// --- Durable Object --- + +export class SessionRelay { + private state: DurableObjectState; + + constructor(state: DurableObjectState) { + this.state = state; + } + + async fetch(request: Request): Promise { + const url = new URL(request.url); + const { method } = request; + + if (method === 'OPTIONS') return cors(new Response(null, { status: 204 })); + + if (method === 'POST' && url.pathname === '/init') return this.handleInit(request); + if (method === 'GET' && url.pathname === '/public-key') return this.handleGetPublicKey(); + if (method === 'POST' && url.pathname === '/session') return this.handlePostSession(request); + if (method === 'GET' && url.pathname === '/status') return this.handleGetStatus(); + if (method === 'POST' && url.pathname === '/retrieve') return this.handleRetrieve(request); + + return err('Not found', 404); + } + + private async handleInit(request: Request): Promise { + let body: unknown; + try { body = await request.json(); } catch { return err('Invalid JSON', 400); } + const { cli_pk_hex } = body as Record; + if (!isHex(cli_pk_hex, 64)) return err('cli_pk_hex must be 64 hex chars', 400); + + await this.state.storage.put('cli_pk_hex', cli_pk_hex); + await this.state.storage.put('status', 'pending'); + await this.state.storage.put('attempts_remaining', MAX_CODE_ATTEMPTS); + await this.state.storage.setAlarm(Date.now() + REQUEST_TTL_SECONDS * 1000); + + return cors(new Response(null, { status: 204 })); + } + + private async handleGetPublicKey(): Promise { + const cli_pk_hex = await this.state.storage.get('cli_pk_hex'); + if (!cli_pk_hex) return err('Not found', 404); + return json({ cli_pk_hex }); + } + + private async handlePostSession(request: Request): Promise { + let body: unknown; + try { body = await request.json(); } catch { return err('Invalid JSON', 400); } + const { wallet_pk_hex, nonce_hex, ciphertext_b64url, code_hash_hex } = body as Record; + + if (!isHex(wallet_pk_hex, 64)) return err('wallet_pk_hex must be 64 hex chars', 400); + if (!isHex(nonce_hex, 48)) return err('nonce_hex must be 48 hex chars', 400); + if (!isB64url(ciphertext_b64url)) return err('ciphertext_b64url must be base64url', 400); + if (!isHex(code_hash_hex, 64)) return err('code_hash_hex must be 64 hex chars', 400); + + const status = await this.state.storage.get('status'); + if (status !== 'pending') return err('Request not in pending state', 409); + + await this.state.storage.put('wallet_pk_hex', wallet_pk_hex); + await this.state.storage.put('nonce_hex', nonce_hex); + await this.state.storage.put('ciphertext_b64url', ciphertext_b64url); + await this.state.storage.put('code_hash_hex', code_hash_hex); + await this.state.storage.put('status', 'ready'); + + return cors(new Response(null, { status: 204 })); + } + + private async handleGetStatus(): Promise { + const status = await this.state.storage.get('status'); + if (!status) return err('Not found', 404); + return json({ status }); + } + + private async handleRetrieve(request: Request): Promise { + let body: unknown; + try { body = await request.json(); } catch { return err('Invalid JSON', 400); } + const { code_hash_hex } = body as Record; + if (!isHex(code_hash_hex, 64)) return err('code_hash_hex must be 64 hex chars', 400); + + const stored = await this.state.storage.get('code_hash_hex'); + const attempts = await this.state.storage.get('attempts_remaining') ?? 0; + + if (!stored) return err('Not found', 404); + if (attempts <= 0) { + await this.state.storage.deleteAll(); + return err('Expired', 410); + } + + if (!constantTimeEqual(code_hash_hex, stored)) { + const remaining = attempts - 1; + await this.state.storage.put('attempts_remaining', remaining); + if (remaining <= 0) { + await this.state.storage.deleteAll(); + return err('Expired', 410); + } + return json({ attempts_remaining: remaining }, 403); + } + + // Correct code — retrieve and delete everything + const [wallet_pk_hex, nonce_hex, ciphertext_b64url] = await Promise.all([ + this.state.storage.get('wallet_pk_hex'), + this.state.storage.get('nonce_hex'), + this.state.storage.get('ciphertext_b64url') + ]); + await this.state.storage.deleteAll(); + + return json({ wallet_pk_hex, nonce_hex, ciphertext_b64url }); + } + + async alarm(): Promise { + await this.state.storage.deleteAll(); + } +} + +// --- Relay route handler (called from main Worker) --- + +export async function handleRelayRequest( + request: Request, + env: { SESSION_RELAY: DurableObjectNamespace } +): Promise { + if (request.method === 'OPTIONS') { + return cors(new Response(null, { + status: 204, + headers: { + 'Access-Control-Allow-Origin': '*', + 'Access-Control-Allow-Methods': 'GET,POST,OPTIONS', + 'Access-Control-Allow-Headers': 'Content-Type' + } + })); + } + + const url = new URL(request.url); + const parts = url.pathname.split('/').filter(Boolean); + // Expected: ['api', 'relay', , ] + + if (parts[0] !== 'api' || parts[1] !== 'relay') return err('Not found', 404); + + const action = parts[2]; + const rid = parts[3]; + + // POST /api/relay/request → create new relay request + if (request.method === 'POST' && action === 'request' && !rid) { + let body: unknown; + try { body = await request.json(); } catch { return err('Invalid JSON', 400); } + const { cli_pk_hex } = body as Record; + if (!isHex(cli_pk_hex, 64)) return err('cli_pk_hex must be 64 hex chars', 400); + + // Generate a random 8-char alphanumeric request ID + const bytes = new Uint8Array(8); + crypto.getRandomValues(bytes); + const alphabet = 'abcdefghijklmnopqrstuvwxyz0123456789'; + const request_id = Array.from(bytes).map(b => alphabet[b % alphabet.length]).join(''); + + const stub = env.SESSION_RELAY.get(env.SESSION_RELAY.idFromName(request_id)); + await stub.fetch(new Request('https://do/init', { + method: 'POST', + body: JSON.stringify({ cli_pk_hex }), + headers: { 'Content-Type': 'application/json' } + })); + + return json({ request_id }); + } + + if (!rid) return err('Missing request ID', 400); + + const stub = env.SESSION_RELAY.get(env.SESSION_RELAY.idFromName(rid)); + + // GET /api/relay/request/:rid → get CLI public key + if (request.method === 'GET' && action === 'request') { + const res = await stub.fetch(new Request('https://do/public-key')); + if (!res.ok) return err('Not found', 404); + return cors(res); + } + + // POST /api/relay/session/:rid → browser posts encrypted payload + if (request.method === 'POST' && action === 'session') { + const body = await request.text(); + const res = await stub.fetch(new Request('https://do/session', { + method: 'POST', + body, + headers: { 'Content-Type': 'application/json' } + })); + return cors(res); + } + + // GET /api/relay/status/:rid → poll for "pending" | "ready" + if (request.method === 'GET' && action === 'status') { + const res = await stub.fetch(new Request('https://do/status')); + if (!res.ok) return err('Not found', 404); + return cors(res); + } + + // POST /api/relay/retrieve/:rid → CLI submits code hash, gets ciphertext + if (request.method === 'POST' && action === 'retrieve') { + const body = await request.text(); + const res = await stub.fetch(new Request('https://do/retrieve', { + method: 'POST', + body, + headers: { 'Content-Type': 'application/json' } + })); + return cors(res); + } + + return err('Not found', 404); +} +``` + +- [ ] **Step 2: Update worker.mjs to route relay requests** + +Replace the entire contents of `packages/connector-ui/worker.mjs`: + +```javascript +// packages/connector-ui/worker.mjs +import { handleRelayRequest, SessionRelay } from './src/relay.ts'; + +export { SessionRelay }; + +export default { + async fetch(request, env) { + const url = new URL(request.url); + + // Route /api/relay/* to Durable Object relay + if (url.pathname.startsWith('/api/relay')) { + return handleRelayRequest(request, env); + } + + if (!env.ASSETS) { + return new Response('ASSETS binding is missing', { status: 500 }); + } + + // SPA fallback: serve index.html for non-file paths + const res = await env.ASSETS.fetch(request); + if (res.status !== 404) return res; + + if (/\.[a-z0-9]+$/i.test(url.pathname)) return res; + + const indexUrl = new URL(request.url); + indexUrl.pathname = '/index.html'; + return env.ASSETS.fetch(new Request(indexUrl.toString(), request)); + } +}; +``` + +- [ ] **Step 3: Update wrangler.toml with Durable Object binding** + +Add below the existing `[assets]` block: + +```toml +# packages/connector-ui/wrangler.toml +name = "agentconnect" +compatibility_date = "2024-07-04" +workers_dev = false +preview_urls = false +send_metrics = false +placement = { mode = "smart" } + +main = "worker.mjs" + +[assets] +directory = "./dist" +binding = "ASSETS" + +[[durable_objects.bindings]] +name = "SESSION_RELAY" +class_name = "SessionRelay" + +[[migrations]] +tag = "v1" +new_classes = ["SessionRelay"] + +[env.staging] +name = "agentconnect-staging" + +routes = [ + { pattern = "agentconnect.staging.polygon.technology", custom_domain = true } +] + +[env.production] +name = "agentconnect-production" + +routes = [ + { pattern = "agentconnect.polygon.technology", custom_domain = true } +] +``` + +- [ ] **Step 4: Add shared package dep to connector-ui** + +In `packages/connector-ui/package.json`, add to `"dependencies"`: +```json +"@polygonlabs/agent-shared": "workspace:*" +``` + +And add relay URL to `packages/connector-ui/src/config.ts`: +```typescript +// Add to the bottom of config.ts +export const relayUrl = import.meta.env.VITE_RELAY_URL || ''; +// When relayUrl is empty, the SPA calls relative paths (/api/relay/*) +// so it works both locally (proxied) and deployed. +``` + +- [ ] **Step 5: Run pnpm install to link workspace dep** + +```bash +cd /path/to/polygon-agent-kit +pnpm install +``` + +Expected: no errors, `@polygonlabs/agent-shared` linked in connector-ui node_modules. + +- [ ] **Step 6: Commit** + +```bash +git add packages/connector-ui/src/relay.ts packages/connector-ui/worker.mjs \ + packages/connector-ui/wrangler.toml packages/connector-ui/package.json \ + packages/connector-ui/src/config.ts +git commit -m "feat(connector-ui): add Durable Object relay API + upgrade worker routing" +``` + +--- + +## Task 3: connector-ui — SPA session flow update + +**Files:** +- Create: `packages/connector-ui/src/components/CodeDisplay.tsx` +- Modify: `packages/connector-ui/src/App.tsx` + +The new flow replaces the sealed-box POST-to-tunnel with: +1. Fetch `cli_pk_hex` from relay (`GET /api/relay/request/:rid`) +2. Connect wallet (same as before) +3. Build `SessionPayload`, call `encryptSession(payload, cli_pk_hex, rid)` +4. POST `EncryptedPayload` to relay (`POST /api/relay/session/:rid`) +5. Show `CodeDisplay` with the 6-digit code + +No tunnel, no local server, no callback URL. + +- [ ] **Step 1: Create CodeDisplay component (existing Tailwind style)** + +```tsx +// packages/connector-ui/src/components/CodeDisplay.tsx +import { Copy, Check } from 'lucide-react'; +import { useState } from 'react'; + +interface CodeDisplayProps { + code: string; + walletAddress: string; + walletName: string; +} + +export function CodeDisplay({ code, walletAddress, walletName }: CodeDisplayProps) { + const [copied, setCopied] = useState(false); + + function handleCopy() { + navigator.clipboard.writeText(code).catch(() => {}); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + } + + const digits = code.split(''); + + return ( +
+
+

Session approved

+

+ Enter this code in your terminal to complete setup +

+
+ +
+ {digits.map((d, i) => ( +
+ {d} +
+ ))} +
+ + + +
+
+ Wallet + {walletName} +
+
+ Address + {walletAddress} +
+
+ +

+ This code expires in 5 minutes. Do not share it. +

+
+ ); +} +``` + +- [ ] **Step 2: Update App.tsx — new state machine** + +App.tsx is 1090 lines. The changes are targeted: + +**2a. Replace imports** — add new imports, remove tweetnacl sealedbox: + +Replace: +```typescript +import { seal } from 'tweetnacl-sealedbox-js'; +``` +With: +```typescript +import { encryptSession, hexToBytes } from '@polygonlabs/agent-shared'; +import type { SessionPayload } from '@polygonlabs/agent-shared'; +import { CodeDisplay } from './components/CodeDisplay.js'; +``` + +**2b. Update App state** — Add state for new flow. Find the `function App()` block and the existing state declarations. Add: + +```typescript +// After existing state declarations inside function App(): +const [cliPkHex, setCliPkHex] = useState(''); +const [sessionCode, setSessionCode] = useState(''); +``` + +Also update the stage type to include `'code_display'`. Find the existing `stage` state (likely `useState`) and change the type annotation if present to include `'code_display'`. + +**2c. Add fetchCliPk effect** — After the existing `useEffect` that reads URL params, add: + +```typescript +// Fetch CLI public key from relay on mount (replaces reading 'pub' from URL) +useEffect(() => { + if (!rid) return; + const base = window.location.origin; // relay co-hosted + fetch(`${base}/api/relay/request/${rid}`) + .then((r) => { + if (!r.ok) throw new Error(`Relay returned ${r.status}`); + return r.json() as Promise<{ cli_pk_hex: string }>; + }) + .then(({ cli_pk_hex }) => setCliPkHex(cli_pk_hex)) + .catch((e) => setError(`Failed to fetch session key: ${e.message}`)); +}, [rid]); +``` + +**2d. Replace the post-connect encryption block** — Find the section in App.tsx that builds the sealed-box payload and POSTs to the callback URL. It starts roughly with code that calls `seal(...)`. Replace that entire block with: + +```typescript +// Build SessionPayload for the CLI +const sessionPayloadObj: SessionPayload = { + version: 1, + wallet_address: walletAddress, + chain_id: chainId, + session_private_key: sessionPk ?? '', + session_address: sessionConfig?.address ?? '', + permissions: { + native_limit: nativeLimitParam + ? String(BigInt(Math.round(parseFloat(nativeLimitParam) * 1e18))) + : undefined, + erc20_limits: buildErc20Limits(), // existing helper or inline + contract_calls: autoWhitelistedContracts.map((addr) => ({ address: addr, functions: [] })) + }, + expiry: Math.floor(Date.now() / 1000) + 86400 * 183, + ecosystem_wallet_url: walletUrl, + dapp_origin: dappOrigin, + project_access_key: projectAccessKey, + session_config: explicitSessionStr, // the stringified full session config + implicit_session: implicitMeta + ? { + pk: implicitPk ?? '', + attestation: implicitAttestation ?? '', + identity_sig: implicitIdentitySig ?? '' + } + : undefined +}; + +// Encrypt and post to relay +const { encrypted, code } = encryptSession(sessionPayloadObj, cliPkHex, rid); +const base = window.location.origin; +const relayRes = await fetch(`${base}/api/relay/session/${rid}`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(encrypted) +}); +if (!relayRes.ok) throw new Error(`Relay rejected session: ${relayRes.status}`); + +setSessionCode(code); +setStage('code_display'); +``` + +**Note:** The exact variable names (`sessionPk`, `sessionConfig`, `implicitPk`, etc.) must match what App.tsx currently uses after the dapp-client connect callback. Read the existing post-connect block carefully when implementing and adjust accordingly. + +**2e. Add code_display render branch** — In the JSX render section, find where stages are rendered (likely a series of `{stage === 'X' && ...}` branches). Add before the closing tag: + +```tsx +{stage === 'code_display' && ( + +)} +``` + +- [ ] **Step 3: Remove pub param from URL construction note** + +The `pub` URL param is no longer used in the connector URL (public key comes from relay). No change needed in the SPA (it just ignores unknown params). The CLI wallet.ts change in Task 4 will stop sending it. + +- [ ] **Step 4: Build connector-ui to check for TS errors** + +```bash +cd packages/connector-ui +pnpm build +``` + +Expected: Build completes without TypeScript errors. Fix any type errors before committing. + +- [ ] **Step 5: Commit** + +```bash +git add packages/connector-ui/src/App.tsx \ + packages/connector-ui/src/components/CodeDisplay.tsx +git commit -m "feat(connector-ui): v2 session flow — relay-based encryption + code display screen" +``` + +--- + +## Task 4: polygon-agent-cli — replace tunnel with relay + +**Files:** +- Create: `packages/polygon-agent-cli/src/lib/relay-client.ts` +- Modify: `packages/polygon-agent-cli/src/commands/wallet.ts` +- Modify: `packages/polygon-agent-cli/src/lib/storage.ts` +- Modify: `packages/polygon-agent-cli/package.json` + +The `wallet create` command currently: generates nacl keypair → starts local HTTP server → spawns cloudflared → waits for POST callback → decrypts sealed-box. + +New flow: generates X25519 keypair → registers with relay → opens browser → polls relay for "ready" → prompts user for 6-digit code → retrieves + decrypts from relay → saves session. + +- [ ] **Step 1: Add @noble/* and @polygonlabs/agent-shared to CLI deps** + +In `packages/polygon-agent-cli/package.json`, add to `"dependencies"`: +```json +"@noble/curves": "^1.8.1", +"@noble/hashes": "^1.7.2", +"@noble/ciphers": "^1.2.1", +"@polygonlabs/agent-shared": "workspace:*" +``` + +Keep `tweetnacl` for now (other code may use it); it will be removed in a follow-up cleanup. + +Run: +```bash +pnpm install +``` + +- [ ] **Step 2: Create relay-client.ts** + +```typescript +// packages/polygon-agent-cli/src/lib/relay-client.ts +import type { EncryptedPayload, RelayCreateResponse, RelayStatusResponse } from '@polygonlabs/agent-shared'; + +export class RelayClient { + constructor(private baseUrl: string) {} + + /** Register CLI public key with relay. Returns request_id. */ + async createRequest(cliPkHex: string): Promise { + const res = await fetch(`${this.baseUrl}/api/relay/request`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ cli_pk_hex: cliPkHex }) + }); + if (!res.ok) { + const text = await res.text(); + throw new Error(`Relay createRequest failed (${res.status}): ${text}`); + } + const data = (await res.json()) as RelayCreateResponse; + return data.request_id; + } + + /** Poll until status is "ready" or timeout. */ + async waitForReady(requestId: string, timeoutMs = 300_000, intervalMs = 2_000): Promise { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + const res = await fetch(`${this.baseUrl}/api/relay/status/${requestId}`); + if (res.status === 404) throw new Error('Relay request not found (expired or invalid)'); + if (!res.ok) throw new Error(`Relay status check failed (${res.status})`); + const data = (await res.json()) as RelayStatusResponse; + if (data.status === 'ready') return; + await new Promise((r) => setTimeout(r, intervalMs)); + } + throw new Error('Timed out waiting for wallet approval (5 minutes)'); + } + + /** Submit code hash and retrieve encrypted payload if correct. */ + async retrieve(requestId: string, codeHashHex: string): Promise { + const res = await fetch(`${this.baseUrl}/api/relay/retrieve/${requestId}`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ code_hash_hex: codeHashHex }) + }); + + if (res.status === 403) { + const data = (await res.json()) as { attempts_remaining: number }; + throw new RelayCodeError( + `Wrong code. ${data.attempts_remaining} attempt(s) remaining.`, + data.attempts_remaining + ); + } + if (res.status === 410) throw new RelayCodeError('Too many wrong attempts. Session expired.', 0); + if (!res.ok) throw new Error(`Relay retrieve failed (${res.status})`); + + return (await res.json()) as EncryptedPayload; + } +} + +export class RelayCodeError extends Error { + constructor( + message: string, + public readonly attemptsRemaining: number + ) { + super(message); + this.name = 'RelayCodeError'; + } +} +``` + +- [ ] **Step 3: Add session-mapping helper to storage.ts** + +The new `SessionPayload` type from shared needs to be mapped into the existing `WalletSession` interface that `dapp-client.ts` reads. Add this function at the bottom of `packages/polygon-agent-cli/src/lib/storage.ts`: + +```typescript +import type { SessionPayload } from '@polygonlabs/agent-shared'; + +/** Map a v2 SessionPayload into the WalletSession shape expected by dapp-client.ts */ +export function sessionPayloadToWalletSession( + payload: SessionPayload, + walletName: string +): WalletSession { + const chainName = resolveChainName(payload.chain_id); // see note below + return { + walletAddress: payload.wallet_address, + chainId: payload.chain_id, + chain: chainName, + projectAccessKey: payload.project_access_key, + explicitSession: payload.session_config ?? '', + sessionPk: payload.session_private_key, + implicitPk: payload.implicit_session?.pk, + implicitAttestation: payload.implicit_session?.attestation, + implicitIdentitySig: payload.implicit_session?.identity_sig, + createdAt: new Date().toISOString() + }; +} + +/** Map numeric chainId to the chain name string used internally (e.g. 137 → "polygon"). */ +function resolveChainName(chainId: number): string { + const map: Record = { + 137: 'polygon', + 80002: 'polygon-amoy', + 42161: 'arbitrum', + 10: 'optimism', + 8453: 'base', + 1: 'mainnet' + }; + return map[chainId] ?? String(chainId); +} +``` + +Note: `WalletSession` is already defined in `storage.ts` — add this function after its definition. + +- [ ] **Step 4: Update wallet.ts — replace tunnel logic with relay** + +The key change is in the `wallet create` command handler. Find the handler (the `handler` function or `builder`+`handler` export in the `create` subcommand) in `packages/polygon-agent-cli/src/commands/wallet.ts`. + +**4a. Replace nacl keypair generation and tunnel startup** with relay registration: + +Remove: +- The `nacl.box.keyPair()` call (or equivalent for sealed-box) +- The `http.createServer(...)` block +- The cloudflared spawn + tunnel URL detection +- The `callbackUrl` construction +- The callback waiting loop +- The sealed-box `sealedbox.open(...)` decryption + +Add (replacing that entire block): + +```typescript +import readline from 'node:readline'; +import { open as openBrowser } from 'open'; // already in node ecosystem or use child_process +// ... inside the handler: +const connectorBase = + process.env.SEQUENCE_ECOSYSTEM_CONNECTOR_URL?.replace(/\/$/, '') || + 'https://agentconnect.polygon.technology'; +const relayBase = connectorBase; // relay API is co-hosted on same origin + +const relay = new RelayClient(relayBase); + +// 1. Generate CLI X25519 keypair +const { secretKey: cliSk, publicKey: cliPk } = generateX25519Keypair(); +const cliPkHex = bytesToHex(cliPk); + +// 2. Register with relay → get request ID +process.stderr.write('Registering with relay...\n'); +const rid = await relay.createRequest(cliPkHex); + +// 3. Build connector URL (no 'pub' param — key is fetched from relay) +const connectorUrl = new URL(`${connectorBase}/link`); +connectorUrl.searchParams.set('rid', rid); +connectorUrl.searchParams.set('wallet', argv.wallet); +connectorUrl.searchParams.set('chain', argv.chain); +applySessionPermissionParams(connectorUrl, argv); // existing helper + +// 4. Open browser (or output URL for --no-wait) +if (argv['no-wait']) { + console.log(JSON.stringify({ approvalUrl: connectorUrl.toString(), requestId: rid })); + return; +} + +process.stderr.write(`Opening: ${connectorUrl.toString()}\n`); +await open(connectorUrl.toString()).catch(() => { + process.stderr.write(`Could not open browser. Open manually:\n${connectorUrl.toString()}\n`); +}); + +// 5. Poll relay until wallet approved +process.stderr.write('Waiting for wallet approval in browser...\n'); +await relay.waitForReady(rid); +process.stderr.write('Wallet approved. '); + +// 6. Prompt for 6-digit code +const code = await promptCode(); + +// 7. Retrieve encrypted payload from relay (retry up to 3 times on wrong code) +const codeHashHex = bytesToHex(computeCodeHash(rid, code)); +const encrypted = await relay.retrieve(rid, codeHashHex); + +// 8. Decrypt session payload +let payload; +try { + payload = decryptSession(encrypted, cliSk, code, rid); +} catch { + throw new Error('Decryption failed — wrong code or tampered payload.'); +} + +// 9. Map to WalletSession and save +const session = sessionPayloadToWalletSession(payload, argv.wallet); +await saveWalletSession(argv.wallet, session); +console.log( + JSON.stringify({ + walletAddress: session.walletAddress, + chain: session.chain, + wallet: argv.wallet + }) +); +``` + +**4b. Add `promptCode` helper** (add as a module-level function in wallet.ts): + +```typescript +function promptCode(): Promise { + return new Promise((resolve) => { + const rl = readline.createInterface({ input: process.stdin, output: process.stderr }); + rl.question('Enter the 6-digit code from the browser: ', (answer) => { + rl.close(); + resolve(answer.trim()); + }); + }); +} +``` + +**4c. Add imports** at the top of wallet.ts (replace tweetnacl/sealedbox imports): + +```typescript +import readline from 'node:readline'; +import open from 'open'; +import { + generateX25519Keypair, + bytesToHex, + computeCodeHash, + decryptSession +} from '@polygonlabs/agent-shared'; +import { RelayClient, RelayCodeError } from '../lib/relay-client.ts'; +import { sessionPayloadToWalletSession } from '../lib/storage.ts'; +``` + +- [ ] **Step 5: Add `open` dependency** + +In `packages/polygon-agent-cli/package.json`, add: +```json +"open": "^10.1.0" +``` + +Run `pnpm install`. + +- [ ] **Step 6: TypeScript check** + +```bash +cd packages/polygon-agent-cli +pnpm typecheck +``` + +Expected: No errors. Fix any before committing. + +- [ ] **Step 7: Commit** + +```bash +git add packages/polygon-agent-cli/src/lib/relay-client.ts \ + packages/polygon-agent-cli/src/lib/storage.ts \ + packages/polygon-agent-cli/src/commands/wallet.ts \ + packages/polygon-agent-cli/package.json +git commit -m "feat(cli): replace cloudflared tunnel with relay + 6-digit code handoff" +``` + +--- + +## Task 5: End-to-end smoke test + +This verifies the full flow works locally using the Wrangler dev server. + +- [ ] **Step 1: Build connector-ui** + +```bash +cd packages/connector-ui +pnpm build +``` + +Expected: `dist/` populated with SPA assets. + +- [ ] **Step 2: Start local Wrangler dev (with Durable Objects)** + +```bash +cd packages/connector-ui +npx wrangler dev --local +``` + +Expected: Worker starts on `http://localhost:8787`. You'll see "Ready on http://localhost:8787". + +- [ ] **Step 3: Smoke test relay API** + +In a separate terminal: + +```bash +# Create a relay request (use a fake 64-char hex cli_pk) +curl -s -X POST http://localhost:8787/api/relay/request \ + -H 'Content-Type: application/json' \ + -d '{"cli_pk_hex":"'$(python3 -c "print('ab'*32)")'"}' | jq . +# Expected: {"request_id":"<8-char-id>"} + +RID= + +# Fetch public key back +curl -s http://localhost:8787/api/relay/request/$RID | jq . +# Expected: {"cli_pk_hex":"abab..."} + +# Check status +curl -s http://localhost:8787/api/relay/status/$RID | jq . +# Expected: {"status":"pending"} +``` + +- [ ] **Step 4: Smoke test full CLI → browser → code flow** + +Set env to point CLI at local worker: + +```bash +export SEQUENCE_ECOSYSTEM_CONNECTOR_URL=http://localhost:8787 +cd packages/polygon-agent-cli +node src/index.ts wallet create --wallet smoketest --chain polygon +``` + +Expected output: +1. "Registering with relay..." printed to stderr +2. Browser opens to `http://localhost:8787/link?rid=...` +3. After approving in browser (or simulating), code digits displayed in browser +4. Terminal prompts "Enter the 6-digit code from the browser:" +5. After entering code, session saved and JSON printed + +- [ ] **Step 5: Commit smoke test result note (optional)** + +If any issues found and fixed during smoke test: + +```bash +git add -p # stage only the fixes +git commit -m "fix: smoke test corrections for v2 relay flow" +``` + +--- + +## Notes for Figma UI Integration + +When Figma designs arrive, the following files are the target for visual updates: + +- `packages/connector-ui/src/App.tsx` — stage rendering, layout structure +- `packages/connector-ui/src/components/CodeDisplay.tsx` — the new code screen +- `packages/connector-ui/src/App.css` / `src/index.css` — global styles + +The new `CodeDisplay` component uses the same Tailwind patterns as the rest of App.tsx (zinc color scale, rounded-lg, border-zinc-700, etc.). To restyle, update Tailwind classes in `CodeDisplay.tsx`. + +--- + +## Cloudflare Deployment Checklist + +When deploying to staging: + +```bash +cd packages/connector-ui +pnpm build +npx wrangler deploy --env staging +``` + +First deploy with Durable Objects requires the migration to be applied. Wrangler handles this automatically via the `[[migrations]]` block in wrangler.toml (class `SessionRelay`, tag `v1`). diff --git a/eslint.config.js b/eslint.config.js index 8387cd5..beb8e61 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -4,6 +4,6 @@ import { recommended, typescript } from '@polygonlabs/apps-team-lint'; export default defineConfig([ ...recommended({ globals: 'node' }), - ...typescript(), + ...typescript({ tsconfigRootDir: import.meta.dirname }), { ignores: ['.claude/**', '**/dist/**'] } ]); diff --git a/package.json b/package.json index 5ac5b4a..428093a 100644 --- a/package.json +++ b/package.json @@ -10,24 +10,31 @@ "format": "eslint . --fix && prettier --write .", "build": "pnpm -r run build", "typecheck": "pnpm -r run typecheck", - "polygon-agent": "node packages/polygon-agent-cli/src/index.ts" + "polygon-agent": "tsx packages/polygon-agent-cli/src/index.ts" }, "engines": { "node": ">=24" }, + "pnpm": { + "overrides": { + "@noble/curves": "1.9.7", + "@noble/hashes": "1.8.0" + } + }, "devDependencies": { "@commitlint/cli": "^20.4.2", "@commitlint/config-conventional": "^20.4.2", "@polygonlabs/apps-team-lint": "^1.0.0", - "@tsconfig/node24": "^24.0.4", "@tsconfig/node-ts": "^23.6.4", + "@tsconfig/node24": "^24.0.4", + "@types/node": "^22.15.0", "concurrently": "^9.2.1", "eslint": "^10.0.0", "husky": "^9.1.7", - "lint-staged": "^16.2.7", "lerna": "^9.0.5", + "lint-staged": "^16.2.7", "prettier": "^3.0.0", - "@types/node": "^22.15.0", + "tsx": "^4.21.0", "typescript": "^5.8.3" } } diff --git a/packages/connector-ui/package.json b/packages/connector-ui/package.json index 6d2f4d1..77a7799 100644 --- a/packages/connector-ui/package.json +++ b/packages/connector-ui/package.json @@ -19,19 +19,20 @@ "typecheck": "tsc --noEmit" }, "dependencies": { + "0xtrails": "^0.13.1", "@0xsequence/dapp-client": "3.0.0-beta.16", "@0xsequence/wallet-primitives": "3.0.0-beta.16", + "@polygonlabs/agent-shared": "workspace:*", "@tailwindcss/vite": "^4.1.18", "lucide-react": "^0.564.0", "ox": "^0.11.3", "react": "^18.3.1", "react-dom": "^18.3.1", "serve": "^14.2.4", - "tailwindcss": "^4", - "tweetnacl": "^1.0.3", - "tweetnacl-sealedbox-js": "^1.2.0" + "tailwindcss": "^4" }, "devDependencies": { + "@cloudflare/workers-types": "^4.20260317.1", "@types/react": "^18.3.3", "@types/react-dom": "^18.3.0", "@vitejs/plugin-react": "^4.3.1", diff --git a/packages/connector-ui/public/SKILL.md b/packages/connector-ui/public/SKILL.md new file mode 100644 index 0000000..bd3ff47 --- /dev/null +++ b/packages/connector-ui/public/SKILL.md @@ -0,0 +1,181 @@ +--- +name: polygon-agent-kit +description: Complete Polygon agent toolkit. Session-based smart contract wallets (Sequence), token ops (send/swap/bridge/deposit via Trails), ERC-8004 on-chain identity + reputation, x402 micropayments. Single CLI entry point, AES-256-GCM encrypted storage. +--- + +# Polygon Agentic CLI + +## Prerequisites +- Node.js 20+ +- Install globally: `npm install -g github:0xPolygon/polygon-agent-kit` (reinstall to update) +- Entry point: `polygon-agent ` +- Storage: `~/.polygon-agent/` (AES-256-GCM encrypted) + +## Architecture + +| Wallet | Created by | Purpose | Fund? | +|--------|-----------|---------|-------| +| EOA | `setup` | Auth with Sequence Builder | NO | +| Ecosystem Wallet | `wallet create` | Primary spending wallet | YES | + +## Environment Variables + +### Required +| Variable | When | +|----------|------| +| `SEQUENCE_PROJECT_ACCESS_KEY` | Wallet creation, swaps, balance checks, Trails | + +**One key, three names** — `SEQUENCE_INDEXER_ACCESS_KEY` and `TRAILS_API_KEY` are the same value as `SEQUENCE_PROJECT_ACCESS_KEY`. Set them all once: +```bash +export SEQUENCE_PROJECT_ACCESS_KEY= +export SEQUENCE_INDEXER_ACCESS_KEY=$SEQUENCE_PROJECT_ACCESS_KEY +export TRAILS_API_KEY=$SEQUENCE_PROJECT_ACCESS_KEY +``` + +### Optional +| Variable | Default | +|----------|---------| +| `SEQUENCE_ECOSYSTEM_CONNECTOR_URL` | `https://agentconnect.polygon.technology` | +| `SEQUENCE_DAPP_ORIGIN` | Same as connector URL origin | +| `TRAILS_TOKEN_MAP_JSON` | Token-directory lookup | +| `POLYGON_AGENT_DEBUG_FETCH` | Off — logs HTTP to `~/.polygon-agent/fetch-debug.log` | +| `POLYGON_AGENT_DEBUG_FEE` | Off — dumps fee options to stderr | + +## Complete Setup Flow + +```bash +# Phase 1: Setup (creates EOA + Sequence project, returns access key) +polygon-agent setup --name "MyAgent" +# → save privateKey (not shown again), eoaAddress, accessKey + +# Phase 2: Create ecosystem wallet (opens browser, waits for 6-digit code) +export SEQUENCE_PROJECT_ACCESS_KEY= +polygon-agent wallet create --usdc-limit 100 --native-limit 5 +# → opens https://agentconnect.polygon.technology/link?rid=&... +# → user approves in browser, browser shows a 6-digit code +# → enter the 6-digit code in the terminal when prompted +# → session saved to ~/.polygon-agent/wallets/main.json + +# Phase 3: Fund wallet +polygon-agent fund +# → reads walletAddress from session, builds Trails widget URL with toAddress= +# → ALWAYS run this command to get the URL — never construct it manually or hardcode any address +# → send the returned `fundingUrl` to the user; `walletAddress` in the output confirms the recipient + +# Phase 4: Verify +export SEQUENCE_INDEXER_ACCESS_KEY=$SEQUENCE_PROJECT_ACCESS_KEY +polygon-agent balances + +# Phase 5: Register agent on-chain (ERC-8004, Polygon mainnet) +polygon-agent agent register --name "MyAgent" --broadcast +# → mints ERC-721 NFT, emits agentId in Registered event +# → use agentId for reputation queries and feedback +``` + +## Commands Reference + +### Setup +```bash +polygon-agent setup --name [--force] +``` + +### Wallet +```bash +polygon-agent wallet create [--name ] [--chain polygon] [--timeout ] [--no-wait] + [--native-limit ] [--usdc-limit ] [--usdt-limit ] + [--token-limit ] # repeatable + [--usdc-to --usdc-amount ] # one-off scoped transfer + [--contract ] # whitelist contract (repeatable) +polygon-agent wallet import --code <6-digit-code> --rid [--name ] +polygon-agent wallet import --ciphertext '|@' [--name ] # legacy +polygon-agent wallet list +polygon-agent wallet address [--name ] +polygon-agent wallet remove [--name ] +``` + +### Operations +```bash +polygon-agent balances [--wallet ] [--chain ] +polygon-agent send --to --amount [--symbol ] [--broadcast] +polygon-agent send-native --to --amount [--broadcast] [--direct] +polygon-agent send-token --symbol --to --amount [--broadcast] +polygon-agent swap --from --to --amount [--to-chain ] [--slippage ] [--broadcast] +polygon-agent deposit --asset --amount [--protocol aave|morpho] [--broadcast] +polygon-agent fund [--wallet ] [--token ] +polygon-agent x402-pay --url --wallet [--method GET] [--body ] [--header Key:Value] +``` + +### Agent (ERC-8004) +```bash +polygon-agent agent register --name [--agent-uri ] [--metadata ] [--broadcast] +polygon-agent agent wallet --agent-id +polygon-agent agent metadata --agent-id --key +polygon-agent agent reputation --agent-id [--tag1 ] +polygon-agent agent reviews --agent-id +polygon-agent agent feedback --agent-id --value [--tag1 ] [--tag2 ] [--endpoint ] [--broadcast] +``` + +**ERC-8004 contracts (Polygon mainnet):** +- IdentityRegistry: `0x8004A169FB4a3325136EB29fA0ceB6D2e539a432` +- ReputationRegistry: `0x8004BAa17C55a88189AE136b182e5fdA19dE9b63` + +## Key Behaviors + +- **Dry-run by default** — all write commands require `--broadcast` to execute +- **Smart defaults** — `--wallet main`, `--chain polygon`, auto-wait on `wallet create` +- **Fee preference** — auto-selects USDC over native POL when both available +- **`fund`** — reads `walletAddress` from the wallet session and sets it as `toAddress` in the Trails widget URL. Always run `polygon-agent fund` to get the correct URL — never construct it manually or hardcode any address. +- **`deposit`** — picks highest-TVL pool via Trails `getEarnPools`. If session rejects, re-create wallet with `--contract ` +- **`x402-pay`** — probes endpoint for 402, smart wallet funds builder EOA with exact token amount, EOA signs EIP-3009 payment. Chain auto-detected from 402 response +- **`send-native --direct`** — bypasses ValueForwarder contract for direct EOA transfer +- **Session permissions** — without `--usdc-limit` etc., session gets bare-bones defaults and may not transact +- **Session expiry** — 6 months from creation + +## Wallet Creation Flow (v2 Relay) + +`wallet create` uses a Cloudflare Durable Object relay and a 6-digit out-of-band code — no cloudflared tunnel required: + +1. CLI registers its X25519 public key with the relay, gets a request ID (`rid`) +2. CLI opens `https://agentconnect.polygon.technology/link?rid=&...` in the browser +3. User approves the wallet session in the browser (Sequence popup) +4. Browser encrypts the session with the CLI's public key and posts it to the relay +5. Browser displays a **6-digit code** +6. User enters the code in the terminal when prompted +7. CLI fetches the encrypted payload from the relay, decrypts it using the code, saves the session + +**`--no-wait` flow:** CLI outputs the URL without blocking. Complete later with: +```bash +polygon-agent wallet import --code <6-digit-code> --rid +``` + +## CRITICAL: Wallet Approval URL + +When `wallet create` outputs a URL in the `url` or `approvalUrl` field, send the **complete, untruncated URL** to the user. The URL contains the relay request ID required for session approval. + +- Do NOT shorten, summarize, or add `...` to the URL +- Do NOT split the URL across multiple messages +- Output the raw URL exactly as returned by the CLI + +## Troubleshooting + +| Issue | Fix | +|-------|-----| +| `Builder configured already` | Add `--force` | +| `Missing SEQUENCE_PROJECT_ACCESS_KEY` | Run `setup` first | +| `Missing wallet` | `wallet list`, re-run `wallet create` | +| `Session expired` | Re-run `wallet create` (6-month expiry) | +| `Fee option errors` | Set `POLYGON_AGENT_DEBUG_FEE=1`, ensure wallet has funds | +| `Timed out waiting for wallet approval` | Add `--timeout 600` | +| `Invalid code: hash mismatch` | Wrong 6-digit code entered — retry (3 attempts allowed) | +| `Relay request not found` | Session expired or already used — re-run `wallet create` | +| Deposit session rejected | Re-create wallet with `--contract ` | +| Wrong recipient in Trails widget | Run `polygon-agent fund` (do not construct the URL manually) | + +## File Structure +``` +~/.polygon-agent/ +├── .encryption-key # AES-256-GCM key (auto-generated, 0600) +├── builder.json # EOA privateKey (encrypted), eoaAddress, accessKey, projectId +├── wallets/.json # walletAddress, session, chainId, chain +└── requests/.json # Pending wallet creation requests (deleted after successful import) +``` diff --git a/packages/connector-ui/public/polygon-logo-full.webp b/packages/connector-ui/public/polygon-logo-full.webp new file mode 100644 index 0000000..5c50d0b Binary files /dev/null and b/packages/connector-ui/public/polygon-logo-full.webp differ diff --git a/packages/connector-ui/public/polygon-logo.png b/packages/connector-ui/public/polygon-logo.png new file mode 100644 index 0000000..4c66bc3 Binary files /dev/null and b/packages/connector-ui/public/polygon-logo.png differ diff --git a/packages/connector-ui/src/App.css b/packages/connector-ui/src/App.css index 9e85bc2..c007ce6 100644 --- a/packages/connector-ui/src/App.css +++ b/packages/connector-ui/src/App.css @@ -1,42 +1,3 @@ -/* Animated gradient border for the main card */ -.card-glow { - position: relative; -} -.card-glow::before { - content: ''; - position: absolute; - inset: -1px; - border-radius: inherit; - background: linear-gradient( - 135deg, - rgba(130, 71, 229, 0.4), - rgba(130, 71, 229, 0.1) 30%, - transparent 50%, - rgba(130, 71, 229, 0.1) 70%, - rgba(130, 71, 229, 0.3) - ); - z-index: -1; - mask: - linear-gradient(#fff 0 0) content-box, - linear-gradient(#fff 0 0); - mask-composite: exclude; - padding: 1px; -} - -/* SVG checkmark draw animation */ -.check-circle { - stroke-dasharray: 24; - stroke-dashoffset: 24; - animation: check-draw 0.4s ease-out 0.2s forwards; -} - -/* Button press effect */ .btn-press:active { transform: scale(0.98); } - -/* Cipher textarea */ -.cipher-textarea { - resize: none; - scrollbar-width: thin; -} diff --git a/packages/connector-ui/src/App.tsx b/packages/connector-ui/src/App.tsx index 3dd59c9..b7c10fb 100644 --- a/packages/connector-ui/src/App.tsx +++ b/packages/connector-ui/src/App.tsx @@ -1,10 +1,23 @@ -import { Wallet, Copy, Check, ExternalLink, ArrowRight, AlertCircle } from 'lucide-react'; +import type { ElementType } from 'react'; import './App.css'; +import { + Wallet, + Copy, + AlertCircle, + Plus, + CalendarClock, + Twitter, + BarChart2, + Globe, + ArrowLeftRight, + TrendingUp +} from 'lucide-react'; import { Hex, Signature } from 'ox'; import { useEffect, useMemo, useState } from 'react'; -import { seal } from 'tweetnacl-sealedbox-js'; + +import type { SessionPayload } from '@polygonlabs/agent-shared'; import { DappClient, @@ -14,41 +27,14 @@ import { Utils, Permission } from '@0xsequence/dapp-client'; +import { encryptSession } from '@polygonlabs/agent-shared'; +import { CodeDisplay } from './components/CodeDisplay.js'; +import { FundingScreen } from './components/FundingScreen.js'; import { dappOrigin, projectAccessKey, walletUrl, relayerUrl, nodesUrl } from './config'; -import { - fetchBalancesAllChains, - pickChainBalances, - resolveChainId, - resolveNetwork -} from './indexer'; +import { resolveChainId, fetchTotalUsdBalance } from './indexer'; import { resolveErc20Symbol } from './tokenDirectory'; -function b64urlDecode(str: string): Uint8Array { - const norm = str.replace(/-/g, '+').replace(/_/g, '/'); - const pad = norm.length % 4 === 0 ? '' : '='.repeat(4 - (norm.length % 4)); - const bin = atob(norm + pad); - const out = new Uint8Array(bin.length); - for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i); - return out; -} - -function b64urlEncode(bytes: Uint8Array): string { - let bin = ''; - for (let i = 0; i < bytes.length; i++) bin += String.fromCharCode(bytes[i]); - return btoa(bin).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, ''); -} - -function formatUnits(raw: string, decimals: number): string { - if (!raw) return '0'; - const neg = raw.startsWith('-'); - const v = neg ? raw.slice(1) : raw; - const padded = v.padStart(decimals + 1, '0'); - const i = padded.slice(0, -decimals); - const f = padded.slice(-decimals).replace(/0+$/, ''); - return `${neg ? '-' : ''}${i}${f ? '.' + f : ''}`; -} - async function deleteIndexedDb(dbName: string): Promise { await new Promise((resolve) => { const req = indexedDB.deleteDatabase(dbName); @@ -63,74 +49,110 @@ async function resetLocalSessionStateForNewRid(rid: string): Promise { const key = 'moltbot.lastRid'; const lastRid = window.localStorage.getItem(key); if (lastRid === rid) return false; - window.localStorage.setItem(key, rid); - - // dapp-client uses sessionStorage for pending redirect state try { sessionStorage.clear(); } catch {} - - // and IndexedDB for sessions await deleteIndexedDb('SequenceDappStorage'); - - // also clear local storage keys we might set (keep the rid marker) - for (const k of Object.keys(localStorage)) { - if (k === key) continue; - // keep vite keys etc? (none expected) - } - return true; } -type BalanceSummary = { - nativeBalances?: Array<{ name: string; symbol: string; balance: string }>; - balances?: Array<{ - contractType: string; - contractAddress: string; - balance: string; - contractInfo?: { symbol?: string; name?: string; decimals?: number; logoURI?: string }; - }>; -}; +// --- Static background: use-cases panel --- + +const SKILL_URL = 'https://agentconnect.polygon.technology/SKILL.md'; + +const AGENTS: { + id: 'claude' | 'codex' | 'gemini'; + label: string; + color: string; + terminalPrefix: string; + buildCommand: (display: string) => string; +}[] = [ + { + id: 'claude', + label: 'Claude', + color: '#D97706', + terminalPrefix: '$ Claude', + buildCommand: (display) => `$ Claude Read ${SKILL_URL} and ${display}` + }, + { + id: 'codex', + label: 'Codex', + color: '#10A37F', + terminalPrefix: '$ codex', + buildCommand: (display) => `$ codex "Read ${SKILL_URL} and ${display}"` + }, + { + id: 'gemini', + label: 'Gemini', + color: '#4285F4', + terminalPrefix: '$ gemini', + buildCommand: (display) => `$ gemini "Read ${SKILL_URL} and ${display}"` + } +]; + +const USE_CASES: { label: string; display: string; icon: ElementType }[] = [ + { + label: 'DCA into POL on a schedule', + display: + 'DCA a small amount of USDC into POL every Monday at 9am. If POL drops more than 10% in a day, double the buy.', + icon: CalendarClock + }, + { + label: 'Read Twitter/X profiles & tweets', + display: + 'Read Twitter/X profiles and tweets without API keys. Get follower counts, recent tweets, engagement metrics.', + icon: Twitter + }, + { + label: 'Bet on Polymarket predictions', + display: + 'Find the top 3 open Polymarket markets about crypto. Allocate a small amount of USDC to each bet you have at least 60% confidence in.', + icon: BarChart2 + }, + { + label: 'Purchase a domain', + display: + 'Find and register an available .com domain for my project using my wallet. Check pricing, complete the purchase, and confirm registration.', + icon: Globe + }, + { + label: 'Bridge assets cross-chain', + display: + 'Bridge some USDC from Polygon to Base using the cheapest available route. Confirm the arrival and report the final balance on both chains.', + icon: ArrowLeftRight + }, + { + label: 'Automate yield strategies', + display: + 'Find the highest-yield USDC lending pool on Polygon with TVL above $10M. Deposit some USDC and report the current APY.', + icon: TrendingUp + } +]; + +// --- Main App --- function App() { const params = useMemo(() => new URLSearchParams(window.location.search), []); const rid = params.get('rid') || ''; const walletName = params.get('wallet') || ''; - const pub = params.get('pub') || ''; - const callbackUrl = params.get('callbackUrl') || ''; const chainId = useMemo(() => resolveChainId(params), [params]); - const network = useMemo(() => resolveNetwork(chainId), [chainId]); const [error, setError] = useState(''); const [walletAddress, setWalletAddress] = useState(''); - const [ciphertext, setCiphertext] = useState(''); - const [callbackSent, setCallbackSent] = useState(false); - const [callbackFailed, setCallbackFailed] = useState(false); - - const getSafeCallbackUrl = (rawUrl: string): string | null => { - if (!rawUrl) return null; - try { - if (rawUrl.startsWith('/')) return rawUrl; - const url = new URL(rawUrl); - if (url.protocol === 'https:') return url.toString(); - if ( - url.protocol === 'http:' && - (url.hostname === 'localhost' || url.hostname === '127.0.0.1') - ) { - return url.toString(); - } - return null; - } catch { - return null; - } - }; - const [balances, setBalances] = useState(null); + const [cliPkHex, setCliPkHex] = useState(''); + const [sessionCode, setSessionCode] = useState(''); + const [showFunding, setShowFunding] = useState(false); + const [showDashboard, setShowDashboard] = useState(false); const [feeTokens, setFeeTokens] = useState(null); + const [selectedUseCase, setSelectedUseCase] = useState(0); + const [selectedAgent, setSelectedAgent] = useState<'claude' | 'codex' | 'gemini'>('claude'); const [copied, setCopied] = useState(false); + const [connecting, setConnecting] = useState(false); + const [totalUsd, setTotalUsd] = useState(null); - // Reset local session state every time a new rid is opened. + // Reset local session state on new rid useEffect(() => { void (async () => { const didReset = await resetLocalSessionStateForNewRid(rid); @@ -138,12 +160,62 @@ function App() { })(); }, [rid]); + // Fetch CLI public key from relay + useEffect(() => { + if (!rid) return; + if (!/^[a-z0-9]{8}$/.test(rid)) { + setError('Invalid session link. Please generate a new connection URL.'); + return; + } + fetch(`/api/relay/request/${rid}`) + .then((r) => { + if (!r.ok) throw new Error(`Relay returned ${r.status}`); + return r.json() as Promise<{ cli_pk_hex: string }>; + }) + .then(({ cli_pk_hex }) => { + if (!/^[0-9a-f]{64}$/.test(cli_pk_hex)) { + throw new Error('Invalid cli_pk_hex format received from relay'); + } + setCliPkHex(cli_pk_hex); + }) + .catch((e: any) => setError(`Failed to load session key: ${e?.message || String(e)}`)); + }, [rid]); + + // Fetch USD portfolio balance when wallet address is first known + useEffect(() => { + if (!walletAddress) return; + setTotalUsd(null); + fetchTotalUsdBalance(walletAddress, chainId) + .then(setTotalUsd) + .catch(() => setTotalUsd(null)); + }, [walletAddress, chainId]); + + // Poll relay status after code shown — auto-transition to funding when CLI retrieves payload + useEffect(() => { + if (!sessionCode || !rid || showFunding) return; + let active = true; + const poll = async () => { + try { + const res = await fetch(`/api/relay/status/${rid}`); + if (res.status === 404 && active) { + setShowFunding(true); + } + } catch { + // network error — keep polling + } + }; + const id = setInterval(poll, 2000); + return () => { + active = false; + clearInterval(id); + }; + }, [sessionCode, rid, showFunding]); + const dappClient = useMemo(() => { return new DappClient(walletUrl, dappOrigin, projectAccessKey, { transportMode: TransportMode.POPUP, relayerUrl, nodesUrl, - // default WebStorage (IndexedDB) is fine for browser sequenceStorage: new WebStorage() }); }, []); @@ -152,7 +224,6 @@ function App() { void (async () => { try { await dappClient.initialize(); - // Prefetch fee tokens so the actual Connect click can open the popup synchronously. try { setFeeTokens(await dappClient.getFeeTokens(chainId)); } catch { @@ -165,49 +236,37 @@ function App() { }, [dappClient]); const connect = async () => { - // feeTokens are prefetched to keep UX snappy. void feeTokens; setError(''); - setCiphertext(''); - setCallbackSent(false); - setCallbackFailed(false); + setSessionCode(''); + setConnecting(true); - if (!rid || !walletName || !pub) { - setError('Invalid link. Missing rid/wallet/pub.'); + if (!rid || !walletName) { + setError('Invalid link. Missing rid or wallet.'); + return; + } + if (!cliPkHex) { + setError('Session key not loaded yet. Please wait or refresh.'); return; } try { const VALUE_FORWARDER = '0xABAAd93EeE2a569cF0632f39B10A9f5D734777ca'; - // Resolve ERC20 addresses per-chain via Sequence Token Directory const USDC = (await resolveErc20Symbol(chainId, 'USDC'))?.address; const USDT = (await resolveErc20Symbol(chainId, 'USDT'))?.address; - - // Base explicit session permissions: - // - ValueForwarder: where we route native token sends (open-ended recipient). - // - // NOTE: demo-dapp-v3 does NOT include an explicit permission for the Sessions module. - // The Sessions module's internal `incrementUsageLimit` call (when present) is handled by the session system - // itself and should not require an explicit Permission{target,rules} entry. const basePermissions: any[] = [{ target: VALUE_FORWARDER, rules: [] }]; - - const params = new URLSearchParams(window.location.search); - - // Optional: one-off ERC20 permission scoped by link params (kept for backwards-compat). - const erc20 = params.get('erc20'); - const erc20To = params.get('erc20To'); - const erc20Amount = params.get('erc20Amount'); - + const searchParams = new URLSearchParams(window.location.search); + const erc20 = searchParams.get('erc20'); + const erc20To = searchParams.get('erc20To'); + const erc20Amount = searchParams.get('erc20Amount'); const oneOffErc20Permissions: any[] = erc20 && erc20To && erc20Amount ? (() => { const tokenAddr = erc20.toLowerCase() === 'usdc' ? USDC : erc20; const decimals = erc20.toLowerCase() === 'usdc' ? 6 : 18; - const [i, fRaw = ''] = String(erc20Amount).split('.'); const f = (fRaw + '0'.repeat(decimals)).slice(0, decimals); const valueLimit = BigInt(i || '0') * 10n ** BigInt(decimals) + BigInt(f || '0'); - return [ Utils.PermissionBuilder.for(tokenAddr as any) .forFunction('function transfer(address to, uint256 value)') @@ -229,23 +288,12 @@ function App() { })() : []; - // Open-ended per-token limits (no fixed recipient), so we can operate without per-target sessions. - // Query params: - // - usdcLimit (e.g. 50) - // - usdtLimit (e.g. 50) - // - nativeLimit (e.g. 1.5) (back-compat: polLimit) - const usdcLimit = params.get('usdcLimit'); - const usdtLimit = params.get('usdtLimit'); - const nativeLimit = params.get('nativeLimit') || params.get('polLimit'); - const tokenLimitsRaw = params.get('tokenLimits'); - - // Bridged USDC (USDC.e) on Polygon — always include alongside native USDC to avoid - // troubleshooting when the relayer selects a different USDC variant for fee payment. + const usdcLimit = searchParams.get('usdcLimit'); + const usdtLimit = searchParams.get('usdtLimit'); + const nativeLimit = searchParams.get('nativeLimit') || searchParams.get('polLimit'); + const tokenLimitsRaw = searchParams.get('tokenLimits'); const USDC_E_POLYGON = '0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174'; - const openTokenPermissions: any[] = []; - - // Generic ERC20 limits via token-directory: tokenLimits=USDC:50,WETH:0.1 const dynamicTokenPermissions: any[] = []; if (tokenLimitsRaw) { const parts = tokenLimitsRaw @@ -257,14 +305,12 @@ function App() { if (!sym || !amt) throw new Error(`Invalid tokenLimits entry: ${p}`); const td = await resolveErc20Symbol(chainId, sym); if (!td) throw new Error(`${sym} not found for this chain in token-directory`); - const decimals = td.decimals; - const valueLimit = BigInt(Math.floor(parseFloat(amt) * 10 ** decimals)); dynamicTokenPermissions.push( Utils.PermissionBuilder.for(td.address as any) .forFunction('function transfer(address to, uint256 value)') .withUintNParam( 'value', - valueLimit, + BigInt(Math.floor(parseFloat(amt) * 10 ** td.decimals)), 256, Permission.ParameterOperation.LESS_THAN_OR_EQUAL, true @@ -276,7 +322,6 @@ function App() { if (usdcLimit) { if (!USDC) throw new Error('USDC not found for this chain in token-directory'); const valueLimit = BigInt(parseFloat(usdcLimit) * 1e6); - // Native USDC openTokenPermissions.push( Utils.PermissionBuilder.for(USDC as any) .forFunction('function transfer(address to, uint256 value)') @@ -289,7 +334,6 @@ function App() { ) .build() ); - // Bridged USDC (USDC.e) on Polygon — same limit, covers relayer fee variant if (chainId === 137) { openTokenPermissions.push( Utils.PermissionBuilder.for(USDC_E_POLYGON as any) @@ -307,13 +351,12 @@ function App() { } if (usdtLimit) { if (!USDT) throw new Error('USDT not found for this chain in token-directory'); - const valueLimit = BigInt(parseFloat(usdtLimit) * 1e6); openTokenPermissions.push( Utils.PermissionBuilder.for(USDT as any) .forFunction('function transfer(address to, uint256 value)') .withUintNParam( 'value', - valueLimit, + BigInt(parseFloat(usdtLimit) * 1e6), 256, Permission.ParameterOperation.LESS_THAN_OR_EQUAL, true @@ -321,14 +364,7 @@ function App() { .build() ); } - - // Fee-option permissions (pre-approvals) so the session can pay fees with ERC20s. - // IMPORTANT: We do NOT add a blanket permission for paymentAddress itself. - // Instead, we scope permissions to ERC20.transfer(to=paymentAddress, value<=limit) per fee token. - // Note: we include these regardless of isFeeRequired — wallets funded only with ERC20 tokens - // always need these, and including them when not needed is harmless. const nativeFeePermission: any[] = []; - const feePermissions: any[] = (feeTokens as any)?.paymentAddress && Array.isArray((feeTokens as any)?.tokens) ? ((feeTokens as any).tokens as any[]) @@ -336,10 +372,7 @@ function App() { .map((token: any) => { const decimals = typeof token.decimals === 'number' ? token.decimals : 6; const valueLimit = - decimals === 18 - ? 100000000000000000n // 0.1 * 1e18 - : 50n * 10n ** BigInt(decimals); - + decimals === 18 ? 100000000000000000n : 50n * 10n ** BigInt(decimals); return Utils.PermissionBuilder.for(token.contractAddress as any) .forFunction('function transfer(address to, uint256 value)') .withUintNParam( @@ -359,29 +392,23 @@ function App() { }) : []; - // Contract whitelist (--contract 0x... repeatable): allow calls to specified contracts (e.g. ERC-8004 registries). - // Format: contracts=0xaddr1,0xaddr2 - const contractsRaw = params.get('contracts'); + const contractsRaw = searchParams.get('contracts'); const contractWhitelistPermissions: any[] = []; if (contractsRaw) { - const addrs = contractsRaw + for (const addr of contractsRaw .split(',') - .map((s) => (s || '').trim()) - .filter(Boolean); - for (const addr of addrs) { - if (/^0x[a-fA-F0-9]{40}$/.test(addr)) { + .map((s) => s.trim()) + .filter(Boolean)) { + if (/^0x[a-fA-F0-9]{40}$/.test(addr)) contractWhitelistPermissions.push({ target: addr as any, rules: [] }); - } } } const polValueLimit = nativeLimit ? BigInt(Math.floor(parseFloat(nativeLimit) * 1e18)) : 2000000000000000000n; - const sessionConfig = { chainId, - // Native spend limit (chain native token) valueLimit: polValueLimit, deadline: BigInt(Math.floor(Date.now() / 1000) + 60 * 60 * 24 * 183), permissions: [ @@ -395,16 +422,13 @@ function App() { ] }; - // Connect will open the wallet UI (popup). await dappClient.connect(chainId, sessionConfig as any, { includeImplicitSession: true }); const addr = await dappClient.getWalletAddress(); if (!addr) throw new Error('Wallet address not available after connect'); setWalletAddress(addr); - // Read explicit + implicit session material from dapp-client storage. const storage = (dappClient as any).sequenceStorage; - const sessions = await storage.getExplicitSessions(); const explicit = (sessions || []).find( (s: any) => @@ -418,440 +442,411 @@ function App() { throw new Error('Could not locate implicit session material after connect'); } - // identitySignature must be a serialized 65-byte signature hex string. - // In some dapp-client/ox paths, this can be an object (e.g. { r, s, yParity }) or Uint8Array. const sigAny: any = implicit.identitySignature; let identitySignature: string; - try { - if (typeof sigAny === 'string') { - identitySignature = sigAny; - } else if (sigAny instanceof Uint8Array) { - identitySignature = Hex.from(sigAny); - } else if (sigAny && typeof sigAny === 'object') { - if (typeof sigAny.data === 'string') { - // jsonReplacers may have wrapped a Uint8Array as { _isUint8Array: true, data: '0x..' } - identitySignature = sigAny.data; - } else { - identitySignature = Signature.toHex(sigAny); - } - } else { - throw new Error('Unsupported identitySignature type'); - } - } catch (e: any) { - throw new Error(`Could not serialize identitySignature: ${e?.message || String(e)}`); + if (typeof sigAny === 'string') { + identitySignature = sigAny; + } else if (sigAny instanceof Uint8Array) { + identitySignature = Hex.from(sigAny); + } else if (sigAny && typeof sigAny === 'object') { + identitySignature = typeof sigAny.data === 'string' ? sigAny.data : Signature.toHex(sigAny); + } else { + throw new Error('Unsupported identitySignature type'); } - // Export material needed for headless v3 signing: - // - explicit session pk - // - explicit session config used during connect (permissions/valueLimit/deadline/chainId) - // - derived sessionAddress - // dapp-client storage only persists {pk,walletAddress,chainId,...}, not the permissions config. const { Secp256k1, Address: OxAddress, Hex: OxHex } = await import('ox'); const sessionAddress = OxAddress.fromPublicKey( Secp256k1.getPublicKey({ privateKey: OxHex.toBytes(explicit.pk) }) ); - const payload = { - rid, - walletName, - walletAddress: addr, - chainId, - explicitSession: { - pk: explicit.pk, - sessionAddress, - config: sessionConfig + const sessionPayloadData: SessionPayload = { + version: 1, + wallet_address: addr, + chain_id: chainId, + session_private_key: explicit.pk, + session_address: sessionAddress, + permissions: { + native_limit: polValueLimit.toString(), + erc20_limits: [], + contract_calls: [] }, - implicit: { - pk: implicit.pk, - attestation: implicit.attestation, - identitySignature, - chainId: implicit.chainId, - // Immutable uses guard/keymachine; preserve metadata so headless can initialize correctly. - guard: (implicit as any).guard, - loginMethod: (implicit as any).loginMethod, - userEmail: (implicit as any).userEmail + expiry: Math.floor(Date.now() / 1000) + 60 * 60 * 24 * 183, + ecosystem_wallet_url: walletUrl, + dapp_origin: dappOrigin, + project_access_key: projectAccessKey, + session_config: JSON.stringify(sessionConfig, jsonReplacers), + implicit_session: { + pk: + typeof implicit.pk === 'string' + ? implicit.pk + : JSON.stringify(implicit.pk, jsonReplacers), + attestation: + typeof implicit.attestation === 'string' + ? implicit.attestation + : JSON.stringify(implicit.attestation, jsonReplacers), + identity_sig: identitySignature, + guard: (implicit as any).guard + ? JSON.stringify((implicit as any).guard, jsonReplacers) + : undefined, + login_method: (implicit as any).loginMethod ?? undefined, + user_email: (implicit as any).userEmail ?? undefined } }; - const pubBytes = b64urlDecode(pub); - const msg = new TextEncoder().encode(JSON.stringify(payload, jsonReplacers)); - const sealed = seal(msg, pubBytes); - const ciphertextB64u = b64urlEncode(sealed); - setCiphertext(ciphertextB64u); - - // Deliver ciphertext to the callback URL. - // HTTPS callbacks (cloudflared tunnel): use fetch so the page stays and can show fallback ciphertext on error. - // Localhost callbacks: must use form submission — fetch is blocked by mixed-content from HTTPS pages. - const safeCallbackUrl = getSafeCallbackUrl(callbackUrl); - const isHttpsCallback = !!callbackUrl && callbackUrl.startsWith('https://'); - const isLocalCallback = - !!callbackUrl && - (callbackUrl.startsWith('http://localhost:') || - callbackUrl.startsWith('http://127.0.0.1:')); - - if (isHttpsCallback && safeCallbackUrl) { - try { - const res = await fetch(safeCallbackUrl, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ rid, ciphertext: ciphertextB64u }) - }); - if (res.ok) { - setCallbackSent(true); - } else { - setCallbackFailed(true); - } - } catch { - setCallbackFailed(true); - } - return; - } - - if (isLocalCallback && safeCallbackUrl) { - // Form submission is a top-level navigation — browsers allow it across HTTP/HTTPS boundaries. - setCallbackSent(true); - const form = document.createElement('form'); - form.method = 'POST'; - form.action = safeCallbackUrl; - form.style.display = 'none'; - const ridInput = document.createElement('input'); - ridInput.type = 'hidden'; - ridInput.name = 'rid'; - ridInput.value = rid; - form.appendChild(ridInput); - const ctInput = document.createElement('input'); - ctInput.type = 'hidden'; - ctInput.name = 'ciphertext'; - ctInput.value = ciphertextB64u; - form.appendChild(ctInput); - document.body.appendChild(form); - form.submit(); - return; - } - - if (callbackUrl && !safeCallbackUrl) { - // URL is set but couldn't be validated — show ciphertext for manual copy. - setCallbackFailed(true); - return; - } + const { encrypted, code } = encryptSession(sessionPayloadData, cliPkHex, rid); + const relayRes = await fetch(`/api/relay/session/${rid}`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(encrypted) + }); + if (!relayRes.ok) throw new Error(`Failed to deliver session to relay (${relayRes.status})`); - // No callback URL — fetch balances and show ciphertext for manual copy - try { - const all = await fetchBalancesAllChains(addr); - const picked = pickChainBalances(all, chainId); - setBalances(picked); - } catch { - setBalances(null); - } + setSessionCode(code); + setConnecting(false); } catch (e: any) { console.error(e); setError(e?.message || String(e)); + setConnecting(false); } }; - const copyCiphertext = async () => { - if (!ciphertext) return; - await navigator.clipboard.writeText(ciphertext); - setCopied(true); - setTimeout(() => setCopied(false), 2000); - }; - - const downloadCiphertext = () => { - if (!ciphertext) return; - const blob = new Blob([ciphertext], { type: 'text/plain' }); - const url = URL.createObjectURL(blob); - const a = document.createElement('a'); - a.href = url; - a.download = `session-${rid || 'blob'}.txt`; - document.body.appendChild(a); - a.click(); - document.body.removeChild(a); - URL.revokeObjectURL(url); - }; - - const nativeRows = (balances?.nativeBalances || []).map((b) => ({ - key: `native:${b.symbol}`, - symbol: b.symbol || b.name || 'NATIVE', - decimals: 18, - balance: b.balance, - logoURI: undefined as string | undefined - })); - - const erc20Rows = (balances?.balances || []).map((b) => ({ - key: `erc20:${b.contractAddress}`, - symbol: b.contractInfo?.symbol || 'ERC20', - decimals: b.contractInfo?.decimals ?? 0, - balance: b.balance, - logoURI: b.contractInfo?.logoURI - })); - - const allRows = [...nativeRows, ...erc20Rows]; + const shortAddr = walletAddress + ? `${walletAddress.slice(0, 6)}..${walletAddress.slice(-4)}` + : null; + + // ── Screen 1: Connecting (no wallet yet, or encrypting) ── + if (!walletAddress || (walletAddress && !sessionCode && !showFunding && !showDashboard)) { + const isWaiting = connecting || (walletAddress && !sessionCode); + return ( +
+ {/* Floating card */} +
+ {/* Purple accent line */} +
+ +
+ {/* Logo + badge */} +
+ Polygon + + >_ agent + +
- return ( -
-
- {/* Main Card */} -
- {/* Brand Header */} -
-
-
-
- - - - -
-
+ {/* CTA / status */} + {isWaiting ? ( +
+
+ Waiting for wallet authorization
-
-

- Polygon Agent Kit -

-

- {network.title} · Wallet Session + ) : ( + <> +

+ Create a secure session for your agent

+ + + )} + + {/* Error */} + {error && ( +
+ +
+

{error}

+ +
-
+ )}
+
+
+ ); + } - {/* ======== PRE-CONNECT STATE ======== */} - {!walletAddress && ( -
- {/* Instructions */} -

- Click connect, approve the session for your agent, then the encrypted blob will be - sent back to your agent to create a secure session. -

+ // ── Screen 2: Code confirm ── + if (sessionCode && !showFunding && !showDashboard) { + return ( +
+
+ Polygon + + >_ agent + +
+ setShowFunding(true)} + onRegenerate={() => { + setSessionCode(''); + void connect(); + }} + /> +
+ ); + } - {/* Connect Button */} - + // ── Screen 3: Funding ── + if (showFunding && !showDashboard) { + return ( +
+ {/* Fixed so Trails modal overlay can never cover the logo */} +
+ Polygon + + >_ agent + +
+
+ { + setShowDashboard(true); + setTotalUsd(null); + fetchTotalUsdBalance(walletAddress, chainId) + .then(setTotalUsd) + .catch(() => setTotalUsd(null)); + }} + /> +
+
+ ); + } - {/* Error */} - {error && ( -
- -

{error}

-
+ // ── Screen 4: Dashboard ── + return ( +
+ {/* Nav */} + + +
+ {/* Balance row */} +
+
+
+ {totalUsd === null ? ( + $— + ) : ( + `$${totalUsd.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}` )}
- )} - - {/* ======== POST-CONNECT STATE ======== */} - {walletAddress && ( -
- {/* Wallet Address Badge */} -
- -
-
- -
- - {walletAddress} - - - - -
-
+
+
+ {walletAddress} +
+
+ +
- {/* Balance Table */} - {balances && allRows.length > 0 && ( -
- -
- {allRows.map((row, i) => ( -
-
- {row.logoURI ? ( - - ) : ( -
- - {row.symbol.charAt(0)} - -
- )} - - {row.symbol} - -
- - {formatUnits(row.balance, row.decimals)} - -
- ))} -
-
- )} + {/* Section header */} +
+

Use your wallet with agents

+ + + polygon-agent connected + +
- {/* Divider */} -
- - {/* Next Step */} -
- - - {/* Success: callback sent */} - {callbackUrl && callbackSent && ( -
-
- - - -
-
-

- Session encrypted & sent -

-

- Switch back to your agent — it will confirm once the wallet session is - ingested. -

-
-
- )} - - {/* Callback failed */} - {callbackUrl && callbackFailed && ( -
- -

- Auto-send failed. Copy the encrypted blob manually below. -

-
- )} - - {/* Callback in progress */} - {callbackUrl && !callbackSent && !callbackFailed && ( -
-
-

- Sending encrypted session to callback... -

-
- )} - - {/* No callback - manual copy */} - {!callbackUrl && ciphertext && ( -

- Copy the encrypted blob and paste it to your CLI or agent. -

- )} - - {/* Ciphertext textarea + copy button */} - {ciphertext && (!callbackUrl || callbackFailed) && ( -
+ {/* Left: use cases */} +
+
+ {USE_CASES.map((uc, i) => { + const Icon = uc.icon; + return ( + + ); + })} +
+ + + + + See all use cases + +
+ + {/* Right: terminal */} +
+
+               a.id === selectedAgent)?.color }}
+              >
+                {AGENTS.find((a) => a.id === selectedAgent)?.terminalPrefix}
+              
+              {' "'}
+              {USE_CASES[selectedUseCase].display}"
+            
+
+ {/* Agent selector chips */} +
+ Run with + {AGENTS.map((agent) => ( +