From bea51e1d51aa935ec10f73366fcc3a93b02d80fb Mon Sep 17 00:00:00 2001 From: SuPuHe Date: Sun, 13 Sep 2026 04:40:15 +0200 Subject: [PATCH 1/3] remove: retire Circle paid API product --- ...20260913T012306Z-remove-circle-paid-api.md | 50 + .agent/context/legacy-plan-20260913.md | 287 ++++++ .env.example | 18 - Dockerfile.seller | 29 - README.md | 29 +- apps/api/src/app.ts | 338 +------ apps/api/src/config.ts | 15 - apps/api/src/paid-api.ts | 565 ----------- apps/api/src/runtime.ts | 37 - apps/api/test/app.test.ts | 279 ------ apps/api/test/config.test.ts | 15 - apps/api/test/paid-api-approval.test.ts | 126 --- apps/seller/package.json | 29 - apps/seller/src/app.ts | 197 ---- apps/seller/src/config.ts | 74 -- apps/seller/src/entrypoint.ts | 20 - apps/seller/src/index.ts | 3 - apps/seller/src/server.ts | 38 - apps/seller/test/seller.test.ts | 259 ----- apps/seller/tsconfig.json | 9 - apps/seller/vitest.config.ts | 7 - apps/web/README.md | 9 +- apps/web/package.json | 2 - apps/web/src/App.tsx | 32 +- apps/web/src/api/job-client.ts | 13 - apps/web/src/api/paid-api-client.ts | 145 --- apps/web/src/auth/privy-session.tsx | 365 +------ apps/web/src/auth/session.ts | 36 - apps/web/src/components/IntentForm.tsx | 2 +- apps/web/src/components/JobWorkspace.tsx | 574 +---------- apps/web/src/components/workspace-copy.ts | 6 +- apps/web/src/styles.css | 96 +- apps/web/test/client.test.ts | 61 +- apps/web/test/components.test.tsx | 45 +- apps/web/test/paid-api.test.tsx | 332 ------- apps/web/test/privy-session.test.tsx | 304 +----- apps/web/test/styles.test.ts | 15 - apps/web/test/worker-proxy.test.ts | 79 -- apps/web/worker.ts | 47 +- apps/worker/src/composition.ts | 4 - apps/worker/src/recovery-bridge.ts | 141 +-- apps/worker/src/runtime-config.ts | 34 - apps/worker/src/runtime.ts | 45 - apps/worker/src/types.ts | 4 +- apps/worker/src/worker.ts | 12 +- .../test/restart-recovery.integration.test.ts | 2 +- apps/worker/test/runtime-config.test.ts | 10 - apps/worker/test/worker.integration.test.ts | 2 +- apps/worker/test/worker.test.ts | 91 -- apps/worker/test/x402-recovery-bridge.test.ts | 98 -- cloudbuild-seller.yaml | 14 - docs/CIRCLE_X402_DEMO.md | 87 -- docs/CIRCLE_X402_SELLER.md | 150 --- docs/DEMO_SCRIPT.md | 8 - docs/DOMAIN_ARCHITECTURE.md | 2 +- docs/ONE_DAY_RESCUE.md | 204 ---- docs/settlement/PROVIDER_SETUP.md | 5 +- package.json | 1 - packages/arc-adapter/src/receipt.ts | 89 -- packages/arc-adapter/test/receipt.test.ts | 83 -- .../contracts/generated/contracts.schema.json | 263 ----- packages/contracts/openapi/openapi.v1.json | 920 +----------------- .../contracts/scripts/generate-contracts.mjs | 274 ------ packages/contracts/src/circle.ts | 6 - packages/contracts/src/generated/api-types.ts | 51 - packages/contracts/src/index.ts | 2 - packages/contracts/src/paid-api.ts | 32 - packages/contracts/test/artifacts.test.ts | 9 +- packages/domain/src/index.ts | 1 - packages/domain/src/paid-api.ts | 51 - packages/privy-adapter/src/index.ts | 1 - .../privy-adapter/src/privy-x402-signer.ts | 27 - packages/reconciliation/src/service.ts | 3 +- packages/storage-postgres/MIGRATIONS.md | 4 +- packages/storage-postgres/src/bootstrap.ts | 1 - packages/storage-postgres/src/jobs.ts | 15 +- packages/storage-postgres/src/ledger.ts | 521 +--------- .../test/ledger.integration.test.ts | 211 +--- packages/supplier-adapter/package.json | 2 - .../src/circle-x402-settlement.ts | 312 ------ packages/supplier-adapter/src/circle-x402.ts | 784 --------------- packages/supplier-adapter/src/index.ts | 3 - .../test/circle-x402-settlement.test.ts | 308 ------ .../supplier-adapter/test/circle-x402.test.ts | 520 ---------- plan.md | 297 +----- pnpm-lock.yaml | 54 +- scripts/demo-circle-x402.mjs | 47 - tsconfig.json | 3 - wrangler.jsonc | 2 - 89 files changed, 479 insertions(+), 9918 deletions(-) create mode 100644 .agent/context/20260913T012306Z-remove-circle-paid-api.md create mode 100644 .agent/context/legacy-plan-20260913.md delete mode 100644 Dockerfile.seller delete mode 100644 apps/api/src/paid-api.ts delete mode 100644 apps/api/test/paid-api-approval.test.ts delete mode 100644 apps/seller/package.json delete mode 100644 apps/seller/src/app.ts delete mode 100644 apps/seller/src/config.ts delete mode 100644 apps/seller/src/entrypoint.ts delete mode 100644 apps/seller/src/index.ts delete mode 100644 apps/seller/src/server.ts delete mode 100644 apps/seller/test/seller.test.ts delete mode 100644 apps/seller/tsconfig.json delete mode 100644 apps/seller/vitest.config.ts delete mode 100644 apps/web/src/api/paid-api-client.ts delete mode 100644 apps/web/test/paid-api.test.tsx delete mode 100644 apps/web/test/worker-proxy.test.ts delete mode 100644 apps/worker/test/x402-recovery-bridge.test.ts delete mode 100644 cloudbuild-seller.yaml delete mode 100644 docs/CIRCLE_X402_DEMO.md delete mode 100644 docs/CIRCLE_X402_SELLER.md delete mode 100644 docs/ONE_DAY_RESCUE.md delete mode 100644 packages/contracts/src/circle.ts delete mode 100644 packages/contracts/src/paid-api.ts delete mode 100644 packages/domain/src/paid-api.ts delete mode 100644 packages/privy-adapter/src/privy-x402-signer.ts delete mode 100644 packages/supplier-adapter/src/circle-x402-settlement.ts delete mode 100644 packages/supplier-adapter/src/circle-x402.ts delete mode 100644 packages/supplier-adapter/test/circle-x402-settlement.test.ts delete mode 100644 packages/supplier-adapter/test/circle-x402.test.ts delete mode 100644 scripts/demo-circle-x402.mjs diff --git a/.agent/context/20260913T012306Z-remove-circle-paid-api.md b/.agent/context/20260913T012306Z-remove-circle-paid-api.md new file mode 100644 index 0000000..9197052 --- /dev/null +++ b/.agent/context/20260913T012306Z-remove-circle-paid-api.md @@ -0,0 +1,50 @@ +# Session context: remove Circle paid API surface + +- Date: 2026-09-13 +- User goal: create a new branch that removes the Circle x402 paid-API product, the repository's own paid API seller/proxy, and related product documentation while preserving `.agent/context/` history. +- Branch: `feature/remove-circle-paid-api` +- Base: `origin/develop` at `b942732b0229f8e749da13df36a8f6d378894ec1` + +## Assumptions and non-goals + +- Remove active Circle paid-API buyer/seller/proxy routes, UI, Gateway funding/signing helpers, contracts, adapters, runtime configuration, and user-facing documentation. +- Preserve Team Report, direct Arc/User Wallet payment, Privy authentication, generic Arc receipt verification, and recovery for the remaining direct-payment flow. +- Preserve historical `paid_api_requests` migrations and existing context records; do not rewrite applied migration history or destroy production data. +- This branch removes the product surface; it does not attempt to reconcile or delete already-created paid-API intents. + +## Safety and acceptance criteria + +- No active `/v1/paid-api*` or `/api/premium/*` product route remains. +- The web console contains no Circle paid-API purchase, Gateway funding, or x402 signing controls. +- Team Report and direct USER_WALLET payment paths remain available and preserve at-most-once settlement behavior. +- Historical migrations remain ordered and schema-digest checks remain valid. +- Circle paid-API packages, seller deployment artifacts, dependencies, tests, and non-context documentation are removed or updated without secrets. +- Existing durable paid-API records remain untouched and are not blindly retried or deleted. + +## Plan + +1. Remove active paid-API API, worker, supplier, seller, web, contract, and configuration paths. +2. Remove paid-API-specific tests/dependencies and regenerate contracts. +3. Remove user-facing Circle paid-API/proxy documentation while preserving context history and migration history. +4. Run focused tests, full validation, generated checks, browser checks, conflict scan, and secret scan. + +## Gate state + +- Gate A: not run. +- Gate B: not run. +- Commit/PR: not created. + +## Local validation + +- `pnpm test`: passed — 76 files / 1032 tests. +- `pnpm test:browser`: passed — 8/8. +- `pnpm typecheck`: passed. +- `pnpm lint`: passed. +- `pnpm format:check`: passed. +- `pnpm check:generated`: passed. +- `git diff --check`: passed. +- PostgreSQL-gated integration tests were not run locally because no container runtime was available. + +## Handoff + +- The feature removal is intentionally separate from any settlement-reconciliation repair. Existing paid-API records remain durable for audit; this branch does not claim that their prior UNKNOWN outcomes are resolved. diff --git a/.agent/context/legacy-plan-20260913.md b/.agent/context/legacy-plan-20260913.md new file mode 100644 index 0000000..30e6848 --- /dev/null +++ b/.agent/context/legacy-plan-20260913.md @@ -0,0 +1,287 @@ +# OneShot Product Delivery Plan + +Current 24-hour execution priority: [One-day rescue plan](docs/ONE_DAY_RESCUE.md) +(2026-09-12). It records current code, sponsor research, demo cutoffs and UI fixes; +the R0–R5 text below remains the historical planning baseline. + +Revision: 2026-09-10. Planning baseline: `develop` at `86c8f86`. +This PR changes documentation only; new runtime and UX capabilities remain planned. + +This replaces the previous roadmap, not completed code or historical evidence. +[A01–C06 packets](milestones/README.md) remain the original implementation record. +P0–P6 refer to that settlement baseline. R0–R5 below cover the new product +increment and do not replace mandatory FreePi Gate A, CI, and Gate B. + +## 1. Purpose and vision + +**OneShot provides resumable paid tools for business agents.** + +An agent should resume an interrupted purchase, not create another payment. +A company approves an obligation; the original or replacement agent continues +the same job, resolves its financial outcome, and retrieves the existing result. + +Product message: **Resume the job, not the payment.** +Core invariant: **One job. Many retries. One settlement.** + +Initial customer: a developer operating business agents that buy paid API +results. Initial vertical: one company-data report from one integrated supplier. +If necessary, use a clearly labelled team-operated testnet supplier with a real +result; do not claim third-party adoption from that demonstration. + +The guarantee is at-most-once settlement per stable Business Intent. Resumable +delivery requires supplier support for idempotent orders and result retrieval. +OneShot does not guarantee exactly-once execution of arbitrary external tools, +supplier quality, refunds, or commercial dispute resolution. + +## 2. Current state versus planned work + +Existing code includes durable intents/attempts, transactional outbox, +submission ownership, Privy signing, Arc receipt verification, recovery, +operator authentication, and a four-tab console on the marketing page. + +RecoveryService already queries Graph during recovery even when known-identity +evidence exists, and verifies eligible candidates before asking the advisor. +This is not an always-on wallet audit or a multi-step investigation agent. +The Subgraph indexes transfer properties with `memoId` currently null; +amount/recipient/time-window matching does not prove business-order identity. + +New work, not delivered by this planning PR: + +- Stable task-to-purchase identity above the intent API. +- One supplier order/result connector and separately persisted delivery state. +- Separate public landing page and authenticated job-centered cabinet. +- Refreshable wallet reconciliation and job-aware evidence triage. +- Fresh live demonstration of an interrupted paid job returning its result. + +Old P4/P5 evidence is build-specific; it does not qualify the new workflow or +prove current deployment health. See [current gaps](plan_missing_parts.md). + +## 3. Smallest complete workflow + +1. Operator signs in with Privy and selects the permitted execution wallet and + supported tool. Login is not wallet authorization. +2. Operator approves the exact purchase: task, supplier, quote, recipient, + amount, asset/network and applicable expiry. Reuse existing controls; do + not imply pooled budgets or daily limits that are not implemented. +3. Agent supplies a stable task key. OneShot durably binds it to a supplier + order and Business Intent before any chargeable effect. +4. Existing worker pays through Privy on Arc Testnet. Supplier fulfills the + existing order after verified payment. +5. A repeated or replacement agent call returns the same job state/result. + Uncertain payment triggers reconciliation, never replacement payment. + Paid-but-undelivered work resumes only idempotent supplier fulfillment or + retrieval using the original order reference. +6. Cabinet presents the result, receipt and any unresolved exception. + +Conceptual agent operations: start approved job, get job, resume job, get result. +These are proposed capabilities, not existing endpoint names. Extend the +existing API/client additively. No new framework, SDK package or MCP server +is required. + +## 4. Identity, delivery and safety contracts + +- Durable uniqueness is scoped by authorized workspace, supplier/tool and + caller task key. Bind a canonical payload and the existing intent ID. + Changed payload under the same key is a conflict. +- Never infer task identity from amount, recipient, time or fuzzy similarity. + Legitimate repeat purchases require an explicit new task key; agent restart + must preserve the old one. +- Enforce workspace ownership server-side for create, resume, status, results + and evidence. Privy login or possession of a UUID is insufficient. Start with + one allowlisted workspace; do not claim open multi-tenant readiness. +- Freeze the supplier contract first: non-chargeable order creation, immutable + quote, stable order reference, idempotent paid fulfillment, authenticated + retrieval. If unsupported, stop that connector instead of promising safety. +- Payment state remains unchanged. Delivery state is separate: not requested, + pending, available or retrieval failed are proposed concepts. Expired quotes + cannot silently change an approved payment. +- Delivery failure never resets COMMITTED, creates a new intent or authorizes + another payment. Persist supplier reference and result/reference across restart. +- Store minimal results with explicit retention and authorization. Validate + supplier payloads and result URLs; prevent arbitrary URL fetching, secret + exposure in logs/exports and cross-workspace access. +- Privy controls signing. OneShot/PostgreSQL controls submission ownership. + Arc verifies execution. Graph/AI never grant settlement permission. +- Preserve integer atomic money, exact receipt/log checks, testnet-only scope + and no blind retry from UNKNOWN. Resume/result retrieval cannot bypass these. + +## 5. Graph and AI responsibilities + +### Routine reconciliation + +Add a bounded, refreshable wallet-activity view using existing Graph adapters +and provenance validation. Compare indexed transfers with recorded settlements; +surface unmatched transfers, uncertain jobs and index lag. Scope queries to +authorized wallets, implement pagination and disclose coverage before claiming +complete history. Start with manual refresh, not a new scheduled agent service. + +Show RPC-verified payment separately from Graph indexing status. Graph failure +must not erase known payment success or block unrelated purchases. An unmatched +transfer is an investigation item, not fraud proof or permission to pay. + +### Incident recovery and binding + +Keep provider/known-hash lookup first for resolution. Graph discovers candidate +transactions when those sources cannot resolve the obligation; it may also +supply background observations. Do not disable working lookup or discard +durable evidence to make Graph necessary. + +R0 must establish an order-to-transfer binding strategy: verified provider +reference, policy-compatible correlation mechanism, or hold/escalation when +association cannot be proved. Prevent one transfer/log being assigned to two +jobs. A nullable memo field is not an implemented correlation mechanism. + +Identical transfer tuples can represent different orders. Neither one matching +candidate nor model confidence alone proves attribution. Multiple or +insufficiently bound candidates remain unresolved. Any memo/contract route +requires separate Privy scope and compatibility proof, not weaker policies. + +### AI incident triage + +Extend bounded advisor context with permitted job and supplier evidence. +Recommendations cite evidence and explain safe next steps: a verified payment +with missing delivery needs retrieval, not repurchase. Ambiguous chain data +requires explanation/escalation, not a guessed match. + +Keep the four-action financial recommendation contract and +`settlementPermission: NEVER`. Supplier suggestions remain explanatory until +a reviewed versioned contract and deterministic delivery handler exist. +No arbitrary execution tools, wallet secrets or payment retry capabilities +are exposed to the advisor. Treat supplier/index data as untrusted. + +Receipt truth remains deterministic. Show useful triage across job, supplier +and chain facts rather than presenting existing deterministic matching as AI. + +## 6. Frontend: public landing and private cabinet + +Reuse React/Vite and existing components. Separate routes/layouts, not another +frontend stack. The following routes and features are targets, not shipped APIs. + +### Landing page: / + +- Lead with “Resume the job, not the payment” and one concrete paid-tool example. +- Explain permissions, payment, interrupted execution and result retrieval. + Move architecture below the user story. +- Replace mathematical-proof and unrestricted exactly-once-execution claims + with the scoped at-most-once payment guarantee. +- Primary CTA: Open workspace. Secondary: How it works / developer docs. + Returning users proceed directly to the cabinet after authentication. +- Keep testnet/integration labels honest. No private jobs, operational health + details, machine-token input or embedded console on the public page. +- Label sample/demo previews; never present fixtures as live customer activity. + +### Cabinet: /app + +The cabinet is the working area, with shared navigation and a selected job, +not another marketing page. + +| Section | User purpose | Minimum tools | +| --- | --- | --- | +| Overview | Find work needing attention | Active jobs, available results, uncertain payments; totals with explicit scope | +| Tools | Start supported paid work | One supplier tool, inputs, quote, purchase approval summary; no fictional catalog | +| Jobs | Resume and retrieve | Filterable jobs, payment/delivery badges, safe resume, saved results and receipts | +| Recovery & activity | Investigate exceptions | Graph freshness/coverage, unmatched activity, cited advice and core disposition | +| Wallet & permissions | Understand spending authority | Execution wallet, supplier/recipient scope, cap and policy status; edits only with enforced APIs | +| Developer access | Connect agents | Existing client examples for stable task identity and resume/result; no fake key issuance | + +Proposed detail route: `/app/jobs/:jobId`. Carry job context across payment, +delivery and evidence tabs; do not require repeated intent-ID copy/paste. +Developer access may be a small settings section, not a new service. + +### UX acceptance + +- Start with tool/task inputs, not raw recipient/hash fields. Show amount, + recipient and authorization before any chargeable action. +- Keep supplier, cost, result, human-readable status and next safe action + prominent. IDs, hashes and raw evidence live in expandable advanced details. +- Resume reuses the job; Check payment is read-only reconciliation; Get result + cannot pay. Explain disabled actions. No force-pay or disguised repurchase. +- Separate payment and delivery badges, e.g. Paid / Result pending, or Payment + uncertain — investigating. Evidence absence never changes authoritative state. +- Compact loading, empty, stale, offline, denied and expired-session states. + Preserve useful data during refresh; no giant empty evidence panels. + Display last updated time and manual refresh. +- Keyboard navigation, visible focus, labelled fields, semantic headings, + readable contrast, screen-reader announcements and reduced motion. + Do not encode status only by color. +- Mobile navigation without horizontal page overflow. Preserve form input on + recoverable errors. Test reload/deep links and post-login return paths. +- Never put credentials in URLs, analytics, browser persistence or exports. + Raw developer machine tokens remain memory-only and outside normal UX. + +## 7. Increment gates + +All R gates start **NOT STARTED**. One focused implementation branch/PR per +gate or small acceptance slice. Reuse established package ownership. + +| Gate | Scope and dependency | Required exit evidence | +| --- | --- | --- | +| R0: feasibility and contracts | First: supplier semantics, task identity, ownership, delivery states, chain binding and routes | Additive contracts/fixtures; supplier proof; actual Arc Privy signing/fallback controls; correlation limitations documented | +| R1: resumable job | After R0: durable job/order/result and one connector | Two agents, ten concurrent calls and restart share one intent/payment; conflicts denied; paid delivery failure resumes only delivery; isolated result access | +| R2: landing and cabinet | After R0; mock work may parallel R1, integration follows R1 | Separate public/private routes; six scoped sections; job navigation; keyboard/mobile/deep-link/auth tests; no misleading controls | +| R3: evidence and triage | After R1; cabinet integration after R2 | Live bounded activity query, coverage/freshness, job-aware citations; Graph lag cannot undo payment; ambiguous binding holds | +| R4: live failure demo | After R1–R3 | Real testnet purchase, labelled response-loss fault, live Studio evidence, verified original settlement or explicit hold, no replacement payment, supplier result | +| R5: release | After R4 | Exact-head checks, FreePi A/B, public docs/diagram, video, verified prize pool, sanitized evidence and human review | + +R0 is not authorization to deploy contracts or change external wallet policy. +External configuration, live effects and mainnet activation require appropriate +human authorization. Historical packet gates do not close these new gates. + +## 8. Tests and demonstration + +Use [.agent/TEST_MATRIX.md](.agent/TEST_MATRIX.md): duplicate/conflicting input, +sequential/concurrent retries, two agents, restart, pre/post-submission faults, +provider denial, Graph lag/absence/ambiguity, invalid advice and downstream +failure after payment. + +Add job assertions: one stable supplier order/intent, at most one payment, +independently counted supplier executions, same retrievable result, workspace +isolation, no transfer reused across jobs, and no payment on paid-job resume. + +[Demo script](docs/DEMO_SCRIPT.md) separates existing offline rehearsal from +the planned live walkthrough. Never seed an old transfer into a new job and +call it live recovery. Inject faults at response boundaries without deleting +durable records or rewriting chain history. Preserve working provider lookup; +label any simulated provider unavailability separately. + +Capture deployment/query identity, _meta freshness, candidates, cited advice, +core disposition, receipt/log, payment count, supplier order and result outcome. +Measure real timings; do not invent savings or latency. + +## 9. Prize priorities + +1. **Privy — Best B2B financial product:** primary positioning; a business-agent + purchase constrained by actual wallet permissions. +2. **Arc — Best DeFi/Onchain Finance Application:** secondary for the eligible + pool; real USDC purchase, conditional authorization and recovery. +3. **The Graph — Best AI Tooling or AI Use Case:** meaningful triage/automation + over live Studio data, not just a Graph panel. +4. **Privy — Best financial flow:** additional fit from the same polished + purchase; no separate feature roadmap. + +Verify project history and registration before selecting Start Fresh or +Continuity. Graph has separate AI pools; Arc lists a separate Continuity +category. Do not assume eligibility or multiple awards. + +The Arc $3,500 DeFi award includes $2,500 conditional on mainnet deployment by +September 30, not an extra bonus. Readiness documents are not deployment proof. +Mainnet remains separately authorized. + +Requirements checked 2026-09-10: +[Privy](https://ethglobal.com/events/ethonline2026/prizes/privy), +[Arc](https://ethglobal.com/events/ethonline2026/prizes/arc), +[The Graph](https://ethglobal.com/events/ethonline2026/prizes/the-graph). +Studio live queries are accepted; MCP is optional. Qualification for the new +workflow is **NOT VERIFIED** until live evidence and submission artifacts exist. +Follow [.agent/SPONSOR_REQUIREMENTS.md](.agent/SPONSOR_REQUIREMENTS.md). + +## 10. Scope cuts and next action + +Keep one supplier, one testnet network/asset and the existing Privy/API/worker/UI +stack. Defer pooled budgets, daily limits, payroll, treasury dashboards, +marketplaces, extra agent frameworks, Circle Agent Stack, multichain and generic +workflow automation until the first resumable paid job serves a real user. + +Never cut identity, authorization, receipt verification, supplier feasibility, +failure tests, accessible interaction or honest evidence. Next implementation: +R0 contracts and feasibility, not another cosmetic transaction-console redesign. diff --git a/.env.example b/.env.example index 737a593..b18d2fb 100644 --- a/.env.example +++ b/.env.example @@ -13,22 +13,6 @@ ONESHOT_WORKSPACE_ID=team-testnet-workspace ONESHOT_API_RATE_LIMIT_MAX_REQUESTS=60 ONESHOT_API_RATE_LIMIT_WINDOW_MS=60000 -# Circle Gateway x402 paid API. Configure the same resource URL for the API and -# payment-worker Cloud Run deployments. The seller route is deployed separately; -# use the same-domain Cloudflare path after SELLER_BACKEND_URL is configured. -# Never commit a private key; x402 uses the Privy wallet's EIP-712 signer and a -# pre-funded Gateway balance. -# ONESHOT_X402_URL=https://oneshot.kapustazh.dev/api/premium/dataset -# ONESHOT_X402_BUSINESS_INTENT_ID=x402-demo-2026-09-11 -# ONESHOT_X402_GATEWAY_FUNDED=true -# ONESHOT_X402_MAX_AMOUNT_ATOMIC=10000 - -# Circle seller service. The address is public and receives testnet Gateway -# payments; this service does not require a seller private key. -# ONESHOT_X402_SELLER_ADDRESS=0x<40-hex-testnet-seller-address> -# ONESHOT_X402_SELLER_PORT=8081 -# ONESHOT_X402_FACILITATOR_URL=https://gateway-api-testnet.circle.com - # Production worker effect boundary. Public identifiers are placeholders; # secrets must be injected by the deployment secret store, never committed. ONESHOT_ARC_PROFILE=arc-testnet @@ -66,8 +50,6 @@ ONESHOT_GRAPH_API_KEY= # Optional bounded manual wallet-activity refresh for the authenticated cabinet. # When unset, activity reports Graph as unavailable without affecting payments. # ONESHOT_GRAPH_QUERY_URL=https://api.studio.thegraph.com/query/// -# For Circle x402 activity, use the Gateway wallet address: -# ONESHOT_ACTIVITY_WALLET_ADDRESS=0x0077777d7EBA4688BDeF3E311b846F25870A19B9 # ONESHOT_SUBGRAPH_MCP_SERVER_VERSION=1.0.0 ONESHOT_SUBGRAPH_DEPLOYMENT_ID=0x<64-hex-deployment-id> ONESHOT_SUBGRAPH_MANIFEST_CID= diff --git a/Dockerfile.seller b/Dockerfile.seller deleted file mode 100644 index b666eca..0000000 --- a/Dockerfile.seller +++ /dev/null @@ -1,29 +0,0 @@ -FROM node:24-bookworm-slim AS builder - -RUN corepack enable && corepack prepare pnpm@11.19.0 --activate -WORKDIR /app - -COPY pnpm-lock.yaml pnpm-workspace.yaml package.json tsconfig.json tsconfig.base.json ./ -COPY apps ./apps -COPY packages ./packages -COPY subgraph ./subgraph - -RUN pnpm install --frozen-lockfile -RUN pnpm build - -FROM node:24-bookworm-slim AS runner - -WORKDIR /app -ENV NODE_ENV=production - -COPY pnpm-lock.yaml pnpm-workspace.yaml package.json tsconfig.json tsconfig.base.json ./ -COPY --from=builder /app/node_modules ./node_modules -COPY --from=builder /app/packages ./packages -COPY --from=builder /app/apps ./apps - -WORKDIR /app/apps/seller -ENV PORT=8080 -ENV HOST=0.0.0.0 -EXPOSE 8080 - -CMD ["node", "dist/entrypoint.js"] diff --git a/README.md b/README.md index 5fd2e92..d0a6245 100644 --- a/README.md +++ b/README.md @@ -21,8 +21,8 @@ The cardinality it protects is: ## The problem -An autonomous agent is told to buy a paid API result for 1.25 USDC. It submits -the payment. The connection drops before the response arrives. +An autonomous agent is told to make a business payment for 1.25 USDC. It +submits the payment. The connection drops before the response arrives. The agent now cannot tell the difference between: @@ -201,8 +201,8 @@ The Team Report browser flow uses a separate user-funded path: after the quote, the connected Privy Ethereum wallet is shown the exact Arc Testnet USDC transfer and signs it in the browser. The API stores the payer binding and accepts the job only after verifying the submitted receipt. The server-side -Privy execution wallet remains for worker-owned integrations such as the -Circle x402 demo; it is not the payer for a Team Report started from Tools. +Privy execution wallet remains for worker-owned settlement operations; it is +not the payer for a Team Report started from Tools. Bootstrap an operator by setting `VITE_PRIVY_APP_ID`, starting the web app, signing in, copying the DID shown by the console, adding that DID to @@ -261,22 +261,6 @@ shown for retries; users do not need to invent one. After settlement, the job list links directly to ArcScan and keeps the supplier result separate from payment evidence. -The Tools cabinet also supports **Paid API purchase via Circle x402**. Deploy -the repository's Circle Arc Testnet seller from -[`docs/CIRCLE_X402_SELLER.md`](docs/CIRCLE_X402_SELLER.md), then configure its -same-domain dataset endpoint in the API environment. The connected Privy -wallet is durably bound to the quote and signs the Circle Gateway authorization; -OneShot forwards that signed authorization to the seller and verifies the Arc -receipt. The server-side Privy wallet remains available for the legacy worker -buyer adapter and is not used for the website's user-funded path. Approval -includes the exact quote shown to the operator; the API rejects a changed -price, recipient or destination before creating durable payment work. -`pnpm demo:x402` remains an operator fallback. A lost or ambiguous x402 -response is held as `UNKNOWN`; it is never retried blindly. See -[`docs/CIRCLE_X402_DEMO.md`](docs/CIRCLE_X402_DEMO.md). This rail is not the -direct Arc settlement proof; the paid API result has its own durable payment -state and recovery evidence. - | Method | Path | Purpose | | ------ | -------------------------------- | ------------------------------------------------------------- | | `POST` | `/v1/intents` | Create an intent; an identical replay returns the same result | @@ -285,11 +269,6 @@ state and recovery evidence. | `GET` | `/v1/intents/{id}/recovery-view` | Local authority plus labelled provider observations | | `POST` | `/v1/jobs` | Start/replay one workspace-scoped team report task | | `POST` | `/v1/jobs/quote` | Return a non-chargeable quote before explicit approval | -| `POST` | `/v1/paid-api/quote` | Return a non-chargeable Circle x402 quote | -| `POST` | `/v1/paid-api` | Start/replay one workspace-scoped paid API request | -| `POST` | `/v1/paid-api/user-wallet/prepare` | Bind quote and connected payer before signing | -| `POST` | `/v1/paid-api/{id}/user-wallet/submit` | Forward signed x402 payment and verify settlement | -| `GET` | `/v1/paid-api/{id}` | Read paid API state, transaction hash, and result | | `GET` | `/v1/jobs` | List workspace jobs and delivery state | | `GET` | `/v1/jobs/{jobId}` | Read a workspace-owned job | | `POST` | `/v1/jobs/{jobId}/resume` | Resume original supplier delivery; never submits payment | diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts index fc3092c..98eaa1e 100644 --- a/apps/api/src/app.ts +++ b/apps/api/src/app.ts @@ -5,30 +5,20 @@ import { asProviderReferenceId, asTransactionHash, ContractValidationError, - parseCreatePaidApiRequest, parseCreateJobRequest, parseCreateUserWalletJobRequest, type SupplierPort, type ErrorCode, type ErrorResponse, type SupplierQuote, - type ApprovePaidApiRequest, - type PreparePaidApiUserWalletRequest, - type SubmitPaidApiUserWalletRequest, } from '@oneshot/contracts'; import { derivedJobId } from '@oneshot/domain'; -import { CircleX402PreSubmitError } from '@oneshot/supplier-adapter'; import type { IntentLedger, JobLedger } from '@oneshot/storage-postgres'; import Fastify, { type FastifyReply, type FastifyRequest } from 'fastify'; import type { ServiceAuthenticator } from './auth.js'; import { allowAllRateLimiter, type RateLimiter } from './rate-limit.js'; import { UnavailableWalletActivityPort, type WalletActivityPort } from './wallet-activity.js'; -import { - PaidApiQuoteChangedError, - PaidApiUserWalletConflictError, - PaidApiUserWalletNotReadyError, - type PaidApiService, -} from './paid-api.js'; + import type { UserWalletVerificationPort } from './user-wallet.js'; export interface ServiceConfig { @@ -71,7 +61,6 @@ export interface ApiDependencies { | 'activity' >; readonly supplier?: SupplierPort; - readonly paidApi?: PaidApiService; readonly walletActivity?: WalletActivityPort; readonly userWalletVerifier?: UserWalletVerificationPort; readonly authenticator: ServiceAuthenticator; @@ -110,16 +99,6 @@ const createJobBodySchema = { }, } as const; -const createPaidApiBodySchema = { - type: 'object', - additionalProperties: false, - required: ['task_key', 'tool_id'], - properties: { - task_key: { type: 'string', minLength: 1, maxLength: 128 }, - tool_id: { type: 'string', const: 'circle-x402-api-v1' }, - }, -} as const; - const createUserWalletJobBodySchema = { ...createJobBodySchema, required: [...createJobBodySchema.required, 'payer_wallet'], @@ -138,60 +117,6 @@ const userWalletPaymentBodySchema = { }, } as const; -const approvePaidApiBodySchema = { - ...createPaidApiBodySchema, - required: ['task_key', 'tool_id', 'approved_quote'], - properties: { - ...createPaidApiBodySchema.properties, - approved_quote: { - type: 'object', - additionalProperties: false, - required: [ - 'supplier_id', - 'resource_url', - 'recipient', - 'amount_atomic', - 'asset', - 'network', - 'x402_version', - 'max_timeout_seconds', - ], - properties: { - supplier_id: { type: 'string', const: 'circle-x402-v1' }, - resource_url: { type: 'string', minLength: 1, maxLength: 2048 }, - recipient: { type: 'string', pattern: '^0x[0-9a-fA-F]{40}$' }, - amount_atomic: { type: 'string', pattern: '^(0|[1-9][0-9]*)$', maxLength: 78 }, - asset: { type: 'string', const: 'USDC' }, - network: { type: 'string', const: 'eip155:5042002' }, - x402_version: { type: 'integer', const: 2 }, - max_timeout_seconds: { type: 'integer', minimum: 1, maximum: 604900 }, - }, - }, - }, -} as const; - -const prepareUserWalletPaidApiBodySchema = { - ...approvePaidApiBodySchema, - required: ['task_key', 'tool_id', 'approved_quote', 'payer_wallet'], - properties: { - ...approvePaidApiBodySchema.properties, - payer_wallet: { type: 'string', pattern: '^0x[0-9a-fA-F]{40}$' }, - }, -} as const; - -const submitUserWalletPaidApiBodySchema = { - type: 'object', - additionalProperties: false, - required: ['payer_wallet', 'payment_payload'], - properties: { - payer_wallet: { type: 'string', pattern: '^0x[0-9a-fA-F]{40}$' }, - payment_payload: { - type: 'object', - additionalProperties: true, - }, - }, -} as const; - function sendError( reply: FastifyReply, status: number, @@ -218,14 +143,6 @@ export function buildApi(dependencies: ApiDependencies) { 'Resumable jobs are not configured', correlationFor(request), ); - const paidApiUnavailable = (reply: FastifyReply, request: FastifyRequest): void => - sendError( - reply, - 503, - 'NOT_READY', - 'Paid API integration is not configured', - correlationFor(request), - ); const userWalletUnavailable = (reply: FastifyReply, request: FastifyRequest): void => sendError( reply, @@ -420,259 +337,6 @@ export function buildApi(dependencies: ApiDependencies) { }, ); - app.post( - '/v1/paid-api/quote', - { schema: { body: createPaidApiBodySchema } }, - async (request, reply) => { - if (!dependencies.paidApi) { - paidApiUnavailable(reply, request); - return; - } - const parsed = parseCreatePaidApiRequest(request.body); - try { - return reply.code(200).send(await dependencies.paidApi.quote(parsed)); - } catch { - sendError( - reply, - 503, - 'NOT_READY', - 'Paid API quote is unavailable', - correlationFor(request), - ); - } - }, - ); - - app.post( - '/v1/paid-api', - { schema: { body: approvePaidApiBodySchema } }, - async (request, reply) => { - if (!dependencies.paidApi) { - paidApiUnavailable(reply, request); - return; - } - const body = request.body as ApprovePaidApiRequest; - const parsed = parseCreatePaidApiRequest({ task_key: body.task_key, tool_id: body.tool_id }); - try { - const result = await dependencies.paidApi.start( - parsed, - correlationFor(request), - body.approved_quote, - ); - if (result.kind === 'INTENT_PAYLOAD_CONFLICT') { - sendError( - reply, - 409, - 'INTENT_PAYLOAD_CONFLICT', - 'Task key already has a different immutable paid API quote', - correlationFor(request), - ); - return; - } - return reply.code(result.kind === 'ACCEPTED' ? 202 : 200).send(result.request); - } catch (error) { - if (error instanceof PaidApiQuoteChangedError) { - sendError(reply, 409, 'INTENT_PAYLOAD_CONFLICT', error.message, correlationFor(request)); - return; - } - sendError( - reply, - 503, - 'NOT_READY', - 'Paid API request could not be created', - correlationFor(request), - ); - } - }, - ); - - app.post( - '/v1/paid-api/user-wallet/prepare', - { schema: { body: prepareUserWalletPaidApiBodySchema } }, - async (request, reply) => { - if (!dependencies.paidApi) { - paidApiUnavailable(reply, request); - return; - } - const body = request.body as PreparePaidApiUserWalletRequest; - const parsed = parseCreatePaidApiRequest({ task_key: body.task_key, tool_id: body.tool_id }); - try { - const result = await dependencies.paidApi.prepareUserWallet( - parsed, - correlationFor(request), - body.approved_quote, - body.payer_wallet, - ); - if (result.kind === 'INTENT_PAYLOAD_CONFLICT') { - sendError( - reply, - 409, - 'INTENT_PAYLOAD_CONFLICT', - 'Task key already has a different immutable quote or payer wallet', - correlationFor(request), - ); - return; - } - return reply.code(result.kind === 'ACCEPTED' ? 202 : 200).send(result.request); - } catch (error) { - if (error instanceof PaidApiQuoteChangedError) { - sendError(reply, 409, 'INTENT_PAYLOAD_CONFLICT', error.message, correlationFor(request)); - return; - } - sendError( - reply, - 503, - 'NOT_READY', - 'User-wallet paid API request could not be prepared', - correlationFor(request), - ); - } - }, - ); - - app.post<{ Params: { id: string } }>( - '/v1/paid-api/:id/user-wallet/submit', - { schema: { body: submitUserWalletPaidApiBodySchema } }, - async (request, reply) => { - if (!dependencies.paidApi) { - paidApiUnavailable(reply, request); - return; - } - const body = request.body as SubmitPaidApiUserWalletRequest; - try { - const result = await dependencies.paidApi.submitUserWallet( - request.params.id, - body.payer_wallet, - body.payment_payload, - correlationFor(request), - ); - return reply - .code( - result.payment_state === 'COMMITTED' || result.payment_state === 'FAILED_SAFE' - ? 200 - : 202, - ) - .send(result); - } catch (error) { - if (error instanceof CircleX402PreSubmitError) { - sendError( - reply, - 400, - 'INVALID_REQUEST', - 'The Circle authorization is no longer valid. No payment was sent; sign a fresh authorization.', - correlationFor(request), - ); - return; - } - if (error instanceof PaidApiUserWalletConflictError) { - sendError( - reply, - 409, - 'RECONCILIATION_NOT_ALLOWED', - error.message, - correlationFor(request), - ); - return; - } - if (error instanceof PaidApiUserWalletNotReadyError) { - sendError(reply, 503, 'NOT_READY', error.message, correlationFor(request)); - return; - } - sendError( - reply, - 503, - 'NOT_READY', - 'User-wallet payment processing is temporarily unavailable; check the same request later', - correlationFor(request), - ); - } - }, - ); - - app.post<{ Params: { id: string } }>( - '/v1/paid-api/:id/user-wallet/reconcile', - async (request, reply) => { - if (!dependencies.paidApi) { - paidApiUnavailable(reply, request); - return; - } - try { - const result = await dependencies.paidApi.reconcileUserWallet( - request.params.id, - correlationFor(request), - ); - return reply - .code( - result.payment_state === 'COMMITTED' || result.payment_state === 'FAILED_SAFE' - ? 200 - : 202, - ) - .send(result); - } catch (error) { - if (error instanceof PaidApiUserWalletConflictError) { - sendError( - reply, - 409, - 'RECONCILIATION_NOT_ALLOWED', - error.message, - correlationFor(request), - ); - return; - } - sendError( - reply, - 503, - 'NOT_READY', - 'User-wallet payment reconciliation is temporarily unavailable; no new payment was submitted', - correlationFor(request), - ); - } - }, - ); - - app.get<{ Params: { id: string } }>('/v1/paid-api/:id', async (request, reply) => { - if (!dependencies.paidApi) { - paidApiUnavailable(reply, request); - return; - } - const paidApi = await dependencies.paidApi.get(request.params.id); - if (!paidApi) { - sendError( - reply, - 404, - 'INTENT_NOT_FOUND', - 'Paid API request was not found in this workspace', - correlationFor(request), - ); - return; - } - return paidApi; - }); - - app.get('/v1/requests', async (request, reply) => { - if (!dependencies.jobs && !dependencies.paidApi) { - sendError( - reply, - 503, - 'NOT_READY', - 'Durable request listing is not configured', - correlationFor(request), - ); - return; - } - const [jobs, paidApi] = await Promise.all([ - dependencies.jobs?.list(workspaceId) ?? Promise.resolve([]), - dependencies.paidApi?.list() ?? Promise.resolve([]), - ]); - const requests = [...jobs, ...paidApi] - .sort((left, right) => { - const updated = Date.parse(right.updated_at) - Date.parse(left.updated_at); - return updated || right.business_intent_id.localeCompare(left.business_intent_id); - }) - .slice(0, 100); - return { requests }; - }); - app.get('/v1/jobs', async (request, reply) => { if (!dependencies.jobs) { jobsUnavailable(reply, request); diff --git a/apps/api/src/config.ts b/apps/api/src/config.ts index 83fff38..f375249 100644 --- a/apps/api/src/config.ts +++ b/apps/api/src/config.ts @@ -27,10 +27,6 @@ export interface ApiRuntimeConfig { }; /** Credential-free read-only RPC used to verify user-submitted receipts. */ readonly userWalletRpcUrl?: string; - readonly paidApi?: { - readonly url: string; - readonly maxAmountAtomic: bigint; - }; } function required(environment: NodeJS.ProcessEnv, name: string, minimumLength = 1): string { @@ -77,14 +73,6 @@ function optionalHttpsUrl(environment: NodeJS.ProcessEnv, name: string): string return parsed.toString(); } -function optionalAtomicAmount(environment: NodeJS.ProcessEnv, name: string): bigint { - const raw = environment[name]?.trim() || '10000'; - if (!/^(0|[1-9][0-9]*)$/.test(raw) || raw === '0') { - throw new Error(`Invalid environment variable: ${name}`); - } - return BigInt(raw); -} - function optionalRpcUrl(environment: NodeJS.ProcessEnv, name: string): string | undefined { return optionalHttpsUrl(environment, name); } @@ -176,8 +164,6 @@ export function loadApiRuntimeConfig( const privyAuth = privyAuthConfig(environment); const activityEndpoint = environment.ONESHOT_GRAPH_QUERY_URL?.trim(); const activityWallet = environment.ONESHOT_ACTIVITY_WALLET_ADDRESS?.trim(); - const paidApiUrl = optionalHttpsUrl(environment, 'ONESHOT_X402_URL'); - const paidApiMaxAmount = optionalAtomicAmount(environment, 'ONESHOT_X402_MAX_AMOUNT_ATOMIC'); const userWalletRpcUrl = optionalRpcUrl(environment, 'ONESHOT_ARC_RPC_URL'); if ((activityEndpoint && !activityWallet) || (!activityEndpoint && activityWallet)) { throw new Error( @@ -216,7 +202,6 @@ export function loadApiRuntimeConfig( }, } : {}), - ...(paidApiUrl ? { paidApi: { url: paidApiUrl, maxAmountAtomic: paidApiMaxAmount } } : {}), ...(userWalletRpcUrl ? { userWalletRpcUrl } : {}), }; } diff --git a/apps/api/src/paid-api.ts b/apps/api/src/paid-api.ts deleted file mode 100644 index 1b062d5..0000000 --- a/apps/api/src/paid-api.ts +++ /dev/null @@ -1,565 +0,0 @@ -import { - asBlockNumber, - asEvmAddress, - asProviderReferenceId, - asTransactionHash, - type CreatePaidApiRequest, - type PaidApiQuote, - type PaidApiResponse, -} from '@oneshot/contracts'; -import { derivedPaidApiBusinessIntentId, paidApiFingerprint } from '@oneshot/domain'; -import { - CircleX402AmbiguousError, - CircleX402PreSubmitError, - fetchCircleX402Quote, - parseCircleX402Quote, - parseCircleX402UserWalletPayload, - safeCircleX402Response, - verifyCircleX402Receipt, - type CircleX402Quote, - type CircleX402UserWalletForwarder, -} from '@oneshot/supplier-adapter'; -import type { - CreatePaidApiResult, - IntentLedger, - PaidApiQuoteSnapshot, -} from '@oneshot/storage-postgres'; - -export interface PaidApiService { - quote(request: CreatePaidApiRequest): Promise; - start( - request: CreatePaidApiRequest, - correlationId: string, - approvedQuote: PaidApiQuote, - ): Promise; - prepareUserWallet( - request: CreatePaidApiRequest, - correlationId: string, - approvedQuote: PaidApiQuote, - payerWallet: string, - ): Promise; - submitUserWallet( - businessIntentId: string, - payerWallet: string, - paymentPayload: unknown, - correlationId: string, - ): Promise; - reconcileUserWallet(businessIntentId: string, correlationId: string): Promise; - get(businessIntentId: string): Promise; - list(): Promise; -} - -function publicQuote(quote: CircleX402Quote): PaidApiQuote { - return { - supplier_id: 'circle-x402-v1', - resource_url: quote.resourceUrl, - recipient: quote.requirements.payTo, - amount_atomic: quote.requirements.amount, - asset: 'USDC', - network: 'eip155:5042002', - x402_version: quote.x402Version, - max_timeout_seconds: quote.requirements.maxTimeoutSeconds, - }; -} - -export class PaidApiQuoteChangedError extends Error {} -export class PaidApiUserWalletConflictError extends Error {} -export class PaidApiUserWalletNotReadyError extends Error {} - -function sameQuote(left: PaidApiQuote, right: PaidApiQuote): boolean { - return ( - left.supplier_id === right.supplier_id && - left.resource_url === right.resource_url && - left.recipient.toLowerCase() === right.recipient.toLowerCase() && - left.amount_atomic === right.amount_atomic && - left.asset === right.asset && - left.network === right.network && - left.x402_version === right.x402_version && - left.max_timeout_seconds === right.max_timeout_seconds - ); -} - -export class CircleX402PaidApiService implements PaidApiService { - readonly #ledger: Pick< - IntentLedger, - | 'getPaidApi' - | 'listPaidApi' - | 'getPaidApiTarget' - | 'getIntent' - | 'getProviderRequestIdentity' - | 'createPaidApiOrReplay' - | 'claimSubmission' - | 'recordProviderTransaction' - | 'recordPaidApiResponse' - | 'recordPaidApiTransfer' - | 'completeSubmission' - >; - readonly #workspaceId: string; - readonly #url: string; - readonly #maxAmountAtomic: bigint; - readonly #fetch: typeof fetch | undefined; - readonly #userWalletForwarder: CircleX402UserWalletForwarder | undefined; - - constructor(options: { - readonly ledger: Pick< - IntentLedger, - | 'getPaidApi' - | 'listPaidApi' - | 'getPaidApiTarget' - | 'getIntent' - | 'getProviderRequestIdentity' - | 'createPaidApiOrReplay' - | 'claimSubmission' - | 'recordProviderTransaction' - | 'recordPaidApiResponse' - | 'recordPaidApiTransfer' - | 'completeSubmission' - >; - readonly workspaceId: string; - readonly url: string; - readonly maxAmountAtomic: bigint; - readonly fetchFn?: typeof fetch; - readonly userWalletForwarder?: CircleX402UserWalletForwarder; - }) { - this.#ledger = options.ledger; - this.#workspaceId = options.workspaceId; - this.#url = options.url; - this.#maxAmountAtomic = options.maxAmountAtomic; - this.#fetch = options.fetchFn; - this.#userWalletForwarder = options.userWalletForwarder; - } - - async #quote(): Promise { - return fetchCircleX402Quote(this.#url, { - maxAmountAtomic: this.#maxAmountAtomic, - ...(this.#fetch ? { fetchFn: this.#fetch } : {}), - }); - } - - async quote(): Promise { - return publicQuote(await this.#quote()); - } - - async start( - request: CreatePaidApiRequest, - correlationId: string, - approvedQuote: PaidApiQuote, - ): Promise { - return this.#start(request, correlationId, approvedQuote, 'SERVER_PRIVY'); - } - - async prepareUserWallet( - request: CreatePaidApiRequest, - correlationId: string, - approvedQuote: PaidApiQuote, - payerWallet: string, - ): Promise { - return this.#start(request, correlationId, approvedQuote, 'USER_WALLET', payerWallet); - } - - async #start( - request: CreatePaidApiRequest, - correlationId: string, - approvedQuote: PaidApiQuote, - paymentMode: 'SERVER_PRIVY' | 'USER_WALLET', - payerWalletValue?: string, - ): Promise { - const payerWallet = payerWalletValue === undefined ? undefined : asEvmAddress(payerWalletValue); - const existing = await this.#ledger.getPaidApi( - this.#workspaceId, - derivedPaidApiBusinessIntentId(this.#workspaceId, request), - ); - if (existing) { - return { - kind: - sameQuote(existing.quote, approvedQuote) && - (existing.payment_mode ?? 'SERVER_PRIVY') === paymentMode && - (paymentMode === 'SERVER_PRIVY' || - existing.payer_wallet?.toLowerCase() === payerWallet?.toLowerCase()) - ? 'REPLAY_IDENTICAL' - : 'INTENT_PAYLOAD_CONFLICT', - request: existing, - }; - } - const quote = await this.#quote(); - if (!sameQuote(publicQuote(quote), approvedQuote)) { - throw new PaidApiQuoteChangedError( - 'The quote changed. Review the current price and recipient before approving.', - ); - } - const snapshot: PaidApiQuoteSnapshot = { - resourceUrl: quote.resourceUrl, - x402Version: quote.x402Version, - maxTimeoutSeconds: quote.requirements.maxTimeoutSeconds, - recipient: quote.requirements.payTo, - amountAtomic: quote.requirements.amount, - quotePayload: quote, - }; - const result = await this.#ledger.createPaidApiOrReplay({ - workspaceId: this.#workspaceId, - request, - quote: snapshot, - correlationId, - paymentMode, - ...(payerWallet ? { payerWallet } : {}), - }); - // Another caller may have bound this task while the live quote was loading. - return sameQuote(result.request.quote, approvedQuote) && - (result.request.payment_mode ?? 'SERVER_PRIVY') === paymentMode && - (paymentMode === 'SERVER_PRIVY' || - result.request.payer_wallet?.toLowerCase() === payerWallet?.toLowerCase()) - ? result - : { kind: 'INTENT_PAYLOAD_CONFLICT', request: result.request }; - } - - async submitUserWallet( - businessIntentId: string, - payerWalletValue: string, - paymentPayload: unknown, - correlationId: string, - ): Promise { - const existing = await this.get(businessIntentId); - if (!existing) throw new PaidApiUserWalletNotReadyError('Paid API request was not found'); - if ( - existing.payment_mode !== 'USER_WALLET' || - !existing.payer_wallet || - existing.payer_wallet.toLowerCase() !== payerWalletValue.toLowerCase() - ) { - throw new PaidApiUserWalletConflictError( - 'The payer wallet does not match the durable paid API authorization', - ); - } - if (!this.#userWalletForwarder) { - throw new PaidApiUserWalletNotReadyError( - 'User-wallet Circle forwarding is not configured on this API', - ); - } - const target = await this.#ledger.getPaidApiTarget(businessIntentId); - if (!target || target.paymentMode !== 'USER_WALLET') { - throw new PaidApiUserWalletNotReadyError('The user-wallet paid API target is unavailable'); - } - const quote = parseCircleX402Quote(target.quotePayload); - const parsedPayload = parseCircleX402UserWalletPayload( - paymentPayload, - quote, - existing.payer_wallet, - ); - const nonce = String( - (parsedPayload.payload['authorization'] as Record)['nonce'], - ); - const providerIdentity = { - idempotencyKey: `circle-x402-user:${paidApiFingerprint( - { task_key: existing.task_key, tool_id: existing.tool_id }, - existing.resource_url, - existing.payer_wallet, - ).slice(0, 32)}:${nonce.slice(2, 18)}`, - referenceId: `circle-x402-user:${nonce.slice(2, 18)}`, - requestFingerprint: paidApiFingerprint( - { task_key: existing.task_key, tool_id: existing.tool_id }, - existing.resource_url, - existing.payer_wallet, - ), - providerKind: 'CIRCLE_X402' as const, - }; - const claim = await this.#ledger.claimSubmission( - businessIntentId, - correlationId, - providerIdentity, - ); - if (!claim.claimed) { - const current = await this.get(businessIntentId); - if (current) return current; - throw new PaidApiUserWalletNotReadyError('Paid API request is no longer readable'); - } - - let result; - try { - result = await this.#userWalletForwarder.forward({ - businessIntentId, - quote, - payerAddress: existing.payer_wallet, - paymentPayload: parsedPayload, - }); - } catch (error) { - const settlement = - error instanceof CircleX402PreSubmitError - ? ({ - kind: 'DEFINITELY_NOT_SUBMITTED', - reason: 'User-wallet x402 authorization was refused', - } as const) - : ({ - kind: 'POSSIBLY_SUBMITTED', - reason: - error instanceof CircleX402AmbiguousError - ? 'User-wallet x402 request outcome is ambiguous' - : 'User-wallet x402 request failed after signing', - } as const); - await this.#ledger.completeSubmission(businessIntentId, claim.attemptId, settlement); - const current = await this.get(businessIntentId); - if (!current) throw new PaidApiUserWalletNotReadyError('Paid API result is unavailable'); - return current; - } - - let transactionHash = result.settlement?.transactionHash; - const providerTransferId = result.settlement?.providerTransferId; - if (providerTransferId) { - await this.#ledger.recordPaidApiTransfer( - businessIntentId, - safeCircleX402Response(result.data), - providerTransferId, - ); - let transfer; - try { - transfer = await this.#userWalletForwarder.getTransfer(providerTransferId); - } catch { - await this.#ledger.completeSubmission(businessIntentId, claim.attemptId, { - kind: 'POSSIBLY_SUBMITTED', - reason: 'Circle x402 transfer status could not be checked', - }); - return this.#readCurrentPaidApi(businessIntentId); - } - const transferMatches = - transfer.sendingNetwork === 'eip155:5042002' && - transfer.recipientNetwork === 'eip155:5042002' && - transfer.fromAddress.toLowerCase() === existing.payer_wallet.toLowerCase() && - transfer.toAddress.toLowerCase() === existing.quote.recipient.toLowerCase() && - transfer.amount === existing.quote.amount_atomic; - if (transfer.status === 'failed' && transferMatches) { - await this.#ledger.completeSubmission(businessIntentId, claim.attemptId, { - kind: 'DEFINITELY_NOT_SUBMITTED', - reason: 'Circle x402 transfer failed before settlement', - }); - return this.#readCurrentPaidApi(businessIntentId); - } - if (transfer.status !== 'completed' || !transfer.txHash || !transferMatches) { - await this.#ledger.completeSubmission(businessIntentId, claim.attemptId, { - kind: 'POSSIBLY_SUBMITTED', - reason: 'Circle x402 transfer is not final yet', - }); - return this.#readCurrentPaidApi(businessIntentId); - } - transactionHash = transfer.txHash; - } else if (transactionHash) { - await this.#ledger.recordPaidApiResponse( - businessIntentId, - safeCircleX402Response(result.data), - transactionHash, - ); - } - if (!transactionHash) { - await this.#ledger.completeSubmission(businessIntentId, claim.attemptId, { - kind: 'POSSIBLY_SUBMITTED', - reason: 'Circle x402 response omitted settlement identity', - }); - return this.#readCurrentPaidApi(businessIntentId); - } - await this.#ledger.recordProviderTransaction(claim.attemptId, transactionHash); - let receipt; - try { - receipt = await this.#userWalletForwarder.getReceipt(transactionHash); - } catch { - await this.#ledger.completeSubmission(businessIntentId, claim.attemptId, { - kind: 'POSSIBLY_SUBMITTED', - reason: 'Circle x402 Arc receipt could not be checked', - }); - return this.#readCurrentPaidApi(businessIntentId); - } - if (!receipt || typeof receipt !== 'object') { - await this.#ledger.completeSubmission(businessIntentId, claim.attemptId, { - kind: 'POSSIBLY_SUBMITTED', - reason: 'Circle x402 Arc receipt is not final yet', - }); - } else { - let transactionInput: string | undefined; - try { - transactionInput = await this.#userWalletForwarder.getTransactionInput(transactionHash); - } catch { - await this.#ledger.completeSubmission(businessIntentId, claim.attemptId, { - kind: 'POSSIBLY_SUBMITTED', - reason: 'Circle x402 transaction evidence could not be checked', - }); - return this.#readCurrentPaidApi(businessIntentId); - } - const verdict = verifyCircleX402Receipt(receipt, transactionInput, { - tokenContract: '0x3600000000000000000000000000000000000000', - payer: existing.payer_wallet, - recipient: existing.quote.recipient, - amountAtomic: BigInt(existing.quote.amount_atomic), - gatewayWalletAddress: '0x0077777d7EBA4688BDeF3E311b846F25870A19B9', - }); - await this.#ledger.completeSubmission( - businessIntentId, - claim.attemptId, - verdict.result === 'CONFIRMED' - ? { - kind: 'CONFIRMED', - provider_reference_id: asProviderReferenceId( - providerTransferId ?? providerIdentity.referenceId, - ), - transaction_hash: asTransactionHash(transactionHash), - block_number: asBlockNumber(receipt.blockNumber.toString(10)), - transfer_log_index: verdict.transferLogIndex, - verified_by: 'ARC_RPC_EXACT_TRANSFER', - } - : verdict.result === 'FINAL_REVERT' - ? { kind: 'DEFINITELY_NOT_SUBMITTED', reason: verdict.detail } - : { kind: 'POSSIBLY_SUBMITTED', reason: verdict.detail }, - ); - } - return this.#readCurrentPaidApi(businessIntentId); - } - - async reconcileUserWallet( - businessIntentId: string, - correlationId: string, - ): Promise { - void correlationId; - const existing = await this.get(businessIntentId); - if (!existing) throw new PaidApiUserWalletNotReadyError('Paid API request was not found'); - if (existing.payment_mode !== 'USER_WALLET' || !existing.payer_wallet) { - throw new PaidApiUserWalletConflictError( - 'The paid API request is not configured for a user-wallet payment', - ); - } - if (!this.#userWalletForwarder) { - throw new PaidApiUserWalletNotReadyError( - 'User-wallet Circle forwarding is not configured on this API', - ); - } - if (existing.payment_state === 'COMMITTED' || existing.payment_state === 'FAILED_SAFE') { - return existing; - } - const identity = await this.#ledger.getProviderRequestIdentity(businessIntentId); - if (!identity?.transactionHash && !identity?.providerTransferId) return existing; - const intent = await this.#ledger.getIntent(businessIntentId); - const attemptId = intent?.attempts.at(-1)?.attempt_id; - if (!attemptId) return existing; - let transactionHash = identity.transactionHash; - let providerReferenceId = identity.providerTransferId ?? identity.referenceId; - if (identity.providerTransferId) { - let transfer; - try { - transfer = await this.#userWalletForwarder.getTransfer(identity.providerTransferId); - } catch { - return existing; - } - const transferMatches = - transfer.sendingNetwork === 'eip155:5042002' && - transfer.recipientNetwork === 'eip155:5042002' && - transfer.fromAddress.toLowerCase() === existing.payer_wallet.toLowerCase() && - transfer.toAddress.toLowerCase() === existing.quote.recipient.toLowerCase() && - transfer.amount === existing.quote.amount_atomic; - if (!transferMatches) { - return existing; - } - if (transfer.status === 'failed') { - await this.#ledger.completeSubmission(businessIntentId, attemptId, { - kind: 'DEFINITELY_NOT_SUBMITTED', - reason: 'Circle x402 transfer failed before settlement', - }); - return this.#readCurrentPaidApi(businessIntentId); - } - if (transfer.status !== 'completed' || !transfer.txHash) return existing; - transactionHash = transfer.txHash; - providerReferenceId = identity.providerTransferId; - } - if (!transactionHash) return existing; - await this.#ledger.recordProviderTransaction(attemptId, transactionHash); - return this.#verifyUserWalletTransaction( - businessIntentId, - attemptId, - existing, - transactionHash, - providerReferenceId, - ); - } - - async #verifyUserWalletTransaction( - businessIntentId: string, - attemptId: string, - existing: PaidApiResponse, - transactionHash: string, - providerReferenceId: string, - ): Promise { - if (!this.#userWalletForwarder) { - throw new PaidApiUserWalletNotReadyError( - 'User-wallet Circle forwarding is not configured on this API', - ); - } - let receipt; - try { - receipt = await this.#userWalletForwarder.getReceipt(transactionHash); - } catch { - return this.#readCurrentPaidApi(businessIntentId); - } - if (!receipt || typeof receipt !== 'object') return this.#readCurrentPaidApi(businessIntentId); - let transactionInput: string | undefined; - try { - transactionInput = await this.#userWalletForwarder.getTransactionInput(transactionHash); - } catch { - return this.#readCurrentPaidApi(businessIntentId); - } - const verdict = verifyCircleX402Receipt(receipt, transactionInput, { - tokenContract: '0x3600000000000000000000000000000000000000', - payer: existing.payer_wallet!, - recipient: existing.quote.recipient, - amountAtomic: BigInt(existing.quote.amount_atomic), - gatewayWalletAddress: '0x0077777d7EBA4688BDeF3E311b846F25870A19B9', - }); - await this.#ledger.completeSubmission( - businessIntentId, - attemptId, - verdict.result === 'CONFIRMED' - ? { - kind: 'CONFIRMED', - provider_reference_id: asProviderReferenceId(providerReferenceId), - transaction_hash: asTransactionHash(transactionHash), - block_number: asBlockNumber(receipt.blockNumber.toString(10)), - transfer_log_index: verdict.transferLogIndex, - verified_by: 'ARC_RPC_EXACT_TRANSFER', - } - : verdict.result === 'FINAL_REVERT' - ? { kind: 'DEFINITELY_NOT_SUBMITTED', reason: verdict.detail } - : { kind: 'POSSIBLY_SUBMITTED', reason: verdict.detail }, - ); - return this.#readCurrentPaidApi(businessIntentId); - } - - async #readCurrentPaidApi(businessIntentId: string): Promise { - const current = await this.get(businessIntentId); - if (!current) throw new PaidApiUserWalletNotReadyError('Paid API result is unavailable'); - return current; - } - - async get(businessIntentId: string): Promise { - return this.#ledger.getPaidApi(this.#workspaceId, businessIntentId); - } - - async list(): Promise { - return this.#ledger.listPaidApi(this.#workspaceId); - } -} - -export function createCircleX402PaidApiService(options: { - readonly ledger: Pick< - IntentLedger, - | 'getPaidApi' - | 'listPaidApi' - | 'getPaidApiTarget' - | 'getIntent' - | 'getProviderRequestIdentity' - | 'createPaidApiOrReplay' - | 'claimSubmission' - | 'recordProviderTransaction' - | 'recordPaidApiResponse' - | 'recordPaidApiTransfer' - | 'completeSubmission' - >; - readonly workspaceId: string; - readonly url: string; - readonly maxAmountAtomic: bigint; - readonly fetchFn?: typeof fetch; - readonly userWalletForwarder?: CircleX402UserWalletForwarder; -}): PaidApiService { - return new CircleX402PaidApiService(options); -} diff --git a/apps/api/src/runtime.ts b/apps/api/src/runtime.ts index 9c713b6..3f31622 100644 --- a/apps/api/src/runtime.ts +++ b/apps/api/src/runtime.ts @@ -1,5 +1,4 @@ import { randomUUID } from 'node:crypto'; -import { createArcReceiptSource } from '@oneshot/arc-adapter'; import { IntentLedger, JobLedger, migrate } from '@oneshot/storage-postgres'; import { createUserWalletVerificationPort } from './user-wallet.js'; import { TeamReportSupplier } from '@oneshot/supplier-adapter'; @@ -14,8 +13,6 @@ import { import { loadApiRuntimeConfig, type ApiRuntimeConfig } from './config.js'; import { createPrivyAccessTokenAuthenticator, isJwtCredential } from './privy-auth.js'; import { PostgresRateLimiter } from './rate-limit.js'; -import { createCircleX402PaidApiService } from './paid-api.js'; -import { CircleX402UserWalletForwarder } from '@oneshot/supplier-adapter'; export interface ApiRuntime { readonly address: string; @@ -44,16 +41,6 @@ export function buildApiAuthenticator( export async function startApiRuntime(config: ApiRuntimeConfig): Promise { const pool = new Pool(config.database); - const boundedFetch: typeof fetch = (input, init = {}) => - fetch(input, { - ...init, - signal: init.signal - ? AbortSignal.any([init.signal, AbortSignal.timeout(10_000)]) - : AbortSignal.timeout(10_000), - }); - const userWalletReceiptSource = config.userWalletRpcUrl - ? createArcReceiptSource({ rpcUrl: config.userWalletRpcUrl }) - : undefined; try { await migrate(pool); const ledger = new IntentLedger(pool, { @@ -65,30 +52,6 @@ export async function startApiRuntime(config: ApiRuntimeConfig): Promise { }); describe('OpenAPI contract endpoints', () => { - it('quotes and starts the durable Circle x402 paid API request with replay semantics', async () => { - const quote: PaidApiQuote = { - supplier_id: 'circle-x402-v1', - resource_url: 'https://x402.example.test/api/dataset', - recipient: request.recipient, - amount_atomic: '10000', - asset: 'USDC', - network: 'eip155:5042002', - x402_version: 2, - max_timeout_seconds: 60, - }; - const paidRequest: PaidApiResponse = { - business_intent_id: 'intent-paid-api-1', - task_key: 'circle-api-test', - tool_id: 'circle-x402-api-v1', - resource_url: quote.resource_url, - payment_state: 'AUTHORIZING', - quote, - created_at: '2026-09-11T12:00:00.000Z', - updated_at: '2026-09-11T12:00:00.000Z', - }; - let mode: 'ACCEPTED' | 'REPLAY_IDENTICAL' = 'ACCEPTED'; - let starts = 0; - const app = buildApi({ - ledger: createMockLedger(), - paidApi: { - async quote() { - return quote; - }, - async start() { - starts += 1; - return { kind: mode, request: paidRequest }; - }, - async get() { - return paidRequest; - }, - async list() { - return [paidRequest]; - }, - }, - authenticator: staticBearerAuthenticator('test-token'), - config: { workspaceId: 'workspace-paid-api' }, - nextCorrelationId: () => 'correlation-paid-api', - }); - - const quoteResponse = await app.inject({ - method: 'POST', - url: '/v1/paid-api/quote', - headers: { authorization: 'Bearer test-token' }, - payload: { task_key: 'circle-api-test', tool_id: 'circle-x402-api-v1' }, - }); - expect(quoteResponse.statusCode).toBe(200); - expect(quoteResponse.json()).toEqual(quote); - - const missingApproval = await app.inject({ - method: 'POST', - url: '/v1/paid-api', - headers: { authorization: 'Bearer test-token' }, - payload: { task_key: 'circle-api-test', tool_id: 'circle-x402-api-v1' }, - }); - expect(missingApproval.statusCode).toBe(400); - expect(starts).toBe(0); - - const accepted = await app.inject({ - method: 'POST', - url: '/v1/paid-api', - headers: { authorization: 'Bearer test-token' }, - payload: { - task_key: 'circle-api-test', - tool_id: 'circle-x402-api-v1', - approved_quote: quote, - }, - }); - expect(accepted.statusCode).toBe(202); - expect(accepted.json()).toEqual(paidRequest); - - mode = 'REPLAY_IDENTICAL'; - const replayed = await app.inject({ - method: 'POST', - url: '/v1/paid-api', - headers: { authorization: 'Bearer test-token' }, - payload: { - task_key: 'circle-api-test', - tool_id: 'circle-x402-api-v1', - approved_quote: quote, - }, - }); - expect(replayed.statusCode).toBe(200); - expect(starts).toBe(2); - - const found = await app.inject({ - method: 'GET', - url: '/v1/paid-api/intent-paid-api-1', - headers: { authorization: 'Bearer test-token' }, - }); - expect(found.statusCode).toBe(200); - expect(found.json()).toEqual(paidRequest); - - const listed = await app.inject({ - method: 'GET', - url: '/v1/requests', - headers: { authorization: 'Bearer test-token' }, - }); - expect(listed.statusCode).toBe(200); - expect(listed.json()).toEqual({ requests: [paidRequest] }); - await app.close(); - }); - - it('prepares and submits a Circle payment signed by the connected user wallet', async () => { - const quote: PaidApiQuote = { - supplier_id: 'circle-x402-v1', - resource_url: 'https://x402.example.test/api/dataset', - recipient: request.recipient, - amount_atomic: '10000', - asset: 'USDC', - network: 'eip155:5042002', - x402_version: 2, - max_timeout_seconds: 60, - }; - const payer = '0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'; - const prepared: PaidApiResponse = { - business_intent_id: 'intent-paid-api-user-wallet', - task_key: 'circle-api-user-wallet', - tool_id: 'circle-x402-api-v1', - resource_url: quote.resource_url, - payment_state: 'READY', - payment_mode: 'USER_WALLET', - payer_wallet: payer, - quote, - created_at: '2026-09-12T00:00:00Z', - updated_at: '2026-09-12T00:00:00Z', - }; - const committed: PaidApiResponse = { - ...prepared, - payment_state: 'COMMITTED', - provider_transaction_hash: `0x${'b'.repeat(64)}`, - settlement: { - provider_reference_id: 'circle-x402-user:nonce', - transaction_hash: `0x${'b'.repeat(64)}`, - block_number: '100', - transfer_log_index: 0, - }, - response: { rows: 1 }, - }; - const calls: Array<{ name: string; value: unknown }> = []; - const app = buildApi({ - ledger: createMockLedger(), - paidApi: { - async quote() { - return quote; - }, - async start() { - throw new Error('server-paid path must not be used'); - }, - async prepareUserWallet(_request, _correlationId, approvedQuote, payerWallet) { - calls.push({ name: 'prepare', value: { approvedQuote, payerWallet } }); - return { kind: 'ACCEPTED' as const, request: prepared }; - }, - async submitUserWallet(id, payerWallet, paymentPayload) { - calls.push({ name: 'submit', value: { id, payerWallet, paymentPayload } }); - return committed; - }, - async reconcileUserWallet(id) { - calls.push({ name: 'reconcile', value: { id } }); - return committed; - }, - async get() { - return committed; - }, - async list() { - return [committed]; - }, - }, - authenticator: staticBearerAuthenticator('test-token'), - config: { workspaceId: 'workspace-paid-api-user-wallet' }, - nextCorrelationId: () => 'correlation-paid-api-user-wallet', - }); - const headers = { authorization: 'Bearer test-token' }; - const prepare = await app.inject({ - method: 'POST', - url: '/v1/paid-api/user-wallet/prepare', - headers, - payload: { - task_key: prepared.task_key, - tool_id: prepared.tool_id, - approved_quote: quote, - payer_wallet: payer, - }, - }); - expect(prepare.statusCode).toBe(202); - expect(prepare.json()).toEqual(prepared); - - const paymentPayload = { - x402Version: 2, - payload: { - authorization: { nonce: `0x${'c'.repeat(64)}` }, - signature: `0x${'d'.repeat(128)}`, - }, - }; - const submit = await app.inject({ - method: 'POST', - url: `/v1/paid-api/${prepared.business_intent_id}/user-wallet/submit`, - headers, - payload: { payer_wallet: payer, payment_payload: paymentPayload }, - }); - expect(submit.statusCode).toBe(200); - expect(submit.json()).toEqual(committed); - const reconciled = await app.inject({ - method: 'POST', - url: `/v1/paid-api/${prepared.business_intent_id}/user-wallet/reconcile`, - headers, - }); - expect(reconciled.statusCode).toBe(200); - expect(reconciled.json()).toEqual(committed); - expect(calls).toEqual([ - { name: 'prepare', value: { approvedQuote: quote, payerWallet: payer } }, - { - name: 'submit', - value: { id: prepared.business_intent_id, payerWallet: payer, paymentPayload }, - }, - { name: 'reconcile', value: { id: prepared.business_intent_id } }, - ]); - await app.close(); - }); - - it('returns a safe 400 when a Circle authorization is refused before forwarding', async () => { - const payer = '0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'; - const app = buildApi({ - ledger: createMockLedger(), - paidApi: { - async quote() { - throw new Error('not used'); - }, - async start() { - throw new Error('not used'); - }, - async prepareUserWallet() { - throw new Error('not used'); - }, - async submitUserWallet() { - throw new CircleX402PreSubmitError('internal parsing detail'); - }, - async reconcileUserWallet() { - throw new Error('not used'); - }, - async get() { - return undefined; - }, - async list() { - return []; - }, - }, - authenticator: staticBearerAuthenticator('test-token'), - nextCorrelationId: () => 'correlation-circle-presubmit', - }); - - const response = await app.inject({ - method: 'POST', - url: '/v1/paid-api/intent-paid-api-user-wallet/user-wallet/submit', - headers: { authorization: 'Bearer test-token' }, - payload: { - payer_wallet: payer, - payment_payload: { x402Version: 2, payload: {} }, - }, - }); - - expect(response.statusCode).toBe(400); - expect(response.json()).toEqual({ - code: 'INVALID_REQUEST', - message: - 'The Circle authorization is no longer valid. No payment was sent; sign a fresh authorization.', - correlation_id: 'correlation-circle-presubmit', - }); - await app.close(); - }); - it('POST /v1/intents returns 202 for new intent and 200 for identical replay', async () => { let mode: 'ACCEPTED' | 'REPLAY_IDENTICAL' = 'ACCEPTED'; const app = buildApi({ diff --git a/apps/api/test/config.test.ts b/apps/api/test/config.test.ts index 1bbac7b..b9f01b0 100644 --- a/apps/api/test/config.test.ts +++ b/apps/api/test/config.test.ts @@ -68,21 +68,6 @@ describe('API runtime configuration', () => { }); }); - it('loads the optional Circle x402 resource and bounded amount', () => { - const config = loadApiRuntimeConfig({ - ...base, - ONESHOT_X402_URL: 'https://x402.example.test/api/dataset', - ONESHOT_X402_MAX_AMOUNT_ATOMIC: '10000', - }); - expect(config.paidApi).toEqual({ - url: 'https://x402.example.test/api/dataset', - maxAmountAtomic: 10000n, - }); - expect(() => - loadApiRuntimeConfig({ ...base, ONESHOT_X402_URL: 'http://remote.example.test/api' }), - ).toThrow('HTTPS'); - }); - it('loads a complete Privy configuration', () => { const config = loadApiRuntimeConfig({ ...base, diff --git a/apps/api/test/paid-api-approval.test.ts b/apps/api/test/paid-api-approval.test.ts deleted file mode 100644 index 48f23f2..0000000 --- a/apps/api/test/paid-api-approval.test.ts +++ /dev/null @@ -1,126 +0,0 @@ -import { describe, expect, it, vi } from 'vitest'; -import type { PaidApiQuote, PaidApiResponse } from '@oneshot/contracts'; -import type { IntentLedger } from '@oneshot/storage-postgres'; -import { derivedPaidApiBusinessIntentId } from '@oneshot/domain'; -import { CircleX402PaidApiService, PaidApiQuoteChangedError } from '../src/paid-api.js'; - -const task = { task_key: 'approved-task', tool_id: 'circle-x402-api-v1' } as const; -const approved: PaidApiQuote = { - supplier_id: 'circle-x402-v1', - resource_url: 'https://supplier.example.test/result', - recipient: '0x1111111111111111111111111111111111111111', - amount_atomic: '10000', - asset: 'USDC', - network: 'eip155:5042002', - x402_version: 2, - max_timeout_seconds: 60, -}; -const saved: PaidApiResponse = { - ...task, - business_intent_id: derivedPaidApiBusinessIntentId('workspace', task), - resource_url: approved.resource_url, - quote: approved, - payment_state: 'UNKNOWN', - created_at: '2026-09-12T00:00:00Z', - updated_at: '2026-09-12T00:00:00Z', -}; -function setup(live: PaidApiQuote = approved) { - const getPaidApi = vi.fn().mockResolvedValue(undefined); - const listPaidApi = vi.fn().mockResolvedValue([]); - const createPaidApiOrReplay = vi - .fn() - .mockResolvedValue({ kind: 'ACCEPTED', request: saved }); - const fetchFn = vi.fn().mockImplementation( - async () => - new Response('{}', { - status: 402, - headers: { - 'PAYMENT-REQUIRED': Buffer.from( - JSON.stringify({ - x402Version: 2, - resource: { url: live.resource_url }, - accepts: [ - { - scheme: 'exact', - network: live.network, - asset: '0x3600000000000000000000000000000000000000', - amount: live.amount_atomic, - payTo: live.recipient, - maxTimeoutSeconds: live.max_timeout_seconds, - extra: { - name: 'GatewayWalletBatched', - version: '1', - verifyingContract: '0x0077777d7EBA4688BDeF3E311b846F25870A19B9', - }, - }, - ], - }), - ).toString('base64'), - }, - }), - ); - const service = new CircleX402PaidApiService({ - ledger: { getPaidApi, listPaidApi, createPaidApiOrReplay }, - workspaceId: 'workspace', - url: approved.resource_url, - maxAmountAtomic: 1000000n, - fetchFn, - }); - return { service, getPaidApi, createPaidApiOrReplay, fetchFn }; -} - -describe('approved x402 quote boundary', () => { - it.each([ - { amount_atomic: '20000' }, - { recipient: '0x2222222222222222222222222222222222222222' }, - { resource_url: 'https://supplier.example.test/other' }, - { max_timeout_seconds: 120 }, - ])('rejects changed quote %j before any durable payment work', async (change) => { - const { service, createPaidApiOrReplay } = setup({ ...approved, ...change }); - await expect(service.start(task, 'correlation', approved)).rejects.toBeInstanceOf( - PaidApiQuoteChangedError, - ); - expect(createPaidApiOrReplay).not.toHaveBeenCalled(); - }); - it('binds the exact approved amount and recipient into the durable request', async () => { - const { service, createPaidApiOrReplay } = setup(); - await service.start(task, 'correlation', approved); - expect(createPaidApiOrReplay).toHaveBeenCalledExactlyOnceWith( - expect.objectContaining({ - request: task, - quote: expect.objectContaining({ amountAtomic: '10000', recipient: approved.recipient }), - }), - ); - }); - it('returns UNKNOWN unchanged across 10 replays without querying or submitting again', async () => { - const { service, getPaidApi, createPaidApiOrReplay, fetchFn } = setup(); - getPaidApi.mockResolvedValue(saved); - for (const result of await Promise.all( - Array.from({ length: 10 }, () => service.start(task, 'correlation', approved)), - )) { - expect(result).toEqual({ kind: 'REPLAY_IDENTICAL', request: saved }); - } - expect(fetchFn).not.toHaveBeenCalled(); - expect(createPaidApiOrReplay).not.toHaveBeenCalled(); - }); - it('reports conflicting approval for an existing task without new work', async () => { - const { service, getPaidApi, createPaidApiOrReplay, fetchFn } = setup(); - getPaidApi.mockResolvedValue(saved); - expect( - (await service.start(task, 'correlation', { ...approved, amount_atomic: '20000' })).kind, - ).toBe('INTENT_PAYLOAD_CONFLICT'); - expect(fetchFn).not.toHaveBeenCalled(); - expect(createPaidApiOrReplay).not.toHaveBeenCalled(); - }); - it('reports a concurrent differently-approved binding as a conflict', async () => { - const { service, createPaidApiOrReplay } = setup(); - createPaidApiOrReplay.mockResolvedValue({ - kind: 'REPLAY_IDENTICAL', - request: { ...saved, quote: { ...approved, amount_atomic: '20000' } }, - }); - expect((await service.start(task, 'correlation', approved)).kind).toBe( - 'INTENT_PAYLOAD_CONFLICT', - ); - expect(createPaidApiOrReplay).toHaveBeenCalledOnce(); - }); -}); diff --git a/apps/seller/package.json b/apps/seller/package.json deleted file mode 100644 index 1c7cd4f..0000000 --- a/apps/seller/package.json +++ /dev/null @@ -1,29 +0,0 @@ -{ - "name": "@oneshot/seller", - "version": "0.1.0", - "private": true, - "type": "module", - "main": "./dist/index.js", - "types": "./dist/index.d.ts", - "exports": { - ".": { - "types": "./dist/index.d.ts", - "import": "./dist/index.js" - } - }, - "scripts": { - "build": "tsc -b", - "clean": "tsc -b --clean", - "lint": "eslint src test", - "start": "node dist/entrypoint.js", - "start:local": "node --env-file=../../.env dist/entrypoint.js", - "test": "vitest run --config vitest.config.ts", - "typecheck": "tsc -b --pretty false" - }, - "dependencies": { - "@circle-fin/x402-batching": "3.4.0", - "@x402/core": "2.25.0", - "@x402/evm": "2.25.0", - "viem": "2.56.3" - } -} diff --git a/apps/seller/src/app.ts b/apps/seller/src/app.ts deleted file mode 100644 index 41fa3a4..0000000 --- a/apps/seller/src/app.ts +++ /dev/null @@ -1,197 +0,0 @@ -import { createHash } from 'node:crypto'; -import type { IncomingMessage, ServerResponse } from 'node:http'; -import { - createGatewayMiddleware, - type PaymentRequest, - type PaymentResponse, -} from '@circle-fin/x402-batching/server'; -import { ARC_TESTNET_NETWORK, type SellerRuntimeConfig } from './config.js'; - -const MAX_REQUEST_BODY_BYTES = 16 * 1024; - -export const PREMIUM_ROUTES = [ - { method: 'GET', path: '/api/premium/quote', price: '$0.001' }, - { method: 'GET', path: '/api/premium/dataset', price: '$0.01' }, - { method: 'POST', path: '/api/premium/compute', price: '$0.0003' }, - { method: 'GET', path: '/api/premium/agent-task', price: '$0.03' }, -] as const; - -type PremiumRoute = (typeof PREMIUM_ROUTES)[number]; -type SellerHandler = (request: PaymentRequest, response: PaymentResponse) => Promise; - -const CORS_HEADERS: Record = { - 'access-control-allow-origin': '*', - 'access-control-allow-methods': 'GET, POST, OPTIONS', - 'access-control-allow-headers': 'content-type, payment-signature', - 'access-control-expose-headers': 'PAYMENT-REQUIRED, PAYMENT-RESPONSE', -}; - -function sendJson(response: ServerResponse, status: number, payload: unknown): void { - if (response.writableEnded) return; - response.statusCode = status; - response.setHeader('content-type', 'application/json'); - response.end(JSON.stringify(payload)); -} - -function routeKey(method: string, path: string): string { - return `${method.toUpperCase()} ${path}`; -} - -function pathFor(request: IncomingMessage): string { - try { - return new URL(request.url ?? '/', 'http://oneshot-seller.invalid').pathname; - } catch { - return ''; - } -} - -async function readJson(request: IncomingMessage): Promise { - const chunks: Buffer[] = []; - let size = 0; - for await (const chunk of request) { - const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk); - size += buffer.byteLength; - if (size > MAX_REQUEST_BODY_BYTES) throw new Error('Request body exceeds the seller limit'); - chunks.push(buffer); - } - const text = Buffer.concat(chunks).toString('utf8').trim(); - if (!text) return {}; - try { - return JSON.parse(text) as unknown; - } catch { - throw new Error('Seller request body must be valid JSON'); - } -} - -function bodyBytes(value: unknown): number { - return Buffer.byteLength(JSON.stringify(value), 'utf8'); -} - -function objectKeyCount(value: unknown): number { - return typeof value === 'object' && value !== null && !Array.isArray(value) - ? Object.keys(value).length - : 0; -} - -const routeHandlers: Record = { - '/api/premium/quote': async (_request, response) => { - sendJson(response, 200, { - quote: 'A paid API result is more useful when its payment can be resumed safely.', - network: ARC_TESTNET_NETWORK, - }); - }, - '/api/premium/dataset': async (_request, response) => { - sendJson(response, 200, { - dataset: [ - { metric: 'resumable_intents', value: 1 }, - { metric: 'committed_settlements', value: 1 }, - { metric: 'duplicate_settlements', value: 0 }, - ], - source: 'OneShot Circle x402 seller demo', - network: ARC_TESTNET_NETWORK, - }); - }, - '/api/premium/compute': async (request, response) => { - const input = await readJson(request); - const serialized = JSON.stringify(input); - sendJson(response, 200, { - result: 'text-analysis-complete', - input_bytes: bodyBytes(input), - object_keys: objectKeyCount(input), - input_sha256: createHash('sha256').update(serialized, 'utf8').digest('hex'), - network: ARC_TESTNET_NETWORK, - }); - }, - '/api/premium/agent-task': async (_request, response) => { - sendJson(response, 200, { - task: 'Inspect the OneShot activity timeline and find the single committed settlement.', - network: ARC_TESTNET_NETWORK, - }); - }, -}; - -function routeFor(method: string, path: string): PremiumRoute | undefined { - return PREMIUM_ROUTES.find( - (route) => routeKey(route.method, route.path) === routeKey(method, path), - ); -} - -function routeWithPath(path: string): PremiumRoute | undefined { - return PREMIUM_ROUTES.find((route) => route.path === path); -} - -function allowForPath(path: string): string { - return PREMIUM_ROUTES.filter((route) => route.path === path) - .map((route) => route.method) - .join(', '); -} - -function setCors(response: ServerResponse): void { - for (const [name, value] of Object.entries(CORS_HEADERS)) response.setHeader(name, value); -} - -export function createSellerRequestHandler( - config: SellerRuntimeConfig, -): (request: IncomingMessage, response: ServerResponse) => Promise { - const gateway = createGatewayMiddleware({ - sellerAddress: config.sellerAddress, - networks: [ARC_TESTNET_NETWORK], - facilitatorUrl: config.facilitatorUrl, - description: 'OneShot Circle x402 Arc Testnet paid API demo', - }); - const middlewareByPath = new Map( - PREMIUM_ROUTES.map((route) => [ - routeKey(route.method, route.path), - gateway.require(route.price), - ]), - ); - - return async (request, response) => { - setCors(response); - const path = pathFor(request); - if (request.method === 'GET' && path === '/health/live') { - sendJson(response, 200, { status: 'ok' }); - return; - } - if (request.method === 'GET' && path === '/health/ready') { - sendJson(response, 200, { status: 'ok', network: ARC_TESTNET_NETWORK }); - return; - } - if (request.method === 'OPTIONS' && routeWithPath(path)) { - response.statusCode = 204; - response.setHeader('access-control-allow-methods', allowForPath(path)); - response.end(); - return; - } - - const route = routeFor(request.method ?? '', path); - if (!route) { - const samePath = routeWithPath(path); - if (samePath) { - response.setHeader('allow', allowForPath(path)); - sendJson(response, 405, { error: 'Method not allowed' }); - } else { - sendJson(response, 404, { error: 'Premium resource was not found' }); - } - return; - } - - const middleware = middlewareByPath.get(routeKey(route.method, route.path)); - const handler = routeHandlers[route.path]; - if (!middleware || !handler) { - sendJson(response, 500, { error: 'Premium resource is misconfigured' }); - return; - } - - const next = async (): Promise => { - await handler(request as PaymentRequest, response as PaymentResponse); - }; - try { - await middleware(request as PaymentRequest, response as PaymentResponse, next); - } catch { - if (!response.writableEnded) - sendJson(response, 500, { error: 'Premium resource unavailable' }); - else response.destroy(); - } - }; -} diff --git a/apps/seller/src/config.ts b/apps/seller/src/config.ts deleted file mode 100644 index 9315f7d..0000000 --- a/apps/seller/src/config.ts +++ /dev/null @@ -1,74 +0,0 @@ -export const ARC_TESTNET_NETWORK = 'eip155:5042002'; -export const DEFAULT_FACILITATOR_URL = 'https://gateway-api-testnet.circle.com'; - -const EVM_ADDRESS = /^0x[0-9a-fA-F]{40}$/u; - -export interface SellerRuntimeConfig { - readonly host: string; - readonly port: number; - readonly sellerAddress: string; - readonly facilitatorUrl: string; -} - -function required(environment: NodeJS.ProcessEnv, name: string): string { - const value = environment[name]?.trim(); - if (!value) throw new Error(`Missing required environment variable: ${name}`); - return value; -} - -function integer( - environment: NodeJS.ProcessEnv, - name: string, - fallback: number, - minimum: number, - maximum: number, -): number { - const raw = environment[name]?.trim(); - if (!raw) return fallback; - const value = Number(raw); - if (!Number.isSafeInteger(value) || value < minimum || value > maximum) { - throw new Error(`Invalid environment variable: ${name}`); - } - return value; -} - -function facilitatorUrl(environment: NodeJS.ProcessEnv): string { - const raw = environment.ONESHOT_X402_FACILITATOR_URL?.trim() || DEFAULT_FACILITATOR_URL; - const url = new URL(raw); - const normalized = url.toString().replace(/\/$/u, ''); - if ( - url.protocol !== 'https:' || - url.username || - url.password || - url.search || - url.hash || - normalized !== DEFAULT_FACILITATOR_URL - ) { - throw new Error( - `ONESHOT_X402_FACILITATOR_URL must be the Circle Arc Testnet facilitator: ${DEFAULT_FACILITATOR_URL}`, - ); - } - return normalized; -} - -export function loadSellerRuntimeConfig( - environment: NodeJS.ProcessEnv = process.env, -): SellerRuntimeConfig { - const sellerAddress = required(environment, 'ONESHOT_X402_SELLER_ADDRESS'); - if (!EVM_ADDRESS.test(sellerAddress)) { - throw new Error('ONESHOT_X402_SELLER_ADDRESS must be a 20-byte EVM address'); - } - - return { - host: environment.HOST?.trim() || '0.0.0.0', - port: integer( - environment, - 'ONESHOT_X402_SELLER_PORT', - integer(environment, 'PORT', 8080, 1, 65_535), - 1, - 65_535, - ), - sellerAddress, - facilitatorUrl: facilitatorUrl(environment), - }; -} diff --git a/apps/seller/src/entrypoint.ts b/apps/seller/src/entrypoint.ts deleted file mode 100644 index d23f5d3..0000000 --- a/apps/seller/src/entrypoint.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { startSellerFromEnvironment } from './server.js'; - -const runtime = await startSellerFromEnvironment(); -let shuttingDown = false; - -async function shutdown(): Promise { - if (shuttingDown) return; - shuttingDown = true; - await runtime.close(); -} - -function requestShutdown(): void { - void shutdown().catch(() => { - process.exitCode = 1; - }); -} - -process.once('SIGTERM', requestShutdown); -process.once('SIGINT', requestShutdown); -process.stdout.write(`OneShot Circle seller listening at ${runtime.address}\n`); diff --git a/apps/seller/src/index.ts b/apps/seller/src/index.ts deleted file mode 100644 index 3272e8b..0000000 --- a/apps/seller/src/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -export * from './app.js'; -export * from './config.js'; -export * from './server.js'; diff --git a/apps/seller/src/server.ts b/apps/seller/src/server.ts deleted file mode 100644 index d7d7142..0000000 --- a/apps/seller/src/server.ts +++ /dev/null @@ -1,38 +0,0 @@ -import { createServer, type Server } from 'node:http'; -import { createSellerRequestHandler } from './app.js'; -import { loadSellerRuntimeConfig, type SellerRuntimeConfig } from './config.js'; - -export interface SellerRuntime { - readonly address: string; - close(): Promise; -} - -export function createSellerServer(config: SellerRuntimeConfig): Server { - const handler = createSellerRequestHandler(config); - return createServer((request, response) => { - void handler(request, response); - }); -} - -export async function startSellerRuntime(config: SellerRuntimeConfig): Promise { - const server = createSellerServer(config); - await new Promise((resolve, reject) => { - server.once('error', reject); - server.listen({ host: config.host, port: config.port }, resolve); - }); - const address = server.address(); - const port = typeof address === 'object' && address ? address.port : config.port; - return { - address: `http://${config.host}:${port}`, - close: () => - new Promise((resolve, reject) => { - server.close((error) => (error ? reject(error) : resolve())); - }), - }; -} - -export function startSellerFromEnvironment( - environment: NodeJS.ProcessEnv = process.env, -): Promise { - return startSellerRuntime(loadSellerRuntimeConfig(environment)); -} diff --git a/apps/seller/test/seller.test.ts b/apps/seller/test/seller.test.ts deleted file mode 100644 index 746e499..0000000 --- a/apps/seller/test/seller.test.ts +++ /dev/null @@ -1,259 +0,0 @@ -import { createServer, request as httpRequest, type Server } from 'node:http'; -import { afterEach, describe, expect, it, vi } from 'vitest'; -import { - ARC_TESTNET_NETWORK, - DEFAULT_FACILITATOR_URL, - loadSellerRuntimeConfig, -} from '../src/config.js'; -import { createSellerRequestHandler, PREMIUM_ROUTES } from '../src/app.js'; - -const SELLER_ADDRESS = '0x1111111111111111111111111111111111111111'; -const VERIFYING_CONTRACT = '0x0077777d7EBA4688BDeF3E311b846F25870A19B9'; -const USDC = '0x3600000000000000000000000000000000000000'; -const TRANSACTION = `0x${'a'.repeat(64)}`; - -function encoded(value: unknown): string { - return Buffer.from(JSON.stringify(value), 'utf8').toString('base64'); -} - -function supportedResponse(): Response { - return new Response( - JSON.stringify({ - kinds: [ - { - x402Version: 2, - scheme: 'exact', - network: ARC_TESTNET_NETWORK, - extra: { - verifyingContract: VERIFYING_CONTRACT, - assets: [{ symbol: 'USDC', address: USDC }], - }, - }, - ], - extensions: [], - signers: {}, - }), - { status: 200, headers: { 'content-type': 'application/json' } }, - ); -} - -function facilitatorResponse(url: string): Response { - if (url.endsWith('/v1/x402/supported')) return supportedResponse(); - if (url.endsWith('/v1/x402/verify')) { - return new Response(JSON.stringify({ isValid: true, payer: SELLER_ADDRESS }), { - status: 200, - headers: { 'content-type': 'application/json' }, - }); - } - if (url.endsWith('/v1/x402/settle')) { - return new Response( - JSON.stringify({ - success: true, - transaction: TRANSACTION, - network: ARC_TESTNET_NETWORK, - payer: SELLER_ADDRESS, - }), - { status: 200, headers: { 'content-type': 'application/json' } }, - ); - } - throw new Error(`Unexpected facilitator URL in test: ${url}`); -} - -function sellerConfig() { - return { - host: '127.0.0.1', - port: 0, - sellerAddress: SELLER_ADDRESS, - facilitatorUrl: DEFAULT_FACILITATOR_URL, - } as const; -} - -async function listen(handler: ReturnType): Promise<{ - readonly server: Server; - readonly port: number; -}> { - const server = createServer((request, response) => { - void handler(request, response); - }); - await new Promise((resolve, reject) => { - server.once('error', reject); - server.listen({ host: '127.0.0.1', port: 0 }, resolve); - }); - const address = server.address(); - if (!address || typeof address === 'string') throw new Error('Test server did not bind a port'); - return { server, port: address.port }; -} - -async function close(server: Server): Promise { - await new Promise((resolve, reject) => { - server.close((error) => (error ? reject(error) : resolve())); - }); -} - -async function request( - port: number, - path: string, - options: { - readonly method?: string; - readonly headers?: Record; - readonly body?: string; - } = {}, -): Promise { - return new Promise((resolve, reject) => { - const req = httpRequest({ - hostname: '127.0.0.1', - port, - path, - method: options.method ?? 'GET', - headers: options.headers, - }); - req.once('error', reject); - req.once('response', (response) => { - const chunks: Buffer[] = []; - response.on('data', (chunk: Buffer) => chunks.push(chunk)); - response.once('end', () => { - const headers = new Headers(); - for (const [name, value] of Object.entries(response.headers)) { - if (typeof value === 'string') headers.set(name, value); - else if (Array.isArray(value)) headers.set(name, value.join(', ')); - } - resolve( - new Response(Buffer.concat(chunks), { - status: response.statusCode ?? 500, - headers, - }), - ); - }); - }); - if (options.body) req.write(options.body); - req.end(); - }); -} - -afterEach(() => { - vi.restoreAllMocks(); -}); - -describe('Circle Arc Testnet seller', () => { - it('matches the official sample route methods and prices', () => { - expect(PREMIUM_ROUTES).toEqual([ - { method: 'GET', path: '/api/premium/quote', price: '$0.001' }, - { method: 'GET', path: '/api/premium/dataset', price: '$0.01' }, - { method: 'POST', path: '/api/premium/compute', price: '$0.0003' }, - { method: 'GET', path: '/api/premium/agent-task', price: '$0.03' }, - ]); - }); - - it('returns one Arc Gateway payment requirement for an unpaid dataset request', async () => { - vi.spyOn(globalThis, 'fetch').mockImplementation(async (input) => { - return facilitatorResponse(typeof input === 'string' ? input : input.toString()); - }); - const { server, port } = await listen(createSellerRequestHandler(sellerConfig())); - try { - const response = await request(port, '/api/premium/dataset'); - expect(response.status).toBe(402); - const header = response.headers.get('payment-required'); - expect(header).toBeTruthy(); - const paymentRequired = JSON.parse(Buffer.from(header!, 'base64').toString('utf8')) as { - readonly x402Version: number; - readonly accepts: readonly Record[]; - }; - expect(paymentRequired.x402Version).toBe(2); - expect(paymentRequired.accepts).toHaveLength(1); - expect(paymentRequired.accepts[0]).toMatchObject({ - scheme: 'exact', - network: ARC_TESTNET_NETWORK, - asset: USDC, - amount: '10000', - payTo: SELLER_ADDRESS, - }); - } finally { - await close(server); - } - }); - - it('settles a paid dataset request through the Circle facilitator and returns the resource', async () => { - vi.spyOn(globalThis, 'fetch').mockImplementation(async (input) => { - return facilitatorResponse(typeof input === 'string' ? input : input.toString()); - }); - const { server, port } = await listen(createSellerRequestHandler(sellerConfig())); - try { - const paymentSignature = encoded({ - x402Version: 2, - resource: { - url: '/api/premium/dataset', - description: 'Dataset', - mimeType: 'application/json', - }, - accepted: { network: ARC_TESTNET_NETWORK }, - payload: { authorization: 'test-fixture' }, - }); - const response = await request(port, '/api/premium/dataset', { - headers: { 'payment-signature': paymentSignature }, - }); - expect(response.status).toBe(200); - await expect(response.json()).resolves.toMatchObject({ - source: 'OneShot Circle x402 seller demo', - }); - const settlement = response.headers.get('payment-response'); - expect(settlement).toBeTruthy(); - expect(JSON.parse(Buffer.from(settlement!, 'base64').toString('utf8'))).toMatchObject({ - success: true, - transaction: TRANSACTION, - network: ARC_TESTNET_NETWORK, - }); - } finally { - await close(server); - } - }); - - it('rejects wrong methods and handles compute payloads only after payment', async () => { - vi.spyOn(globalThis, 'fetch').mockImplementation(async (input) => { - return facilitatorResponse(typeof input === 'string' ? input : input.toString()); - }); - const { server, port } = await listen(createSellerRequestHandler(sellerConfig())); - try { - const wrongMethod = await request(port, '/api/premium/dataset', { method: 'POST' }); - expect(wrongMethod.status).toBe(405); - expect(wrongMethod.headers.get('allow')).toBe('GET'); - - const unpaidCompute = await request(port, '/api/premium/compute', { - method: 'POST', - headers: { 'content-type': 'application/json' }, - body: JSON.stringify({ text: 'demo' }), - }); - expect(unpaidCompute.status).toBe(402); - const paymentRequired = JSON.parse( - Buffer.from(unpaidCompute.headers.get('payment-required')!, 'base64').toString('utf8'), - ) as { readonly accepts: readonly Record[] }; - expect(paymentRequired.accepts[0]?.amount).toBe('300'); - } finally { - await close(server); - } - }); -}); - -describe('seller runtime configuration', () => { - it('requires a valid seller address and stays on Circle Arc Testnet', () => { - expect( - loadSellerRuntimeConfig({ - ONESHOT_X402_SELLER_ADDRESS: SELLER_ADDRESS, - ONESHOT_X402_SELLER_PORT: '8081', - }), - ).toEqual({ - host: '0.0.0.0', - port: 8081, - sellerAddress: SELLER_ADDRESS, - facilitatorUrl: DEFAULT_FACILITATOR_URL, - }); - expect(() => - loadSellerRuntimeConfig({ ONESHOT_X402_SELLER_ADDRESS: 'not-an-address' }), - ).toThrow('20-byte EVM address'); - expect(() => - loadSellerRuntimeConfig({ - ONESHOT_X402_SELLER_ADDRESS: SELLER_ADDRESS, - ONESHOT_X402_FACILITATOR_URL: 'https://gateway-api.circle.com', - }), - ).toThrow('Arc Testnet facilitator'); - }); -}); diff --git a/apps/seller/tsconfig.json b/apps/seller/tsconfig.json deleted file mode 100644 index 4cdf26d..0000000 --- a/apps/seller/tsconfig.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "extends": "../../tsconfig.base.json", - "compilerOptions": { - "outDir": "dist", - "rootDir": "src", - "tsBuildInfoFile": "dist/.tsbuildinfo" - }, - "include": ["src/**/*.ts"] -} diff --git a/apps/seller/vitest.config.ts b/apps/seller/vitest.config.ts deleted file mode 100644 index 0466358..0000000 --- a/apps/seller/vitest.config.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { defineConfig } from 'vitest/config'; - -export default defineConfig({ - test: { - include: ['test/**/*.{test,spec}.ts'], - }, -}); diff --git a/apps/web/README.md b/apps/web/README.md index e7c4515..772b6c7 100644 --- a/apps/web/README.md +++ b/apps/web/README.md @@ -5,9 +5,8 @@ details, and Recovery Agent/Subgraph MCP evidence. The Cloudflare asset deployment serves this app at the domain root. Deploy it only after `VITE_ONESHOT_API_BASE_URL` points to a reachable OneShot API. The -Worker also proxies `/api/premium/*` to the separately deployed Circle seller -when `SELLER_BACKEND_URL` is configured. An assets-only Worker cannot serve -`/health` or `/v1`. +Worker proxies `/health` and `/v1` requests to that API; static assets are +served from the Cloudflare asset bundle. ```powershell pnpm --filter @oneshot/web dev @@ -15,9 +14,7 @@ pnpm --filter @oneshot/web dev Vite proxies `/v1` and `/health` to the local API. For a separate deployed API, set the public build variable `VITE_ONESHOT_API_BASE_URL`. Enter the demo service -token at runtime; the UI keeps it in memory and never persists it. The seller -backend URL is a Cloudflare Worker deployment variable, not a browser build -variable; see [`docs/CIRCLE_X402_SELLER.md`](../../docs/CIRCLE_X402_SELLER.md). +token at runtime; the UI keeps it in memory and never persists it. The combined production asset tree is built with: diff --git a/apps/web/package.json b/apps/web/package.json index 42d1717..60cb15c 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -15,13 +15,11 @@ "typecheck": "tsc -b --pretty false" }, "dependencies": { - "@circle-fin/x402-batching": "3.4.0", "@oneshot/brand": "workspace:*", "@oneshot/contracts": "workspace:*", "@oneshot/recovery-ui": "workspace:*", "@oneshot/settlement-ui": "workspace:*", "@privy-io/react-auth": "3.6.1", - "@x402/core": "2.25.0", "viem": "2.36.0", "react": "19.2.8", "react-dom": "19.2.8" diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index 8bcad52..ccb06e8 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -8,7 +8,6 @@ import '@oneshot/settlement-ui/styles.css'; import { OneShotApiClient } from './api/client.js'; import { JobApiClient } from './api/job-client.js'; -import { PaidApiClient } from './api/paid-api-client.js'; import { createApiRecoveryClient } from './api/recovery-client.js'; import { selectCredential, @@ -22,7 +21,7 @@ import { IntentForm } from './components/IntentForm.js'; import { IntentStatusView } from './components/IntentStatusView.js'; import { LoginGate } from './components/LoginGate.js'; import { ReadinessBanner } from './components/ReadinessBanner.js'; -import { CircleX402DemoPanel, JobList, JobWorkspace } from './components/JobWorkspace.js'; +import { JobList, JobWorkspace } from './components/JobWorkspace.js'; import { RecoverySurface, SettlementSurface } from './components/FrontendSurfaces.js'; import { PaymentProtectionPanel } from './components/WorkspacePanels.js'; import { applyTheme, readStoredTheme, type Theme } from './theme.js'; @@ -40,7 +39,6 @@ const TAB_LABELS: Readonly> = { export interface AppProps { readonly apiClient?: OneShotApiClient; readonly jobClient?: JobApiClient; - readonly paidApiClient?: PaidApiClient; readonly settlementClient?: SettlementClient; readonly recoveryClient?: RecoveryClient; readonly useOperatorSession?: UseOperatorSession; @@ -79,7 +77,7 @@ function LandingPage(props: { readonly theme: Theme; readonly onToggleTheme: ()

