diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f90addc..7d75817 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -75,6 +75,12 @@ jobs: - name: Typecheck, test, and build run: pnpm check + - name: Test the standalone testnet runner + run: | + npm ci --prefix scripts/runner --ignore-scripts --no-audit --no-fund + npm test --prefix scripts/runner + bash -n scripts/runner/roque_run + solidity: name: Solidity checks runs-on: ubuntu-24.04 diff --git a/README.md b/README.md index 583a3c1..b4783d7 100644 --- a/README.md +++ b/README.md @@ -126,6 +126,12 @@ pnpm relayer # the relayer and API pnpm keeper # the keeper loop that settles resting orders ``` +For randomized testnet swaps against the deployed app, run `~/rialo/roque_run`. +Use `~/rialo/roque_run --dry-run` for a read-only balance check and sample plans. +The runner uses Autonomous Confirm/Direct exclusively, with a hard limit of +100 Sepolia submissions including setup. Configuration, Latch routing, logs and +recovery are documented in [the runner guide](scripts/runner/README.md). + ## Testing The contracts carry a full Foundry suite, and it is meant to stay green. diff --git a/apps/relayer/src/diagnostics.ts b/apps/relayer/src/diagnostics.ts new file mode 100644 index 0000000..aaf7611 --- /dev/null +++ b/apps/relayer/src/diagnostics.ts @@ -0,0 +1,23 @@ +import type {FastifyInstance,FastifyRequest} from "fastify"; + +// Log constructed records only. OAuth URLs, bodies, headers and Error objects +// can contain credentials, authorization codes and PKCE material. +const reasons=["success","rejected","invalid_client_metadata","invalid_redirect_uri","invalid_scope","invalid_request","invalid_grant","invalid_client","wallet_required","invalid_target","rate_limited","internal_error"] as const; +type Reason=typeof reasons[number]; +const details=new WeakMap(); +const metadataFields=["client_name","redirect_uris","grant_types","response_types","token_endpoint_auth_method","scope"]; +export function diagnosticReason(req:FastifyRequest,reason:Reason,invalidFields?:string[]){ + details.set(req,{reason,...(invalidFields?{invalidFields:metadataFields.filter(field=>invalidFields.includes(field))}:{})}); +} +export function registerDiagnostics(app:FastifyInstance){ + app.addHook("onResponse",async(req,reply)=>{ + const oauth=req.url.startsWith("/oauth/"); + const publicFailure=reply.statusCode>=400&&(req.url.startsWith("/v1/")||req.url.split("?",1)[0]==="/mcp"); + if(!oauth&&!publicFailure)return; + const record=details.get(req); + // Fastify route templates exclude both query values and dynamic path values. + const route=req.routeOptions.url??(oauth?"/oauth/*":"/public/*"); + const reason=record?.reason??(reply.statusCode>=500?"internal_error":reply.statusCode===429?"rate_limited":reply.statusCode>=400?"rejected":"success"); + console.info(JSON.stringify({event:oauth?"oauth_request":"public_api_error",timestamp:new Date().toISOString(),requestId:req.id,route,method:req.method,status:reply.statusCode,reason,...(record?.invalidFields?{invalidFields:record.invalidFields}:{})})); + }); +} diff --git a/apps/relayer/src/oauth.ts b/apps/relayer/src/oauth.ts index c9399b6..66d1693 100644 --- a/apps/relayer/src/oauth.ts +++ b/apps/relayer/src/oauth.ts @@ -7,6 +7,7 @@ import {authOrigin} from "@roque/core/env"; import {API_SCOPES} from "@roque/core/public-api"; import {authenticatedOwner,tokenHash,revokeSession} from "../../../packages/core/src/auth.js"; import {admit} from "../../../packages/core/src/admission.js"; +import {diagnosticReason} from "./diagnostics.js"; export function mcpResource(){const url=process.env.MCP_RESOURCE_URL??`${authOrigin()}/mcp`;return new URL(url).href;} const clientModel=async(id:string)=>{ const rows=await q<{id:string;redirects:string[];scopes:string[]}>("SELECT id,redirects,scopes FROM api_clients WHERE id=$1 AND revoked_at IS NULL",[id]); @@ -39,6 +40,16 @@ export const oauth=new OAuth2Server({accessTokenLifetime:3600,authorizationCodeL verifyScope:async(token,scope)=>scope.every(s=>token.scope?.includes(s)), }}); function bearer(header:string|undefined){return header?.match(/^Bearer ([A-Za-z0-9_-]+)$/u)?.[1];} +const scopeString=z.string().min(1).max(300).regex(/^[\x21\x23-\x5B\x5D-\x7E]+(?: [\x21\x23-\x5B\x5D-\x7E]+)*$/u); +const registration=z.object({ + client_name:z.string().min(1).max(100).optional(), + redirect_uris:z.array(z.string().url().max(2000)).min(1).max(5), + grant_types:z.array(z.literal("authorization_code")).length(1).optional(), + response_types:z.array(z.literal("code")).length(1).optional(), + token_endpoint_auth_method:z.literal("none").optional(), + scope:scopeString.optional(), + // RFC 7591 requires ignoring client metadata we do not understand. +}).strip(); const authQuery=z.object({client_id:z.string().max(128),redirect_uri:z.string().url().max(2000),response_type:z.literal("code"), code_challenge:z.string().regex(/^[A-Za-z0-9_-]{43}$/u),code_challenge_method:z.literal("S256"),state:z.string().min(16).max(500), resource:z.string().url(),scope:z.string().max(300).optional()}).strict(); @@ -49,16 +60,27 @@ export async function registerOAuth(app:FastifyInstance){ app.get("/.well-known/oauth-authorization-server",async()=>({issuer,authorization_endpoint:`${issuer}/oauth/authorize`,token_endpoint:`${issuer}/oauth/token`,registration_endpoint:`${issuer}/oauth/register`,revocation_endpoint:`${issuer}/oauth/revoke`,response_types_supported:["code"],grant_types_supported:["authorization_code"],token_endpoint_auth_methods_supported:["none"],code_challenge_methods_supported:["S256"],scopes_supported:[...API_SCOPES]})); app.post("/oauth/register",async(req,reply)=>{ await admit(`oauth-register:${req.ip}`,10); - const input=z.object({client_name:z.string().min(1).max(100),redirect_uris:z.array(z.string().url().max(2000)).min(1).max(5),grant_types:z.array(z.literal("authorization_code")).optional(),response_types:z.array(z.literal("code")).optional(),token_endpoint_auth_method:z.literal("none").optional()}).strict().parse(req.body); - const allowed=(process.env.OAUTH_ALLOWED_REDIRECTS??"").split(",").filter(Boolean); - if(input.redirect_uris.some(u=>!allowed.includes(u))) return reply.status(400).send({error:"invalid_redirect_uri"}); - const id=randomUUID();await q("INSERT INTO api_clients(id,key_hash,name,scopes,redirects) VALUES($1,$2,$3,$4,$5)",[id,tokenHash(randomBytes(32).toString("base64url")),input.client_name,[...API_SCOPES],input.redirect_uris]); - return reply.status(201).send({client_id:id,client_name:input.client_name,redirect_uris:input.redirect_uris,grant_types:["authorization_code"],response_types:["code"],token_endpoint_auth_method:"none"}); + const parsed=registration.safeParse(req.body); + if(!parsed.success){ + diagnosticReason(req,"invalid_client_metadata",parsed.error.issues.map(issue=>String(issue.path[0]))); + return reply.status(400).send({error:"invalid_client_metadata",error_description:"Invalid or unsupported registration fields."}); + } + const input=parsed.data,scopes=input.scope?[...new Set(input.scope.split(" "))]:[...API_SCOPES]; + if(scopes.some(scope=>!API_SCOPES.includes(scope as typeof API_SCOPES[number]))){ + diagnosticReason(req,"invalid_client_metadata",["scope"]); + return reply.status(400).send({error:"invalid_client_metadata",error_description:"Requested scope is not supported."}); + } + const allowed=(process.env.OAUTH_ALLOWED_REDIRECTS??"").split(",").map(value=>value.trim()).filter(Boolean); + if(input.redirect_uris.some(u=>!allowed.includes(u))){diagnosticReason(req,"invalid_redirect_uri");return reply.status(400).send({error:"invalid_redirect_uri",error_description:"Every redirect URI must be explicitly allowlisted."});} + const id=randomUUID(),name=input.client_name??`MCP client ${id}`; + await q("INSERT INTO api_clients(id,key_hash,name,scopes,redirects) VALUES($1,$2,$3,$4,$5)",[id,tokenHash(randomBytes(32).toString("base64url")),name,scopes,input.redirect_uris]); + return reply.status(201).send({client_id:id,client_name:name,redirect_uris:input.redirect_uris,grant_types:["authorization_code"],response_types:["code"],token_endpoint_auth_method:"none",scope:scopes.join(" ")}); }); app.get("/oauth/authorize",async(req,reply)=>{ await admit(`oauth-authorize:${req.ip}`,20);const query=authQuery.parse(req.query); const client=await clientModel(query.client_id);if(!client||!client.redirectUris.includes(query.redirect_uri)||query.resource!==mcpResource())return reply.status(400).send({error:"invalid_request"}); - query.scope=query.scope??API_SCOPES.join(" "); + query.scope=query.scope??client.scope.join(" "); + if(!scopeString.safeParse(query.scope).success||query.scope.split(" ").some(scope=>!(client.scope as string[]).includes(scope))){diagnosticReason(req,"invalid_scope");return reply.status(400).send({error:"invalid_scope",error_description:"Requested scope is not permitted for this client."});} const requestId=randomUUID();await q("INSERT INTO oauth_requests(id,query,expires_at) VALUES($1,$2::jsonb,now()+interval '5 minutes')",[requestId,JSON.stringify(query)]); return reply.redirect(`${authOrigin()}/connect?request=${requestId}`); }); diff --git a/apps/relayer/src/server.ts b/apps/relayer/src/server.ts index 64c462d..5e64703 100644 --- a/apps/relayer/src/server.ts +++ b/apps/relayer/src/server.ts @@ -7,8 +7,10 @@ */ import {pathToFileURL} from "node:url"; +import {randomUUID} from "node:crypto"; import OAuth2Server from "@node-oauth/oauth2-server"; import {registerOAuth} from "./oauth.js"; +import {registerDiagnostics,diagnosticReason} from "./diagnostics.js"; import Fastify from "fastify"; import {z,ZodError} from "zod"; import {admitTransport,admit} from "../../../packages/core/src/admission.js"; @@ -61,16 +63,25 @@ import { import { startWorkers } from "./workers.js"; export async function createServer() { -const app = Fastify({logger:false,bodyLimit:16384}); +const app = Fastify({logger:false,bodyLimit:16384,requestIdHeader:false,genReqId:()=>randomUUID()}); +registerDiagnostics(app); app.addHook("onRequest",async req=>{admitTransport(req.ip);await admit("global:http",600);}); -app.addHook("onSend",async(_req,reply)=>{reply.header("Cache-Control","no-store");reply.header("X-Content-Type-Options","nosniff");}); +app.addHook("onSend",async(req,reply)=>{reply.header("Cache-Control","no-store");reply.header("X-Content-Type-Options","nosniff");reply.header("X-Request-Id",req.id);}); app.addContentTypeParser("application/x-www-form-urlencoded",{parseAs:"string"},(_req,body,done)=>{ const parsed=Object.fromEntries(new URLSearchParams(String(body)));done(null,parsed); }); const allowedOrigins=(process.env.API_ALLOWED_ORIGINS??"http://localhost:3000").split(","); -await app.register(cors,{origin:allowedOrigins,allowedHeaders:["content-type","authorization","x-api-key","idempotency-key"],methods:["GET","POST","DELETE","OPTIONS"]}); +await app.register(cors,{origin:allowedOrigins,allowedHeaders:["content-type","authorization","x-api-key","idempotency-key"],exposedHeaders:["x-request-id"],methods:["GET","POST","DELETE","OPTIONS"]}); app.setErrorHandler((err, _req, reply) => { + if(_req.url.split("?",1)[0]==="/oauth/register"){ + if((err as {status?:number}).status===429){diagnosticReason(_req,"rate_limited");reply.header("Retry-After","60").status(429).send({error:"temporarily_unavailable",error_description:"Registration quota exceeded. Retry after one minute."});return;} + const transport=err as {code?:string;statusCode?:number}; + if(err instanceof ZodError||(transport.code?.startsWith("FST_ERR_")&&transport.statusCode&&transport.statusCode>=400&&transport.statusCode<500)){ + diagnosticReason(_req,"invalid_client_metadata");reply.status(transport.statusCode===413?413:400).send({error:"invalid_client_metadata",error_description:"Invalid registration fields, body or content type."});return; + } + diagnosticReason(_req,"internal_error");reply.status(500).send({error:"server_error",error_description:"Registration service unavailable."});return; + } if(err instanceof OAuth2Server.OAuthError){reply.status(err.code).send({error:err.name,error_description:err.code<500?err.message:"Authorization service unavailable."});return;} if(err instanceof ZodError) {reply.status(400).send({error:{code:"INVALID_REQUEST",message:"Invalid request fields.",requestId:_req.id}});return;} if(err instanceof v1.PublicApiError) {reply.status(err.status).send({error:{code:err.code,message:err.message,requestId:_req.id}});return;} diff --git a/apps/web/src/app/_public-api.ts b/apps/web/src/app/_public-api.ts index 114e335..3bf20ee 100644 --- a/apps/web/src/app/_public-api.ts +++ b/apps/web/src/app/_public-api.ts @@ -1,4 +1,5 @@ import "server-only"; +import {randomUUID} from "node:crypto"; import {after} from "next/server"; import {createMcpServer} from "@roque/mcp"; import {interpretationTick} from "@roque/core/public-api"; @@ -12,7 +13,13 @@ export async function dispatchPublic(req:Request,path?:string){ let payload:string|undefined; if(!["GET","HEAD"].includes(req.method)){ const reader=req.body?.getReader();const chunks:Uint8Array[]=[];let size=0; - if(reader)try{for(;;){const {done,value}=await reader.read();if(done)break;size+=value.byteLength;if(size>16384){await reader.cancel();return new Response(JSON.stringify({error:{code:"BODY_TOO_LARGE",message:"Request exceeds 16 KiB."}}),{status:413,headers:{"content-type":"application/json"}});}chunks.push(value);}}finally{reader.releaseLock();} + if(reader)try{for(;;){const {done,value}=await reader.read();if(done)break;size+=value.byteLength;if(size>16384){ + await reader.cancel(); + const registration=url.pathname==="/oauth/register",requestId=randomUUID(); + console.info(JSON.stringify({event:registration?"oauth_request":"public_api_error",timestamp:new Date().toISOString(),requestId,route:registration?"/oauth/register":"/public/*",method:req.method,status:413,reason:registration?"invalid_client_metadata":"rejected"})); + const body=registration?{error:"invalid_client_metadata",error_description:"Registration body exceeds 16 KiB."}:{error:{code:"BODY_TOO_LARGE",message:"Request exceeds 16 KiB.",requestId}}; + return new Response(JSON.stringify(body),{status:413,headers:{"content-type":"application/json","cache-control":"no-store","x-content-type-options":"nosniff","x-request-id":requestId}}); + }chunks.push(value);}}finally{reader.releaseLock();} if(size>0)payload=Buffer.concat(chunks).toString("utf8"); } server??=createMcpServer().catch(err=>{server=undefined;throw err;}); diff --git a/docs/API_TEST_REVIEW_2026-11-10.md b/docs/API_TEST_REVIEW_2026-11-10.md new file mode 100644 index 0000000..06bf508 --- /dev/null +++ b/docs/API_TEST_REVIEW_2026-11-10.md @@ -0,0 +1,44 @@ +# Teammate API test review — 2026-11-10 + +The public API is healthy. REST testing is blocked by missing operator-issued credentials. OAuth registration has reproducible interoperability defects, but the specific Claude failure `ofid_982b6a297f3bb622` could not be correlated with the available logs. Its exact cause remains unconfirmed. + +## Findings + +| Area | Evidence | Assessment | +| --- | --- | --- | +| Deployment and discovery | Canonical Vercel deployment `dpl_8FUMYohz8DWNLZSz7arUudUqPo69` is READY at commit `102a908f50117f2bb3a4116fb71a13e1c46091a7`. Live `/v1/health`, `/v1/chains`, `/v1/tokens`, `/v1/openapi.json` and both OAuth discovery documents return 200. OpenAPI exposes 15 operations. | Healthy. Discovery responses alone do not validate trading or real Claude registration. | +| REST application onboarding | [Registration script](../scripts/register-api-client.ts) creates a random application key, writes it exclusively to an external file with mode 0600, and stores its SHA-256 hash. The [Dev page](../apps/web/src/app/dev/page.tsx) documents operator provisioning and no public signup endpoint. | Expected operator step remains undone for this teammate; missing-key rejection is correct. | +| OAuth versus REST credentials | [OAuth registration](../apps/relayer/src/oauth.ts) creates a client with a hash of a random value, discards that value, and returns only OAuth registration metadata. MCP uses the OAuth bearer token after wallet consent. | An OAuth client ID cannot be used as `X-Api-Key`, and the probe's discarded random value cannot be recovered. Use a separate REST client. | +| Registration metadata | Local HTTP probes: the minimal configured client receives 201; adding `scope`, `client_uri` or `software_version`, or omitting `client_name`, receives 400. The route uses a strict six-field Zod schema. | Confirmed compatibility problem. [RFC 7591 §2](https://www.rfc-editor.org/rfc/rfc7591.html#section-2) says unrecognized metadata must be ignored and describes `client_name` as optional. Validate supported security-sensitive fields and safely discard unsupported metadata. | +| Registration error format | Zod errors from `/oauth/register` use the shared `{error:{code:"INVALID_REQUEST",...}}` response. | Return OAuth registration error strings such as `invalid_client_metadata` with safe descriptions, as specified in [RFC 7591 §3.2.2](https://www.rfc-editor.org/rfc/rfc7591.html#section-3.2.2). Current generic errors obscure the failure. | +| Callback configuration | Production allows only `https://claude.ai/api/mcp/auth_callback`. `AUTH_ORIGIN` and `MCP_RESOURCE_URL` correctly identify the canonical host. A local probe using `https://claude.com/api/mcp/auth_callback` is rejected. | Exact redirect matching works. Capture the real Claude redirect list before changing the allowlist; there is no evidence here that Claude used the alternate URL. Referenced official support articles returned 404 during this investigation. | +| Failure logs | Production log searches over seven days for `/oauth/register` and the supplied `ofid_...` return no matches. A broad search returns recent traffic only. An explicit November 6, 02:00–04:00 UTC query is rejected by the log API with HTTP 400, so no original-period data was recovered. [Fastify configuration](../apps/relayer/src/server.ts) sets `logger:false`; expected validation failures also return before any error logging. | Observability gap. The supplied reference is not present in available logs. This does not prove the failed request never reached Roque. | +| Probe cleanup | Read-only database queries locate active client `8e137cc8-2513-45ab-9ae3-38ee4664b20a`, name `probe`, created `2026-11-06T02:43:08Z`, with the configured Claude callback. All checked dependencies are empty: sessions, challenges, API operations, OAuth requests/codes, quotes, plans and approval links. | It is an OAuth registration experiment, not a usable REST API credential. Recheck all dependencies immediately before deleting it. | + +## Next steps, in order + +1. **Unblock REST testing.** Run the existing operator registration script with private production database configuration, a meaningful teammate application name and an absolute credential-file path outside the repository. Use a private directory (0700); the script creates the file with mode 0600 and refuses to overwrite it. Verify the inserted client before handing the key file to the teammate through the team's secure transfer channel. Keep the key on their backend. Then complete wallet challenge/signature/session authentication: the key alone does not authorize wallet operations. +2. **Repair OAuth registration compatibility.** Support optional display names with a safe fallback, validate requested supported metadata/scopes, ignore unsupported metadata, and return RFC 7591 error responses. Retain exact redirect matching, S256 PKCE, resource audience binding, client isolation and explicit wallet consent. Add regression cases for the reproduced failures to the durable test suite. +3. **Make the Claude retry diagnosable.** Add structured, redacted logs for route, timestamp, request/trace ID, response status and a safe rejection reason. Never log full request bodies, bearer/API tokens, authorization codes, PKCE verifiers, connection strings or signed payloads. Capture the real Claude registration field names, requested callbacks, HTTP status and returned error while retrying; correlate with Vercel's request ID. The original `ofid_...` alone cannot establish the cause. +4. **Check the exact Claude request against configuration.** Confirm every callback in its registration request is an explicitly approved exact URL and its token authentication/grant choices match advertised support. Change production settings only when that evidence identifies a mismatch; deploy the verified registration fix and repeat the real Claude-account flow. +5. **Remove the probe.** Recheck the exact ID and name plus dependent records, then delete it if still unused. Prefer revocation if sessions or operations have appeared and preserve financial records. This investigation performed no client creation, credential issuance or deletion. +6. **Finish acceptance testing.** For REST: wallet proof → intent/poll → quote → prepare → reviewed wallet signing → submission → three-confirmation settlement. For Claude: registration → PKCE authorization/wallet consent → token exchange → MCP initialize/tools → approval URL → owner signing/status → revocation. Use a funded Sepolia test wallet and a small exact-input amount. Actual Claude certification remains pending. + +## Verification and scope + +- Existing focused tests: `/usr/bin/node node_modules/vitest/vitest.mjs run test/oauth-http.sql.test.ts test/public-api.sql.test.ts` from `packages/core`: **10 tests passed**. +- Six isolated PGlite-backed HTTP probes passed, confirming baseline registration, no REST-key disclosure, the four metadata/name rejection cases and alternate-callback rejection. These assert current behavior, including defects; they do not certify Claude compatibility. The temporary probe file was removed from the repository after execution. +- Only read-only production configuration/database inspection and public discovery/log requests were performed. No trading, signing, deployment, production setting changes, provisioning or deletion. +- Detailed private log and nonsecret query evidence is retained under `~/.local/state/roque-review-2026-11-10` (directory 0700, files 0600). Database credentials remained in memory and were not written to these artifacts. + +## Implementation follow-up — 2026-11-10 + +Implemented registration metadata stripping, optional display names, supported-scope validation/deduplication, default authorization limited to registered scopes, and rejection of scope elevation before consent. Registration validation, body parser, quota and service errors now use OAuth error strings. The Next adapter also handles oversized registration bodies with the same standard error shape. + +Added generated response `X-Request-Id` values and structured, redacted OAuth diagnostics. Raw automatic Fastify logging remains disabled intentionally; the new diagnostic records contain only controlled route/reason/field names, generated IDs, time, method and status. Headers, bodies, callback values, query strings, client names and raw error objects never enter these records. + +Verified 278 core tests across 23 files, including registration compatibility/security/redaction, oversized Next request handling, PKCE/revocation and the isolated SQL/EVM wallet settlement flow. All six workspace typechecks and three wallet dependency checks passed; final production build and rollout are tracked separately in the local recovery checklist. + +Provisioned REST client `teammate-api-test`, ID `393898c8-98ef-4609-86ce-9721904d2997`, with the five beta scopes and no browser origins or OAuth redirects. Its credential is kept exclusively in a private external file; no key is included in this document or Git. Live checks confirm the key is recognized while incorrect/missing keys receive 401. Deleted probe `8e137cc8-2513-45ab-9ae3-38ee4664b20a` with an exact ID/name guard and eight empty dependency checks; independent read confirms absence. + +No speculative callback URL was added. The original Claude failure remains unattributed; a real Claude-account retry is still required after deployment. Existing GitHub credentials returned 401, so publishing the verified source commit requires renewed GitHub authentication. diff --git a/docs/PUBLIC_API.md b/docs/PUBLIC_API.md index 116d158..8ae81a4 100644 --- a/docs/PUBLIC_API.md +++ b/docs/PUBLIC_API.md @@ -22,6 +22,10 @@ Connector URL: `https://roque-dex.vercel.app/mcp`. OAuth discovery is published Authorization code flow requires S256 PKCE, exact redirects, resource audience and explicit wallet consent at `/connect`. Tokens and codes are hashed in storage; codes are one-time and connections can be revoked. Access tokens expire after one hour; reconnect is required because refresh-token grants are not enabled. +`POST /oauth/register` requires `redirect_uris`; `client_name` is optional. Unsupported client metadata is ignored. An optional space-separated `scope` may select only supported scopes; omitted scope grants the five beta scopes. Authorization defaults to that client's registered scopes and rejects requests for additional permissions. Include `swaps:read` when requesting a subset for MCP, since connector access requires it. This beta supports only `authorization_code`, response type `code` and token authentication method `none`. Registration returns a client ID and granted scope, not a REST API key or client secret. Use the separate operator registration script to provision REST access. + +Registration errors use OAuth error strings such as `invalid_client_metadata` and `invalid_redirect_uri`, with a safe `error_description`. Responses include a generated `X-Request-Id` for support. Structured OAuth logs record the route template, timestamp, request ID, method, status and a safe rejection reason; validation diagnostics list only known field names. Request bodies, query values, client names, callback values, headers and raw errors are excluded. Keep the response request ID and failure time when retrying Claude so the operator can correlate Vercel logs. Callback changes require checking the actual client request against the operator's exact allowlist. + The five tools list tokens, queue an interpretation, prepare a supported trade, request an owner-bound approval URL and read swap status. Approval URLs expire with their plan, require fresh owner proof and become unavailable when their connection is revoked. Each trade requires a wallet signature on `/approve`. Raw commands may also be publicly visible in GenLayer transaction data; private HTTP history does not make on-chain prompts private. ## Workers and quotas diff --git a/packages/core/test/oauth-http.sql.test.ts b/packages/core/test/oauth-http.sql.test.ts index 35e5d92..4252800 100644 --- a/packages/core/test/oauth-http.sql.test.ts +++ b/packages/core/test/oauth-http.sql.test.ts @@ -1,9 +1,12 @@ import {PGlite} from '@electric-sql/pglite'; -import {beforeAll,afterAll,it,expect,vi} from 'vitest'; +import {beforeAll,afterAll,afterEach,it,expect,vi} from 'vitest'; import {createHash} from 'node:crypto'; import {privateKeyToAccount} from 'viem/accounts'; -const s=vi.hoisted(()=>({db:null as unknown as PGlite})); -vi.mock('../src/db/index.js',async original=>({...await original(),q:async(t:string,p:unknown[]=[])=> (await s.db.query(t,p)).rows})); +const s=vi.hoisted(()=>({db:null as unknown as PGlite,failRegistration:false})); +vi.mock('../src/db/index.js',async original=>({...await original(),q:async(t:string,p:unknown[]=[])=>{ + if(s.failRegistration&&t.startsWith('INSERT INTO api_clients'))throw new Error('do-not-log-database-credentials'); + return (await s.db.query(t,p)).rows; +}})); const {SCHEMA_SQL}=await import('../src/db/index.js'); const {migration0002}=await import('../src/db/migrations/0002-public-api.js'); const {createMcpServer}=await import('../../../apps/mcp/src/server.js'); @@ -11,6 +14,61 @@ let app:Awaited>; const wallet=privateKeyToAccount('0x59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d'); beforeAll(async()=>{s.db=new PGlite();await s.db.exec(SCHEMA_SQL+'\n'+migration0002);process.env.OAUTH_ALLOWED_REDIRECTS='https://claude.example/callback';app=await createMcpServer();},30000); afterAll(async()=>{await app.close();await s.db.close();}); +afterEach(()=>{s.failRegistration=false;vi.restoreAllMocks();}); +const callback='https://claude.example/callback'; +it('accepts optional names and extra metadata without leaking or granting it',async()=>{ + const reg=await app.inject({method:'POST',url:'/oauth/register',payload:{redirect_uris:[callback],scope:'swaps:read intents:read swaps:read',client_uri:'https://claude.example',software_version:'1.0',client_secret:'ignored-secret',unknown_metadata:{private:'ignored'}}}); + expect(reg.statusCode,reg.body).toBe(201); + const result=reg.json();expect(result.client_name).toBeTruthy();expect(result.scope).toBe('swaps:read intents:read'); + expect(result.client_secret).toBeUndefined();expect(result.api_key).toBeUndefined();expect(result.client_uri).toBeUndefined(); + const stored=await s.db.query<{scopes:string[];name:string}>('SELECT scopes,name FROM api_clients WHERE id=$1',[result.client_id]); + expect(stored.rows[0].scopes).toEqual(['swaps:read','intents:read']);expect(stored.rows[0].name).toBe(result.client_name); + const verifier='x'.repeat(64),challenge=createHash('sha256').update(verifier).digest('base64url'); + const query=new URLSearchParams({client_id:result.client_id,redirect_uri:callback,response_type:'code',code_challenge:challenge,code_challenge_method:'S256',state:'state-with-at-least-16-bytes',resource:'http://localhost:3000/mcp'}); + const auth=await app.inject({method:'GET',url:'/oauth/authorize?'+query});expect(auth.statusCode,auth.body).toBe(302); + const requestId=new URL(auth.headers.location!).searchParams.get('request'); + const pending=await s.db.query<{scope:string}>("SELECT query->>'scope' AS scope FROM oauth_requests WHERE id=$1",[requestId]);expect(pending.rows[0].scope).toBe(result.scope); + query.set('scope','swaps:read swaps:prepare');const escalated=await app.inject({method:'GET',url:'/oauth/authorize?'+query});expect(escalated.statusCode).toBe(400);expect(escalated.json().error).toBe('invalid_scope'); +}); +it('uses all beta scopes when scope is omitted and ignores unsupported metadata',async()=>{ + const reg=await app.inject({method:'POST',url:'/oauth/register',payload:{client_name:'Claude',redirect_uris:[callback],client_uri:'https://claude.example',logo_uri:'https://claude.example/logo.svg'}}); + expect(reg.statusCode,reg.body).toBe(201);expect(reg.json().scope.split(' ')).toEqual(['intents:write','intents:read','quotes:read','swaps:prepare','swaps:read']); +}); +it('returns standard registration errors and retains metadata and redirect restrictions',async()=>{ + // Use a separate IP so this validation matrix does not exhaust the registration quota. + for(const metadata of [{scope:'admin'},{scope:'swaps:read\nintents:read'},{scope:''},{client_name:''},{redirect_uris:[]},{grant_types:[]},{grant_types:['refresh_token']},{response_types:['token']},{token_endpoint_auth_method:'client_secret_basic'}]){ + const r=await app.inject({method:'POST',url:'/oauth/register',remoteAddress:'127.0.0.2',payload:{client_name:'Invalid',redirect_uris:[callback],...metadata}}); + expect(r.statusCode,r.body).toBe(400);expect(r.json().error).toBe('invalid_client_metadata');expect(r.json().error_description).toEqual(expect.any(String)); + } + const redirect=await app.inject({method:'POST',url:'/oauth/register',remoteAddress:'127.0.0.3',payload:{redirect_uris:[callback,'https://evil.example/callback']}}); + expect(redirect.statusCode).toBe(400);expect(redirect.json().error).toBe('invalid_redirect_uri'); + for(const payload of ['', '{']){ + const r=await app.inject({method:'POST',url:'/oauth/register',remoteAddress:'127.0.0.3',headers:{'content-type':'application/json'},payload}); + expect(r.statusCode).toBe(400);expect(r.json().error).toBe('invalid_client_metadata'); + } +}); +it('logs safe correlated registration diagnostics without request secrets or metadata values',async()=>{ + const log=vi.spyOn(console,'info').mockImplementation(()=>{}); + const secret='do-not-log-this-secret'; + const r=await app.inject({method:'POST',url:'/oauth/register?code='+secret,remoteAddress:'127.0.0.4',headers:{authorization:'Bearer '+secret,'x-api-key':secret,'x-request-id':secret},payload:{client_name:secret,redirect_uris:['https://evil.example/callback?token='+secret],client_secret:secret,code_verifier:secret,[secret]:secret}}); + expect(r.statusCode).toBe(400);expect(r.headers['x-request-id']).toMatch(/^[0-9a-f-]{36}$/u);expect(r.headers['x-request-id']).not.toBe(secret); + const output=log.mock.calls.flat().join('\n');expect(output).not.toContain(secret);expect(output).not.toContain('evil.example'); + const entry=log.mock.calls.flat().map(value=>JSON.parse(String(value))).find(value=>value.event==='oauth_request'); + expect(entry).toMatchObject({requestId:r.headers['x-request-id'],route:'/oauth/register',method:'POST',status:400,reason:'invalid_redirect_uri'});expect(entry.timestamp).toEqual(expect.any(String)); +}); +it('reports registration outages safely and generates distinct request IDs',async()=>{ + const log=vi.spyOn(console,'info').mockImplementation(()=>{});s.failRegistration=true; + const r=await app.inject({method:'POST',url:'/oauth/register',remoteAddress:'127.0.0.6',payload:{redirect_uris:[callback]}}); + expect(r.statusCode).toBe(500);expect(r.json()).toEqual({error:'server_error',error_description:'Registration service unavailable.'}); + expect(log.mock.calls.flat().join('\n')).not.toContain('do-not-log-database-credentials'); + expect(log.mock.calls.flat().map(value=>JSON.parse(String(value)))).toContainEqual(expect.objectContaining({status:500,reason:'internal_error',requestId:r.headers['x-request-id']})); + const health=await app.inject({method:'GET',url:'/v1/health'});expect(health.headers['x-request-id']).not.toBe(r.headers['x-request-id']); +}); +it('preserves the registration quota with a retryable OAuth error',async()=>{ + for(let n=0;n<10;n++)expect((await app.inject({method:'POST',url:'/oauth/register',remoteAddress:'127.0.0.5',payload:{redirect_uris:[callback]}})).statusCode).toBe(201); + const r=await app.inject({method:'POST',url:'/oauth/register',remoteAddress:'127.0.0.5',payload:{redirect_uris:[callback]}}); + expect(r.statusCode).toBe(429);expect(r.headers['retry-after']).toBe('60');expect(r.json().error).toBe('temporarily_unavailable'); +}); it('rejects unregistered redirects, malformed input and unconnected MCP access',async()=>{ const r=await app.inject({method:'POST',url:'/oauth/register',payload:{client_name:'Bad',redirect_uris:['https://evil.example']}});expect(r.statusCode).toBe(400); const invalid=await app.inject({method:'POST',url:'/v1/intents',payload:{bad:true}});expect(invalid.statusCode).toBe(401); diff --git a/packages/core/test/public-http-bridge.test.ts b/packages/core/test/public-http-bridge.test.ts new file mode 100644 index 0000000..30bef1e --- /dev/null +++ b/packages/core/test/public-http-bridge.test.ts @@ -0,0 +1,16 @@ +import {afterEach,it,expect,vi} from 'vitest'; +vi.mock('server-only',()=>({})); +vi.mock('next/server',()=>({after:vi.fn()})); +vi.mock('@roque/mcp',()=>({createMcpServer:vi.fn()})); +const {dispatchPublic}=await import('../../../apps/web/src/app/_public-api.js'); +afterEach(()=>vi.restoreAllMocks()); +it('rejects oversized OAuth registration before dispatch with a standard correlated, redacted error',async()=>{ + const log=vi.spyOn(console,'info').mockImplementation(()=>{}),secret='never-log-request-secrets'; + const request=new Request('https://roque.example/oauth/register?code='+secret,{method:'POST',headers:{authorization:'Bearer '+secret,'x-api-key':secret},body:secret.repeat(1000)}); + const response=await dispatchPublic(request); + expect(response.status).toBe(413);expect(await response.json()).toEqual({error:'invalid_client_metadata',error_description:'Registration body exceeds 16 KiB.'}); + expect(response.headers.get('x-request-id')).toMatch(/^[0-9a-f-]{36}$/u); + const output=log.mock.calls.flat().join('\n');expect(output).not.toContain(secret); + expect(JSON.parse(String(log.mock.calls[0][0]))).toMatchObject({requestId:response.headers.get('x-request-id'),route:'/oauth/register',status:413,reason:'invalid_client_metadata'}); + const {createMcpServer}=await import('@roque/mcp');expect(createMcpServer).not.toHaveBeenCalled(); +}); diff --git a/scripts/runner/README.md b/scripts/runner/README.md new file mode 100644 index 0000000..eec38f8 --- /dev/null +++ b/scripts/runner/README.md @@ -0,0 +1,79 @@ +Run from `~/rialo`: + +```bash +./roque_run --dry-run +./roque_run +./roque_run --max-txs 10 +``` + +`~/rialo/roque_run` links to the executable in this directory. It uses a separate, +pinned viem dependency; the first launch installs it with `npm ci` if needed. +It does not require the app, relayer, pnpm or development server to run locally. + +The default API is `https://roque-dex.vercel.app/api`. The wallet lives only in +`~/.config/roque-run/config.json`, with permissions `0600`. `--config PATH` or +`ROQUE_RUN_CONFIG` selects another file. Supported fields are `privateKey`, +`apiUrl`, `rpcUrl`, `latchRpcUrl` and `latchToken`. Environment overrides and all +CLI options are listed by `./roque_run --help`. + +The runner uses Autonomous Confirm and Direct exclusively. It reads current +wallet and unlocked vault balances, chooses funded vault +input tokens and different random output tokens, and sizes swaps between $0.25 +and $5, using at most 25% of a token's available balance. Random selection is +test coverage; requests identify themselves as `Roque-Testnet-Runner/1.0`. +Amounts, grant ceilings and token decimals are checked before sending. Existing +live grants and daily caps are honored; exhausted daily caps are not reset. +If necessary, it signs a bounded 24-hour capability and deposits a small part +of one wallet balance in the vault. Deposits stay in the vault between runs. +Approvals are bounded by the current wallet balance rather than unlimited. + +Every swap follows wallet authentication → interpret → execute. +Confirm and Direct are frontend behaviors using the same API: the runner's +Confirm adds an automated review/logging step, while Direct submits after +interpretation validation. This is API workload coverage, not a browser UI test. +Confirm and Direct are shuffled with equal weight. `--modes` can select either +or both; Copilot is disabled and explicit Copilot selections are rejected. +Small budgets or insufficient funds may not exercise both modes. + +Autonomous grants and swaps use the deployed app's relayer and its server-side +Latch configuration. This script cannot independently verify the hosted +relayer's Latch settings; check the Latch audit trail. Local Latch credentials +are not required. Funding an empty vault still needs owner-signed token approvals +and deposits, which use `rpcUrl` by default. Optional `latchRpcUrl` and `latchToken` +can route that setup through local Latch; `--require-wallet-latch` enforces this +optional setup routing. All swaps use the app relayer with the normal command: + +```bash +./roque_run +``` + +The hard limit is 100 Sepolia transaction submissions per invocation, including +setup, grants, approvals, deposits, reverts and uncertain sends. A smaller limit +can be selected with `--max-txs`. This can mean fewer completed swaps. GenLayer +interpretation transactions initiated internally by the app are outside this +Sepolia limit. Interpretation attempts are separately bounded (150 by default, +maximum 300). Runs are sequential and may take over an hour because each trade +waits for interpretation and a mined receipt. The runner stops after five +consecutive failed trade attempts. `--interval-ms` is fixed pacing, not a human +activity simulation. + +`--dry-run` only reads the app and chain and prints sample plans. It does not +authenticate, sign, interpret, deposit, grant or send transactions. It cannot +prove that the deployed interpreter, Latch proxy or relayer will execute a swap. + +Logs are private JSONL files under `~/.local/state/roque-run/runs/`. Each reserved +submission is recorded before sending. A per-wallet process lock prevents +overlapping local runs. Ctrl-C requests a stop after the current operation. +If interrupted after a submission, the next run checks its stored hash for a +receipt before continuing. For a relayer call whose response was lost before a +hash was returned, inspect app activity and the pending journal; do not resend +blindly. `--acknowledge-unresolved` clears the unresolved marker after you have +checked its outcome. Auth sessions, private keys, Latch credentials, signatures +and signed raw transactions are not logged. + +Validation: + +```bash +npm test --prefix scripts/runner +bash -n scripts/runner/roque_run +``` diff --git a/scripts/runner/main.mjs b/scripts/runner/main.mjs new file mode 100644 index 0000000..87b0b8e --- /dev/null +++ b/scripts/runner/main.mjs @@ -0,0 +1,240 @@ +import { readFileSync, writeFileSync, mkdirSync, openSync, closeSync, unlinkSync, existsSync, statSync, renameSync } from 'node:fs'; +import { homedir } from 'node:os'; +import { dirname, join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { parseArgs } from 'node:util'; +import { createPublicClient, createWalletClient, http } from 'viem'; +import { privateKeyToAccount } from 'viem/accounts'; +import { sepolia } from 'viem/chains'; +import { MODES, Runner, StopRun } from './runner.mjs'; + +const root = resolve(dirname(fileURLToPath(import.meta.url)), '../..'); +const defaultConfig = join(process.env.XDG_CONFIG_HOME || join(homedir(), '.config'), 'roque-run/config.json'); +const stateDir = join(process.env.XDG_STATE_HOME || join(homedir(), '.local/state'), 'roque-run'); +const help = `Usage: ./roque_run [options] + +Randomized Sepolia test swaps through https://roque-dex.vercel.app/api. +Defaults: up to 100 Sepolia submissions (setup included), $0.25-$5 per swap, +25% maximum of an available token balance, 1% slippage, 1s between attempts. +Autonomous Confirm and Direct are randomly interleaved with equal weight. + + --dry-run Read-only preflight and sample plans; no signing/sends + --max-txs N Submission budget, 1-100 (default 100) + --max-attempts N Bound interpretation attempts, 1-300 (default 150) + --min-usd N Minimum swap notional (default 0.25) + --max-usd N Maximum swap notional (default 5) + --slippage-bps N Slippage bound, 1-500 (default 100) + --interval-ms N Fixed pause between attempts, 0-60000 (default 1000) + --modes LIST autonomous-confirm,autonomous-direct + --api-url URL Full API base, including /api for the hosted app + --config PATH Config file (default ${defaultConfig}) + --require-wallet-latch Refuse wallet sends without local Latch credentials + --acknowledge-unresolved Clear a previous uncertain send AFTER checking chain + --help Show this help + +Config keys: privateKey, apiUrl, rpcUrl, latchRpcUrl, latchToken. +Environment overrides: ROQUE_PRIVATE_KEY, ROQUE_API_URL, SEPOLIA_RPC_URL, +LATCH_RPC_URL, LATCH_TOKEN, ROQUE_RUN_CONFIG. Credentials are never logged. +Ctrl-C stops after the current request/receipt. Logs: ${stateDir}/runs/ + +The limit covers Sepolia sends initiated by this runner, including relayer +grants, approvals, deposits, reverted sends and uncertain submissions. The app +also performs GenLayer interpretation transactions outside that Sepolia budget. +Confirm/Direct use the same API; Confirm adds a logged automated review step. +All swaps and capability grants use the deployed relayer's Latch settings. +Owner-signed vault approvals/deposits use the normal Sepolia RPC unless optional +local Latch credentials are configured. Local Latch credentials are not required. +`; + +export function optionsFrom(argv) { + const { values } = parseArgs({ args: argv, options: { + help: { type: 'boolean' }, 'dry-run': { type: 'boolean' }, + 'max-txs': { type: 'string' }, 'max-attempts': { type: 'string' }, + 'min-usd': { type: 'string' }, 'max-usd': { type: 'string' }, + 'slippage-bps': { type: 'string' }, 'interval-ms': { type: 'string' }, + modes: { type: 'string' }, 'api-url': { type: 'string' }, config: { type: 'string' }, + 'require-wallet-latch': { type: 'boolean' }, 'acknowledge-unresolved': { type: 'boolean' }, + }, strict: true }); + const number = (name, fallback, low, high, integer = false) => { + const n = Number(values[name] ?? fallback); + if (!Number.isFinite(n) || n < low || n > high || (integer && !Number.isInteger(n))) throw new Error(`--${name} must be ${integer ? 'an integer' : 'a number'} between ${low} and ${high}.`); + return n; + }; + const options = { + maxTxs: number('max-txs', 100, 1, 100, true), maxAttempts: number('max-attempts', 150, 1, 300, true), + minUsd: number('min-usd', 0.25, 0.000001, 10000), maxUsd: number('max-usd', 5, 0.000001, 10000), + slippageBps: number('slippage-bps', 100, 1, 500, true), intervalMs: number('interval-ms', 1000, 0, 60000, true), + modes: [...new Set((values.modes ?? MODES.join(',')).split(','))], + dryRun: values['dry-run'] ?? false, + }; + if (options.minUsd > options.maxUsd) throw new Error('--min-usd cannot exceed --max-usd.'); + if (!options.modes.length || options.modes.some(m => !MODES.includes(m))) throw new Error('Only autonomous-confirm and autonomous-direct are supported.'); + return { values, options }; +} + +function atomicJson(path, value) { + const temporary = `${path}.${process.pid}.tmp`; + writeFileSync(temporary, JSON.stringify(value, null, 2) + '\n', { mode: 0o600 }); + renameSync(temporary, path); +} + +export function makeApi(baseUrl, account, fetcher = fetch) { + const base = new URL(baseUrl); + if (base.protocol !== 'https:' && !(base.protocol === 'http:' && ['localhost', '127.0.0.1', '[::1]'].includes(base.hostname))) throw new Error('API URL must use HTTPS, or HTTP on localhost.'); + if (base.username || base.password || base.search || base.hash) throw new Error('API URL must not contain credentials, query parameters or a fragment.'); + let session; + const request = async (path, body, authenticated = false) => { + if (authenticated) await request.authenticate(); + let response; + try { + response = await fetcher(base.href.replace(/\/$/, '') + path, { + method: body === undefined ? 'GET' : 'POST', redirect: 'error', + headers: { 'content-type': 'application/json', 'user-agent': 'Roque-Testnet-Runner/1.0', + ...(authenticated ? { authorization: `Bearer ${session.token}` } : {}) }, + body: body === undefined ? undefined : JSON.stringify(body), signal: AbortSignal.timeout(300_000), + }); + } catch { + throw new Error(`API request ${path} failed or timed out; it was not retried.`); + } + if (!response.ok) throw new Error(`API ${path} returned HTTP ${response.status}; it was not retried.`); + try { return await response.json(); } catch { throw new Error(`API ${path} returned unreadable JSON.`); } + }; + request.authenticate = async () => { + if (session?.expiresAt > Date.now() / 1000 + 60) return; + const challenge = await request('/auth/challenge', { owner: account.address }); + if (!challenge.challengeId || typeof challenge.message !== 'string' || + !challenge.message.startsWith('Roque autonomous access\n') || + !challenge.message.toLowerCase().includes(`wallet: ${account.address.toLowerCase()}`) || + !challenge.message.includes('Chain ID: 11155111') || challenge.expiresAt <= Date.now() / 1000) { + throw new Error('App returned an unexpected wallet authentication challenge.'); + } + const signature = await account.signMessage({ message: challenge.message }); + session = await request('/auth/session', { owner: account.address, challengeId: challenge.challengeId, signature }); + if (!session.token || session.owner?.toLowerCase() !== account.address.toLowerCase() || !(session.expiresAt > Date.now() / 1000)) throw new Error('Invalid wallet authentication session.'); + }; + return request; +} + +async function main() { + const { values, options } = optionsFrom(process.argv.slice(2)); + if (values.help) { process.stdout.write(help); return; } + const configPath = resolve(values.config || process.env.ROQUE_RUN_CONFIG || defaultConfig); + if (existsSync(configPath) && (statSync(configPath).mode & 0o077)) throw new Error(`Config permissions are too open. Run: chmod 600 '${configPath}'`); + const saved = existsSync(configPath) ? JSON.parse(readFileSync(configPath, 'utf8')) : {}; + const config = { + privateKey: process.env.ROQUE_PRIVATE_KEY || saved.privateKey, + apiUrl: values['api-url'] || process.env.ROQUE_API_URL || saved.apiUrl || 'https://roque-dex.vercel.app/api', + rpcUrl: process.env.SEPOLIA_RPC_URL || saved.rpcUrl || 'https://ethereum-sepolia-rpc.publicnode.com', + latchRpcUrl: process.env.LATCH_RPC_URL || saved.latchRpcUrl || '', + latchToken: process.env.LATCH_TOKEN || saved.latchToken || '', + }; + if (!/^(0x)?[\da-f]{64}$/i.test(config.privateKey ?? '')) throw new Error(`Set privateKey in ${configPath}, or ROQUE_PRIVATE_KEY.`); + if (!!config.latchRpcUrl !== !!config.latchToken) throw new Error('Configure both Latch RPC URL and token, or neither.'); + if (values['require-wallet-latch'] && !config.latchRpcUrl) throw new Error('Local Latch credentials are required by --require-wallet-latch.'); + for (const value of [config.rpcUrl, config.latchRpcUrl].filter(Boolean)) { + const url = new URL(value); + if (url.protocol !== 'https:' && !(url.protocol === 'http:' && ['localhost', '127.0.0.1', '[::1]'].includes(url.hostname))) throw new Error('RPC URLs must use HTTPS or localhost HTTP.'); + } + let account; + try { account = privateKeyToAccount(`0x${config.privateKey.replace(/^0x/i, '')}`); } + catch { throw new Error('The configured private key is invalid.'); } + const json = path => JSON.parse(readFileSync(join(root, 'packages/shared/src', path), 'utf8')); + const deployment = json('deployment.json'); + const tokens = deployment.tokenSymbols.map((symbol, i) => ({ symbol, address: deployment.tokenAddresses[i], decimals: deployment.tokenDecimals[i] })); + const abis = { token: json('abis/TestToken.json'), executor: json('abis/AgentExecutor.json') }; + const transport = () => http(config.rpcUrl, { timeout: 30_000, retryCount: 1 }); + const publicClient = createPublicClient({ chain: sepolia, transport: transport() }); + const wallet = createWalletClient({ account, chain: sepolia, transport: transport() }); + const writeClient = createPublicClient({ chain: sepolia, transport: http(config.latchRpcUrl || config.rpcUrl, { + timeout: 30_000, retryCount: 0, + ...(config.latchRpcUrl ? { fetchOptions: { headers: { authorization: `Bearer ${config.latchToken}` } } } : {}), + }) }); + const api = makeApi(config.apiUrl, account); + mkdirSync(join(stateDir, 'runs'), { recursive: true, mode: 0o700 }); + const walletStateDir = join(stateDir, account.address.toLowerCase()); + mkdirSync(walletStateDir, { recursive: true, mode: 0o700 }); + const lockPath = join(walletStateDir, 'run.lock'); + const pendingPath = join(walletStateDir, 'pending.json'); + if (existsSync(lockPath)) { + const owner = Number(readFileSync(lockPath, 'utf8')); + if (!Number.isInteger(owner) || owner <= 0) throw new Error(`Invalid lock file: ${lockPath}`); + try { process.kill(owner, 0); throw new Error(`Runner PID ${owner} already holds the wallet lock.`); } + catch (error) { if (error.code !== 'ESRCH') throw error; } + unlinkSync(lockPath); + } + const lock = openSync(lockPath, 'wx', 0o600); + writeFileSync(lock, String(process.pid)); + closeSync(lock); + let logFd; + let runner; + let signalHandler; + let journalSafe = true; + let pending; + const logPath = join(stateDir, 'runs', `${new Date().toISOString().replace(/[:.]/g, '-')}-${process.pid}.jsonl`); + try { + if (existsSync(pendingPath)) { + pending = JSON.parse(readFileSync(pendingPath, 'utf8')); + if (!options.dryRun) { + const receipt = pending.hash ? await publicClient.getTransactionReceipt({ hash: pending.hash }).catch(() => null) : null; + if (receipt || values['acknowledge-unresolved']) { + unlinkSync(pendingPath); + pending = undefined; + } else throw new Error(`Previous submission is unresolved (${pending.hash || pending.label}). Check ${pendingPath} and app activity. Use --acknowledge-unresolved only after checking its outcome.`); + } + } + logFd = openSync(logPath, 'ax', 0o600); + const log = (event, details = {}) => { + try { + if (!options.dryRun && event === 'reserved') { + pending = { ...details, logPath, time: new Date().toISOString() }; + atomicJson(pendingPath, pending); + } + if (!options.dryRun && event === 'submitted' && pending) { + pending = { ...pending, hash: details.hash }; + atomicJson(pendingPath, pending); + } + writeFileSync(logFd, JSON.stringify({ time: new Date().toISOString(), event, ...details }) + '\n'); + if (!options.dryRun && event === 'receipt' && existsSync(pendingPath)) { + unlinkSync(pendingPath); + pending = undefined; + } + } catch { + journalSafe = false; + throw new StopRun('Cannot persist the transaction journal. Stopping sends.'); + } + if (['submitted', 'trade-complete', 'trade-skipped', 'dry-run-plan', 'preflight', 'confirmed', 'stopped'].includes(event)) { + process.stdout.write(`${event}: ${JSON.stringify(details)}\n`); + } + }; + const pause = async ms => { for (let elapsed = 0; elapsed < ms && !runner?.stopping; elapsed += 200) await new Promise(resolve => setTimeout(resolve, Math.min(200, ms - elapsed))); }; + runner = new Runner({ publicClient, wallet, writeClient, account, api, tokens, deployment, abis, chain: sepolia, config, options, log, pause }); + signalHandler = () => { runner.stopping = true; process.stdout.write('Stopping after the current request/receipt...\n'); }; + process.on('SIGINT', signalHandler); + process.on('SIGTERM', signalHandler); + log('run-start', { wallet: account.address, api: config.apiUrl, maxTxs: options.maxTxs, dryRun: options.dryRun, walletRoute: config.latchRpcUrl ? 'latch' : 'rpc', modes: options.modes }); + process.stdout.write(`Roque testnet automation | wallet ${account.address} | max ${options.maxTxs} Sepolia submissions\nLog: ${logPath}\n`); + if (!config.latchRpcUrl) process.stdout.write('Autonomous swaps/grants: deployed app relayer/Latch configuration. Vault setup only: wallet via standard RPC.\n'); + try { await runner.run(); } + catch (error) { + const message = error instanceof StopRun ? error.message : (error.shortMessage || 'Preflight failed. Check app connectivity, RPC connectivity and wallet funding.'); + if (journalSafe) log('stopped', { message }); + else process.stderr.write(message + '\n'); + if (pending || !(error instanceof StopRun) || (runner.budget.used < options.maxTxs && !runner.stopping)) process.exitCode = 1; + } + const summary = { budgetUsed: runner.budget.used, completedSwaps: runner.completed, skippedAttempts: runner.failed, modes: runner.modeCounts, unresolvedSubmission: !!pending }; + if (journalSafe) log('summary', summary); + process.stdout.write(`Finished: ${JSON.stringify(summary)}\n`); + } finally { + if (signalHandler) { process.off('SIGINT', signalHandler); process.off('SIGTERM', signalHandler); } + if (logFd !== undefined) closeSync(logFd); + unlinkSync(lockPath); + } +} + +if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + main().catch(error => { + // Never print transport exceptions, config objects, signatures or raw txs. + process.stderr.write(`roque_run: ${error.shortMessage || error.message}\n`); + process.exitCode = 1; + }); +} diff --git a/scripts/runner/package-lock.json b/scripts/runner/package-lock.json new file mode 100644 index 0000000..0a45c65 --- /dev/null +++ b/scripts/runner/package-lock.json @@ -0,0 +1,220 @@ +{ + "name": "roque-testnet-runner", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "roque-testnet-runner", + "dependencies": { + "viem": "2.55.13" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@adraffy/ens-normalize": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@adraffy/ens-normalize/-/ens-normalize-1.11.1.tgz", + "integrity": "sha512-nhCBV3quEgesuf7c7KYfperqSS14T8bYuvJ8PcLJp6znkZpFc0AuW4qBtr8eKVyPPe/8RSr7sglCWPU5eaxwKQ==", + "license": "MIT" + }, + "node_modules/@noble/ciphers": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-1.3.0.tgz", + "integrity": "sha512-2I0gnIVPtfnMw9ee9h1dJG7tp81+8Ob3OJb3Mv37rx5L40/b0i7djjCVvGOVqc9AEIQyvyu1i6ypKdFw8R8gQw==", + "license": "MIT", + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@noble/curves": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.9.1.tgz", + "integrity": "sha512-k11yZxZg+t+gWvBbIswW0yoJlu8cHOC7dhunwOzoWH/mXGBiYyR4YY6hAEK/3EUs4UpB8la1RfdRpeGsFHkWsA==", + "license": "MIT", + "dependencies": { + "@noble/hashes": "1.8.0" + }, + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@noble/hashes": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz", + "integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==", + "license": "MIT", + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@scure/base": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/@scure/base/-/base-1.2.6.tgz", + "integrity": "sha512-g/nm5FgUa//MCj1gV09zTJTaM6KBAHqLN907YVQqf7zC49+DcO4B1so4ZX07Ef10Twr6nuqYEH9GEggFXA4Fmg==", + "license": "MIT", + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@scure/bip32": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/@scure/bip32/-/bip32-1.7.0.tgz", + "integrity": "sha512-E4FFX/N3f4B80AKWp5dP6ow+flD1LQZo/w8UnLGYZO674jS6YnYeepycOOksv+vLPSpgN35wgKgy+ybfTb2SMw==", + "license": "MIT", + "dependencies": { + "@noble/curves": "~1.9.0", + "@noble/hashes": "~1.8.0", + "@scure/base": "~1.2.5" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@scure/bip39": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@scure/bip39/-/bip39-1.6.0.tgz", + "integrity": "sha512-+lF0BbLiJNwVlev4eKelw1WWLaiKXw7sSl8T6FvBlWkdX+94aGJ4o8XjUdlyhTCjd8c+B3KT3JfS8P0bLRNU6A==", + "license": "MIT", + "dependencies": { + "@noble/hashes": "~1.8.0", + "@scure/base": "~1.2.5" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/abitype": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/abitype/-/abitype-1.2.3.tgz", + "integrity": "sha512-Ofer5QUnuUdTFsBRwARMoWKOH1ND5ehwYhJ3OJ/BQO+StkwQjHw0XyVh4vDttzHB7QOFhPHa/o413PJ82gU/Tg==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/wevm" + }, + "peerDependencies": { + "typescript": ">=5.0.4", + "zod": "^3.22.0 || ^4.0.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + }, + "zod": { + "optional": true + } + } + }, + "node_modules/eventemitter3": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.1.tgz", + "integrity": "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==", + "license": "MIT" + }, + "node_modules/isows": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/isows/-/isows-1.0.7.tgz", + "integrity": "sha512-I1fSfDCZL5P0v33sVqeTDSpcstAg/N+wF5HS033mogOVIp4B+oHC7oOCsA3axAbBSGTJ8QubbNmnIRN/h8U7hg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/wevm" + } + ], + "license": "MIT", + "peerDependencies": { + "ws": "*" + } + }, + "node_modules/ox": { + "version": "0.14.33", + "resolved": "https://registry.npmjs.org/ox/-/ox-0.14.33.tgz", + "integrity": "sha512-rooA/4o7bBof4Ge2VH/eovfNPb/AEEYyrNj03wggc55g5HZD8Pjs/OeWhttgjic3dDcqn0r29bDuvQEdTiUemQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/wevm" + } + ], + "license": "MIT", + "dependencies": { + "@adraffy/ens-normalize": "^1.11.0", + "@noble/ciphers": "^1.3.0", + "@noble/curves": "1.9.1", + "@noble/hashes": "^1.8.0", + "@scure/bip32": "^1.7.0", + "@scure/bip39": "^1.6.0", + "abitype": "^1.2.3", + "eventemitter3": "5.0.1" + }, + "peerDependencies": { + "typescript": ">=5.4.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/viem": { + "version": "2.55.13", + "resolved": "https://registry.npmjs.org/viem/-/viem-2.55.13.tgz", + "integrity": "sha512-Rt1NAsdtTdvJgqaCxsv2TuO55xv2d2dKEuRuzrg34xMGxvTSFOX0iIFvEfymPvh+fwN2tbgEU2JwN0YHOVM+Bg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/wevm" + } + ], + "license": "MIT", + "dependencies": { + "@noble/curves": "1.9.1", + "@noble/hashes": "1.8.0", + "@scure/bip32": "1.7.0", + "@scure/bip39": "1.6.0", + "abitype": "1.2.3", + "isows": "1.0.7", + "ox": "0.14.33", + "ws": "8.21.0" + }, + "peerDependencies": { + "typescript": ">=5.0.4" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/ws": { + "version": "8.21.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz", + "integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + } + } +} diff --git a/scripts/runner/package.json b/scripts/runner/package.json new file mode 100644 index 0000000..fbc8c92 --- /dev/null +++ b/scripts/runner/package.json @@ -0,0 +1,8 @@ +{ + "name": "roque-testnet-runner", + "private": true, + "type": "module", + "engines": { "node": ">=20" }, + "scripts": { "test": "node --test runner.test.mjs" }, + "dependencies": { "viem": "2.55.13" } +} diff --git a/scripts/runner/roque_run b/scripts/runner/roque_run new file mode 100755 index 0000000..37f1fe3 --- /dev/null +++ b/scripts/runner/roque_run @@ -0,0 +1,8 @@ +#!/usr/bin/env bash +set -euo pipefail +runner_dir="$(dirname -- "$(readlink -f -- "${BASH_SOURCE[0]}")")" +if [[ ! -d "$runner_dir/node_modules/viem" ]]; then + echo "Installing the runner's pinned dependency..." >&2 + npm ci --prefix "$runner_dir" --ignore-scripts --no-audit --no-fund >&2 +fi +exec node "$runner_dir/main.mjs" "$@" diff --git a/scripts/runner/runner.mjs b/scripts/runner/runner.mjs new file mode 100644 index 0000000..a4cb4ae --- /dev/null +++ b/scripts/runner/runner.mjs @@ -0,0 +1,286 @@ +import { randomInt } from 'node:crypto'; +import { encodeFunctionData, formatUnits, parseUnits, keccak256 } from 'viem'; + +export const MODES = ['autonomous-confirm', 'autonomous-direct']; +export class StopRun extends Error {} +export class SkipTrade extends Error {} +export const min = (...values) => values.reduce((a, b) => a < b ? a : b); + +// All spend arithmetic stays in integers. Leave a margin below grant ceilings. +export function amountRange(balance, decimals, price, minUsd, maxUsd, ceilingUsd = maxUsd) { + if (!(price > 0) || !Number.isFinite(price)) return null; + const scale = 10n ** BigInt(decimals); + const priceRaw = parseUnits(price.toFixed(8), 8); + if (!priceRaw) return null; + const unitsFor = (value, roundUp = false) => { + const numerator = parseUnits(value.toFixed(8), 8) * scale; + return (numerator + (roundUp ? priceRaw - 1n : 0n)) / priceRaw; + }; + const low = unitsFor(minUsd, true) || 1n; + const high = min(balance / 4n, unitsFor(maxUsd), unitsFor(ceilingUsd)); + return high >= low ? { low, high } : null; +} + +export function randomAmount(range, rng = randomInt) { + return range.low + (range.high - range.low) * BigInt(rng(0, 1_000_001)) / 1_000_000n; +} + +export class Budget { + constructor(limit, log) { + if (!Number.isInteger(limit) || limit < 1 || limit > 100) throw new Error('max-txs must be between 1 and 100.'); + this.limit = limit; + this.used = 0; + this.log = log; + } + require(slots = 1) { + if (this.used + slots > this.limit) throw new StopRun('Transaction limit reached.'); + } + reserve(label, details = {}) { + this.require(); + // Persist before making a request: a lost response can still mean a send. + this.log('reserved', { number: this.used + 1, label, ...details }); + this.used++; + } +} + +export function assertInterpretation(result, plan) { + const i = result?.interpretation; + if (!result?.id || !i?.ok || i.kind !== 'swap' || i.amountIsPercent || + i.tokenIn !== plan.from.symbol || i.tokenOut !== plan.to.symbol) { + throw new SkipTrade('Interpreter did not return the requested exact-input market swap.'); + } + if (!/^\d+(\.\d+)?$/.test(i.amount) || parseUnits(i.amount, plan.from.decimals) !== plan.raw) { + throw new SkipTrade('Interpreter changed the requested amount.'); + } +} + +export class Runner { + constructor(deps) { + Object.assign(this, deps); + if (!this.options.modes.length || this.options.modes.some(mode => !MODES.includes(mode))) { + throw new StopRun('Only autonomous-confirm and autonomous-direct are supported.'); + } + this.budget = new Budget(this.options.maxTxs, this.log); + this.completed = 0; + this.failed = 0; + this.modeCounts = Object.fromEntries(MODES.map(m => [m, 0])); + this.stopping = false; + } + guard() { + if (this.stopping) throw new StopRun('Stopped by signal.'); + } + async read(address, abi, functionName, args) { + return this.publicClient.readContract({ address, abi, functionName, args }); + } + async snapshot() { + const contracts = this.tokens.flatMap(t => [ + { address: t.address, abi: this.abis.token, functionName: 'balanceOf', args: [this.account.address] }, + { address: this.deployment.agentExecutor, abi: this.abis.executor, functionName: 'availableBalance', args: [this.account.address, t.address] }, + ]); + const [raws, price] = await Promise.all([ + this.publicClient.multicall({ contracts, allowFailure: false }), + this.api('/price'), + ]); + return this.tokens.map((token, i) => ({ ...token, wallet: BigInt(raws[i * 2]), vault: BigInt(raws[i * 2 + 1]), price: Number(price.prices[token.symbol]) })); + } + async capability() { + const user = this.account.address; + const [cap, remaining, nonce] = await Promise.all([ + this.read(this.deployment.agentExecutor, this.abis.executor, 'getCapability', [user]), + this.read(this.deployment.agentExecutor, this.abis.executor, 'remainingDailyUsd', [user]), + this.read(this.deployment.agentExecutor, this.abis.executor, 'grantNonce', [user]), + ]); + return { ...cap, remaining, nonce }; + } + async receipt(hash, label) { + let receipt; + try { + receipt = await this.publicClient.waitForTransactionReceipt({ hash, timeout: 180_000, pollingInterval: 3000 }); + } catch { + throw new StopRun(`Receipt unresolved for ${label}: ${hash}. Check the log and chain before rerunning.`); + } + this.log('receipt', { label, hash, status: receipt.status, block: String(receipt.blockNumber) }); + if (receipt.status !== 'success') throw new SkipTrade(`${label} reverted (${hash}).`); + } + async localWrite(label, address, abi, functionName, args) { + this.guard(); + this.budget.require(); + const data = encodeFunctionData({ abi, functionName, args }); + // Prepare and estimate through the read RPC; only the signed send uses Latch. + const gas = await this.publicClient.estimateGas({ account: this.account.address, to: address, data }); + const nonce = await this.publicClient.getTransactionCount({ address: this.account.address, blockTag: 'pending' }); + const request = await this.wallet.prepareTransactionRequest({ account: this.account, chain: this.chain, to: address, data, nonce, gas: gas + gas / 5n }); + const native = await this.publicClient.getBalance({ address: this.account.address }); + if (native < request.gas * (request.maxFeePerGas ?? request.gasPrice)) throw new StopRun('Wallet needs Sepolia ETH for gas.'); + const signed = await this.wallet.signTransaction(request); + const hash = keccak256(signed); + this.guard(); + this.budget.reserve(label, { hash, sender: this.account.address, nonce }); + try { + const returned = await this.writeClient.sendRawTransaction({ serializedTransaction: signed }); + if (returned.toLowerCase() !== hash.toLowerCase()) throw new Error('RPC returned a different transaction hash.'); + } catch { + throw new StopRun(`Send outcome uncertain for ${label}: ${hash}. No replacement or retry was sent.`); + } + this.log('submitted', { label, hash, route: this.config.latchRpcUrl ? 'latch' : 'wallet-rpc' }); + await this.receipt(hash, label); + return hash; + } + async remoteWrite(path, body, label) { + await this.api.authenticate(); + this.guard(); + this.budget.reserve(label, { intentId: body.id }); + let result; + try { + result = await this.api(path, body, true); + } catch { + // A server can submit before its HTTP response is lost. Never retry a write. + throw new StopRun(`${label} did not return a confirmed outcome. Inspect app activity and this run's log before rerunning.`); + } + if (!/^0x[\da-f]{64}$/i.test(result.txHash ?? '')) throw new StopRun(`${label} returned no transaction hash.`); + this.log('submitted', { label, hash: result.txHash, intentId: body.id, route: 'app-relayer' }); + await this.receipt(result.txHash, label); + return result.txHash; + } + async approve(token, spender, needed, reserveFor) { + const allowed = await this.read(token.address, this.abis.token, 'allowance', [this.account.address, spender]); + if (allowed >= needed) return; + this.budget.require(1 + reserveFor); + // A bounded allowance up to the current wallet balance, never unlimited. + const limit = min(token.wallet, needed * BigInt(this.options.maxTxs)); + await this.localWrite(`approve ${token.symbol}`, token.address, this.abis.token, 'approve', [spender, limit]); + } + async ensureGrant() { + const cap = await this.capability(); + const valid = !cap.revoked && Number(cap.validUntil) > Date.now() / 1000 + 600 && + cap.agentSigner.toLowerCase() === this.agentSigner.toLowerCase() && + cap.maxPerTradeUsd > 0n && cap.maxSlippageBps >= BigInt(this.options.slippageBps); + // Preserve existing live caps, including an exhausted daily allowance. + if (valid) return; + this.budget.require(2); // Leave room for at least one trade. + const message = { + user: this.account.address, agentSigner: this.agentSigner, + maxPerTradeUsd: parseUnits(String(this.options.maxUsd), 18), + maxDailyUsd: parseUnits(String(this.options.maxUsd), 18) * BigInt(this.options.maxTxs), + maxSlippageBps: BigInt(this.options.slippageBps), + validUntil: BigInt(Math.floor(Date.now() / 1000) + 86400), grantNonce: cap.nonce, + }; + const signature = await this.account.signTypedData({ + domain: { name: 'RoqueAgentExecutor', version: '1', chainId: 11155111, verifyingContract: this.deployment.agentExecutor }, + primaryType: 'Grant', types: { Grant: [ + { name: 'user', type: 'address' }, { name: 'agentSigner', type: 'address' }, + { name: 'maxPerTradeUsd', type: 'uint256' }, { name: 'maxDailyUsd', type: 'uint256' }, + { name: 'maxSlippageBps', type: 'uint256' }, { name: 'validUntil', type: 'uint64' }, + { name: 'grantNonce', type: 'uint256' }, + ] }, message, + }); + const body = Object.fromEntries(Object.entries(message).filter(([key]) => key !== 'grantNonce').map(([key, value]) => [key, String(value)])); + await this.remoteWrite('/grant', { ...body, signature }, 'grant capability'); + } + async bootstrapVault(snapshot) { + if (snapshot.some(t => amountRange(t.vault, t.decimals, t.price, this.options.minUsd, this.options.maxUsd))) return false; + const candidates = snapshot.filter(t => amountRange(t.wallet, t.decimals, t.price, this.options.minUsd * 8, this.options.maxUsd * 10)); + if (!candidates.length) return false; + this.budget.require(3); // Deposit, possible approval, and a trade. + const token = candidates[randomInt(candidates.length)]; + const range = amountRange(token.wallet, token.decimals, token.price, this.options.minUsd * 8, this.options.maxUsd * 10); + const amount = range.high; + await this.approve(token, this.deployment.agentExecutor, amount, 2); + await this.localWrite(`deposit ${token.symbol}`, this.deployment.agentExecutor, this.abis.executor, 'deposit', [token.address, amount]); + return true; + } + choosePlan(mode, snapshot, cap) { + if (!MODES.includes(mode)) throw new StopRun('Only autonomous-confirm and autonomous-direct are supported.'); + const ceiling = Math.min(Number(formatUnits(cap.maxPerTradeUsd, 18)), Number(formatUnits(cap.remaining, 18))) * 0.95; + const choices = snapshot.flatMap(token => { + const range = amountRange(token.vault, token.decimals, token.price, this.options.minUsd, this.options.maxUsd, ceiling); + return range ? [{ token, range }] : []; + }); + if (!choices.length) return null; + const { token: from, range } = choices[randomInt(choices.length)]; + const others = snapshot.filter(t => t.symbol !== from.symbol); + const to = others[randomInt(others.length)]; + const raw = randomAmount(range); + return { mode, from, to, raw, amount: formatUnits(raw, from.decimals) }; + } + async trade(plan) { + if (!MODES.includes(plan.mode)) throw new StopRun('Only autonomous-confirm and autonomous-direct are supported.'); + const commands = [ + `Swap ${plan.amount} ${plan.from.symbol} for ${plan.to.symbol}`, + `Exchange ${plan.amount} ${plan.from.symbol} into ${plan.to.symbol}`, + `Sell ${plan.amount} ${plan.from.symbol} for ${plan.to.symbol} now`, + ]; + this.guard(); + const command = commands[randomInt(commands.length)]; + this.log('intent-request', { mode: plan.mode, command }); + const result = await this.api('/interpret', { command, mode: 'autonomous', user: this.account.address }, true); + this.guard(); + this.log('interpreted', { id: result.id, mode: plan.mode }); + assertInterpretation(result, plan); + // Confirm models the frontend's reviewed-card path. Both modes validate the + // interpretation before sending; Direct calls execute immediately after it. + if (plan.mode === 'autonomous-confirm') this.log('confirmed', { id: result.id, amount: plan.amount, from: plan.from.symbol, to: plan.to.symbol }); + const hash = await this.remoteWrite('/execute', { id: result.id, user: this.account.address, slippageBps: this.options.slippageBps }, plan.mode); + this.completed++; + this.modeCounts[plan.mode]++; + this.log('trade-complete', { mode: plan.mode, hash, amount: plan.amount, from: plan.from.symbol, to: plan.to.symbol }); + } + async run() { + if (await this.publicClient.getChainId() !== 11155111 || Number(this.deployment.chainId) !== 11155111) throw new StopRun('Only Ethereum Sepolia (11155111) is supported.'); + const agent = await this.api('/agent'); + this.agentSigner = agent.agentSigner; + if (this.agentSigner?.toLowerCase() !== this.deployment.agentSigner.toLowerCase()) throw new StopRun('Deployed app agent signer differs from the local deployment.'); + for (const address of [this.deployment.router, this.deployment.agentExecutor]) { + const code = await this.publicClient.getCode({ address }); + if (!code || code === '0x') throw new StopRun(`No deployed contract at ${address}.`); + } + const pending = await this.publicClient.getTransactionCount({ address: this.account.address, blockTag: 'pending' }); + const latest = await this.publicClient.getTransactionCount({ address: this.account.address, blockTag: 'latest' }); + if (pending !== latest) throw new StopRun('This wallet has pending transactions. Wait for them before running.'); + const initial = await this.snapshot(); + const native = await this.publicClient.getBalance({ address: this.account.address }); + this.log('preflight', { wallet: this.account.address, gasEth: formatUnits(native, 18), tokens: initial.map(t => ({ symbol: t.symbol, wallet: formatUnits(t.wallet, t.decimals), vaultFree: formatUnits(t.vault, t.decimals) })) }); + if (this.options.dryRun) { + const cap = await this.capability(); + for (const mode of this.options.modes) { + const plan = this.choosePlan(mode, initial, cap); + this.log('dry-run-plan', plan ? { mode, from: plan.from.symbol, to: plan.to.symbol, amount: plan.amount } : { mode, setupNeeded: true }); + } + return; + } + await this.ensureGrant(); + const cap = await this.capability(); + if (cap.remaining >= parseUnits(String(this.options.minUsd), 18)) await this.bootstrapVault(initial); + let failures = 0; + let bag = []; + for (let attempt = 0; attempt < this.options.maxAttempts; attempt++) { + this.guard(); + this.budget.require(); + const [snapshot, cap] = await Promise.all([this.snapshot(), this.capability()]); + if (!bag.length) bag = [...this.options.modes]; + const plans = bag.map(mode => this.choosePlan(mode, snapshot, cap)); + const eligible = plans.map((p, i) => p ? i : -1).filter(i => i >= 0); + if (!eligible.length) { + // Some modes may have run out of funds while others remain available. + const alternatives = this.options.modes.map(mode => this.choosePlan(mode, snapshot, cap)).filter(Boolean); + if (!alternatives.length) throw new StopRun('No eligible funded swaps remain within the configured sizes and daily cap.'); + bag = alternatives.map(p => p.mode); + continue; + } + const index = eligible[randomInt(eligible.length)]; + const plan = plans[index]; + bag.splice(index, 1); + try { + await this.trade(plan); + failures = 0; + } catch (error) { + if (error instanceof StopRun) throw error; + this.failed++; + this.log('trade-skipped', { mode: plan.mode, message: error.shortMessage ?? error.message }); + if (++failures >= 5) throw new StopRun('Stopped after five consecutive unsuccessful trades.'); + } + await this.pause(this.options.intervalMs); + } + this.log('attempt-limit', { limit: this.options.maxAttempts }); + } +} diff --git a/scripts/runner/runner.test.mjs b/scripts/runner/runner.test.mjs new file mode 100644 index 0000000..31ce4a0 --- /dev/null +++ b/scripts/runner/runner.test.mjs @@ -0,0 +1,356 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { Budget, Runner, StopRun, SkipTrade, amountRange, randomAmount, assertInterpretation } from './runner.mjs'; +import { makeApi, optionsFrom } from './main.mjs'; +import { keccak256, parseUnits, parseAbi, createWalletClient, custom, parseTransaction, recoverTransactionAddress, recoverTypedDataAddress } from 'viem'; +import { privateKeyToAccount } from 'viem/accounts'; +import { sepolia } from 'viem/chains'; + +const user = '0x1111111111111111111111111111111111111111'; +const agent = '0x2222222222222222222222222222222222222222'; +const router = '0x3333333333333333333333333333333333333333'; +const executor = '0x4444444444444444444444444444444444444444'; +const tokens = [ + { symbol: 'rUSDC', address: '0x5555555555555555555555555555555555555555', decimals: 6 }, + { symbol: 'rWETH', address: '0x6666666666666666666666666666666666666666', decimals: 18 }, +]; +const hash = `0x${'ab'.repeat(32)}`; +const options = optionsFrom([]).options; +const cap = { agentSigner: agent, revoked: false, validUntil: BigInt(Math.floor(Date.now() / 1000) + 86400), maxPerTradeUsd: parseUnits('5', 18), maxDailyUsd: parseUnits('500', 18), remaining: parseUnits('500', 18), maxSlippageBps: 100n, nonce: 0n }; + +function fixture(overrides = {}) { + const events = []; + const calls = []; + const publicClient = { + getChainId: async () => 11155111, + getCode: async () => '0x1234', + getTransactionCount: async () => 0, + getBalance: async () => parseUnits('1', 18), + estimateGas: async () => 50000n, + getBlock: async () => ({ timestamp: 1700000000n }), + waitForTransactionReceipt: async () => ({ status: 'success', blockNumber: 1n }), + multicall: async () => [100_000000n, 100_000000n, parseUnits('1', 18), parseUnits('1', 18)], + readContract: async ({ functionName }) => { + if (functionName === 'getCapability') return cap; + if (functionName === 'remainingDailyUsd') return cap.remaining; + if (functionName === 'grantNonce') return 0n; + if (functionName === 'allowance') return parseUnits('1000000', 18); + throw new Error(`Unexpected read: ${functionName}`); + }, + }; + const api = async (path, body, auth) => { + calls.push({ path, body, auth }); + if (path === '/agent') return { agentSigner: agent }; + if (path === '/price') return { prices: { rUSDC: 1, rWETH: 2000 } }; + if (path === '/execute' || path === '/grant') return { txHash: hash }; + throw new Error(`Unexpected API call: ${path}`); + }; + api.authenticate = async () => {}; + const runner = new Runner({ publicClient, api, account: { address: user }, tokens, + deployment: { router, agentExecutor: executor, agentSigner: agent, chainId: 11155111 }, + abis: {}, config: {}, options: { ...options }, chain: {}, + wallet: { prepareTransactionRequest: async args => ({ ...args, maxFeePerGas: 1n }), signTransaction: async () => '0x1234' }, + writeClient: { sendRawTransaction: async () => keccak256('0x1234') }, + log: (event, data) => events.push({ event, ...data }), pause: async () => {}, ...overrides, + }); + runner.agentSigner = agent; + return { runner, calls, events, publicClient }; +} + +test('budget enforces 100 across all categories, counting uncertain reservations', () => { + const entries = []; + const b = new Budget(100, (...entry) => entries.push(entry)); + for (let i = 0; i < 100; i++) b.reserve(['approval', 'deposit', 'grant', 'swap'][i % 4]); + assert.equal(b.used, 100); + assert.throws(() => b.reserve('101st'), StopRun); + assert.equal(entries.length, 100); + assert.throws(() => new Budget(101, () => {})); + assert.throws(() => new Budget(0, () => {})); +}); + +test('journal failure prevents reservation', () => { + const b = new Budget(1, () => { throw new Error('disk full'); }); + assert.throws(() => b.reserve('swap'), /disk full/); + assert.equal(b.used, 0); +}); + +test('random sizes honor token decimals, balance fraction, USD sizes and caps', () => { + for (const decimals of [6, 8, 18]) { + for (const price of [0.1761575, 1, 2366.71, 81290.97654154]) { + const balance = 10n ** BigInt(decimals) * 10_000n; + const range = amountRange(balance, decimals, price, 0.25, 5, 2); + assert.ok(range); + for (const raw of [range.low, range.high, ...Array.from({ length: 50 }, () => randomAmount(range))]) { + assert.ok(raw >= range.low && raw <= range.high); + assert.ok(raw <= balance / 4n); + const usd = Number(raw) / 10 ** decimals * price; + assert.ok(usd >= 0.249 && usd <= 2.000001); + } + assert.equal(randomAmount(range, () => 0), range.low); + assert.equal(randomAmount(range, () => 1_000_000), range.high); + } + } + assert.equal(amountRange(0n, 6, 1, 0.25, 5), null); + assert.equal(amountRange(10n ** 20n, 18, NaN, 0.25, 5), null); + assert.equal(amountRange(10n ** 20n, 18, 1, 0.25, 5, 0), null); +}); + +test('CLI defaults to autonomous only and rejects Copilot, excess budgets, unknown modes, invalid amounts and flags', () => { + assert.deepEqual(optionsFrom([]).options.modes, ['autonomous-confirm', 'autonomous-direct']); + for (const args of [['--max-txs', '101'], ['--max-txs', '1.5'], ['--modes', 'bogus'], ['--modes', 'copilot'], ['--modes', 'autonomous-direct,copilot'], ['--min-usd', '6', '--max-usd', '5'], ['--interval-ms', '-1'], ['--unknown']]) { + assert.throws(() => optionsFrom(args)); + } + assert.equal(optionsFrom(['--max-txs', '2']).options.maxTxs, 2); +}); + +const plan = { mode: 'autonomous-direct', from: tokens[0], to: tokens[1], raw: 1_000000n, amount: '1' }; +const result = { id: 'intent-1', interpretation: { ok: true, kind: 'swap', tokenIn: 'rUSDC', tokenOut: 'rWETH', amount: '1', amountIsPercent: false } }; + +test('interpreter results cannot silently change pair, amount, percentage or order type', () => { + assert.doesNotThrow(() => assertInterpretation(result, plan)); + for (const patch of [{ amount: '2' }, { amount: '1e0' }, { tokenOut: 'rUSDC' }, { kind: 'limit' }, { amountIsPercent: true }, { ok: false }]) { + assert.throws(() => assertInterpretation({ ...result, interpretation: { ...result.interpretation, ...patch } }, plan), SkipTrade); + } +}); + +test('dry-run only reads: no authentication, signatures, interpretation, or broadcasts', async () => { + const { runner, calls, events } = fixture({ options: { ...options, dryRun: true } }); + runner.api.authenticate = async () => assert.fail('dry-run authenticated'); + runner.localWrite = async () => assert.fail('dry-run sent'); + runner.remoteWrite = async () => assert.fail('dry-run sent'); + await runner.run(); + assert.ok(calls.every(c => c.body === undefined)); + assert.deepEqual(events.filter(e => e.event === 'dry-run-plan').map(e => e.mode), ['autonomous-confirm', 'autonomous-direct']); + assert.equal(runner.budget.used, 0); +}); + +test('wrong chain, deployment mismatch and pending transactions stop before any write', async () => { + for (const problem of ['chain', 'agent', 'pending']) { + const { runner, publicClient } = fixture(); + if (problem === 'chain') publicClient.getChainId = async () => 1; + if (problem === 'agent') runner.deployment.agentSigner = user; + if (problem === 'pending') publicClient.getTransactionCount = async ({ blockTag }) => blockTag === 'pending' ? 1 : 0; + await assert.rejects(runner.run(), StopRun); + assert.equal(runner.budget.used, 0); + } +}); + +test('Confirm and Direct authenticate, interpret and execute through the same API', async () => { + for (const mode of ['autonomous-confirm', 'autonomous-direct']) { + const { runner, events, calls } = fixture(); + const base = runner.api; + runner.api = Object.assign(async (path, body, auth) => path === '/interpret' ? (calls.push({ path, body, auth }), result) : base(path, body, auth), { authenticate: base.authenticate }); + await runner.trade({ ...plan, mode }); + assert.deepEqual(calls.map(c => c.path), ['/interpret', '/execute']); + assert.equal(calls[0].body.mode, 'autonomous'); + assert.equal(calls[0].auth, true); + assert.equal(events.some(e => e.event === 'confirmed'), mode.endsWith('confirm')); + assert.equal(runner.completed, 1); + assert.equal(runner.budget.used, 1); + } +}); + +test('incorrect interpretation never reaches execute', async () => { + const { runner } = fixture(); + runner.api = async () => ({ ...result, interpretation: { ...result.interpretation, amount: '10' } }); + await assert.rejects(runner.trade(plan), SkipTrade); + assert.equal(runner.budget.used, 0); +}); + +test('remote errors are not retried and still consume one submission slot', async () => { + const { runner } = fixture(); + let requests = 0; + runner.api = Object.assign(async () => { requests++; throw new Error('timeout'); }, { authenticate: async () => {} }); + await assert.rejects(runner.remoteWrite('/execute', { id: '1' }, 'swap'), StopRun); + assert.equal(requests, 1); + assert.equal(runner.budget.used, 1); +}); + +test('setup cannot exceed remaining budget', async () => { + const { runner } = fixture({ options: { ...options, maxTxs: 1 } }); + runner.read = async () => 0n; + await assert.rejects(runner.approve({ ...tokens[0], wallet: 100_000000n }, router, 1_000000n, 1), StopRun); + assert.equal(runner.budget.used, 0); +}); + +test('API challenge authenticates owner, caches session and does not retry writes', async () => { + const calls = []; + let signed = 0; + const now = Math.floor(Date.now() / 1000); + const fetcher = async (url, init) => { + calls.push({ url, init }); + const body = url.endsWith('/auth/challenge') ? { challengeId: 'challenge', expiresAt: now + 300, message: `Roque autonomous access\n\nWallet: ${user}\nChain ID: 11155111` } + : url.endsWith('/auth/session') ? { token: 'test-session', owner: user, expiresAt: now + 3600 } : { txHash: hash }; + return { ok: true, json: async () => body }; + }; + const api = makeApi('https://example.com/api', { address: user, signMessage: async () => { signed++; return '0xab'; } }, fetcher); + await api('/execute', { id: '1' }, true); + await api('/execute', { id: '2' }, true); + assert.equal(signed, 1); + assert.equal(calls.length, 4); + assert.equal(calls[2].init.headers.authorization, 'Bearer test-session'); + assert.equal(calls[2].init.headers['user-agent'], 'Roque-Testnet-Runner/1.0'); + assert.equal(calls[2].init.redirect, 'error'); + let badCalls = 0; + const bad = makeApi('https://example.com/api', {}, async () => { badCalls++; return { ok: false, status: 504 }; }); + await assert.rejects(bad('/execute', { id: '1' }), /HTTP 504/); + assert.equal(badCalls, 1); +}); + +test('API refuses insecure remote endpoints and unexpected challenges', async () => { + assert.throws(() => makeApi('http://example.com/api', {}), /HTTPS/); + assert.throws(() => makeApi('https://secret@example.com/api', {}), /credentials/); + const api = makeApi('https://example.com/api', { address: user, signMessage: async () => assert.fail('signed invalid challenge') }, async () => ({ ok: true, json: async () => ({ message: 'Sign anything' }) })); + await assert.rejects(api.authenticate(), /unexpected/); +}); + +const approveAbi = parseAbi(['function approve(address spender, uint256 amount) returns (bool)']); + +test('real viem signing sends only the signed transaction via the write client and shares the cap', async () => { + // Public deterministic test fixture key; never the user's configured wallet. + const account = privateKeyToAccount(`0x${'01'.repeat(32)}`); + const rpcReads = []; + const wallet = createWalletClient({ account, chain: sepolia, transport: custom({ + request: async ({ method }) => { rpcReads.push(method); assert.equal(method, 'eth_chainId'); return '0xaa36a7'; }, + }) }); + wallet.prepareTransactionRequest = async args => ({ ...args, chainId: 11155111, type: 'eip1559', maxFeePerGas: 2n, maxPriorityFeePerGas: 1n }); + let sends = 0; + const { runner, events } = fixture({ account, wallet, chain: sepolia, options: { ...options, maxTxs: 2 }, + writeClient: { sendRawTransaction: async ({ serializedTransaction }) => { + sends++; + const decoded = parseTransaction(serializedTransaction); + assert.equal(decoded.chainId, 11155111); + assert.equal(decoded.to.toLowerCase(), tokens[0].address.toLowerCase()); + assert.equal((await recoverTransactionAddress({ serializedTransaction })).toLowerCase(), account.address.toLowerCase()); + assert.ok(events.some(e => e.event === 'reserved' && e.hash === keccak256(serializedTransaction))); + return keccak256(serializedTransaction); + } }, + }); + await runner.localWrite('approve', tokens[0].address, approveAbi, 'approve', [router, 100n]); + await runner.remoteWrite('/execute', { id: 'last' }, 'autonomous'); + await assert.rejects(runner.localWrite('over budget', tokens[0].address, approveAbi, 'approve', [router, 1n]), StopRun); + await assert.rejects(runner.remoteWrite('/execute', { id: 'extra' }, 'autonomous'), StopRun); + assert.equal(sends, 1); + assert.equal(runner.budget.used, 2); + assert.deepEqual(rpcReads, ['eth_chainId']); + assert.ok(events.every(e => !JSON.stringify(e).includes('serializedTransaction'))); +}); + +test('a lost local send response records its known hash, consumes a slot and never retries', async () => { + const { runner, events } = fixture(); + let sends = 0; + runner.writeClient.sendRawTransaction = async () => { sends++; throw new Error('lost response'); }; + await assert.rejects(runner.localWrite('approve', tokens[0].address, approveAbi, 'approve', [router, 1n]), /outcome uncertain/); + assert.equal(sends, 1); + assert.equal(runner.budget.used, 1); + assert.equal(events[0].event, 'reserved'); + assert.equal(events[0].hash, keccak256('0x1234')); +}); + +test('insufficient gas and a stop signal prevent a local submission', async () => { + for (const stop of [false, true]) { + const { runner, publicClient } = fixture(); + runner.stopping = stop; + publicClient.getBalance = async () => 0n; + runner.writeClient.sendRawTransaction = async () => assert.fail('sent despite stop/gas'); + await assert.rejects(runner.localWrite('approve', tokens[0].address, approveAbi, 'approve', [router, 1n]), StopRun); + assert.equal(runner.budget.used, 0); + } +}); + +test('reverted receipts are recorded; unresolved receipts stop the run', async () => { + const { runner, publicClient, events } = fixture(); + publicClient.waitForTransactionReceipt = async () => ({ status: 'reverted', blockNumber: 1n }); + await assert.rejects(runner.receipt(hash, 'test'), SkipTrade); + assert.equal(events[0].status, 'reverted'); + publicClient.waitForTransactionReceipt = async () => { throw new Error('timeout'); }; + await assert.rejects(runner.receipt(hash, 'test'), StopRun); +}); + +test('Copilot is rejected at construction, planning and execution before any request', async () => { + assert.throws(() => fixture({ options: { ...options, modes: ['copilot'] } }), StopRun); + const { runner, calls } = fixture(); + assert.throws(() => runner.choosePlan('copilot', [], cap), StopRun); + await assert.rejects(runner.trade({ ...plan, mode: 'copilot' }), StopRun); + assert.equal(calls.length, 0); + assert.equal(runner.budget.used, 0); +}); + +test('a full 100-swap run uses only authenticated autonomous APIs and both modes without wallet swaps', async () => { + const { runner, calls } = fixture(); + const base = runner.api; + runner.api = Object.assign(async (path, body, auth) => { + if (path === '/interpret') { + calls.push({ path, body, auth }); + const match = /^(?:Swap|Exchange|Sell) ([0-9.]+) (r\w+) (?:for|into) (r\w+)/.exec(body.command); + assert.ok(match); + return { id: `intent-${runner.completed}`, interpretation: { ...result.interpretation, amount: match[1], tokenIn: match[2], tokenOut: match[3] } }; + } + return base(path, body, auth); + }, { authenticate: base.authenticate }); + runner.localWrite = async () => assert.fail('funded autonomous run sent a wallet transaction'); + await assert.rejects(runner.run(), /Transaction limit reached/); + assert.equal(runner.completed, 100); + assert.equal(runner.budget.used, 100); + assert.deepEqual(runner.modeCounts, { 'autonomous-confirm': 50, 'autonomous-direct': 50 }); + const interpretations = calls.filter(call => call.path === '/interpret'); + const executions = calls.filter(call => call.path === '/execute'); + assert.equal(interpretations.length, 100); + assert.equal(executions.length, 100); + assert.ok(interpretations.every(call => call.auth === true && call.body.mode === 'autonomous')); + assert.ok(executions.every(call => call.auth === true)); + assert.ok(calls.every(call => !call.path.startsWith('/swap/'))); +}); + +test('live existing grants including exhausted daily caps are preserved', async () => { + const { runner } = fixture(); + runner.capability = async () => ({ ...cap, remaining: 0n }); + runner.remoteWrite = async () => assert.fail('reset live grant'); + await runner.ensureGrant(); + const initial = await runner.snapshot(); + assert.equal(runner.choosePlan('autonomous-direct', initial, { ...cap, remaining: 0n }), null); + assert.equal(runner.choosePlan('autonomous-confirm', initial, { ...cap, remaining: 0n }), null); + const walletOnly = initial.map(token => ({ ...token, vault: 0n })); + assert.equal(runner.choosePlan('autonomous-direct', walletOnly, cap), null); +}); + +test('new grant signature binds the wallet, correct chain/domain, scaled limits and current nonce', async () => { + const account = privateKeyToAccount(`0x${'01'.repeat(32)}`); + let typed; + let submitted; + const { runner } = fixture({ account: { ...account, signTypedData: async data => { typed = data; return account.signTypedData(data); } } }); + runner.capability = async () => ({ ...cap, revoked: true, nonce: 7n }); + runner.remoteWrite = async (path, body) => { assert.equal(path, '/grant'); submitted = body; return hash; }; + await runner.ensureGrant(); + assert.equal(typed.domain.chainId, 11155111); + assert.equal(typed.domain.verifyingContract, executor); + assert.equal(typed.message.grantNonce, 7n); + assert.equal(submitted.maxPerTradeUsd, parseUnits('5', 18).toString()); + assert.equal(submitted.maxDailyUsd, parseUnits('500', 18).toString()); + assert.equal(submitted.maxSlippageBps, '100'); + assert.equal(submitted.grantNonce, undefined); + assert.equal((await recoverTypedDataAddress({ ...typed, signature: submitted.signature })).toLowerCase(), account.address.toLowerCase()); +}); + +test('empty vault bootstraps with a bounded wallet deposit and budget room for a trade', async () => { + const { runner } = fixture(); + const snapshot = (await runner.snapshot()).map(t => ({ ...t, vault: 0n })); + let deposit; + runner.approve = async (token, spender, amount, reserveFor) => { + assert.equal(spender, executor); + assert.ok(amount <= token.wallet / 4n); + assert.equal(reserveFor, 2); + }; + runner.localWrite = async (label, address, abi, name, args) => { + assert.equal(address, executor); + assert.equal(name, 'deposit'); + deposit = args; + runner.budget.reserve(label); + return hash; + }; + assert.equal(await runner.bootstrapVault(snapshot), true); + assert.ok(deposit[1] > 0n); + assert.equal(runner.budget.used, 1); + const small = fixture({ options: { ...options, maxTxs: 2 } }).runner; + await assert.rejects(small.bootstrapVault(snapshot), StopRun); +});