diff --git a/TESTING.md b/TESTING.md index 12141c182..bb8d8d584 100644 --- a/TESTING.md +++ b/TESTING.md @@ -142,6 +142,35 @@ pnpm test:ci # Optimized for CI (unit + integration) pnpm test:ci:main # Full test suite including E2E ``` +### Agent GMX Allora E2E (Starts Local onchain-actions) + +`agent-gmx-allora` E2E tests now start a local `onchain-actions` dev server (plus Memgraph) automatically. + +Requirements: +- Docker running (for Memgraph via `docker compose`) +- A local onchain-actions worktree at `worktrees/onchain-actions-001` (override with `ONCHAIN_ACTIONS_WORKTREE_DIR`) +- Network access (E2E hits Allora consumer API; set `ALLORA_API_KEY` if needed) + +Commands: +```bash +cd typescript/clients/web-ag-ui/apps/agent-gmx-allora + +# Full suite (unit + integration + e2e) +pnpm test + +# E2E only (starts Memgraph + onchain-actions automatically) +pnpm test:e2e +``` + +Optional overrides: +```bash +# Use a different onchain-actions worktree location +ONCHAIN_ACTIONS_WORKTREE_DIR=/absolute/path/to/worktrees/onchain-actions-001 pnpm test:e2e + +# Use a different compose file (rare) +ONCHAIN_ACTIONS_MEMGRAPH_COMPOSE_FILE=/absolute/path/to/compose.dev.db.yaml pnpm test:e2e +``` + ### Running Specific Tests ```bash diff --git a/typescript/clients/web-a2a/src/components/PrivyClientProvider.tsx b/typescript/clients/web-a2a/src/components/PrivyClientProvider.tsx index 91c2819ee..bd7ef36ad 100644 --- a/typescript/clients/web-a2a/src/components/PrivyClientProvider.tsx +++ b/typescript/clients/web-a2a/src/components/PrivyClientProvider.tsx @@ -3,16 +3,12 @@ import { PrivyProvider } from '@privy-io/react-auth'; import type React from 'react'; -function isPlaceholderAppId(appId: string): boolean { - return appId === 'your_privy_app_id_here'; -} - export function PrivyClientProvider({ children }: { children: React.ReactNode }) { const appId = process.env.NEXT_PUBLIC_PRIVY_APP_ID; - if (!appId || isPlaceholderAppId(appId)) { + if (!appId) { throw new Error( - 'Privy is not configured: set NEXT_PUBLIC_PRIVY_APP_ID to a valid Privy App ID (build can succeed without it, but the app cannot run without it).', + 'Privy is not configured. Set NEXT_PUBLIC_PRIVY_APP_ID to a valid Privy App ID and restart the dev server.', ); } diff --git a/typescript/clients/web-ag-ui/TESTING.md b/typescript/clients/web-ag-ui/TESTING.md new file mode 100644 index 000000000..96c864ded --- /dev/null +++ b/typescript/clients/web-ag-ui/TESTING.md @@ -0,0 +1,75 @@ +# Web AG-UI Agent Testing Notes + +This note documents how to correctly run tests for any given agent in `clients/web-ag-ui`, based on what was tried initially, what was wrong, and where we landed. + +## What I Tried Initially (Wrong) + +1. I ran repo-level commands from `typescript/`: + - `pnpm lint` + - `pnpm build` + +2. I also ran a general `pnpm install` in the monorepo root. + +These actions triggered unrelated packages (for example `clients/web-a2a`) and caused failures unrelated to the target agent. This made the results noisy and misleading for the actual work. + +Additionally, I attempted to add dependencies directly inside `apps/web`: +- `pnpm add -D prettier` + +That failed with: +- `ERR_PNPM_PATCH_NOT_APPLIED` because `clients/web-ag-ui` uses `patchedDependencies` at the workspace root, and installing inside a leaf app can conflict with those patches. + +## What Was Actually Correct + +All test and build workflows should be scoped to **`clients/web-ag-ui` only**, and ideally filtered to the specific app (agent) being worked on. + +### Correct Pattern (for any agent app) + +Use `pnpm -C` to scope to `clients/web-ag-ui` and then filter to the agent package name. + +Example for `agent-gmx-allora`: + +``` +pnpm -C typescript/clients/web-ag-ui --filter agent-gmx-allora run lint +pnpm -C typescript/clients/web-ag-ui --filter agent-gmx-allora run build +pnpm -C typescript/clients/web-ag-ui --filter agent-gmx-allora run test +``` + +This runs unit + integration + e2e (if defined) without touching unrelated workspaces. + +### Example for `apps/web` + +``` +pnpm -C typescript/clients/web-ag-ui --filter web run lint +pnpm -C typescript/clients/web-ag-ui --filter web run build +pnpm -C typescript/clients/web-ag-ui --filter web run test +``` + +Note: `apps/web` currently has no test suites, so the test scripts are no-ops (by design). + +## Practical Outcome / Final Approach + +- Always scope commands to `clients/web-ag-ui`. +- Always use `--filter ` to target the exact app you are testing. +- Avoid running monorepo-root scripts for this workstream. +- Avoid `pnpm add` inside leaf apps unless patches are fully resolved at the workspace root. + +## Recommended Quick Checklist + +For any agent in `clients/web-ag-ui/apps/`: + +1. Lint: + - `pnpm -C typescript/clients/web-ag-ui --filter run lint` + +2. Build: + - `pnpm -C typescript/clients/web-ag-ui --filter run build` + +3. Tests: + - `pnpm -C typescript/clients/web-ag-ui --filter run test` + +4. Format (if needed): + - `pnpm -C typescript/clients/web-ag-ui --filter run format` + +5. Format check: + - `pnpm -C typescript/clients/web-ag-ui --filter run format:check` + +This keeps the scope tight and avoids unrelated failures. diff --git a/typescript/clients/web-ag-ui/apps/agent-clmm/.env.test.example b/typescript/clients/web-ag-ui/apps/agent-clmm/.env.test.example index beff93321..d9a09b796 100644 --- a/typescript/clients/web-ag-ui/apps/agent-clmm/.env.test.example +++ b/typescript/clients/web-ag-ui/apps/agent-clmm/.env.test.example @@ -14,4 +14,4 @@ CLMM_E2E_PRIVATE_KEY=replace-with-private-key # Optional: increase live test timeout (milliseconds). # EMBER_E2E_TIMEOUT_MS=180000 # Optional: provide a real Arbitrum RPC endpoint for live txs. -# ARBITRUM_RPC_URL=https://arb-mainnet.g.alchemy.com/v2/replace-with-key +# ARBITRUM_RPC_URL=https://arb1.arbitrum.io/rpc diff --git a/typescript/clients/web-ag-ui/apps/agent-clmm/src/clients/clients.ts b/typescript/clients/web-ag-ui/apps/agent-clmm/src/clients/clients.ts index c06736cf2..c1a77aef2 100644 --- a/typescript/clients/web-ag-ui/apps/agent-clmm/src/clients/clients.ts +++ b/typescript/clients/web-ag-ui/apps/agent-clmm/src/clients/clients.ts @@ -1,10 +1,11 @@ import { createPublicClient, createWalletClient, http, type Account } from 'viem'; import { arbitrum } from 'viem/chains'; +const DEFAULT_ARBITRUM_RPC_URL = 'https://arb1.arbitrum.io/rpc'; + const ARBITRUM_RPC_URL = - // Default to a public Arbitrum One RPC so local dev works without requiring a paid RPC key. - // If you need higher reliability/throughput, set ARBITRUM_RPC_URL to an Alchemy/Infura/etc endpoint. - process.env['ARBITRUM_RPC_URL'] ?? 'https://arb1.arbitrum.io/rpc'; + // Default to a public Arbitrum One RPC for local dev; set ARBITRUM_RPC_URL to override. + process.env['ARBITRUM_RPC_URL'] ?? DEFAULT_ARBITRUM_RPC_URL; const RPC_RETRY_COUNT = 2; const RPC_TIMEOUT_MS = 8000; diff --git a/typescript/clients/web-ag-ui/apps/agent-clmm/src/core/delegatedExecution.ts b/typescript/clients/web-ag-ui/apps/agent-clmm/src/core/delegatedExecution.ts index 9a9c2ad7f..96223f723 100644 --- a/typescript/clients/web-ag-ui/apps/agent-clmm/src/core/delegatedExecution.ts +++ b/typescript/clients/web-ag-ui/apps/agent-clmm/src/core/delegatedExecution.ts @@ -149,10 +149,11 @@ export async function redeemDelegationsAndExecuteTransactions(params: { } const rpcUrl = (params.clients.public as unknown as { transport?: { url?: unknown } }).transport?.url; + const defaultArbitrumRpcUrl = 'https://arb1.arbitrum.io/rpc'; const resolvedRpcUrl = typeof rpcUrl === 'string' ? rpcUrl - : process.env['ARBITRUM_RPC_URL'] ?? 'https://arb-mainnet.g.alchemy.com/v2/demo-key'; + : process.env['ARBITRUM_RPC_URL'] ?? defaultArbitrumRpcUrl; const simulationClient = createClient({ account: params.clients.wallet.account, diff --git a/typescript/clients/web-ag-ui/apps/agent-clmm/src/workflow/context.unit.test.ts b/typescript/clients/web-ag-ui/apps/agent-clmm/src/workflow/context.unit.test.ts index 9a7195fb7..519212188 100644 --- a/typescript/clients/web-ag-ui/apps/agent-clmm/src/workflow/context.unit.test.ts +++ b/typescript/clients/web-ag-ui/apps/agent-clmm/src/workflow/context.unit.test.ts @@ -66,7 +66,7 @@ describe('ClmmStateAnnotation view reducer history limits', () => { expect(view.transactionHistory).toHaveLength(STATE_HISTORY_LIMIT); expect(view.transactionHistory[0]?.cycle).toBe(5); expect(view.transactionHistory.at(-1)?.cycle).toBe(STATE_HISTORY_LIMIT + 4); - }); + }, 20_000); it('caps accounting history lists at the configured accounting limit', async () => { vi.resetModules(); diff --git a/typescript/clients/web-ag-ui/apps/agent-gmx-allora/.env.example b/typescript/clients/web-ag-ui/apps/agent-gmx-allora/.env.example index c0a47f715..4befe1e86 100644 --- a/typescript/clients/web-ag-ui/apps/agent-gmx-allora/.env.example +++ b/typescript/clients/web-ag-ui/apps/agent-gmx-allora/.env.example @@ -1,2 +1,74 @@ +# GMX Allora Agent (.env.example) +# +# Copy to `.env` (gitignored) and adjust for local development. +# This app uses Node's native env loading via `tsx --env-file=...` in scripts. + # Optional: bypass delegation signing by using the agent wallet for execution DELEGATIONS_BYPASS=false + +# Agent wallet (delegatee). +# Used for delegation signing context and for tx execution when broadcasting is enabled. +# If GMX_ALLORA_AGENT_WALLET_ADDRESS is not set, it will be derived from A2A_TEST_AGENT_NODE_PRIVATE_KEY. +# GMX_ALLORA_AGENT_WALLET_ADDRESS=0x0000000000000000000000000000000000000000 + +# Onchain-actions base URL. +# Accepts either a base URL (e.g. https://api.emberai.xyz) or an OpenAPI spec URL +# (e.g. https://api.emberai.xyz/openapi.json); the agent normalizes to the base URL. +# ONCHAIN_ACTIONS_API_URL=https://api.emberai.xyz + +# Allora API (agent is the only component that talks to Allora directly). +# ALLORA_API_BASE_URL=https://api.allora.network +# ALLORA_API_KEY= +# ALLORA_CHAIN_ID=ethereum-11155111 +# +# Allora topic whitelist enforced in agent config (`src/config/constants.ts`): +# - TOPIC 1: BTC/USD - Log-Return - 8h +# - TOPIC 3: SOL/USD - Log-Return - 8h +# - TOPIC 14: BTC/USD - Price - 8h +# - TOPIC 19: NEAR/USD - Log-Return - 8h +# - TOPIC 2: ETH/USD - Log-Return - 24h +# - TOPIC 16: ETH/USD - Log-Return - 24h +# - TOPIC 2: ETH/USD - Log-Return - 8h +# - TOPIC 17: SOL/USD - Log-Return - 24h +# - TOPIC 10: SOL/USD - Price - 8h + +# Allora inference cache TTLs (ms). Set to 0 to disable caching. +# ALLORA_INFERENCE_CACHE_TTL_MS=30000 +# ALLORA_8H_INFERENCE_CACHE_TTL_MS=30000 + +# GMX agent runtime controls. +# GMX_ALLORA_MODE=debug # production (default) | debug +# GMX_ALLORA_POLL_INTERVAL_MS=1800000 # default 1800000 (30m) +# GMX_ALLORA_STREAM_LIMIT=-1 # default -1 (no limit) +# GMX_ALLORA_STATE_HISTORY_LIMIT=100 # default 100 + +# Transaction submission mode. +# - plan: build transaction plans via onchain-actions only (no broadcasting) +# - submit/execute: broadcast planned transactions (dev only; requires agent key) +# GMX_ALLORA_TX_SUBMISSION_MODE=plan +# A2A_TEST_AGENT_NODE_PRIVATE_KEY=0x0000000000000000000000000000000000000000000000000000000000000000 + +# RPC used when transaction broadcasting is enabled. +# ARBITRUM_RPC_URL=https://arb1.arbitrum.io/rpc + +# Smoke helpers (tests/smoke/gmx-allora-smoke.ts). +# SMOKE_WALLET=0x0000000000000000000000000000000000000000 +# SMOKE_USDC_ADDRESS=0xaf88d065e77c8cC2239327C5EDb3A432268e5831 +# When DELEGATIONS_BYPASS=false and GMX_ALLORA_TX_SUBMISSION_MODE=execute, smoke runs need a delegator key +# to sign delegations that the agent wallet can redeem. +# SMOKE_DELEGATOR_PRIVATE_KEY=0x0000000000000000000000000000000000000000000000000000000000000000 + +# LangGraph dev server configuration. +# If omitted, defaults to http://localhost:8126 and graph id "agent-gmx-allora". +# LANGGRAPH_DEPLOYMENT_URL=http://localhost:8126 +# LANGGRAPH_GRAPH_ID=agent-gmx-allora + +# Optional: pin a thread id for cron/runner scripts (otherwise a UUIDv7 is generated). +# STARTER_THREAD_ID= + +# Optional: run the "starter cron" API runner (see src/cronApiRunner.ts). +# STARTER_CRON_INTERVAL_MS=5000 +# STARTER_CRON_EXPRESSION=*/5 * * * * * + +# Optional: override where the agent looks for config/service.json (expects service.json inside this dir). +# AGENT_CONFIG_DIR=/absolute/path/to/config/dir diff --git a/typescript/clients/web-ag-ui/apps/agent-gmx-allora/.env.test.example b/typescript/clients/web-ag-ui/apps/agent-gmx-allora/.env.test.example index 151b824c1..b3e613b3a 100644 --- a/typescript/clients/web-ag-ui/apps/agent-gmx-allora/.env.test.example +++ b/typescript/clients/web-ag-ui/apps/agent-gmx-allora/.env.test.example @@ -1,2 +1,89 @@ # CI-safe defaults for integration/e2e tests. # Copy this file to `.env.test` to override locally (file is gitignored). + +# If set, tests use this URL and skip booting a local onchain-actions worktree. +# Leave unset to auto-boot a local worktree on http://localhost:50051. +ONCHAIN_ACTIONS_API_URL=http://localhost:50051 + +# If agent/web E2E needs to boot onchain-actions + memgraph locally, point at the worktree. +# Auto-discovery will look for a single `worktrees/onchain-actions-*` directory, but if you have +# more than one, set this explicitly. +# ONCHAIN_ACTIONS_WORKTREE_DIR=/absolute/path/to/forge/worktrees/onchain-actions-001 + +# Optional: override which docker compose file is used to boot memgraph in the onchain-actions worktree. +# Defaults to `${ONCHAIN_ACTIONS_WORKTREE_DIR}/compose.dev.db.yaml`. +# ONCHAIN_ACTIONS_MEMGRAPH_COMPOSE_FILE=/absolute/path/to/compose.dev.db.yaml + +# Optional: onchain-actions boot overrides used by tests/setup/onchainActions.globalSetup.ts. +# These are forwarded to the onchain-actions worktree when the test global setup runs. +# (Most have CI-safe defaults in the setup script, so you typically don't need to set them.) +# COINGECKO_API_KEY= +# COINGECKO_USE_PRO=false +# SQUID_INTEGRATOR_ID=test +# DUNE_API_KEY=test +# BIRDEYE_API_KEY= +# PENDLE_CHAIN_IDS=42161 +# ALGEBRA_CHAIN_IDS=42161 +# GMX_CHAIN_IDS=42161 +# SERVICE_WALLET_PRIVATE_KEY=0x0000000000000000000000000000000000000000000000000000000000000000 +# DUST_CHAIN_ID=1 +# DUST_CHAIN_RECEIVER_ADDRESS=0x0000000000000000000000000000000000000000 + +# Allora API (agent is the only component that talks to Allora directly). +ALLORA_API_BASE_URL=https://api.allora.network +# ALLORA_API_KEY= +# ALLORA_CHAIN_ID=ethereum-11155111 +# +# Allora topic whitelist enforced in agent config (`src/config/constants.ts`): +# - TOPIC 1: BTC/USD - Log-Return - 8h +# - TOPIC 3: SOL/USD - Log-Return - 8h +# - TOPIC 14: BTC/USD - Price - 8h +# - TOPIC 19: NEAR/USD - Log-Return - 8h +# - TOPIC 2: ETH/USD - Log-Return - 24h +# - TOPIC 16: ETH/USD - Log-Return - 24h +# - TOPIC 2: ETH/USD - Log-Return - 8h +# - TOPIC 17: SOL/USD - Log-Return - 24h +# - TOPIC 10: SOL/USD - Price - 8h + +# Allora inference cache TTLs (ms). Set to 0 to disable caching (useful in tests). +# ALLORA_INFERENCE_CACHE_TTL_MS=0 +# ALLORA_8H_INFERENCE_CACHE_TTL_MS=0 + +# GMX agent runtime controls (optional). +# GMX_ALLORA_MODE=debug # default is production +# GMX_ALLORA_POLL_INTERVAL_MS=1800000 +# GMX_ALLORA_STREAM_LIMIT=-1 +# GMX_ALLORA_STATE_HISTORY_LIMIT=100 + +# Agent wallet (delegatee). +# If GMX_ALLORA_AGENT_WALLET_ADDRESS is not set, it will be derived from A2A_TEST_AGENT_NODE_PRIVATE_KEY. +# GMX_ALLORA_AGENT_WALLET_ADDRESS=0x0000000000000000000000000000000000000000 + +# Transaction submission mode (tests default to plan-building). +GMX_ALLORA_TX_SUBMISSION_MODE=plan +# A2A_TEST_AGENT_NODE_PRIVATE_KEY=0x0000000000000000000000000000000000000000000000000000000000000000 + +# RPC used when transaction broadcasting is enabled. +# ARBITRUM_RPC_URL=https://arb1.arbitrum.io/rpc + +# Smoke/E2E helpers. +# Used by tests/smoke/gmx-allora-smoke.ts and apps/web/tests/gmxAllora.system.e2e.test.ts. +SMOKE_WALLET=0x0000000000000000000000000000000000000001 +# Optional profile for web-driven E2E: +# - mocked: enable agent-local MSW interceptors for Allora + onchain-actions. +# - live: disable interceptors and use real HTTP providers. +# E2E_PROFILE=mocked +# When DELEGATIONS_BYPASS=false and GMX_ALLORA_TX_SUBMISSION_MODE=execute, smoke runs need a delegator key +# to sign delegations that the agent wallet can redeem. +# SMOKE_DELEGATOR_PRIVATE_KEY=0x0000000000000000000000000000000000000000000000000000000000000000 +# SMOKE_USDC_ADDRESS=0x0000000000000000000000000000000000000000 + +# LangGraph API runner config (optional; used by src/cronApiRunner.ts). +# LANGGRAPH_DEPLOYMENT_URL=http://localhost:8126 +# LANGGRAPH_GRAPH_ID=agent-gmx-allora +# STARTER_THREAD_ID= +# STARTER_CRON_INTERVAL_MS=5000 +# STARTER_CRON_EXPRESSION=*/5 * * * * * + +# Optional: override where the agent looks for config/service.json. +# AGENT_CONFIG_DIR=/absolute/path/to/config/dir diff --git a/typescript/clients/web-ag-ui/apps/agent-gmx-allora/README.md b/typescript/clients/web-ag-ui/apps/agent-gmx-allora/README.md new file mode 100644 index 000000000..15725d0bb --- /dev/null +++ b/typescript/clients/web-ag-ui/apps/agent-gmx-allora/README.md @@ -0,0 +1,65 @@ +# GMX Allora Agent + +This agent uses Allora prediction feeds to make deterministic trading decisions for GMX perpetuals on Arbitrum and then: + +- builds **transaction plans** to open/modify/close positions via `onchain-actions` +- optionally **submits user transactions** in embedded-wallet mode (normal user flows, not GMX keeper "execution") + +## Roadmap Vocabulary + +- **Transaction planning**: producing `transactions[]` that a wallet can sign and submit. +- **Transaction submission**: broadcasting signed transactions and recording tx hashes in artifacts/history. + +## Current Milestones + +- Plan-building mode (no submission) is implemented. +- Next: validate onchain-actions read-path correctness (markets/positions/balances) before enabling transaction submission. + +## Test Taxonomy + +- `test:unit`: deterministic unit coverage for core decisioning, plan building, and client adapters. +- `test:int`: workflow-level integration tests (node-level orchestration and action wiring). +- `test:e2e`: intentionally reserved for full graph + service lifecycle tests; currently no e2e specs are checked in yet. +- `test:smoke`: live end-to-end transaction smoke script against a configured onchain-actions API URL. + +For web-driven E2E (`apps/web/tests/gmxAllora.system.e2e.test.ts`), the agent supports: +- `E2E_PROFILE=mocked`: enable agent-local MSW interception for Allora + onchain-actions. +- `E2E_PROFILE=live`: disable interception and use real HTTP providers. + +## Transaction Submission Behavior + +The agent always uses onchain-actions to build a `transactions[]` plan for the chosen action (`long`, `short`, `close`). + +- `GMX_ALLORA_TX_SUBMISSION_MODE=plan`: + - The agent emits the planned `transactions[]` in artifacts/history and does not broadcast anything. +- `GMX_ALLORA_TX_SUBMISSION_MODE=submit`: + - `long`: build `transactions[]` via onchain-actions, then broadcast each transaction sequentially and wait for receipts; record `txHashes` and `lastTxHash` in artifacts/history. + - `short`: same as `long`. + - `close`: build `transactions[]` via onchain-actions, then broadcast each transaction sequentially and wait for receipts. + - Note: this requires an onchain-actions GMX plugin that plans position closes using GMX decrease orders. Older onchain-actions versions may return order-cancellation transactions instead. + +## Environment + +- `GMX_ALLORA_POLL_INTERVAL_MS`: poll interval (ms) for each agent cycle. Defaults to `1800000` (30m). +- `GMX_MIN_NATIVE_ETH_WEI`: minimum native ETH (in wei) required in the operator wallet before the agent will proceed (defaults to `2000000000000000` = 0.002 ETH). +- `Allora topic whitelist` (enforced in `src/config/constants.ts`): + - `TOPIC 1`: `BTC/USD - Log-Return - 8h` + - `TOPIC 3`: `SOL/USD - Log-Return - 8h` + - `TOPIC 14`: `BTC/USD - Price - 8h` + - `TOPIC 19`: `NEAR/USD - Log-Return - 8h` + - `TOPIC 2`: `ETH/USD - Log-Return - 24h` + - `TOPIC 16`: `ETH/USD - Log-Return - 24h` + - `TOPIC 2`: `ETH/USD - Log-Return - 8h` + - `TOPIC 17`: `SOL/USD - Log-Return - 24h` + - `TOPIC 10`: `SOL/USD - Price - 8h` +- `ALLORA_INFERENCE_CACHE_TTL_MS`: cache TTL (ms) for Allora consumer inference requests. Defaults to `30000`; set to `0` to disable caching. +- `ALLORA_8H_INFERENCE_CACHE_TTL_MS`: cache TTL (ms) specifically for the GMX agent's 8-hour inference fetch. Defaults to `30000`; set to `3600000` (1 hour) to avoid re-fetching on every 5s poll tick. +- `GMX_ALLORA_TX_SUBMISSION_MODE`: transaction submission mode. Supported values: + - `plan` (default): build and emit `transactions[]` but do not broadcast. + - `submit`: broadcast planned transactions via an embedded wallet (no delegations). Requires an onchain-actions version that correctly plans the requested GMX action (especially close via decrease order). +- `E2E_PROFILE`: optional system-test profile. + - `mocked`: deterministic agent-local MSW handlers intercept Allora + onchain-actions. + - `live` (default): normal runtime behavior with real providers. +- `GMX_ALLORA_AGENT_WALLET_ADDRESS`: optional override for the agent wallet (delegatee) address. If omitted, it is derived from `A2A_TEST_AGENT_NODE_PRIVATE_KEY`. +- `A2A_TEST_AGENT_NODE_PRIVATE_KEY`: required when `GMX_ALLORA_TX_SUBMISSION_MODE=submit` (0x + 64 hex chars). Only for local/dev use. +- `ARBITRUM_RPC_URL`: RPC URL for broadcasting transactions when submission is enabled. diff --git a/typescript/clients/web-ag-ui/apps/agent-gmx-allora/package.json b/typescript/clients/web-ag-ui/apps/agent-gmx-allora/package.json index 9a995e7a7..ddaece2b1 100644 --- a/typescript/clients/web-ag-ui/apps/agent-gmx-allora/package.json +++ b/typescript/clients/web-ag-ui/apps/agent-gmx-allora/package.json @@ -13,7 +13,9 @@ "test:unit": "vitest run --config vitest.config.unit.ts", "test:int": "bash -lc 'ENV_FILE=.env.test; [ -f \"$ENV_FILE\" ] || ENV_FILE=.env.test.example; exec tsx --env-file=\"$ENV_FILE\" ./node_modules/vitest/vitest.mjs run --config vitest.config.int.ts \"$@\"' --", "test:e2e": "bash -lc 'ENV_FILE=.env.test; [ -f \"$ENV_FILE\" ] || ENV_FILE=.env.test.example; exec tsx --env-file=\"$ENV_FILE\" ./node_modules/vitest/vitest.mjs run --config vitest.config.e2e.ts --no-file-parallelism --maxConcurrency=1 \"$@\"' --", + "test:smoke": "bash -lc 'ENV_FILE=.env; [ -f \"$ENV_FILE\" ] || ENV_FILE=.env.test; [ -f \"$ENV_FILE\" ] || ENV_FILE=.env.test.example; exec tsx --env-file=\"$ENV_FILE\" tests/smoke/gmx-allora-smoke.ts'", "test:watch": "vitest watch", + "test:coverage": "vitest run --config vitest.config.coverage.ts", "test:ci": "pnpm test:unit && pnpm test:int", "lint": "eslint \"src\" --ext .ts", "lint:fix": "eslint \"src\" --ext .ts --fix", @@ -35,6 +37,7 @@ "eslint-config-prettier": "^10.1.8", "eslint-import-resolver-typescript": "^4.4.4", "eslint-plugin-import": "2.32.0", + "msw": "^2.12.8", "prettier": "catalog:", "tsx": "catalog:", "typescript": "^5.9.3", @@ -52,6 +55,7 @@ "@langchain/openai": "^1.1.3", "@metamask/delegation-toolkit": "^0.13.0", "node-cron": "^4.2.1", + "p-retry": "^7.1.1", "permissionless": "^0.2.57", "uuid": "^13.0.0", "viem": "catalog:", diff --git a/typescript/clients/web-ag-ui/apps/agent-gmx-allora/rationales.md b/typescript/clients/web-ag-ui/apps/agent-gmx-allora/rationales.md new file mode 100644 index 000000000..cfda0790a --- /dev/null +++ b/typescript/clients/web-ag-ui/apps/agent-gmx-allora/rationales.md @@ -0,0 +1,26 @@ +# GMX Allora Agent Rationales + +## Strategy Rule Format + +We derive a normalized confidence score from Allora's confidence interval band to keep signal selection deterministic and auditable. + +**Rule** +- Use the inner band of `confidence_interval_values` (P15.87, P84.13 when available) to estimate spread. +- Compute `confidence = 1 - (upper - lower) / max(|combined_value|, 1)`. +- Clamp to `[0, 1]` and round to 2 decimals. + +**Why** +- Allora's API provides an inference band but not a single confidence scalar. The spread-to-price ratio yields a consistent, explainable score without introducing probabilistic modeling. + +**Trade-offs** +- This is a heuristic and may under/over-estimate confidence in volatile regimes; it is deterministic and easy to audit, which matches the PRD requirement. + +## Position Sizing Safety Buffer + +We allocate `baseContributionUsd * 0.8` (20% buffer) for position sizing. + +**Why** +- The PRD requires a safety buffer that tolerates roughly 20% adverse movement. Using 80% of the allocation keeps exposure below the full balance while preserving deterministic sizing. + +**Trade-offs** +- This may underutilize capital during strong signals, but it keeps the strategy conservative by design. diff --git a/typescript/clients/web-ag-ui/apps/agent-gmx-allora/scripts/mnemonic-to-private-key.ts b/typescript/clients/web-ag-ui/apps/agent-gmx-allora/scripts/mnemonic-to-private-key.ts new file mode 100644 index 000000000..799cb07a9 --- /dev/null +++ b/typescript/clients/web-ag-ui/apps/agent-gmx-allora/scripts/mnemonic-to-private-key.ts @@ -0,0 +1,92 @@ +import process from 'node:process'; + +import { bytesToHex } from 'viem'; +import { mnemonicToAccount } from 'viem/accounts'; + +type Args = { + index: number; + count: number; + basePath: string; +}; + +function parseInteger(value: string | undefined, fallback: number): number { + if (!value) return fallback; + const parsed = Number(value); + if (!Number.isFinite(parsed)) return fallback; + return Math.trunc(parsed); +} + +function parseArgs(argv: string[]): Args { + // Minimal flag parsing: --index N --count N --base-path "m/44'/60'/0'/0" + let index = 0; + let count = 1; + let basePath = `m/44'/60'/0'/0`; + + for (let i = 0; i < argv.length; i += 1) { + const arg = argv[i]; + const next = argv[i + 1]; + if (arg === '--index') { + index = parseInteger(next, index); + i += 1; + continue; + } + if (arg === '--count') { + count = parseInteger(next, count); + i += 1; + continue; + } + if (arg === '--base-path') { + if (next) { + basePath = next; + } + i += 1; + continue; + } + } + + if (index < 0) index = 0; + if (count < 1) count = 1; + if (count > 50) count = 50; + + return { index, count, basePath }; +} + +function resolveMnemonic(): string { + const raw = process.env['MNEMONIC']?.trim(); + if (!raw) { + throw new Error( + 'MNEMONIC is required. Use the `scripts/mnemonic` wrapper which prompts securely.', + ); + } + return raw; +} + +function resolvePrivateKeyHex(account: ReturnType): `0x${string}` { + const hdKey = account.getHdKey(); + const pk = hdKey.privateKey; + if (typeof pk === 'string') { + return pk as `0x${string}`; + } + return bytesToHex(pk) as `0x${string}`; +} + +const args = parseArgs(process.argv.slice(2)); +const mnemonic = resolveMnemonic(); + +for (let offset = 0; offset < args.count; offset += 1) { + const index = args.index + offset; + const path = `${args.basePath}/${index}`; + const account = mnemonicToAccount(mnemonic, { path }); + const privateKey = resolvePrivateKeyHex(account); + + // Print machine-readable JSON for easy copy/paste. + process.stdout.write( + `${JSON.stringify({ + index, + path, + address: account.address, + privateKey, + })}\n`, + ); +} + diff --git a/typescript/clients/web-ag-ui/apps/agent-gmx-allora/src/agent.ts b/typescript/clients/web-ag-ui/apps/agent-gmx-allora/src/agent.ts index 1835b93dc..377dc7190 100644 --- a/typescript/clients/web-ag-ui/apps/agent-gmx-allora/src/agent.ts +++ b/typescript/clients/web-ag-ui/apps/agent-gmx-allora/src/agent.ts @@ -9,6 +9,7 @@ import { resolveLangGraphDurability, type LangGraphDurability, } from './config/serviceConfig.js'; +import { setupAgentLocalE2EMocksIfNeeded } from './e2e/agentLocalMocks.js'; import { ClmmStateAnnotation, memory, type ClmmState } from './workflow/context.js'; import { configureCronExecutor } from './workflow/cronScheduler.js'; import { bootstrapNode } from './workflow/nodes/bootstrap.js'; @@ -19,11 +20,17 @@ import { fireCommandNode } from './workflow/nodes/fireCommand.js'; import { hireCommandNode } from './workflow/nodes/hireCommand.js'; import { pollCycleNode } from './workflow/nodes/pollCycle.js'; import { prepareOperatorNode } from './workflow/nodes/prepareOperator.js'; -import { extractCommand, resolveCommandTarget, runCommandNode } from './workflow/nodes/runCommand.js'; +import { + extractCommand, + resolveCommandTarget, + runCommandNode, +} from './workflow/nodes/runCommand.js'; import { runCycleCommandNode } from './workflow/nodes/runCycleCommand.js'; import { summarizeNode } from './workflow/nodes/summarize.js'; import { syncStateNode } from './workflow/nodes/syncState.js'; +await setupAgentLocalE2EMocksIfNeeded(); + function resolvePostBootstrap(state: ClmmState): 'collectSetupInput' | 'syncState' { const command = extractCommand(state.messages) ?? state.view.command; return command === 'sync' ? 'syncState' : 'collectSetupInput'; @@ -162,7 +169,10 @@ async function updateCycleState( } catch (error: unknown) { const message = error instanceof Error ? error.message : typeof error === 'string' ? error : 'Unknown error'; - console.warn('[cron] Unable to fetch thread state before cycle update', { threadId, error: message }); + console.warn('[cron] Unable to fetch thread state before cycle update', { + threadId, + error: message, + }); } const view = existingView ? { ...existingView, command: 'cycle' } : { command: 'cycle' }; @@ -201,7 +211,9 @@ async function createRun(params: { if (response.status === 422) { const payloadText = await response.text(); - console.info(`[cron] Run rejected; thread busy (thread=${params.threadId})`, { detail: payloadText }); + console.info(`[cron] Run rejected; thread busy (thread=${params.threadId})`, { + detail: payloadText, + }); return undefined; } @@ -219,11 +231,14 @@ async function waitForRunStreamCompletion(params: { threadId: string; runId: string; }): Promise { - const response = await fetch(`${params.baseUrl}/threads/${params.threadId}/runs/${params.runId}/stream`, { - headers: { - Accept: 'text/event-stream', + const response = await fetch( + `${params.baseUrl}/threads/${params.threadId}/runs/${params.runId}/stream`, + { + headers: { + Accept: 'text/event-stream', + }, }, - }); + ); if (!response.ok) { const payloadText = await response.text(); throw new Error(`LangGraph run stream failed (${response.status}): ${payloadText}`); @@ -273,7 +288,9 @@ export async function runGraphOnce( } const status = await waitForRunStreamCompletion({ baseUrl, threadId, runId }); if (status === 'interrupted') { - console.warn('[cron] Graph interrupted awaiting operator input; supply input via UI and rerun.'); + console.warn( + '[cron] Graph interrupted awaiting operator input; supply input via UI and rerun.', + ); return; } if (status && status !== 'success') { @@ -288,10 +305,7 @@ export async function runGraphOnce( } } -export async function startCron( - threadId: string, - options?: { durability?: LangGraphDurability }, -) { +export async function startCron(threadId: string, options?: { durability?: LangGraphDurability }) { await runGraphOnce(threadId, options); } diff --git a/typescript/clients/web-ag-ui/apps/agent-gmx-allora/src/clients/allora.ts b/typescript/clients/web-ag-ui/apps/agent-gmx-allora/src/clients/allora.ts new file mode 100644 index 000000000..3ed25db3d --- /dev/null +++ b/typescript/clients/web-ag-ui/apps/agent-gmx-allora/src/clients/allora.ts @@ -0,0 +1,209 @@ +import { z } from 'zod'; + +type RawInference = { + network_inferences: { + topic_id: string | number; + combined_value: string; + }; + confidence_interval_values: string[]; +}; + +const RawInferenceSchema = z.object({ + network_inferences: z.object({ + topic_id: z.union([z.string(), z.number()]), + combined_value: z.string(), + }), + confidence_interval_values: z.array(z.string()), +}); + +type ConsumerInferencePayload = { + request_id?: string; + status?: boolean; + data?: { + inference_data?: { + network_inference_normalized?: string; + network_inference?: string; + topic_id?: string | number; + }; + }; +}; + +const ConsumerInferenceSchema = z.object({ + request_id: z.string().optional(), + status: z.boolean().optional(), + data: z + .object({ + inference_data: z + .object({ + network_inference_normalized: z.string().optional(), + network_inference: z.string().optional(), + topic_id: z.union([z.string(), z.number()]).optional(), + }) + .optional(), + }) + .optional(), +}); + +export type AlloraInference = { + topicId: number; + combinedValue: number; + confidenceIntervalValues: number[]; +}; + +function parseFiniteNumber(value: string | number, label: string): number { + const parsed = typeof value === 'number' ? value : Number(value); + if (!Number.isFinite(parsed)) { + throw new Error(`Invalid ${label}: ${value}`); + } + return parsed; +} + +export function parseAlloraInferenceResponse(payload: unknown): AlloraInference { + const rawParse = RawInferenceSchema.safeParse(payload); + if (rawParse.success) { + const parsed: RawInference = rawParse.data; + const topicId = parseFiniteNumber(parsed.network_inferences.topic_id, 'topic_id'); + const combinedValue = parseFiniteNumber( + parsed.network_inferences.combined_value, + 'combined_value', + ); + const confidenceIntervalValues = parsed.confidence_interval_values.map((value) => + parseFiniteNumber(value, 'confidence_interval_values'), + ); + + return { + topicId, + combinedValue, + confidenceIntervalValues, + }; + } + + const consumerParsed: ConsumerInferencePayload = ConsumerInferenceSchema.parse(payload); + const inference = consumerParsed.data?.inference_data; + if (!inference?.network_inference_normalized && !inference?.network_inference) { + throw new Error('Allora inference payload missing network_inference'); + } + + const combinedValue = parseFiniteNumber( + inference.network_inference_normalized ?? inference.network_inference ?? '', + 'combined_value', + ); + const topicId = parseFiniteNumber(inference.topic_id ?? '', 'topic_id'); + return { + topicId, + combinedValue, + confidenceIntervalValues: [combinedValue], + }; +} + +export async function fetchAlloraInference(params: { + baseUrl: string; + chainId: string; + topicId: number; + apiKey?: string; + cacheTtlMs?: number; +}): Promise { + const base = params.baseUrl.replace(/\/$/u, ''); + const query = new URLSearchParams({ allora_topic_id: params.topicId.toString() }); + const url = `${base}/v2/allora/consumer/${params.chainId}?${query.toString()}`; + const cacheTtlMs = params.cacheTtlMs ?? 0; + + if (cacheTtlMs > 0) { + const cached = getCachedInference({ cacheKey: url, now: Date.now() }); + if (cached) { + return cached; + } + const inflight = inflightRequests.get(url); + if (inflight) { + return inflight; + } + } + + const request = (async () => { + const response = await fetch(url, { + headers: params.apiKey ? { 'x-api-key': params.apiKey } : undefined, + }); + + const bodyText = await response.text(); + if (!response.ok) { + throw new Error(`Allora API request failed (${response.status}): ${bodyText}`); + } + + const payload = bodyText.trim().length > 0 ? (JSON.parse(bodyText) as unknown) : {}; + const parsed = parseAlloraInferenceResponse(payload); + if (cacheTtlMs > 0) { + storeCachedInference({ cacheKey: url, now: Date.now(), ttlMs: cacheTtlMs, value: parsed }); + } + return parsed; + })(); + + if (cacheTtlMs > 0) { + inflightRequests.set(url, request); + try { + return await request; + } finally { + inflightRequests.delete(url); + } + } + + return await request; +} + +type CacheEntry = { expiresAt: number; value: AlloraInference }; + +const MAX_CACHE_ENTRIES = 32; +const inferenceCache = new Map(); +const inflightRequests = new Map>(); + +function getCachedInference(params: { cacheKey: string; now: number }): AlloraInference | null { + const cached = inferenceCache.get(params.cacheKey); + if (!cached) { + return null; + } + if (cached.expiresAt <= params.now) { + inferenceCache.delete(params.cacheKey); + return null; + } + return cached.value; +} + +function storeCachedInference(params: { + cacheKey: string; + now: number; + ttlMs: number; + value: AlloraInference; +}): void { + if (params.ttlMs <= 0) { + return; + } + inferenceCache.set(params.cacheKey, { expiresAt: params.now + params.ttlMs, value: params.value }); + pruneCache(params.now); +} + +function pruneCache(now: number): void { + // Fast path: keep under limit without scanning. + if (inferenceCache.size <= MAX_CACHE_ENTRIES) { + return; + } + + // Drop expired first. + for (const [key, entry] of inferenceCache) { + if (entry.expiresAt <= now) { + inferenceCache.delete(key); + } + } + + // If still too large, evict oldest insertions (Map iteration order). + while (inferenceCache.size > MAX_CACHE_ENTRIES) { + const oldest = inferenceCache.keys().next(); + if (oldest.done) { + break; + } + inferenceCache.delete(oldest.value); + } +} + +export function clearAlloraInferenceCache(): void { + inferenceCache.clear(); + inflightRequests.clear(); +} diff --git a/typescript/clients/web-ag-ui/apps/agent-gmx-allora/src/clients/allora.unit.test.ts b/typescript/clients/web-ag-ui/apps/agent-gmx-allora/src/clients/allora.unit.test.ts new file mode 100644 index 000000000..97fa46f8e --- /dev/null +++ b/typescript/clients/web-ag-ui/apps/agent-gmx-allora/src/clients/allora.unit.test.ts @@ -0,0 +1,134 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { clearAlloraInferenceCache, fetchAlloraInference, parseAlloraInferenceResponse } from './allora.js'; + +afterEach(() => { + clearAlloraInferenceCache(); + vi.unstubAllGlobals(); +}); + +describe('parseAlloraInferenceResponse', () => { + it('parses combined value and confidence intervals into numbers', () => { + const payload = { + network_inferences: { + topic_id: '14', + combined_value: '2605.5338791850806', + }, + confidence_interval_values: [ + '2492.1675618299669', + '2543.9249467952655', + '2611.0331303511152', + '2662.2952339563844', + '2682.827040221238', + ], + }; + + expect(parseAlloraInferenceResponse(payload)).toEqual({ + topicId: 14, + combinedValue: Number('2605.5338791850806'), + confidenceIntervalValues: [ + Number('2492.1675618299669'), + Number('2543.9249467952655'), + Number('2611.0331303511152'), + Number('2662.2952339563844'), + Number('2682.827040221238'), + ], + }); + }); + + it('parses consumer inference payloads', () => { + const payload = { + request_id: 'abc', + status: true, + data: { + inference_data: { + network_inference: '71380522596524715399145', + network_inference_normalized: '71380.522596524715399145', + topic_id: '14', + }, + }, + }; + + expect(parseAlloraInferenceResponse(payload)).toEqual({ + topicId: 14, + combinedValue: Number('71380.522596524715399145'), + confidenceIntervalValues: [Number('71380.522596524715399145')], + }); + }); +}); + +describe('fetchAlloraInference caching', () => { + it('dedupes repeated requests within the TTL', async () => { + const fetchSpy = vi.fn(() => { + return { + ok: true, + status: 200, + text: () => + Promise.resolve( + JSON.stringify({ + status: true, + data: { inference_data: { topic_id: '14', network_inference_normalized: '65000' } }, + }), + ), + } satisfies Partial as Response; + }); + + vi.stubGlobal('fetch', fetchSpy); + + const first = await fetchAlloraInference({ + baseUrl: 'http://127.0.0.1:1234', + chainId: 'test', + topicId: 14, + cacheTtlMs: 60_000, + }); + const second = await fetchAlloraInference({ + baseUrl: 'http://127.0.0.1:1234', + chainId: 'test', + topicId: 14, + cacheTtlMs: 60_000, + }); + + expect(first).toEqual(second); + expect(fetchSpy).toHaveBeenCalledTimes(1); + }); + + it('re-fetches once the TTL has elapsed', async () => { + const fetchSpy = vi.fn(() => { + return { + ok: true, + status: 200, + text: () => + Promise.resolve( + JSON.stringify({ + status: true, + data: { inference_data: { topic_id: '14', network_inference_normalized: '65000' } }, + }), + ), + } satisfies Partial as Response; + }); + + vi.stubGlobal('fetch', fetchSpy); + + vi.useFakeTimers(); + vi.setSystemTime(new Date('2025-01-01T00:00:00.000Z')); + + await fetchAlloraInference({ + baseUrl: 'http://127.0.0.1:1234', + chainId: 'test', + topicId: 14, + cacheTtlMs: 1000, + }); + + vi.setSystemTime(new Date('2025-01-01T00:00:02.000Z')); + + await fetchAlloraInference({ + baseUrl: 'http://127.0.0.1:1234', + chainId: 'test', + topicId: 14, + cacheTtlMs: 1000, + }); + + expect(fetchSpy).toHaveBeenCalledTimes(2); + vi.useRealTimers(); + }); +}); diff --git a/typescript/clients/web-ag-ui/apps/agent-gmx-allora/src/clients/clients.ts b/typescript/clients/web-ag-ui/apps/agent-gmx-allora/src/clients/clients.ts new file mode 100644 index 000000000..c1a77aef2 --- /dev/null +++ b/typescript/clients/web-ag-ui/apps/agent-gmx-allora/src/clients/clients.ts @@ -0,0 +1,50 @@ +import { createPublicClient, createWalletClient, http, type Account } from 'viem'; +import { arbitrum } from 'viem/chains'; + +const DEFAULT_ARBITRUM_RPC_URL = 'https://arb1.arbitrum.io/rpc'; + +const ARBITRUM_RPC_URL = + // Default to a public Arbitrum One RPC for local dev; set ARBITRUM_RPC_URL to override. + process.env['ARBITRUM_RPC_URL'] ?? DEFAULT_ARBITRUM_RPC_URL; + +const RPC_RETRY_COUNT = 2; +const RPC_TIMEOUT_MS = 8000; + +type WalletInstance = ReturnType; + +export type OnchainClients = { + public: ReturnType; + wallet: WalletInstance & { account: Account }; +}; + +export function createRpcTransport(url: string): ReturnType { + const baseTransport = http(url); + const baseTransportValue: unknown = baseTransport; + if (typeof baseTransportValue !== 'function') { + return baseTransport; + } + return ((params: Parameters[0]) => + baseTransport({ + ...params, + retryCount: RPC_RETRY_COUNT, + timeout: RPC_TIMEOUT_MS, + })) as ReturnType; +} + +export function createClients(account: Account): OnchainClients { + const transport = createRpcTransport(ARBITRUM_RPC_URL); + const publicClient = createPublicClient({ + chain: arbitrum, + transport, + }); + const walletClient = createWalletClient({ + account, + chain: arbitrum, + transport, + }) as WalletInstance & { account: Account }; + + return { + public: publicClient, + wallet: walletClient, + }; +} diff --git a/typescript/clients/web-ag-ui/apps/agent-gmx-allora/src/clients/onchainActions.ts b/typescript/clients/web-ag-ui/apps/agent-gmx-allora/src/clients/onchainActions.ts new file mode 100644 index 000000000..e06eb069a --- /dev/null +++ b/typescript/clients/web-ag-ui/apps/agent-gmx-allora/src/clients/onchainActions.ts @@ -0,0 +1,344 @@ +import { z } from 'zod'; + +const HTTP_TIMEOUT_MS = 60_000; + +const PaginationSchema = z.object({ + cursor: z.string().nullable(), + currentPage: z.number().int(), + totalPages: z.number().int(), + totalItems: z.number().int(), +}); + +type TokenIdentifier = { + chainId: string; + address: string; +}; + +const TokenIdentifierSchema = z.object({ + chainId: z.string(), + address: z.string(), +}); + +const TokenSchema = z.object({ + tokenUid: TokenIdentifierSchema, + name: z.string(), + symbol: z.string(), + isNative: z.boolean(), + decimals: z.number().int(), + iconUri: z.string().nullish(), + isVetted: z.boolean(), +}); + +const normalizeTokenInput = (value: unknown): unknown => { + if (!value || typeof value !== 'object') { + return value; + } + const record = value as Record; + if (record['iconUri'] === null) { + return { ...record, iconUri: undefined }; + } + return record; +}; + +const TokenSchemaBridge: z.ZodType> = z + .unknown() + .transform((value) => normalizeTokenInput(value)) + .superRefine((value, ctx) => { + const result = TokenSchema.safeParse(value); + if (!result.success) { + ctx.addIssue({ code: z.ZodIssueCode.custom, message: result.error.message }); + } + }) + .transform((value) => TokenSchema.parse(value)); + +const PerpetualMarketSchema = z.object({ + marketToken: TokenIdentifierSchema, + longFundingFee: z.string(), + shortFundingFee: z.string(), + longBorrowingFee: z.string(), + shortBorrowingFee: z.string(), + chainId: z.string(), + name: z.string(), + // Some markets returned by onchain-actions omit indexToken. Keep the boundary + // validation, but allow skipping incomplete markets in our selection logic. + indexToken: TokenSchemaBridge.optional(), + longToken: TokenSchemaBridge.optional(), + shortToken: TokenSchemaBridge.optional(), +}); +export type PerpetualMarket = z.infer; + +const PerpetualMarketsResponseSchema = PaginationSchema.extend({ + markets: z.array(PerpetualMarketSchema), +}); + +const PerpetualPositionSchema = z.object({ + chainId: z.string(), + key: z.string(), + contractKey: z.string(), + account: z.string(), + marketAddress: z.string(), + sizeInUsd: z.string(), + sizeInTokens: z.string(), + collateralAmount: z.string(), + pendingBorrowingFeesUsd: z.string(), + increasedAtTime: z.string(), + decreasedAtTime: z.string(), + positionSide: z.enum(['long', 'short']), + isLong: z.boolean(), + fundingFeeAmount: z.string(), + claimableLongTokenAmount: z.string(), + claimableShortTokenAmount: z.string(), + isOpening: z.boolean().optional(), + pnl: z.string(), + positionFeeAmount: z.string(), + traderDiscountAmount: z.string(), + uiFeeAmount: z.string(), + data: z.string().optional(), + collateralToken: TokenSchemaBridge, +}); +export type PerpetualPosition = z.infer; + +const PerpetualPositionsResponseSchema = PaginationSchema.extend({ + positions: z.array(PerpetualPositionSchema), +}); + +const WalletBalanceSchema = z.object({ + tokenUid: TokenIdentifierSchema, + amount: z.string(), + symbol: z.string().optional(), + valueUsd: z.number().optional(), + decimals: z.number().int().optional(), +}); +export type WalletBalance = z.infer; + +const WalletBalancesResponseSchema = PaginationSchema.extend({ + balances: z.array(WalletBalanceSchema), +}); + +export const TransactionPlanSchema = z.object({ + type: z.string(), + to: z.string(), + data: z.string(), + value: z + .string() + .optional() + .transform((value) => value ?? '0'), + chainId: z.string(), +}); +export type TransactionPlan = z.infer; + +const PerpetualActionResponseSchema = z + .object({ + transactions: z.array(TransactionPlanSchema), + }) + .catchall(z.unknown()); +export type PerpetualActionResponse = z.infer; + +export class OnchainActionsRequestError extends Error { + readonly status: number; + readonly url: string; + readonly bodyText: string; + + constructor(params: { message: string; status: number; url: string; bodyText: string }) { + super(params.message); + this.name = 'OnchainActionsRequestError'; + this.status = params.status; + this.url = params.url; + this.bodyText = params.bodyText; + } +} + +export type PerpetualLongRequest = { + // REST API accepts token base units as a bigint-like decimal string + // (e.g., 10 USDC => "10000000" with 6 decimals). + amount: string; + walletAddress: `0x${string}`; + chainId: string; + marketAddress: string; + payTokenAddress: string; + collateralTokenAddress: string; + leverage: string; + referralCode?: string; + limitPrice?: string; +}; + +export type PerpetualShortRequest = PerpetualLongRequest; + +export type PerpetualCloseRequest = { + walletAddress: `0x${string}`; + marketAddress: string; + positionSide?: 'long' | 'short'; + isLimit?: boolean; +}; + +export type PerpetualReduceRequest = { + walletAddress: `0x${string}`; + key: string; + // onchain-actions expects a bigint-like decimal string (GMX USD units, 30 decimals). + sizeDeltaUsd: string; + providerName?: string; +}; + +export class OnchainActionsClient { + constructor(private readonly baseUrl: string) {} + + private buildQuery(params: Record): URLSearchParams { + const query = new URLSearchParams(); + for (const [key, value] of Object.entries(params)) { + if (!value) { + continue; + } + if (Array.isArray(value)) { + for (const item of value) { + query.append(key, item); + } + continue; + } + query.set(key, value); + } + return query; + } + + private async fetchEndpoint( + endpoint: string, + schema: z.ZodType, + init?: RequestInit, + ): Promise { + const url = `${this.baseUrl}${endpoint}`; + const response = await fetch(url, { + ...init, + signal: init?.signal ?? AbortSignal.timeout(HTTP_TIMEOUT_MS), + headers: { + 'Content-Type': 'application/json', + ...(init?.headers ?? {}), + }, + }); + + if (!response.ok) { + const text = await response.text().catch(() => 'No error body'); + throw new OnchainActionsRequestError({ + message: `Onchain actions request failed (${response.status}): ${text}`, + status: response.status, + url, + bodyText: text, + }); + } + + return schema.parse(await response.json()); + } + + async listPerpetualMarkets(params?: { chainIds?: string[] }): Promise { + const baseQuery = this.buildQuery({ + chainIds: params?.chainIds, + }); + const endpoint = baseQuery.toString() + ? `/perpetuals/markets?${baseQuery.toString()}` + : '/perpetuals/markets'; + const firstPage = await this.fetchEndpoint(endpoint, PerpetualMarketsResponseSchema); + const markets = [...firstPage.markets]; + const cursor = firstPage.cursor ?? undefined; + if (!cursor || firstPage.totalPages <= 1) { + return markets; + } + + for (let page = 2; page <= firstPage.totalPages; page += 1) { + const query = this.buildQuery({ + chainIds: params?.chainIds, + cursor, + page: page.toString(), + }); + const pageEndpoint = `/perpetuals/markets?${query.toString()}`; + const data = await this.fetchEndpoint(pageEndpoint, PerpetualMarketsResponseSchema); + markets.push(...data.markets); + } + return markets; + } + + async listPerpetualPositions(params: { + walletAddress: `0x${string}`; + chainIds?: string[]; + }): Promise { + const baseQuery = this.buildQuery({ + chainIds: params.chainIds, + }); + const endpoint = baseQuery.toString() + ? `/perpetuals/positions/${params.walletAddress}?${baseQuery.toString()}` + : `/perpetuals/positions/${params.walletAddress}`; + const firstPage = await this.fetchEndpoint(endpoint, PerpetualPositionsResponseSchema); + const positions = [...firstPage.positions]; + const cursor = firstPage.cursor ?? undefined; + if (!cursor || firstPage.totalPages <= 1) { + return positions; + } + + for (let page = 2; page <= firstPage.totalPages; page += 1) { + const query = this.buildQuery({ + chainIds: params.chainIds, + cursor, + page: page.toString(), + }); + const pageEndpoint = `/perpetuals/positions/${params.walletAddress}?${query.toString()}`; + const data = await this.fetchEndpoint(pageEndpoint, PerpetualPositionsResponseSchema); + positions.push(...data.positions); + } + return positions; + } + + async listWalletBalances(params: { walletAddress: `0x${string}` }): Promise { + const endpoint = `/wallet/balances/${params.walletAddress}`; + const firstPage = await this.fetchEndpoint(endpoint, WalletBalancesResponseSchema); + const balances = [...firstPage.balances]; + const cursor = firstPage.cursor ?? undefined; + if (!cursor || firstPage.totalPages <= 1) { + return balances; + } + + for (let page = 2; page <= firstPage.totalPages; page += 1) { + const query = this.buildQuery({ + cursor, + page: page.toString(), + }); + const pageEndpoint = `/wallet/balances/${params.walletAddress}?${query.toString()}`; + const data = await this.fetchEndpoint(pageEndpoint, WalletBalancesResponseSchema); + balances.push(...data.balances); + } + + return balances; + } + + private stringifyPayload(value: unknown): string { + return JSON.stringify(value, (_key: string, item: unknown): unknown => + typeof item === 'bigint' ? item.toString() : item, + ); + } + + async createPerpetualLong(request: PerpetualLongRequest): Promise { + return this.fetchEndpoint('/perpetuals/long', PerpetualActionResponseSchema, { + method: 'POST', + body: this.stringifyPayload(request), + }); + } + + async createPerpetualShort(request: PerpetualShortRequest): Promise { + return this.fetchEndpoint('/perpetuals/short', PerpetualActionResponseSchema, { + method: 'POST', + body: this.stringifyPayload(request), + }); + } + + async createPerpetualClose(request: PerpetualCloseRequest): Promise { + return this.fetchEndpoint('/perpetuals/close', PerpetualActionResponseSchema, { + method: 'POST', + body: this.stringifyPayload(request), + }); + } + + async createPerpetualReduce(request: PerpetualReduceRequest): Promise { + return this.fetchEndpoint('/perpetuals/reduce', PerpetualActionResponseSchema, { + method: 'POST', + body: this.stringifyPayload(request), + }); + } +} + +export type { TokenIdentifier }; diff --git a/typescript/clients/web-ag-ui/apps/agent-gmx-allora/src/clients/onchainActions.unit.test.ts b/typescript/clients/web-ag-ui/apps/agent-gmx-allora/src/clients/onchainActions.unit.test.ts new file mode 100644 index 000000000..25f24b233 --- /dev/null +++ b/typescript/clients/web-ag-ui/apps/agent-gmx-allora/src/clients/onchainActions.unit.test.ts @@ -0,0 +1,338 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { OnchainActionsClient } from './onchainActions.js'; + +describe('OnchainActionsClient', () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it('lists perpetual markets across paginated responses', async () => { + const fetchMock = vi + .fn() + .mockResolvedValueOnce( + new Response( + JSON.stringify({ + markets: [ + { + marketToken: { chainId: '42161', address: '0xmarket1' }, + longFundingFee: '0.01', + shortFundingFee: '0.02', + longBorrowingFee: '0.03', + shortBorrowingFee: '0.04', + chainId: '42161', + name: 'GMX BTC/USD', + indexToken: { + tokenUid: { chainId: '42161', address: '0xbtc' }, + name: 'Bitcoin', + symbol: 'BTC', + isNative: false, + decimals: 8, + iconUri: null, + isVetted: true, + }, + longToken: { + tokenUid: { chainId: '42161', address: '0xusdc' }, + name: 'USD Coin', + symbol: 'USDC', + isNative: false, + decimals: 6, + iconUri: null, + isVetted: true, + }, + shortToken: { + tokenUid: { chainId: '42161', address: '0xusdc' }, + name: 'USD Coin', + symbol: 'USDC', + isNative: false, + decimals: 6, + iconUri: null, + isVetted: true, + }, + }, + ], + cursor: 'next', + currentPage: 1, + totalPages: 2, + totalItems: 2, + }), + { status: 200, headers: { 'Content-Type': 'application/json' } }, + ), + ) + .mockResolvedValueOnce( + new Response( + JSON.stringify({ + markets: [ + { + marketToken: { chainId: '42161', address: '0xmarket2' }, + longFundingFee: '0.01', + shortFundingFee: '0.02', + longBorrowingFee: '0.03', + shortBorrowingFee: '0.04', + chainId: '42161', + name: 'GMX ETH/USD', + indexToken: { + tokenUid: { chainId: '42161', address: '0xeth' }, + name: 'Ether', + symbol: 'ETH', + isNative: false, + decimals: 18, + iconUri: null, + isVetted: true, + }, + longToken: { + tokenUid: { chainId: '42161', address: '0xusdc' }, + name: 'USD Coin', + symbol: 'USDC', + isNative: false, + decimals: 6, + iconUri: null, + isVetted: true, + }, + shortToken: { + tokenUid: { chainId: '42161', address: '0xusdc' }, + name: 'USD Coin', + symbol: 'USDC', + isNative: false, + decimals: 6, + iconUri: null, + isVetted: true, + }, + }, + ], + cursor: 'next', + currentPage: 2, + totalPages: 2, + totalItems: 2, + }), + { status: 200, headers: { 'Content-Type': 'application/json' } }, + ), + ); + + vi.stubGlobal('fetch', fetchMock); + + const client = new OnchainActionsClient('https://api.example.test'); + const markets = await client.listPerpetualMarkets({ chainIds: ['42161'] }); + + expect(markets).toHaveLength(2); + expect(markets[0]?.name).toBe('GMX BTC/USD'); + expect(markets[1]?.name).toBe('GMX ETH/USD'); + }); + + it('posts perpetual long requests', async () => { + const fetchMock = vi.fn( + () => + new Response( + JSON.stringify({ + transactions: [ + { + type: 'evm', + to: '0xrouter', + data: '0xdeadbeef', + // Intentionally omit `value` so the client normalizes to "0". + chainId: '42161', + }, + ], + }), + { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }, + ), + ); + vi.stubGlobal('fetch', fetchMock); + + const client = new OnchainActionsClient('https://api.example.test'); + const response = await client.createPerpetualLong({ + amount: '100', + walletAddress: '0x0000000000000000000000000000000000000001', + chainId: '42161', + marketAddress: '0xmarket', + payTokenAddress: '0xusdc', + collateralTokenAddress: '0xusdc', + leverage: '2', + }); + + const requestInit = fetchMock.mock.calls[0]?.[1] as RequestInit | undefined; + expect(requestInit?.method).toBe('POST'); + + const transactions = (response as { transactions?: Array<{ value?: string }> }).transactions; + expect(transactions?.[0]?.value).toBe('0'); + }); + + it('posts perpetual close requests', async () => { + const fetchMock = vi.fn( + () => + new Response(JSON.stringify({ transactions: [] }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }), + ); + vi.stubGlobal('fetch', fetchMock); + + const client = new OnchainActionsClient('https://api.example.test'); + await client.createPerpetualClose({ + walletAddress: '0x0000000000000000000000000000000000000001', + marketAddress: '0xmarket', + positionSide: 'long', + isLimit: false, + }); + + const requestInit = fetchMock.mock.calls[0]?.[1] as RequestInit | undefined; + expect(requestInit?.method).toBe('POST'); + }); + + it('posts perpetual reduce requests', async () => { + const fetchMock = vi.fn( + () => + new Response(JSON.stringify({ transactions: [] }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }), + ); + vi.stubGlobal('fetch', fetchMock); + + const client = new OnchainActionsClient('https://api.example.test'); + await client.createPerpetualReduce({ + walletAddress: '0x0000000000000000000000000000000000000001', + key: '0xposition', + sizeDeltaUsd: '1000000000000000000000000000000', + }); + + const [url, requestInit] = fetchMock.mock.calls[0] ?? []; + expect(url).toBe('https://api.example.test/perpetuals/reduce'); + expect((requestInit as RequestInit | undefined)?.method).toBe('POST'); + }); + + it('lists perpetual positions across paginated responses', async () => { + const fetchMock = vi + .fn() + .mockResolvedValueOnce( + new Response( + JSON.stringify({ + positions: [ + { + chainId: '42161', + key: '0xpos1', + contractKey: '0xcontract', + account: '0xwallet', + marketAddress: '0xmarket', + sizeInUsd: '100', + sizeInTokens: '0.01', + collateralAmount: '50', + pendingBorrowingFeesUsd: '0', + increasedAtTime: '0', + decreasedAtTime: '0', + positionSide: 'long', + isLong: true, + fundingFeeAmount: '0', + claimableLongTokenAmount: '0', + claimableShortTokenAmount: '0', + isOpening: false, + pnl: '0', + positionFeeAmount: '0', + traderDiscountAmount: '0', + uiFeeAmount: '0', + collateralToken: { + tokenUid: { chainId: '42161', address: '0xusdc' }, + name: 'USD Coin', + symbol: 'USDC', + isNative: false, + decimals: 6, + iconUri: null, + isVetted: true, + }, + }, + ], + cursor: 'next', + currentPage: 1, + totalPages: 2, + totalItems: 2, + }), + { status: 200, headers: { 'Content-Type': 'application/json' } }, + ), + ) + .mockResolvedValueOnce( + new Response( + JSON.stringify({ + positions: [ + { + chainId: '42161', + key: '0xpos2', + contractKey: '0xcontract', + account: '0xwallet', + marketAddress: '0xmarket', + sizeInUsd: '200', + sizeInTokens: '0.02', + collateralAmount: '100', + pendingBorrowingFeesUsd: '0', + increasedAtTime: '0', + decreasedAtTime: '0', + positionSide: 'short', + isLong: false, + fundingFeeAmount: '0', + claimableLongTokenAmount: '0', + claimableShortTokenAmount: '0', + isOpening: false, + pnl: '0', + positionFeeAmount: '0', + traderDiscountAmount: '0', + uiFeeAmount: '0', + collateralToken: { + tokenUid: { chainId: '42161', address: '0xusdc' }, + name: 'USD Coin', + symbol: 'USDC', + isNative: false, + decimals: 6, + iconUri: null, + isVetted: true, + }, + }, + ], + cursor: 'next', + currentPage: 2, + totalPages: 2, + totalItems: 2, + }), + { status: 200, headers: { 'Content-Type': 'application/json' } }, + ), + ); + + vi.stubGlobal('fetch', fetchMock); + + const client = new OnchainActionsClient('https://api.example.test'); + const positions = await client.listPerpetualPositions({ + walletAddress: '0x0000000000000000000000000000000000000001', + chainIds: ['42161'], + }); + + expect(positions).toHaveLength(2); + expect(positions[0]?.key).toBe('0xpos1'); + expect(positions[1]?.key).toBe('0xpos2'); + }); + + it('raises an error when the API returns a non-200 response', async () => { + const fetchMock = vi.fn( + () => + new Response('bad request', { + status: 400, + headers: { 'Content-Type': 'text/plain' }, + }), + ); + vi.stubGlobal('fetch', fetchMock); + + const client = new OnchainActionsClient('https://api.example.test'); + + await expect( + client.createPerpetualLong({ + amount: '100', + walletAddress: '0x0000000000000000000000000000000000000001', + chainId: '42161', + marketAddress: '0xmarket', + payTokenAddress: '0xusdc', + collateralTokenAddress: '0xusdc', + leverage: '2', + }), + ).rejects.toThrow('Onchain actions request failed (400)'); + }); +}); diff --git a/typescript/clients/web-ag-ui/apps/agent-gmx-allora/src/config/constants.ts b/typescript/clients/web-ag-ui/apps/agent-gmx-allora/src/config/constants.ts index 6ed826d1a..ff970bd29 100644 --- a/typescript/clients/web-ag-ui/apps/agent-gmx-allora/src/config/constants.ts +++ b/typescript/clients/web-ag-ui/apps/agent-gmx-allora/src/config/constants.ts @@ -1,6 +1,255 @@ +import { privateKeyToAccount } from 'viem/accounts'; + export const ARBITRUM_CHAIN_ID = 42161; -const DEFAULT_POLL_INTERVAL_MS = 5_000; +const DEFAULT_ONCHAIN_ACTIONS_API_URL = 'https://api.emberai.xyz'; +const DEFAULT_ALLORA_API_BASE_URL = 'https://api.allora.network'; +const DEFAULT_E2E_PROFILE: E2EProfile = 'live'; +// Allora Consumer API expects a "signature format" / target chain slug. +// Docs commonly use Sepolia: "ethereum-11155111". +// Production deployments should override via ALLORA_CHAIN_ID. +const DEFAULT_ALLORA_CHAIN_ID = 'ethereum-11155111'; +const DEFAULT_ALLORA_INFERENCE_CACHE_TTL_MS = 30_000; +const DEFAULT_ALLORA_8H_INFERENCE_CACHE_TTL_MS = 30_000; +const DEFAULT_GMX_ALLORA_MODE: GmxAlloraMode = 'production'; +const DEFAULT_GMX_ALLORA_TX_EXECUTION_MODE: GmxAlloraTxExecutionMode = 'plan'; +const DEFAULT_DELEGATIONS_BYPASS = false; + +export type GmxAlloraMode = 'debug' | 'production'; +export type GmxAlloraTxExecutionMode = 'plan' | 'execute'; +export type E2EProfile = 'mocked' | 'live'; + +type OnchainActionsBaseUrlLogger = (message: string, metadata?: Record) => void; + +type OnchainActionsBaseUrlOptions = { + endpoint?: string; + logger?: OnchainActionsBaseUrlLogger; +}; + +export function resolveOnchainActionsApiUrl(options?: OnchainActionsBaseUrlOptions): string { + const envUrl = process.env['ONCHAIN_ACTIONS_API_URL']; + const rawEndpoint = options?.endpoint ?? envUrl ?? DEFAULT_ONCHAIN_ACTIONS_API_URL; + + const source = options?.endpoint + ? 'override' + : envUrl + ? 'ONCHAIN_ACTIONS_API_URL' + : 'default'; + + const endpoint = rawEndpoint.replace(/\/$/u, ''); + const isOpenApi = endpoint.endsWith('/openapi.json'); + const baseUrl = isOpenApi ? endpoint.replace(/\/openapi\.json$/u, '') : endpoint; + + if (options?.logger && source !== 'default') { + if (isOpenApi) { + options.logger('Normalized onchain-actions endpoint from OpenAPI spec URL', { + endpoint, + baseUrl, + source, + }); + } else if (baseUrl !== DEFAULT_ONCHAIN_ACTIONS_API_URL) { + options.logger('Using custom onchain-actions base URL', { baseUrl, source }); + } + } + + return baseUrl; +} + +export const ONCHAIN_ACTIONS_API_URL = resolveOnchainActionsApiUrl(); + +export function resolveAlloraApiBaseUrl(): string { + return process.env['ALLORA_API_BASE_URL']?.replace(/\/$/u, '') ?? DEFAULT_ALLORA_API_BASE_URL; +} + +export function resolveAlloraApiKey(): string | undefined { + return process.env['ALLORA_API_KEY']; +} + +export function resolveAlloraChainId(): string { + return process.env['ALLORA_CHAIN_ID']?.trim() || DEFAULT_ALLORA_CHAIN_ID; +} + +export function resolveE2EProfile(): E2EProfile { + const raw = process.env['E2E_PROFILE']; + if (!raw) { + return DEFAULT_E2E_PROFILE; + } + + const normalized = raw.trim().toLowerCase(); + if (normalized === 'mocked') { + return 'mocked'; + } + if (normalized === 'live') { + return 'live'; + } + return DEFAULT_E2E_PROFILE; +} + +export function resolveAlloraInferenceCacheTtlMs(): number { + const raw = process.env['ALLORA_INFERENCE_CACHE_TTL_MS']; + if (!raw) { + return DEFAULT_ALLORA_INFERENCE_CACHE_TTL_MS; + } + + const parsed = Number(raw); + // Allow disabling caching by setting <= 0 or invalid values. + if (!Number.isFinite(parsed) || parsed <= 0) { + return 0; + } + return Math.trunc(parsed); +} + +export function resolveAllora8hInferenceCacheTtlMs(): number { + const raw = process.env['ALLORA_8H_INFERENCE_CACHE_TTL_MS']; + if (!raw) { + return DEFAULT_ALLORA_8H_INFERENCE_CACHE_TTL_MS; + } + + const parsed = Number(raw); + if (!Number.isFinite(parsed) || parsed <= 0) { + return 0; + } + return Math.trunc(parsed); +} + +export const ALLORA_HORIZON_HOURS = 8; +export type AlloraTopicInferenceType = 'Log-Return' | 'Price'; + +export type AlloraTopicWhitelistEntry = { + topicId: number; + pair: `${string}/USD`; + horizonHours: 8 | 24; + inferenceType: AlloraTopicInferenceType; +}; + +export const ALLORA_TOPIC_WHITELIST: readonly AlloraTopicWhitelistEntry[] = [ + { topicId: 1, pair: 'BTC/USD', horizonHours: 8, inferenceType: 'Log-Return' }, + { topicId: 3, pair: 'SOL/USD', horizonHours: 8, inferenceType: 'Log-Return' }, + { topicId: 14, pair: 'BTC/USD', horizonHours: 8, inferenceType: 'Price' }, + { topicId: 19, pair: 'NEAR/USD', horizonHours: 8, inferenceType: 'Log-Return' }, + { topicId: 2, pair: 'ETH/USD', horizonHours: 24, inferenceType: 'Log-Return' }, + { topicId: 16, pair: 'ETH/USD', horizonHours: 24, inferenceType: 'Log-Return' }, + { topicId: 2, pair: 'ETH/USD', horizonHours: 8, inferenceType: 'Log-Return' }, + { topicId: 17, pair: 'SOL/USD', horizonHours: 24, inferenceType: 'Log-Return' }, + { topicId: 10, pair: 'SOL/USD', horizonHours: 8, inferenceType: 'Price' }, +] as const; + +function buildTopicLabel(entry: AlloraTopicWhitelistEntry): string { + return `${entry.pair} - ${entry.inferenceType} - ${entry.horizonHours}h`; +} + +function getWhitelistedTopicOrThrow( + topicId: number, + horizonHours?: AlloraTopicWhitelistEntry['horizonHours'], +): AlloraTopicWhitelistEntry { + const whitelisted = ALLORA_TOPIC_WHITELIST.find((entry) => { + if (entry.topicId !== topicId) { + return false; + } + if (horizonHours === undefined) { + return true; + } + return entry.horizonHours === horizonHours; + }); + if (!whitelisted) { + const horizonSuffix = horizonHours ? ` (${horizonHours}h)` : ''; + throw new Error(`Allora topic ${topicId}${horizonSuffix} is not in whitelist.`); + } + return whitelisted; +} + +export const ALLORA_TOPIC_IDS = { + BTC: 14, + ETH: 2, +} as const; + +export const ALLORA_TOPIC_LABELS = { + BTC: buildTopicLabel(getWhitelistedTopicOrThrow(ALLORA_TOPIC_IDS.BTC, ALLORA_HORIZON_HOURS)), + ETH: buildTopicLabel(getWhitelistedTopicOrThrow(ALLORA_TOPIC_IDS.ETH, ALLORA_HORIZON_HOURS)), +} as const; + +export function resolveDelegationsBypass(): boolean { + const raw = process.env['DELEGATIONS_BYPASS']; + if (!raw) { + return DEFAULT_DELEGATIONS_BYPASS; + } + const normalized = raw.trim().toLowerCase(); + return normalized === 'true' || normalized === '1' || normalized === 'yes'; +} + +export function resolveGmxAlloraMode(): GmxAlloraMode { + const raw = process.env['GMX_ALLORA_MODE']; + if (!raw) { + return DEFAULT_GMX_ALLORA_MODE; + } + + const normalized = raw.trim().toLowerCase(); + if (normalized === 'debug') { + return 'debug'; + } + if (normalized === 'production') { + return 'production'; + } + return DEFAULT_GMX_ALLORA_MODE; +} + +function normalizeHexAddress(value: string, label: string): `0x${string}` { + if (!value.startsWith('0x')) { + throw new Error(`Invalid ${label}: ${value}`); + } + return value.toLowerCase() as `0x${string}`; +} + +export function resolveAgentWalletAddress(): `0x${string}` { + const explicitAddress = process.env['GMX_ALLORA_AGENT_WALLET_ADDRESS']; + if (explicitAddress) { + const normalized = normalizeHexAddress(explicitAddress.trim(), 'GMX_ALLORA_AGENT_WALLET_ADDRESS'); + + const rawPrivateKey = process.env['A2A_TEST_AGENT_NODE_PRIVATE_KEY']; + if (rawPrivateKey) { + const privateKey = normalizeHexAddress(rawPrivateKey.trim(), 'A2A_TEST_AGENT_NODE_PRIVATE_KEY'); + const derived = privateKeyToAccount(privateKey).address.toLowerCase() as `0x${string}`; + if (derived !== normalized) { + throw new Error( + `GMX_ALLORA_AGENT_WALLET_ADDRESS (${normalized}) does not match A2A_TEST_AGENT_NODE_PRIVATE_KEY address (${derived}).`, + ); + } + } + + return normalized; + } + + const rawPrivateKey = process.env['A2A_TEST_AGENT_NODE_PRIVATE_KEY']; + if (!rawPrivateKey) { + throw new Error( + 'Missing agent wallet configuration. Set GMX_ALLORA_AGENT_WALLET_ADDRESS (address only) or A2A_TEST_AGENT_NODE_PRIVATE_KEY (0x + 64 hex chars).', + ); + } + const privateKey = normalizeHexAddress(rawPrivateKey.trim(), 'A2A_TEST_AGENT_NODE_PRIVATE_KEY'); + const account = privateKeyToAccount(privateKey); + return account.address.toLowerCase() as `0x${string}`; +} + +export function resolveGmxAlloraTxExecutionMode(): GmxAlloraTxExecutionMode { + const raw = process.env['GMX_ALLORA_TX_SUBMISSION_MODE']; + if (!raw) { + return DEFAULT_GMX_ALLORA_TX_EXECUTION_MODE; + } + + const normalized = raw.trim().toLowerCase(); + if (normalized === 'plan') { + return 'plan'; + } + + // Support both "submit" (documented) and "execute" (consistent with other agents). + if (normalized === 'submit' || normalized === 'execute') { + return 'execute'; + } + + return DEFAULT_GMX_ALLORA_TX_EXECUTION_MODE; +} + +const DEFAULT_POLL_INTERVAL_MS = 1_800_000; const DEFAULT_STREAM_LIMIT = -1; const DEFAULT_STATE_HISTORY_LIMIT = 100; diff --git a/typescript/clients/web-ag-ui/apps/agent-gmx-allora/src/config/constants.unit.test.ts b/typescript/clients/web-ag-ui/apps/agent-gmx-allora/src/config/constants.unit.test.ts new file mode 100644 index 000000000..c37ebe4a1 --- /dev/null +++ b/typescript/clients/web-ag-ui/apps/agent-gmx-allora/src/config/constants.unit.test.ts @@ -0,0 +1,184 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { + ALLORA_TOPIC_LABELS, + ALLORA_TOPIC_WHITELIST, + resolveAgentWalletAddress, + resolveDelegationsBypass, + resolveE2EProfile, + resolveGmxAlloraMode, + resolveGmxAlloraTxExecutionMode, + resolveOnchainActionsApiUrl, + resolvePollIntervalMs, +} from './constants.js'; + +describe('config/constants', () => { + it('normalizes the OpenAPI endpoint to a base URL and logs the change', () => { + process.env.ONCHAIN_ACTIONS_API_URL = 'https://api.emberai.xyz/openapi.json'; + + const logger = vi.fn(); + const baseUrl = resolveOnchainActionsApiUrl({ logger }); + + expect(baseUrl).toBe('https://api.emberai.xyz'); + expect(logger).toHaveBeenCalledWith( + 'Normalized onchain-actions endpoint from OpenAPI spec URL', + expect.objectContaining({ + endpoint: 'https://api.emberai.xyz/openapi.json', + baseUrl: 'https://api.emberai.xyz', + source: 'ONCHAIN_ACTIONS_API_URL', + }), + ); + }); + + it('returns the trimmed base URL for explicit overrides and logs the override', () => { + process.env.ONCHAIN_ACTIONS_API_URL = 'https://api.example.test/'; + + const logger = vi.fn(); + const baseUrl = resolveOnchainActionsApiUrl({ logger }); + + expect(baseUrl).toBe('https://api.example.test'); + expect(logger).toHaveBeenCalledWith( + 'Using custom onchain-actions base URL', + expect.objectContaining({ + baseUrl: 'https://api.example.test', + source: 'ONCHAIN_ACTIONS_API_URL', + }), + ); + }); + + it('uses defaults without logging when no overrides are supplied', () => { + delete process.env.ONCHAIN_ACTIONS_API_URL; + + const logger = vi.fn(); + const baseUrl = resolveOnchainActionsApiUrl({ logger }); + + expect(baseUrl).toBe('https://api.emberai.xyz'); + expect(logger).not.toHaveBeenCalled(); + }); + + it('defaults poll interval to 30 minutes', () => { + delete process.env.GMX_ALLORA_POLL_INTERVAL_MS; + expect(resolvePollIntervalMs()).toBe(1_800_000); + }); + + it('defaults to plan mode for transaction execution', () => { + delete process.env.GMX_ALLORA_TX_SUBMISSION_MODE; + + expect(resolveGmxAlloraTxExecutionMode()).toBe('plan'); + }); + + it('uses execute mode when submission mode is submit', () => { + process.env.GMX_ALLORA_TX_SUBMISSION_MODE = 'submit'; + + expect(resolveGmxAlloraTxExecutionMode()).toBe('execute'); + }); + + it('uses plan mode when submission mode is plan', () => { + process.env.GMX_ALLORA_TX_SUBMISSION_MODE = 'plan'; + + expect(resolveGmxAlloraTxExecutionMode()).toBe('plan'); + }); + + it('defaults E2E profile to live', () => { + delete process.env.E2E_PROFILE; + expect(resolveE2EProfile()).toBe('live'); + }); + + it('accepts mocked E2E profile', () => { + process.env.E2E_PROFILE = 'mocked'; + expect(resolveE2EProfile()).toBe('mocked'); + }); + + it('falls back to live for unknown E2E profile values', () => { + process.env.E2E_PROFILE = 'something-else'; + expect(resolveE2EProfile()).toBe('live'); + }); + + it('parses delegations bypass flag', () => { + delete process.env.DELEGATIONS_BYPASS; + expect(resolveDelegationsBypass()).toBe(false); + + process.env.DELEGATIONS_BYPASS = 'true'; + expect(resolveDelegationsBypass()).toBe(true); + + process.env.DELEGATIONS_BYPASS = 'TRUE'; + expect(resolveDelegationsBypass()).toBe(true); + + process.env.DELEGATIONS_BYPASS = '1'; + expect(resolveDelegationsBypass()).toBe(true); + + process.env.DELEGATIONS_BYPASS = 'yes'; + expect(resolveDelegationsBypass()).toBe(true); + + process.env.DELEGATIONS_BYPASS = 'false'; + expect(resolveDelegationsBypass()).toBe(false); + }); + + it('defaults GMX mode to production', () => { + delete process.env.GMX_ALLORA_MODE; + expect(resolveGmxAlloraMode()).toBe('production'); + }); + + it('parses GMX mode from environment', () => { + process.env.GMX_ALLORA_MODE = 'debug'; + expect(resolveGmxAlloraMode()).toBe('debug'); + + process.env.GMX_ALLORA_MODE = 'production'; + expect(resolveGmxAlloraMode()).toBe('production'); + }); + + it('falls back to production for unknown GMX mode values', () => { + process.env.GMX_ALLORA_MODE = 'staging'; + expect(resolveGmxAlloraMode()).toBe('production'); + }); + + it('resolves agent wallet address from explicit address env var', () => { + process.env.GMX_ALLORA_AGENT_WALLET_ADDRESS = '0xAbCd000000000000000000000000000000000000'; + delete process.env.A2A_TEST_AGENT_NODE_PRIVATE_KEY; + + expect(resolveAgentWalletAddress()).toBe('0xabcd000000000000000000000000000000000000'); + }); + + it('throws when explicit agent wallet address does not match private key', () => { + process.env.GMX_ALLORA_AGENT_WALLET_ADDRESS = '0x0000000000000000000000000000000000000001'; + process.env.A2A_TEST_AGENT_NODE_PRIVATE_KEY = `0x${'1'.repeat(64)}`; + + expect(() => resolveAgentWalletAddress()).toThrow(/does not match A2A_TEST_AGENT_NODE_PRIVATE_KEY/u); + }); + + it('resolves agent wallet address from private key when address is not provided', () => { + delete process.env.GMX_ALLORA_AGENT_WALLET_ADDRESS; + process.env.A2A_TEST_AGENT_NODE_PRIVATE_KEY = `0x${'1'.repeat(64)}`; + + const resolved = resolveAgentWalletAddress(); + expect(resolved).toMatch(/^0x[0-9a-f]{40}$/u); + }); + + it('throws when no agent wallet configuration is available', () => { + delete process.env.GMX_ALLORA_AGENT_WALLET_ADDRESS; + delete process.env.A2A_TEST_AGENT_NODE_PRIVATE_KEY; + + expect(() => resolveAgentWalletAddress()).toThrow(/Missing agent wallet configuration/u); + }); + + it('contains the curated Allora topic whitelist entries', () => { + expect(ALLORA_TOPIC_WHITELIST).toEqual( + expect.arrayContaining([ + { topicId: 1, pair: 'BTC/USD', horizonHours: 8, inferenceType: 'Log-Return' }, + { topicId: 3, pair: 'SOL/USD', horizonHours: 8, inferenceType: 'Log-Return' }, + { topicId: 14, pair: 'BTC/USD', horizonHours: 8, inferenceType: 'Price' }, + { topicId: 19, pair: 'NEAR/USD', horizonHours: 8, inferenceType: 'Log-Return' }, + { topicId: 2, pair: 'ETH/USD', horizonHours: 24, inferenceType: 'Log-Return' }, + { topicId: 16, pair: 'ETH/USD', horizonHours: 24, inferenceType: 'Log-Return' }, + { topicId: 2, pair: 'ETH/USD', horizonHours: 8, inferenceType: 'Log-Return' }, + { topicId: 17, pair: 'SOL/USD', horizonHours: 24, inferenceType: 'Log-Return' }, + { topicId: 10, pair: 'SOL/USD', horizonHours: 8, inferenceType: 'Price' }, + ]), + ); + }); + + it('uses whitelist metadata for active topic labels', () => { + expect(ALLORA_TOPIC_LABELS.BTC).toBe('BTC/USD - Price - 8h'); + expect(ALLORA_TOPIC_LABELS.ETH).toBe('ETH/USD - Log-Return - 8h'); + }); +}); diff --git a/typescript/clients/web-ag-ui/apps/agent-gmx-allora/src/config/serviceConfig.ts b/typescript/clients/web-ag-ui/apps/agent-gmx-allora/src/config/serviceConfig.ts index 12b74505c..8401b6530 100644 --- a/typescript/clients/web-ag-ui/apps/agent-gmx-allora/src/config/serviceConfig.ts +++ b/typescript/clients/web-ag-ui/apps/agent-gmx-allora/src/config/serviceConfig.ts @@ -85,9 +85,7 @@ export function resolveLangGraphDefaults(): LangGraphDefaults { return cachedDefaults; } -export function resolveLangGraphDurability( - override?: LangGraphDurability, -): LangGraphDurability { +export function resolveLangGraphDurability(override?: LangGraphDurability): LangGraphDurability { if (override) { return override; } diff --git a/typescript/clients/web-ag-ui/apps/agent-gmx-allora/src/core/alloraPrediction.ts b/typescript/clients/web-ag-ui/apps/agent-gmx-allora/src/core/alloraPrediction.ts new file mode 100644 index 000000000..f4d09b8c9 --- /dev/null +++ b/typescript/clients/web-ag-ui/apps/agent-gmx-allora/src/core/alloraPrediction.ts @@ -0,0 +1,36 @@ +import type { AlloraInference } from '../clients/allora.js'; +import type { AlloraPrediction } from '../domain/types.js'; + +type BuildAlloraPredictionParams = { + inference: AlloraInference; + currentPrice: number; + topic: string; + horizonHours: number; + now?: Date; +}; + +const clamp = (value: number, min: number, max: number) => Math.min(max, Math.max(min, value)); + +function deriveConfidence(inference: AlloraInference): number { + const values = inference.confidenceIntervalValues; + const lower = values[1] ?? values[0] ?? inference.combinedValue; + const upper = values[3] ?? values[values.length - 1] ?? inference.combinedValue; + const spread = Math.abs(upper - lower); + const normalizedSpread = spread / Math.max(Math.abs(inference.combinedValue), 1); + const confidence = clamp(1 - normalizedSpread, 0, 1); + return Number(confidence.toFixed(2)); +} + +export function buildAlloraPrediction(params: BuildAlloraPredictionParams): AlloraPrediction { + const direction = params.inference.combinedValue >= params.currentPrice ? 'up' : 'down'; + const confidence = deriveConfidence(params.inference); + + return { + topic: params.topic, + horizonHours: params.horizonHours, + confidence, + direction, + predictedPrice: params.inference.combinedValue, + timestamp: (params.now ?? new Date()).toISOString(), + }; +} diff --git a/typescript/clients/web-ag-ui/apps/agent-gmx-allora/src/core/alloraPrediction.unit.test.ts b/typescript/clients/web-ag-ui/apps/agent-gmx-allora/src/core/alloraPrediction.unit.test.ts new file mode 100644 index 000000000..2cf3667de --- /dev/null +++ b/typescript/clients/web-ag-ui/apps/agent-gmx-allora/src/core/alloraPrediction.unit.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, it } from 'vitest'; + +import type { AlloraInference } from '../clients/allora.js'; + +import { buildAlloraPrediction } from './alloraPrediction.js'; + +describe('buildAlloraPrediction', () => { + it('derives direction and confidence from inference vs current price', () => { + const inference: AlloraInference = { + topicId: 14, + combinedValue: 110, + confidenceIntervalValues: [100, 105, 110, 115, 120], + }; + + const prediction = buildAlloraPrediction({ + inference, + currentPrice: 100, + topic: 'BTC/USD - Price Prediction - 8h', + horizonHours: 8, + now: new Date('2026-02-05T12:00:00.000Z'), + }); + + expect(prediction).toEqual({ + topic: 'BTC/USD - Price Prediction - 8h', + horizonHours: 8, + confidence: 0.91, + direction: 'up', + predictedPrice: 110, + timestamp: '2026-02-05T12:00:00.000Z', + }); + }); +}); diff --git a/typescript/clients/web-ag-ui/apps/agent-gmx-allora/src/core/cycle.ts b/typescript/clients/web-ag-ui/apps/agent-gmx-allora/src/core/cycle.ts new file mode 100644 index 000000000..3590280b2 --- /dev/null +++ b/typescript/clients/web-ag-ui/apps/agent-gmx-allora/src/core/cycle.ts @@ -0,0 +1,62 @@ +import type { AlloraPrediction, GmxAlloraActionKind, GmxAlloraTelemetry } from '../domain/types.js'; + +import { decideTradeAction } from './decision.js'; + +type BuildCycleTelemetryParams = { + prediction: AlloraPrediction; + decisionThreshold: number; + cooldownCycles: number; + maxLeverage: number; + baseContributionUsd: number; + previousAction?: GmxAlloraActionKind; + previousSide?: 'long' | 'short'; + cyclesSinceTrade: number; + isFirstCycle: boolean; + iteration: number; + marketSymbol: string; + now?: Date; +}; + +function isTradeAction(action: GmxAlloraActionKind): action is 'open' | 'reduce' | 'close' { + return action === 'open' || action === 'reduce' || action === 'close'; +} + +export function buildCycleTelemetry(params: BuildCycleTelemetryParams): { + telemetry: GmxAlloraTelemetry; + nextCyclesSinceTrade: number; +} { + const cooldownRemaining = 0; + + const decision = decideTradeAction({ + prediction: params.prediction, + decisionThreshold: params.decisionThreshold, + cooldownRemaining, + maxLeverage: params.maxLeverage, + baseContributionUsd: params.baseContributionUsd, + previousAction: params.previousAction, + previousSide: params.previousSide, + }); + + const timestamp = (params.now ?? new Date()).toISOString(); + const telemetry: GmxAlloraTelemetry = { + cycle: params.iteration, + action: decision.action, + reason: decision.reason, + marketSymbol: params.marketSymbol, + side: isTradeAction(decision.action) ? decision.side : undefined, + leverage: isTradeAction(decision.action) ? decision.leverage : undefined, + sizeUsd: isTradeAction(decision.action) ? decision.sizeUsd : undefined, + prediction: params.prediction, + timestamp, + metrics: { + confidence: params.prediction.confidence, + decisionThreshold: params.decisionThreshold, + cooldownRemaining, + }, + }; + + return { + telemetry, + nextCyclesSinceTrade: isTradeAction(decision.action) ? 0 : params.cyclesSinceTrade + 1, + }; +} diff --git a/typescript/clients/web-ag-ui/apps/agent-gmx-allora/src/core/cycle.unit.test.ts b/typescript/clients/web-ag-ui/apps/agent-gmx-allora/src/core/cycle.unit.test.ts new file mode 100644 index 000000000..d61424db8 --- /dev/null +++ b/typescript/clients/web-ag-ui/apps/agent-gmx-allora/src/core/cycle.unit.test.ts @@ -0,0 +1,95 @@ +import { describe, expect, it } from 'vitest'; + +import type { AlloraPrediction } from '../domain/types.js'; + +import { buildCycleTelemetry } from './cycle.js'; + +describe('buildCycleTelemetry', () => { + it('returns trade telemetry even when cooldown counters are non-zero', () => { + const prediction: AlloraPrediction = { + topic: 'ETH/USD - Log-Return - 8h', + horizonHours: 8, + confidence: 0.75, + direction: 'down', + predictedPrice: 2400, + timestamp: '2026-02-05T12:00:00.000Z', + }; + + const result = buildCycleTelemetry({ + prediction, + decisionThreshold: 0.62, + cooldownCycles: 2, + baseContributionUsd: 100, + maxLeverage: 2, + previousAction: 'open', + previousSide: 'long', + cyclesSinceTrade: 1, + isFirstCycle: false, + iteration: 4, + marketSymbol: 'ETH/USDC', + now: new Date('2026-02-05T12:01:00.000Z'), + }); + + expect(result.telemetry).toEqual({ + cycle: 4, + action: 'close', + reason: 'Signal direction flipped to short; closing open position.', + marketSymbol: 'ETH/USDC', + side: 'short', + leverage: 2, + sizeUsd: 80, + prediction, + timestamp: '2026-02-05T12:01:00.000Z', + metrics: { + confidence: 0.75, + decisionThreshold: 0.62, + cooldownRemaining: 0, + }, + }); + expect(result.nextCyclesSinceTrade).toBe(0); + }); + + it('opens with capped leverage and safety buffer sizing when signal is strong', () => { + const prediction: AlloraPrediction = { + topic: 'BTC/USD - Price Prediction - 8h', + horizonHours: 8, + confidence: 0.81, + direction: 'up', + predictedPrice: 47000, + timestamp: '2026-02-05T12:00:00.000Z', + }; + + const result = buildCycleTelemetry({ + prediction, + decisionThreshold: 0.62, + cooldownCycles: 2, + baseContributionUsd: 200, + maxLeverage: 4, + previousAction: undefined, + previousSide: undefined, + cyclesSinceTrade: 3, + isFirstCycle: false, + iteration: 2, + marketSymbol: 'BTC/USDC', + now: new Date('2026-02-05T12:02:00.000Z'), + }); + + expect(result.telemetry).toEqual({ + cycle: 2, + action: 'open', + reason: 'Signal confidence 0.81 >= 0.62; opening long position.', + marketSymbol: 'BTC/USDC', + side: 'long', + leverage: 2, + sizeUsd: 160, + prediction, + timestamp: '2026-02-05T12:02:00.000Z', + metrics: { + confidence: 0.81, + decisionThreshold: 0.62, + cooldownRemaining: 0, + }, + }); + expect(result.nextCyclesSinceTrade).toBe(0); + }); +}); diff --git a/typescript/clients/web-ag-ui/apps/agent-gmx-allora/src/core/decision.ts b/typescript/clients/web-ag-ui/apps/agent-gmx-allora/src/core/decision.ts new file mode 100644 index 000000000..d9597afd7 --- /dev/null +++ b/typescript/clients/web-ag-ui/apps/agent-gmx-allora/src/core/decision.ts @@ -0,0 +1,69 @@ +import type { AlloraPrediction, GmxAlloraActionKind } from '../domain/types.js'; + +type DecideTradeActionParams = { + prediction: AlloraPrediction; + decisionThreshold: number; + cooldownRemaining: number; + maxLeverage: number; + baseContributionUsd: number; + previousAction?: GmxAlloraActionKind; + previousSide?: 'long' | 'short'; +}; + +type TradeDecision = { + action: GmxAlloraActionKind; + reason: string; + side?: 'long' | 'short'; + leverage?: number; + sizeUsd?: number; +}; + +const MAX_LEVERAGE_CAP = 2; +const SAFETY_BUFFER = 0.2; + +const formatNumber = (value: number) => String(value); + +export function decideTradeAction(params: DecideTradeActionParams): TradeDecision { + if (params.prediction.confidence < params.decisionThreshold) { + return { + action: 'hold', + reason: `Signal confidence ${formatNumber(params.prediction.confidence)} below threshold ${formatNumber( + params.decisionThreshold, + )}; holding position.`, + }; + } + + const side: 'long' | 'short' = params.prediction.direction === 'up' ? 'long' : 'short'; + const leverage = Math.min(params.maxLeverage, MAX_LEVERAGE_CAP); + const sizeUsd = Number((params.baseContributionUsd * (1 - SAFETY_BUFFER)).toFixed(2)); + + if (params.previousAction === 'open' && params.previousSide && params.previousSide !== side) { + return { + action: 'close', + side, + leverage, + sizeUsd, + reason: `Signal direction flipped to ${side}; closing open position.`, + }; + } + + if (params.previousAction === 'open' && params.previousSide === side) { + return { + action: 'hold', + side, + leverage, + sizeUsd, + reason: `Signal persists in ${side}; holding open position.`, + }; + } + + return { + action: 'open', + side, + leverage, + sizeUsd, + reason: `Signal confidence ${formatNumber(params.prediction.confidence)} >= ${formatNumber( + params.decisionThreshold, + )}; opening ${side} position.`, + }; +} diff --git a/typescript/clients/web-ag-ui/apps/agent-gmx-allora/src/core/decision.unit.test.ts b/typescript/clients/web-ag-ui/apps/agent-gmx-allora/src/core/decision.unit.test.ts new file mode 100644 index 000000000..ad40e40d4 --- /dev/null +++ b/typescript/clients/web-ag-ui/apps/agent-gmx-allora/src/core/decision.unit.test.ts @@ -0,0 +1,89 @@ +import { describe, expect, it } from 'vitest'; + +import type { AlloraPrediction } from '../domain/types.js'; + +import { decideTradeAction } from './decision.js'; + +describe('decideTradeAction', () => { + it('opens a position when confidence meets threshold and no cooldown is active', () => { + const prediction: AlloraPrediction = { + topic: 'BTC/USD - Price Prediction - 8h', + horizonHours: 8, + confidence: 0.8, + direction: 'up', + predictedPrice: 110, + timestamp: '2026-02-05T12:00:00.000Z', + }; + + const decision = decideTradeAction({ + prediction, + decisionThreshold: 0.62, + cooldownRemaining: 0, + maxLeverage: 2, + baseContributionUsd: 100, + previousAction: undefined, + previousSide: undefined, + }); + + expect(decision).toEqual({ + action: 'open', + side: 'long', + leverage: 2, + sizeUsd: 80, + reason: 'Signal confidence 0.8 >= 0.62; opening long position.', + }); + }); + + it('holds when a position is already open and the signal stays in the same direction', () => { + const prediction: AlloraPrediction = { + topic: 'BTC/USD - Price Prediction - 8h', + horizonHours: 8, + confidence: 0.9, + direction: 'up', + predictedPrice: 110, + timestamp: '2026-02-05T12:00:00.000Z', + }; + + const decision = decideTradeAction({ + prediction, + decisionThreshold: 0.62, + cooldownRemaining: 0, + maxLeverage: 2, + baseContributionUsd: 100, + previousAction: 'open', + previousSide: 'long', + }); + + expect(decision.action).toBe('hold'); + expect(decision.reason.toLowerCase()).toContain('persists'); + }); + + it('ignores cooldown counters and still returns a trade decision', () => { + const prediction: AlloraPrediction = { + topic: 'BTC/USD - Price - 8h', + horizonHours: 8, + confidence: 0.9, + direction: 'up', + predictedPrice: 110, + timestamp: '2026-02-05T12:00:00.000Z', + }; + + const decision = decideTradeAction({ + prediction, + decisionThreshold: 0.62, + cooldownRemaining: 2, + maxLeverage: 2, + baseContributionUsd: 100, + previousAction: undefined, + previousSide: undefined, + }); + + expect(decision).toEqual({ + action: 'open', + side: 'long', + leverage: 2, + sizeUsd: 80, + reason: 'Signal confidence 0.9 >= 0.62; opening long position.', + }); + }); +}); diff --git a/typescript/clients/web-ag-ui/apps/agent-gmx-allora/src/core/executionPlan.ts b/typescript/clients/web-ag-ui/apps/agent-gmx-allora/src/core/executionPlan.ts new file mode 100644 index 000000000..be7b9fdd7 --- /dev/null +++ b/typescript/clients/web-ag-ui/apps/agent-gmx-allora/src/core/executionPlan.ts @@ -0,0 +1,140 @@ +import { parseUnits } from 'viem'; + +import type { GmxAlloraTelemetry } from '../domain/types.js'; + +export type ExecutionPlan = { + action: 'none' | 'long' | 'short' | 'close' | 'reduce'; + request?: { + amount?: string; + walletAddress?: `0x${string}`; + chainId?: string; + marketAddress?: string; + payTokenAddress?: string; + collateralTokenAddress?: string; + leverage?: string; + positionSide?: 'long' | 'short'; + isLimit?: boolean; + key?: string; + sizeDeltaUsd?: string; + }; +}; + +type BuildPlanParams = { + telemetry: GmxAlloraTelemetry; + chainId: string; + marketAddress: `0x${string}`; + walletAddress: `0x${string}`; + payTokenAddress: `0x${string}`; + collateralTokenAddress: `0x${string}`; + positionContractKey?: string; + positionSizeInUsd?: string; +}; + +function formatNumber(value: number | undefined): string | undefined { + if (value === undefined) { + return undefined; + } + return String(value); +} + +const USDC_DECIMALS = 6; + +function toAmountString(value: number | undefined): string | undefined { + if (value === undefined) { + return undefined; + } + if (!Number.isFinite(value) || value <= 0) { + return undefined; + } + + // onchain-actions expects token base units (e.g., 10 USDC => 10000000). + const normalized = value.toFixed(USDC_DECIMALS); + try { + return parseUnits(normalized, USDC_DECIMALS).toString(); + } catch { + return undefined; + } +} + +function toGmxUsdDelta(positionSizeInUsd: string | undefined): string | undefined { + if (!positionSizeInUsd) { + return undefined; + } + + let size: bigint; + try { + size = BigInt(positionSizeInUsd); + } catch { + return undefined; + } + + if (size <= 0n) { + return undefined; + } + + // Deterministic default: reduce by 50% of current notional. + const delta = size / 2n; + if (delta <= 0n) { + return undefined; + } + + return delta.toString(); +} + +export function buildPerpetualExecutionPlan(params: BuildPlanParams): ExecutionPlan { + const { telemetry } = params; + + if (telemetry.action === 'open') { + if (!telemetry.side || telemetry.sizeUsd === undefined || telemetry.leverage === undefined) { + return { action: 'none' }; + } + + return { + action: telemetry.side === 'long' ? 'long' : 'short', + request: { + amount: toAmountString(telemetry.sizeUsd), + walletAddress: params.walletAddress, + chainId: params.chainId, + marketAddress: params.marketAddress, + payTokenAddress: params.payTokenAddress, + collateralTokenAddress: params.collateralTokenAddress, + leverage: formatNumber(telemetry.leverage), + }, + }; + } + + if (telemetry.action === 'reduce' || telemetry.action === 'close') { + if (!telemetry.side) { + return { action: 'none' }; + } + + if (telemetry.action === 'reduce') { + const key = params.positionContractKey; + const sizeDeltaUsd = toGmxUsdDelta(params.positionSizeInUsd); + if (!key || !sizeDeltaUsd) { + return { action: 'none' }; + } + + return { + action: 'reduce', + request: { + walletAddress: params.walletAddress, + key, + sizeDeltaUsd, + }, + }; + } + + return { + action: 'close', + request: { + walletAddress: params.walletAddress, + marketAddress: params.marketAddress, + positionSide: telemetry.side, + isLimit: false, + }, + }; + } + + return { action: 'none' }; +} diff --git a/typescript/clients/web-ag-ui/apps/agent-gmx-allora/src/core/executionPlan.unit.test.ts b/typescript/clients/web-ag-ui/apps/agent-gmx-allora/src/core/executionPlan.unit.test.ts new file mode 100644 index 000000000..6f20a97ee --- /dev/null +++ b/typescript/clients/web-ag-ui/apps/agent-gmx-allora/src/core/executionPlan.unit.test.ts @@ -0,0 +1,196 @@ +import { describe, expect, it } from 'vitest'; + +import type { GmxAlloraTelemetry } from '../domain/types.js'; + +import { buildPerpetualExecutionPlan } from './executionPlan.js'; + +describe('buildPerpetualExecutionPlan', () => { + it('builds a long request for open long actions', () => { + const telemetry: GmxAlloraTelemetry = { + cycle: 1, + action: 'open', + reason: 'Signal strong', + marketSymbol: 'BTC/USDC', + side: 'long', + leverage: 2, + sizeUsd: 160, + timestamp: '2026-02-05T12:00:00.000Z', + }; + + const plan = buildPerpetualExecutionPlan({ + telemetry, + chainId: '42161', + marketAddress: '0xmarket', + walletAddress: '0xwallet', + payTokenAddress: '0xusdc', + collateralTokenAddress: '0xusdc', + }); + + expect(plan.action).toBe('long'); + expect(plan.request).toEqual({ + amount: '160000000', + walletAddress: '0xwallet', + chainId: '42161', + marketAddress: '0xmarket', + payTokenAddress: '0xusdc', + collateralTokenAddress: '0xusdc', + leverage: '2', + }); + }); + + it('builds a reduce request for reduce actions', () => { + const telemetry: GmxAlloraTelemetry = { + cycle: 2, + action: 'reduce', + reason: 'Reduce exposure', + marketSymbol: 'ETH/USDC', + side: 'short', + leverage: 2, + sizeUsd: 120, + timestamp: '2026-02-05T12:05:00.000Z', + }; + + const plan = buildPerpetualExecutionPlan({ + telemetry, + chainId: '42161', + marketAddress: '0xmarket', + walletAddress: '0xwallet', + payTokenAddress: '0xusdc', + collateralTokenAddress: '0xusdc', + positionContractKey: '0xposition', + positionSizeInUsd: '2000000000000000000000000000000', + }); + + expect(plan.action).toBe('reduce'); + expect(plan.request).toEqual({ + walletAddress: '0xwallet', + key: '0xposition', + sizeDeltaUsd: '1000000000000000000000000000000', + }); + }); + + it('builds a short request for open short actions', () => { + const telemetry: GmxAlloraTelemetry = { + cycle: 3, + action: 'open', + reason: 'Signal bearish', + marketSymbol: 'BTC/USDC', + side: 'short', + leverage: 2, + sizeUsd: 180, + timestamp: '2026-02-05T12:10:00.000Z', + }; + + const plan = buildPerpetualExecutionPlan({ + telemetry, + chainId: '42161', + marketAddress: '0xmarket', + walletAddress: '0xwallet', + payTokenAddress: '0xusdc', + collateralTokenAddress: '0xusdc', + }); + + expect(plan.action).toBe('short'); + expect(plan.request).toEqual({ + amount: '180000000', + walletAddress: '0xwallet', + chainId: '42161', + marketAddress: '0xmarket', + payTokenAddress: '0xusdc', + collateralTokenAddress: '0xusdc', + leverage: '2', + }); + }); + + it('builds a close request for close actions', () => { + const telemetry: GmxAlloraTelemetry = { + cycle: 4, + action: 'close', + reason: 'Direction flipped', + marketSymbol: 'BTC/USDC', + side: 'short', + leverage: 2, + sizeUsd: 180, + timestamp: '2026-02-05T12:15:00.000Z', + }; + + const plan = buildPerpetualExecutionPlan({ + telemetry, + chainId: '42161', + marketAddress: '0xmarket', + walletAddress: '0xwallet', + payTokenAddress: '0xusdc', + collateralTokenAddress: '0xusdc', + }); + + expect(plan.action).toBe('close'); + expect(plan.request).toEqual({ + walletAddress: '0xwallet', + marketAddress: '0xmarket', + positionSide: 'short', + isLimit: false, + }); + }); + + it('returns none when required telemetry fields are missing', () => { + const openWithoutSide: GmxAlloraTelemetry = { + cycle: 5, + action: 'open', + reason: 'Incomplete telemetry', + marketSymbol: 'BTC/USDC', + timestamp: '2026-02-05T12:20:00.000Z', + }; + const closeWithoutSide: GmxAlloraTelemetry = { + cycle: 6, + action: 'close', + reason: 'Missing side', + marketSymbol: 'BTC/USDC', + timestamp: '2026-02-05T12:25:00.000Z', + }; + + const openPlan = buildPerpetualExecutionPlan({ + telemetry: openWithoutSide, + chainId: '42161', + marketAddress: '0xmarket', + walletAddress: '0xwallet', + payTokenAddress: '0xusdc', + collateralTokenAddress: '0xusdc', + }); + const closePlan = buildPerpetualExecutionPlan({ + telemetry: closeWithoutSide, + chainId: '42161', + marketAddress: '0xmarket', + walletAddress: '0xwallet', + payTokenAddress: '0xusdc', + collateralTokenAddress: '0xusdc', + }); + + expect(openPlan).toEqual({ action: 'none' }); + expect(closePlan).toEqual({ action: 'none' }); + }); + + it('converts fractional open size into USDC base units', () => { + const telemetry: GmxAlloraTelemetry = { + cycle: 7, + action: 'open', + reason: 'fractional sizing', + marketSymbol: 'BTC/USDC', + side: 'long', + leverage: 2, + sizeUsd: 10.5, + timestamp: '2026-02-05T12:30:00.000Z', + }; + + const plan = buildPerpetualExecutionPlan({ + telemetry, + chainId: '42161', + marketAddress: '0xmarket', + walletAddress: '0xwallet', + payTokenAddress: '0xusdc', + collateralTokenAddress: '0xusdc', + }); + + expect(plan.action).toBe('long'); + expect(plan.request?.amount).toBe('10500000'); + }); +}); diff --git a/typescript/clients/web-ag-ui/apps/agent-gmx-allora/src/core/exposure.ts b/typescript/clients/web-ag-ui/apps/agent-gmx-allora/src/core/exposure.ts new file mode 100644 index 000000000..27e840648 --- /dev/null +++ b/typescript/clients/web-ag-ui/apps/agent-gmx-allora/src/core/exposure.ts @@ -0,0 +1,68 @@ +import type { PerpetualPosition } from '../clients/onchainActions.js'; +import type { GmxAlloraTelemetry } from '../domain/types.js'; + +function parseUsd(value: string | undefined): number { + if (!value) { + return 0; + } + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : 0; +} + +function isTradeAction( + action: GmxAlloraTelemetry['action'], +): action is 'open' | 'reduce' | 'close' { + return action === 'open' || action === 'reduce' || action === 'close'; +} + +export function applyExposureLimits(params: { + telemetry: GmxAlloraTelemetry; + positions: PerpetualPosition[]; + targetMarketAddress: string; + maxMarketExposureUsd: number; + maxTotalExposureUsd: number; +}): GmxAlloraTelemetry { + const { telemetry, positions } = params; + + if (telemetry.action !== 'open' || !isTradeAction(telemetry.action)) { + return telemetry; + } + + const sizeUsd = telemetry.sizeUsd ?? 0; + if (sizeUsd <= 0) { + return telemetry; + } + + const normalizedTarget = params.targetMarketAddress.toLowerCase(); + let marketExposure = 0; + let totalExposure = 0; + + for (const position of positions) { + const exposure = parseUsd(position.sizeInUsd); + totalExposure += exposure; + if (position.marketAddress.toLowerCase() === normalizedTarget) { + marketExposure += exposure; + } + } + + const nextMarketExposure = marketExposure + sizeUsd; + const nextTotalExposure = totalExposure + sizeUsd; + + if ( + nextMarketExposure > params.maxMarketExposureUsd || + nextTotalExposure > params.maxTotalExposureUsd + ) { + const reason = `Exposure limit reached (market ${nextMarketExposure.toFixed(2)} / total ${nextTotalExposure.toFixed(2)}).`; + return { + ...telemetry, + action: 'hold', + reason, + side: undefined, + leverage: undefined, + sizeUsd: undefined, + txHash: undefined, + }; + } + + return telemetry; +} diff --git a/typescript/clients/web-ag-ui/apps/agent-gmx-allora/src/core/exposure.unit.test.ts b/typescript/clients/web-ag-ui/apps/agent-gmx-allora/src/core/exposure.unit.test.ts new file mode 100644 index 000000000..58e252a65 --- /dev/null +++ b/typescript/clients/web-ag-ui/apps/agent-gmx-allora/src/core/exposure.unit.test.ts @@ -0,0 +1,81 @@ +import { describe, expect, it } from 'vitest'; + +import type { PerpetualPosition } from '../clients/onchainActions.js'; +import type { GmxAlloraTelemetry } from '../domain/types.js'; + +import { applyExposureLimits } from './exposure.js'; + +describe('applyExposureLimits', () => { + it('blocks opening trades that exceed per-market exposure', () => { + const telemetry: GmxAlloraTelemetry = { + cycle: 2, + action: 'open', + reason: 'Signal strong', + marketSymbol: 'BTC/USDC', + side: 'long', + leverage: 2, + sizeUsd: 160, + prediction: { + topic: 'BTC/USD - Price Prediction - 8h', + horizonHours: 8, + confidence: 0.8, + direction: 'up', + predictedPrice: 47000, + timestamp: '2026-02-05T12:00:00.000Z', + }, + timestamp: '2026-02-05T12:01:00.000Z', + metrics: { + confidence: 0.8, + decisionThreshold: 0.62, + cooldownRemaining: 0, + }, + }; + + const positions: PerpetualPosition[] = [ + { + chainId: '42161', + key: '0xpos', + contractKey: '0xcontract', + account: '0xwallet', + marketAddress: '0xmarket', + sizeInUsd: '200', + sizeInTokens: '0.01', + collateralAmount: '100', + pendingBorrowingFeesUsd: '0', + increasedAtTime: '0', + decreasedAtTime: '0', + positionSide: 'long', + isLong: true, + fundingFeeAmount: '0', + claimableLongTokenAmount: '0', + claimableShortTokenAmount: '0', + isOpening: false, + pnl: '0', + positionFeeAmount: '0', + traderDiscountAmount: '0', + uiFeeAmount: '0', + collateralToken: { + tokenUid: { chainId: '42161', address: '0xusdc' }, + name: 'USD Coin', + symbol: 'USDC', + isNative: false, + decimals: 6, + iconUri: null, + isVetted: true, + }, + }, + ]; + + const result = applyExposureLimits({ + telemetry, + positions, + targetMarketAddress: '0xmarket', + maxMarketExposureUsd: 300, + maxTotalExposureUsd: 500, + }); + + expect(result.action).toBe('hold'); + expect(result.reason).toContain('Exposure limit'); + expect(result.sizeUsd).toBeUndefined(); + }); +}); diff --git a/typescript/clients/web-ag-ui/apps/agent-gmx-allora/src/core/marketSelection.ts b/typescript/clients/web-ag-ui/apps/agent-gmx-allora/src/core/marketSelection.ts new file mode 100644 index 000000000..6fd80deb9 --- /dev/null +++ b/typescript/clients/web-ag-ui/apps/agent-gmx-allora/src/core/marketSelection.ts @@ -0,0 +1,37 @@ +import type { PerpetualMarket } from '../clients/onchainActions.js'; + +type MarketSelectionParams = { + markets: PerpetualMarket[]; + baseSymbol: string; + quoteSymbol: string; +}; + +export function selectGmxPerpetualMarket( + params: MarketSelectionParams, +): PerpetualMarket | undefined { + const base = params.baseSymbol.toUpperCase(); + const quote = params.quoteSymbol.toUpperCase(); + const quotes = quote === 'USDC' ? ['USDC', 'USD'] : [quote]; + + return params.markets.find((market) => { + const name = market.name.toUpperCase().replaceAll(' ', ''); + const nameMatches = quotes.some((q) => { + const patterns = [`${base}/${q}`, `${base}-${q}`, `${base}_${q}`, `${base}${q}`]; + return patterns.some((pattern) => name.includes(pattern)); + }); + if (nameMatches) { + return true; + } + + if (!market.indexToken || !market.longToken || !market.shortToken) { + return false; + } + const index = market.indexToken.symbol.toUpperCase(); + const longToken = market.longToken.symbol.toUpperCase(); + const shortToken = market.shortToken.symbol.toUpperCase(); + const matchesSymbols = index === base && (quotes.includes(longToken) || quotes.includes(shortToken)); + // onchain-actions aggregates markets across plugins; GMX market names are not guaranteed + // to include "GMX" (GMX SDK typically returns names like "BTC/USD"). + return matchesSymbols; + }); +} diff --git a/typescript/clients/web-ag-ui/apps/agent-gmx-allora/src/core/marketSelection.unit.test.ts b/typescript/clients/web-ag-ui/apps/agent-gmx-allora/src/core/marketSelection.unit.test.ts new file mode 100644 index 000000000..eb27f5787 --- /dev/null +++ b/typescript/clients/web-ag-ui/apps/agent-gmx-allora/src/core/marketSelection.unit.test.ts @@ -0,0 +1,127 @@ +import { describe, expect, it } from 'vitest'; + +import type { PerpetualMarket } from '../clients/onchainActions.js'; + +import { selectGmxPerpetualMarket } from './marketSelection.js'; + +describe('selectGmxPerpetualMarket', () => { + it('matches GMX market by index + collateral symbols', () => { + const markets: PerpetualMarket[] = [ + { + marketToken: { chainId: '42161', address: '0x1' }, + longFundingFee: '0.01', + shortFundingFee: '0.02', + longBorrowingFee: '0.03', + shortBorrowingFee: '0.04', + chainId: '42161', + name: 'GMX BTC/USD', + indexToken: { + tokenUid: { chainId: '42161', address: '0xbtc' }, + name: 'Bitcoin', + symbol: 'BTC', + isNative: false, + decimals: 8, + iconUri: null, + isVetted: true, + }, + longToken: { + tokenUid: { chainId: '42161', address: '0xusdc' }, + name: 'USD Coin', + symbol: 'USDC', + isNative: false, + decimals: 6, + iconUri: null, + isVetted: true, + }, + shortToken: { + tokenUid: { chainId: '42161', address: '0xusdc' }, + name: 'USD Coin', + symbol: 'USDC', + isNative: false, + decimals: 6, + iconUri: null, + isVetted: true, + }, + }, + ]; + + const result = selectGmxPerpetualMarket({ + markets, + baseSymbol: 'BTC', + quoteSymbol: 'USDC', + }); + + expect(result?.marketToken.address).toBe('0x1'); + }); + + it('does not require the market name to include GMX', () => { + const markets: PerpetualMarket[] = [ + { + marketToken: { chainId: '42161', address: '0x1' }, + longFundingFee: '0.01', + shortFundingFee: '0.02', + longBorrowingFee: '0.03', + shortBorrowingFee: '0.04', + chainId: '42161', + name: 'BTC/USD', + indexToken: { + tokenUid: { chainId: '42161', address: '0xbtc' }, + name: 'Bitcoin', + symbol: 'BTC', + isNative: false, + decimals: 8, + iconUri: null, + isVetted: true, + }, + longToken: { + tokenUid: { chainId: '42161', address: '0xusdc' }, + name: 'USD Coin', + symbol: 'USDC', + isNative: false, + decimals: 6, + iconUri: null, + isVetted: true, + }, + shortToken: { + tokenUid: { chainId: '42161', address: '0xusdc' }, + name: 'USD Coin', + symbol: 'USDC', + isNative: false, + decimals: 6, + iconUri: null, + isVetted: true, + }, + }, + ]; + + const result = selectGmxPerpetualMarket({ + markets, + baseSymbol: 'BTC', + quoteSymbol: 'USDC', + }); + + expect(result?.marketToken.address).toBe('0x1'); + }); + + it('falls back to matching by market name when token metadata is missing', () => { + const markets: PerpetualMarket[] = [ + { + marketToken: { chainId: '42161', address: '0x1' }, + longFundingFee: '0.01', + shortFundingFee: '0.02', + longBorrowingFee: '0.03', + shortBorrowingFee: '0.04', + chainId: '42161', + name: 'BTC/USD', + }, + ]; + + const result = selectGmxPerpetualMarket({ + markets, + baseSymbol: 'BTC', + quoteSymbol: 'USDC', + }); + + expect(result?.marketToken.address).toBe('0x1'); + }); +}); diff --git a/typescript/clients/web-ag-ui/apps/agent-gmx-allora/src/core/transaction.ts b/typescript/clients/web-ag-ui/apps/agent-gmx-allora/src/core/transaction.ts new file mode 100644 index 000000000..30bbd5a91 --- /dev/null +++ b/typescript/clients/web-ag-ui/apps/agent-gmx-allora/src/core/transaction.ts @@ -0,0 +1,119 @@ +import pRetry, { AbortError } from 'p-retry'; +import { parseEther } from 'viem'; + +import type { OnchainClients } from '../clients/clients.js'; +import { logInfo } from '../workflow/context.js'; + +const RPC_RATE_LIMIT_STATUS = 429; +const SEND_TRANSACTION_RETRIES = 5; +const SEND_TRANSACTION_BASE_DELAY_MS = 1000; +const SEND_TRANSACTION_MAX_DELAY_MS = 12000; + +function formatRetryError(error: unknown): string { + if (error instanceof Error) { + return error.message; + } + if (typeof error === 'string') { + return error; + } + return 'Unknown error'; +} + +function readStatusCode(error: unknown): number | undefined { + if (!error || typeof error !== 'object') { + return undefined; + } + if ('status' in error && typeof (error as { status?: unknown }).status === 'number') { + return (error as { status: number }).status; + } + if ( + 'response' in error && + typeof (error as { response?: unknown }).response === 'object' && + (error as { response?: { status?: unknown } }).response?.status !== undefined + ) { + const status = (error as { response?: { status?: unknown } }).response?.status; + return typeof status === 'number' ? status : undefined; + } + return undefined; +} + +function isRateLimitError(error: unknown): boolean { + if (typeof error === 'string') { + return /Status:\s*429\b/u.test(error) || /"code"\s*:\s*429\b/u.test(error); + } + if (error instanceof Error) { + if (/Status:\s*429\b/u.test(error.message)) { + return true; + } + const status = readStatusCode(error); + if (status === RPC_RATE_LIMIT_STATUS) { + return true; + } + const cause = (error as { cause?: unknown }).cause; + if (cause && cause !== error) { + return isRateLimitError(cause); + } + } + return false; +} + +function sendTransactionWithRetry( + clients: OnchainClients, + tx: { + to: `0x${string}`; + data: `0x${string}`; + value?: bigint; + }, +): Promise<`0x${string}`> { + return pRetry<`0x${string}`>( + async () => + clients.wallet.sendTransaction({ + account: clients.wallet.account, + chain: clients.wallet.chain, + to: tx.to, + data: tx.data, + value: tx.value, + }), + { + retries: SEND_TRANSACTION_RETRIES, + factor: 2, + minTimeout: SEND_TRANSACTION_BASE_DELAY_MS, + maxTimeout: SEND_TRANSACTION_MAX_DELAY_MS, + randomize: true, + onFailedAttempt: ({ attemptNumber, retriesLeft, error }) => { + if (!isRateLimitError(error)) { + throw new AbortError(error); + } + logInfo('RPC rate limit detected; retrying transaction', { + attemptNumber, + retriesLeft, + error: formatRetryError(error), + }); + }, + }, + ); +} + +export async function executeTransaction( + clients: OnchainClients, + tx: { + to: `0x${string}`; + data: `0x${string}`; + value?: bigint; + }, +) { + const hash = await sendTransactionWithRetry(clients, tx); + const receipt = await clients.public.waitForTransactionReceipt({ hash }); + return receipt; +} + +export function assertGasBudget(maxGasSpendEth: number) { + if (maxGasSpendEth <= 0) { + throw new Error('Gas budget must be positive'); + } +} + +export function toWei(amountEth: number) { + return parseEther(amountEth.toString()); +} + diff --git a/typescript/clients/web-ag-ui/apps/agent-gmx-allora/src/core/transaction.unit.test.ts b/typescript/clients/web-ag-ui/apps/agent-gmx-allora/src/core/transaction.unit.test.ts new file mode 100644 index 000000000..fad7a95fd --- /dev/null +++ b/typescript/clients/web-ag-ui/apps/agent-gmx-allora/src/core/transaction.unit.test.ts @@ -0,0 +1,65 @@ +import { describe, expect, it, vi } from 'vitest'; + +import type { OnchainClients } from '../clients/clients.js'; + +import { assertGasBudget, executeTransaction, toWei } from './transaction.js'; + +const partial = >(value: T): unknown => + expect.objectContaining(value) as unknown; + +function makeClients({ + txHash, + receipt, +}: { + txHash: `0x${string}`; + receipt: { transactionHash: `0x${string}` }; +}): OnchainClients { + return { + public: { + waitForTransactionReceipt: vi.fn().mockResolvedValue(receipt), + } as unknown as OnchainClients['public'], + wallet: { + account: { address: '0xaaaa' } as `0x${string}`, + sendTransaction: vi.fn().mockResolvedValue(txHash), + } as unknown as OnchainClients['wallet'], + }; +} + +describe('executeTransaction', () => { + it('submits transactions via the wallet client and waits for the receipt', async () => { + const clients = makeClients({ + txHash: '0xhash', + receipt: { transactionHash: '0xreceipt' as `0x${string}` }, + }); + + const receipt = await executeTransaction(clients, { + to: '0xbbb' as `0x${string}`, + data: '0x01' as `0x${string}`, + value: 123n, + }); + + expect(clients.wallet.sendTransaction).toHaveBeenCalledWith( + partial({ + account: clients.wallet.account, + to: '0xbbb', + data: '0x01', + value: 123n, + }), + ); + expect(clients.public.waitForTransactionReceipt).toHaveBeenCalledWith({ hash: '0xhash' }); + expect(receipt.transactionHash).toBe('0xreceipt'); + }); +}); + +describe('assertGasBudget', () => { + it('rejects zero or negative budgets', () => { + expect(() => assertGasBudget(0)).toThrow(/must be positive/); + }); +}); + +describe('toWei', () => { + it('parses floating-point ETH amounts into wei', () => { + expect(toWei(0.5)).toBe(500000000000000000n); + }); +}); + diff --git a/typescript/clients/web-ag-ui/apps/agent-gmx-allora/src/cronApiRunner.ts b/typescript/clients/web-ag-ui/apps/agent-gmx-allora/src/cronApiRunner.ts index fe6c05fdb..964f2c87c 100644 --- a/typescript/clients/web-ag-ui/apps/agent-gmx-allora/src/cronApiRunner.ts +++ b/typescript/clients/web-ag-ui/apps/agent-gmx-allora/src/cronApiRunner.ts @@ -97,10 +97,7 @@ const buildCycleMessage = (): MessageInput => ({ content: JSON.stringify({ command: 'cycle' }), }); -const buildRunPayload = (params: { - graphId: string; - threadId: string; -}): RunCreatePayload => ({ +const buildRunPayload = (params: { graphId: string; threadId: string }): RunCreatePayload => ({ assistant_id: params.graphId, input: { messages: [buildCycleMessage()], @@ -142,11 +139,7 @@ const ensureThread = async (baseUrl: string, threadId: string) => { await parseJsonResponse(response, ThreadResponseSchema); }; -const createRun = async (params: { - baseUrl: string; - threadId: string; - graphId: string; -}) => { +const createRun = async (params: { baseUrl: string; threadId: string; graphId: string }) => { const response = await fetch(`${params.baseUrl}/threads/${params.threadId}/runs`, { method: 'POST', headers: { @@ -175,8 +168,7 @@ const startStarterCron = async () => { const graphId = process.env.LANGGRAPH_GRAPH_ID ?? 'agent-gmx-allora'; const threadId = resolveThreadId(); const intervalMs = resolveIntervalMs(); - const cronExpression = - process.env.STARTER_CRON_EXPRESSION ?? toCronExpression(intervalMs); + const cronExpression = process.env.STARTER_CRON_EXPRESSION ?? toCronExpression(intervalMs); if (!process.env.STARTER_THREAD_ID) { console.info(`[starter-cron] STARTER_THREAD_ID not provided; using ${threadId}`); diff --git a/typescript/clients/web-ag-ui/apps/agent-gmx-allora/src/domain/types.ts b/typescript/clients/web-ag-ui/apps/agent-gmx-allora/src/domain/types.ts index 07a8cc4ca..7ce438338 100644 --- a/typescript/clients/web-ag-ui/apps/agent-gmx-allora/src/domain/types.ts +++ b/typescript/clients/web-ag-ui/apps/agent-gmx-allora/src/domain/types.ts @@ -28,17 +28,34 @@ export const AlloraPredictionSchema = z.object({ }); export type AlloraPrediction = z.infer; -export const GmxSetupInputSchema = z.object({ +const GmxSetupInputWithUsdcAllocationSchema = z.object({ walletAddress: z.templateLiteral(['0x', z.string()]), - baseContributionUsd: z.number().positive().optional(), + usdcAllocation: z.number().positive(), targetMarket: z.enum(['BTC', 'ETH']), }); -type GmxSetupInputBase = z.infer; -export interface GmxSetupInput extends GmxSetupInputBase { +const GmxSetupInputWithBaseContributionSchema = z.object({ + walletAddress: z.templateLiteral(['0x', z.string()]), + // Web UI currently uses this field name. + baseContributionUsd: z.number().positive(), + targetMarket: z.enum(['BTC', 'ETH']), +}); + +// NOTE: No transforms here. This schema is used both for parsing and for +// `z.toJSONSchema(...)` in the interrupt payload. Zod transforms cannot be +// represented in JSON Schema. +export const GmxSetupInputSchema = z.union([ + GmxSetupInputWithUsdcAllocationSchema, + GmxSetupInputWithBaseContributionSchema, +]); + +// Normalized internal shape used by the workflow once onboarding is complete. +// (The interrupt schema accepts multiple input shapes for backwards/UX reasons.) +export type GmxSetupInput = { walletAddress: `0x${string}`; + usdcAllocation: number; targetMarket: 'BTC' | 'ETH'; -} +}; export const FundingTokenInputSchema = z.object({ fundingTokenAddress: z.templateLiteral(['0x', z.string()]), @@ -50,20 +67,19 @@ export interface FundingTokenInput extends FundingTokenInputBase { } export type ResolvedGmxConfig = { - walletAddress: `0x${string}`; + // Delegator: wallet whose positions/balances this strategy manages. + // When delegations bypass is enabled, this is the agent wallet address. + delegatorWalletAddress: `0x${string}`; + // Delegatee: agent wallet address that would execute actions when delegations are enabled. + // When delegations bypass is enabled, this equals the delegator wallet. + delegateeWalletAddress: `0x${string}`; baseContributionUsd: number; fundingTokenAddress: `0x${string}`; targetMarket: GmxMarket; maxLeverage: number; }; -export type GmxAlloraActionKind = - | 'signal' - | 'open' - | 'reduce' - | 'close' - | 'hold' - | 'cooldown'; +export type GmxAlloraActionKind = 'signal' | 'open' | 'reduce' | 'close' | 'hold' | 'cooldown'; export type GmxAlloraTelemetry = { cycle: number; diff --git a/typescript/clients/web-ag-ui/apps/agent-gmx-allora/src/domain/types.unit.test.ts b/typescript/clients/web-ag-ui/apps/agent-gmx-allora/src/domain/types.unit.test.ts new file mode 100644 index 000000000..d32d33e83 --- /dev/null +++ b/typescript/clients/web-ag-ui/apps/agent-gmx-allora/src/domain/types.unit.test.ts @@ -0,0 +1,19 @@ +import { describe, expect, it } from 'vitest'; + +import { GmxSetupInputSchema } from './types.js'; + +describe('GmxSetupInputSchema', () => { + it('accepts the UI payload shape (baseContributionUsd)', () => { + const parsed = GmxSetupInputSchema.safeParse({ + walletAddress: '0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', + baseContributionUsd: 250, + targetMarket: 'BTC', + }); + + expect(parsed.success).toBe(true); + if (!parsed.success) { + return; + } + expect('baseContributionUsd' in parsed.data).toBe(true); + }); +}); diff --git a/typescript/clients/web-ag-ui/apps/agent-gmx-allora/src/e2e/agentLocalMocks.ts b/typescript/clients/web-ag-ui/apps/agent-gmx-allora/src/e2e/agentLocalMocks.ts new file mode 100644 index 000000000..500e00db8 --- /dev/null +++ b/typescript/clients/web-ag-ui/apps/agent-gmx-allora/src/e2e/agentLocalMocks.ts @@ -0,0 +1,249 @@ +import { z } from 'zod'; + +import { resolveE2EProfile } from '../config/constants.js'; + +type PositionSide = 'long' | 'short'; + +type ScenarioPosition = { + walletAddress: `0x${string}`; + marketAddress: `0x${string}`; + positionSide: PositionSide; + contractKey: `0x${string}`; +}; + +type ScenarioState = { + alloraCallsByTopic: Map; + positionsByWallet: Map; + txCounter: number; +}; + +const HexAddressSchema = z.string().regex(/^0x[a-fA-F0-9]{40}$/u); + +const OpenPositionRequestSchema = z.object({ + walletAddress: HexAddressSchema, + marketAddress: HexAddressSchema, +}); + +const ClosePositionRequestSchema = z.object({ + walletAddress: HexAddressSchema, + marketAddress: HexAddressSchema, + positionSide: z.enum(['long', 'short']).optional(), +}); + +const USDC_ADDRESS = '0x1111111111111111111111111111111111111111' as const; +const BTC_MARKET_ADDRESS = '0x0000000000000000000000000000000000000001' as const; +const CHAIN_ID = '42161' as const; + +const ALLORA_TOPIC_SEQUENCE: Record = { + '14': ['47000', '47000', '43000', '42000'], + '2': ['3200', '3200', '2900', '2800'], +}; + +const createState = (): ScenarioState => ({ + alloraCallsByTopic: new Map(), + positionsByWallet: new Map(), + txCounter: 0, +}); + +const normalizeWalletAddress = (value: string): `0x${string}` => value.toLowerCase() as `0x${string}`; + +const nextTopicValue = (state: ScenarioState, topicId: string): string => { + const current = (state.alloraCallsByTopic.get(topicId) ?? 0) + 1; + state.alloraCallsByTopic.set(topicId, current); + const sequence = ALLORA_TOPIC_SEQUENCE[topicId]; + if (!sequence || sequence.length === 0) { + return '100'; + } + return sequence[Math.min(current - 1, sequence.length - 1)] ?? '100'; +}; + +const nextContractKey = (state: ScenarioState): `0x${string}` => { + state.txCounter += 1; + const hex = state.txCounter.toString(16).padStart(64, '0'); + return `0x${hex}`; +}; + +const buildToken = (address: `0x${string}`, symbol: string, name: string, decimals: number) => ({ + tokenUid: { chainId: CHAIN_ID, address }, + name, + symbol, + isNative: false, + decimals, + iconUri: null, + isVetted: true, +}); + +const buildMarketsPayload = () => ({ + cursor: null, + currentPage: 1, + totalPages: 1, + totalItems: 1, + markets: [ + { + marketToken: { chainId: CHAIN_ID, address: BTC_MARKET_ADDRESS }, + longFundingFee: '0', + shortFundingFee: '0', + longBorrowingFee: '0', + shortBorrowingFee: '0', + chainId: CHAIN_ID, + name: 'GMX BTC/USD', + indexToken: buildToken('0x2222222222222222222222222222222222222222', 'BTC', 'Bitcoin', 8), + longToken: buildToken(USDC_ADDRESS, 'USDC', 'USD Coin', 6), + shortToken: buildToken(USDC_ADDRESS, 'USDC', 'USD Coin', 6), + }, + ], +}); + +const buildWalletBalancesPayload = (walletAddress: `0x${string}`) => ({ + cursor: null, + currentPage: 1, + totalPages: 1, + totalItems: 1, + balances: [ + { + tokenUid: { chainId: CHAIN_ID, address: USDC_ADDRESS }, + amount: '1000000000', + symbol: 'USDC', + valueUsd: 1000, + decimals: 6, + walletAddress, + }, + ], +}); + +const buildPositionsPayload = (position?: ScenarioPosition) => ({ + cursor: null, + currentPage: 1, + totalPages: 1, + totalItems: position ? 1 : 0, + positions: position + ? [ + { + chainId: CHAIN_ID, + key: position.contractKey, + contractKey: position.contractKey, + account: position.walletAddress, + marketAddress: position.marketAddress, + sizeInUsd: '2000000000000000000000000000000', + sizeInTokens: '1', + collateralAmount: '200000000', + pendingBorrowingFeesUsd: '0', + increasedAtTime: '0', + decreasedAtTime: '0', + positionSide: position.positionSide, + isLong: position.positionSide === 'long', + fundingFeeAmount: '0', + claimableLongTokenAmount: '0', + claimableShortTokenAmount: '0', + isOpening: false, + pnl: '0', + positionFeeAmount: '0', + traderDiscountAmount: '0', + uiFeeAmount: '0', + collateralToken: buildToken(USDC_ADDRESS, 'USDC', 'USD Coin', 6), + }, + ] + : [], +}); + +const buildTransactionResponse = () => ({ + transactions: [ + { + type: 'transaction', + to: '0x3333333333333333333333333333333333333333', + data: '0xdeadbeef', + value: '0', + chainId: CHAIN_ID, + }, + ], +}); + +let setupPromise: Promise | null = null; + +export async function setupAgentLocalE2EMocksIfNeeded(): Promise { + if (resolveE2EProfile() !== 'mocked') { + return; + } + + if (setupPromise) { + await setupPromise; + return; + } + + setupPromise = (async () => { + const [{ setupServer }, { http, HttpResponse }] = await Promise.all([ + import('msw/node'), + import('msw'), + ]); + + const state = createState(); + + const server = setupServer( + http.get('*/v2/allora/consumer/:chainId', ({ request }) => { + const url = new URL(request.url); + const topicId = url.searchParams.get('allora_topic_id') ?? '0'; + const combined = nextTopicValue(state, topicId); + return HttpResponse.json({ + status: true, + data: { + inference_data: { + topic_id: topicId, + network_inference_normalized: combined, + }, + }, + }); + }), + http.get('*/perpetuals/markets', () => { + return HttpResponse.json(buildMarketsPayload()); + }), + http.get('*/perpetuals/positions/:walletAddress', ({ params }) => { + const walletAddress = normalizeWalletAddress(String(params['walletAddress'] ?? '')); + const position = state.positionsByWallet.get(walletAddress); + return HttpResponse.json(buildPositionsPayload(position)); + }), + http.get('*/wallet/balances/:walletAddress', ({ params }) => { + const walletAddress = normalizeWalletAddress(String(params['walletAddress'] ?? '')); + return HttpResponse.json(buildWalletBalancesPayload(walletAddress)); + }), + http.post('*/perpetuals/long', async ({ request }) => { + const parsed = OpenPositionRequestSchema.parse(await request.json()); + const walletAddress = normalizeWalletAddress(parsed.walletAddress); + const marketAddress = normalizeWalletAddress(parsed.marketAddress); + state.positionsByWallet.set(walletAddress, { + walletAddress, + marketAddress, + positionSide: 'long', + contractKey: nextContractKey(state), + }); + return HttpResponse.json(buildTransactionResponse()); + }), + http.post('*/perpetuals/short', async ({ request }) => { + const parsed = OpenPositionRequestSchema.parse(await request.json()); + const walletAddress = normalizeWalletAddress(parsed.walletAddress); + const marketAddress = normalizeWalletAddress(parsed.marketAddress); + state.positionsByWallet.set(walletAddress, { + walletAddress, + marketAddress, + positionSide: 'short', + contractKey: nextContractKey(state), + }); + return HttpResponse.json(buildTransactionResponse()); + }), + http.post('*/perpetuals/close', async ({ request }) => { + const parsed = ClosePositionRequestSchema.parse(await request.json()); + const walletAddress = normalizeWalletAddress(parsed.walletAddress); + const marketAddress = normalizeWalletAddress(parsed.marketAddress); + const existing = state.positionsByWallet.get(walletAddress); + if (existing && existing.marketAddress === marketAddress) { + state.positionsByWallet.delete(walletAddress); + } + return HttpResponse.json(buildTransactionResponse()); + }), + ); + + server.listen({ onUnhandledRequest: 'bypass' }); + console.info('[gmx-allora] E2E mocked profile enabled with agent-local MSW handlers'); + })(); + + await setupPromise; +} diff --git a/typescript/clients/web-ag-ui/apps/agent-gmx-allora/src/shallowMemorySaver.ts b/typescript/clients/web-ag-ui/apps/agent-gmx-allora/src/shallowMemorySaver.ts index f568ed4d3..08cde5750 100644 --- a/typescript/clients/web-ag-ui/apps/agent-gmx-allora/src/shallowMemorySaver.ts +++ b/typescript/clients/web-ag-ui/apps/agent-gmx-allora/src/shallowMemorySaver.ts @@ -12,9 +12,7 @@ type CheckpointConfig = RunnableConfig> & { type ThreadStorage = MemorySaver['storage'][string]; export class ShallowMemorySaver extends MemorySaver { - override async put( - ...args: Parameters - ): ReturnType { + override async put(...args: Parameters): ReturnType { const nextConfig = await super.put(...args); this.pruneHistory(nextConfig as CheckpointConfig); return nextConfig; diff --git a/typescript/clients/web-ag-ui/apps/agent-gmx-allora/src/workflow/artifacts.int.test.ts b/typescript/clients/web-ag-ui/apps/agent-gmx-allora/src/workflow/artifacts.int.test.ts new file mode 100644 index 000000000..d81c9bd82 --- /dev/null +++ b/typescript/clients/web-ag-ui/apps/agent-gmx-allora/src/workflow/artifacts.int.test.ts @@ -0,0 +1,92 @@ +import { describe, expect, it } from 'vitest'; + +import type { GmxAlloraTelemetry } from '../domain/types.js'; + +import { buildSummaryArtifact } from './artifacts.js'; + +describe('buildSummaryArtifact (integration)', () => { + it('summarizes telemetry timeline and signal stats', () => { + const telemetry: GmxAlloraTelemetry[] = [ + { + cycle: 1, + action: 'open', + reason: 'Signal bullish', + marketSymbol: 'BTC/USDC', + side: 'long', + leverage: 2, + sizeUsd: 250, + timestamp: '2026-02-05T10:00:00.000Z', + prediction: { + topic: 'BTC/USD - Price Prediction - 8h', + horizonHours: 8, + confidence: 0.4, + direction: 'up', + predictedPrice: 48000, + timestamp: '2026-02-05T10:00:00.000Z', + }, + }, + { + cycle: 2, + action: 'hold', + reason: 'Exposure limit', + marketSymbol: 'BTC/USDC', + timestamp: '2026-02-05T12:00:00.000Z', + prediction: { + topic: 'BTC/USD - Price Prediction - 8h', + horizonHours: 8, + confidence: 0.2, + direction: 'down', + predictedPrice: 47000, + timestamp: '2026-02-05T12:00:00.000Z', + }, + }, + { + cycle: 3, + action: 'open', + reason: 'Signal bearish', + marketSymbol: 'ETH/USDC', + timestamp: '2026-02-05T14:00:00.000Z', + side: 'short', + leverage: 2, + sizeUsd: 250, + prediction: { + topic: 'ETH/USD - Price Prediction - 8h', + horizonHours: 8, + confidence: 0.8, + direction: 'down', + predictedPrice: 2600, + timestamp: '2026-02-05T14:00:00.000Z', + }, + txHash: '0xabc', + }, + ]; + + const artifact = buildSummaryArtifact(telemetry); + expect(artifact.artifactId).toBe('gmx-allora-summary'); + expect(artifact.description.toLowerCase()).toContain('bearish'); + + const data = artifact.parts[1]?.data as { + cycles: number; + actionCounts: Record; + timeWindow: { firstTimestamp?: string; lastTimestamp?: string }; + signalSummary: { bestConfidence: number; lastConfidence: number; lastMarket: string }; + latestCycle?: GmxAlloraTelemetry; + actionsTimeline: Array<{ cycle: number; action: string; reason: string; txHash?: string }>; + }; + + expect(data.cycles).toBe(3); + expect(data.actionCounts).toEqual({ open: 2, hold: 1 }); + expect(data.timeWindow.firstTimestamp).toBe('2026-02-05T10:00:00.000Z'); + expect(data.timeWindow.lastTimestamp).toBe('2026-02-05T14:00:00.000Z'); + expect(data.signalSummary.bestConfidence).toBe(0.8); + expect(data.signalSummary.lastConfidence).toBe(0.8); + expect(data.signalSummary.lastMarket).toBe('ETH/USDC'); + expect(data.latestCycle?.cycle).toBe(3); + expect(data.actionsTimeline[2]).toEqual({ + cycle: 3, + action: 'open', + reason: 'Signal bearish', + txHash: '0xabc', + }); + }); +}); diff --git a/typescript/clients/web-ag-ui/apps/agent-gmx-allora/src/workflow/artifacts.ts b/typescript/clients/web-ag-ui/apps/agent-gmx-allora/src/workflow/artifacts.ts index b7cb054a5..0e909ff89 100644 --- a/typescript/clients/web-ag-ui/apps/agent-gmx-allora/src/workflow/artifacts.ts +++ b/typescript/clients/web-ag-ui/apps/agent-gmx-allora/src/workflow/artifacts.ts @@ -1,13 +1,110 @@ +import crypto from 'node:crypto'; + import { type Artifact } from '@emberai/agent-node/workflow'; -import { type GmxAlloraTelemetry } from '../domain/types.js'; +import type { TransactionPlan } from '../clients/onchainActions.js'; +import type { ExecutionPlan } from '../core/executionPlan.js'; +import type { AlloraPrediction, GmxAlloraTelemetry } from '../domain/types.js'; + +function formatConfidence(value: number | undefined): string | null { + if (value === undefined || !Number.isFinite(value)) { + return null; + } + return `${Math.round(value * 100)}%`; +} + +function formatUsd(value: number | undefined): string | null { + if (value === undefined || !Number.isFinite(value)) { + return null; + } + return `$${value.toFixed(2)}`; +} + +function formatSignal(prediction?: AlloraPrediction): string | null { + if (!prediction) { + return null; + } + const direction = prediction.direction === 'up' ? 'bullish' : 'bearish'; + const confidence = formatConfidence(prediction.confidence); + const predicted = Number.isFinite(prediction.predictedPrice) + ? `$${Math.round(prediction.predictedPrice).toLocaleString()}` + : null; + const parts = [ + `Allora ${prediction.horizonHours}h signal: ${direction}`, + confidence ? `confidence ${confidence}` : null, + predicted ? `predicted ${predicted}` : null, + ].filter((value): value is string => Boolean(value)); + + return parts.join(' · '); +} + +function formatRebalance(telemetry: GmxAlloraTelemetry): string { + switch (telemetry.action) { + case 'open': { + const side = telemetry.side ? telemetry.side.toUpperCase() : 'POSITION'; + const leverage = telemetry.leverage !== undefined ? `${telemetry.leverage}x` : null; + const size = formatUsd(telemetry.sizeUsd); + const parts = ['Rebalance: OPEN', side, leverage, size].filter( + (value): value is string => Boolean(value), + ); + return parts.join(' '); + } + case 'reduce': { + const side = telemetry.side ? telemetry.side.toUpperCase() : 'POSITION'; + return `Rebalance: REDUCE ${side}`; + } + case 'close': { + const side = telemetry.side ? telemetry.side.toUpperCase() : 'POSITION'; + return `Rebalance: CLOSE ${side}`; + } + case 'hold': + return 'Rebalance: HOLD (no trade)'; + case 'cooldown': + return 'Rebalance: COOLDOWN (no trade)'; + case 'signal': + return formatSignal(telemetry.prediction) ?? 'Allora signal summarized'; + default: { + const exhaustive: never = telemetry.action; + return `Rebalance: ${String(exhaustive)}`; + } + } +} + +function createExecutionPlanSlug(plan: ExecutionPlan): string { + // Stable placeholder for the UI before we have a concrete `transactions[]` plan. + const digest = crypto.createHash('sha256').update(JSON.stringify(plan)).digest('hex'); + return `planreq_${digest.slice(0, 10)}`; +} + +function createTxPlanSlug(transactions: TransactionPlan[] | undefined): string | null { + if (!transactions || transactions.length === 0) { + return null; + } + + const normalized = transactions.map((tx) => ({ + chainId: tx.chainId, + to: tx.to.toLowerCase(), + data: tx.data.toLowerCase(), + value: tx.value.toLowerCase(), + })); + const digest = crypto.createHash('sha256').update(JSON.stringify(normalized)).digest('hex'); + return `plan_${digest.slice(0, 10)}`; +} export function buildTelemetryArtifact(entry: GmxAlloraTelemetry): Artifact { + const signal = formatSignal(entry.prediction); + const rebalance = formatRebalance(entry); + const description = signal ? `${rebalance} · ${signal}` : rebalance; + return { artifactId: 'gmx-allora-telemetry', name: 'gmx-allora-telemetry.json', - description: 'GMX Allora telemetry entry', + description, parts: [ + { + kind: 'text', + text: description, + }, { kind: 'data', data: entry, @@ -16,6 +113,92 @@ export function buildTelemetryArtifact(entry: GmxAlloraTelemetry): Artifact { }; } +export function buildExecutionPlanArtifact(params: { + plan: ExecutionPlan; + telemetry: GmxAlloraTelemetry; +}): Artifact { + const rebalance = formatRebalance(params.telemetry); + const signal = formatSignal(params.telemetry.prediction); + const planSlug = createExecutionPlanSlug(params.plan); + const description = [rebalance, signal, `plan ${planSlug}`] + .filter((value): value is string => Boolean(value)) + .join(' · '); + + return { + artifactId: 'gmx-allora-execution-plan', + name: 'gmx-allora-execution-plan.json', + description, + parts: [ + { + kind: 'text', + text: description, + }, + { + kind: 'data', + data: { ...params.plan, planSlug }, + }, + ], + }; +} + +export function buildExecutionResultArtifact(params: { + action: ExecutionPlan['action']; + plan?: ExecutionPlan; + ok: boolean; + error?: string; + telemetry?: GmxAlloraTelemetry; + transactions?: TransactionPlan[]; + txHashes?: `0x${string}`[]; + lastTxHash?: `0x${string}`; +}): Artifact { + const txHash = params.lastTxHash; + const txPlanSlug = txHash ? null : createTxPlanSlug(params.transactions); + const placeholderSlug = + !txHash && !txPlanSlug && params.plan ? createExecutionPlanSlug(params.plan) : null; + const planSlug = txPlanSlug ?? placeholderSlug; + const rebalance = params.telemetry ? formatRebalance(params.telemetry) : null; + const signal = params.telemetry ? formatSignal(params.telemetry.prediction) : null; + + const txRef = txHash + ? { kind: 'tx' as const, value: txHash, url: `https://arbiscan.io/tx/${txHash}` } + : planSlug + ? { kind: 'plan' as const, value: planSlug } + : undefined; + + const headline = [ + rebalance, + signal, + txRef ? `${txRef.kind === 'tx' ? 'tx' : 'plan'} ${txRef.value}` : null, + ] + .filter((value): value is string => Boolean(value)) + .join(' · '); + + return { + artifactId: 'gmx-allora-execution-result', + name: 'gmx-allora-execution-result.json', + description: headline.length > 0 ? headline : 'GMX Allora execution result', + parts: [ + { + kind: 'text', + text: headline.length > 0 ? headline : `Execution ${params.ok ? 'succeeded' : 'failed'}`, + }, + { + kind: 'data', + data: { + action: params.action, + ok: params.ok, + error: params.error, + txHashes: params.txHashes, + lastTxHash: params.lastTxHash, + txRef, + // Useful for the UI to render the signal next to the plan/tx reference. + signal, + }, + }, + ], + }; +} + export function buildSummaryArtifact(telemetry: GmxAlloraTelemetry[]): Artifact { const actions: Record = {}; let firstTimestamp: string | undefined; @@ -41,12 +224,21 @@ export function buildSummaryArtifact(telemetry: GmxAlloraTelemetry[]): Artifact } const latest = telemetry.length > 0 ? telemetry[telemetry.length - 1] : undefined; + const summaryText = latest + ? [formatRebalance(latest), formatSignal(latest.prediction)] + .filter((value): value is string => Boolean(value)) + .join(' · ') + : 'Allora signal summarized'; return { artifactId: 'gmx-allora-summary', name: 'gmx-allora-summary.json', - description: 'Summary of GMX Allora trade cycles', + description: summaryText, parts: [ + { + kind: 'text', + text: summaryText, + }, { kind: 'data', data: { diff --git a/typescript/clients/web-ag-ui/apps/agent-gmx-allora/src/workflow/artifacts.unit.test.ts b/typescript/clients/web-ag-ui/apps/agent-gmx-allora/src/workflow/artifacts.unit.test.ts new file mode 100644 index 000000000..a91514c5c --- /dev/null +++ b/typescript/clients/web-ag-ui/apps/agent-gmx-allora/src/workflow/artifacts.unit.test.ts @@ -0,0 +1,111 @@ +import { describe, expect, it } from 'vitest'; + +import type { ExecutionPlan } from '../core/executionPlan.js'; +import type { GmxAlloraTelemetry } from '../domain/types.js'; + +import { buildExecutionPlanArtifact, buildExecutionResultArtifact, buildTelemetryArtifact } from './artifacts.js'; + +describe('buildExecutionPlanArtifact', () => { + it('wraps execution plan data into an artifact', () => { + const plan: ExecutionPlan = { + action: 'long', + request: { + amount: '160', + walletAddress: '0xwallet', + chainId: '42161', + marketAddress: '0xmarket', + payTokenAddress: '0xusdc', + collateralTokenAddress: '0xusdc', + leverage: '2', + }, + }; + + const telemetry: GmxAlloraTelemetry = { + cycle: 1, + action: 'open', + reason: 'Signal bullish', + marketSymbol: 'BTC/USDC', + side: 'long', + leverage: 2, + sizeUsd: 250, + timestamp: '2026-02-05T20:00:00.000Z', + prediction: { + topic: 'BTC/USD - Price Prediction - 8h', + horizonHours: 8, + confidence: 0.71, + direction: 'up', + predictedPrice: 47000, + timestamp: '2026-02-05T20:00:00.000Z', + }, + }; + + const artifact = buildExecutionPlanArtifact({ plan, telemetry }); + + expect(artifact.artifactId).toBe('gmx-allora-execution-plan'); + expect(artifact.name).toBe('gmx-allora-execution-plan.json'); + expect(artifact.description.toLowerCase()).toContain('bullish'); + expect(artifact.parts[0]).toEqual({ kind: 'text', text: artifact.description }); + expect(artifact.parts[1]).toMatchObject({ kind: 'data', data: plan }); + expect((artifact.parts[1] as { kind: 'data'; data: { planSlug?: unknown } }).data.planSlug).toMatch( + /^planreq_[0-9a-f]{10}$/u, + ); + }); + + it('wraps execution result data into an artifact', () => { + const artifact = buildExecutionResultArtifact({ + action: 'long', + ok: true, + }); + + expect(artifact.artifactId).toBe('gmx-allora-execution-result'); + expect(artifact.parts[1]?.data).toMatchObject({ action: 'long', ok: true }); + }); + + it('includes a stable plan placeholder when transactions are unavailable', () => { + const plan: ExecutionPlan = { + action: 'long', + request: { + amount: '160', + walletAddress: '0xwallet', + chainId: '42161', + marketAddress: '0xmarket', + payTokenAddress: '0xusdc', + collateralTokenAddress: '0xusdc', + leverage: '2', + }, + }; + + const artifact = buildExecutionResultArtifact({ + action: 'long', + plan, + ok: true, + transactions: [], + }); + + expect(artifact.description).toMatch(/plan planreq_[0-9a-f]{10}$/u); + }); + + it('wraps telemetry data into an artifact', () => { + const telemetry: GmxAlloraTelemetry = { + cycle: 3, + action: 'hold', + reason: 'No position', + marketSymbol: 'BTC/USDC', + timestamp: '2026-02-05T20:00:00.000Z', + prediction: { + topic: 'BTC/USD - Price Prediction - 8h', + horizonHours: 8, + confidence: 0.42, + direction: 'down', + predictedPrice: 47000, + timestamp: '2026-02-05T20:00:00.000Z', + }, + }; + + const artifact = buildTelemetryArtifact(telemetry); + + expect(artifact.artifactId).toBe('gmx-allora-telemetry'); + expect(artifact.description.length).toBeGreaterThan(0); + expect(artifact.parts[1]?.data).toEqual(telemetry); + }); +}); diff --git a/typescript/clients/web-ag-ui/apps/agent-gmx-allora/src/workflow/clientFactory.ts b/typescript/clients/web-ag-ui/apps/agent-gmx-allora/src/workflow/clientFactory.ts new file mode 100644 index 000000000..f5cb27ff6 --- /dev/null +++ b/typescript/clients/web-ag-ui/apps/agent-gmx-allora/src/workflow/clientFactory.ts @@ -0,0 +1,35 @@ +import { privateKeyToAccount } from 'viem/accounts'; + +import { createClients, type OnchainClients } from '../clients/clients.js'; +import { OnchainActionsClient } from '../clients/onchainActions.js'; +import { ONCHAIN_ACTIONS_API_URL } from '../config/constants.js'; + +import { normalizeHexAddress } from './context.js'; + +let cachedOnchainActionsClient: OnchainActionsClient | null = null; +let cachedOnchainClients: OnchainClients | null = null; + +export function getOnchainActionsClient(): OnchainActionsClient { + if (!cachedOnchainActionsClient) { + cachedOnchainActionsClient = new OnchainActionsClient(ONCHAIN_ACTIONS_API_URL); + } + return cachedOnchainActionsClient; +} + +export function getOnchainClients(): OnchainClients { + if (!cachedOnchainClients) { + const rawPrivateKey = process.env['A2A_TEST_AGENT_NODE_PRIVATE_KEY']; + if (!rawPrivateKey) { + throw new Error('A2A_TEST_AGENT_NODE_PRIVATE_KEY environment variable is required'); + } + const privateKey = normalizeHexAddress(rawPrivateKey, 'embedded private key'); + const account = privateKeyToAccount(privateKey); + cachedOnchainClients = createClients(account); + } + return cachedOnchainClients; +} + +export function clearClientCache(): void { + cachedOnchainActionsClient = null; + cachedOnchainClients = null; +} diff --git a/typescript/clients/web-ag-ui/apps/agent-gmx-allora/src/workflow/clientFactory.unit.test.ts b/typescript/clients/web-ag-ui/apps/agent-gmx-allora/src/workflow/clientFactory.unit.test.ts new file mode 100644 index 000000000..449323c27 --- /dev/null +++ b/typescript/clients/web-ag-ui/apps/agent-gmx-allora/src/workflow/clientFactory.unit.test.ts @@ -0,0 +1,61 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { clearClientCache, getOnchainActionsClient, getOnchainClients } from './clientFactory.js'; + +const { createClientsMock, onchainActionsCtorMock, privateKeyToAccountMock } = vi.hoisted(() => ({ + createClientsMock: vi.fn(), + onchainActionsCtorMock: vi.fn(), + privateKeyToAccountMock: vi.fn(), +})); + +vi.mock('../clients/onchainActions.js', () => ({ + OnchainActionsClient: onchainActionsCtorMock, +})); + +vi.mock('../clients/clients.js', () => ({ + createClients: createClientsMock, +})); + +vi.mock('viem/accounts', () => ({ + privateKeyToAccount: privateKeyToAccountMock, +})); + +describe('clientFactory', () => { + afterEach(() => { + clearClientCache(); + onchainActionsCtorMock.mockReset(); + createClientsMock.mockReset(); + privateKeyToAccountMock.mockReset(); + delete process.env.A2A_TEST_AGENT_NODE_PRIVATE_KEY; + }); + + it('creates and caches the onchain actions client', () => { + const first = getOnchainActionsClient(); + const second = getOnchainActionsClient(); + + expect(first).toBe(second); + expect(onchainActionsCtorMock).toHaveBeenCalledTimes(1); + expect(onchainActionsCtorMock).toHaveBeenCalledWith('https://api.emberai.xyz'); + }); + + it('creates and caches the onchain clients from the embedded private key', () => { + process.env.A2A_TEST_AGENT_NODE_PRIVATE_KEY = + '0x0000000000000000000000000000000000000000000000000000000000000001'; + + privateKeyToAccountMock.mockReturnValue({ address: '0xabc' }); + createClientsMock.mockReturnValue({ kind: 'clients' }); + + const first = getOnchainClients(); + const second = getOnchainClients(); + + expect(first).toBe(second); + expect(privateKeyToAccountMock).toHaveBeenCalledTimes(1); + expect(createClientsMock).toHaveBeenCalledTimes(1); + }); + + it('throws when embedded private key is missing', () => { + delete process.env.A2A_TEST_AGENT_NODE_PRIVATE_KEY; + + expect(() => getOnchainClients()).toThrow(/A2A_TEST_AGENT_NODE_PRIVATE_KEY/); + }); +}); diff --git a/typescript/clients/web-ag-ui/apps/agent-gmx-allora/src/workflow/context.ts b/typescript/clients/web-ag-ui/apps/agent-gmx-allora/src/workflow/context.ts index 82f7f8822..922fe13e0 100644 --- a/typescript/clients/web-ag-ui/apps/agent-gmx-allora/src/workflow/context.ts +++ b/typescript/clients/web-ag-ui/apps/agent-gmx-allora/src/workflow/context.ts @@ -71,6 +71,24 @@ export type ClmmTransaction = { timestamp: string; }; +export type GmxLatestSnapshot = { + poolAddress?: `0x${string}`; + totalUsd?: number; + leverage?: number; + feesUsd?: number; + feesApy?: number; + timestamp?: string; + positionOpenedAt?: string; + positionTokens: Array<{ + address: `0x${string}`; + symbol: string; + decimals: number; + amount?: number; + amountBaseUnits?: string; + valueUsd?: number; + }>; +}; + export type ClmmMetrics = { lastSnapshot?: GmxMarket; previousPrice?: number; @@ -78,6 +96,19 @@ export type ClmmMetrics = { staleCycles: number; iteration: number; latestCycle?: GmxAlloraTelemetry; + aumUsd?: number; + apy?: number; + lifetimePnlUsd?: number; + latestSnapshot?: GmxLatestSnapshot; + // When running in plan-only mode (no submission), we may want to avoid re-planning + // the same open action every time the signal stays stable. This field tracks the + // last assumed position side for decisioning until a close/flip occurs. + assumedPositionSide?: 'long' | 'short'; + // Last observed Allora inference metrics fingerprint for the selected topic. + lastInferenceSnapshotKey?: string; + // Fingerprint of the last successful trade action. Used to prevent duplicate actions + // when inference metrics have not changed. + lastTradedInferenceSnapshotKey?: string; }; export type TaskState = @@ -246,6 +277,13 @@ const defaultViewState = (): ClmmViewState => ({ staleCycles: 0, iteration: 0, latestCycle: undefined, + aumUsd: undefined, + apy: undefined, + lifetimePnlUsd: undefined, + latestSnapshot: undefined, + assumedPositionSide: undefined, + lastInferenceSnapshotKey: undefined, + lastTradedInferenceSnapshotKey: undefined, }, transactionHistory: [], }); @@ -327,13 +365,30 @@ const mergeViewState = (left: ClmmViewState, right?: Partial): Cl pools: right.profile?.pools ?? left.profile.pools, allowedPools: right.profile?.allowedPools ?? left.profile.allowedPools, }; + const rightMetrics = right.metrics; + const hasAssumedPositionSideUpdate = + rightMetrics !== undefined && + Object.prototype.hasOwnProperty.call(rightMetrics, 'assumedPositionSide'); + const hasLatestSnapshotUpdate = + rightMetrics !== undefined && Object.prototype.hasOwnProperty.call(rightMetrics, 'latestSnapshot'); const nextMetrics: ClmmMetrics = { - lastSnapshot: right.metrics?.lastSnapshot ?? left.metrics.lastSnapshot, - previousPrice: right.metrics?.previousPrice ?? left.metrics.previousPrice, - cyclesSinceRebalance: right.metrics?.cyclesSinceRebalance ?? left.metrics.cyclesSinceRebalance, - staleCycles: right.metrics?.staleCycles ?? left.metrics.staleCycles, - iteration: right.metrics?.iteration ?? left.metrics.iteration, - latestCycle: right.metrics?.latestCycle ?? left.metrics.latestCycle, + lastSnapshot: rightMetrics?.lastSnapshot ?? left.metrics.lastSnapshot, + previousPrice: rightMetrics?.previousPrice ?? left.metrics.previousPrice, + cyclesSinceRebalance: rightMetrics?.cyclesSinceRebalance ?? left.metrics.cyclesSinceRebalance, + staleCycles: rightMetrics?.staleCycles ?? left.metrics.staleCycles, + iteration: rightMetrics?.iteration ?? left.metrics.iteration, + latestCycle: rightMetrics?.latestCycle ?? left.metrics.latestCycle, + aumUsd: rightMetrics?.aumUsd ?? left.metrics.aumUsd, + apy: rightMetrics?.apy ?? left.metrics.apy, + lifetimePnlUsd: rightMetrics?.lifetimePnlUsd ?? left.metrics.lifetimePnlUsd, + latestSnapshot: hasLatestSnapshotUpdate ? rightMetrics?.latestSnapshot : left.metrics.latestSnapshot, + assumedPositionSide: hasAssumedPositionSideUpdate + ? rightMetrics?.assumedPositionSide + : left.metrics.assumedPositionSide, + lastInferenceSnapshotKey: + rightMetrics?.lastInferenceSnapshotKey ?? left.metrics.lastInferenceSnapshotKey, + lastTradedInferenceSnapshotKey: + rightMetrics?.lastTradedInferenceSnapshotKey ?? left.metrics.lastTradedInferenceSnapshotKey, }; return { @@ -361,7 +416,10 @@ const mergeViewState = (left: ClmmViewState, right?: Partial): Cl }; }; -const mergeCopilotkit = (left: CopilotkitState, right?: Partial): CopilotkitState => ({ +const mergeCopilotkit = ( + left: CopilotkitState, + right?: Partial, +): CopilotkitState => ({ actions: right?.actions ?? left.actions ?? [], context: right?.context ?? left.context ?? [], }); diff --git a/typescript/clients/web-ag-ui/apps/agent-gmx-allora/src/workflow/execution.ts b/typescript/clients/web-ag-ui/apps/agent-gmx-allora/src/workflow/execution.ts new file mode 100644 index 000000000..0e5ca472c --- /dev/null +++ b/typescript/clients/web-ag-ui/apps/agent-gmx-allora/src/workflow/execution.ts @@ -0,0 +1,267 @@ +import type { Delegation } from '@metamask/delegation-toolkit'; +import { erc7710WalletActions } from '@metamask/delegation-toolkit/experimental'; +import { encodePermissionContexts } from '@metamask/delegation-toolkit/utils'; + +import type { OnchainClients } from '../clients/clients.js'; +import type { OnchainActionsClient, TransactionPlan } from '../clients/onchainActions.js'; +import type { ExecutionPlan } from '../core/executionPlan.js'; +import { executeTransaction } from '../core/transaction.js'; + +import { logInfo, normalizeHexAddress, type DelegationBundle } from './context.js'; + +export type ExecutionResult = { + action: ExecutionPlan['action']; + ok: boolean; + transactions?: TransactionPlan[]; + txHashes?: `0x${string}`[]; + lastTxHash?: `0x${string}`; + error?: string; +}; + +function normalizeHexData(value: string, label: string): `0x${string}` { + if (!value.startsWith('0x')) { + throw new Error(`Invalid ${label}: ${value}`); + } + return value as `0x${string}`; +} + +function parseTransactionValue(value: string | undefined): bigint { + if (!value) { + return 0n; + } + try { + return BigInt(value); + } catch (error) { + const reason = error instanceof Error ? error.message : String(error); + throw new Error(`Unable to parse transaction value "${value}": ${reason}`); + } +} + +async function executePlannedTransaction(params: { + clients: OnchainClients; + tx: TransactionPlan; +}): Promise<`0x${string}`> { + const to = normalizeHexAddress(params.tx.to, 'transaction target'); + const data = normalizeHexData(params.tx.data, 'transaction data'); + const value = parseTransactionValue(params.tx.value); + + logInfo('Submitting GMX planned transaction', { + to, + chainId: params.tx.chainId, + value: params.tx.value, + }); + + const receipt = await executeTransaction(params.clients, { to, data, value }); + + logInfo('GMX transaction confirmed', { transactionHash: receipt.transactionHash }); + + return receipt.transactionHash; +} + +function resolveDelegationExecutionConfig(params: { + delegationBundle?: DelegationBundle; + delegatorWalletAddress?: `0x${string}`; + delegateeWalletAddress?: `0x${string}`; +}): { delegationManager: `0x${string}`; permissionsContext: `0x${string}` } { + const bundle = params.delegationBundle; + if (!bundle) { + throw new Error( + 'Delegations are required for embedded execution. Complete delegation signing or set DELEGATIONS_BYPASS=true.', + ); + } + + if (params.delegatorWalletAddress && bundle.delegatorAddress !== params.delegatorWalletAddress) { + throw new Error( + `Delegation bundle delegatorAddress (${bundle.delegatorAddress}) does not match operator delegator wallet (${params.delegatorWalletAddress}).`, + ); + } + if (params.delegateeWalletAddress && bundle.delegateeAddress !== params.delegateeWalletAddress) { + throw new Error( + `Delegation bundle delegateeAddress (${bundle.delegateeAddress}) does not match operator delegatee wallet (${params.delegateeWalletAddress}).`, + ); + } + + const contexts = encodePermissionContexts([bundle.delegations as unknown as Delegation[]]); + const permissionsContext = contexts[0]; + if (!permissionsContext) { + throw new Error('Delegation bundle did not produce a permissions context.'); + } + + return { + delegationManager: normalizeHexAddress(bundle.delegationManager, 'delegation manager'), + permissionsContext, + }; +} + +async function executePlannedTransactionWithDelegation(params: { + clients: OnchainClients; + tx: TransactionPlan; + delegationManager: `0x${string}`; + permissionsContext: `0x${string}`; +}): Promise<`0x${string}`> { + const to = normalizeHexAddress(params.tx.to, 'transaction target'); + const data = normalizeHexData(params.tx.data, 'transaction data'); + const value = parseTransactionValue(params.tx.value); + + logInfo('Submitting GMX planned transaction via delegations', { + to, + chainId: params.tx.chainId, + delegationManager: params.delegationManager, + value: params.tx.value, + }); + + const hash = await erc7710WalletActions()(params.clients.wallet).sendTransactionWithDelegation({ + account: params.clients.wallet.account, + chain: params.clients.wallet.chain, + to, + data, + value, + permissionsContext: params.permissionsContext, + delegationManager: params.delegationManager, + }); + + logInfo('GMX delegated transaction submitted', { transactionHash: hash }); + + const receipt = await params.clients.public.waitForTransactionReceipt({ hash }); + if (receipt.status !== 'success') { + throw new Error(`Delegated GMX transaction reverted: ${hash}`); + } + logInfo('GMX delegated transaction confirmed', { transactionHash: hash }); + + return hash; +} + +async function planOrExecuteTransactions(params: { + txExecutionMode: 'plan' | 'execute'; + clients?: OnchainClients; + transactions: TransactionPlan[]; + delegation?: { delegationManager: `0x${string}`; permissionsContext: `0x${string}` }; +}): Promise<{ txHashes: `0x${string}`[]; lastTxHash?: `0x${string}` }> { + if (params.txExecutionMode === 'plan') { + return { txHashes: [] }; + } + if (!params.clients) { + throw new Error('Onchain clients are required to execute GMX transactions'); + } + if (params.transactions.length === 0) { + return { txHashes: [] }; + } + + const txHashes: `0x${string}`[] = []; + for (const tx of params.transactions) { + const hash = params.delegation + ? await executePlannedTransactionWithDelegation({ + clients: params.clients, + tx, + ...params.delegation, + }) + : await executePlannedTransaction({ clients: params.clients, tx }); + txHashes.push(hash); + } + return { txHashes, lastTxHash: txHashes.at(-1) }; +} + +export async function executePerpetualPlan(params: { + client: Pick< + OnchainActionsClient, + 'createPerpetualLong' | 'createPerpetualShort' | 'createPerpetualClose' | 'createPerpetualReduce' + >; + plan: ExecutionPlan; + txExecutionMode: 'plan' | 'execute'; + clients?: OnchainClients; + delegationsBypassActive: boolean; + delegationBundle?: DelegationBundle; + delegatorWalletAddress?: `0x${string}`; + delegateeWalletAddress?: `0x${string}`; +}): Promise { + const { plan } = params; + + if (plan.action === 'none' || !plan.request) { + return { action: plan.action, ok: true, txHashes: [] }; + } + + const delegation = + params.txExecutionMode === 'execute' && params.delegationsBypassActive === false + ? resolveDelegationExecutionConfig({ + delegationBundle: params.delegationBundle, + delegatorWalletAddress: params.delegatorWalletAddress, + delegateeWalletAddress: params.delegateeWalletAddress, + }) + : undefined; + + try { + if (plan.action === 'long') { + const response = await params.client.createPerpetualLong( + plan.request as Parameters[0], + ); + const execution = await planOrExecuteTransactions({ + txExecutionMode: params.txExecutionMode, + clients: params.clients, + transactions: response.transactions, + delegation, + }); + return { + action: plan.action, + ok: true, + transactions: response.transactions, + txHashes: execution.txHashes, + lastTxHash: execution.lastTxHash, + }; + } + if (plan.action === 'short') { + const response = await params.client.createPerpetualShort( + plan.request as Parameters[0], + ); + const execution = await planOrExecuteTransactions({ + txExecutionMode: params.txExecutionMode, + clients: params.clients, + transactions: response.transactions, + delegation, + }); + return { + action: plan.action, + ok: true, + transactions: response.transactions, + txHashes: execution.txHashes, + lastTxHash: execution.lastTxHash, + }; + } + if (plan.action === 'reduce') { + const response = await params.client.createPerpetualReduce( + plan.request as Parameters[0], + ); + const execution = await planOrExecuteTransactions({ + txExecutionMode: params.txExecutionMode, + clients: params.clients, + transactions: response.transactions, + delegation, + }); + return { + action: plan.action, + ok: true, + transactions: response.transactions, + txHashes: execution.txHashes, + lastTxHash: execution.lastTxHash, + }; + } + const response = await params.client.createPerpetualClose( + plan.request as Parameters[0], + ); + const execution = await planOrExecuteTransactions({ + txExecutionMode: params.txExecutionMode, + clients: params.clients, + transactions: response.transactions, + delegation, + }); + return { + action: plan.action, + ok: true, + transactions: response.transactions, + txHashes: execution.txHashes, + lastTxHash: execution.lastTxHash, + }; + } catch (error: unknown) { + const message = error instanceof Error ? error.message : String(error); + return { action: plan.action, ok: false, error: message }; + } +} diff --git a/typescript/clients/web-ag-ui/apps/agent-gmx-allora/src/workflow/execution.unit.test.ts b/typescript/clients/web-ag-ui/apps/agent-gmx-allora/src/workflow/execution.unit.test.ts new file mode 100644 index 000000000..c2fde974e --- /dev/null +++ b/typescript/clients/web-ag-ui/apps/agent-gmx-allora/src/workflow/execution.unit.test.ts @@ -0,0 +1,260 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import type { OnchainClients } from '../clients/clients.js'; +import type { ExecutionPlan } from '../core/executionPlan.js'; + +import type { DelegationBundle } from './context.js'; +import { executePerpetualPlan } from './execution.js'; + +const { + executeTransactionMock, + encodePermissionContextsMock, + sendTransactionWithDelegationMock, + waitForTransactionReceiptMock, +} = + vi.hoisted(() => ({ + executeTransactionMock: vi.fn(), + encodePermissionContextsMock: vi.fn(), + sendTransactionWithDelegationMock: vi.fn(), + waitForTransactionReceiptMock: vi.fn(), + })); + +vi.mock('../core/transaction.js', () => ({ + executeTransaction: executeTransactionMock, +})); + +vi.mock('@metamask/delegation-toolkit/utils', () => ({ + encodePermissionContexts: encodePermissionContextsMock, +})); + +vi.mock('@metamask/delegation-toolkit/experimental', () => ({ + erc7710WalletActions: () => () => ({ + sendTransactionWithDelegation: sendTransactionWithDelegationMock, + }), +})); + +const createPerpetualLong = vi.fn(() => + Promise.resolve({ + transactions: [ + { + type: 'evm', + to: '0xrouter', + data: '0xdeadbeef', + chainId: '42161', + value: '0', + }, + ], + }), +); +const createPerpetualShort = vi.fn(() => Promise.resolve({ transactions: [] })); +const createPerpetualClose = vi.fn(() => Promise.resolve({ transactions: [] })); +const createPerpetualReduce = vi.fn(() => Promise.resolve({ transactions: [] })); + +const client = { + createPerpetualLong, + createPerpetualShort, + createPerpetualClose, + createPerpetualReduce, +}; + +describe('executePerpetualPlan', () => { + beforeEach(() => { + executeTransactionMock.mockReset(); + encodePermissionContextsMock.mockReset(); + sendTransactionWithDelegationMock.mockReset(); + waitForTransactionReceiptMock.mockReset(); + }); + + it('skips execution when plan action is none', async () => { + const plan: ExecutionPlan = { action: 'none' }; + + const result = await executePerpetualPlan({ + client, + plan, + txExecutionMode: 'plan', + delegationsBypassActive: true, + }); + + expect(result.ok).toBe(true); + expect(createPerpetualLong).not.toHaveBeenCalled(); + expect(executeTransactionMock).not.toHaveBeenCalled(); + }); + + it('executes long plans', async () => { + const plan: ExecutionPlan = { + action: 'long', + request: { + amount: '100', + walletAddress: '0x0000000000000000000000000000000000000001', + chainId: '42161', + marketAddress: '0xmarket', + payTokenAddress: '0xusdc', + collateralTokenAddress: '0xusdc', + leverage: '2', + }, + }; + + const result = await executePerpetualPlan({ + client, + plan, + txExecutionMode: 'plan', + delegationsBypassActive: true, + }); + + expect(result.ok).toBe(true); + expect(createPerpetualLong).toHaveBeenCalled(); + expect(result.transactions).toHaveLength(1); + expect(result.transactions?.[0]?.to).toBe('0xrouter'); + expect(executeTransactionMock).not.toHaveBeenCalled(); + }); + + it('executes reduce plans', async () => { + const plan: ExecutionPlan = { + action: 'reduce', + request: { + walletAddress: '0x0000000000000000000000000000000000000001', + key: '0xposition', + sizeDeltaUsd: '1000000000000000000000000000000', + }, + }; + + const result = await executePerpetualPlan({ + client, + plan, + txExecutionMode: 'plan', + delegationsBypassActive: true, + }); + + expect(result.ok).toBe(true); + expect(createPerpetualReduce).toHaveBeenCalled(); + expect(executeTransactionMock).not.toHaveBeenCalled(); + }); + + it('captures execution errors', async () => { + createPerpetualClose.mockRejectedValueOnce(new Error('boom')); + const plan: ExecutionPlan = { + action: 'close', + request: { + walletAddress: '0x0000000000000000000000000000000000000001', + marketAddress: '0xmarket', + positionSide: 'long', + isLimit: false, + }, + }; + + const result = await executePerpetualPlan({ + client, + plan, + txExecutionMode: 'plan', + delegationsBypassActive: true, + }); + + expect(result.ok).toBe(false); + expect(result.error).toContain('boom'); + }); + + it('submits transactions when tx execution mode is execute', async () => { + executeTransactionMock.mockResolvedValueOnce({ transactionHash: '0xhash' }); + const clients = {} as OnchainClients; + + const plan: ExecutionPlan = { + action: 'long', + request: { + amount: '100', + walletAddress: '0x0000000000000000000000000000000000000001', + chainId: '42161', + marketAddress: '0xmarket', + payTokenAddress: '0xusdc', + collateralTokenAddress: '0xusdc', + leverage: '2', + }, + }; + + const result = await executePerpetualPlan({ + client, + clients, + plan, + txExecutionMode: 'execute', + delegationsBypassActive: true, + }); + + expect(result.ok).toBe(true); + expect(result.transactions).toHaveLength(1); + expect(executeTransactionMock).toHaveBeenCalledTimes(1); + expect(result.txHashes).toEqual(['0xhash']); + expect(result.lastTxHash).toBe('0xhash'); + }); + + it('submits transactions via delegation redemption when delegations are active', async () => { + encodePermissionContextsMock.mockReturnValue(['0xperm']); + sendTransactionWithDelegationMock.mockResolvedValueOnce('0xhash2'); + waitForTransactionReceiptMock.mockResolvedValueOnce({ status: 'success' }); + const clients = { + public: { + waitForTransactionReceipt: waitForTransactionReceiptMock, + }, + wallet: { + account: { address: '0x00000000000000000000000000000000000000cc' }, + chain: null, + }, + } as unknown as OnchainClients; + + const delegationBundle: DelegationBundle = { + chainId: 42161, + delegationManager: '0x00000000000000000000000000000000000000aa', + delegatorAddress: '0x00000000000000000000000000000000000000bb', + delegateeAddress: '0x00000000000000000000000000000000000000cc', + delegations: [ + { + delegate: '0x00000000000000000000000000000000000000cc', + delegator: '0x00000000000000000000000000000000000000bb', + authority: `0x${'0'.repeat(64)}`, + caveats: [], + salt: `0x${'1'.repeat(64)}`, + signature: `0x${'2'.repeat(130)}`, + }, + ], + intents: [], + descriptions: [], + warnings: [], + }; + + const plan: ExecutionPlan = { + action: 'long', + request: { + amount: '100', + walletAddress: delegationBundle.delegatorAddress, + chainId: '42161', + marketAddress: '0xmarket', + payTokenAddress: '0xusdc', + collateralTokenAddress: '0xusdc', + leverage: '2', + }, + }; + + const result = await executePerpetualPlan({ + client, + clients, + plan, + txExecutionMode: 'execute', + delegationsBypassActive: false, + delegationBundle, + delegatorWalletAddress: delegationBundle.delegatorAddress, + delegateeWalletAddress: delegationBundle.delegateeAddress, + }); + + expect(result.ok).toBe(true); + expect(executeTransactionMock).not.toHaveBeenCalled(); + expect(sendTransactionWithDelegationMock).toHaveBeenCalledTimes(1); + expect(sendTransactionWithDelegationMock).toHaveBeenCalledWith( + expect.objectContaining({ + to: '0xrouter', + permissionsContext: '0xperm', + delegationManager: delegationBundle.delegationManager, + }), + ); + expect(waitForTransactionReceiptMock).toHaveBeenCalledWith({ hash: '0xhash2' }); + expect(result.txHashes).toEqual(['0xhash2']); + expect(result.lastTxHash).toBe('0xhash2'); + }); +}); diff --git a/typescript/clients/web-ag-ui/apps/agent-gmx-allora/src/workflow/nodes/bootstrap.ts b/typescript/clients/web-ag-ui/apps/agent-gmx-allora/src/workflow/nodes/bootstrap.ts index e41d144ff..f2a6d8d5a 100644 --- a/typescript/clients/web-ag-ui/apps/agent-gmx-allora/src/workflow/nodes/bootstrap.ts +++ b/typescript/clients/web-ag-ui/apps/agent-gmx-allora/src/workflow/nodes/bootstrap.ts @@ -1,7 +1,14 @@ import { copilotkitEmitState } from '@copilotkit/sdk-js/langgraph'; import { Command } from '@langchain/langgraph'; -import { resolvePollIntervalMs, resolveStreamLimit } from '../../config/constants.js'; +import { + resolveAlloraApiBaseUrl, + resolveDelegationsBypass, + resolveGmxAlloraMode, + resolveOnchainActionsApiUrl, + resolvePollIntervalMs, + resolveStreamLimit, +} from '../../config/constants.js'; import { logInfo, type ClmmEvent, type ClmmState, type ClmmUpdate } from '../context.js'; import { ALLOWED_TOKENS, MARKETS } from '../seedData.js'; @@ -22,16 +29,19 @@ export const bootstrapNode = async ( }); } - const mode = process.env['GMX_ALLORA_MODE'] === 'production' ? 'production' : 'debug'; + const mode = resolveGmxAlloraMode(); const pollIntervalMs = resolvePollIntervalMs(); const streamLimit = resolveStreamLimit(); - const delegationsBypassActive = process.env['DELEGATIONS_BYPASS'] === 'true'; + const delegationsBypassActive = resolveDelegationsBypass(); + const onchainActionsBaseUrl = resolveOnchainActionsApiUrl({ logger: logInfo }); logInfo('Initialized GMX Allora workflow context', { mode, pollIntervalMs, streamLimit, delegationsBypassActive, + onchainActionsBaseUrl, + alloraApiBaseUrl: resolveAlloraApiBaseUrl(), }); const dispatch: ClmmEvent = { diff --git a/typescript/clients/web-ag-ui/apps/agent-gmx-allora/src/workflow/nodes/collectDelegations.ts b/typescript/clients/web-ag-ui/apps/agent-gmx-allora/src/workflow/nodes/collectDelegations.ts index 90f51984d..a110a9a3f 100644 --- a/typescript/clients/web-ag-ui/apps/agent-gmx-allora/src/workflow/nodes/collectDelegations.ts +++ b/typescript/clients/web-ag-ui/apps/agent-gmx-allora/src/workflow/nodes/collectDelegations.ts @@ -1,8 +1,13 @@ import { copilotkitEmitState } from '@copilotkit/sdk-js/langgraph'; import { Command, interrupt } from '@langchain/langgraph'; +import { getDeleGatorEnvironment } from '@metamask/delegation-toolkit'; import { z } from 'zod'; -import { ARBITRUM_CHAIN_ID } from '../../config/constants.js'; +import { + ARBITRUM_CHAIN_ID, + resolveAgentWalletAddress, + resolveGmxAlloraMode, +} from '../../config/constants.js'; import { buildTaskStatus, logInfo, @@ -15,10 +20,8 @@ import { type SignedDelegation, } from '../context.js'; import { - AGENT_WALLET_ADDRESS, DELEGATION_DESCRIPTIONS, DELEGATION_INTENTS, - DELEGATION_MANAGER, DELEGATION_WARNINGS, buildDelegations, } from '../seedData.js'; @@ -135,20 +138,27 @@ export const collectDelegationsNode = async ( }); } - const delegatorAddress = normalizeHexAddress(operatorInput.walletAddress, 'delegator wallet address'); - const delegateeAddress = AGENT_WALLET_ADDRESS; + const delegatorAddress = normalizeHexAddress( + operatorInput.walletAddress, + 'delegator wallet address', + ); + const mode = state.private.mode ?? resolveGmxAlloraMode(); + const warnings = mode === 'debug' ? [...DELEGATION_WARNINGS] : []; + const delegateeAddress = resolveAgentWalletAddress(); + const { DelegationManager } = getDeleGatorEnvironment(ARBITRUM_CHAIN_ID); + const delegationManager = normalizeHexAddress(DelegationManager, 'delegation manager'); const request: DelegationSigningInterrupt = { type: 'gmx-delegation-signing-request', message: 'Review and approve the permissions needed to manage your GMX perps.', payloadSchema: z.toJSONSchema(DelegationSigningResponseJsonSchema), chainId: ARBITRUM_CHAIN_ID, - delegationManager: DELEGATION_MANAGER, + delegationManager, delegatorAddress, delegateeAddress, - delegationsToSign: buildDelegations(delegatorAddress), + delegationsToSign: buildDelegations({ delegatorAddress, delegateeAddress }), descriptions: [...DELEGATION_DESCRIPTIONS], - warnings: [...DELEGATION_WARNINGS], + warnings, }; const awaitingInput = buildTaskStatus( @@ -221,13 +231,13 @@ export const collectDelegationsNode = async ( const delegationBundle: DelegationBundle = { chainId: ARBITRUM_CHAIN_ID, - delegationManager: DELEGATION_MANAGER, + delegationManager, delegatorAddress, delegateeAddress, delegations: signedDelegations, intents: [...DELEGATION_INTENTS], descriptions: [...DELEGATION_DESCRIPTIONS], - warnings: [...DELEGATION_WARNINGS], + warnings, }; const { task, statusEvent } = buildTaskStatus( diff --git a/typescript/clients/web-ag-ui/apps/agent-gmx-allora/src/workflow/nodes/collectFundingTokenInput.ts b/typescript/clients/web-ag-ui/apps/agent-gmx-allora/src/workflow/nodes/collectFundingTokenInput.ts index e9074e626..81b937d05 100644 --- a/typescript/clients/web-ag-ui/apps/agent-gmx-allora/src/workflow/nodes/collectFundingTokenInput.ts +++ b/typescript/clients/web-ag-ui/apps/agent-gmx-allora/src/workflow/nodes/collectFundingTokenInput.ts @@ -1,18 +1,19 @@ import { copilotkitEmitState } from '@copilotkit/sdk-js/langgraph'; -import { Command, interrupt } from '@langchain/langgraph'; -import { z } from 'zod'; +import { Command } from '@langchain/langgraph'; -import { FundingTokenInputSchema, type FundingTokenInput } from '../../domain/types.js'; +import type { PerpetualMarket } from '../../clients/onchainActions.js'; +import { ARBITRUM_CHAIN_ID, ONCHAIN_ACTIONS_API_URL } from '../../config/constants.js'; +import { selectGmxPerpetualMarket } from '../../core/marketSelection.js'; +import { type FundingTokenInput } from '../../domain/types.js'; +import { getOnchainActionsClient } from '../clientFactory.js'; import { buildTaskStatus, logInfo, normalizeHexAddress, type ClmmState, type ClmmUpdate, - type FundingTokenInterrupt, type OnboardingState, } from '../context.js'; -import { FUNDING_TOKENS } from '../seedData.js'; type CopilotKitConfig = Parameters[0]; @@ -20,6 +21,22 @@ const ONBOARDING: Pick = { totalSteps: 3, }; +function resolveUsdcTokenAddressFromMarket(market: PerpetualMarket): `0x${string}` { + const longToken = market.longToken; + const shortToken = market.shortToken; + if (!longToken || !shortToken) { + throw new Error('Selected GMX market is missing long/short token metadata.'); + } + + const candidates = [shortToken, longToken]; + const usdcToken = candidates.find((token) => token.symbol.toUpperCase() === 'USDC'); + if (!usdcToken) { + throw new Error('Selected GMX market does not provide USDC collateral.'); + } + + return normalizeHexAddress(usdcToken.tokenUid.address, 'funding token address'); +} + export const collectFundingTokenInputNode = async ( state: ClmmState, config: CopilotKitConfig, @@ -51,17 +68,10 @@ export const collectFundingTokenInputNode = async ( }); } - const request: FundingTokenInterrupt = { - type: 'gmx-funding-token-request', - message: 'Select the token to swap into USDC collateral for GMX perps.', - payloadSchema: z.toJSONSchema(FundingTokenInputSchema), - options: FUNDING_TOKENS, - }; - const awaitingInput = buildTaskStatus( state.view.task, - 'input-required', - 'Awaiting funding-token selection to continue onboarding.', + 'working', + 'Using USDC as collateral for GMX perps.', ); await copilotkitEmitState(config, { view: { @@ -71,43 +81,25 @@ export const collectFundingTokenInputNode = async ( }, }); - const incoming: unknown = await interrupt(request); - - let inputToParse: unknown = incoming; - if (typeof incoming === 'string') { - try { - inputToParse = JSON.parse(incoming); - } catch { - // ignore - } - } - - const parsed = FundingTokenInputSchema.safeParse(inputToParse); - if (!parsed.success) { - const issues = parsed.error.issues.map((issue) => issue.message).join('; '); - const failureMessage = `Invalid funding-token input: ${issues}`; - const { task, statusEvent } = buildTaskStatus(awaitingInput.task, 'failed', failureMessage); - await copilotkitEmitState(config, { - view: { task, activity: { events: [statusEvent], telemetry: state.view.activity.telemetry } }, + let normalizedFundingToken: `0x${string}`; + try { + const onchainActionsClient = getOnchainActionsClient(); + const markets = await onchainActionsClient.listPerpetualMarkets({ + chainIds: [ARBITRUM_CHAIN_ID.toString()], }); - return { - view: { - haltReason: failureMessage, - task, - activity: { events: [statusEvent], telemetry: state.view.activity.telemetry }, - }, - }; - } + const selectedMarket = selectGmxPerpetualMarket({ + markets, + baseSymbol: operatorInput.targetMarket, + quoteSymbol: 'USDC', + }); + if (!selectedMarket) { + throw new Error(`No GMX ${operatorInput.targetMarket}/USDC market available`); + } - const normalizedFundingToken = normalizeHexAddress( - parsed.data.fundingTokenAddress, - 'funding token address', - ); - const isAllowed = FUNDING_TOKENS.some( - (option) => option.address.toLowerCase() === normalizedFundingToken.toLowerCase(), - ); - if (!isAllowed) { - const failureMessage = `Invalid funding-token input: address ${normalizedFundingToken} not in allowed options`; + normalizedFundingToken = resolveUsdcTokenAddressFromMarket(selectedMarket); + } catch (error: unknown) { + const message = error instanceof Error ? error.message : 'Unknown error'; + const failureMessage = `ERROR: Failed to resolve USDC funding token from ${ONCHAIN_ACTIONS_API_URL}: ${message}`; const { task, statusEvent } = buildTaskStatus(awaitingInput.task, 'failed', failureMessage); await copilotkitEmitState(config, { view: { task, activity: { events: [statusEvent], telemetry: state.view.activity.telemetry } }, @@ -124,7 +116,7 @@ export const collectFundingTokenInputNode = async ( const { task, statusEvent } = buildTaskStatus( awaitingInput.task, 'working', - 'Funding token selected. Preparing delegation request.', + 'USDC collateral selected. Preparing delegation request.', ); await copilotkitEmitState(config, { view: { task, activity: { events: [statusEvent], telemetry: state.view.activity.telemetry } }, diff --git a/typescript/clients/web-ag-ui/apps/agent-gmx-allora/src/workflow/nodes/collectSetupInput.ts b/typescript/clients/web-ag-ui/apps/agent-gmx-allora/src/workflow/nodes/collectSetupInput.ts index 8bf895156..87346408c 100644 --- a/typescript/clients/web-ag-ui/apps/agent-gmx-allora/src/workflow/nodes/collectSetupInput.ts +++ b/typescript/clients/web-ag-ui/apps/agent-gmx-allora/src/workflow/nodes/collectSetupInput.ts @@ -75,10 +75,19 @@ export const collectSetupInputNode = async ( }; } + const normalized = + 'usdcAllocation' in parsed.data + ? parsed.data + : { + walletAddress: parsed.data.walletAddress, + usdcAllocation: parsed.data.baseContributionUsd, + targetMarket: parsed.data.targetMarket, + }; + const { task, statusEvent } = buildTaskStatus( awaitingInput.task, 'working', - 'Market and allocation received. Preparing funding token options.', + 'Market and USDC allocation received. Preparing funding token options.', ); await copilotkitEmitState(config, { view: { task, activity: { events: [statusEvent], telemetry: [] } }, @@ -86,7 +95,7 @@ export const collectSetupInputNode = async ( return { view: { - operatorInput: parsed.data, + operatorInput: normalized, onboarding: { ...ONBOARDING, step: 2 }, task, activity: { events: [statusEvent], telemetry: [] }, diff --git a/typescript/clients/web-ag-ui/apps/agent-gmx-allora/src/workflow/nodes/fireCommand.ts b/typescript/clients/web-ag-ui/apps/agent-gmx-allora/src/workflow/nodes/fireCommand.ts index af7915b63..5ab0727ea 100644 --- a/typescript/clients/web-ag-ui/apps/agent-gmx-allora/src/workflow/nodes/fireCommand.ts +++ b/typescript/clients/web-ag-ui/apps/agent-gmx-allora/src/workflow/nodes/fireCommand.ts @@ -40,7 +40,9 @@ export const fireCommandNode = async ( ? 'Agent fired. Workflow completed.' : 'Agent fired before onboarding completed.'; const { task, statusEvent } = buildTaskStatus(currentTask, terminalState, terminalMessage); - await copilotkitEmitState(config, { view: { task, activity: { events: [statusEvent], telemetry: [] } } }); + await copilotkitEmitState(config, { + view: { task, activity: { events: [statusEvent], telemetry: [] } }, + }); return { view: { diff --git a/typescript/clients/web-ag-ui/apps/agent-gmx-allora/src/workflow/nodes/hireCommand.ts b/typescript/clients/web-ag-ui/apps/agent-gmx-allora/src/workflow/nodes/hireCommand.ts index 6e40509b0..7801edda9 100644 --- a/typescript/clients/web-ag-ui/apps/agent-gmx-allora/src/workflow/nodes/hireCommand.ts +++ b/typescript/clients/web-ag-ui/apps/agent-gmx-allora/src/workflow/nodes/hireCommand.ts @@ -33,7 +33,9 @@ export const hireCommandNode = async ( 'submitted', `Agent hired!${amount ? ` Trading ${amount} tokens...` : ''}`, ); - await copilotkitEmitState(config, { view: { task, activity: { events: [statusEvent], telemetry: [] } } }); + await copilotkitEmitState(config, { + view: { task, activity: { events: [statusEvent], telemetry: [] } }, + }); return { view: { diff --git a/typescript/clients/web-ag-ui/apps/agent-gmx-allora/src/workflow/nodes/pollCycle.ts b/typescript/clients/web-ag-ui/apps/agent-gmx-allora/src/workflow/nodes/pollCycle.ts index 680f8618c..9cc6b3a9f 100644 --- a/typescript/clients/web-ag-ui/apps/agent-gmx-allora/src/workflow/nodes/pollCycle.ts +++ b/typescript/clients/web-ag-ui/apps/agent-gmx-allora/src/workflow/nodes/pollCycle.ts @@ -1,30 +1,53 @@ import { copilotkitEmitState } from '@copilotkit/sdk-js/langgraph'; import { Command } from '@langchain/langgraph'; -import { resolvePollIntervalMs } from '../../config/constants.js'; -import { type AlloraPrediction, type GmxAlloraTelemetry } from '../../domain/types.js'; -import { buildTelemetryArtifact } from '../artifacts.js'; +import { fetchAlloraInference, type AlloraInference } from '../../clients/allora.js'; +import type { PerpetualPosition, TransactionPlan } from '../../clients/onchainActions.js'; +import { + ALLORA_HORIZON_HOURS, + ALLORA_TOPIC_IDS, + ALLORA_TOPIC_LABELS, + ARBITRUM_CHAIN_ID, + ONCHAIN_ACTIONS_API_URL, + resolveAlloraApiBaseUrl, + resolveAlloraApiKey, + resolveAlloraChainId, + resolveAllora8hInferenceCacheTtlMs, + resolveGmxAlloraTxExecutionMode, + resolvePollIntervalMs, +} from '../../config/constants.js'; +import { buildAlloraPrediction } from '../../core/alloraPrediction.js'; +import { buildCycleTelemetry } from '../../core/cycle.js'; +import { buildPerpetualExecutionPlan } from '../../core/executionPlan.js'; +import { applyExposureLimits } from '../../core/exposure.js'; +import { selectGmxPerpetualMarket } from '../../core/marketSelection.js'; +import type { AlloraPrediction } from '../../domain/types.js'; +import { + buildExecutionPlanArtifact, + buildExecutionResultArtifact, + buildTelemetryArtifact, +} from '../artifacts.js'; +import { getOnchainActionsClient, getOnchainClients } from '../clientFactory.js'; import { buildTaskStatus, logInfo, + normalizeHexAddress, type ClmmEvent, + type GmxLatestSnapshot, type ClmmState, type ClmmUpdate, } from '../context.js'; import { ensureCronForThread } from '../cronScheduler.js'; -import { ALLORA_PREDICTIONS } from '../seedData.js'; +import { executePerpetualPlan } from '../execution.js'; type CopilotKitConfig = Parameters[0]; type Configurable = { configurable?: { thread_id?: string } }; const DECISION_THRESHOLD = 0.62; -const COOLDOWN_CYCLES = 2; const CONNECT_DELAY_MS = 2500; const CONNECT_DELAY_STEPS = 3; - -function buildTxHash(iteration: number): string { - return `0x${iteration.toString(16).padStart(64, '0')}`; -} +const ALLORA_STALE_CYCLE_LIMIT = 3; +const ERC20_APPROVE_SELECTOR = '0x095ea7b3'; function shouldDelayIteration(iteration: number): boolean { return iteration % 3 === 0; @@ -34,19 +57,166 @@ function delay(ms: number): Promise { return new Promise((resolve) => setTimeout(resolve, ms)); } -function adjustPrediction(prediction: AlloraPrediction, iteration: number): AlloraPrediction { - const confidenceDelta = ((iteration % 3) - 1) * 0.05; - const confidence = Math.min(0.9, Math.max(0.45, prediction.confidence + confidenceDelta)); - const flipDirection = iteration % 5 === 0; - const direction = flipDirection ? (prediction.direction === 'up' ? 'down' : 'up') : prediction.direction; - const priceDrift = (iteration % 4) * (direction === 'up' ? 45 : -35); +function resolveTopicKey(symbol: string): 'BTC' | 'ETH' { + return symbol === 'BTC' ? 'BTC' : 'ETH'; +} + +function buildInferenceSnapshotKey(inference: AlloraInference): string { + return JSON.stringify({ + topicId: inference.topicId, + combinedValue: inference.combinedValue, + confidenceIntervalValues: inference.confidenceIntervalValues, + }); +} + +function isTradePlanAction(action: 'none' | 'long' | 'short' | 'close' | 'reduce'): boolean { + return action !== 'none'; +} + +function parseUsdMetric(raw: string | undefined): number | undefined { + if (!raw) { + return undefined; + } + const normalized = raw.trim(); + if (normalized.length === 0) { + return undefined; + } + if (/^-?\d+\.\d+$/u.test(normalized)) { + const value = Number(normalized); + return Number.isFinite(value) ? value : undefined; + } + if (!/^-?\d+$/u.test(normalized)) { + return undefined; + } + + const sign = normalized.startsWith('-') ? -1 : 1; + const digits = normalized.startsWith('-') ? normalized.slice(1) : normalized; + if (digits.length > 18) { + const scale = 10n ** 30n; + const bigint = BigInt(normalized); + const abs = bigint < 0n ? -bigint : bigint; + const integerPart = abs / scale; + const fractionPart = abs % scale; + return sign * (Number(integerPart) + Number(fractionPart) / Number(scale)); + } + + const value = Number(normalized); + return Number.isFinite(value) ? value : undefined; +} + +function parseBaseUnitAmount(raw: string | undefined, decimals: number): number | undefined { + if (!raw) { + return undefined; + } + if (!Number.isInteger(decimals) || decimals < 0) { + return undefined; + } + const normalized = raw.trim(); + if (!/^-?\d+$/u.test(normalized)) { + return undefined; + } + + const base = 10n ** BigInt(decimals); + const value = BigInt(normalized); + const sign = value < 0n ? -1 : 1; + const abs = value < 0n ? -value : value; + const integerPart = abs / base; + const fractionalPart = abs % base; + return sign * (Number(integerPart) + Number(fractionalPart) / Number(base)); +} + +function parseEpochToIso(raw: string | undefined): string | undefined { + if (!raw) { + return undefined; + } + const trimmed = raw.trim(); + if (!/^\d+$/u.test(trimmed)) { + return undefined; + } + const asNumber = Number(trimmed); + if (!Number.isFinite(asNumber) || asNumber <= 0) { + return undefined; + } + const millis = asNumber > 1_000_000_000_000 ? asNumber : asNumber * 1000; + const date = new Date(millis); + return Number.isNaN(date.getTime()) ? undefined : date.toISOString(); +} + +function getCallSelector(data: string): string | undefined { + if (!data.startsWith('0x') || data.length < 10) { + return undefined; + } + return data.slice(0, 10).toLowerCase(); +} + +function isApprovalOnlyTransactions(transactions: TransactionPlan[] | undefined): boolean { + if (!transactions || transactions.length === 0) { + return false; + } + return transactions.every((tx) => getCallSelector(tx.data) === ERC20_APPROVE_SELECTOR); +} + +function buildLatestSnapshot(params: { + marketAddress: `0x${string}`; + timestamp: string; + position?: PerpetualPosition; + fallbackSizeUsd?: number; + fallbackLeverage?: number; + fallbackOpenedAt?: string; + previous?: GmxLatestSnapshot; +}): GmxLatestSnapshot { + const positionSize = params.position ? parseUsdMetric(params.position.sizeInUsd) : undefined; + const totalUsd = positionSize ?? params.fallbackSizeUsd; + const collateralUsd = params.position + ? parseBaseUnitAmount(params.position.collateralAmount, params.position.collateralToken.decimals) + : undefined; + const derivedLeverage = + positionSize !== undefined && collateralUsd !== undefined && collateralUsd > 0 + ? positionSize / collateralUsd + : undefined; + const leverage = derivedLeverage ?? params.fallbackLeverage ?? params.previous?.leverage; + + const openedAt = params.position + ? parseEpochToIso(params.position.increasedAtTime) + : params.fallbackOpenedAt ?? params.previous?.positionOpenedAt; + + if (!params.position && totalUsd === undefined && params.previous) { + return { + ...params.previous, + timestamp: params.timestamp, + }; + } + + if (params.position) { + const collateralAddress = normalizeHexAddress( + params.position.collateralToken.tokenUid.address, + 'collateral token address', + ); + return { + poolAddress: normalizeHexAddress(params.position.marketAddress, 'market address'), + totalUsd, + leverage, + timestamp: params.timestamp, + positionOpenedAt: openedAt, + positionTokens: [ + { + address: collateralAddress, + symbol: params.position.collateralToken.symbol, + decimals: params.position.collateralToken.decimals, + amountBaseUnits: params.position.collateralAmount, + valueUsd: collateralUsd, + }, + ], + }; + } return { - ...prediction, - confidence: Number(confidence.toFixed(2)), - direction, - predictedPrice: Number((prediction.predictedPrice + priceDrift).toFixed(2)), - timestamp: new Date().toISOString(), + poolAddress: params.marketAddress, + totalUsd, + leverage, + timestamp: params.timestamp, + positionOpenedAt: openedAt, + positionTokens: [], }; } @@ -78,60 +248,255 @@ export const pollCycleNode = async ( } const iteration = (state.view.metrics.iteration ?? 0) + 1; - const basePrediction = ALLORA_PREDICTIONS[selectedPool.baseSymbol === 'BTC' ? 'BTC' : 'ETH']; - const prediction = adjustPrediction(basePrediction, iteration); - const strongSignal = prediction.confidence >= DECISION_THRESHOLD; - - const cyclesSinceTrade = state.view.metrics.cyclesSinceRebalance ?? 0; - const cooldownRemaining = - iteration === 1 ? 0 : Math.max(0, COOLDOWN_CYCLES - cyclesSinceTrade); - const inCooldown = cooldownRemaining > 0; - - let action: GmxAlloraTelemetry['action'] = 'hold'; - let reason = 'Signal below confidence threshold; holding position.'; - - if (inCooldown) { - action = 'cooldown'; - reason = `Cooldown active for ${cooldownRemaining} more cycle(s).`; - } else if (strongSignal) { - if (iteration % 7 === 0) { - action = 'close'; - reason = 'Strong signal reversal detected; closing position.'; - } else if (iteration % 5 === 0) { - action = 'reduce'; - reason = 'Reducing exposure after consecutive signals.'; - } else { - action = 'open'; - reason = `Opening ${prediction.direction} position based on Allora signal.`; + const topicKey = resolveTopicKey(selectedPool.baseSymbol); + const topicId = ALLORA_TOPIC_IDS[topicKey]; + const topicLabel = ALLORA_TOPIC_LABELS[topicKey]; + + let prediction: AlloraPrediction; + let inferenceSnapshotKey = state.view.metrics.lastInferenceSnapshotKey; + let staleCycles = state.view.metrics.staleCycles ?? 0; + try { + const inference = await fetchAlloraInference({ + baseUrl: resolveAlloraApiBaseUrl(), + chainId: resolveAlloraChainId(), + topicId, + apiKey: resolveAlloraApiKey(), + cacheTtlMs: resolveAllora8hInferenceCacheTtlMs(), + }); + inferenceSnapshotKey = buildInferenceSnapshotKey(inference); + staleCycles = 0; + const currentPrice = state.view.metrics.previousPrice ?? inference.combinedValue; + prediction = buildAlloraPrediction({ + inference, + currentPrice, + topic: topicLabel, + horizonHours: ALLORA_HORIZON_HOURS, + }); + } catch (error: unknown) { + const message = error instanceof Error ? error.message : 'Unknown error'; + staleCycles += 1; + + // Auth errors are configuration errors; surface them immediately. + if (message.includes('(401)') || message.includes('(403)')) { + const failureMessage = `ERROR: Failed to fetch Allora prediction: ${message}`; + const { task, statusEvent } = buildTaskStatus(state.view.task, 'failed', failureMessage); + await copilotkitEmitState(config, { + view: { + task, + activity: { events: [statusEvent], telemetry: state.view.activity.telemetry }, + }, + }); + return new Command({ + update: { + view: { + haltReason: failureMessage, + activity: { events: [statusEvent], telemetry: state.view.activity.telemetry }, + metrics: { ...state.view.metrics, staleCycles, iteration }, + task, + profile: state.view.profile, + transactionHistory: state.view.transactionHistory, + }, + }, + goto: 'summarize', + }); } + + // Transient failures should not brick the agent; skip trades and retry on the next cycle. + if (staleCycles > ALLORA_STALE_CYCLE_LIMIT) { + const failureMessage = `ERROR: Abort: Allora API unreachable for ${staleCycles} consecutive cycles (last error: ${message})`; + const { task, statusEvent } = buildTaskStatus(state.view.task, 'failed', failureMessage); + await copilotkitEmitState(config, { + view: { + task, + activity: { events: [statusEvent], telemetry: state.view.activity.telemetry }, + }, + }); + return new Command({ + update: { + view: { + haltReason: failureMessage, + activity: { events: [statusEvent], telemetry: state.view.activity.telemetry }, + metrics: { ...state.view.metrics, staleCycles, iteration }, + task, + profile: state.view.profile, + transactionHistory: state.view.transactionHistory, + }, + }, + goto: 'summarize', + }); + } + + const warningMessage = `WARNING: Allora prediction unavailable (attempt ${staleCycles}/${ALLORA_STALE_CYCLE_LIMIT}); skipping trades this cycle.`; + const { task, statusEvent } = buildTaskStatus(state.view.task, 'working', warningMessage); + await copilotkitEmitState(config, { + view: { + task, + activity: { events: [statusEvent], telemetry: state.view.activity.telemetry }, + }, + }); + return new Command({ + update: { + view: { + activity: { events: [statusEvent], telemetry: state.view.activity.telemetry }, + metrics: { ...state.view.metrics, staleCycles, iteration }, + task, + profile: state.view.profile, + transactionHistory: state.view.transactionHistory, + }, + }, + goto: 'summarize', + }); } - const side = prediction.direction === 'up' ? 'long' : 'short'; - const leverage = Math.min(operatorConfig.maxLeverage, 2); - const sizeUsd = Number((operatorConfig.baseContributionUsd * 0.9).toFixed(2)); - const txHash = ['open', 'reduce', 'close'].includes(action) ? buildTxHash(iteration) : undefined; - const timestamp = new Date().toISOString(); + let gmxMarketAddress: string; + let positions: PerpetualPosition[] = []; + const onchainActionsClient = getOnchainActionsClient(); + try { + const chainIds = [ARBITRUM_CHAIN_ID.toString()]; + const [markets, walletPositions] = await Promise.all([ + onchainActionsClient.listPerpetualMarkets({ chainIds }), + onchainActionsClient.listPerpetualPositions({ + walletAddress: operatorConfig.delegatorWalletAddress, + chainIds, + }), + ]); - const telemetry: GmxAlloraTelemetry = { - cycle: iteration, - action, - reason, - marketSymbol: `${selectedPool.baseSymbol}/${selectedPool.quoteSymbol}`, - side: ['open', 'reduce', 'close'].includes(action) ? side : undefined, - leverage: ['open', 'reduce', 'close'].includes(action) ? leverage : undefined, - sizeUsd: ['open', 'reduce', 'close'].includes(action) ? sizeUsd : undefined, + const selectedMarket = selectGmxPerpetualMarket({ + markets, + baseSymbol: selectedPool.baseSymbol, + quoteSymbol: selectedPool.quoteSymbol, + }); + + if (!selectedMarket) { + const failureMessage = `ERROR: No GMX ${selectedPool.baseSymbol}/${selectedPool.quoteSymbol} market available`; + const { task, statusEvent } = buildTaskStatus(state.view.task, 'failed', failureMessage); + await copilotkitEmitState(config, { + view: { + task, + activity: { events: [statusEvent], telemetry: state.view.activity.telemetry }, + }, + }); + return new Command({ + update: { + view: { + haltReason: failureMessage, + activity: { events: [statusEvent], telemetry: state.view.activity.telemetry }, + metrics: state.view.metrics, + task, + profile: state.view.profile, + transactionHistory: state.view.transactionHistory, + }, + }, + goto: 'summarize', + }); + } + + gmxMarketAddress = selectedMarket.marketToken.address; + positions = walletPositions; + } catch (error: unknown) { + const message = error instanceof Error ? error.message : 'Unknown error'; + const failureMessage = `ERROR: Failed to fetch GMX markets/positions from ${ONCHAIN_ACTIONS_API_URL}: ${message}`; + const { task, statusEvent } = buildTaskStatus(state.view.task, 'failed', failureMessage); + await copilotkitEmitState(config, { + view: { task, activity: { events: [statusEvent], telemetry: state.view.activity.telemetry } }, + }); + return new Command({ + update: { + view: { + haltReason: failureMessage, + activity: { events: [statusEvent], telemetry: state.view.activity.telemetry }, + metrics: state.view.metrics, + task, + profile: state.view.profile, + transactionHistory: state.view.transactionHistory, + }, + }, + goto: 'summarize', + }); + } + + const previousCycle = state.view.metrics.latestCycle; + const assumedPositionSide = state.view.metrics.assumedPositionSide; + const normalizedTargetMarket = gmxMarketAddress.toLowerCase(); + const currentMarketPosition = positions.find( + (position) => position.marketAddress.toLowerCase() === normalizedTargetMarket, + ); + const currentPositionSide = currentMarketPosition?.positionSide; + const previousOpenSide = previousCycle?.action === 'open' ? previousCycle.side : undefined; + + const decisionPreviousSide = currentPositionSide ?? assumedPositionSide ?? previousOpenSide; + const decisionPreviousAction = decisionPreviousSide ? 'open' : previousCycle?.action; + const { telemetry, nextCyclesSinceTrade: initialCyclesSinceTrade } = buildCycleTelemetry({ prediction, - txHash, - timestamp, - metrics: { - confidence: prediction.confidence, - decisionThreshold: DECISION_THRESHOLD, - cooldownRemaining, - }, - }; + decisionThreshold: DECISION_THRESHOLD, + cooldownCycles: 0, + maxLeverage: operatorConfig.maxLeverage, + baseContributionUsd: operatorConfig.baseContributionUsd, + previousAction: decisionPreviousAction, + previousSide: decisionPreviousSide, + cyclesSinceTrade: state.view.metrics.cyclesSinceRebalance ?? 0, + isFirstCycle: iteration === 1, + iteration, + marketSymbol: `${selectedPool.baseSymbol}/${selectedPool.quoteSymbol}`, + }); + + const exposureAdjusted = applyExposureLimits({ + telemetry, + positions, + targetMarketAddress: gmxMarketAddress, + maxMarketExposureUsd: operatorConfig.baseContributionUsd * operatorConfig.maxLeverage, + maxTotalExposureUsd: operatorConfig.baseContributionUsd * operatorConfig.maxLeverage, + }); + + const positionForReduce = + exposureAdjusted.action === 'reduce' && exposureAdjusted.side + ? positions.find( + (position) => + position.marketAddress.toLowerCase() === normalizedTargetMarket && + position.positionSide === exposureAdjusted.side, + ) + : undefined; + + const plannedExecutionPlan = buildPerpetualExecutionPlan({ + telemetry: exposureAdjusted, + chainId: ARBITRUM_CHAIN_ID.toString(), + marketAddress: gmxMarketAddress as `0x${string}`, + walletAddress: operatorConfig.delegatorWalletAddress, + payTokenAddress: operatorConfig.fundingTokenAddress, + collateralTokenAddress: operatorConfig.fundingTokenAddress, + positionContractKey: positionForReduce?.contractKey, + positionSizeInUsd: positionForReduce?.sizeInUsd, + }); + + const skipTradeForUnchangedInference = + isTradePlanAction(plannedExecutionPlan.action) && + Boolean(inferenceSnapshotKey) && + state.view.metrics.lastTradedInferenceSnapshotKey === inferenceSnapshotKey; + + const adjustedTelemetry = skipTradeForUnchangedInference + ? { + ...exposureAdjusted, + action: 'hold' as const, + reason: 'Inference metrics unchanged since last trade; skipping additional action.', + side: undefined, + leverage: undefined, + sizeUsd: undefined, + txHash: undefined, + } + : exposureAdjusted; + + const executionPlan = skipTradeForUnchangedInference + ? ({ action: 'none' } as const) + : plannedExecutionPlan; const nextCyclesSinceTrade = - ['open', 'reduce', 'close'].includes(action) ? 0 : cyclesSinceTrade + 1; + adjustedTelemetry.action === 'hold' && telemetry.action === 'open' + ? (state.view.metrics.cyclesSinceRebalance ?? 0) + 1 + : initialCyclesSinceTrade; + + const action = adjustedTelemetry.action; + const reason = adjustedTelemetry.reason; + const txHash = adjustedTelemetry.txHash; const cycleStatusMessage = `[Cycle ${iteration}] ${action}: ${reason}${txHash ? ` (tx: ${txHash.slice(0, 10)}...)` : ''}`; let { task, statusEvent } = buildTaskStatus(state.view.task, 'working', cycleStatusMessage); @@ -139,14 +504,14 @@ export const pollCycleNode = async ( view: { task, activity: { events: [statusEvent], telemetry: state.view.activity.telemetry }, - metrics: { latestCycle: telemetry }, + metrics: { latestCycle: adjustedTelemetry }, }, }); if (shouldDelayIteration(iteration)) { const stepDelayMs = Math.max(1, Math.floor(CONNECT_DELAY_MS / CONNECT_DELAY_STEPS)); for (let step = 1; step <= CONNECT_DELAY_STEPS; step += 1) { - const waitMessage = `[Cycle ${iteration}] streaming… (${step}/${CONNECT_DELAY_STEPS})`; + const waitMessage = `[Cycle ${iteration}] streaming... (${step}/${CONNECT_DELAY_STEPS})`; const updated = buildTaskStatus(task, 'working', waitMessage); task = updated.task; statusEvent = updated.statusEvent; @@ -154,16 +519,162 @@ export const pollCycleNode = async ( view: { task, activity: { events: [statusEvent], telemetry: state.view.activity.telemetry }, - metrics: { latestCycle: telemetry }, + metrics: { latestCycle: adjustedTelemetry }, }, }); await delay(stepDelayMs); } } + const txExecutionMode = resolveGmxAlloraTxExecutionMode(); + const clients = txExecutionMode === 'execute' ? getOnchainClients() : undefined; + const executionResult = await executePerpetualPlan({ + client: onchainActionsClient, + clients, + plan: executionPlan, + txExecutionMode, + delegationsBypassActive: state.view.delegationsBypassActive === true, + delegationBundle: state.view.delegationBundle, + delegatorWalletAddress: operatorConfig.delegatorWalletAddress, + delegateeWalletAddress: operatorConfig.delegateeWalletAddress, + }); + const approvalOnlyExecution = + executionResult.ok && + (executionPlan.action === 'long' || executionPlan.action === 'short') && + isApprovalOnlyTransactions(executionResult.transactions); + + if (!executionResult.ok) { + const executionFailure = buildTaskStatus( + task, + 'working', + `[Cycle ${iteration}] execution failed: ${executionResult.error ?? 'Unknown error'}`, + ); + task = executionFailure.task; + statusEvent = executionFailure.statusEvent; + } else if (approvalOnlyExecution) { + const approvalStatus = buildTaskStatus( + task, + 'working', + `[Cycle ${iteration}] approval completed; waiting for executable GMX trade transaction.`, + ); + task = approvalStatus.task; + statusEvent = approvalStatus.statusEvent; + } + + const latestCycle = + approvalOnlyExecution + ? { + ...adjustedTelemetry, + action: 'hold' as const, + side: undefined, + leverage: undefined, + sizeUsd: undefined, + txHash: undefined, + reason: `${adjustedTelemetry.reason} Approval completed; waiting for executable GMX trade transaction.`, + } + : adjustedTelemetry; + + let positionAfterExecution = currentMarketPosition; + if (executionResult.ok && executionPlan.action !== 'none') { + try { + const refreshedPositions = await onchainActionsClient.listPerpetualPositions({ + walletAddress: operatorConfig.delegatorWalletAddress, + chainIds: [ARBITRUM_CHAIN_ID.toString()], + }); + positionAfterExecution = refreshedPositions.find( + (position) => position.marketAddress.toLowerCase() === normalizedTargetMarket, + ); + } catch (error: unknown) { + const message = error instanceof Error ? error.message : 'Unknown error'; + logInfo('Unable to refresh GMX position snapshot after execution', { error: message }); + } + } + + const fallbackSizeUsd = + approvalOnlyExecution + ? undefined + : executionResult.ok && executionPlan.action === 'close' + ? 0 + : executionResult.ok && (executionPlan.action === 'long' || executionPlan.action === 'short') + ? adjustedTelemetry.sizeUsd + : undefined; + + const latestSnapshot = buildLatestSnapshot({ + marketAddress: normalizeHexAddress(gmxMarketAddress, 'market address'), + timestamp: latestCycle.timestamp, + position: positionAfterExecution, + fallbackSizeUsd, + fallbackLeverage: + executionResult.ok && (executionPlan.action === 'long' || executionPlan.action === 'short') + ? latestCycle.leverage + : undefined, + fallbackOpenedAt: + executionResult.ok && (executionPlan.action === 'long' || executionPlan.action === 'short') + ? latestCycle.timestamp + : undefined, + previous: state.view.metrics.latestSnapshot, + }); + + const hasCompletedTradeEffect = + executionResult.ok && executionPlan.action !== 'none' && !approvalOnlyExecution; + const lifetimePnlUsd = positionAfterExecution + ? parseUsdMetric(positionAfterExecution.pnl) + : executionResult.ok && executionPlan.action === 'close' + ? 0 + : state.view.metrics.lifetimePnlUsd; + + const nextAssumedPositionSide = (() => { + if (!executionResult.ok) { + return assumedPositionSide; + } + if (approvalOnlyExecution) { + return assumedPositionSide; + } + // Planned actions should advance local assumptions immediately so we don't + // repeat stale intent on the next cycle. + if (executionPlan.action === 'close') { + return undefined; + } + if (executionPlan.action === 'long') { + return 'long'; + } + if (executionPlan.action === 'short') { + return 'short'; + } + // Otherwise, prefer actual onchain state when available. + if (positionAfterExecution?.positionSide) { + return positionAfterExecution.positionSide; + } + return assumedPositionSide; + })(); + const executionPlanEvent: ClmmEvent | undefined = + executionPlan.action === 'none' + ? undefined + : { + type: 'artifact', + artifact: buildExecutionPlanArtifact({ plan: executionPlan, telemetry: latestCycle }), + append: true, + }; + const executionResultEvent: ClmmEvent | undefined = + executionPlan.action === 'none' + ? undefined + : { + type: 'artifact', + artifact: buildExecutionResultArtifact({ + action: executionResult.action, + plan: executionPlan, + ok: executionResult.ok, + error: executionResult.error, + telemetry: latestCycle, + transactions: executionResult.transactions, + txHashes: executionResult.txHashes, + lastTxHash: executionResult.lastTxHash, + }), + append: true, + }; const telemetryEvent: ClmmEvent = { type: 'artifact', - artifact: buildTelemetryArtifact(telemetry), + artifact: buildTelemetryArtifact(latestCycle), append: true, }; @@ -176,21 +687,25 @@ export const pollCycleNode = async ( cronScheduled = true; } - const transactionEntry = txHash - ? { - cycle: iteration, - action, - txHash, - status: 'success' as const, - reason, - timestamp, - } - : undefined; + const finalAction = latestCycle.action; + const finalReason = latestCycle.reason; + const resolvedTxHash = executionResult.lastTxHash ?? latestCycle.txHash; + const transactionEntry = + executionPlan.action !== 'none' + ? { + cycle: iteration, + action: finalAction, + txHash: resolvedTxHash, + status: executionResult.ok ? ('success' as const) : ('failed' as const), + reason: executionResult.ok ? finalReason : executionResult.error ?? finalReason, + timestamp: latestCycle.timestamp, + } + : undefined; const baseAum = state.view.profile.aum ?? 52_000; const baseIncome = state.view.profile.agentIncome ?? 5_400; - const aumDelta = action === 'hold' || action === 'cooldown' ? 10 : 180; - const incomeDelta = action === 'hold' || action === 'cooldown' ? 1.2 : 9.5; + const aumDelta = finalAction === 'hold' || finalAction === 'cooldown' ? 10 : 180; + const incomeDelta = finalAction === 'hold' || finalAction === 'cooldown' ? 1.2 : 9.5; const nextProfile = { ...state.view.profile, aum: Number((baseAum + aumDelta).toFixed(2)), @@ -203,15 +718,31 @@ export const pollCycleNode = async ( metrics: { lastSnapshot: selectedPool, previousPrice: prediction.predictedPrice, - cyclesSinceRebalance: nextCyclesSinceTrade, + cyclesSinceRebalance: approvalOnlyExecution + ? (state.view.metrics.cyclesSinceRebalance ?? 0) + 1 + : nextCyclesSinceTrade, staleCycles: state.view.metrics.staleCycles ?? 0, iteration, - latestCycle: telemetry, + latestCycle, + aumUsd: latestSnapshot.totalUsd, + apy: state.view.metrics.apy ?? state.view.profile.apy, + lifetimePnlUsd, + latestSnapshot, + assumedPositionSide: nextAssumedPositionSide, + lastInferenceSnapshotKey: inferenceSnapshotKey, + lastTradedInferenceSnapshotKey: + hasCompletedTradeEffect && inferenceSnapshotKey + ? inferenceSnapshotKey + : state.view.metrics.lastTradedInferenceSnapshotKey, }, task, activity: { - telemetry: [telemetry], - events: [telemetryEvent, statusEvent], + telemetry: [latestCycle], + events: executionPlanEvent + ? executionResultEvent + ? [telemetryEvent, executionPlanEvent, executionResultEvent, statusEvent] + : [telemetryEvent, executionPlanEvent, statusEvent] + : [telemetryEvent, statusEvent], }, transactionHistory: transactionEntry ? [...state.view.transactionHistory, transactionEntry] diff --git a/typescript/clients/web-ag-ui/apps/agent-gmx-allora/src/workflow/nodes/prepareOperator.ts b/typescript/clients/web-ag-ui/apps/agent-gmx-allora/src/workflow/nodes/prepareOperator.ts index 1e9a08dc3..a4fed23e7 100644 --- a/typescript/clients/web-ag-ui/apps/agent-gmx-allora/src/workflow/nodes/prepareOperator.ts +++ b/typescript/clients/web-ag-ui/apps/agent-gmx-allora/src/workflow/nodes/prepareOperator.ts @@ -1,6 +1,7 @@ import { copilotkitEmitState } from '@copilotkit/sdk-js/langgraph'; import { Command } from '@langchain/langgraph'; +import { resolveAgentWalletAddress } from '../../config/constants.js'; import { type ResolvedGmxConfig } from '../../domain/types.js'; import { buildTaskStatus, @@ -10,12 +11,10 @@ import { type ClmmState, type ClmmUpdate, } from '../context.js'; -import { AGENT_WALLET_ADDRESS, MARKETS } from '../seedData.js'; +import { MARKETS } from '../seedData.js'; type CopilotKitConfig = Parameters[0]; -const DEFAULT_ALLOCATION_USD = 100; - export const prepareOperatorNode = async ( state: ClmmState, config: CopilotKitConfig, @@ -42,8 +41,6 @@ export const prepareOperatorNode = async ( }); } - const operatorWalletAddress = normalizeHexAddress(operatorInput.walletAddress, 'wallet address'); - const fundingTokenInput = state.view.fundingTokenInput; if (!fundingTokenInput) { const failureMessage = 'ERROR: Funding token input missing before strategy setup'; @@ -72,6 +69,11 @@ export const prepareOperatorNode = async ( ); const delegationsBypassActive = state.view.delegationsBypassActive === true; + const agentWalletAddress = resolveAgentWalletAddress(); + const delegatorWalletAddress = delegationsBypassActive + ? agentWalletAddress + : normalizeHexAddress(operatorInput.walletAddress, 'delegator wallet address'); + const delegatorInputWalletAddress = delegationsBypassActive ? undefined : delegatorWalletAddress; if (!delegationsBypassActive && !state.view.delegationBundle) { const failureMessage = 'ERROR: Delegation bundle missing. Complete delegation signing before continuing.'; @@ -94,9 +96,7 @@ export const prepareOperatorNode = async ( }); } - const targetMarket = MARKETS.find( - (market) => market.baseSymbol === operatorInput.targetMarket, - ); + const targetMarket = MARKETS.find((market) => market.baseSymbol === operatorInput.targetMarket); if (!targetMarket) { const failureMessage = `ERROR: Unsupported GMX market ${operatorInput.targetMarket}`; @@ -119,17 +119,22 @@ export const prepareOperatorNode = async ( }); } + const delegateeWalletAddress = agentWalletAddress; + const operatorConfig: ResolvedGmxConfig = { - walletAddress: delegationsBypassActive ? AGENT_WALLET_ADDRESS : operatorWalletAddress, - baseContributionUsd: operatorInput.baseContributionUsd ?? DEFAULT_ALLOCATION_USD, + delegatorWalletAddress, + delegateeWalletAddress, + baseContributionUsd: operatorInput.usdcAllocation, fundingTokenAddress, targetMarket, maxLeverage: targetMarket.maxLeverage, }; logInfo('GMX Allora strategy configuration established', { - operatorWalletAddress, - baseContributionUsd: operatorConfig.baseContributionUsd, + delegatorInputWalletAddress, + delegatorWalletAddress, + delegateeWalletAddress: operatorConfig.delegateeWalletAddress, + usdcAllocation: operatorConfig.baseContributionUsd, fundingToken: fundingTokenAddress, market: `${targetMarket.baseSymbol}/${targetMarket.quoteSymbol}`, maxLeverage: targetMarket.maxLeverage, @@ -140,7 +145,7 @@ export const prepareOperatorNode = async ( 'working', delegationsBypassActive ? `Delegation bypass active. Preparing ${targetMarket.baseSymbol} GMX strategy from agent wallet.` - : `Delegations active. Preparing ${targetMarket.baseSymbol} GMX strategy from user wallet ${operatorWalletAddress}.`, + : `Delegations active. Preparing ${targetMarket.baseSymbol} GMX strategy from user wallet ${delegatorInputWalletAddress}.`, ); await copilotkitEmitState(config, { view: { task, activity: { events: [statusEvent], telemetry: state.view.activity.telemetry } }, diff --git a/typescript/clients/web-ag-ui/apps/agent-gmx-allora/src/workflow/nodes/runCommand.ts b/typescript/clients/web-ag-ui/apps/agent-gmx-allora/src/workflow/nodes/runCommand.ts index 21773b6d2..cba782e3b 100644 --- a/typescript/clients/web-ag-ui/apps/agent-gmx-allora/src/workflow/nodes/runCommand.ts +++ b/typescript/clients/web-ag-ui/apps/agent-gmx-allora/src/workflow/nodes/runCommand.ts @@ -55,7 +55,7 @@ export function extractCommand(messages: ClmmState['messages']): Command | null export function runCommandNode(state: ClmmState): ClmmState { const parsedCommand = extractCommand(state.messages); const nextCommand = - parsedCommand === 'sync' ? state.view.command : parsedCommand ?? state.view.command; + parsedCommand === 'sync' ? state.view.command : (parsedCommand ?? state.view.command); return { ...state, view: { diff --git a/typescript/clients/web-ag-ui/apps/agent-gmx-allora/src/workflow/nodes/runCycleCommand.ts b/typescript/clients/web-ag-ui/apps/agent-gmx-allora/src/workflow/nodes/runCycleCommand.ts index 8e118a6fb..938dbbeff 100644 --- a/typescript/clients/web-ag-ui/apps/agent-gmx-allora/src/workflow/nodes/runCycleCommand.ts +++ b/typescript/clients/web-ag-ui/apps/agent-gmx-allora/src/workflow/nodes/runCycleCommand.ts @@ -13,7 +13,9 @@ export const runCycleCommandNode = async ( 'working', 'Running scheduled GMX Allora cycle.', ); - await copilotkitEmitState(config, { view: { task, activity: { events: [statusEvent], telemetry: [] } } }); + await copilotkitEmitState(config, { + view: { task, activity: { events: [statusEvent], telemetry: [] } }, + }); return { view: { diff --git a/typescript/clients/web-ag-ui/apps/agent-gmx-allora/src/workflow/nodes/summarize.ts b/typescript/clients/web-ag-ui/apps/agent-gmx-allora/src/workflow/nodes/summarize.ts index 7726d5729..2501b965e 100644 --- a/typescript/clients/web-ag-ui/apps/agent-gmx-allora/src/workflow/nodes/summarize.ts +++ b/typescript/clients/web-ag-ui/apps/agent-gmx-allora/src/workflow/nodes/summarize.ts @@ -1,12 +1,7 @@ import { copilotkitEmitState } from '@copilotkit/sdk-js/langgraph'; import { buildSummaryArtifact } from '../artifacts.js'; -import { - buildTaskStatus, - type ClmmState, - type ClmmUpdate, - type TaskState, -} from '../context.js'; +import { buildTaskStatus, type ClmmState, type ClmmUpdate, type TaskState } from '../context.js'; type CopilotKitConfig = Parameters[0]; diff --git a/typescript/clients/web-ag-ui/apps/agent-gmx-allora/src/workflow/seedData.ts b/typescript/clients/web-ag-ui/apps/agent-gmx-allora/src/workflow/seedData.ts index 8898c7532..6aed01fdc 100644 --- a/typescript/clients/web-ag-ui/apps/agent-gmx-allora/src/workflow/seedData.ts +++ b/typescript/clients/web-ag-ui/apps/agent-gmx-allora/src/workflow/seedData.ts @@ -1,28 +1,20 @@ +import { ROOT_AUTHORITY } from '@metamask/delegation-toolkit'; + import type { AlloraPrediction, GmxMarket } from '../domain/types.js'; -import type { - DelegationIntentSummary, - FundingTokenOption, - UnsignedDelegation, -} from './context.js'; +import type { DelegationIntentSummary, FundingTokenOption, UnsignedDelegation } from './context.js'; -export const AGENT_WALLET_ADDRESS = - '0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa' as const; -export const DELEGATION_MANAGER = - '0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb' as const; -export const DELEGATION_ENFORCER = - '0xcccccccccccccccccccccccccccccccccccccccc' as const; const ZERO_WORD = `0x${'0'.repeat(64)}` as const; const SALT_WORD = `0x${'1'.repeat(64)}` as const; -const USDC_ADDRESS = '0x1111111111111111111111111111111111111111' as const; -const USDT_ADDRESS = '0x2222222222222222222222222222222222222222' as const; -const WETH_ADDRESS = '0x3333333333333333333333333333333333333333' as const; -const ARB_ADDRESS = '0x4444444444444444444444444444444444444444' as const; +const USDC_ADDRESS = '0xaf88d065e77c8cc2239327c5edb3a432268e5831' as const; +const USDT_ADDRESS = '0xfd086bc7cd5c481dcc9c85ebe478a1c0b69fcbb9' as const; +const WETH_ADDRESS = '0x82af49447d8a07e3bd95bd0d56f35241523fbab1' as const; +const ARB_ADDRESS = '0x912ce59144191c1204e64559fe8253a0e49e6548' as const; export const MARKETS: GmxMarket[] = [ { - address: '0xaaaa000000000000000000000000000000000101', + address: '0x47c031236e19d024b42f8ae6780e44a573170703', baseSymbol: 'BTC', quoteSymbol: 'USDC', token0: { symbol: 'BTC' }, @@ -30,7 +22,7 @@ export const MARKETS: GmxMarket[] = [ maxLeverage: 2, }, { - address: '0xaaaa000000000000000000000000000000000102', + address: '0x70d95587d40a2caf56bd97485ab3eec10bee6336', baseSymbol: 'ETH', quoteSymbol: 'USDC', token0: { symbol: 'ETH' }, @@ -104,25 +96,19 @@ export const DELEGATION_DESCRIPTIONS = [ 'Use Allora predictions to size low-leverage trades.', ]; -export const DELEGATION_WARNINGS = [ - 'This delegation flow is for testing only.', -]; +export const DELEGATION_WARNINGS = ['This delegation flow is for testing only.']; -export function buildDelegations( - delegatorAddress: `0x${string}`, -): UnsignedDelegation[] { +export function buildDelegations(params: { + delegatorAddress: `0x${string}`; + delegateeAddress: `0x${string}`; +}): UnsignedDelegation[] { return [ { - delegate: AGENT_WALLET_ADDRESS, - delegator: delegatorAddress, - authority: ZERO_WORD, - caveats: [ - { - enforcer: DELEGATION_ENFORCER, - terms: ZERO_WORD, - args: ZERO_WORD, - }, - ], + delegate: params.delegateeAddress, + delegator: params.delegatorAddress, + authority: ROOT_AUTHORITY, + // Keep this open for now; in production we'd want to constrain scope via caveats. + caveats: [], salt: SALT_WORD, }, ]; diff --git a/typescript/clients/web-ag-ui/apps/agent-gmx-allora/tests/onboarding.int.test.ts b/typescript/clients/web-ag-ui/apps/agent-gmx-allora/tests/onboarding.int.test.ts new file mode 100644 index 000000000..32f681f4d --- /dev/null +++ b/typescript/clients/web-ag-ui/apps/agent-gmx-allora/tests/onboarding.int.test.ts @@ -0,0 +1,251 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import type { PerpetualMarket } from '../src/clients/onchainActions.js'; +import type { ClmmState } from '../src/workflow/context.js'; +import { collectDelegationsNode } from '../src/workflow/nodes/collectDelegations.js'; +import { collectFundingTokenInputNode } from '../src/workflow/nodes/collectFundingTokenInput.js'; +import { collectSetupInputNode } from '../src/workflow/nodes/collectSetupInput.js'; +import { prepareOperatorNode } from '../src/workflow/nodes/prepareOperator.js'; + +const { copilotkitEmitStateMock, interruptMock, listPerpetualMarketsMock } = vi.hoisted(() => ({ + copilotkitEmitStateMock: vi.fn(async () => undefined), + interruptMock: vi.fn<[], Promise>(), + listPerpetualMarketsMock: vi.fn<[], Promise>(), +})); + +vi.mock('@copilotkit/sdk-js/langgraph', () => ({ + copilotkitEmitState: copilotkitEmitStateMock, +})); + +vi.mock('@langchain/langgraph', async () => { + const actual = await vi.importActual('@langchain/langgraph'); + return { + ...actual, + interrupt: interruptMock, + }; +}); + +vi.mock('../src/workflow/clientFactory.js', () => ({ + getOnchainActionsClient: () => ({ + listPerpetualMarkets: listPerpetualMarketsMock, + }), + getOnchainClients: vi.fn(), +})); + +function buildBaseState(): ClmmState { + return { + messages: [], + copilotkit: { actions: [], context: [] }, + settings: { amount: undefined }, + private: { + mode: undefined, + pollIntervalMs: 5000, + streamLimit: -1, + cronScheduled: false, + bootstrapped: false, + }, + view: { + command: undefined, + task: undefined, + poolArtifact: undefined, + operatorInput: undefined, + onboarding: undefined, + fundingTokenInput: undefined, + selectedPool: undefined, + operatorConfig: undefined, + delegationBundle: undefined, + haltReason: undefined, + executionError: undefined, + delegationsBypassActive: true, + profile: { + agentIncome: undefined, + aum: undefined, + totalUsers: undefined, + apy: undefined, + chains: [], + protocols: [], + tokens: [], + pools: [], + allowedPools: [], + }, + activity: { telemetry: [], events: [] }, + metrics: { + lastSnapshot: undefined, + previousPrice: undefined, + cyclesSinceRebalance: 0, + staleCycles: 0, + iteration: 0, + latestCycle: undefined, + }, + transactionHistory: [], + }, + }; +} + +function mergeState(state: ClmmState, update: Partial): ClmmState { + return { + ...state, + ...update, + view: { + ...state.view, + ...update.view, + activity: update.view?.activity ?? state.view.activity, + metrics: update.view?.metrics ?? state.view.metrics, + profile: update.view?.profile ?? state.view.profile, + transactionHistory: update.view?.transactionHistory ?? state.view.transactionHistory, + }, + private: { + ...state.private, + ...update.private, + }, + settings: { + ...state.settings, + ...update.settings, + }, + copilotkit: { + ...state.copilotkit, + ...update.copilotkit, + }, + }; +} + +afterEach(() => { + copilotkitEmitStateMock.mockReset(); + interruptMock.mockReset(); + listPerpetualMarketsMock.mockReset(); +}); + +describe('GMX Allora onboarding (integration)', () => { + beforeEach(() => { + process.env.GMX_ALLORA_AGENT_WALLET_ADDRESS = '0x0000000000000000000000000000000000000002'; + }); + + afterEach(() => { + delete process.env.GMX_ALLORA_AGENT_WALLET_ADDRESS; + }); + + it('collects USDC allocation and prepares operator config', async () => { + listPerpetualMarketsMock.mockResolvedValueOnce([ + { + marketToken: { chainId: '42161', address: '0x70d95587d40a2caf56bd97485ab3eec10bee6336' }, + longFundingFee: '0', + shortFundingFee: '0', + longBorrowingFee: '0', + shortBorrowingFee: '0', + chainId: '42161', + name: 'ETH/USD [WETH-USDC]', + indexToken: { + tokenUid: { chainId: '42161', address: '0x0000000000000000000000000000000000000000' }, + name: 'Ethereum', + symbol: 'ETH', + isNative: true, + decimals: 18, + iconUri: null, + isVetted: true, + }, + longToken: { + tokenUid: { chainId: '42161', address: '0x82af49447d8a07e3bd95bd0d56f35241523fbab1' }, + name: 'Wrapped Ether', + symbol: 'WETH', + isNative: false, + decimals: 18, + iconUri: null, + isVetted: true, + }, + shortToken: { + tokenUid: { chainId: '42161', address: '0xaf88d065e77c8cc2239327c5edb3a432268e5831' }, + name: 'USDC', + symbol: 'USDC', + isNative: false, + decimals: 6, + iconUri: null, + isVetted: true, + }, + }, + ]); + + const state = buildBaseState(); + + interruptMock.mockResolvedValueOnce({ + walletAddress: '0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', + usdcAllocation: 250, + targetMarket: 'ETH', + }); + + const setupUpdate = await collectSetupInputNode(state, {}); + const stateAfterSetup = mergeState(state, setupUpdate); + + const fundingUpdate = await collectFundingTokenInputNode(stateAfterSetup, {}); + const stateAfterFunding = mergeState(stateAfterSetup, fundingUpdate); + + const delegationsUpdate = await collectDelegationsNode(stateAfterFunding, {}); + const stateAfterDelegations = mergeState(stateAfterFunding, delegationsUpdate); + + const prepared = await prepareOperatorNode(stateAfterDelegations, {}); + + expect(prepared.view?.operatorConfig?.baseContributionUsd).toBe(250); + expect(prepared.view?.operatorConfig?.fundingTokenAddress).toBe( + '0xaf88d065e77c8cc2239327c5edb3a432268e5831', + ); + }); + + it('skips delegation signing interrupts when bypass is active', async () => { + const state = buildBaseState(); + state.view.delegationsBypassActive = true; + + const update = await collectDelegationsNode(state, {}); + + expect(interruptMock).not.toHaveBeenCalled(); + expect(update.view?.onboarding?.step).toBe(3); + }); + + it('omits testing warning in production-mode delegation requests', async () => { + const state = buildBaseState(); + state.view.delegationsBypassActive = false; + state.private.mode = 'production'; + state.view.operatorInput = { + walletAddress: '0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', + usdcAllocation: 10, + targetMarket: 'ETH', + }; + + interruptMock.mockResolvedValueOnce({ outcome: 'rejected' }); + + await collectDelegationsNode(state, {}); + + expect(interruptMock).toHaveBeenCalledTimes(1); + const request = interruptMock.mock.calls[0]?.[0] as { warnings?: string[] }; + expect(request.warnings).toEqual([]); + }); + + it('includes testing warning in debug-mode delegation requests', async () => { + const state = buildBaseState(); + state.view.delegationsBypassActive = false; + state.private.mode = 'debug'; + state.view.operatorInput = { + walletAddress: '0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', + usdcAllocation: 10, + targetMarket: 'ETH', + }; + + interruptMock.mockResolvedValueOnce({ outcome: 'rejected' }); + + await collectDelegationsNode(state, {}); + + const request = interruptMock.mock.calls[0]?.[0] as { warnings?: string[] }; + expect(request.warnings).toEqual(['This delegation flow is for testing only.']); + }); + + it('rejects setup input without USDC allocation', async () => { + const state = buildBaseState(); + + interruptMock.mockResolvedValueOnce({ + walletAddress: '0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', + targetMarket: 'BTC', + }); + + const setupUpdate = await collectSetupInputNode(state, {}); + + expect(setupUpdate.view?.haltReason).toContain('Invalid setup input'); + }); +}); diff --git a/typescript/clients/web-ag-ui/apps/agent-gmx-allora/tests/pollCycle.int.test.ts b/typescript/clients/web-ag-ui/apps/agent-gmx-allora/tests/pollCycle.int.test.ts new file mode 100644 index 000000000..0b5e444c7 --- /dev/null +++ b/typescript/clients/web-ag-ui/apps/agent-gmx-allora/tests/pollCycle.int.test.ts @@ -0,0 +1,644 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import type { AlloraInference } from '../src/clients/allora.js'; +import type { PerpetualMarket, PerpetualPosition } from '../src/clients/onchainActions.js'; +import { ONCHAIN_ACTIONS_API_URL } from '../src/config/constants.js'; +import type { ClmmState } from '../src/workflow/context.js'; +import { pollCycleNode } from '../src/workflow/nodes/pollCycle.js'; + +const { + copilotkitEmitStateMock, + fetchAlloraInferenceMock, + listPerpetualMarketsMock, + listPerpetualPositionsMock, + createPerpetualLongMock, + createPerpetualShortMock, + createPerpetualCloseMock, + createPerpetualReduceMock, +} = vi.hoisted(() => ({ + copilotkitEmitStateMock: vi.fn(async () => undefined), + fetchAlloraInferenceMock: vi.fn<[], Promise>(), + listPerpetualMarketsMock: vi.fn<[], Promise>(), + listPerpetualPositionsMock: vi.fn<[], Promise>(), + createPerpetualLongMock: vi.fn<[], Promise>(), + createPerpetualShortMock: vi.fn<[], Promise>(), + createPerpetualCloseMock: vi.fn<[], Promise>(), + createPerpetualReduceMock: vi.fn<[], Promise>(), +})); + +vi.mock('@copilotkit/sdk-js/langgraph', () => ({ + copilotkitEmitState: copilotkitEmitStateMock, +})); + +vi.mock('../src/clients/allora.js', async () => { + const actual = await vi.importActual( + '../src/clients/allora.js', + ); + return { + ...actual, + fetchAlloraInference: fetchAlloraInferenceMock, + }; +}); + +vi.mock('../src/workflow/clientFactory.js', () => ({ + getOnchainActionsClient: () => ({ + listPerpetualMarkets: listPerpetualMarketsMock, + listPerpetualPositions: listPerpetualPositionsMock, + createPerpetualLong: createPerpetualLongMock, + createPerpetualShort: createPerpetualShortMock, + createPerpetualClose: createPerpetualCloseMock, + createPerpetualReduce: createPerpetualReduceMock, + }), + getOnchainClients: vi.fn(), +})); + +vi.mock('../src/workflow/cronScheduler.js', () => ({ + ensureCronForThread: vi.fn(), +})); + +function buildBaseState(): ClmmState { + return { + messages: [], + copilotkit: { actions: [], context: [] }, + settings: { amount: undefined }, + private: { + mode: undefined, + pollIntervalMs: 5000, + streamLimit: -1, + cronScheduled: false, + bootstrapped: true, + }, + view: { + command: undefined, + task: undefined, + poolArtifact: undefined, + operatorInput: undefined, + onboarding: undefined, + fundingTokenInput: undefined, + selectedPool: { + address: '0xmarket', + baseSymbol: 'BTC', + quoteSymbol: 'USDC', + token0: { symbol: 'BTC' }, + token1: { symbol: 'USDC' }, + maxLeverage: 2, + }, + operatorConfig: { + delegatorWalletAddress: '0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', + delegateeWalletAddress: '0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', + baseContributionUsd: 200, + fundingTokenAddress: '0x1111111111111111111111111111111111111111', + targetMarket: { + address: '0xmarket', + baseSymbol: 'BTC', + quoteSymbol: 'USDC', + token0: { symbol: 'BTC' }, + token1: { symbol: 'USDC' }, + maxLeverage: 2, + }, + maxLeverage: 2, + }, + delegationBundle: undefined, + haltReason: undefined, + executionError: undefined, + delegationsBypassActive: true, + profile: { + agentIncome: undefined, + aum: undefined, + totalUsers: undefined, + apy: undefined, + chains: [], + protocols: [], + tokens: [], + pools: [], + allowedPools: [], + }, + activity: { telemetry: [], events: [] }, + metrics: { + lastSnapshot: undefined, + previousPrice: undefined, + cyclesSinceRebalance: 0, + staleCycles: 0, + iteration: 0, + latestCycle: undefined, + }, + transactionHistory: [], + }, + }; +} + +const baseMarket: PerpetualMarket = { + marketToken: { chainId: '42161', address: '0xmarket' }, + longFundingFee: '0', + shortFundingFee: '0', + longBorrowingFee: '0', + shortBorrowingFee: '0', + chainId: '42161', + name: 'GMX BTC/USD', + indexToken: { + tokenUid: { chainId: '42161', address: '0xbtc' }, + name: 'Bitcoin', + symbol: 'BTC', + isNative: false, + decimals: 8, + iconUri: null, + isVetted: true, + }, + longToken: { + tokenUid: { chainId: '42161', address: '0xusdc' }, + name: 'USD Coin', + symbol: 'USDC', + isNative: false, + decimals: 6, + iconUri: null, + isVetted: true, + }, + shortToken: { + tokenUid: { chainId: '42161', address: '0xusdc' }, + name: 'USD Coin', + symbol: 'USDC', + isNative: false, + decimals: 6, + iconUri: null, + isVetted: true, + }, +}; + +const approvalOnlyTransaction = { + type: 'transaction', + to: '0xaf88d065e77c8cc2239327c5edb3a432268e5831', + data: '0x095ea7b30000000000000000000000001111111111111111111111111111111111111111', + value: '0', + chainId: '42161', +}; + +describe('pollCycleNode (integration)', () => { + beforeEach(() => { + fetchAlloraInferenceMock.mockReset(); + listPerpetualMarketsMock.mockReset(); + listPerpetualPositionsMock.mockReset(); + createPerpetualLongMock.mockReset(); + createPerpetualShortMock.mockReset(); + createPerpetualCloseMock.mockReset(); + createPerpetualReduceMock.mockReset(); + copilotkitEmitStateMock.mockReset(); + }); + + it('falls back to cached state when Allora fetch fails transiently', async () => { + fetchAlloraInferenceMock.mockRejectedValueOnce(new TypeError('fetch failed')); + + const state = buildBaseState(); + state.view.metrics.previousPrice = 47000; + + const result = await pollCycleNode(state, {}); + const update = (result as { update: ClmmState }).update; + + expect(update.view?.haltReason).toBeUndefined(); + expect(update.view?.metrics.staleCycles).toBe(1); + expect(update.view?.metrics.previousPrice).toBe(47000); + + const statusMessages = (update.view?.activity.events ?? []) + .filter((event) => event.type === 'status') + .map((event) => event.message); + expect(statusMessages.join(' ')).toContain('WARNING'); + }); + + it('surfaces onchain-actions endpoint in GMX markets/positions fetch failures', async () => { + fetchAlloraInferenceMock.mockResolvedValueOnce({ + topicId: 14, + combinedValue: 47000, + confidenceIntervalValues: [46000, 46500, 47000, 47500, 48000], + }); + listPerpetualMarketsMock.mockRejectedValueOnce(new TypeError('fetch failed')); + + const state = buildBaseState(); + const result = await pollCycleNode(state, {}); + const update = (result as { update: ClmmState }).update; + + expect(update.view?.haltReason).toContain( + `ERROR: Failed to fetch GMX markets/positions from ${ONCHAIN_ACTIONS_API_URL}`, + ); + expect(update.view?.haltReason).toContain('fetch failed'); + }); + + it('emits telemetry and execution plan artifacts on open action', async () => { + fetchAlloraInferenceMock.mockResolvedValueOnce({ + topicId: 14, + combinedValue: 47000, + confidenceIntervalValues: [46000, 46500, 47000, 47500, 48000], + }); + listPerpetualMarketsMock.mockResolvedValueOnce([baseMarket]); + listPerpetualPositionsMock.mockResolvedValueOnce([]); + createPerpetualLongMock.mockResolvedValueOnce({ transactions: [] }); + + const state = buildBaseState(); + state.view.metrics.previousPrice = 46000; + const result = await pollCycleNode(state, {}); + + const update = (result as { update: ClmmState }).update; + const events = update.view?.activity.events ?? []; + const artifactIds = events + .filter((event) => event.type === 'artifact') + .map((event) => event.artifact.artifactId); + + expect(artifactIds).toContain('gmx-allora-telemetry'); + expect(artifactIds).toContain('gmx-allora-execution-plan'); + expect(update.view?.metrics.latestSnapshot?.totalUsd).toBeGreaterThan(0); + }); + + it('routes open long decisions to createPerpetualLong', async () => { + fetchAlloraInferenceMock.mockResolvedValueOnce({ + topicId: 14, + combinedValue: 47000, + confidenceIntervalValues: [46000, 46500, 47000, 47500, 48000], + }); + listPerpetualMarketsMock.mockResolvedValueOnce([baseMarket]); + listPerpetualPositionsMock.mockResolvedValueOnce([]); + + const state = buildBaseState(); + state.view.metrics.previousPrice = 46000; + await pollCycleNode(state, {}); + + expect(createPerpetualLongMock).toHaveBeenCalledTimes(1); + expect(createPerpetualShortMock).not.toHaveBeenCalled(); + expect(createPerpetualCloseMock).not.toHaveBeenCalled(); + expect(createPerpetualReduceMock).not.toHaveBeenCalled(); + }); + + it('routes open short decisions to createPerpetualShort', async () => { + fetchAlloraInferenceMock.mockResolvedValueOnce({ + topicId: 14, + combinedValue: 47000, + confidenceIntervalValues: [46000, 46500, 47000, 47500, 48000], + }); + listPerpetualMarketsMock.mockResolvedValueOnce([baseMarket]); + listPerpetualPositionsMock.mockResolvedValueOnce([]); + + const state = buildBaseState(); + state.view.metrics.previousPrice = 48000; + await pollCycleNode(state, {}); + + expect(createPerpetualShortMock).toHaveBeenCalledTimes(1); + expect(createPerpetualLongMock).not.toHaveBeenCalled(); + expect(createPerpetualCloseMock).not.toHaveBeenCalled(); + expect(createPerpetualReduceMock).not.toHaveBeenCalled(); + }); + + it('routes direction-flip decisions to createPerpetualClose', async () => { + fetchAlloraInferenceMock.mockResolvedValueOnce({ + topicId: 14, + combinedValue: 47000, + confidenceIntervalValues: [46000, 46500, 47000, 47500, 48000], + }); + listPerpetualMarketsMock.mockResolvedValueOnce([baseMarket]); + listPerpetualPositionsMock.mockResolvedValueOnce([]); + + const state = buildBaseState(); + state.view.metrics.previousPrice = 48000; + state.view.metrics.assumedPositionSide = 'long'; + await pollCycleNode(state, {}); + + expect(createPerpetualCloseMock).toHaveBeenCalledTimes(1); + expect(createPerpetualLongMock).not.toHaveBeenCalled(); + expect(createPerpetualShortMock).not.toHaveBeenCalled(); + expect(createPerpetualReduceMock).not.toHaveBeenCalled(); + }); + + it('skips a second trade when inference metrics are unchanged', async () => { + fetchAlloraInferenceMock + .mockResolvedValueOnce({ + topicId: 14, + combinedValue: 47000, + confidenceIntervalValues: [46000, 46500, 47000, 47500, 48000], + }) + .mockResolvedValueOnce({ + topicId: 14, + combinedValue: 47000, + confidenceIntervalValues: [46000, 46500, 47000, 47500, 48000], + }); + listPerpetualMarketsMock.mockResolvedValue([baseMarket]); + listPerpetualPositionsMock.mockResolvedValue([]); + + const firstState = buildBaseState(); + firstState.view.metrics.previousPrice = 48000; + firstState.view.metrics.assumedPositionSide = 'long'; + const firstResult = await pollCycleNode(firstState, {}); + + const firstUpdate = (firstResult as { update: { view?: { metrics?: ClmmState['view']['metrics'] } } }) + .update; + + const secondState = buildBaseState(); + secondState.view.metrics = { + ...secondState.view.metrics, + ...(firstUpdate.view?.metrics ?? {}), + }; + secondState.view.metrics.assumedPositionSide = firstUpdate.view?.metrics?.assumedPositionSide; + secondState.view.metrics.latestCycle = firstUpdate.view?.metrics?.latestCycle; + secondState.view.metrics.previousPrice = firstUpdate.view?.metrics?.previousPrice; + secondState.view.metrics.cyclesSinceRebalance = 3; + + await pollCycleNode(secondState, {}); + + expect(createPerpetualCloseMock).toHaveBeenCalledTimes(1); + }); + + it('allows a second trade when inference metrics change', async () => { + fetchAlloraInferenceMock + .mockResolvedValueOnce({ + topicId: 14, + combinedValue: 47000, + confidenceIntervalValues: [46000, 46500, 47000, 47500, 48000], + }) + .mockResolvedValueOnce({ + topicId: 14, + combinedValue: 45000, + confidenceIntervalValues: [44000, 44500, 45000, 45500, 46000], + }); + listPerpetualMarketsMock.mockResolvedValue([baseMarket]); + listPerpetualPositionsMock.mockResolvedValue([]); + + const firstState = buildBaseState(); + firstState.view.metrics.previousPrice = 46000; + const firstResult = await pollCycleNode(firstState, {}); + + const firstUpdate = (firstResult as { update: { view?: { metrics?: ClmmState['view']['metrics'] } } }) + .update; + + const secondState = buildBaseState(); + secondState.view.metrics = { + ...secondState.view.metrics, + ...(firstUpdate.view?.metrics ?? {}), + }; + secondState.view.metrics.assumedPositionSide = firstUpdate.view?.metrics?.assumedPositionSide; + secondState.view.metrics.latestCycle = firstUpdate.view?.metrics?.latestCycle; + secondState.view.metrics.previousPrice = firstUpdate.view?.metrics?.previousPrice; + secondState.view.metrics.cyclesSinceRebalance = 3; + + await pollCycleNode(secondState, {}); + + expect(createPerpetualLongMock).toHaveBeenCalledTimes(1); + expect(createPerpetualCloseMock).toHaveBeenCalledTimes(1); + }); + + it('retries open trades when the prior cycle only submitted approval transactions', async () => { + fetchAlloraInferenceMock + .mockResolvedValueOnce({ + topicId: 14, + combinedValue: 47000, + confidenceIntervalValues: [46000, 46500, 47000, 47500, 48000], + }) + .mockResolvedValueOnce({ + topicId: 14, + combinedValue: 47000, + confidenceIntervalValues: [46000, 46500, 47000, 47500, 48000], + }); + listPerpetualMarketsMock.mockResolvedValue([baseMarket]); + listPerpetualPositionsMock.mockResolvedValue([]); + createPerpetualLongMock.mockResolvedValue({ transactions: [approvalOnlyTransaction] }); + + const firstState = buildBaseState(); + firstState.view.metrics.previousPrice = 46000; + const firstResult = await pollCycleNode(firstState, {}); + + const firstUpdate = (firstResult as { update: { view?: { metrics?: ClmmState['view']['metrics'] } } }) + .update; + + const secondState = buildBaseState(); + secondState.view.metrics = { + ...secondState.view.metrics, + ...(firstUpdate.view?.metrics ?? {}), + }; + secondState.view.metrics.latestCycle = firstUpdate.view?.metrics?.latestCycle; + secondState.view.metrics.previousPrice = firstUpdate.view?.metrics?.previousPrice; + + await pollCycleNode(secondState, {}); + + expect(createPerpetualLongMock).toHaveBeenCalledTimes(2); + }); + + it('preserves the last known position snapshot when no fresh position snapshot is available', async () => { + fetchAlloraInferenceMock + .mockResolvedValueOnce({ + topicId: 14, + combinedValue: 47000, + confidenceIntervalValues: [46000, 46500, 47000, 47500, 48000], + }) + .mockResolvedValueOnce({ + topicId: 14, + combinedValue: 47000, + confidenceIntervalValues: [46000, 46500, 47000, 47500, 48000], + }); + listPerpetualMarketsMock.mockResolvedValue([baseMarket]); + listPerpetualPositionsMock.mockResolvedValue([]); + createPerpetualLongMock.mockResolvedValueOnce({ transactions: [] }); + + const firstState = buildBaseState(); + firstState.view.metrics.previousPrice = 46000; + const firstResult = await pollCycleNode(firstState, {}); + const firstUpdate = (firstResult as { update: ClmmState }).update; + const firstSnapshot = firstUpdate.view?.metrics.latestSnapshot; + expect(firstSnapshot?.totalUsd).toBeGreaterThan(0); + + const secondState = buildBaseState(); + secondState.view.metrics = { + ...secondState.view.metrics, + ...(firstUpdate.view?.metrics ?? {}), + }; + secondState.view.metrics.assumedPositionSide = firstUpdate.view?.metrics.assumedPositionSide; + secondState.view.metrics.latestCycle = firstUpdate.view?.metrics.latestCycle; + secondState.view.metrics.previousPrice = firstUpdate.view?.metrics.previousPrice; + + const secondResult = await pollCycleNode(secondState, {}); + const secondUpdate = (secondResult as { update: ClmmState }).update; + + expect(secondUpdate.view?.metrics.latestSnapshot?.totalUsd).toBe(firstSnapshot?.totalUsd); + }); + + it('executes reduce plans via onchain-actions reduce endpoint when position exists', async () => { + fetchAlloraInferenceMock.mockResolvedValueOnce({ + topicId: 14, + combinedValue: 47000, + confidenceIntervalValues: [46000, 46500, 47000, 47500, 48000], + }); + listPerpetualMarketsMock.mockResolvedValueOnce([baseMarket]); + listPerpetualPositionsMock.mockResolvedValueOnce([ + { + chainId: '42161', + key: '0xpos1', + contractKey: '0xposition', + account: '0xwallet', + marketAddress: '0xmarket', + sizeInUsd: '2000000000000000000000000000000', + sizeInTokens: '0.01', + collateralAmount: '50', + pendingBorrowingFeesUsd: '0', + increasedAtTime: '0', + decreasedAtTime: '0', + positionSide: 'long', + isLong: true, + fundingFeeAmount: '0', + claimableLongTokenAmount: '0', + claimableShortTokenAmount: '0', + isOpening: false, + pnl: '0', + positionFeeAmount: '0', + traderDiscountAmount: '0', + uiFeeAmount: '0', + collateralToken: { + tokenUid: { chainId: '42161', address: '0xusdc' }, + name: 'USD Coin', + symbol: 'USDC', + isNative: false, + decimals: 6, + iconUri: null, + isVetted: true, + }, + }, + ]); + + const state = buildBaseState(); + state.view.metrics.previousPrice = 46000; + state.view.metrics.iteration = 10; + state.view.metrics.cyclesSinceRebalance = 2; + state.view.metrics.assumedPositionSide = 'long'; + + const result = await pollCycleNode(state, {}); + const update = (result as { update: ClmmState }).update; + + // When we assume the position is already open and the signal stays bullish, + // we should not keep planning repeated opens. + expect(createPerpetualLongMock).not.toHaveBeenCalled(); + expect(createPerpetualReduceMock).not.toHaveBeenCalled(); + + const artifactIds = (update.view?.activity.events ?? []) + .filter((event) => event.type === 'artifact') + .map((event) => event.artifact.artifactId); + expect(artifactIds).toContain('gmx-allora-telemetry'); + expect(artifactIds).not.toContain('gmx-allora-execution-plan'); + }); + + it('hydrates leverage and notional from an existing onchain position when holding', async () => { + fetchAlloraInferenceMock.mockResolvedValueOnce({ + topicId: 14, + combinedValue: 47000, + confidenceIntervalValues: [46000, 46500, 47000, 47500, 48000], + }); + listPerpetualMarketsMock.mockResolvedValueOnce([baseMarket]); + listPerpetualPositionsMock.mockResolvedValueOnce([ + { + chainId: '42161', + key: '0xpos2', + contractKey: '0xposition2', + account: '0xwallet', + marketAddress: '0xmarket', + sizeInUsd: '16000000000000000000000000000000', + sizeInTokens: '0.01', + collateralAmount: '8000000', + pendingBorrowingFeesUsd: '0', + increasedAtTime: '1739325000', + decreasedAtTime: '0', + positionSide: 'long', + isLong: true, + fundingFeeAmount: '0', + claimableLongTokenAmount: '0', + claimableShortTokenAmount: '0', + isOpening: false, + pnl: '0', + positionFeeAmount: '0', + traderDiscountAmount: '0', + uiFeeAmount: '0', + collateralToken: { + tokenUid: { chainId: '42161', address: '0xusdc' }, + name: 'USD Coin', + symbol: 'USDC', + isNative: false, + decimals: 6, + iconUri: null, + isVetted: true, + }, + }, + ]); + + const state = buildBaseState(); + state.view.metrics.previousPrice = 46000; + state.view.metrics.assumedPositionSide = 'long'; + + const result = await pollCycleNode(state, {}); + const update = (result as { update: ClmmState }).update; + const snapshot = update.view?.metrics.latestSnapshot; + + expect(createPerpetualLongMock).not.toHaveBeenCalled(); + expect(snapshot?.totalUsd).toBe(16); + expect(snapshot?.leverage).toBe(2); + expect(snapshot?.positionTokens[0]?.valueUsd).toBe(8); + }); + + it('fails the cycle when no GMX market matches', async () => { + fetchAlloraInferenceMock.mockResolvedValueOnce({ + topicId: 14, + combinedValue: 47000, + confidenceIntervalValues: [46000, 46500, 47000, 47500, 48000], + }); + listPerpetualMarketsMock.mockResolvedValueOnce([]); + listPerpetualPositionsMock.mockResolvedValueOnce([]); + + const state = buildBaseState(); + const result = await pollCycleNode(state, {}); + + const update = (result as { update: ClmmState }).update; + expect(update.view?.haltReason).toContain('No GMX'); + }); + + it('blocks open trades when exposure exceeds caps', async () => { + fetchAlloraInferenceMock.mockResolvedValueOnce({ + topicId: 14, + combinedValue: 47000, + confidenceIntervalValues: [46000, 46500, 47000, 47500, 48000], + }); + listPerpetualMarketsMock.mockResolvedValueOnce([baseMarket]); + listPerpetualPositionsMock.mockResolvedValueOnce([ + { + chainId: '42161', + key: '0xpos', + contractKey: '0xcontract', + account: '0xwallet', + // Different market: still counts towards total exposure, but does not satisfy "already open". + marketAddress: '0xother', + sizeInUsd: '1000', + sizeInTokens: '0.02', + collateralAmount: '500', + pendingBorrowingFeesUsd: '0', + increasedAtTime: '0', + decreasedAtTime: '0', + positionSide: 'long', + isLong: true, + fundingFeeAmount: '0', + claimableLongTokenAmount: '0', + claimableShortTokenAmount: '0', + isOpening: false, + pnl: '0', + positionFeeAmount: '0', + traderDiscountAmount: '0', + uiFeeAmount: '0', + collateralToken: { + tokenUid: { chainId: '42161', address: '0xusdc' }, + name: 'USD Coin', + symbol: 'USDC', + isNative: false, + decimals: 6, + iconUri: null, + isVetted: true, + }, + }, + ]); + + const state = buildBaseState(); + const result = await pollCycleNode(state, {}); + + const update = (result as { update: ClmmState }).update; + const latestCycle = update.view?.metrics.latestCycle; + + expect(latestCycle?.action).toBe('hold'); + expect(latestCycle?.reason).toContain('Exposure limit'); + }); +}); diff --git a/typescript/clients/web-ag-ui/apps/agent-gmx-allora/tests/setup/onchainActions.globalSetup.ts b/typescript/clients/web-ag-ui/apps/agent-gmx-allora/tests/setup/onchainActions.globalSetup.ts new file mode 100644 index 000000000..40b999eb4 --- /dev/null +++ b/typescript/clients/web-ag-ui/apps/agent-gmx-allora/tests/setup/onchainActions.globalSetup.ts @@ -0,0 +1,302 @@ +import { spawn } from 'node:child_process'; +import net from 'node:net'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import fs from 'node:fs'; + +type Cleanup = () => Promise | void; + +function resolveForgeRoot(): string { + const currentFilePath = fileURLToPath(import.meta.url); + const marker = `${path.sep}worktrees${path.sep}`; + const index = currentFilePath.lastIndexOf(marker); + if (index < 0) { + return process.cwd(); + } + return currentFilePath.slice(0, index); +} + +function resolveOnchainActionsDir(): string { + const override = process.env['ONCHAIN_ACTIONS_WORKTREE_DIR']; + if (override && override.trim().length > 0) { + return override.trim(); + } + + const worktreesDir = path.join(resolveForgeRoot(), 'worktrees'); + try { + const entries = fs.readdirSync(worktreesDir, { withFileTypes: true }); + const candidates = entries + .filter((entry) => entry.isDirectory() && entry.name.startsWith('onchain-actions-')) + .map((entry) => path.join(worktreesDir, entry.name)) + .filter((dir) => { + return ( + fs.existsSync(path.join(dir, 'package.json')) && + fs.existsSync(path.join(dir, 'compose.dev.db.yaml')) + ); + }); + + if (candidates.length === 1) { + return candidates[0]!; + } + if (candidates.length > 1) { + throw new Error( + `Multiple onchain-actions worktrees found. Set ONCHAIN_ACTIONS_WORKTREE_DIR explicitly.\n` + + candidates.map((value) => `- ${value}`).join('\n'), + ); + } + } catch { + // fall through to error below + } + + throw new Error( + `Unable to locate an onchain-actions worktree.\n` + + `Set ONCHAIN_ACTIONS_WORKTREE_DIR to an existing worktree directory (e.g. .../worktrees/onchain-actions-XXX).`, + ); +} + +const ONCHAIN_ACTIONS_DIR = resolveOnchainActionsDir(); +const MEMGRAPH_COMPOSE_FILE = + process.env['ONCHAIN_ACTIONS_MEMGRAPH_COMPOSE_FILE'] ?? + path.join(ONCHAIN_ACTIONS_DIR, 'compose.dev.db.yaml'); +const ONCHAIN_ACTIONS_PORT = 50051; +const HEALTH_URL = `http://localhost:${ONCHAIN_ACTIONS_PORT}/health` as const; +const MARKETS_URL = `http://localhost:${ONCHAIN_ACTIONS_PORT}/perpetuals/markets?chainIds=42161` as const; + +async function delay(ms: number): Promise { + await new Promise((resolve) => setTimeout(resolve, ms)); +} + +async function waitForHttpOk(url: string, timeoutMs: number): Promise { + const deadline = Date.now() + timeoutMs; + let lastError: unknown; + + while (Date.now() < deadline) { + try { + const response = await fetch(url); + if (response.ok) { + return; + } + lastError = new Error(`Non-OK response: ${response.status}`); + } catch (error: unknown) { + lastError = error; + } + + await delay(250); + } + + const message = lastError instanceof Error ? lastError.message : String(lastError); + throw new Error(`Timed out waiting for ${url}: ${message}`); +} + +async function waitForNonEmptyMarkets(url: string, timeoutMs: number): Promise { + const deadline = Date.now() + timeoutMs; + let lastError: unknown; + + while (Date.now() < deadline) { + try { + const response = await fetch(url); + if (!response.ok) { + lastError = new Error(`Non-OK response: ${response.status}`); + await delay(500); + continue; + } + const payload = (await response.json()) as { markets?: unknown[] }; + const count = Array.isArray(payload.markets) ? payload.markets.length : 0; + if (count > 0) { + return; + } + lastError = new Error('Markets response was empty'); + } catch (error: unknown) { + lastError = error; + } + + await delay(1000); + } + + const message = lastError instanceof Error ? lastError.message : String(lastError); + throw new Error(`Timed out waiting for non-empty markets from ${url}: ${message}`); +} + +async function waitForTcpPort(params: { host: string; port: number; timeoutMs: number }): Promise { + const deadline = Date.now() + params.timeoutMs; + let lastError: unknown; + + while (Date.now() < deadline) { + try { + await new Promise((resolve, reject) => { + const socket = net.connect({ host: params.host, port: params.port }); + socket.once('connect', () => { + socket.end(); + resolve(); + }); + socket.once('error', reject); + }); + return; + } catch (error: unknown) { + lastError = error; + } + + await delay(250); + } + + const message = lastError instanceof Error ? lastError.message : String(lastError); + throw new Error(`Timed out waiting for TCP ${params.host}:${params.port}: ${message}`); +} + +async function runCommandAndWait( + cmd: string, + args: string[], + options: { cwd: string; env?: NodeJS.ProcessEnv }, +): Promise { + const child = spawn(cmd, args, { + cwd: options.cwd, + env: options.env, + stdio: 'inherit', + }); + + await new Promise((resolve, reject) => { + child.once('error', reject); + child.once('exit', (code) => { + if (code === 0) { + resolve(); + return; + } + reject(new Error(`${cmd} ${args.join(' ')} exited with code ${code ?? 'null'}`)); + }); + }); +} + +function spawnLongLivedCommand( + cmd: string, + args: string[], + options: { cwd: string; env?: NodeJS.ProcessEnv }, +): { cleanup: Cleanup; getExitError: () => Error | undefined } { + const child = spawn(cmd, args, { + cwd: options.cwd, + env: options.env, + stdio: 'inherit', + }); + + let exitError: Error | undefined; + child.once('exit', (code, signal) => { + if (code === 0) { + return; + } + exitError = new Error( + `${cmd} ${args.join(' ')} exited unexpectedly (code=${code ?? 'null'}, signal=${signal ?? 'null'})`, + ); + }); + child.once('error', (error) => { + exitError = error instanceof Error ? error : new Error(String(error)); + }); + + return { + getExitError: () => exitError, + cleanup: async () => { + if (child.exitCode !== null) { + return; + } + child.kill('SIGTERM'); + + // Give it a moment to shutdown cleanly. + for (let i = 0; i < 20; i += 1) { + if (child.exitCode !== null) { + return; + } + await delay(100); + } + + child.kill('SIGKILL'); + }, + }; +} + +async function dockerCompose(args: string[]): Promise { + // Use the plugin-style CLI ("docker compose") to match modern setups. + await runCommandAndWait('docker', ['compose', ...args], { + cwd: ONCHAIN_ACTIONS_DIR, + env: process.env, + }); +} + +export default async function onchainActionsGlobalSetup(): Promise { + const configured = process.env['ONCHAIN_ACTIONS_API_URL']; + if (configured) { + // When ONCHAIN_ACTIONS_API_URL is explicitly provided, tests should use that URL + // and skip booting a local onchain-actions worktree. + const trimmed = configured.trim(); + const normalized = trimmed.endsWith('/') + ? trimmed.slice(0, -1) + : trimmed.endsWith('/openapi.json') + ? trimmed.slice(0, -'/openapi.json'.length) + : trimmed; + process.env['ONCHAIN_ACTIONS_API_URL'] = normalized; + await waitForNonEmptyMarkets( + `${normalized}/perpetuals/markets?chainIds=42161`, + 30_000, + ); + return async () => { + // no-op + }; + } + + // Ensure tests target the local server started by this global setup. + process.env['ONCHAIN_ACTIONS_API_URL'] = `http://localhost:${ONCHAIN_ACTIONS_PORT}`; + + // Start Memgraph (required by onchain-actions container initialization). + await dockerCompose(['-f', MEMGRAPH_COMPOSE_FILE, 'up', '-d', 'memgraph']); + await waitForTcpPort({ host: '127.0.0.1', port: 7687, timeoutMs: 30_000 }); + + // Start onchain-actions REST server. + const onchainActionsEnv: NodeJS.ProcessEnv = { + ...process.env, + PORT: String(ONCHAIN_ACTIONS_PORT), + MEMGRAPH_HOST: 'localhost', + MEMGRAPH_BOLT_PORT: '7687', + MEMGRAPH_LAB_PORT: '7444', + TEST_ENV: 'mock', + // Avoid heavy/long-running services and first-import loops during tests. + SKIP_FIRST_IMPORT: 'false', + ENABLE_CONTRACT_SNIPER: 'false', + PRE_FETCH_GMX_MARKET_QUERY: 'false', + GMX_SKIP_SIMULATION: 'true', + // Minimal required settings for container init. + COINGECKO_API_KEY: process.env['COINGECKO_API_KEY'] ?? '', + COINGECKO_USE_PRO: process.env['COINGECKO_USE_PRO'] ?? 'false', + SQUID_INTEGRATOR_ID: process.env['SQUID_INTEGRATOR_ID'] ?? 'test', + DUNE_API_KEY: process.env['DUNE_API_KEY'] ?? 'test', + BIRDEYE_API_KEY: process.env['BIRDEYE_API_KEY'] ?? '', + PENDLE_CHAIN_IDS: process.env['PENDLE_CHAIN_IDS'] ?? '42161', + ALGEBRA_CHAIN_IDS: process.env['ALGEBRA_CHAIN_IDS'] ?? '42161', + GMX_CHAIN_IDS: process.env['GMX_CHAIN_IDS'] ?? '42161', + SERVICE_WALLET_PRIVATE_KEY: + process.env['SERVICE_WALLET_PRIVATE_KEY'] ?? `0x${'1'.repeat(64)}`, + DUST_CHAIN_ID: process.env['DUST_CHAIN_ID'] ?? '1', + DUST_CHAIN_RECEIVER_ADDRESS: + process.env['DUST_CHAIN_RECEIVER_ADDRESS'] ?? + '0x0000000000000000000000000000000000000000', + }; + + const server = spawnLongLivedCommand('pnpm', ['dev'], { + cwd: ONCHAIN_ACTIONS_DIR, + env: onchainActionsEnv, + }); + + // Wait for server readiness. + await waitForHttpOk(HEALTH_URL, 60_000); + const earlyExit = server.getExitError(); + if (earlyExit) { + throw earlyExit; + } + // Wait for plugin import to populate GMX perpetual markets. + await waitForNonEmptyMarkets(MARKETS_URL, 120_000); + const postImportExit = server.getExitError(); + if (postImportExit) { + throw postImportExit; + } + + return async () => { + await server.cleanup(); + await dockerCompose(['-f', MEMGRAPH_COMPOSE_FILE, 'down', '--remove-orphans']); + }; +} diff --git a/typescript/clients/web-ag-ui/apps/agent-gmx-allora/tests/setup/vitest.setup.ts b/typescript/clients/web-ag-ui/apps/agent-gmx-allora/tests/setup/vitest.setup.ts new file mode 100644 index 000000000..fd2c7551b --- /dev/null +++ b/typescript/clients/web-ag-ui/apps/agent-gmx-allora/tests/setup/vitest.setup.ts @@ -0,0 +1,3 @@ +// Placeholder setup file referenced by the shared Vitest config. +// Projects can extend this stub with log controls or global mocks as needed. +export {}; diff --git a/typescript/clients/web-ag-ui/apps/agent-gmx-allora/tests/smoke/README.md b/typescript/clients/web-ag-ui/apps/agent-gmx-allora/tests/smoke/README.md new file mode 100644 index 000000000..84f7ade3a --- /dev/null +++ b/typescript/clients/web-ag-ui/apps/agent-gmx-allora/tests/smoke/README.md @@ -0,0 +1,25 @@ +# GMX Allora Smoke Tests + +## Purpose + +Manual smoke checks for Phase 2 execution planning against onchain-actions and Allora. +These replace the old “happy path” e2e test; they are intended to be run explicitly by a developer. + +## Environment Variables + +- `SMOKE_WALLET`: Delegator wallet address used for listing positions / planning. +- `SMOKE_USDC_ADDRESS`: USDC token address for collateral/pay token. Defaults to Arbitrum USDC + (`0xaf88d065e77c8cC2239327C5EDb3A432268e5831`) when unset. +- `ONCHAIN_ACTIONS_API_URL`: Optional override (default: `https://api.emberai.xyz`). +- `ALLORA_API_BASE_URL`: Optional override (default uses `resolveAlloraApiBaseUrl`). +- `ALLORA_API_KEY`: Allora API key. +- `DELEGATIONS_BYPASS`: When `true`, smoke execution uses the agent wallet directly (no delegations). +- `GMX_ALLORA_TX_SUBMISSION_MODE`: `plan` (default) or `execute` (broadcast). +- `A2A_TEST_AGENT_NODE_PRIVATE_KEY`: Required when `GMX_ALLORA_TX_SUBMISSION_MODE=execute`. +- `SMOKE_DELEGATOR_PRIVATE_KEY`: Required when `DELEGATIONS_BYPASS=false` and `GMX_ALLORA_TX_SUBMISSION_MODE=execute`. + +## Run + +```bash +pnpm test:smoke +``` diff --git a/typescript/clients/web-ag-ui/apps/agent-gmx-allora/tests/smoke/gmx-allora-smoke.ts b/typescript/clients/web-ag-ui/apps/agent-gmx-allora/tests/smoke/gmx-allora-smoke.ts new file mode 100644 index 000000000..7af52cee3 --- /dev/null +++ b/typescript/clients/web-ag-ui/apps/agent-gmx-allora/tests/smoke/gmx-allora-smoke.ts @@ -0,0 +1,761 @@ +import crypto from 'node:crypto'; + +import { + createPublicClient, + erc20Abi, + formatUnits, + getAddress, + http, + type Address, + type Hash, +} from 'viem'; +import { getDeleGatorEnvironment, ROOT_AUTHORITY, signDelegation } from '@metamask/delegation-toolkit'; +import { privateKeyToAccount } from 'viem/accounts'; +import { arbitrum } from 'viem/chains'; + +import { fetchAlloraInference } from '../../src/clients/allora.js'; +import { OnchainActionsClient } from '../../src/clients/onchainActions.js'; +import { + ALLORA_TOPIC_IDS, + ARBITRUM_CHAIN_ID, + resolveAlloraApiBaseUrl, + resolveAlloraApiKey, + resolveAlloraChainId, + resolveDelegationsBypass, + resolveGmxAlloraTxExecutionMode, + resolveAgentWalletAddress, + resolveOnchainActionsApiUrl, +} from '../../src/config/constants.js'; +import type { DelegationBundle, SignedDelegation } from '../../src/workflow/context.js'; +import { executePerpetualPlan } from '../../src/workflow/execution.js'; +import { getOnchainClients } from '../../src/workflow/clientFactory.js'; +import type { ExecutionPlan } from '../../src/core/executionPlan.js'; +import type { ExecutionResult } from '../../src/workflow/execution.js'; +import type { PerpetualPosition } from '../../src/clients/onchainActions.js'; + +const DEFAULT_SMOKE_USDC_ADDRESS = '0xaf88d065e77c8cC2239327C5EDb3A432268e5831' as const; +// 1.1 USDC in base units (6 decimals). 1.0 USDC can fail GMX simulation as "LiquidatablePosition" +// due to fees pushing remaining collateral below the min collateral threshold. +const DEFAULT_LONG_AMOUNT_BASE_UNITS = 1_100_000n; +const USDC_DECIMALS = 6; +const DEFAULT_STEP_TIMEOUT_MS = 15_000; +const CLOSE_RETRY_INTERVAL_MS = 5_000; +const CLOSE_RETRY_TIMEOUT_MS = 90_000; +const OPEN_POSITION_READY_TIMEOUT_MS = 180_000; +const TX_RECEIPT_TIMEOUT_MS = 180_000; +const CLOSE_POSITION_SETTLE_TIMEOUT_MS = 180_000; +const MAX_FULL_CYCLE_USDC_LOSS_BASE_UNITS = 1_000_000n; + +const resolveArbitrumRpcUrl = (): string => + process.env['ARBITRUM_RPC_URL'] ?? 'https://arb1.arbitrum.io/rpc'; + +const resolveBaseUrl = (): string => + resolveOnchainActionsApiUrl({ + endpoint: process.env['ONCHAIN_ACTIONS_API_URL'], + logger: (message, metadata) => { + console.info(`[smoke] ${message}`, metadata); + }, + }); + +const resolveWalletAddress = (): `0x${string}` | undefined => { + const value = process.env['SMOKE_WALLET']; + if (!value) { + return undefined; + } + if (!value.startsWith('0x')) { + throw new Error(`SMOKE_WALLET must be a hex address, got: ${value}`); + } + return value as `0x${string}`; +}; + +function resolveDelegatorPrivateKey(): `0x${string}` | undefined { + const value = process.env['SMOKE_DELEGATOR_PRIVATE_KEY']; + if (!value) { + return undefined; + } + if (!value.startsWith('0x')) { + throw new Error(`SMOKE_DELEGATOR_PRIVATE_KEY must be a hex string, got: ${value}`); + } + return value as `0x${string}`; +} + +const resolveUsdcAddress = (): `0x${string}` | undefined => { + const value = process.env['SMOKE_USDC_ADDRESS']; + if (!value) { + console.info('[smoke] SMOKE_USDC_ADDRESS not set; using default Arbitrum USDC', { + address: DEFAULT_SMOKE_USDC_ADDRESS, + }); + return DEFAULT_SMOKE_USDC_ADDRESS; + } + if (!value.startsWith('0x')) { + throw new Error(`SMOKE_USDC_ADDRESS must be a hex address, got: ${value}`); + } + return value as `0x${string}`; +}; + +function parseBigIntOrZero(value: string): bigint { + try { + return BigInt(value); + } catch { + return 0n; + } +} + +function isNonZeroPosition(position: PerpetualPosition): boolean { + if (parseBigIntOrZero(position.sizeInUsd) > 0n) { + return true; + } + const sizeInTokens = Number(position.sizeInTokens); + return Number.isFinite(sizeInTokens) && sizeInTokens > 0; +} + +const baseUrl = resolveBaseUrl(); +const delegationsBypassActive = resolveDelegationsBypass(); +const txExecutionMode = resolveGmxAlloraTxExecutionMode(); +const agentWalletAddress = + delegationsBypassActive || txExecutionMode === 'execute' ? resolveAgentWalletAddress() : undefined; +const delegatorPrivateKey = resolveDelegatorPrivateKey(); +const walletAddress = + resolveWalletAddress() ?? + (delegationsBypassActive ? agentWalletAddress : undefined) ?? + (delegatorPrivateKey ? (privateKeyToAccount(delegatorPrivateKey).address as `0x${string}`) : undefined); +const usdcAddress = resolveUsdcAddress(); +const client = new OnchainActionsClient(baseUrl); +const arbitrumClient = createPublicClient({ + chain: arbitrum, + transport: http(resolveArbitrumRpcUrl(), { retryCount: 0 }), +}); + +const run = async () => { + console.log('[smoke] Using onchain-actions base URL:', baseUrl); + console.log('[smoke] Delegations bypass active:', delegationsBypassActive); + console.log('[smoke] TX submission mode:', txExecutionMode); + if (agentWalletAddress) { + console.log('[smoke] Agent wallet address:', agentWalletAddress); + } + + const markets = await client.listPerpetualMarkets({ chainIds: ['42161'] }); + if (markets.length === 0) { + throw new Error('No perpetual markets returned.'); + } + console.log(`[smoke] Perpetual markets: ${markets.length}`); + + if (!walletAddress) { + throw new Error( + 'Missing delegator wallet configuration. Set SMOKE_WALLET, or set SMOKE_DELEGATOR_PRIVATE_KEY, or set DELEGATIONS_BYPASS=true with GMX_ALLORA_AGENT_WALLET_ADDRESS/A2A_TEST_AGENT_NODE_PRIVATE_KEY.', + ); + } + + const positions = await client.listPerpetualPositions({ walletAddress, chainIds: ['42161'] }); + console.log(`[smoke] Positions for ${walletAddress}: ${positions.length}`); + + // Preflight balances: simulation requires collateral + gas. + if (!usdcAddress) { + throw new Error('SMOKE_USDC_ADDRESS resolved to empty value.'); + } + + const fetchBalancesViaRpc = async (address: `0x${string}`) => { + const [eth, usdc] = await Promise.all([ + arbitrumClient.getBalance({ address: address as Address }), + arbitrumClient.readContract({ + address: usdcAddress as Address, + abi: erc20Abi, + functionName: 'balanceOf', + args: [address as Address], + }), + ]); + return { eth, usdc }; + }; + + // These RPC reads are the source of truth for preflight (onchain-actions' wallet balances endpoint + // can depend on third party APIs like Dune and may return empty results in local/dev). + const delegatorRpcBalances = await fetchBalancesViaRpc(walletAddress); + const agentRpcBalances = + agentWalletAddress && agentWalletAddress.toLowerCase() !== walletAddress.toLowerCase() + ? await fetchBalancesViaRpc(agentWalletAddress) + : undefined; + + // Best-effort: still call onchain-actions balances for debugging (not gating). + const onchainActionsDelegatorBalances = await client.listWalletBalances({ walletAddress }); + const onchainActionsAgentBalances = + agentWalletAddress && agentWalletAddress.toLowerCase() !== walletAddress.toLowerCase() + ? await client.listWalletBalances({ walletAddress: agentWalletAddress }) + : undefined; + + const findOnchainActionsBalance = ( + balances: typeof onchainActionsDelegatorBalances, + params: { chainId: string; address: `0x${string}` }, + ) => + balances.find( + (balance) => + balance.tokenUid.chainId === params.chainId && + balance.tokenUid.address.toLowerCase() === params.address.toLowerCase(), + ); + + const delegatorEthBalance = findOnchainActionsBalance(onchainActionsDelegatorBalances, { + chainId: '42161', + address: '0x0000000000000000000000000000000000000000', + }); + const delegatorUsdcBalance = findOnchainActionsBalance(onchainActionsDelegatorBalances, { + chainId: '42161', + address: usdcAddress, + }); + const agentEthBalance = onchainActionsAgentBalances + ? findOnchainActionsBalance(onchainActionsAgentBalances, { + chainId: '42161', + address: '0x0000000000000000000000000000000000000000', + }) + : undefined; + + const delegatorUsdcAmountBaseUnits = delegatorRpcBalances.usdc; + + console.log('[smoke] Wallet balances (chainId=42161)', { + rpc: { + delegator: { + address: walletAddress, + eth: { + amount: delegatorRpcBalances.eth.toString(), + decimals: 18, + formatted: formatUnits(delegatorRpcBalances.eth, 18), + }, + usdc: { + address: usdcAddress, + amount: delegatorRpcBalances.usdc.toString(), + decimals: USDC_DECIMALS, + formatted: formatUnits(delegatorRpcBalances.usdc, USDC_DECIMALS), + }, + }, + agent: agentWalletAddress + ? { + address: agentWalletAddress, + eth: agentRpcBalances + ? { + amount: agentRpcBalances.eth.toString(), + decimals: 18, + formatted: formatUnits(agentRpcBalances.eth, 18), + } + : undefined, + } + : null, + }, + onchainActions: { + delegator: { + address: walletAddress, + eth: delegatorEthBalance + ? { + amount: delegatorEthBalance.amount, + decimals: delegatorEthBalance.decimals, + formatted: + delegatorEthBalance.decimals === undefined + ? undefined + : formatUnits(BigInt(delegatorEthBalance.amount), delegatorEthBalance.decimals), + } + : null, + usdc: delegatorUsdcBalance + ? { + address: usdcAddress, + amount: delegatorUsdcBalance.amount, + formatted: + delegatorUsdcBalance.decimals === undefined + ? undefined + : formatUnits(BigInt(delegatorUsdcBalance.amount), delegatorUsdcBalance.decimals), + } + : { address: usdcAddress, amount: '0' }, + }, + agent: agentWalletAddress + ? { + address: agentWalletAddress, + eth: agentEthBalance + ? { + amount: agentEthBalance.amount, + decimals: agentEthBalance.decimals, + formatted: + agentEthBalance.decimals === undefined + ? undefined + : formatUnits(BigInt(agentEthBalance.amount), agentEthBalance.decimals), + } + : null, + } + : null, + }, + }); + + if (txExecutionMode === 'execute' && delegationsBypassActive === false) { + if (!agentWalletAddress) { + throw new Error('Agent wallet address is required when executing with DELEGATIONS_BYPASS=false.'); + } + const agentEthAmount = agentEthBalance ? BigInt(agentEthBalance.amount) : 0n; + if (agentEthAmount === 0n) { + throw new Error( + [ + 'GMX execute preflight failed: agent/delegatee wallet needs ETH on Arbitrum to pay gas for delegated execution.', + `agentWalletAddress=${agentWalletAddress}`, + ].join(' '), + ); + } + } + + console.log('[smoke] Preflight checks passed.'); + + const btcMarket = + markets.find( + (market) => + market.indexToken.symbol.toUpperCase() === 'BTC' && market.name.includes('GMX'), + ) ?? markets[0]; + if (!btcMarket) { + throw new Error('No GMX market found for smoke test.'); + } + + const marketAddress = getAddress(btcMarket.marketToken.address); + const normalizedMarketAddress = marketAddress.toLowerCase(); + const payTokenAddress = getAddress(usdcAddress); + const matchingMarketPositions = positions.filter( + (position) => position.marketAddress.toLowerCase() === normalizedMarketAddress, + ); + const preexistingPosition = matchingMarketPositions.find((position) => position.positionSide === 'long'); + const preexistingMarketPosition = preexistingPosition ?? matchingMarketPositions[0]; + const shouldOpenLongPosition = !preexistingMarketPosition; + let closePositionSide: 'long' | 'short' = preexistingMarketPosition?.positionSide ?? 'long'; + let openedPositionThisRun = false; + + if (preexistingMarketPosition) { + console.log('[smoke] Preexisting market position found; skipping long open and moving to close.', { + marketAddress, + positionSide: preexistingMarketPosition.positionSide, + key: preexistingMarketPosition.key, + }); + } + + const waitForOpenedPosition = async (): Promise => { + const deadline = Date.now() + OPEN_POSITION_READY_TIMEOUT_MS; + let attempt = 0; + + while (true) { + attempt += 1; + const latestPositions = await client.listPerpetualPositions({ walletAddress, chainIds: ['42161'] }); + const latestMatchingPositions = latestPositions.filter( + (position) => position.marketAddress.toLowerCase() === normalizedMarketAddress, + ); + + if (latestMatchingPositions.length > 0) { + const closeCandidate = + latestMatchingPositions.find((position) => position.positionSide === 'long') ?? + latestMatchingPositions[0]; + closePositionSide = closeCandidate.positionSide; + console.log('[smoke] Opened position is indexed and ready for close.', { + marketAddress, + positionSide: closeCandidate.positionSide, + key: closeCandidate.key, + }); + return; + } + + if (Date.now() >= deadline) { + throw new Error( + [ + `Opened position was not indexed within ${OPEN_POSITION_READY_TIMEOUT_MS}ms.`, + 'GMX order execution may still be pending keeper fulfillment.', + ].join(' '), + ); + } + + console.warn( + `[smoke] waiting for opened position to index before close (attempt ${attempt}); retrying in ${CLOSE_RETRY_INTERVAL_MS}ms`, + ); + await new Promise((resolve) => setTimeout(resolve, CLOSE_RETRY_INTERVAL_MS)); + } + }; + + if (shouldOpenLongPosition && delegatorUsdcAmountBaseUnits < DEFAULT_LONG_AMOUNT_BASE_UNITS) { + throw new Error( + [ + 'GMX long planning failed preflight: wallet has insufficient Arbitrum USDC for simulation.', + `walletAddress=${walletAddress}`, + `usdcAddress=${usdcAddress}`, + `required>=${formatUnits(DEFAULT_LONG_AMOUNT_BASE_UNITS, USDC_DECIMALS)} USDC`, + `found=${formatUnits(delegatorUsdcAmountBaseUnits, USDC_DECIMALS)} USDC`, + 'Fund this wallet with USDC on Arbitrum (chainId=42161) or lower the smoke amount.', + ].join(' '), + ); + } + + const inference = await fetchAlloraInference({ + baseUrl: resolveAlloraApiBaseUrl(), + chainId: resolveAlloraChainId(), + topicId: ALLORA_TOPIC_IDS.BTC, + apiKey: resolveAlloraApiKey(), + }); + console.log('[smoke] Allora inference fetched', { topicId: inference.topicId }); + + const failures: string[] = []; + const skips: string[] = []; + + const runStep = async ( + label: string, + fn: () => Promise, + options?: { + skipWhen?: (message: string) => string | null; + timeoutMs?: number; + }, + ) => { + try { + const timeoutMs = options?.timeoutMs ?? DEFAULT_STEP_TIMEOUT_MS; + await Promise.race([ + fn(), + new Promise((_, reject) => + setTimeout(() => reject(new Error(`timeout after ${timeoutMs}ms`)), timeoutMs), + ), + ]); + console.log(`[smoke] ${label}: ok`); + } catch (error: unknown) { + const message = error instanceof Error ? error.message : String(error); + const skipReason = options?.skipWhen ? options.skipWhen(message) : null; + if (skipReason) { + skips.push(`${label}: ${skipReason}`); + console.warn(`[smoke] ${label}: skipped -> ${skipReason}`); + return; + } + failures.push(`${label}: ${message}`); + console.error(`[smoke] ${label}: failed -> ${message}`); + } + }; + + let attemptedOpenLongPlan = false; + let longResult: ExecutionResult | undefined; + let closeResult: ExecutionResult | undefined; + + const assertTransactionsPlanned = (label: string, result: ExecutionResult): void => { + const transactions = result.transactions ?? []; + if (transactions.length === 0) { + throw new Error(`${label} returned no transaction plan entries.`); + } + }; + + const assertSuccessfulReceipts = async (label: string, txHashes: `0x${string}`[]): Promise => { + if (txHashes.length === 0) { + throw new Error(`${label} returned no tx hashes in execute mode.`); + } + for (const txHash of txHashes) { + const receipt = await arbitrumClient.waitForTransactionReceipt({ + hash: txHash as Hash, + timeout: TX_RECEIPT_TIMEOUT_MS, + }); + if (receipt.status !== 'success') { + throw new Error(`${label} transaction reverted: ${txHash}`); + } + console.log(`[smoke] tx confirmed`, { + label, + txHash, + blockNumber: receipt.blockNumber.toString(), + }); + } + }; + + const waitForNoOpenMarketPosition = async (): Promise => { + const deadline = Date.now() + CLOSE_POSITION_SETTLE_TIMEOUT_MS; + let attempt = 0; + while (true) { + attempt += 1; + const latestPositions = await client.listPerpetualPositions({ walletAddress, chainIds: ['42161'] }); + const latestOpenPositions = latestPositions.filter( + (position) => + position.marketAddress.toLowerCase() === normalizedMarketAddress && isNonZeroPosition(position), + ); + if (latestOpenPositions.length === 0) { + return; + } + if (Date.now() >= deadline) { + const summary = latestOpenPositions.map((position) => ({ + key: position.key, + contractKey: position.contractKey, + side: position.positionSide, + sizeInUsd: position.sizeInUsd, + sizeInTokens: position.sizeInTokens, + })); + throw new Error( + `Position still open after close timeout (${CLOSE_POSITION_SETTLE_TIMEOUT_MS}ms): ${JSON.stringify(summary)}`, + ); + } + console.warn( + `[smoke] waiting for close settlement (attempt ${attempt}); retrying in ${CLOSE_RETRY_INTERVAL_MS}ms`, + ); + await new Promise((resolve) => setTimeout(resolve, CLOSE_RETRY_INTERVAL_MS)); + } + }; + + const buildDelegationBundle = async (): Promise => { + if (delegationsBypassActive) { + throw new Error('Delegation bundle requested while DELEGATIONS_BYPASS=true.'); + } + if (!agentWalletAddress) { + throw new Error('Agent wallet address is required to build a delegation bundle.'); + } + if (!delegatorPrivateKey) { + throw new Error( + 'SMOKE_DELEGATOR_PRIVATE_KEY is required when DELEGATIONS_BYPASS=false and broadcasting is enabled.', + ); + } + const derivedDelegator = privateKeyToAccount(delegatorPrivateKey).address.toLowerCase() as `0x${string}`; + if (derivedDelegator !== walletAddress.toLowerCase()) { + throw new Error( + `Delegator private key address (${derivedDelegator}) does not match SMOKE_WALLET (${walletAddress}).`, + ); + } + + const { DelegationManager } = getDeleGatorEnvironment(ARBITRUM_CHAIN_ID); + const salt = (`0x${crypto.randomBytes(32).toString('hex')}` as const) satisfies `0x${string}`; + + const unsigned = { + delegate: agentWalletAddress, + delegator: walletAddress, + authority: ROOT_AUTHORITY, + caveats: [], + salt, + } satisfies Omit; + + // Smoke uses an unrestricted delegation so the agent wallet can redeem onchain-actions plans. + const signature = await signDelegation({ + privateKey: delegatorPrivateKey, + delegation: unsigned, + delegationManager: DelegationManager, + chainId: ARBITRUM_CHAIN_ID, + allowInsecureUnrestrictedDelegation: true, + }); + + const signed: SignedDelegation = { ...unsigned, signature }; + + return { + chainId: ARBITRUM_CHAIN_ID, + delegationManager: DelegationManager, + delegatorAddress: walletAddress, + delegateeAddress: agentWalletAddress, + delegations: [signed], + intents: [], + descriptions: [], + warnings: [], + }; + }; + + const clients = txExecutionMode === 'execute' ? getOnchainClients() : undefined; + const delegationBundle = + txExecutionMode === 'execute' && delegationsBypassActive === false + ? await buildDelegationBundle() + : undefined; + + if (txExecutionMode === 'execute' && delegationsBypassActive) { + if (!agentWalletAddress) { + throw new Error('Agent wallet address is required for bypass execution mode.'); + } + if (walletAddress.toLowerCase() !== agentWalletAddress.toLowerCase()) { + throw new Error( + `SMOKE_WALLET (${walletAddress}) must equal agent wallet (${agentWalletAddress}) when DELEGATIONS_BYPASS=true and broadcasting is enabled.`, + ); + } + } + + const runClosePlan = async (): Promise => { + const plan: ExecutionPlan = { + action: 'close', + request: { + walletAddress, + marketAddress, + positionSide: closePositionSide, + isLimit: false, + }, + }; + + const result = await executePerpetualPlan({ + client, + clients, + plan, + txExecutionMode, + delegationsBypassActive, + delegationBundle, + delegatorWalletAddress: walletAddress, + delegateeWalletAddress: agentWalletAddress, + }); + + if (!result.ok) { + throw new Error(result.error ?? 'unknown execution error'); + } + assertTransactionsPlanned('perpetual close', result); + return result; + }; + + const closeWithRetry = async (): Promise => { + const closeMissingReason = 'No position or order found matching criteria'; + const deadline = Date.now() + CLOSE_RETRY_TIMEOUT_MS; + let attempt = 0; + + while (true) { + attempt += 1; + try { + return await runClosePlan(); + } catch (error: unknown) { + const message = error instanceof Error ? error.message : String(error); + const canRetry = message.includes(closeMissingReason) && Date.now() < deadline; + if (!canRetry) { + throw error; + } + console.warn(`[smoke] close attempt ${attempt} not ready yet; retrying in ${CLOSE_RETRY_INTERVAL_MS}ms`, { + reason: message, + }); + await new Promise((resolve) => setTimeout(resolve, CLOSE_RETRY_INTERVAL_MS)); + } + } + }; + + await runStep( + txExecutionMode === 'execute' ? 'perpetual long execute' : 'perpetual long planning', + async () => { + if (!shouldOpenLongPosition) { + return; + } + attemptedOpenLongPlan = true; + + const plan: ExecutionPlan = { + action: 'long', + request: { + amount: DEFAULT_LONG_AMOUNT_BASE_UNITS.toString(), + walletAddress, + chainId: ARBITRUM_CHAIN_ID.toString(), + marketAddress, + payTokenAddress, + collateralTokenAddress: payTokenAddress, + leverage: '2', + }, + }; + + const result = await executePerpetualPlan({ + client, + clients, + plan, + txExecutionMode, + delegationsBypassActive, + delegationBundle, + delegatorWalletAddress: walletAddress, + delegateeWalletAddress: agentWalletAddress, + }); + + if (!result.ok) { + throw new Error(result.error ?? 'unknown execution error'); + } + + assertTransactionsPlanned('perpetual long', result); + longResult = result; + openedPositionThisRun = true; + closePositionSide = 'long'; + }, + ); + + if (txExecutionMode === 'execute' && openedPositionThisRun) { + await runStep('wait for opened position indexing', waitForOpenedPosition, { + timeoutMs: OPEN_POSITION_READY_TIMEOUT_MS + DEFAULT_STEP_TIMEOUT_MS, + }); + } + + const closeStepLabel = txExecutionMode === 'execute' ? 'perpetual close execute' : 'perpetual close planning'; + const shouldAttemptExecuteClose = txExecutionMode !== 'execute' || preexistingMarketPosition !== undefined || openedPositionThisRun; + + if (!shouldAttemptExecuteClose) { + skips.push(`${closeStepLabel}: no opened/preexisting position to close`); + console.warn(`[smoke] ${closeStepLabel}: skipped -> no opened/preexisting position to close`); + } else { + await runStep( + closeStepLabel, + async () => { + if (txExecutionMode === 'execute') { + closeResult = await closeWithRetry(); + return; + } + closeResult = await runClosePlan(); + }, + { + timeoutMs: txExecutionMode === 'execute' ? CLOSE_RETRY_TIMEOUT_MS + DEFAULT_STEP_TIMEOUT_MS : DEFAULT_STEP_TIMEOUT_MS, + skipWhen: + txExecutionMode === 'execute' + ? undefined + : (message) => { + if (message.includes('No position or order found')) { + return 'no closeable positions for wallet'; + } + return null; + }, + }, + ); + } + + await runStep('preexisting-position branch behavior', async () => { + if (preexistingMarketPosition && attemptedOpenLongPlan) { + throw new Error('Expected open step to be skipped for preexisting market position.'); + } + if (!preexistingMarketPosition && !attemptedOpenLongPlan) { + throw new Error('Expected open step to run when no preexisting market position exists.'); + } + }); + + if (txExecutionMode === 'execute' && longResult?.txHashes) { + await runStep( + 'perpetual long tx lifecycle', + async () => { + await assertSuccessfulReceipts('perpetual long', longResult?.txHashes ?? []); + }, + { timeoutMs: TX_RECEIPT_TIMEOUT_MS + DEFAULT_STEP_TIMEOUT_MS }, + ); + } + + if (txExecutionMode === 'execute' && closeResult?.txHashes) { + await runStep( + 'perpetual close tx lifecycle', + async () => { + await assertSuccessfulReceipts('perpetual close', closeResult?.txHashes ?? []); + }, + { timeoutMs: TX_RECEIPT_TIMEOUT_MS + DEFAULT_STEP_TIMEOUT_MS }, + ); + } + + if (txExecutionMode === 'execute' && closeResult?.ok) { + await runStep('post-close position state', waitForNoOpenMarketPosition, { + timeoutMs: CLOSE_POSITION_SETTLE_TIMEOUT_MS + DEFAULT_STEP_TIMEOUT_MS, + }); + } + + if (txExecutionMode === 'execute' && openedPositionThisRun && closeResult?.ok) { + await runStep('full-cycle USDC balance delta sanity', async () => { + const endingBalances = await fetchBalancesViaRpc(walletAddress); + const delta = endingBalances.usdc - delegatorUsdcAmountBaseUnits; + console.log('[smoke] USDC balance delta', { + walletAddress, + startBaseUnits: delegatorUsdcAmountBaseUnits.toString(), + endBaseUnits: endingBalances.usdc.toString(), + deltaBaseUnits: delta.toString(), + deltaFormattedUsdc: formatUnits(delta, USDC_DECIMALS), + }); + if (delta < -MAX_FULL_CYCLE_USDC_LOSS_BASE_UNITS) { + throw new Error( + [ + 'USDC loss exceeded configured full-cycle tolerance.', + `maxLossBaseUnits=${MAX_FULL_CYCLE_USDC_LOSS_BASE_UNITS.toString()}`, + `actualLossBaseUnits=${(-delta).toString()}`, + ].join(' '), + ); + } + }); + } + + if (failures.length > 0) { + throw new Error(`Smoke checks failed:\n- ${failures.join('\n- ')}`); + } + + if (skips.length > 0) { + console.warn(`[smoke] Skipped checks:\n- ${skips.join('\n- ')}`); + } + + console.log('[smoke] OK'); +}; + +run().catch((error: unknown) => { + const message = error instanceof Error ? error.message : String(error); + console.error('[smoke] FAILED:', message); + process.exitCode = 1; +}); diff --git a/typescript/clients/web-ag-ui/apps/agent-gmx-allora/vitest.config.coverage.ts b/typescript/clients/web-ag-ui/apps/agent-gmx-allora/vitest.config.coverage.ts new file mode 100644 index 000000000..3dfa23c27 --- /dev/null +++ b/typescript/clients/web-ag-ui/apps/agent-gmx-allora/vitest.config.coverage.ts @@ -0,0 +1,24 @@ +import { defineConfig } from 'vitest/config'; + +const setupFiles = ['./tests/setup/vitest.setup.ts']; + +export default defineConfig({ + test: { + name: 'coverage', + globals: true, + environment: 'node', + setupFiles, + passWithNoTests: true, + include: ['src/**/*.unit.test.ts', 'src/**/*.int.test.ts'], + exclude: ['src/**/*.e2e.test.ts'], + coverage: { + enabled: true, + provider: 'v8', + reportsDirectory: './coverage', + reporter: ['text', 'lcov'], + }, + typecheck: { + tsconfig: './tsconfig.vitest.json', + }, + }, +}); diff --git a/typescript/clients/web-ag-ui/apps/agent-gmx-allora/vitest.config.e2e.ts b/typescript/clients/web-ag-ui/apps/agent-gmx-allora/vitest.config.e2e.ts index 4709bd442..80d8492cd 100644 --- a/typescript/clients/web-ag-ui/apps/agent-gmx-allora/vitest.config.e2e.ts +++ b/typescript/clients/web-ag-ui/apps/agent-gmx-allora/vitest.config.e2e.ts @@ -8,6 +8,7 @@ export default defineConfig({ globals: true, environment: 'node', setupFiles, + globalSetup: ['./tests/setup/onchainActions.globalSetup.ts'], include: ['tests/**/*.e2e.test.ts'], passWithNoTests: true, testTimeout: 60_000, diff --git a/typescript/clients/web-ag-ui/apps/agent-pendle/src/clients/clients.ts b/typescript/clients/web-ag-ui/apps/agent-pendle/src/clients/clients.ts index c06736cf2..c1a77aef2 100644 --- a/typescript/clients/web-ag-ui/apps/agent-pendle/src/clients/clients.ts +++ b/typescript/clients/web-ag-ui/apps/agent-pendle/src/clients/clients.ts @@ -1,10 +1,11 @@ import { createPublicClient, createWalletClient, http, type Account } from 'viem'; import { arbitrum } from 'viem/chains'; +const DEFAULT_ARBITRUM_RPC_URL = 'https://arb1.arbitrum.io/rpc'; + const ARBITRUM_RPC_URL = - // Default to a public Arbitrum One RPC so local dev works without requiring a paid RPC key. - // If you need higher reliability/throughput, set ARBITRUM_RPC_URL to an Alchemy/Infura/etc endpoint. - process.env['ARBITRUM_RPC_URL'] ?? 'https://arb1.arbitrum.io/rpc'; + // Default to a public Arbitrum One RPC for local dev; set ARBITRUM_RPC_URL to override. + process.env['ARBITRUM_RPC_URL'] ?? DEFAULT_ARBITRUM_RPC_URL; const RPC_RETRY_COUNT = 2; const RPC_TIMEOUT_MS = 8000; diff --git a/typescript/clients/web-ag-ui/apps/agent-pendle/src/workflow/clientFactory.unit.test.ts b/typescript/clients/web-ag-ui/apps/agent-pendle/src/workflow/clientFactory.unit.test.ts index 55a824dee..dfbc4cd26 100644 --- a/typescript/clients/web-ag-ui/apps/agent-pendle/src/workflow/clientFactory.unit.test.ts +++ b/typescript/clients/web-ag-ui/apps/agent-pendle/src/workflow/clientFactory.unit.test.ts @@ -41,7 +41,7 @@ describe('clientFactory', () => { expect(first).toBe(second); expect(onchainActionsCtorMock).toHaveBeenCalledTimes(1); expect(onchainActionsCtorMock).toHaveBeenCalledWith('https://api.emberai.xyz'); - }); + }, 15_000); it('creates and caches onchain clients using the agent private key', async () => { process.env.A2A_TEST_AGENT_NODE_PRIVATE_KEY = diff --git a/typescript/clients/web-ag-ui/apps/agent-pendle/tests/mocks/data/onchain-actions/tokens-page-1.json b/typescript/clients/web-ag-ui/apps/agent-pendle/tests/mocks/data/onchain-actions/tokens-page-1.json index a49b4885e..d7d48e1ac 100644 --- a/typescript/clients/web-ag-ui/apps/agent-pendle/tests/mocks/data/onchain-actions/tokens-page-1.json +++ b/typescript/clients/web-ag-ui/apps/agent-pendle/tests/mocks/data/onchain-actions/tokens-page-1.json @@ -14,13 +14,13 @@ "status": 200, "headers": { "connection": "keep-alive", - "content-length": "1441", + "content-length": "325", "content-type": "application/json; charset=utf-8", "date": "Thu, 05 Feb 2026 18:11:31 GMT", "etag": "W/\"5a1-VBHNSs2LYcd00t09YeNEe6Fya4k\"", "keep-alive": "timeout=5", "x-powered-by": "Express" }, - "rawBody": "eyJ0b2tlbnMiOlt7InRva2VuVWlkIjp7ImFkZHJlc3MiOiIweDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAiLCJjaGFpbklkIjoiNDIxNjEifSwic3ltYm9sIjoiRVRIIiwibmFtZSI6IkV0aGVyZXVtIiwiZGVjaW1hbHMiOjE4LCJpY29uVXJpIjoiaHR0cHM6Ly9hc3NldHMuY29pbmdlY2tvLmNvbS9jb2lucy9pbWFnZXMvMS9ldGhlcmV1bS5wbmciLCJpc05hdGl2ZSI6dHJ1ZSwiaXNWZXR0ZWQiOnRydWV9LHsidG9rZW5VaWQiOnsiYWRkcmVzcyI6IjB4M0ExOGRjQzk3NDVlRGNEMUVmMzNlY0I5M2IwYjZlQkE1NjcxZTdDYSIsImNoYWluSWQiOiI0MjE2MSJ9LCJzeW1ib2wiOiJLVUpJIiwibmFtZSI6Ikt1amlyYSIsImRlY2ltYWxzIjo2LCJpY29uVXJpIjoiaHR0cHM6Ly9yYXcuZ2l0aHVidXNlcmNvbnRlbnQuY29tL2F4ZWxhcm5ldHdvcmsvYXhlbGFyLWNvbmZpZ3MvbWFpbi9pbWFnZXMvdG9rZW5zL2t1amkuc3ZnIiwiaXNOYXRpdmUiOmZhbHNlLCJpc1ZldHRlZCI6dHJ1ZX0seyJ0b2tlblVpZCI6eyJhZGRyZXNzIjoiMHhjMjRBMzY1QTg3MDgyMUVCODNGZDIxNmM5NTk2ZUREODk0NzlkOGQ3IiwiY2hhaW5JZCI6IjQyMTYxIn0sInN5bWJvbCI6IkczIiwibmFtZSI6IkdBTTNTLkdHIiwiZGVjaW1hbHMiOjE4LCJpY29uVXJpIjoiaHR0cHM6Ly9yYXcuZ2l0aHVidXNlcmNvbnRlbnQuY29tL2F4ZWxhcm5ldHdvcmsvYXhlbGFyLWNvbmZpZ3MvbWFpbi9pbWFnZXMvdG9rZW5zL2czLnN2ZyIsImlzTmF0aXZlIjpmYWxzZSwiaXNWZXR0ZWQiOnRydWV9LHsidG9rZW5VaWQiOnsiYWRkcmVzcyI6IjB4NjRENTk5YjNkMGM1ZjEzNzVlNWU2MzlFOThhQjg2Mjk4MjYxQTMwQiIsImNoYWluSWQiOiI0MjE2MSJ9LCJzeW1ib2wiOiJIVUFIVUEiLCJuYW1lIjoiQ2hpaHVhaHVhIFRva2VuIiwiZGVjaW1hbHMiOjYsImljb25VcmkiOiJodHRwczovL3Jhdy5naXRodWJ1c2VyY29udGVudC5jb20vYXhlbGFybmV0d29yay9heGVsYXItY29uZmlncy9tYWluL2ltYWdlcy90b2tlbnMvaHVhaHVhLnN2ZyIsImlzTmF0aXZlIjpmYWxzZSwiaXNWZXR0ZWQiOnRydWV9LHsidG9rZW5VaWQiOnsiYWRkcmVzcyI6IjB4NDFiOTRjNTg2N2Y3RjYyMTdDOWEzMDUyMENiM2U3OTNCMWVlMWI5NyIsImNoYWluSWQiOiI0MjE2MSJ9LCJzeW1ib2wiOiJUSUEuYXhsIiwibmFtZSI6IlRJQSAoQXhlbGFyKSIsImRlY2ltYWxzIjo2LCJpY29uVXJpIjoiaHR0cHM6Ly9yYXcuZ2l0aHVidXNlcmNvbnRlbnQuY29tL2F4ZWxhcm5ldHdvcmsvYXhlbGFyLWNvbmZpZ3MvbWFpbi9pbWFnZXMvdG9rZW5zL3RpYS5zdmciLCJpc05hdGl2ZSI6ZmFsc2UsImlzVmV0dGVkIjp0cnVlfV0sImN1cnNvciI6ImQ1NDQ3MjEwLWY1NmItNDEzNS05ZWU2LTg3Zjg3NTJlYzIxZCIsImN1cnJlbnRQYWdlIjoxLCJ0b3RhbFBhZ2VzIjo5NCwidG90YWxJdGVtcyI6NDY3fQ==" + "rawBody": "eyJ0b2tlbnMiOlt7InRva2VuVWlkIjp7ImFkZHJlc3MiOiIweGFmODhkMDY1ZTc3YzhjQzIyMzkzMjdDNUVEYjNBNDMyMjY4ZTU4MzEiLCJjaGFpbklkIjoiNDIxNjEifSwic3ltYm9sIjoiVVNEQyIsIm5hbWUiOiJVU0RDIiwiZGVjaW1hbHMiOjYsImljb25VcmkiOiJodHRwczovL3Jhdy5naXRodWJ1c2VyY29udGVudC5jb20vMHhzcXVpZC9hc3NldHMvbWFpbi9pbWFnZXMvdG9rZW5zL3VzZGMuc3ZnIiwiaXNOYXRpdmUiOmZhbHNlLCJpc1ZldHRlZCI6dHJ1ZX1dLCJjdXJzb3IiOm51bGwsImN1cnJlbnRQYWdlIjoxLCJ0b3RhbFBhZ2VzIjoxLCJ0b3RhbEl0ZW1zIjoxfQ==" } } diff --git a/typescript/clients/web-ag-ui/apps/web/.env.example b/typescript/clients/web-ag-ui/apps/web/.env.example index 255f8f560..3020e51a0 100644 --- a/typescript/clients/web-ag-ui/apps/web/.env.example +++ b/typescript/clients/web-ag-ui/apps/web/.env.example @@ -1,12 +1,21 @@ -# Privy authentication + embedded wallet support (required). -# Use the placeholder value to intentionally trigger the "Privy is not configured" UI. -NEXT_PUBLIC_PRIVY_APP_ID=your_privy_app_id_here +# Required for wallet connection UX (Privy). +NEXT_PUBLIC_PRIVY_APP_ID= -# Optional: bypass delegation signing and allow wallet-less onboarding in the UI. +# Optional: bypass delegation signing and allow wallet-less onboarding in the UI DELEGATIONS_BYPASS=false NEXT_PUBLIC_DELEGATIONS_BYPASS=false -# Optional: wallet address used when DELEGATIONS_BYPASS=true and no wallet is connected. +# Optional: enable extra agent connection debugging in the browser console. +# NEXT_PUBLIC_AGENT_CONNECT_DEBUG=false + +# Optional: which agent is selected by default. +# NEXT_PUBLIC_DEFAULT_AGENT_ID=agent-clmm + +# Optional: polling intervals (ms) for sync refresh. +# NEXT_PUBLIC_AGENT_LIST_SYNC_POLL_MS=15000 +# NEXT_PUBLIC_AGENT_DETAIL_SYNC_POLL_MS=5000 + +# Optional: wallet address used when DELEGATIONS_BYPASS=true and no wallet is connected NEXT_PUBLIC_WALLET_BYPASS_ADDRESS=0x0000000000000000000000000000000000000000 # Required only if you use the "Upgrade to smart account" flow in the UI. @@ -17,22 +26,10 @@ NEXT_PUBLIC_WALLET_BYPASS_ADDRESS=0x0000000000000000000000000000000000000000 # Never expose this key publicly. # FUNDING_WALLET_PRIVATE_KEY= -# Optional: debug UI-only connection flows. -NEXT_PUBLIC_AGENT_CONNECT_DEBUG=false - -# Optional: default agent selected by the UI. -NEXT_PUBLIC_DEFAULT_AGENT_ID=agent-clmm - -# Optional: poll interval (ms) for refreshing the agent list in the UI. -NEXT_PUBLIC_AGENT_LIST_SYNC_POLL_MS=15000 - -# Optional: poll interval (ms) for refreshing an agent detail view (default: 5000). -# NEXT_PUBLIC_AGENT_DETAIL_SYNC_POLL_MS=5000 - -# Optional: LangGraph API endpoints (defaults shown below). -LANGGRAPH_DEPLOYMENT_URL=http://localhost:8124 -LANGGRAPH_PENDLE_DEPLOYMENT_URL=http://localhost:8125 -LANGGRAPH_GMX_ALLORA_DEPLOYMENT_URL=http://localhost:8126 +# Server-side: LangGraph agent deployments (defaults match `scripts/qa.sh`). +# LANGGRAPH_DEPLOYMENT_URL=http://localhost:8124 +# LANGGRAPH_PENDLE_DEPLOYMENT_URL=http://localhost:8125 +# LANGGRAPH_GMX_ALLORA_DEPLOYMENT_URL=http://localhost:8126 -# Optional: LangSmith tracing for LangGraphAgent. -LANGSMITH_API_KEY= +# Optional: LangSmith API key for tracing (used by the CopilotKit LangGraph agent clients). +# LANGSMITH_API_KEY= diff --git a/typescript/clients/web-ag-ui/apps/web/.env.test.example b/typescript/clients/web-ag-ui/apps/web/.env.test.example new file mode 100644 index 000000000..4e1de20cb --- /dev/null +++ b/typescript/clients/web-ag-ui/apps/web/.env.test.example @@ -0,0 +1,26 @@ +# Values loaded automatically by `pnpm test:*` scripts via `tsx --env-file=...`. +# Copy this file to `.env.test` to override locally (file is gitignored). + +# Path to a local onchain-actions worktree. +# E2E can try to auto-discover a single `worktrees/onchain-actions-*` directory, but if you have +# more than one, you must set this explicitly. +# ONCHAIN_ACTIONS_WORKTREE_DIR=/absolute/path/to/forge/worktrees/onchain-actions-001 + +# If set, web E2E uses this onchain-actions instance and skips booting a local worktree. +# Leave unset to auto-boot a local onchain-actions worktree + memgraph (Docker required). +ONCHAIN_ACTIONS_API_URL=http://localhost:50051 + +# Optional: override which docker compose file is used to boot memgraph. +# Defaults to `${ONCHAIN_ACTIONS_WORKTREE_DIR}/compose.dev.db.yaml`. +# ONCHAIN_ACTIONS_MEMGRAPH_COMPOSE_FILE=/absolute/path/to/compose.dev.db.yaml + +# E2E profile: +# - mocked (default): no local onchain-actions boot; agent-local MSW mocks intercept Allora + onchain-actions. +# - live: uses real HTTP providers; boots local onchain-actions when ONCHAIN_ACTIONS_API_URL is unset. +E2E_PROFILE=mocked + +# Optional: override the base URL used by the web E2E runner. +# WEB_E2E_BASE_URL=http://localhost:3000 + +# Optional: override the LangGraph base URL used by the web E2E runner when it boots local agents. +# WEB_E2E_LANGGRAPH_BASE_URL=http://localhost:8126 diff --git a/typescript/clients/web-ag-ui/apps/web/README.md b/typescript/clients/web-ag-ui/apps/web/README.md index 10357e785..477c51ffc 100644 --- a/typescript/clients/web-ag-ui/apps/web/README.md +++ b/typescript/clients/web-ag-ui/apps/web/README.md @@ -27,6 +27,25 @@ Optional configuration: - `NEXT_PUBLIC_AGENT_LIST_SYNC_POLL_MS` — polling interval (ms) for list-page sync refresh. Defaults to `15000`. +### E2E Profiles + +- `E2E_PROFILE=mocked` (default for `pnpm test:e2e`): + - Runs deterministic GMX Allora system tests with agent-local MSW handlers for Allora + onchain-actions. + - Skips booting local onchain-actions/docker dependencies. +- `E2E_PROFILE=live`: + - Runs against real HTTP providers. + - Uses `ONCHAIN_ACTIONS_API_URL` when set, otherwise boots local onchain-actions + Memgraph. + +Examples: + +```bash +# Fast deterministic lane +E2E_PROFILE=mocked pnpm test:e2e tests/gmxAllora.system.e2e.test.ts + +# Live-provider lane +E2E_PROFILE=live pnpm test:e2e tests/gmxAllora.system.e2e.test.ts +``` + ## 📁 Project Structure ``` diff --git a/typescript/clients/web-ag-ui/apps/web/package.json b/typescript/clients/web-ag-ui/apps/web/package.json index bb8837453..288f285f5 100644 --- a/typescript/clients/web-ag-ui/apps/web/package.json +++ b/typescript/clients/web-ag-ui/apps/web/package.json @@ -6,13 +6,17 @@ "dev": "next dev", "build": "next build --webpack", "start": "next start", + "qa": "next build --webpack && next start", "lint": "eslint .", - "test": "pnpm test:unit", - "test:ci": "pnpm test:unit", + "lint:fix": "eslint . --fix", + "format": "prettier \"src/**/*.{ts,tsx,js,jsx}\" --write", + "format:check": "prettier \"src/**/*.{ts,tsx,js,jsx}\" --check", + "test": "pnpm test:unit && pnpm test:int && pnpm test:e2e", "test:watch": "vitest watch --config vitest.config.unit.ts", - "test:unit": "vitest run --config vitest.config.unit.ts", - "test:int": "vitest run --config vitest.config.unit.ts", - "test:e2e": "vitest run --config vitest.config.unit.ts", + "test:unit": "bash -lc 'ENV_FILE=.env.test; [ -f \"$ENV_FILE\" ] || ENV_FILE=.env.test.example; exec node --env-file=\"$ENV_FILE\" ./node_modules/vitest/vitest.mjs run --config vitest.config.unit.ts \"$@\"' --", + "test:int": "bash -lc 'ENV_FILE=.env.test; [ -f \"$ENV_FILE\" ] || ENV_FILE=.env.test.example; exec node --env-file=\"$ENV_FILE\" ./node_modules/vitest/vitest.mjs run --config vitest.config.unit.ts \"$@\"' --", + "test:e2e": "bash -lc 'ENV_FILE=.env.test; [ -f \"$ENV_FILE\" ] || ENV_FILE=.env.test.example; exec node --env-file=\"$ENV_FILE\" ./node_modules/vitest/vitest.mjs run --config vitest.config.e2e.ts --no-file-parallelism --maxConcurrency=1 \"$@\"' --", + "test:ci": "pnpm test:unit && pnpm test:int", "test:coverage": "vitest run --config vitest.config.unit.ts --coverage" }, "dependencies": { @@ -33,6 +37,7 @@ "zod": "^3.24.4" }, "devDependencies": { + "@ag-ui/client": "0.0.42", "@eslint/eslintrc": "^3", "@tailwindcss/postcss": "^4", "@types/node": "^20", @@ -41,6 +46,7 @@ "@vitest/coverage-v8": "^4.0.18", "eslint": "^9", "eslint-config-next": "16.0.8", + "rxjs": "7.8.1", "tailwindcss": "^4", "typescript": "^5", "vite-tsconfig-paths": "^6.0.5", diff --git a/typescript/clients/web-ag-ui/apps/web/src/app/api/agents/sync/route.e2e.test.ts b/typescript/clients/web-ag-ui/apps/web/src/app/api/agents/sync/route.e2e.test.ts new file mode 100644 index 000000000..207b662ce --- /dev/null +++ b/typescript/clients/web-ag-ui/apps/web/src/app/api/agents/sync/route.e2e.test.ts @@ -0,0 +1,36 @@ +import crypto from 'node:crypto'; + +import { describe, expect, it } from 'vitest'; + +function requireEnv(name: string): string { + const value = process.env[name]; + if (!value) { + throw new Error(`Missing env var: ${name}`); + } + return value; +} + +describe('POST /api/agents/sync (e2e)', () => { + it('syncs against a running agent runtime without stubbing fetch', async () => { + const webBaseUrl = requireEnv('WEB_E2E_BASE_URL'); + + const response = await fetch(`${webBaseUrl}/api/agents/sync`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + agentId: 'agent-gmx-allora', + threadId: crypto.randomUUID(), + }), + }); + + expect(response.status).toBe(200); + const payload = (await response.json()) as unknown; + + expect(payload).toEqual( + expect.objectContaining({ + agentId: 'agent-gmx-allora', + }), + ); + }); +}); + diff --git a/typescript/clients/web-ag-ui/apps/web/src/app/api/agents/sync/route.ts b/typescript/clients/web-ag-ui/apps/web/src/app/api/agents/sync/route.ts index 4fb9d73b3..1650995bd 100644 --- a/typescript/clients/web-ag-ui/apps/web/src/app/api/agents/sync/route.ts +++ b/typescript/clients/web-ag-ui/apps/web/src/app/api/agents/sync/route.ts @@ -245,7 +245,10 @@ async function fetchViewState(baseUrl: string, threadId: string): Promise { const runtime = resolveAgentRuntime(parsed.data.agentId); if (!runtime) { - return NextResponse.json({ error: 'Unknown agent', agentId: parsed.data.agentId }, { status: 404 }); + return NextResponse.json( + { error: 'Unknown agent', agentId: parsed.data.agentId }, + { status: 404 }, + ); } const baseUrl = normalizeBaseUrl(runtime.deploymentUrl); @@ -306,18 +312,15 @@ export async function POST(request: NextRequest): Promise { transactionHistory: state?.view?.transactionHistory ?? null, task: task ?? null, taskId, - taskState: hasTask ? task?.taskStatus?.state ?? null : null, - haltReason: hasTask ? state?.view?.haltReason ?? null : null, - executionError: hasTask ? state?.view?.executionError ?? null : null, + taskState: hasTask ? (task?.taskStatus?.state ?? null) : null, + haltReason: hasTask ? (state?.view?.haltReason ?? null) : null, + executionError: hasTask ? (state?.view?.executionError ?? null) : null, }, { status: 200 }, ); } catch (error) { const message = error instanceof Error ? error.message : 'Unknown error'; console.error('[agent-sync] Sync failed', { agentId: parsed.data.agentId, error: message }); - return NextResponse.json( - { error: 'Sync failed', details: message }, - { status: 500 }, - ); + return NextResponse.json({ error: 'Sync failed', details: message }, { status: 500 }); } } diff --git a/typescript/clients/web-ag-ui/apps/web/src/app/hire-agents/[id]/page.tsx b/typescript/clients/web-ag-ui/apps/web/src/app/hire-agents/[id]/page.tsx index c294710a0..18d793807 100644 --- a/typescript/clients/web-ag-ui/apps/web/src/app/hire-agents/[id]/page.tsx +++ b/typescript/clients/web-ag-ui/apps/web/src/app/hire-agents/[id]/page.tsx @@ -5,11 +5,7 @@ import { useRouter } from 'next/navigation'; import { AgentDetailPage } from '@/components/AgentDetailPage'; import { useAgent } from '@/contexts/AgentContext'; -export default function AgentDetailRoute({ - params, -}: { - params: Promise<{ id: string }>; -}) { +export default function AgentDetailRoute({ params }: { params: Promise<{ id: string }> }) { const { id } = use(params); const router = useRouter(); const agent = useAgent(); diff --git a/typescript/clients/web-ag-ui/apps/web/src/app/layout.tsx b/typescript/clients/web-ag-ui/apps/web/src/app/layout.tsx index 6dab5c56b..07728e7a2 100644 --- a/typescript/clients/web-ag-ui/apps/web/src/app/layout.tsx +++ b/typescript/clients/web-ag-ui/apps/web/src/app/layout.tsx @@ -17,7 +17,7 @@ export default function RootLayout({ children, }: Readonly<{ children: React.ReactNode; - }>) { +}>) { const themeColor = '#fd6731'; return ( diff --git a/typescript/clients/web-ag-ui/apps/web/src/components/AgentDetailPage.gmxAllora.unit.test.ts b/typescript/clients/web-ag-ui/apps/web/src/components/AgentDetailPage.gmxAllora.unit.test.ts new file mode 100644 index 000000000..e45545bc1 --- /dev/null +++ b/typescript/clients/web-ag-ui/apps/web/src/components/AgentDetailPage.gmxAllora.unit.test.ts @@ -0,0 +1,273 @@ +import React from 'react'; +import { describe, expect, it } from 'vitest'; +import { renderToStaticMarkup } from 'react-dom/server'; + +import { AgentDetailPage } from './AgentDetailPage'; + +describe('AgentDetailPage (GMX Allora)', () => { + it('keeps the metrics tab labeled as Metrics for GMX Allora in the for-hire view', () => { + const html = renderToStaticMarkup( + React.createElement(AgentDetailPage, { + agentId: 'agent-gmx-allora', + agentName: 'GMX Allora Trader', + agentDescription: 'Trades GMX perps using Allora 8-hour prediction feeds.', + creatorName: 'Ember AI Team', + creatorVerified: true, + ownerAddress: undefined, + rank: 3, + rating: 5, + avatar: '📈', + avatarBg: 'linear-gradient(135deg, #22c55e 0%, #16a34a 100%)', + profile: { chains: [], protocols: [], tokens: [] }, + metrics: { + iteration: 0, + cyclesSinceRebalance: 0, + staleCycles: 0, + rebalanceCycles: 0, + }, + fullMetrics: undefined, + isHired: false, + isHiring: false, + isFiring: false, + isSyncing: false, + currentCommand: undefined, + onHire: () => {}, + onFire: () => {}, + onSync: () => {}, + onBack: () => {}, + activeInterrupt: undefined, + allowedPools: [], + onInterruptSubmit: () => {}, + taskId: undefined, + taskStatus: undefined, + haltReason: undefined, + executionError: undefined, + delegationsBypassActive: undefined, + onboarding: undefined, + transactions: [], + telemetry: [], + events: [], + settings: undefined, + onSettingsChange: () => {}, + }), + ); + + expect(html).toContain('>Metrics<'); + expect(html).not.toContain('>Signals<'); + expect(html).not.toContain('>Latest Signal<'); + expect(html).not.toContain('>Latest Plan<'); + }); + + it('renders GMX execution and signal fields instead of CLMM-only metrics labels', () => { + const html = renderToStaticMarkup( + React.createElement(AgentDetailPage, { + agentId: 'agent-gmx-allora', + agentName: 'GMX Allora Trader', + agentDescription: 'Trades GMX perps using Allora 8-hour prediction feeds.', + creatorName: 'Ember AI Team', + creatorVerified: true, + ownerAddress: undefined, + rank: 3, + rating: 5, + avatar: '📈', + avatarBg: 'linear-gradient(135deg, #22c55e 0%, #16a34a 100%)', + profile: { + chains: ['Arbitrum One'], + protocols: ['GMX', 'Allora'], + tokens: ['USDC'], + agentIncome: 4109.5, + aum: 42180, + apy: 9.2, + totalUsers: 58, + }, + metrics: { + iteration: 1, + cyclesSinceRebalance: 0, + staleCycles: 0, + aumUsd: 8, + apy: 9.2, + lifetimePnlUsd: 0, + }, + fullMetrics: { + cyclesSinceRebalance: 0, + staleCycles: 0, + iteration: 1, + previousPrice: 67602.611, + latestCycle: { + cycle: 1, + action: 'open', + reason: 'Signal confidence 1 >= 0.62; opening long position.', + marketSymbol: 'BTC/USDC', + side: 'long', + leverage: 2, + sizeUsd: 10, + txHash: '0xb24f42dbfc6c0a30c16b7660ad5878a2a92abfb53a5ce02609bfd7e06a2cde7e', + timestamp: '2026-02-12T02:11:35.221Z', + prediction: { + topic: 'allora:btc:8h', + horizonHours: 8, + confidence: 1, + direction: 'up', + predictedPrice: 67603, + timestamp: '2026-02-12T02:11:30.000Z', + }, + metrics: { + confidence: 1, + decisionThreshold: 0.62, + cooldownRemaining: 0, + }, + }, + latestSnapshot: { + poolAddress: '0x47c031236e19d024b42f8AE6780E44A573170703', + totalUsd: 8, + timestamp: '2026-02-12T02:11:35.221Z', + positionTokens: [], + }, + }, + isHired: false, + isHiring: false, + isFiring: false, + isSyncing: false, + currentCommand: undefined, + onHire: () => {}, + onFire: () => {}, + onSync: () => {}, + onBack: () => {}, + activeInterrupt: undefined, + allowedPools: [], + onInterruptSubmit: () => {}, + taskId: undefined, + taskStatus: undefined, + haltReason: undefined, + executionError: undefined, + delegationsBypassActive: undefined, + onboarding: undefined, + transactions: [ + { + cycle: 1, + action: 'open', + txHash: '0xb24f42dbfc6c0a30c16b7660ad5878a2a92abfb53a5ce02609bfd7e06a2cde7e', + status: 'success', + timestamp: '2026-02-12T02:11:35.221Z', + }, + ], + telemetry: [], + events: [ + { + type: 'artifact', + artifact: { + artifactId: 'gmx-allora-execution-result', + description: + 'Rebalance: OPEN LONG 2x $10.00 · Allora 8h signal: bullish · confidence 100% · tx 0xb24f42...', + parts: [ + { + kind: 'data', + data: { + ok: true, + txHashes: [ + '0xe62fc16e0f8e3dcdd8fdb429a6d43a29921fa7ee1cdea9b861fc29d9f0e38854', + '0xb24f42dbfc6c0a30c16b7660ad5878a2a92abfb53a5ce02609bfd7e06a2cde7e', + ], + lastTxHash: + '0xb24f42dbfc6c0a30c16b7660ad5878a2a92abfb53a5ce02609bfd7e06a2cde7e', + }, + }, + ], + }, + }, + ], + settings: undefined, + onSettingsChange: () => {}, + }), + ); + + expect(html).toContain('Latest Execution'); + expect(html).toContain('Signal Confidence'); + expect(html).toContain('Transaction Hashes'); + expect(html).toContain('https://arbiscan.io/tx/0xb24f42dbfc6c0a30c16b7660ad5878a2a92abfb53a5ce02609bfd7e06a2cde7e'); + expect(html).not.toContain('Rebalance Cycles'); + }); + + it('falls back to snapshot leverage/notional when the latest cycle is hold', () => { + const html = renderToStaticMarkup( + React.createElement(AgentDetailPage, { + agentId: 'agent-gmx-allora', + agentName: 'GMX Allora Trader', + agentDescription: 'Trades GMX perps using Allora 8-hour prediction feeds.', + creatorName: 'Ember AI Team', + creatorVerified: true, + ownerAddress: undefined, + rank: 3, + rating: 5, + avatar: '📈', + avatarBg: 'linear-gradient(135deg, #22c55e 0%, #16a34a 100%)', + profile: { + chains: ['Arbitrum One'], + protocols: ['GMX', 'Allora'], + tokens: ['USDC'], + agentIncome: 4109.5, + aum: 42180, + apy: 9.2, + totalUsers: 58, + }, + metrics: { + iteration: 2, + cyclesSinceRebalance: 1, + staleCycles: 0, + aumUsd: 16, + apy: 9.2, + lifetimePnlUsd: 0, + }, + fullMetrics: { + cyclesSinceRebalance: 1, + staleCycles: 0, + iteration: 2, + previousPrice: 67602.611, + latestCycle: { + cycle: 2, + action: 'hold', + reason: 'Inference metrics unchanged since last trade; holding position.', + marketSymbol: 'BTC/USDC', + side: undefined, + leverage: undefined, + sizeUsd: undefined, + timestamp: '2026-02-12T02:40:35.221Z', + }, + latestSnapshot: { + poolAddress: '0x47c031236e19d024b42f8AE6780E44A573170703', + totalUsd: 16, + leverage: 2, + timestamp: '2026-02-12T02:40:35.221Z', + positionTokens: [], + }, + }, + isHired: false, + isHiring: false, + isFiring: false, + isSyncing: false, + currentCommand: undefined, + onHire: () => {}, + onFire: () => {}, + onSync: () => {}, + onBack: () => {}, + activeInterrupt: undefined, + allowedPools: [], + onInterruptSubmit: () => {}, + taskId: undefined, + taskStatus: undefined, + haltReason: undefined, + executionError: undefined, + delegationsBypassActive: undefined, + onboarding: undefined, + transactions: [], + telemetry: [], + events: [], + settings: undefined, + onSettingsChange: () => {}, + }), + ); + + expect(html).toContain('>2.0x<'); + expect(html).toContain('$16'); + }); +}); diff --git a/typescript/clients/web-ag-ui/apps/web/src/components/AgentDetailPage.tsx b/typescript/clients/web-ag-ui/apps/web/src/components/AgentDetailPage.tsx index 0daf8c729..ab204017d 100644 --- a/typescript/clients/web-ag-ui/apps/web/src/components/AgentDetailPage.tsx +++ b/typescript/clients/web-ag-ui/apps/web/src/components/AgentDetailPage.tsx @@ -37,6 +37,7 @@ import type { } from '../types/agent'; import { usePrivyWalletClient } from '../hooks/usePrivyWalletClient'; import { formatPoolPair } from '../utils/poolFormat'; +import { resolveMetricsTabLabel } from '../utils/agentUi'; export type { AgentProfile, AgentMetrics, Transaction, TelemetryItem, ClmmEvent }; @@ -281,9 +282,7 @@ export function AgentDetailPage({ onClick={onFire} disabled={isFiring} className={`px-4 py-1.5 rounded-lg text-white text-sm font-medium transition-colors ${ - isFiring - ? 'bg-gray-600 cursor-wait' - : 'bg-[#fd6731] hover:bg-[#e55a28]' + isFiring ? 'bg-gray-600 cursor-wait' : 'bg-[#fd6731] hover:bg-[#e55a28]' }`} > {isFiring ? 'Firing...' : 'Fire'} @@ -321,7 +320,7 @@ export function AgentDetailPage({ Agent Blockers setActiveTab('metrics')}> - Metrics + {resolveMetricsTabLabel(agentId)} )} - {resolvedTab === 'transactions' && ( - - )} + {resolvedTab === 'transactions' && } {resolvedTab === 'settings' && ( - + )} @@ -490,21 +485,21 @@ export function AgentDetailPage({ -
- - +
@@ -515,14 +510,15 @@ export function AgentDetailPage({ profile={profile} metrics={metrics} fullMetrics={fullMetrics} - events={[]} + events={events} + transactions={transactions} /> )} - - ); + + ); } // Tab Button Component @@ -591,35 +587,38 @@ function TransactionHistoryTab({ transactions }: TransactionHistoryTabProps) {

{transactions.length} transactions

- {transactions.slice(-10).reverse().map((tx, index) => ( -
-
-
-

- Cycle {tx.cycle} • {tx.action} -

-

- {tx.txHash ? `${tx.txHash.slice(0, 12)}…` : 'pending'} - {tx.reason ? ` · ${tx.reason}` : ''} -

-
-
- - {tx.status} - - {formatDate(tx.timestamp)} + {transactions + .slice(-10) + .reverse() + .map((tx, index) => ( +
+
+
+

+ Cycle {tx.cycle} • {tx.action} +

+

+ {tx.txHash ? `${tx.txHash.slice(0, 12)}…` : 'pending'} + {tx.reason ? ` · ${tx.reason}` : ''} +

+
+
+ + {tx.status} + + {formatDate(tx.timestamp)} +
-
- ))} + ))}
); @@ -682,6 +681,11 @@ function AgentBlockersTab({ settings, onSettingsChange, }: AgentBlockersTabProps) { + const preferredDelegatorAddress = + activeInterrupt && 'delegatorAddress' in activeInterrupt + ? activeInterrupt.delegatorAddress + : undefined; + const { walletClient, privyWallet, @@ -689,7 +693,7 @@ function AgentBlockersTab({ switchChain, isLoading: isWalletLoading, error: walletError, - } = usePrivyWalletClient(); + } = usePrivyWalletClient(preferredDelegatorAddress); const delegationsBypassEnabled = (typeof process !== 'undefined' ? process.env.NEXT_PUBLIC_DELEGATIONS_BYPASS : undefined) === 'true'; @@ -952,8 +956,10 @@ function AgentBlockersTab({ const fundingOptions: FundingTokenOption[] = showFundingTokenForm ? [...(activeInterrupt as { options: FundingTokenOption[] }).options].sort((a, b) => { - const aValue = typeof a.valueUsd === 'number' && Number.isFinite(a.valueUsd) ? a.valueUsd : null; - const bValue = typeof b.valueUsd === 'number' && Number.isFinite(b.valueUsd) ? b.valueUsd : null; + const aValue = + typeof a.valueUsd === 'number' && Number.isFinite(a.valueUsd) ? a.valueUsd : null; + const bValue = + typeof b.valueUsd === 'number' && Number.isFinite(b.valueUsd) ? b.valueUsd : null; if (aValue !== null && bValue !== null && aValue !== bValue) { return bValue - aValue; } @@ -1006,6 +1012,7 @@ function AgentBlockersTab({ const interrupt = activeInterrupt as unknown as { chainId: number; delegationManager: `0x${string}`; + delegatorAddress: `0x${string}`; delegationsToSign: UnsignedDelegation[]; }; @@ -1026,15 +1033,33 @@ function AgentBlockersTab({ return; } + const requiredDelegatorAddress = interrupt.delegatorAddress.toLowerCase(); + const signerAddress = walletClient.account?.address?.toLowerCase(); + if (!signerAddress || signerAddress !== requiredDelegatorAddress) { + setError( + `Switch to Privy wallet ${interrupt.delegatorAddress} to sign delegations. Current signer: ${ + walletClient.account?.address ?? 'unknown' + }.`, + ); + return; + } + setIsSigningDelegations(true); try { const signedDelegations = []; for (const delegation of delegationsToSign) { + if (delegation.delegator.toLowerCase() !== requiredDelegatorAddress) { + throw new Error( + `Delegation delegator ${delegation.delegator} does not match required signer ${interrupt.delegatorAddress}.`, + ); + } + const allowInsecureUnrestrictedDelegation = delegation.caveats.length === 0; const signature = await signDelegation(walletClient, { delegation, delegationManager: interrupt.delegationManager, chainId: interrupt.chainId, - account: walletClient.account, + account: interrupt.delegatorAddress, + allowInsecureUnrestrictedDelegation, }); signedDelegations.push({ ...delegation, signature }); } @@ -1044,7 +1069,11 @@ function AgentBlockersTab({ onInterruptSubmit?.(response); } catch (signError: unknown) { const message = - signError instanceof Error ? signError.message : typeof signError === 'string' ? signError : 'Unknown error'; + signError instanceof Error + ? signError.message + : typeof signError === 'string' + ? signError + : 'Unknown error'; setError(`Failed to sign delegations: ${message}`); } finally { setIsSigningDelegations(false); @@ -1113,19 +1142,19 @@ function AgentBlockersTab({
Latest Activity
- {telemetry.slice(-3).reverse().map((t, i) => ( -
-
- Cycle {t.cycle} - - {t.action} + {telemetry + .slice(-3) + .reverse() + .map((t, i) => ( +
+
+ Cycle {t.cycle} + + {t.action} +
+ {formatDate(t.timestamp)}
- {formatDate(t.timestamp)} -
- ))} + ))}
)} @@ -1134,8 +1163,8 @@ function AgentBlockersTab({

Set up agent

- Get this agent started working on your wallet in a few steps, delegate assets and set - your preferences. + Get this agent started working on your wallet in a few steps, delegate assets and set your + preferences.

@@ -1162,12 +1191,18 @@ function AgentBlockersTab({
-
Auto-selected yield
+
+ Auto-selected yield +

- The agent will automatically select the highest-yield YT market and rotate when yields change. + The agent will automatically select the highest-yield YT market and rotate + when yields change.

- Wallet: {connectedWalletAddress ? `${connectedWalletAddress.slice(0, 10)}…` : 'Not connected'} + Wallet:{' '} + {connectedWalletAddress + ? `${connectedWalletAddress.slice(0, 10)}…` + : 'Not connected'}

@@ -1249,12 +1284,18 @@ function AgentBlockersTab({
-
Allora Signal Source
+
+ Allora Signal Source +

- The agent consumes 8-hour Allora prediction feeds and enforces max 2x leverage. + The agent consumes 8-hour Allora prediction feeds and enforces max 2x + leverage.

- Wallet: {connectedWalletAddress ? `${connectedWalletAddress.slice(0, 10)}…` : 'Not connected'} + Wallet:{' '} + {connectedWalletAddress + ? `${connectedWalletAddress.slice(0, 10)}…` + : 'Not connected'}

@@ -1297,7 +1338,9 @@ function AgentBlockersTab({
- + Choose a token... {fundingOptions.map((option) => ( ))} @@ -1373,17 +1417,23 @@ function AgentBlockersTab({
Warnings
    - {(activeInterrupt as unknown as { warnings: string[] }).warnings.map((w) => ( -
  • {w}
  • - ))} + {(activeInterrupt as unknown as { warnings: string[] }).warnings.map( + (w) => ( +
  • {w}
  • + ), + )}
) : null}
-
What you are authorizing
+
+ What you are authorizing +
    - {(activeInterrupt as unknown as { descriptions?: string[] }).descriptions?.map((d) => ( + {( + activeInterrupt as unknown as { descriptions?: string[] } + ).descriptions?.map((d) => (
  • {d}
  • ))}
@@ -1418,9 +1468,9 @@ function AgentBlockersTab({