diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2fc54cf..d551b3a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -35,3 +35,11 @@ jobs: - run: bun run typecheck - run: bun test + + - name: Validate plugin and desktop browser build + run: | + export GLOOMBERB_HOME="$RUNNER_TEMP/hyperliquid-profile" + mkdir -p "$GLOOMBERB_HOME" + echo "{\"dataDir\":\"$GLOOMBERB_HOME\",\"onboardingComplete\":true}" > "$GLOOMBERB_HOME/config.json" + bun "$RUNNER_TEMP/gloomberb/src/cli/entry.ts" plugin link "$GITHUB_WORKSPACE" + bun "$RUNNER_TEMP/gloomberb/src/cli/entry.ts" plugin doctor "$GITHUB_WORKSPACE" diff --git a/README.md b/README.md index 77b383b..764de9c 100644 --- a/README.md +++ b/README.md @@ -1,11 +1,63 @@ # Hyperliquid for Gloom -Perpetual futures on Hyperliquid inside Gloom. Work in progress. +A live perpetuals workspace for the terminal and desktop app: the default +Hyperliquid dex and every active HIP-3 dex, market charts and books, funding +and open interest, and self-custody order entry. + +Private development build for GLO-217. No registry listing or release. + +| Command | View | +| --- | --- | +| `HLP` | Market board | +| `HLP BTC`, `HLP TSLA`, `HLP xyz:TSLA` | Market, chart, book and ticket | +| `HLM BTC` | Separate market pane | +| `HLB BTC` | Separate order book | +| `HLA` | Positions, orders, fills, funding and balances | +| `HLF` | Funding and OI analytics | +| `HLS` | Watch-only account or wallet connection | + +The command bar also offers **Open Hyperliquid trading workspace** for a +docked market and account layout, and ticker actions can open a matching perp. +`HLP` is the plugin command. The host reserves `PERP` for market history and +`HL` for Help. + +```sh +gloomberb plugin link /path/to/gloom-hyperliquid +gloomberb plugin doctor hyperliquid +gloomberb fn HLP --json +gloomberb fn HLP xyz:TSLA --json +gloomberb fn HLP --network testnet --json +``` + +Market data and watch-only accounts need no key. Trading uses a local API wallet +approved by your browser wallet; the plugin never asks for a seed phrase. +See [connection, fees, risks and methodology](docs/hyperliquid.md). + +The official build charges Gloom's **0.1% builder fee per mainnet fill**, +in addition to exchange fees. The main wallet approves this fee before trading. +Testnet always skips it. + +[Headless connection and trading](docs/headless-trading.md) covers secure key +import, scripted approval, order preview, placement, cancellation and closing. + +## Development ```sh -gloomberb install gloom-sh/gloom-hyperliquid +bun install +ln -s /path/to/gloomberb node_modules/gloomberb +ln -s /path/to/gloomberb/node_modules/react node_modules/react +bun run typecheck +bun test ``` +The host and React are peers, linked exactly as Gloom's installer links them. +The desktop browser entry delegates account operations to the native capability; +market and account subscriptions share one WebSocket per network. + +Development verification uses read-only mainnet and throwaway testnet keys only. +See the [scope and verification ledger](docs/verification.md) for tested paths, +live evidence and remaining integration gaps. + ## License MIT diff --git a/bun.lock b/bun.lock new file mode 100644 index 0000000..1f8e859 --- /dev/null +++ b/bun.lock @@ -0,0 +1,86 @@ +{ + "lockfileVersion": 1, + "configVersion": 1, + "workspaces": { + "": { + "name": "gloom-hyperliquid", + "dependencies": { + "@nktkas/hyperliquid": "^0.33.3", + "decimal.js": "^10.6.0", + "viem": "^2.57.2", + }, + "devDependencies": { + "@types/bun": "^1.3.14", + "@types/react": "^19.2.7", + "typescript": "^5.9.3", + }, + "peerDependencies": { + "gloomberb": ">=0.15.0", + "react": ">=19", + }, + "optionalPeers": [ + "gloomberb", + "react", + ], + }, + }, + "packages": { + "@adraffy/ens-normalize": ["@adraffy/ens-normalize@1.11.1", "", {}, "sha512-nhCBV3quEgesuf7c7KYfperqSS14T8bYuvJ8PcLJp6znkZpFc0AuW4qBtr8eKVyPPe/8RSr7sglCWPU5eaxwKQ=="], + + "@nktkas/hyperliquid": ["@nktkas/hyperliquid@0.33.3", "", { "dependencies": { "@nktkas/rews": "^4.1.0", "@noble/hashes": "^2.2.0", "decimal.js": "^10.6.0", "valibot": "^1.4.2" } }, "sha512-fvnEw/2ejN14ZZVXA+nFQ9YgfFNjCI1yLliYyUboC6hr3Gwd/RwC4Ox90K4e8tkKWqkVpOcLRQ30p0nTeK4NBQ=="], + + "@nktkas/rews": ["@nktkas/rews@4.1.0", "", {}, "sha512-lPcTIPD2AE8JsJgnI+YGn9hzOhZHW3E0hrzDNUpyuVP5d1NERNz7EugR1IxW4Ly7QsM9/WmVeLif4olZsKVKjQ=="], + + "@noble/ciphers": ["@noble/ciphers@1.3.0", "", {}, "sha512-2I0gnIVPtfnMw9ee9h1dJG7tp81+8Ob3OJb3Mv37rx5L40/b0i7djjCVvGOVqc9AEIQyvyu1i6ypKdFw8R8gQw=="], + + "@noble/curves": ["@noble/curves@1.9.1", "", { "dependencies": { "@noble/hashes": "1.8.0" } }, "sha512-k11yZxZg+t+gWvBbIswW0yoJlu8cHOC7dhunwOzoWH/mXGBiYyR4YY6hAEK/3EUs4UpB8la1RfdRpeGsFHkWsA=="], + + "@noble/hashes": ["@noble/hashes@2.4.0", "", {}, "sha512-X5XaVWZIBCT7HHZGm5I7ZQXDwLG+bGXuSrMQAW+7Zvl87h1kmc1ZB1VSRJcpUfoUrGQp4Fkoxm5kZ+Ms+aW+eA=="], + + "@scure/base": ["@scure/base@1.2.6", "", {}, "sha512-g/nm5FgUa//MCj1gV09zTJTaM6KBAHqLN907YVQqf7zC49+DcO4B1so4ZX07Ef10Twr6nuqYEH9GEggFXA4Fmg=="], + + "@scure/bip32": ["@scure/bip32@1.7.0", "", { "dependencies": { "@noble/curves": "~1.9.0", "@noble/hashes": "~1.8.0", "@scure/base": "~1.2.5" } }, "sha512-E4FFX/N3f4B80AKWp5dP6ow+flD1LQZo/w8UnLGYZO674jS6YnYeepycOOksv+vLPSpgN35wgKgy+ybfTb2SMw=="], + + "@scure/bip39": ["@scure/bip39@1.6.0", "", { "dependencies": { "@noble/hashes": "~1.8.0", "@scure/base": "~1.2.5" } }, "sha512-+lF0BbLiJNwVlev4eKelw1WWLaiKXw7sSl8T6FvBlWkdX+94aGJ4o8XjUdlyhTCjd8c+B3KT3JfS8P0bLRNU6A=="], + + "@types/bun": ["@types/bun@1.4.2", "", { "dependencies": { "bun-types": "1.4.2" } }, "sha512-GimotNn7+ZV0uVArItBbriZsR1oNf0+WTzPkdcFrzShI7k2norL0uzEaJT8T33dWr7O/c9ZDuAFQrctKCi72oQ=="], + + "@types/node": ["@types/node@26.6.4", "", { "dependencies": { "undici-types": "~8.9.0" } }, "sha512-ldVPDCzj7fsaGZrLB0NuHuTvJcsNasysBAqMolr/cgxrLd1xbqxIr3XJiPnHHJUCxj5sNF1vnRj9aWnrVh5Jcg=="], + + "@types/react": ["@types/react@19.3.0", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-N0rFCuH9YoxG9/m61l9MfpJKfmLOVU0em7ipIz6TRgSSkvReLB9vL85GB+yr8Bs5leqpvg96JSwF4ZS1s4viQg=="], + + "abitype": ["abitype@1.2.3", "", { "peerDependencies": { "typescript": ">=5.0.4", "zod": "^3.22.0 || ^4.0.0" }, "optionalPeers": ["typescript", "zod"] }, "sha512-Ofer5QUnuUdTFsBRwARMoWKOH1ND5ehwYhJ3OJ/BQO+StkwQjHw0XyVh4vDttzHB7QOFhPHa/o413PJ82gU/Tg=="], + + "bun-types": ["bun-types@1.4.2", "", { "dependencies": { "@types/node": "*" } }, "sha512-bxV1FgK7yBIzjRe5zBozIM4Bem11ZJcCXSrjWRG3YWLt8yFDePu4cLjpebO8OvPeIE9trbyPF4fuj3Cia4Fj3w=="], + + "csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="], + + "decimal.js": ["decimal.js@10.6.0", "", {}, "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg=="], + + "eventemitter3": ["eventemitter3@5.0.1", "", {}, "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA=="], + + "isows": ["isows@1.0.7", "", { "peerDependencies": { "ws": "*" } }, "sha512-I1fSfDCZL5P0v33sVqeTDSpcstAg/N+wF5HS033mogOVIp4B+oHC7oOCsA3axAbBSGTJ8QubbNmnIRN/h8U7hg=="], + + "ox": ["ox@0.14.45", "", { "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" }, "optionalPeers": ["typescript"] }, "sha512-jpsQ+p0JZh9mKCxxzxz0d5qFHZZg2/O9xW18IpEC/dNXXPVqmDJ0c//9RMz0CILkbSGPA42+cbU0fC/ghwJ03w=="], + + "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], + + "undici-types": ["undici-types@8.9.0", "", {}, "sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg=="], + + "valibot": ["valibot@1.5.0", "", { "peerDependencies": { "typescript": ">=5" }, "optionalPeers": ["typescript"] }, "sha512-nil6AkP2TChWL43Z5uJ6GTxX01CUA+g8LWUM+N/rB9NBbkUMaUsi9PUNzlUPgoKASgmx9f7eGOYpJ04/fSa6FQ=="], + + "viem": ["viem@2.57.2", "", { "dependencies": { "@noble/curves": "1.9.1", "@noble/hashes": "1.8.0", "@scure/bip32": "1.7.0", "@scure/bip39": "1.6.0", "abitype": "1.2.3", "isows": "1.0.7", "ox": "0.14.45", "ws": "8.21.0" }, "peerDependencies": { "typescript": ">=5.0.4" }, "optionalPeers": ["typescript"] }, "sha512-ZbPvu+5fvRsbFh+OymDz0GyEySsSco8GWrEAiHM4ckhEY1vJTPy0tcLVkn0cs70dPiUOjLxkQBnfVyt9YkJ7cA=="], + + "ws": ["ws@8.21.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g=="], + + "@noble/curves/@noble/hashes": ["@noble/hashes@1.8.0", "", {}, "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A=="], + + "@scure/bip32/@noble/hashes": ["@noble/hashes@1.8.0", "", {}, "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A=="], + + "@scure/bip39/@noble/hashes": ["@noble/hashes@1.8.0", "", {}, "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A=="], + + "ox/@noble/hashes": ["@noble/hashes@1.8.0", "", {}, "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A=="], + + "viem/@noble/hashes": ["@noble/hashes@1.8.0", "", {}, "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A=="], + } +} diff --git a/docs/headless-trading.md b/docs/headless-trading.md new file mode 100644 index 0000000..41cfb5f --- /dev/null +++ b/docs/headless-trading.md @@ -0,0 +1,126 @@ +# Headless connection and trading + +These commands run in the native process on the machine holding the local API +wallet. Every command requires an explicit `--network mainnet|testnet`. Results +are JSON; rejected and unknown trading outcomes exit nonzero. This guide is for +the account owner. Development verification never submits mainnet signed requests. + +Use `gloomberb help hyperliquid` for the installed command reference. A custom +`GLOOMBERB_HOME` must contain a config whose `dataDir` points into that profile; +use the same profile for setup, trading and the app. + +## Import an approved API wallet + +Put an already approved API wallet's hexadecimal private key in a file owned by +your user with mode `0600`, inside a private directory. Do not put secrets in +shell arguments, shell history, environment variables, pane fields or JSON +capability requests. Symlinks and group/world-readable files are rejected. The +plugin never accepts a recovery phrase. + +```sh +gloomberb hyperliquid import-key --network testnet \ + --key-file /private/path/trading.key --address 0xYOUR_ACCOUNT_ADDRESS \ + --yes --acknowledge-risk --eligible +``` + +Alternatively pipe from a trusted secret source to `--key-stdin`. Do not use a +literal `echo PRIVATE_KEY` command. Interactive stdin is refused because it +would echo. Import validates the API-wallet role, account and expiry against the +selected exchange. A main-wallet key cannot be imported as a trading key. + +## Approve from a script without a browser wallet + +`connect-key` reads the **main wallet's key**, generates a separate API wallet +locally, and signs its approval. The main key is kept in memory only and is not +stored or sent to the desktop renderer. The API key is saved using the normal +OS keychain/owner-only-file storage. Run only with your own fresh wallet and +read Hyperliquid's terms first. + +```sh +gloomberb hyperliquid connect-key --network testnet \ + --key-file /private/path/main-wallet.key \ + --yes --acknowledge-risk --eligible +``` + +On mainnet this also asks the main wallet to approve Gloom's **0.1% builder fee** +(maxFeeRate `0.1%`, f=100), in addition to exchange fees, for +`0x84085f25eDDdFc86D8b66343f97D68e4D5Bd1096`. The command prints this disclosure +before signing. Testnet skips builder approval and fees. It never signs as the +builder. Region lookup and restricted-person/risk checks still apply. Failed or +uncertain approval is not retried automatically; inspect status before retrying. + +For a browser wallet on another machine, keep this command running: + +```sh +gloomberb hyperliquid connect --network testnet --approval-port 8765 \ + --yes --acknowledge-risk --eligible +``` + +Forward `ssh -N -L 8765:127.0.0.1:8765 server` and open the full printed token URL +in the local browser. The server binds only to `127.0.0.1`. `wallet --yes` opens +management for an existing connection. Ctrl+C closes the short-lived page. + +## Preview, place, cancel and close + +Inspect the local public connection and account first: + +```sh +gloomberb hyperliquid status --network testnet +gloomberb hyperliquid account --network testnet +``` + +Preview a small order using a price you select from the live market. The example +price is deliberately illustrative; replace it with an appropriate price and +review the output's notional, margin, fees, warnings and errors before sending. +A post-only order can still fill after it rests. Hyperliquid generally requires +at least $10 notional; rounding may put an exact $10 request below the minimum. + +```sh +gloomberb hyperliquid preview --network testnet --market BTC \ + --side buy --kind limit --price 10000 --size 20 --unit usd \ + --tif Alo --leverage 1 + +gloomberb hyperliquid order --network testnet --market BTC \ + --side buy --kind limit --price 10000 --size 20 --unit usd \ + --tif Alo --leverage 1 --address 0xYOUR_ACCOUNT_ADDRESS \ + --client-id trial-entry-001 --yes +``` + +The account address must match the local connection. Use a stable, unique client +intent ID per action. Reuse the **same** ID to reconcile an uncertain response; +do not invent another ID or blindly repeat a command after timeout. An accepted +response does not mean a full fill. Inspect its individual statuses and the +account's Orders/Fills. The service never retries signed requests automatically. + +Cancel the returned numeric order ID (replace `123456`): + +```sh +gloomberb hyperliquid cancel --network testnet --oid 123456 \ + --address 0xYOUR_ACCOUNT_ADDRESS --client-id trial-cancel-001 --yes +``` + +`--all --market BTC` explicitly cancels all open BTC orders; `--all` cancels all +open orders. For an actual position, close with a capped IOC order: + +```sh +gloomberb hyperliquid close --network testnet --market BTC --kind market \ + --percent 100 --slippage-bps 50 --address 0xYOUR_ACCOUNT_ADDRESS \ + --client-id trial-close-001 --yes +``` + +Closing is reduce-only. A slippage cap does not guarantee a fill; inspect the +remaining position afterward. A limit close needs `--kind limit --price PRICE`. +For the owner's later real round trip, change the network only after reviewing +the builder fee, account, market, price and amount; these examples themselves +were not executed on mainnet. The CLI covers market/limit entry and management; +conditional orders, scale and TWAP are available in the pane ticket. + +## Disconnect + +```sh +gloomberb hyperliquid disconnect --network testnet --yes +``` + +This deletes the local API-wallet credential. It does not revoke the exchange +approval; use wallet management to revoke with the main wallet before deleting +local access. Never use a leaked/reused seed or any address derived from it. diff --git a/docs/hyperliquid.md b/docs/hyperliquid.md new file mode 100644 index 0000000..557c518 --- /dev/null +++ b/docs/hyperliquid.md @@ -0,0 +1,193 @@ +# Hyperliquid perpetuals + +`HLP` opens all active markets. A market argument opens its chart, +book, trades and ticket. A fully qualified name such as `xyz:TSLA` identifies one +dex unambiguously. A plain ticker prefers the default crypto market, then the most +liquid matching builder market. Delisted markets are excluded without changing +their exchange asset IDs. + +## Market numbers + +| Number | Definition | +| --- | --- | +| Mark | Hyperliquid's mark price, used for margin and unrealized PnL | +| Oracle | The exchange-reported reference price for that specific contract | +| Mid | Best bid/ask midpoint, kept separate from the mark | +| Premium | `mark / oracle - 1`, not a dated-futures basis | +| Rolling 24h | `mark / prevDayPx - 1`, not the change since UTC midnight | +| Current / 1h funding | Current hourly funding rate; positive means longs pay shorts | +| Current / 8h funding | Hourly rate multiplied by eight, not a payment interval | +| Simple APR | Hourly rate multiplied by 8,760, without compounding or a forecast | +| Historical funding | Server-observed funding from Gloom Cloud, separate from account paid-funding ledger | +| OI coin | Open interest in units of the contract | +| OI USD | OI coin multiplied by mark at the observation time | +| 24h volume | Exchange-reported notional traded volume | +| OI / volume | OI USD divided by 24h notional volume | + +Hyperliquid pays funding hourly. The countdown points to the next UTC hour; +rates can change before that payment. HIP-3 funding multipliers and contract +rules can differ. The market information view includes available margin tiers, +collateral, maximum leverage and isolated-only restrictions. Price precision uses +Hyperliquid's five significant figures and `6 - szDecimals` decimal limit; +integer prices are exempt from the significant-figure limit. Size rounds down +to `szDecimals`. + +**Predicted funding** is Hyperliquid's `predictedFundings` report of rates at +named venues. The reported interval is retained, and 8h/APR values normalize +that interval. The plugin does not connect to those venues or claim that these +are independently verified executable spreads. + +**Historical funding, OI and premium** come from Gloom Cloud's +`/cloud/perps/history` and `/cloud/perps/rankings`. History starts when the server +began recording each market, with gaps retained. OI changes keep coin and USD +changes separate; price moves alone can change USD OI. Full history requires Pro. +The plugin keeps no local historical database. Live charts and current prices +continue directly from Hyperliquid when cloud history is unavailable. + +The current host plugin API cannot forward its authenticated Cloud session to +these new routes. Anonymous ranking previews are supported where deployed; +history shows an explicit host-integration unavailable state. A supported host +transport can be injected when that API is exposed. Signing into Gloom alone +does not fix the missing transport in this host version. + +**Cash comparisons** use the host's existing quote API for a known underlying +equity or index. A dated cash close is preferred outside its regular session; +otherwise the last reported cash price is shown with its observation time. Only +matching USD quotes are compared. These observations can have different clocks +from the 24/7 perp; the label and date stay visible. An unavailable host quote +does not cause a scrape or a substitute price. + +Asset classes follow the exchange's contract categories, with conservative +fallbacks. Private-company and pre-IPO contracts do not acquire a fictitious +listed-equity price. Every dex retains its own collateral identity: USDC, USDH, +USDE and USDT0 are not pooled as if they were the same token. + +## Connections and keys + +Market data works without an account. **Watch-only** accepts a public address and +shows its positions, orders, fills and balances without a key or region gate. + +**Connect wallet** generates a local trading API wallet. A short-lived page at +`127.0.0.1` lists injected browser wallets through EIP-6963 and the legacy +EIP-1193 interface. The browser wallet signs the trading approval directly with +Hyperliquid. Only the API wallet's public address reaches the page; its private +key stays in the native process. The approval lasts up to 90 days and the pane +shows its expiry. An expired approval requires connection again. + +The [native headless commands](headless-trading.md) import an already approved +API wallet key and account address from an owner-only file or piped stdin. +There is no key field in the pane. The import command rejects main-wallet keys; +a separate explicit `connect-key` command lets the owner approve a generated +API wallet from a script without storing the main-wallet key. Never use a seed +phrase. An imported key must be recognized as approved for that account. + +Storage first uses `Bun.secrets`, backed by the operating system's credential +store. When it is unavailable, the key is stored at +`/hyperliquid//api-wallet.key` with permissions +`0600`, inside a `0700` directory. Public connection metadata, a monotonic nonce +and order reconciliation records live beside it. The desktop renderer receives +public status and account state over the native capability bridge, never a +stored private key. Credentials are never placed in a shared layout. + +**Disconnect** removes the local credential and connection metadata. To revoke +the on-chain approval as well, use **Wallet** before disconnecting and sign +**Revoke trading approval** in the main wallet. Local deletion cannot revoke an +on-chain approval by itself. Keep machine access and backups secure: an API +wallet cannot withdraw, but it can trade and lose collateral. + +### Headless host and SSH tunnel + +Set **Wallet approval port** to an unused port, for example `8765`, in plugin +settings. The default `0` selects a random port. The native service also accepts +`GLOOM_HYPERLIQUID_APPROVAL_PORT`. The wallet pane always shows the full URL, +including its random token, even if a browser cannot be opened on the host. + +```sh +ssh -N -L 8765:127.0.0.1:8765 your-server +``` + +Open the displayed `http://127.0.0.1:8765/` URL in the local machine's +wallet browser. Forward the same port on both ends. The page binds only to +`127.0.0.1`, rejects unexpected hosts/origins, and expires after ten minutes. +The token grants access to that temporary local page; do not share it. + +## Funds and collateral + +Deposits, withdrawals, collateral transfers and account-mode changes are signed +by the main browser wallet, not by the API wallet. The page supports USDC +deposits through Hyperliquid's Arbitrum bridge, withdrawals to the connected +wallet, spot/perp USDC transfers and named-dex collateral transfers. It does +not silently swap collateral tokens. The bridge's minimum deposit and withdrawal +fee are disclosed before signing. + +Standard accounts keep collateral per dex. Unified accounts share eligible +collateral by token; the account pane names the detected mode. Opening new +positions in portfolio-margin mode is currently blocked because its risk-weighted +collateral requires an exchange-specific margin solver. Existing positions and +reduce-only management remain available. + +## Orders and fees + +Market orders are IOC limits with a configurable slippage cap; a fill is not +guaranteed. Limit orders support GTC, post-only ALO and IOC. Stop and take-profit +orders support market or limit execution. Attached TP/SL uses `normalTpsl`; +position protection uses `positionTpsl`. TWAP and scale orders retain their own +size and duration validation. A partial fill or a rejected child order remains +an exchange outcome to inspect, not a successful full execution. + +Ticket estimates include size rounding, margin, maker/taker fees and visible +book slippage. Estimated liquidation is shown only where the isolated-position +calculation is supported; existing positions use the exchange's liquidation +price. A cross-margin account has shared risk and is not represented by a +single-position liquidation guess. + +The official Gloom builder fee is **0.1% (10 basis points) per perp fill**, in +addition to exchange fees. The address and `f = 100` live in +`src/trading/builder.ts`. The official builder is +`0x84085f25eDDdFc86D8b66343f97D68e4D5Bd1096`. Invalid, placeholder and zero +addresses disable both the fee and its approval. Testnet always skips builder +fees. The approval page discloses +the fee before the main wallet signs a maximum **0.1%** approval, the ticket says +**including Gloom's 0.1% builder fee**, and the wallet page offers revocation. +Hyperliquid's native TWAP action does not accept builder fees. Mainnet TWAP +therefore runs locally as capped IOC slices, each with the same builder fee. +Keep Gloom running: shutdown or restart pauses remaining slices, and resuming +requires an explicit action. Unknown execution outcomes pause the schedule and block resumption; +inspect Orders and Fills, then cancel that uncertain schedule before creating a +new intent for any remaining quantity. Cancel stops future slices but cannot undo one +already submitted. Testnet uses the venue's native TWAP. The ticket discloses +this difference before submission. + +Order confirmations are configurable. Size and distance-from-mark guards require +explicit confirmation. Stable client order IDs, a persisted monotonic nonce and +an intent journal protect submissions. A timeout is an unknown outcome: the +plugin queries order status and never automatically resends a signed request. + +## Regions and risk + +Trading requires a first-run risk and restricted-person acknowledgement plus a +recent region lookup. A failed lookup blocks trading and leaves data available. +The jurisdiction policy is centralized in `src/trading/regions.ts`. Country-only +location cannot establish Ontario or sanctioned subregions, so the initial +policy blocks Canada and Ukraine conservatively pending legal guidance, along +with the United States and configured sanctioned jurisdictions. Citizenship and +entity restrictions also require the user's attestation; an IP check alone does +not establish eligibility. Do not use a VPN to evade restrictions. + +Leverage can liquidate collateral. Exchange, oracle, deployer, bridge, network, +token and local-key risks remain with the user. HIP-3 liquidity and oracle +quality vary widely. Minimum volume/OI filters help identify liquidity but do +not guarantee executable prices or protect against losses. + +## Network access + +Historical analytics use `api.gloom.sh`. Live data and signed exchange actions go directly to `api.hyperliquid.xyz` or +`api.hyperliquid-testnet.xyz`; region checks use `www.cloudflare.com`. The local +approval page is served only on `127.0.0.1`. Browser wallets use their own RPC +for Arbitrum transactions. The plugin has no analytics SDK, custodian, +third-party authentication service or required API key. WalletConnect is not +included; use an injected EIP-1193/EIP-6963 wallet. + +Reference: [Hyperliquid API documentation](https://hyperliquid.gitbook.io/hyperliquid-docs/for-developers/api), +[funding](https://hyperliquid.gitbook.io/hyperliquid-docs/trading/funding), +[terms](https://app.hyperliquid.xyz/terms). diff --git a/docs/implementation.md b/docs/implementation.md new file mode 100644 index 0000000..e659196 --- /dev/null +++ b/docs/implementation.md @@ -0,0 +1,40 @@ +# Hyperliquid implementation + +Tracking: GLO-217. The plugin remains private and is not registered or released. + +## Integration boundary + +`HLP [market]` opens the market board or a market. `PERP` belongs to the +built-in cloud history/evidence view; `HL` is the host's Help alias. +Plugin pane and template IDs use `hyperliquid-`. +`HLM`, `HLB`, `HLA`, `HLF`, and `HLS` open the market, book, account, analytics, +and connection panes. The plugin owns the board and rankings; it does not depend +on the unmerged built-in function. + +## Architecture + +- One market service per network shares REST scheduling and a reference-counted + WebSocket across panes. Asset contexts supply marks; midpoints stay separate. + History and historical rankings use the Gloom Cloud perps REST contract. + The plugin does not record local history. +- Pure order and risk calculations are renderer-neutral. The maintained + `@nktkas/hyperliquid` SDK handles typed exchange actions and signing, with + `viem` for wallet interoperability. Both are MIT licensed. +- A native capability owns credentials and exchange submission. The desktop + renderer calls it over the host's capability bridge. Browser bundles do not + import native modules or store trading credentials. +- A short-lived loopback page presents the public API-wallet address to the + user's injected browser wallet. The user's main wallet signs approvals and + fund movements. The trading key stays on the machine. +- Mainnet and testnet credentials and acknowledgements are separate. The + official builder fee is 0.1% on mainnet, with the address in one constants + file. Invalid/zero addresses disable both fees and approval; testnet skips them. + Region failures block signing, never market data. + +## Verification constraints + +Development uses mainnet reads and testnet throwaway keys only. No mainnet +signed request, real funds, existing wallet, or Godel account is used. Host +execution uses a detached throwaway worktree and isolated HOME/profile. Test +evidence, screenshot review, scope coverage, and remaining decisions are recorded +in the final verification report. diff --git a/docs/verification.md b/docs/verification.md new file mode 100644 index 0000000..15af6c6 --- /dev/null +++ b/docs/verification.md @@ -0,0 +1,160 @@ +# Verification and scope ledger + +Private draft PR: https://github.com/gloom-sh/gloom-hyperliquid/pull/1 + +This ledger distinguishes implemented paths from live verification. No mainnet +signed request, real funds, existing wallet, seed phrase or Godel account was +used in development. The public builder address was checked read-only; its key +was never accessed. No paid API key or third-party project was created. + +## Scope + +| Area | Implementation | Verification / limit | +| --- | --- | --- | +| Live board | Done: default + all HIP-3 dexes, classes, favorites, position filter, search/sort and persisted liquidity filters | Mainnet and testnet live reads, shared WS soak and forced reconnect | +| Market view | Done: real candles/volume, live book aggregation/depth, tape, stats, position/order levels, market metadata | Live terminal and desktop screenshot renderers | +| Historical analytics | Partial, awaiting host/server integration: client/UI implements cloud history, funding extremes, OI changes and premium rankings; no local recorder | Platform PR #626 contract fixtures pass. Routes currently return 404 and public host API lacks authenticated Cloud requests. Live Pro history remains blocked by those integrations | +| Venue predictions / cash comparison | Done: exactly labelled Hyperliquid venue predictions, dated matching-USD host cash quotes and underlying action | Mainnet predictions live; cash projection checks; unavailable quotes stay blank | +| Watch-only account | Done: live balances, collateral identities, account mode, positions, orders, fills, history, funding, ledger, fees and TWAP | Public vault with open positions, terminal interactions and both screenshot sizes | +| Wallet connection | Done: injected browser wallets, local API-wallet approval, main-wallet fee approval, configurable loopback port/token and full URL, secure native import and scripted approval | Loopback security tests and scripted testnet validation. No live browser wallet or hardware-wallet signing in development | +| Orders and management | Done: market/limit, TIF, conditional orders, entry/position TP/SL, scale, reduce-only, leverage/margin, modify/cancel, partial close/reverse/close-all | Precision/signing/risk/request tests and funded testnet lifecycle (results recorded below) | +| TWAP | Done: testnet venue TWAP; mainnet local capped IOC slices with builder fee, pause/resume/cancel and persisted journal | Offline schedule/restart/uncertainty/race tests. Mainnet local execution intentionally not run live | +| Funds | Implemented; bridge execution partially verified: main-wallet deposit, withdraw3, spot/perp/dex transfer and unified-mode request | Typed-action checks. The single testnet withdraw3 attempt was rejected by the bridge with no debit. Real bridge deposit/withdrawal and browser signing remain for the owner | +| Safety | Done: region/attestation gates, confirmations, fat-finger warnings, per-market stale checks, exact sizes, buying power, idempotency/reconciliation and persistent nonces | Unit tests plus testnet. Portfolio-margin opening trades blocked; legal review of country policy remains an owner task | +| Host integration | Done: HLP primary with no PERP alias, separate panes, docked workspace, ticker action, catalog, headless and settings | Host collision search, fn/catalog, doctor and browser build | +| Documentation | Done: methodology, keys/storage, fees, risk, regions, headless commands and SSH | README, hyperliquid.md and headless-trading.md | + +## Integration gaps + +- `PERP` is reserved by the owner's GLO-220 coordination decision. It was absent + from the checked host revision `90fc2a0024c1d86f723d4138ee241abd0341e27a`. + A future action opening its history pane is a follow-up, not a plugin dependency. +- Gloom's public plugin API has no authenticated generic Cloud request method. + `httpFetch` does not forward the current session. The history client accepts a + supported injected transport; it never reads internal auth tokens. Until a + public bridge exists, Pro history cannot work even after signing into this host. +- The screenshot host discards ordinary footer info for external panes and may + classify setup forms/unavailable states as empty. Images are manually reviewed; + live/stale/auth/testnet footer behavior is checked in the actual terminal. +- Host password fields include their raw value in remote semantic metadata. + The plugin therefore has no renderer key field and no key-import capability; + import is native CLI only through protected file/stdin. +- Portfolio margin opening orders are blocked. Standard and unified modes have + separate collateral handling. WalletConnect is not included; injected + EIP-1193/EIP-6963 wallets and scripted owner approval are supported. + +## Security model + +API keys prefer `Bun.secrets` (OS credential store), with a `0600` file fallback +inside a `0700` directory at `/hyperliquid//`. Mainnet and +testnet are isolated. Disconnect removes local keys; exchange revocation needs +the main wallet. Main-wallet keys used by `connect-key` are never persisted. +The loopback page sees only public API-wallet details, binds to `127.0.0.1`, uses +an unguessable token, checks host/origin context and expires after ten minutes. + +Every signed trading intent gets a persistent record before submission. Unknown +outcomes are reconciled and never resent automatically. Signed CLI order-management commands require +explicit network, account, intent ID and confirmation. Local TWAP reuses these +checks for each slice and pauses across process restarts or uncertainty. + +The official mainnet builder fee is 0.1% in addition to exchange fees, addressed +only through `src/trading/builder.ts`; testnet and invalid addresses skip it. +Guard and main-wallet approval tests cover this distinction. + +## Evidence + +Screenshots are under +`~/.local/state/gloom-pm/hyperliquid/shots/`, at 1280x540 and 720x360 for board, +market/ticket, positions, setup and funding analytics. Public account reads use +`0x010461c14e146ac35fe42271bdc1134ee31c703a`; no credentials are needed. + +Live transport evidence is under +`~/.local/state/gloom-pm/hyperliquid/verification/market-{mainnet,testnet}-soak.jsonl`. +Earlier local-OI experimental artifacts in that directory are obsolete and are +not part of this implementation. Current history always comes from the cloud. + +The isolated host checkout is `/tmp/gloom-hyperliquid-host` and its test profile +is `/tmp/gloom-hyperliquid-home/profile`. The shared host checkout was not edited. + +Dependencies added: `@nktkas/hyperliquid` for the typed exchange protocol and +signing, `viem` for wallet interoperability and independently checked EIP-712, +and `decimal.js` for exact size/notional/scale arithmetic. Host and React stay +peer dependencies. + +## Validation results (2026-10-04) + +- `bun run typecheck`: passed. +- `bun test`: 74 passed, 343 assertions, zero failures across nine files. +- `gloomberb plugin doctor hyperliquid`: all checks passed, including the desktop + browser entry (343 KB at this revision). +- `fn HLP BTC --json`: real market and order book, with the explicit Cloud + history-unavailable result. `fn HLP --network testnet --limit 5 --json`: + complete, five rows, no errors. Catalog contains HLP/HLM/HLB/HLA/HLF/HLS. +- Mainnet transport soak: more than three minutes, 98 board and 134 detail + updates, nine mark changes; forced reconnect recovered in about 1.3 seconds. + Testnet: more than three minutes, 92 board and 130 detail updates, seven mark + changes; recovered in about 1.24 seconds. Sockets stopped after disposal. +- Terminal: HLP BTC, chart/book/ticket, keyboard wallet navigation, public + watch-only account with 177 positions, and mainnet/testnet switching verified. + A false account-change race discovered here was fixed and retested. Footer + status, TESTNET and watch-only indicators were visible. Bounded logs contained + no React hook/update-depth/listener warnings. Our tmux session was stopped. +- All 13 screenshots were opened and reviewed: five required views at both + sizes, plus compact ticket and both predicted-funding views. Cloud history + shows its true unavailable state; predicted-funding tables use live data. + +## Funded testnet evidence + +Only the generated throwaway account +`0x3dD36eb4677E32b42EA46dB1C936DDDBbfbf0459` was used. The owner supplied 499 mock +USDC after the unsigned faucet rejected the address for lacking mainnet history. +Testnet skips builder fees and builder approval. + +The service lifecycle passed approveAgent, ALO limit, modify, cancel, a BTC market +fill with attached TP/SL, position-level TP/SL, cancellation of both bracket +variants, 50% close, reverse and close-all. That phase ended with zero positions, +orders and TWAPs. The native CLI separately passed connect-key from a protected +file, resting order, cancellation, market fill and full close; every command +exited zero. Its account finished flat with 498.942231 mock USDC at that checkpoint. + +Public evidence lives under +`~/.local/state/gloom-pm/hyperliquid/verification/testnet-wallet-p80HTX/`: +`funded-evidence.json` and `cli-evidence.json`. These supersede the earlier +unfunded-only evidence. Generated testnet credentials remain in owner-only files +in the isolated test directories, outside the repository. No production key was +created or accessed. + +Mainnet signed behavior, browser/hardware-wallet signing and real bridge deposits +remain for the owner's later fresh-wallet round trip. The builder address is +already configured; no mainnet key needs to be supplied to development. A legal +review of the conservative region list and public Cloud auth transport remain +follow-ups. + + +Additional funded checks passed isolated margin add/remove, leverage changes, +two-leg scale orders and cancellation, standalone stop-limit/TP-limit orders and +cancellation, and native TWAP start/cancel (its first fill was closed). The local +persisted scheduler sent two real testnet IOC fills of 0.00018 BTC each with a +controlled test clock, paused after restart, required explicit resume, and then +closed the combined 0.00036 BTC position. This verifies scheduler execution and +restart behavior; it is not a full-duration mainnet execution test. + +The actual `import-key` command passed using the already-approved generated key +from an owner-only file, including exchange expiry verification. No secret was +printed. The single `withdraw3` request for 2 mock USDC to the same throwaway +address received the definitive exchange error `Error withdrawing from bridge`; +there was no debit or withdrawal ledger entry. It was not retried. + +Final independent REST and WebSocket checks at **2026-10-04 00:56:23 UTC** showed +**498.881492 mock USDC, zero positions, zero open orders and zero active TWAPs**. +All verification helpers exited. Our tmux sessions and screenshot processes were +stopped; no shared checkout or unrelated processes were changed. + +Additional public evidence files in the same owner-only verification directory: +`extra-evidence.json`, `local-twap-evidence.json`, `import-evidence.json`, +`withdraw-evidence.json`, and `final-evidence.json`. The older +`trading-security.md` summary has been updated to point to these funded results. + +2026-10-04 close regression: reproduced the idle-stop race with a 17-second testnet collateral delay; fixed CLI/pane close, close-all and exact percentage sizing; three full BTC closes, 50% close, limit close/cancel and delayed close-all during pane removal passed; final REST/WS checks show zero positions/orders/TWAPs (498.560973 mock USDC); typecheck, 84 tests/413 assertions and doctor pass; evidence: `close-matrix-fixed.json`, `closeall-regression.json`, `close-final-evidence.json` in the same verification directory. + +2026-10-04 CLI rate-limit regression: catalog failures/empty results retain their real cause; info reads make up to three HTTP 429 attempts with backoff and Retry-After, while signed sends remain single-attempt; offline tests cover recovery, exhaustion, cancellation and preview/order/close errors; typecheck, 95 tests/483 assertions and doctor pass; no UI files changed or mainnet signed requests sent. diff --git a/gloom.json b/gloom.json index 7915a70..246e3fb 100644 --- a/gloom.json +++ b/gloom.json @@ -14,11 +14,49 @@ "desktop" ], "hosts": [ - "api.hyperliquid.xyz" + "api.hyperliquid.xyz", + "api.hyperliquid-testnet.xyz", + "www.cloudflare.com", + "arb1.arbitrum.io", + "sepolia-rollup.arbitrum.io", + "127.0.0.1", + "app.hyperliquid.xyz", + "api.gloom.sh" ], "contributes": { "panes": [ - "hyperliquid" + "hyperliquid-board", + "hyperliquid-market", + "hyperliquid-book", + "hyperliquid-account", + "hyperliquid-analytics", + "hyperliquid-setup" + ], + "shortcuts": [ + { + "code": "HLP", + "name": "Hyperliquid" + }, + { + "code": "HLM", + "name": "Hyperliquid Market" + }, + { + "code": "HLB", + "name": "Hyperliquid Book" + }, + { + "code": "HLA", + "name": "Hyperliquid Account" + }, + { + "code": "HLF", + "name": "Hyperliquid Analytics" + }, + { + "code": "HLS", + "name": "Hyperliquid Wallet" + } ] }, "minGloom": "0.15.0" diff --git a/index.browser.tsx b/index.browser.tsx new file mode 100644 index 0000000..26f784c --- /dev/null +++ b/index.browser.tsx @@ -0,0 +1,2 @@ +export { hyperliquidPlugin as default, hyperliquidPlugin } from "./src/plugin"; +export { hyperliquidHeadless } from "./src/headless"; diff --git a/index.tsx b/index.tsx index b7c70ab..10cc255 100644 --- a/index.tsx +++ b/index.tsx @@ -1,14 +1,29 @@ -import type { GloomPlugin } from "gloomberb/types/plugin"; - -export const hyperliquidPlugin: GloomPlugin = { - id: "hyperliquid", - name: "Hyperliquid", - version: "0.1.0", - description: "Hyperliquid perpetual futures board and trading", - homepage: "https://github.com/gloom-sh/gloom-hyperliquid", - toggleable: true, - targets: ["cli", "tui", "desktop"], - hosts: ["api.hyperliquid.xyz"], +import { hyperliquidPlugin as shared, setupShared } from "./src/plugin"; +import { registerNativeCapabilities } from "./src/capability"; +import { hyperliquidCli } from "./src/native/cli"; +import { takeRuntimeDisposer } from "./src/runtime"; +let disposeNative: (() => Promise) | undefined; +export const hyperliquidPlugin = { + ...shared, + cliCommands: [hyperliquidCli], + setup(ctx: Parameters[0]) { + setupShared(ctx); + disposeNative = registerNativeCapabilities(ctx); + }, + dispose() { + const stop = disposeNative; + disposeNative = undefined; + const disposeMarket = takeRuntimeDisposer(); + // The host disposal API is synchronous. Keep transports until pending local + // execution is journaled, and consume failures without exposing payloads. + void Promise.resolve(stop?.()) + .finally(disposeMarket) + .catch(() => { + console.error( + "Hyperliquid shutdown was incomplete. Inspect local TWAP state before resuming.", + ); + }); + }, }; - +export { hyperliquidHeadless } from "./src/headless"; export default hyperliquidPlugin; diff --git a/package.json b/package.json index a15d7ee..53ed916 100644 --- a/package.json +++ b/package.json @@ -29,5 +29,11 @@ "@types/bun": "^1.3.14", "@types/react": "^19.2.7", "typescript": "^5.9.3" - } + }, + "dependencies": { + "@nktkas/hyperliquid": "^0.33.3", + "decimal.js": "^10.6.0", + "viem": "^2.57.2" + }, + "browser": "index.browser.tsx" } diff --git a/src/capability.ts b/src/capability.ts new file mode 100644 index 0000000..5e3608b --- /dev/null +++ b/src/capability.ts @@ -0,0 +1,321 @@ +import type { + CapabilityOperation, + PluginCapability, +} from "gloomberb/capabilities"; +import type { GloomPluginContext } from "gloomberb/types/plugin"; +import { join } from "node:path"; +import { createTradingService } from "./native/service"; +import { getLocalMarketService, MARKET_CAPABILITY } from "./runtime"; +import { readSettings, tradingSettings } from "./settings"; +import type { TradingOperation } from "./trading/types"; +import { + CANDLE_INTERVALS, + type MarketOptions, + type Network, +} from "./market/types"; +import { AccountStore } from "./trading/account"; +import type { Address } from "./trading/types"; + +function networkOf(input: Record): Network { + if (input.network !== "mainnet" && input.network !== "testnet") + throw new Error("Choose mainnet or testnet explicitly."); + return input.network; +} +function marketOf(input: Record): string { + if ( + typeof input.coin !== "string" || + input.coin.length < 1 || + input.coin.length > 128 || + /[\x00-\x1f\x7f]/.test(input.coin) + ) + throw new Error("A valid market is required."); + return input.coin; +} +function optionsOf(input: Record): MarketOptions { + const options = input.options as MarketOptions | undefined; + if (!options) return {}; + if (options.interval && !CANDLE_INTERVALS.includes(options.interval)) + throw new Error("Unsupported candle interval."); + if (options.nSigFigs != null && ![2, 3, 4, 5].includes(options.nSigFigs)) + throw new Error("Unsupported book precision."); + if (options.mantissa != null && ![1, 2, 5].includes(options.mantissa)) + throw new Error("Unsupported book aggregation."); + return { + interval: options.interval, + nSigFigs: options.nSigFigs, + mantissa: options.mantissa, + }; +} +export function registerNativeCapabilities( + ctx: GloomPluginContext, +): () => Promise { + const services = new Map>(); + const watches = new Map< + string, + { store: AccountStore; refs: number; release?: () => void } + >(); + const configValues = () => + Object.fromEntries( + ctx.configState.keys().map((key) => [key, ctx.configState.get(key)]), + ); + const trading = (network: Network) => { + let service = services.get(network); + if (!service) { + service = createTradingService({ + network, + dataDir: join(ctx.getConfig().dataDir, "hyperliquid"), + shared: getLocalMarketService(network), + settings: () => tradingSettings(configValues()), + approvalPort: () => readSettings(configValues()).approvalPort, + }); + services.set(network, service); + } + return service; + }; + const reads = new Set([ + "status", + "account", + "preview", + "connectionStatus", + "history", + "region", + ]); + const local = new Set([ + "watch", + "connect", + "import", + "disconnect", + "acknowledge", + "wallet", + ]); + // Key import is native CLI only: renderer and generic capability inputs may be recorded. + const operationNames: TradingOperation[] = [ + "status", + "watch", + "connect", + "disconnect", + "account", + "preview", + "submit", + "cancel", + "modify", + "leverage", + "margin", + "wallet", + "connectionStatus", + "acknowledge", + "region", + "close", + "reverse", + "closeAll", + "twapCancel", + "twapResume", + "history", + ]; + const operations: Record = {}; + for (const name of operationNames) + operations[name] = { + kind: reads.has(name) ? "query" : "action", + rendererSafe: true, + handler: (input) => { + const payload = input as Record; + const network = networkOf(payload); + const { network: _network, ...value } = payload; + return trading(network).invoke(name, value); + }, + cli: { + summary: `Hyperliquid ${name}`, + inputShape: '{ network: "mainnet" | "testnet", ...parameters }', + outputShape: "Operation result", + formats: ["json"], + sideEffectLevel: reads.has(name) + ? "none" + : local.has(name) + ? "local-write" + : "external-trade", + safety: [ + "Signing requires a local trading key, current region eligibility, and risk acknowledgement.", + ], + }, + }; + operations.accountStream = { + kind: "stream", + rendererSafe: true, + subscribe(input, emit) { + const service = trading(networkOf(input as Record)); + const release = service.subscribe(() => emit(service.getSnapshot())); + emit(service.getSnapshot()); + return release; + }, + }; + const watch = (input: Record) => { + const network = networkOf(input); + if ( + typeof input.address !== "string" || + !/^0x[0-9a-fA-F]{40}$/.test(input.address) + ) + throw new Error("A valid watch-only address is required."); + const address = input.address as Address; + const key = `${network}:${address.toLowerCase()}`; + let entry = watches.get(key); + if (!entry) { + entry = { + store: new AccountStore(network, getLocalMarketService(network)), + refs: 0, + }; + watches.set(key, entry); + } + const snapshot = () => ({ + status: { + network, + address, + mode: "watch", + riskAcknowledged: false, + eligibleAcknowledged: false, + }, + account: entry!.store.getSnapshot() ?? null, + }); + return { entry, address, network, snapshot }; + }; + operations.watchSnapshot = { + kind: "query", + rendererSafe: true, + async handler(input) { + const { entry, address, network, snapshot } = watch( + input as Record, + ); + entry.refs++; + if (entry.refs === 1) + entry.release = getLocalMarketService(network).subscribe(() => {}); + try { + await entry.store.setAddress(address); + return snapshot(); + } finally { + if (--entry.refs === 0) { + entry.release?.(); + entry.release = undefined; + entry.store.stop(); + } + } + }, + }; + operations.watchStream = { + kind: "stream", + rendererSafe: true, + subscribe(input, emit) { + const { entry, address, network, snapshot } = watch( + input as Record, + ); + entry.refs++; + if (entry.refs === 1) + entry.release = getLocalMarketService(network).subscribe(() => {}); + const releaseListener = entry.store.subscribe(() => emit(snapshot())); + let active = true; + void entry.store + .setAddress(address) + .then(() => { + if (active) emit(snapshot()); + }) + .catch((error) => { + if (active) + emit({ + ...snapshot(), + error: error instanceof Error ? error.message : String(error), + }); + }); + emit(snapshot()); + return () => { + active = false; + releaseListener(); + if (--entry.refs === 0) { + entry.release?.(); + entry.release = undefined; + entry.store.stop(); + } + }; + }, + }; + ctx.registerCapability({ + id: "hyperliquid.trading", + name: "Hyperliquid Trading", + kind: "plugin-service", + operations, + }); + const marketCapability: PluginCapability = { + id: MARKET_CAPABILITY, + name: "Hyperliquid Markets", + kind: "plugin-service", + operations: { + boardSnapshot: { + kind: "query", + rendererSafe: true, + handler: async (input) => { + const service = getLocalMarketService(networkOf(input)); + await service.refresh(); + return service.getSnapshot(); + }, + }, + marketSnapshot: { + kind: "query", + rendererSafe: true, + handler: async (input) => { + const service = getLocalMarketService(networkOf(input)); + await service.refresh(); + const session = service.getMarket(marketOf(input), optionsOf(input)); + await session.refresh(); + return session.getSnapshot(); + }, + }, + boardStream: { + kind: "stream", + rendererSafe: true, + subscribe: (input, emit) => { + const service = getLocalMarketService(networkOf(input)); + const release = service.subscribe(() => emit(service.getSnapshot())); + emit(service.getSnapshot()); + return release; + }, + }, + marketStream: { + kind: "stream", + rendererSafe: true, + subscribe: (input, emit) => { + const session = getLocalMarketService(networkOf(input)).getMarket( + marketOf(input), + optionsOf(input), + ); + const release = session.subscribe(() => emit(session.getSnapshot())); + emit(session.getSnapshot()); + return release; + }, + }, + aggregation: { + kind: "action", + rendererSafe: true, + handler: (input) => { + getLocalMarketService(networkOf(input)).setBookAggregation( + marketOf(input), + optionsOf(input), + ); + return { ok: true }; + }, + }, + }, + }; + ctx.registerCapability(marketCapability); + return async () => { + for (const entry of watches.values()) { + entry.release?.(); + entry.store.stop(); + } + watches.clear(); + const stopping = [...services.values()].map((service) => service.dispose()); + services.clear(); + const results = await Promise.allSettled(stopping); + if (results.some((result) => result.status === "rejected")) { + throw new Error( + "Hyperliquid shutdown could not persist all local schedules. Inspect them before resuming.", + ); + } + }; +} diff --git a/src/cash.test.ts b/src/cash.test.ts new file mode 100644 index 0000000..79a4b9e --- /dev/null +++ b/src/cash.test.ts @@ -0,0 +1,37 @@ +import { expect, test } from "bun:test"; +import { projectCashReference } from "./cash"; +import type { Quote } from "gloomberb/types/financials"; + +test("cash comparison uses a dated completed close and keeps the observed session", () => { + const quote: Quote = { + symbol: "TSLA", + price: 255, + currency: "USD", + change: 5, + changePercent: 2, + lastUpdated: 1000, + regularClose: 250, + regularCloseSessionDate: "2026-10-02", + marketState: "CLOSED", + }; + expect(projectCashReference(quote, 260, "TSLA")).toMatchObject({ + price: 250, + sessionDate: "2026-10-02", + label: "Last cash close", + premium: 0.040000000000000036, + }); + expect( + projectCashReference( + { ...quote, regularCloseSessionDate: undefined }, + 260, + "TSLA", + )?.price, + ).toBe(255); + expect( + projectCashReference({ ...quote, currency: "EUR" }, 260, "TSLA"), + ).toBeNull(); + expect(projectCashReference(quote, 260, "AAPL")).toBeNull(); + expect( + projectCashReference({ ...quote, lastUpdated: 0 }, 260, "TSLA"), + ).toBeNull(); +}); diff --git a/src/cash.ts b/src/cash.ts new file mode 100644 index 0000000..1559521 --- /dev/null +++ b/src/cash.ts @@ -0,0 +1,59 @@ +import type { Quote } from "gloomberb/types/financials"; +import type { DataProvider } from "gloomberb/types/data-provider"; + +export interface CashReference { + symbol: string; + price: number; + asOf: number; + label: "Last cash close" | "Last cash price"; + premium: number; + currency: "USD"; + sessionDate?: string; + stale: boolean; +} + +/** A dated USD cash observation, never a currency conversion or proxy price. */ +export function projectCashReference( + quote: Quote, + mark: number, + symbol: string, +): CashReference | null { + if ( + quote.symbol.toUpperCase() !== symbol.toUpperCase() || + quote.currency !== "USD" || + quote.priceBasis === "percent-of-par" || + !(quote.lastUpdated > 0) || + !Number.isFinite(mark) || + mark <= 0 + ) + return null; + const hasDatedClose = + quote.marketState !== "REGULAR" && + Boolean(quote.regularCloseSessionDate?.match(/^\d{4}-\d{2}-\d{2}$/)) && + Number.isFinite(quote.regularClose) && + quote.regularClose! > 0; + const price = hasDatedClose ? quote.regularClose! : quote.price; + if (!Number.isFinite(price) || price <= 0) return null; + return { + symbol, + price, + asOf: quote.lastUpdated, + label: hasDatedClose ? "Last cash close" : "Last cash price", + premium: mark / price - 1, + currency: "USD", + ...(hasDatedClose ? { sessionDate: quote.regularCloseSessionDate } : {}), + stale: quote.stale === true, + }; +} + +export async function loadCashReference( + provider: Pick, + symbol: string, + mark: number, + signal?: AbortSignal, +): Promise { + signal?.throwIfAborted(); + const quote = await provider.getQuote(symbol); + signal?.throwIfAborted(); + return projectCashReference(quote, mark, symbol); +} diff --git a/src/headless.ts b/src/headless.ts new file mode 100644 index 0000000..af50c54 --- /dev/null +++ b/src/headless.ts @@ -0,0 +1,568 @@ +import type { + HeadlessPaneColumn, + HeadlessPaneDefinition, + HeadlessPaneOptionDef, +} from "gloomberb/types/plugin"; +import { getLocalMarketService } from "./runtime"; +import { resolveMarket } from "./market/normalize"; +import { + CANDLE_INTERVALS, + type AssetClass, + type CandleInterval, +} from "./market/types"; +import { AccountStore } from "./trading/account"; +import type { AccountSnapshot, Address, TradingStatus } from "./trading/types"; +import { configuredBuilder } from "./trading/builder"; +import { + getCloudPerpsClient, + RANKING_SECTIONS, + type CloudHistoryQuery, +} from "./market/cloud-history"; + +const numeric = (value: unknown) => + value == null + ? "--" + : Number(value).toLocaleString("en-US", { maximumFractionDigits: 6 }); +const percent = (value: unknown) => + value == null ? "--" : `${(Number(value) * 100).toFixed(4)}%`; +export const marketColumns: HeadlessPaneColumn[] = [ + { key: "coin", header: "Market" }, + { key: "dex", header: "Dex" }, + { key: "assetClass", header: "Class" }, + { key: "mark", header: "Mark", align: "right", format: numeric }, + { key: "oracle", header: "Oracle", align: "right", format: numeric }, + { key: "premium", header: "Premium", align: "right", format: percent }, + { key: "change24h", header: "Rolling 24h", align: "right", format: percent }, + { + key: "fundingHourly", + header: "Current / 1h", + align: "right", + format: percent, + }, + { key: "funding8h", header: "Current / 8h", align: "right", format: percent }, + { key: "fundingApr", header: "Simple APR", align: "right", format: percent }, + { key: "oiUsd", header: "OI USD", align: "right", format: numeric }, + { key: "oiCoin", header: "OI coin", align: "right", format: numeric }, + { + key: "volume24h", + header: "Volume 24h USD", + align: "right", + format: numeric, + }, + { key: "oiVolume", header: "OI / volume", align: "right", format: numeric }, + { key: "maxLeverage", header: "Max leverage", align: "right" }, + { key: "collateral", header: "Collateral" }, + { key: "onlyIsolated", header: "Isolated only" }, +]; +export const reportOptions: HeadlessPaneOptionDef[] = [ + { + key: "network", + type: "enum", + values: [{ value: "mainnet" }, { value: "testnet" }], + defaultValue: "mainnet", + description: "Exchange network", + settingKey: "network", + }, + { + key: "class", + type: "enum", + values: [ + "All", + "Stocks", + "Indices", + "Energy", + "Metals", + "FX", + "Crypto", + "Other", + ].map((value) => ({ value })), + defaultValue: "All", + description: "Asset class", + settingKey: "assetClass", + }, + { + key: "min-volume", + type: "integer", + minimum: 0, + description: "Minimum 24h notional volume in USD", + defaultValue: 0, + settingKey: "minVolumeUsd", + }, + { + key: "min-oi", + type: "integer", + minimum: 0, + description: "Minimum open interest in USD", + defaultValue: 0, + settingKey: "minOiUsd", + }, + { + key: "limit", + type: "integer", + minimum: 1, + maximum: 5000, + defaultValue: 100, + description: "Maximum markets", + }, + { + key: "interval", + type: "enum", + values: CANDLE_INTERVALS.map((value) => ({ value })), + defaultValue: "15m", + description: "Candle interval", + settingKey: "interval", + }, + { + key: "days", + type: "integer", + minimum: 1, + maximum: 3650, + defaultValue: 7, + description: "Pro history lookback in days", + }, + { + key: "resolution", + type: "enum", + values: ["auto", "minute", "hour", "day"].map((value) => ({ value })), + defaultValue: "auto", + description: "Pro history resolution", + }, + { + key: "tab", + type: "string", + description: "Initial pane tab", + settingKey: "defaultTab", + }, + { + key: "watch-address", + type: "string", + description: "Read-only account address for account views", + settingKey: "watchAddress", + }, +]; + +export const hyperliquidHeadless: HeadlessPaneDefinition<"bundle"> = { + shape: "bundle", + argument: { + kind: "free-text", + placeholder: "market", + optional: true, + description: "Native coin (BTC, xyz:TSLA) or underlying ticker (TSLA).", + }, + options: reportOptions, + discovery: { + id: "hyperliquid.perpetuals", + aliases: ["HLP"], + screenshotReadiness: "live-dom", + limitations: [ + "Durable analytics require Gloom Pro and a host with plugin cloud authentication.", + "Trading requires the native account service.", + ], + }, + async load(args, ctx) { + const network = args.options.network === "testnet" ? "testnet" : "mainnet"; + const service = getLocalMarketService(network); + ctx.signal.throwIfAborted(); + await service.refresh(); + ctx.signal.throwIfAborted(); + const board = service.getSnapshot(); + const query = typeof args.argument === "string" ? args.argument.trim() : ""; + if (query) { + const market = resolveMarket(board.markets, query); + if (!market) + return { + sections: [], + complete: false, + unavailableSymbols: [query], + errors: [ + `No active perpetual market matches ${query}.`, + ...(board.error ? [board.error] : []), + ], + }; + const session = service.getMarket(market.coin, { + interval: (args.options.interval as CandleInterval) ?? "15m", + }); + const [, history] = await Promise.all([ + session.refresh(), + getCloudPerpsClient(network).history(market.coin, { + days: Number(args.options.days ?? 7), + resolution: + (args.options.resolution as CloudHistoryQuery["resolution"]) ?? + "auto", + signal: ctx.signal, + }), + ]); + ctx.signal.throwIfAborted(); + const detail = session.getSnapshot(); + return { + sections: [ + { title: market.coin, columns: marketColumns, rows: [{ ...market }] }, + { + title: "Order book", + rows: [ + ...(detail.book?.bids ?? []).map((level) => ({ + side: "bid", + ...level, + })), + ...(detail.book?.asks ?? []).map((level) => ({ + side: "ask", + ...level, + })), + ], + columns: [ + { key: "side", header: "Side" }, + { key: "price", header: "Price", align: "right" }, + { key: "size", header: "Size", align: "right" }, + { key: "totalSize", header: "Cumulative", align: "right" }, + ], + }, + { + title: "Cloud server-observed funding history", + rows: (history.data?.funding ?? []).map((point) => ({ ...point })), + columns: [ + { + key: "time", + header: "UTC", + format: (value) => new Date(Number(value)).toISOString(), + }, + { + key: "rate", + header: "Historical rate", + align: "right", + format: percent, + }, + { + key: "intervalHours", + header: "Interval hours", + align: "right", + }, + { + key: "premium", + header: "Funding premium", + align: "right", + format: percent, + }, + ], + }, + { + title: "Cloud OI and premium history", + rows: (history.data?.points ?? []).map((point) => ({ ...point })), + columns: [ + { + key: "time", + header: "UTC", + format: (value) => new Date(Number(value)).toISOString(), + }, + { + key: "oiCoin", + header: "OI coin", + align: "right", + format: numeric, + }, + { + key: "oiUsd", + header: "OI USD", + align: "right", + format: numeric, + }, + { + key: "premium", + header: "Premium", + align: "right", + format: percent, + }, + { key: "resolution", header: "Resolution" }, + { key: "sampleCount", header: "Samples", align: "right" }, + ], + }, + ], + symbols: [market.coin], + complete: + !detail.error && + !board.error && + history.state === "ready" && + !history.data?.truncated, + errors: [board.error, detail.error, history.error].filter( + (error): error is string => Boolean(error), + ), + metadata: { + network, + asOf: board.asOf, + candleInterval: args.options.interval, + candles: detail.candles, + trades: detail.trades, + annotation: detail.annotation, + cloudHistory: history, + }, + }; + } + const rows = board.markets + .filter( + (market) => + (args.options.class === "All" || + !args.options.class || + market.assetClass === (args.options.class as AssetClass)) && + (market.volume24h ?? 0) >= Number(args.options["min-volume"] ?? 0) && + (market.oiUsd ?? 0) >= Number(args.options["min-oi"] ?? 0), + ) + .sort((a, b) => (b.volume24h ?? -1) - (a.volume24h ?? -1)) + .slice(0, Number(args.options.limit ?? 100)); + return { + sections: [ + { + title: network === "testnet" ? "TESTNET perpetuals" : "Perpetuals", + columns: marketColumns, + rows: rows.map((row) => ({ ...row })), + }, + ], + complete: !board.error, + errors: board.error ? [board.error] : [], + metadata: { + network, + asOf: board.asOf, + dexes: board.dexes, + fundingIntervalHours: 1, + annualization: "simple, hourly rate × 8760", + changeDefinition: "mark / prevDayPx - 1", + oiUsdDefinition: "open interest in coin × mark", + ratesAreFractions: true, + }, + }; + }, +}; + +export const analyticsHeadless: HeadlessPaneDefinition<"bundle"> = { + ...hyperliquidHeadless, + discovery: { id: "hyperliquid.analytics", screenshotReadiness: "live-dom" }, + async load(args, ctx) { + if (typeof args.argument === "string" && args.argument.trim()) + return hyperliquidHeadless.load(args, ctx); + const network = args.options.network === "testnet" ? "testnet" : "mainnet"; + const service = getLocalMarketService(network); + const [, rankings] = await Promise.all([ + service.refresh(), + getCloudPerpsClient(network).rankings({ signal: ctx.signal }), + ]); + ctx.signal.throwIfAborted(); + const snapshot = service.getSnapshot(); + const titles: Record<(typeof RANKING_SECTIONS)[number], string> = { + fundingPositive: "Cloud positive funding", + fundingNegative: "Cloud negative funding", + oiSurges: "Cloud OI surges", + premiumDislocations: "Cloud premium dislocations", + closedMarketDislocations: "Cloud closed-market dislocations", + }; + const columns: HeadlessPaneColumn[] = [ + { key: "coin", header: "Market" }, + { key: "assetClass", header: "Class" }, + { + key: "fundingRate", + header: "Funding rate", + align: "right", + format: percent, + }, + { key: "fundingIntervalHours", header: "Interval hours", align: "right" }, + { key: "fundingKind", header: "Funding kind" }, + ...marketColumns.filter((column) => + ["funding8h", "fundingApr", "oiUsd", "volume24h", "premium"].includes( + column.key, + ), + ), + { + key: "oiChange1h", + header: "OI coin 1h", + align: "right", + format: percent, + }, + { + key: "oiChange24h", + header: "OI coin 24h", + align: "right", + format: percent, + }, + { + key: "oiChange1hUsd", + header: "OI USD 1h", + align: "right", + format: percent, + }, + { + key: "oiChange24hUsd", + header: "OI USD 24h", + align: "right", + format: percent, + }, + { + key: "closedMarketPremium", + header: "Closed cash premium", + align: "right", + format: percent, + }, + ]; + return { + sections: [ + ...RANKING_SECTIONS.map((section) => ({ + title: titles[section], + columns, + rows: (rankings.data?.[section] ?? []) + .filter( + (market) => + (!args.options.class || + args.options.class === "All" || + market.assetClass === args.options.class) && + (market.volume24h ?? 0) >= + Number(args.options["min-volume"] ?? 0) && + (market.oiUsd ?? 0) >= Number(args.options["min-oi"] ?? 0), + ) + .slice(0, Number(args.options.limit ?? 100)) + .map((row) => ({ ...row })), + })), + { + title: "Predicted funding reported by Hyperliquid", + columns: [ + { key: "coin", header: "Market" }, + { key: "venue", header: "Reported venue" }, + { key: "rate", header: "Reported rate", format: percent }, + { key: "intervalHours", header: "Interval hours" }, + { key: "per8h", header: "Per 8h", format: percent }, + { key: "apr", header: "Simple APR", format: percent }, + ], + rows: snapshot.predictedFundings.map((row) => ({ ...row })), + }, + ], + complete: !snapshot.error && rankings.state === "ready", + errors: [snapshot.error, rankings.error].filter( + (error): error is string => Boolean(error), + ), + metadata: { + asOf: snapshot.asOf, + network, + cloudRankings: { ...rankings, data: undefined }, + historyMethod: + "Gloom Pro cloud observations. No local history reconstruction.", + ratesAreFractions: true, + }, + }; + }, +}; + +export const accountHeadless: HeadlessPaneDefinition<"bundle"> = { + ...hyperliquidHeadless, + argument: { kind: "none" }, + discovery: { id: "hyperliquid.account", screenshotReadiness: "live-dom" }, + async load(args, ctx) { + const network = args.options.network === "testnet" ? "testnet" : "mainnet"; + const watchAddress = args.options["watch-address"]; + let snapshot: AccountSnapshot; + if ( + typeof watchAddress === "string" && + /^0x[0-9a-fA-F]{40}$/.test(watchAddress) + ) { + const store = new AccountStore(network, getLocalMarketService(network)); + try { + snapshot = await store.setAddress(watchAddress as Address); + } finally { + store.stop(); + } + } else { + if (watchAddress) throw new Error("Enter a valid watch-only address."); + if (!ctx.capabilities) + throw new Error( + "Use --watch-address or connect an account in the native app.", + ); + const status = await ctx.capabilities.invokeCapability( + "hyperliquid.trading", + "status", + { network }, + ); + if (!status.address) + return { + sections: [], + errors: ["No account connected. Use HLS or --watch-address."], + complete: false, + }; + snapshot = await ctx.capabilities.invokeCapability( + "hyperliquid.trading", + "account", + { network }, + ); + } + ctx.signal.throwIfAborted(); + return { + sections: [ + { + title: "Account", + entries: [ + { label: "Address", value: snapshot.address }, + { label: "Network", value: network }, + { label: "Mode", value: snapshot.abstraction }, + { label: "USDC account value", value: snapshot.accountValue }, + { label: "Available USDC", value: snapshot.available }, + { label: "Margin used USDC", value: snapshot.marginUsed }, + ], + }, + { + title: "Positions", + rows: snapshot.positions.map((position) => ({ ...position })), + columns: [ + { key: "coin", header: "Market" }, + { key: "szi", header: "Size" }, + { key: "entryPx", header: "Entry" }, + { key: "unrealizedPnl", header: "Unrealized PnL" }, + { key: "liquidationPx", header: "Liquidation" }, + { key: "marginUsed", header: "Margin" }, + ], + }, + { + title: "Open orders", + rows: snapshot.orders.map((order) => ({ ...order })), + }, + { + title: "Balances by dex", + rows: snapshot.balances.map((balance) => ({ ...balance })), + }, + ], + metadata: { + asOf: snapshot.updatedAt, + network, + fills: snapshot.fills, + orderHistory: snapshot.orderHistory, + funding: snapshot.funding, + ledger: snapshot.ledger, + spot: snapshot.spot, + fees: snapshot.fees, + }, + complete: !snapshot.stale && !snapshot.error, + errors: snapshot.error ? [snapshot.error] : [], + }; + }, +}; + +export const setupHeadless: HeadlessPaneDefinition<"bundle"> = { + shape: "bundle", + argument: { kind: "none" }, + options: reportOptions, + discovery: { id: "hyperliquid.connection", screenshotReadiness: "live-dom" }, + load(args) { + const network = args.options.network === "testnet" ? "testnet" : "mainnet"; + return { + sections: [ + { + title: "Connection", + entries: [ + { label: "Network", value: network }, + { + label: "Builder fee active", + value: Boolean(configuredBuilder(network)), + }, + { + label: "Connect", + value: + "Open HLS in the app. API wallet credentials stay in the native process.", + }, + ], + }, + ], + }; + }, +}; diff --git a/src/market/cloud-history.test.ts b/src/market/cloud-history.test.ts new file mode 100644 index 0000000..17b6925 --- /dev/null +++ b/src/market/cloud-history.test.ts @@ -0,0 +1,408 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { + CloudPerpsClient, + cloudMarketId, + configureCloudPerpsClient, + getCloudPerpsClient, + normalizeCloudHistory, + normalizeCloudRankings, +} from "./cloud-history.ts"; + +const time = "2026-10-04T10:00:00.000Z"; +const marketId = "hyperliquid:default:BTC"; +const observedAt = "2026-10-04T10:59:00.000Z"; +function history(overrides: Record = {}) { + return { + status: "ok", + marketId, + asOf: observedAt, + from: time, + to: observedAt, + resolution: "hour", + truncated: true, + access: "pro", + locked: false, + rows: [ + { + time, + resolution: "day", + markPrice: 101_000, + oraclePrice: 100_000, + premium: 0.01, + fundingRate: 0.0001, + fundingIntervalHours: 1, + openInterestBase: 50, + openInterestUsd: 5_050_000, + sampleCount: 58, + firstObservedAt: time, + lastObservedAt: observedAt, + }, + ], + funding: [ + { + marketId, + time, + rate: 0.00005, + intervalHours: 1, + premium: 0.0002, + observedAt, + sourceUrl: "https://api.hyperliquid.xyz/info", + }, + ], + candles: [ + { + marketId, + time, + interval: "1h", + open: 100_000, + high: 102_000, + low: 99_000, + close: 101_000, + volumeBase: 3, + trades: 7, + observedAt, + sourceUrl: "https://api.hyperliquid.xyz/info", + }, + ], + ...overrides, + }; +} +function ranked(coin: string, overrides: Record = {}) { + return { + marketId: cloudMarketId(coin), + venue: "hyperliquid", + dex: coin.includes(":") ? coin.split(":")[0] : "default", + baseAsset: coin.split(":").at(-1), + assetClass: "crypto", + markPrice: 101_000, + oraclePrice: 100_000, + premium: 0.01, + fundingRate: 0.0001, + fundingIntervalHours: 1, + fundingKind: "current", + fundingRate8h: 0.0008, + fundingApr: 0.876, + openInterestBase: 50, + openInterestUsd: 5_050_000, + volume24hUsd: 20_000_000, + oiChange1h: 0.02, + oiChange24h: 0.1, + oiUsdChange1h: 0.03, + oiUsdChange24h: 0.12, + observedAt, + sourceAsOf: null, + sourceUrl: "https://api.hyperliquid.xyz/info", + confidence: "high", + qualityFlags: ["non-usdc-collateral"], + marginCurrency: "USDH", + stale: false, + ...overrides, + }; +} +const ranks = (overrides: Record = {}) => ({ + status: "ok", + asOf: observedAt, + access: "pro", + locked: false, + fundingPositive: [ + ranked("BTC"), + ranked("xyz:TSLA", { assetClass: "stocks" }), + ], + fundingNegative: [], + oiSurges: [], + premiumDislocations: [], + closedMarketDislocations: [], + ...overrides, +}); +afterEach(() => configureCloudPerpsClient(null)); + +describe("cloud history contract and financial semantics", () => { + test("identifiers preserve exact native and HIP-3 contract identity", () => { + expect(cloudMarketId(" BTC ")).toBe(marketId); + expect(cloudMarketId("xyz:TSLA")).toBe("hyperliquid:xyz:TSLA"); + expect(cloudMarketId("hyperliquid:xyz:TSLA")).toBe("hyperliquid:xyz:TSLA"); + expect(() => cloudMarketId("a:b:c:d")).toThrow(); + expect(() => cloudMarketId("BTC?marketId=ETH")).toThrow(); + }); + test("rollups keep true resolution, sample counts, observation bounds, truncation and fractional rates", () => { + const result = normalizeCloudHistory(history()); + expect(result.resolution).toBe("hour"); + expect(result.points[0]).toEqual({ + time: Date.parse(time), + resolution: "day", + mark: 101_000, + oracle: 100_000, + premium: 0.01, + fundingRate: 0.0001, + fundingIntervalHours: 1, + oiCoin: 50, + oiUsd: 5_050_000, + sampleCount: 58, + firstObservedAt: Date.parse(time), + lastObservedAt: Date.parse(observedAt), + }); + expect(result.truncated).toBe(true); + expect(result.funding[0]?.rate).toBe(0.00005); + expect(result.funding[0]?.premium).toBe(0.0002); + expect(result.candles[0]?.volume).toBe(3); + expect(result.funding[0]?.sourceUrl).toBe( + "https://api.hyperliquid.xyz/info", + ); + }); + test("missing history remains missing and cannot be reconstructed from funding or candles", () => { + const result = normalizeCloudHistory(history({ rows: [] })); + expect(result.points).toEqual([]); + expect(result.funding).toHaveLength(1); + expect(result.candles).toHaveLength(1); + }); + test("nested funding/candle rows from another contract are rejected", () => { + const data = history(); + expect( + normalizeCloudHistory( + history({ + funding: [{ ...data.funding[0], marketId: "hyperliquid:xyz:BTC" }], + candles: [{ ...data.candles[0], marketId: "hyperliquid:xyz:BTC" }], + }), + ).funding, + ).toEqual([]); + expect( + normalizeCloudHistory( + history({ + candles: [{ ...data.candles[0], marketId: "hyperliquid:xyz:BTC" }], + }), + ).candles, + ).toEqual([]); + }); + test("rankings keep server order, exact units and provenance while excluding other venues", () => { + const result = normalizeCloudRankings( + ranks({ + fundingPositive: [ + ranked("xyz:TSLA", { assetClass: "stocks" }), + ranked("BTC"), + ranked("ETH", { venue: "binance" }), + ], + }), + ); + expect(result.fundingPositive.map((row) => row.coin)).toEqual([ + "xyz:TSLA", + "BTC", + ]); + expect(result.fundingPositive[0]).toMatchObject({ + dex: "xyz", + assetClass: "Stocks", + funding8h: 0.0008, + fundingApr: 0.876, + oiChange1h: 0.02, + oiChange24h: 0.1, + oiChange1hUsd: 0.03, + oiChange24hUsd: 0.12, + sourceAsOf: null, + collateral: "USDH", + confidence: "high", + qualityFlags: ["non-usdc-collateral"], + }); + }); +}); + +describe("cloud transport, entitlement and availability", () => { + test("history sends a bounded UTC query and forwards cancellation", async () => { + const controller = new AbortController(); + let requested = ""; + const client = new CloudPerpsClient({ + request: async (path, options) => { + requested = path; + expect(options?.signal).toBe(controller.signal); + return { status: 200, body: history() }; + }, + }); + expect( + ( + await client.history("BTC", { + from: time, + to: observedAt, + resolution: "hour", + limit: 123, + signal: controller.signal, + }) + ).state, + ).toBe("ready"); + const query = new URL(requested, "https://example.invalid").searchParams; + expect(query.get("marketId")).toBe(marketId); + expect(query.get("from")).toBe(time); + expect(query.get("to")).toBe(observedAt); + expect(query.get("resolution")).toBe("hour"); + expect(query.get("limit")).toBe("123"); + }); + test("preview never exposes history, even if rows incorrectly arrive in the response", async () => { + const request = async () => ({ + status: 200, + body: history({ access: "preview", locked: true }), + }); + const anonymous = await new CloudPerpsClient({ request }).history("BTC"); + const free = await new CloudPerpsClient({ + request, + signedIn: () => true, + }).history("BTC"); + expect(anonymous).toMatchObject({ + state: "sign-in", + locked: true, + data: null, + }); + expect(free).toMatchObject({ + state: "pro-required", + locked: true, + data: null, + }); + }); + test("anonymous ranking preview retains allowed rows and reports the host auth gap honestly", async () => { + const client = new CloudPerpsClient({ + request: async () => ({ + status: 200, + body: ranks({ + access: "preview", + locked: true, + fundingPositive: [ + ranked("BTC"), + ranked("ETH"), + ranked("SOL"), + ranked("xyz:TSLA"), + ], + }), + }), + authenticationAvailable: false, + }); + const result = await client.rankings(); + expect(result.state).toBe("host-unavailable"); + expect(result.locked).toBe(true); + expect(result.data?.fundingPositive).toHaveLength(3); + expect(result.error).toContain("cannot share your Cloud login"); + }); + test("anonymous transport never claims a user entitlement or prompts an already signed-in user", async () => { + for (const status of [401, 402, 403]) { + const result = await new CloudPerpsClient({ + request: async () => ({ status, body: {} }), + authenticationAvailable: false, + }).rankings(); + expect(result.state).toBe("host-unavailable"); + } + const unexpected = await new CloudPerpsClient({ + request: async () => ({ status: 200, body: ranks() }), + authenticationAvailable: false, + }).rankings(); + expect(unexpected).toMatchObject({ state: "error", data: null }); + }); + test("transport failures do not reflect response bodies or internal credential errors", async () => { + const result = await new CloudPerpsClient({ + request: async () => { + throw new Error("Private transport failure detail"); + }, + }).history("BTC"); + expect(result.state).toBe("unavailable"); + expect(result.error).not.toContain("Private transport failure detail"); + }); + test("history without host authentication and testnet make no cloud request", async () => { + let calls = 0; + const request = async () => { + calls++; + return { status: 200, body: history() }; + }; + expect( + ( + await new CloudPerpsClient({ + request, + authenticationAvailable: false, + }).history("BTC") + ).state, + ).toBe("host-unavailable"); + const testnet = new CloudPerpsClient({ request, network: "testnet" }); + expect((await testnet.history("BTC")).state).toBe("testnet"); + expect((await testnet.history("i<3fl:TEST")).state).toBe("testnet"); + expect((await testnet.rankings()).state).toBe("testnet"); + expect(calls).toBe(0); + }); + test("missing or unavailable server routes have no direct or local history fallback", async () => { + for (const status of [404, 503]) { + let calls = 0; + const result = await new CloudPerpsClient({ + request: async () => { + calls++; + return { status, body: {} }; + }, + }).history("BTC"); + expect(result).toMatchObject({ state: "unavailable", data: null }); + expect(calls).toBe(1); + } + }); + test("collecting and partial responses retain the reported state", async () => { + expect( + ( + await new CloudPerpsClient({ + request: async () => ({ + status: 200, + body: history({ status: "collecting", rows: [] }), + }), + }).history("BTC") + ).state, + ).toBe("collecting"); + expect( + ( + await new CloudPerpsClient({ + request: async () => ({ + status: 200, + body: ranks({ status: "partial" }), + }), + }).rankings() + ).state, + ).toBe("partial"); + }); + test("different market, malformed history and absent entitlement are explicit errors", async () => { + for (const body of [ + history({ marketId: "hyperliquid:xyz:BTC" }), + history({ from: "invalid" }), + history({ access: undefined }), + ]) { + const result = await new CloudPerpsClient({ + request: async () => ({ status: 200, body }), + }).history("BTC"); + expect(result).toMatchObject({ state: "error", data: null }); + } + }); + test("aborted requests reject and obsolete data cannot complete", async () => { + const controller = new AbortController(); + const client = new CloudPerpsClient({ + request: async () => { + controller.abort(new Error("Obsolete market")); + return { status: 200, body: history() }; + }, + }); + await expect( + client.history("BTC", { signal: controller.signal }), + ).rejects.toThrow("Obsolete market"); + let calls = 0; + await expect( + new CloudPerpsClient({ + request: async () => { + calls++; + return { status: 200, body: ranks() }; + }, + }).rankings({ signal: controller.signal }), + ).rejects.toThrow("Obsolete market"); + expect(calls).toBe(0); + }); + test("supported injected transport replaces the default without exposing credentials", async () => { + configureCloudPerpsClient({ + request: async () => ({ status: 200, body: history() }), + signedIn: () => true, + }); + expect((await getCloudPerpsClient("mainnet").history("BTC")).state).toBe( + "ready", + ); + expect((await getCloudPerpsClient("testnet").history("BTC")).state).toBe( + "testnet", + ); + configureCloudPerpsClient(null); + expect((await getCloudPerpsClient("mainnet").history("BTC")).state).toBe( + "host-unavailable", + ); + }); +}); diff --git a/src/market/cloud-history.ts b/src/market/cloud-history.ts new file mode 100644 index 0000000..cd9c317 --- /dev/null +++ b/src/market/cloud-history.ts @@ -0,0 +1,641 @@ +import type { AssetClass, Network } from "./types.ts"; +import { numberOrNull } from "./normalize.ts"; + +/** Public response contract pinned to cloud PR 626, 057f23788b6bd85a3843ab407b47d43f23e72545. + * An authenticated connection must be supplied by a supported host API. This module never reads session tokens. + * Historical analytics have no direct-exchange or locally reconstructed fallback. + */ +export type CloudState = + | "ready" + | "partial" + | "collecting" + | "sign-in" + | "pro-required" + | "host-unavailable" + | "unavailable" + | "error" + | "testnet"; +export type HistoryResolution = "minute" | "hour" | "day"; +export interface CloudResult { + state: CloudState; + data: T | null; + asOf: number | null; + error: string | null; + access: "pro" | "preview" | null; + locked: boolean; +} +export type CloudRequest = ( + path: string, + options?: { signal?: AbortSignal }, +) => Promise<{ status: number; body: unknown }>; +export interface CloudClientOptions { + request?: CloudRequest; + signedIn?: () => boolean; + network?: Network; + authenticationAvailable?: boolean; +} +export interface CloudHistoryPoint { + time: number; + resolution: HistoryResolution; + mark: number | null; + oracle: number | null; + premium: number | null; + fundingRate: number | null; + fundingIntervalHours: number | null; + oiCoin: number | null; + oiUsd: number | null; + sampleCount: number; + firstObservedAt: number; + lastObservedAt: number; +} +export interface CloudFundingPoint { + time: number; + rate: number; + intervalHours: number; + premium: number | null; + observedAt: number; + sourceUrl: string; +} +export interface CloudCandle { + time: number; + open: number; + high: number; + low: number; + close: number; + volume: number | null; + trades: number | null; + interval: "1h"; + observedAt: number; + sourceUrl: string; +} +export interface CloudHistory { + marketId: string; + from: number; + to: number; + resolution: HistoryResolution; + points: CloudHistoryPoint[]; + funding: CloudFundingPoint[]; + candles: CloudCandle[]; + truncated: boolean; +} +export interface CloudRankedMarket { + marketId: string; + coin: string; + symbol: string; + dex: string; + assetClass: AssetClass; + displayName: string; + mark: number | null; + oracle: number | null; + premium: number | null; + fundingRate: number | null; + fundingIntervalHours: number | null; + fundingKind: "current" | "last-paid" | "continuous"; + funding8h: number | null; + fundingApr: number | null; + oiCoin: number | null; + oiUsd: number | null; + volume24h: number | null; + oiChange1h: number | null; + oiChange24h: number | null; + oiChange1hUsd: number | null; + oiChange24hUsd: number | null; + underlyingPremium: number | null; + closedMarketPremium: number | null; + collateral: string; + observedAt: number; + sourceAsOf: number | null; + sourceUrl: string; + qualityFlags: string[]; + confidence: "high" | "medium" | "low"; + stale: boolean; +} +export const RANKING_SECTIONS = [ + "fundingPositive", + "fundingNegative", + "oiSurges", + "premiumDislocations", + "closedMarketDislocations", +] as const; +export type CloudRankings = Record< + (typeof RANKING_SECTIONS)[number], + CloudRankedMarket[] +>; +export interface CloudHistoryQuery { + days?: number; + from?: number | string; + to?: number | string; + resolution?: "auto" | HistoryResolution; + limit?: number; + signal?: AbortSignal; +} +export interface CloudPerpsClientLike { + history( + coin: string, + query?: CloudHistoryQuery, + ): Promise>; + rankings(options?: { + signal?: AbortSignal; + }): Promise>; +} +const object = (value: unknown): Record => + value !== null && typeof value === "object" && !Array.isArray(value) + ? (value as Record) + : {}; +const text = (value: unknown): string => + typeof value === "string" ? value : ""; +const rows = (value: unknown): unknown[] => (Array.isArray(value) ? value : []); +const timestamp = (value: unknown): number | null => { + const n = + typeof value === "number" + ? value + : typeof value === "string" + ? Date.parse(value) + : NaN; + return Number.isFinite(n) ? n : null; +}; +const resolutionOf = (value: unknown): HistoryResolution | null => + ["minute", "hour", "day"].includes(String(value)) + ? (value as HistoryResolution) + : null; +const classes: Record = { + stocks: "Stocks", + indices: "Indices", + energy: "Energy", + metals: "Metals", + fx: "FX", + crypto: "Crypto", + other: "Other", +}; + +export function cloudMarketId(coin: string): string { + const value = coin.trim(); + if (!value || value.length > 160 || !/^[A-Za-z0-9_.,:/-]+$/.test(value)) + throw new Error("This market is not supported by cloud history."); + if (value.startsWith("hyperliquid:")) { + if (value.split(":").length !== 3) + throw new Error("Invalid cloud market identifier."); + return value; + } + if (value.includes(":")) { + if (value.split(":").length !== 2) + throw new Error("Invalid Hyperliquid market."); + return `hyperliquid:${value}`; + } + return `hyperliquid:default:${value}`; +} +function coinFromId(id: string): string | null { + const match = /^hyperliquid:([^:]+):(.+)$/.exec(id); + return match + ? match[1] === "default" + ? match[2]! + : `${match[1]}:${match[2]}` + : null; +} +export function normalizeCloudHistory(raw: unknown): CloudHistory { + const data = object(raw), + from = timestamp(data.from), + to = timestamp(data.to), + resolution = resolutionOf(data.resolution); + if ( + !text(data.marketId) || + from === null || + to === null || + from > to || + !resolution + ) + throw new Error("Cloud history returned an invalid time range."); + const points = rows(data.rows) + .flatMap((value) => { + const p = object(value), + time = timestamp(p.time), + firstObservedAt = timestamp(p.firstObservedAt), + lastObservedAt = timestamp(p.lastObservedAt), + actualResolution = resolutionOf(p.resolution); + if ( + time === null || + firstObservedAt === null || + lastObservedAt === null || + !actualResolution + ) + return []; + return [ + { + time, + resolution: actualResolution, + mark: numberOrNull(p.markPrice), + oracle: numberOrNull(p.oraclePrice), + premium: numberOrNull(p.premium), + fundingRate: numberOrNull(p.fundingRate), + fundingIntervalHours: numberOrNull(p.fundingIntervalHours), + oiCoin: numberOrNull(p.openInterestBase), + oiUsd: numberOrNull(p.openInterestUsd), + sampleCount: Math.max( + 0, + Math.trunc(numberOrNull(p.sampleCount) ?? 0), + ), + firstObservedAt, + lastObservedAt, + }, + ]; + }) + .sort((a, b) => a.time - b.time); + const funding = rows(data.funding) + .flatMap((value) => { + const p = object(value), + time = timestamp(p.time), + rate = numberOrNull(p.rate), + intervalHours = numberOrNull(p.intervalHours), + observedAt = timestamp(p.observedAt); + if ( + p.marketId !== data.marketId || + time === null || + rate === null || + intervalHours === null || + intervalHours <= 0 || + observedAt === null + ) + return []; + return [ + { + time, + rate, + intervalHours, + premium: numberOrNull(p.premium), + observedAt, + sourceUrl: text(p.sourceUrl), + }, + ]; + }) + .sort((a, b) => a.time - b.time); + const candles = rows(data.candles) + .flatMap((value) => { + const p = object(value), + time = timestamp(p.time), + observedAt = timestamp(p.observedAt); + const open = numberOrNull(p.open), + high = numberOrNull(p.high), + low = numberOrNull(p.low), + close = numberOrNull(p.close); + if ( + p.marketId !== data.marketId || + time === null || + observedAt === null || + open === null || + high === null || + low === null || + close === null || + p.interval !== "1h" + ) + return []; + return [ + { + time, + observedAt, + open, + high, + low, + close, + interval: "1h" as const, + volume: numberOrNull(p.volumeBase), + trades: numberOrNull(p.trades), + sourceUrl: text(p.sourceUrl), + }, + ]; + }) + .sort((a, b) => a.time - b.time); + return { + marketId: text(data.marketId), + from, + to, + resolution, + points, + funding, + candles, + truncated: data.truncated === true, + }; +} +export function normalizeCloudRankings(raw: unknown): CloudRankings { + const data = object(raw); + const normalize = (value: unknown): CloudRankedMarket[] => + rows(value).flatMap((value) => { + const p = object(value), + marketId = text(p.marketId), + coin = coinFromId(marketId), + observedAt = timestamp(p.observedAt); + // Optional cloud venues are independent integrations. This plugin displays Hyperliquid contracts only. + if (p.venue !== "hyperliquid" || !coin || observedAt === null) return []; + return [ + { + marketId, + coin, + symbol: text(p.baseAsset) || coin.split(":").at(-1)!, + dex: p.dex === "default" ? "" : text(p.dex), + assetClass: classes[text(p.assetClass)] ?? "Other", + displayName: text(p.displayName), + mark: numberOrNull(p.markPrice), + oracle: numberOrNull(p.oraclePrice), + premium: numberOrNull(p.premium), + fundingRate: numberOrNull(p.fundingRate), + fundingIntervalHours: numberOrNull(p.fundingIntervalHours), + fundingKind: + p.fundingKind === "last-paid" || p.fundingKind === "continuous" + ? p.fundingKind + : ("current" as const), + funding8h: numberOrNull(p.fundingRate8h), + fundingApr: numberOrNull(p.fundingApr), + oiCoin: numberOrNull(p.openInterestBase), + oiUsd: numberOrNull(p.openInterestUsd), + volume24h: numberOrNull(p.volume24hUsd), + oiChange1h: numberOrNull(p.oiChange1h), + oiChange24h: numberOrNull(p.oiChange24h), + oiChange1hUsd: numberOrNull(p.oiUsdChange1h), + oiChange24hUsd: numberOrNull(p.oiUsdChange24h), + underlyingPremium: numberOrNull(p.underlyingPremium), + closedMarketPremium: numberOrNull(p.closedMarketPremium), + collateral: text(p.marginCurrency), + observedAt, + sourceAsOf: timestamp(p.sourceAsOf), + sourceUrl: text(p.sourceUrl), + qualityFlags: rows(p.qualityFlags).filter( + (v): v is string => typeof v === "string", + ), + confidence: + p.confidence === "high" || p.confidence === "medium" + ? p.confidence + : ("low" as const), + stale: p.stale === true, + }, + ]; + }); + return Object.fromEntries( + RANKING_SECTIONS.map((key) => [ + key, + normalize(data[key]).slice(0, data.access === "preview" ? 3 : undefined), + ]), + ) as CloudRankings; +} +/** Requests go through an injected transport; historical access is checked independently. */ +export class CloudPerpsClient implements CloudPerpsClientLike { + constructor(private options: CloudClientOptions = {}) {} + async history( + coin: string, + query: CloudHistoryQuery = {}, + ): Promise> { + if (query.signal?.aborted) + throw ( + query.signal.reason ?? + new DOMException("Request cancelled", "AbortError") + ); + if (this.options.network === "testnet") + return this.failure( + "testnet", + "Cloud historical analytics cover mainnet. Live testnet trading data remains available.", + ); + try { + const marketId = cloudMarketId(coin), + end = query.to === undefined ? Date.now() : timestamp(query.to), + days = query.days ?? 7; + if (!Number.isFinite(days) || days <= 0 || end === null) + throw new Error("Choose a valid history range."); + const start = + query.from === undefined + ? end - days * 86_400_000 + : timestamp(query.from); + if (start === null || start > end) + throw new Error("History start must precede its end."); + const resolution = query.resolution ?? "auto", + limit = query.limit ?? 2000; + if ( + !["auto", "minute", "hour", "day"].includes(resolution) || + !Number.isInteger(limit) || + limit < 1 || + limit > 5000 + ) + throw new Error("Invalid history resolution or point limit."); + const params = new URLSearchParams({ + marketId, + from: new Date(start).toISOString(), + to: new Date(end).toISOString(), + resolution, + limit: String(limit), + }); + return await this.load( + `/cloud/perps/history?${params}`, + normalizeCloudHistory, + query.signal, + true, + marketId, + ); + } catch (error) { + if (query.signal?.aborted) throw error; + return this.failure( + "error", + error instanceof Error + ? error.message + : "Could not load historical analytics.", + ); + } + } + rankings( + options: { signal?: AbortSignal } = {}, + ): Promise> { + return this.load( + "/cloud/perps/rankings", + normalizeCloudRankings, + options.signal, + false, + ); + } + private failure(state: CloudState, error: string): CloudResult { + return { + state, + data: null, + asOf: null, + error, + access: null, + locked: state === "sign-in" || state === "pro-required", + }; + } + private async load( + path: string, + normalize: (raw: unknown) => T, + signal: AbortSignal | undefined, + history: boolean, + marketId?: string, + ): Promise> { + if (signal?.aborted) + throw ( + signal.reason ?? new DOMException("Request cancelled", "AbortError") + ); + if (this.options.network === "testnet") + return this.failure( + "testnet", + "Cloud historical analytics cover mainnet. Live testnet trading data remains available.", + ); + if (!this.options.request) + return this.failure( + "host-unavailable", + "This version of Gloom cannot connect plugins to Pro historical analytics yet.", + ); + if (history && this.options.authenticationAvailable === false) + return this.failure( + "host-unavailable", + "This version of Gloom cannot share your Cloud login with plugins. Pro history is unavailable here.", + ); + try { + const response = await this.options.request(path, { signal }); + if (signal?.aborted) + throw signal.reason ?? new Error("Request cancelled"); + const raw = object(response.body), + signedIn = this.options.signedIn?.() ?? false; + if ( + [401, 402, 403].includes(response.status) && + this.options.authenticationAvailable === false + ) + return this.failure( + "host-unavailable", + "This version of Gloom cannot share your Cloud login with plugins.", + ); + if (response.status === 401) + return this.failure( + "sign-in", + "Sign in to Gloom for Pro historical analytics.", + ); + if (response.status === 402 || response.status === 403) + return this.failure( + signedIn ? "pro-required" : "sign-in", + signedIn + ? "Historical analytics require Gloom Pro." + : "Sign in to Gloom for Pro historical analytics.", + ); + if ( + response.status === 404 || + response.status === 503 || + raw.status === "unavailable" + ) + return this.failure( + "unavailable", + "Cloud perpetual analytics are not available on this server yet.", + ); + if (response.status < 200 || response.status >= 300) + return this.failure( + "error", + `Cloud perpetual analytics request failed (${response.status}).`, + ); + if (!["ok", "partial", "collecting"].includes(String(raw.status))) + return this.failure( + "unavailable", + "Cloud perpetual analytics returned an unsupported response.", + ); + const access = + raw.access === "pro" + ? "pro" + : raw.access === "preview" + ? "preview" + : null; + if (access === null) + return this.failure( + "error", + "Cloud analytics did not report an entitlement.", + ); + if (access === "pro" && this.options.authenticationAvailable === false) + return this.failure( + "error", + "The anonymous cloud connection returned an unexpected entitlement.", + ); + const locked = access !== "pro" || raw.locked === true; + const hostUnavailable = + locked && this.options.authenticationAvailable === false; + const state: CloudState = hostUnavailable + ? "host-unavailable" + : locked + ? signedIn + ? "pro-required" + : "sign-in" + : raw.status === "collecting" + ? "collecting" + : raw.status === "partial" + ? "partial" + : "ready"; + if (history && marketId && raw.marketId !== marketId) + return this.failure( + "error", + "Cloud history returned a different market.", + ); + let data: T | null = null; + try { + data = history && locked ? null : normalize(raw); + } catch { + return this.failure( + "error", + "Cloud analytics returned invalid market data.", + ); + } + return { + state, + data, + asOf: timestamp(raw.asOf), + access, + locked, + error: hostUnavailable + ? "Ranking preview. This version of Gloom cannot share your Cloud login with plugins." + : locked + ? signedIn + ? "Historical analytics require Gloom Pro." + : "Sign in to Gloom for Pro historical analytics." + : raw.status === "collecting" + ? "Cloud history is still collecting for this market." + : null, + }; + } catch (error) { + if (signal?.aborted) throw error; + return this.failure( + "unavailable", + "Could not reach Gloom cloud historical analytics.", + ); + } + } +} + +const anonymousRequest: CloudRequest = async (path, options) => { + const { httpFetch } = await import("gloomberb/utils"); + const timeout = AbortSignal.timeout(10_000); + const signal = options?.signal + ? AbortSignal.any([options.signal, timeout]) + : timeout; + const response = await httpFetch(`https://api.gloom.sh${path}`, { + method: "GET", + signal, + credentials: "omit", + redirect: "error", + }); + return { + status: response.status, + body: await response.json().catch(() => null), + }; +}; +const defaultConnection: Omit = { + request: anonymousRequest, + authenticationAvailable: false, +}; +let connection = defaultConnection; +const clients = new Map(); + +/** A supported host connection can be installed without exposing its credentials. */ +export function configureCloudPerpsClient( + options: Omit | null, +): void { + connection = options ?? defaultConnection; + clients.clear(); +} + +/** Safe default until the host exposes an authenticated external-plugin request API. */ +export function getCloudPerpsClient(network: Network): CloudPerpsClientLike { + let client = clients.get(network); + if (!client) { + client = new CloudPerpsClient({ ...connection, network }); + clients.set(network, client); + } + return client; +} diff --git a/src/market/index.ts b/src/market/index.ts new file mode 100644 index 0000000..67e61d4 --- /dev/null +++ b/src/market/index.ts @@ -0,0 +1,5 @@ +export * from "./types.ts"; +export * from "./normalize.ts"; +export * from "./transport.ts"; +export * from "./service.ts"; +export * from "./cloud-history.ts"; diff --git a/src/market/normalize.test.ts b/src/market/normalize.test.ts new file mode 100644 index 0000000..d6e3d16 --- /dev/null +++ b/src/market/normalize.test.ts @@ -0,0 +1,166 @@ +import { describe, expect, test } from "bun:test"; +import { + applyContext, + assetClass, + normalizeBook, + normalizeMarkets, + normalizePredicted, + normalizeTrade, + numberOrNull, + resolveMarket, +} from "./normalize.ts"; +import type { Dex, Market, RawMeta } from "./types.ts"; +const dex: Dex = { + name: "xyz", + fullName: "Trade[XYZ]", + index: 1, + deployer: null, + oracleUpdater: null, + collateral: "USDC", + collateralToken: 0, + fundingMultipliers: {}, +}; +const meta: RawMeta = { + universe: [ + { name: "xyz:OLD", szDecimals: 2, maxLeverage: 5, isDelisted: true }, + { name: "xyz:TSLA", szDecimals: 3, maxLeverage: 20, marginMode: "noCross" }, + ], +}; +const context = { + markPx: "250", + oraclePx: "200", + midPx: "251", + prevDayPx: "240", + funding: "0.00001", + openInterest: "100", + dayNtlVlm: "5000", + premium: "0.03", +}; +const market = () => + normalizeMarkets( + meta, + [{}, context], + dex, + new Map([["xyz:TSLA", "stocks"]]), + 1000, + )[0]!; +describe("venue financial normalization", () => { + test("asset ids retain original dex and universe indices after delisted markets are removed", () => { + const m = market(); + expect(m.assetId).toBe(110001); + expect(m.universeIndex).toBe(1); + expect(m.onlyIsolated).toBe(true); + expect(m.underlyingSymbol).toBe("TSLA"); + }); + test("OI uses mark, not oracle or mids; hourly funding is annualized simply", () => { + const m = market(); + expect(m.oiUsd).toBe(25_000); + expect(m.oiVolume).toBe(5); + expect(m.premium).toBe(0.25); + expect(m.fundingPremium).toBe(0.03); + expect(m.change24h).toBeCloseTo(250 / 240 - 1); + expect(m.funding8h).toBeCloseTo(0.00008); + expect(m.fundingApr).toBeCloseTo(0.0876); + expect(m.mid).toBe(251); + }); + test("unavailable inputs remain blank and denominators do not create infinity", () => { + const m = applyContext( + market(), + { + markPx: "0", + oraclePx: "0", + openInterest: "0", + dayNtlVlm: "0", + funding: "NaN", + }, + 2000, + ); + expect(m.premium).toBeNull(); + expect(m.change24h).toBeNull(); + expect(m.oiVolume).toBeNull(); + expect(m.fundingApr).toBeNull(); + expect(m.oiUsd).toBe(0); + expect(numberOrNull(null)).toBeNull(); + expect(numberOrNull("")).toBeNull(); + expect(numberOrNull(false)).toBeNull(); + }); + test("venue categories preserve unknown HIP-3 identity and special asset classes", () => { + expect(assetClass("xyz:NEWCO")).toBe("Other"); + expect(assetClass("xyz:TSLA", "stocks")).toBe("Stocks"); + expect(assetClass("hyna:BTC", "crypto")).toBe("Crypto"); + expect(assetClass("xyz:GOLD", "commodities")).toBe("Metals"); + expect(assetClass("xyz:CL", "commodities")).toBe("Energy"); + expect( + normalizeMarkets( + { universe: [{ name: "io:ANTH", szDecimals: 2, maxLeverage: 2 }] }, + [context], + dex, + new Map([["io:ANTH", "preipo"]]), + 0, + )[0]?.underlyingSymbol, + ).toBeNull(); + }); + test("reported predicted rates retain each venue interval and absent venues", () => { + const rows = normalizePredicted([ + [ + "BTC", + [ + [ + "HlPerp", + { + fundingRate: "0.00001", + fundingIntervalHours: 1, + nextFundingTime: 123, + }, + ], + [ + "BinPerp", + { + fundingRate: "-0.00008", + fundingIntervalHours: 8, + nextFundingTime: 456, + }, + ], + ["Missing", null], + ], + ], + ]); + expect(rows).toHaveLength(2); + expect(rows[0]?.per8h).toBeCloseTo(0.00008); + expect(rows[1]?.apr).toBeCloseTo(-0.0876); + expect(rows[1]?.nextFundingTime).toBe(456); + }); + test("unqualified symbols resolve exact native crypto then the most liquid HIP-3 listing", () => { + const m = market(), + other = { ...m, coin: "cash:TSLA", dex: "cash", volume24h: 10_000 }; + expect(resolveMarket([m, other], "tsla")?.coin).toBe("cash:TSLA"); + expect(resolveMarket([m, other], "xyz:tsla")?.coin).toBe("xyz:TSLA"); + }); + test("book cumulative depth and spread are in matching units", () => { + const b = normalizeBook({ + coin: "BTC", + time: 1, + levels: [ + [ + { px: "99", sz: "2", n: 1 }, + { px: "98", sz: "3", n: 2 }, + ], + [{ px: "101", sz: "4", n: 1 }], + ], + }); + expect(b.bids[1]?.totalSize).toBe(5); + expect(b.bids[1]?.totalUsd).toBe(492); + expect(b.spread).toBe(2); + expect(b.spreadBps).toBe(200); + expect( + normalizeTrade({ + coin: "BTC", + time: 1, + tid: 2, + side: "A", + px: "100", + sz: "2", + })?.side, + ).toBe("sell"); + }); +}); diff --git a/src/market/normalize.ts b/src/market/normalize.ts new file mode 100644 index 0000000..32eb939 --- /dev/null +++ b/src/market/normalize.ts @@ -0,0 +1,336 @@ +import type { + AssetClass, + BookLevel, + Candle, + Dex, + Market, + OrderBook, + PredictedFunding, + RawContext, + RawMeta, + Trade, +} from "./types.ts"; +export function numberOrNull(value: unknown): number | null { + if ( + value === null || + value === undefined || + value === "" || + typeof value === "boolean" + ) + return null; + const n = Number(value); + return Number.isFinite(n) ? n : null; +} +const product = (a: number | null, b: number | null) => + a === null || b === null ? null : a * b; +const ratio = (a: number | null, b: number | null) => + a === null || b === null || b <= 0 ? null : a / b; +const metals = new Set([ + "GOLD", + "SILVER", + "COPPER", + "PLATINUM", + "PALLADIUM", + "ALUMINIUM", + "ALUMINUM", + "XAU", + "XAG", +]); +const energy = new Set([ + "OIL", + "USOIL", + "BRENTOIL", + "BRENT", + "WTI", + "CL", + "GAS", + "NATGAS", + "TTF", + "HO", + "URANIUM", +]); +/** Venue categories take precedence. Unknown HIP-3 listings remain Other. */ +export function assetClass(coin: string, category?: string | null): AssetClass { + const symbol = coin.split(":").at(-1)!.toUpperCase(); + if (metals.has(symbol)) return "Metals"; + if (energy.has(symbol)) return "Energy"; + switch (category?.toLowerCase()) { + case "stock": + case "stocks": + case "equity": + case "equities": + case "preipo": + return "Stocks"; + case "index": + case "indices": + return "Indices"; + case "fx": + case "forex": + return "FX"; + case "crypto": + case "cryptocurrency": + return "Crypto"; + case "energy": + return "Energy"; + case "metals": + return "Metals"; + } + return coin.includes(":") ? "Other" : "Crypto"; +} +export function applyContext( + market: Market, + ctx: RawContext, + time: number, +): Market { + const mark = numberOrNull(ctx.markPx), + oracle = numberOrNull(ctx.oraclePx), + oiCoin = numberOrNull(ctx.openInterest); + const fundingHourly = numberOrNull(ctx.funding), + prevDayPrice = numberOrNull(ctx.prevDayPx), + volume24h = numberOrNull(ctx.dayNtlVlm); + const oiUsd = product(oiCoin, mark), + premium = ratio(mark, oracle), + change = ratio(mark, prevDayPrice); + return { + ...market, + mark, + oracle, + mid: numberOrNull(ctx.midPx), + premium: premium === null ? null : premium - 1, + fundingPremium: numberOrNull(ctx.premium), + prevDayPrice, + change24h: change === null ? null : change - 1, + fundingHourly, + funding8h: product(fundingHourly, 8), + fundingApr: product(fundingHourly, 8760), + oiCoin, + oiUsd, + volume24h, + oiVolume: ratio(oiUsd, volume24h), + asOf: time, + priceTime: mark !== market.mark ? time : market.priceTime, + tickDirection: + mark !== null && market.mark !== null + ? mark > market.mark + ? 1 + : mark < market.mark + ? -1 + : market.tickDirection + : 0, + }; +} +const cashIndices: Record = { + SP500: "^GSPC", + US500: "^GSPC", + USA500: "^GSPC", + USTECH: "^NDX", + USA100: "^NDX", + SMALL2000: "^RUT", + VIX: "^VIX", + JP225: "^N225", + DXY: "DX-Y.NYB", +}; +const cashStocks = new Set( + "AAPL AAOI AMAT AMD AMZN ARM ASML AVGO BABA BB BE BMNR BX CIEN CIFR COHR COIN COST CRCL CRDO CRWD CRWV CVX DELL DKNG EBAY GEV GLW GME GOOGL GPRO HIMS HOOD IBM INTC IONQ IREN LITE LLY LRCX MELI META MRNA MRVL MSFT MSTR MU NBIS NET NFLX NOK NOW NVDA ORCL PLTR QCOM RDDT RIVN RKLB RTX SMCI SNDK SOFI STX TER TSLA TSM TTWO USAR VST WDC ZM".split( + " ", + ), +); +function underlyingSymbol( + symbol: string, + klass: AssetClass, + category: string | null, +): string | null { + if (category === "preipo") return null; + if (klass === "Indices") return cashIndices[symbol] ?? null; + if (klass === "Stocks" && cashStocks.has(symbol)) return symbol; + return null; +} +export function normalizeMarkets( + meta: RawMeta, + contexts: RawContext[], + dex: Dex, + categories: Map, + now: number, +): Market[] { + const tables = new Map(meta.marginTables ?? []); + return meta.universe.flatMap((asset, index) => { + if (asset.isDelisted) return []; + const category = categories.get(asset.name) ?? null, + klass = assetClass(asset.name, category); + const market: Market = { + coin: asset.name, + symbol: asset.name.split(":").at(-1)!, + dex: dex.name, + assetId: dex.index === 0 ? index : 100_000 + dex.index * 10_000 + index, + universeIndex: index, + assetClass: klass, + category, + underlyingSymbol: underlyingSymbol( + asset.name.split(":").at(-1)!, + klass, + category, + ), + alwaysOpen: ["Stocks", "Indices", "Energy", "Metals", "FX"].includes( + klass, + ), + maxLeverage: asset.maxLeverage, + onlyIsolated: + !!asset.onlyIsolated || + asset.marginMode === "strictIsolated" || + asset.marginMode === "noCross", + marginMode: asset.marginMode ?? null, + collateral: dex.collateral, + collateralToken: dex.collateralToken, + szDecimals: asset.szDecimals, + marginTiers: ( + tables.get(asset.marginTableId ?? asset.maxLeverage)?.marginTiers ?? [ + { lowerBound: "0", maxLeverage: asset.maxLeverage }, + ] + ).map((t) => ({ + lowerBound: Number(t.lowerBound), + maxLeverage: t.maxLeverage, + })), + deployer: dex.deployer, + oracleUpdater: dex.oracleUpdater, + fundingCap: 0.04, + fundingMultiplier: dex.fundingMultipliers[asset.name] ?? null, + growthMode: asset.growthMode ?? null, + deployerFeeScale: numberOrNull(asset.deployerFeeScale), + mark: null, + oracle: null, + mid: null, + premium: null, + fundingPremium: null, + change24h: null, + prevDayPrice: null, + fundingHourly: null, + funding8h: null, + fundingApr: null, + oiCoin: null, + oiUsd: null, + volume24h: null, + oiVolume: null, + asOf: now, + priceTime: now, + tickDirection: 0, + }; + return [applyContext(market, contexts[index] ?? {}, now)]; + }); +} +export function normalizeBook(raw: any): OrderBook { + const levels = (rows: any[]): BookLevel[] => { + let totalSize = 0, + totalUsd = 0; + return rows.flatMap((row) => { + const price = numberOrNull(row.px), + size = numberOrNull(row.sz); + if (price === null || price <= 0 || size === null || size < 0) return []; + totalSize += size; + totalUsd += price * size; + return [{ price, size, orders: Number(row.n) || 0, totalSize, totalUsd }]; + }); + }; + const bids = levels(raw.levels?.[0] ?? []), + asks = levels(raw.levels?.[1] ?? []); + const bestBid = bids[0]?.price, + bestAsk = asks[0]?.price; + const mid = + bestBid !== undefined && bestAsk !== undefined + ? (bestBid + bestAsk) / 2 + : null; + const spread = + bestBid !== undefined && bestAsk !== undefined ? bestAsk - bestBid : null; + return { + coin: raw.coin, + bids, + asks, + spread, + spreadBps: spread !== null && mid ? (spread / mid) * 10000 : null, + mid, + time: Number(raw.time), + }; +} +export function normalizeCandle(raw: any): Candle | null { + const values = [raw.t, raw.T, raw.o, raw.h, raw.l, raw.c, raw.v, raw.n].map( + numberOrNull, + ); + if (values.some((v) => v === null)) return null; + const [time, endTime, open, high, low, close, volume, trades] = values as [ + number, + number, + number, + number, + number, + number, + number, + number, + ]; + return { time, endTime, open, high, low, close, volume, trades }; +} +export function normalizeTrade(raw: any): Trade | null { + const price = numberOrNull(raw.px), + size = numberOrNull(raw.sz), + time = numberOrNull(raw.time); + if ( + price === null || + size === null || + time === null || + !["B", "A"].includes(raw.side) + ) + return null; + return { + id: `${raw.coin}:${time}:${raw.tid ?? raw.hash}`, + coin: raw.coin, + side: raw.side === "B" ? "buy" : "sell", + price, + size, + time, + }; +} +export function normalizePredicted(raw: unknown): PredictedFunding[] { + if (!Array.isArray(raw)) return []; + return raw.flatMap(([coin, venues]) => + Array.isArray(venues) + ? venues.flatMap(([venue, data]) => { + if (!data) return []; + const rate = numberOrNull(data.fundingRate), + intervalHours = numberOrNull(data.fundingIntervalHours), + nextFundingTime = numberOrNull(data.nextFundingTime); + return rate === null || + intervalHours === null || + intervalHours <= 0 || + nextFundingTime === null + ? [] + : [ + { + coin, + venue, + rate, + intervalHours, + nextFundingTime, + per8h: (rate * 8) / intervalHours, + apr: (rate * 8760) / intervalHours, + }, + ]; + }) + : [], + ); +} +export function resolveMarket( + markets: Market[], + query: string, +): Market | undefined { + const normalized = query + .trim() + .replace(/-PERP$/i, "") + .toUpperCase(); + return ( + markets.find((m) => m.coin.toUpperCase() === normalized) ?? + markets + .filter((m) => m.symbol.toUpperCase() === normalized) + .sort((a, b) => (b.volume24h ?? 0) - (a.volume24h ?? 0))[0] + ); +} +export function nextFundingTime(now = Date.now()): number { + return (Math.floor(now / 3_600_000) + 1) * 3_600_000; +} diff --git a/src/market/service.test.ts b/src/market/service.test.ts new file mode 100644 index 0000000..4a656de --- /dev/null +++ b/src/market/service.test.ts @@ -0,0 +1,261 @@ +import { describe, expect, test } from "bun:test"; +import { MarketDataService } from "./service.ts"; +import { InfoClient, SharedWebSocket } from "./transport.ts"; +import type { SocketLike } from "./transport.ts"; +class FakeSocket implements SocketLike { + readyState = 0; + onopen: ((e: any) => void) | null = null; + onclose: ((e: any) => void) | null = null; + onerror: ((e: any) => void) | null = null; + onmessage: ((e: any) => void) | null = null; + sent: any[] = []; + send(data: string) { + const m = JSON.parse(data); + this.sent.push(m); + if (m.method === "subscribe") this.receive("subscriptionResponse", m); + } + close() { + this.readyState = 3; + this.onclose?.({}); + } + open() { + this.readyState = 1; + this.onopen?.({}); + } + receive(channel: string, data: any) { + this.onmessage?.({ data: JSON.stringify({ channel, data }) }); + } +} +const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); +const dexes = [null, { name: "xyz", fullName: "XYZ" }]; +const context = { + markPx: "100", + oraclePx: "101", + midPx: "102", + funding: "0.00001", + openInterest: "3", + prevDayPx: "99", + dayNtlVlm: "1000", +}; +const metadata = (dex: string) => ({ + universe: [ + { name: dex ? "xyz:TSLA" : "BTC", maxLeverage: 20, szDecimals: 3 }, + ], + collateralToken: dex ? 8 : 0, +}); +function setup(count = 2) { + const availableDexes = + count === 2 + ? dexes + : [ + null, + ...Array.from({ length: count - 1 }, (_, i) => ({ + name: `dex${i}`, + fullName: `Dex ${i}`, + })), + ]; + const requests: Record[] = []; + const sockets: FakeSocket[] = []; + const info = new InfoClient("testnet", (async (_url: any, options: any) => { + const body = JSON.parse(options.body); + requests.push(body); + let response: any; + switch (body.type) { + case "perpDexs": + response = availableDexes; + break; + case "allPerpMetas": + response = availableDexes.map((d) => metadata(d?.name ?? "")); + break; + case "spotMeta": + response = { + tokens: [ + { index: 8, name: "USDH" }, + { index: 0, name: "USDC" }, + ], + }; + break; + case "perpCategories": + response = [["xyz:TSLA", "stocks"]]; + break; + case "metaAndAssetCtxs": + response = [metadata(body.dex), [context]]; + break; + case "predictedFundings": + response = []; + break; + case "l2Book": + response = { + coin: body.coin, + time: 100, + levels: [ + [{ px: "99", sz: "3", n: 1 }], + [{ px: "101", sz: "4", n: 1 }], + ], + }; + break; + case "candleSnapshot": + response = [ + { + t: 0, + T: 60_000, + o: "100", + h: "101", + l: "98", + c: "99", + v: "5", + n: 2, + }, + ]; + break; + case "recentTrades": + response = [ + { coin: body.coin, time: 5, tid: 1, px: "99", sz: "2", side: "B" }, + ]; + break; + case "perpAnnotation": + response = { + category: "stocks", + description: "Public venue description", + }; + break; + default: + throw new Error("Unexpected request " + body.type); + } + return Response.json(response); + }) as unknown as typeof fetch); + const ws = new SharedWebSocket("testnet", () => { + const socket = new FakeSocket(); + sockets.push(socket); + return socket; + }); + const service = new MarketDataService({ + network: "testnet", + info, + ws, + batchMs: 5, + }); + return { service, sockets, requests }; +} +describe("market service data integration", () => { + test("headless board/detail snapshots need no socket and collateral resolves token index, not array position", async () => { + const { service, sockets } = setup(); + await service.refresh(); + const board = service.getSnapshot(); + expect(board.markets).toHaveLength(2); + expect(board.markets[1]?.collateral).toBe("USDH"); + expect(board.markets[1]?.assetId).toBe(110000); + const detail = service.getMarket("xyz:TSLA"); + await detail.refresh(); + const state = detail.getSnapshot(); + expect(state.market?.coin).toBe("xyz:TSLA"); + expect(state.candles[0]?.close).toBe(99); + expect(state.book?.spread).toBe(2); + expect(sockets).toHaveLength(0); + service.dispose(); + }); + test("true mark ticks update OI USD while allMids changes only mid", async () => { + const { service, sockets } = setup(); + const stop = service.subscribe(() => {}); + sockets[0]!.open(); + await service.refresh(); + sockets[0]!.receive("allMids", { mids: { BTC: "500", "xyz:TSLA": "600" } }); + expect(service.getSnapshot().markets[0]?.mark).toBe(100); + expect(service.getSnapshot().markets[0]?.mid).toBe(500); + sockets[0]!.receive("allDexsAssetCtxs", { + ctxs: [ + ["", [{ ...context, markPx: "200", openInterest: "4" }]], + ["xyz", [{ ...context, markPx: "300" }]], + ], + }); + expect(service.getSnapshot().markets[0]?.mark).toBe(200); + expect(service.getSnapshot().markets[0]?.oiUsd).toBe(800); + expect(service.getSnapshot().markets[1]?.mark).toBe(300); + stop(); + service.dispose(); + }); + test("large dex universes bootstrap via aggregate contexts without per-dex REST fanout", async () => { + const { service, sockets, requests } = setup(40); + const stop = service.subscribe(() => {}); + sockets[0]!.open(); + sockets[0]!.receive("allDexsAssetCtxs", { + ctxs: [ + ["", [context]], + ...Array.from({ length: 39 }, (_, i) => [`dex${i}`, [context]]), + ], + }); + await service.refresh(); + expect(service.getSnapshot().dexes).toHaveLength(40); + expect(service.getSnapshot().markets).toHaveLength(40); + expect(requests.filter((r) => r.type === "metaAndAssetCtxs")).toHaveLength( + 0, + ); + expect(requests.filter((r) => r.type === "allPerpMetas")).toHaveLength(1); + stop(); + service.dispose(); + }); + test("two detail panes share one book, emit batches, dedupe trades and stop on last close", async () => { + const { service, sockets } = setup(); + await service.refresh(); + let firstUpdates = 0, + secondUpdates = 0; + const first = service.getMarket("BTC", { interval: "1m" }), + second = service.getMarket("BTC", { interval: "15m" }); + const closeFirst = first.subscribe(() => firstUpdates++), + closeSecond = second.subscribe(() => secondUpdates++); + sockets[0]!.open(); + await Promise.all([first.refresh(), second.refresh()]); + expect( + sockets[0]!.sent.filter( + (m) => m.method === "subscribe" && m.subscription.type === "l2Book", + ), + ).toHaveLength(1); + const trade = { + coin: "BTC", + time: 10, + tid: 2, + px: "101", + sz: "1", + side: "A", + }; + sockets[0]!.receive("trades", [trade, trade]); + sockets[0]!.receive("trades", [trade]); + for (let i = 0; i < 20; i++) + sockets[0]!.receive("l2Book", { + coin: "BTC", + time: 101 + i, + levels: [[{ px: "99", sz: "3", n: 1 }], [{ px: "101", sz: "4", n: 1 }]], + }); + await sleep(20); + expect(first.getSnapshot().trades).toHaveLength(2); + expect(firstUpdates).toBeLessThan(5); + expect(secondUpdates).toBeLessThan(5); + closeFirst(); + expect(sockets[0]!.readyState).toBe(1); + closeSecond(); + expect(sockets[0]!.readyState).toBe(3); + service.dispose(); + }); + test("book aggregation switches all same-market sessions together", async () => { + const { service, sockets } = setup(); + await service.refresh(); + const first = service.getMarket("BTC", { interval: "1m" }), + second = service.getMarket("BTC", { interval: "15m" }); + const a = first.subscribe(() => {}), + b = second.subscribe(() => {}); + sockets[0]!.open(); + await first.refresh(); + service.setBookAggregation("BTC", { nSigFigs: 5, mantissa: 2 }); + await sleep(20); + const subscriptions = sockets[0]!.sent.filter( + (m) => m.subscription?.type === "l2Book", + ); + expect(subscriptions.at(-2)?.method).toBe("unsubscribe"); + expect(subscriptions.at(-1)?.subscription.mantissa).toBe(2); + expect(first.getSnapshot().book?.mantissa).toBe(2); + expect(second.getSnapshot().book?.mantissa).toBe(2); + a(); + b(); + service.dispose(); + }); +}); diff --git a/src/market/service.ts b/src/market/service.ts new file mode 100644 index 0000000..3f2df93 --- /dev/null +++ b/src/market/service.ts @@ -0,0 +1,740 @@ +import { + applyContext, + normalizeBook, + normalizeCandle, + normalizeMarkets, + normalizePredicted, + normalizeTrade, + numberOrNull, + resolveMarket, +} from "./normalize.ts"; +import { InfoClient, SharedWebSocket } from "./transport.ts"; +import type { + AssetClass, + BoardSnapshot, + Candle, + CandleInterval, + Dex, + Market, + MarketOptions, + MarketSession, + MarketSnapshot, + Network, + OrderBook, + PerpAnnotation, + RawContext, + RawDex, + RawMeta, + Trade, +} from "./types.ts"; +export interface MarketServiceOptions { + network: Network; + fetch?: typeof fetch; + info?: InfoClient; + ws?: SharedWebSocket; + pollIntervalMs?: number; + batchMs?: number; +} +type BookConfig = { nSigFigs?: 2 | 3 | 4 | 5 | null; mantissa?: 1 | 2 | 5 }; +const message = (e: unknown) => (e instanceof Error ? e.message : String(e)); +const intervalMs = (interval: CandleInterval) => { + const count = parseInt(interval); + return ( + count * + ({ + m: 60_000, + h: 3_600_000, + d: 86_400_000, + w: 604_800_000, + M: 2_678_400_000, + }[interval.at(-1)!] ?? 60_000) + ); +}; +export class MarketDataService { + readonly network: Network; + readonly info: InfoClient; + readonly ws: SharedWebSocket; + private snapshot: BoardSnapshot = { + markets: [], + dexes: [], + status: "idle", + asOf: null, + error: null, + catalogError: null, + predictedFundings: [], + }; + private listeners = new Set<() => void>(); + private metas = new Map(); + private categories = new Map(); + private rawDexes: (RawDex | null)[] = []; + private tokens = new Map(); + private latestContexts = new Map(); + private contextTime = 0; + private discoveryTime = 0; + private predictionTime = 0; + private refreshing: Promise | null = null; + private releases: (() => void)[] = []; + private midReleases = new Map void>(); + private poll: ReturnType | null = null; + private health: ReturnType | null = null; + private notifyTimer: ReturnType | null = null; + private sessions = new Map(); + private books = new Map(); + private disposed = false; + private readonly pollInterval: number; + private readonly batchMs: number; + constructor(options: MarketServiceOptions) { + this.network = options.network; + this.info = options.info ?? new InfoClient(options.network, options.fetch); + this.ws = options.ws ?? new SharedWebSocket(options.network); + this.pollInterval = options.pollIntervalMs ?? 30_000; + this.batchMs = options.batchMs ?? 200; + } + getSnapshot = (): BoardSnapshot => this.snapshot; + subscribe = (listener: () => void): (() => void) => { + if (this.disposed) throw new Error("Market data service was disposed"); + this.listeners.add(listener); + if (this.listeners.size === 1) this.start(); + let released = false; + return () => { + if (released) return; + released = true; + this.listeners.delete(listener); + if (!this.listeners.size) this.stop(); + }; + }; + async refresh(): Promise { + if (this.refreshing) return this.refreshing; + this.refreshing = this.fetchBoard().finally(() => { + this.refreshing = null; + }); + return this.refreshing; + } + getMarket(coin: string, options: MarketOptions = {}): MarketSession { + const resolved = resolveMarket(this.snapshot.markets, coin)?.coin ?? coin; + const interval = options.interval ?? "15m", + key = `${resolved}|${interval}`; + let session = this.sessions.get(key); + if (!session) { + session = new DetailSession(this, resolved, interval, this.batchMs); + this.sessions.set(key, session); + } + if (options.nSigFigs !== undefined || options.mantissa !== undefined) + this.setBookAggregation(resolved, options); + return session; + } + setBookAggregation(coin: string, config: BookConfig): void { + this.book(coin).setConfig(config); + } + book(coin: string): BookResource { + let book = this.books.get(coin); + if (!book) { + book = new BookResource(this, coin); + this.books.set(coin, book); + } + return book; + } + dispose(): void { + this.disposed = true; + for (const s of this.sessions.values()) s.dispose(); + for (const b of this.books.values()) b.dispose(); + this.listeners.clear(); + this.stop(); + this.ws.dispose(); + } + private start(): void { + this.patch({ status: "connecting" }); + this.releases.push(this.ws.onStatus(() => this.updateHealth())); + this.releases.push( + this.ws.subscribe( + { type: "allDexsAssetCtxs" }, + (data) => this.receiveContexts(data), + () => { + void this.refresh(); + }, + ), + ); + this.syncMids(); + void this.refresh().then(() => this.schedulePoll()); + this.health = setInterval(() => { + this.updateHealth(); + }, 5_000); + } + private stop(): void { + if (this.poll) clearTimeout(this.poll); + if (this.health) clearInterval(this.health); + if (this.notifyTimer) clearTimeout(this.notifyTimer); + this.poll = null; + this.health = null; + this.notifyTimer = null; + for (const release of this.releases) release(); + this.releases = []; + for (const release of this.midReleases.values()) release(); + this.midReleases.clear(); + this.snapshot = { ...this.snapshot, status: "idle" }; + } + private schedulePoll(): void { + if (!this.listeners.size || this.disposed) return; + if (this.poll) clearTimeout(this.poll); + // Discovery growth cannot silently exhaust the shared IP budget. + const interval = Math.max( + this.pollInterval, + ((this.rawDexes.length * 20) / 450) * 60_000, + ); + this.poll = setTimeout(() => { + this.poll = null; + void this.refresh().finally(() => this.schedulePoll()); + }, interval); + } + private updateHealth(): void { + if (!this.listeners.size) return; + const connection = this.ws.getStatus(); + const streamTime = this.ws.getSubscriptionTime({ + type: "allDexsAssetCtxs", + }); + const stale = + this.snapshot.asOf !== null && + (Date.now() - this.snapshot.asOf > 60_000 || + (streamTime !== null && Date.now() - streamTime > 60_000)); + const status = + connection === "live" + ? stale + ? "stale" + : this.snapshot.asOf === null + ? "connecting" + : "live" + : connection; + this.patch({ + status, + ...(this.ws.getError() ? { error: this.ws.getError() } : {}), + }); + } + private patch(patch: Partial): void { + this.snapshot = { ...this.snapshot, ...patch }; + this.notify(); + } + private notify(): void { + if (this.notifyTimer || !this.listeners.size) return; + this.notifyTimer = setTimeout(() => { + this.notifyTimer = null; + for (const fn of this.listeners) { + try { + fn(); + } catch {} + } + }, this.batchMs); + } + private async discover(): Promise { + const results = await Promise.allSettled([ + this.info.request<(RawDex | null)[]>({ type: "perpDexs" }), + this.info.request<{ tokens: { index: number; name: string }[] }>({ + type: "spotMeta", + }), + this.info.request<[string, string][]>({ type: "perpCategories" }), + this.info.request({ type: "allPerpMetas" }), + ]); + if (results[0].status === "rejected") throw results[0].reason; + this.rawDexes = results[0].value; + if (results[1].status === "fulfilled") + this.tokens = new Map( + results[1].value.tokens.map((t) => [t.index, t.name]), + ); + if (results[2].status === "fulfilled") + this.categories = new Map(results[2].value); + if (results[3].status === "fulfilled") + results[3].value.forEach((meta, index) => { + const dex = this.rawDexes[index]; + if (dex !== undefined) this.metas.set(dex?.name ?? "", meta); + }); + this.discoveryTime = Date.now(); + this.syncMids(); + } + private syncMids(): void { + if (!this.listeners.size) return; + const wanted = new Set(this.rawDexes.map((d) => d?.name ?? "")); + for (const [dex, release] of this.midReleases) + if (!wanted.has(dex)) { + release(); + this.midReleases.delete(dex); + } + for (const dex of wanted) + if (!this.midReleases.has(dex)) { + this.midReleases.set( + dex, + this.ws.subscribe({ type: "allMids", dex }, (data) => { + if (!data?.mids) return; + let changed = false; + const markets = this.snapshot.markets.map((m) => { + if (m.dex !== dex) return m; + const mid = numberOrNull(data.mids[m.coin]); + if (mid === null || mid === m.mid) return m; + changed = true; + return { ...m, mid }; + }); + if (changed) this.patch({ markets }); + }), + ); + } + } + private async fetchBoard(): Promise { + try { + if (!this.rawDexes.length || Date.now() - this.discoveryTime > 600_000) + await this.discover(); + if ( + this.rawDexes.length > 20 && + this.metas.size === this.rawDexes.length + ) { + // Testnet has hundreds of deployer dexes. One aggregate stream snapshot is the only + // way to discover all quotes promptly without spending several minutes of REST weight. + await this.aggregateSnapshot(); + const now = Date.now(); + const dexes = this.rawDexes.map((raw, index) => + this.makeDex(raw, index, this.metas.get(raw?.name ?? "")!), + ); + const markets = dexes.flatMap((dex) => + normalizeMarkets( + this.metas.get(dex.name)!, + this.latestContexts.get(dex.name) ?? [], + dex, + this.categories, + now, + ), + ); + this.patch({ + markets, + dexes, + asOf: this.contextTime, + error: null, + catalogError: null, + status: this.listeners.size ? this.ws.getStatus() : "idle", + }); + await this.fetchPredicted(); + return; + } + const started = Date.now(); + const results = await Promise.allSettled( + this.rawDexes.map(async (raw, index) => { + const name = raw?.name ?? ""; + const [meta, contexts] = await this.info.request< + [RawMeta, RawContext[]] + >({ type: "metaAndAssetCtxs", dex: name }); + this.metas.set(name, meta); + const dex = this.makeDex(raw, index, meta); + const previous = new Map( + this.snapshot.markets + .filter((m) => m.dex === name) + .map((m) => [m.coin, m]), + ); + const markets = normalizeMarkets( + meta, + contexts, + dex, + this.categories, + Date.now(), + ).map((m) => { + const live = previous.get(m.coin); + return live && live.asOf > started + ? applyContext( + m, + { + markPx: live.mark ?? undefined, + oraclePx: live.oracle ?? undefined, + midPx: live.mid, + funding: live.fundingHourly ?? undefined, + premium: live.fundingPremium ?? undefined, + openInterest: live.oiCoin ?? undefined, + prevDayPx: live.prevDayPrice ?? undefined, + dayNtlVlm: live.volume24h ?? undefined, + }, + live.asOf, + ) + : m; + }); + return { dex, markets }; + }), + ); + const dexes: Dex[] = [], + markets: Market[] = [], + errors: string[] = []; + results.forEach((r, i) => { + if (r.status === "fulfilled") { + dexes.push(r.value.dex); + markets.push(...r.value.markets); + } else { + const name = this.rawDexes[i]?.name ?? ""; + errors.push(`${name || "Hyperliquid"}: ${message(r.reason)}`); + const previous = this.snapshot.dexes.find((d) => d.name === name); + if (previous) dexes.push(previous); + markets.push(...this.snapshot.markets.filter((m) => m.dex === name)); + } + }); + if (!markets.length && errors.length) throw new Error(errors.join("; ")); + this.patch({ + markets, + dexes, + asOf: markets.length + ? Math.max(...markets.map((m) => m.asOf)) + : Date.now(), + error: errors.length ? errors.join("; ") : null, + catalogError: errors.length ? errors.join("; ") : null, + status: this.listeners.size + ? this.ws.getStatus() === "live" + ? "live" + : this.ws.getStatus() + : "idle", + }); + await this.fetchPredicted(); + } catch (error) { + this.patch({ + error: message(error), + catalogError: message(error), + status: this.snapshot.markets.length ? "stale" : "error", + }); + } + } + private makeDex(raw: RawDex | null, index: number, meta: RawMeta): Dex { + const token = meta.collateralToken ?? 0; + return { + name: raw?.name ?? "", + fullName: raw?.fullName ?? "Hyperliquid", + index, + deployer: raw?.deployer ?? null, + oracleUpdater: raw?.oracleUpdater ?? null, + collateralToken: token, + collateral: this.tokens.get(token) ?? `Token #${token}`, + fundingMultipliers: Object.fromEntries( + (raw?.assetToFundingMultiplier ?? []).map(([coin, m]) => [ + coin, + Number(m), + ]), + ), + }; + } + private async fetchPredicted(): Promise { + if (Date.now() - this.predictionTime <= 60_000) return; + try { + const predicted = await this.info.request({ + type: "predictedFundings", + }); + this.predictionTime = Date.now(); + this.patch({ predictedFundings: normalizePredicted(predicted) }); + } catch (error) { + this.patch({ error: `Predicted funding: ${message(error)}` }); + } + } + private async aggregateSnapshot(): Promise { + if (this.contextTime && Date.now() - this.contextTime < 15_000) return; + await new Promise((resolve, reject) => { + const timer = setTimeout(() => { + release(); + reject( + new Error( + "Waiting for the aggregate market snapshot. The connection will retry.", + ), + ); + }, 20_000); + const release = this.ws.subscribe( + { type: "allDexsAssetCtxs" }, + (data) => { + if (!Array.isArray(data?.ctxs)) return; + this.receiveContexts(data); + clearTimeout(timer); + release(); + resolve(); + }, + ); + }); + } + private receiveContexts(data: any): void { + if (!Array.isArray(data?.ctxs)) return; + const contexts = new Map(data.ctxs), + now = Date.now(); + this.latestContexts = contexts; + this.contextTime = now; + const markets = this.snapshot.markets.map((m) => { + const ctx = contexts.get(m.dex)?.[m.universeIndex]; + return ctx ? applyContext(m, ctx, now) : m; + }); + if (markets.length) this.patch({ markets, asOf: now, status: "live" }); + } +} +class BookResource { + private config: BookConfig = {}; + private value: OrderBook | null = null; + private listeners = new Set<() => void>(); + private release: (() => void) | null = null; + private generation = 0; + error: string | null = null; + constructor( + private service: MarketDataService, + private coin: string, + ) {} + getSnapshot(): OrderBook | null { + return this.value; + } + subscribe(listener: () => void): () => void { + this.listeners.add(listener); + if (this.listeners.size === 1) this.start(); + return () => { + this.listeners.delete(listener); + if (!this.listeners.size) { + this.release?.(); + this.release = null; + } + }; + } + setConfig(config: BookConfig): void { + if (config.mantissa !== undefined && config.nSigFigs !== 5) + throw new Error("Book mantissa requires 5 significant figures"); + const next = { + nSigFigs: config.nSigFigs ?? null, + ...(config.mantissa !== undefined ? { mantissa: config.mantissa } : {}), + }; + if (JSON.stringify(next) === JSON.stringify(this.config)) return; + this.config = next; + this.generation++; + this.value = null; + this.release?.(); + this.release = null; + if (this.listeners.size) this.start(); + this.emit(); + } + async refresh(): Promise { + const generation = this.generation; + try { + const data = await this.service.info.request({ + type: "l2Book", + coin: this.coin, + ...this.config, + }); + if (generation === this.generation) this.accept(data); + } catch (error) { + this.error = message(error); + this.emit(); + } + } + dispose(): void { + this.release?.(); + this.release = null; + this.listeners.clear(); + } + private start(): void { + const generation = this.generation; + this.release = this.service.ws.subscribe( + { type: "l2Book", coin: this.coin, ...this.config }, + (data) => { + if (generation === this.generation) this.accept(data); + }, + () => { + void this.refresh(); + }, + ); + void this.refresh(); + } + private accept(data: any): void { + if (!data || data.coin !== this.coin) return; + const book = normalizeBook(data); + if (this.value && book.time < this.value.time) return; + this.error = null; + this.value = { + ...book, + nSigFigs: this.config.nSigFigs ?? null, + mantissa: this.config.mantissa ?? null, + }; + this.emit(); + } + private emit(): void { + for (const listener of this.listeners) listener(); + } +} +class DetailSession implements MarketSession { + private snapshot: MarketSnapshot = { + market: null, + book: null, + candles: [], + trades: [], + annotation: null, + status: "idle", + asOf: null, + error: null, + }; + private listeners = new Set<() => void>(); + private releases: (() => void)[] = []; + private timer: ReturnType | null = null; + private refreshing: Promise | null = null; + private candleVersions = new Map(); + constructor( + private service: MarketDataService, + private coin: string, + private interval: CandleInterval, + private batchMs: number, + ) {} + getSnapshot = (): MarketSnapshot => this.snapshot; + subscribe = (listener: () => void): (() => void) => { + this.listeners.add(listener); + if (this.listeners.size === 1) this.start(); + let released = false; + return () => { + if (released) return; + released = true; + this.listeners.delete(listener); + if (!this.listeners.size) this.stop(); + }; + }; + async refresh(): Promise { + if (this.refreshing) return this.refreshing; + this.refreshing = this.fetch().finally(() => { + this.refreshing = null; + }); + return this.refreshing; + } + dispose(): void { + this.listeners.clear(); + this.stop(); + } + private start(): void { + const update = () => { + const board = this.service.getSnapshot(); + this.patch({ + market: board.markets.find((m) => m.coin === this.coin) ?? null, + status: board.status, + asOf: board.asOf, + }); + }; + this.releases.push(this.service.subscribe(update)); + update(); + const book = this.service.book(this.coin); + this.releases.push( + book.subscribe(() => + this.patch({ + book: book.getSnapshot(), + ...(book.error ? { error: book.error } : {}), + }), + ), + ); + this.releases.push( + this.service.ws.subscribe( + { type: "candle", coin: this.coin, interval: this.interval }, + (data) => { + const rows = (Array.isArray(data) ? data : [data]) + .map(normalizeCandle) + .filter((c): c is Candle => c !== null); + const byTime = new Map(this.snapshot.candles.map((c) => [c.time, c])); + for (const candle of rows) { + const existing = byTime.get(candle.time); + if (existing && candle.trades < existing.trades) continue; + byTime.set(candle.time, candle); + this.candleVersions.set(candle.time, Date.now()); + } + this.patch({ + candles: [...byTime.values()] + .sort((a, b) => a.time - b.time) + .slice(-600), + }); + }, + () => { + void this.refresh(); + }, + ), + ); + this.releases.push( + this.service.ws.subscribe( + { type: "trades", coin: this.coin }, + (data) => this.acceptTrades(data), + () => { + void this.refresh(); + }, + ), + ); + void this.refresh(); + } + private stop(): void { + for (const release of this.releases) release(); + this.releases = []; + if (this.timer) clearTimeout(this.timer); + this.timer = null; + } + private patch(patch: Partial): void { + this.snapshot = { ...this.snapshot, ...patch }; + if (this.timer || !this.listeners.size) return; + this.timer = setTimeout(() => { + this.timer = null; + for (const listener of this.listeners) listener(); + }, this.batchMs); + } + private acceptTrades(data: any): void { + if (!Array.isArray(data)) return; + const trades = data + .map(normalizeTrade) + .filter((t): t is Trade => t !== null); + const byId = new Map( + [...this.snapshot.trades, ...trades].map((t) => [t.id, t]), + ); + this.patch({ + trades: [...byId.values()] + .sort((a, b) => b.time - a.time || b.id.localeCompare(a.id)) + .slice(0, 120), + }); + } + private async fetch(): Promise { + if (!this.service.getSnapshot().markets.length) + await this.service.refresh(); + const board = this.service.getSnapshot(); + this.patch({ + market: board.markets.find((m) => m.coin === this.coin) ?? null, + status: board.status, + asOf: board.asOf, + }); + const started = Date.now(); + const results = await Promise.allSettled([ + this.service.info.request({ + type: "candleSnapshot", + req: { + coin: this.coin, + interval: this.interval, + startTime: started - 600 * intervalMs(this.interval), + endTime: started, + }, + }), + this.service.info.request({ + type: "recentTrades", + coin: this.coin, + }), + this.service.info.request({ + type: "perpAnnotation", + coin: this.coin, + }), + this.service.book(this.coin).refresh(), + ]); + const errors: string[] = []; + const candles = results[0]; + if (candles.status === "fulfilled") { + const byTime = new Map( + candles.value + .map(normalizeCandle) + .filter((c): c is Candle => c !== null) + .map((c) => [c.time, c]), + ); + for (const candle of this.snapshot.candles) + if ((this.candleVersions.get(candle.time) ?? 0) >= started) + byTime.set(candle.time, candle); + this.patch({ + candles: [...byTime.values()] + .sort((a, b) => a.time - b.time) + .slice(-600), + }); + } else errors.push(`Candles: ${message(candles.reason)}`); + const trades = results[1]; + if (trades.status === "fulfilled") this.acceptTrades(trades.value); + else errors.push(`Trades: ${message(trades.reason)}`); + const annotation = results[2]; + if (annotation.status === "fulfilled") + this.patch({ annotation: annotation.value }); + if (this.service.book(this.coin).error) + errors.push(this.service.book(this.coin).error!); + this.patch({ + book: this.service.book(this.coin).getSnapshot(), + error: errors.length ? errors.join("; ") : null, + }); + } +} diff --git a/src/market/transport.test.ts b/src/market/transport.test.ts new file mode 100644 index 0000000..ebb3193 --- /dev/null +++ b/src/market/transport.test.ts @@ -0,0 +1,473 @@ +import { describe, expect, test } from "bun:test"; +import { InfoClient, InfoError, SharedWebSocket, infoWeight } from "./transport.ts"; +import type { InfoClock, SocketLike, StreamClock } from "./transport.ts"; +class FakeSocket implements SocketLike { + readyState = 0; + onopen: ((e: any) => void) | null = null; + onclose: ((e: any) => void) | null = null; + onerror: ((e: any) => void) | null = null; + onmessage: ((e: any) => void) | null = null; + sent: any[] = []; + send(data: string) { + const m = JSON.parse(data); + this.sent.push(m); + if (m.method === "subscribe") this.receive("subscriptionResponse", m); + } + close() { + this.readyState = 3; + this.onclose?.({}); + } + open() { + this.readyState = 1; + this.onopen?.({}); + } + receive(channel: string, data: any) { + this.onmessage?.({ data: JSON.stringify({ channel, data }) }); + } +} +const delay = (ms: number) => new Promise((r) => setTimeout(r, ms)); +class FakeClock implements StreamClock { + time = 1000; + next = 1; + jobs = new Map< + number, + { callback: () => void; at: number; repeat: number } + >(); + now = () => this.time; + timeout = (callback: () => void, ms: number) => { + const id = this.next++; + this.jobs.set(id, { callback, at: this.time + ms, repeat: 0 }); + return id as unknown as ReturnType; + }; + interval = (callback: () => void, ms: number) => { + const id = this.next++; + this.jobs.set(id, { callback, at: this.time + ms, repeat: ms }); + return id as unknown as ReturnType; + }; + clearTimeout = (id: ReturnType) => { + this.jobs.delete(id as unknown as number); + }; + clearInterval = (id: ReturnType) => { + this.jobs.delete(id as unknown as number); + }; + advance(ms: number) { + const end = this.time + ms; + for (;;) { + const first = [...this.jobs] + .filter(([, job]) => job.at <= end) + .sort((a, b) => a[1].at - b[1].at)[0]; + if (!first) break; + const [id, job] = first; + this.time = job.at; + if (job.repeat) job.at += job.repeat; + else this.jobs.delete(id); + job.callback(); + } + this.time = end; + } +} +function makeHub( + timings = { heartbeat: 1000, stale: 2000, reconnect: 1, maxReconnect: 2 }, +) { + const sockets: FakeSocket[] = []; + const clock = new FakeClock(); + const hub = new SharedWebSocket( + "testnet", + () => { + const s = new FakeSocket(); + sockets.push(s); + return s; + }, + timings, + clock, + ); + return { hub, sockets, clock }; +} +function restClock(clock: FakeClock): InfoClock { + return { + now: clock.now, + sleep: (ms, signal) => + new Promise((resolve, reject) => { + signal?.throwIfAborted(); + const abort = () => { + clock.clearTimeout(timer); + reject(signal?.reason); + }; + const timer = clock.timeout(() => { + signal?.removeEventListener("abort", abort); + resolve(); + }, ms); + signal?.addEventListener("abort", abort, { once: true }); + }), + }; +} +async function flushMicrotasks() { + for (let i = 0; i < 12; i++) await Promise.resolve(); +} +async function advanceRestClock(clock: FakeClock, ms: number) { + const end = clock.time + ms; + await flushMicrotasks(); + for (;;) { + const next = Math.min(...[...clock.jobs.values()].map((job) => job.at)); + if (next > end) break; + clock.advance(next - clock.time); + await flushMicrotasks(); + } + clock.advance(end - clock.time); + await flushMicrotasks(); +} +describe("shared WebSocket lifecycle", () => { + test("deduplicates subscriptions and unsubscribes only when final listener closes", () => { + const { hub, sockets } = makeHub(); + const received: number[] = []; + const a = hub.subscribe({ type: "trades", coin: "BTC" }, () => + received.push(1), + ); + const b = hub.subscribe({ coin: "BTC", type: "trades" }, () => + received.push(2), + ); + expect(sockets).toHaveLength(1); + sockets[0]!.open(); + expect(sockets[0]!.sent).toHaveLength(1); + sockets[0]!.receive("trades", [{ coin: "BTC" }]); + expect(received).toEqual([1, 2]); + a(); + expect(sockets[0]!.sent).toHaveLength(1); + b(); + expect(sockets[0]!.sent.at(-1)?.method).toBe("unsubscribe"); + expect(hub.getStatus()).toBe("idle"); + expect(sockets[0]!.readyState).toBe(3); + hub.dispose(); + }); + test("reconnects, resubscribes, refetches and ignores superseded sockets", () => { + const { hub, sockets, clock } = makeHub(); + let refetched = 0, + updates = 0; + const release = hub.subscribe( + { type: "allDexsAssetCtxs" }, + () => updates++, + () => refetched++, + ); + sockets[0]!.open(); + expect(refetched).toBe(0); + sockets[0]!.close(); + expect(hub.getStatus()).toBe("reconnecting"); + clock.advance(12); + expect(sockets).toHaveLength(2); + sockets[1]!.open(); + expect(sockets[1]!.sent).toEqual([ + { method: "subscribe", subscription: { type: "allDexsAssetCtxs" } }, + ]); + expect(refetched).toBe(1); + sockets[0]!.receive("allDexsAssetCtxs", {}); + expect(updates).toBe(0); + sockets[1]!.receive("allDexsAssetCtxs", {}); + expect(updates).toBe(1); + release(); + hub.dispose(); + }); + test("rejects stale book timestamps and routes by coin without losing sibling streams", () => { + const { hub, sockets } = makeHub(); + const times: number[] = []; + let eth = 0; + hub.subscribe({ type: "l2Book", coin: "BTC" }, (d) => times.push(d.time)); + hub.subscribe({ type: "l2Book", coin: "ETH" }, () => eth++); + sockets[0]!.open(); + for (const time of [100, 99, 101]) + sockets[0]!.receive("l2Book", { coin: "BTC", time }); + expect(times).toEqual([100, 101]); + expect(eth).toBe(0); + hub.dispose(); + }); + test("book aggregation waits for the matching subscription acknowledgement", () => { + const { hub, sockets } = makeHub(); + let updates = 0; + const close = hub.subscribe( + { type: "l2Book", coin: "BTC", nSigFigs: 3 }, + () => updates++, + ); + sockets[0]!.open(); + sockets[0]!.receive("l2Book", { coin: "BTC", time: 1 }); + expect(updates).toBe(1); + // Keep a different feed open while replacing the aggregation. + hub.subscribe({ type: "trades", coin: "BTC" }, () => {}); + close(); + sockets[0]!.send = (data) => sockets[0]!.sent.push(JSON.parse(data)); + hub.subscribe( + { type: "l2Book", coin: "BTC", nSigFigs: 5 }, + () => updates++, + ); + sockets[0]!.receive("l2Book", { coin: "BTC", time: 2 }); + expect(updates).toBe(1); + sockets[0]!.receive("subscriptionResponse", { + method: "subscribe", + subscription: { + type: "l2Book", + coin: "BTC", + nSigFigs: 5, + mantissa: null, + fast: false, + }, + }); + sockets[0]!.receive("l2Book", { coin: "BTC", time: 3 }); + expect(updates).toBe(2); + hub.dispose(); + }); + test("silence triggers ping then reconnect, and final unsubscribe cancels backoff", () => { + const { hub, sockets, clock } = makeHub({ + heartbeat: 5, + stale: 12, + reconnect: 10, + maxReconnect: 10, + }); + const release = hub.subscribe({ type: "allMids" }, () => {}); + sockets[0]!.open(); + clock.advance(22); + expect(sockets[0]!.sent.some((m) => m.method === "ping")).toBe(true); + expect(sockets[0]!.readyState).toBe(3); + release(); + const count = sockets.length; + clock.advance(25); + expect(sockets).toHaveLength(count); + hub.dispose(); + }); + test("prevents indistinguishable books and account packets being cross-routed", () => { + const { hub } = makeHub(); + hub.subscribe({ type: "l2Book", coin: "BTC", nSigFigs: 3 }, () => {}); + expect(() => + hub.subscribe({ type: "l2Book", coin: "BTC", nSigFigs: 5 }, () => {}), + ).toThrow("aggregation"); + hub.subscribe({ type: "orderUpdates", user: "0x1" }, () => {}); + expect(() => + hub.subscribe({ type: "orderUpdates", user: "0x2" }, () => {}), + ).toThrow("another address"); + hub.dispose(); + }); +}); +describe("public REST transport", () => { + const rateLimitMessage = + "Hyperliquid rate limited the request. Retry in a minute."; + test("uses documented weight classes and deduplicates concurrent reads", async () => { + expect(infoWeight({ type: "l2Book" })).toBe(2); + expect(infoWeight({ type: "userRole" })).toBe(60); + expect(infoWeight({ type: "metaAndAssetCtxs" })).toBe(20); + let requests = 0; + const client = new InfoClient("testnet", (async (url: any) => { + requests++; + expect(String(url)).toBe("https://api.hyperliquid-testnet.xyz/info"); + await delay(5); + return Response.json({ ok: true }); + }) as unknown as typeof fetch); + const [a, b] = await Promise.all([ + client.request({ type: "meta" }), + client.request({ type: "meta" }), + ]); + expect(a).toEqual({ ok: true }); + expect(a).toBe(b); + expect(requests).toBe(1); + }); + test("aborted requests cannot wait forever in the local weight queue", async () => { + const client = new InfoClient( + "testnet", + (async () => Response.json({})) as unknown as typeof fetch, + 20, + ); + await client.request({ type: "meta" }); + const controller = new AbortController(); + const promise = client.request( + { type: "spotMeta" }, + { signal: controller.signal }, + ); + controller.abort(new Error("Cancelled queue")); + await expect(promise).rejects.toThrow("Cancelled queue"); + }); + for (const header of ["seconds", "date"] as const) { + test(`recovers after 429 and honors Retry-After ${header}`, async () => { + const clock = new FakeClock(); + const requests: number[] = []; + const client = new InfoClient( + "testnet", + (async () => { + requests.push(clock.now()); + return requests.length === 1 + ? new Response("busy", { + status: 429, + headers: { + "retry-after": header === "seconds" + ? "7" + : new Date(clock.now() + 7000).toUTCString(), + }, + }) + : Response.json({ ok: true }); + }) as unknown as typeof fetch, + 900, + restClock(clock), + ); + const result = client.request({ type: "meta" }); + await advanceRestClock(clock, 6999); + expect(requests).toEqual([1000]); + await advanceRestClock(clock, 1); + expect(await result).toEqual({ ok: true }); + expect(requests).toEqual([1000, 8000]); + }); + } + test("exhausts three attempts with bounded backoff and an actionable 429", async () => { + const clock = new FakeClock(); + const requests: number[] = []; + const client = new InfoClient( + "testnet", + (async () => { + requests.push(clock.now()); + return new Response("provider details", { + status: 429, + headers: { "retry-after": "invalid" }, + }); + }) as unknown as typeof fetch, + 900, + restClock(clock), + ); + const result = client.request({ type: "meta" }).catch((error) => error); + await advanceRestClock(clock, 6000); + const error = await result; + expect(error).toBeInstanceOf(InfoError); + if (!(error instanceof InfoError)) throw error; + expect(error.status).toBe(429); + expect(error.message).toBe(rateLimitMessage); + expect(requests).toEqual([1000, 3000, 7000]); + expect(clock.jobs.size).toBe(0); + }); + test("does not retry before a server cooldown beyond the bounded retry window", async () => { + const clock = new FakeClock(); + let requests = 0; + const client = new InfoClient( + "testnet", + (async () => { + requests++; + return new Response(null, { + status: 429, + headers: { "retry-after": "120" }, + }); + }) as unknown as typeof fetch, + 900, + restClock(clock), + ); + await expect(client.request({ type: "meta" })).rejects.toThrow(rateLimitMessage); + await expect(client.request({ type: "spotMeta" })).rejects.toThrow(rateLimitMessage); + expect(requests).toBe(1); + expect(clock.jobs.size).toBe(0); + }); + test("allows a full minute Retry-After even when its timer wakes late", async () => { + const clock = new FakeClock(); + const requests: number[] = []; + const client = new InfoClient( + "testnet", + (async () => { + requests.push(clock.now()); + return requests.length === 1 + ? new Response(null, { + status: 429, + headers: { "retry-after": "60" }, + }) + : Response.json({ ok: true }); + }) as unknown as typeof fetch, + 900, + restClock(clock), + ); + const result = client.request({ type: "meta" }); + await advanceRestClock(clock, 59_000); + // Emulate a timer dispatched 5ms late, without rewinding the clock to its due time. + clock.time += 1005; + for (const [id, job] of [...clock.jobs]) { + if (job.at > clock.time) continue; + clock.jobs.delete(id); + job.callback(); + } + expect(await result).toEqual({ ok: true }); + expect(requests).toEqual([1000, 61005]); + }); + test("local weight queueing before the first 429 does not consume the retry window", async () => { + const clock = new FakeClock(); + const requests: number[] = []; + const client = new InfoClient( + "testnet", + (async () => { + requests.push(clock.now()); + return requests.length === 2 + ? new Response(null, { status: 429 }) + : Response.json({ ok: true }); + }) as unknown as typeof fetch, + 40, + restClock(clock), + ); + await client.request({ type: "bootstrap" }, { weight: 40 }); + const result = client.request({ type: "meta" }); + await advanceRestClock(clock, 60_000); + expect(requests).toEqual([1000, 61000]); + await advanceRestClock(clock, 2000); + expect(await result).toEqual({ ok: true }); + expect(requests).toEqual([1000, 61000, 63000]); + }); + test("a concurrent 429 cannot shorten a longer shared cooldown", async () => { + const clock = new FakeClock(); + const requests: { type: string; time: number }[] = []; + const client = new InfoClient( + "testnet", + (async (_url: unknown, init: RequestInit) => { + const { type } = JSON.parse(String(init.body)); + const first = !requests.some((request) => request.type === type); + requests.push({ type, time: clock.now() }); + return first + ? new Response(null, { + status: 429, + headers: { "retry-after": type === "meta" ? "10" : "2" }, + }) + : Response.json({ type }); + }) as unknown as typeof fetch, + 900, + restClock(clock), + ); + const results = Promise.all([ + client.request({ type: "meta" }), + client.request({ type: "spotMeta" }), + ]); + await advanceRestClock(clock, 9999); + expect(requests).toHaveLength(2); + await advanceRestClock(clock, 1); + expect(await results).toEqual([{ type: "meta" }, { type: "spotMeta" }]); + expect(requests.map((request) => request.time)).toEqual([1000, 1000, 11000, 11000]); + }); + test("aborting during a rate-limit cooldown prevents another fetch", async () => { + const clock = new FakeClock(); + const controller = new AbortController(); + let requests = 0; + const client = new InfoClient( + "testnet", + (async () => { + requests++; + return new Response(null, { status: 429 }); + }) as unknown as typeof fetch, + 900, + restClock(clock), + ); + const result = client.request( + { type: "meta" }, + { signal: controller.signal }, + ); + await flushMicrotasks(); + expect(clock.jobs.size).toBe(1); + controller.abort(new Error("Cancelled cooldown")); + await expect(result).rejects.toThrow("Cancelled cooldown"); + await advanceRestClock(clock, 10_000); + expect(requests).toBe(1); + expect(clock.jobs.size).toBe(0); + }); + test("does not retry other HTTP failures", async () => { + let requests = 0; + const client = new InfoClient("testnet", (async () => { + requests++; + return new Response("unavailable", { status: 503 }); + }) as unknown as typeof fetch); + await expect(client.request({ type: "meta" })).rejects.toThrow("503 unavailable"); + expect(requests).toBe(1); + }); +}); diff --git a/src/market/transport.ts b/src/market/transport.ts new file mode 100644 index 0000000..aaf4d30 --- /dev/null +++ b/src/market/transport.ts @@ -0,0 +1,550 @@ +import type { LiveStatus, Network } from "./types.ts"; +export const ENDPOINTS = { + mainnet: "https://api.hyperliquid.xyz", + testnet: "https://api.hyperliquid-testnet.xyz", +} as const; +const lowWeight = new Set([ + "l2Book", + "allMids", + "clearinghouseState", + "orderStatus", + "spotClearinghouseState", + "exchangeStatus", +]); +const historyWeight = new Set([ + "recentTrades", + "historicalOrders", + "userFills", + "userFillsByTime", + "fundingHistory", + "userFunding", + "nonUserFundingUpdates", + "twapHistory", + "userTwapSliceFills", + "userTwapSliceFillsByTime", + "delegatorHistory", + "delegatorRewards", + "validatorStats", +]); +export function infoWeight(body: Record): number { + return body.type === "userRole" + ? 60 + : lowWeight.has(String(body.type)) + ? 2 + : 20; +} +function sleep(ms: number, signal?: AbortSignal): Promise { + return new Promise((resolve, reject) => { + if (signal?.aborted) { + reject(signal.reason ?? new Error("Aborted")); + return; + } + const abort = () => { + clearTimeout(timer); + reject(signal?.reason ?? new Error("Aborted")); + }; + const timer = setTimeout(() => { + signal?.removeEventListener("abort", abort); + resolve(); + }, ms); + signal?.addEventListener("abort", abort, { once: true }); + }); +} +export class InfoError extends Error { + constructor( + message: string, + readonly status: number, + ) { + super(message); + this.name = "InfoError"; + } +} +export interface InfoClock { + now(): number; + sleep(ms: number, signal?: AbortSignal): Promise; +} +const infoClock: InfoClock = { now: () => Date.now(), sleep }; +const RATE_LIMIT_MESSAGE = + "Hyperliquid rate limited the request. Retry in a minute."; +const RETRY_WINDOW_MS = 60_000; +function retryAfterMs(value: string | null, now: number): number { + if (!value?.trim()) return 0; + const seconds = Number(value); + if (Number.isFinite(seconds)) return Math.max(0, seconds * 1000); + const date = Date.parse(value); + return Number.isFinite(date) ? Math.max(0, date - now) : 0; +} +/** One sliding-window budget for all market and account reads. Keeps 300 weight/min for other clients. */ +export class InfoClient { + private spent: { time: number; weight: number }[] = []; + private inFlight = 0; + private cooldownUntil = 0; + private pending = new Map>(); + constructor( + readonly network: Network, + private fetcher: typeof fetch = fetch, + private budget = 900, + private clock: InfoClock = infoClock, + ) {} + request( + body: Record, + options: { signal?: AbortSignal; weight?: number } = {}, + ): Promise { + const key = JSON.stringify(body); + const existing = !options.signal && this.pending.get(key); + if (existing) return existing as Promise; + const promise = this.perform(body, options); + if (!options.signal) { + this.pending.set(key, promise); + void promise + .finally(() => { + if (this.pending.get(key) === promise) this.pending.delete(key); + }) + .catch(() => {}); + } + return promise; + } + private async reserve( + weight: number, + signal: AbortSignal | undefined, + retryUntil?: number, + ) { + if (weight > this.budget) + throw new Error("Request exceeds local REST weight budget"); + for (;;) { + if (signal?.aborted) throw signal.reason ?? new Error("Aborted"); + const now = this.clock.now(); + if (now < this.cooldownUntil) retryUntil ??= now + RETRY_WINDOW_MS; + if ( + retryUntil !== undefined && + this.cooldownUntil > retryUntil + ) + throw new InfoError(RATE_LIMIT_MESSAGE, 429); + this.spent = this.spent.filter((s) => now - s.time < 60_000); + const used = this.spent.reduce((sum, s) => sum + s.weight, 0); + if ( + this.inFlight < 3 && + used + weight <= this.budget && + now >= this.cooldownUntil + ) { + this.spent.push({ time: now, weight }); + this.inFlight++; + return retryUntil; + } + // A permitted cooldown may wake late. Retry if ready; do not extend queueing. + if (retryUntil !== undefined && now >= retryUntil) + throw new InfoError(RATE_LIMIT_MESSAGE, 429); + const wait = + now < this.cooldownUntil + ? this.cooldownUntil - now + : used + weight > this.budget + ? 60_010 - (now - (this.spent[0]?.time ?? now)) + : 25; + await this.clock.sleep(Math.max(25, Math.min(wait, 1000)), signal); + } + } + private async perform( + body: Record, + options: { signal?: AbortSignal; weight?: number }, + ): Promise { + // Only idempotent /info reads retry, with three attempts and a bounded cooldown window. + // Ordinary local-budget queueing before the first 429 does not consume that window. + let retryUntil: number | undefined; + for (let attempt = 0; attempt < 3; attempt++) { + retryUntil = await this.reserve( + options.weight ?? infoWeight(body), + options.signal, + retryUntil, + ); + try { + const timeout = AbortSignal.timeout(20_000); + const response = await this.fetcher(`${ENDPOINTS[this.network]}/info`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + signal: options.signal + ? AbortSignal.any([options.signal, timeout]) + : timeout, + }); + if (response.status === 429) { + const now = this.clock.now(); + retryUntil ??= now + RETRY_WINDOW_MS; + this.cooldownUntil = Math.max( + this.cooldownUntil, + now + + Math.max( + retryAfterMs(response.headers.get("retry-after"), now), + 2_000 * 2 ** attempt, + ), + ); + await response.body?.cancel().catch(() => {}); + if (attempt === 2) throw new InfoError(RATE_LIMIT_MESSAGE, 429); + continue; + } + if (!response.ok) + throw new InfoError( + `Hyperliquid ${String(body.type)}: ${response.status} ${(await response.text()).slice(0, 240)}`, + response.status, + ); + const result: T = await response.json(); + if (Array.isArray(result)) { + const divisor = + body.type === "candleSnapshot" + ? 60 + : historyWeight.has(String(body.type)) + ? 20 + : 0; + if (divisor) + this.spent.push({ + time: this.clock.now(), + weight: Math.ceil(result.length / divisor), + }); + } + return result; + } finally { + this.inFlight--; + } + } + throw new InfoError(RATE_LIMIT_MESSAGE, 429); + } +} +export interface SocketLike { + readyState: number; + onopen: ((event: any) => void) | null; + onclose: ((event: any) => void) | null; + onerror: ((event: any) => void) | null; + onmessage: ((event: any) => void) | null; + send(data: string): void; + close(): void; +} +export type WsSubscription = { type: string; [key: string]: unknown }; +type Subscriber = { handler: (data: any) => void; reconnect?: () => void }; +type Entry = { + subscription: WsSubscription; + listeners: Set; + lastReceived: number; + lastTimestamp: number; + acknowledged: boolean; +}; +function stableKey(subscription: WsSubscription): string { + return JSON.stringify( + Object.fromEntries( + Object.entries(subscription) + .filter(([, v]) => v !== undefined) + .sort(([a], [b]) => a.localeCompare(b)), + ), + ); +} +export interface StreamClock { + now(): number; + timeout(callback: () => void, ms: number): ReturnType; + interval(callback: () => void, ms: number): ReturnType; + clearTimeout(id: ReturnType): void; + clearInterval(id: ReturnType): void; +} +const streamClock: StreamClock = { + now: () => Date.now(), + timeout: (fn, ms) => setTimeout(fn, ms), + interval: (fn, ms) => setInterval(fn, ms), + clearTimeout: (id) => clearTimeout(id), + clearInterval: (id) => clearInterval(id), +}; +/** Single ref-counted socket. Public feed messages have no sequence numbers; timestamped snapshots reject regressions. */ +export class SharedWebSocket { + private entries = new Map(); + private socket: SocketLike | null = null; + private reconnectTimer: ReturnType | null = null; + private heartbeat: ReturnType | null = null; + private attempts = 0; + private opened = false; + private lastMessage = 0; + private connectedAt = 0; + private status: LiveStatus = "idle"; + private statusListeners = new Set<() => void>(); + private lastError: string | null = null; + constructor( + readonly network: Network, + private factory: (url: string) => SocketLike = (url) => new WebSocket(url), + private timings = { + heartbeat: 15_000, + stale: 35_000, + reconnect: 750, + maxReconnect: 30_000, + }, + private clock: StreamClock = streamClock, + ) {} + getStatus(): LiveStatus { + return this.status; + } + getError(): string | null { + return this.lastError; + } + getSubscriptionTime(subscription: WsSubscription): number | null { + return this.entries.get(stableKey(subscription))?.lastReceived || null; + } + onStatus(listener: () => void): () => void { + this.statusListeners.add(listener); + return () => this.statusListeners.delete(listener); + } + subscribe( + subscription: WsSubscription, + handler: (data: any) => void, + onReconnect?: () => void, + ): () => void { + const key = stableKey(subscription); + let entry = this.entries.get(key); + if (!entry) { + if (this.entries.size >= 950) + throw new Error("Hyperliquid subscription limit reached"); + const users = new Set( + [...this.entries.values()] + .map((e) => e.subscription.user) + .filter(Boolean), + ); + if ( + subscription.user && + !users.has(subscription.user) && + users.size >= 10 + ) + throw new Error("Hyperliquid supports at most 10 users per connection"); + // These payloads lack a user identifier. Mixing accounts would misroute private account data. + if ( + ["orderUpdates", "userEvents", "notification"].includes( + subscription.type, + ) && + [...this.entries.values()].some( + (e) => + e.subscription.type === subscription.type && + e.subscription.user !== subscription.user, + ) + ) + throw new Error("This account stream already watches another address"); + if ( + subscription.type === "l2Book" && + [...this.entries.values()].some( + (e) => + e.subscription.type === "l2Book" && + e.subscription.coin === subscription.coin, + ) + ) + throw new Error("Book aggregation must be shared for this market"); + entry = { + subscription, + listeners: new Set(), + lastReceived: 0, + lastTimestamp: 0, + acknowledged: false, + }; + this.entries.set(key, entry); + if (this.socket?.readyState === 1) this.send("subscribe", subscription); + } + const listener = { handler, reconnect: onReconnect }; + entry.listeners.add(listener); + if (!this.socket && !this.reconnectTimer) this.connect(); + let released = false; + return () => { + if (released) return; + released = true; + const current = this.entries.get(key); + current?.listeners.delete(listener); + if (current?.listeners.size === 0) { + this.send("unsubscribe", subscription); + this.entries.delete(key); + } + if (this.entries.size === 0) this.stop(); + }; + } + /** Used by diagnostics and network recovery, never sends a trading action. */ + reconnect(): void { + if (!this.entries.size) return; + const old = this.socket; + this.socket = null; + old?.close(); + this.scheduleReconnect(); + } + dispose(): void { + this.entries.clear(); + this.stop(); + this.statusListeners.clear(); + } + private setStatus(status: LiveStatus): void { + if (status === this.status) return; + this.status = status; + for (const f of this.statusListeners) { + try { + f(); + } catch {} + } + } + private send(method: string, subscription?: WsSubscription): void { + if (this.socket?.readyState === 1) { + try { + this.socket.send( + JSON.stringify(subscription ? { method, subscription } : { method }), + ); + } catch { + this.reconnect(); + } + } + } + private connect(): void { + if (!this.entries.size) return; + this.setStatus(this.opened ? "reconnecting" : "connecting"); + let socket: SocketLike; + try { + socket = this.factory( + `${ENDPOINTS[this.network].replace("https:", "wss:")}/ws`, + ); + } catch (error) { + this.lastError = String(error); + this.scheduleReconnect(); + return; + } + this.socket = socket; + socket.onopen = () => { + if (this.socket !== socket) return; + const isReconnect = this.opened; + this.opened = true; + this.connectedAt = this.clock.now(); + this.lastMessage = this.clock.now(); + this.lastError = null; + this.setStatus("live"); + for (const entry of this.entries.values()) { + entry.lastTimestamp = 0; + entry.acknowledged = false; + this.send("subscribe", entry.subscription); + if (isReconnect) + for (const listener of entry.listeners) { + try { + listener.reconnect?.(); + } catch {} + } + } + if (this.heartbeat) this.clock.clearInterval(this.heartbeat); + this.heartbeat = this.clock.interval(() => { + if (this.clock.now() - this.lastMessage > this.timings.stale) { + this.setStatus("stale"); + this.reconnect(); + } else this.send("ping"); + }, this.timings.heartbeat); + }; + socket.onmessage = (event) => { + if (this.socket !== socket) return; + this.lastMessage = this.clock.now(); + if (this.lastMessage - this.connectedAt > 60_000) this.attempts = 0; + let message: any; + try { + message = JSON.parse(String(event.data)); + } catch { + return; + } + if (message.channel === "error") { + this.lastError = String(message.data); + this.setStatus("error"); + return; + } + if (message.channel === "pong") return; + if (message.channel === "subscriptionResponse") { + if (message.data?.method === "subscribe") + for (const entry of this.entries.values()) { + const actual = message.data.subscription; + if ( + actual && + Object.entries(entry.subscription).every( + ([key, value]) => (actual[key] ?? null) === (value ?? null), + ) + ) + entry.acknowledged = true; + } + return; + } + const channel = + message.channel === "user" ? "userEvents" : message.channel, + data = message.data; + for (const entry of this.entries.values()) { + const s = entry.subscription; + if (s.type !== channel) continue; + // Book payloads do not identify aggregation. Ignore in-flight old snapshots until + // the server acknowledges the replacement subscription. + if (s.type === "l2Book" && !entry.acknowledged) continue; + const coin = + data?.coin ?? + data?.s ?? + (Array.isArray(data) ? data[0]?.coin : undefined); + if (s.coin !== undefined && coin !== s.coin) continue; + const interval = + data?.i ?? (Array.isArray(data) ? data[0]?.i : undefined); + if (s.interval !== undefined && interval !== s.interval) continue; + if ( + s.user !== undefined && + data?.user !== undefined && + String(s.user).toLowerCase() !== String(data.user).toLowerCase() + ) + continue; + if ( + s.dex !== undefined && + data?.dex !== undefined && + s.dex !== data.dex + ) + continue; + const stamp = Number(data?.time ?? 0); + if (stamp && stamp < entry.lastTimestamp) continue; + if (stamp) entry.lastTimestamp = stamp; + entry.lastReceived = this.clock.now(); + for (const listener of entry.listeners) { + try { + listener.handler(data); + } catch (error) { + this.lastError = `Stream data: ${String(error)}`; + } + } + } + }; + socket.onerror = () => { + if (this.socket === socket) { + this.lastError = "Hyperliquid WebSocket connection failed"; + this.reconnect(); + } + }; + socket.onclose = () => { + if (this.socket === socket) { + this.socket = null; + this.scheduleReconnect(); + } + }; + } + private scheduleReconnect(): void { + if (this.heartbeat) { + this.clock.clearInterval(this.heartbeat); + this.heartbeat = null; + } + if (!this.entries.size) { + this.setStatus("idle"); + return; + } + if (this.reconnectTimer) return; + this.setStatus("reconnecting"); + const delay = + Math.min( + this.timings.maxReconnect, + this.timings.reconnect * 2 ** Math.min(this.attempts++, 8), + ) * + (0.8 + Math.random() * 0.4); + this.reconnectTimer = this.clock.timeout(() => { + this.reconnectTimer = null; + this.connect(); + }, delay); + } + private stop(): void { + if (this.reconnectTimer) this.clock.clearTimeout(this.reconnectTimer); + if (this.heartbeat) this.clock.clearInterval(this.heartbeat); + this.reconnectTimer = null; + this.heartbeat = null; + const socket = this.socket; + this.socket = null; + socket?.close(); + this.opened = false; + this.attempts = 0; + this.setStatus("idle"); + } +} diff --git a/src/market/types.ts b/src/market/types.ts new file mode 100644 index 0000000..a69bd65 --- /dev/null +++ b/src/market/types.ts @@ -0,0 +1,220 @@ +export type Network = "mainnet" | "testnet"; +export type LiveStatus = + | "idle" + | "connecting" + | "live" + | "reconnecting" + | "stale" + | "error"; +export type AssetClass = + | "Stocks" + | "Indices" + | "Energy" + | "Metals" + | "FX" + | "Crypto" + | "Other"; +export const CANDLE_INTERVALS = [ + "1m", + "3m", + "5m", + "15m", + "30m", + "1h", + "2h", + "4h", + "8h", + "12h", + "1d", + "3d", + "1w", + "1M", +] as const; +export type CandleInterval = (typeof CANDLE_INTERVALS)[number]; +export interface MarginTier { + lowerBound: number; + maxLeverage: number; +} +export interface Dex { + name: string; + fullName: string; + index: number; + deployer: string | null; + oracleUpdater: string | null; + collateral: string; + collateralToken: number; + fundingMultipliers: Record; +} +/** All rates/changes/premiums are fractions, never percent points. Missing data stays null. */ +export interface Market { + coin: string; + symbol: string; + dex: string; + assetId: number; + universeIndex: number; + assetClass: AssetClass; + category: string | null; + alwaysOpen: boolean; + underlyingSymbol: string | null; + maxLeverage: number; + onlyIsolated: boolean; + marginMode: string | null; + collateral: string; + collateralToken: number; + szDecimals: number; + marginTiers: MarginTier[]; + deployer: string | null; + oracleUpdater: string | null; + fundingCap: number; + fundingMultiplier: number | null; + growthMode: string | null; + deployerFeeScale: number | null; + mark: number | null; + oracle: number | null; + mid: number | null; + premium: number | null; + /** Venue funding premium differs from the simple mark/oracle premium. */ + fundingPremium: number | null; + change24h: number | null; + prevDayPrice: number | null; + fundingHourly: number | null; + funding8h: number | null; + fundingApr: number | null; + oiCoin: number | null; + oiUsd: number | null; + volume24h: number | null; + oiVolume: number | null; + asOf: number; + priceTime: number; + tickDirection: -1 | 0 | 1; +} +export interface PredictedFunding { + coin: string; + venue: string; + rate: number; + intervalHours: number; + per8h: number; + apr: number; + nextFundingTime: number; +} +export interface BoardSnapshot { + markets: Market[]; + dexes: Dex[]; + status: LiveStatus; + asOf: number | null; + error: string | null; + /** Catalog completeness failures, separate from optional analytics errors. */ + catalogError?: string | null; + predictedFundings: PredictedFunding[]; +} +export interface Candle { + time: number; + endTime: number; + open: number; + high: number; + low: number; + close: number; + volume: number; + trades: number; +} +export interface BookLevel { + price: number; + size: number; + orders: number; + totalSize: number; + totalUsd: number; +} +export interface OrderBook { + coin: string; + bids: BookLevel[]; + asks: BookLevel[]; + spread: number | null; + spreadBps: number | null; + mid: number | null; + time: number; + nSigFigs?: 2 | 3 | 4 | 5 | null; + mantissa?: 1 | 2 | 5 | null; +} +export interface Trade { + id: string; + coin: string; + side: "buy" | "sell"; + price: number; + size: number; + time: number; +} +export interface PerpAnnotation { + category?: string; + description?: string; + displayName?: string; + keywords?: string[]; +} +export interface MarketSnapshot { + market: Market | null; + book: OrderBook | null; + candles: Candle[]; + trades: Trade[]; + annotation: PerpAnnotation | null; + status: LiveStatus; + asOf: number | null; + error: string | null; +} +export interface MarketOptions { + interval?: CandleInterval; + nSigFigs?: 2 | 3 | 4 | 5 | null; + mantissa?: 1 | 2 | 5; +} +export interface MarketSession { + getSnapshot(): MarketSnapshot; + subscribe(listener: () => void): () => void; + refresh(): Promise; +} +export interface RawAsset { + name: string; + szDecimals: number; + maxLeverage: number; + marginTableId?: number; + isDelisted?: boolean; + onlyIsolated?: boolean; + marginMode?: string; + growthMode?: string; + deployerFeeScale?: string; +} +export interface RawMeta { + universe: RawAsset[]; + collateralToken?: number; + marginTables?: [ + number, + { marginTiers: { lowerBound: string; maxLeverage: number }[] }, + ][]; +} +export interface RawContext { + markPx?: string | number; + oraclePx?: string | number; + midPx?: string | number | null; + funding?: string | number; + premium?: string | number; + openInterest?: string | number; + prevDayPx?: string | number; + dayNtlVlm?: string | number; +} +export interface RawDex { + name: string; + fullName: string; + deployer?: string | null; + oracleUpdater?: string | null; + assetToFundingMultiplier?: [string, string][]; +} + +export interface MarketDataServiceLike { + readonly network: Network; + getSnapshot(): BoardSnapshot; + subscribe(listener: () => void): () => void; + refresh(): Promise; + getMarket(coin: string, options?: MarketOptions): MarketSession; + setBookAggregation( + coin: string, + config: Pick, + ): void; + dispose(): void; +} diff --git a/src/native/cli-input.test.ts b/src/native/cli-input.test.ts new file mode 100644 index 0000000..0ff6839 --- /dev/null +++ b/src/native/cli-input.test.ts @@ -0,0 +1,59 @@ +import { test, expect } from "bun:test"; +import { mkdtemp, writeFile, chmod, symlink, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { generatePrivateKey } from "viem/accounts"; +import { parseCliArgs, readSecretKey, redactCliError } from "./cli-input"; + +test("CLI requires explicit network and never accepts inline credentials", () => { + expect(() => parseCliArgs(["status"])).toThrow("Choose --network"); + expect(() => + parseCliArgs([ + "import-key", + "--network", + "testnet", + "--private-key", + generatePrivateKey(), + ]), + ).toThrow("Unknown or duplicate"); + expect(() => + parseCliArgs(["order", "--network", "testnet", "--key-file", "/unused"]), + ).toThrow("only accepted"); + expect(() => + parseCliArgs(["status", "--network", "testnet", "--network", "mainnet"]), + ).toThrow("duplicate"); + expect( + parseCliArgs(["cancel", "--network", "testnet", "--all", "--yes"]).flags + .all, + ).toBe(true); +}); + +test("native key reader rejects public permissions and symlinks without printing contents", async () => { + const dir = await mkdtemp(join(tmpdir(), "hl-cli-secret-")); + const path = join(dir, "key"), + link = join(dir, "link"); + const key = generatePrivateKey(); + try { + await writeFile(path, key, { mode: 0o600 }); + expect(await readSecretKey({ "key-file": path })).toBe(key); + await chmod(path, 0o644); + await expect(readSecretKey({ "key-file": path })).rejects.toThrow( + "owner-only", + ); + await chmod(path, 0o600); + await symlink(path, link); + await expect(readSecretKey({ "key-file": link })).rejects.toThrow( + "no symlink", + ); + await expect( + readSecretKey({ "key-file": path, "key-stdin": true }), + ).rejects.toThrow("exactly one"); + await writeFile(path, "not a private key"); + await expect(readSecretKey({ "key-file": path })).rejects.toThrow( + "hexadecimal private key", + ); + expect(redactCliError(new Error(`Invalid: ${key}`))).not.toContain(key); + } finally { + await rm(dir, { recursive: true, force: true }); + } +}); diff --git a/src/native/cli-input.ts b/src/native/cli-input.ts new file mode 100644 index 0000000..d16e78c --- /dev/null +++ b/src/native/cli-input.ts @@ -0,0 +1,149 @@ +import { constants } from "node:fs"; +import { open } from "node:fs/promises"; +import type { Network } from "../trading/types"; + +const switches = new Set([ + "yes", + "json", + "acknowledge-risk", + "eligible", + "key-stdin", + "reduce-only", + "all", +]); +const options = new Set([ + "network", + "key-file", + "address", + "expires-at", + "market", + "side", + "kind", + "size", + "unit", + "price", + "tif", + "leverage", + "margin-mode", + "client-id", + "oid", + "percent", + "approval-port", + "slippage-bps", +]); +export type CliArgs = { + command: string; + flags: Record; + network: Network; +}; +export function parseCliArgs(args: string[]): CliArgs { + const command = args[0] || "status"; + if ( + ![ + "status", + "account", + "import-key", + "connect-key", + "connect", + "wallet", + "disconnect", + "preview", + "order", + "cancel", + "close", + ].includes(command) + ) + throw new Error( + "Unknown Hyperliquid command. See gloomberb help hyperliquid.", + ); + const flags: CliArgs["flags"] = {}; + for (let i = 1; i < args.length; i++) { + const token = args[i]!; + if (!token.startsWith("--")) + throw new Error( + "Use named options. Private keys must come from a file or piped stdin.", + ); + const name = token.slice(2); + if (name in flags || (!switches.has(name) && !options.has(name))) + throw new Error( + "Unknown or duplicate option. See gloomberb help hyperliquid.", + ); + if (switches.has(name)) flags[name] = true; + else { + const value = args[++i]; + if (!value || value.startsWith("--")) + throw new Error("An option value is missing."); + flags[name] = value; + } + } + if (flags.network !== "mainnet" && flags.network !== "testnet") + throw new Error( + "Choose --network mainnet or --network testnet explicitly.", + ); + if ( + (flags["key-file"] || flags["key-stdin"]) && + !["import-key", "connect-key"].includes(command) + ) + throw new Error( + "Key input is only accepted by import-key and connect-key.", + ); + return { command, flags, network: flags.network }; +} + +/** Open once with O_NOFOLLOW and inspect the open descriptor before reading. */ +export async function readSecretKey( + flags: CliArgs["flags"], +): Promise<`0x${string}`> { + if (Boolean(flags["key-file"]) === Boolean(flags["key-stdin"])) + throw new Error( + "Choose exactly one of --key-file PATH or --key-stdin. Never pass a key as an argument.", + ); + let value = ""; + if (typeof flags["key-file"] === "string") { + let file; + try { + file = await open( + flags["key-file"], + constants.O_RDONLY | constants.O_NOFOLLOW, + ); + const stat = await file.stat(); + if ( + !stat.isFile() || + stat.size > 256 || + (stat.mode & 0o077) !== 0 || + (process.getuid && stat.uid !== process.getuid()) + ) + throw new Error(); + value = (await file.readFile("utf8")).trim(); + } catch { + throw new Error( + "Key file must be a small owner-only regular file owned by this user (chmod 600), with no symlink.", + ); + } finally { + await file?.close(); + } + } else { + if (process.stdin.isTTY) + throw new Error( + "Pipe key input; interactive stdin would echo the secret.", + ); + for await (const chunk of process.stdin) { + value += chunk.toString(); + if (value.length > 256) throw new Error("Invalid key input."); + } + value = value.trim(); + } + if (!/^0x[0-9a-fA-F]{64}$/.test(value)) + throw new Error( + "Expected one hexadecimal private key in the input, never a recovery phrase.", + ); + return value as `0x${string}`; +} + +export function redactCliError(error: unknown): string { + return ( + error instanceof Error ? error.message : "Hyperliquid command failed." + ) + .replace(/(?:0x)?[0-9a-fA-F]{64}/g, "[redacted]") + .slice(0, 600); +} diff --git a/src/native/cli.test.ts b/src/native/cli.test.ts new file mode 100644 index 0000000..e5fcd5a --- /dev/null +++ b/src/native/cli.test.ts @@ -0,0 +1,133 @@ +import { expect, spyOn, test } from "bun:test"; +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { hyperliquidCli } from "./cli"; +import { KeyStore } from "./storage"; +import { InfoClient, InfoError } from "../market/transport"; +import { singleAttemptExchangeTransport } from "./exchange-transport"; + +test("preview, order and close distinguish failed or incomplete catalogs from an unknown market", async () => { + const folder = await mkdtemp(join(tmpdir(), "hyperliquid-cli-catalog-")); + const address = "0x1111111111111111111111111111111111111111"; + const rateLimit = "Hyperliquid rate limited the request. Retry in a minute."; + const exitCode = process.exitCode ?? 0; + let stderr = "", + closed = 0; + const err = spyOn(process.stderr, "write").mockImplementation((value) => { + stderr += String(value); + return true; + }); + const network = spyOn(globalThis, "fetch").mockRejectedValue( + new Error("Unexpected network request"), + ); + let mode = "limited"; + const info = spyOn(InfoClient.prototype, "request").mockImplementation( + async (body) => { + if ( + (mode === "limited" && body.type === "perpDexs") || + (mode === "unknown-predictions" && body.type === "predictedFundings") || + (mode === "partial" && + body.type === "metaAndAssetCtxs" && + body.dex === "xyz") + ) + throw new InfoError(rateLimit, 429); + if (body.type === "perpDexs") + return ( + mode === "empty" + ? [] + : mode === "partial" + ? [null, { name: "xyz" }] + : [null] + ) as any; + if (body.type === "spotMeta") return { tokens: [] } as any; + if (body.type === "metaAndAssetCtxs") + return [ + { universe: [{ name: "BTC", szDecimals: 5, maxLeverage: 40 }] }, + mode === "no-price" ? [] : [{ markPx: "60000" }], + ] as any; + return [] as any; + }, + ); + try { + await new KeyStore(join(folder, "hyperliquid"), "testnet").writeProfile({ + version: 1, + mode: "watch", + address, + }); + for (const command of ["preview", "order", "close"]) { + for (const [scenario, expected] of [ + ["limited", rateLimit], + ["empty", "market catalog is unavailable"], + ["partial", rateLimit], + ["no-price", "price is unavailable"], + ["unknown", "No active market matches"], + ["unknown-predictions", "No active market matches"], + ]) { + mode = scenario!; + stderr = ""; + process.exitCode = 0; + await hyperliquidCli.execute( + [ + command, + "--network", + "testnet", + "--market", + mode === "partial" + ? "xyz:TSLA" + : mode.startsWith("unknown") + ? "MISSING" + : "BTC", + "--address", + address, + "--client-id", + "catalog-test", + "--yes", + ], + { + cliOptions: {}, + initConfigData: async () => ({ + dataDir: folder, + config: {}, + persistence: { + close: () => { + closed++; + }, + }, + }), + } as any, + ); + expect(stderr).toContain(expected!); + expect(process.exitCode).toBe(1); + } + } + expect(network).not.toHaveBeenCalled(); + expect(closed).toBe(18); + } finally { + process.exitCode = exitCode; + err.mockRestore(); + network.mockRestore(); + info.mockRestore(); + await rm(folder, { recursive: true, force: true }); + } +}); + +test("HTTP 429 never retries the signed exchange transport", async () => { + const fetcher = spyOn(globalThis, "fetch").mockResolvedValue( + new Response("Rate limited", { + status: 429, + headers: { "retry-after": "1" }, + }), + ); + try { + await expect( + singleAttemptExchangeTransport("testnet").request("exchange", {}), + ).rejects.toThrow("Exchange HTTP 429"); + expect(fetcher).toHaveBeenCalledTimes(1); + expect(fetcher.mock.calls[0]?.[0]).toBe( + "https://api.hyperliquid-testnet.xyz/exchange", + ); + } finally { + fetcher.mockRestore(); + } +}); diff --git a/src/native/cli.ts b/src/native/cli.ts new file mode 100644 index 0000000..ec7ea24 --- /dev/null +++ b/src/native/cli.ts @@ -0,0 +1,369 @@ +import type { CliCommandDef } from "gloomberb/types/plugin"; +import { join } from "node:path"; +import { createTradingService } from "./service"; +import { connectWithMainWallet } from "./scripted-connect"; +import { + parseCliArgs, + readSecretKey, + redactCliError, + type CliArgs, +} from "./cli-input"; +import { MarketDataService } from "../market/service"; +import { resolveMarket } from "../market/normalize"; +import { configuredBuilder } from "../trading/builder"; +import { readSettings, tradingSettings } from "../settings"; +import type { + TicketRequest, + TradingResult, + TradingStatus, +} from "../trading/types"; + +function required(flags: CliArgs["flags"], key: string): string { + const value = flags[key]; + if (typeof value !== "string" || !value.trim()) + throw new Error(`Missing --${key}.`); + return value; +} +function number( + flags: CliArgs["flags"], + key: string, + fallback?: number, +): number { + const value = flags[key] === undefined ? fallback : Number(flags[key]); + if (value === undefined || !Number.isFinite(value)) + throw new Error(`Enter a valid --${key}.`); + return value; +} +function choice( + flags: CliArgs["flags"], + key: string, + values: readonly T[], + fallback?: T, +): T { + const value = flags[key] ?? fallback; + if (!values.includes(value as T)) + throw new Error(`Choose --${key} ${values.join("|")}.`); + return value as T; +} +const emit = (value: unknown) => + process.stdout.write(`${JSON.stringify(value, null, 2)}\n`); +function checkResult(result: unknown) { + if ( + result && + typeof result === "object" && + "state" in result && + (result as TradingResult).state !== "accepted" + ) + process.exitCode = 1; +} + +export const hyperliquidCli: CliCommandDef = { + name: "hyperliquid", + description: "Manage a local Hyperliquid API wallet and trade explicitly", + help: { + usage: [ + "hyperliquid --network [options]", + ], + options: [ + { + flags: "--key-file PATH | --key-stdin", + description: + "Read secret only from an owner-only file or piped stdin. import-key: API wallet; connect-key: main wallet.", + }, + { + flags: "--address ADDRESS", + description: + "Required account address for import and every signed trading command.", + }, + { + flags: "--yes --acknowledge-risk --eligible", + description: + "Explicit consent for connection/import; --yes also required for orders, cancels, closes and disconnect.", + }, + { + flags: + "--market COIN --side buy|sell --kind market|limit --size NUMBER --unit coin|usd|percent", + description: "Preview/order ticket. Limit orders also require --price.", + }, + { + flags: + "--tif Gtc|Alo|Ioc --leverage NUMBER --margin-mode cross|isolated --reduce-only", + description: "Order policy (defaults: Alo, 1x, cross).", + }, + { + flags: "--client-id ID", + description: + "Required stable intent ID for order/cancel/close. Reuse after timeout; never blindly resend with a new ID.", + }, + { + flags: "--oid NUMBER | --all", + description: + "Cancel exactly one order, or explicitly all (optionally --market).", + }, + { + flags: "--percent NUMBER", + description: + "Close percentage, default 100. --kind limit requires --price.", + }, + { + flags: "--approval-port NUMBER", + description: + "Loopback approval server port (0=random); full token URL is printed.", + }, + { + flags: "--slippage-bps NUMBER", + description: "IOC cap, otherwise the saved plugin setting.", + }, + ], + examples: [ + "hyperliquid status --network testnet", + "hyperliquid preview --network testnet --market BTC --side buy --kind limit --price 10000 --size 20 --unit usd", + ], + }, + async execute(args, ctx) { + let resources: Awaited> | undefined; + let service: ReturnType | undefined; + let shared: MarketDataService | undefined; + try { + const parsed = parseCliArgs(args), + { command, flags, network } = parsed; + const yes = flags.yes === true || ctx.cliOptions.yes === true; + const connection = ["import-key", "connect-key", "connect"].includes( + command, + ); + if ( + ["order", "cancel", "close", "disconnect", "wallet"].includes( + command, + ) && + !yes + ) + throw new Error( + "This operation requires --yes after reviewing its parameters.", + ); + if ( + connection && + (!yes || flags["acknowledge-risk"] !== true || flags.eligible !== true) + ) + throw new Error( + "Connection requires --yes --acknowledge-risk --eligible after reading the risks and Hyperliquid terms.", + ); + if (["order", "cancel", "close"].includes(command)) { + required(flags, "client-id"); + required(flags, "address"); + } + resources = await ctx.initConfigData(); + const values = { ...resources.config.pluginConfig?.hyperliquid }; + if (flags["slippage-bps"] !== undefined) { + const bps = number(flags, "slippage-bps"); + if (bps < 1 || bps > 2000) + throw new Error("Slippage must be 1 to 2000 basis points."); + values.slippageBps = bps; + } + const port = number( + flags, + "approval-port", + readSettings(values).approvalPort, + ); + if (!Number.isInteger(port) || port < 0 || port > 65535) + throw new Error("Approval port must be 0 to 65535."); + shared = new MarketDataService({ network }); + const options = { + network, + dataDir: join(resources.dataDir, "hyperliquid"), + shared, + settings: () => tradingSettings(values), + approvalPort: port, + }; + if (command === "connect-key") { + const builder = configuredBuilder(network); + process.stderr.write( + `${network}: approve a new local trading API wallet${builder ? ` and Gloom's 0.1% builder fee (${builder.address})` : "; builder fee skipped"}. Main wallet key is not stored.\n`, + ); + const key = await readSecretKey(flags); + emit( + await connectWithMainWallet(options, key, { + risk: true, + eligible: true, + }), + ); + return { kind: "handled" }; + } + service = createTradingService(options); + if (connection) + await service.invoke("acknowledge", { risk: true, eligible: true }); + if (command === "import-key") { + const address = required(flags, "address"); + const key = await readSecretKey(flags); + emit( + await service.invoke("import", { + privateKey: key, + address, + ...(flags["expires-at"] + ? { expiresAt: number(flags, "expires-at") } + : {}), + }), + ); + } else if (command === "connect" || command === "wallet") { + const session = await service.invoke(command); + emit(session); + process.stderr.write( + "Approval page is bound to 127.0.0.1. Keep this command running while approving. Ctrl+C closes the page.\n", + ); + const controller = new AbortController(); + const stop = () => controller.abort(); + process.once("SIGINT", stop); + process.once("SIGTERM", stop); + try { + while (!controller.signal.aborted && Date.now() < session.expiresAt) { + await new Promise((resolve) => { + const done = () => { + clearTimeout(timer); + controller.signal.removeEventListener("abort", done); + resolve(); + }; + const timer = setTimeout(done, 2000); + controller.signal.addEventListener("abort", done, { once: true }); + }); + if (command === "connect") { + const status = (await service.invoke( + "connectionStatus", + )) as TradingStatus; + if (status.mode === "trading") { + emit(status); + break; + } + } + } + } finally { + process.removeListener("SIGINT", stop); + process.removeListener("SIGTERM", stop); + } + } else if (["status", "account", "disconnect"].includes(command)) + emit(await service.invoke(command)); + else { + const status = (await service.invoke("status")) as TradingStatus; + if ( + flags.address && + String(flags.address).toLowerCase() !== status.address?.toLowerCase() + ) + throw new Error( + "The requested account differs from the local connected account.", + ); + const base = { + clientId: flags["client-id"], + confirmed: yes, + expectedAddress: flags.address, + }; + if (command === "cancel") { + if (Boolean(flags.oid) === Boolean(flags.all)) + throw new Error( + "Cancel requires exactly one of --oid NUMBER or --all.", + ); + const oid = flags.oid ? number(flags, "oid") : undefined; + if (oid !== undefined && (!Number.isSafeInteger(oid) || oid < 0)) + throw new Error("Invalid order id."); + const result = await service.invoke("cancel", { + ...base, + oid, + coin: flags.market, + }); + emit(result); + checkResult(result); + } else { + await shared.refresh(); + const catalog = shared.getSnapshot(); + if ( + !catalog.markets.length || + ["error", "stale"].includes(catalog.status) + ) + throw new Error( + catalog.catalogError ?? + "Hyperliquid market catalog is unavailable. Retry in a minute.", + ); + const market = resolveMarket( + catalog.markets, + required(flags, "market"), + ); + if (!market) + throw new Error( + catalog.catalogError ?? "No active market matches.", + ); + if (market.mark == null) + throw new Error( + catalog.catalogError ?? + `Hyperliquid price is unavailable for ${market.coin}. Retry in a minute.`, + ); + const kind = choice( + flags, + "kind", + ["market", "limit"] as const, + command === "close" ? "market" : "limit", + ); + const limitPrice = + kind === "limit" ? number(flags, "price") : undefined; + if (command === "close") { + const result = await service.invoke("close", { + ...base, + coin: market.coin, + percent: number(flags, "percent", 100), + kind, + limitPrice, + }); + emit(result); + checkResult(result); + } else { + const ticket: TicketRequest = { + market: { + coin: market.coin, + assetId: market.assetId, + dex: market.dex, + szDecimals: market.szDecimals, + maxLeverage: market.maxLeverage, + onlyIsolated: market.onlyIsolated, + mark: market.mark, + }, + side: choice(flags, "side", ["buy", "sell"]), + kind, + size: number(flags, "size"), + sizeUnit: choice( + flags, + "unit", + ["coin", "usd", "percent"], + "usd", + ), + leverage: number(flags, "leverage", 1), + marginMode: choice( + flags, + "margin-mode", + ["cross", "isolated"], + market.onlyIsolated ? "isolated" : "cross", + ), + limitPrice, + tif: choice(flags, "tif", ["Gtc", "Alo", "Ioc"] as const, "Alo"), + reduceOnly: flags["reduce-only"] === true, + clientId: + typeof flags["client-id"] === "string" + ? flags["client-id"] + : `preview-${crypto.randomUUID()}`, + }; + const result = await service.invoke( + command === "preview" ? "preview" : "submit", + { ...base, ticket }, + ); + emit(result); + checkResult(result); + } + } + } + return { kind: "handled" }; + } catch (error) { + process.stderr.write(`${redactCliError(error)}\n`); + process.exitCode = 1; + return { kind: "handled" }; + } finally { + await service?.dispose(); + shared?.dispose(); + resources?.persistence.close(); + } + }, +}; diff --git a/src/native/exchange-transport.ts b/src/native/exchange-transport.ts new file mode 100644 index 0000000..ccdb8f4 --- /dev/null +++ b/src/native/exchange-transport.ts @@ -0,0 +1,39 @@ +import type { IRequestTransport } from "@nktkas/hyperliquid"; +import type { Network } from "../trading/types"; + +/** Signed requests get exactly one HTTP attempt. Callers reconcile uncertain outcomes. */ +export function singleAttemptExchangeTransport( + network: Network, + beforeSend?: () => void, +): IRequestTransport { + return { + isTestnet: network === "testnet", + async request( + endpoint: "info" | "exchange", + payload: unknown, + signal?: AbortSignal, + ): Promise { + if (endpoint !== "exchange") + throw new Error("Unexpected signed transport endpoint."); + beforeSend?.(); + const response = await fetch( + (network === "testnet" + ? "https://api.hyperliquid-testnet.xyz" + : "https://api.hyperliquid.xyz") + "/exchange", + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + signal: signal + ? AbortSignal.any([signal, AbortSignal.timeout(12_000)]) + : AbortSignal.timeout(12_000), + }, + ); + if (!response.ok) + throw new Error( + `Exchange HTTP ${response.status}. Check the account before submitting another request.`, + ); + return response.json() as Promise; + }, + }; +} diff --git a/src/native/main-wallet.ts b/src/native/main-wallet.ts new file mode 100644 index 0000000..a34ff36 --- /dev/null +++ b/src/native/main-wallet.ts @@ -0,0 +1,39 @@ +import { ExchangeClient, type IRequestTransport } from "@nktkas/hyperliquid"; +import type { AbstractWallet } from "@nktkas/hyperliquid/signing"; +import { + configuredBuilder, + GLOOM_BUILDER_MAX_FEE_RATE, +} from "../trading/builder"; +import type { Address, Network } from "../trading/types"; +import { singleAttemptExchangeTransport } from "./exchange-transport"; + +/** Main-wallet approval only. The caller owns key input; this function never stores it. */ +export async function approveApiWallet(options: { + network: Network; + wallet: AbstractWallet; + agentAddress: Address; + agentName: string; + nonceManager: () => Promise; + transport?: IRequestTransport; +}): Promise { + const transport = + options.transport ?? singleAttemptExchangeTransport(options.network); + if (transport.isTestnet !== (options.network === "testnet")) + throw new Error("Signing network and transport do not match."); + const exchange = new ExchangeClient({ + transport, + wallet: options.wallet, + nonceManager: options.nonceManager, + signatureChainId: "0x66eee", + }); + await exchange.approveAgent({ + agentAddress: options.agentAddress, + agentName: options.agentName, + }); + const builder = configuredBuilder(options.network); + if (builder) + await exchange.approveBuilderFee({ + builder: builder.address, + maxFeeRate: GLOOM_BUILDER_MAX_FEE_RATE, + }); +} diff --git a/src/native/scripted-connect.ts b/src/native/scripted-connect.ts new file mode 100644 index 0000000..bfc44e1 --- /dev/null +++ b/src/native/scripted-connect.ts @@ -0,0 +1,17 @@ +import { createTradingService, type TradingServiceOptions } from "./service"; +import type { TradingStatus } from "../trading/types"; + +/** Native-only script entry. Read key from owner-only file/stdin, never CLI arguments or config. */ +export async function connectWithMainWallet( + options: TradingServiceOptions, + privateKey: `0x${string}`, + acknowledgements: { risk: true; eligible: true }, +): Promise { + const service = createTradingService(options); + try { + await service.invoke("acknowledge", acknowledgements); + return await service.connectMainWallet(privateKey); + } finally { + await service.dispose(); + } +} diff --git a/src/native/security.test.ts b/src/native/security.test.ts new file mode 100644 index 0000000..67177bc --- /dev/null +++ b/src/native/security.test.ts @@ -0,0 +1,299 @@ +import { expect, test } from "bun:test"; +import { mkdtemp, rm, stat } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { KeyStore } from "./storage"; +import { startWalletPage } from "./wallet-page"; + +test("monotonic nonce persists across service restarts and networks, credentials directory stays private", async () => { + const folder = await mkdtemp(join(tmpdir(), "hyperliquid-storage-")); + try { + const a = new KeyStore(folder, "testnet"), + b = new KeyStore(folder, "testnet"); + const nonces = await Promise.all([ + a.nextNonce(), + b.nextNonce(), + a.nextNonce(), + ]); + expect(new Set(nonces).size).toBe(3); + expect(await b.nextNonce()).toBeGreaterThan(Math.max(...nonces)); + expect((await stat(a.directory)).mode & 0o777).toBe(0o700); + expect((await stat(join(a.directory, "nonce.json"))).mode & 0o777).toBe( + 0o600, + ); + const main = new KeyStore(folder, "mainnet"); + expect(main.directory).not.toBe(a.directory); + } finally { + await rm(folder, { recursive: true, force: true }); + } +}); + +test("loopback wallet approval serves only unguessable token, rejects foreign origins, binds configured port, contains no key material", async () => { + const page = startWalletPage({ + network: "testnet", + approvalPort: 0, + checkRegion: async () => ({ allowed: true }), + }); + try { + const response = await fetch(page.url); + expect(response.status).toBe(200); + expect(response.headers.get("content-security-policy")).toContain( + "frame-ancestors 'none'", + ); + expect(response.headers.get("cache-control")).toBe("no-store"); + const html = await response.text(); + expect(html).toContain("TESTNET"); + expect(html).not.toContain("privateKey"); + expect(html).not.toContain('maxFeeRate:\"0.1%\",builder:'); + const url = new URL(page.url); + expect(url.hostname).toBe("127.0.0.1"); + expect(url.pathname).toMatch(/^\/[0-9a-f]{64}\/$/); + expect((await fetch(url.origin + "/")).status).toBe(404); + expect( + (await fetch(page.url, { headers: { "sec-fetch-site": "cross-site" } })) + .status, + ).toBe(403); + expect((await fetch(page.url, { method: "POST" })).status).toBe(403); + } finally { + page.close(); + } +}); + +test("unknown intent is persisted and never resent after timeout or process restart", async () => { + const { createTradingService } = await import("./service"); + const folder = await mkdtemp(join(tmpdir(), "hyperliquid-intent-")); + const shared = { + info: { request: async () => ({ status: "unknownOid" }) as any }, + ws: { subscribe: () => () => {} }, + }; + let sends = 0; + const service = createTradingService({ + network: "testnet", + dataDir: folder, + shared, + }); + try { + await service.invoke("status"); + const submit = () => + (service as any).once( + "persistent-intent", + ["0x12345678901234567890123456789012"], + async () => { + sends++; + throw new Error("Network timeout"); + }, + ); + const [first, second] = await Promise.all([submit(), submit()]); + expect(first.state).toBe("unknown"); + expect(second.state).toBe("unknown"); + expect(sends).toBe(1); + await service.dispose(); + const restarted = createTradingService({ + network: "testnet", + dataDir: folder, + shared, + }); + try { + await restarted.invoke("status"); + const retry = await (restarted as any).once( + "persistent-intent", + [], + async () => { + sends++; + return {}; + }, + ); + expect(retry.state).toBe("unknown"); + expect(sends).toBe(1); + } finally { + await restarted.dispose(); + } + } finally { + await service.dispose(); + await rm(folder, { recursive: true, force: true }); + } +}); + +test("service setup, status and disconnected account stream stay offline", async () => { + const { createTradingService } = await import("./service"); + const folder = await mkdtemp(join(tmpdir(), "hyperliquid-lifecycle-")); + let subscribes = 0, + releases = 0, + requests = 0; + const service = createTradingService({ + network: "testnet", + dataDir: folder, + shared: { + info: { + request: async () => { + requests++; + return {} as any; + }, + }, + ws: { subscribe: () => () => {} }, + subscribe: () => { + subscribes++; + return () => { + releases++; + }; + }, + }, + }); + try { + await service.invoke("status"); + expect(subscribes).toBe(0); + expect(requests).toBe(0); + const release = service.subscribe(() => {}); + await Bun.sleep(1); + expect(subscribes).toBe(0); + release(); + expect(releases).toBe(0); + await service.dispose(); + expect(releases).toBe(0); + } finally { + await service.dispose(); + await rm(folder, { recursive: true, force: true }); + } +}); + +test("scripted API-wallet approval signs with main wallet, skips testnet builder and never retries", async () => { + const { approveApiWallet } = await import("./main-wallet"); + const { generatePrivateKey, privateKeyToAccount } = await import( + "viem/accounts" + ); + const wallet = privateKeyToAccount(generatePrivateKey()), + agent = privateKeyToAccount(generatePrivateKey()); + const sent: any[] = []; + await approveApiWallet({ + network: "testnet", + wallet, + agentAddress: agent.address, + agentName: "gloom test valid_until 1900000000000", + nonceManager: async () => 1770000000000, + transport: { + isTestnet: true, + request: async (_endpoint, payload) => { + sent.push(payload); + return { status: "ok", response: { type: "default" } } as any; + }, + }, + }); + expect(sent.length).toBe(1); + expect(sent[0].action.type).toBe("approveAgent"); + expect(sent[0].action.hyperliquidChain).toBe("Testnet"); + expect(sent[0].action.agentAddress.toLowerCase()).toBe( + agent.address.toLowerCase(), + ); + let attempts = 0; + await expect( + approveApiWallet({ + network: "testnet", + wallet, + agentAddress: agent.address, + agentName: "gloom", + nonceManager: async () => 1770000000001, + transport: { + isTestnet: true, + request: async () => { + attempts++; + throw new Error("timeout"); + }, + }, + }), + ).rejects.toThrow("timeout"); + expect(attempts).toBe(1); +}); + +test("scripted builder approval uses exact official address and cap on an offline transport", async () => { + const { approveApiWallet } = await import("./main-wallet"); + const { GLOOM_BUILDER_ADDRESS } = await import("../trading/builder"); + const { generatePrivateKey, privateKeyToAccount } = await import( + "viem/accounts" + ); + const wallet = privateKeyToAccount(generatePrivateKey()), + agent = privateKeyToAccount(generatePrivateKey()), + sent: any[] = []; + await approveApiWallet({ + network: "mainnet", + wallet, + agentAddress: agent.address, + agentName: "gloom test", + nonceManager: async () => 1770000000002, + transport: { + isTestnet: false, + request: async (_endpoint, payload) => { + sent.push(payload); + return { status: "ok", response: { type: "default" } } as any; + }, + }, + }); + expect(sent.map((s) => s.action.type)).toEqual([ + "approveAgent", + "approveBuilderFee", + ]); + expect(sent[1].action.builder.toLowerCase()).toBe( + GLOOM_BUILDER_ADDRESS.toLowerCase(), + ); + expect(sent[1].action.maxFeeRate).toBe("0.1%"); +}); + +test("shutdown blocks late connection callbacks and rejects new trading work", async () => { + const { createTradingService } = await import("./service"); + const folder = await mkdtemp(join(tmpdir(), "hyperliquid-shutdown-")); + let resolveRole!: (value: unknown) => void; + const role = new Promise((resolve) => { + resolveRole = resolve; + }); + let marketSubscriptions = 0; + const service = createTradingService({ + network: "testnet", + dataDir: folder, + shared: { + info: { request: async () => (await role) as any }, + ws: { subscribe: () => () => {} }, + subscribe: () => { + marketSubscriptions++; + return () => {}; + }, + }, + }); + try { + await service.invoke("status"); + (service as any).status = { + network: "testnet", + mode: "pending", + agentAddress: "0x1111111111111111111111111111111111111111", + riskAcknowledged: true, + eligibleAcknowledged: true, + }; + const pending = service.invoke("connectionStatus"); + await Bun.sleep(1); + await service.dispose(); + resolveRole({ + role: "agent", + data: { user: "0x2222222222222222222222222222222222222222" }, + }); + await expect(pending).rejects.toThrow("closed"); + expect(marketSubscriptions).toBe(0); + expect(service.getSnapshot().status.mode).toBe("pending"); + await expect(service.invoke("submit", {})).rejects.toThrow("closed"); + } finally { + await service.dispose(); + await rm(folder, { recursive: true, force: true }); + } +}); + +test("signed transport checks lifecycle immediately before sending", async () => { + const { singleAttemptExchangeTransport } = await import( + "./exchange-transport" + ); + let admissions = 0; + const transport = singleAttemptExchangeTransport("testnet", () => { + admissions++; + throw new Error("closed before send"); + }); + await expect(transport.request("exchange", {})).rejects.toThrow( + "closed before send", + ); + expect(admissions).toBe(1); +}); diff --git a/src/native/service.test.ts b/src/native/service.test.ts new file mode 100644 index 0000000..876b469 --- /dev/null +++ b/src/native/service.test.ts @@ -0,0 +1,322 @@ +import { expect, spyOn, test } from "bun:test"; +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { createTradingService } from "./service"; + +const user = "0x1111111111111111111111111111111111111111"; +const other = "0x2222222222222222222222222222222222222222"; + +async function closeFixture( + operation = "close", + positionSize = "0.002", + percent = 50, +) { + const folder = await mkdtemp(join(tmpdir(), "hyperliquid-close-race-")); + const pendingRisk = Promise.withResolvers(); + const releaseRisk = Promise.withResolvers(); + const sent: any[] = []; + const handlers = new Map void>(); + let reads = 0, + subscribes = 0, + releases = 0; + const state = { + marginSummary: { accountValue: "100", totalMarginUsed: "12" }, + crossMarginSummary: { accountValue: "100", totalMarginUsed: "12" }, + crossMaintenanceMarginUsed: "1.2", + withdrawable: "88", + assetPositions: [ + { + position: { + coin: "BTC", + szi: positionSize, + entryPx: "60000", + positionValue: "120", + leverage: { type: "cross", value: 10 }, + }, + }, + ], + time: Date.now(), + }; + const market = { + coin: "BTC", + assetId: 0, + dex: "", + szDecimals: 5, + maxLeverage: 50, + mark: 60000, + collateral: "USDC", + asOf: Date.now(), + }; + const service = createTradingService({ + network: "testnet", + dataDir: folder, + shared: { + info: { + request: async (body) => { + if (body.type === "clearinghouseState") { + // Initialization, full position refresh, then the pre-send risk read. + if (++reads === (operation === "submit" ? 2 : 3)) { + pendingRisk.resolve(); + await releaseRisk.promise; + } + return state as any; + } + if (body.type === "spotClearinghouseState") + return { balances: [] } as any; + if (body.type === "userAbstraction") return "default" as any; + if (body.type === "l2Book") + return { + levels: [[{ px: "59999", sz: "1" }], [{ px: "60001", sz: "1" }]], + } as any; + if (body.type === "userFees") + return { userAddRate: "0.00015", userCrossRate: "0.00045" } as any; + return [] as any; + }, + }, + ws: { + subscribe: (subscription, handler) => { + handlers.set(String(subscription.type), handler); + return () => handlers.delete(String(subscription.type)); + }, + }, + subscribe: () => { + subscribes++; + return () => { + releases++; + }; + }, + refresh: async () => {}, + getSnapshot: () => + ({ + markets: [market], + dexes: [{ name: "", collateral: "USDC" }], + asOf: Date.now(), + status: "live", + }) as any, + }, + }); + await service.invoke("status"); + // No keys or network: keep the real lifecycle, AccountStore, preview and journal. + const native = service as any; + native.status.address = user; + native.guard = async () => native.assertOpen(); + native.exchange = async () => { + native.assertOpen(); + return { + order: async (order: any) => { + sent.push({ + address: service.getSnapshot().account?.address, + ...order, + }); + return { + status: "ok", + response: { type: "order", data: { statuses: [] } }, + }; + }, + }; + }; + await service.invoke("account"); + return { + service, + native, + sent, + pendingRisk, + releaseRisk, + releases: () => releases, + subscribes: () => subscribes, + update: () => + handlers.get("allDexsClearinghouseState")?.({ + clearinghouseStates: [["", { ...state, time: Date.now() }]], + }), + close: (clientId = "close-race") => + service.invoke(operation, { + coin: "BTC", + percent, + kind: "market", + expectedAddress: user, + confirmed: true, + clientId, + ticket: { + market, + side: "sell", + kind: "market", + size: "0.001", + sizeUnit: "coin", + leverage: 10, + marginMode: "cross", + reduceOnly: true, + clientId, + }, + }), + cleanup: async () => { + releaseRisk.resolve(); + await service.dispose(); + await rm(folder, { recursive: true, force: true }); + }, + }; +} + +// Run the service's actual idle callback deterministically, without slowing the +// suite by 15 seconds. All other timers retain their normal behavior. +function idleClock() { + const timers = new Map, () => void>(); + const set = globalThis.setTimeout, + clear = globalThis.clearTimeout; + const setSpy = spyOn(globalThis, "setTimeout").mockImplementation((( + callback: (...args: any[]) => void, + delay?: number, + ...args: any[] + ) => { + const timer = set(callback, delay, ...args); + if (delay === 15_000) timers.set(timer, () => callback(...args)); + return timer; + }) as typeof setTimeout); + const clearSpy = spyOn(globalThis, "clearTimeout").mockImplementation( + (timer) => { + timers.delete(timer as ReturnType); + clear(timer as ReturnType | undefined); + }, + ); + return { + expire: () => { + for (const [timer, callback] of [...timers]) { + timers.delete(timer); + clear(timer); + callback(); + } + }, + restore: () => { + setSpy.mockRestore(); + clearSpy.mockRestore(); + }, + }; +} + +for (const [operation, idle] of [ + ["close", true], + ["submit", true], + ["closeAll", true], + ["close", false], + ["closeAll", false], +] as const) { + test(`${operation} retains its account through ${idle ? "idle expiry" : "last pane removal"} and live updates`, async () => { + const clock = idleClock(); + const f = await closeFixture(operation); + try { + const unsubscribe = idle ? undefined : f.service.subscribe(() => {}); + const closing = f.close(); + await f.pendingRisk.promise; + await expect( + f.service.invoke("watch", { address: other }), + ).rejects.toThrow("before changing accounts"); + f.update(); + if (unsubscribe) unsubscribe(); + clock.expire(); + f.releaseRisk.resolve(); + expect((await closing).state).toBe("accepted"); + expect(f.sent).toHaveLength(1); + expect(f.sent[0].address).toBe(user); + expect(f.sent[0].orders[0]).toMatchObject({ + b: false, + r: true, + s: operation === "closeAll" ? "0.002" : "0.001", + }); + expect(f.releases()).toBe(0); + // Completion must restore idle cleanup instead of retaining sockets forever. + clock.expire(); + expect(f.releases()).toBe(1); + expect(f.service.getSnapshot().account).toBeNull(); + } finally { + await f.cleanup(); + clock.restore(); + } + }); +} + +test("pending close still rejects a replaced account or forced shutdown without sending", async () => { + for (const shutdown of [false, true]) { + const f = await closeFixture(); + try { + const closing = f.close(); + await f.pendingRisk.promise; + if (shutdown) await f.service.dispose(); + else await f.native.accounts.setAddress(other); + f.releaseRisk.resolve(); + await expect(closing).rejects.toThrow( + "Account changed while checking collateral", + ); + expect(f.sent).toHaveLength(0); + expect(f.service.getSnapshot().account?.address).toBe( + shutdown ? undefined : other, + ); + } finally { + await f.cleanup(); + } + } +}); + +test("idle cleanup waits for the last concurrent request even when it fails", async () => { + const clock = idleClock(); + const f = await closeFixture(); + try { + const first = f.close(); + await f.pendingRisk.promise; + expect((await f.close("second-close")).state).toBe("accepted"); + clock.expire(); + expect(f.releases()).toBe(0); + expect(f.service.getSnapshot().account?.address).toBe(user); + f.releaseRisk.reject(new Error("Collateral request failed")); + await expect(first).rejects.toThrow("Collateral request failed"); + expect(f.sent).toHaveLength(1); + clock.expire(); + expect(f.releases()).toBe(1); + expect(f.service.getSnapshot().account).toBeNull(); + } finally { + await f.cleanup(); + clock.restore(); + } +}); + +test("opening an account pane cancels an earlier headless idle timeout", async () => { + const clock = idleClock(); + const f = await closeFixture(); + try { + const unsubscribe = f.service.subscribe(() => {}); + clock.expire(); + expect(f.service.getSnapshot().account?.address).toBe(user); + expect(f.releases()).toBe(0); + unsubscribe(); + expect(f.releases()).toBe(1); + // The queued subscription activation must not reopen an abandoned pane. + await f.service.invoke("status"); + expect(f.subscribes()).toBe(1); + expect(f.service.getSnapshot().account).toBeNull(); + } finally { + await f.cleanup(); + clock.restore(); + } +}); + +test("full and partial closes preserve exact BTC lots for long and short positions", async () => { + for (const [size, percent] of [ + ["0.00035", 100], + ["0.0007", 50], + ["-0.00035", 100], + ] as const) { + const f = await closeFixture("close", size, percent); + try { + const closing = f.close(); + await f.pendingRisk.promise; + f.releaseRisk.resolve(); + expect((await closing).state).toBe("accepted"); + expect(f.sent[0].orders[0]).toMatchObject({ + b: size.startsWith("-"), + r: true, + s: "0.00035", + }); + } finally { + await f.cleanup(); + } + } +}); diff --git a/src/native/service.ts b/src/native/service.ts new file mode 100644 index 0000000..7fa3534 --- /dev/null +++ b/src/native/service.ts @@ -0,0 +1,1198 @@ +import { ExchangeClient } from "@nktkas/hyperliquid"; +import { generatePrivateKey, privateKeyToAccount } from "viem/accounts"; +import { getAddress, isAddress } from "viem"; +import Decimal from "decimal.js"; +import type { + OrderParameters, + ModifyParameters, +} from "@nktkas/hyperliquid/api/exchange"; +import { AccountStore, availableForMarket } from "../trading/account"; +import { configuredBuilder } from "../trading/builder"; +import { previewTicket } from "../trading/orders"; +import { checkRegion, REGION_POLICY } from "../trading/regions"; +import { + DEFAULT_TRADING_SETTINGS, + type AccountSnapshot, + type Address, + type Network, + type SharedTransport, + type TicketRequest, + type TradingMarket, + type TradingOperation, + type TradingResult, + type TradingSettings, + type TradingStatus, +} from "../trading/types"; +import { KeyStore } from "./storage"; +import { LocalTwapScheduler, type LocalTwapJob } from "./twap"; +import { approveApiWallet } from "./main-wallet"; +import { singleAttemptExchangeTransport } from "./exchange-transport"; +import { startWalletPage } from "./wallet-page"; + +export interface TradingServiceOptions { + network: Network; + dataDir: string; + shared: SharedTransport; + settings?: () => TradingSettings; + approvalPort?: number | (() => number); +} +const errorText = (error: unknown) => + error instanceof Error + ? error.message.replace(/0x[0-9a-fA-F]{64}/g, "[redacted]").slice(0, 600) + : "Trading request failed."; +const address = (value: unknown): Address => { + if (typeof value !== "string" || !isAddress(value)) + throw new Error("Enter a valid 0x wallet address."); + return getAddress(value); +}; + +export function createTradingService(options: TradingServiceOptions) { + return new TradingService(options); +} +export class TradingService { + private store: KeyStore; + private accounts: AccountStore; + private status: TradingStatus; + private ready: Promise; + private twaps: LocalTwapScheduler; + private lastSnapshotAccount?: AccountSnapshot | null; + private walletPage?: ReturnType; + private results = new Map(); + private pending = new Map>(); + private listeners = new Set<() => void>(); + private closed = false; + private markets = new Map(); + private marketTime = 0; + private marketAsOf = new Map(); + private poll?: ReturnType; + private lastRoleCheck = 0; + private roleCheck?: Promise; + private marketRelease?: () => void; + private lease?: ReturnType; + private signedInFlight = 0; + private transitioning = false; + private builderChecked = 0; + private snapshotCache?: { + status: TradingStatus; + account: AccountSnapshot | null; + }; + constructor(private options: TradingServiceOptions) { + this.store = new KeyStore(options.dataDir, options.network); + this.accounts = new AccountStore(options.network, options.shared); + this.status = { + network: options.network, + mode: "disconnected", + riskAcknowledged: false, + eligibleAcknowledged: false, + }; + this.accounts.subscribe(() => this.emit()); + this.twaps = new LocalTwapScheduler({ + read: () => this.store.readJson("local-twaps.json", []), + write: (jobs) => this.store.writeJson("local-twaps.json", jobs), + acquire: (id) => this.store.acquireTwapLease(id), + execute: (ticket) => + this.invoke("submit", { + ticket, + confirmed: true, + expectedAddress: ticket.accountAddress, + }), + mark: async (coin) => (await this.market(coin)).mark, + onChange: () => { + this.emit(); + if (this.twaps?.getJobs().some((j) => j.status === "running")) + void this.activate()?.catch((error) => { + this.status.error = errorText(error); + this.emit(); + }); + else if (!this.listeners.size && !this.lease) this.suspend(); + }, + }); + this.ready = Promise.all([this.initialize(), this.twaps.ready]).then( + () => {}, + ); + } + private async initialize() { + const profile = await this.store.readProfile(); + Object.assign(this.status, profile); + this.results = new Map( + ( + await this.store.readJson<[string, TradingResult][]>("intents.json", []) + ).slice(-500), + ); + this.emit(); + } + subscribe(listener: () => void) { + this.listeners.add(listener); + this.scheduleIdle(); + void this.ready + .then(() => { + if (this.listeners.has(listener)) return this.activate(); + }) + .catch((error) => { + this.status.error = errorText(error); + this.emit(); + }); + return () => { + this.listeners.delete(listener); + if (!this.listeners.size) this.suspend(); + }; + } + private withTwaps(account: AccountSnapshot) { + return { + ...account, + twaps: [...account.twaps, ...this.twaps.rows(account.address)], + }; + } + getSnapshot() { + const account = this.accounts.getSnapshot() ?? null; + if (this.lastSnapshotAccount !== account) this.snapshotCache = undefined; + this.lastSnapshotAccount = account; + return ( + this.snapshotCache ?? + (this.snapshotCache = { + status: { ...this.status }, + account: account ? this.withTwaps(account) : null, + }) + ); + } + private assertOpen() { + if (this.closed) { + const error = new Error( + "Trading service is closed. Reopen the plugin before submitting another request.", + ); + error.name = "RequestNotSentError"; + throw error; + } + } + private activate() { + if (this.closed || !this.status.address) return; + if (!this.marketRelease) + this.marketRelease = this.options.shared.subscribe?.(() => {}); + if (this.status.address) + return this.accounts.setAddress(this.status.address); + } + private suspend(force = false) { + if ( + !force && + (this.signedInFlight > 0 || + this.twaps?.getJobs().some((j) => j.status === "running")) + ) + return; + if (this.lease) clearTimeout(this.lease); + this.lease = undefined; + this.marketRelease?.(); + this.marketRelease = undefined; + this.accounts.stop(); + } + private scheduleIdle() { + if (this.lease) clearTimeout(this.lease); + this.lease = undefined; + // Idle starts after the last trading request settles. Stopping account + // tracking during a collateral read invalidates its account identity guard. + if (this.closed || this.listeners.size || this.signedInFlight > 0) return; + this.lease = setTimeout(() => { + this.lease = undefined; + this.suspend(); + }, 15_000); + this.lease.unref(); + } + private touch() { + this.assertOpen(); + this.scheduleIdle(); + return this.activate(); + } + private emit() { + this.snapshotCache = undefined; + for (const listener of this.listeners) listener(); + } + private settings(): TradingSettings { + return { + ...DEFAULT_TRADING_SETTINGS, + ...this.options.settings?.(), + builder: configuredBuilder(this.options.network), + }; + } + private async persist() { + const { network, region, error, ...profile } = this.status; + await this.store.writeProfile({ version: 1, ...profile }); + this.emit(); + } + private async region() { + if ( + !this.status.region || + Date.now() - this.status.region.checkedAt > REGION_POLICY.maxAgeMs + ) + this.status.region = await checkRegion(); + return this.status.region; + } + private async guard(requireKey = true) { + this.assertOpen(); + if (!this.status.riskAcknowledged) + throw new Error( + "Acknowledge leveraged trading risks before enabling trading.", + ); + if (!this.status.eligibleAcknowledged) + throw new Error( + "Confirm you are not a restricted person under Hyperliquid terms.", + ); + const region = await this.region(); + this.assertOpen(); + if (!region.allowed) + throw new Error(region.reason ?? "Trading unavailable in this region."); + if (requireKey) { + if (this.status.mode !== "trading" || !this.status.storage) + throw new Error("Connect an approved trading wallet first."); + if (!this.status.expiresAt || this.status.expiresAt <= Date.now()) + throw new Error("Trading approval expired. Reconnect your wallet."); + const builder = this.settings().builder; + if (builder && Date.now() - this.builderChecked > 60_000) { + const fee = await this.options.shared.info.request({ + type: "maxBuilderFee", + user: this.status.address, + builder: builder.address, + }); + if (fee < builder.feeTenthsBps) + throw new Error( + "Approve Gloom's 0.1% builder fee from Wallet management before trading.", + ); + this.builderChecked = Date.now(); + } + } + } + private async exchange() { + await this.guard(); + const key = await this.store.readKey(this.status.storage!); + this.assertOpen(); + const wallet = privateKeyToAccount(key); + if ( + wallet.address.toLowerCase() !== this.status.agentAddress?.toLowerCase() + ) + throw new Error( + "The local API wallet changed in another process. Reopen the connection before trading.", + ); + const network = this.options.network; + return new ExchangeClient({ + wallet, + nonceManager: () => this.store.nextNonce(), + defaultExpiresAfter: () => Date.now() + 30_000, + transport: singleAttemptExchangeTransport(network, () => + this.assertOpen(), + ), + }); + } + + private async loadMarkets(force = false) { + const shared = this.options.shared; + if (!shared.getSnapshot || !shared.refresh) + throw new Error("Shared market service is unavailable."); + let snapshot = shared.getSnapshot(); + if ( + !snapshot.markets.length || + !snapshot.asOf || + Date.now() - snapshot.asOf > 60_000 + ) { + await shared.refresh(); + snapshot = shared.getSnapshot(); + } + if ( + !snapshot.markets.length || + !snapshot.asOf || + Date.now() - snapshot.asOf > 60_000 + ) + throw new Error( + "Market data is stale. Wait for live prices before trading.", + ); + this.markets = new Map( + snapshot.markets + .filter((m) => m.mark !== null) + .map((m) => [ + m.coin, + { + coin: m.coin, + assetId: m.assetId, + dex: m.dex, + szDecimals: m.szDecimals, + maxLeverage: m.maxLeverage, + onlyIsolated: m.onlyIsolated, + marginMode: m.marginMode, + mark: m.mark!, + collateral: m.collateral, + marginTiers: m.marginTiers, + deployerFeeScale: m.deployerFeeScale ?? (m.dex ? 1 : 0), + growthMode: m.growthMode === "enabled", + alignedCollateral: (m as any).alignedCollateral ?? false, + }, + ]), + ); + this.marketAsOf = new Map(snapshot.markets.map((m) => [m.coin, m.asOf])); + if (["stale", "reconnecting", "error"].includes(snapshot.status)) + throw new Error( + "Market connection is stale or reconnecting. Wait before trading.", + ); + this.marketTime = Date.now(); + } + private async market(coin: string) { + await this.loadMarkets(); + const result = this.markets.get(coin); + if (!result) throw new Error("Market is unavailable or delisted."); + if (Date.now() - (this.marketAsOf.get(coin) ?? 0) > 60_000) + throw new Error( + "This market price is stale. Wait for a new market update.", + ); + return result; + } + private async account() { + await this.touch(); + if (!this.status.address) + throw new Error("Add a watch-only address or connect your wallet."); + return this.withTwaps( + this.accounts.getSnapshot() ?? + (await this.accounts.setAddress(this.status.address)), + ); + } + private async preview(input: TicketRequest, force = false) { + if (force) await this.loadMarkets(true); + const market = await this.market(input.market.coin); + await this.account(); + const snapshot = force + ? await this.accounts.refreshRisk(market.dex) + : await this.account(); + const position = snapshot.positions.find((p) => p.coin === market.coin); + const book = await this.options.shared.info.request({ + type: "l2Book", + coin: market.coin, + }); + const ticket = { ...input, market }; + const active = + force && !ticket.reduceOnly && !ticket.positionTpsl + ? await this.options.shared.info.request({ + type: "activeAssetData", + user: snapshot.address, + coin: market.coin, + }) + : undefined; + const buyingPower = + active?.availableToTrade?.[ticket.side === "buy" ? 0 : 1]; + const preview = previewTicket( + ticket, + { + available: + buyingPower !== undefined + ? Math.max(0, Number(buyingPower)) + : availableForMarket(snapshot, market), + makerRate: Number(snapshot.fees?.userAddRate ?? 0.00015), + takerRate: Number(snapshot.fees?.userCrossRate ?? 0.00045), + referralDiscount: Number(snapshot.fees?.activeReferralDiscount ?? 0), + positionSize: Number(position?.szi ?? 0), + accountValue: snapshot.accountValue, + book: book?.levels, + crossMaintenanceMargin: snapshot.maintenanceMargin, + }, + this.settings(), + ); + if ( + active?.leverage?.value === ticket.leverage && + active?.leverage?.type === ticket.marginMode && + Number(preview.size) > + Number(active.maxTradeSzs?.[ticket.side === "buy" ? 0 : 1] ?? Infinity) + ) + preview.errors.push( + "Order exceeds the exchange-reported maximum trade size.", + ); + if (snapshot.stale) + preview.errors.push( + "Account data is stale. Wait for the connection to recover.", + ); + if (snapshot.abstraction === "portfolioMargin" && !ticket.reduceOnly) + preview.errors.push( + "Opening orders in portfolio margin mode is not supported. Use standard or unified account mode.", + ); + if (!snapshot.fees) + preview.warnings.push( + "Using base fee estimates until your fee tier is available.", + ); + return { ticket, preview, snapshot }; + } + private confirm(confirmed: boolean | undefined, warnings: string[] = []) { + if ((this.settings().confirmations || warnings.length) && !confirmed) + throw new Error( + warnings.length + ? `Confirmation required: ${warnings.join(" ")}` + : "Confirm the order before submitting.", + ); + } + private async saveResults() { + await this.store.writeJson("intents.json", [...this.results].slice(-500)); + } + private async once( + id: string, + cloids: string[], + send: () => Promise, + ): Promise { + if (!id || id.length > 200) + throw new Error("A stable client intent id is required."); + const pending = this.pending.get(id); + if (pending) return pending; + const previous = this.results.get(id); + if (previous) + return previous.state === "unknown" ? this.reconcile(previous) : previous; + const task = this.store.tradingLock(async () => { + this.results = new Map( + await this.store.readJson<[string, TradingResult][]>( + "intents.json", + [], + ), + ); + const known = this.results.get(id); + if (known) + return known.state === "unknown" ? this.reconcile(known) : known; + const initial: TradingResult = { + clientId: id, + state: "unknown", + message: "Submission pending. Do not submit again.", + cloids, + at: Date.now(), + }; + this.results.set(id, initial); + await this.saveResults(); + let result: TradingResult; + try { + const response = await send(); + const serialized = JSON.stringify(response); + const errors: string[] = []; + const walk = (value: any) => { + if (value && typeof value === "object") { + if (typeof value.error === "string") errors.push(value.error); + for (const child of Object.values(value)) walk(child); + } + }; + walk(response); + result = { + ...initial, + state: errors.length ? "rejected" : "accepted", + message: errors.length + ? `Some or all orders were rejected: ${errors.join("; ")}. Inspect each order before resubmitting.` + : (response as any)?.response?.type === "localTwap" + ? "Local TWAP scheduled. Keep Gloom running; each slice includes the builder fee." + : "Accepted by Hyperliquid.", + response: JSON.parse(serialized), + }; + } catch (error) { + // API-level rejection is definitive. All transport failures remain unknown. + const definitive = + error instanceof Error && + ["ApiRequestError", "RequestNotSentError"].includes(error.name); + result = { + ...initial, + state: definitive ? "rejected" : "unknown", + message: errorText(error), + ...(definitive ? { response: (error as any).response } : {}), + }; + } + this.results.set(id, result); + await this.saveResults(); + return result.state === "unknown" ? this.reconcile(result) : result; + }); + this.pending.set(id, task); + try { + return await task; + } finally { + this.pending.delete(id); + } + } + private async reconcile(previous: TradingResult): Promise { + if (!previous.cloids?.length || !this.status.address) + return { + ...previous, + message: + previous.message + + " Outcome is unknown. Inspect account history; this action will not be resent.", + }; + try { + const statuses = await Promise.all( + previous.cloids.map((oid) => + this.options.shared.info.request({ + type: "orderStatus", + user: this.status.address, + oid, + }), + ), + ); + if (statuses.every((status) => status.status === "order")) { + const result: TradingResult = { + ...previous, + state: "accepted", + message: + "Exchange order records found. Check each order status and any unfilled remainder.", + response: statuses, + }; + this.results.set(previous.clientId, result); + await this.saveResults(); + return result; + } + } catch {} + return { + ...previous, + message: + "Exchange outcome is still unknown. No automatic resend. Inspect Orders and Fills before creating another intent.", + }; + } + private async submit( + ticket: TicketRequest, + confirmed?: boolean, + ): Promise { + await this.guard(); + const previous = this.results.get(ticket.clientId); + if (previous) + return previous.state === "unknown" ? this.reconcile(previous) : previous; + const built = await this.preview(ticket, true); + if (built.preview.errors.length) + throw new Error(built.preview.errors.join(" ")); + this.confirm(confirmed, built.preview.warnings); + const cloids = + built.preview.order?.orders.flatMap((o) => (o.c ? [o.c] : [])) ?? []; + return this.once(ticket.clientId, cloids, async () => { + if (built.preview.twap && this.settings().builder) { + const job = await this.twaps.start( + { ...built.ticket, accountAddress: this.status.address }, + built.preview.size, + built.preview.notional, + ); + return { + status: "ok", + response: { type: "localTwap", data: { twapId: job.id } }, + }; + } + const exchange = await this.exchange(); + // Leverage is set explicitly before entry; a timeout here prevents the order from being sent. + if (!ticket.reduceOnly && !ticket.positionTpsl) + await exchange.updateLeverage({ + asset: built.ticket.market.assetId, + isCross: ticket.marginMode === "cross", + leverage: ticket.leverage, + }); + return built.preview.twap + ? exchange.twapOrder(built.preview.twap) + : exchange.order(built.preview.order!); + }); + } + private async connectionStatus() { + this.assertOpen(); + if (!this.status.agentAddress || this.status.mode !== "pending") + return this.status; + if (this.roleCheck) return this.roleCheck; + if (Date.now() - this.lastRoleCheck < 15_000) return this.status; + this.lastRoleCheck = Date.now(); + const expectedAgent = this.status.agentAddress; + this.roleCheck = (async () => { + const role = await this.options.shared.info.request({ + type: "userRole", + user: expectedAgent, + }); + this.assertOpen(); + if ( + this.status.agentAddress !== expectedAgent || + this.status.mode !== "pending" + ) + return this.status; + if (role.role === "agent" && role.data?.user) { + this.status.address = address(role.data.user); + const builder = this.settings().builder; + if (builder) { + const allowance = await this.options.shared.info.request({ + type: "maxBuilderFee", + user: this.status.address, + builder: builder.address, + }); + this.assertOpen(); + if ( + this.status.agentAddress !== expectedAgent || + this.status.mode !== "pending" + ) + return this.status; + if (allowance < builder.feeTenthsBps) { + this.status.error = + "Approve Gloom's 0.1% builder fee in the browser to finish connecting."; + await this.persist(); + return this.status; + } + } + this.status.error = undefined; + this.status.mode = "trading"; + await this.persist(); + await this.touch(); + if (this.poll) clearInterval(this.poll); + this.poll = undefined; + } + return this.status; + })(); + try { + return await this.roleCheck; + } finally { + this.roleCheck = undefined; + } + } + private async openWallet(generate: boolean) { + await this.guard(false); + this.walletPage?.close(); + if (generate) { + if (this.status.storage) await this.store.disconnect(); + const key = generatePrivateKey(), + agent = privateKeyToAccount(key); + this.status = { + ...this.status, + mode: "pending", + address: undefined, + agentAddress: agent.address, + expiresAt: Date.now() + 90 * 86_400_000, + storage: await this.store.saveKey(key), + }; + this.status.agentName = `gloom valid_until ${this.status.expiresAt}`; + await this.persist(); + this.accounts.stop(); + if (this.poll) clearInterval(this.poll); + this.poll = setInterval( + () => void this.connectionStatus().catch(() => {}), + 15000, + ); + this.poll.unref(); + } + const configuredPort = + typeof this.options.approvalPort === "function" + ? this.options.approvalPort() + : this.options.approvalPort; + const port = + configuredPort || + Number(process.env.GLOOM_HYPERLIQUID_APPROVAL_PORT ?? 0); + if (!Number.isInteger(port) || port < 0 || port > 65535) + throw new Error("Approval port must be 0 or a valid TCP port."); + this.walletPage = startWalletPage({ + network: this.options.network, + agentAddress: this.status.agentAddress, + agentName: this.status.agentName, + address: this.status.address, + builder: this.settings().builder, + approvalPort: port, + checkRegion: () => this.region(), + }); + const { close, ...session } = this.walletPage; + return session; + } + /** Native-only approval path for scripts. Never registered as a renderer capability. */ + async connectMainWallet(privateKey: `0x${string}`): Promise { + await this.ready; + this.assertOpen(); + if (this.transitioning || this.signedInFlight || this.pending.size) + throw new Error( + "Wait for the current trading request before connecting a wallet.", + ); + this.transitioning = true; + try { + for (const job of this.twaps.getJobs()) + if (job.status === "running" || job.status === "paused") + await this.twaps.cancel(job.id); + return await this.store.tradingLock(async () => { + await this.guard(false); + let wallet; + try { + wallet = privateKeyToAccount(privateKey); + } catch { + throw new Error("Invalid main-wallet key."); + } + const reusable = + this.status.mode === "pending" && + this.status.address?.toLowerCase() === wallet.address.toLowerCase() && + this.status.agentAddress && + this.status.storage && + this.status.expiresAt && + this.status.expiresAt > Date.now(); + if (!reusable) { + this.walletPage?.close(); + this.accounts.stop(); + await this.store.disconnect(); + const key = generatePrivateKey(), + apiWallet = privateKeyToAccount(key), + expiresAt = Date.now() + 90 * 86_400_000; + this.status = { + ...this.status, + mode: "pending", + address: wallet.address, + agentAddress: apiWallet.address, + agentName: `gloom valid_until ${expiresAt}`, + expiresAt, + storage: await this.store.saveKey(key), + error: undefined, + }; + await this.persist(); + } + // A failed or timed-out approval is never automatically resent. Pending state + // retains the same local API wallet for deliberate later reconciliation/retry. + await approveApiWallet({ + network: this.options.network, + wallet, + agentAddress: this.status.agentAddress!, + agentName: this.status.agentName!, + nonceManager: () => this.store.nextNonce(), + transport: singleAttemptExchangeTransport(this.options.network, () => + this.assertOpen(), + ), + }); + this.lastRoleCheck = 0; + await this.connectionStatus(); + if (this.status.mode !== "trading") + throw new Error( + "Approval submitted but not yet confirmed. Check connectionStatus before retrying.", + ); + return { ...this.status }; + }); + } catch (error) { + this.status.error = errorText(error); + await this.persist(); + throw new Error(this.status.error); + } finally { + this.transitioning = false; + } + } + async invoke( + operation: TradingOperation | string, + payload: any = {}, + ): Promise { + await this.ready; + this.assertOpen(); + const transitions = ["watch", "connect", "import", "disconnect"].includes( + operation, + ), + signed = [ + "submit", + "cancel", + "modify", + "leverage", + "margin", + "close", + "reverse", + "closeAll", + "twapCancel", + "twapResume", + ].includes(operation); + if (transitions && (this.transitioning || this.signedInFlight > 0)) + throw new Error( + "Wait for the current trading request before changing accounts.", + ); + if ( + signed && + payload.expectedAddress && + String(payload.expectedAddress).toLowerCase() !== + this.status.address?.toLowerCase() + ) + throw new Error("The connected account changed. Review the order again."); + if ((signed || operation === "preview") && this.transitioning) + throw new Error("Wait for the wallet change to finish."); + if (transitions) { + this.transitioning = true; + this.builderChecked = 0; + } + if (signed) this.signedInFlight++; + try { + if (transitions) { + for (const job of this.twaps.getJobs()) + if (job.status === "running" || job.status === "paused") + await this.twaps.cancel(job.id); + return await this.store.tradingLock(() => + this.execute(operation, payload), + ); + } + if (signed && this.status.address) await this.touch(); + return await this.execute(operation, payload); + } finally { + if (transitions) this.transitioning = false; + if (signed) { + this.signedInFlight--; + this.scheduleIdle(); + } + } + } + private async execute( + operation: TradingOperation | string, + payload: any = {}, + ): Promise { + switch (operation) { + case "status": + return { + ...this.status, + ...(this.status.expiresAt && this.status.expiresAt < Date.now() + ? { error: "Trading approval expired. Reconnect your wallet." } + : {}), + }; + case "account": + return this.account(); + case "region": + return this.region(); + case "acknowledge": + if (payload.risk !== true || payload.eligible !== true) + throw new Error( + "Both trading risk and eligibility acknowledgements are required.", + ); + this.status.riskAcknowledged = true; + this.status.eligibleAcknowledged = true; + await this.persist(); + return this.status; + case "watch": { + const user = address(payload.address); + await this.store.disconnect(); + this.walletPage?.close(); + this.status = { + network: this.options.network, + mode: "watch", + address: user, + riskAcknowledged: false, + eligibleAcknowledged: false, + }; + await this.persist(); + await this.touch(); + return this.status; + } + case "connect": + return this.openWallet(true); + case "wallet": + return this.openWallet(false); + case "connectionStatus": + return this.connectionStatus(); + case "disconnect": { + this.walletPage?.close(); + if (this.poll) clearInterval(this.poll); + this.suspend(); + await this.store.disconnect(); + this.status = { + network: this.options.network, + mode: "disconnected", + riskAcknowledged: false, + eligibleAcknowledged: false, + }; + this.emit(); + return this.status; + } + case "import": { + await this.guard(false); + const user = address(payload.address); + if ( + typeof payload.privateKey !== "string" || + !/^0x[0-9a-fA-F]{64}$/.test(payload.privateKey) + ) + throw new Error("Enter a valid API wallet private key."); + let wallet; + try { + wallet = privateKeyToAccount(payload.privateKey); + } catch { + throw new Error("Invalid API wallet private key."); + } + if (wallet.address.toLowerCase() === user.toLowerCase()) + throw new Error( + "Import an API wallet key, never the main wallet key.", + ); + const role = await this.options.shared.info.request({ + type: "userRole", + user: wallet.address, + }); + if ( + role.role !== "agent" || + role.data?.user?.toLowerCase() !== user.toLowerCase() + ) + throw new Error( + "This API wallet is not approved for the account on this network.", + ); + const agents = await this.options.shared.info.request({ + type: "extraAgents", + user, + }); + const known = agents.find( + (a) => a.address?.toLowerCase() === wallet.address.toLowerCase(), + ); + const expiresAt = Number(known?.validUntil ?? payload.expiresAt); + if (!Number.isFinite(expiresAt) || expiresAt <= Date.now()) + throw new Error("A valid future API wallet expiry is required."); + const builder = this.settings().builder; + const approved = + !builder || + (await this.options.shared.info.request({ + type: "maxBuilderFee", + user, + builder: builder.address, + })) >= builder.feeTenthsBps; + await this.store.disconnect(); + this.status = { + ...this.status, + mode: approved ? "trading" : "pending", + error: approved + ? undefined + : "Approve Gloom's 0.1% builder fee from Wallet management to finish importing.", + address: user, + agentAddress: wallet.address, + expiresAt, + agentName: known?.name, + storage: await this.store.saveKey(payload.privateKey), + }; + await this.persist(); + await this.touch(); + return this.status; + } + case "preview": + return (await this.preview(payload.ticket)).preview; + case "submit": + return this.submit(payload.ticket, payload.confirmed); + case "leverage": { + await this.guard(); + const market = await this.market(payload.coin); + if ( + !Number.isInteger(payload.leverage) || + payload.leverage < 1 || + payload.leverage > market.maxLeverage + ) + throw new Error("Invalid leverage."); + if (market.onlyIsolated && payload.marginMode !== "isolated") + throw new Error("Market requires isolated margin."); + const exchange = await this.exchange(); + return this.once(payload.clientId ?? crypto.randomUUID(), [], () => + exchange.updateLeverage({ + asset: market.assetId, + isCross: payload.marginMode === "cross", + leverage: payload.leverage, + }), + ); + } + case "margin": { + await this.guard(); + const market = await this.market(payload.coin), + amount = Number(payload.amount); + if (!Number.isFinite(amount) || !amount) + throw new Error("Enter a nonzero margin change in USD."); + const position = (await this.account()).positions.find( + (p) => p.coin === market.coin, + ); + if (position?.leverage.type !== "isolated") + throw new Error("Only isolated positions support margin changes."); + if (amount < 0 && market.marginMode === "strictIsolated") + throw new Error( + "Strict isolated markets do not allow margin removal.", + ); + this.confirm(payload.confirmed); + const exchange = await this.exchange(); + return this.once(payload.clientId ?? crypto.randomUUID(), [], () => + exchange.updateIsolatedMargin({ + asset: market.assetId, + isBuy: Number(position.szi) > 0, + ntli: Math.trunc(amount * 1e6), + }), + ); + } + case "cancel": { + await this.guard(); + this.confirm(payload.confirmed); + const snapshot = await this.accounts.refresh(); + const selected = snapshot.orders.filter( + (o) => + (!payload.coin || o.coin === payload.coin) && + (payload.oid === undefined || o.oid === Number(payload.oid)), + ); + if (!selected.length) throw new Error("No matching open orders."); + await this.loadMarkets(); + const cancels = selected.map((o) => { + const m = this.markets.get(o.coin); + if (!m) throw new Error("Cannot resolve market for order."); + return { a: m.assetId, o: o.oid }; + }); + const exchange = await this.exchange(); + return this.once(payload.clientId ?? crypto.randomUUID(), [], () => + exchange.cancel({ cancels }), + ); + } + case "modify": { + await this.guard(); + const built = await this.preview(payload.ticket, true); + if (built.preview.errors.length) + throw new Error(built.preview.errors.join(" ")); + if (!built.preview.order || built.preview.order.orders.length !== 1) + throw new Error("Modify supports one order at a time."); + this.confirm(payload.confirmed, built.preview.warnings); + const existing = (await this.account()).orders.find( + (o) => o.oid === Number(payload.oid), + ); + if (!existing || existing.coin !== built.ticket.market.coin) + throw new Error("Order is no longer open in this market."); + const order = built.preview.order.orders[0]!; + if (existing.isTrigger && !("trigger" in order.t)) + throw new Error( + "Modifying a trigger order must preserve its trigger type and price.", + ); + if (existing.isPositionTpsl) + throw new Error( + "Use position TP/SL to replace a position-level bracket.", + ); + const exchange = await this.exchange(); + return this.once( + payload.ticket.clientId, + order.c ? [order.c] : [], + () => + exchange.modify({ + oid: Number(payload.oid), + order, + } as ModifyParameters), + ); + } + case "twapResume": { + await this.guard(); + this.confirm(payload.confirmed); + const job = this.twaps.getJobs().find((j) => j.id === payload.twapId); + if (!job) throw new Error("Local TWAP not found."); + if ( + job.ticket.accountAddress?.toLowerCase() !== + this.status.address?.toLowerCase() + ) + throw new Error( + "Reconnect the original TWAP account before resuming.", + ); + const resumed = await this.twaps.resume(job.id); + return { + state: "accepted", + message: "Local TWAP resumed. Keep Gloom running.", + twapId: resumed.id, + }; + } + case "twapCancel": { + this.confirm(payload.confirmed); + if ( + typeof payload.twapId === "string" && + payload.twapId.startsWith("local:") + ) { + const job = await this.twaps.cancel(payload.twapId); + return { state: "accepted", message: job.reason, twapId: job.id }; + } + await this.guard(); + const market = await this.market(payload.coin), + exchange = await this.exchange(); + if (!Number.isInteger(payload.twapId)) + throw new Error("Invalid TWAP id."); + return this.once(payload.clientId ?? crypto.randomUUID(), [], () => + exchange.twapCancel({ a: market.assetId, t: payload.twapId }), + ); + } + case "close": + case "reverse": { + await this.guard(); + const snapshot = await this.accounts.refresh(), + position = snapshot.positions.find((p) => p.coin === payload.coin); + if (!position) throw new Error("Position is no longer open."); + const market = await this.market(position.coin); + const percent = + operation === "reverse" ? 200 : Number(payload.percent ?? 100); + if ( + !Number.isFinite(percent) || + percent <= 0 || + percent > (operation === "reverse" ? 200 : 100) + ) + throw new Error( + "Close percentage must be greater than 0 and at most 100.", + ); + return this.submit( + { + market, + side: Number(position.szi) > 0 ? "sell" : "buy", + kind: payload.kind ?? "market", + limitPrice: payload.limitPrice, + size: new Decimal(position.szi).abs().mul(percent).div(100).toFixed(), + sizeUnit: "coin", + leverage: position.leverage.value, + marginMode: position.leverage.type, + reduceOnly: operation === "close", + clientId: payload.clientId, + }, + payload.confirmed, + ); + } + case "closeAll": { + await this.guard(); + this.confirm(payload.confirmed); + if (!payload.clientId) + throw new Error("A stable close-all intent id is required."); + let snapshot = await this.accounts.refresh(); + for (const dex of new Set(snapshot.positions.map((p) => p.dex))) + snapshot = await this.accounts.refreshRisk(dex); + await this.loadMarkets(); + const orders: OrderParameters["orders"] = []; + for (const position of snapshot.positions) { + const market = await this.market(position.coin); + if (!market) + throw new Error( + `Cannot resolve ${position.coin}; close it separately.`, + ); + const ticket: TicketRequest = { + market, + side: Number(position.szi) > 0 ? "sell" : "buy", + kind: "market", + size: Math.abs(Number(position.szi)), + sizeUnit: "coin", + leverage: position.leverage.value, + marginMode: position.leverage.type, + reduceOnly: true, + clientId: `${payload.clientId}:${position.coin}`, + }; + const preview = previewTicket( + ticket, + { + available: 0, + makerRate: Number(snapshot.fees?.userAddRate ?? 0.00015), + takerRate: Number(snapshot.fees?.userCrossRate ?? 0.00045), + referralDiscount: Number( + snapshot.fees?.activeReferralDiscount ?? 0, + ), + positionSize: Number(position.szi), + }, + this.settings(), + ); + if (preview.errors.length) + throw new Error(`${position.coin}: ${preview.errors.join(" ")}`); + orders.push(...preview.order!.orders); + } + if (!orders.length) throw new Error("There are no positions to close."); + const exchange = await this.exchange(); + return this.once( + payload.clientId, + orders.flatMap((o) => (o.c ? [o.c] : [])), + () => + exchange.order({ + orders, + grouping: "na", + ...(this.settings().builder + ? { + builder: { + b: this.settings().builder!.address, + f: this.settings().builder!.feeTenthsBps, + }, + } + : {}), + }), + ); + } + case "history": + return this.accounts.history( + payload.kind, + Number(payload.startTime), + payload.endTime, + ); + default: + throw new Error("Unknown trading operation."); + } + } + dispose() { + return this.shutdown(); + } + shutdown() { + this.closed = true; + this.suspend(true); + const stopped = this.twaps.dispose(); + this.walletPage?.close(); + if (this.poll) clearInterval(this.poll); + this.accounts.stop(); + this.listeners.clear(); + return stopped; + } +} diff --git a/src/native/storage.ts b/src/native/storage.ts new file mode 100644 index 0000000..c8ac1b7 --- /dev/null +++ b/src/native/storage.ts @@ -0,0 +1,245 @@ +import { + mkdir, + readFile, + writeFile, + rename, + unlink, + chmod, + lstat, + rm, +} from "node:fs/promises"; +import { join } from "node:path"; +import { createHash } from "node:crypto"; +import type { Address, Network, TradingStatus } from "../trading/types"; + +interface StoredProfile { + version: 1; + mode: TradingStatus["mode"]; + address?: Address; + agentAddress?: Address; + expiresAt?: number; + agentName?: string; + storage?: "keychain" | "file"; + riskAcknowledged?: boolean; + eligibleAcknowledged?: boolean; +} +/** Never returns the private key through the public service interface. */ +export class KeyStore { + readonly directory: string; + private service: string; + constructor(dataDir: string, network: Network) { + this.directory = join(dataDir, network); + this.service = `gloom-hyperliquid-${network}-${createHash("sha256").update(dataDir).digest("hex").slice(0, 20)}`; + } + private async ensure() { + await mkdir(this.directory, { recursive: true, mode: 0o700 }); + const stat = await lstat(this.directory); + if (stat.isSymbolicLink() || !stat.isDirectory()) + throw new Error("Credential directory must be a real directory."); + await chmod(this.directory, 0o700); + } + async readProfile(): Promise { + try { + return JSON.parse( + await readFile(join(this.directory, "profile.json"), "utf8"), + ); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") + throw new Error("Could not read wallet profile."); + return { version: 1, mode: "disconnected" }; + } + } + async writeJson(name: string, value: unknown) { + if (!/^[a-z0-9-]+\.json$/.test(name)) + throw new Error("Invalid storage name."); + await this.ensure(); + const target = join(this.directory, name), + temporary = `${target}.${crypto.randomUUID()}.tmp`; + await writeFile(temporary, JSON.stringify(value), { + mode: 0o600, + flag: "wx", + }); + await rename(temporary, target); + await chmod(target, 0o600); + } + async writeProfile(value: StoredProfile) { + await this.writeJson("profile.json", value); + } + async saveKey(key: `0x${string}`): Promise<"keychain" | "file"> { + await this.ensure(); + try { + await Bun.secrets.set({ + service: this.service, + name: "api-wallet", + value: key, + }); + await unlink(join(this.directory, "api-wallet.key")).catch(() => {}); + return "keychain"; + } catch { + if ((await this.readProfile()).storage === "keychain") + throw new Error( + "Unlock the OS keychain before replacing its API wallet.", + ); + const temporary = join(this.directory, `.key-${crypto.randomUUID()}`); + await writeFile(temporary, key, { mode: 0o600, flag: "wx" }); + await rename(temporary, join(this.directory, "api-wallet.key")); + return "file"; + } + } + async readKey(storage: "keychain" | "file"): Promise<`0x${string}`> { + let key: string | null; + try { + if (storage === "keychain") + key = await Bun.secrets.get({ + service: this.service, + name: "api-wallet", + }); + else { + const file = join(this.directory, "api-wallet.key"); + const stat = await lstat(file); + if ( + stat.isSymbolicLink() || + !stat.isFile() || + (stat.mode & 0o077) !== 0 + ) + throw new Error(); + key = await readFile(file, "utf8"); + } + } catch { + throw new Error( + "API wallet key is unavailable or has unsafe permissions. Reconnect the wallet.", + ); + } + if (!key || !/^0x[0-9a-fA-F]{64}$/.test(key)) + throw new Error("API wallet key is unavailable. Reconnect the wallet."); + return key as `0x${string}`; + } + async disconnect() { + const profile = await this.readProfile(); + if (profile.storage === "keychain") { + try { + await Bun.secrets.delete({ service: this.service, name: "api-wallet" }); + if ( + await Bun.secrets.get({ service: this.service, name: "api-wallet" }) + ) + throw new Error(); + } catch { + throw new Error( + "Could not remove the API wallet from the OS keychain. Disconnect was not completed.", + ); + } + } + await unlink(join(this.directory, "api-wallet.key")).catch((error) => { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") + throw new Error( + "Could not remove the API wallet file. Disconnect was not completed.", + ); + }); + await this.writeProfile({ version: 1, mode: "disconnected" }); + } + async readJson(name: string, fallback: T): Promise { + try { + return JSON.parse(await readFile(join(this.directory, name), "utf8")); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return fallback; + throw new Error("Could not read local trading state."); + } + } + async acquireTwapLease(id: string): Promise<() => Promise> { + await this.ensure(); + const name = createHash("sha256").update(id).digest("hex").slice(0, 32), + lock = join(this.directory, `twap-${name}.lock`); + try { + await mkdir(lock, { mode: 0o700 }); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error; + const recovery = `${lock}.recovery`; + try { + await mkdir(recovery, { mode: 0o700 }); + } catch { + throw new Error("Another process is inspecting the local TWAP lock."); + } + try { + let owner: { pid: number }; + try { + owner = JSON.parse(await readFile(join(lock, "owner.json"), "utf8")); + } catch { + throw new Error( + "Local TWAP lock is incomplete. Inspect the running process before retrying.", + ); + } + if (!Number.isSafeInteger(owner.pid) || owner.pid <= 0) + throw new Error("Local TWAP lock owner is invalid."); + try { + process.kill(owner.pid, 0); + throw new Error( + "This local TWAP is running in another Gloom process.", + ); + } catch (check) { + if ((check as NodeJS.ErrnoException).code !== "ESRCH") throw check; + } + await rm(lock, { recursive: true }); + await mkdir(lock, { mode: 0o700 }); + } finally { + await rm(recovery, { recursive: true }); + } + } + const token = crypto.randomUUID(); + await writeFile( + join(lock, "owner.json"), + JSON.stringify({ pid: process.pid, token }), + { mode: 0o600, flag: "wx" }, + ); + return async () => { + const owner = JSON.parse( + await readFile(join(lock, "owner.json"), "utf8"), + ); + if (owner.token !== token) + throw new Error("Local TWAP lock changed owner."); + await rm(lock, { recursive: true }); + }; + } + async tradingLock(work: () => Promise): Promise { + await this.ensure(); + const lock = join(this.directory, "trading.lock"); + try { + await mkdir(lock, { mode: 0o700 }); + } catch { + throw new Error( + "Another trading request is in progress. Wait for its outcome.", + ); + } + try { + return await work(); + } finally { + await import("node:fs/promises").then((fs) => fs.rmdir(lock)); + } + } + /** Persist before signing; file lock serializes multiple app processes sharing one profile. */ + async nextNonce(): Promise { + await this.ensure(); + const lock = join(this.directory, "nonce.lock"); + const deadline = Date.now() + 5000; + for (;;) { + try { + await mkdir(lock, { mode: 0o700 }); + break; + } catch (error) { + if ( + (error as NodeJS.ErrnoException).code !== "EEXIST" || + Date.now() > deadline + ) + throw new Error("Another trading process holds the nonce lock."); + await new Promise((resolve) => setTimeout(resolve, 30)); + } + } + try { + const previous = await this.readJson("nonce.json", 0); + const next = Math.max(Date.now(), previous + 1); + await this.writeJson("nonce.json", next); + return next; + } finally { + await import("node:fs/promises").then((fs) => fs.rmdir(lock)); + } + } +} diff --git a/src/native/twap.test.ts b/src/native/twap.test.ts new file mode 100644 index 0000000..f03c71c --- /dev/null +++ b/src/native/twap.test.ts @@ -0,0 +1,423 @@ +import { describe, expect, test } from "bun:test"; +import { LocalTwapScheduler, type LocalTwapJob } from "./twap"; +import type { TicketRequest, TradingResult } from "../trading/types"; + +const ticket: TicketRequest = { + market: { + coin: "BTC", + assetId: 0, + dex: "", + mark: 100, + maxLeverage: 20, + szDecimals: 3, + onlyIsolated: false, + }, + clientId: "offline-schedule", + accountAddress: "0x0000000000000000000000000000000000000001", + kind: "twap", + side: "buy", + size: "1.2", + sizeUnit: "coin", + leverage: 2, + marginMode: "cross", + twapMinutes: 5, + twapRandomize: false, +}; +function deferred() { + let resolve!: (value: T) => void; + const promise = new Promise((r) => { + resolve = r; + }); + return { promise, resolve }; +} +function filled(size: string): TradingResult { + return { + clientId: "", + state: "accepted", + at: 0, + message: "Accepted", + response: { + status: "ok", + response: { + type: "order", + data: { statuses: [{ filled: { totalSz: size } }] }, + }, + }, + }; +} +function harness() { + let clock = 0, + persisted: LocalTwapJob[] = [], + owner = false, + releases = 0, + failWrite = false; + const submitted: TicketRequest[] = []; + let mark = async () => 100; + let execute = async (request: TicketRequest) => filled(String(request.size)); + let beforeWrite: (() => Promise) | undefined; + const scheduler = () => + new LocalTwapScheduler({ + automatic: false, + now: () => clock, + random: () => 1, + read: async () => structuredClone(persisted), + write: async (jobs) => { + await beforeWrite?.(); + if (failWrite) throw new Error("Disk full"); + persisted = structuredClone(jobs); + }, + acquire: async () => { + if (owner) throw new Error("Another process owns the journal"); + owner = true; + return async () => { + owner = false; + releases++; + }; + }, + mark: () => mark(), + execute: (request) => { + submitted.push(request); + return execute(request); + }, + }); + return { + scheduler, + submitted, + get persisted() { + return persisted; + }, + get owner() { + return owner; + }, + get releases() { + return releases; + }, + clock: (value: number) => { + clock = value; + }, + mark: (value: typeof mark) => { + mark = value; + }, + execute: (value: typeof execute) => { + execute = value; + }, + write: (value?: () => Promise) => { + beforeWrite = value; + }, + fail: (value: boolean) => { + failWrite = value; + }, + }; +} + +// Every executor below is a local double. No exchange client, key or network request is created. +describe("local TWAP safety", () => { + test("partial fills remain within total quantity, use unique slice ids, and finish at the planned end", async () => { + const h = harness(), + scheduler = h.scheduler(); + h.execute(async (request) => + h.submitted.length === 1 ? filled("0.06") : filled(String(request.size)), + ); + const job = await scheduler.start(ticket, "1.2", 120); + for (let i = 0; i < job.slices; i++) { + const current = scheduler.getJobs()[0]!; + h.clock(current.nextTime); + await scheduler.runDue(); + } + const completed = scheduler.getJobs()[0]!; + expect(completed.status).toBe("completed"); + expect(completed.executed).toBe("1.2"); + expect(new Set(h.submitted.map((request) => request.clientId)).size).toBe( + job.slices, + ); + expect( + h.submitted.every( + (request) => request.kind === "market" && request.sizeUnit === "coin", + ), + ).toBe(true); + expect(completed.nextTime).toBe(301_000); + await scheduler.dispose(); + expect(h.releases).toBe(1); + }); + test("cancel during price lookup sends no order and retains canceled status", async () => { + const h = harness(), + scheduler = h.scheduler(), + quote = deferred(), + entered = deferred(); + h.mark(async () => { + entered.resolve(); + return quote.promise; + }); + const job = await scheduler.start(ticket, "1.2", 120); + h.clock(job.nextTime); + const running = scheduler.runDue(); + await entered.promise; + await scheduler.cancel(job.id); + quote.resolve(100); + await running; + expect(h.submitted).toHaveLength(0); + expect(scheduler.getJobs()[0]?.status).toBe("canceled"); + await scheduler.dispose(); + }); + test("cancel while the pending slice journal is being saved sends no order", async () => { + const h = harness(), + scheduler = h.scheduler(), + writing = deferred(), + release = deferred(); + const job = await scheduler.start(ticket, "1.2", 120); + let first = true; + h.write(async () => { + if (first) { + first = false; + writing.resolve(); + await release.promise; + } + }); + h.clock(job.nextTime); + const running = scheduler.runDue(); + await writing.promise; + const cancellation = scheduler.cancel(job.id); + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); + release.resolve(); + await Promise.all([running, cancellation]); + expect(h.submitted).toHaveLength(0); + expect(h.persisted[0]?.status).toBe("canceled"); + expect(h.persisted[0]?.pending).toBeUndefined(); + await scheduler.dispose(); + }); + test("dispose waits for an in-flight slice, accounts for its fill and never resumes after restart", async () => { + const h = harness(), + scheduler = h.scheduler(), + outcome = deferred(), + entered = deferred(); + h.execute(async () => { + entered.resolve(); + return outcome.promise; + }); + const job = await scheduler.start(ticket, "1.2", 120); + h.clock(job.nextTime); + const running = scheduler.runDue(); + await entered.promise; + const disposing = scheduler.dispose(); + expect(scheduler.dispose()).toBe(disposing); + expect(h.owner).toBe(true); + outcome.resolve(filled("0.12")); + await Promise.all([running, disposing]); + expect(h.persisted[0]).toMatchObject({ + status: "paused", + executed: "0.12", + nextSlice: 1, + }); + expect(h.owner).toBe(false); + const restarted = h.scheduler(); + await restarted.ready; + h.clock(1_000_000); + await restarted.runDue(); + expect(h.submitted).toHaveLength(1); + await restarted.dispose(); + expect(h.releases).toBe(1); + }); + test("shutdown while a quote is pending prevents submission and cannot resume implicitly", async () => { + const h = harness(), + scheduler = h.scheduler(), + quote = deferred(), + entered = deferred(); + h.mark(async () => { + entered.resolve(); + return quote.promise; + }); + const job = await scheduler.start(ticket, "1.2", 120); + h.clock(job.nextTime); + const running = scheduler.runDue(); + await entered.promise; + const disposing = scheduler.dispose(); + quote.resolve(100); + await Promise.all([running, disposing]); + expect(h.submitted).toHaveLength(0); + expect(h.persisted[0]?.status).toBe("paused"); + }); + test("unknown outcome survives restart, blocks resume and is never resent", async () => { + const h = harness(), + scheduler = h.scheduler(); + h.execute(async () => ({ + clientId: "", + state: "unknown", + at: 0, + message: "Timeout", + })); + const job = await scheduler.start(ticket, "1.2", 120); + h.clock(job.nextTime); + await scheduler.runDue(); + expect(scheduler.getJobs()[0]).toMatchObject({ + status: "paused", + executed: "0", + pending: { clientId: `${ticket.clientId}:slice:0` }, + }); + await scheduler.dispose(); + const restarted = h.scheduler(); + await restarted.ready; + await expect(restarted.resume(job.id)).rejects.toThrow("No retry"); + await restarted.runDue(); + expect(h.submitted).toHaveLength(1); + await restarted.cancel(job.id); + await restarted.dispose(); + }); + test("cancel during uncertain reconciliation cannot be overwritten by paused status", async () => { + const h = harness(), + scheduler = h.scheduler(), + outcome = deferred(), + entered = deferred(); + h.execute(async () => { + entered.resolve(); + return outcome.promise; + }); + const job = await scheduler.start(ticket, "1.2", 120); + h.clock(job.nextTime); + const running = scheduler.runDue(); + await entered.promise; + await scheduler.cancel(job.id); + outcome.resolve({ + clientId: "", + state: "accepted", + at: 0, + message: "Found", + response: [{ status: "order" }], + }); + await running; + expect(scheduler.getJobs()[0]?.status).toBe("canceled"); + expect(scheduler.getJobs()[0]?.pending).toBeDefined(); + await scheduler.dispose(); + }); + test("malformed, resting or oversized fill responses fail closed without retry", async () => { + for (const response of [ + {}, + { + status: "ok", + response: { + type: "order", + data: { statuses: [{ resting: { oid: 1 } }] }, + }, + }, + filled("9").response, + filled("NaN").response, + filled("").response, + ]) { + const h = harness(), + scheduler = h.scheduler(); + h.execute(async () => ({ + clientId: "", + state: "accepted", + at: 0, + message: "Accepted", + response, + })); + const job = await scheduler.start(ticket, "1.2", 120); + h.clock(job.nextTime); + await scheduler.runDue(); + expect(scheduler.getJobs()[0]?.status).toBe("paused"); + expect(scheduler.getJobs()[0]?.pending).toBeDefined(); + await expect(scheduler.resume(job.id)).rejects.toThrow("No retry"); + expect(h.submitted).toHaveLength(1); + await scheduler.dispose(); + } + }); + test("journal write failure blocks submission and further scheduling", async () => { + const h = harness(), + scheduler = h.scheduler(); + const job = await scheduler.start(ticket, "1.2", 120); + h.fail(true); + h.clock(job.nextTime); + await scheduler.runDue(); + expect(h.submitted).toHaveLength(0); + await expect(scheduler.resume(job.id)).rejects.toThrow( + "persistence failed", + ); + h.fail(false); + await scheduler.dispose(); + expect(h.owner).toBe(false); + }); + test("a second process cannot cancel, resume, overwrite, or dispose the owning journal", async () => { + const h = harness(), + first = h.scheduler(); + const job = await first.start(ticket, "1.2", 120); + const second = h.scheduler(); + await second.ready; + await expect(second.cancel(job.id)).rejects.toThrow("Another process"); + await expect(second.resume(job.id)).rejects.toThrow("Another process"); + await expect( + second.start({ ...ticket, clientId: "other" }, "1.2", 120), + ).rejects.toThrow("Another process"); + await second.dispose(); + expect(h.persisted[0]?.status).toBe("running"); + expect(h.owner).toBe(true); + await first.dispose(); + }); + test("reopening rereads the journal under its lease and filters account rows", async () => { + const h = harness(), + first = h.scheduler(); + const job = await first.start(ticket, "1.2", 120); + const second = h.scheduler(); + await second.ready; + await first.cancel(job.id); + await first.dispose(); + await expect(second.resume(job.id)).rejects.toThrow("Only a paused"); + expect(second.rows(ticket.accountAddress)).toHaveLength(1); + expect( + second.rows("0x0000000000000000000000000000000000000002"), + ).toHaveLength(0); + await second.dispose(); + }); + test("jitter stays within its scheduled interval and preserves the final end time", async () => { + const h = harness(), + scheduler = h.scheduler(); + const job = await scheduler.start( + { ...ticket, twapRandomize: true }, + "1.2", + 120, + ); + h.clock(job.nextTime); + await scheduler.runDue(); + expect(scheduler.getJobs()[0]!.nextTime).toBeCloseTo( + 1000 + job.intervalMs * 1.1, + 8, + ); + for (let i = 1; i < job.slices; i++) { + h.clock(scheduler.getJobs()[0]!.nextTime); + await scheduler.runDue(); + } + expect(scheduler.getJobs()[0]!.nextTime).toBe(301_000); + expect(scheduler.getJobs()[0]!.status).toBe("completed"); + await scheduler.dispose(); + }); + test("long sleep pauses missed slices instead of sending overdue orders", async () => { + const h = harness(), + scheduler = h.scheduler(); + const job = await scheduler.start(ticket, "1.2", 120); + h.clock(3_600_000); + await scheduler.runDue(); + expect(h.submitted).toHaveLength(0); + expect(scheduler.getJobs()[0]?.status).toBe("paused"); + await scheduler.resume(job.id); + h.clock(scheduler.getJobs()[0]!.nextTime); + await scheduler.runDue(); + expect(h.submitted).toHaveLength(1); + await scheduler.dispose(); + }); + test("duplicate starts return a detached existing job and cannot add caller fields to the journal", async () => { + const h = harness(), + scheduler = h.scheduler(); + const first = await scheduler.start( + { ...ticket, unrelated: "not an order field" } as TicketRequest, + "1.2", + 120, + ); + first.ticket.market.coin = "ETH"; + const second = await scheduler.start(ticket, "8", 800); + expect(second.total).toBe("1.2"); + expect(second.ticket.market.coin).toBe("BTC"); + expect("unrelated" in h.persisted[0]!.ticket).toBe(false); + await scheduler.dispose(); + }); +}); diff --git a/src/native/twap.ts b/src/native/twap.ts new file mode 100644 index 0000000..def0618 --- /dev/null +++ b/src/native/twap.ts @@ -0,0 +1,495 @@ +import Decimal from "decimal.js"; +import { roundSize } from "../trading/math"; +import type { TicketRequest, TradingResult } from "../trading/types"; + +export type LocalTwapStatus = "running" | "paused" | "completed" | "canceled"; +export interface LocalTwapJob { + id: string; + ticket: TicketRequest; + total: string; + executed: string; + slices: number; + nextSlice: number; + intervalMs: number; + nextTime: number; + startedAt: number; + status: LocalTwapStatus; + reason?: string; + pending?: { clientId: string; size: string }; + updatedAt: number; +} +interface Options { + read: () => Promise; + write: (jobs: LocalTwapJob[]) => Promise; + acquire: (id: string) => Promise<() => Promise>; + execute: (ticket: TicketRequest) => Promise; + mark: (coin: string) => Promise; + now?: () => number; + random?: () => number; + automatic?: boolean; + onChange?: () => void; +} +const clone = (value: T): T => structuredClone(value); +const uncertain = + "Slice outcome needs review. No retry. Inspect Orders and Fills, then cancel this schedule."; + +/** A local slice is exactly one IOC order, with no attached legs or resting remainder. */ +export function confirmedFillSize(response: unknown): number { + const raw = response as any; + const statuses = + raw?.response?.type === "order" ? raw.response.data?.statuses : undefined; + if (!Array.isArray(statuses) || statuses.length !== 1) + throw new Error(uncertain); + const status = statuses[0]; + if (typeof status?.error === "string" && !status.filled && !status.resting) + return 0; + if (status?.error || status?.resting) throw new Error(uncertain); + const rawSize = status?.filled?.totalSz; + const size = + typeof rawSize === "string" && /^(?:\d+(?:\.\d*)?|\.\d+)$/.test(rawSize) + ? Number(rawSize) + : NaN; + if (!Number.isFinite(size) || size < 0) throw new Error(uncertain); + return size; +} + +/** Persisted local IOC schedule. Restarts and uncertain outcomes never resume automatically. */ +export class LocalTwapScheduler { + private jobs = new Map(); + private timer?: ReturnType; + private active = new Map>(); + private disposed = false; + private storageFailed = false; + private releaseOwner?: () => Promise; + private mutations: Promise = Promise.resolve(); + private saves: Promise = Promise.resolve(); + private disposing?: Promise; + private now: () => number; + readonly ready: Promise; + + constructor(private options: Options) { + this.now = options.now ?? Date.now; + this.ready = this.restore(); + } + private async restore() { + const jobs = await this.options.read(); + const next = new Map(); + for (const saved of jobs) { + const job = clone(saved); + if ( + !job?.ticket?.clientId || + job.id !== `local:${job.ticket.clientId}` || + !["running", "paused", "completed", "canceled"].includes(job.status) || + !new Decimal(job.total).isPositive() || + !new Decimal(job.total).isFinite() || + !new Decimal(job.executed).isFinite() || + new Decimal(job.executed).isNegative() || + new Decimal(job.executed).gt(job.total) || + !Number.isInteger(job.nextSlice) || + job.nextSlice < 0 || + !Number.isInteger(job.slices) || + job.slices < 2 || + !Number.isFinite(job.intervalMs) || + job.intervalMs <= 0 + ) + throw new Error( + "Invalid local TWAP journal. Review local trading state before sending orders.", + ); + if (job.status === "running") { + job.status = "paused"; + job.reason = job.pending + ? uncertain + : "Gloom restarted. Resume explicitly to send remaining slices."; + } + next.set(job.id, job); + } + this.jobs = next; + this.notify(); + } + getJobs() { + return [...this.jobs.values()].map(clone); + } + rows(accountAddress?: string) { + return this.getJobs() + .filter( + (job) => + !accountAddress || + job.ticket.accountAddress?.toLowerCase() === + accountAddress.toLowerCase(), + ) + .map((job) => ({ + twapId: job.id, + local: true, + state: { + coin: job.ticket.market.coin, + side: job.ticket.side === "buy" ? "B" : "A", + sz: job.total, + executedSz: job.executed, + minutes: job.ticket.twapMinutes, + reduceOnly: Boolean(job.ticket.reduceOnly), + }, + status: { status: job.status }, + reason: job.reason, + nextTime: job.status === "running" ? job.nextTime : undefined, + })); + } + private notify() { + try { + this.options.onChange?.(); + } catch {} + } + private assertOpen() { + if (this.disposed) throw new Error("Local TWAP scheduler is closed."); + if (this.storageFailed) + throw new Error( + "Local TWAP persistence failed. Reopen Gloom and review the schedule before resuming.", + ); + } + private mutate(operation: () => Promise): Promise { + const result = this.mutations + .catch(() => {}) + .then(async () => { + await this.ready; + this.assertOpen(); + return operation(); + }); + this.mutations = result; + return result; + } + /** One owner for the whole journal prevents separate processes overwriting each other's schedules. */ + private async ownJournal() { + if (!this.releaseOwner) { + const release = await this.options.acquire("local-twap-scheduler"); + try { + await this.restore(); + this.assertOpen(); + this.releaseOwner = release; + } catch (error) { + await release(); + throw error; + } + } + this.assertOpen(); + } + private save() { + const snapshot = this.getJobs(); + this.saves = this.saves + .catch(() => {}) + .then(() => this.options.write(snapshot)) + .catch((error) => { + this.storageFailed = true; + if (this.timer) clearTimeout(this.timer); + this.timer = undefined; + for (const job of this.jobs.values()) + if (job.status === "running") { + job.status = "paused"; + job.reason = + "Local TWAP persistence failed. Reopen Gloom and inspect Orders and Fills."; + } + this.notify(); + throw error; + }); + return this.saves; + } + private changed() { + this.notify(); + this.arm(); + } + private canRun(job: LocalTwapJob) { + return !this.disposed && !this.storageFailed && job.status === "running"; + } + private arm() { + if (this.timer) clearTimeout(this.timer); + this.timer = undefined; + if (this.disposed || this.storageFailed || this.options.automatic === false) + return; + const times = [...this.jobs.values()] + .filter((job) => this.canRun(job) && !this.active.has(job.id)) + .map((job) => job.nextTime); + if (times.length) + this.timer = setTimeout( + () => { + this.timer = undefined; + void this.runDue().catch(() => {}); + }, + Math.max(50, Math.min(...times) - this.now()), + ); + } + start(ticket: TicketRequest, total: string, notional: number) { + return this.mutate(async () => { + await this.ownJournal(); + const id = `local:${ticket.clientId}`, + existing = this.jobs.get(id); + if (existing) return clone(existing); + if (this.jobs.size >= 1000) + throw new Error( + "The local TWAP journal is full. Keep existing records and contact support before creating another schedule.", + ); + const duration = (ticket.twapMinutes ?? 0) * 60_000; + const size = new Decimal(total); + if ( + !ticket.clientId || + ticket.clientId.length > 170 || + !ticket.accountAddress || + !size.isFinite() || + !size.isPositive() || + !Number.isFinite(notional) || + notional <= 0 || + !Number.isInteger(ticket.twapMinutes) || + duration < 300_000 || + duration > 86_400_000 + ) + throw new Error("Invalid local TWAP schedule."); + const slices = Math.min( + 400, + Math.floor(duration / 30_000), + Math.floor(notional / 12), + ); + if (slices < 2) + throw new Error( + "A local TWAP needs at least $24 so each slice clears the minimum order size.", + ); + const now = this.now(); + // Only order fields enter the journal; arbitrary caller fields can never persist credentials. + const safeTicket: TicketRequest = { + market: clone(ticket.market), + clientId: ticket.clientId, + accountAddress: ticket.accountAddress, + side: ticket.side, + kind: "twap", + size: total, + sizeUnit: "coin", + leverage: ticket.leverage, + marginMode: ticket.marginMode, + reduceOnly: ticket.reduceOnly, + twapMinutes: ticket.twapMinutes, + twapRandomize: ticket.twapRandomize ?? true, + }; + const job: LocalTwapJob = { + id, + ticket: safeTicket, + total, + executed: "0", + slices, + nextSlice: 0, + intervalMs: duration / (slices - 1), + nextTime: now + 1000, + startedAt: now, + status: "running", + updatedAt: now, + }; + this.jobs.set(id, job); + await this.save(); + this.changed(); + return clone(job); + }); + } + resume(id: string) { + return this.mutate(async () => { + await this.ownJournal(); + const job = this.jobs.get(id); + if (!job) throw new Error("Local TWAP not found."); + if (job.status !== "paused") + throw new Error("Only a paused local TWAP can resume."); + if (job.pending) throw new Error(uncertain); + if (this.active.has(id)) + throw new Error("Wait for the current slice outcome before resuming."); + job.status = "running"; + job.reason = undefined; + job.nextTime = this.now() + 1000; + job.updatedAt = this.now(); + // Explicit resume rebases only the remaining schedule; missed slices never replay in a burst. + job.startedAt = this.now() - job.nextSlice * job.intervalMs; + await this.save(); + this.changed(); + return clone(job); + }); + } + cancel(id: string) { + return this.mutate(async () => { + await this.ownJournal(); + const job = this.jobs.get(id); + if (!job) throw new Error("Local TWAP not found."); + if (job.status === "completed" || job.status === "canceled") + return clone(job); + job.status = "canceled"; + job.reason = this.active.has(id) + ? "Future slices canceled. A slice already submitted may still fill." + : "Future slices canceled."; + job.updatedAt = this.now(); + await this.save(); + this.changed(); + return clone(job); + }); + } + async runDue() { + await this.ready; + if (this.disposed || this.storageFailed) return; + for (const job of this.jobs.values()) + if ( + this.canRun(job) && + job.nextTime <= this.now() && + !this.active.has(job.id) + ) { + const task = this.runSlice(job); + this.active.set(job.id, task); + try { + await task; + } finally { + this.active.delete(job.id); + } + } + this.arm(); + } + private async runSlice(job: LocalTwapJob) { + try { + if (this.now() - job.nextTime > job.intervalMs) { + job.status = "paused"; + job.reason = + "The schedule fell behind. Resume explicitly to send the remaining slices."; + await this.save(); + return; + } + const remaining = new Decimal(job.total).sub(job.executed), + slots = Math.max(1, job.slices - job.nextSlice); + const mark = await this.options.mark(job.ticket.market.coin); + if (!this.canRun(job)) return; + if (!Number.isFinite(mark) || mark <= 0) + throw new Error("A current positive market price is required."); + const size = roundSize( + remaining.div(slots).toFixed(), + job.ticket.market.szDecimals, + ); + if (Number(size) * mark < 10 && !job.ticket.reduceOnly) { + job.status = "paused"; + job.reason = + "Remaining slice is below $10 at the current price. Cancel and resize the remaining quantity."; + await this.save(); + return; + } + const clientId = `${job.ticket.clientId}:slice:${job.nextSlice}`; + job.pending = { clientId, size }; + job.updatedAt = this.now(); + await this.save(); + if (!this.canRun(job)) { + job.pending = undefined; + await this.save(); + return; + } + const result = await this.options.execute({ + ...clone(job.ticket), + kind: "market", + size, + sizeUnit: "coin", + clientId, + }); + if (result.state === "unknown" || Array.isArray(result.response)) { + if (job.status !== "canceled") job.status = "paused"; + job.reason = uncertain; + await this.save(); + return; + } + const filled = + result.state === "rejected" && + (result.response as any)?.status === "err" + ? 0 + : confirmedFillSize(result.response); + if ( + new Decimal(filled).gt(size) || + new Decimal(job.executed).add(filled).gt(job.total) + ) + throw new Error( + "Exchange fill exceeds the scheduled quantity. Inspect the account.", + ); + job.executed = new Decimal(job.executed).add(filled).toFixed(); + job.updatedAt = this.now(); + job.pending = undefined; + job.nextSlice++; + if (result.state === "rejected") { + if (job.status !== "canceled") job.status = "paused"; + job.reason = + "Slice rejected. Inspect the order result before resuming the remaining quantity."; + } else if (job.status === "canceled") { + /* The in-flight fill is accounted for; future slices remain canceled. */ + } else if (this.disposed || this.storageFailed) { + job.status = "paused"; + job.reason = + "Gloom closed. Resume explicitly to send remaining slices."; + } else if ( + new Decimal(job.total) + .sub(job.executed) + .lessThan(new Decimal(10).pow(-job.ticket.market.szDecimals)) + ) { + job.status = "completed"; + job.reason = undefined; + } else if (job.nextSlice >= job.slices) { + job.status = "paused"; + job.reason = + "Schedule ended with an unfilled remainder. Resume to try the remaining quantity."; + } else { + const end = + job.startedAt + 1000 + (job.ticket.twapMinutes ?? 0) * 60_000; + const random = Math.max( + 0, + Math.min(1, (this.options.random ?? Math.random)()), + ); + const jitter = + job.ticket.twapRandomize && job.nextSlice < job.slices - 1 + ? (random - 0.5) * 0.2 * job.intervalMs + : 0; + const planned = Math.min( + end, + job.startedAt + 1000 + job.nextSlice * job.intervalMs + jitter, + ); + if (planned <= this.now()) { + job.status = "paused"; + job.reason = + "The schedule fell behind. Resume explicitly to send the remaining slices."; + } else { + job.nextTime = planned; + job.reason = + filled < Number(size) + ? "Last slice partially filled; remainder is included in later slices." + : undefined; + } + } + await this.save(); + } catch { + if (job.status !== "canceled") job.status = "paused"; + job.reason = job.pending + ? uncertain + : "Local TWAP paused before submission. Check market and account availability before resuming."; + job.updatedAt = this.now(); + await this.save().catch(() => {}); + } finally { + this.changed(); + } + } + dispose(): Promise { + if (this.disposing) return this.disposing; + this.disposed = true; + if (this.timer) clearTimeout(this.timer); + this.timer = undefined; + this.disposing = (async () => { + try { + await this.ready; + await this.mutations.catch(() => {}); + for (const job of this.jobs.values()) + if (job.status === "running") { + job.status = "paused"; + job.reason = job.pending + ? uncertain + : "Gloom closed. Resume explicitly to send remaining slices."; + job.updatedAt = this.now(); + } + await Promise.allSettled([...this.active.values()]); + if (this.releaseOwner) await this.save(); + } finally { + const release = this.releaseOwner; + this.releaseOwner = undefined; + await release?.(); + } + })(); + return this.disposing; + } +} diff --git a/src/native/verify-testnet.ts b/src/native/verify-testnet.ts new file mode 100644 index 0000000..ed49095 --- /dev/null +++ b/src/native/verify-testnet.ts @@ -0,0 +1,56 @@ +/** Explicit smoke check: ephemeral, unfunded keys, testnet endpoints only. Never persists keys. */ +import { ExchangeClient, HttpTransport, InfoClient } from "@nktkas/hyperliquid"; +import { generatePrivateKey, privateKeyToAccount } from "viem/accounts"; +import { roundPrice, roundSize } from "../trading/math"; + +if (import.meta.main) { + const transport = new HttpTransport({ isTestnet: true }); + const master = privateKeyToAccount(generatePrivateKey()), + agent = privateKeyToAccount(generatePrivateKey()); + const exchange = new ExchangeClient({ transport, wallet: master }); + const info = new InfoClient({ transport }); + const report: { + network: string; + funded: boolean; + checks: Record; + } = { network: "testnet", funded: false, checks: {} }; + const attempt = async (name: string, send: () => Promise) => { + try { + report.checks[name] = { outcome: "accepted", response: await send() }; + } catch (error) { + report.checks[name] = { + outcome: "rejected", + message: + error instanceof Error + ? error.message.replace(/0x[0-9a-fA-F]{64}/g, "[redacted]") + : "Request rejected", + }; + } + }; + const [meta, contexts] = await info.metaAndAssetCtxs(); + const asset = meta.universe.findIndex((m) => m.name === "BTC"); + const btc = meta.universe[asset]!, + mark = Number(contexts[asset]!.markPx); + await attempt("userSignedApproveAgent", () => + exchange.approveAgent({ + agentAddress: agent.address, + agentName: `gloom testnet valid_until ${Date.now() + 3_600_000}`, + }), + ); + await attempt("l1UnfundedOrder", () => + exchange.order({ + orders: [ + { + a: asset, + b: true, + p: roundPrice(mark * 0.99, btc.szDecimals), + s: roundSize(20 / mark, btc.szDecimals), + r: false, + t: { limit: { tif: "Alo" } }, + }, + ], + grouping: "na", + }), + ); + console.log(JSON.stringify(report, null, 2)); +} diff --git a/src/native/wallet-page.ts b/src/native/wallet-page.ts new file mode 100644 index 0000000..7321bde --- /dev/null +++ b/src/native/wallet-page.ts @@ -0,0 +1,403 @@ +import { createHash, randomBytes } from "node:crypto"; +import { WALLET_TYPES } from "../trading/wallet-actions"; +import type { + Address, + Network, + TradingSettings, + WalletSession, +} from "../trading/types"; + +interface WalletPageConfig { + network: Network; + agentAddress?: Address; + agentName?: string; + address?: Address; + expiresAt: number; + builder?: TradingSettings["builder"]; + types: typeof WALLET_TYPES; + api: string; + tokenPath: string; +} +/** This function is serialized locally. It receives public addresses only, never private keys. */ +function walletBrowser(config: WalletPageConfig) { + const get = (id: string) => document.getElementById(id)!; + const status = get("status"); + const wallets = new Map(); + let provider: any; + let account = ""; + let pending = false; + const say = (message: string) => { + status.textContent = message; + }; + const value = (id: string) => (get(id) as HTMLInputElement).value.trim(); + const addWallet = (id: string, name: string, p: any) => { + if (wallets.has(id)) return; + wallets.set(id, { name, provider: p }); + const option = document.createElement("option"); + option.value = id; + option.textContent = name; + (get("wallets") as HTMLSelectElement).append(option); + }; + window.addEventListener("eip6963:announceProvider", (event: any) => + addWallet( + event.detail.info.uuid, + event.detail.info.name, + event.detail.provider, + ), + ); + window.dispatchEvent(new Event("eip6963:requestProvider")); + if ((window as any).ethereum) + addWallet("injected", "Browser wallet", (window as any).ethereum); + const run = (fn: () => Promise) => async () => { + if (pending) return; + pending = true; + for (const b of document.querySelectorAll("button")) b.disabled = true; + try { + if (Date.now() > config.expiresAt) + throw new Error("This page expired. Open Wallet again in Gloom."); + const region = await fetch(config.tokenPath + "/region", { + cache: "no-store", + }).then((r) => r.json()); + if (!region.allowed) + throw new Error(region.reason || "Trading unavailable."); + await fn(); + } catch (error) { + say(error instanceof Error ? error.message : "Wallet request failed."); + } finally { + pending = false; + for (const b of document.querySelectorAll("button")) b.disabled = false; + } + }; + const connect = async () => { + provider = wallets.get(value("wallets"))?.provider; + if (!provider) + throw new Error( + "Enable an injected browser wallet, then reload this page.", + ); + const addresses = await provider.request({ method: "eth_requestAccounts" }); + account = String(addresses[0]).toLowerCase(); + if (config.address && account !== config.address.toLowerCase()) + throw new Error("Select the account connected to Gloom."); + get("account").textContent = account; + }; + const amount = () => { + const a = value("amount"); + if (!/^\d+(\.\d{1,6})?$/.test(a) || Number(a) <= 0) + throw new Error("Enter a positive amount, with at most six decimals."); + return a; + }; + const sign = async ( + type: keyof typeof config.types, + fields: Record, + ) => { + await connect(); + const signatureChainId = String( + await provider.request({ method: "eth_chainId" }), + ); + const nonce = Date.now(), + action = { + type, + signatureChainId, + hyperliquidChain: config.network === "testnet" ? "Testnet" : "Mainnet", + ...fields, + ...(type === "withdraw3" ? { time: nonce } : { nonce }), + }; + const types = config.types[type]; + const domain = { + name: "HyperliquidSignTransaction", + version: "1", + chainId: Number(signatureChainId), + verifyingContract: "0x0000000000000000000000000000000000000000", + }; + const typed = { + domain, + types: { + EIP712Domain: [ + { name: "name", type: "string" }, + { name: "version", type: "string" }, + { name: "chainId", type: "uint256" }, + { name: "verifyingContract", type: "address" }, + ], + ...types, + }, + primaryType: Object.keys(types)[0], + message: action, + }; + say("Review the request in your wallet."); + const signature: string = await provider.request({ + method: "eth_signTypedData_v4", + params: [account, JSON.stringify(typed)], + }); + if (!/^0x[0-9a-fA-F]{130}$/.test(signature)) + throw new Error("Unsupported wallet signature."); + const recovery = parseInt(signature.slice(130, 132), 16); + const response = await fetch(config.api + "/exchange", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + action, + nonce, + signature: { + r: signature.slice(0, 66), + s: "0x" + signature.slice(66, 130), + v: recovery < 27 ? recovery + 27 : recovery, + }, + }), + }); + const result = await response.json(); + if (!response.ok || result.status !== "ok") + throw new Error( + typeof result.response === "string" + ? result.response + : "Hyperliquid rejected the request.", + ); + say("Accepted by Hyperliquid. Gloom will update automatically."); + }; + get("connect").onclick = run(async () => { + await connect(); + say("Wallet connected. Review the action you want to perform."); + }); + get("approve").onclick = run(async () => { + if (!config.agentAddress) + throw new Error("Open Connect wallet in Gloom first."); + if (!(get("risk") as HTMLInputElement).checked) + throw new Error("Read and acknowledge the trading risks first."); + await sign("approveAgent", { + agentAddress: config.agentAddress.toLowerCase(), + agentName: config.agentName, + }); + if (config.builder) + await sign("approveBuilderFee", { + maxFeeRate: "0.1%", + builder: config.builder.address.toLowerCase(), + }); + }); + get("revoke").onclick = run(async () => { + if (!config.agentName) + throw new Error( + "Imported API wallets must be revoked in Hyperliquid using their original name.", + ); + await sign("approveAgent", { + agentAddress: "0x0000000000000000000000000000000000000000", + agentName: config.agentName, + }); + say( + "Trading approval revoked. Disconnect in Gloom to remove the local key.", + ); + }); + get("fee").onclick = run(async () => { + if (!config.builder) throw new Error("No builder fee is configured."); + await sign("approveBuilderFee", { + maxFeeRate: "0%", + builder: config.builder.address.toLowerCase(), + }); + }); + get("withdraw").onclick = run(async () => { + await connect(); + const a = amount(); + if (Number(a) <= 1) + throw new Error("Withdrawal must exceed the 1 USDC bridge fee."); + if ( + !confirm( + "Withdraw " + + a + + " USDC to " + + account + + " on Arbitrum? The bridge charges a 1 USDC fee.", + ) + ) + return; + await sign("withdraw3", { destination: account, amount: a }); + }); + get("deposit").onclick = run(async () => { + await connect(); + const a = amount(); + if (Number(a) < 5) + throw new Error( + "Minimum bridge deposit is 5 USDC. Smaller deposits are lost.", + ); + const chainId = config.network === "testnet" ? "0x66eee" : "0xa4b1"; + await provider.request({ + method: "wallet_switchEthereumChain", + params: [{ chainId }], + }); + const actual = await provider.request({ method: "eth_chainId" }); + if (BigInt(actual) !== BigInt(chainId)) + throw new Error("Wrong wallet network."); + const bridge = + config.network === "testnet" + ? "0x08cfc1B6b2dCF36A1480b99353A354AA8AC56f89" + : "0x2df1c51e09aecf9cacb7bc98cb1742757f163df7"; + const token = + config.network === "testnet" + ? "0x1baAbB04529D43a73232B713C0FE471f7c7334d5" + : "0xaf88d065e77c8cC2239327C5EDb3A432268e5831"; + const [whole, decimal = ""] = a.split("."); + const units = BigInt(whole!) * 1_000_000n + BigInt(decimal.padEnd(6, "0")); + if ( + !confirm( + "Deposit " + + a + + " USDC from " + + account + + " using the Arbitrum bridge?", + ) + ) + return; + const tx = await provider.request({ + method: "eth_sendTransaction", + params: [ + { + from: account, + to: token, + data: + "0xa9059cbb" + + bridge.slice(2).toLowerCase().padStart(64, "0") + + units.toString(16).padStart(64, "0"), + value: "0x0", + }, + ], + }); + say( + "Submitted to Arbitrum: " + + tx + + ". Wait for confirmation and the Gloom balance update.", + ); + }); + get("transfer").onclick = run(async () => { + await connect(); + const a = amount(), + source = value("source"), + destination = value("destination"); + if (source === destination) + throw new Error("Choose different source and destination balances."); + if ( + !confirm( + "Transfer " + + a + + " " + + value("token") + + " from " + + source + + " to " + + destination + + "?", + ) + ) + return; + if ( + (source === "spot" && destination === "perp") || + (source === "perp" && destination === "spot") + ) { + if (value("token") !== "USDC") + throw new Error( + "Spot/perp transfer supports USDC here. Use a dex transfer for another token.", + ); + await sign("usdClassTransfer", { + amount: a, + toPerp: destination === "perp", + }); + } else + await sign("sendAsset", { + destination: account, + sourceDex: source === "perp" ? "" : source, + destinationDex: destination === "perp" ? "" : destination, + token: value("token"), + amount: a, + fromSubAccount: "", + }); + }); + get("unify").onclick = run(async () => { + await connect(); + if ( + !confirm( + "Enable unified account mode? Spot collateral will back eligible perpetual positions. Review Hyperliquid margin rules before continuing.", + ) + ) + return; + await sign("userSetAbstraction", { + user: account, + abstraction: "unifiedAccount", + }); + }); +} +const escape = (text: string) => + text.replace( + /[&<>"']/g, + (c) => + ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[ + c + ]!, + ); +export function startWalletPage(options: { + network: Network; + agentAddress?: Address; + agentName?: string; + address?: Address; + builder?: TradingSettings["builder"]; + approvalPort?: number; + checkRegion: () => Promise<{ allowed: boolean; reason?: string }>; +}): WalletSession & { close: () => void } { + const token = randomBytes(32).toString("hex"), + tokenPath = "/" + token, + expiresAt = Date.now() + 10 * 60_000; + const api = + options.network === "testnet" + ? "https://api.hyperliquid-testnet.xyz" + : "https://api.hyperliquid.xyz"; + const config: WalletPageConfig = { + ...options, + expiresAt, + types: WALLET_TYPES, + api, + tokenPath, + }; + const script = `(${walletBrowser.toString()})(${JSON.stringify(config).replace(/Gloom Hyperliquid wallet
${options.network === "testnet" ? "TESTNET · No real funds" : "MAINNET · Real funds"}

Connect your wallet to Gloom

Your browser wallet signs approvals and fund movements directly with Hyperliquid. The locally generated trading key never enters this page and cannot withdraw.

${escape(options.address ?? "No wallet connected")}

Trading approval

API wallet: ${escape(options.agentAddress ?? "No local API wallet")}

${escape(fee)}

${options.builder ? '' : ''}

Funds

The legacy Arbitrum bridge requires at least 5 USDC per deposit. Withdrawal fee: 1 USDC. Check your wallet network and address before signing.

Collateral balances

Use the exact dex name (for example xyz) for a HIP-3 balance. A transfer to another collateral requires that collateral token; this page does not swap tokens.

This local page expires in 10 minutes. Hyperliquid terms

`; + let server: Bun.Server; + server = Bun.serve({ + hostname: "127.0.0.1", + port: options.approvalPort ?? 0, + fetch: async (request) => { + const url = new URL(request.url); + const expected = `127.0.0.1:${server.port}`; + const headers = { + "Cache-Control": "no-store", + "Referrer-Policy": "no-referrer", + "X-Content-Type-Options": "nosniff", + "Content-Security-Policy": `default-src 'none'; script-src 'sha256-${hash}'; style-src 'unsafe-inline'; connect-src 'self' ${api}; frame-ancestors 'none'; base-uri 'none'; form-action 'none'`, + }; + if ( + request.headers.get("host") !== expected || + Date.now() > expiresAt || + request.method !== "GET" || + !["none", "same-origin", null].includes( + request.headers.get("sec-fetch-site"), + ) + ) + return new Response("Not available", { status: 403, headers }); + if (url.pathname === tokenPath + "/region") + return Response.json(await options.checkRegion(), { headers }); + if (url.pathname !== tokenPath + "/") + return new Response("Not found", { status: 404, headers }); + return new Response(html, { + headers: { ...headers, "Content-Type": "text/html; charset=utf-8" }, + }); + }, + }); + const timer = setTimeout(() => server.stop(true), 10 * 60_000); + timer.unref(); + return { + url: `http://127.0.0.1:${server.port}${tokenPath}/`, + expiresAt, + agentAddress: options.agentAddress, + close: () => { + clearTimeout(timer); + server.stop(true); + }, + }; +} diff --git a/src/plugin.tsx b/src/plugin.tsx new file mode 100644 index 0000000..aeb50da --- /dev/null +++ b/src/plugin.tsx @@ -0,0 +1,209 @@ +import type { + GloomPlugin, + GloomPluginContext, + PaneDef, + PaneTemplateDef, +} from "gloomberb/types/plugin"; +import { + HyperliquidBoardPane, + HyperliquidMarketPane, + HyperliquidAccountPane, + HyperliquidBookPane, + HyperliquidAnalyticsPane, + HyperliquidSetupPane, +} from "./ui"; +import { disposeRuntime, getMarketService } from "./runtime"; +import { configSchema } from "./settings"; +import { + hyperliquidHeadless, + analyticsHeadless, + accountHeadless, + setupHeadless, +} from "./headless"; +import { resolveMarket } from "./market/normalize"; + +export const HOSTS = [ + "api.hyperliquid.xyz", + "api.hyperliquid-testnet.xyz", + "app.hyperliquid.xyz", + "api.gloom.sh", + "www.cloudflare.com", + "arb1.arbitrum.io", + "sepolia-rollup.arbitrum.io", + "127.0.0.1", +]; +const pane = ( + id: string, + name: string, + component: PaneDef["component"], + width = 140, + height = 36, +): PaneDef => ({ + id: `hyperliquid-${id}`, + name, + component, + defaultPosition: "right", + defaultMode: "floating", + defaultFloatingSize: { width, height }, + tableExport: true, + portableShare: { private: { settings: ["watchAddress"], state: true } }, + settings: { + fields: [ + { + key: "market", + label: "Market", + type: "text", + placeholder: "BTC or xyz:TSLA", + }, + ], + }, +}); +export const PANES = [ + { + ...pane("board", "Hyperliquid", HyperliquidBoardPane), + headless: hyperliquidHeadless, + }, + pane("market", "Hyperliquid Market", HyperliquidMarketPane, 160, 42), + pane("book", "Hyperliquid Book", HyperliquidBookPane, 50, 32), + pane("account", "Hyperliquid Account", HyperliquidAccountPane, 140, 32), + pane("analytics", "Hyperliquid Analytics", HyperliquidAnalyticsPane), + pane("setup", "Hyperliquid Wallet", HyperliquidSetupPane, 90, 32), +]; +const template = ( + id: string, + code: string, + label: string, + hasMarket = false, +): PaneTemplateDef => ({ + id: `hyperliquid-${id}-new`, + paneId: `hyperliquid-${id}`, + label, + description: `Open ${label.toLowerCase()}.`, + keywords: ["hyperliquid", "perpetuals", "trading", code], + shortcut: { + prefix: code, + ...(hasMarket + ? { + argKind: "text" as const, + argPlaceholder: "market", + argOptional: true, + openWithoutArg: true, + } + : {}), + }, + headless: + id === "account" + ? accountHeadless + : id === "analytics" + ? analyticsHeadless + : id === "setup" + ? setupHeadless + : hyperliquidHeadless, + createInstance: (_ctx, options) => { + const market = ( + options?.arg ?? + options?.symbol ?? + (id === "market" || id === "book" ? "BTC" : "") + ).trim(); + const initialLimitPrice = Number(options?.values?.initialLimitPrice); + return { + title: market ? `${label} ${market}` : label, + settings: { + market, + ...(Number.isFinite(initialLimitPrice) && initialLimitPrice > 0 + ? { initialLimitPrice, defaultTab: "Ticket" } + : {}), + }, + params: { market }, + placement: "floating", + }; + }, +}); +export function setupShared(ctx: GloomPluginContext): void { + ctx.registerTickerAction({ + id: "hyperliquid-open-perpetual", + label: "Open Hyperliquid perpetual", + keywords: ["hyperliquid", "perp", "trade"], + async execute(ticker) { + const network = + ctx.configState.get("network") === "testnet" ? "testnet" : "mainnet"; + const service = getMarketService(network); + await service.refresh(); + const market = resolveMarket( + service.getSnapshot().markets, + ticker.metadata.ticker, + ); + if (!market) { + ctx.notify({ + body: `No active perpetual for ${ticker.metadata.ticker}.`, + }); + return; + } + ctx.createPaneFromTemplate("hyperliquid-market-new", { + arg: market.coin, + }); + }, + }); + ctx.registerCommand({ + id: "hyperliquid-workspace", + label: "Open Hyperliquid trading workspace", + category: "navigation", + keywords: ["hyperliquid", "workspace", "docked"], + wizard: [ + { key: "market", label: "Market", defaultValue: "BTC", type: "text" }, + ], + execute(values) { + const market = values?.market || "BTC"; + ctx.createPaneFromTemplate("hyperliquid-docked-market", { arg: market }); + ctx.createPaneFromTemplate("hyperliquid-docked-account", { arg: market }); + }, + }); +} + +export const hyperliquidPlugin: GloomPlugin = { + id: "hyperliquid", + name: "Hyperliquid", + version: "0.1.0", + toggleable: true, + description: "Live perpetual markets, analytics and self-custody trading", + homepage: "https://github.com/gloom-sh/gloom-hyperliquid", + targets: ["cli", "tui", "desktop"], + hosts: HOSTS, + configSchema, + panes: PANES, + paneTemplates: [ + template("board", "HLP", "Hyperliquid", true), + template("market", "HLM", "Hyperliquid Market", true), + template("book", "HLB", "Hyperliquid Book", true), + template("account", "HLA", "Hyperliquid Account"), + template("analytics", "HLF", "Hyperliquid Analytics"), + template("setup", "HLS", "Hyperliquid Wallet"), + { + id: "hyperliquid-docked-market", + paneId: "hyperliquid-market", + label: "Hyperliquid docked market", + description: "Chart, book and order ticket", + createInstance: (_ctx, options) => ({ + instanceId: "hyperliquid-market:workspace", + title: `Hyperliquid ${options?.arg || "BTC"}`, + settings: { market: options?.arg || "BTC" }, + placement: "docked", + relativePosition: "right", + }), + }, + { + id: "hyperliquid-docked-account", + paneId: "hyperliquid-account", + label: "Hyperliquid docked account", + description: "Positions and orders below the market", + createInstance: () => ({ + instanceId: "hyperliquid-account:workspace", + placement: "docked", + relativeToPaneId: "hyperliquid-market:workspace", + relativePosition: "below", + }), + }, + ], + setup: setupShared, + dispose: disposeRuntime, +}; diff --git a/src/runtime.ts b/src/runtime.ts new file mode 100644 index 0000000..c675d6f --- /dev/null +++ b/src/runtime.ts @@ -0,0 +1,151 @@ +import { getCapabilityStreamClient } from "gloomberb/capabilities"; +import { MarketDataService } from "./market/service"; +import type { + BoardSnapshot, + MarketOptions, + MarketSession, + MarketSnapshot, + Network, +} from "./market/types"; + +export const MARKET_CAPABILITY = "hyperliquid.markets"; +export type MarketServiceView = Pick< + MarketDataService, + "getSnapshot" | "subscribe" | "refresh" | "getMarket" | "setBookAggregation" +>; +const localServices = new Map(); +const remoteServices = new Map(); +const remoteDisposers: (() => void)[] = []; + +/** Native account and market capabilities share this exact service instance. */ +export function getLocalMarketService(network: Network): MarketDataService { + let service = localServices.get(network); + if (!service) { + service = new MarketDataService({ network }); + localServices.set(network, service); + } + return service; +} + +function remoteStore( + network: Network, + scope: "board" | "market", + initial: T, + details: Record = {}, +) { + let snapshot = initial; + let stop: (() => void) | undefined; + const listeners = new Set<() => void>(); + const publish = (next: T) => { + snapshot = next; + for (const listener of listeners) listener(); + }; + const payload = { network, ...details }; + const client = getCapabilityStreamClient()!; + const dispose = () => { + stop?.(); + stop = undefined; + listeners.clear(); + }; + remoteDisposers.push(dispose); + return { + getSnapshot: () => snapshot, + subscribe(listener: () => void) { + listeners.add(listener); + if (!stop) + stop = client.subscribe({ + capabilityId: MARKET_CAPABILITY, + operationId: `${scope}Stream`, + payload, + onEvent: (next) => publish(next as T), + onError: (error) => + publish({ + ...snapshot, + status: "error", + error: error instanceof Error ? error.message : String(error), + }), + }); + return () => { + listeners.delete(listener); + if (!listeners.size) { + stop?.(); + stop = undefined; + } + }; + }, + async refresh() { + publish( + await client.invoke(MARKET_CAPABILITY, `${scope}Snapshot`, payload), + ); + }, + }; +} + +function createRemoteService(network: Network): MarketServiceView { + const board = remoteStore(network, "board", { + markets: [], + dexes: [], + predictedFundings: [], + status: "connecting", + asOf: null, + error: null, + }); + const sessions = new Map(); + return { + ...board, + getMarket(coin, options = {}) { + const key = JSON.stringify([coin, options]); + let session = sessions.get(key); + if (!session) { + session = remoteStore( + network, + "market", + { + market: null, + book: null, + candles: [], + trades: [], + annotation: null, + status: "connecting", + asOf: null, + error: null, + }, + { coin, options }, + ); + sessions.set(key, session); + } + return session; + }, + setBookAggregation(coin: string, options: MarketOptions) { + void getCapabilityStreamClient()! + .invoke(MARKET_CAPABILITY, "aggregation", { network, coin, options }) + .catch(() => board.refresh().catch(() => {})); + }, + }; +} + +export function getMarketService(network: Network): MarketServiceView { + if (!getCapabilityStreamClient()) return getLocalMarketService(network); + let service = remoteServices.get(network); + if (!service) { + service = createRemoteService(network); + remoteServices.set(network, service); + } + return service; +} + +/** Detach this runtime generation before awaiting native shutdown, so reloads are isolated. */ +export function takeRuntimeDisposer(): () => void { + const remote = remoteDisposers.splice(0); + const local = [...localServices.values()]; + localServices.clear(); + remoteServices.clear(); + return () => { + for (const dispose of remote) dispose(); + for (const service of local) service.dispose(); + }; +} + +export function disposeRuntime(): void { + takeRuntimeDisposer()(); +} diff --git a/src/settings.ts b/src/settings.ts new file mode 100644 index 0000000..7823257 --- /dev/null +++ b/src/settings.ts @@ -0,0 +1,154 @@ +import type { PluginConfigField } from "gloomberb/types/plugin"; +import type { TradingSettings } from "./trading/types"; + +export const configSchema: PluginConfigField[] = [ + { + key: "network", + label: "Network", + type: "select", + required: false, + defaultValue: "mainnet", + options: [ + { label: "Mainnet", value: "mainnet" }, + { label: "TESTNET", value: "testnet" }, + ], + }, + { + key: "approvalPort", + label: "Wallet approval port (0 = random)", + type: "number", + required: false, + defaultValue: "0", + description: + "For SSH tunnels, choose an unused local port and forward the same port to this machine.", + }, + { + key: "slippageBps", + label: "Market slippage cap (basis points)", + type: "number", + required: false, + defaultValue: "50", + }, + { + key: "confirmations", + label: "Confirm orders", + type: "select", + required: false, + defaultValue: "true", + options: [ + { label: "Always", value: "true" }, + { label: "Only warnings", value: "false" }, + ], + }, + { + key: "sizeUnit", + label: "Default size unit", + type: "select", + required: false, + defaultValue: "usd", + options: [ + { label: "USD", value: "usd" }, + { label: "Coin", value: "coin" }, + { label: "% buying power", value: "percent" }, + ], + }, + { + key: "defaultLeverage", + label: "Default leverage", + type: "number", + required: false, + defaultValue: "3", + }, + { + key: "leverageBehavior", + label: "Leverage selection", + type: "select", + required: false, + defaultValue: "position", + options: [ + { label: "Use existing position, otherwise default", value: "position" }, + { label: "Use default", value: "default" }, + ], + }, + { + key: "minVolumeUsd", + label: "Minimum 24h volume (USD)", + type: "number", + required: false, + defaultValue: "0", + }, + { + key: "minOiUsd", + label: "Minimum open interest (USD)", + type: "number", + required: false, + defaultValue: "0", + }, + { + key: "fatFingerPercent", + label: "Warn if price differs from mark (%)", + type: "number", + required: false, + defaultValue: "5", + }, + { + key: "maxOrderUsd", + label: "Warn above order notional (USD)", + type: "number", + required: false, + defaultValue: "10000", + }, +]; + +function bounded( + value: unknown, + fallback: number, + min: number, + max: number, +): number { + if (value === "" || value == null) return fallback; + const number = Number(value); + return Number.isFinite(number) && number >= min && number <= max + ? number + : fallback; +} + +export function readSettings(values: Record) { + return { + network: + values.network === "testnet" + ? ("testnet" as const) + : ("mainnet" as const), + approvalPort: Math.floor(bounded(values.approvalPort, 0, 0, 65535)), + slippageBps: bounded(values.slippageBps, 50, 1, 2000), + confirmations: + values.confirmations !== false && values.confirmations !== "false", + sizeUnit: + values.sizeUnit === "coin" + ? ("coin" as const) + : values.sizeUnit === "percent" + ? ("percent" as const) + : ("usd" as const), + defaultLeverage: Math.floor(bounded(values.defaultLeverage, 3, 1, 100)), + leverageBehavior: + values.leverageBehavior === "default" + ? ("default" as const) + : ("position" as const), + minVolumeUsd: bounded(values.minVolumeUsd, 0, 0, 1e15), + minOiUsd: bounded(values.minOiUsd, 0, 0, 1e15), + fatFingerPercent: bounded(values.fatFingerPercent, 5, 0.1, 100), + maxOrderUsd: bounded(values.maxOrderUsd, 10_000, 10, 1e12), + }; +} + +export function tradingSettings( + values: Record, +): TradingSettings { + const settings = readSettings(values); + return { + slippagePercent: settings.slippageBps / 100, + confirmations: settings.confirmations, + maxOrderNotional: settings.maxOrderUsd, + maxPriceDistancePercent: settings.fatFingerPercent, + }; +} diff --git a/src/trading/account.ts b/src/trading/account.ts new file mode 100644 index 0000000..10bb008 --- /dev/null +++ b/src/trading/account.ts @@ -0,0 +1,536 @@ +import type { + ClearinghouseStateResponse, + SpotClearinghouseStateResponse, +} from "@nktkas/hyperliquid/api/info"; +import type { + AccountSnapshot, + Address, + DexBalance, + Network, + SharedTransport, + TradingMarket, +} from "./types"; + +const number = (value: unknown) => + Number.isFinite(Number(value)) ? Number(value) : 0; +export function normalizeBalances( + states: Map, + spot: SpotClearinghouseStateResponse, + abstraction: AccountSnapshot["abstraction"], + collateralByDex: Map = new Map(), +) { + const balances: DexBalance[] = [...states].map(([dex, s]) => ({ + dex, + collateral: collateralByDex.get(dex) ?? "USDC", + accountValue: number(s.marginSummary.accountValue), + withdrawable: Math.max(0, number(s.withdrawable)), + available: Math.max( + 0, + number(s.marginSummary.accountValue) - + number(s.marginSummary.totalMarginUsed), + ), + marginUsed: number(s.marginSummary.totalMarginUsed), + maintenanceMargin: number(s.crossMaintenanceMarginUsed), + crossAccountValue: number(s.crossMarginSummary.accountValue), + crossMarginUsed: number(s.crossMarginSummary.totalMarginUsed), + })); + const positions = [...states].flatMap(([dex, s]) => + s.assetPositions + .filter((p) => number(p.position.szi) !== 0) + .map((p) => ({ ...p.position, dex })), + ); + const sum = ( + field: keyof Omit, + ) => + balances + .filter((b) => b.collateral === "USDC") + .reduce((v, b) => v + b[field], 0); + const unified = + abstraction === "unifiedAccount" || abstraction === "portfolioMargin"; + const usd = spot.balances.find((b) => b.coin === "USDC"); + const available = unified + ? Math.max(0, number(usd?.total) - number(usd?.hold)) + : sum("available"); + const crossAccountValue = sum("crossAccountValue"), + maintenanceMargin = sum("maintenanceMargin"); + const ratios = [...new Set(balances.map((b) => b.collateral ?? "USDC"))].map( + (token) => { + const group = balances.filter((b) => b.collateral === token); + const isolated = positions + .filter( + (p) => + (collateralByDex.get(p.dex) ?? "USDC") === token && + p.leverage.type === "isolated", + ) + .reduce((n, p) => n + number(p.marginUsed), 0); + const total = + number(spot.balances.find((b) => b.coin === token)?.total) - isolated; + return total > 0 + ? group.reduce((n, b) => n + b.maintenanceMargin, 0) / total + : 0; + }, + ); + return { + balances, + positions, + accountValue: unified ? number(usd?.total) : sum("accountValue"), + available, + withdrawable: unified + ? available + : (balances.find((b) => b.dex === "")?.withdrawable ?? 0), + marginUsed: sum("marginUsed"), + maintenanceMargin, + crossMarginRatio: + abstraction === "portfolioMargin" + ? number(spot.portfolioMarginRatio) + : unified + ? Math.max(0, ...ratios) + : balances.some((b) => b.crossAccountValue > 0) + ? Math.max( + ...balances + .filter((b) => b.crossAccountValue > 0) + .map((b) => b.maintenanceMargin / b.crossAccountValue), + ) + : null, + unrealizedPnl: positions + .filter((p) => (collateralByDex.get(p.dex) ?? "USDC") === "USDC") + .reduce((n, p) => n + number(p.unrealizedPnl), 0), + }; +} +export function availableForMarket( + snapshot: AccountSnapshot, + market: TradingMarket, +): number { + if (snapshot.abstraction === "portfolioMargin") return 0; // Risk-weighted collateral requires venue portfolio-margin solver. + if (snapshot.abstraction === "unifiedAccount") { + const balance = snapshot.spot.balances.find( + (b) => b.coin === (market.collateral ?? "USDC"), + ); + return Math.max(0, number(balance?.total) - number(balance?.hold)); + } + return snapshot.balances.find((b) => b.dex === market.dex)?.available ?? 0; +} +export class AccountStore { + private address?: Address; + private snapshot?: AccountSnapshot; + private states = new Map(); + private unsubs: (() => void)[] = []; + private interval?: ReturnType; + private refreshTimer?: ReturnType; + private listeners = new Set<() => void>(); + private inflight?: Promise; + private lastRefresh = 0; + private generation = 0; + private collateralByDex = new Map(); + private subscribedDexes = new Set(); + private ordersByDex = new Map(); + private twapsByDex = new Map(); + private emitTimer?: ReturnType; + private health?: ReturnType; + constructor( + private network: Network, + private shared: SharedTransport, + ) {} + subscribe(listener: () => void) { + this.listeners.add(listener); + return () => this.listeners.delete(listener); + } + getSnapshot() { + if (this.snapshot) { + const connection = this.shared.ws.getStatus?.(); + const stale = + Boolean(this.snapshot.error) || + (connection !== undefined && connection !== "live") || + Date.now() - this.snapshot.updatedAt > 150_000; + if (stale !== this.snapshot.stale) + this.snapshot = { ...this.snapshot, stale }; + } + return this.snapshot; + } + private emit() { + if (this.snapshot) this.snapshot = { ...this.snapshot }; + if (!this.emitTimer) + this.emitTimer = setTimeout(() => { + this.emitTimer = undefined; + for (const listener of this.listeners) listener(); + }, 200); + } + async setAddress(address: Address) { + if (this.address?.toLowerCase() === address.toLowerCase()) + return this.getSnapshot() ?? this.inflight ?? this.refresh(); + this.stop(); + this.address = address; + this.states.clear(); + this.snapshot = undefined; + const generation = ++this.generation; + const schedule = () => { + if (!this.refreshTimer) + this.refreshTimer = setTimeout( + () => { + this.refreshTimer = undefined; + void this.refresh().catch(() => {}); + }, + Math.max(250, 120_000 - (Date.now() - this.lastRefresh)), + ); + }; + const subscribe = (type: string, handler: (data: any) => void) => + this.unsubs.push( + this.shared.ws.subscribe( + { type, user: address }, + (data) => { + if (generation === this.generation) handler(data); + }, + () => { + void this.refresh().catch(() => {}); + }, + ), + ); + subscribe("allDexsClearinghouseState", (data) => { + if (!Array.isArray(data.clearinghouseStates)) return; + for (const [dex, state] of data.clearinghouseStates) { + if ( + !this.states.has(dex) || + number(state.time) >= number(this.states.get(dex)?.time) + ) + this.states.set(dex, state); + } + this.rebuild(); + }); + subscribe("webData3", (data) => { + if (this.snapshot && data.userState?.abstraction) { + this.snapshot.abstraction = data.userState.abstraction; + this.rebuild(); + } + }); + subscribe("orderUpdates", schedule); + subscribe("userHistoricalOrders", (data) => { + if (this.snapshot && Array.isArray(data.orderHistory)) { + this.snapshot.orderHistory = [ + ...new Map( + [...data.orderHistory, ...this.snapshot.orderHistory].map((o) => [ + o.order.oid, + o, + ]), + ).values(), + ].slice(0, 2000); + this.emit(); + } + }); + subscribe("spotState", (data) => { + if (this.snapshot && data.spotState) { + this.snapshot.spot = data.spotState; + this.rebuild(); + } + }); + subscribe("userFundings", (data) => { + if (this.snapshot && Array.isArray(data.fundings)) { + this.snapshot.funding = [ + ...new Map( + [...data.fundings, ...this.snapshot.funding].map((f) => [ + `${f.hash}:${f.delta.coin}:${f.time}`, + f, + ]), + ).values(), + ].slice(0, 2000); + this.emit(); + } + }); + subscribe("userNonFundingLedgerUpdates", (data) => { + if (this.snapshot && Array.isArray(data.nonFundingLedgerUpdates)) { + this.snapshot.ledger = [ + ...new Map( + [...data.nonFundingLedgerUpdates, ...this.snapshot.ledger].map( + (f) => [f.hash, f], + ), + ).values(), + ].slice(0, 2000); + this.emit(); + } + }); + subscribe("userFills", (data) => { + if (this.snapshot && Array.isArray(data.fills)) { + const fills = [...data.fills, ...this.snapshot.fills]; + this.snapshot.fills = [ + ...new Map(fills.map((f) => [`${f.tid}:${f.hash}`, f])).values(), + ].slice(0, 2000); + this.emit(); + } + schedule(); + }); + subscribe("userEvents", schedule); + subscribe("notification", (data) => { + if (this.snapshot) { + this.snapshot.notifications = [ + String(data.notification ?? data).slice(0, 500), + ...this.snapshot.notifications, + ].slice(0, 20); + this.emit(); + } + }); + this.health = setInterval(() => { + const previous = this.snapshot; + if (this.getSnapshot() !== previous) this.emit(); + }, 5000); + this.interval = setInterval( + () => void this.refresh().catch(() => {}), + 120_000, + ); + return this.refresh(); + } + private rebuild() { + if (!this.snapshot) return; + Object.assign( + this.snapshot, + normalizeBalances( + this.states, + this.snapshot.spot, + this.snapshot.abstraction, + this.collateralByDex, + ), + { updatedAt: Date.now() }, + ); + this.emit(); + } + async refresh(): Promise { + if (this.inflight) return this.inflight; + const address = this.address, + generation = this.generation; + if (!address) + throw new Error("Connect a wallet or add a watch-only address."); + this.lastRefresh = Date.now(); + this.inflight = (async () => { + const request = (type: string, extra: Record = {}) => + this.shared.info.request({ type, user: address, ...extra }); + try { + if (this.shared.refresh && !this.shared.getSnapshot?.().dexes.length) + await this.shared.refresh(); + if (generation !== this.generation) throw new Error("Account changed."); + const board = this.shared.getSnapshot?.(); + const dexes = board?.dexes; + const names = dexes?.length ? dexes.map((d) => d.name) : [""]; + this.collateralByDex = new Map( + dexes?.map((d) => [d.name, d.collateral]) ?? [["", "USDC"]], + ); + for (const dex of names) + if (!this.subscribedDexes.has(dex)) { + this.subscribedDexes.add(dex); + this.unsubs.push( + this.shared.ws.subscribe( + { type: "openOrders", user: address, dex }, + (data) => { + if ( + generation === this.generation && + Array.isArray(data.orders) + ) { + this.ordersByDex.set(dex, data.orders); + if (this.snapshot) { + this.snapshot.orders = [ + ...this.ordersByDex.values(), + ].flat(); + this.emit(); + } + } + }, + ), + ); + this.unsubs.push( + this.shared.ws.subscribe( + { type: "twapStates", user: address, dex }, + (data) => { + if ( + generation === this.generation && + Array.isArray(data.states) + ) { + this.twapsByDex.set( + dex, + data.states.map(([twapId, state]: any) => ({ + twapId, + state, + dex, + })), + ); + if (this.snapshot) { + this.snapshot.twaps = [ + ...this.twapsByDex.values(), + ].flat(); + this.emit(); + } + } + }, + ), + ); + } + const [ + stateResults, + orderResults, + spot, + abstraction, + fills, + orderHistory, + funding, + ledger, + fees, + ] = await Promise.all([ + Promise.all( + (names.length < 30 + ? names + : names.filter( + (dex) => + dex === "" || this.states.get(dex)?.assetPositions.length, + ) + ).map( + async (dex) => + [ + dex, + await request( + "clearinghouseState", + { dex }, + ), + ] as const, + ), + ), + request("frontendOpenOrders").then( + (orders) => [orders], + ), + request("spotClearinghouseState"), + request("userAbstraction"), + request("userFills"), + request("historicalOrders"), + request("userFunding", { + startTime: Date.now() - 30 * 86_400_000, + }), + request("userNonFundingLedgerUpdates", { + startTime: Date.now() - 30 * 86_400_000, + }), + request("userFees"), + ]); + if (generation !== this.generation) throw new Error("Account changed."); + for (const [dex, state] of stateResults) + if ( + !this.states.has(dex) || + number(state.time) >= number(this.states.get(dex)?.time) + ) + this.states.set(dex, state); + this.snapshot = { + address, + network: this.network, + updatedAt: Date.now(), + stale: false, + abstraction, + ...normalizeBalances( + this.states, + spot, + abstraction, + this.collateralByDex, + ), + orders: [ + ...new Map( + [ + ...orderResults.flat(), + ...[...this.ordersByDex.values()].flat(), + ].map((o) => [o.oid, o]), + ).values(), + ], + fills, + orderHistory, + funding, + ledger, + spot, + fees, + twaps: [...this.twapsByDex.values()].flat(), + notifications: this.snapshot?.notifications ?? [], + }; + this.emit(); + return this.snapshot; + } catch (error) { + if (generation === this.generation && this.snapshot) { + this.snapshot.error = + error instanceof Error ? error.message : "Account refresh failed."; + this.snapshot.stale = true; + this.emit(); + } + throw error; + } finally { + if (generation === this.generation) this.inflight = undefined; + } + })(); + return this.inflight; + } + async refreshRisk(dex: string): Promise { + const user = this.address, + generation = this.generation; + if (!user) throw new Error("No account selected."); + if (!this.snapshot) await this.refresh(); + const [spot, state, abstraction] = await Promise.all([ + this.shared.info.request({ + type: "spotClearinghouseState", + user, + }), + this.shared.info.request({ + type: "clearinghouseState", + user, + dex, + }), + this.shared.info.request({ + type: "userAbstraction", + user, + }), + ]); + if (generation !== this.generation || !this.snapshot) + throw new Error("Account changed while checking collateral."); + this.states.set(dex, state); + this.snapshot = { + ...this.snapshot, + spot, + abstraction, + ...normalizeBalances( + this.states, + spot, + abstraction, + this.collateralByDex, + ), + updatedAt: Date.now(), + stale: false, + error: undefined, + }; + this.emit(); + return this.snapshot; + } + async history( + kind: "funding" | "ledger" | "fills", + startTime: number, + endTime?: number, + ) { + if (!this.address) throw new Error("No account selected."); + const types = { + funding: "userFunding", + ledger: "userNonFundingLedgerUpdates", + fills: "userFillsByTime", + }; + return this.shared.info.request({ + type: types[kind], + user: this.address, + startTime, + ...(endTime ? { endTime } : {}), + }); + } + stop() { + if (this.health) clearInterval(this.health); + this.health = undefined; + if (this.emitTimer) clearTimeout(this.emitTimer); + this.emitTimer = undefined; + this.generation++; + for (const unsub of this.unsubs) unsub(); + this.unsubs = []; + if (this.interval) clearInterval(this.interval); + if (this.refreshTimer) clearTimeout(this.refreshTimer); + this.interval = undefined; + this.refreshTimer = undefined; + this.address = undefined; + this.subscribedDexes.clear(); + this.ordersByDex.clear(); + this.twapsByDex.clear(); + this.snapshot = undefined; + this.inflight = undefined; + } +} diff --git a/src/trading/builder.ts b/src/trading/builder.ts new file mode 100644 index 0000000..4893475 --- /dev/null +++ b/src/trading/builder.ts @@ -0,0 +1,23 @@ +import { getAddress, isAddress, zeroAddress } from "viem"; +import type { Network, TradingSettings } from "./types"; + +/** Official Gloom builder address supplied by Vince. Testnet always disables builder fees. */ +export const GLOOM_BUILDER_ADDRESS = + "0x84085f25eDDdFc86D8b66343f97D68e4D5Bd1096"; +export const GLOOM_BUILDER_FEE_TENTHS_BPS = 100; +export const GLOOM_BUILDER_MAX_FEE_RATE = "0.1%"; +export function configuredBuilder( + network: Network, + address: string = GLOOM_BUILDER_ADDRESS, +): TradingSettings["builder"] { + if ( + network !== "mainnet" || + !isAddress(address) || + address.toLowerCase() === zeroAddress + ) + return undefined; + return { + address: getAddress(address), + feeTenthsBps: GLOOM_BUILDER_FEE_TENTHS_BPS, + }; +} diff --git a/src/trading/fees.ts b/src/trading/fees.ts new file mode 100644 index 0000000..bbf4e5a --- /dev/null +++ b/src/trading/fees.ts @@ -0,0 +1,29 @@ +/** Hyperliquid fee formula, trading/fees.md, verified 2026-10-04. Fractions, not percentages. */ +export function perpFeeRates(input: { + maker: number; + taker: number; + referralDiscount: number; + deployerFeeScale: number; + growthMode: boolean; + alignedCollateral: boolean; +}) { + const { deployerFeeScale: s } = input; + const hip3 = s < 1 ? s + 1 : s * 2, + share = s < 1 ? s / (1 + s) : 0.5, + growth = input.growthMode ? 0.1 : 1; + const maker = + input.maker * + growth * + (input.maker > 0 + ? hip3 * (1 - input.referralDiscount) + : input.alignedCollateral + ? (1 - share) * 1.5 + share + : 1); + const taker = + input.taker * + hip3 * + growth * + (1 - input.referralDiscount) * + (input.alignedCollateral ? (1 - share) * 0.8 + share : 1); + return { maker, taker }; +} diff --git a/src/trading/math.ts b/src/trading/math.ts new file mode 100644 index 0000000..f7c1440 --- /dev/null +++ b/src/trading/math.ts @@ -0,0 +1,99 @@ +import { formatPrice, formatSize } from "@nktkas/hyperliquid/utils"; +import type { BookLevel, TradingMarket } from "./types"; + +export function finitePositive(value: unknown): value is number { + return typeof value === "number" && Number.isFinite(value) && value > 0; +} +export function roundSize(size: number | string, decimals: number): string { + if ( + !finitePositive(Number(size)) || + !Number.isInteger(decimals) || + decimals < 0 || + decimals > 8 + ) + throw new Error("Size must be positive and use valid lot precision."); + return formatSize(size, decimals); +} +/** Floor buys and ceil sells so rounding cannot exceed the user's price cap. */ +export function roundPrice( + price: number | string, + decimals: number, + direction: "down" | "up" = "down", +): string { + const n = Number(price); + if (!finitePositive(n)) throw new Error("Price must be positive."); + const down = formatPrice(price, decimals); + if (direction === "down" || Number(down) === n) return down; + const places = Math.max( + 0, + Math.min(6 - decimals, 4 - Math.floor(Math.log10(n))), + ); + const tick = 10 ** -places; + // Integers are exempt from the significant-figure rule; ceiling the original + // price matters when the SDK rounded a >5-digit fractional value by tens. + const upper = Math.ceil(n / tick) * tick; + return formatPrice(upper.toFixed(places), decimals); +} +export function maintenanceTier( + notional: number, + market: TradingMarket, +): { rate: number; deduction: number } { + const tiers = [ + ...(market.marginTiers?.length + ? market.marginTiers + : [{ lowerBound: 0, maxLeverage: market.maxLeverage }]), + ].sort((a, b) => a.lowerBound - b.lowerBound); + let rate = 1 / (2 * tiers[0]!.maxLeverage), + deduction = 0; + for (const tier of tiers.slice(1)) { + if (notional < tier.lowerBound) break; + const next = 1 / (2 * tier.maxLeverage); + deduction += tier.lowerBound * (next - rate); + rate = next; + } + return { rate, deduction }; +} +/** Isolated new-position estimate. Existing/cross positions use the exchange's liquidationPx. */ +export function isolatedLiquidation( + entry: number, + size: number, + margin: number, + buy: boolean, + market: TradingMarket, +): number | null { + if (![entry, size, margin].every(finitePositive)) return null; + let result = entry; + for (let i = 0; i < 8; i++) { + const { rate, deduction } = maintenanceTier(result * size, market); + const next = buy + ? (size * entry - margin - deduction) / (size * (1 - rate)) + : (size * entry + margin + deduction) / (size * (1 + rate)); + if (next <= 0) return null; + if (Math.abs(result - next) < 1e-8) return next; + result = next; + } + return result; +} +export function estimateFill( + levels: BookLevel[], + size: number, + cap: number, + buy: boolean, +) { + let remaining = size, + notional = 0; + for (const level of levels) { + const px = Number(level.px), + available = Number(level.sz); + if (!finitePositive(px) || !finitePositive(available)) continue; + if (buy ? px > cap : px < cap) break; + const fill = Math.min(remaining, available); + notional += fill * px; + remaining -= fill; + if (remaining < 1e-12) break; + } + return { + average: size > remaining ? notional / (size - remaining) : null, + unfilled: Math.max(0, remaining), + }; +} diff --git a/src/trading/orders.ts b/src/trading/orders.ts new file mode 100644 index 0000000..57dd1d8 --- /dev/null +++ b/src/trading/orders.ts @@ -0,0 +1,420 @@ +import Decimal from "decimal.js"; +import { keccak256, stringToHex } from "viem"; +import type { OrderParameters } from "@nktkas/hyperliquid/api/exchange"; +import { perpFeeRates } from "./fees"; +import { + estimateFill, + finitePositive, + isolatedLiquidation, + roundPrice, + roundSize, +} from "./math"; +import { + DEFAULT_TRADING_SETTINGS, + type TicketContext, + type TicketPreview, + type TicketRequest, + type TradingSettings, +} from "./types"; + +export function clientOrderId(intent: string, leg = 0): `0x${string}` { + if (!intent || intent.length > 200) + throw new Error("An order intent id is required."); + return keccak256(stringToHex(`gloom-hyperliquid:${intent}:${leg}`)).slice( + 0, + 34, + ) as `0x${string}`; +} +export function previewTicket( + ticket: TicketRequest, + context: TicketContext, + settings: TradingSettings = DEFAULT_TRADING_SETTINGS, +): TicketPreview { + const result: TicketPreview = { + size: "0", + price: "0", + notional: 0, + marginRequired: 0, + fee: 0, + builderFee: 0, + liquidationPrice: null, + averageFill: null, + slippagePercent: null, + unfilledSize: 0, + errors: [], + warnings: [], + }; + try { + const m = ticket.market, + buy = ticket.side === "buy", + reduce = Boolean(ticket.reduceOnly || ticket.positionTpsl); + if (!finitePositive(m.mark)) + throw new Error("A current market price is required."); + if (!Number.isInteger(m.assetId) || m.assetId < 0) + throw new Error("Invalid market asset id."); + if ( + !Number.isInteger(ticket.leverage) || + ticket.leverage < 1 || + ticket.leverage > m.maxLeverage + ) + result.errors.push(`Leverage must be between 1 and ${m.maxLeverage}x.`); + if (m.onlyIsolated && ticket.marginMode !== "isolated") + result.errors.push("This market only supports isolated margin."); + const input = Number(ticket.size); + if (!finitePositive(input)) throw new Error("Enter a positive size."); + if (ticket.sizeUnit === "percent" && input > 100) + result.errors.push("Buying power percentage must be at most 100."); + const trigger = + ticket.kind.startsWith("stop-") || ticket.kind.startsWith("take-profit-"); + const marketOrder = + ticket.kind === "market" || ticket.kind.endsWith("-market"); + const reference = trigger ? ticket.triggerPrice : m.mark; + if (trigger && !finitePositive(reference)) + throw new Error("Enter a positive trigger price."); + if ( + !finitePositive(settings.slippagePercent) || + settings.slippagePercent > 20 + ) + throw new Error("Slippage must be greater than 0 and at most 20%."); + const rawPrice = marketOrder + ? reference! * (1 + ((buy ? 1 : -1) * settings.slippagePercent) / 100) + : ticket.kind === "scale" + ? ticket.scaleStart + : ticket.kind === "twap" + ? m.mark + : ticket.limitPrice; + if (!finitePositive(rawPrice)) + throw new Error("Enter a positive limit price."); + result.price = roundPrice(rawPrice, m.szDecimals, buy ? "down" : "up"); + let sizingPrice = marketOrder ? Number(result.price) : rawPrice; + if (ticket.kind === "scale") { + const count = ticket.scaleCount ?? 0; + if ( + Number.isInteger(count) && + count >= 2 && + count <= 50 && + finitePositive(ticket.scaleStart) && + finitePositive(ticket.scaleEnd) + ) + sizingPrice = + Array.from({ length: count }, (_, i) => + Number( + roundPrice( + ticket.scaleStart! + + ((ticket.scaleEnd! - ticket.scaleStart!) * i) / (count - 1), + m.szDecimals, + buy ? "down" : "up", + ), + ), + ).reduce((a, b) => a + b, 0) / count; + } + if (trigger) { + const shouldAbove = ticket.kind.startsWith("stop-") ? buy : !buy; + if ( + shouldAbove + ? ticket.triggerPrice! <= m.mark + : ticket.triggerPrice! >= m.mark + ) + result.errors.push( + "Trigger price is on the wrong side of mark and would trigger immediately.", + ); + } + const rates = perpFeeRates({ + maker: context.makerRate, + taker: context.takerRate, + referralDiscount: context.referralDiscount ?? 0, + deployerFeeScale: m.deployerFeeScale ?? (m.dex ? 1 : 0), + growthMode: m.growthMode ?? false, + alignedCollateral: m.alignedCollateral ?? false, + }); + const feeRate = + ticket.tif === "Alo" && !marketOrder ? rates.maker : rates.taker; + const builder = + settings.builder && settings.builder.feeTenthsBps > 0 + ? settings.builder + : undefined; + if ( + builder && + (!/^0x[0-9a-fA-F]{40}$/.test(builder.address) || + !Number.isInteger(builder.feeTenthsBps) || + builder.feeTenthsBps > 100) + ) + throw new Error("Invalid builder fee configuration."); + const builderRate = (builder?.feeTenthsBps ?? 0) / 100_000; + const budgetSize = + (context.available * (input / 100)) / + (m.mark / ticket.leverage + + sizingPrice * (Math.max(feeRate, 0) + builderRate)); + let rawSize: number | string = + ticket.sizeUnit === "coin" + ? input + : ticket.sizeUnit === "usd" + ? input / sizingPrice + : budgetSize; + if (reduce && ticket.sizeUnit === "percent") + rawSize = new Decimal(context.positionSize ?? 0) + .abs() + .mul(input) + .div(100) + .toFixed(); + result.size = roundSize(rawSize, m.szDecimals); + const size = Number(result.size), + px = Number(result.price); + result.notional = new Decimal(result.size) + .mul(ticket.kind === "twap" ? m.mark : result.price) + .toNumber(); + result.builderFee = result.notional * builderRate; + result.fee = result.notional * feeRate + result.builderFee; + const resultingNotional = + Math.abs((context.positionSize ?? 0) + (buy ? size : -size)) * m.mark; + const tier = [...(m.marginTiers ?? [])] + .sort((a, b) => a.lowerBound - b.lowerBound) + .filter((t) => t.lowerBound <= resultingNotional) + .at(-1); + if (!reduce && tier && ticket.leverage > tier.maxLeverage) + result.errors.push( + `This position size allows at most ${tier.maxLeverage}x leverage in its margin tier.`, + ); + const netIncrease = Math.max( + 0, + Math.abs((context.positionSize ?? 0) + (buy ? size : -size)) - + Math.abs(context.positionSize ?? 0), + ); + result.marginRequired = reduce + ? 0 + : new Decimal(netIncrease).mul(m.mark).div(ticket.leverage).toNumber(); + if (result.notional < 10 && !reduce) + result.errors.push("Minimum order value is $10."); + if ( + ticket.positionTpsl && + Math.sign(context.positionSize ?? 0) !== (buy ? 1 : -1) + ) + result.errors.push("TP/SL side must match the position being protected."); + if ( + reduce && + !ticket.positionTpsl && + (!(context.positionSize ?? 0) || + Math.sign(context.positionSize!) === (buy ? 1 : -1)) + ) + result.errors.push("Reduce-only side must reduce an existing position."); + if (reduce && size > Math.abs(context.positionSize ?? 0) + 1e-12) + result.errors.push("Reduce-only size exceeds the position."); + if ( + !reduce && + result.marginRequired + Math.max(result.fee, 0) > context.available + 1e-8 + ) + result.errors.push( + "Insufficient available collateral for margin and estimated fees.", + ); + if (result.notional > settings.maxOrderNotional) + result.warnings.push( + `Order value exceeds the $${settings.maxOrderNotional.toLocaleString()} guard.`, + ); + if ( + !marketOrder && + Math.abs(px / m.mark - 1) * 100 > settings.maxPriceDistancePercent + ) + result.warnings.push( + `Price is more than ${settings.maxPriceDistancePercent}% from mark.`, + ); + if (ticket.marginMode === "isolated" && !context.positionSize && !reduce) + result.liquidationPrice = isolatedLiquidation( + px, + size, + result.marginRequired - Math.max(result.fee, 0), + buy, + m, + ); + if (context.book && marketOrder && !trigger) { + const fill = estimateFill(context.book[buy ? 1 : 0], size, px, buy); + result.averageFill = fill.average; + result.unfilledSize = fill.unfilled; + if (fill.average) + result.slippagePercent = + (fill.average / m.mark - 1) * (buy ? 1 : -1) * 100; + if (fill.unfilled > 1e-10) + result.warnings.push( + "Visible book cannot fill the full size within the slippage cap.", + ); + } + const orders: OrderParameters["orders"] = []; + const add = ( + price: string, + quantity: string, + side: boolean, + ro: boolean, + type: OrderParameters["orders"][number]["t"], + ) => + orders.push({ + a: m.assetId, + b: side, + p: price, + s: quantity, + r: ro, + t: type, + c: clientOrderId(ticket.clientId, orders.length), + }); + const attach = (value: number, kind: "tp" | "sl", side: boolean) => { + if (!finitePositive(value)) + throw new Error("TP/SL prices must be positive."); + const shouldAbove = kind === "tp" ? buy : !buy; + if (shouldAbove ? value <= m.mark : value >= m.mark) + result.errors.push( + `${kind === "tp" ? "Take-profit" : "Stop-loss"} price is on the wrong side of mark.`, + ); + add( + roundPrice( + value * (1 + ((side ? 1 : -1) * settings.slippagePercent) / 100), + m.szDecimals, + side ? "down" : "up", + ), + ticket.positionTpsl ? "0" : result.size, + side, + true, + { + trigger: { + triggerPx: roundPrice(value, m.szDecimals), + isMarket: true, + tpsl: kind, + }, + }, + ); + }; + if (ticket.kind === "twap") { + if ( + !Number.isInteger(ticket.twapMinutes) || + ticket.twapMinutes! < 5 || + ticket.twapMinutes! > 1440 + ) + result.errors.push("TWAP duration must be 5 to 1440 whole minutes."); + if (ticket.takeProfit || ticket.stopLoss || ticket.positionTpsl) + result.errors.push( + "Attach TP/SL to the position after the TWAP begins.", + ); + if (builder) { + result.warnings.push( + "Runs on this computer while Gloom is open. Closing Gloom pauses remaining slices; resume explicitly. Each slice includes the Gloom builder fee.", + ); + if (result.notional < 24) + result.errors.push( + "A local TWAP needs at least $24 so each slice clears the minimum order size.", + ); + } + result.twap = { + twap: { + a: m.assetId, + b: buy, + s: result.size, + r: reduce, + m: ticket.twapMinutes ?? 5, + t: ticket.twapRandomize ?? true, + }, + }; + } else { + if (ticket.positionTpsl) { + if (!ticket.takeProfit && !ticket.stopLoss) + result.errors.push("Enter a take-profit or stop-loss price."); + } else if (ticket.kind === "scale") { + const count = ticket.scaleCount ?? 0; + if ( + !Number.isInteger(count) || + count < 2 || + count > 50 || + !finitePositive(ticket.scaleEnd) || + !finitePositive(ticket.scaleStart) + ) + throw new Error( + "Scale requires 2 to 50 orders and positive start/end prices.", + ); + if (ticket.takeProfit || ticket.stopLoss) + result.errors.push( + "Set position TP/SL separately from a scale order.", + ); + const slice = roundSize( + new Decimal(result.size).div(count).toFixed(), + m.szDecimals, + ); + for (let i = 0; i < count; i++) { + const price = roundPrice( + ticket.scaleStart + + ((ticket.scaleEnd - ticket.scaleStart) * i) / (count - 1), + m.szDecimals, + buy ? "down" : "up", + ); + if (Number(slice) * Number(price) < 10 && !reduce) + result.errors.push("Each scale order must be at least $10."); + add(price, slice, buy, reduce, { + limit: { tif: ticket.tif ?? "Gtc" }, + }); + } + result.size = roundSize( + new Decimal(slice).mul(count).toFixed(), + m.szDecimals, + ); + result.notional = orders.reduce( + (total, order) => total + Number(order.p) * Number(order.s), + 0, + ); + const netScaleIncrease = Math.max( + 0, + Math.abs( + (context.positionSize ?? 0) + (buy ? 1 : -1) * Number(result.size), + ) - Math.abs(context.positionSize ?? 0), + ); + result.marginRequired = reduce + ? 0 + : (netScaleIncrease * m.mark) / ticket.leverage; + result.builderFee = result.notional * builderRate; + result.fee = result.notional * feeRate + result.builderFee; + if ( + !reduce && + result.marginRequired + Math.max(result.fee, 0) > context.available + ) + result.errors.push("Insufficient collateral for the scale orders."); + } else if (trigger) { + add(result.price, result.size, buy, reduce, { + trigger: { + triggerPx: roundPrice(ticket.triggerPrice!, m.szDecimals), + isMarket: marketOrder, + tpsl: ticket.kind.startsWith("stop-") ? "sl" : "tp", + }, + }); + } else + add(result.price, result.size, buy, reduce, { + limit: { tif: marketOrder ? "Ioc" : (ticket.tif ?? "Gtc") }, + }); + // Position TP/SL ticket side describes the position being protected. + if (ticket.takeProfit) attach(ticket.takeProfit, "tp", !buy); + if (ticket.stopLoss) attach(ticket.stopLoss, "sl", !buy); + result.order = { + orders, + grouping: ticket.positionTpsl + ? "positionTpsl" + : ticket.takeProfit || ticket.stopLoss + ? "normalTpsl" + : "na", + ...(builder + ? { + builder: { + b: builder.address.toLowerCase() as `0x${string}`, + f: builder.feeTenthsBps, + }, + } + : {}), + }; + } + } catch (error) { + result.errors.push( + error instanceof Error ? error.message : "Invalid order.", + ); + } + if ( + result.notional > settings.maxOrderNotional && + !result.warnings.some((w) => w.startsWith("Order value")) + ) + result.warnings.push( + `Order value exceeds the $${settings.maxOrderNotional.toLocaleString()} guard.`, + ); + result.errors = [...new Set(result.errors)]; + return result; +} diff --git a/src/trading/regions.ts b/src/trading/regions.ts new file mode 100644 index 0000000..473c3ae --- /dev/null +++ b/src/trading/regions.ts @@ -0,0 +1,54 @@ +import type { RegionStatus } from "./types"; +/** Hyperliquid Interface Terms §1.6, verified 2026-10-04, terms updated 2026-06-15. + * Country lookup cannot resolve Ontario or sanctioned Ukrainian territories: fail closed + * for CA/UA until a province-aware lookup is adopted. Counsel should review this list. + */ +export const REGION_POLICY = { + source: "https://app.hyperliquid.xyz/terms", + blockedCountries: [ + "US", + "CA", + "CU", + "IR", + "KP", + "SY", + "RU", + "BY", + "UA", + ] as readonly string[], + lookup: "https://www.cloudflare.com/cdn-cgi/trace", + maxAgeMs: 5 * 60_000, +}; +export async function checkRegion( + fetcher: typeof fetch = fetch, +): Promise { + const checkedAt = Date.now(); + try { + const response = await fetcher(REGION_POLICY.lookup, { + signal: AbortSignal.timeout(7000), + cache: "no-store", + }); + if (!response.ok) throw new Error("Country lookup failed."); + const country = (await response.text()).match(/^loc=([A-Z]{2})$/m)?.[1]; + if (!country || country === "XX") + throw new Error("Country could not be verified."); + return { + allowed: !REGION_POLICY.blockedCountries.includes(country), + country, + checkedAt, + ...(REGION_POLICY.blockedCountries.includes(country) + ? { + reason: + "Trading is unavailable in this jurisdiction. Market data and watch-only remain available.", + } + : {}), + }; + } catch { + return { + allowed: false, + checkedAt, + reason: + "Location could not be verified. Trading stays disabled; market data remains available.", + }; + } +} diff --git a/src/trading/trading.test.ts b/src/trading/trading.test.ts new file mode 100644 index 0000000..bcbb35b --- /dev/null +++ b/src/trading/trading.test.ts @@ -0,0 +1,466 @@ +import { describe, expect, test } from "bun:test"; +import { ExchangeClient } from "@nktkas/hyperliquid"; +import { + OrderRequest, + ApproveAgentTypes, +} from "@nktkas/hyperliquid/api/exchange"; +import { + canonicalize, + createL1ActionHash, + signUserSignedAction, +} from "@nktkas/hyperliquid/signing"; +import { generatePrivateKey, privateKeyToAccount } from "viem/accounts"; +import { parseSignature, zeroAddress } from "viem"; +import { configuredBuilder } from "./builder"; +import { perpFeeRates } from "./fees"; +import { + roundPrice, + roundSize, + isolatedLiquidation, + maintenanceTier, + estimateFill, +} from "./math"; +import { previewTicket, clientOrderId } from "./orders"; +import { AccountStore, normalizeBalances, availableForMarket } from "./account"; +import { checkRegion } from "./regions"; +import { walletAction, userSignedTypedData } from "./wallet-actions"; +import { + DEFAULT_TRADING_SETTINGS, + type TradingMarket, + type TicketRequest, + type AccountSnapshot, +} from "./types"; + +const market: TradingMarket = { + coin: "BTC", + assetId: 0, + dex: "", + szDecimals: 5, + maxLeverage: 40, + onlyIsolated: false, + mark: 60_000, + collateral: "USDC", +}; +const base: TicketRequest = { + market, + side: "buy", + kind: "market", + size: "0.01", + sizeUnit: "coin", + leverage: 10, + marginMode: "cross", + clientId: "test-intent", +}; +const context = { available: 1000, makerRate: 0.00015, takerRate: 0.00045 }; + +describe("order safety and exchange precision", () => { + test("floors size and keeps both buy and sell price caps with integer exception", () => { + expect(roundSize("0.100009999999", 5)).toBe("0.1"); + expect(roundPrice("123456.9", 0)).toBe("123450"); + expect(roundPrice("123456", 0)).toBe("123456"); + expect(Number(roundPrice("123456.9", 0, "up"))).toBeGreaterThanOrEqual( + 123456.9, + ); + expect(roundPrice("1.234567", 3, "down")).toBe("1.234"); + expect(roundPrice("1.234567", 3, "up")).toBe("1.235"); + expect(() => roundSize("0.000000001", 5)).toThrow(); + expect(() => roundPrice(Infinity, 5)).toThrow(); + }); + test("IOC market request, margin at mark, deterministic distinct cloids, attached reduce-only TP/SL", () => { + const p = previewTicket( + { ...base, takeProfit: 65_000, stopLoss: 55_000 }, + context, + ); + expect(p.errors).toEqual([]); + expect(p.marginRequired).toBe(60); + expect(p.order?.grouping).toBe("normalTpsl"); + expect(p.order?.orders[0]?.t).toEqual({ limit: { tif: "Ioc" } }); + expect(p.order?.orders.slice(1).every((o) => o.r && !o.b)).toBe(true); + expect(new Set(p.order?.orders.map((o) => o.c)).size).toBe(3); + expect(p.order?.orders[0]?.c).toBe(clientOrderId(base.clientId)); + expect(p.order?.builder).toBeUndefined(); + }); + test("validates balance, reduce-only direction, invalid size and leverage", () => { + expect( + previewTicket({ ...base, size: 100 }, context).errors.join(), + ).toContain("Insufficient"); + expect( + previewTicket( + { ...base, reduceOnly: true }, + { ...context, positionSize: 0.1 }, + ).errors.join(), + ).toContain("side"); + expect( + previewTicket( + { ...base, side: "sell", reduceOnly: true, size: 0.2 }, + { ...context, positionSize: 0.1 }, + ).errors.join(), + ).toContain("exceeds"); + expect( + previewTicket({ ...base, size: "NaN" }, context).errors.length, + ).toBeGreaterThan(0); + expect( + previewTicket({ ...base, leverage: 41 }, context).errors.length, + ).toBeGreaterThan(0); + }); + test("reduce-only percentage tickets preserve exact lots without leaving rounding dust", () => { + for (const [positionSize, percent] of [ + [0.00035, 100], + [0.0007, 50], + [-0.00035, 100], + ]) { + const preview = previewTicket( + { + ...base, + side: positionSize! > 0 ? "sell" : "buy", + reduceOnly: true, + sizeUnit: "percent", + size: percent!, + }, + { ...context, positionSize }, + ); + expect(preview.errors).toEqual([]); + expect(preview.order?.orders[0]?.s).toBe("0.00035"); + } + }); + test("position brackets, scale per-leg minimum, TWAP constraints and fat finger warnings", () => { + const position = previewTicket( + { ...base, positionTpsl: true, takeProfit: 70_000, stopLoss: 50_000 }, + { ...context, positionSize: 0.01 }, + ); + expect(position.errors).toEqual([]); + expect(position.order?.grouping).toBe("positionTpsl"); + expect(position.order?.orders.length).toBe(2); + expect( + previewTicket( + { + ...base, + kind: "scale", + size: 0.005, + scaleStart: 60_000, + scaleEnd: 61_000, + scaleCount: 50, + }, + context, + ).errors, + ).toContain("Each scale order must be at least $10."); + expect( + previewTicket({ ...base, kind: "twap", twapMinutes: 4 }, context).errors + .length, + ).toBeGreaterThan(0); + expect( + previewTicket({ ...base, kind: "limit", limitPrice: 90_000 }, context) + .warnings.length, + ).toBeGreaterThan(0); + }); + test("scale USD budget includes every rung and stop trigger cannot fire immediately", () => { + const small = { ...market, mark: 100, szDecimals: 2 }; + const scale = previewTicket( + { + ...base, + market: small, + kind: "scale", + size: 1000, + sizeUnit: "usd", + scaleStart: 100, + scaleEnd: 200, + scaleCount: 5, + }, + context, + ); + expect(scale.errors).toEqual([]); + expect(scale.notional).toBeLessThanOrEqual(1000); + expect(scale.notional).toBeGreaterThan(990); + expect( + previewTicket( + { ...base, kind: "stop-market", triggerPrice: 55_000 }, + context, + ).errors.join(), + ).toContain("trigger immediately"); + expect( + previewTicket( + { ...base, side: "sell", kind: "stop-market", triggerPrice: 65_000 }, + context, + ).errors.join(), + ).toContain("trigger immediately"); + const reverse = previewTicket( + { ...base, side: "sell", size: 0.02 }, + { ...context, available: 1, positionSize: 0.01 }, + ); + expect(reverse.marginRequired).toBe(0); + expect(reverse.errors).toEqual([]); + }); + test("fee calculation preserves maker rebates and incorporates growth, referrals and aligned discounts", () => { + const rates = perpFeeRates({ + maker: -0.00002, + taker: 0.00045, + referralDiscount: 0.1, + deployerFeeScale: 2, + growthMode: true, + alignedCollateral: true, + }); + expect(rates.maker).toBeCloseTo(-0.0000025, 10); + expect(rates.taker).toBeCloseTo(0.0001458, 10); + }); + test("builder cannot charge a placeholder, zero or testnet; valid address charges exactly 10bps", () => { + const publicAddress = privateKeyToAccount(generatePrivateKey()).address; + expect(configuredBuilder("mainnet")?.feeTenthsBps).toBe(100); + expect( + configuredBuilder("mainnet", "BUILDER_ADDRESS_PENDING"), + ).toBeUndefined(); + expect(configuredBuilder("mainnet", "invalid")).toBeUndefined(); + expect(configuredBuilder("mainnet", zeroAddress)).toBeUndefined(); + expect(configuredBuilder("testnet", publicAddress)).toBeUndefined(); + const builder = configuredBuilder("mainnet", publicAddress)!; + expect(builder.feeTenthsBps).toBe(100); + const preview = previewTicket(base, context, { + ...DEFAULT_TRADING_SETTINGS, + builder, + }); + expect(preview.builderFee).toBeCloseTo(preview.notional * 0.001, 10); + expect(preview.order?.builder?.f).toBe(100); + }); + test("liquidation uses maintenance tiers and order-book estimate stops at cap", () => { + expect(isolatedLiquidation(60_000, 0.1, 600, true, market)).toBeCloseTo( + (6000 - 600) / (0.1 * (1 - 1 / 80)), + 6, + ); + const tiered = { + ...market, + marginTiers: [ + { lowerBound: 0, maxLeverage: 40 }, + { lowerBound: 100_000, maxLeverage: 20 }, + ], + }; + expect(maintenanceTier(150_000, tiered)).toEqual({ + rate: 0.025, + deduction: 1250, + }); + expect( + estimateFill( + [ + { px: "100", sz: "2" }, + { px: "101", sz: "2" }, + { px: "103", sz: "9" }, + ], + 6, + 102, + true, + ), + ).toEqual({ average: 100.5, unfilled: 2 }); + }); +}); + +test("standard dex collateral stays isolated; unified collateral is shared without double-counting", () => { + const state = (value: number, margin: number, maintenance: number) => + ({ + marginSummary: { + accountValue: String(value), + totalMarginUsed: String(margin), + }, + crossMarginSummary: { + accountValue: String(value), + totalMarginUsed: String(margin), + }, + crossMaintenanceMarginUsed: String(maintenance), + withdrawable: String(value - margin), + assetPositions: [], + time: 1, + }) as any; + const states = new Map([ + ["", state(100, 10, 2)], + ["xyz", state(100, 20, 3)], + ["cash", state(900, 50, 10)], + ]); + const spot = { + balances: [ + { coin: "USDC", token: 0, total: "200", hold: "30", entryNtl: "200" }, + { coin: "USDT", token: 1, total: "900", hold: "50", entryNtl: "900" }, + ], + }; + const collateral = new Map([ + ["", "USDC"], + ["xyz", "USDC"], + ["cash", "USDT"], + ]); + const constrained = state(100, 25, 10); + constrained.withdrawable = "0"; + const liquid = normalizeBalances( + new Map([["", constrained]]), + spot, + "disabled", + ); + expect(liquid.available).toBe(75); + expect(liquid.withdrawable).toBe(0); + const standard = normalizeBalances(states, spot, "disabled", collateral); + expect(standard.accountValue).toBe(200); + expect(standard.available).toBe(170); + const unified = normalizeBalances(states, spot, "unifiedAccount", collateral); + expect(unified.accountValue).toBe(200); + expect(unified.available).toBe(170); + expect(unified.crossMarginRatio).toBeCloseTo(5 / 200); + expect( + availableForMarket( + { ...standard, abstraction: "disabled", spot } as AccountSnapshot, + { ...market, dex: "xyz" }, + ), + ).toBe(80); + expect( + availableForMarket( + { ...unified, abstraction: "unifiedAccount", spot } as AccountSnapshot, + { ...market, dex: "xyz" }, + ), + ).toBe(170); +}); + +test("country gate fails closed for lookup failures and restricted locations", async () => { + for (const country of ["US", "CA", "IR"]) + expect( + (await checkRegion((async () => new Response(`loc=${country}\n`)) as any)) + .allowed, + ).toBe(false); + expect( + (await checkRegion((async () => new Response("loc=DE\n")) as any)).allowed, + ).toBe(true); + expect( + ( + await checkRegion((async () => { + throw new Error("offline"); + }) as any) + ).allowed, + ).toBe(false); +}); + +test("concurrent views share account initialization without restarting subscriptions", async () => { + let release!: () => void; + const barrier = new Promise((resolve) => { + release = resolve; + }); + let stateRequests = 0, + subscriptions = 0; + const state = { + marginSummary: { accountValue: "100", totalMarginUsed: "0" }, + crossMarginSummary: { accountValue: "100", totalMarginUsed: "0" }, + crossMaintenanceMarginUsed: "0", + withdrawable: "100", + assetPositions: [], + time: 1, + }; + const store = new AccountStore("testnet", { + info: { + request: async (body) => { + await barrier; + if (body.type === "clearinghouseState") { + stateRequests++; + return state as any; + } + if (body.type === "spotClearinghouseState") + return { balances: [] } as any; + if (body.type === "userAbstraction") return "default" as any; + return [] as any; + }, + }, + ws: { + subscribe: () => { + subscriptions++; + return () => {}; + }, + }, + }); + try { + const address = "0x1111111111111111111111111111111111111111"; + const first = store.setAddress(address), + before = subscriptions, + second = store.setAddress(address); + expect(subscriptions).toBe(before); + release(); + const snapshots = await Promise.all([first, second]); + expect(stateRequests).toBe(1); + expect(snapshots[0].accountValue).toBe(100); + expect(snapshots[1].error).toBeUndefined(); + } finally { + store.stop(); + } +}); + +test("L1 submitted order signature matches independently signed EIP712 payload byte for byte", async () => { + const wallet = privateKeyToAccount(generatePrivateKey()); + const nonce = 1_770_000_000_001; + let sent: any; + const client = new ExchangeClient({ + wallet, + nonceManager: () => nonce, + transport: { + isTestnet: true, + async request(_endpoint: string, payload: unknown) { + sent = payload; + return { + status: "ok", + response: { + type: "order", + data: { statuses: [{ resting: { oid: 1 } }] }, + }, + } as T; + }, + }, + }); + const request = previewTicket(base, context).order!; + await client.order(request); + const action = canonicalize(OrderRequest.entries.action, { + type: "order", + ...request, + }); + const connectionId = createL1ActionHash({ action, nonce }); + const signature = parseSignature( + await wallet.signTypedData({ + domain: { + name: "Exchange", + version: "1", + chainId: 1337, + verifyingContract: zeroAddress, + }, + types: { + Agent: [ + { name: "source", type: "string" }, + { name: "connectionId", type: "bytes32" }, + ], + }, + primaryType: "Agent", + message: { source: "b", connectionId }, + }), + ); + expect(sent.signature).toEqual({ + r: signature.r, + s: signature.s, + v: Number(signature.v), + }); + expect(sent.action).toEqual(action); + expect(sent.nonce).toBe(nonce); +}); + +test("main-wallet typed action matches SDK user-signed output without transmitting a signed request", async () => { + const wallet = privateKeyToAccount(generatePrivateKey()), + agent = privateKeyToAccount(generatePrivateKey()); + const action = walletAction( + "approveAgent", + { + agentAddress: agent.address.toLowerCase(), + agentName: "gloom valid_until 1800000000000", + }, + "testnet", + 1_770_000_000_001, + ); + const ours = parseSignature( + await wallet.signTypedData(userSignedTypedData(action, ApproveAgentTypes)), + ); + const reference = await signUserSignedAction({ + wallet, + action, + types: ApproveAgentTypes, + }); + expect(reference).toEqual({ + r: ours.r, + s: ours.s, + v: Number(ours.v) as 27 | 28, + }); +}); diff --git a/src/trading/types.ts b/src/trading/types.ts new file mode 100644 index 0000000..d8f29b1 --- /dev/null +++ b/src/trading/types.ts @@ -0,0 +1,225 @@ +import type { + ClearinghouseStateResponse, + FrontendOpenOrdersResponse, + HistoricalOrdersResponse, + UserFillsResponse, + UserFundingResponse, + UserNonFundingLedgerUpdatesResponse, + SpotClearinghouseStateResponse, + UserFeesResponse, +} from "@nktkas/hyperliquid/api/info"; +import type { + OrderParameters, + TwapOrderParameters, +} from "@nktkas/hyperliquid/api/exchange"; + +export type Network = "mainnet" | "testnet"; +export type Address = `0x${string}`; +export type AccountMode = "disconnected" | "watch" | "pending" | "trading"; +export interface TradingStatus { + network: Network; + mode: AccountMode; + address?: Address; + agentAddress?: Address; + expiresAt?: number; + agentName?: string; + storage?: "keychain" | "file"; + riskAcknowledged: boolean; + eligibleAcknowledged: boolean; + region?: RegionStatus; + error?: string; +} +export interface RegionStatus { + allowed: boolean; + country?: string; + checkedAt: number; + reason?: string; +} +export interface TradingMarket { + coin: string; + assetId: number; + dex: string; + szDecimals: number; + maxLeverage: number; + onlyIsolated: boolean; + marginMode?: string | null; + mark: number; + collateral?: string; + feeMultiplier?: number; + deployerFeeScale?: number; + growthMode?: boolean; + alignedCollateral?: boolean; + marginTiers?: { lowerBound: number; maxLeverage: number }[]; +} +export interface TicketRequest { + accountAddress?: Address; + market: TradingMarket; + side: "buy" | "sell"; + kind: + | "market" + | "limit" + | "stop-market" + | "stop-limit" + | "take-profit-market" + | "take-profit-limit" + | "twap" + | "scale"; + size: number | string; + sizeUnit: "coin" | "usd" | "percent"; + leverage: number; + marginMode: "cross" | "isolated"; + limitPrice?: number; + triggerPrice?: number; + tif?: "Gtc" | "Alo" | "Ioc"; + reduceOnly?: boolean; + takeProfit?: number; + stopLoss?: number; + positionTpsl?: boolean; + twapMinutes?: number; + twapRandomize?: boolean; + scaleStart?: number; + scaleEnd?: number; + scaleCount?: number; + /** Stable per user intent. Reuse to reconcile; never regenerate after a timeout. */ + clientId: string; +} +export interface TradingSettings { + slippagePercent: number; + confirmations: boolean; + maxOrderNotional: number; + maxPriceDistancePercent: number; + builder?: { address: Address; feeTenthsBps: number }; +} +export const DEFAULT_TRADING_SETTINGS: TradingSettings = { + slippagePercent: 1, + confirmations: true, + maxOrderNotional: 100_000, + maxPriceDistancePercent: 10, +}; +export interface BookLevel { + px: string; + sz: string; +} +export interface TicketContext { + available: number; + makerRate: number; + takerRate: number; + referralDiscount?: number; + positionSize?: number; + accountValue?: number; + book?: [BookLevel[], BookLevel[]]; + /** Cross liquidation needs the whole account; no single-position estimate is presented. */ + crossMaintenanceMargin?: number; +} +export interface TicketPreview { + size: string; + price: string; + notional: number; + marginRequired: number; + fee: number; + builderFee: number; + liquidationPrice: number | null; + averageFill: number | null; + slippagePercent: number | null; + unfilledSize: number; + errors: string[]; + warnings: string[]; + order?: OrderParameters; + twap?: TwapOrderParameters; +} +export interface TradingResult { + clientId: string; + state: "accepted" | "rejected" | "unknown"; + message: string; + response?: unknown; + cloids?: string[]; + at: number; +} +export type Position = + ClearinghouseStateResponse["assetPositions"][number]["position"] & { + dex: string; + }; +export interface DexBalance { + dex: string; + collateral?: string; + accountValue: number; + withdrawable?: number; + available: number; + marginUsed: number; + maintenanceMargin: number; + crossAccountValue: number; + crossMarginUsed: number; +} +export interface AccountSnapshot { + address: Address; + network: Network; + updatedAt: number; + error?: string; + stale: boolean; + abstraction: "unifiedAccount" | "portfolioMargin" | "disabled" | "default"; + accountValue: number; + available: number; + withdrawable: number; + marginUsed: number; + maintenanceMargin: number; + crossMarginRatio: number | null; + unrealizedPnl: number; + balances: DexBalance[]; + positions: Position[]; + orders: FrontendOpenOrdersResponse; + fills: UserFillsResponse; + orderHistory: HistoricalOrdersResponse; + funding: UserFundingResponse; + ledger: UserNonFundingLedgerUpdatesResponse; + spot: SpotClearinghouseStateResponse; + fees: UserFeesResponse | null; + twaps: unknown[]; + notifications: string[]; +} +export interface WalletSession { + url: string; + expiresAt: number; + agentAddress?: Address; +} +export interface SharedTransport { + getSnapshot?(): import("../market/types").BoardSnapshot; + refresh?(): Promise; + subscribe?(listener: () => void): () => void; + info: { + request( + body: Record, + options?: { signal?: AbortSignal; weight?: number }, + ): Promise; + }; + ws: { + getStatus?(): string; + subscribe( + subscription: Record, + handler: (data: any) => void, + onReconnect?: () => void, + ): () => void; + }; +} +export type TradingOperation = + | "status" + | "watch" + | "connect" + | "import" + | "disconnect" + | "account" + | "preview" + | "submit" + | "cancel" + | "modify" + | "leverage" + | "margin" + | "wallet" + | "connectionStatus" + | "acknowledge" + | "region" + | "close" + | "reverse" + | "closeAll" + | "twapCancel" + | "twapResume" + | "history"; diff --git a/src/trading/wallet-actions.ts b/src/trading/wallet-actions.ts new file mode 100644 index 0000000..23c2208 --- /dev/null +++ b/src/trading/wallet-actions.ts @@ -0,0 +1,48 @@ +import { + ApproveAgentTypes, + ApproveBuilderFeeTypes, + Withdraw3Types, + UsdClassTransferTypes, + SendAssetTypes, + UserSetAbstractionTypes, +} from "@nktkas/hyperliquid/api/exchange"; +import type { Address, Network } from "./types"; +export const WALLET_TYPES = { + approveAgent: ApproveAgentTypes, + approveBuilderFee: ApproveBuilderFeeTypes, + withdraw3: Withdraw3Types, + usdClassTransfer: UsdClassTransferTypes, + sendAsset: SendAssetTypes, + userSetAbstraction: UserSetAbstractionTypes, +}; +export function userSignedTypedData( + action: Record, + types: Record, +) { + return { + domain: { + name: "HyperliquidSignTransaction", + version: "1", + chainId: Number(action.signatureChainId), + verifyingContract: + "0x0000000000000000000000000000000000000000" as Address, + }, + types, + primaryType: Object.keys(types)[0]!, + message: action, + }; +} +export function walletAction( + type: keyof typeof WALLET_TYPES, + fields: Record, + network: Network, + nonce: number, +) { + return { + type, + signatureChainId: "0x66eee" as const, + hyperliquidChain: network === "testnet" ? "Testnet" : "Mainnet", + ...fields, + ...(type === "withdraw3" ? { time: nonce } : { nonce }), + }; +} diff --git a/src/ui/account.tsx b/src/ui/account.tsx new file mode 100644 index 0000000..e9341c7 --- /dev/null +++ b/src/ui/account.tsx @@ -0,0 +1,938 @@ +import { useMemo, useRef, useState } from "react"; +import { Box } from "gloomberb/ui"; +import { + Button, + DataTableView, + PaneStatusBody, + StatGrid, + TextPromptDialog, + confirmDialog, + usePaneTabs, + type DataTableCell, + type DataTableColumn, +} from "gloomberb/components"; +import { useDialog } from "gloomberb/dialog"; +import { + usePaneSettingValue, + usePluginAppActions, + usePluginPaneState, +} from "gloomberb/react"; +import { colors } from "gloomberb/theme"; +import type { PaneProps } from "gloomberb/types/plugin"; +import type { + AccountSnapshot, + TicketRequest, + TradingOperation, +} from "../trading/types"; +import { getMarketService } from "../runtime"; +import { + amountTone, + dateTime, + fundingRate, + missing, + number, + percent, + price, + signed, + size, + tone, + usd, +} from "./format"; +import { useAccount, useBoard, useLiveFooter } from "./hooks"; + +type Row = { + key: string; + coin?: string; + oid?: number; + raw?: unknown; + [id: string]: unknown; +}; +const col = ( + id: string, + label: string, + width = 14, + align: "left" | "right" = "right", +): DataTableColumn => ({ id, label, width, align }); +const COLUMNS: Record = { + Positions: [ + col("coin", "Market", 14, "left"), + col("side", "Side", 5, "left"), + col("size", "Size", 13), + col("entry", "Entry", 11), + col("mark", "Mark", 11), + col("pnl", "PnL USD", 12), + col("roe", "ROE %", 9), + col("liquidation", "Liq. price", 11), + col("margin", "Margin", 11), + col("leverage", "Leverage", 12, "left"), + col("funding", "Funding", 10), + col("collateral", "Collateral", 10, "left"), + ], + Orders: [ + col("coin", "Market", 14, "left"), + col("side", "Side", 5, "left"), + col("type", "Type", 18, "left"), + col("size", "Size", 12), + col("price", "Price", 12), + col("trigger", "Trigger", 12), + col("flags", "Flags", 20, "left"), + col("time", "Placed UTC", 19, "left"), + col("oid", "Order ID", 12), + ], + History: [ + col("coin", "Market", 14, "left"), + col("status", "Status", 22, "left"), + col("side", "Side", 5, "left"), + col("size", "Size", 12), + col("price", "Price", 12), + col("type", "Type", 18, "left"), + col("time", "Updated UTC", 19, "left"), + ], + Fills: [ + col("coin", "Market", 14, "left"), + col("direction", "Direction", 14, "left"), + col("size", "Size", 12), + col("price", "Price", 12), + col("pnl", "Closed PnL", 12), + col("fee", "Fee", 10), + col("feeToken", "Fee token", 9, "left"), + col("liquidity", "Liquidity", 9, "left"), + col("time", "Filled UTC", 19, "left"), + ], + Funding: [ + col("coin", "Market", 14, "left"), + col("payment", "Payment USD", 13), + col("rate", "Rate /1h", 11), + col("size", "Position size", 14), + col("time", "Paid UTC", 19, "left"), + ], + Ledger: [ + col("type", "Type", 22, "left"), + col("amount", "Amount", 16), + col("token", "Token", 8, "left"), + col("time", "Time UTC", 19, "left"), + col("hash", "Transaction", 28, "left"), + ], + Balances: [ + col("coin", "Account / token", 20, "left"), + col("value", "Value / total", 16), + col("available", "Available", 16), + col("held", "Held / margin", 16), + col("maintenance", "Maintenance", 14), + col("collateral", "Collateral", 10, "left"), + ], + Fees: [col("name", "Fee schedule", 24, "left"), col("value", "Rate", 10)], + TWAP: [ + col("coin", "Market", 14, "left"), + col("status", "Status", 12, "left"), + col("execution", "Execution", 14, "left"), + col("side", "Side", 5, "left"), + col("size", "Size", 12), + col("filled", "Filled", 12), + col("minutes", "Minutes", 8), + col("nextTime", "Next slice UTC", 19, "left"), + col("reduce", "Reduce only", 11, "left"), + col("reason", "Detail", 32, "left"), + col("id", "TWAP ID", 40, "left"), + ], +}; +function rowsFor(account: AccountSnapshot, tab: string): Row[] { + const markets = getMarketService(account.network).getSnapshot().markets; + const metadata = (coin: string, dex?: string) => ({ + decimals: markets.find((m) => m.coin === coin)?.szDecimals, + collateral: + account.balances.find((b) => b.dex === dex)?.collateral ?? + markets.find((m) => m.coin === coin)?.collateral ?? + "--", + }); + if (tab === "Positions") + return account.positions.map((p) => ({ + key: p.coin, + ...metadata(p.coin, p.dex), + coin: p.coin, + side: Number(p.szi) > 0 ? "Long" : "Short", + size: Math.abs(Number(p.szi)), + positionSize: Number(p.szi), + entry: Number(p.entryPx), + mark: Number(p.szi) + ? Number(p.positionValue) / Math.abs(Number(p.szi)) + : null, + pnl: Number(p.unrealizedPnl), + roe: Number(p.returnOnEquity), + liquidation: p.liquidationPx == null ? null : Number(p.liquidationPx), + margin: Number(p.marginUsed), + leverage: `${p.leverage.value}x ${p.leverage.type}`, + funding: Number(p.cumFunding.sinceOpen), + raw: p, + })); + if (tab === "Orders") + return account.orders.map((o) => ({ + key: String(o.oid), + ...metadata(o.coin), + oid: o.oid, + coin: o.coin, + side: o.side === "B" ? "Buy" : "Sell", + type: o.orderType, + size: Number(o.sz), + price: Number(o.limitPx), + trigger: o.isTrigger ? Number(o.triggerPx) : null, + flags: + [ + o.reduceOnly ? "Reduce only" : "", + o.isPositionTpsl ? "Position TP/SL" : "", + o.children.length ? "Attached TP/SL" : "", + ] + .filter(Boolean) + .join(", ") || "--", + time: o.timestamp, + raw: o, + })); + if (tab === "History") + return account.orderHistory.map((h) => ({ + key: String(h.order.oid), + ...metadata(h.order.coin), + coin: h.order.coin, + status: h.status, + side: h.order.side === "B" ? "Buy" : "Sell", + size: Number(h.order.sz), + price: Number(h.order.limitPx), + type: h.order.orderType, + time: h.statusTimestamp, + })); + if (tab === "Fills") + return account.fills.map((f) => ({ + key: `${f.tid}:${f.oid}`, + ...metadata(f.coin), + coin: f.coin, + direction: f.dir, + size: Number(f.sz), + price: Number(f.px), + pnl: Number(f.closedPnl), + fee: Number(f.fee), + feeToken: f.feeToken, + liquidity: f.crossed ? "Taker" : "Maker", + time: f.time, + })); + if (tab === "Funding") + return account.funding.map((f) => ({ + key: f.hash, + ...metadata(f.delta.coin), + coin: f.delta.coin, + payment: Number(f.delta.usdc), + rate: Number(f.delta.fundingRate), + size: Number(f.delta.szi), + time: f.time, + })); + if (tab === "Ledger") + return account.ledger.map((entry, i) => { + const d = entry.delta as unknown as Record; + return { + key: `${entry.hash}:${i}`, + type: d.type, + amount: d.usdc ?? d.amount ?? d.ntli ?? "--", + token: d.token ?? (d.usdc ? "USDC" : "--"), + time: entry.time, + hash: entry.hash, + }; + }); + if (tab === "Balances") + return [ + ...account.balances.map((b) => ({ + key: `dex:${b.dex}`, + coin: b.dex || "Native perpetuals", + collateral: b.collateral, + value: b.accountValue, + available: b.available, + held: b.marginUsed, + maintenance: b.maintenanceMargin, + })), + ...account.spot.balances.map((b) => ({ + key: `spot:${b.coin}`, + coin: b.coin, + value: Number(b.total), + available: Number(b.total) - Number(b.hold), + held: Number(b.hold), + maintenance: null, + })), + ]; + if (tab === "Fees") { + const f = account.fees; + return f + ? [ + { + key: "taker", + name: "Perpetual taker", + value: percent(Number(f.userCrossRate), 4, false), + }, + { + key: "maker", + name: "Perpetual maker", + value: percent(Number(f.userAddRate), 4, false), + }, + { + key: "spotTaker", + name: "Spot taker", + value: percent(Number(f.userSpotCrossRate), 4, false), + }, + { + key: "spotMaker", + name: "Spot maker", + value: percent(Number(f.userSpotAddRate), 4, false), + }, + { + key: "referral", + name: "Referral discount", + value: percent(Number(f.activeReferralDiscount), 2, false), + }, + ] + : []; + } + if (tab === "TWAP") + return account.twaps.map((entry, i) => { + const t = entry as Record; + const v = (t.state ?? t) as Record; + const status = + typeof t.status === "object" && t.status + ? (t.status as Record).status + : t.status; + return { + key: String(t.twapId ?? i), + ...metadata(String(v.coin ?? "")), + id: t.twapId, + coin: String(v.coin ?? "--"), + side: v.side === "B" ? "Buy" : "Sell", + size: Number(v.sz), + filled: Number(v.executedSz), + minutes: v.minutes, + reduce: v.reduceOnly ? "Yes" : "No", + execution: t.local ? "This computer" : "Exchange", + local: !!t.local, + status: status ?? "running", + reason: t.reason, + nextTime: t.nextTime, + }; + }); + return []; +} +export function PositionsTable({ + account, + width, + height, + focused, + market, + first, + loading = false, +}: { + account: AccountSnapshot | null; + width: number; + height: number; + focused: boolean; + market?: string; + loading?: boolean; + /** Lists this market's position ahead of the rest. */ + first?: string; +}) { + const app = usePluginAppActions(); + const rows = account + ? rowsFor(account, "Positions") + .filter((r) => !market || r.coin === market) + .sort((a, b) => Number(b.coin === first) - Number(a.coin === first)) + : []; + return ( + + focused={focused} + rootWidth={width} + rootHeight={height} + columns={COLUMNS.Positions!} + items={rows} + selection={{ kind: "none" }} + sortColumnId={null} + sortDirection="asc" + getItemKey={(r) => r.key} + renderCell={renderCell} + selectedTextOverridesCellColor + onActivate={() => app.createPaneFromTemplate("hyperliquid-account-new")} + emptyContent={ + !account && loading ? ( + + ) : undefined + } + emptyStateTitle={ + account ? "No open positions." : "Connect a wallet or watch an address." + } + /> + ); +} +const DATED_TABS = ["History", "Fills", "Funding", "Ledger"]; +const EMPTY_TITLES: Record = { + Positions: "No open positions.", + Orders: "No open orders.", + History: "No order history.", + Fills: "No fills.", + Funding: "No funding payments.", + Ledger: "No deposits, withdrawals or transfers.", + Balances: "No balances.", + Fees: "Fee schedule unavailable.", + TWAP: "No TWAP orders.", +}; +const PRICE_COLUMNS = ["entry", "mark", "price", "liquidation", "trigger"]; +const SIDE_COLORS: Record = { + Long: colors.positive, + Buy: colors.positive, + Short: colors.negative, + Sell: colors.negative, +}; +function renderCell(row: Row, column: DataTableColumn): DataTableCell { + const value = row[column.id]; + const decimals = typeof row.decimals === "number" ? row.decimals : undefined; + if (value == null) return { text: missing, value: null }; + if (typeof value === "number") { + if (PRICE_COLUMNS.includes(column.id)) + return { text: price(value, decimals ?? 0), value }; + if (["size", "filled"].includes(column.id)) + return { text: size(value, decimals), value }; + if (["pnl", "payment"].includes(column.id)) + return { text: signed(value), value, color: amountTone(value) }; + if (column.id === "roe") + return { + text: percent(value), + value: value * 100, + color: tone(value, 2), + }; + if (column.id === "rate") + return { text: fundingRate(value), value: value * 100 }; + if (column.id === "fee") return { text: number(value, 4), value }; + if (["time", "nextTime"].includes(column.id)) + return { text: dateTime(value), value: new Date(value).toISOString() }; + // Identifiers are copied and searched, so they keep their digits plain. + if (["oid", "id"].includes(column.id)) + return { text: String(value), value: String(value) }; + return { + text: number(value, column.id === "minutes" ? 0 : 2), + value, + }; + } + if (column.id === "side") + return { text: String(value), color: SIDE_COLORS[String(value)] }; + if (column.id === "status") return { text: statusLabel(String(value)) }; + return { text: String(value) }; +} +/** Exchange status ids read as words: `marginCanceled` becomes `Margin canceled`. */ +function statusLabel(value: string) { + const words = value.replace(/([a-z])([A-Z])/g, "$1 $2").toLowerCase(); + return words.charAt(0).toUpperCase() + words.slice(1); +} +export function HyperliquidAccountPane({ focused, width, height }: PaneProps) { + const state = useAccount(); + const app = usePluginAppActions(); + const dialog = useDialog(); + const board = useBoard(); + const [defaultTab] = usePaneSettingValue("defaultTab", "Positions"); + const [tab, setTab] = usePluginPaneState( + "accountTab", + Object.keys(COLUMNS).find( + (v) => v.toLowerCase() === defaultTab.toLowerCase(), + ) ?? "Positions", + ); + const [selected, setSelected] = usePluginPaneState( + "accountSelected", + null, + ); + const [sort, setSort] = useState({ + id: "", + direction: "desc" as "asc" | "desc", + }); + const [message, setMessage] = useState(null); + const [busy, setBusy] = useState(false); + const pendingAction = useRef(false); + // Dated tabs read newest first until the user picks another column. + const activeSort = + sort.id || !DATED_TABS.includes(tab) + ? sort + : { id: "time", direction: "desc" as const }; + const rows = useMemo(() => { + const rows = state.account ? rowsFor(state.account, tab) : []; + if (!activeSort.id) return rows; + return rows.sort((a, b) => { + const av = a[activeSort.id], + bv = b[activeSort.id]; + return ( + (typeof av === "number" && typeof bv === "number" + ? av - bv + : String(av ?? "").localeCompare(String(bv ?? ""))) * + (activeSort.direction === "asc" ? 1 : -1) + ); + }); + }, [state.account, tab, activeSort.id, activeSort.direction, board.markets]); + const current = rows.find((r) => r.key === selected) ?? rows[0]; + const tabs = usePaneTabs( + state.account + ? { + tabs: Object.keys(COLUMNS).map((value) => ({ + value, + label: + value === "Orders" + ? "Open orders" + : value === "History" + ? "Order history" + : value, + })), + activeValue: tab, + onSelect: (value) => { + setTab(value); + setSort({ id: "", direction: "desc" }); + }, + focused, + compact: true, + dense: true, + } + : null, + ); + const ask = (title: string, initialValue = "") => + dialog.prompt({ + content: (ctx) => ( + + ), + }); + const act = async ( + operation: TradingOperation, + payload: Record, + title: string, + detail?: string, + ) => { + if (busy || pendingAction.current) return; + pendingAction.current = true; + const expectedAddress = state.status?.address; + try { + const confirmed = await confirmDialog(dialog, { + title, + body: [`Account ${expectedAddress ?? "--"}`, detail ?? title], + confirmLabel: "Confirm", + confirmVariant: "danger", + }); + if (!confirmed) return; + setBusy(true); + setMessage(null); + const result = await state.invoke<{ message?: string }>(operation, { + ...payload, + expectedAddress, + confirmed: true, + clientId: crypto.randomUUID(), + }); + setMessage(result?.message ?? "Request completed."); + await state.refresh(); + } catch (error) { + setMessage(error instanceof Error ? error.message : String(error)); + } finally { + pendingAction.current = false; + setBusy(false); + } + }; + const close = async () => { + if (!current?.coin) return; + const input = await ask( + `Close ${current.coin}: percent, optional limit price`, + "100", + ); + if (!input) return; + const [share, limit] = input.split(/[,\s]+/).map(Number); + if (!share || share < 0 || share > 100 || (limit != null && !(limit > 0))) { + setMessage( + "Enter 1 to 100 percent and an optional positive limit price.", + ); + return; + } + await act( + "close", + { + coin: current.coin, + percent: share, + kind: limit ? "limit" : "market", + limitPrice: limit, + }, + `Close ${share}% of ${current.coin}`, + limit + ? `Limit ${limit}` + : "Market order with your configured slippage cap.", + ); + }; + const margin = async () => { + if (!current?.coin) return; + const value = await ask( + `Isolated margin USD for ${current.coin} (+ add, - remove)`, + ); + if (!value || !Number.isFinite(Number(value))) return; + await act( + "margin", + { coin: current.coin, amount: Number(value) }, + `Change margin by ${usd(Number(value))}`, + ); + }; + const tpsl = async () => { + if (!current?.coin) return; + const value = await ask( + `TP and SL prices for ${current.coin} (0 to omit)`, + "0 0", + ); + if (!value) return; + const [tp, sl] = value.split(/[,\s]+/).map(Number); + const m = getMarketService(state.network) + .getSnapshot() + .markets.find((m) => m.coin === current.coin); + if (!m?.mark || !Number.isFinite(tp) || !Number.isFinite(sl)) { + setMessage("Enter two valid prices."); + return; + } + const ticket: TicketRequest = { + market: { + ...m, + mark: m.mark, + deployerFeeScale: m.deployerFeeScale ?? undefined, + growthMode: m.growthMode === "enabled", + }, + side: Number(current.positionSize) > 0 ? "buy" : "sell", + kind: "market", + size: Math.abs(Number(current.size)), + sizeUnit: "coin", + leverage: Number(String(current.leverage).split("x")[0]), + marginMode: String(current.leverage).includes("isolated") + ? "isolated" + : "cross", + reduceOnly: true, + takeProfit: tp || undefined, + stopLoss: sl || undefined, + positionTpsl: true, + clientId: crypto.randomUUID(), + }; + await act( + "submit", + { ticket }, + `Set position TP/SL on ${current.coin}`, + `Take profit ${tp || "--"} · stop loss ${sl || "--"}`, + ); + }; + const modify = async () => { + if (!current?.coin || !current.oid) return; + const existing = state.account?.orders.find((o) => o.oid === current.oid); + if (!existing) return; + if (existing.isPositionTpsl) { + setMessage("Edit position TP/SL from the Positions tab."); + return; + } + const kinds: Record = { + Limit: "limit", + "Stop Market": "stop-market", + "Stop Limit": "stop-limit", + "Take Profit Market": "take-profit-market", + "Take Profit Limit": "take-profit-limit", + }; + const kind = kinds[existing.orderType]; + if (!kind) { + setMessage("This order type cannot be modified."); + return; + } + const value = await ask( + `Modify ${current.coin}: size, limit price${existing.isTrigger ? ", trigger price" : ""}`, + `${existing.sz} ${existing.limitPx}${existing.isTrigger ? ` ${existing.triggerPx}` : ""}`, + ); + if (!value) return; + const [size, limit, trigger] = value.split(/[,\s]+/).map(Number); + const m = getMarketService(state.network) + .getSnapshot() + .markets.find((m) => m.coin === current.coin); + if (!m?.mark || !size || !limit || (existing.isTrigger && !trigger)) { + setMessage("Enter positive size and prices."); + return; + } + const position = state.account?.positions.find( + (p) => p.coin === current.coin, + ); + const ticket: TicketRequest = { + market: { + ...m, + mark: m.mark, + deployerFeeScale: m.deployerFeeScale ?? undefined, + growthMode: m.growthMode === "enabled", + }, + side: existing.side === "B" ? "buy" : "sell", + kind, + size, + sizeUnit: "coin", + leverage: position?.leverage.value ?? 1, + marginMode: + position?.leverage.type ?? (m.onlyIsolated ? "isolated" : "cross"), + limitPrice: limit, + triggerPrice: existing.isTrigger ? trigger : undefined, + tif: + existing.tif === "Alo" ? "Alo" : existing.tif === "Ioc" ? "Ioc" : "Gtc", + reduceOnly: existing.reduceOnly, + clientId: crypto.randomUUID(), + }; + await act( + "modify", + { oid: current.oid, ticket }, + `Modify ${current.coin} ${existing.orderType}`, + `${size} @ ${limit}${trigger ? ` · trigger ${trigger}` : ""}`, + ); + }; + const trading = state.status?.mode === "trading"; + useLiveFooter( + "hyperliquid-account", + { + network: state.network, + status: busy + ? "submitting" + : state.account?.stale + ? "stale" + : state.account + ? "live" + : state.loading + ? "connecting" + : undefined, + asOf: state.account?.updatedAt, + error: message ?? state.error, + }, + [ + ...(trading && current && tab === "Positions" + ? [ + { + id: "close", + key: "c", + label: "lose", + onPress: () => void close(), + }, + { + id: "reverse", + key: "v", + label: "reverse", + title: "Reverse position", + onPress: () => + void act( + "reverse", + { coin: current.coin }, + `Reverse ${current.coin}`, + ), + }, + { id: "tpsl", key: "t", label: "p/sl", onPress: () => void tpsl() }, + { + id: "margin", + key: "i", + label: "solated margin", + onPress: () => void margin(), + }, + { + id: "closeAll", + key: "a", + label: "close all", + title: "Close all positions", + onPress: () => + void act( + "closeAll", + {}, + "Close all positions", + "Submit reduce-only market orders for every open position.", + ), + }, + ] + : []), + ...(trading && tab === "Orders" + ? [ + { + id: "cancel", + key: "x", + label: "cancel", + title: "Cancel order", + disabled: !current, + onPress: () => + current && + void act( + "cancel", + { coin: current.coin, oid: current.oid }, + `Cancel ${current.coin} order`, + ), + }, + { + id: "modify", + key: "m", + label: "odify", + disabled: !current, + onPress: () => void modify(), + }, + { + id: "cancelMarket", + key: "c", + label: "ancel market", + disabled: !current, + onPress: () => + current && + void act( + "cancel", + { coin: current.coin }, + `Cancel all ${current.coin} orders`, + ), + }, + { + id: "cancelAll", + key: "a", + label: "cancel all", + title: "Cancel all orders", + onPress: () => void act("cancel", {}, "Cancel all open orders"), + }, + ] + : []), + ...(trading && tab === "TWAP" && current + ? [ + { + id: "cancelTwap", + key: "x", + label: "cancel", + title: "Cancel TWAP", + onPress: () => + void act( + "twapCancel", + { coin: current.coin, twapId: current.id }, + `Cancel ${current.coin} TWAP`, + ), + }, + ] + : []), + ...(trading && + tab === "TWAP" && + current?.local && + current.status === "paused" + ? [ + { + id: "resumeTwap", + key: "s", + label: "tart remaining", + title: "Resume local TWAP", + onPress: () => + void act( + "twapResume", + { twapId: current.id }, + `Resume ${current.coin} TWAP`, + "Continue the remaining slices while Gloom stays open on this computer.", + ), + }, + ] + : []), + { + id: "wallet", + key: "w", + label: "allet", + onPress: () => app.createPaneFromTemplate("hyperliquid-setup-new"), + }, + ], + state.status, + ); + if (!state.account) + return ( + + app.createPaneFromTemplate("hyperliquid-setup-new") + } + /> + ) + } + /> + ); + const figures = [ + { + label: "Account value", + value: number(state.account.accountValue), + detail: "USDC", + }, + { label: "Available", value: number(state.account.available) }, + { label: "Margin used", value: number(state.account.marginUsed) }, + { + label: "Unrealized PnL", + value: signed(state.account.unrealizedPnl), + color: amountTone(state.account.unrealizedPnl), + }, + ...(height >= 20 + ? [ + { + label: "Withdrawable", + value: number(state.account.withdrawable), + }, + { + label: "Maintenance", + value: number(state.account.maintenanceMargin), + }, + { + label: "Margin ratio", + value: percent(state.account.crossMarginRatio, 2, false), + }, + { + label: "Mode", + value: + state.account.abstraction === "default" || + state.account.abstraction === "disabled" + ? "Standard" + : state.account.abstraction === "unifiedAccount" + ? "Unified" + : "Portfolio margin", + }, + ] + : []), + ]; + return ( + + focused={focused} + rootWidth={width} + rootHeight={height} + rootBefore={ + <> + {tabs.strip} + + + } + columns={COLUMNS[tab] ?? COLUMNS.Positions!} + items={rows} + getItemKey={(r) => r.key} + renderCell={renderCell} + selectedTextOverridesCellColor + selection={{ + kind: "id", + selectedId: current?.key ?? null, + getId: (r) => r.key, + onChange: setSelected, + }} + sortColumnId={activeSort.id || null} + sortDirection={activeSort.direction} + onHeaderClick={(id) => + setSort({ + id, + direction: + activeSort.id === id && activeSort.direction === "desc" + ? "asc" + : "desc", + }) + } + onActivate={(row) => + row.coin && + app.createPaneFromTemplate("hyperliquid-market-new", { + symbol: row.coin, + }) + } + emptyStateTitle={EMPTY_TITLES[tab] ?? "Nothing to show."} + /> + ); +} diff --git a/src/ui/analytics.tsx b/src/ui/analytics.tsx new file mode 100644 index 0000000..ad92dfc --- /dev/null +++ b/src/ui/analytics.tsx @@ -0,0 +1,918 @@ +import { useCallback, useMemo, useState } from "react"; +import { Box } from "gloomberb/ui"; +import { + Button, + ChartTableHeader, + DataTableView, + PaneStatusBody, + QueryBar, + scalarPoint, + staticSeries, + usePaneTabs, + useQueryBarSearch, + type DataTableColumn, +} from "gloomberb/components"; +import { + useAsyncResource, + useAutoRefresh, + usePaneSettingValue, + usePluginAppActions, + usePluginPaneState, +} from "gloomberb/react"; +import type { PaneProps } from "gloomberb/types/plugin"; +import { colors } from "gloomberb/theme"; +import type { + CloudRankedMarket, + CloudResult, + PredictedFunding, +} from "../market"; +import { getCloudPerpsClient } from "../market/cloud-history"; +import { + compact, + dateTime, + fundingRate, + fundingTone, + number, + percent, + price, + tone, +} from "./format"; +import { useBoard, useLiveFooter, useNetwork } from "./hooks"; + +const RANGE_OPTIONS = [1, 7, 30, 90, 365].map((days) => ({ + value: String(days), + label: + days === 1 + ? "1D" + : days === 7 + ? "1W" + : days === 30 + ? "1M" + : days === 90 + ? "3M" + : "1Y", +})); +const VENUES: Record = { + BinPerp: "Binance", + BybitPerp: "Bybit", + HlPerp: "Hyperliquid", + OkxPerp: "OKX", +}; +const venueName = (venue: string) => VENUES[venue] ?? venue; +const RESOLUTION_MS = { minute: 60_000, hour: 3_600_000, day: 86_400_000 }; +const RESOLUTION_LABEL = { minute: "1m", hour: "1h", day: "1d" }; +function CloudStateBody({ + result, + loading, + retry, +}: { + result?: CloudResult | null; + loading: boolean; + retry: () => void; +}) { + const app = usePluginAppActions(); + const title = + result?.state === "sign-in" + ? "Sign in for perpetual history." + : result?.state === "pro-required" + ? "Perpetual history is included with Pro." + : result?.state === "testnet" + ? "Historical analytics cover mainnet." + : result?.state === "collecting" + ? "History is still collecting." + : "Historical analytics unavailable."; + // A retry only helps a failed request; a host without plugin cloud access or + // a testnet connection gives the same answer every time. + const retryable = + result?.state !== "testnet" && result?.state !== "host-unavailable"; + return ( + app.openCommandBar("Sign in")} + /> + ) : result?.state === "pro-required" ? ( +