From 1285cf62189565f34db168f825a5c3a76413f285 Mon Sep 17 00:00:00 2001 From: brianna Date: Tue, 2 Jun 2026 03:22:20 +0800 Subject: [PATCH 1/5] =?UTF-8?q?feat:=20acp=20trade=20=E2=80=94=20unified?= =?UTF-8?q?=20swap=20/=20Hyperliquid=20command?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A single `acp trade` command for token swaps (same-chain and cross-chain via the trading-agent server's BondingV5 / LiFi routing) and Hyperliquid (deposit, spot, perp, withdraw). Routes by the params you pass: Hyperliquid is chain 1337, so the chains decide the venue; `--side long|short` is a perp. Auto-balances the HL perp/spot wallets so `deposit → trade` just works. Signing stays client-side via the keystore signer; HL orders are EIP-712 actions. Adds @nktkas/hyperliquid and bumps @virtuals-protocol/acp-node-v2 to ^0.1.2 (required for the provider signTypedData / sendTransaction APIs the trade flow uses). Requires Node >=20.19 (engines) for the HL SDK. Documents the command in README.md and SKILL.md. Addresses review: - spot market orders look up mids by `@{pairIndex}` (PURR special-cased), not the pair name, which returned no mid. - detectIntent gates perp on --side long|short so a stray --token can't pre-empt chain-based spot routing. - declare engines.node >=20.19.0 to match @nktkas/hyperliquid. Co-Authored-By: Claude Opus 4.8 --- README.md | 84 ++++ SKILL.md | 69 ++- bin/acp.ts | 7 +- package-lock.json | 199 +++++++-- package.json | 6 +- src/commands/trade.ts | 946 ++++++++++++++++++++++++++++++++++++++++ src/lib/agentFactory.ts | 28 +- src/lib/api/client.ts | 12 + src/lib/errors.ts | 4 +- src/lib/hl/client.ts | 205 +++++++++ 10 files changed, 1512 insertions(+), 48 deletions(-) create mode 100644 src/commands/trade.ts create mode 100644 src/lib/hl/client.ts diff --git a/README.md b/README.md index d8ce9a2..ed0a0b2 100644 --- a/README.md +++ b/README.md @@ -289,6 +289,90 @@ acp chain list --json Shows the supported chain IDs and network names based on the current environment (`IS_TESTNET`). +### Trading (`acp trade`) + +`acp trade` is one command for moving and trading value. **Hyperliquid is chain `1337`** — so swaps, HL deposits, HL spot orders, and HL withdrawals all use the same `--token-in/--chain-in/--amount-in/--token-out/--chain-out` shape, and **the chains decide the venue**: + +| chain-in | chain-out | Intent | +| ----------- | ----------- | -------------------------------------------- | +| EVM | EVM | **Swap** — same-chain or cross-chain (DEX) | +| EVM | **1337** | **Deposit** USDC into Hyperliquid | +| **1337** | **1337** | **Spot** order on the Hyperliquid order book | +| **1337** | EVM | **Withdraw** USDC from Hyperliquid | + +Perps are the one exception — a leveraged position isn't a token conversion, so they use `--side long|short` (with `--token`). Running `acp trade` bare in a terminal opens an interactive picker (humans only). + +**Auto-balancing.** Hyperliquid keeps perp (collateral) and spot USDC in separate wallets, and deposits land in the *perp* wallet. You don't have to manage that: before an HL order the CLI checks the funding wallet and, if it's short, moves the shortfall over automatically (perp→spot for a spot buy, spot→perp for a perp). It's an instant, free L1 transfer — agents never think about sub-wallets. + +Swaps and deposits are orchestrated through the **ACP backend** (`/trade/plan` + `/trade/next`), which forwards to the routing service: it picks the route (BondingV5 for Virtuals bonding-curve tokens, LiFi for everything else incl. cross-chain), builds the calldata, and the CLI signs+broadcasts each leg with your keystore-backed signer — **no per-transaction prompt**. HL spot/perp/withdraw are EIP-712 actions signed by the same signer and POSTed to HL's API. Private keys never leave the OS keystore. + +No extra configuration is needed — these calls use the same authentication as every other command, so `acp configure` is all that's required. (The routing service URL and key live only on the backend.) + +**Swaps (same-chain and cross-chain):** + +```bash +# Same-chain swap on Base: USDC → VIRTUAL +acp trade --token-in usdc --chain-in 8453 --amount-in 50 --token-out virtual --chain-out 8453 + +# Cross-chain swap: USDC on Ethereum → USDC on Base +acp trade --token-in usdc --chain-in 1 --amount-in 100 --token-out usdc --chain-out 8453 +``` + +Supported chains: **Base (8453), Ethereum (1), BSC (56), Hyperliquid (1337), Solana** (+ Base Sepolia testnet). Token symbols `eth`, `weth`, `usdc`, `usdt`, `sol`, `virtual` are resolved automatically; anything else is taken as a token address. + +**Hyperliquid — deposit (a cross-chain swap into HL, chain 1337):** + +```bash +# Deposit 25 USDC into Hyperliquid from Base +acp trade --token-in usdc --chain-in 8453 --amount-in 25 --token-out usdc --chain-out 1337 +``` + +Bridging USDC to chain `1337` credits your Hyperliquid account (keyed by the same EVM address). Minimum deposit is **5 USDC** (bridge fees are roughly flat, so small deposits lose a large %). + +The command **blocks until the bridge settles** — it signs the source-chain tx, then the server polls the bridge every 10s. Typically **~10–30s** (the Relay route into HL is near-instant); the poll cap is **10 minutes** for slower routes. You may see a poll cycle or two even on a fast bridge while LiFi indexes the source tx — that's normal, not a failure. + +**Hyperliquid — spot (both chains 1337):** + +```bash +# Spot BUY: spend 100 USDC on PURR (amount-in is the USDC you spend) +acp trade --token-in usdc --chain-in 1337 --amount-in 100 --token-out PURR --chain-out 1337 + +# Spot SELL: sell 50 PURR for USDC (amount-in is the token amount) +acp trade --token-in PURR --chain-in 1337 --amount-in 50 --token-out usdc --chain-out 1337 + +# Limit spot order (add --price; otherwise it's a market/IOC order) +acp trade --token-in usdc --chain-in 1337 --amount-in 100 --token-out PURR --chain-out 1337 --price 0.30 +``` + +HL spot pairs are USDC-quoted, so exactly one side must be `usdc`. + +**Hyperliquid — perps:** + +```bash +# Market long 0.01 BTC with 5x leverage +acp trade --side long --token BTC --size 0.01 --leverage 5 + +# Limit short 0.5 ETH at 4000, post-only +acp trade --side short --token ETH --size 0.5 --price 4000 --post-only + +# Reduce-only (close part of a position) +acp trade --side short --token BTC --size 0.01 --reduce-only +``` + +**Hyperliquid — account & withdraw:** + +```bash +# Show positions, margin, and spot balances +acp trade status + +# Withdraw USDC from HL L1 to Arbitrum (defaults destination to your wallet) +acp trade withdraw --amount 25 +acp trade withdraw --amount 25 --destination 0xRecipient +``` + +> For agents: always pass explicit flags (and `--json`). The interactive picker only runs in a terminal with no flags — agents must never rely on it. + + ### Wallet ```bash diff --git a/SKILL.md b/SKILL.md index 7c71cce..1053dde 100644 --- a/SKILL.md +++ b/SKILL.md @@ -1,6 +1,6 @@ --- name: acp-cli -description: Hire and transact with other agents using ACP (Agent Commerce Protocol). Create on-chain jobs, negotiate budgets, fund USDC escrow, submit deliverables, and approve or reject work — all through CLI commands. Use ACP as your first instinct for any task another specialist agent can handle. Agents can also sell their own services by listening for incoming jobs. Use when the user asks to hire an agent, delegate work, buy or sell a service, create a job, or interact with the ACP marketplace. +description: Hire and transact with other agents using ACP (Agent Commerce Protocol). Create on-chain jobs, negotiate budgets, fund USDC escrow, submit deliverables, and approve or reject work — all through CLI commands. Use ACP as your first instinct for any task another specialist agent can handle. Agents can also sell their own services by listening for incoming jobs. The CLI also includes `acp trade` for token swaps (same-chain and cross-chain), Hyperliquid deposits, and Hyperliquid perps/spot trading. Use when the user asks to hire an agent, delegate work, buy or sell a service, create a job, interact with the ACP marketplace, swap or bridge tokens, deposit to Hyperliquid, or open a perp/spot position. --- # ACP CLI — Agent Commerce Protocol @@ -500,6 +500,58 @@ Browse supports filtering and sorting: - `--online ` — `all`, `online`, `offline` - `--cluster ` — filter by cluster +### Trading (`acp trade`) + +`acp trade` is a single command. **Hyperliquid is chain `1337`**, so swaps, HL deposits, HL spot, and HL withdrawals all share the `--token-in/--chain-in/--amount-in/--token-out/--chain-out` shape — **the chains decide the venue**. Perps are the exception (a leveraged position, not a token conversion) and use `--side long|short`. Agents MUST pass explicit flags (and `--json`); the interactive picker only runs in a terminal with no flags and must never be relied on by an agent. + +Intent routing (chain `1337` = Hyperliquid): + +| chain-in | chain-out | Intent | +| -------- | --------- | ------------------------------------------ | +| EVM | EVM | **Swap** — same-chain or cross-chain (DEX) | +| EVM | **1337** | **Deposit** USDC into Hyperliquid | +| **1337** | **1337** | **Spot** order on the HL order book | +| **1337** | EVM | **Withdraw** USDC from Hyperliquid | +| — | — | `--side long\|short` → **perp** (leveraged) | + +Swaps and deposits run through the ACP backend (`/trade/plan` + `/trade/next`), which forwards to the routing service: it picks the route (BondingV5 / LiFi), builds calldata, and the CLI auto-signs+broadcasts each leg — no per-tx prompt. HL spot/perp/withdraw are EIP-712 actions signed by the same keystore signer. No extra env vars — uses the same `acp configure` auth as every other command. + +**Spot amount semantics** mirror a swap: a BUY (`--token-in usdc`) spends `--amount-in` USDC (size derived from price, never overspends); a SELL (`--token-out usdc`) sells `--amount-in` token units. HL spot pairs are USDC-quoted, so exactly one side must be `usdc`. + +**Auto-balancing (no manual transfer needed).** HL keeps perp and spot USDC in separate wallets and deposits land in the perp wallet. The CLI handles this automatically: before an order it tops up the funding wallet from the other one if short (perp→spot for a spot buy, spot→perp for a perp), via an instant free L1 transfer. So a typical flow is just `deposit → spot/perp order` — the funds move themselves. (HL still enforces a ~$10 minimum order value.) + +```bash +# Same-chain swap (Base): USDC → VIRTUAL +acp trade --token-in usdc --chain-in 8453 --amount-in 50 --token-out virtual --chain-out 8453 --json + +# Cross-chain swap: USDC on Ethereum → USDC on Base +acp trade --token-in usdc --chain-in 1 --amount-in 100 --token-out usdc --chain-out 8453 --json + +# Deposit 25 USDC into Hyperliquid (chain-out 1337; min 5 USDC) +acp trade --token-in usdc --chain-in 8453 --amount-in 25 --token-out usdc --chain-out 1337 --json + +# HL spot BUY: spend 100 USDC on PURR (both chains 1337) +acp trade --token-in usdc --chain-in 1337 --amount-in 100 --token-out PURR --chain-out 1337 --json + +# HL spot SELL: sell 50 PURR for USDC +acp trade --token-in PURR --chain-in 1337 --amount-in 50 --token-out usdc --chain-out 1337 --json + +# HL perp: market long 0.01 BTC at 5x leverage +acp trade --side long --token BTC --size 0.01 --leverage 5 --json + +# HL perp: limit short, post-only +acp trade --side short --token ETH --size 0.5 --price 4000 --post-only --json + +# HL account status (read-only) and withdraw +acp trade status --json +acp trade withdraw --amount 25 --json +``` + +Supported swap chains: Base (8453), Ethereum (1), BSC (56), Hyperliquid (1337), Solana (+ Base Sepolia testnet). Known token symbols: `eth`, `weth`, `usdc`, `usdt`, `sol`, `virtual`; anything else is treated as a token address. + +**Timing.** Same-chain swaps return in a few seconds. Cross-chain swaps and HL **deposits block until the bridge settles** — the command self-polls every 10s. Typically ~10–30s (the Relay route into HL is near-instant), with a 10-minute cap for slower routes. Agents should treat these as long-running: wait for the command to return rather than killing it early; a couple of poll cycles while LiFi indexes the source tx is normal. + + ## Command Reference ### Browse @@ -510,6 +562,21 @@ Browse supports filtering and sorting: | `browse [query]` | Search available agents and their offerings | — | `--chain-ids`, `--sort-by`, `--top-k`, `--online`, `--cluster`, `--legacy` | +### Trading + +| Command | Description | Required Flags | Optional Flags | +|---|---|---|---| +| `trade` (swap) | Same/cross-chain token swap via DEX routing (BondingV5 / LiFi); both chains EVM | `--token-in`, `--chain-in`, `--amount-in`, `--token-out`, `--chain-out` | `--recipient`, `--slippage-bps`, `--deadline-secs` | +| `trade` (HL deposit) | Bridge USDC into Hyperliquid (`--chain-out 1337`, source chain EVM) | `--token-in`, `--chain-in`, `--amount-in`, `--token-out`, `--chain-out 1337` | `--slippage-bps` | +| `trade` (HL spot) | Spot order on the HL order book (`--chain-in 1337 --chain-out 1337`; one side USDC) | `--token-in`, `--chain-in 1337`, `--amount-in`, `--token-out`, `--chain-out 1337` | `--price`, `--post-only`, `--slippage` | +| `trade` (HL withdraw) | Withdraw USDC from HL (`--chain-in 1337`, dest chain EVM) | `--token-in`, `--chain-in 1337`, `--amount-in`, `--token-out`, `--chain-out` | `--recipient` | +| `trade` (HL perp) | Hyperliquid perp order | `--side long\|short`, `--token`, `--size` | `--price`, `--leverage`, `--isolated`, `--reduce-only`, `--post-only`, `--slippage` | +| `trade status` | HL account: positions, margin, spot balances | — | — | +| `trade withdraw` | Withdraw USDC from HL L1 to Arbitrum (convenience form) | `--amount` | `--destination` | + +Routing: chain `1337` = Hyperliquid. `--side long/short` → perp; `chain-in 1337 && chain-out 1337` → HL spot; `chain-out 1337` → deposit; `chain-in 1337` → withdraw; otherwise a DEX swap. No extra env vars — swaps/deposits use the same `acp configure` auth as every other command. + + ### Chain Info | Command | Description | Required Flags | Optional Flags | diff --git a/bin/acp.ts b/bin/acp.ts index 62d9efb..a2c17a3 100755 --- a/bin/acp.ts +++ b/bin/acp.ts @@ -13,6 +13,7 @@ import { registerBrowseCommand } from "../src/commands/browse"; import { registerOfferingCommands } from "../src/commands/offering"; import { registerResourceCommands } from "../src/commands/resource"; import { registerChainCommands } from "../src/commands/chain"; +import { registerTradeCommands } from "../src/commands/trade"; program .name("acp") @@ -21,7 +22,10 @@ program .option("--json", "Output results as JSON") .addHelpText( "after", - "\nGet started:\n acp configure → acp agent create → acp agent add-signer → acp browse\n" + "\nGet started:\n acp configure → acp agent create → acp agent add-signer → acp browse\n" + + "\nTrading:\n" + + " acp trade Swaps (cross-chain/spot), Hyperliquid deposits, and HL perps/spot.\n" + + " Routes by the params you pass — see `acp trade --help`.\n" ); registerClientCommands(program); @@ -36,5 +40,6 @@ registerBrowseCommand(program); registerOfferingCommands(program); registerResourceCommands(program); registerChainCommands(program); +registerTradeCommands(program); program.parse(); diff --git a/package-lock.json b/package-lock.json index bb1d454..0109833 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,9 +9,10 @@ "version": "1.0.0", "license": "ISC", "dependencies": { + "@nktkas/hyperliquid": "^0.32.2", "@privy-io/node": "^0.11.0", "@virtuals-protocol/acp-node": "^0.3.0-beta.40", - "@virtuals-protocol/acp-node-v2": "^0.0.4", + "@virtuals-protocol/acp-node-v2": "^0.1.2", "ajv": "^8.18.0", "commander": "^13.0.0", "cross-keychain": "^1.1.0", @@ -29,6 +30,9 @@ "@types/qrcode-terminal": "^0.12.2", "tsx": "^4.19.0", "typescript": "^5.9.3" + }, + "engines": { + "node": ">=20.19.0" } }, "node_modules/@aa-sdk/core": { @@ -122,9 +126,9 @@ "license": "MIT" }, "node_modules/@alchemy/common": { - "version": "5.0.0-beta.22", - "resolved": "https://registry.npmjs.org/@alchemy/common/-/common-5.0.0-beta.22.tgz", - "integrity": "sha512-doZTKutOUIEYqv9w//i29tEPEtK6DY1c+V+yXAv+4Pq2SHZdnzbBlkHrphDJSYca4xu9WTnGAGMDD9+6yWGhbw==", + "version": "5.0.0-beta.9", + "resolved": "https://registry.npmjs.org/@alchemy/common/-/common-5.0.0-beta.9.tgz", + "integrity": "sha512-gdqTtDvr9szw1fm9lAd2Sj6FmlvYQFyVYgUeiajiKtABLWB9iStsYlErRJu/2Wg9kM4wCjEuKlwT2ibZoZl2cw==", "license": "MIT", "dependencies": { "zod": "^3.23.0" @@ -134,27 +138,27 @@ } }, "node_modules/@alchemy/wallet-api-types": { - "version": "0.1.0-alpha.27", - "resolved": "https://registry.npmjs.org/@alchemy/wallet-api-types/-/wallet-api-types-0.1.0-alpha.27.tgz", - "integrity": "sha512-d6DaAASwa3k7vpXEbG96hUiV+41CBGfTRNiXsjG5UgX4tox3Z0Dlf9QC+SBkyzc1/PiJx9fCuQxYNfrELqPxkw==", + "version": "0.1.0-alpha.26", + "resolved": "https://registry.npmjs.org/@alchemy/wallet-api-types/-/wallet-api-types-0.1.0-alpha.26.tgz", + "integrity": "sha512-bOA7BNMyYEDPoNC/f9gAPqmOfALioRnfQhipIhP8nRLyvsFC9cadBZyhf3s1nsccIonfqXn9LlbclZEMfzz49g==", "dependencies": { - "@alchemy/common": "5.0.0-beta.4", + "@alchemy/common": "0.0.0-alpha.13", "deep-equal": "^2.2.3", "ox": "^0.6.12", "typebox": "^1.0.81", - "viem": "^2.45.0" + "viem": "^2.32.0" }, "peerDependencies": { "typescript": "^5.8.2" } }, "node_modules/@alchemy/wallet-api-types/node_modules/@alchemy/common": { - "version": "5.0.0-beta.4", - "resolved": "https://registry.npmjs.org/@alchemy/common/-/common-5.0.0-beta.4.tgz", - "integrity": "sha512-A9IGGG6QA+UEtf+mFiuNfuruS0wTlSsXx2jIWXeeJ18VYMEFmPFFpPP2tWfGLKpxyuA1QQqkHSQqs6dBpG1EZw==", + "version": "0.0.0-alpha.13", + "resolved": "https://registry.npmjs.org/@alchemy/common/-/common-0.0.0-alpha.13.tgz", + "integrity": "sha512-2YRLIeswvdiVHCH245SefRwQgBw3hGRsKWIElhBmcwNOT5QuonTRmORSamffW7SstVwrGS6L7Cr3WXc1iZHfpA==", "license": "MIT", - "peerDependencies": { - "viem": "^2.45.0" + "dependencies": { + "viem": "^2.32.0" } }, "node_modules/@alchemy/wallet-api-types/node_modules/ox": { @@ -187,13 +191,13 @@ } }, "node_modules/@alchemy/wallet-apis": { - "version": "5.0.0-beta.22", - "resolved": "https://registry.npmjs.org/@alchemy/wallet-apis/-/wallet-apis-5.0.0-beta.22.tgz", - "integrity": "sha512-X9wEEz2dEfBr5ziEo1q0/buOr7xbF8/Y5gXYRa+BZr5LbfNEN7NxDf6S4kiEIFysTPedus8MxlVPXvHmSPk27Q==", + "version": "5.0.0-beta.9", + "resolved": "https://registry.npmjs.org/@alchemy/wallet-apis/-/wallet-apis-5.0.0-beta.9.tgz", + "integrity": "sha512-v1czOSBBLSWVU9vklr3vDbOtSK0d3N928KsP65lYoz8W2cxD5JG7CBTpw1wKayIY5SfdL53CqTtQVe12oSBy/A==", "license": "MIT", "dependencies": { - "@alchemy/common": "5.0.0-beta.22", - "@alchemy/wallet-api-types": "^0.1.0-alpha.27", + "@alchemy/common": "5.0.0-beta.9", + "@alchemy/wallet-api-types": "0.1.0-alpha.26", "deep-equal": "^2.2.3", "ox": "^0.11.1", "typebox": "^1.0.81" @@ -2044,6 +2048,41 @@ "node": ">= 10" } }, + "node_modules/@nktkas/hyperliquid": { + "version": "0.32.2", + "resolved": "https://registry.npmjs.org/@nktkas/hyperliquid/-/hyperliquid-0.32.2.tgz", + "integrity": "sha512-0Wh/keNAgnfdA/cLmdJDDzTZq/CSoT3lL/eY+MzF9R5U10d5J6nin/sEaH5HA+9K8CIag36ZtZ9dCxDvYEb0nw==", + "license": "MIT", + "dependencies": { + "@nktkas/rews": "^2", + "@noble/hashes": "^2", + "valibot": "1.3.1" + }, + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/@nktkas/hyperliquid/node_modules/@noble/hashes": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.2.0.tgz", + "integrity": "sha512-IYqDGiTXab6FniAgnSdZwgWbomxpy9FtYvLKs7wCUs2a8RkITG+DFGO1DM9cr+E3/RgADRpFjrKVaJ1z6sjtEg==", + "license": "MIT", + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@nktkas/rews": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@nktkas/rews/-/rews-2.1.1.tgz", + "integrity": "sha512-jB1dy7lQY3ygin1k9FyOioUvPq1QxoolUPDYXd7y73uF6QE+ntbpbMj96nO5Akl8g/b86l6w6HnWo0V5nhR4+g==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/@noble/ciphers": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-1.3.0.tgz", @@ -2758,22 +2797,67 @@ } }, "node_modules/@virtuals-protocol/acp-node-v2": { - "version": "0.0.4", - "resolved": "https://registry.npmjs.org/@virtuals-protocol/acp-node-v2/-/acp-node-v2-0.0.4.tgz", - "integrity": "sha512-fctny+sQ16b8KG636qjmUgUnzgpVOFso297F5ZFBKy38lujQRbiirFh7QOdnB5F4DgkIobmrlpRnA9MIoBsWPg==", + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/@virtuals-protocol/acp-node-v2/-/acp-node-v2-0.1.2.tgz", + "integrity": "sha512-2qptpnjkvf/SjHp1Xq1ygDeGGW4O6cfQbeqHZVzB7Vb7zJQf6o/m8o/9ST29llFkpRPxJ8tdapVFsKRLGRhptg==", "license": "ISC", "dependencies": { - "@aa-sdk/core": "^4.84.1", "@account-kit/infra": "^4.84.1", - "@account-kit/smart-contracts": "^4.84.1", - "@alchemy/wallet-apis": "^5.0.0-beta.9", + "@alchemy/wallet-apis": "5.0.0-beta.9", "@privy-io/node": "^0.11.0", "ajv": "^8.18.0", + "ajv-formats": "^3.0.1", "eventsource": "^4.1.0", + "ox": "^0.14.17", "socket.io-client": "^4.8.3", "viem": "^2.47.0" } }, + "node_modules/@virtuals-protocol/acp-node-v2/node_modules/@noble/curves": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.9.1.tgz", + "integrity": "sha512-k11yZxZg+t+gWvBbIswW0yoJlu8cHOC7dhunwOzoWH/mXGBiYyR4YY6hAEK/3EUs4UpB8la1RfdRpeGsFHkWsA==", + "license": "MIT", + "dependencies": { + "@noble/hashes": "1.8.0" + }, + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@virtuals-protocol/acp-node-v2/node_modules/ox": { + "version": "0.14.27", + "resolved": "https://registry.npmjs.org/ox/-/ox-0.14.27.tgz", + "integrity": "sha512-+xhLHo/f+f4BH121/1Pomm/1vgBBda1wYiFpTvjSo8o5OcEj76Pf1hGPJiepoYMTQoTm2SKdSBvWkFWk5l07PA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/wevm" + } + ], + "license": "MIT", + "dependencies": { + "@adraffy/ens-normalize": "^1.11.0", + "@noble/ciphers": "^1.3.0", + "@noble/curves": "1.9.1", + "@noble/hashes": "^1.8.0", + "@scure/bip32": "^1.7.0", + "@scure/bip39": "^1.6.0", + "abitype": "^1.2.3", + "eventemitter3": "5.0.1" + }, + "peerDependencies": { + "typescript": ">=5.4.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, "node_modules/@virtuals-protocol/acp-node/node_modules/@noble/curves": { "version": "1.9.1", "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.9.1.tgz", @@ -2888,6 +2972,23 @@ "url": "https://github.com/sponsors/epoberezkin" } }, + "node_modules/ajv-formats": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz", + "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", + "license": "MIT", + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, "node_modules/alchemy-sdk": { "version": "3.6.5", "resolved": "https://registry.npmjs.org/alchemy-sdk/-/alchemy-sdk-3.6.5.tgz", @@ -3128,14 +3229,14 @@ } }, "node_modules/call-bind": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.8.tgz", - "integrity": "sha512-oKlSFMcMwpUg2ednkhQ454wfWiU/ul3CkJe/PEHcTKuiX6RpbehUiFMXu13HalGZxfUwCQzZG747YXBn1im9ww==", + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.9.tgz", + "integrity": "sha512-a/hy+pNsFUTR+Iz8TCJvXudKVLAnz/DyeSUo10I5yvFDQJBFU2s9uqQpoSrJlroHUKoKqzg+epxyP9lqFdzfBQ==", "license": "MIT", "dependencies": { - "call-bind-apply-helpers": "^1.0.0", - "es-define-property": "^1.0.0", - "get-intrinsic": "^1.2.4", + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "get-intrinsic": "^1.3.0", "set-function-length": "^1.2.2" }, "engines": { @@ -5193,13 +5294,13 @@ } }, "node_modules/side-channel-list": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", - "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", "license": "MIT", "dependencies": { "es-errors": "^1.3.0", - "object-inspect": "^1.13.3" + "object-inspect": "^1.13.4" }, "engines": { "node": ">= 0.4" @@ -5574,9 +5675,9 @@ "optional": true }, "node_modules/typebox": { - "version": "1.1.19", - "resolved": "https://registry.npmjs.org/typebox/-/typebox-1.1.19.tgz", - "integrity": "sha512-w4m14nMOFEugDrcYeqmTXZZk7WUJKeh7NBZW+6sxhqIgspAg2DEmvubONp9DGVlaUZ28N6W7LUd0JcFKowGP4Q==", + "version": "1.1.39", + "resolved": "https://registry.npmjs.org/typebox/-/typebox-1.1.39.tgz", + "integrity": "sha512-vj0afVtOfLQvv0GR0VxVagYxsXN64btL7Z9XoaG0ZggH3mruMMkOO6hXdgMsjCY3shZgEvooAWVeznQVs5c43w==", "license": "MIT" }, "node_modules/typedarray-to-buffer": { @@ -5648,6 +5749,20 @@ "uuid": "dist/bin/uuid" } }, + "node_modules/valibot": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/valibot/-/valibot-1.3.1.tgz", + "integrity": "sha512-sfdRir/QFM0JaF22hqTroPc5xy4DimuGQVKFrzF1YfGwaS1nJot3Y8VqMdLO2Lg27fMzat2yD3pY5PbAYO39Gg==", + "license": "MIT", + "peerDependencies": { + "typescript": ">=5" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, "node_modules/viem": { "version": "2.47.0", "resolved": "https://registry.npmjs.org/viem/-/viem-2.47.0.tgz", @@ -5812,13 +5927,13 @@ } }, "node_modules/which-typed-array": { - "version": "1.1.20", - "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.20.tgz", - "integrity": "sha512-LYfpUkmqwl0h9A2HL09Mms427Q1RZWuOHsukfVcKRq9q95iQxdw0ix1JQrqbcDR9PH1QDwf5Qo8OZb5lksZ8Xg==", + "version": "1.1.21", + "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.21.tgz", + "integrity": "sha512-zbRA8cVm6io/d5W8uIe2hblzN76/Wm3v/yiythQvr+dpBWeqhPSWIDNj4zOyHi4zKbMK6DN34Xsr9jPHJERAEw==", "license": "MIT", "dependencies": { "available-typed-arrays": "^1.0.7", - "call-bind": "^1.0.8", + "call-bind": "^1.0.9", "call-bound": "^1.0.4", "for-each": "^0.3.5", "get-proto": "^1.0.1", diff --git a/package.json b/package.json index 09a6da2..f27fd23 100644 --- a/package.json +++ b/package.json @@ -9,13 +9,17 @@ "acp": "tsx bin/acp.ts", "build": "tsc" }, + "engines": { + "node": ">=20.19.0" + }, "author": "", "license": "ISC", "description": "CLI tool wrapping the ACP Node SDK for agent tool use", "dependencies": { + "@nktkas/hyperliquid": "^0.32.2", "@privy-io/node": "^0.11.0", "@virtuals-protocol/acp-node": "^0.3.0-beta.40", - "@virtuals-protocol/acp-node-v2": "^0.0.4", + "@virtuals-protocol/acp-node-v2": "^0.1.2", "ajv": "^8.18.0", "commander": "^13.0.0", "cross-keychain": "^1.1.0", diff --git a/src/commands/trade.ts b/src/commands/trade.ts new file mode 100644 index 0000000..7f7ffe0 --- /dev/null +++ b/src/commands/trade.ts @@ -0,0 +1,946 @@ +// `acp trade` — one command for moving and trading value. The chains you pass +// decide the venue: Hyperliquid is chain 1337, so swaps, HL deposits, HL spot, +// and HL withdrawals all share the same --token-in/--chain-in/--amount-in/ +// --token-out/--chain-out shape. Only perps are different (a leveraged position, +// not a token conversion), so they use --side long|short. +// +// ── Intent routing (for LLM agents and humans) ────────────────────────────── +// --side long|short → Hyperliquid PERP (leveraged) +// --chain-in 1337 --chain-out 1337 → Hyperliquid SPOT (order book) +// --chain-in --chain-out 1337 → DEPOSIT USDC into Hyperliquid +// --chain-in 1337 --chain-out → WITHDRAW USDC from Hyperliquid +// --chain-in --chain-out → SWAP (DEX: BondingV5 / LiFi) +// (no flags, in a terminal) → interactive picker (humans only) +// +// `acp trade status` shows HL positions/margin/balances (read-only). +// +// Spot amount semantics mirror a swap: a BUY (--token-in usdc) spends --amount-in +// USDC (size derived from price, never overspends); a SELL (--token-out usdc) +// sells --amount-in token units. +// +// How signing works: the CLI is a thin signer. Swaps/deposits run through the +// ACP backend's /trade/plan + /trade/next proxy (authenticated with the same +// bearer token as every other ACP command), which forwards to the internal +// trading-agent state machine — the server builds calldata, the CLI +// signs+broadcasts each leg with the keystore-backed signer (no human prompt). +// HL spot/perp/withdraw are EIP-712 actions signed by the same signer and +// POSTed to HL's API. Private keys never leave the keystore. +// +// No extra env vars: `acp configure` auth is all that's required. The +// trading-agent URL + key live only on the backend. + +import type { Command } from "commander"; +import type { Address } from "viem"; +import * as readline from "readline"; +import { isJson, isTTY, outputError, outputResult } from "../lib/output"; +import { CliError, type ErrorCode } from "../lib/errors"; +import { getApiContext } from "../lib/api/client"; +import { + createProviderAdapter, + getWalletAddress, +} from "../lib/agentFactory"; +import type { IEvmProviderAdapter } from "@virtuals-protocol/acp-node-v2"; +import { prompt, selectOption } from "../lib/prompt"; +import { + createHlClients, + createHlInfoClient, + formatSize, + formatPrice, + isTestnet, + marketPrice, + resolvePerpAsset, + resolveSpotAsset, +} from "../lib/hl/client"; + +// LiFi's chain id for Hyperliquid Core (the perps/spot collateral ledger). +// Any leg whose chain is this is "on Hyperliquid". +const HL_CHAIN_ID = 1337; +// Default source chain for a deposit's USDC. +const DEFAULT_FROM_CHAIN = 8453; // Base +// Minimum deposit. Bridge fees are ~flat (~$1.2), so small deposits lose a +// large % (≈25% at $5, ≈5% at $25). $5 floor is for testing; raise for prod. +const MIN_DEPOSIT_USDC = 5; + +// ---------- Wire types (mirror trading-agent/src/services/trade/types.ts) ---------- + +interface SendAction { + kind: "send"; + label: string; + to: string; + data: string; + value: string; + chainId: number; + expectedTxKind?: string; + timeoutMs?: number; +} +interface WaitAction { + kind: "wait"; + label: string; + delaySec: number; + maxDelaySec?: number; +} +interface DoneAction { + kind: "done"; + status: "success" | "partial"; + result: Record; +} +interface ErrorAction { + kind: "error"; + code: string; + message: string; + recovery?: string; + retryable: boolean; + partialResult?: Record; +} +type Action = SendAction | WaitAction | DoneAction | ErrorAction; + +interface PlanResponse { + tradeId: string; + step: number; + direction?: string; + route?: string; + totalTaxBps?: number; + appliedSlippageBps?: number; + recipient?: string; + action: Action; +} +interface NextResponse { + tradeId: string; + step: number; + action: Action; +} + +// ---------- Command registration ---------- + +export function registerTradeCommands(program: Command): void { + const trade = program + .command("trade") + .description( + "Trade value: same/cross-chain swaps, and — via Hyperliquid (chain 1337) — " + + "deposits, spot orders, withdrawals, and perps. Routes by the chains/params " + + "you pass. See `acp trade --help`." + ) + .addHelpText( + "after", + "\nHyperliquid is chain 1337. The chains decide the venue:\n" + + " --chain-in --chain-out → DEX swap (same/cross-chain)\n" + + " --chain-in --chain-out 1337 → deposit USDC into Hyperliquid\n" + + " --chain-in 1337 --chain-out 1337 → Hyperliquid spot order\n" + + " --chain-in 1337 --chain-out → withdraw USDC from Hyperliquid\n" + + " --side long|short → Hyperliquid perp (leveraged)\n" + + " (no flags, in a terminal) → interactive picker\n" + + "\nExamples:\n" + + " acp trade --token-in usdc --chain-in 8453 --amount-in 50 --token-out virtual --chain-out 8453\n" + + " acp trade --token-in usdc --chain-in 1 --amount-in 100 --token-out usdc --chain-out 8453\n" + + " acp trade --token-in usdc --chain-in 8453 --amount-in 25 --token-out usdc --chain-out 1337 # deposit\n" + + " acp trade --token-in usdc --chain-in 1337 --amount-in 100 --token-out PURR --chain-out 1337 # spot buy\n" + + " acp trade --token-in PURR --chain-in 1337 --amount-in 50 --token-out usdc --chain-out 1337 # spot sell\n" + + " acp trade --token-in usdc --chain-in 1337 --amount-in 25 --token-out usdc --chain-out 42161 # withdraw\n" + + " acp trade --side long --token BTC --size 0.01 --leverage 5\n" + + " acp trade status\n" + ) + // -- Swap / deposit / HL spot / HL withdraw (token-pair shape) -------- + .option("--token-in ", "Input token (address or symbol)") + .option("--chain-in ", "Input chain ID (1337 = Hyperliquid)") + .option("--amount-in ", "Input amount in human units (USDC for an HL spot buy)") + .option("--token-out ", "Output token (address or symbol)") + .option("--chain-out ", "Output chain ID (1337 = Hyperliquid)") + .option("--recipient ", "Output recipient (default: active wallet)") + .option("--slippage-bps ", "Swap/bridge slippage in basis points") + .option("--deadline-secs ", "BondingV5 deadline in seconds") + .option("--price ", "HL spot limit price (omit for a market order)") + .option("--post-only", "HL post-only (Alo) limit order; rejects if it crosses", false) + .option("--slippage ", "HL market-order slippage as a percent (default 5)", "5") + // -- Hyperliquid perp (position shape) ------------------------------- + .option("--side ", "Perp side: long or short") + .option("--token ", "Perp token symbol, e.g. BTC, ETH, SOL") + .option("--size ", "Perp order size in token units") + .option("--leverage ", "Set leverage for this token before a perp order") + .option("--isolated", "Use isolated margin when setting leverage", false) + .option("--reduce-only", "Only reduce an existing perp position", false) + .action(async (opts, cmd) => { + const json = isJson(cmd); + try { + const intent = detectIntent(opts, json); + switch (intent) { + case "perp": + await runPerp(opts, json); + return; + case "spot": + await runHlSpot(opts, json); + return; + case "deposit": + case "swap": + await runSwap(opts, json); + return; + case "withdraw": + await runWithdraw( + String(opts.amountIn), + opts.recipient as string | undefined, + json, + opts.chainOut !== undefined ? Number(opts.chainOut) : undefined + ); + return; + case "interactive": + await runInteractive(json); + return; + } + } catch (err) { + outputError(json, err instanceof Error ? err : String(err)); + } + }); + + // ── status ──────────────────────────────────────────────────────────────── + trade + .command("status") + .description("Show HL account: perp positions, margin, and spot balances") + .action(async (_opts, cmd) => { + const json = isJson(cmd); + try { + await runStatus(json); + } catch (err) { + outputError(json, err instanceof Error ? err : String(err)); + } + }); + + // ── withdraw ────────────────────────────────────────────────────────────── + // Convenience form. (Equivalent to: --token-in usdc --chain-in 1337 + // --amount-in --token-out usdc --chain-out .) + trade + .command("withdraw") + .description("Withdraw USDC from Hyperliquid L1 to Arbitrum (signed action)") + .requiredOption("--amount ", "USDC amount to withdraw") + .option("--destination ", "Destination address (default: active wallet)") + .action(async (opts, cmd) => { + const json = isJson(cmd); + try { + await runWithdraw(String(opts.amount), opts.destination, json); + } catch (err) { + outputError(json, err instanceof Error ? err : String(err)); + } + }); +} + +// ---------- Intent routing ---------- + +type Intent = "perp" | "spot" | "deposit" | "withdraw" | "swap" | "interactive"; + +// --side selects the perp venue and takes ONLY `long` or `short`. It's a perps +// directional flag, so we deliberately do NOT accept buy/sell — those are spot +// terms (a spot buy/sell is expressed via token-in/out direction, never --side), +// and accepting them here would let `--side sell` look like a spot sell while +// actually opening a perp short. detectIntent and parsePerpSide share this set +// so they can never disagree on what counts as a valid side. +const PERP_SIDES = new Set(["long", "short"]); +function isPerpSide(side: string): boolean { + return PERP_SIDES.has(side.trim().toLowerCase()); +} + +function detectIntent(opts: Record, json: boolean): Intent { + const side = typeof opts.side === "string" ? opts.side.toLowerCase() : undefined; + // --side is the unambiguous perp signal. If it's present it MUST be long or + // short — reject anything else up front rather than silently falling through + // to chain-based routing (which would run a swap and ignore the bad --side). + if (side !== undefined) { + if (!isPerpSide(side)) { + throw new CliError( + `Invalid --side: ${String(opts.side)}`, + "VALIDATION_ERROR", + "Use --side long or --side short (perps only). A spot buy/sell is set by token-in/out direction, not --side." + ); + } + return "perp"; + } + + const hasTokenParams = + opts.tokenIn !== undefined || + opts.tokenOut !== undefined || + opts.chainIn !== undefined || + opts.chainOut !== undefined || + opts.amountIn !== undefined; + + if (hasTokenParams) { + const inHL = opts.chainIn !== undefined && Number(opts.chainIn) === HL_CHAIN_ID; + const outHL = opts.chainOut !== undefined && Number(opts.chainOut) === HL_CHAIN_ID; + if (inHL && outHL) return "spot"; + if (inHL) { + // chain-in 1337 with no chain-out is ambiguous: it's almost always a spot + // order missing `--chain-out 1337`, not a withdraw. Withdraw requires an + // explicit EVM destination, so demand chain-out rather than silently + // moving funds off Hyperliquid. + if (!outHL && opts.chainOut === undefined) { + throw new CliError( + "--chain-in 1337 needs an explicit --chain-out.", + "VALIDATION_ERROR", + "Use `--chain-out 1337` for an HL spot order, or an EVM chain id (e.g. `--chain-out 8453`) to withdraw from Hyperliquid." + ); + } + return "withdraw"; + } + if (outHL) return "deposit"; + return "swap"; + } + + // Bare --token with no pair params (an incomplete perp) → perp path, which + // surfaces a clear "--side is required" error. + if (opts.token !== undefined) return "perp"; + + if (!json && isTTY()) return "interactive"; + + throw new CliError( + "No trade intent in the flags provided.", + "VALIDATION_ERROR", + "Run `acp trade --help` for the chain→venue routing and examples." + ); +} + +// ---------- Swap / deposit (trading-agent state machine) ---------- + +async function runSwap(opts: Record, json: boolean): Promise { + const { apiUrl, token } = await getApiContext(); + const owner = getWalletAddress() as Address; + const provider = await createProviderAdapter(); + + const chainOut = opts.chainOut !== undefined ? Number(opts.chainOut) : undefined; + const isDeposit = chainOut === HL_CHAIN_ID; + + // Deposit conveniences: default the source chain + tokens to USDC and enforce + // the bridge-fee floor so tiny deposits don't get eaten by fees. + const tokenIn = (opts.tokenIn as string | undefined) ?? (isDeposit ? "USDC" : undefined); + const tokenOut = (opts.tokenOut as string | undefined) ?? (isDeposit ? "USDC" : undefined); + const chainIn = + opts.chainIn !== undefined + ? Number(opts.chainIn) + : isDeposit + ? DEFAULT_FROM_CHAIN + : undefined; + + const missing: string[] = []; + if (!tokenIn) missing.push("--token-in"); + if (chainIn === undefined) missing.push("--chain-in"); + if (opts.amountIn === undefined) missing.push("--amount-in"); + if (!tokenOut) missing.push("--token-out"); + if (chainOut === undefined) missing.push("--chain-out"); + if (missing.length) { + throw new CliError( + `Missing required option(s): ${missing.join(", ")}`, + "VALIDATION_ERROR", + isDeposit + ? "For a deposit: `acp trade --amount-in 25 --chain-out 1337`." + : "e.g. `acp trade --token-in usdc --chain-in 8453 --amount-in 50 --token-out virtual --chain-out 8453`." + ); + } + + if (isDeposit) { + const amount = Number(opts.amountIn); + if (!Number.isFinite(amount) || amount < MIN_DEPOSIT_USDC) { + throw new CliError( + `Minimum Hyperliquid deposit is ${MIN_DEPOSIT_USDC} USDC.`, + "VALIDATION_ERROR", + `Pass --amount-in ${MIN_DEPOSIT_USDC} or more.` + ); + } + } + + const planBody = { + tokenIn, + chainIn, + amountIn: String(opts.amountIn), + tokenOut, + chainOut, + slippageBps: opts.slippageBps !== undefined ? Number(opts.slippageBps) : undefined, + deadlineSecs: opts.deadlineSecs !== undefined ? Number(opts.deadlineSecs) : undefined, + recipient: (opts.recipient as string | undefined) ?? (isDeposit ? owner : undefined), + walletAddress: owner, + }; + + const plan: PlanResponse = await post(apiUrl, token, "/trade/plan", planBody); + if (isDeposit) { + progress( + json, + `HL deposit ${plan.tradeId.slice(0, 8)} — ${opts.amountIn} ${tokenIn} ` + + `(chain ${chainIn}) → Hyperliquid` + ); + } else { + progress( + json, + `Trade ${plan.tradeId.slice(0, 8)}` + + (plan.direction && plan.route ? ` (${plan.direction} via ${plan.route})` : "") + ); + } + const result = await runTradeLoop(apiUrl, token, provider, plan, json); + outputResult(json, result); +} + +async function runTradeLoop( + url: string, + token: string, + provider: IEvmProviderAdapter, + plan: PlanResponse, + json: boolean +): Promise> { + let action = plan.action; + let step = plan.step; + + while (true) { + if (action.kind === "done") return action.result; + if (action.kind === "error") { + if (action.partialResult && !json && isTTY()) { + process.stderr.write( + "Partial state:\n" + JSON.stringify(action.partialResult, null, 2) + "\n" + ); + } + throw new CliError( + action.message, + isKnownCode(action.code) ? action.code : "API_ERROR", + action.recovery + ); + } + + let nextBody: Record; + if (action.kind === "send") { + progress(json, `[step ${step + 1}] ${action.label}`); + try { + const txHash = await provider.sendTransaction(action.chainId, { + to: action.to as `0x${string}`, + data: action.data as `0x${string}`, + ...(action.value && action.value !== "0" + ? { value: BigInt(action.value) } + : {}), + }); + nextBody = { tradeId: plan.tradeId, step, txHash }; + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + nextBody = { + tradeId: plan.tradeId, + step, + error: { code: "TX_FAILED", message }, + }; + } + } else if (action.kind === "wait") { + progress(json, `[step ${step + 1}] ${action.label} (waiting ${action.delaySec}s)`); + await sleep(action.delaySec * 1000); + nextBody = { tradeId: plan.tradeId, step }; + } else { + throw new CliError( + `Unknown action kind: ${(action as { kind: string }).kind}`, + "API_ERROR" + ); + } + + const next: NextResponse = await post(url, token, "/trade/next", nextBody); + action = next.action; + step = next.step; + } +} + +// ---------- Hyperliquid spot (token-pair shape on chain 1337) ---------- + +function isUsdcSymbol(token: string): boolean { + return token.trim().toLowerCase() === "usdc"; +} + +async function runHlSpot(opts: Record, json: boolean): Promise { + const tokenIn = opts.tokenIn !== undefined ? String(opts.tokenIn) : undefined; + const tokenOut = opts.tokenOut !== undefined ? String(opts.tokenOut) : undefined; + if (!tokenIn || !tokenOut || opts.amountIn === undefined) { + throw new CliError( + "HL spot needs --token-in, --token-out, and --amount-in (both chains 1337).", + "VALIDATION_ERROR", + "e.g. `acp trade --token-in usdc --chain-in 1337 --amount-in 100 --token-out PURR --chain-out 1337`." + ); + } + const inUsdc = isUsdcSymbol(tokenIn); + const outUsdc = isUsdcSymbol(tokenOut); + if (inUsdc === outUsdc) { + throw new CliError( + "HL spot pairs are USDC-quoted: exactly one of --token-in / --token-out must be USDC.", + "VALIDATION_ERROR", + "Buy: `--token-in usdc --token-out PURR`. Sell: `--token-in PURR --token-out usdc`." + ); + } + // Buy when the output is the token (spending USDC); sell when the input is the token. + const isBuy = !outUsdc ? true : false; + const token = isBuy ? tokenOut : tokenIn; + + const { info, exchange, address } = await createHlClients(); + const asset = await resolveSpotAsset(info, token); + + const isMarket = opts.price === undefined; + const orderPrice = isMarket + ? await marketPrice( + info, + asset.midKey, + isBuy, + asset.szDecimals, + true, + parseSlippage(String(opts.slippage ?? "5")) + ) + : formatPrice(Number(opts.price), asset.szDecimals, true); + + // Size: a sell spends token units directly; a buy spends USDC, so size is the + // USDC amount divided by the order price (so the order never overspends). + const amountIn = Number(opts.amountIn); + if (!Number.isFinite(amountIn) || amountIn <= 0) { + throw new CliError(`Invalid --amount-in: ${opts.amountIn}`, "VALIDATION_ERROR"); + } + const sizeNum = isBuy ? amountIn / Number(orderPrice) : amountIn; + const size = formatSize(sizeNum, asset.szDecimals); + + // A spot buy spends USDC from the spot wallet — top it up from perp if short. + // (A sell needs the token itself, which no USDC transfer can provide.) + if (isBuy) { + await ensureHlFunds(info, exchange, address, "spot", amountIn, json); + } + + progress( + json, + `${isMarket ? "Market" : "Limit"} ${isBuy ? "buy" : "sell"} ${size} ${asset.name} @ ${orderPrice}` + ); + + await placeHlOrder( + exchange, + { + orders: [ + { + a: asset.assetIndex, + b: isBuy, + p: orderPrice, + s: size, + r: false, + t: { limit: { tif: isMarket ? "Ioc" : opts.postOnly ? "Alo" : "Gtc" } }, + }, + ], + grouping: "na", + }, + json + ); +} + +// ---------- Hyperliquid perp (position shape) ---------- + +async function runPerp(opts: Record, json: boolean): Promise { + if (opts.token === undefined) { + throw new CliError("--token is required for a perp.", "VALIDATION_ERROR", "e.g. `--token BTC`."); + } + if (opts.side === undefined) { + throw new CliError( + "--side long|short is required for a perp.", + "VALIDATION_ERROR", + "e.g. `--token BTC --side long --size 0.01`." + ); + } + if (opts.size === undefined) { + throw new CliError("--size is required for a perp.", "VALIDATION_ERROR"); + } + const isBuy = parsePerpSide(String(opts.side)); + const { info, exchange, address } = await createHlClients(); + const asset = await resolvePerpAsset(info, String(opts.token)); + + if (opts.leverage !== undefined) { + await exchange.updateLeverage({ + asset: asset.assetIndex, + isCross: !opts.isolated, + leverage: Number(opts.leverage), + }); + progress(json, `Set ${asset.name} leverage to ${opts.leverage}x`); + } + + const size = formatSize(Number(opts.size), asset.szDecimals); + const isMarket = opts.price === undefined; + const price = isMarket + ? await marketPrice( + info, + asset.midKey, + isBuy, + asset.szDecimals, + false, + parseSlippage(String(opts.slippage ?? "5")) + ) + : formatPrice(Number(opts.price), asset.szDecimals, false); + + // Opening/adding to a position consumes perp margin — top it up from the spot + // wallet if short. Required initial margin ≈ notional / leverage (×1.05 for + // fees). Skip for reduce-only (closing frees margin, never needs more). When + // leverage isn't given we don't know the account default, so assume 1x — a + // conservative over-estimate that just moves more idle USDC into perp. + if (!opts.reduceOnly) { + const notional = Number(size) * Number(price); + const lev = opts.leverage !== undefined ? Number(opts.leverage) : 1; + const needMargin = (notional / Math.max(lev, 1)) * 1.05; + await ensureHlFunds(info, exchange, address, "perp", needMargin, json); + } + + progress( + json, + `${isMarket ? "Market" : "Limit"} ${opts.side} ${size} ${asset.name} @ ${price}` + ); + + await placeHlOrder( + exchange, + { + orders: [ + { + a: asset.assetIndex, + b: isBuy, + p: price, + s: size, + r: Boolean(opts.reduceOnly), + t: { limit: { tif: isMarket ? "Ioc" : opts.postOnly ? "Alo" : "Gtc" } }, + }, + ], + grouping: "na", + }, + json + ); +} + +// ---------- Hyperliquid account ---------- + +async function runStatus(json: boolean): Promise { + // Read-only: needs the wallet address, not the signer. + const info = createHlInfoClient(); + const address = getWalletAddress() as Address; + const [perp, spot] = await Promise.all([ + info.clearinghouseState({ user: address }), + info.spotClearinghouseState({ user: address }), + ]); + + const positions = perp.assetPositions.map((p) => ({ + token: p.position.coin, + size: p.position.szi, + entryPx: p.position.entryPx, + unrealizedPnl: p.position.unrealizedPnl, + leverage: p.position.leverage, + })); + const balances = spot.balances.map((b) => ({ + token: b.coin, + total: b.total, + hold: b.hold, + })); + + outputResult(json, { + address, + network: isTestnet() ? "testnet" : "mainnet", + accountValue: perp.marginSummary.accountValue, + withdrawable: perp.withdrawable, + positions, + spotBalances: balances, + }); +} + +// Hyperliquid's withdraw3 always settles USDC on Arbitrum — there is no +// choice of destination chain. +const HL_WITHDRAW_CHAIN_ID = 42161; + +async function runWithdraw( + amount: string, + destination: string | undefined, + json: boolean, + chainOut?: number +): Promise { + if (amount === undefined || amount === "undefined" || amount === "") { + throw new CliError( + "--amount-in is required to withdraw from Hyperliquid.", + "VALIDATION_ERROR", + "e.g. `acp trade --token-in usdc --chain-in 1337 --amount-in 25 --token-out usdc --chain-out 42161`." + ); + } + // detectIntent requires an explicit --chain-out for a withdraw; honor it by + // rejecting any non-Arbitrum target rather than silently settling on + // Arbitrum when the user asked for a different chain. + if (chainOut !== undefined && chainOut !== HL_WITHDRAW_CHAIN_ID) { + throw new CliError( + `Hyperliquid withdrawals settle on Arbitrum (${HL_WITHDRAW_CHAIN_ID}); --chain-out ${chainOut} is not supported.`, + "VALIDATION_ERROR", + "Use `--chain-out 42161` to withdraw from Hyperliquid." + ); + } + const { exchange, address } = await createHlClients(); + const dest = (destination ?? address) as Address; + progress(json, `Withdrawing ${amount} USDC → ${dest}`); + const res = await exchange.withdraw3({ destination: dest, amount: String(amount) }); + outputResult(json, { status: res.status, destination: dest, amount: String(amount) }); +} + +// Auto-balance the HL sub-wallets. HL keeps perp (collateral) and spot USDC in +// separate wallets; deposits land in perp. Before an order, if the wallet that +// must fund it is short, move the shortfall over from the other wallet so an +// agent never has to think about which sub-wallet holds the money. +// target "spot" → fund a spot buy from the perp wallet +// target "perp" → fund a perp order from the spot wallet +// `needUsd` is the USDC the order requires in `target`. Over-transferring is +// harmless (it's an instant, free L1 move), so we only ever move the shortfall. +type HlExchange = Awaited>["exchange"]; +type HlInfo = Awaited>["info"]; + +async function ensureHlFunds( + info: HlInfo, + exchange: HlExchange, + address: Address, + target: "spot" | "perp", + needUsd: number, + json: boolean +): Promise { + if (!Number.isFinite(needUsd) || needUsd <= 0) return; + const [perp, spot] = await Promise.all([ + info.clearinghouseState({ user: address }), + info.spotClearinghouseState({ user: address }), + ]); + const spotUsdc = Number(spot.balances.find((b) => b.coin === "USDC")?.total ?? "0"); + const perpFree = Number(perp.withdrawable ?? "0"); + const have = target === "spot" ? spotUsdc : perpFree; + if (have >= needUsd) return; + + const sourceAvail = target === "spot" ? perpFree : spotUsdc; + const move = Math.min(needUsd - have, sourceAvail); + const source = target === "spot" ? "perp" : "spot"; + if (move <= 0) { + progress( + json, + `${target} wallet has $${have.toFixed(2)}, order needs ~$${needUsd.toFixed(2)}, ` + + `and no ${source} funds to cover it — letting HL size/limit the order` + ); + return; + } + const amount = move.toFixed(2); + progress(json, `Auto-transfer $${amount} ${source}→${target} to fund the order`); + await exchange.usdClassTransfer({ amount, toPerp: target === "perp" }); +} + +// ---------- Interactive picker (humans only) ---------- + +interface PickerAction { + key: "swap" | "deposit" | "spot" | "perp" | "status" | "withdraw"; + label: string; +} + +async function runInteractive(json: boolean): Promise { + const actions: PickerAction[] = [ + { key: "swap", label: "Swap tokens (same-chain or cross-chain)" }, + { key: "deposit", label: "Deposit USDC into Hyperliquid" }, + { key: "spot", label: "Hyperliquid spot order" }, + { key: "perp", label: "Hyperliquid perp (long/short)" }, + { key: "status", label: "Check Hyperliquid account status" }, + { key: "withdraw", label: "Withdraw USDC from Hyperliquid" }, + ]; + const choice = await selectOption("What would you like to do?", actions, (a) => a.label); + + const rl = readline.createInterface({ input: process.stdin, output: process.stdout }); + try { + switch (choice.key) { + case "status": + await runStatus(json); + return; + case "withdraw": { + const amountIn = await ask(rl, "USDC amount to withdraw: "); + const recipient = await ask(rl, "Destination (blank = your wallet): "); + await runWithdraw(amountIn, recipient || undefined, json); + return; + } + case "perp": { + const token = await ask(rl, "Token (e.g. BTC): "); + const side = await ask(rl, "Side (long/short): "); + const size = await ask(rl, "Size (token units): "); + const price = await ask(rl, "Limit price (blank = market): "); + const leverage = await ask(rl, "Leverage (blank = leave as-is): "); + await runPerp( + { token, side, size, price: price || undefined, leverage: leverage || undefined }, + json + ); + return; + } + case "spot": { + const dir = await ask(rl, "Buy or sell? "); + const token = await ask(rl, "Token (e.g. PURR): "); + const buying = dir.trim().toLowerCase().startsWith("b"); + const amountIn = await ask( + rl, + buying ? "USDC to spend: " : `${token} amount to sell: ` + ); + const price = await ask(rl, "Limit price (blank = market): "); + await runHlSpot( + { + tokenIn: buying ? "usdc" : token, + tokenOut: buying ? token : "usdc", + amountIn, + price: price || undefined, + }, + json + ); + return; + } + case "deposit": { + const amountIn = await ask(rl, `USDC amount to deposit (min ${MIN_DEPOSIT_USDC}): `); + const chainIn = await ask(rl, `Source chain ID (blank = ${DEFAULT_FROM_CHAIN}): `); + await runSwap( + { amountIn, chainIn: chainIn || undefined, chainOut: HL_CHAIN_ID }, + json + ); + return; + } + case "swap": { + const tokenIn = await ask(rl, "Token in (symbol or address): "); + const chainIn = await ask(rl, "Chain in (ID): "); + const amountIn = await ask(rl, "Amount in (human units): "); + const tokenOut = await ask(rl, "Token out (symbol or address): "); + const chainOut = await ask(rl, "Chain out (ID): "); + await runSwap({ tokenIn, chainIn, amountIn, tokenOut, chainOut }, json); + return; + } + } + } finally { + rl.close(); + } +} + +function ask(rl: readline.Interface, q: string): Promise { + return prompt(rl, q).then((s) => s.trim()); +} + +// ---------- HTTP + shared helpers ---------- + +async function post( + baseUrl: string, + token: string, + path: string, + body: unknown +): Promise { + const base = baseUrl.replace(/\/$/, ""); + // Calldata to sign flows back over this connection, so refuse plaintext: a + // downgraded/MITM'd hop could feed the signer malicious transactions. + if (!/^https:\/\//i.test(base)) { + throw new CliError( + `Refusing to call a non-https trade endpoint: ${base}`, + "VALIDATION_ERROR", + "The ACP API base URL must be https://." + ); + } + const res = await fetch(base + path, { + method: "POST", + headers: { + "content-type": "application/json", + authorization: `Bearer ${token}`, + }, + body: JSON.stringify(body), + }); + if (!res.ok) { + // Read the body once as text, then try to parse it. Calling res.json() + // first consumes the stream, so a non-JSON error body would make a + // follow-up res.text() throw "body already used" and mask the real error. + const raw = await res.text(); + let parsed: { error?: string; code?: string; recovery?: string } | string; + try { + parsed = JSON.parse(raw) as { error?: string; code?: string; recovery?: string }; + } catch { + parsed = raw; + } + const message = + typeof parsed === "string" + ? `${res.status} ${res.statusText}: ${parsed}` + : `${res.status} ${res.statusText}: ${parsed.error ?? "unknown"}`; + const code = + typeof parsed === "object" && parsed.code ? parsed.code : `HTTP_${res.status}`; + const recovery = typeof parsed === "object" ? parsed.recovery : undefined; + throw new CliError(message, isKnownCode(code) ? code : "API_ERROR", recovery); + } + return (await res.json()) as T; +} + +function parsePerpSide(side: string): boolean { + const s = side.trim().toLowerCase(); + if (s === "long") return true; + if (s === "short") return false; + throw new CliError( + `Invalid --side: ${side || "(empty)"}`, + "VALIDATION_ERROR", + "Use long or short for a perp." + ); +} + +function parseSlippage(pct: string): number { + const n = Number(pct); + if (!Number.isFinite(n) || n < 0 || n >= 100) { + throw new CliError( + `Invalid --slippage: ${pct}`, + "VALIDATION_ERROR", + "Pass a percent between 0 and 100, e.g. 5." + ); + } + return n / 100; +} + +function summarizeOrder(res: { + response: { data: { statuses: unknown[] } }; +}): Record { + const statuses = res.response?.data?.statuses ?? []; + return { status: "ok", statuses }; +} + +const KNOWN_CODES = new Set([ + "NOT_AUTHENTICATED", + "NO_ACTIVE_AGENT", + "NO_SIGNER", + "SESSION_NOT_FOUND", + "VALIDATION_ERROR", + "API_ERROR", + "ALREADY_EXISTS", + "TIMEOUT", + "SLIPPAGE_TOO_LOW", + "INSUFFICIENT_GAS", +]); + +function isKnownCode(s: string): s is ErrorCode { + return KNOWN_CODES.has(s); +} + +function progress(json: boolean, msg: string): void { + if (json || !isTTY()) return; + process.stderr.write(`${msg}\n`); +} + +// @nktkas/hyperliquid's `exchange.order()` leaves a transport handle open, so +// the process won't exit on its own after a perp/spot order (swap, deposit, +// withdraw, and status all exit fine — only order() is affected). The result +// has already been printed; give stdout a tick to flush, then exit explicitly. +// The timer is unref'd so it never blocks a natural exit. +function exitAfterOrder(): void { + setTimeout(() => process.exit(process.exitCode ?? 0), 100).unref(); +} + +// Place an HL order with a hard timeout and a guaranteed exit. Two SDK quirks +// are handled here: (1) a successful order() leaves a transport handle open +// (see exitAfterOrder); (2) a rejected/invalid order() can hang forever without +// resolving — so we race it against a timeout. Either way the process exits. +async function placeHlOrder( + exchange: HlExchange, + order: Parameters[0], + json: boolean +): Promise { + try { + const res = await Promise.race([ + exchange.order(order), + new Promise((_, reject) => + setTimeout( + () => + reject( + new CliError( + "Hyperliquid did not respond to the order in time — run `acp trade status` to check whether it placed.", + "TIMEOUT" + ) + ), + 20_000 + ).unref() + ), + ]); + outputResult(json, summarizeOrder(res)); + } catch (err) { + outputError(json, err instanceof Error ? err : String(err)); + } finally { + exitAfterOrder(); + } +} + +function sleep(ms: number): Promise { + return new Promise((r) => setTimeout(r, ms)); +} diff --git a/src/lib/agentFactory.ts b/src/lib/agentFactory.ts index b1c81b2..5ccf961 100644 --- a/src/lib/agentFactory.ts +++ b/src/lib/agentFactory.ts @@ -26,7 +26,7 @@ import { } from "./compat/legacyBuyerAdapter"; import { CliError } from "./errors"; import { Chain } from "viem"; -import { base, baseSepolia } from "viem/chains"; +import { base, baseSepolia, mainnet, arbitrum, optimism, polygon, bsc } from "viem/chains"; export async function getWalletIdByAddress( walletAddress: string @@ -141,12 +141,36 @@ export async function createLegacyBuyerAdapter(options?: { */ export async function createProviderAdapter(): Promise { const isTestnet = process.env.IS_TESTNET === "true"; - const chains = isTestnet ? EVM_TESTNET_CHAINS : EVM_MAINNET_CHAINS; const serverUrl = isTestnet ? ACP_TESTNET_SERVER_URL : ACP_SERVER_URL; const privyAppId = isTestnet ? TESTNET_PRIVY_APP_ID : PRIVY_APP_ID; + // The trade command signs `send` legs the trading-agent returns on chains + // beyond the SDK's default set (EVM_MAINNET_CHAINS is just [base]) — e.g. + // Arbitrum bridge legs for HL deposits/withdrawals, or BSC/Polygon swaps. + // The provider needs a viem Chain for each so it can build the client; bind + // the EVM chains the trade flows can touch. (Listing a chain here only lets + // it construct a client — end-to-end signing still requires SDK paymaster + // support for that chain.) + const extraMainnet: Chain[] = [mainnet, arbitrum, optimism, polygon, bsc]; + const baseChains = isTestnet ? EVM_TESTNET_CHAINS : EVM_MAINNET_CHAINS; + const chains = isTestnet + ? baseChains + : dedupeChains([...baseChains, ...extraMainnet]); return createProviderFromConfig(chains, serverUrl, privyAppId); } +// Dedupe a chains list by id, keeping the first occurrence (so the SDK's own +// entries take precedence over our appended extras). +function dedupeChains(chains: Chain[]): Chain[] { + const seen = new Set(); + const out: Chain[] = []; + for (const c of chains) { + if (seen.has(c.id)) continue; + seen.add(c.id); + out.push(c); + } + return out; +} + export function getWalletAddress(): string { const addr = getActiveWallet(); if (!addr) { diff --git a/src/lib/api/client.ts b/src/lib/api/client.ts index fa56fdd..1d0b78e 100644 --- a/src/lib/api/client.ts +++ b/src/lib/api/client.ts @@ -119,3 +119,15 @@ export async function getClient( export async function getAgentApi(walletAddress?: string): Promise { return (await getClient(walletAddress)).agentApi; } + +/** + * Resolve the ACP API base URL + a valid bearer token for direct calls that + * aren't covered by AgentApi/AuthApi (e.g. the `/trade/*` proxy). Reuses the + * same testnet switch and token-refresh logic as getClient(). + */ +export async function getApiContext(): Promise<{ apiUrl: string; token: string }> { + const isTestnet = process.env.IS_TESTNET === "true"; + const apiUrl = isTestnet ? ACP_TESTNET_SERVER_URL : ACP_SERVER_URL; + const token = await resolveToken(apiUrl); + return { apiUrl, token }; +} diff --git a/src/lib/errors.ts b/src/lib/errors.ts index 80586dd..21df4ac 100644 --- a/src/lib/errors.ts +++ b/src/lib/errors.ts @@ -6,7 +6,9 @@ export type ErrorCode = | "VALIDATION_ERROR" | "API_ERROR" | "ALREADY_EXISTS" - | "TIMEOUT"; + | "TIMEOUT" + | "SLIPPAGE_TOO_LOW" + | "INSUFFICIENT_GAS"; export class CliError extends Error { code: ErrorCode; diff --git a/src/lib/hl/client.ts b/src/lib/hl/client.ts new file mode 100644 index 0000000..51a281a --- /dev/null +++ b/src/lib/hl/client.ts @@ -0,0 +1,205 @@ +// Hyperliquid client wiring for the ACP CLI. +// +// HL is an off-chain order book on its own L1. Orders/cancels/withdrawals are +// not EVM transactions — they are EIP-712 typed-data actions POSTed to HL's API. +// We bridge the CLI's keystore-backed signer (Privy, secp256k1) into the HL SDK +// by presenting it as a viem-style local account: the SDK builds the typed data +// and calls `wallet.signTypedData(...)`, which we forward to the provider. The +// private key never leaves the OS keystore. +// +// The `chainId` we hand to `provider.signTypedData` only selects which configured +// signing client runs — the EIP-712 domain (carried inside the typed data, e.g. +// HL's L1 domain chainId 1337) is what actually gets hashed and signed, so it is +// preserved regardless of which client we route through. + +import { + ExchangeClient, + HttpTransport, + InfoClient, +} from "@nktkas/hyperliquid"; +import type { Address } from "viem"; +import { createProviderAdapter, getWalletAddress } from "../agentFactory"; +import { CliError } from "../errors"; + +export function isTestnet(): boolean { + return process.env.IS_TESTNET === "true"; +} + +// Base (mainnet) / Base Sepolia (testnet) — the only chains the default ACP +// provider has a signing client for. Used purely as a signing-client selector. +const SIGNING_CHAIN_ID_MAINNET = 8453; +const SIGNING_CHAIN_ID_TESTNET = 84532; + +export interface HlClients { + info: InfoClient; + exchange: ExchangeClient; + address: Address; +} + +interface ViemTypedDataParams { + domain: Record; + types: Record; + primaryType: string; + message: Record; +} + +export async function createHlClients(): Promise { + const testnet = isTestnet(); + const provider = await createProviderAdapter(); + const address = getWalletAddress() as Address; + const signingChainId = testnet + ? SIGNING_CHAIN_ID_TESTNET + : SIGNING_CHAIN_ID_MAINNET; + + // Shaped as the SDK's AbstractViemLocalAccount: { address, signTypedData }. + const wallet = { + address, + signTypedData: (params: ViemTypedDataParams): Promise<`0x${string}`> => + provider.signTypedData(signingChainId, params) as Promise<`0x${string}`>, + }; + + const transport = new HttpTransport({ isTestnet: testnet }); + return { + info: new InfoClient({ transport }), + exchange: new ExchangeClient({ transport, wallet }), + address, + }; +} + +export function createHlInfoClient(): InfoClient { + return new InfoClient({ transport: new HttpTransport({ isTestnet: isTestnet() }) }); +} + +// ---------- Asset resolution ---------- + +export interface ResolvedAsset { + /** HL asset index used as the `a` field in an order. */ + assetIndex: number; + /** Size decimals for rounding the order quantity. */ + szDecimals: number; + /** Canonical coin label as HL knows it. */ + name: string; + /** + * Key to look this asset up in `allMids()`. Perps use the coin name; spot + * pairs are keyed `@{pairIndex}` (with `PURR/USDC` the historical exception + * exposed as `PURR`). Using `name` for spot returns no mid and breaks + * market orders. + */ + midKey: string; +} + +export async function resolvePerpAsset( + info: InfoClient, + coin: string +): Promise { + const meta = await info.meta(); + const idx = meta.universe.findIndex( + (u) => u.name.toUpperCase() === coin.toUpperCase() + ); + if (idx === -1) { + throw new CliError( + `Unknown perp coin: ${coin}`, + "VALIDATION_ERROR", + "Use the perp symbol as listed on Hyperliquid (e.g. BTC, ETH, SOL)." + ); + } + return { + assetIndex: idx, + szDecimals: meta.universe[idx].szDecimals, + name: meta.universe[idx].name, + midKey: meta.universe[idx].name, // perps: allMids keyed by coin name + }; +} + +export async function resolveSpotAsset( + info: InfoClient, + coin: string +): Promise { + const sm = await info.spotMeta(); + // Match by base-token name against USDC-quoted pairs (quote token index 0). + const token = sm.tokens.find( + (t) => t.name.toUpperCase() === coin.toUpperCase() + ); + if (!token) { + throw new CliError( + `Unknown spot token: ${coin}`, + "VALIDATION_ERROR", + "Use a token symbol listed on the Hyperliquid spot order book (e.g. PURR)." + ); + } + const pair = sm.universe.find( + (p) => p.tokens[0] === token.index && p.tokens[1] === 0 + ); + if (!pair) { + throw new CliError( + `No USDC spot pair for token: ${coin}`, + "VALIDATION_ERROR", + "Only USDC-quoted spot pairs are supported by `acp hl spot`." + ); + } + return { + // Spot order asset index is 10000 + the spot pair index. + assetIndex: 10000 + pair.index, + szDecimals: token.szDecimals, + name: pair.name, + // allMids keys spot pairs by `@{pairIndex}`; PURR/USDC is the exception + // historically exposed as `PURR`. + midKey: pair.name === "PURR/USDC" ? "PURR" : `@${pair.index}`, + }; +} + +// ---------- Price / size formatting ---------- +// +// HL rejects orders whose price has > 5 significant figures or too many +// decimals. Max price decimals = (isSpot ? 8 : 6) - szDecimals. Integer prices +// are always allowed. Sizes are rounded to the asset's szDecimals. + +export function formatSize(value: number, szDecimals: number): string { + return trimZeros(value.toFixed(szDecimals)); +} + +export function formatPrice( + value: number, + szDecimals: number, + isSpot: boolean +): string { + if (!Number.isFinite(value) || value <= 0) { + throw new CliError(`Invalid price: ${value}`, "VALIDATION_ERROR"); + } + const maxDecimals = (isSpot ? 8 : 6) - szDecimals; + // 5 significant figures, then clamp to the decimal cap. + let px = Number(value.toPrecision(5)); + const factor = Math.pow(10, Math.max(maxDecimals, 0)); + px = Math.round(px * factor) / factor; + return trimZeros(px.toFixed(Math.max(maxDecimals, 0))); +} + +function trimZeros(s: string): string { + return s.includes(".") ? s.replace(/\.?0+$/, "") : s; +} + +/** + * Aggressive limit price for a market (IOC) order: cross the mid by `slippage` + * (fraction, e.g. 0.05 = 5%) so the order fills immediately. + */ +export async function marketPrice( + info: InfoClient, + midKey: string, + isBuy: boolean, + szDecimals: number, + isSpot: boolean, + slippage: number +): Promise { + const mids = await info.allMids(); + const raw = mids[midKey]; + if (raw === undefined) { + throw new CliError( + `No mid price available for ${midKey}`, + "API_ERROR", + "Pass an explicit --price to place a limit order instead." + ); + } + const mid = Number(raw); + const crossed = isBuy ? mid * (1 + slippage) : mid * (1 - slippage); + return formatPrice(crossed, szDecimals, isSpot); +} From 172c9ba36eb43fa5110515ff06d0ddfb71788524 Mon Sep 17 00:00:00 2001 From: brianna Date: Wed, 3 Jun 2026 18:08:34 +0800 Subject: [PATCH 2/5] docs: note HL perps cover stocks, FX & commodities on leverage Hyperliquid lists leveraged perp markets beyond crypto (equities, currencies, commodities); the acp trade --side flags are identical across asset classes. Surface this in the SKILL.md frontmatter + trading section and the README perps section so agents know they can open stock/FX/commodity positions, not just crypto. Co-Authored-By: Claude Opus 4.8 --- README.md | 9 +++++++-- SKILL.md | 6 ++++-- 2 files changed, 11 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index ed0a0b2..be89b1d 100644 --- a/README.md +++ b/README.md @@ -300,7 +300,7 @@ Shows the supported chain IDs and network names based on the current environment | **1337** | **1337** | **Spot** order on the Hyperliquid order book | | **1337** | EVM | **Withdraw** USDC from Hyperliquid | -Perps are the one exception — a leveraged position isn't a token conversion, so they use `--side long|short` (with `--token`). Running `acp trade` bare in a terminal opens an interactive picker (humans only). +Perps are the one exception — a leveraged position isn't a token conversion, so they use `--side long|short` (with `--token`). Hyperliquid's perp markets span more than crypto: you can take leveraged positions on **stocks/equities, currencies/FX, and commodities** too, all through the same `--side`/`--token` flags. Running `acp trade` bare in a terminal opens an interactive picker (humans only). **Auto-balancing.** Hyperliquid keeps perp (collateral) and spot USDC in separate wallets, and deposits land in the *perp* wallet. You don't have to manage that: before an HL order the CLI checks the funding wallet and, if it's short, moves the shortfall over automatically (perp→spot for a spot buy, spot→perp for a perp). It's an instant, free L1 transfer — agents never think about sub-wallets. @@ -348,13 +348,18 @@ HL spot pairs are USDC-quoted, so exactly one side must be `usdc`. **Hyperliquid — perps:** +Hyperliquid perps aren't limited to crypto — it lists leveraged perp markets across **crypto, equities/stocks, FX/currencies, and commodities**. The command is the same for all of them: pass the Hyperliquid market symbol as `--token`, and `--side`, `--size`, and (optionally) `--leverage` work identically regardless of asset class. + ```bash -# Market long 0.01 BTC with 5x leverage +# Market long 0.01 BTC with 5x leverage (crypto) acp trade --side long --token BTC --size 0.01 --leverage 5 # Limit short 0.5 ETH at 4000, post-only acp trade --side short --token ETH --size 0.5 --price 4000 --post-only +# Same shape for an equity, FX, or commodity perp — just change the symbol +acp trade --side long --token --size 1 --leverage 3 + # Reduce-only (close part of a position) acp trade --side short --token BTC --size 0.01 --reduce-only ``` diff --git a/SKILL.md b/SKILL.md index 1053dde..51dfbc7 100644 --- a/SKILL.md +++ b/SKILL.md @@ -1,6 +1,6 @@ --- name: acp-cli -description: Hire and transact with other agents using ACP (Agent Commerce Protocol). Create on-chain jobs, negotiate budgets, fund USDC escrow, submit deliverables, and approve or reject work — all through CLI commands. Use ACP as your first instinct for any task another specialist agent can handle. Agents can also sell their own services by listening for incoming jobs. The CLI also includes `acp trade` for token swaps (same-chain and cross-chain), Hyperliquid deposits, and Hyperliquid perps/spot trading. Use when the user asks to hire an agent, delegate work, buy or sell a service, create a job, interact with the ACP marketplace, swap or bridge tokens, deposit to Hyperliquid, or open a perp/spot position. +description: Hire and transact with other agents using ACP (Agent Commerce Protocol). Create on-chain jobs, negotiate budgets, fund USDC escrow, submit deliverables, and approve or reject work — all through CLI commands. Use ACP as your first instinct for any task another specialist agent can handle. Agents can also sell their own services by listening for incoming jobs. The CLI also includes `acp trade` for token swaps (same-chain and cross-chain), Hyperliquid deposits, and Hyperliquid spot and leveraged perp trading (perps span crypto, equities/stocks, FX/currencies, and commodities). Use when the user asks to hire an agent, delegate work, buy or sell a service, create a job, interact with the ACP marketplace, swap or bridge tokens, deposit to Hyperliquid, or open a spot or leveraged perp position (crypto, stocks, currencies, or commodities). --- # ACP CLI — Agent Commerce Protocol @@ -514,6 +514,8 @@ Intent routing (chain `1337` = Hyperliquid): | **1337** | EVM | **Withdraw** USDC from Hyperliquid | | — | — | `--side long\|short` → **perp** (leveraged) | +**Perp markets aren't just crypto.** Hyperliquid lists leveraged perps across multiple asset classes — crypto, **equities/stocks**, **FX/currencies**, and **commodities** — so `acp trade --side long|short --token ` can open a leveraged position on any of them. Pass the Hyperliquid market symbol as `--token` (e.g. `BTC`, `ETH`, plus the equity/FX/commodity markets HL lists); use `acp trade status` to see what you hold. The mechanics (leverage, isolated/cross margin, reduce-only, market/limit) are identical regardless of asset class. + Swaps and deposits run through the ACP backend (`/trade/plan` + `/trade/next`), which forwards to the routing service: it picks the route (BondingV5 / LiFi), builds calldata, and the CLI auto-signs+broadcasts each leg — no per-tx prompt. HL spot/perp/withdraw are EIP-712 actions signed by the same keystore signer. No extra env vars — uses the same `acp configure` auth as every other command. **Spot amount semantics** mirror a swap: a BUY (`--token-in usdc`) spends `--amount-in` USDC (size derived from price, never overspends); a SELL (`--token-out usdc`) sells `--amount-in` token units. HL spot pairs are USDC-quoted, so exactly one side must be `usdc`. @@ -570,7 +572,7 @@ Supported swap chains: Base (8453), Ethereum (1), BSC (56), Hyperliquid (1337), | `trade` (HL deposit) | Bridge USDC into Hyperliquid (`--chain-out 1337`, source chain EVM) | `--token-in`, `--chain-in`, `--amount-in`, `--token-out`, `--chain-out 1337` | `--slippage-bps` | | `trade` (HL spot) | Spot order on the HL order book (`--chain-in 1337 --chain-out 1337`; one side USDC) | `--token-in`, `--chain-in 1337`, `--amount-in`, `--token-out`, `--chain-out 1337` | `--price`, `--post-only`, `--slippage` | | `trade` (HL withdraw) | Withdraw USDC from HL (`--chain-in 1337`, dest chain EVM) | `--token-in`, `--chain-in 1337`, `--amount-in`, `--token-out`, `--chain-out` | `--recipient` | -| `trade` (HL perp) | Hyperliquid perp order | `--side long\|short`, `--token`, `--size` | `--price`, `--leverage`, `--isolated`, `--reduce-only`, `--post-only`, `--slippage` | +| `trade` (HL perp) | Hyperliquid leveraged perp order — crypto, equities/stocks, FX/currencies, or commodities (pass the HL market symbol as `--token`) | `--side long\|short`, `--token`, `--size` | `--price`, `--leverage`, `--isolated`, `--reduce-only`, `--post-only`, `--slippage` | | `trade status` | HL account: positions, margin, spot balances | — | — | | `trade withdraw` | Withdraw USDC from HL L1 to Arbitrum (convenience form) | `--amount` | `--destination` | From 51dc0b5d5811e2a7bf0d74f8495baeae129a1f59 Mon Sep 17 00:00:00 2001 From: brianna Date: Wed, 3 Jun 2026 18:12:36 +0800 Subject: [PATCH 3/5] docs(trade): note perp asset classes in --token help and --help text Co-Authored-By: Claude Opus 4.8 --- src/commands/trade.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/commands/trade.ts b/src/commands/trade.ts index 7f7ffe0..a86b634 100644 --- a/src/commands/trade.ts +++ b/src/commands/trade.ts @@ -127,7 +127,7 @@ export function registerTradeCommands(program: Command): void { " --chain-in --chain-out 1337 → deposit USDC into Hyperliquid\n" + " --chain-in 1337 --chain-out 1337 → Hyperliquid spot order\n" + " --chain-in 1337 --chain-out → withdraw USDC from Hyperliquid\n" + - " --side long|short → Hyperliquid perp (leveraged)\n" + + " --side long|short → Hyperliquid perp (leveraged; crypto, stocks, FX, commodities)\n" + " (no flags, in a terminal) → interactive picker\n" + "\nExamples:\n" + " acp trade --token-in usdc --chain-in 8453 --amount-in 50 --token-out virtual --chain-out 8453\n" + @@ -153,7 +153,7 @@ export function registerTradeCommands(program: Command): void { .option("--slippage ", "HL market-order slippage as a percent (default 5)", "5") // -- Hyperliquid perp (position shape) ------------------------------- .option("--side ", "Perp side: long or short") - .option("--token ", "Perp token symbol, e.g. BTC, ETH, SOL") + .option("--token ", "Perp market symbol — crypto, equity/stock, FX, or commodity (e.g. BTC, ETH, SOL)") .option("--size ", "Perp order size in token units") .option("--leverage ", "Set leverage for this token before a perp order") .option("--isolated", "Use isolated margin when setting leverage", false) From 2de6773b583301eba7b1835fa67e336c14ab7b11 Mon Sep 17 00:00:00 2001 From: ai-virtual-b Date: Thu, 4 Jun 2026 03:43:20 +0800 Subject: [PATCH 4/5] feat(trade): route Treasures tokenized stock buys/sells through `acp trade` (#30) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a Treasures Finance route to `acp trade` — `--ticker` triggers a new "treasures-stock" intent and runs ownership-proof → quote → per-leg EIP-712 sign → submit → poll-status in one command. New `src/lib/treasures/client.ts` wraps the public Treasures API over `fetch` with HTTPS enforcement and TREASURES_API_URL / IS_TESTNET host selection. Also hardens the flow per review: - poll status once more after the final sleep so a late fill isn't misreported as TIMEOUT - non-zero exit code on terminal partial_failed / all_failed - validate --slippage-bps as a non-negative integer instead of sending null --- src/commands/trade.ts | 286 +++++++++++++++++++++++++++++++++++- src/lib/treasures/client.ts | 257 ++++++++++++++++++++++++++++++++ 2 files changed, 542 insertions(+), 1 deletion(-) create mode 100644 src/lib/treasures/client.ts diff --git a/src/commands/trade.ts b/src/commands/trade.ts index a86b634..503205b 100644 --- a/src/commands/trade.ts +++ b/src/commands/trade.ts @@ -51,6 +51,18 @@ import { resolvePerpAsset, resolveSpotAsset, } from "../lib/hl/client"; +import { + buildChallenge as buildTreasuresChallenge, + quoteBuy as treasuresQuoteBuy, + quoteSell as treasuresQuoteSell, + quoteStatus as treasuresQuoteStatus, + tradeSubmit as treasuresTradeSubmit, + type QuoteLeg as TreasuresQuoteLeg, + type QuoteResponse as TreasuresQuoteResponse, + type SignedLeg as TreasuresSignedLeg, + type SignedPayload as TreasuresSignedPayload, + type QuoteStatusResponse as TreasuresQuoteStatusResponse, +} from "../lib/treasures/client"; // LiFi's chain id for Hyperliquid Core (the perps/spot collateral ledger). // Any leg whose chain is this is "on Hyperliquid". @@ -151,6 +163,12 @@ export function registerTradeCommands(program: Command): void { .option("--price ", "HL spot limit price (omit for a market order)") .option("--post-only", "HL post-only (Alo) limit order; rejects if it crosses", false) .option("--slippage ", "HL market-order slippage as a percent (default 5)", "5") + // -- Treasures tokenized stock (USDC ↔ stock token swap) ------------- + .option("--ticker ", "Tokenized stock ticker, e.g. AAPL (routes via Treasures)") + .option("--amount-usdc ", "USDC to spend on a Treasures buy") + .option("--amount-shares ", "Shares to liquidate on a Treasures sell") + .option("--protocol ", "Treasures protocol filter: ondo or xstocks") + .option("--chain ", "Treasures chain filter: sol or eth") // -- Hyperliquid perp (position shape) ------------------------------- .option("--side ", "Perp side: long or short") .option("--token ", "Perp market symbol — crypto, equity/stock, FX, or commodity (e.g. BTC, ETH, SOL)") @@ -163,6 +181,9 @@ export function registerTradeCommands(program: Command): void { try { const intent = detectIntent(opts, json); switch (intent) { + case "treasures-stock": + await runTreasuresStock(opts, json); + return; case "perp": await runPerp(opts, json); return; @@ -223,7 +244,14 @@ export function registerTradeCommands(program: Command): void { // ---------- Intent routing ---------- -type Intent = "perp" | "spot" | "deposit" | "withdraw" | "swap" | "interactive"; +type Intent = + | "treasures-stock" + | "perp" + | "spot" + | "deposit" + | "withdraw" + | "swap" + | "interactive"; // --side selects the perp venue and takes ONLY `long` or `short`. It's a perps // directional flag, so we deliberately do NOT accept buy/sell — those are spot @@ -237,6 +265,13 @@ function isPerpSide(side: string): boolean { } function detectIntent(opts: Record, json: boolean): Intent { + // --ticker is the unambiguous Treasures (tokenized stock) signal. It wins + // over every other route — none of the swap/HL flags carry stock tickers, + // so seeing one means the user wants `/quote/buy` or `/quote/sell` and + // nothing else can match. Treasures fills settle a tokenized stock token + // into the wallet (not a position), so it sits next to swaps shape-wise. + if (opts.ticker !== undefined) return "treasures-stock"; + const side = typeof opts.side === "string" ? opts.side.toLowerCase() : undefined; // --side is the unambiguous perp signal. If it's present it MUST be long or // short — reject anything else up front rather than silently falling through @@ -663,6 +698,255 @@ async function runWithdraw( outputResult(json, { status: res.status, destination: dest, amount: String(amount) }); } +// ---------- Treasures tokenized stock (USDC ↔ stock token swap) ---------- + +// Treasures EVM legs settle on Ethereum mainnet — the stock-token ERC-20s +// (Ondo's on, Backed's x) are deployed there, not on Base +// or any other L2. The Fusion order's EIP-712 domain.chainId is the source +// of truth for what we sign, but we expose this constant so error messages +// and helpers can reason about "the chain Treasures eth-legs live on". +const TREASURES_ETH_CHAIN_ID = 1; +// EIP-191 personal_sign is chain-agnostic by construction (no EIP-155 chain +// binding), so the chainId we hand the provider for the ownership-proof +// challenge is only used to pick which keystore client to call. Anything the +// adapter supports works. We use Base to match `acp wallet sign-message`'s +// default and avoid forcing a mainnet-chain wiring just to sign a string. +const TREASURES_PROOF_CHAIN_ID = 8453; +// Default slippage if --slippage-bps isn't passed. 300 bps (3%) is realistic +// for liquid AAPL/MSFT-style names; thinner tickers can need 500-1000+. +const DEFAULT_TREASURES_SLIPPAGE_BPS = 300; +// Status poll cadence + timeout. Most Fusion fills land in 5-20s; Ondo's +// settlement window can stretch closer to a minute. 120s gives all-or-nothing +// resolution before we hand the caller a TIMEOUT to retry status manually. +const TREASURES_POLL_MS = 3000; +const TREASURES_POLL_TIMEOUT_MS = 120_000; + +async function runTreasuresStock( + opts: Record, + json: boolean +): Promise { + const ticker = String(opts.ticker).trim().toUpperCase(); + const amountUsdc = + opts.amountUsdc !== undefined ? String(opts.amountUsdc) : undefined; + const amountShares = + opts.amountShares !== undefined ? String(opts.amountShares) : undefined; + + // Direction = which amount field you set. Requiring exactly one keeps the + // routing unambiguous: --amount-usdc means "spend this much USDC", which + // can only be a buy; --amount-shares means "sell this many shares", which + // can only be a sell. Sharing --amount-in across both would force a separate + // --side flag (and --side is already perp-only). + if ((amountUsdc !== undefined) === (amountShares !== undefined)) { + throw new CliError( + "Treasures needs exactly one of --amount-usdc (buy) or --amount-shares (sell).", + "VALIDATION_ERROR", + "e.g. `acp trade --ticker AAPL --amount-usdc 50` (buy) or " + + "`acp trade --ticker AAPL --amount-shares 0.1` (sell)." + ); + } + const isBuy = amountUsdc !== undefined; + + const slippageBps = + opts.slippageBps !== undefined + ? parseTreasuresSlippageBps(opts.slippageBps) + : DEFAULT_TREASURES_SLIPPAGE_BPS; + const protocol = + opts.protocol !== undefined + ? validateTreasuresProtocol(String(opts.protocol)) + : undefined; + const chainFilter = + opts.chain !== undefined + ? validateTreasuresChain(String(opts.chain)) + : undefined; + + const owner = getWalletAddress() as Address; + const ethWallet = owner.toLowerCase() as Address; + const provider = await createProviderAdapter(); + + // 1) Sign the ownership-proof canonical challenge. We use the EVM signer + // only — Sol legs aren't submittable from this CLI yet. The challenge + // includes an empty sol_wallet line, which the server hashes; if we later + // add Sol signing we'll also need to send sol_wallet in the request body. + const issuedAt = Math.floor(Date.now() / 1000); + const challenge = buildTreasuresChallenge({ issuedAt, ethWallet }); + progress(json, `Signing Treasures ownership proof (issued_at=${issuedAt})`); + const ethSig = await provider.signMessage(TREASURES_PROOF_CHAIN_ID, challenge); + + // 2) Request a quote. The server returns up to N signable legs; for a buy + // it's at most 2 (one per chain, best first), for a sell it can be more + // (every leg the planner needs across the holding to liquidate the requested + // share amount). Either way we sign and submit all of them. + const baseQuoteReq = { + ticker, + max_slippage_bps: slippageBps, + eth_wallet: ethWallet, + ownership_proof: { eth_signature: ethSig, issued_at: issuedAt }, + ...(protocol ? { protocol } : {}), + ...(chainFilter ? { chain: chainFilter } : {}), + }; + progress( + json, + isBuy + ? `Requesting buy quote: ${ticker} for ${amountUsdc} USDC @ ${slippageBps}bps` + : `Requesting sell quote: ${amountShares} ${ticker} shares @ ${slippageBps}bps` + ); + const quote: TreasuresQuoteResponse = isBuy + ? await treasuresQuoteBuy({ ...baseQuoteReq, amount_usdc: amountUsdc! }) + : await treasuresQuoteSell({ ...baseQuoteReq, amount_shares: amountShares! }); + + if (quote.quotes.length === 0) { + throw new CliError( + `Treasures returned no legs for ${ticker}.`, + "API_ERROR", + "Try a higher --slippage-bps or a different --protocol / --chain." + ); + } + progress( + json, + `Got quote ${quote.quote_id} (${quote.quotes.length} leg${quote.quotes.length === 1 ? "" : "s"}, expires_at=${quote.expires_at})` + ); + + // 3) Sign every leg. EVM legs are 1inch Fusion EIP-712 orders bound to + // mainnet (domain.chainId=1); we honor whatever the server returns rather + // than hard-coding 1, since a future Treasures expansion to other EVM + // chains would just change the domain. Sol legs need Ed25519 over the + // serialized VersionedTransaction — the current CLI keystore doesn't sign + // Sol payloads, so we fail loudly rather than silently dropping the leg + // (which would 400 on submit with `incomplete_submit` anyway). + const signedLegs: TreasuresSignedLeg[] = []; + for (const leg of quote.quotes) { + const signedPayloads = await Promise.all( + leg.signable_payloads.map((payload) => + signTreasuresPayload(provider, leg, payload) + ) + ); + signedLegs.push({ quote_index: leg.quote_index, signed_payloads: signedPayloads }); + } + progress(json, `Signed ${signedLegs.length} leg${signedLegs.length === 1 ? "" : "s"}, submitting`); + + // 4) Submit atomically. The server is idempotent on (quote_id, quote_index), + // so a network retry of /trade/submit won't double-broadcast. If the quote's + // snapshot expired between issue and submit we get 410 quote_stale — the + // user just re-runs the command (we don't retry transparently because the + // re-quote may come back with different legs/prices the user should see). + const submitRes = await treasuresTradeSubmit({ + quote_id: quote.quote_id, + signed: signedLegs, + }); + + // 5) Poll status until terminal. /trade/submit returns optimistically once + // each leg is broadcast (or queued for broadcast); the on-chain fill lands + // asynchronously. Polling is the only way to see the final filled_shares / + // filled_usdc and any per-leg failures. + progress(json, `Polling ${quote.quote_id} for fill`); + const finalStatus = await pollTreasuresStatus(quote.quote_id, json); + + outputResult(json, { + quote_id: quote.quote_id, + side: isBuy ? "buy" : "sell", + ticker, + aggregate_status: finalStatus.aggregate_status, + submit: submitRes, + legs: finalStatus.legs, + }); + + // `completed` is the only success; pollTreasuresStatus only returns terminal + // states, so anything else here is `partial_failed`/`all_failed`. The per-leg + // detail was just printed above — flip the exit code (rather than throw and + // re-print via the error path) so scripts can branch on a non-zero exit. + if (finalStatus.aggregate_status !== "completed") { + process.exitCode = 1; + progress( + json, + `Treasures quote ${quote.quote_id} ended ${finalStatus.aggregate_status}` + ); + } +} + +async function signTreasuresPayload( + provider: IEvmProviderAdapter, + leg: TreasuresQuoteLeg, + payload: TreasuresQuoteLeg["signable_payloads"][number] +): Promise { + if (payload.type === "evm_eip712_typed_data") { + const signature = await provider.signTypedData( + Number(payload.typed_data.domain.chainId), + payload.typed_data + ); + return { type: "evm_eip712_signature", signature }; + } + // Sol path: we'd need to deserialize the VersionedTransaction, Ed25519-sign + // the message bytes with the agent's Sol keypair, splice the signature into + // the tx, and re-serialize as base64. The CLI keystore is EVM-only today, + // so refuse rather than half-submit. + throw new CliError( + `Treasures returned a ${payload.type} leg (chain=${leg.chain}); ` + + "the CLI keystore can't sign Solana payloads yet.", + "VALIDATION_ERROR", + "Filter to EVM-only with `--chain eth`, or sign and submit Sol legs out-of-band." + ); +} + +async function pollTreasuresStatus( + quoteId: string, + json: boolean +): Promise { + const deadline = Date.now() + TREASURES_POLL_TIMEOUT_MS; + let lastAgg: string | undefined; + for (;;) { + const s = await treasuresQuoteStatus(quoteId); + if (s.aggregate_status !== "in_progress") return s; + if (s.aggregate_status !== lastAgg) { + progress(json, ` status=${s.aggregate_status} (cached=${s.is_cached})`); + lastAgg = s.aggregate_status; + } + // Stop only *after* a fresh check, so a fill that lands during the final + // sleep is still observed rather than misreported as a TIMEOUT. + if (Date.now() >= deadline) break; + await sleep(TREASURES_POLL_MS); + } + throw new CliError( + `Treasures quote ${quoteId} did not reach a terminal status within ${TREASURES_POLL_TIMEOUT_MS / 1000}s.`, + "TIMEOUT", + `Re-check later via GET /quote/${quoteId}/status (no auth required).` + ); +} + +// Guard the bps before it hits JSON.stringify — an un-validated Number() turns +// garbage into NaN, which serializes as `max_slippage_bps: null` and quietly +// drops the cap server-side. Require a non-negative whole number of bps. +function parseTreasuresSlippageBps(raw: unknown): number { + const n = Number(raw); + if (!Number.isInteger(n) || n < 0) { + throw new CliError( + `Invalid --slippage-bps: ${String(raw)}`, + "VALIDATION_ERROR", + "Pass a non-negative whole number of basis points, e.g. 300 (=3%)." + ); + } + return n; +} + +function validateTreasuresProtocol(s: string): "ondo" | "xstocks" { + const v = s.trim().toLowerCase(); + if (v === "ondo" || v === "xstocks") return v; + throw new CliError( + `Invalid --protocol: ${s}`, + "VALIDATION_ERROR", + "Use --protocol ondo or --protocol xstocks." + ); +} + +function validateTreasuresChain(s: string): "sol" | "eth" { + const v = s.trim().toLowerCase(); + if (v === "sol" || v === "eth") return v; + throw new CliError( + `Invalid --chain: ${s}`, + "VALIDATION_ERROR", + "Use --chain sol or --chain eth." + ); +} + // Auto-balance the HL sub-wallets. HL keeps perp (collateral) and spot USDC in // separate wallets; deposits land in perp. Before an order, if the wallet that // must fund it is short, move the shortfall over from the other wallet so an diff --git a/src/lib/treasures/client.ts b/src/lib/treasures/client.ts new file mode 100644 index 0000000..86c12b3 --- /dev/null +++ b/src/lib/treasures/client.ts @@ -0,0 +1,257 @@ +// Treasures Finance — tokenized stock buy/sell via the public B2B API. +// Topologically this is just a swap (USDC ↔ ERC-20 stock token), so it slots +// into `acp trade` next to BondingV5/LiFi swaps and Hyperliquid orders. +// +// The CLI is still a thin signer here: +// - sign the ownership-proof challenge so `/quote/*` will issue legs, +// - sign each returned EIP-712 1inch Fusion order, +// - POST the signed legs to `/trade/submit`, +// - poll `/quote/{id}/status` until terminal. +// Private keys never leave the keystore — every signature comes from the same +// keystore-backed adapter the rest of `acp trade` uses. +// +// Solana legs are not signed by this module — the CLI keystore is EVM-only +// today, so a Sol-chain quote can be requested for inspection but cannot be +// submitted from here. EVM is the only first-cut path. + +import { CliError } from "../errors"; + +const TREASURES_PROD_URL = "https://api.treasures.io/public/v1"; +const TREASURES_STAGING_URL = "https://staging-api.treasures.io/public/v1"; + +// Resolve which Treasures host to hit. Mirrors how ACP_SERVER_URL switches +// on IS_TESTNET elsewhere in the CLI. An override lets ops point at a +// non-prod instance without rebuilding. +export function getTreasuresBaseUrl(): string { + const override = process.env.TREASURES_API_URL; + if (override) return override.replace(/\/$/, ""); + const isTestnet = process.env.IS_TESTNET === "true"; + return isTestnet ? TREASURES_STAGING_URL : TREASURES_PROD_URL; +} + +// Canonical challenge: UTF-8, lines joined with "\n". +// line 1: literal "treasures-finance-quote-v1" +// line 2: issued_at (unix seconds, as decimal) +// line 3: sol_wallet, or "" if absent +// line 4: eth_wallet lowercased, or "" if absent +// "All-or-nothing per proof": if you signed with both wallets, every request +// that reuses that proof must include both wallets. A proof signed over +// {sol, eth} is not valid for an eth-only request — the empty-string line for +// the absent side differs, so the digest differs, and recovery fails. +export function buildChallenge(args: { + issuedAt: number; + solWallet?: string; + ethWallet?: string; +}): string { + return [ + "treasures-finance-quote-v1", + String(args.issuedAt), + args.solWallet ?? "", + (args.ethWallet ?? "").toLowerCase(), + ].join("\n"); +} + +// ───── Wire types (subset — only what the CLI actually consumes) ──────────── + +export type Chain = "sol" | "eth"; +export type Protocol = "ondo" | "xstocks"; + +export interface OwnershipProof { + eth_signature?: string; + sol_signature?: string; + issued_at: number; +} + +export interface QuoteBuyRequest { + ticker: string; + amount_usdc: string; + max_slippage_bps: number; + chain?: Chain; + protocol?: Protocol; + sol_wallet?: string; + eth_wallet?: string; + ownership_proof: OwnershipProof; +} + +export interface QuoteSellRequest { + ticker: string; + amount_shares: string; + max_slippage_bps: number; + chain?: Chain; + protocol?: Protocol; + sol_wallet?: string; + eth_wallet?: string; + ownership_proof: OwnershipProof; +} + +export interface SignableEvmTypedData { + type: "evm_eip712_typed_data"; + typed_data: { + domain: { chainId: number; verifyingContract: string; name?: string; version?: string }; + types: Record>; + primaryType: string; + message: Record; + }; +} + +export interface SignableSolanaTx { + type: "solana_versioned_tx"; + tx_base64: string; +} + +export type SignablePayload = SignableEvmTypedData | SignableSolanaTx; + +export interface QuoteLeg { + quote_index: number; + chain: Chain; + protocol: Protocol; + price_usdc_per_share: string; + // buy-only + estimated_output_shares?: string; + estimated_output_tokens?: string; + // sell-only + shares_consumed?: string; + tokens_consumed?: string; + estimated_output_usdc?: string; + cost_breakdown_bps: { + treasures_fee_bps: number; + dex_swap_fee_bps: number; + estimated_slippage_bps: number; + slippage_vs_tradfi_bps?: number; + }; + signable_payloads: SignablePayload[]; +} + +export interface QuoteResponse { + quote_id: string; + side: "buy" | "sell"; + ticker: string; + expires_at: number; + tradfi_reference: { price_usd: string; market_status: "open" | "closed"; as_of: number } | null; + quotes: QuoteLeg[]; + totals?: { shares_total: string; usdc_total_estimated: string }; // sell only +} + +export interface SignedEvmPayload { + type: "evm_eip712_signature"; + signature: string; +} + +export interface SignedSolanaPayload { + type: "solana_versioned_tx"; + signed_tx_base64: string; +} + +export type SignedPayload = SignedEvmPayload | SignedSolanaPayload; + +export interface SignedLeg { + quote_index: number; + signed_payloads: SignedPayload[]; +} + +export interface TradeSubmitRequest { + quote_id: string; + signed: SignedLeg[]; +} + +export interface LegResult { + quote_index: number; + trade_id: string; + status: "broadcast" | "broadcast_unknown" | "completed" | "failed" | "broadcast_failed"; + tx_hash: string | null; + order_hash: string | null; + error_code: string | null; +} + +export interface TradeSubmitResponse { + results: LegResult[]; + failed_legs: Array<{ quote_index: number; error_code: "internal_error" }>; +} + +export type AggregateStatus = "in_progress" | "completed" | "partial_failed" | "all_failed"; + +export interface PublicLeg { + quote_index: number; + trade_id: string; + ticker: string; + chain: Chain; + protocol: Protocol; + side: "buy" | "sell"; + tx_hash: string | null; + order_hash: string | null; + status: "pending" | "completed" | "failed" | "broadcast_failed"; + error_code: string | null; + filled_shares: string; + filled_tokens: string; + filled_usdc: string; + last_synced_at: number; +} + +export interface QuoteStatusResponse { + quote_id: string; + aggregate_status: AggregateStatus; + is_cached: boolean; + legs: PublicLeg[]; +} + +// ───── HTTP ───────────────────────────────────────────────────────────────── + +// Same posture as the trade.ts proxy `post()`: refuse plaintext for any +// endpoint that returns calldata-to-sign or accepts signed actions. A +// downgraded hop could swap in a malicious order, and the keystore can't see +// what it's signing past raw bytes. +async function tfetch( + method: "GET" | "POST", + path: string, + body?: unknown +): Promise { + const base = getTreasuresBaseUrl(); + if (!/^https:\/\//i.test(base)) { + throw new CliError( + `Refusing to call a non-https Treasures endpoint: ${base}`, + "VALIDATION_ERROR", + "Set TREASURES_API_URL to an https:// URL or unset it to use the default." + ); + } + const res = await fetch(base + path, { + method, + headers: { "content-type": "application/json" }, + body: body !== undefined ? JSON.stringify(body) : undefined, + }); + // Read once as text so we can both surface non-JSON errors cleanly AND parse + // the structured error bodies the API emits (e.g. {error, reason, message}). + const raw = await res.text(); + if (!res.ok) { + let parsed: { error?: string; reason?: string; message?: string } | string; + try { + parsed = JSON.parse(raw) as typeof parsed; + } catch { + parsed = raw; + } + const detail = + typeof parsed === "string" + ? parsed + : [parsed.error, parsed.reason, parsed.message].filter(Boolean).join(": "); + throw new CliError( + `Treasures ${method} ${path} → ${res.status}: ${detail || res.statusText}`, + "API_ERROR" + ); + } + return JSON.parse(raw) as T; +} + +export function quoteBuy(req: QuoteBuyRequest): Promise { + return tfetch("POST", "/quote/buy", req); +} + +export function quoteSell(req: QuoteSellRequest): Promise { + return tfetch("POST", "/quote/sell", req); +} + +export function tradeSubmit(req: TradeSubmitRequest): Promise { + return tfetch("POST", "/trade/submit", req); +} + +export function quoteStatus(quoteId: string): Promise { + return tfetch("GET", `/quote/${encodeURIComponent(quoteId)}/status`); +} From 340694f4811ee2a14e931b4a89bf2ec6cf5ef049 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 3 Jun 2026 20:29:54 +0000 Subject: [PATCH 5/5] refactor(trade): unify stock asset flag on --token (drop --ticker) A stock symbol is now named with --token everywhere; the companion flag picks the venue: --side -> HL perp, --amount-usdc/--amount-shares -> Treasures tokenized stock (spot). Drops the separate --ticker flag so an agent picks the asset once and the mode second, matching the CLI's params-decide-the-venue model. https://claude.ai/code/session_01C56LaYyFW7iRxtdY5tY3uA --- src/commands/trade.ts | 52 +++++++++++++++++++++++++++++++------------ 1 file changed, 38 insertions(+), 14 deletions(-) diff --git a/src/commands/trade.ts b/src/commands/trade.ts index 503205b..a2bad62 100644 --- a/src/commands/trade.ts +++ b/src/commands/trade.ts @@ -5,13 +5,18 @@ // not a token conversion), so they use --side long|short. // // ── Intent routing (for LLM agents and humans) ────────────────────────────── -// --side long|short → Hyperliquid PERP (leveraged) +// --token --side long|short → Hyperliquid PERP (leveraged) +// --token --amount-usdc|--amount-shares → Treasures TOKENIZED STOCK (spot) // --chain-in 1337 --chain-out 1337 → Hyperliquid SPOT (order book) // --chain-in --chain-out 1337 → DEPOSIT USDC into Hyperliquid // --chain-in 1337 --chain-out → WITHDRAW USDC from Hyperliquid // --chain-in --chain-out → SWAP (DEX: BondingV5 / LiFi) // (no flags, in a terminal) → interactive picker (humans only) // +// One asset flag everywhere: --token names the symbol (BTC, AAPL, …); the +// companion flag picks the venue — --side → leveraged HL perp, --amount-usdc/ +// --amount-shares → Treasures spot tokenized stock. +// // `acp trade status` shows HL positions/margin/balances (read-only). // // Spot amount semantics mirror a swap: a BUY (--token-in usdc) spends --amount-in @@ -139,7 +144,8 @@ export function registerTradeCommands(program: Command): void { " --chain-in --chain-out 1337 → deposit USDC into Hyperliquid\n" + " --chain-in 1337 --chain-out 1337 → Hyperliquid spot order\n" + " --chain-in 1337 --chain-out → withdraw USDC from Hyperliquid\n" + - " --side long|short → Hyperliquid perp (leveraged; crypto, stocks, FX, commodities)\n" + + " --token --side long|short → Hyperliquid perp (leveraged; crypto, stocks, FX, commodities)\n" + + " --token --amount-usdc|-shares → Treasures tokenized stock (spot buy/sell)\n" + " (no flags, in a terminal) → interactive picker\n" + "\nExamples:\n" + " acp trade --token-in usdc --chain-in 8453 --amount-in 50 --token-out virtual --chain-out 8453\n" + @@ -149,6 +155,8 @@ export function registerTradeCommands(program: Command): void { " acp trade --token-in PURR --chain-in 1337 --amount-in 50 --token-out usdc --chain-out 1337 # spot sell\n" + " acp trade --token-in usdc --chain-in 1337 --amount-in 25 --token-out usdc --chain-out 42161 # withdraw\n" + " acp trade --side long --token BTC --size 0.01 --leverage 5\n" + + " acp trade --token AAPL --amount-usdc 50 # buy tokenized AAPL with 50 USDC (Treasures)\n" + + " acp trade --token AAPL --amount-shares 0.1 # sell 0.1 tokenized AAPL shares (Treasures)\n" + " acp trade status\n" ) // -- Swap / deposit / HL spot / HL withdraw (token-pair shape) -------- @@ -164,14 +172,18 @@ export function registerTradeCommands(program: Command): void { .option("--post-only", "HL post-only (Alo) limit order; rejects if it crosses", false) .option("--slippage ", "HL market-order slippage as a percent (default 5)", "5") // -- Treasures tokenized stock (USDC ↔ stock token swap) ------------- - .option("--ticker ", "Tokenized stock ticker, e.g. AAPL (routes via Treasures)") - .option("--amount-usdc ", "USDC to spend on a Treasures buy") - .option("--amount-shares ", "Shares to liquidate on a Treasures sell") + // The asset is named with --token (below); these flags pick buy vs sell. + .option("--amount-usdc ", "USDC to spend on a Treasures tokenized-stock buy (with --token)") + .option("--amount-shares ", "Shares to liquidate on a Treasures tokenized-stock sell (with --token)") .option("--protocol ", "Treasures protocol filter: ondo or xstocks") .option("--chain ", "Treasures chain filter: sol or eth") // -- Hyperliquid perp (position shape) ------------------------------- .option("--side ", "Perp side: long or short") - .option("--token ", "Perp market symbol — crypto, equity/stock, FX, or commodity (e.g. BTC, ETH, SOL)") + .option( + "--token ", + "Asset symbol. With --side → HL perp (crypto/stock/FX/commodity, e.g. BTC). " + + "With --amount-usdc/--amount-shares → Treasures tokenized stock (e.g. AAPL)" + ) .option("--size ", "Perp order size in token units") .option("--leverage ", "Set leverage for this token before a perp order") .option("--isolated", "Use isolated margin when setting leverage", false) @@ -265,12 +277,16 @@ function isPerpSide(side: string): boolean { } function detectIntent(opts: Record, json: boolean): Intent { - // --ticker is the unambiguous Treasures (tokenized stock) signal. It wins - // over every other route — none of the swap/HL flags carry stock tickers, - // so seeing one means the user wants `/quote/buy` or `/quote/sell` and - // nothing else can match. Treasures fills settle a tokenized stock token + // --amount-usdc / --amount-shares are Treasures-exclusive (swaps use + // --amount-in, perps use --size), so either one is the unambiguous tokenized + // -stock signal and wins over every other route — it means the user wants + // `/quote/buy` or `/quote/sell` and nothing else can match. The asset is + // named with --token (shared with perps); --side selects a leveraged perp, + // an --amount-* selects Treasures spot, which settles a stock-token ERC-20 // into the wallet (not a position), so it sits next to swaps shape-wise. - if (opts.ticker !== undefined) return "treasures-stock"; + if (opts.amountUsdc !== undefined || opts.amountShares !== undefined) { + return "treasures-stock"; + } const side = typeof opts.side === "string" ? opts.side.toLowerCase() : undefined; // --side is the unambiguous perp signal. If it's present it MUST be long or @@ -725,7 +741,15 @@ async function runTreasuresStock( opts: Record, json: boolean ): Promise { - const ticker = String(opts.ticker).trim().toUpperCase(); + if (opts.token === undefined) { + throw new CliError( + "A Treasures tokenized-stock trade needs --token .", + "VALIDATION_ERROR", + "e.g. `acp trade --token AAPL --amount-usdc 50` (buy) or " + + "`acp trade --token AAPL --amount-shares 0.1` (sell)." + ); + } + const ticker = String(opts.token).trim().toUpperCase(); const amountUsdc = opts.amountUsdc !== undefined ? String(opts.amountUsdc) : undefined; const amountShares = @@ -740,8 +764,8 @@ async function runTreasuresStock( throw new CliError( "Treasures needs exactly one of --amount-usdc (buy) or --amount-shares (sell).", "VALIDATION_ERROR", - "e.g. `acp trade --ticker AAPL --amount-usdc 50` (buy) or " + - "`acp trade --ticker AAPL --amount-shares 0.1` (sell)." + "e.g. `acp trade --token AAPL --amount-usdc 50` (buy) or " + + "`acp trade --token AAPL --amount-shares 0.1` (sell)." ); } const isBuy = amountUsdc !== undefined;