RESUMABLE PAID SERVICES / ARC TESTNET

Resume the job, not the payment.

- Approve one direct Arc payment or paid x402 request. If an agent restarts, the original + Approve one direct Arc payment. If an agent restarts, the original task, payment evidence and result stay together.

@@ -134,7 +132,6 @@ function CabinetPage(props: { readonly setMachineToken: (value: string) => void; readonly apiClient: OneShotApiClient; readonly jobClient: JobApiClient; - readonly paidApiClient: PaidApiClient; readonly settlementClient: SettlementClient; readonly recoveryClient: RecoveryClient; readonly theme: Theme; @@ -186,7 +183,7 @@ function CabinetPage(props: {

WORKSPACE

Your payment workspace

- Run approved paid APIs, keep one payment identity per request, and recover results + Run approved Arc payments, keep one payment identity per request, and recover results without paying twice.

@@ -204,8 +201,7 @@ function CabinetPage(props: {
  • Prepare a request. Open Payment services. For a direct Arc payment, - enter its purpose, recipient and amount, then review the details. OneShot x402 Dataset - gets its price from the team-operated demo seller. + enter its purpose, recipient and amount, then review the exact payment details.
  • Approve deliberately. Read “Recipient receives”, the destination and @@ -213,14 +209,12 @@ function CabinetPage(props: { payment request.
  • - Read the result. Open Requests for a direct Arc payment. For OneShot - x402 Dataset, use Check payment status in its service card. Inspect the actual payment - state and result. + Read the result. Open Requests and inspect the actual payment state + and result.
  • - Demonstrate recovery. For a direct Arc payment, resume the existing - sample result from Requests. For x402, replay the same request only when its payment is - confirmed. An uncertain payment needs investigation, not a new key. + Demonstrate recovery. Resume the existing sample result from Requests. + An uncertain payment needs investigation, not a new key.
  • @@ -284,11 +278,6 @@ function CabinetPage(props: { {...(props.userWallet ? { userWallet: props.userWallet } : {})} onSelectIntent={selectRequest} /> - )} {section === 'requests' && ( @@ -359,10 +348,6 @@ export function App(props: AppProps = {}) { () => props.jobClient ?? new JobApiClient({ baseUrl: apiBaseUrl, getAuthToken }), [apiBaseUrl, getAuthToken, props.jobClient], ); - const paidApiClient = useMemo( - () => props.paidApiClient ?? new PaidApiClient({ baseUrl: apiBaseUrl, getAuthToken }), - [apiBaseUrl, getAuthToken, props.paidApiClient], - ); if (props.route === '/') return ; if (props.route?.startsWith('/app')) { @@ -373,7 +358,6 @@ export function App(props: AppProps = {}) { setMachineToken={setMachineToken} apiClient={apiClient} jobClient={jobClient} - paidApiClient={paidApiClient} settlementClient={settlementClient} recoveryClient={recoveryClient} {...(props.userWallet ? { userWallet: props.userWallet } : {})} diff --git a/apps/web/src/api/job-client.ts b/apps/web/src/api/job-client.ts index fba6e9e..29ba3ae 100644 --- a/apps/web/src/api/job-client.ts +++ b/apps/web/src/api/job-client.ts @@ -4,7 +4,6 @@ import type { CreateUserWalletJobRequest, JobListResponse, JobView, - RequestListResponse, SupplierQuote, SupplierResult, } from '@oneshot/contracts'; @@ -43,18 +42,6 @@ export class JobApiClient { return response.ok ? ((await responseJson(response))?.jobs ?? []) : []; } - async listRequests(): Promise { - const response = await this.#fetch(`${this.#baseUrl}/v1/requests`, { - headers: this.#headers(), - }); - if (response.status === 404) { - return (await this.list()).map((job) => job); - } - const body = await responseJson(response); - if (!response.ok || !body) throw new Error('Could not load durable requests'); - return body.requests; - } - async start(request: CreateJobRequest): Promise { const response = await this.#fetch(`${this.#baseUrl}/v1/jobs`, { method: 'POST', diff --git a/apps/web/src/api/paid-api-client.ts b/apps/web/src/api/paid-api-client.ts deleted file mode 100644 index 5ced4d0..0000000 --- a/apps/web/src/api/paid-api-client.ts +++ /dev/null @@ -1,145 +0,0 @@ -import type { - ApprovePaidApiRequest, - CreatePaidApiRequest, - PaidApiQuote, - PaidApiResponse, - PreparePaidApiUserWalletRequest, - SubmitPaidApiUserWalletRequest, -} from '@oneshot/contracts'; -import type { ApiClientConfig } from './client.js'; - -const CIRCLE_AUTHORIZATION_REFUSED_MESSAGE = - 'The Circle authorization is no longer valid. No payment was sent; sign a fresh authorization.'; - -export class PaidApiUserWalletSubmissionError extends Error { - constructor(message: string) { - super(message); - this.name = 'PaidApiUserWalletSubmissionError'; - } -} - -async function responseJson(response: Response): Promise { - if (!response.headers.get('content-type')?.includes('application/json')) return null; - try { - return (await response.json()) as T; - } catch { - return null; - } -} - -function submitErrorMessage(body: unknown): string { - if ( - body !== null && - typeof body === 'object' && - 'code' in body && - body.code === 'INVALID_REQUEST' && - 'message' in body && - body.message === CIRCLE_AUTHORIZATION_REFUSED_MESSAGE - ) { - return body.message; - } - return 'Could not verify the user-wallet paid API payment'; -} - -export class PaidApiClient { - readonly #baseUrl: string; - readonly #getAuthToken: () => string | null; - readonly #fetch: typeof fetch; - - constructor(config: ApiClientConfig = {}) { - this.#baseUrl = config.baseUrl ?? ''; - this.#getAuthToken = config.getAuthToken ?? (() => null); - this.#fetch = config.fetchFn ?? fetch.bind(globalThis); - } - - #headers(withJsonBody = false): HeadersInit { - const token = this.#getAuthToken(); - return { - ...(withJsonBody ? { 'content-type': 'application/json' } : {}), - ...(token ? { authorization: `Bearer ${token}` } : {}), - }; - } - - #jsonHeaders(): HeadersInit { - return this.#headers(true); - } - - async quote(request: CreatePaidApiRequest): Promise { - const response = await this.#fetch(`${this.#baseUrl}/v1/paid-api/quote`, { - method: 'POST', - headers: this.#jsonHeaders(), - body: JSON.stringify(request), - }); - const body = await responseJson(response); - if (!response.ok || !body) throw new Error('Could not load a live paid API quote'); - return body; - } - - async start(request: ApprovePaidApiRequest): Promise { - const response = await this.#fetch(`${this.#baseUrl}/v1/paid-api`, { - method: 'POST', - headers: this.#jsonHeaders(), - body: JSON.stringify(request), - }); - const body = await responseJson(response); - if (!response.ok || !body) throw new Error('Could not approve the paid API request'); - return body; - } - - async prepareUserWallet(request: PreparePaidApiUserWalletRequest): Promise { - const response = await this.#fetch(`${this.#baseUrl}/v1/paid-api/user-wallet/prepare`, { - method: 'POST', - headers: this.#jsonHeaders(), - body: JSON.stringify(request), - }); - const body = await responseJson(response); - if (!response.ok || !body) - throw new Error('Could not prepare the user-wallet paid API request'); - return body; - } - - async submitUserWalletPayment( - businessIntentId: string, - payerWallet: string, - paymentPayload: SubmitPaidApiUserWalletRequest['payment_payload'], - ): Promise { - const response = await this.#fetch( - `${this.#baseUrl}/v1/paid-api/${encodeURIComponent(businessIntentId)}/user-wallet/submit`, - { - method: 'POST', - headers: this.#jsonHeaders(), - body: JSON.stringify({ payer_wallet: payerWallet, payment_payload: paymentPayload }), - }, - ); - const body = await responseJson(response); - if (!response.ok) { - throw new PaidApiUserWalletSubmissionError(submitErrorMessage(body)); - } - if (!body) - throw new PaidApiUserWalletSubmissionError( - 'Could not verify the user-wallet paid API payment', - ); - return body as PaidApiResponse; - } - - async get(businessIntentId: string): Promise { - const response = await this.#fetch( - `${this.#baseUrl}/v1/paid-api/${encodeURIComponent(businessIntentId)}`, - { method: 'GET', headers: this.#headers() }, - ); - const body = await responseJson(response); - if (!response.ok || !body) throw new Error('Could not refresh the paid API request'); - return body; - } - - async reconcileUserWalletPayment(businessIntentId: string): Promise { - const response = await this.#fetch( - `${this.#baseUrl}/v1/paid-api/${encodeURIComponent(businessIntentId)}/user-wallet/reconcile`, - { method: 'POST', headers: this.#headers() }, - ); - const body = await responseJson(response); - if (!response.ok || !body) - throw new Error('Could not reconcile the user-wallet paid API payment'); - return body; - } -} diff --git a/apps/web/src/auth/privy-session.tsx b/apps/web/src/auth/privy-session.tsx index ad8be01..c384bbd 100644 --- a/apps/web/src/auth/privy-session.tsx +++ b/apps/web/src/auth/privy-session.tsx @@ -4,34 +4,13 @@ import { useLogin, usePrivy, useWallets, - type BaseConnectedWalletType, - type ConnectedWallet, } from '@privy-io/react-auth'; -import { BatchEvmScheme } from '@circle-fin/x402-batching/client'; -import { encodeFunctionData, erc20Abi, defineChain } from 'viem'; +import type { BaseConnectedWalletType, ConnectedWallet } from '@privy-io/react-auth'; +import { defineChain } from 'viem'; import { useEffect, useRef, useState, type ReactNode } from 'react'; -import { - CIRCLE_X402_USER_WALLET_VALIDITY_WINDOW_SECONDS, - type PaidApiQuote, - type SubmitPaidApiUserWalletRequest, -} from '@oneshot/contracts'; - -import { - GatewayFundingError, - type GatewayFundingResult, - type GatewayPendingDeposit, - type OperatorSession, - type OperatorSessionStatus, - type UserWalletSession, -} from './session.js'; -import { usdcToAtomicUnits } from '../utils/money.js'; +import type { OperatorSession, OperatorSessionStatus, UserWalletSession } from './session.js'; const REFRESH_INTERVAL_MS = 5 * 60 * 1000; -const ARC_TESTNET_GATEWAY_DOMAIN = 26; -const CIRCLE_GATEWAY_BALANCES_URL = 'https://gateway-api-testnet.circle.com/v1/balances'; -const CIRCLE_GATEWAY_DEPOSITS_URL = 'https://gateway-api-testnet.circle.com/v1/deposits'; -const ARC_TESTNET_USDC = '0x3600000000000000000000000000000000000000' as const; -const ARC_TESTNET_GATEWAY_WALLET = '0x0077777d7EBA4688BDeF3E311b846F25870A19B9' as const; const ARC_TESTNET_CHAIN_ID = 'eip155:5042002' as const; const ARC_TESTNET = defineChain({ id: 5042002, @@ -41,21 +20,6 @@ const ARC_TESTNET = defineChain({ rpcUrls: { default: { http: ['https://rpc.testnet.arc.network'] } }, blockExplorers: { default: { name: 'Arcscan', url: 'https://testnet.arcscan.app' } }, }); -const GATEWAY_DEPOSIT_ABI = [ - { - type: 'function', - name: 'deposit', - inputs: [ - { name: 'token', type: 'address' }, - { name: 'value', type: 'uint256' }, - ], - outputs: [], - stateMutability: 'nonpayable', - }, -] as const; -const TRANSACTION_HASH = /^0x[0-9a-fA-F]{64}$/u; -const EVM_ADDRESS = /^0x[0-9a-fA-F]{40}$/u; -const MAX_UINT256 = 2n ** 256n - 1n; export function PrivyOperatorProvider(props: { readonly appId: string; @@ -132,85 +96,12 @@ function transferData(recipient: string, amountAtomic: string): `0x${string}` { } type EthereumWallet = Extract; -type EthereumProvider = Awaited>; - -function jsonSafe(value: unknown): unknown { - if (typeof value === 'bigint') return value.toString(10); - if (Array.isArray(value)) return value.map(jsonSafe); - if (value && typeof value === 'object') { - return Object.fromEntries( - Object.entries(value as Record).map(([key, child]) => [ - key, - jsonSafe(child), - ]), - ); - } - return value; -} - -export function circleX402SigningRequirements(quote: PaidApiQuote) { - return { - scheme: 'exact' as const, - network: quote.network, - asset: '0x3600000000000000000000000000000000000000', - amount: quote.amount_atomic, - payTo: quote.recipient, - // The SDK's 100-second buffer is too narrow for a human wallet prompt - // plus network forwarding. This is used only inside the signed payload; - // the durable quote remains unchanged and is reconstructed server-side. - maxTimeoutSeconds: Math.max( - quote.max_timeout_seconds, - CIRCLE_X402_USER_WALLET_VALIDITY_WINDOW_SECONDS, - ), - extra: { - name: 'GatewayWalletBatched', - version: '1', - verifyingContract: '0x0077777d7EBA4688BDeF3E311b846F25870A19B9', - }, - }; -} - function isPrivyEthereumWallet( value: BaseConnectedWalletType | undefined, ): value is ConnectedWallet { return value?.type === 'ethereum' && value.walletClientType === 'privy'; } -function validateAddress(value: string, label: string): asserts value is `0x${string}` { - if (!EVM_ADDRESS.test(value)) throw new Error(`${label} is invalid`); -} - -function validatePositiveAtomic(value: string, label: string): bigint { - if (!/^[1-9][0-9]*$/u.test(value)) throw new Error(`${label} must be a positive integer`); - const parsed = BigInt(value); - if (parsed > MAX_UINT256) throw new Error(`${label} is too large`); - return parsed; -} - -function validateTransactionHash(value: unknown): `0x${string}` { - if (typeof value !== 'string' || !TRANSACTION_HASH.test(value)) { - throw new Error('Wallet did not return a valid transaction hash'); - } - return value.toLowerCase() as `0x${string}`; -} - -async function waitForSuccessfulReceipt(provider: EthereumProvider, transactionHash: string) { - for (let attempt = 0; attempt < 90; attempt += 1) { - const receipt = await provider.request({ - method: 'eth_getTransactionReceipt', - params: [transactionHash], - }); - if (receipt !== null && typeof receipt === 'object' && !Array.isArray(receipt)) { - const status = (receipt as Record).status; - if (status === '0x1') return; - if (status === '0x0') throw new Error('Gateway transaction reverted'); - throw new Error('Gateway transaction receipt is malformed'); - } - await new Promise((resolve) => window.setTimeout(resolve, 1000)); - } - throw new Error('Gateway transaction confirmation is not available yet'); -} - export function usePrivyUserWallet(): UserWalletSession { const { user } = usePrivy(); const { ready: walletsReady, wallets } = useWallets(); @@ -277,252 +168,6 @@ export function usePrivyUserWallet(): UserWalletSession { return result.toLowerCase(); } - async function getGatewayBalance(payerWallet: string): Promise { - validateAddress(payerWallet, 'Gateway balance payer wallet'); - const response = await fetch(CIRCLE_GATEWAY_BALANCES_URL, { - method: 'POST', - headers: { 'content-type': 'application/json' }, - body: JSON.stringify({ - token: 'USDC', - sources: [{ depositor: payerWallet, domain: ARC_TESTNET_GATEWAY_DOMAIN }], - }), - }); - const body: unknown = await response.json().catch(() => null); - if (!response.ok || body === null || typeof body !== 'object' || Array.isArray(body)) { - throw new Error('Circle Gateway balance lookup failed'); - } - const balances = (body as Record).balances; - if (!Array.isArray(balances)) throw new Error('Circle Gateway balance response is invalid'); - const matching = balances.find( - (entry): entry is Record => - entry !== null && - typeof entry === 'object' && - !Array.isArray(entry) && - entry.domain === ARC_TESTNET_GATEWAY_DOMAIN && - typeof entry.depositor === 'string' && - entry.depositor.toLowerCase() === payerWallet.toLowerCase(), - ); - if ( - !matching || - typeof matching.balance !== 'string' || - !/^(?:0|[1-9][0-9]*)(?:\.[0-9]{1,6})?$/u.test(matching.balance) - ) { - throw new Error('Circle Gateway balance response is invalid'); - } - try { - return usdcToAtomicUnits(matching.balance); - } catch { - throw new Error('Circle Gateway balance response is invalid'); - } - } - - async function getGatewayPendingDeposits( - payerWallet: string, - ): Promise { - validateAddress(payerWallet, 'Gateway deposit payer wallet'); - const response = await fetch(CIRCLE_GATEWAY_DEPOSITS_URL, { - method: 'POST', - headers: { 'content-type': 'application/json' }, - body: JSON.stringify({ - token: 'USDC', - sources: [{ depositor: payerWallet, domain: ARC_TESTNET_GATEWAY_DOMAIN }], - }), - }); - const body: unknown = await response.json().catch(() => null); - if (!response.ok || body === null || typeof body !== 'object' || Array.isArray(body)) { - throw new Error('Circle Gateway pending-deposit lookup failed'); - } - const deposits = (body as Record).deposits; - if (!Array.isArray(deposits)) - throw new Error('Circle Gateway pending-deposit response is invalid'); - return deposits.flatMap((entry): GatewayPendingDeposit[] => { - if (entry === null || typeof entry !== 'object' || Array.isArray(entry)) return []; - const record = entry as Record; - if ( - record.domain !== ARC_TESTNET_GATEWAY_DOMAIN || - typeof record.depositor !== 'string' || - record.depositor.toLowerCase() !== payerWallet.toLowerCase() || - record.status !== 'pending' || - typeof record.transactionHash !== 'string' || - !TRANSACTION_HASH.test(record.transactionHash) || - typeof record.amount !== 'string' - ) { - return []; - } - return [ - { - transaction_hash: record.transactionHash.toLowerCase(), - amount: record.amount, - status: record.status, - }, - ]; - }); - } - - async function fundGateway(targetAmountAtomic: string): Promise { - const target = validatePositiveAtomic(targetAmountAtomic, 'Gateway target amount'); - const current = await resolveArcWallet(); - const payerWallet = current.address; - const available = BigInt(await getGatewayBalance(payerWallet)); - if (available >= target) { - return { - target_amount_atomic: targetAmountAtomic, - deposited_amount_atomic: '0', - approval_transaction_hash: null, - deposit_transaction_hash: null, - }; - } - - const pending = await getGatewayPendingDeposits(payerWallet); - const existingPending = pending[0]; - if (existingPending) { - throw new GatewayFundingError( - 'A Gateway deposit is already pending for this wallet. Check its status before funding again.', - 'DEPOSIT', - existingPending.transaction_hash, - ); - } - - const amount = target - available; - const provider = await current.getEthereumProvider(); - const allowanceData = encodeFunctionData({ - abi: erc20Abi, - functionName: 'allowance', - args: [payerWallet as `0x${string}`, ARC_TESTNET_GATEWAY_WALLET], - }); - let allowance = 0n; - try { - const rawAllowance = await provider.request({ - method: 'eth_call', - params: [{ to: ARC_TESTNET_USDC, data: allowanceData }, 'latest'], - }); - if (typeof rawAllowance === 'string') allowance = BigInt(rawAllowance); - } catch { - allowance = 0n; - } - - let approvalTransactionHash: string | null = null; - if (allowance < amount) { - const approvalData = encodeFunctionData({ - abi: erc20Abi, - functionName: 'approve', - args: [ARC_TESTNET_GATEWAY_WALLET, amount], - }); - try { - approvalTransactionHash = validateTransactionHash( - await provider.request({ - method: 'eth_sendTransaction', - params: [ - { - from: payerWallet, - to: ARC_TESTNET_USDC, - data: approvalData, - value: '0x0', - }, - ], - }), - ); - await waitForSuccessfulReceipt(provider, approvalTransactionHash); - } catch (error) { - throw new GatewayFundingError( - error instanceof Error ? error.message : 'Gateway approval did not complete', - 'APPROVAL', - approvalTransactionHash, - ); - } - } - - const depositData = encodeFunctionData({ - abi: GATEWAY_DEPOSIT_ABI, - functionName: 'deposit', - args: [ARC_TESTNET_USDC, amount], - }); - let depositTransactionHash: string | null = null; - try { - depositTransactionHash = validateTransactionHash( - await provider.request({ - method: 'eth_sendTransaction', - params: [ - { - from: payerWallet, - to: ARC_TESTNET_GATEWAY_WALLET, - data: depositData, - value: '0x0', - }, - ], - }), - ); - await waitForSuccessfulReceipt(provider, depositTransactionHash); - } catch (error) { - throw new GatewayFundingError( - error instanceof Error ? error.message : 'Gateway deposit did not complete', - 'DEPOSIT', - typeof depositTransactionHash === 'string' ? depositTransactionHash : null, - ); - } - return { - target_amount_atomic: targetAmountAtomic, - deposited_amount_atomic: amount.toString(), - approval_transaction_hash: approvalTransactionHash, - deposit_transaction_hash: depositTransactionHash, - }; - } - - async function signX402Payment( - quote: PaidApiQuote, - ): Promise { - const current = await resolveArcWallet(); - const provider = await current.getEthereumProvider(); - const signer = { - address: current.address as `0x${string}`, - signTypedData: async (parameters: { - readonly domain: { - readonly name: string; - readonly version: string; - readonly chainId: number; - readonly verifyingContract: `0x${string}`; - }; - readonly types: Record>; - readonly primaryType: string; - readonly message: Record; - }): Promise<`0x${string}`> => { - const signature = await provider.request({ - method: 'eth_signTypedData_v4', - params: [ - current!.address, - JSON.stringify({ - domain: parameters.domain, - types: - current.walletClientType === 'privy' - ? parameters.types - : { - ...parameters.types, - EIP712Domain: [ - { name: 'name', type: 'string' }, - { name: 'version', type: 'string' }, - { name: 'chainId', type: 'uint256' }, - { name: 'verifyingContract', type: 'address' }, - ], - }, - primaryType: parameters.primaryType, - message: jsonSafe(parameters.message), - }), - ], - }); - if (typeof signature !== 'string' || !/^0x[0-9a-fA-F]{128,130}$/u.test(signature)) { - throw new Error('Wallet did not return a valid x402 signature'); - } - return signature as `0x${string}`; - }, - }; - const requirements = circleX402SigningRequirements(quote); - const partial = await new BatchEvmScheme(signer).createPaymentPayload( - quote.x402_version, - requirements, - ); - return { x402Version: partial.x402Version, payload: partial.payload }; - } - return { address: selectedWallet?.address ?? @@ -530,10 +175,6 @@ export function usePrivyUserWallet(): UserWalletSession { ? explicitlyConnectedWallet.current.wallet.address : null), connect, - getGatewayBalance, - getGatewayPendingDeposits, - fundGateway, sendTransfer, - signX402Payment, }; } diff --git a/apps/web/src/auth/session.ts b/apps/web/src/auth/session.ts index 7effec4..d987b3c 100644 --- a/apps/web/src/auth/session.ts +++ b/apps/web/src/auth/session.ts @@ -1,5 +1,3 @@ -import type { PaidApiQuote, SubmitPaidApiUserWalletRequest } from '@oneshot/contracts'; - export type OperatorSessionStatus = 'UNCONFIGURED' | 'LOADING' | 'SIGNED_OUT' | 'SIGNED_IN'; /** @@ -17,42 +15,9 @@ export interface OperatorSession { export type UseOperatorSession = () => OperatorSession; -export interface GatewayPendingDeposit { - readonly transaction_hash: string; - readonly amount: string; - readonly status: string; -} - -export interface GatewayFundingResult { - readonly target_amount_atomic: string; - readonly deposited_amount_atomic: string; - readonly approval_transaction_hash: string | null; - readonly deposit_transaction_hash: string | null; -} - -export type GatewayFundingPhase = 'APPROVAL' | 'DEPOSIT'; - -export class GatewayFundingError extends Error { - readonly phase: GatewayFundingPhase; - readonly transaction_hash: string | null; - - constructor(message: string, phase: GatewayFundingPhase, transactionHash: string | null = null) { - super(message); - this.name = 'GatewayFundingError'; - this.phase = phase; - this.transaction_hash = transactionHash; - } -} - export interface UserWalletSession { readonly address: string | null; connect(): Promise; - /** Read-only Circle Gateway USDC balance in atomic units. */ - getGatewayBalance(payerWallet: string): Promise; - /** Read-only pending Circle Gateway deposits for this depositor. */ - getGatewayPendingDeposits(payerWallet: string): Promise; - /** Top up this wallet's own Gateway balance to the requested atomic target. */ - fundGateway(targetAmountAtomic: string): Promise; sendTransfer(payment: { readonly chain_id: 5042002; readonly token_contract: string; @@ -60,7 +25,6 @@ export interface UserWalletSession { readonly recipient: string; readonly amount_atomic: string; }): Promise; - signX402Payment(quote: PaidApiQuote): Promise; } export const unconfiguredOperatorSession: UseOperatorSession = () => ({ diff --git a/apps/web/src/components/IntentForm.tsx b/apps/web/src/components/IntentForm.tsx index 730799e..4b0bc24 100644 --- a/apps/web/src/components/IntentForm.tsx +++ b/apps/web/src/components/IntentForm.tsx @@ -83,7 +83,7 @@ export function IntentForm({ client, onIntentCreatedOrSelected }: Props) { const [intentId, setIntentId] = useState(() => crypto.randomUUID()); const [recipient, setRecipient] = useState(''); const [amount, setAmount] = useState('1.00'); - const [purpose, setPurpose] = useState('Paid API job'); + const [purpose, setPurpose] = useState('Direct Arc payment'); const [submitting, setSubmitting] = useState(false); const [validationError, setValidationError] = useState(null); const [outcome, setOutcome] = useState(null); diff --git a/apps/web/src/components/JobWorkspace.tsx b/apps/web/src/components/JobWorkspace.tsx index baff9de..2e549d9 100644 --- a/apps/web/src/components/JobWorkspace.tsx +++ b/apps/web/src/components/JobWorkspace.tsx @@ -1,9 +1,8 @@ import { formatAtomicUsdcWithAsset } from '@oneshot/settlement-ui'; import { useEffect, useState } from 'react'; -import type { JobView, PaidApiQuote, PaidApiResponse, SupplierQuote } from '@oneshot/contracts'; +import type { JobView, SupplierQuote } from '@oneshot/contracts'; import type { JobApiClient } from '../api/job-client.js'; -import { GatewayFundingError, type UserWalletSession } from '../auth/session.js'; -import { PaidApiUserWalletSubmissionError, type PaidApiClient } from '../api/paid-api-client.js'; +import type { UserWalletSession } from '../auth/session.js'; import { usdcToAtomicUnits } from '../utils/money.js'; import { deliveryStatusCopy, @@ -17,7 +16,6 @@ import { function shortenAddress(value: string): string { return maskAddress(value); } - function quoteAmount(quote: SupplierQuote): string { return formatAtomicUsdcWithAsset(quote.amount_atomic, quote.asset) ?? 'Unavailable'; } @@ -427,488 +425,11 @@ export function JobWorkspace(props: { ); } -export function CircleX402DemoPanel(props: { - readonly client?: PaidApiClient; - readonly userWallet?: UserWalletSession; - readonly onSelectIntent: (id: string) => void; -}) { - const [taskKey, setTaskKey] = useState(() => `circle-api-${crypto.randomUUID().slice(0, 8)}`); - const [quote, setQuote] = useState(null); - const [request, setRequest] = useState(null); - const [loading, setLoading] = useState< - 'quote' | 'start' | 'sign' | 'refresh' | 'fund' | 'balance' | null - >(null); - const [notice, setNotice] = useState(''); - const [gatewayTarget, setGatewayTarget] = useState('1'); - const [gatewayBalance, setGatewayBalance] = useState(null); - const [gatewayFundingHash, setGatewayFundingHash] = useState(null); - const paidApiRequest = { task_key: taskKey.trim(), tool_id: 'circle-x402-api-v1' as const }; - - function clear(): void { - setQuote(null); - setRequest(null); - setNotice(''); - } - - async function loadQuote(): Promise { - if (!props.client || !paidApiRequest.task_key) return; - setLoading('quote'); - setNotice(''); - try { - setQuote(await props.client.quote(paidApiRequest)); - } catch { - setQuote(null); - setNotice('A current price is unavailable. Check the connected service and try again.'); - } finally { - setLoading(null); - } - } - - async function approve(): Promise { - const approvedQuote = request?.quote ?? quote; - if (!props.client || !approvedQuote) return; - setLoading('start'); - setNotice(''); - try { - if (!props.userWallet) { - const result = await props.client.start({ - ...paidApiRequest, - approved_quote: approvedQuote, - }); - setRequest(result); - setNotice('Request accepted. OneShot now owns the payment attempt.'); - return; - } - const payerWallet = props.userWallet.address ?? (await props.userWallet.connect()); - if (!payerWallet) throw new Error('No Ethereum wallet is connected'); - const result = await props.client.prepareUserWallet({ - ...paidApiRequest, - approved_quote: approvedQuote, - payer_wallet: payerWallet, - }); - setRequest(result); - setNotice('Request prepared. Your wallet will now ask you to sign the exact Circle payment.'); - await signAndSubmit(result, approvedQuote, payerWallet); - } catch (error) { - if (!props.userWallet) setQuote(null); - setNotice( - props.userWallet && error instanceof PaidApiUserWalletSubmissionError - ? error.message - : props.userWallet - ? 'The payment was not completed. If your wallet showed a signature request, check the same request status before trying again.' - : 'The API request was not accepted. Keep the same request key before retrying.', - ); - } finally { - setLoading(null); - } - } - - async function signAndSubmit( - prepared: PaidApiResponse, - approvedQuote: PaidApiQuote, - payerWallet: string, - ): Promise { - if (!props.client || !props.userWallet) return; - setLoading('sign'); - const gatewayBalance = await props.userWallet.getGatewayBalance(payerWallet); - if ( - !/^\d+$/u.test(gatewayBalance) || - BigInt(gatewayBalance) < BigInt(approvedQuote.amount_atomic) - ) { - throw new PaidApiUserWalletSubmissionError( - 'Your Arc Testnet Circle Gateway balance is below this price. Fund the Gateway balance, then sign this same prepared request.', - ); - } - const paymentPayload = await props.userWallet.signX402Payment(approvedQuote); - const submitted = await props.client.submitUserWalletPayment( - prepared.business_intent_id, - payerWallet, - paymentPayload, - ); - setRequest(submitted); - setNotice( - submitted.payment_state === 'COMMITTED' - ? 'Payment confirmed from your connected wallet. The dataset result is ready.' - : submitted.payment_state === 'UNKNOWN' - ? 'The signed payment was recorded but is not final. Check the same request; do not sign another payment.' - : `Payment state: ${submitted.payment_state}.`, - ); - } - - async function refreshGatewayBalance(): Promise { - if (!props.userWallet) return; - setLoading('balance'); - setNotice(''); - try { - const payerWallet = props.userWallet.address ?? (await props.userWallet.connect()); - if (!payerWallet) throw new Error('No Ethereum wallet is connected'); - setGatewayBalance(await props.userWallet.getGatewayBalance(payerWallet)); - setNotice('Gateway balance refreshed.'); - } catch { - setNotice('The Gateway balance could not be checked. No payment was submitted.'); - } finally { - setLoading(null); - } - } - - async function fundGateway(): Promise { - if (!props.userWallet) return; - let targetAmountAtomic: string; - try { - targetAmountAtomic = usdcToAtomicUnits(gatewayTarget); - if (targetAmountAtomic === '0') throw new Error('Gateway target must be positive'); - } catch { - setNotice('Enter a positive Gateway balance target with up to 6 decimal places.'); - return; - } - setLoading('fund'); - setNotice('Your wallet may ask for approval, then a Gateway deposit.'); - try { - const payerWallet = props.userWallet.address ?? (await props.userWallet.connect()); - if (!payerWallet) throw new Error('No Ethereum wallet is connected'); - const result = await props.userWallet.fundGateway(targetAmountAtomic); - setGatewayFundingHash(result.deposit_transaction_hash); - try { - setGatewayBalance(await props.userWallet.getGatewayBalance(payerWallet)); - } catch { - setGatewayBalance(null); - } - setNotice( - result.deposit_transaction_hash - ? 'Gateway deposit confirmed on Arc Testnet. Wait for Circle balance processing, then sign the API payment.' - : 'Your Gateway balance already meets the requested target.', - ); - } catch (error) { - if (error instanceof GatewayFundingError && error.transaction_hash) { - setGatewayFundingHash(error.transaction_hash); - } - if (error instanceof GatewayFundingError && error.phase === 'DEPOSIT') { - setNotice( - 'The Gateway deposit result is not fully resolved. Do not fund again; refresh the Gateway balance and check the same transaction first.', - ); - } else if (error instanceof GatewayFundingError && error.phase === 'APPROVAL') { - setNotice( - 'The Gateway approval result is not fully resolved. No deposit was submitted by OneShot; check the same approval before trying again.', - ); - } else { - setNotice('Gateway funding was not completed. No API payment was submitted.'); - } - } finally { - setLoading(null); - } - } - - async function signPrepared(): Promise { - if (!request || !quote || !props.userWallet) return; - const payerWallet = request.payer_wallet ?? props.userWallet.address; - if (!payerWallet) { - setNotice('Connect the same wallet that was bound to this request.'); - return; - } - setNotice(''); - try { - await signAndSubmit(request, quote, payerWallet); - } catch (error) { - setNotice( - error instanceof PaidApiUserWalletSubmissionError - ? error.message - : 'The payment was not completed. Check the same request status before trying again.', - ); - } finally { - setLoading(null); - } - } - - async function refresh(): Promise { - if (!props.client || !request) return; - setLoading('refresh'); - try { - const updated = - props.userWallet && request.payment_mode === 'USER_WALLET' - ? await props.client.reconcileUserWalletPayment(request.business_intent_id) - : await props.client.get(request.business_intent_id); - setRequest(updated); - setNotice('Payment status checked from the OneShot ledger.'); - } catch { - setNotice('Payment state could not be refreshed; no new payment was submitted.'); - } finally { - setLoading(null); - } - } - - return ( -

    -
    -
    -

    TEAM-OPERATED X402 DEMO

    -

    OneShot x402 Dataset

    -
    - Arc Testnet -
    -

    - Buy a demo dataset from OneShot’s own seller through Circle x402. OneShot keeps one request - key so a retry reuses the original payment instead of charging twice. When a connected - wallet is used, it signs the exact payment to the API seller; OneShot never substitutes its - own wallet. -

    - {props.userWallet && ( -
    -
    -
    -

    Your Circle Gateway balance

    - Buyer-funded -
    - - {gatewayBalance === null - ? 'Not checked' - : `${formatAtomicUsdcWithAsset(gatewayBalance, 'USDC') ?? 'Invalid'} USDC`} - -
    -

    - This is your wallet's own Arc Testnet balance for gas-free Circle payments. OneShot - never pays for you and never deposits into another user's balance. -

    - - setGatewayTarget(event.target.value)} - inputMode="decimal" - autoComplete="off" - /> -

    - Funding may show up to two wallet confirmations: allowance approval and Gateway deposit. - Use testnet USDC only. A confirmed deposit may take a moment to appear in Circle's - available balance. -

    -
    - - -
    - {gatewayFundingHash && ( -

    - Funding transaction:{' '} - - View on ArcScan - -

    - )} -
    - )} - - { - setTaskKey(event.target.value); - clear(); - }} - maxLength={128} - autoComplete="off" - spellCheck={false} - /> - - Keep this exact key if the browser or agent retries. It identifies the same API request. - - {!props.client ? ( -

    - The paid API integration is not configured in this environment. -

    - ) : !quote && !request ? ( - - ) : null} - {quote && !request && ( - <> -
    -
    -

    Review payment

    - No charge yet -
    -
    -
    -
    Recipient receives
    -
    - {formatAtomicUsdcWithAsset(quote.amount_atomic, quote.asset) ?? 'Unavailable'} -
    -
    -
    -
    Service destination
    -
    - {shortenAddress(quote.recipient)} -
    -
    - {props.userWallet?.address && ( -
    -
    Payer wallet
    -
    - {shortenAddress(props.userWallet.address)} -
    -
    - )} -
    -
    Network
    -
    {networkLabel(quote.network)}
    -
    -
    -
    - Show API payment details -
    -
    -
    Resource
    -
    {quote.resource_url}
    -
    -
    -
    Full destination
    -
    {quote.recipient}
    -
    -
    -
    -
    -

    - {props.userWallet - ? 'Approval binds your wallet, the seller, the amount and Arc Testnet. Your wallet signs one Circle Gateway authorization; OneShot forwards it once and verifies the Arc receipt.' - : 'Approval creates the request. The server-side Privy execution wallet pays in this test composition; your connected wallet is not charged here.'} -

    - - - )} - {request && ( -
    -
    - {paymentStatusCopy(request.payment_state).label} - - One request - -
    -

    {paymentStatusCopy(request.payment_state).description}

    - {request.settlement ? ( -

    - Arc payment confirmed.{' '} - - View committed settlement - -

    - ) : ( -

    - Check this request again later. Do not start a new request while payment verification - is in progress. -

    - )} - {props.userWallet && request.payment_state === 'READY' && ( - - )} - {request.response !== undefined && ( -
    - API result -
    {JSON.stringify(request.response, null, 2)}
    -
    - )} - - -
    - Show technical request details -
    -
    -
    Request identity
    -
    {maskIdentifier(request.business_intent_id, 10)}
    -
    -
    -
    Resource
    -
    {request.resource_url}
    -
    - {request.provider_transaction_hash && ( -
    -
    Circle transaction
    -
    - {explorerHref(request.provider_transaction_hash) ? ( - - View on ArcScan - - ) : ( - {request.provider_transaction_hash} - )} -
    -
    - )} -
    -
    -
    - )} - {notice && ( -

    - {notice} -

    - )} - - Open service deployment runbook - -
    - ); -} - export function JobList(props: { readonly client: JobApiClient; readonly onSelectIntent: (id: string) => void; }) { - const [requests, setRequests] = useState([]); + const [requests, setRequests] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(''); const [resumingJobId, setResumingJobId] = useState(null); @@ -917,10 +438,7 @@ export function JobList(props: { async function refresh(): Promise { setLoading(true); try { - const listed = - typeof props.client.listRequests === 'function' - ? await props.client.listRequests() - : (await props.client.list()).map((job) => job); + const listed = await props.client.list(); setRequests(listed); setError(''); } catch { @@ -999,90 +517,6 @@ export function JobList(props: { ) : (
      {requests.map((request, index) => { - if (request.tool_id === 'circle-x402-api-v1') { - const payment = paymentStatusCopy(request.payment_state); - return ( -
    • -
      - - {payment.label} -
      -

      - OneShot x402 Dataset · {payment.label} -

      -

      - Price:{' '} - - {formatAtomicUsdcWithAsset( - request.quote.amount_atomic, - request.quote.asset, - ) ?? 'Unavailable'} - {' '} - · {request.resource_url} -

      - {request.response !== undefined && ( -

      - API result recorded. Open this request to inspect payment - proof. -

      - )} - {request.settlement ? ( -

      - Payment confirmed:{' '} - {explorerHref(request.settlement.transaction_hash) ? ( - - View the ArcScan transaction - - ) : ( - {request.settlement.transaction_hash} - )} -

      - ) : request.provider_transaction_hash ? ( -

      - Transaction recorded: payment proof is still being checked. -

      - ) : null} - -
      - Show request details -
      -
      -
      Request key
      -
      {maskIdentifier(request.task_key)}
      -
      -
      -
      Business intent
      -
      - {maskIdentifier(request.business_intent_id, 10)} -
      -
      - {request.payer_wallet && ( -
      -
      Payer wallet
      -
      {request.payer_wallet}
      -
      - )} -
      -
      -
    • - ); - } const job = request; const payment = paymentStatusCopy(job.payment_state); const delivery = deliveryStatusCopy(job.delivery_state); diff --git a/apps/web/src/components/workspace-copy.ts b/apps/web/src/components/workspace-copy.ts index a069530..8cd8d06 100644 --- a/apps/web/src/components/workspace-copy.ts +++ b/apps/web/src/components/workspace-copy.ts @@ -58,7 +58,7 @@ const DELIVERY_STATUS: Readonly> = { AVAILABLE: { label: 'Result ready', tone: 'success', - description: 'The paid API result is available.', + description: 'The supplier result is available.', }, RETRIEVAL_FAILED: { label: 'Result needs attention', @@ -77,12 +77,10 @@ export function deliveryStatusCopy(state: DeliveryState): StatusCopy { export function serviceLabel(toolId: string): string { switch (toolId) { - case 'circle-x402-api-v1': - return 'OneShot x402 Dataset'; case 'team-report-v1': return 'Direct Arc payment'; default: - return 'Paid API service'; + return 'Arc payment'; } } diff --git a/apps/web/src/styles.css b/apps/web/src/styles.css index e79e289..7422e19 100644 --- a/apps/web/src/styles.css +++ b/apps/web/src/styles.css @@ -778,9 +778,7 @@ a:hover { margin-bottom: 0; } -/* Two or more panels stacked in one tab (Tools renders the report workspace - and the x402 demo back to back). Without this they met with no gap and the - lower panel's rounded corners cut into the one above. */ +/* Two or more panels stacked in one tab. */ .panel-stack { display: grid; gap: 1.25rem; @@ -1068,57 +1066,6 @@ a:hover { margin-bottom: 0.5rem; } -.gateway-funding-panel { - display: grid; - gap: 0.65rem; - margin-top: 0.25rem; - padding: 1rem; -} - -.gateway-funding-panel > p { - margin: 0; - line-height: 1.5; -} - -.gateway-funding-panel > label { - color: var(--os-panel-ink); - font-size: 0.85rem; - font-weight: 300; -} - -.gateway-funding-panel > input { - width: 100%; - min-height: 42px; - padding: 0.65rem 0.85rem; - border: 1px solid var(--os-line); - border-radius: 0.6rem; - color: var(--os-ink); - background: var(--os-surface); - font-family: var(--os-font-mono); - font-size: 0.9rem; -} - -.paid-api-status { - display: grid; - gap: 0.65rem; - margin-top: 1.25rem; - padding: 1rem; - border: 1px solid var(--os-line); - border-radius: 0.75rem; - background: var(--os-field); -} - -.paid-api-status p { - margin: 0; -} - -/* The lime field never flips, so its fact rows need a border that does not - either. Everything else about ink on these two surfaces is handled by the - token rebinding further down, which is the general form of this. */ -.paid-api-status .facts div { - border-bottom-color: var(--os-field-line); -} - .response-output { max-height: 260px; margin: 0; @@ -1149,30 +1096,14 @@ a:hover { margin-bottom: 0.25rem; } -/* The x402 demo and the recovery section lay their controls out the same way +/* The recovery section lays its controls out the same way the report workspace does. Both used to render label, input and help text in plain inline flow, so the input sat on the same line as the text before it and the help text ran on after it. */ -.paid-api-panel { - display: grid; - gap: 0.75rem; -} - -.paid-api-panel > p { - margin: 0; - line-height: 1.6; -} - /* Buttons and links inside these grids size to their content instead of stretching the full column, and sit at the start of the row — which is where - the x402 runbook link belongs. */ -.paid-api-panel > button, -.paid-api-panel > a { - justify-self: start; -} - -.job-workspace > label, -.paid-api-panel > label { + the supporting evidence links belong. */ +.job-workspace > label { color: var(--os-panel-ink); font-size: 0.85rem; font-weight: 300; @@ -1187,8 +1118,7 @@ a:hover { } .job-workspace > input, -.advanced-fields input, -.paid-api-panel > input { +.advanced-fields input { width: 100%; min-height: 42px; padding: 0.65rem 0.85rem; @@ -1202,13 +1132,12 @@ a:hover { } .job-workspace > input:focus, -.advanced-fields input:focus, -.paid-api-panel > input:focus { +.advanced-fields input:focus { border-color: var(--os-signal); } -/* `.secondary` was only ever styled for