mirror of
https://github.com/michaelshimeles/terminal.git
synced 2026-11-07 05:56:17 -10:00
Compare commits
No files matched your search
@@ -22,7 +22,7 @@ jobs:
|
||||
arch: x86_64
|
||||
runs-on: ${{ matrix.os }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v7
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
components: clippy, rustfmt
|
||||
@@ -40,9 +40,9 @@ jobs:
|
||||
TR_PROVIDER: sample
|
||||
TR_CONGRESS_REFRESH_MIN: "0"
|
||||
TR_SMOKE_SECS: "8"
|
||||
TR_DATA_DIR: ${{ runner.temp }}/terminal-rs
|
||||
TR_DATA_DIR: ${{ runner.temp }}/terminal
|
||||
run: |
|
||||
./target/release/terminal-rs > smoke.log 2>&1 &
|
||||
./target/release/terminal > smoke.log 2>&1 &
|
||||
pid=$!
|
||||
sleep 6
|
||||
screencapture -x screenshot-${{ matrix.arch }}.png || true
|
||||
@@ -56,9 +56,9 @@ jobs:
|
||||
env:
|
||||
TR_SMOKE_SECS: "12"
|
||||
TR_CONGRESS_REFRESH_MIN: "0"
|
||||
TR_DATA_DIR: ${{ runner.temp }}/terminal-rs-live
|
||||
TR_DATA_DIR: ${{ runner.temp }}/terminal-live
|
||||
run: |
|
||||
./target/release/terminal-rs > live.log 2>&1 &
|
||||
./target/release/terminal > live.log 2>&1 &
|
||||
pid=$!
|
||||
sleep 10
|
||||
screencapture -x screenshot-live-${{ matrix.arch }}.png || true
|
||||
@@ -67,7 +67,7 @@ jobs:
|
||||
grep -q "smoke:" live.log
|
||||
- name: Bundle .app
|
||||
run: ./scripts/bundle-macos.sh
|
||||
- uses: actions/upload-artifact@v4
|
||||
- uses: actions/upload-artifact@v7
|
||||
if: always()
|
||||
with:
|
||||
name: macos-${{ matrix.arch }}
|
||||
@@ -80,19 +80,26 @@ jobs:
|
||||
linux:
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v7
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
components: clippy, rustfmt
|
||||
targets: wasm32-unknown-unknown
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
- run: sudo apt-get update && sudo apt-get install -y libxkbcommon-x11-0 libxcb-xkb1 mesa-vulkan-drivers xvfb
|
||||
- run: cargo fmt --all -- --check
|
||||
- run: cargo build --release --workspace --all-targets
|
||||
- run: cargo test --release --workspace
|
||||
- run: cargo clippy --release --workspace --all-targets -- -D warnings
|
||||
- name: The protocol crate builds for the browser
|
||||
run: cargo build -p tr-proto --target wasm32-unknown-unknown
|
||||
- name: The Hyperliquid crate builds for the browser
|
||||
run: cargo build -p tr-hl --target wasm32-unknown-unknown && cargo clippy -p tr-hl --target wasm32-unknown-unknown -- -D warnings
|
||||
- name: The wallet crate builds for the browser
|
||||
run: cargo build -p tr-turnkey --target wasm32-unknown-unknown && cargo clippy -p tr-turnkey --target wasm32-unknown-unknown -- -D warnings
|
||||
- name: Run the app (smoke test)
|
||||
env:
|
||||
TR_PROVIDER: sample
|
||||
TR_CONGRESS_REFRESH_MIN: "0"
|
||||
TR_SMOKE_SECS: "6"
|
||||
run: xvfb-run -a ./target/release/terminal-rs 2>&1 | tee smoke.log && grep -q "smoke:" smoke.log
|
||||
run: xvfb-run -a ./target/release/terminal 2>&1 | tee smoke.log && grep -q "smoke:" smoke.log
|
||||
@@ -0,0 +1,128 @@
|
||||
name: deploy site
|
||||
|
||||
# Builds the browser app with scripts/build-web.sh and deploys the site with it to Vercel:
|
||||
# production on every push to master, a preview for each pull request. Vercel's own Git
|
||||
# deployments are off (site/vercel.json) because Vercel's build doesn't run Rust.
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [master]
|
||||
pull_request:
|
||||
workflow_dispatch:
|
||||
|
||||
# Production runs queue instead of cancelling each other, so the newest commit always deploys
|
||||
# last. A new push to a pull request cancels the preview run for the old one.
|
||||
concurrency:
|
||||
group: deploy-site-${{ github.event.pull_request.number || github.ref }}
|
||||
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
env:
|
||||
CARGO_TERM_COLOR: always
|
||||
|
||||
jobs:
|
||||
wasm:
|
||||
name: Build the browser app
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
# Pull requests build the branch head, like Vercel's Git previews did, not the merge commit.
|
||||
ref: ${{ github.event.pull_request.head.sha || github.sha }}
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
targets: wasm32-unknown-unknown
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
- name: Read the wasm-bindgen version from Cargo.lock
|
||||
id: lock
|
||||
run: echo "wasm-bindgen=$(awk '/^name = "wasm-bindgen"$/ { getline; gsub(/version = |"/, ""); print; exit }' Cargo.lock)" >> "$GITHUB_OUTPUT"
|
||||
- uses: taiki-e/install-action@v2
|
||||
with:
|
||||
tool: wasm-bindgen@${{ steps.lock.outputs.wasm-bindgen }}
|
||||
- name: Install wasm-opt (binaryen)
|
||||
env:
|
||||
BINARYEN: version_133
|
||||
SHA256: 2dc9c7813f5375db93d96ead4b78222fcc3e2677bbb832297af4797782a37489
|
||||
run: |
|
||||
curl -sSfLo "$RUNNER_TEMP/binaryen.tar.gz" "https://github.com/WebAssembly/binaryen/releases/download/$BINARYEN/binaryen-$BINARYEN-x86_64-linux.tar.gz"
|
||||
echo "$SHA256 $RUNNER_TEMP/binaryen.tar.gz" | sha256sum -c -
|
||||
tar xzf "$RUNNER_TEMP/binaryen.tar.gz" -C "$RUNNER_TEMP"
|
||||
echo "$RUNNER_TEMP/binaryen-$BINARYEN/bin" >> "$GITHUB_PATH"
|
||||
# The repository variable TR_SERVER_URL, when set, picks the server the site connects to,
|
||||
# for example after the gateway moves to a custom domain. Unset, build-web.sh's default holds.
|
||||
- name: Build the browser app
|
||||
env:
|
||||
SERVER_URL: ${{ vars.TR_SERVER_URL }}
|
||||
run: |
|
||||
if [ -n "$SERVER_URL" ]; then export TR_SERVER_URL="$SERVER_URL"; fi
|
||||
scripts/build-web.sh
|
||||
- uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: web-app
|
||||
path: site/static/app
|
||||
if-no-files-found: error
|
||||
retention-days: 7
|
||||
|
||||
deploy:
|
||||
name: Deploy to Vercel
|
||||
needs: wasm
|
||||
# Pull requests from forks get no secrets, so they stop after the build.
|
||||
if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository
|
||||
runs-on: ubuntu-24.04
|
||||
environment:
|
||||
name: ${{ github.ref == 'refs/heads/master' && 'production' || 'preview' }}
|
||||
url: ${{ steps.deploy.outputs.url }}
|
||||
env:
|
||||
TARGET: ${{ github.ref == 'refs/heads/master' && 'production' || 'preview' }}
|
||||
VERCEL_ORG_ID: ${{ secrets.VERCEL_ORG_ID }}
|
||||
VERCEL_PROJECT_ID: ${{ secrets.VERCEL_PROJECT_ID }}
|
||||
steps:
|
||||
- name: Check the Vercel secrets
|
||||
env:
|
||||
VERCEL_TOKEN: ${{ secrets.VERCEL_TOKEN }}
|
||||
run: |
|
||||
missing=0
|
||||
for name in VERCEL_TOKEN VERCEL_ORG_ID VERCEL_PROJECT_ID; do
|
||||
if [ -z "${!name}" ]; then
|
||||
echo "::error::The $name repository secret is not set (see site/README.md)"
|
||||
missing=1
|
||||
fi
|
||||
done
|
||||
exit $missing
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.sha || github.sha }}
|
||||
- uses: actions/download-artifact@v8
|
||||
with:
|
||||
name: web-app
|
||||
path: site/static/app
|
||||
- uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: 24
|
||||
cache: npm
|
||||
cache-dependency-path: site/package-lock.json
|
||||
- run: npm install --global [email protected]
|
||||
# Run from the repository root, because the Vercel project's Root Directory is site/.
|
||||
- name: Pull the project settings and environment variables
|
||||
env:
|
||||
VERCEL_TOKEN: ${{ secrets.VERCEL_TOKEN }}
|
||||
run: vercel pull --yes --environment="$TARGET" --token="$VERCEL_TOKEN"
|
||||
# The build gets no token, since it runs the site's npm install and build scripts.
|
||||
- name: Build
|
||||
run: |
|
||||
if [ "$TARGET" = production ]; then vercel build --prod; else vercel build; fi
|
||||
test -f .vercel/output/static/app/index.html
|
||||
- name: Deploy
|
||||
id: deploy
|
||||
env:
|
||||
VERCEL_TOKEN: ${{ secrets.VERCEL_TOKEN }}
|
||||
run: |
|
||||
if [ "$TARGET" = production ]; then
|
||||
url=$(vercel deploy --prebuilt --prod --token="$VERCEL_TOKEN")
|
||||
else
|
||||
url=$(vercel deploy --prebuilt --token="$VERCEL_TOKEN")
|
||||
fi
|
||||
echo "url=$url" >> "$GITHUB_OUTPUT"
|
||||
echo "Deployed $TARGET: $url ($url/app)" >> "$GITHUB_STEP_SUMMARY"
|
||||
Generated
+2410
-36
File diff suppressed because it is too large.
Load diff
+15
-2
@@ -1,20 +1,23 @@
|
||||
[workspace]
|
||||
resolver = "2"
|
||||
members = ["crates/core", "crates/broker", "crates/congress", "crates/data", "crates/chart", "crates/macos", "app"]
|
||||
members = ["crates/core", "crates/broker", "crates/hl", "crates/turnkey", "crates/congress", "crates/data", "crates/chart", "crates/macos", "crates/proto", "app", "server"]
|
||||
default-members = ["app"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.1.0"
|
||||
version = "0.1.1"
|
||||
edition = "2024"
|
||||
rust-version = "1.85"
|
||||
|
||||
[workspace.dependencies]
|
||||
tr-core = { path = "crates/core" }
|
||||
tr-broker = { path = "crates/broker" }
|
||||
tr-hl = { path = "crates/hl" }
|
||||
tr-turnkey = { path = "crates/turnkey" }
|
||||
tr-congress = { path = "crates/congress" }
|
||||
tr-data = { path = "crates/data" }
|
||||
tr-chart = { path = "crates/chart" }
|
||||
tr-macos = { path = "crates/macos" }
|
||||
tr-proto = { path = "crates/proto" }
|
||||
anyhow = "1"
|
||||
thiserror = "2"
|
||||
chrono = { version = "0.4", default-features = false, features = ["std", "clock", "serde"] }
|
||||
@@ -36,3 +39,13 @@ debug = 1
|
||||
|
||||
[profile.dev.package."*"]
|
||||
opt-level = 2
|
||||
|
||||
# The browser build (scripts/build-web.sh): one fully optimised unit, no debug info, so the
|
||||
# .wasm is as small as it gets before wasm-opt.
|
||||
[profile.web]
|
||||
inherits = "release"
|
||||
opt-level = "s"
|
||||
lto = "fat"
|
||||
codegen-units = 1
|
||||
debug = false
|
||||
strip = true
|
||||
@@ -1,4 +1,4 @@
|
||||
# terminal-rs
|
||||
# Terminal
|
||||
|
||||
A fast, keyboard-driven market terminal in pure Rust: TradingView-class GPU charting with a
|
||||
Bloomberg Terminal look (dense tiled panes, monospace numbers, a command line like `AAPL <GO>`)
|
||||
@@ -9,7 +9,7 @@ systems** and **your own brokerage accounts through [SnapTrade](https://snaptrad
|
||||
to Solarized, and a fully dockable
|
||||
workspace with saved layouts.
|
||||
|
||||

|
||||

|
||||
|
||||
> **Built from reverse-engineering research.** Chart UX was studied behaviourally on
|
||||
> tradingview.com with [REA](https://github.com/morluto/rea) in website mode (passive captures of
|
||||
@@ -84,6 +84,11 @@ gets the native title bar with traffic lights, window vibrancy behind the sideba
|
||||
| US Treasuries | Treasury.gov daily par yield curve CSV (FRED fallback); Yahoo `^IRX/^FVX/^TNX/^TYX` intraday | Treasury.gov | — |
|
||||
| FX, indices, futures | Yahoo Finance (`EURUSD=X`, `^GSPC`, `CL=F`) | Yahoo spark (batched) | — |
|
||||
| Stocks, ETFs | Alpaca when `APCA_API_KEY_ID`/`APCA_API_SECRET_KEY` are set, else Yahoo | Yahoo spark | — |
|
||||
| Hyperliquid HIP-3 perps (`xyz:AAPL`, `xyz:GOLD`, `xyz:EUR`) | The Terminal data server only (`TR_SERVER_URL`) | Server mids | Server candles, book and trades |
|
||||
|
||||
A HIP-3 perp is its own instrument, labelled `PERP · HL xyz` everywhere. `AAPL US Equity` stays the
|
||||
listed stock. `AAPL PERP` opens the perp linked to it, and `PERP` or ⇧P switches back and forth.
|
||||
A perp has no other source, so it never gets sample data; without the server it shows why.
|
||||
|
||||
No keys are required. `TR_PROVIDER=sample` runs fully offline on deterministic, class-aware sample
|
||||
data (BTCUSD near 65,000, EURUSD near 1.09, US10Y near 4.2%) **with simulated live ticks**, and
|
||||
@@ -122,7 +127,7 @@ gets the native title bar with traffic lights, window vibrancy behind the sideba
|
||||
row in a linked watchlist or world-markets pane loads it into the group.
|
||||
- **Saved layouts**: save, name, rename, duplicate, delete and switch (`LAYOUT <name>`, the
|
||||
sidebar list, ⌥1…⌥9); the active layout autosaves and is restored on launch. Each layout is a
|
||||
JSON file in `~/Library/Application Support/terminal-rs/layouts` (`$XDG_DATA_HOME` on Linux)
|
||||
JSON file in `~/Library/Application Support/terminal/layouts` (`$XDG_DATA_HOME` on Linux)
|
||||
and can be imported or exported (`LAYOUT EXPORT`/`IMPORT`, or Settings › Layouts). Per-pane
|
||||
settings (chart type, indicators, drawings, filters, depth, link, tab title) and, optionally,
|
||||
the theme (`LAYOUT THEME ON`) save with the layout.
|
||||
@@ -181,16 +186,19 @@ TR_PROVIDER=sample cargo run --release # offline
|
||||
APCA_API_KEY_ID=… APCA_API_SECRET_KEY=… cargo run --release # Alpaca
|
||||
```
|
||||
|
||||
Data lives in `~/Library/Application Support/terminal-rs` (macOS) or
|
||||
`$XDG_DATA_HOME/terminal-rs` (Linux), or `TR_DATA_DIR`. The first congress ingest covers the
|
||||
current and previous year (a few minutes; it runs in the background while you work).
|
||||
Data lives in `~/Library/Application Support/terminal` (macOS) or
|
||||
`$XDG_DATA_HOME/terminal` (Linux), or `TR_DATA_DIR`. Versions from before the project was renamed
|
||||
used a `terminal-rs` folder in the same place; the first launch moves it to `terminal` and leaves
|
||||
a link at the old path, so builds that still use the old name find the same files. The first
|
||||
congress ingest covers the current and previous year (a few minutes; it runs in the background
|
||||
while you work).
|
||||
`cargo run -p tr-congress --example ingest -- congress.sqlite3` runs one ingest from the CLI.
|
||||
|
||||
Appearance: `TR_THEME=light|dark|<theme name>` overrides the saved theme, `TR_MSAA=1|2|4|8` sets
|
||||
chart anti-aliasing (default 4 on macOS), `TR_VIBRANCY=0` disables the translucent sidebar, and
|
||||
⌃⌘S (Ctrl+Shift+S elsewhere) toggles the sidebar. `scripts/bundle-macos.sh` builds a retina-capable
|
||||
`Terminal.app` (icon from `scripts/build-icon.sh`); `scripts/release-macos.sh` builds the universal (Apple Silicon + Intel) app,
|
||||
signs it with a Developer ID certificate, notarizes it and packages `terminal-rs-universal.dmg`,
|
||||
signs it with a Developer ID certificate, notarizes it and packages `Terminal.dmg`,
|
||||
plus the zip and signed `appcast.xml` that installed copies update from (one-time credential
|
||||
setup is at the top of the script). The app checks that feed once a day through
|
||||
[Sparkle](https://sparkle-project.org) and offers to install a newer release; Terminal › Check
|
||||
@@ -198,6 +206,57 @@ for Updates… or `UPDATE` checks at once. `scripts/check-macos-cross.sh` compil
|
||||
and lints the whole workspace for aarch64-apple-darwin from Linux (zig as the C cross-compiler;
|
||||
linking needs the macOS SDK).
|
||||
|
||||
## In the browser
|
||||
|
||||
The same code also builds for WebAssembly and runs in a browser tab on WebGPU:
|
||||
|
||||
```sh
|
||||
rustup target add wasm32-unknown-unknown
|
||||
cargo install wasm-bindgen-cli --version 0.2.129 --locked # must match Cargo.lock
|
||||
brew install binaryen # wasm-opt
|
||||
scripts/build-web.sh # writes site/static/app
|
||||
cd site && npm run dev # then open http://localhost:5173/app/index.html
|
||||
```
|
||||
|
||||
`scripts/build-web.sh <dir>` writes somewhere else instead; any static file server works. Add
|
||||
`?sample` to the URL to run offline on sample data, or `?fps` for the frame-time readout.
|
||||
Every push to `master` builds it and deploys it with the site to
|
||||
[terminal.rasmic.xyz/app](https://terminal.rasmic.xyz/app/). Pull requests get a preview;
|
||||
[site/README.md](site/README.md#deploys) covers the workflow and the secrets it needs.
|
||||
|
||||

|
||||
|
||||
What the browser build can and can't do yet:
|
||||
|
||||
- Crypto is live, because Binance and Coinbase allow cross-origin requests. History, quotes, order
|
||||
books and websocket bars all come from them. Yahoo, Treasury.gov, Alpaca and CoinDesk don't allow
|
||||
them, so every other market shows sample data and the toolbar says "Crypto live". A same-origin
|
||||
proxy can fix that later.
|
||||
- HIP-3 perps (`xyz:AAPL`) are live through the Terminal data server the page is built with. They
|
||||
never fall back to sample data: while the server is unreachable they say so.
|
||||
- Congress disclosures, SnapTrade accounts and live order entry need the desktop app. The paper
|
||||
trading demo account works.
|
||||
- Layouts, settings, themes and the demo account are saved in `localStorage` under `terminal/`.
|
||||
The first load copies anything saved under the old `terminal-rs/` prefix, without overwriting
|
||||
newer entries.
|
||||
- Charts need WebGPU, since they read bars from storage buffers in the vertex shader. Browsers
|
||||
without a WebGPU adapter get a second, larger build on WebGL2 where everything except the chart
|
||||
series works.
|
||||
|
||||
Measured with Playwright driving Chrome 155 on an Apple M4 Pro:
|
||||
|
||||
| | WebGPU build | WebGL2 build |
|
||||
|---|---|---|
|
||||
| `.wasm` | 5.60 MB (2.26 MB gzip, 1.69 MB brotli) | 7.48 MB (2.99 MB gzip, 2.22 MB brotli) |
|
||||
| JS glue | 115 KB | 169 KB |
|
||||
|
||||
- A cold load from localhost draws its first frame 91 to 97 ms after navigation, the medians of
|
||||
three runs of 9 loads, two headless and one in a Retina window. On a 50 Mbit/s link with 40 ms
|
||||
of latency it takes 446 ms, and 1.64 s on 10 Mbit/s with 80 ms.
|
||||
- Panning and zooming a chart costs 1.3 to 1.5 ms of CPU in the UI code and 2.4 to 3.3 ms for the
|
||||
whole frame (medians across runs), and holds the display's 60 Hz.
|
||||
- egui's two emoji fonts, 740 KB together, load after the first frame instead of inside the `.wasm`.
|
||||
|
||||
## Brokerage setup (SnapTrade)
|
||||
|
||||
Everything happens in **Settings › Brokerage**, with no commands needed. Open it from the sidebar
|
||||
@@ -221,8 +280,9 @@ the portal, and **Disconnect** removes a connection and its accounts from SnapTr
|
||||
explicit confirmation.
|
||||
|
||||
**Where credentials live.** On macOS the client ID, consumer key, user ID and user secret are
|
||||
stored together in the **Keychain** (generic password, service `terminal-rs`, account
|
||||
`snaptrade`). Elsewhere they go in `<data dir>/secrets/snaptrade.json` with owner-only permissions
|
||||
stored together in the **Keychain** (generic password, service `terminal`, account
|
||||
`snaptrade`); credentials saved under the old service, `terminal-rs`, move there the first time
|
||||
the app reads them. Elsewhere they go in `<data dir>/secrets/snaptrade.json` with owner-only permissions
|
||||
(0600 in a 0700 directory). They are never written to the repository, layouts, prefs or logs;
|
||||
request errors are scrubbed of the URL (which carries the user secret), and the settings screen
|
||||
masks secrets. **Forget Credentials** removes them. `SNAPTRADE_CLIENT_ID`,
|
||||
@@ -327,10 +387,14 @@ app/ eframe shell: command line, docked panes, chart widget, panels
|
||||
themes.rs theme model, built-in themes, TOML/JSON theme files and the themes folder
|
||||
app/ workspace (drag and drop, pop-out windows, link groups), panels, settings, chrome
|
||||
worker.rs tokio runtime: all network, SQLite, pyramid and indicator work (UI never blocks)
|
||||
storage.rs saved files: the data folder on desktop, localStorage in the browser
|
||||
web.rs browser entry point (eframe web runner), console logging, frame timings
|
||||
web/ the page that hosts the browser build
|
||||
crates/core Bar/Series, timeframes, market keys + security catalog, indicators and their
|
||||
registry (codes, parameters, panes, levels), sample data
|
||||
crates/data MarketData trait; per-class router (Binance/Coinbase, Treasury.gov/FRED, Yahoo,
|
||||
Alpaca), websocket live feeds, sample provider with simulated ticks; bar cache
|
||||
Alpaca), websocket live feeds, sample provider with simulated ticks; bar cache;
|
||||
the browser router (crypto live, the rest sampled) and a tokio/browser task shim
|
||||
crates/chart wgpu renderer, LOD pyramids, viewport math, axes, drawings, egui paint callback
|
||||
crates/congress Senate eFD + House Clerk parsers, rosters, SQLite store, refresh, alerts, performance
|
||||
crates/broker SnapTrade client (request signing, accounts, balances, positions, orders, activity),
|
||||
|
||||
+53
-6
@@ -1,13 +1,20 @@
|
||||
[package]
|
||||
name = "terminal-rs"
|
||||
name = "terminal"
|
||||
description = "Bloomberg-style trading terminal with GPU charts and congressional trade tracking"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
default-run = "terminal-rs"
|
||||
default-run = "terminal"
|
||||
|
||||
[features]
|
||||
# Browser build only: add wgpu's WebGL2 backend for browsers without WebGPU (about 1 MB more,
|
||||
# and charts stay off there because WebGL2 has no storage buffers).
|
||||
webgl = ["eframe/wgpu"]
|
||||
|
||||
[dependencies]
|
||||
tr-core.workspace = true
|
||||
tr-broker.workspace = true
|
||||
tr-hl.workspace = true
|
||||
tr-turnkey.workspace = true
|
||||
tr-congress.workspace = true
|
||||
tr-data.workspace = true
|
||||
tr-chart.workspace = true
|
||||
@@ -16,13 +23,53 @@ chrono.workspace = true
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
toml.workspace = true
|
||||
tokio.workspace = true
|
||||
tracing.workspace = true
|
||||
tracing-subscriber.workspace = true
|
||||
futures = "0.3"
|
||||
egui = "0.36"
|
||||
eframe = { version = "0.36", default-features = false, features = ["wgpu", "default_fonts", "wayland", "x11"] }
|
||||
zeroize = "1"
|
||||
egui = { version = "0.36", default-features = false }
|
||||
eframe = { version = "0.36", default-features = false, features = ["wgpu_no_default_features", "wayland", "x11"] }
|
||||
tr-macos.workspace = true
|
||||
web-time = "1"
|
||||
# The Deposit window's QR code (no image dependencies).
|
||||
qrcode = { version = "0.14", default-features = false }
|
||||
|
||||
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
|
||||
eframe = { version = "0.36", default-features = false, features = ["wgpu", "default_fonts"] }
|
||||
tokio.workspace = true
|
||||
tracing-subscriber.workspace = true
|
||||
|
||||
[target.'cfg(target_os = "macos")'.dependencies]
|
||||
raw-window-handle = "0.6"
|
||||
|
||||
# The browser build (see `src/web.rs` and scripts/build-web.sh): WebGPU only, unless the
|
||||
# `webgl` feature adds the WebGL2 fallback.
|
||||
[target.'cfg(target_arch = "wasm32")'.dependencies]
|
||||
wgpu = { version = "30", default-features = false, features = ["webgpu", "wgsl"] }
|
||||
egui-wgpu = { version = "0.36", default-features = false, features = ["fragile-send-sync-non-atomic-wasm"] }
|
||||
# egui's text fonts only; the page fetches its emoji fonts after start-up (see theme::base_fonts).
|
||||
epaint_default_fonts = "0.36"
|
||||
tokio = { version = "1", default-features = false, features = ["sync", "macros"] }
|
||||
tracing-subscriber = { version = "0.3", default-features = false, features = ["fmt", "registry", "std"] }
|
||||
chrono = { workspace = true, features = ["wasmbind"] }
|
||||
wasm-bindgen = "0.2"
|
||||
wasm-bindgen-futures = "0.4"
|
||||
js-sys = "0.3"
|
||||
web-sys = { version = "0.3", features = [
|
||||
"Blob",
|
||||
"Document",
|
||||
"Element",
|
||||
"HtmlAnchorElement",
|
||||
"HtmlCanvasElement",
|
||||
"Location",
|
||||
"Performance",
|
||||
"Response",
|
||||
"Storage",
|
||||
"Url",
|
||||
"UrlSearchParams",
|
||||
"Window",
|
||||
"console",
|
||||
] }
|
||||
|
||||
[dev-dependencies]
|
||||
async-trait = "0.1"
|
||||
tr-turnkey = { workspace = true, features = ["mock"] }
|
||||
+190
-18
@@ -7,6 +7,11 @@
|
||||
mod broker;
|
||||
mod chrome;
|
||||
mod demo;
|
||||
mod hl;
|
||||
pub(crate) use hl::store_account as hl_store_account;
|
||||
mod hl_view;
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
mod hl_web;
|
||||
mod hotkeys_view;
|
||||
mod options_view;
|
||||
mod palette_view;
|
||||
@@ -15,12 +20,14 @@ mod portfolio_view;
|
||||
mod settings_view;
|
||||
mod theming;
|
||||
mod trading;
|
||||
mod wallet;
|
||||
mod wallet_view;
|
||||
mod workspace;
|
||||
|
||||
pub use broker::PosSort;
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::time::{Duration, Instant};
|
||||
use std::time::Duration;
|
||||
|
||||
use egui::{Color32, Event, Key, Modifiers, Rect, Ui, UiBuilder, pos2, vec2};
|
||||
use serde::{Deserialize, Serialize};
|
||||
@@ -28,6 +35,7 @@ use tr_chart::drawings::Tool;
|
||||
use tr_congress::Trade;
|
||||
use tr_congress::db::{Alert, PoliticianSummary};
|
||||
use tr_data::{NewsItem, OrderBook, Quote, SearchHit, YieldCurves};
|
||||
use web_time::Instant;
|
||||
|
||||
use crate::anim;
|
||||
use crate::chart_view::ChartState;
|
||||
@@ -44,6 +52,9 @@ use crate::worker::{Profile, Request, Response, Worker, data_dir};
|
||||
use chrome::{SIDEBAR_W, STATUS_H, SidebarCmd};
|
||||
use settings_view::SettingsTab;
|
||||
|
||||
/// How often a visible order book pane asks for its book.
|
||||
const BOOK_POLL: Duration = Duration::from_millis(400);
|
||||
|
||||
pub enum PaneKind {
|
||||
Chart(Box<ChartState>),
|
||||
Feed {
|
||||
@@ -154,18 +165,50 @@ pub struct BookPane {
|
||||
pub book: Option<OrderBook>,
|
||||
pub error: Option<String>,
|
||||
pub fetched: Option<Instant>,
|
||||
/// A request is out. A REST book can take longer than [`BOOK_POLL`], and requests run
|
||||
/// concurrently, so the pane waits for the answer before it asks again.
|
||||
pub pending: bool,
|
||||
/// Numbers the pane's asks; only the answer to the latest one clears `pending`.
|
||||
pub asked: u64,
|
||||
/// The latest trades, oldest first, when the book comes from the Terminal server.
|
||||
pub trades: Vec<tr_data::TradePrint>,
|
||||
}
|
||||
|
||||
impl BookPane {
|
||||
pub fn new(symbol: &str, depth: usize, group: f64) -> Self {
|
||||
Self { symbol: symbol.into(), depth, group, book: None, error: None, fetched: None }
|
||||
Self {
|
||||
symbol: symbol.into(),
|
||||
depth,
|
||||
group,
|
||||
book: None,
|
||||
error: None,
|
||||
fetched: None,
|
||||
pending: false,
|
||||
asked: 0,
|
||||
trades: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Time to ask for the book again: [`BOOK_POLL`] since the last ask, once it was answered (or
|
||||
/// went unanswered for 10 s).
|
||||
fn due(&self) -> bool {
|
||||
self.fetched.is_none_or(|t| t.elapsed() > BOOK_POLL && (!self.pending || t.elapsed() > Duration::from_secs(10)))
|
||||
}
|
||||
|
||||
/// Whether the book came through the Terminal server, which also has the market's trades.
|
||||
pub fn served_by_server(&self) -> bool {
|
||||
self.book.as_ref().is_some_and(|b| b.source.ends_with("Terminal server"))
|
||||
}
|
||||
}
|
||||
|
||||
impl PaneKind {
|
||||
/// Short tab title.
|
||||
fn tab_title(&self) -> String {
|
||||
let short = |s: &str| tr_data::security(s).ticker;
|
||||
// A perp's tab says so: `xyz:AAPL PERP`.
|
||||
let short = |s: &str| {
|
||||
let sec = tr_data::security(s);
|
||||
if sec.class == tr_core::AssetClass::Perp { format!("{} PERP", sec.ticker) } else { sec.ticker }
|
||||
};
|
||||
match self {
|
||||
PaneKind::Chart(c) => short(&c.symbol),
|
||||
PaneKind::Feed { .. } => "Congress".into(),
|
||||
@@ -463,6 +506,10 @@ pub struct TerminalApp {
|
||||
wei_updated: Option<String>,
|
||||
/// Latest network search results and the query they answer.
|
||||
net_hits: (String, Vec<SearchHit>),
|
||||
/// HIP-3 perps the Terminal server lists as live (`None` until it has answered once), and
|
||||
/// when they were last asked for.
|
||||
live_perps: Option<Vec<String>>,
|
||||
last_perps: Option<(Instant, bool)>,
|
||||
/// Query typed in the command line awaiting a debounced network search.
|
||||
search_pending: Option<(String, Instant)>,
|
||||
suggestion: Option<usize>,
|
||||
@@ -513,11 +560,26 @@ pub struct TerminalApp {
|
||||
trading: trading::TradingState,
|
||||
/// The paper-trading demo account (selected by default).
|
||||
demo: demo::DemoState,
|
||||
/// Hyperliquid (testnet): the account, the agent key and its confirmations.
|
||||
hl: hl::HlState,
|
||||
/// ⌘K command palette.
|
||||
palette: crate::palette::Palette,
|
||||
}
|
||||
|
||||
/// Resolve a run of words (`BTCUSD CRNCY AAPL US EQUITY MSFT`) into market keys.
|
||||
/// Lay fresh quotes over the ones shown, and return the keys that got one. A perp that was
|
||||
/// asked for and left out has no other source, so its old quote goes rather than pass for current.
|
||||
fn merge_quotes(shown: &mut HashMap<String, Quote>, asked: &[String], quotes: Vec<Quote>) -> Vec<String> {
|
||||
let keys: Vec<String> = quotes.iter().map(|q| q.symbol.clone()).collect();
|
||||
for gone in asked.iter().filter(|k| tr_data::is_perp(k) && !keys.contains(k)) {
|
||||
shown.remove(gone);
|
||||
}
|
||||
for q in quotes {
|
||||
shown.insert(q.symbol.clone(), q);
|
||||
}
|
||||
keys
|
||||
}
|
||||
|
||||
fn keys_from_words(words: &[String]) -> Vec<String> {
|
||||
let tokens: Vec<&str> = words.iter().map(String::as_str).collect();
|
||||
let mut out = Vec::new();
|
||||
@@ -568,13 +630,14 @@ impl TerminalApp {
|
||||
};
|
||||
}
|
||||
let settings: Option<Settings> =
|
||||
std::fs::read(settings_path()).ok().and_then(|b| serde_json::from_slice(&b).ok());
|
||||
crate::storage::read(&settings_path()).ok().and_then(|b| serde_json::from_slice(&b).ok());
|
||||
let broker = broker::BrokerState::load(provider.eq_ignore_ascii_case("sample"));
|
||||
let (demo, demo_note) = demo::DemoState::load(demo::demo_path());
|
||||
let mut app = Self {
|
||||
broker,
|
||||
trading: Default::default(),
|
||||
demo,
|
||||
hl: hl::HlState::platform(),
|
||||
worker,
|
||||
provider,
|
||||
panes: Vec::new(),
|
||||
@@ -611,6 +674,8 @@ impl TerminalApp {
|
||||
last_curve: None,
|
||||
wei_updated: None,
|
||||
net_hits: (String::new(), Vec::new()),
|
||||
live_perps: None,
|
||||
last_perps: None,
|
||||
search_pending: None,
|
||||
suggestion: None,
|
||||
cmd: String::new(),
|
||||
@@ -672,6 +737,11 @@ impl TerminalApp {
|
||||
app.save_layout();
|
||||
}
|
||||
app.refresh_layout_names();
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
{
|
||||
app.hl.ctx = Some(ctx.clone());
|
||||
}
|
||||
app.hl_start();
|
||||
app.worker.send(Request::Latest { limit: 2_000 });
|
||||
app.worker.send(Request::SearchPoliticians(String::new()));
|
||||
app.worker.send(Request::Alerts);
|
||||
@@ -681,6 +751,17 @@ impl TerminalApp {
|
||||
app
|
||||
}
|
||||
|
||||
/// Replace the status bar message.
|
||||
pub fn set_status(&mut self, status: String) {
|
||||
self.status = status;
|
||||
}
|
||||
|
||||
/// Show the frame-time readout (as F12 does); the browser's `?fps` flag.
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
pub fn show_perf(&mut self) {
|
||||
self.show_perf = true;
|
||||
}
|
||||
|
||||
fn save_settings(&self) {
|
||||
let s = Settings {
|
||||
watchlist: self.watchlist.clone(),
|
||||
@@ -690,7 +771,7 @@ impl TerminalApp {
|
||||
panes: None,
|
||||
};
|
||||
if let Ok(json) = serde_json::to_vec_pretty(&s)
|
||||
&& let Err(e) = crate::layouts::write_atomic(&settings_path(), &json)
|
||||
&& let Err(e) = crate::storage::write_atomic(&settings_path(), &json)
|
||||
{
|
||||
tracing::warn!("saving settings: {e}");
|
||||
}
|
||||
@@ -766,6 +847,7 @@ impl TerminalApp {
|
||||
self.status = format!("{s}: option chain");
|
||||
}
|
||||
Command::SymbolFunction(s, Function::Portfolio(view, arg)) => self.portfolio_command(view, &arg, Some(s)),
|
||||
Command::SymbolFunction(s, Function::Perp) => self.open_perp(Some(s)),
|
||||
Command::SymbolFunction(s, Function::Trade(mut t)) => {
|
||||
t.symbol = Some(s);
|
||||
self.open_ticket(t, None);
|
||||
@@ -920,6 +1002,11 @@ impl TerminalApp {
|
||||
}
|
||||
self.set_account_mode(mode, false);
|
||||
}
|
||||
Function::Kill => self.hl_kill(),
|
||||
Function::Perp => {
|
||||
let key = symbol.or_else(|| self.focused_symbol());
|
||||
self.open_perp(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -945,6 +1032,7 @@ impl TerminalApp {
|
||||
prepended,
|
||||
from_cache,
|
||||
exhausted,
|
||||
full,
|
||||
live,
|
||||
} => {
|
||||
let worker = &self.worker;
|
||||
@@ -952,7 +1040,7 @@ impl TerminalApp {
|
||||
&& c.symbol == symbol
|
||||
&& c.tf == tf
|
||||
{
|
||||
c.on_history(generation, bars, gpu, prepended, from_cache, exhausted, live, worker);
|
||||
c.on_history(generation, bars, gpu, prepended, from_cache, exhausted, full, live, worker);
|
||||
}
|
||||
}
|
||||
Response::HistoryError { pane, generation, error } => {
|
||||
@@ -980,14 +1068,11 @@ impl TerminalApp {
|
||||
c.on_markers(&symbol, trades);
|
||||
}
|
||||
}
|
||||
Response::Quotes(q) => {
|
||||
if q.len() > 20 {
|
||||
Response::Quotes { asked, quotes } => {
|
||||
if quotes.len() > 20 {
|
||||
self.wei_updated = Some(format!("updated {}", chrono::Local::now().format("%H:%M:%S")));
|
||||
}
|
||||
let keys: Vec<String> = q.iter().map(|x| x.symbol.clone()).collect();
|
||||
for x in q {
|
||||
self.quotes.insert(x.symbol.clone(), x);
|
||||
}
|
||||
let keys = merge_quotes(&mut self.quotes, &asked, quotes);
|
||||
self.demo_on_quotes(&keys);
|
||||
}
|
||||
Response::Latest(t) => self.latest = t,
|
||||
@@ -1040,12 +1125,15 @@ impl TerminalApp {
|
||||
}
|
||||
}
|
||||
}
|
||||
Response::OrderBook { pane, symbol, book } => {
|
||||
Response::OrderBook { pane, symbol, ask, book } => {
|
||||
if let Some(p) = self.panes.iter_mut().find(|p| p.id == pane)
|
||||
&& let PaneKind::OrderBook(b) = &mut p.kind
|
||||
&& b.symbol == symbol
|
||||
{
|
||||
if ask == b.asked {
|
||||
b.pending = false;
|
||||
}
|
||||
match book {
|
||||
_ if b.symbol != symbol => {}
|
||||
Ok(book) => {
|
||||
b.book = Some(book);
|
||||
b.error = None;
|
||||
@@ -1054,6 +1142,15 @@ impl TerminalApp {
|
||||
}
|
||||
}
|
||||
}
|
||||
Response::Trades { pane, symbol, trades } => {
|
||||
if let Some(p) = self.panes.iter_mut().find(|p| p.id == pane)
|
||||
&& let PaneKind::OrderBook(b) = &mut p.kind
|
||||
&& b.symbol == symbol
|
||||
{
|
||||
// A failed fetch clears the list rather than leave old trades up.
|
||||
b.trades = trades.unwrap_or_default();
|
||||
}
|
||||
}
|
||||
Response::Portfolio(result) => {
|
||||
let result = self.keep_simulated_trades(result);
|
||||
self.on_portfolio(result)
|
||||
@@ -1073,7 +1170,19 @@ impl TerminalApp {
|
||||
Response::Status(s) => self.status = s,
|
||||
Response::Curves(c) => self.curves = Some(c),
|
||||
Response::SearchHits { query, hits } => self.net_hits = (query, hits),
|
||||
Response::PerpMarkets(result) => {
|
||||
let ok = result.is_ok();
|
||||
match result {
|
||||
Ok(perps) => self.live_perps = Some(perps),
|
||||
// Keep what the server said last; links to perps it listed stay usable.
|
||||
Err(e) => tracing::debug!("live perps: {e}"),
|
||||
}
|
||||
if let Some((_, answered)) = &mut self.last_perps {
|
||||
*answered = ok;
|
||||
}
|
||||
}
|
||||
Response::Trade(r) => self.on_trade(*r),
|
||||
Response::Hl(r) => self.on_hl(*r),
|
||||
Response::CongressRefreshed(summary) => {
|
||||
self.congress_status = summary.to_string();
|
||||
self.congress_checked = Some(chrono::Local::now());
|
||||
@@ -1086,6 +1195,9 @@ impl TerminalApp {
|
||||
|
||||
/// Freshness line for the Congress feed: last check, newest filing, next check.
|
||||
fn congress_freshness(&self) -> String {
|
||||
if cfg!(target_arch = "wasm32") {
|
||||
return "desktop app only for now".into();
|
||||
}
|
||||
let newest = self.latest.iter().map(|t| t.filed_date).max();
|
||||
let minutes = std::env::var("TR_CONGRESS_REFRESH_MIN")
|
||||
.ok()
|
||||
@@ -1273,6 +1385,8 @@ impl TerminalApp {
|
||||
| A::ResetView
|
||||
| A::LogScale
|
||||
| A::Magnet => self.focused_is_chart(),
|
||||
// Otherwise ⇧P types a P into the command line.
|
||||
A::TogglePerp => self.focused_symbol().is_some_and(|s| self.switch_target(&s).is_some()),
|
||||
a => a.layout_slot().is_none_or(|i| i < self.layout_names.len()),
|
||||
}
|
||||
}
|
||||
@@ -1292,6 +1406,7 @@ impl TerminalApp {
|
||||
A::Orders => self.apply(Function::Portfolio(command::PortView::Orders, String::new())),
|
||||
A::Buy => self.quick_trade(true),
|
||||
A::Sell => self.quick_trade(false),
|
||||
A::TogglePerp => self.apply(Function::Perp),
|
||||
A::NextPane => self.cycle_focus(1),
|
||||
A::PrevPane => self.cycle_focus(-1),
|
||||
A::ToggleSidebar => self.sidebar_open = !self.sidebar_open,
|
||||
@@ -1343,13 +1458,20 @@ impl TerminalApp {
|
||||
self.worker.send(Request::Quotes(markets_view::overview_keys()));
|
||||
self.last_wei = Some(Instant::now());
|
||||
}
|
||||
// Which perps are live: every 5 minutes, or every minute while the server isn't answering.
|
||||
let every = |ok: bool| Duration::from_secs(if ok { 300 } else { 60 });
|
||||
if self.last_perps.is_none_or(|(t, ok)| t.elapsed() > every(ok)) {
|
||||
self.worker.send(Request::PerpMarkets);
|
||||
self.last_perps = Some((Instant::now(), self.last_perps.is_none_or(|(_, ok)| ok)));
|
||||
}
|
||||
if shown(&|k| matches!(k, PaneKind::Curve))
|
||||
&& self.last_curve.is_none_or(|t| t.elapsed() > Duration::from_secs(600))
|
||||
{
|
||||
self.worker.send(Request::YieldCurves);
|
||||
self.last_curve = Some(Instant::now());
|
||||
}
|
||||
// News every 5 minutes and order books every second, while visible.
|
||||
// News every 5 minutes and order books about twice a second, while visible. The Terminal
|
||||
// server's books change about every 0.5 s; 400 ms puts a poll on every 500 ms repaint tick.
|
||||
for &i in &visible {
|
||||
let p = &mut self.panes[i];
|
||||
match &mut p.kind {
|
||||
@@ -1365,13 +1487,24 @@ impl TerminalApp {
|
||||
expiration: o.expiration,
|
||||
});
|
||||
}
|
||||
PaneKind::OrderBook(b) if b.fetched.is_none_or(|t| t.elapsed() > Duration::from_millis(1000)) => {
|
||||
PaneKind::OrderBook(b) if b.due() => {
|
||||
b.fetched = Some(Instant::now());
|
||||
self.worker.send(Request::OrderBook { pane: p.id, symbol: b.symbol.clone(), depth: b.depth });
|
||||
b.pending = true;
|
||||
b.asked += 1;
|
||||
self.worker.send(Request::OrderBook {
|
||||
pane: p.id,
|
||||
symbol: b.symbol.clone(),
|
||||
depth: b.depth,
|
||||
ask: b.asked,
|
||||
});
|
||||
if b.served_by_server() {
|
||||
self.worker.send(Request::Trades { pane: p.id, symbol: b.symbol.clone() });
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
self.hl_periodic();
|
||||
self.broker_periodic(&visible);
|
||||
if let Some((q, at)) = &self.search_pending
|
||||
&& at.elapsed() > Duration::from_millis(300)
|
||||
@@ -1384,7 +1517,9 @@ impl TerminalApp {
|
||||
self.last_feed = Some(Instant::now());
|
||||
}
|
||||
self.save_dirty_themes(false);
|
||||
if self.last_save.elapsed() > Duration::from_secs(10) {
|
||||
// A browser tab can close without an exit callback, so save sooner there.
|
||||
let every = Duration::from_secs(if cfg!(target_arch = "wasm32") { 2 } else { 10 });
|
||||
if self.last_save.elapsed() > every {
|
||||
self.autosave();
|
||||
self.last_save = Instant::now();
|
||||
}
|
||||
@@ -1560,6 +1695,13 @@ impl eframe::App for TerminalApp {
|
||||
}
|
||||
let cpu = frame_start.elapsed().as_secs_f64() * 1e3;
|
||||
self.perf_cpu_ms = self.perf_cpu_ms * 0.9 + cpu * 0.1;
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
crate::web::record_frame(&crate::web::FrameStats {
|
||||
ui_ms: self.perf_cpu_ms,
|
||||
interval_ms: self.perf_dt_ms,
|
||||
last_cpu_ms: frame.info().cpu_usage.map_or(0.0, |s| f64::from(s) * 1e3),
|
||||
frames: ctx.cumulative_frame_nr(),
|
||||
});
|
||||
if self.show_perf {
|
||||
// Measure continuously while the readout is visible.
|
||||
ctx.request_repaint();
|
||||
@@ -1583,6 +1725,17 @@ impl eframe::App for TerminalApp {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn a_book_pane_asks_again_once_answered() {
|
||||
let ago = |ms| Instant::now().checked_sub(Duration::from_millis(ms));
|
||||
let pane = |fetched, pending| BookPane { fetched, pending, ..BookPane::new("BTCUSD Curncy", 20, 0.0) };
|
||||
assert!(pane(None, false).due());
|
||||
assert!(!pane(ago(0), false).due());
|
||||
assert!(pane(ago(450), false).due(), "a poll on every 500 ms repaint tick");
|
||||
assert!(!pane(ago(450), true).due(), "one request out at a time");
|
||||
assert!(pane(ago(11_000), true).due(), "a lost answer doesn't stop the pane");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_settings_migrate_to_a_layout() {
|
||||
let old = r#"{"layout":"MainRight2","watchlist":["AAPL","BTCUSD Curncy"],"sidebar":false,
|
||||
@@ -1601,6 +1754,25 @@ mod tests {
|
||||
assert_eq!(new.active_layout.as_deref(), Some("Desk"));
|
||||
}
|
||||
|
||||
/// When the server can't quote a perp, the watchlist drops its old price instead of showing
|
||||
/// it as current. Listed instruments keep theirs, as before.
|
||||
#[test]
|
||||
fn perps_left_out_of_an_answer_lose_their_old_quote() {
|
||||
let q = |s: &str, last: f64| Quote { symbol: s.into(), last, prev_close: last, ts: 0, spark: vec![] };
|
||||
let mut shown: HashMap<String, Quote> =
|
||||
[q("xyz:AAPL", 336.0), q("xyz:GOLD", 3958.0), q("AAPL US Equity", 336.5)]
|
||||
.map(|x| (x.symbol.clone(), x))
|
||||
.into();
|
||||
let asked: Vec<String> = ["xyz:AAPL", "xyz:GOLD", "AAPL US Equity"].map(String::from).to_vec();
|
||||
let got = merge_quotes(&mut shown, &asked, vec![q("xyz:GOLD", 3960.0)]);
|
||||
assert_eq!(got, ["xyz:GOLD"]);
|
||||
assert!(!shown.contains_key("xyz:AAPL"), "the perp the server didn't quote");
|
||||
assert_eq!(shown["xyz:GOLD"].last, 3960.0);
|
||||
assert_eq!(shown["AAPL US Equity"].last, 336.5, "a listed instrument keeps its last quote");
|
||||
merge_quotes(&mut shown, &["xyz:SP500".into()], vec![]);
|
||||
assert!(shown.contains_key("xyz:GOLD"), "a perp nobody asked about this time stays");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pane_settings_round_trip_through_kinds() {
|
||||
let d = prefs::PanelDefaults::default();
|
||||
|
||||
+89
-16
@@ -3,9 +3,10 @@
|
||||
//! refresh scheduling and the `PORT`/`ACCT`/`POS`/`ORD`/`ACTV` commands. Order entry is in
|
||||
//! `trading`.
|
||||
|
||||
use std::time::{Duration, Instant};
|
||||
use std::time::Duration;
|
||||
|
||||
use tr_broker::{Credentials, Portfolio, Position, SecretStore};
|
||||
use web_time::Instant;
|
||||
|
||||
use super::{PaneKind, TerminalApp};
|
||||
use crate::command::PortView;
|
||||
@@ -100,9 +101,15 @@ pub struct BrokerState {
|
||||
|
||||
impl BrokerState {
|
||||
pub fn load(offline: bool) -> Self {
|
||||
let store = SecretStore::platform(&data_dir());
|
||||
Self::with_store(SecretStore::platform(&data_dir()), offline)
|
||||
}
|
||||
|
||||
/// [`Self::load`] with credentials from `store` instead of the platform's.
|
||||
pub fn with_store(store: SecretStore, offline: bool) -> Self {
|
||||
let mut message = None;
|
||||
let stored = match store.load() {
|
||||
// The browser build can't reach SnapTrade, and keys don't belong in page storage.
|
||||
let stored = if cfg!(target_arch = "wasm32") { Ok(None) } else { store.load() };
|
||||
let stored = match stored {
|
||||
Ok(c) => c.unwrap_or_default(),
|
||||
Err(e) => {
|
||||
tracing::warn!("SnapTrade credentials unavailable: {e:#}");
|
||||
@@ -112,7 +119,9 @@ impl BrokerState {
|
||||
};
|
||||
let mut creds = stored.clone();
|
||||
let from_env = creds.apply_env(|k| std::env::var(k).ok());
|
||||
let simulate = offline || std::env::var("TR_BROKER").is_ok_and(|v| v.eq_ignore_ascii_case("sample"));
|
||||
let simulate = offline
|
||||
|| cfg!(target_arch = "wasm32")
|
||||
|| std::env::var("TR_BROKER").is_ok_and(|v| v.eq_ignore_ascii_case("sample"));
|
||||
Self {
|
||||
store,
|
||||
saved: stored,
|
||||
@@ -145,18 +154,31 @@ impl BrokerState {
|
||||
BrokerMode::Setup
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The terminal's own quote for a position, when it maps onto a market key (options are
|
||||
/// never repriced from their underlying).
|
||||
pub fn live_price(&self, quotes: &std::collections::HashMap<String, tr_data::Quote>, p: &Position) -> Option<f64> {
|
||||
if p.kind == tr_broker::InstrumentKind::Option {
|
||||
return None;
|
||||
}
|
||||
quotes.get(&p.market_key()?).map(|q| q.last).filter(|x| x.is_finite() && *x > 0.0)
|
||||
/// The terminal's own price for a position, from the network the position is on: testnet
|
||||
/// mids for Hyperliquid testnet rows, the Terminal server's (mainnet) quotes for everything
|
||||
/// else. Both use the same market keys (`xyz:AAPL`), so a row never falls back to the other
|
||||
/// map. Options are never repriced from their underlying.
|
||||
pub fn live_price(
|
||||
quotes: &std::collections::HashMap<String, tr_data::Quote>,
|
||||
testnet: &std::collections::HashMap<String, f64>,
|
||||
p: &Position,
|
||||
) -> Option<f64> {
|
||||
if p.kind == tr_broker::InstrumentKind::Option {
|
||||
return None;
|
||||
}
|
||||
let key = p.market_key()?;
|
||||
let px = if super::hl::is_testnet_account(&p.account_id) {
|
||||
testnet.get(&key).copied()
|
||||
} else {
|
||||
quotes.get(&key).map(|q| q.last)
|
||||
};
|
||||
px.filter(|x| x.is_finite() && *x > 0.0)
|
||||
}
|
||||
|
||||
/// Open a URL or path with the system handler.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub fn open_external(target: &str) -> std::io::Result<()> {
|
||||
let opener = if cfg!(target_os = "macos") {
|
||||
"open"
|
||||
@@ -168,11 +190,23 @@ pub fn open_external(target: &str) -> std::io::Result<()> {
|
||||
std::process::Command::new(opener).arg(target).spawn().map(|_| ())
|
||||
}
|
||||
|
||||
/// Open a URL in a new browser tab.
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
pub fn open_external(target: &str) -> std::io::Result<()> {
|
||||
let window = web_sys::window().ok_or_else(|| std::io::Error::other("no browser window"))?;
|
||||
window
|
||||
.open_with_url_and_target_and_features(target, "_blank", "noopener")
|
||||
.map(|_| ())
|
||||
.map_err(|_| std::io::Error::other("the browser blocked the new tab"))
|
||||
}
|
||||
|
||||
/// A stable, non-identifying SnapTrade user id for this installation.
|
||||
pub fn new_user_id() -> String {
|
||||
let nanos = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map_or(0, |d| d.as_nanos());
|
||||
let mix = nanos ^ (u128::from(std::process::id()) << 64);
|
||||
format!("terminal-rs-{:016x}", (mix as u64) ^ ((mix >> 64) as u64))
|
||||
let nanos = web_time::SystemTime::now().duration_since(web_time::UNIX_EPOCH).map_or(0, |d| d.as_nanos());
|
||||
// Browsers have no process ids.
|
||||
let pid = if cfg!(target_arch = "wasm32") { 0 } else { std::process::id() };
|
||||
let mix = nanos ^ (u128::from(pid) << 64);
|
||||
format!("terminal-{:016x}", (mix as u64) ^ ((mix >> 64) as u64))
|
||||
}
|
||||
|
||||
impl TerminalApp {
|
||||
@@ -207,10 +241,13 @@ impl TerminalApp {
|
||||
if let Some(pf) = self.shown_portfolio()
|
||||
&& self.broker.last_quotes.is_none_or(|t| t.elapsed() > Duration::from_secs(20))
|
||||
{
|
||||
// Testnet rows price from testnet mids (the Hyperliquid stream), never the server.
|
||||
let mut keys: Vec<String> = pf
|
||||
.positions
|
||||
.iter()
|
||||
.filter(|p| p.kind != tr_broker::InstrumentKind::Option)
|
||||
.filter(|p| {
|
||||
p.kind != tr_broker::InstrumentKind::Option && !super::hl::is_testnet_account(&p.account_id)
|
||||
})
|
||||
.filter_map(Position::market_key)
|
||||
.collect();
|
||||
keys.sort();
|
||||
@@ -784,10 +821,46 @@ mod tests {
|
||||
let a = new_user_id();
|
||||
std::thread::sleep(Duration::from_millis(1));
|
||||
let b = new_user_id();
|
||||
assert!(a.starts_with("terminal-rs-") && a.len() == "terminal-rs-".len() + 16);
|
||||
assert!(a.starts_with("terminal-") && a.len() == "terminal-".len() + 16);
|
||||
assert_ne!(a, b);
|
||||
}
|
||||
|
||||
/// A Hyperliquid testnet row and a mainnet row on the same key (`xyz:AAPL`) with mainnet
|
||||
/// and testnet updates arriving interleaved: each reads only its own network's price.
|
||||
#[test]
|
||||
fn testnet_rows_never_read_mainnet_quotes() {
|
||||
let quote =
|
||||
|last: f64| tr_data::Quote { symbol: "xyz:AAPL".into(), last, prev_close: last, ts: 0, spark: Vec::new() };
|
||||
let row = |account: &str| Position {
|
||||
account_id: account.into(),
|
||||
symbol: "xyz:AAPL".into(),
|
||||
description: String::new(),
|
||||
kind: tr_broker::InstrumentKind::Future,
|
||||
units: 1.0,
|
||||
price: None,
|
||||
cost_basis: Some(300.0),
|
||||
multiplier: 1.0,
|
||||
currency: "USD".into(),
|
||||
cash_equivalent: false,
|
||||
underlying: None,
|
||||
market: Some("xyz:AAPL".into()),
|
||||
};
|
||||
let testnet = row("hl:testnet:0x00000000000000000000000000000000000f107e");
|
||||
let mainnet = row("mainnet-account");
|
||||
let mut quotes = std::collections::HashMap::new();
|
||||
let mut mids = std::collections::HashMap::new();
|
||||
for (main, test) in [(230.0, 322.0), (231.0, 321.5), (229.5, 323.0)] {
|
||||
quotes.insert("xyz:AAPL".to_string(), quote(main));
|
||||
assert_eq!(live_price("es, &mids, &testnet).is_some(), mids.contains_key("xyz:AAPL"));
|
||||
mids.insert("xyz:AAPL".to_string(), test);
|
||||
assert_eq!(live_price("es, &mids, &testnet), Some(test), "the testnet row reads the testnet mid");
|
||||
assert_eq!(live_price("es, &mids, &mainnet), Some(main), "the mainnet row reads the gateway quote");
|
||||
}
|
||||
// With only the other network's price, neither row borrows it.
|
||||
assert_eq!(live_price("es, &Default::default(), &testnet), None);
|
||||
assert_eq!(live_price(&Default::default(), &mids, &mainnet), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pane_titles() {
|
||||
assert_eq!(PortfolioPane::new(PortView::Summary).title(), "Portfolio");
|
||||
|
||||
+97
-14
@@ -1,13 +1,14 @@
|
||||
//! Window chrome in the style of a SwiftUI macOS app: a unified toolbar holding the
|
||||
//! command line, a translucent (vibrancy) sidebar, rounded pane cards and a status bar.
|
||||
|
||||
use std::time::{Duration, Instant};
|
||||
use std::time::Duration;
|
||||
|
||||
use egui::{
|
||||
Align2, Color32, CornerRadius, CursorIcon, Id, Key, Modifiers, Rect, RichText, Sense, Shadow, Stroke, StrokeKind,
|
||||
Ui, UiBuilder, ViewportCommand, pos2, vec2,
|
||||
};
|
||||
use tr_data::SearchHit;
|
||||
use web_time::Instant;
|
||||
|
||||
use super::{PaneKind, TerminalApp};
|
||||
use crate::anim;
|
||||
@@ -101,13 +102,13 @@ impl TerminalApp {
|
||||
resp.context_menu(|ui| self.view_menu(ui));
|
||||
|
||||
// Data source chip (the window title itself is redundant in a compact toolbar).
|
||||
let live = !self.provider.eq_ignore_ascii_case("sample");
|
||||
let chip_text = p.layout_no_wrap(if live { "Live" } else { "Offline" }.into(), ui_font(11.0), muted());
|
||||
let (label, live) = source_chip(&self.provider);
|
||||
let chip_text = p.layout_no_wrap(label.into(), ui_font(11.0), muted());
|
||||
let chip = Rect::from_min_size(pos2(x, cy - 9.0), vec2(chip_text.size().x + 20.0, 18.0));
|
||||
p.rect_filled(chip, 9.0, theme::control());
|
||||
p.circle_filled(pos2(chip.left() + 8.0, cy), 3.0, if live { theme::up() } else { amber() });
|
||||
p.galley(pos2(chip.left() + 14.0, cy - chip_text.size().y / 2.0), chip_text, muted());
|
||||
ui.interact(chip, Id::new("source-chip"), Sense::hover()).on_hover_text(self.provider_label());
|
||||
ui.interact(chip, Id::new("source-chip"), Sense::hover()).on_hover_text(provider_label(&self.provider));
|
||||
x = chip.right() + 8.0;
|
||||
|
||||
// Which account trades: DEMO (paper) or LIVE (real money, in red). Click to switch.
|
||||
@@ -133,6 +134,17 @@ impl TerminalApp {
|
||||
p.hline(rect.x_range(), rect.bottom() - 1.0, Stroke::new(2.0, theme::down()));
|
||||
}
|
||||
x = badge.right() + 10.0;
|
||||
// A Hyperliquid account in LIVE is on testnet: say so next to the badge.
|
||||
if live_mode && self.hl_master().is_some() {
|
||||
let t = p.layout_no_wrap("TESTNET".into(), mono(11.0), Color32::BLACK);
|
||||
let pill = Rect::from_min_size(pos2(x - 4.0, cy - 10.0), vec2(t.size().x + 18.0, 20.0));
|
||||
p.rect_filled(pill, 10.0, amber());
|
||||
p.galley(pill.center() - t.size() / 2.0, t, Color32::BLACK);
|
||||
ui.interact(pill, Id::new("hl-testnet-badge"), Sense::hover()).on_hover_text(
|
||||
"The Hyperliquid account in LIVE is on testnet (test funds). This build cannot reach mainnet.",
|
||||
);
|
||||
x = pill.right() + 10.0;
|
||||
}
|
||||
|
||||
// Right-hand cluster, laid out from the right edge: one-line clock (UTC on hover).
|
||||
let now = chrono::Local::now();
|
||||
@@ -281,11 +293,6 @@ impl TerminalApp {
|
||||
}
|
||||
}
|
||||
|
||||
fn provider_label(&self) -> String {
|
||||
let n = self.provider.to_lowercase();
|
||||
if n.contains("sample") { "Offline · sample data".into() } else { format!("Live · {}", self.provider) }
|
||||
}
|
||||
|
||||
/// The Bloomberg command line, drawn as a rounded macOS search field.
|
||||
fn command_field(&mut self, ui: &mut Ui, field: Rect) -> bool {
|
||||
let suggestions = self.suggestions();
|
||||
@@ -448,7 +455,7 @@ impl TerminalApp {
|
||||
return Vec::new();
|
||||
}
|
||||
let net: &[SearchHit] = if self.net_hits.0 == q { &self.net_hits.1 } else { &[] };
|
||||
let mut v = markets_view::merged_hits(q, net);
|
||||
let mut v = markets_view::merged_hits(q, net, &self.perp_candidates());
|
||||
v.truncate(8);
|
||||
v
|
||||
}
|
||||
@@ -541,7 +548,7 @@ impl TerminalApp {
|
||||
let head = Rect::from_min_size(rect.min, vec2(rect.width(), self.toolbar_h()));
|
||||
window_drag_region(ui, head, "sidebar");
|
||||
if !cfg!(target_os = "macos") {
|
||||
p.text(head.left_center() + vec2(14.0, 0.0), Align2::LEFT_CENTER, "TERMINAL-RS", mono(12.5), amber());
|
||||
p.text(head.left_center() + vec2(14.0, 0.0), Align2::LEFT_CENTER, "TERMINAL", mono(12.5), amber());
|
||||
}
|
||||
|
||||
let body = Rect::from_min_max(pos2(rect.left(), head.bottom()), rect.max);
|
||||
@@ -559,7 +566,9 @@ impl TerminalApp {
|
||||
let resp = row_frame(ui, false);
|
||||
let r = resp.rect;
|
||||
let pt = ui.painter();
|
||||
pt.text(pos2(r.left() + 8.0, r.center().y), Align2::LEFT_CENTER, &sec.ticker, mono(12.0), text());
|
||||
let perp = sec.class == tr_core::AssetClass::Perp;
|
||||
let tc = if perp { theme::perp() } else { text() };
|
||||
pt.text(pos2(r.left() + 8.0, r.center().y), Align2::LEFT_CENTER, &sec.ticker, mono(12.0), tc);
|
||||
if let Some(q) = self.quotes.get(key) {
|
||||
let up = q.change() >= 0.0;
|
||||
let c = if up { theme::up() } else { theme::down() };
|
||||
@@ -587,7 +596,15 @@ impl TerminalApp {
|
||||
if resp.clicked() {
|
||||
out.push(SidebarCmd::Action(Action::OpenChart(key.clone())));
|
||||
}
|
||||
let resp = resp.on_hover_text(format!("{} — {}", sec.key(), sec.name().unwrap_or(&sec.ticker)));
|
||||
let resp = resp.on_hover_text(match perp {
|
||||
true => format!(
|
||||
"{} — {} · {}",
|
||||
sec.key(),
|
||||
sec.name().unwrap_or("HIP-3 perp"),
|
||||
tr_core::perps::badge(key)
|
||||
),
|
||||
false => format!("{} — {}", sec.key(), sec.name().unwrap_or(&sec.ticker)),
|
||||
});
|
||||
resp.context_menu(|ui| {
|
||||
if ui.button("Open Chart").clicked() {
|
||||
out.push(SidebarCmd::Action(Action::OpenChart(key.clone())));
|
||||
@@ -647,7 +664,7 @@ impl TerminalApp {
|
||||
});
|
||||
let pf = self.shown_portfolio();
|
||||
let value_badge = pf.filter(|p| !p.accounts.is_empty()).map(|p| {
|
||||
let t = p.totals(None, |pos| self.broker.live_price(&self.quotes, pos));
|
||||
let t = p.totals(None, |pos| super::broker::live_price(&self.quotes, &self.hl.mids, pos));
|
||||
compact_money(t.value)
|
||||
});
|
||||
let open_orders = pf.map_or(0, |p| p.orders.iter().filter(|o| o.is_open()).count());
|
||||
@@ -968,3 +985,69 @@ fn nav_row(ui: &mut Ui, icon: Icon, label: &str, trailing: Option<&str>, selecte
|
||||
}
|
||||
resp
|
||||
}
|
||||
|
||||
/// What the data-source chip says on hover, from the provider's name.
|
||||
/// The data-source chip's label, and whether any of the data is live.
|
||||
fn source_chip(provider: &str) -> (&'static str, bool) {
|
||||
let n = provider.to_lowercase();
|
||||
if n == "sample" {
|
||||
("Offline", false)
|
||||
} else if n.contains("sample") && n.starts_with("server+") {
|
||||
("Crypto + perps live", true)
|
||||
} else if n.contains("sample") {
|
||||
("Crypto live", true)
|
||||
} else {
|
||||
("Live", true)
|
||||
}
|
||||
}
|
||||
|
||||
fn provider_label(provider: &str) -> String {
|
||||
let n = provider.to_lowercase();
|
||||
if n == "sample" {
|
||||
"Offline · sample data".into()
|
||||
} else if let Some(rest) = provider.strip_prefix("server+") {
|
||||
// A Terminal server (tr_data::remote::RemoteMarkets), in front of the browser's sources
|
||||
// (tr_data::web::WebMarkets) or the desktop's.
|
||||
if rest.contains("sample") {
|
||||
"Live crypto from Hyperliquid perps through the Terminal server, or from Binance and Coinbase while \
|
||||
it is unreachable, and HIP-3 perps (xyz:AAPL) from the server only; every other market is sample \
|
||||
data in the browser"
|
||||
.into()
|
||||
} else {
|
||||
format!(
|
||||
"Live · crypto from Hyperliquid perps and HIP-3 perps (xyz:AAPL) through the Terminal server; other \
|
||||
markets from {rest}"
|
||||
)
|
||||
}
|
||||
} else if n.contains("sample") {
|
||||
// The browser build (tr_data::web::WebMarkets).
|
||||
"Live crypto from Binance and Coinbase; every other market is sample data in the browser".into()
|
||||
} else {
|
||||
format!("Live · {provider}")
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{provider_label, source_chip};
|
||||
|
||||
#[test]
|
||||
fn the_source_tooltip_names_the_terminal_server() {
|
||||
assert_eq!(provider_label("sample"), "Offline · sample data");
|
||||
assert!(provider_label("binance+sample").starts_with("Live crypto from Binance"));
|
||||
let web = provider_label("server+binance+sample");
|
||||
assert!(web.contains("Terminal server") && web.contains("sample data in the browser"), "{web}");
|
||||
let desktop = provider_label("server+markets");
|
||||
assert!(desktop.contains("Terminal server") && desktop.ends_with("other markets from markets"), "{desktop}");
|
||||
assert!(web.contains("HIP-3 perps") && desktop.contains("HIP-3 perps"), "{web} / {desktop}");
|
||||
}
|
||||
|
||||
/// With a server in front, the browser build has live perps as well as crypto.
|
||||
#[test]
|
||||
fn the_source_chip_says_what_is_live() {
|
||||
assert_eq!(source_chip("sample"), ("Offline", false));
|
||||
assert_eq!(source_chip("binance+sample"), ("Crypto live", true));
|
||||
assert_eq!(source_chip("server+binance+sample"), ("Crypto + perps live", true));
|
||||
assert_eq!(source_chip("server+yahoo+binance+ust"), ("Live", true));
|
||||
}
|
||||
}
|
||||
+15
-4
@@ -7,11 +7,13 @@
|
||||
//! demo change is saved at once to `demo-account.json` in the app data folder.
|
||||
|
||||
use std::path::PathBuf;
|
||||
use std::time::{Duration, Instant};
|
||||
use std::time::Duration;
|
||||
|
||||
use anyhow::Context;
|
||||
use tr_broker::demo::{DemoBook, Event, STARTING_CASH};
|
||||
use tr_broker::trading::OrderRequest;
|
||||
use tr_broker::{InstrumentKind, Order, Portfolio};
|
||||
use web_time::Instant;
|
||||
|
||||
use super::TerminalApp;
|
||||
use crate::prefs::AccountMode;
|
||||
@@ -49,11 +51,16 @@ impl DemoState {
|
||||
/// account starts; the returned message says so.
|
||||
pub fn load(path: PathBuf) -> (Self, Option<String>) {
|
||||
let now = chrono::Utc::now();
|
||||
let (book, note) = match DemoBook::load(&path, now) {
|
||||
let saved = match crate::storage::read(&path) {
|
||||
Ok(bytes) => DemoBook::from_json(&bytes),
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(DemoBook::new(now)),
|
||||
Err(e) => Err(anyhow::Error::new(e).context("reading the demo account")),
|
||||
};
|
||||
let (book, note) = match saved {
|
||||
Ok(b) => (b, None),
|
||||
Err(e) => {
|
||||
let aside = path.with_extension(format!("damaged-{}.json", now.format("%Y%m%d%H%M%S")));
|
||||
let moved = std::fs::rename(&path, &aside).is_ok();
|
||||
let moved = crate::storage::rename(&path, &aside).is_ok();
|
||||
tracing::warn!("demo account: {e:#}");
|
||||
let note = if moved {
|
||||
format!(
|
||||
@@ -91,7 +98,11 @@ impl DemoState {
|
||||
/// Save now. Returns whether the book is on disk.
|
||||
pub fn save(&mut self) -> bool {
|
||||
self.last_save_try = Some(Instant::now());
|
||||
match self.book.save(&self.path) {
|
||||
let saved = self
|
||||
.book
|
||||
.to_json()
|
||||
.and_then(|json| crate::storage::write_atomic(&self.path, &json).context("saving the demo account"));
|
||||
match saved {
|
||||
Ok(()) => {
|
||||
self.save_error = None;
|
||||
true
|
||||
|
||||
+1426
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,810 @@
|
||||
//! Hyperliquid's windows: the ticket body for a Hyperliquid account, the confirmations,
|
||||
//! the enable dialog and Settings › Trading › Hyperliquid. Every one shows the amber
|
||||
//! TESTNET badge.
|
||||
|
||||
use egui::{Color32, RichText, Ui};
|
||||
use tr_broker::trading::Side;
|
||||
use tr_hl::actions::Tif;
|
||||
use tr_hl::caps::{MAX_LEVERAGE, MAX_MARKET_SLIPPAGE, MAX_ORDER_NOTIONAL_USD};
|
||||
use tr_hl::rounding::to_wire;
|
||||
use tr_hl::trader::{EXPIRES_AFTER_MS, OrderKind};
|
||||
|
||||
use super::TerminalApp;
|
||||
use super::hl::{HlConfirm, HlKind, intent, is_hl_account, market_edited, resolve_coin, settle_size};
|
||||
use super::portfolio_view::{money, price, qty};
|
||||
use super::settings_view::SettingsCmd;
|
||||
use super::trading::TicketAct;
|
||||
use crate::prefs::AccountMode;
|
||||
use crate::theme::{self, amber, dp, mono, muted, text, ui_font};
|
||||
use crate::widgets;
|
||||
|
||||
/// The amber TESTNET pill.
|
||||
pub fn testnet_badge(ui: &mut Ui) -> egui::Response {
|
||||
ui.add(
|
||||
egui::Button::new(RichText::new("TESTNET").font(mono(11.0)).strong().color(Color32::BLACK))
|
||||
.fill(amber())
|
||||
.corner_radius(9.0)
|
||||
.sense(egui::Sense::hover()),
|
||||
)
|
||||
.on_hover_text("Hyperliquid testnet: test funds only. This build cannot reach mainnet.")
|
||||
}
|
||||
|
||||
pub(super) fn masked(addr: &str) -> String {
|
||||
tr_hl::key::parse_address(addr).map(|a| tr_hl::mask_address(&a)).unwrap_or_else(|_| addr.to_string())
|
||||
}
|
||||
|
||||
const HIP3_RISK: &str = "A leveraged perpetual future with funding, priced by the deployer's oracle. It can diverge from the \
|
||||
listed stock (off-hours, opens, earnings, corporate actions), and the deployer can halt the market and settle it at mark.";
|
||||
|
||||
impl TerminalApp {
|
||||
/// The ticket's body when a Hyperliquid account is selected.
|
||||
pub(super) fn hl_ticket_ui(&mut self, ui: &mut Ui) -> Option<TicketAct> {
|
||||
let accounts = self.trade_accounts_pub();
|
||||
let blocker = self.hl_blocker();
|
||||
let reg = self.hl.registry.clone();
|
||||
let sending = self.hl.sending.is_some();
|
||||
let coin = self
|
||||
.trading
|
||||
.ticket
|
||||
.as_ref()
|
||||
.and_then(|t| reg.as_ref().map(|r| resolve_coin(&t.symbol, r)))
|
||||
.unwrap_or_else(|| Err("Loading Hyperliquid testnet markets…".into()));
|
||||
let ctx = coin.as_ref().map(|c| self.hl.ctx(c)).unwrap_or_default();
|
||||
let asset = coin.as_ref().ok().and_then(|c| reg.as_ref()?.get(c).cloned());
|
||||
let t = self.trading.ticket.as_mut()?;
|
||||
let mut act = None;
|
||||
let mut review = false;
|
||||
let mut leverage = None;
|
||||
let mut settings = false;
|
||||
ui.spacing_mut().item_spacing.y = dp(6.0);
|
||||
|
||||
ui.horizontal(|ui| {
|
||||
if let Some(i) = widgets::segmented(ui, "ticket-mode", &["DEMO", "LIVE"], Some(1), 22.0)
|
||||
&& i == 0
|
||||
&& !sending
|
||||
{
|
||||
act = Some(TicketAct::Mode(AccountMode::Demo));
|
||||
}
|
||||
testnet_badge(ui);
|
||||
ui.label(RichText::new("Hyperliquid · test funds only").font(ui_font(12.0)).color(amber()));
|
||||
});
|
||||
let current = t
|
||||
.account
|
||||
.as_ref()
|
||||
.and_then(|id| accounts.iter().find(|a| &a.id == id))
|
||||
.map_or("Choose an account…".to_string(), |a| format!("{} {}", a.label(), a.masked_number()));
|
||||
ui.add_enabled_ui(!sending, |ui| {
|
||||
egui::ComboBox::from_id_salt("hl-ticket-account")
|
||||
.width(ui.available_width())
|
||||
.selected_text(current)
|
||||
.show_ui(ui, |ui| {
|
||||
for a in &accounts {
|
||||
let label = if is_hl_account(&a.id) {
|
||||
format!("{} {} · TESTNET", a.label(), a.masked_number())
|
||||
} else {
|
||||
format!("{} · {} {}", a.label(), a.institution, a.masked_number())
|
||||
};
|
||||
ui.selectable_value(&mut t.account, Some(a.id.clone()), label);
|
||||
}
|
||||
});
|
||||
});
|
||||
if let Some(b) = &blocker {
|
||||
ui.horizontal_wrapped(|ui| {
|
||||
ui.label(RichText::new(b).font(ui_font(11.5)).color(amber()));
|
||||
if ui.small_button("Settings…").clicked() {
|
||||
settings = true;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
ui.horizontal(|ui| {
|
||||
let w = (ui.available_width() - dp(6.0)) / 2.0;
|
||||
for (side, color) in [(Side::Buy, theme::up()), (Side::Sell, theme::down())] {
|
||||
let b = if t.side == side {
|
||||
egui::Button::new(RichText::new(side.label()).strong().color(Color32::WHITE)).fill(color)
|
||||
} else {
|
||||
egui::Button::new(RichText::new(side.label()).color(color))
|
||||
};
|
||||
if ui.add_enabled(!sending, b.min_size(egui::vec2(w, dp(26.0)))).clicked() {
|
||||
t.side = side;
|
||||
}
|
||||
}
|
||||
});
|
||||
let label = |ui: &mut Ui, s: &str| {
|
||||
ui.add_sized([dp(64.0), dp(20.0)], egui::Label::new(RichText::new(s).font(ui_font(12.0)).color(muted())));
|
||||
};
|
||||
ui.horizontal(|ui| {
|
||||
label(ui, "Market");
|
||||
let r = ui.add_enabled(
|
||||
!sending,
|
||||
egui::TextEdit::singleline(&mut t.symbol)
|
||||
.hint_text("BTC, xyz:AAPL")
|
||||
.font(mono(13.0))
|
||||
.desired_width(dp(96.0)),
|
||||
);
|
||||
if r.changed() {
|
||||
t.error = None;
|
||||
market_edited(t);
|
||||
}
|
||||
match ctx.mid {
|
||||
Some(m) => {
|
||||
ui.label(RichText::new(price(m)).font(mono(12.5)).color(text()))
|
||||
.on_hover_text("Hyperliquid testnet mid (not the Terminal server's mainnet quote)");
|
||||
}
|
||||
None if coin.is_ok() => {
|
||||
ui.label(RichText::new("testnet mid…").font(ui_font(11.5)).color(muted()));
|
||||
}
|
||||
None => {}
|
||||
}
|
||||
});
|
||||
match (&coin, &asset) {
|
||||
(Ok(c), Some(a)) => {
|
||||
let kind = if a.is_hip3() { "perpetual, not a share" } else { "perpetual" };
|
||||
ui.horizontal(|ui| {
|
||||
ui.add_space(dp(70.0));
|
||||
ui.label(
|
||||
RichText::new(format!("{} ({kind})", a.label())).font(mono(11.5)).strong().color(theme::cyan()),
|
||||
)
|
||||
.on_hover_text(format!("{c}: asset {} on Hyperliquid testnet", a.id));
|
||||
});
|
||||
}
|
||||
(Err(e), _) if !t.symbol.trim().is_empty() => {
|
||||
ui.label(RichText::new(e).font(ui_font(11.5)).color(amber()));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
let mut unsized_note = None;
|
||||
ui.horizontal(|ui| {
|
||||
label(ui, if t.dollars { "Amount" } else { "Size" });
|
||||
let r = ui.add_enabled(
|
||||
!sending,
|
||||
egui::TextEdit::singleline(&mut t.qty)
|
||||
.hint_text(if t.dollars { "$" } else { "units" })
|
||||
.font(mono(13.0))
|
||||
.desired_width(dp(84.0)),
|
||||
);
|
||||
if r.changed() {
|
||||
t.hl.from_dollars = None;
|
||||
}
|
||||
if t.focus_qty > 0 {
|
||||
t.focus_qty -= 1;
|
||||
if !r.has_focus() {
|
||||
r.request_focus();
|
||||
}
|
||||
}
|
||||
// `BUY xyz:AAPL $50` gets its size from the testnet mid. It is never 50 contracts.
|
||||
unsized_note = reg.as_ref().and_then(|r| settle_size(t, r, ctx.mid, asset.as_ref()));
|
||||
if let (false, Some(m), Ok(q)) = (t.dollars, ctx.mid, t.qty.trim().parse::<f64>()) {
|
||||
let from = t.hl.from_dollars.map_or(String::new(), |d| format!(" (from ${d}, at the testnet mid)"));
|
||||
ui.label(RichText::new(format!("≈ {}{from}", money(q * m, "USD"))).font(mono(11.5)).color(muted()));
|
||||
}
|
||||
});
|
||||
if let Some(n) = unsized_note {
|
||||
ui.horizontal_wrapped(|ui| {
|
||||
ui.add_space(dp(70.0));
|
||||
ui.label(RichText::new(n).font(ui_font(11.5)).color(amber()));
|
||||
});
|
||||
}
|
||||
ui.horizontal(|ui| {
|
||||
label(ui, "Type");
|
||||
let labels: Vec<&str> = HlKind::ALL.iter().map(|k| k.label()).collect();
|
||||
let sel = HlKind::ALL.iter().position(|k| *k == t.hl.kind);
|
||||
if let Some(i) = widgets::segmented(ui, "hl-ticket-type", &labels, sel, 20.0)
|
||||
&& !sending
|
||||
{
|
||||
t.hl.kind = HlKind::ALL[i];
|
||||
if t.hl.kind == HlKind::Limit
|
||||
&& t.limit.trim().is_empty()
|
||||
&& let Some(m) = ctx.mid
|
||||
{
|
||||
t.limit = to_wire(tr_hl::rounding::round_price(
|
||||
m,
|
||||
asset.as_ref().map_or(0, |a| a.sz_decimals),
|
||||
tr_hl::rounding::Round::Nearest,
|
||||
))
|
||||
.unwrap_or_default();
|
||||
}
|
||||
}
|
||||
});
|
||||
match t.hl.kind {
|
||||
HlKind::Limit => {
|
||||
ui.horizontal(|ui| {
|
||||
label(ui, "Limit");
|
||||
ui.add_enabled(
|
||||
!sending,
|
||||
egui::TextEdit::singleline(&mut t.limit)
|
||||
.hint_text("price")
|
||||
.font(mono(13.0))
|
||||
.desired_width(dp(84.0)),
|
||||
);
|
||||
if let Some(m) = ctx.mid
|
||||
&& ui.small_button("Mid").on_hover_text("Use the testnet mid").clicked()
|
||||
{
|
||||
t.limit = format!("{m}");
|
||||
}
|
||||
});
|
||||
ui.horizontal(|ui| {
|
||||
label(ui, "Time");
|
||||
let tifs = [Tif::Gtc, Tif::Ioc, Tif::Alo];
|
||||
let labels: Vec<&str> = tifs.iter().map(|x| x.label()).collect();
|
||||
if let Some(i) =
|
||||
widgets::segmented(ui, "hl-ticket-tif", &labels, tifs.iter().position(|x| *x == t.hl.tif), 20.0)
|
||||
&& !sending
|
||||
{
|
||||
t.hl.tif = tifs[i];
|
||||
}
|
||||
});
|
||||
}
|
||||
HlKind::StopLoss | HlKind::TakeProfit => {
|
||||
ui.horizontal(|ui| {
|
||||
label(ui, "Trigger");
|
||||
ui.add_enabled(
|
||||
!sending,
|
||||
egui::TextEdit::singleline(&mut t.hl.trigger)
|
||||
.hint_text("price")
|
||||
.font(mono(13.0))
|
||||
.desired_width(dp(84.0)),
|
||||
);
|
||||
ui.label(
|
||||
RichText::new("market order when it trades, reduce-only").font(ui_font(11.0)).color(muted()),
|
||||
);
|
||||
});
|
||||
}
|
||||
HlKind::Market => {
|
||||
ui.horizontal(|ui| {
|
||||
ui.add_space(dp(70.0));
|
||||
ui.label(
|
||||
RichText::new(format!("IOC limit {:.0}% through the testnet mid", MAX_MARKET_SLIPPAGE * 100.0))
|
||||
.font(ui_font(11.0))
|
||||
.color(muted()),
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
ui.horizontal(|ui| {
|
||||
ui.add_space(dp(70.0));
|
||||
let forced = matches!(t.hl.kind, HlKind::StopLoss | HlKind::TakeProfit);
|
||||
let mut ro = t.hl.reduce_only || forced;
|
||||
if ui.add_enabled(!forced && !sending, egui::Checkbox::new(&mut ro, "Reduce only")).changed() {
|
||||
t.hl.reduce_only = ro;
|
||||
}
|
||||
});
|
||||
if let (Ok(c), Some(a)) = (&coin, &asset) {
|
||||
ui.horizontal_wrapped(|ui| {
|
||||
label(ui, "Leverage");
|
||||
match ctx.leverage {
|
||||
Some(l) => {
|
||||
let over = l.value > MAX_LEVERAGE;
|
||||
ui.label(
|
||||
RichText::new(format!("{}× {}", l.value, if l.cross { "cross" } else { "isolated" }))
|
||||
.font(mono(12.0))
|
||||
.color(if over { amber() } else { text() }),
|
||||
);
|
||||
if over {
|
||||
ui.label(
|
||||
RichText::new(format!("over this build's {MAX_LEVERAGE}× limit"))
|
||||
.font(ui_font(11.0))
|
||||
.color(amber()),
|
||||
);
|
||||
}
|
||||
}
|
||||
None => {
|
||||
ui.label(RichText::new("reading…").font(ui_font(11.5)).color(muted()));
|
||||
}
|
||||
}
|
||||
let target = MAX_LEVERAGE.min(a.max_leverage);
|
||||
if ctx.leverage.is_none_or(|l| l.value != target)
|
||||
&& blocker.is_none()
|
||||
&& ui
|
||||
.add_enabled(!sending, egui::Button::new(format!("Set {target}×")))
|
||||
.on_hover_text("Asks for confirmation, then signs updateLeverage")
|
||||
.clicked()
|
||||
{
|
||||
leverage = Some((c.clone(), target));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
ui.separator();
|
||||
// Value it with the market `intent` resolved now, not the one read before an edit.
|
||||
let hl = &self.hl;
|
||||
let planned = reg.as_ref().map(|r| {
|
||||
intent(t, r).and_then(|i| {
|
||||
tr_hl::trader::plan_order(r, &i, &hl.ctx(&i.coin), &tr_hl::actions::cloid(1)).map_err(|e| e.to_string())
|
||||
})
|
||||
});
|
||||
let row = |ui: &mut Ui, k: &str, v: String, strong: bool| {
|
||||
ui.horizontal(|ui| {
|
||||
ui.add_sized(
|
||||
[dp(110.0), dp(16.0)],
|
||||
egui::Label::new(RichText::new(k).font(ui_font(11.5)).color(muted())),
|
||||
);
|
||||
let rt = RichText::new(v).font(mono(if strong { 13.0 } else { 12.0 })).color(text());
|
||||
ui.label(if strong { rt.strong() } else { rt });
|
||||
});
|
||||
};
|
||||
if let Some(Ok(p)) = &planned {
|
||||
row(ui, "Est. value", money(p.notional(), "USD"), true);
|
||||
row(
|
||||
ui,
|
||||
"Sends",
|
||||
format!("{} @ {}", to_wire(p.size()).unwrap_or_default(), to_wire(p.price()).unwrap_or_default()),
|
||||
false,
|
||||
);
|
||||
}
|
||||
row(
|
||||
ui,
|
||||
"Limits",
|
||||
format!(
|
||||
"${MAX_ORDER_NOTIONAL_USD:.0}/order · {MAX_LEVERAGE}× · {:.0}% slippage",
|
||||
MAX_MARKET_SLIPPAGE * 100.0
|
||||
),
|
||||
false,
|
||||
);
|
||||
if asset.as_ref().is_some_and(|a| a.is_hip3()) {
|
||||
let coin = asset.as_ref().map(|a| a.coin.clone()).unwrap_or_default();
|
||||
let under = coin.split(':').nth(1).unwrap_or_default().to_string();
|
||||
ui.label(
|
||||
RichText::new(format!("⚠ Perpetual future on Hyperliquid (xyz). Not a share of {under}."))
|
||||
.font(ui_font(11.5))
|
||||
.strong()
|
||||
.color(amber()),
|
||||
);
|
||||
}
|
||||
if let Some(e) = &t.error {
|
||||
ui.label(RichText::new(e).font(ui_font(12.0)).color(theme::down()));
|
||||
} else if let Some(Err(e)) = &planned
|
||||
&& !t.qty.trim().is_empty()
|
||||
{
|
||||
ui.label(RichText::new(e).font(ui_font(11.5)).color(amber()));
|
||||
}
|
||||
if let Some(d) = &t.done {
|
||||
ui.label(RichText::new(d).font(ui_font(12.0)).color(theme::up()));
|
||||
}
|
||||
let can = blocker.is_none() && !sending && matches!(&planned, Some(Ok(_)));
|
||||
let fill = if t.side == Side::Buy { theme::up() } else { theme::down() };
|
||||
ui.horizontal(|ui| {
|
||||
let label = format!("Review {}…", t.side.label());
|
||||
let b = if can {
|
||||
egui::Button::new(RichText::new(&label).strong().color(Color32::WHITE)).fill(fill)
|
||||
} else {
|
||||
egui::Button::new(RichText::new(&label).strong())
|
||||
};
|
||||
// Review stays clickable with a refusal, so the cap's reason shows in the ticket.
|
||||
let clickable = blocker.is_none() && !sending;
|
||||
if ui
|
||||
.add_enabled(clickable, b.min_size(egui::vec2(ui.available_width() - dp(24.0), dp(30.0))))
|
||||
.on_hover_text("Rounds the order, checks the limits, then asks you to confirm")
|
||||
.clicked()
|
||||
{
|
||||
review = true;
|
||||
}
|
||||
if sending {
|
||||
ui.add(egui::Spinner::new().size(14.0));
|
||||
}
|
||||
});
|
||||
ui.label(
|
||||
RichText::new(format!(
|
||||
"Signed on this device by the agent key and sent to Hyperliquid testnet; valid {} s.",
|
||||
EXPIRES_AFTER_MS / 1000
|
||||
))
|
||||
.font(ui_font(11.0))
|
||||
.color(muted()),
|
||||
);
|
||||
if review {
|
||||
self.hl_review();
|
||||
}
|
||||
if let Some((c, l)) = leverage {
|
||||
self.hl_request_leverage(&c, l);
|
||||
}
|
||||
if settings {
|
||||
self.settings_tab = super::settings_view::SettingsTab::Trading;
|
||||
self.open_panel(super::PaneKind::Settings);
|
||||
}
|
||||
act
|
||||
}
|
||||
|
||||
/// The one confirmation for every Hyperliquid action.
|
||||
pub(super) fn hl_confirm_dialog(&mut self, ctx: &egui::Context) {
|
||||
let Some(c) = self.hl.confirm.clone() else { return };
|
||||
// Editing the ticket after Review invalidates the confirmation.
|
||||
if let HlConfirm::Order { ticket, plan, .. } = &c {
|
||||
let same = self
|
||||
.trading
|
||||
.ticket
|
||||
.as_ref()
|
||||
.filter(|t| t.id == *ticket)
|
||||
.zip(self.hl.registry.as_ref())
|
||||
.is_some_and(|(t, r)| intent(t, r).is_ok_and(|i| &i == plan.intent()));
|
||||
if !same {
|
||||
self.hl.confirm = None;
|
||||
return;
|
||||
}
|
||||
}
|
||||
let master = self.hl_master().map(|m| masked(&m)).unwrap_or_default();
|
||||
let agent = self
|
||||
.prefs
|
||||
.trading
|
||||
.hl
|
||||
.agent
|
||||
.as_ref()
|
||||
.map(|a| tr_hl::trader::agent_base_name(&a.name).to_string())
|
||||
.unwrap_or_default();
|
||||
let mut choice = None;
|
||||
let modal = egui::Modal::new(egui::Id::new("hl-confirm")).show(ctx, |ui| {
|
||||
ui.set_width(dp(430.0));
|
||||
let grid_row = |ui: &mut Ui, k: &str, v: RichText| {
|
||||
ui.label(RichText::new(k).font(ui_font(12.0)).color(muted()));
|
||||
ui.label(v);
|
||||
ui.end_row();
|
||||
};
|
||||
let plain = |s: String| RichText::new(s).font(ui_font(12.5)).color(text());
|
||||
let (title, go, go_color) = match &c {
|
||||
HlConfirm::Order { plan, modify, .. } => {
|
||||
let side = if plan.intent().is_buy { "buy" } else { "sell" };
|
||||
let t =
|
||||
if modify.is_some() { format!("Confirm modify ({side})") } else { format!("Confirm {side}") };
|
||||
let color = if plan.intent().is_buy { theme::up() } else { theme::down() };
|
||||
(
|
||||
t,
|
||||
format!(
|
||||
"{} {} — Sign & send",
|
||||
if plan.intent().is_buy { "Buy" } else { "Sell" },
|
||||
plan.asset().coin
|
||||
),
|
||||
color,
|
||||
)
|
||||
}
|
||||
HlConfirm::Cancel { .. } => {
|
||||
("Cancel order?".to_string(), "Cancel order — Sign & send".to_string(), theme::down())
|
||||
}
|
||||
HlConfirm::Kill { .. } => {
|
||||
("Kill switch".to_string(), "Cancel all and turn orders off".to_string(), theme::down())
|
||||
}
|
||||
HlConfirm::Leverage { coin, leverage, .. } => {
|
||||
(format!("Set {coin} leverage?"), format!("Set {leverage}× — Sign & send"), theme::accent())
|
||||
}
|
||||
};
|
||||
ui.horizontal(|ui| {
|
||||
testnet_badge(ui);
|
||||
ui.label(RichText::new(title).font(ui_font(16.0)).strong());
|
||||
});
|
||||
ui.add_space(dp(4.0));
|
||||
egui::Grid::new("hl-confirm-grid").num_columns(2).spacing([dp(14.0), dp(5.0)]).show(ui, |ui| {
|
||||
grid_row(
|
||||
ui,
|
||||
"Network",
|
||||
RichText::new("Hyperliquid TESTNET (test funds)").font(ui_font(12.5)).strong().color(amber()),
|
||||
);
|
||||
grid_row(ui, "Account", plain(format!("{master} · agent {agent}")));
|
||||
match &c {
|
||||
HlConfirm::Order { plan, modify, .. } => {
|
||||
let a = plan.asset();
|
||||
let kind = if a.is_hip3() { "perpetual, not a share" } else { "perpetual" };
|
||||
grid_row(
|
||||
ui,
|
||||
"Market",
|
||||
RichText::new(format!("{} · {} ({kind})", a.coin, a.label()))
|
||||
.font(mono(12.5))
|
||||
.color(amber()),
|
||||
);
|
||||
if let Some(oid) = modify {
|
||||
grid_row(ui, "Replaces", plain(format!("order #{oid}")));
|
||||
}
|
||||
let side = if plan.intent().is_buy { "BUY" } else { "SELL" };
|
||||
let color = if plan.intent().is_buy { theme::up() } else { theme::down() };
|
||||
grid_row(
|
||||
ui,
|
||||
"Order",
|
||||
RichText::new(format!("{side} {} {}", qty(plan.size()), a.coin))
|
||||
.font(mono(13.0))
|
||||
.strong()
|
||||
.color(color),
|
||||
);
|
||||
grid_row(ui, "Type", plain(plan.kind_label()));
|
||||
let px = match plan.intent().kind {
|
||||
OrderKind::Market => format!(
|
||||
"IOC limit {} (at most {:.0}% from the mid)",
|
||||
price(plan.price()),
|
||||
MAX_MARKET_SLIPPAGE * 100.0
|
||||
),
|
||||
OrderKind::Trigger { .. } => format!(
|
||||
"trigger {} · fills down to {}",
|
||||
plan.trigger().map_or("—".into(), price),
|
||||
price(plan.price())
|
||||
),
|
||||
OrderKind::Limit { .. } => price(plan.price()),
|
||||
};
|
||||
grid_row(ui, "Price", RichText::new(px).font(mono(12.5)).color(text()));
|
||||
grid_row(
|
||||
ui,
|
||||
"Value",
|
||||
RichText::new(money(plan.notional(), "USD")).font(mono(14.0)).strong().color(text()),
|
||||
);
|
||||
let lev = self.hl.ctx(&a.coin).leverage.map_or("unknown".into(), |l| {
|
||||
format!("{}× {}", l.value, if l.cross { "cross" } else { "isolated" })
|
||||
});
|
||||
grid_row(ui, "Leverage", plain(lev));
|
||||
grid_row(
|
||||
ui,
|
||||
"Reduce only",
|
||||
plain(if plan.intent().reduce_only { "Yes".into() } else { "No".into() }),
|
||||
);
|
||||
grid_row(ui, "Fees", plain("Hyperliquid's maker/taker fee · no builder fee".into()));
|
||||
grid_row(ui, "Expires", plain(format!("{} s after you confirm", EXPIRES_AFTER_MS / 1000)));
|
||||
grid_row(ui, "Client id", RichText::new(plan.cloid()).font(mono(11.0)).color(muted()));
|
||||
}
|
||||
HlConfirm::Cancel { line, oid, .. } => {
|
||||
grid_row(ui, "Order", RichText::new(line).font(mono(12.5)).color(text()));
|
||||
grid_row(ui, "Order id", plain(format!("#{oid}")));
|
||||
}
|
||||
HlConfirm::Kill { orders } => {
|
||||
grid_row(
|
||||
ui,
|
||||
"Cancels",
|
||||
plain(format!("every open order ({} seen; read again before cancelling)", orders.len())),
|
||||
);
|
||||
grid_row(ui, "Then", plain("Hyperliquid orders turn off until you enable them again".into()));
|
||||
}
|
||||
HlConfirm::Leverage { coin, leverage, cross, current } => {
|
||||
grid_row(ui, "Market", RichText::new(coin).font(mono(12.5)).color(amber()));
|
||||
let now = current.map_or("unknown".into(), |l| {
|
||||
format!("{}× {}", l.value, if l.cross { "cross" } else { "isolated" })
|
||||
});
|
||||
grid_row(ui, "Now", plain(now));
|
||||
grid_row(
|
||||
ui,
|
||||
"New",
|
||||
plain(format!("{leverage}× {}", if *cross { "cross" } else { "isolated" })),
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
ui.add_space(dp(6.0));
|
||||
if let HlConfirm::Order { plan, .. } = &c {
|
||||
if plan.asset().is_hip3() {
|
||||
ui.label(RichText::new(HIP3_RISK).font(ui_font(11.5)).color(amber()));
|
||||
}
|
||||
ui.label(
|
||||
RichText::new("Orders on Hyperliquid are on-chain and can't be undone once they fill.")
|
||||
.font(ui_font(11.5))
|
||||
.color(muted()),
|
||||
);
|
||||
}
|
||||
if let HlConfirm::Kill { orders } = &c
|
||||
&& orders.is_empty()
|
||||
{
|
||||
ui.label(RichText::new("No open orders seen right now.").font(ui_font(11.5)).color(muted()));
|
||||
}
|
||||
ui.add_space(dp(6.0));
|
||||
ui.horizontal(|ui| {
|
||||
if ui.button("Cancel").clicked() {
|
||||
choice = Some(false);
|
||||
}
|
||||
let b = egui::Button::new(RichText::new(go).strong().color(Color32::WHITE)).fill(go_color);
|
||||
if ui.add(b).clicked() {
|
||||
choice = Some(true);
|
||||
}
|
||||
});
|
||||
});
|
||||
if choice.is_none() && modal.should_close() {
|
||||
choice = Some(false);
|
||||
}
|
||||
match choice {
|
||||
Some(true) => self.hl_confirmed(),
|
||||
Some(false) => self.hl.confirm = None,
|
||||
None => {}
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn hl_enable_dialog(&mut self, ctx: &egui::Context) {
|
||||
let mut choice = None;
|
||||
let modal = egui::Modal::new(egui::Id::new("hl-enable")).show(ctx, |ui| {
|
||||
ui.set_width(dp(450.0));
|
||||
ui.horizontal(|ui| {
|
||||
testnet_badge(ui);
|
||||
ui.label(RichText::new("Enable Hyperliquid orders?").font(ui_font(16.0)).strong());
|
||||
});
|
||||
ui.add_space(dp(4.0));
|
||||
for line in [
|
||||
"This build trades on Hyperliquid testnet only, with test funds. It cannot reach mainnet.",
|
||||
"Orders are on-chain transactions: once they fill they can't be undone.",
|
||||
"Perps are leveraged and can be liquidated. HIP-3 stock markets (xyz) are perpetual futures with funding, not shares.",
|
||||
"Hyperliquid's Terms of Use restrict some regions, including Ontario, Canada and the United States. You are responsible for whether you may use it.",
|
||||
"Fixed limits: $100 per order, 3× leverage, 1% market slippage. Every order, cancel and leverage change asks for confirmation.",
|
||||
] {
|
||||
ui.label(RichText::new(format!("• {line}")).font(ui_font(12.0)).color(text()));
|
||||
}
|
||||
ui.add_space(dp(6.0));
|
||||
ui.checkbox(&mut self.hl.ack, "I am not a Restricted Person under Hyperliquid's Terms, and I understand the above");
|
||||
ui.add_space(dp(6.0));
|
||||
ui.horizontal(|ui| {
|
||||
if ui.button("Keep Off").clicked() {
|
||||
choice = Some(false);
|
||||
}
|
||||
let on = self.hl.ack;
|
||||
let b = if on {
|
||||
egui::Button::new(RichText::new("Enable Hyperliquid orders").strong().color(Color32::BLACK)).fill(amber())
|
||||
} else {
|
||||
egui::Button::new(RichText::new("Enable Hyperliquid orders").strong())
|
||||
};
|
||||
if ui.add_enabled(on, b).clicked() {
|
||||
choice = Some(true);
|
||||
}
|
||||
});
|
||||
});
|
||||
if choice.is_none() && modal.should_close() {
|
||||
choice = Some(false);
|
||||
}
|
||||
if let Some(on) = choice {
|
||||
self.hl.enable_prompt = false;
|
||||
self.hl.ack = false;
|
||||
if on {
|
||||
self.prefs.trading.hl.enabled = true;
|
||||
if let Err(e) = self.prefs.save(&super::prefs_path()) {
|
||||
self.status = format!("Could not save preferences: {e}");
|
||||
}
|
||||
self.hl.message = Some(("Hyperliquid testnet orders enabled.".into(), false));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Settings › Trading › Hyperliquid.
|
||||
pub(super) fn hl_settings(&mut self, ui: &mut Ui, cmds: &mut Vec<SettingsCmd>) {
|
||||
let note = |ui: &mut Ui, s: &str| {
|
||||
ui.label(RichText::new(s).font(ui_font(11.5)).color(muted()));
|
||||
};
|
||||
ui.add_space(dp(10.0));
|
||||
ui.horizontal(|ui| {
|
||||
ui.label(RichText::new("Hyperliquid").font(ui_font(13.5)).color(text()));
|
||||
testnet_badge(ui);
|
||||
});
|
||||
ui.separator();
|
||||
let h = self.prefs.trading.hl.clone();
|
||||
let on = h.enabled && self.hl_signing_blocker().is_none();
|
||||
ui.label(
|
||||
RichText::new(match (h.master.is_some(), h.agent.is_some(), on) {
|
||||
(false, _, _) => {
|
||||
"No wallet yet. Create one to trade perps on the core dex and HIP-3 xyz (BTC, xyz:AAPL), testnet only."
|
||||
.to_string()
|
||||
}
|
||||
(true, false, _) if h.wallet.is_some() => {
|
||||
"Wallet ready. Approve a trading key to trade; until then PORT, POS and ORD are read-only.".into()
|
||||
}
|
||||
(true, false, _) => {
|
||||
"Watching an address (read-only): positions and orders show in PORT, POS and ORD.".into()
|
||||
}
|
||||
(true, true, true) => {
|
||||
"Orders are ON (testnet): confirmed orders are signed here and sent to Hyperliquid testnet.".into()
|
||||
}
|
||||
(true, true, false) => "Orders are OFF.".into(),
|
||||
})
|
||||
.font(ui_font(12.5))
|
||||
.color(if on { amber() } else { theme::accent() }),
|
||||
);
|
||||
|
||||
self.wallet_section(ui, &h);
|
||||
if let Some(e) = &self.hl.store_error {
|
||||
ui.label(
|
||||
RichText::new(format!("Trading is off on this device: {e}")).font(ui_font(12.0)).color(theme::down()),
|
||||
);
|
||||
}
|
||||
|
||||
// Orders on or off.
|
||||
ui.add_space(dp(6.0));
|
||||
ui.horizontal(|ui| {
|
||||
if h.enabled {
|
||||
if ui.button("Turn Hyperliquid Orders Off").clicked() {
|
||||
self.prefs.trading.hl.enabled = false;
|
||||
self.hl.confirm = None;
|
||||
cmds.push(SettingsCmd::SavePrefs);
|
||||
}
|
||||
} else if ui
|
||||
.add_enabled(h.agent.is_some(), egui::Button::new(RichText::new("Enable Hyperliquid Orders…").strong()))
|
||||
.on_disabled_hover_text("Approve a trading key first")
|
||||
.clicked()
|
||||
{
|
||||
self.hl.enable_prompt = true;
|
||||
self.hl.ack = false;
|
||||
}
|
||||
if h.master.is_some()
|
||||
&& ui.button("KILL").on_hover_text("Cancel every open Hyperliquid order and turn orders off").clicked()
|
||||
{
|
||||
self.hl_kill();
|
||||
}
|
||||
});
|
||||
note(
|
||||
ui,
|
||||
&format!(
|
||||
"Fixed in this build: ${MAX_ORDER_NOTIONAL_USD:.0} per order, {MAX_LEVERAGE}× leverage, {:.0}% market slippage, no builder fee. KILL cancels every open order and turns orders off.",
|
||||
MAX_MARKET_SLIPPAGE * 100.0
|
||||
),
|
||||
);
|
||||
let mut dms = self.prefs.trading.hl.dead_mans_switch;
|
||||
if ui
|
||||
.checkbox(&mut dms, "Dead man's switch: cancel all open orders if Terminal stops for a minute")
|
||||
.on_hover_text(
|
||||
"Keeps a scheduleCancel about 60 s ahead while Terminal runs. It also cancels orders placed elsewhere.",
|
||||
)
|
||||
.changed()
|
||||
{
|
||||
self.prefs.trading.hl.dead_mans_switch = dms;
|
||||
cmds.push(SettingsCmd::SavePrefs);
|
||||
}
|
||||
if cfg!(target_arch = "wasm32") {
|
||||
let mut keep = self.prefs.trading.hl.remember_on_browser;
|
||||
if ui
|
||||
.checkbox(
|
||||
&mut keep,
|
||||
"Remember the agent key on this browser (encrypted with a key the page can't read)",
|
||||
)
|
||||
.on_hover_text(
|
||||
"Off: the key lasts for this tab only, and each visit needs a new approval, which is one paid Turnkey signature.",
|
||||
)
|
||||
.changed()
|
||||
{
|
||||
self.prefs.trading.hl.remember_on_browser = keep;
|
||||
cmds.push(SettingsCmd::SavePrefs);
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
if keep {
|
||||
self.hl_web_remember_key();
|
||||
} else {
|
||||
self.hl_web_forget_key();
|
||||
}
|
||||
}
|
||||
if !self.prefs.trading.hl.remember_on_browser {
|
||||
ui.label(
|
||||
RichText::new("Each visit without it approves a new trading key: one Turnkey signature each time.")
|
||||
.font(ui_font(11.5))
|
||||
.color(muted()),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Roles.
|
||||
if h.master.is_some() {
|
||||
ui.horizontal_wrapped(|ui| {
|
||||
if ui
|
||||
.add_enabled(!self.hl.roles_busy, egui::Button::new("Check on Hyperliquid"))
|
||||
.on_hover_text("userRole and extraAgents")
|
||||
.clicked()
|
||||
{
|
||||
self.hl_check_roles();
|
||||
}
|
||||
match &self.hl.roles {
|
||||
Some(Ok(r)) => {
|
||||
let agents: Vec<String> =
|
||||
r.agents.iter().map(|a| format!("{} ({})", a.name, masked(&a.address))).collect();
|
||||
note(
|
||||
ui,
|
||||
&format!(
|
||||
"account role: {}{} · approved agents: {}",
|
||||
r.master,
|
||||
r.agent.as_ref().map_or(String::new(), |a| format!(" · agent role: {a}")),
|
||||
if agents.is_empty() { "none".into() } else { agents.join(", ") }
|
||||
),
|
||||
);
|
||||
}
|
||||
Some(Err(e)) => {
|
||||
ui.label(RichText::new(e).font(ui_font(11.5)).color(theme::down()));
|
||||
}
|
||||
None => {}
|
||||
}
|
||||
});
|
||||
}
|
||||
if let Some(b) = self.hl.busy {
|
||||
ui.horizontal(|ui| {
|
||||
ui.add(egui::Spinner::new().size(12.0));
|
||||
note(ui, b);
|
||||
});
|
||||
}
|
||||
if let Some((m, err)) = &self.hl.message {
|
||||
ui.label(RichText::new(m).font(ui_font(12.0)).color(if *err { theme::down() } else { theme::up() }));
|
||||
}
|
||||
}
|
||||
|
||||
/// Accounts the ticket can choose from (as `trade_accounts`, for the HL body).
|
||||
fn trade_accounts_pub(&self) -> Vec<tr_broker::Account> {
|
||||
self.broker.portfolio.as_ref().map(|p| p.accounts.clone()).unwrap_or_default()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,206 @@
|
||||
//! Hyperliquid in the browser: the built-in wallet's sign-in popup, and the agent key, which
|
||||
//! lives in memory for the session or, if the user opts in, in IndexedDB encrypted with a
|
||||
//! non-extractable WebCrypto AES-GCM key (the page can use that key but never read it).
|
||||
//!
|
||||
//! Sign-in opens this site's `/wallet/` page in a popup with the session key's public half
|
||||
//! and a `state`. The page answers with `postMessage`; only messages from this page's own
|
||||
//! origin, carrying the same `state`, are taken (see `super::wallet`). Browser wallets
|
||||
//! (EIP-1193) are not used.
|
||||
|
||||
use std::cell::RefCell;
|
||||
|
||||
use tr_hl::{AgentKey, SigningKey};
|
||||
use wasm_bindgen::prelude::*;
|
||||
|
||||
use super::TerminalApp;
|
||||
use super::hl::store_account;
|
||||
|
||||
#[wasm_bindgen(inline_js = r#"
|
||||
const DB = 'terminal-hl', STORE = 'keys';
|
||||
function open() {
|
||||
return new Promise((res, rej) => {
|
||||
const r = indexedDB.open(DB, 1);
|
||||
r.onupgradeneeded = () => r.result.createObjectStore(STORE);
|
||||
r.onsuccess = () => res(r.result);
|
||||
r.onerror = () => rej(r.error);
|
||||
});
|
||||
}
|
||||
function tx(db, mode, f) {
|
||||
return new Promise((res, rej) => {
|
||||
const t = db.transaction(STORE, mode);
|
||||
const req = f(t.objectStore(STORE));
|
||||
t.oncomplete = () => res(req ? req.result : undefined);
|
||||
t.onerror = () => rej(t.error);
|
||||
});
|
||||
}
|
||||
// A non-extractable AES-GCM key, kept as a CryptoKey in IndexedDB: usable, never readable.
|
||||
async function wrapping(db) {
|
||||
let k = await tx(db, 'readonly', s => s.get('wrap'));
|
||||
if (!k) {
|
||||
k = await crypto.subtle.generateKey({ name: 'AES-GCM', length: 256 }, false, ['encrypt', 'decrypt']);
|
||||
await tx(db, 'readwrite', s => s.put(k, 'wrap'));
|
||||
}
|
||||
return k;
|
||||
}
|
||||
export async function hl_key_save(account, hex) {
|
||||
const db = await open();
|
||||
const k = await wrapping(db);
|
||||
const iv = crypto.getRandomValues(new Uint8Array(12));
|
||||
const ct = new Uint8Array(await crypto.subtle.encrypt({ name: 'AES-GCM', iv }, k, new TextEncoder().encode(hex)));
|
||||
await tx(db, 'readwrite', s => s.put({ iv, ct }, 'agent:' + account));
|
||||
}
|
||||
export async function hl_key_load(account) {
|
||||
const db = await open();
|
||||
const rec = await tx(db, 'readonly', s => s.get('agent:' + account));
|
||||
const k = await tx(db, 'readonly', s => s.get('wrap'));
|
||||
if (!rec || !k) return null;
|
||||
return new TextDecoder().decode(await crypto.subtle.decrypt({ name: 'AES-GCM', iv: rec.iv }, k, rec.ct));
|
||||
}
|
||||
export async function hl_key_delete(account) {
|
||||
const db = await open();
|
||||
await tx(db, 'readwrite', s => s.delete('agent:' + account));
|
||||
}
|
||||
// The sign-in page's answer, from this origin only. Kept until Rust takes it.
|
||||
let walletInbox = null;
|
||||
if (typeof window !== 'undefined') {
|
||||
window.addEventListener('message', (e) => {
|
||||
if (e.origin !== window.location.origin) return;
|
||||
const d = e.data;
|
||||
if (!d || d.type !== 'terminal-wallet' || typeof d.state !== 'string') return;
|
||||
walletInbox = JSON.stringify({ state: d.state, session: typeof d.session === 'string' ? d.session : null,
|
||||
error: typeof d.error === 'string' ? d.error : null });
|
||||
});
|
||||
}
|
||||
export function wallet_popup(url) {
|
||||
const w = window.open(url, 'terminal-wallet', 'popup,width=480,height=760');
|
||||
return !!w;
|
||||
}
|
||||
export function wallet_take() { const v = walletInbox; walletInbox = null; return v; }
|
||||
export function page_origin() { return window.location.origin; }
|
||||
"#)]
|
||||
extern "C" {
|
||||
#[wasm_bindgen(catch)]
|
||||
async fn hl_key_save(account: &str, hex: &str) -> Result<JsValue, JsValue>;
|
||||
#[wasm_bindgen(catch)]
|
||||
async fn hl_key_load(account: &str) -> Result<JsValue, JsValue>;
|
||||
#[wasm_bindgen(catch)]
|
||||
async fn hl_key_delete(account: &str) -> Result<JsValue, JsValue>;
|
||||
#[wasm_bindgen(js_name = wallet_popup)]
|
||||
fn js_wallet_popup(url: &str) -> bool;
|
||||
fn wallet_take() -> Option<String>;
|
||||
fn page_origin() -> String;
|
||||
}
|
||||
|
||||
/// Open the sign-in page in a popup; `false` if the browser blocked it.
|
||||
pub(super) fn wallet_popup(url: &str) -> bool {
|
||||
js_wallet_popup(url)
|
||||
}
|
||||
|
||||
/// This page's origin (`https://terminal.rasmic.xyz`).
|
||||
pub(super) fn origin() -> String {
|
||||
page_origin()
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
struct PageAnswer {
|
||||
state: String,
|
||||
session: Option<String>,
|
||||
error: Option<String>,
|
||||
}
|
||||
|
||||
/// What the page's promises came back with, for the next frame.
|
||||
enum Event {
|
||||
Restored { account: String, hex: Option<zeroize::Zeroizing<String>> },
|
||||
Note(String, bool),
|
||||
}
|
||||
|
||||
thread_local! {
|
||||
static EVENTS: RefCell<Vec<Event>> = const { RefCell::new(Vec::new()) };
|
||||
}
|
||||
|
||||
fn post(ctx: &egui::Context, e: Event) {
|
||||
EVENTS.with(|v| v.borrow_mut().push(e));
|
||||
ctx.request_repaint();
|
||||
}
|
||||
|
||||
fn js_err(e: JsValue) -> String {
|
||||
e.as_string()
|
||||
.or_else(|| js_sys::Reflect::get(&e, &"message".into()).ok().and_then(|m| m.as_string()))
|
||||
.unwrap_or_else(|| format!("{e:?}"))
|
||||
}
|
||||
|
||||
impl TerminalApp {
|
||||
/// Handle what the page's promises returned.
|
||||
pub(super) fn hl_web_poll(&mut self) {
|
||||
if let Some(a) = wallet_take().and_then(|t| serde_json::from_str::<PageAnswer>(&t).ok()) {
|
||||
self.wallet_page_answered(&a.state, a.session, a.error);
|
||||
}
|
||||
let events = EVENTS.with(|v| std::mem::take(&mut *v.borrow_mut()));
|
||||
for e in events {
|
||||
match e {
|
||||
Event::Restored { account, hex } => {
|
||||
let Some(hex) = hex else { continue };
|
||||
let expected = self.prefs.trading.hl.agent.as_ref().map(|a| a.address.clone());
|
||||
if let Ok(k) = AgentKey::from_hex(&hex)
|
||||
&& Some(tr_hl::key::address_hex(&k.address())) == expected
|
||||
&& self.hl_master().is_some_and(|m| store_account(&m) == account)
|
||||
{
|
||||
let _ = self.hl.store.save(&account, &hex);
|
||||
self.hl.key = Some(std::sync::Arc::new(k));
|
||||
}
|
||||
}
|
||||
Event::Note(m, err) => {
|
||||
self.hl.busy = None;
|
||||
self.hl.message = Some((m, err));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// After loading from memory finds nothing: try the encrypted copy, if the user opted in.
|
||||
pub(super) fn hl_web_restore_key(&mut self) {
|
||||
if self.hl.key.is_some() || !self.prefs.trading.hl.remember_on_browser || self.prefs.trading.hl.agent.is_none()
|
||||
{
|
||||
return;
|
||||
}
|
||||
let Some(account) = self.hl_master().map(|m| store_account(&m)) else { return };
|
||||
let Some(ctx) = self.hl.ctx.clone() else { return };
|
||||
wasm_bindgen_futures::spawn_local(async move {
|
||||
let hex = match hl_key_load(&account).await {
|
||||
Ok(v) => v.as_string().map(zeroize::Zeroizing::new),
|
||||
Err(e) => {
|
||||
tracing::warn!("hyperliquid: reading the remembered key: {}", js_err(e));
|
||||
None
|
||||
}
|
||||
};
|
||||
post(&ctx, Event::Restored { account, hex });
|
||||
});
|
||||
}
|
||||
|
||||
/// Keep an encrypted copy in this browser (opted in).
|
||||
pub(super) fn hl_web_remember_key(&mut self) {
|
||||
let (Some(key), Some(master)) = (self.hl.key.clone(), self.hl_master()) else { return };
|
||||
if !self.prefs.trading.hl.remember_on_browser {
|
||||
return;
|
||||
}
|
||||
let account = store_account(&master);
|
||||
let Some(ctx) = self.hl.ctx.clone() else { return };
|
||||
wasm_bindgen_futures::spawn_local(async move {
|
||||
let hex = key.to_hex();
|
||||
if let Err(e) = hl_key_save(&account, &hex).await {
|
||||
post(&ctx, Event::Note(format!("Couldn't remember the key on this browser: {}", js_err(e)), true));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Delete the encrypted copy.
|
||||
pub(super) fn hl_web_forget_key(&mut self) {
|
||||
let Some(master) = self.hl_master() else { return };
|
||||
let account = store_account(&master);
|
||||
wasm_bindgen_futures::spawn_local(async move {
|
||||
if let Err(e) = hl_key_delete(&account).await {
|
||||
tracing::warn!("hyperliquid: deleting the remembered key: {}", js_err(e));
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -242,6 +242,10 @@ impl TerminalApp {
|
||||
(&["BTCUSD CRNCY", "EURUSD CRNCY"], "Crypto (live) and FX"),
|
||||
(&["US10Y GOVT"], "US Treasury yields"),
|
||||
(&["CL1 CMDTY", "GC1 CMDTY"], "Futures and commodities"),
|
||||
(
|
||||
&["xyz:AAPL", "AAPL PERP", "PERP"],
|
||||
"Hyperliquid HIP-3 perps (needs the Terminal server); PERP or ⇧P switches",
|
||||
),
|
||||
(&["SECF gold"], "Search securities"),
|
||||
],
|
||||
);
|
||||
|
||||
@@ -37,6 +37,7 @@ const COMMANDS: &[(&str, &str, &str, Option<Action>)] = &[
|
||||
("WL", "", "watchlist monitor", Some(Action::Watchlist)),
|
||||
("N", "", "news headlines", None),
|
||||
("OB", "", "order book depth dom", None),
|
||||
("PERP", "", "hip-3 perpetual hyperliquid xyz perps stock perp", Some(Action::TogglePerp)),
|
||||
("WEI", "", "world equity indices markets", None),
|
||||
("GC", "", "yield curve treasury rates", None),
|
||||
("SECF", "Security search", "search find", None),
|
||||
@@ -217,6 +218,19 @@ fn indicator_items(active: &[IndicatorSpec]) -> Vec<Item> {
|
||||
}
|
||||
|
||||
fn ticker_item(key: &str) -> Item {
|
||||
use tr_core::perps;
|
||||
if perps::is_perp(key) {
|
||||
// A perp's row says it is one, and finds it by the underlying's ticker and name too.
|
||||
let link = perps::link_for(key);
|
||||
let badge = perps::badge(key);
|
||||
let sub = match link {
|
||||
Some(l) => format!("{} · {badge}", l.name),
|
||||
None => badge.clone(),
|
||||
};
|
||||
let under = link.and_then(|l| tr_core::Security::parse(l.underlying)).map(|s| s.ticker).unwrap_or_default();
|
||||
let words = format!("{under} {} perp perpetual hyperliquid hip-3 {}", link.map_or("", |l| l.name), badge);
|
||||
return Item::new(Kind::Ticker, key, sub, Target::Run(key.into())).keywords(words);
|
||||
}
|
||||
let name = tr_core::Security::parse(key).and_then(|s| s.name()).unwrap_or("");
|
||||
Item::new(
|
||||
Kind::Ticker,
|
||||
@@ -278,6 +292,9 @@ impl TerminalApp {
|
||||
for l in tr_core::catalog::search(q, 12) {
|
||||
v.push(ticker_item(&l.key()));
|
||||
}
|
||||
for key in tr_core::perps::search(q, self.perp_candidates(), 8) {
|
||||
v.push(ticker_item(key));
|
||||
}
|
||||
// Whatever the command line would do with the query, offered as-is.
|
||||
match command::parse(q) {
|
||||
// An indicator with custom parameters (`SMA 200`, `BB 20 3`).
|
||||
@@ -654,6 +671,23 @@ mod tests {
|
||||
assert!(b.contains(&"GC".to_string()), "{b:?}");
|
||||
}
|
||||
|
||||
/// A perp's row is labelled as one and found by its underlying, after the listed stock.
|
||||
#[test]
|
||||
fn perp_rows_say_what_they_are() {
|
||||
let perp = ticker_item("xyz:AAPL");
|
||||
assert_eq!((perp.title.as_str(), perp.subtitle.as_str()), ("xyz:AAPL", "Apple Inc perp · PERP · HL xyz"));
|
||||
assert_eq!(perp.target, Target::Run("xyz:AAPL".into()));
|
||||
assert_eq!(command::parse("xyz:AAPL"), Some(command::Command::Symbol("xyz:AAPL".into())));
|
||||
assert_eq!(ticker_item("xyz:SKHX").subtitle, "PERP · HL xyz");
|
||||
let items = vec![ticker_item("AAPL US Equity"), perp];
|
||||
let titles = |q: &str| rank(&items, &[], q).into_iter().map(|i| i.title).collect::<Vec<_>>();
|
||||
assert_eq!(titles("aapl"), ["AAPL US Equity", "xyz:AAPL"], "the listed stock first");
|
||||
assert_eq!(titles("apple perp"), ["xyz:AAPL"]);
|
||||
assert_eq!(titles("hyperliquid"), ["xyz:AAPL"]);
|
||||
let perp_cmd = static_items().into_iter().find(|i| i.title == "PERP").expect("PERP is offered");
|
||||
assert_eq!(perp_cmd.hotkey, Some(Action::TogglePerp));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn indicators_are_found_by_code_name_and_category() {
|
||||
let active = [IndicatorSpec::new("RSI", &[21.0]).unwrap()];
|
||||
|
||||
+108
-16
@@ -1,6 +1,6 @@
|
||||
//! Pane content for every panel kind.
|
||||
|
||||
use std::time::Instant;
|
||||
use web_time::Instant;
|
||||
|
||||
use egui::{Align2, Color32, Rect, RichText, Sense, Stroke, Ui, UiBuilder, pos2, vec2};
|
||||
use tr_data::OrderBook;
|
||||
@@ -18,8 +18,14 @@ impl TerminalApp {
|
||||
let mut actions = Vec::new();
|
||||
let id = self.panes[i].id;
|
||||
let dragging = self.drag.is_some_and(|d| d.active);
|
||||
let switch_to = self.panes[i].kind.symbol().and_then(|s| self.switch_target(s));
|
||||
let perps: Vec<String> = match self.panes[i].kind {
|
||||
PaneKind::Search { .. } => self.perp_candidates().into_iter().map(String::from).collect(),
|
||||
_ => Vec::new(),
|
||||
};
|
||||
if let PaneKind::Chart(c) = &mut self.panes[i].kind {
|
||||
c.hover_enabled = !dragging;
|
||||
c.switch_to = switch_to;
|
||||
let clicked = c.show(ui, rect, focused, &self.worker);
|
||||
actions.append(&mut c.actions);
|
||||
return (actions, clicked);
|
||||
@@ -55,7 +61,8 @@ impl TerminalApp {
|
||||
PaneKind::Curve => actions.extend(markets_view::curve(ui, self.curves.as_deref())),
|
||||
PaneKind::Search { query } => {
|
||||
let hits = if self.net_hits.0 == *query { self.net_hits.1.as_slice() } else { &[] };
|
||||
let (a, changed) = markets_view::search(ui, query, hits);
|
||||
let perps: Vec<&str> = perps.iter().map(String::as_str).collect();
|
||||
let (a, changed) = markets_view::search(ui, query, hits, &perps);
|
||||
actions.extend(a);
|
||||
if changed && query.trim().len() >= 2 {
|
||||
self.search_pending = Some((query.clone(), Instant::now()));
|
||||
@@ -76,14 +83,14 @@ impl TerminalApp {
|
||||
}
|
||||
}
|
||||
PaneKind::OrderBook(b) => {
|
||||
if let Some(a) = order_book(ui, b) {
|
||||
if let Some(a) = order_book(ui, b, switch_to) {
|
||||
actions.push(a);
|
||||
}
|
||||
}
|
||||
PaneKind::Portfolio(pp) => {
|
||||
let demo =
|
||||
(self.prefs.trading.account_mode == crate::prefs::AccountMode::Demo).then_some(&*self.demo.pf);
|
||||
for a in super::portfolio_view::portfolio(ui, pp, &self.broker, demo, &self.quotes) {
|
||||
for a in super::portfolio_view::portfolio(ui, pp, &self.broker, demo, &self.quotes, &self.hl.mids) {
|
||||
use super::portfolio_view::PortAction as P;
|
||||
match a {
|
||||
P::Core(a) => actions.push(a),
|
||||
@@ -103,6 +110,7 @@ impl TerminalApp {
|
||||
),
|
||||
P::CancelOrder { account, id } => self.request_cancel(&account, &id),
|
||||
P::ClosePosition { account, key } => self.close_position(&account, &key),
|
||||
P::Kill => self.hl_kill(),
|
||||
P::TradingSettings => {
|
||||
self.settings_tab = super::settings_view::SettingsTab::Trading;
|
||||
self.open_panel(PaneKind::Settings);
|
||||
@@ -149,14 +157,14 @@ impl TerminalApp {
|
||||
let (r, resp) = ui.allocate_exact_size(vec2(w, row_h), Sense::click());
|
||||
widgets::hover_wash(ui, r, &resp, false);
|
||||
let p = ui.painter();
|
||||
p.text(pos2(col(r, 0), r.center().y), Align2::LEFT_CENTER, &sec.ticker, mono(12.5), amber());
|
||||
p.text(
|
||||
pos2(col(r, 0) + 74.0, r.center().y),
|
||||
Align2::LEFT_CENTER,
|
||||
sec.class.yellow_key().label(),
|
||||
ui_font(11.0),
|
||||
muted(),
|
||||
);
|
||||
let tick = p.layout_no_wrap(sec.ticker.clone(), mono(12.5), amber());
|
||||
let label_x = col(r, 0) + tick.size().x.max(66.0) + 8.0;
|
||||
p.galley(pos2(col(r, 0), r.center().y - tick.size().y / 2.0), tick, amber());
|
||||
let (label, lc) = match sec.class {
|
||||
tr_core::AssetClass::Perp => (tr_core::perps::badge(s), theme::perp()),
|
||||
c => (c.yellow_key().label().to_string(), muted()),
|
||||
};
|
||||
p.text(pos2(label_x, r.center().y), Align2::LEFT_CENTER, label, ui_font(11.0), lc);
|
||||
match self.quotes.get(s) {
|
||||
Some(q) => {
|
||||
let c = if q.change() >= 0.0 { theme::up() } else { theme::down() };
|
||||
@@ -328,17 +336,30 @@ fn fmt_size(q: f64) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
/// Price ladder: asks above, bids below, depth bars by cumulative size.
|
||||
fn order_book(ui: &mut Ui, b: &mut super::BookPane) -> Option<Action> {
|
||||
/// Price ladder: asks above, bids below, depth bars by cumulative size. `perp` is the live
|
||||
/// HIP-3 perp linked to the pane's security, whose book it can show instead of a simulated one.
|
||||
fn order_book(ui: &mut Ui, b: &mut super::BookPane, perp: Option<&'static str>) -> Option<Action> {
|
||||
let mut action = None;
|
||||
let key = tr_data::security(&b.symbol);
|
||||
let book: Option<OrderBook> = b.book.as_ref().map(|x| x.grouped(b.group));
|
||||
let mut show_perp = false;
|
||||
ui.horizontal_wrapped(|ui| {
|
||||
let r = ui.label(RichText::new(&key.ticker).font(mono(13.0)).color(amber())).interact(Sense::click());
|
||||
if r.clicked() {
|
||||
action = Some(Action::OpenChart(b.symbol.clone()));
|
||||
}
|
||||
r.on_hover_text("Open chart");
|
||||
if key.class == tr_core::AssetClass::Perp {
|
||||
let tip = match tr_core::perps::link_for(&b.symbol) {
|
||||
Some(l) => format!(
|
||||
"The perp's own book on Hyperliquid. It references {}; it is not that market's book.",
|
||||
l.reference
|
||||
),
|
||||
None => "The perp's own book on Hyperliquid.".into(),
|
||||
};
|
||||
ui.label(RichText::new(tr_core::perps::badge(&b.symbol)).font(ui_font(11.5)).color(theme::perp()))
|
||||
.on_hover_text(tip);
|
||||
}
|
||||
if let Some(bk) = &book {
|
||||
ui.label(RichText::new(&bk.source).font(ui_font(11.5)).color(muted()));
|
||||
if bk.simulated {
|
||||
@@ -349,6 +370,26 @@ fn order_book(ui: &mut Ui, b: &mut super::BookPane) -> Option<Action> {
|
||||
}
|
||||
}
|
||||
});
|
||||
// A listed instrument with no public book, but a live perp on the same underlying.
|
||||
if let Some(perp) = perp.filter(|p| tr_core::perps::is_perp(p) && book.as_ref().is_none_or(|bk| bk.simulated)) {
|
||||
ui.horizontal_wrapped(|ui| {
|
||||
let label = format!("Show the {perp} perp's book");
|
||||
let tip = format!(
|
||||
"Load the book of {perp} ({}) here. It is the perp's order book on Hyperliquid, not {}'s.",
|
||||
tr_core::perps::badge(perp),
|
||||
key.key()
|
||||
);
|
||||
show_perp = ui.small_button(RichText::new(label).color(theme::perp())).on_hover_text(tip).clicked();
|
||||
});
|
||||
}
|
||||
if show_perp && let Some(perp) = perp {
|
||||
b.symbol = perp.to_string();
|
||||
b.book = None;
|
||||
b.error = None;
|
||||
b.fetched = None;
|
||||
b.trades.clear();
|
||||
return action;
|
||||
}
|
||||
ui.horizontal_wrapped(|ui| {
|
||||
let depths = [10usize, 20, 50];
|
||||
let dl = ["10", "20", "50"];
|
||||
@@ -376,7 +417,20 @@ fn order_book(ui: &mut Ui, b: &mut super::BookPane) -> Option<Action> {
|
||||
ui.add(egui::Spinner::new());
|
||||
return action;
|
||||
};
|
||||
let rect = ui.available_rect_before_wrap();
|
||||
let full = ui.available_rect_before_wrap();
|
||||
// The book's trades go beside the ladder in a wide pane, below it in a tall one.
|
||||
let has_tape = !b.trades.is_empty();
|
||||
let (rect, tape) = if has_tape && full.width() >= dp(560.0) {
|
||||
let split = full.left() + (full.width() * 0.6).floor();
|
||||
let tape = Rect::from_min_max(pos2(split + dp(10.0), full.top()), full.max);
|
||||
(Rect::from_min_max(full.min, pos2(split, full.bottom())), Some(tape))
|
||||
} else if has_tape && full.height() >= dp(280.0) {
|
||||
let split = full.bottom() - (full.height() * 0.36).floor();
|
||||
let tape = Rect::from_min_max(pos2(full.left(), split + dp(4.0)), full.max);
|
||||
(Rect::from_min_max(full.min, pos2(full.right(), split)), Some(tape))
|
||||
} else {
|
||||
(full, None)
|
||||
};
|
||||
let d = key.decimals(book.mid().unwrap_or(1.0));
|
||||
let row = dp(18.0);
|
||||
let rows_each = (((rect.height() - row * 2.0) / row / 2.0).floor() as usize).max(1);
|
||||
@@ -441,11 +495,49 @@ fn order_book(ui: &mut Ui, b: &mut super::BookPane) -> Option<Action> {
|
||||
for (k, level) in bids.iter().enumerate() {
|
||||
draw((y + k as f32 * row) as f64, *level, true);
|
||||
}
|
||||
ui.allocate_rect(rect, Sense::hover());
|
||||
if let Some(tape) = tape {
|
||||
trades_tape(&ui.painter_at(tape), tape, &key, &b.symbol, &b.trades, d);
|
||||
}
|
||||
ui.allocate_rect(full, Sense::hover());
|
||||
ui.ctx().request_repaint_after(std::time::Duration::from_millis(500));
|
||||
action
|
||||
}
|
||||
|
||||
/// Recent trades next to the book, newest first: time, price coloured by the aggressor's side,
|
||||
/// size. A perp's tape is titled with its venue.
|
||||
fn trades_tape(
|
||||
p: &egui::Painter,
|
||||
rect: Rect,
|
||||
key: &tr_core::Security,
|
||||
symbol: &str,
|
||||
trades: &[tr_data::TradePrint],
|
||||
d: usize,
|
||||
) {
|
||||
let row = dp(18.0);
|
||||
let x_price = rect.left() + (rect.width() * 0.62).clamp(dp(110.0), (rect.width() - dp(60.0)).max(dp(110.0)));
|
||||
let x_size = rect.right() - 6.0;
|
||||
let head = Rect::from_min_size(rect.min, vec2(rect.width(), row));
|
||||
p.rect_filled(head, 3.0, theme::header());
|
||||
let title = match key.class {
|
||||
tr_core::AssetClass::Perp => format!("Trades · {} · {}", key.ticker, tr_core::perps::badge(symbol)),
|
||||
_ => format!("Trades · {}", key.ticker),
|
||||
};
|
||||
let tc = if key.class == tr_core::AssetClass::Perp { theme::perp() } else { muted() };
|
||||
p.text(pos2(rect.left() + 6.0, head.center().y), Align2::LEFT_CENTER, title, ui_font(11.0), tc);
|
||||
let mut y = head.bottom();
|
||||
for t in trades.iter().rev().take(((rect.bottom() - y) / row).floor().max(0.0) as usize) {
|
||||
let c = if t.buy { theme::up() } else { theme::down() };
|
||||
let when = chrono::DateTime::from_timestamp_millis(t.ts_ms)
|
||||
.map(|x| x.with_timezone(&chrono::Local).format("%H:%M:%S").to_string())
|
||||
.unwrap_or_default();
|
||||
let cy = y + row / 2.0;
|
||||
p.text(pos2(rect.left() + 6.0, cy), Align2::LEFT_CENTER, when, mono(11.5), muted());
|
||||
p.text(pos2(x_price, cy), Align2::RIGHT_CENTER, format!("{:.*}", d, t.px), mono(12.0), c);
|
||||
p.text(pos2(x_size, cy), Align2::RIGHT_CENTER, fmt_size(t.sz), mono(12.0), text());
|
||||
y += row;
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
@@ -43,6 +43,8 @@ pub enum PortAction {
|
||||
account: String,
|
||||
key: String,
|
||||
},
|
||||
/// The Hyperliquid kill switch (`KILL`).
|
||||
Kill,
|
||||
}
|
||||
|
||||
// ---- formatting ---------------------------------------------------------------------------
|
||||
@@ -294,6 +296,7 @@ pub fn portfolio(
|
||||
b: &BrokerState,
|
||||
demo: Option<&Portfolio>,
|
||||
quotes: &HashMap<String, Quote>,
|
||||
testnet: &HashMap<String, f64>,
|
||||
) -> Vec<PortAction> {
|
||||
let mut out = Vec::new();
|
||||
toolbar(ui, pane, b, demo, &mut out);
|
||||
@@ -303,7 +306,8 @@ pub fn portfolio(
|
||||
None => (b.mode(), b.portfolio.as_deref(), b.error.as_ref()),
|
||||
};
|
||||
match (mode, pf) {
|
||||
(BrokerMode::Setup, _) => setup_screen(ui, b, &mut out),
|
||||
// Without SnapTrade the panes still show a Hyperliquid account.
|
||||
(BrokerMode::Setup, pf) if pf.is_none_or(|p| p.accounts.is_empty()) => setup_screen(ui, b, &mut out),
|
||||
(_, None) if b.loading => {
|
||||
ui.horizontal(|ui| {
|
||||
ui.add(egui::Spinner::new().size(14.0));
|
||||
@@ -327,7 +331,7 @@ pub fn portfolio(
|
||||
if pane.account.as_ref().is_some_and(|a| pf.account(a).is_none()) {
|
||||
pane.account = None;
|
||||
}
|
||||
let price_of = |p: &Position| b.live_price(quotes, p);
|
||||
let price_of = |p: &Position| super::broker::live_price(quotes, testnet, p);
|
||||
match pane.view {
|
||||
PortView::Summary => {
|
||||
egui::ScrollArea::vertical()
|
||||
@@ -454,7 +458,7 @@ fn setup_screen(ui: &mut Ui, b: &BrokerState, out: &mut Vec<PortAction>) {
|
||||
ui.add_space(dp(4.0));
|
||||
note(
|
||||
ui,
|
||||
"terminal-rs reads accounts, balances, positions, orders and activity through SnapTrade. Access is read-only \
|
||||
"Terminal reads accounts, balances, positions, orders and activity through SnapTrade. Access is read-only \
|
||||
unless you turn on trading in Settings › Trading.",
|
||||
);
|
||||
ui.add_space(dp(8.0));
|
||||
@@ -729,16 +733,27 @@ fn position_row(
|
||||
(None, _) => resp.on_hover_text("Not available as a terminal market key"),
|
||||
};
|
||||
if let Some(k) = &key {
|
||||
let tradable = matches!(p.kind, tr_broker::InstrumentKind::Stock | tr_broker::InstrumentKind::Etf)
|
||||
&& matches!(tr_data::security(k).class, tr_core::AssetClass::Equity | tr_core::AssetClass::Etf);
|
||||
// Hyperliquid perps trade on their own account; a short closes too (reduce-only).
|
||||
let perp = super::hl::is_hl_account(&p.account_id) && p.market.is_some();
|
||||
let tradable = perp
|
||||
|| (matches!(p.kind, tr_broker::InstrumentKind::Stock | tr_broker::InstrumentKind::Etf)
|
||||
&& matches!(tr_data::security(k).class, tr_core::AssetClass::Equity | tr_core::AssetClass::Etf));
|
||||
let mut pills = Vec::new();
|
||||
if tradable {
|
||||
pills.extend(widgets::buy_sell_pills());
|
||||
if p.units > 0.0 {
|
||||
if p.units > 0.0 || (perp && p.units != 0.0) {
|
||||
pills.push(("Close", theme::control(), text()));
|
||||
}
|
||||
}
|
||||
let tips = ["Buy more (⇧B)", "Sell (⇧S)", "Close: sell the whole position"];
|
||||
let tips = if perp {
|
||||
[
|
||||
"Buy (Hyperliquid testnet)",
|
||||
"Sell (Hyperliquid testnet)",
|
||||
"Close: a reduce-only market order for all of it",
|
||||
]
|
||||
} else {
|
||||
["Buy more (⇧B)", "Sell (⇧S)", "Close: sell the whole position"]
|
||||
};
|
||||
let pill = widgets::row_pills(ui, r, ("pos-pills", &p.account_id, &p.symbol), &pills, &tips);
|
||||
match pill {
|
||||
Some(0) => out.push(PortAction::Trade {
|
||||
@@ -758,7 +773,13 @@ fn position_row(
|
||||
None => {}
|
||||
}
|
||||
resp.context_menu(|ui| {
|
||||
if p.kind != tr_broker::InstrumentKind::Option {
|
||||
if perp {
|
||||
if ui.button(format!("Close Position ({} {})…", qty(p.units), p.short_label())).clicked() {
|
||||
out.push(PortAction::ClosePosition { account: p.account_id.clone(), key: k.clone() });
|
||||
ui.close();
|
||||
}
|
||||
ui.separator();
|
||||
} else if p.kind != tr_broker::InstrumentKind::Option {
|
||||
trade_items(ui, k, Some(&p.account_id), Some(p.units), out);
|
||||
if tradable && p.units > 0.0 {
|
||||
if ui.button(format!("Close Position ({} {})…", qty(p.units), p.short_label())).clicked() {
|
||||
@@ -944,6 +965,19 @@ fn orders(ui: &mut Ui, pane: &mut PortfolioPane, pf: &Portfolio, out: &mut Vec<P
|
||||
if let Some(i) = widgets::segmented(ui, "port-orders", &["Open", "All"], sel, 20.0) {
|
||||
pane.open_only = i == 0;
|
||||
}
|
||||
if pf.accounts.iter().any(|a| super::hl::is_hl_account(&a.id)) {
|
||||
let b = egui::Button::new(RichText::new("KILL").font(mono(11.5)).strong().color(Color32::WHITE))
|
||||
.fill(theme::down());
|
||||
if ui
|
||||
.add(b)
|
||||
.on_hover_text(
|
||||
"Kill switch: cancel every open Hyperliquid testnet order and turn Hyperliquid orders off",
|
||||
)
|
||||
.clicked()
|
||||
{
|
||||
out.push(PortAction::Kill);
|
||||
}
|
||||
}
|
||||
if pf.demo {
|
||||
note(ui, "Demo orders · hover an open order to cancel it");
|
||||
} else {
|
||||
@@ -1174,20 +1208,22 @@ mod tests {
|
||||
#[test]
|
||||
fn renders_every_view_without_panicking() {
|
||||
let ctx = egui::Context::default();
|
||||
let mut b = BrokerState::load(true);
|
||||
// A store that holds nothing, so the test never reads (or moves) the user's Keychain item.
|
||||
let none = std::env::temp_dir().join(format!("tr-portfolio-test-{}/snaptrade.json", std::process::id()));
|
||||
let mut b = BrokerState::with_store(tr_broker::SecretStore::file(none), true);
|
||||
b.creds = tr_broker::Credentials::default();
|
||||
b.portfolio = Some(Box::new(tr_broker::sample::portfolio()));
|
||||
let quotes = HashMap::new();
|
||||
for view in PortView::ALL {
|
||||
let mut pane = PortfolioPane::new(view);
|
||||
let mut out = ctx.run_ui(Default::default(), |ui| {
|
||||
let _ = portfolio(ui, &mut pane, &b, None, "es);
|
||||
let _ = portfolio(ui, &mut pane, &b, None, "es, &HashMap::new());
|
||||
});
|
||||
out.textures_delta.clear();
|
||||
pane.account = Some("sim-ira".into());
|
||||
pane.symbol = Some("SPY US Equity".into());
|
||||
let mut out = ctx.run_ui(Default::default(), |ui| {
|
||||
let _ = portfolio(ui, &mut pane, &b, None, "es);
|
||||
let _ = portfolio(ui, &mut pane, &b, None, "es, &HashMap::new());
|
||||
});
|
||||
out.textures_delta.clear();
|
||||
}
|
||||
@@ -1196,7 +1232,7 @@ mod tests {
|
||||
for view in PortView::ALL {
|
||||
let mut pane = PortfolioPane::new(view);
|
||||
let mut out = ctx.run_ui(Default::default(), |ui| {
|
||||
let _ = portfolio(ui, &mut pane, &b, Some(&demo), "es);
|
||||
let _ = portfolio(ui, &mut pane, &b, Some(&demo), "es, &HashMap::new());
|
||||
});
|
||||
out.textures_delta.clear();
|
||||
}
|
||||
@@ -1204,7 +1240,7 @@ mod tests {
|
||||
b.simulate = false;
|
||||
let mut pane = PortfolioPane::new(PortView::Summary);
|
||||
let mut out = ctx.run_ui(Default::default(), |ui| {
|
||||
let _ = portfolio(ui, &mut pane, &b, None, "es);
|
||||
let _ = portfolio(ui, &mut pane, &b, None, "es, &HashMap::new());
|
||||
});
|
||||
out.textures_delta.clear();
|
||||
}
|
||||
|
||||
@@ -471,7 +471,7 @@ impl TerminalApp {
|
||||
match self.theme_store.save(&self.themes[i]) {
|
||||
Ok(path) => {
|
||||
if let Some(f) = old_file.filter(|f| *f != path) {
|
||||
let _ = std::fs::remove_file(f);
|
||||
let _ = crate::storage::remove(&f);
|
||||
}
|
||||
self.themes[i].source = Some(path);
|
||||
}
|
||||
@@ -519,7 +519,7 @@ impl TerminalApp {
|
||||
expand(given).with_extension(op)
|
||||
};
|
||||
self.status = match themes::ThemeStore::export(&self.themes[idx], &path) {
|
||||
Ok(()) => format!("Exported {name:?} to {}", path.display()),
|
||||
Ok(to) => format!("Exported {name:?} to {to}"),
|
||||
Err(e) => format!("Export failed: {e:#}"),
|
||||
};
|
||||
}
|
||||
@@ -551,7 +551,7 @@ impl TerminalApp {
|
||||
}
|
||||
"import" => {
|
||||
let path = expand(self.theme_io.trim());
|
||||
match themes::load_file(&path) {
|
||||
match themes::import_file(&path) {
|
||||
Ok(mut t) => {
|
||||
t.name = themes::unique_name(&self.themes, &t.name);
|
||||
t.source = None;
|
||||
@@ -566,9 +566,7 @@ impl TerminalApp {
|
||||
}
|
||||
}
|
||||
"folder" => {
|
||||
let _ = std::fs::create_dir_all(&self.theme_store.dir);
|
||||
let opener = if cfg!(target_os = "macos") { "open" } else { "xdg-open" };
|
||||
if let Err(e) = std::process::Command::new(opener).arg(&self.theme_store.dir).spawn() {
|
||||
if let Err(e) = crate::storage::reveal(&self.theme_store.dir) {
|
||||
self.status = format!("Could not open {}: {e}", self.theme_store.dir.display());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,11 +2,12 @@
|
||||
//! configured dark/light pair), cycle, the THEME command, copy-on-write edits of built-ins,
|
||||
//! and debounced saving of edited custom themes.
|
||||
|
||||
use std::time::{Duration, Instant};
|
||||
use std::time::Duration;
|
||||
|
||||
use super::TerminalApp;
|
||||
use crate::prefs::{Density, UiFont};
|
||||
use crate::themes::{self, SYSTEM, Theme};
|
||||
use web_time::Instant;
|
||||
|
||||
impl TerminalApp {
|
||||
/// Index of the theme currently in effect.
|
||||
|
||||
+86
-17
@@ -23,7 +23,7 @@
|
||||
//! quietly when SnapTrade rate-limits.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::time::{Duration, Instant};
|
||||
use std::time::Duration;
|
||||
|
||||
use egui::{Color32, RichText, Ui};
|
||||
use serde::{Deserialize, Serialize};
|
||||
@@ -31,6 +31,7 @@ use tr_broker::trading::{
|
||||
self, Amount, Capability, OrderPreview, OrderRequest, OrderType, PlaceError, Side, TimeInForce,
|
||||
};
|
||||
use tr_broker::{Account, InstrumentKind, Order, demo};
|
||||
use web_time::Instant;
|
||||
|
||||
use super::TerminalApp;
|
||||
use super::broker::{BrokerMode, open_external};
|
||||
@@ -80,6 +81,8 @@ pub struct Ticket {
|
||||
pub error: Option<String>,
|
||||
/// Result of the last placement.
|
||||
pub done: Option<String>,
|
||||
/// Hyperliquid's own fields, used when a Hyperliquid account is chosen.
|
||||
pub hl: super::hl::HlTicket,
|
||||
}
|
||||
|
||||
fn parse_num(s: &str) -> Option<f64> {
|
||||
@@ -115,6 +118,7 @@ impl Ticket {
|
||||
placing: false,
|
||||
error: None,
|
||||
done: None,
|
||||
hl: Default::default(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -126,6 +130,9 @@ impl Ticket {
|
||||
if let Some(k) = &args.symbol {
|
||||
self.symbol = broker_symbol(k);
|
||||
}
|
||||
if args.symbol.is_some() || args.units.is_some() || args.notional.is_some() {
|
||||
self.hl.from_dollars = None;
|
||||
}
|
||||
if let Some(u) = args.units {
|
||||
self.dollars = false;
|
||||
self.qty = fmt_input(u);
|
||||
@@ -151,6 +158,15 @@ impl Ticket {
|
||||
if self.order_type.needs_stop() {
|
||||
self.advanced = true;
|
||||
}
|
||||
self.hl.kind = match (args.stop, args.limit) {
|
||||
(Some(_), _) => super::hl::HlKind::StopLoss,
|
||||
(None, Some(_)) => super::hl::HlKind::Limit,
|
||||
(None, None) if args.symbol.is_some() => super::hl::HlKind::Market,
|
||||
_ => self.hl.kind,
|
||||
};
|
||||
if let Some(s) = args.stop {
|
||||
self.hl.trigger = fmt_input(s);
|
||||
}
|
||||
self.preview = None;
|
||||
self.review = false;
|
||||
self.error = None;
|
||||
@@ -302,6 +318,12 @@ impl TerminalApp {
|
||||
if account == demo::ACCOUNT_ID {
|
||||
return Some(everything("Demo account".into()));
|
||||
}
|
||||
if super::hl::is_hl_account(account) {
|
||||
return Some(Capability {
|
||||
connection_trading: self.hl_signing_blocker().is_none(),
|
||||
..everything("Hyperliquid testnet".into())
|
||||
});
|
||||
}
|
||||
match self.broker.mode() {
|
||||
BrokerMode::Simulated => Some(everything(
|
||||
self.broker
|
||||
@@ -322,7 +344,8 @@ impl TerminalApp {
|
||||
if std::mem::replace(&mut self.trading.caps_loaded, true) {
|
||||
return;
|
||||
}
|
||||
let cached: Option<CapsCache> = std::fs::read(caps_path()).ok().and_then(|b| serde_json::from_slice(&b).ok());
|
||||
let cached: Option<CapsCache> =
|
||||
crate::storage::read(&caps_path()).ok().and_then(|b| serde_json::from_slice(&b).ok());
|
||||
if self.trading.caps.is_none() {
|
||||
self.trading.caps = cached;
|
||||
}
|
||||
@@ -370,7 +393,7 @@ impl TerminalApp {
|
||||
Ok(caps) => {
|
||||
let cache = CapsCache { fetched: Some(chrono::Utc::now()), caps };
|
||||
if let Ok(json) = serde_json::to_vec_pretty(&cache)
|
||||
&& let Err(e) = crate::layouts::write_atomic(&caps_path(), &json)
|
||||
&& let Err(e) = crate::storage::write_atomic(&caps_path(), &json)
|
||||
{
|
||||
tracing::warn!("saving trading access: {e}");
|
||||
}
|
||||
@@ -462,8 +485,14 @@ impl TerminalApp {
|
||||
ticket.apply(&args);
|
||||
let accounts = self.trade_accounts();
|
||||
let known = |id: &str| accounts.iter().any(|a| a.id == id);
|
||||
// A Hyperliquid perp only trades on Hyperliquid.
|
||||
let hl_account = self
|
||||
.hl_master()
|
||||
.map(|m| super::hl::account_id(&m))
|
||||
.filter(|id| known(id) && args.symbol.as_deref().is_some_and(tr_data::is_perp));
|
||||
ticket.account = account
|
||||
.filter(|a| known(a))
|
||||
.or(hl_account)
|
||||
.or(ticket.account.filter(|a| known(a)))
|
||||
.or(self.prefs.trading.default_account.clone().filter(|a| known(a)))
|
||||
.or((accounts.len() == 1).then(|| accounts[0].id.clone()));
|
||||
@@ -539,6 +568,9 @@ impl TerminalApp {
|
||||
if account == demo::ACCOUNT_ID {
|
||||
return self.demo_close(&symbol);
|
||||
}
|
||||
if super::hl::is_hl_account(account) {
|
||||
return self.hl_close_position(account, key);
|
||||
}
|
||||
self.open_ticket(
|
||||
TradeArgs { buy: Some(false), symbol: Some(key.into()), all: true, ..Default::default() },
|
||||
Some(account.into()),
|
||||
@@ -590,6 +622,9 @@ impl TerminalApp {
|
||||
}
|
||||
self.trading.last_submit = Some(Instant::now());
|
||||
let Some(t) = self.trading.ticket.as_ref() else { return };
|
||||
if t.account.as_deref().is_some_and(super::hl::is_hl_account) {
|
||||
return self.hl_review();
|
||||
}
|
||||
let quote = self.ticket_quote(t);
|
||||
let req = match t.request() {
|
||||
Ok(r) => r,
|
||||
@@ -745,6 +780,9 @@ impl TerminalApp {
|
||||
if account == demo::ACCOUNT_ID {
|
||||
return self.demo_cancel(order_id);
|
||||
}
|
||||
if super::hl::is_hl_account(account) {
|
||||
return self.hl_request_cancel(order_id);
|
||||
}
|
||||
if !self.trading_enabled() {
|
||||
self.status = "Live orders are off: enable them in Settings › Trading to cancel orders here".into();
|
||||
return;
|
||||
@@ -924,18 +962,36 @@ impl TerminalApp {
|
||||
if self.trading.live_prompt {
|
||||
self.live_dialog(ctx);
|
||||
}
|
||||
if self.hl.confirm.is_some() {
|
||||
self.hl_confirm_dialog(ctx);
|
||||
}
|
||||
if self.hl.enable_prompt {
|
||||
self.hl_enable_dialog(ctx);
|
||||
}
|
||||
if self.hl.wallet.deposit || self.hl.wallet.backup_prompt {
|
||||
self.wallet_windows(ctx);
|
||||
}
|
||||
}
|
||||
|
||||
fn ticket_window(&mut self, ctx: &egui::Context) {
|
||||
let mut open = true;
|
||||
let live = !self.demo_mode();
|
||||
let title = if live { "Order Ticket · LIVE" } else { "Order Ticket · Demo" };
|
||||
let hl = live
|
||||
&& self.trading.ticket.as_ref().and_then(|t| t.account.as_deref()).is_some_and(super::hl::is_hl_account);
|
||||
let title = match (live, hl) {
|
||||
(true, true) => "Order Ticket · Hyperliquid TESTNET",
|
||||
(true, false) => "Order Ticket · LIVE",
|
||||
_ => "Order Ticket · Demo",
|
||||
};
|
||||
let color = match (live, hl) {
|
||||
(true, true) => amber(),
|
||||
(true, false) => theme::down(),
|
||||
_ => theme::border(),
|
||||
};
|
||||
let mut act = None;
|
||||
let frame = egui::Frame::window(&ctx.global_style()).stroke(egui::Stroke::new(
|
||||
if live { 2.0 } else { 1.0 },
|
||||
if live { theme::down() } else { theme::border() },
|
||||
));
|
||||
egui::Window::new(RichText::new(title).font(ui_font(13.0)).color(if live { theme::down() } else { text() }))
|
||||
let frame =
|
||||
egui::Frame::window(&ctx.global_style()).stroke(egui::Stroke::new(if live { 2.0 } else { 1.0 }, color));
|
||||
egui::Window::new(RichText::new(title).font(ui_font(13.0)).color(if live { color } else { text() }))
|
||||
.id(egui::Id::new("order-ticket"))
|
||||
.frame(frame)
|
||||
.open(&mut open)
|
||||
@@ -943,11 +999,12 @@ impl TerminalApp {
|
||||
.resizable(false)
|
||||
.default_width(dp(320.0))
|
||||
.default_pos(ctx.content_rect().center() - egui::vec2(dp(160.0), dp(220.0)))
|
||||
.show(ctx, |ui| act = self.ticket_ui(ui));
|
||||
.show(ctx, |ui| act = if hl { self.hl_ticket_ui(ui) } else { self.ticket_ui(ui) });
|
||||
self.sync_sell_all();
|
||||
if !open
|
||||
|| (ctx.input(|i| i.key_pressed(egui::Key::Escape))
|
||||
&& !self.trading.ticket.as_ref().is_some_and(|t| t.confirming))
|
||||
&& !self.trading.ticket.as_ref().is_some_and(|t| t.confirming)
|
||||
&& self.hl.confirm.is_none())
|
||||
{
|
||||
self.trading.ticket = None;
|
||||
return;
|
||||
@@ -1607,8 +1664,8 @@ impl TerminalApp {
|
||||
ui.add_space(dp(4.0));
|
||||
for line in [
|
||||
"Orders you confirm are sent to your real brokerage account through SnapTrade and can execute immediately with real money.",
|
||||
"Market orders fill at the prevailing price, which can differ from the preview. Quotes in terminal-rs may be delayed.",
|
||||
"terminal-rs is provided as is, without warranty. You are responsible for every order you place.",
|
||||
"Market orders fill at the prevailing price, which can differ from the preview. Quotes in Terminal may be delayed.",
|
||||
"Terminal is provided as is, without warranty. You are responsible for every order you place.",
|
||||
"Your brokerage connection must allow trading (reconnect with trading access if it was connected read-only).",
|
||||
] {
|
||||
ui.label(RichText::new(format!("• {line}")).font(ui_font(12.0)).color(text()));
|
||||
@@ -1692,7 +1749,7 @@ impl TerminalApp {
|
||||
requests read-only access; use Reconnect with Trading Access in Settings › Trading for brokers that trade."
|
||||
.into()
|
||||
} else {
|
||||
"Read-only: terminal-rs reads accounts, balances, positions, orders and activity. Live trading is off \
|
||||
"Read-only: Terminal reads accounts, balances, positions, orders and activity. Live trading is off \
|
||||
(Settings › Trading), and the Connection Portal requests read-only access."
|
||||
.into()
|
||||
}
|
||||
@@ -1834,7 +1891,9 @@ impl TerminalApp {
|
||||
}
|
||||
});
|
||||
|
||||
section(ui, "Safety");
|
||||
self.hl_settings(ui, cmds);
|
||||
|
||||
section(ui, "Safety (SnapTrade)");
|
||||
ui.horizontal(|ui| {
|
||||
let mut limited = self.prefs.trading.max_order_value.is_some();
|
||||
if ui.checkbox(&mut limited, "Maximum live order value").changed() {
|
||||
@@ -1849,7 +1908,13 @@ impl TerminalApp {
|
||||
}
|
||||
});
|
||||
note(ui, "Live orders whose estimated value is above the limit are refused before they are sent.");
|
||||
let accounts: Vec<Account> = self.broker.portfolio.as_ref().map(|p| p.accounts.clone()).unwrap_or_default();
|
||||
// SnapTrade's accounts only: Hyperliquid has its own section above.
|
||||
let accounts: Vec<Account> = self
|
||||
.broker
|
||||
.portfolio
|
||||
.as_ref()
|
||||
.map(|p| p.accounts.iter().filter(|a| !super::hl::is_hl_account(&a.id)).cloned().collect())
|
||||
.unwrap_or_default();
|
||||
if !accounts.is_empty() {
|
||||
ui.horizontal(|ui| {
|
||||
ui.label(RichText::new("Default live account").font(ui_font(12.5)));
|
||||
@@ -1972,10 +2037,14 @@ impl TerminalApp {
|
||||
),
|
||||
);
|
||||
note(ui, "Commands (or ⌘K): BUY AAPL 10 · SELL ALL TSLA · BUY NVDA $500 · SELL MSFT 5 LMT 410 · DEMO · LIVE");
|
||||
note(
|
||||
ui,
|
||||
"Hyperliquid testnet: BUY xyz:AAPL 0.05 @250 · SELL BTC 0.0002 MKT · KILL cancels every open Hyperliquid order.",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
enum TicketAct {
|
||||
pub(super) enum TicketAct {
|
||||
Submit,
|
||||
EnableLive,
|
||||
Mode(AccountMode),
|
||||
|
||||
@@ -0,0 +1,464 @@
|
||||
//! The built-in wallet (Turnkey), testnet only: sign-in through the browser, the short Turnkey
|
||||
//! session, approving and revoking the trading (agent) key with the wallet, Deposit and backup.
|
||||
//!
|
||||
//! The wallet's key never leaves Turnkey. Turnkey signs only `approveAgent` (see
|
||||
//! `crate::wallet_worker`); orders are signed by the agent key on this device, as before.
|
||||
//! See `docs/architecture/trading-hyperliquid.md` §4.5.
|
||||
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use tr_hl::model::SpotUsdc;
|
||||
use tr_turnkey::session::random_state;
|
||||
use tr_turnkey::{Session, SessionKey};
|
||||
use web_time::Instant;
|
||||
|
||||
use super::TerminalApp;
|
||||
use crate::prefs::HlWallet;
|
||||
use crate::wallet_worker::{SignedIn, WalletRequest, WalletResponse, session_account};
|
||||
use crate::worker::Request;
|
||||
|
||||
/// A session with less than this left isn't used to sign: the user signs in again first.
|
||||
const SESSION_MARGIN_SECS: u64 = 60;
|
||||
/// How often the Deposit window re-reads spot USDC while it is open.
|
||||
const BALANCES_EVERY: Duration = Duration::from_secs(15);
|
||||
|
||||
/// What to do once a sign-in finishes.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum After {
|
||||
Approve,
|
||||
Revoke,
|
||||
}
|
||||
|
||||
/// A sign-in waiting for the browser.
|
||||
pub struct SignIn {
|
||||
id: u64,
|
||||
state: String,
|
||||
/// The device key's public half (the page asks Turnkey for a session bound to it).
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
public_key: String,
|
||||
/// The page to open (again), once known.
|
||||
pub url: Option<String>,
|
||||
pub started: Instant,
|
||||
then: Option<After>,
|
||||
/// The wallet the pending action is for. A sign-in to any other wallet is refused.
|
||||
expected: Option<String>,
|
||||
/// Web: the key waits here for the page's message. Desktop: the worker holds it.
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
key: Option<SessionKey>,
|
||||
}
|
||||
|
||||
impl SignIn {
|
||||
fn new(id: u64, state: String, then: Option<After>, expected: Option<String>) -> Self {
|
||||
Self {
|
||||
id,
|
||||
state,
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
public_key: String::new(),
|
||||
url: None,
|
||||
started: Instant::now(),
|
||||
then,
|
||||
expected,
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
key: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Past the sign-in's deadline ([`tr_turnkey::SIGN_IN_TIMEOUT`]).
|
||||
pub fn expired(&self) -> bool {
|
||||
self.started.elapsed() > tr_turnkey::SIGN_IN_TIMEOUT
|
||||
}
|
||||
|
||||
/// Refuse a wallet other than the one the pending action is for.
|
||||
fn check_account(&self, address: &str) -> Result<(), String> {
|
||||
match &self.expected {
|
||||
Some(want) if !want.eq_ignore_ascii_case(address) => Err(format!(
|
||||
"You signed in to wallet {}, but this was for wallet {}. Nothing changed; sign in with that wallet's email or passkey.",
|
||||
super::hl_view::masked(address),
|
||||
super::hl_view::masked(want)
|
||||
)),
|
||||
_ => Ok(()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct WalletState {
|
||||
/// The live Turnkey session (at most 15 minutes), if any.
|
||||
pub session: Option<Arc<Session>>,
|
||||
/// The secret store item already read, so it isn't read again every frame.
|
||||
session_tried: Option<String>,
|
||||
pub signing_in: Option<SignIn>,
|
||||
next_id: u64,
|
||||
pub deposit: bool,
|
||||
pub backup_prompt: bool,
|
||||
pub backup_ack: bool,
|
||||
/// Spot USDC for the Deposit window: `(address, result)`.
|
||||
pub spot: Option<(String, Result<SpotUsdc, String>)>,
|
||||
spot_at: Option<Instant>,
|
||||
/// A revoke is with the wallet: the agent is forgotten only once it really happens.
|
||||
pub revoking: bool,
|
||||
}
|
||||
|
||||
impl WalletState {
|
||||
/// A new id for a sign-in, never one used before in this run.
|
||||
fn next_request_id(&mut self) -> u64 {
|
||||
self.next_id += 1;
|
||||
self.next_id
|
||||
}
|
||||
|
||||
/// Forget the wallet's state but keep counting request ids, so a late answer to an old
|
||||
/// sign-in can never match a new one.
|
||||
fn reset(&mut self) {
|
||||
*self = Self { next_id: self.next_id, ..Default::default() };
|
||||
}
|
||||
|
||||
/// Minutes left on the session, if it can still sign.
|
||||
pub fn session_minutes(&self) -> Option<u64> {
|
||||
let s = self.session.as_ref()?;
|
||||
let left = s.remaining(now_secs());
|
||||
(left > SESSION_MARGIN_SECS).then(|| left.div_ceil(60))
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn now_secs() -> u64 {
|
||||
web_time::SystemTime::now().duration_since(web_time::UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0)
|
||||
}
|
||||
|
||||
/// The sign-in page in the browser build: this site's own `/wallet/`.
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
fn web_page() -> String {
|
||||
format!("{}/wallet", super::hl_web::origin())
|
||||
}
|
||||
|
||||
impl TerminalApp {
|
||||
fn wallet_send(&self, r: WalletRequest) {
|
||||
self.worker.send(Request::Wallet(r));
|
||||
}
|
||||
|
||||
/// The signed-in wallet, if any.
|
||||
pub(super) fn wallet(&self) -> Option<&HlWallet> {
|
||||
self.prefs.trading.hl.wallet.as_ref()
|
||||
}
|
||||
|
||||
/// Open the sign-in page. `then` runs once the session is back.
|
||||
pub(super) fn wallet_sign_in(&mut self, then: Option<After>) {
|
||||
let id = self.hl.wallet.next_request_id();
|
||||
let key = SessionKey::generate();
|
||||
let state = random_state();
|
||||
let public_key = key.public_hex();
|
||||
// An approve or revoke is for the wallet shown now, and only that one.
|
||||
let expected = then.and(self.wallet().map(|w| w.address.clone()));
|
||||
#[allow(unused_mut)]
|
||||
let mut sign_in = SignIn::new(id, state.clone(), then, expected);
|
||||
self.hl.message = None;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
sign_in.public_key = public_key;
|
||||
self.hl.wallet.signing_in = Some(sign_in);
|
||||
self.wallet_send(WalletRequest::SignIn { id, store: self.hl.store.clone(), key, state });
|
||||
}
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
{
|
||||
let url = tr_turnkey::web_sign_in_url(&web_page(), &public_key, &state);
|
||||
let opened = super::hl_web::wallet_popup(&url);
|
||||
sign_in.url = Some(url);
|
||||
sign_in.key = Some(key);
|
||||
self.hl.wallet.signing_in = Some(sign_in);
|
||||
if !opened {
|
||||
self.hl.message = Some((
|
||||
"The browser blocked the sign-in window: allow pop-ups, then press Open Again.".into(),
|
||||
true,
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Stop waiting for the browser.
|
||||
pub(super) fn wallet_cancel_sign_in(&mut self) {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
crate::wallet_worker::cancel_sign_in();
|
||||
self.hl.wallet.signing_in = None;
|
||||
}
|
||||
|
||||
/// Open the sign-in page again (the browser tab was closed).
|
||||
pub(super) fn wallet_reopen(&mut self) {
|
||||
let Some(url) = self.hl.wallet.signing_in.as_ref().and_then(|s| s.url.clone()) else { return };
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
if let Err(e) = super::broker::open_external(&url) {
|
||||
self.hl.message = Some((format!("Couldn't open the browser: {e}"), true));
|
||||
}
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
if !super::hl_web::wallet_popup(&url) {
|
||||
self.hl.message = Some(("The browser blocked the sign-in window: allow pop-ups.".into(), true));
|
||||
}
|
||||
}
|
||||
|
||||
/// Web: the page's message for the sign-in with `state`.
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
pub(super) fn wallet_page_answered(&mut self, state: &str, session: Option<String>, error: Option<String>) {
|
||||
let Some(sign_in) = self.hl.wallet.signing_in.as_mut() else { return };
|
||||
if !tr_turnkey::session::same_secret(&sign_in.state, state) {
|
||||
return;
|
||||
}
|
||||
match (session, sign_in.key.take()) {
|
||||
(Some(token), Some(key)) => {
|
||||
let id = sign_in.id;
|
||||
self.wallet_send(WalletRequest::Finish { id, store: self.hl.store.clone(), key, token });
|
||||
}
|
||||
(_, _) => {
|
||||
self.hl.wallet.signing_in = None;
|
||||
self.hl.message =
|
||||
Some((format!("Not signed in: {}", error.unwrap_or_else(|| "no answer".into())), true));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Approve a trading key with the wallet (signing in first if there's no session).
|
||||
pub(super) fn wallet_approve(&mut self) {
|
||||
let Some(w) = self.wallet().cloned() else { return };
|
||||
let Some(session) = self.hl.wallet.session.clone().filter(|_| self.hl.wallet.session_minutes().is_some())
|
||||
else {
|
||||
return self.wallet_sign_in(Some(After::Approve));
|
||||
};
|
||||
self.hl.busy = Some("Approving the trading key: the wallet signs approveAgent on Turnkey…");
|
||||
self.hl.message = None;
|
||||
self.hl.pending_approval = Some(w.address.clone());
|
||||
self.wallet_send(WalletRequest::Approve {
|
||||
store: self.hl.store.clone(),
|
||||
session,
|
||||
master: w.address,
|
||||
device: super::hl::device(),
|
||||
});
|
||||
}
|
||||
|
||||
/// Replace the agent on Hyperliquid with one nobody holds (the wallet signs), and delete its
|
||||
/// key here.
|
||||
pub(super) fn wallet_revoke(&mut self) {
|
||||
let Some(w) = self.wallet().cloned() else { return };
|
||||
let Some(session) = self.hl.wallet.session.clone().filter(|_| self.hl.wallet.session_minutes().is_some())
|
||||
else {
|
||||
return self.wallet_sign_in(Some(After::Revoke));
|
||||
};
|
||||
let base = self
|
||||
.prefs
|
||||
.trading
|
||||
.hl
|
||||
.agent
|
||||
.as_ref()
|
||||
.map(|a| tr_hl::trader::agent_base_name(&a.name).to_string())
|
||||
.unwrap_or_else(|| format!("terminal-{}", super::hl::device()));
|
||||
// Orders stop now; the agent itself is forgotten when the revoke happens.
|
||||
self.prefs.trading.hl.enabled = false;
|
||||
self.save_hl_prefs();
|
||||
self.hl.wallet.revoking = true;
|
||||
self.hl.busy = Some("Revoking: the wallet signs the replacement on Turnkey…");
|
||||
self.wallet_send(WalletRequest::Revoke { store: self.hl.store.clone(), session, master: w.address, base });
|
||||
}
|
||||
|
||||
/// Forget the Turnkey session on this device (trading keeps working with the agent key).
|
||||
pub(super) fn wallet_end_session(&mut self) {
|
||||
let Some(w) = self.wallet() else { return };
|
||||
let account = session_account(&w.address);
|
||||
self.hl.wallet.session = None;
|
||||
self.wallet_send(WalletRequest::EndSession { store: self.hl.store.clone(), account });
|
||||
}
|
||||
|
||||
/// Back up the wallet: the sign-in page's export mode, where Turnkey's own iframe shows
|
||||
/// the recovery phrase. Terminal never sees it.
|
||||
pub(super) fn wallet_backup(&mut self) {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
let url = tr_turnkey::export_url(&tr_turnkey::sign_in_page());
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
let url = tr_turnkey::export_url(&web_page());
|
||||
if let Err(e) = super::broker::open_external(&url) {
|
||||
self.hl.message = Some((format!("Couldn't open the browser: {e}"), true));
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn wallet_open_deposit(&mut self) {
|
||||
self.hl.wallet.deposit = true;
|
||||
self.hl.wallet.spot_at = None;
|
||||
}
|
||||
|
||||
/// Read the session kept in the store (once), drop an expired one, refresh Deposit.
|
||||
pub(super) fn wallet_periodic(&mut self) {
|
||||
let Some(w) = self.wallet().cloned() else { return };
|
||||
let account = session_account(&w.address);
|
||||
if self.hl.wallet.session.is_none() && self.hl.wallet.session_tried.as_deref() != Some(&account) {
|
||||
self.hl.wallet.session_tried = Some(account.clone());
|
||||
self.wallet_send(WalletRequest::LoadSession { store: self.hl.store.clone(), account: account.clone() });
|
||||
}
|
||||
if self.hl.wallet.session.as_ref().is_some_and(|s| s.expired(now_secs())) {
|
||||
self.hl.wallet.session = None;
|
||||
self.wallet_send(WalletRequest::EndSession { store: self.hl.store.clone(), account });
|
||||
}
|
||||
if self.hl.wallet.deposit && self.hl.wallet.spot_at.is_none_or(|t| t.elapsed() > BALANCES_EVERY) {
|
||||
self.hl.wallet.spot_at = Some(Instant::now());
|
||||
self.wallet_send(WalletRequest::Balances { user: w.address });
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn on_wallet(&mut self, r: WalletResponse) {
|
||||
match r {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
WalletResponse::Listening { id, port } => {
|
||||
let Some(s) = self.hl.wallet.signing_in.as_mut().filter(|s| s.id == id) else { return };
|
||||
let url = tr_turnkey::desktop_sign_in_url(&tr_turnkey::sign_in_page(), &s.public_key, &s.state, port);
|
||||
s.url = Some(url);
|
||||
self.wallet_reopen();
|
||||
}
|
||||
WalletResponse::SignedIn { id, result } => {
|
||||
let Some(s) = self.hl.wallet.signing_in.take() else { return };
|
||||
if s.id != id {
|
||||
self.hl.wallet.signing_in = Some(s);
|
||||
return;
|
||||
}
|
||||
match result {
|
||||
Ok(signed) => self.wallet_signed_in(*signed, &s),
|
||||
Err(e) => self.hl.message = Some((tr_hl::mask_addresses(&e), true)),
|
||||
}
|
||||
}
|
||||
WalletResponse::SessionLoaded { account, session } => {
|
||||
if self.wallet().is_some_and(|w| session_account(&w.address) == account)
|
||||
&& self.hl.wallet.session.is_none()
|
||||
{
|
||||
self.hl.wallet.session = session;
|
||||
}
|
||||
}
|
||||
WalletResponse::ApproveNotSigned(e) => {
|
||||
self.hl.busy = None;
|
||||
self.hl.pending_approval = None;
|
||||
let e = tr_hl::mask_addresses(&e);
|
||||
self.hl.message = Some((format!("Nothing was approved: {e}"), true));
|
||||
}
|
||||
WalletResponse::RevokeNotSigned(e) => {
|
||||
self.hl.wallet.revoking = false;
|
||||
self.hl.busy = None;
|
||||
let e = tr_hl::mask_addresses(&e);
|
||||
self.hl.message = Some((format!("Nothing was revoked; the trading key still works: {e}"), true));
|
||||
}
|
||||
WalletResponse::Balances { user, result } => {
|
||||
if self.wallet().is_some_and(|w| w.address == user) {
|
||||
self.hl.wallet.spot = Some((user, result));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn wallet_signed_in(&mut self, s: SignedIn, sign_in: &SignIn) {
|
||||
let SignedIn { session, address, user, saved } = s;
|
||||
if let Err(e) = sign_in.check_account(&address) {
|
||||
// Not the wallet the action was for: drop that session and change nothing.
|
||||
let account = session_account(&address);
|
||||
self.wallet_send(WalletRequest::EndSession { store: self.hl.store.clone(), account });
|
||||
self.hl.message = Some((e, true));
|
||||
return;
|
||||
}
|
||||
let then = sign_in.then;
|
||||
let organization_id = session.organization_id().to_string();
|
||||
if self.hl_master().as_deref() != Some(&address) {
|
||||
// Another account: its agent (if any) is not this one's.
|
||||
self.prefs.trading.hl.agent = None;
|
||||
self.prefs.trading.hl.enabled = false;
|
||||
self.hl.key = None;
|
||||
self.hl.reset_key_lookup();
|
||||
}
|
||||
self.prefs.trading.hl.master = Some(address.clone());
|
||||
self.prefs.trading.hl.wallet = Some(HlWallet { address: address.clone(), organization_id, user: user.clone() });
|
||||
self.save_hl_prefs();
|
||||
self.hl.wallet.session_tried = Some(session_account(&address));
|
||||
self.hl.wallet.session = Some(Arc::new(session));
|
||||
let masked = super::hl_view::masked(&address);
|
||||
let mut note = format!("Signed in: wallet {masked} (Turnkey user {user}).");
|
||||
if let Err(e) = saved {
|
||||
note.push_str(&format!(" The session couldn't be kept in {} ({e}).", self.hl.store.describe()));
|
||||
}
|
||||
self.hl.message = Some((note, false));
|
||||
self.hl_start();
|
||||
match then {
|
||||
Some(After::Approve) => self.wallet_approve(),
|
||||
Some(After::Revoke) => self.wallet_revoke(),
|
||||
None => {}
|
||||
}
|
||||
}
|
||||
|
||||
/// Stop showing the wallet here and forget its session (its agent key stays unless revoked).
|
||||
pub(super) fn wallet_remove(&mut self) {
|
||||
self.wallet_cancel_sign_in();
|
||||
self.wallet_end_session();
|
||||
self.prefs.trading.hl.wallet = None;
|
||||
self.hl.wallet.reset();
|
||||
}
|
||||
|
||||
/// Give up on a sign-in the browser never finished (the desktop's listener stops at the
|
||||
/// same deadline; the browser build has no listener to time out).
|
||||
pub(super) fn wallet_sign_in_deadline(&mut self) {
|
||||
if self.hl.wallet.signing_in.as_ref().is_some_and(SignIn::expired) {
|
||||
self.wallet_cancel_sign_in();
|
||||
let mins = tr_turnkey::SIGN_IN_TIMEOUT.as_secs() / 60;
|
||||
self.hl.message =
|
||||
Some((format!("The sign-in wasn't finished within {mins} minutes. Start it again."), true));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Greptile #38: removing the wallet reset the request counter, so a cancelled sign-in's
|
||||
/// late answer carried the same id as the next one and ended it.
|
||||
#[test]
|
||||
fn request_ids_stay_unique_when_the_wallet_is_removed() {
|
||||
let mut w = WalletState::default();
|
||||
let first = w.next_request_id();
|
||||
w.reset();
|
||||
assert_ne!(w.next_request_id(), first);
|
||||
}
|
||||
|
||||
/// Greptile #38: a revoke (or approval) that needed a sign-in must not act on whichever
|
||||
/// wallet the user happened to sign in to.
|
||||
#[test]
|
||||
fn a_sign_in_for_an_action_accepts_only_that_wallet() {
|
||||
let a = "0x00000000000000000000000000000000000000aa";
|
||||
let b = "0x00000000000000000000000000000000000000bb";
|
||||
let pending = SignIn::new(1, "s".into(), Some(After::Revoke), Some(a.into()));
|
||||
assert!(pending.check_account(a).is_ok());
|
||||
assert!(pending.check_account(&a.to_ascii_uppercase().replace("0X", "0x")).is_ok());
|
||||
assert!(pending.check_account(b).unwrap_err().contains("0x0000…00aa"));
|
||||
let plain = SignIn::new(2, "s".into(), None, None);
|
||||
assert!(plain.check_account(b).is_ok(), "a plain sign-in takes any wallet");
|
||||
}
|
||||
|
||||
/// Greptile #38: the browser build's sign-in had no deadline at all.
|
||||
#[test]
|
||||
fn a_sign_in_expires_after_ten_minutes() {
|
||||
let mut s = SignIn::new(1, "s".into(), None, None);
|
||||
assert!(!s.expired());
|
||||
s.started = Instant::now() - (tr_turnkey::SIGN_IN_TIMEOUT + Duration::from_secs(1));
|
||||
assert!(s.expired());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_session_close_to_expiry_does_not_sign() {
|
||||
let key = SessionKey::generate();
|
||||
let claims = |exp| tr_turnkey::session::Claims {
|
||||
organization_id: "org".into(),
|
||||
user_id: "u".into(),
|
||||
public_key: key.public_hex(),
|
||||
exp,
|
||||
session_type: String::new(),
|
||||
};
|
||||
let mut w = WalletState::default();
|
||||
assert_eq!(w.session_minutes(), None);
|
||||
let token = tr_turnkey::session::unsigned_token(&claims(now_secs() + 600));
|
||||
w.session =
|
||||
Some(Arc::new(Session::accept(SessionKey::from_hex(&key.to_hex()).unwrap(), &token, now_secs()).unwrap()));
|
||||
assert_eq!(w.session_minutes(), Some(10));
|
||||
let token = tr_turnkey::session::unsigned_token(&claims(now_secs() + 30));
|
||||
w.session =
|
||||
Some(Arc::new(Session::accept(SessionKey::from_hex(&key.to_hex()).unwrap(), &token, now_secs()).unwrap()));
|
||||
assert_eq!(w.session_minutes(), None, "under a minute left: sign in again first");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,374 @@
|
||||
//! The built-in wallet's views: its part of Settings › Trading › Hyperliquid, the Deposit
|
||||
//! window (address, QR code, testnet balances) and the warning before a backup. Each shows
|
||||
//! the amber TESTNET badge.
|
||||
|
||||
use egui::{Color32, RichText, Ui};
|
||||
use tr_hl::nonce::now_ms;
|
||||
|
||||
use super::TerminalApp;
|
||||
use super::hl_view::{masked, testnet_badge};
|
||||
use super::portfolio_view::money;
|
||||
use crate::prefs::HlPrefs;
|
||||
use crate::theme::{self, amber, dp, mono, muted, text, ui_font};
|
||||
|
||||
/// A QR code of `data`, `side` points square, dark modules on white with a 4-module margin.
|
||||
pub fn qr_code(ui: &mut Ui, data: &str, side: f32) -> egui::Response {
|
||||
let (rect, resp) = ui.allocate_exact_size(egui::vec2(side, side), egui::Sense::hover());
|
||||
let Ok(code) = qrcode::QrCode::new(data.as_bytes()) else { return resp };
|
||||
let n = code.width();
|
||||
let colors = code.to_colors();
|
||||
let cell = side / (n + 8) as f32;
|
||||
let p = ui.painter_at(rect);
|
||||
p.rect_filled(rect, 4.0, Color32::WHITE);
|
||||
for (i, c) in colors.iter().enumerate() {
|
||||
if *c == qrcode::Color::Dark {
|
||||
let (x, y) = ((i % n) as f32 + 4.0, (i / n) as f32 + 4.0);
|
||||
let min = rect.min + egui::vec2(x * cell, y * cell);
|
||||
p.rect_filled(egui::Rect::from_min_size(min, egui::vec2(cell, cell)), 0.0, Color32::BLACK);
|
||||
}
|
||||
}
|
||||
resp
|
||||
}
|
||||
|
||||
impl TerminalApp {
|
||||
/// Settings › Trading › Hyperliquid: the wallet (or a watched address) and the trading key.
|
||||
pub(super) fn wallet_section(&mut self, ui: &mut Ui, h: &HlPrefs) {
|
||||
let note = |ui: &mut Ui, s: &str| {
|
||||
ui.label(RichText::new(s).font(ui_font(11.5)).color(muted()));
|
||||
};
|
||||
let busy = self.hl.busy.is_some();
|
||||
let signing_in = self.hl.wallet.signing_in.is_some();
|
||||
|
||||
match (&h.wallet, &h.master) {
|
||||
(Some(w), _) => {
|
||||
ui.horizontal_wrapped(|ui| {
|
||||
ui.label(RichText::new(format!("Wallet {}", masked(&w.address))).font(mono(12.5)).color(text()));
|
||||
self.stream_dot(ui);
|
||||
if !w.user.is_empty() {
|
||||
note(ui, &format!("· Turnkey · {}", w.user));
|
||||
}
|
||||
});
|
||||
ui.horizontal_wrapped(|ui| {
|
||||
if ui
|
||||
.button("Deposit…")
|
||||
.on_hover_text("The wallet's address, a QR code and its testnet balances")
|
||||
.clicked()
|
||||
{
|
||||
self.wallet_open_deposit();
|
||||
}
|
||||
if ui
|
||||
.button("Back Up Wallet…")
|
||||
.on_hover_text("Show the recovery phrase in Turnkey's own window")
|
||||
.clicked()
|
||||
{
|
||||
self.hl.wallet.backup_prompt = true;
|
||||
self.hl.wallet.backup_ack = false;
|
||||
}
|
||||
if ui
|
||||
.add_enabled(!busy, egui::Button::new("Remove").small())
|
||||
.on_hover_text("Stop showing this wallet here (the trading key stays unless revoked)")
|
||||
.clicked()
|
||||
{
|
||||
self.hl_disconnect();
|
||||
}
|
||||
});
|
||||
ui.horizontal_wrapped(|ui| match self.hl.wallet.session_minutes() {
|
||||
Some(m) => {
|
||||
note(ui, &format!("Signed in to Turnkey for {m} more min."));
|
||||
if ui.add(egui::Button::new("End Session").small()).clicked() {
|
||||
self.wallet_end_session();
|
||||
}
|
||||
}
|
||||
None => {
|
||||
note(ui, "Not signed in to Turnkey. You only need to be to approve or revoke a trading key.")
|
||||
}
|
||||
});
|
||||
}
|
||||
(None, Some(m)) => {
|
||||
ui.horizontal_wrapped(|ui| {
|
||||
ui.label(
|
||||
RichText::new(format!("Watching {} (read-only)", masked(m))).font(mono(12.5)).color(text()),
|
||||
);
|
||||
self.stream_dot(ui);
|
||||
if ui
|
||||
.add_enabled(!busy, egui::Button::new("Remove").small())
|
||||
.on_hover_text("Stop showing this account")
|
||||
.clicked()
|
||||
{
|
||||
self.hl_disconnect();
|
||||
}
|
||||
});
|
||||
self.sign_in_button(ui, signing_in, "Create Wallet / Sign In…");
|
||||
}
|
||||
(None, None) => {
|
||||
self.sign_in_button(ui, signing_in, "Create Wallet / Sign In…");
|
||||
note(
|
||||
ui,
|
||||
"Opens Terminal's sign-in page in your browser. Sign in with an email code or a passkey; a new account gets a wallet held by Turnkey. Terminal never sees the wallet's key, and you never paste one.",
|
||||
);
|
||||
egui::CollapsingHeader::new(RichText::new("Watch an address (read-only)").font(ui_font(12.0)))
|
||||
.id_salt("hl-watch")
|
||||
.show(ui, |ui| {
|
||||
ui.horizontal(|ui| {
|
||||
ui.add(
|
||||
egui::TextEdit::singleline(&mut self.hl.connect_form)
|
||||
.hint_text("0x… (testnet address)")
|
||||
.font(mono(12.0))
|
||||
.desired_width(dp(330.0)),
|
||||
);
|
||||
if ui.button("Show Account").on_hover_text("Read-only: no key involved").clicked() {
|
||||
let a = self.hl.connect_form.clone();
|
||||
self.hl_connect(&a);
|
||||
}
|
||||
});
|
||||
note(ui, "Fills PORT, POS, ORD and ACTV from Hyperliquid testnet. Nothing is signed.");
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(s) = &self.hl.wallet.signing_in {
|
||||
let waited = s.started.elapsed().as_secs() / 60;
|
||||
let reopen = s.url.is_some();
|
||||
ui.horizontal_wrapped(|ui| {
|
||||
ui.add(egui::Spinner::new().size(12.0));
|
||||
note(
|
||||
ui,
|
||||
&format!("Finish signing in in your browser (terminal.rasmic.xyz/wallet). {waited} min so far, 10 at most."),
|
||||
);
|
||||
if reopen && ui.add(egui::Button::new("Open Again").small()).clicked() {
|
||||
self.wallet_reopen();
|
||||
}
|
||||
if ui.add(egui::Button::new("Cancel").small()).clicked() {
|
||||
self.wallet_cancel_sign_in();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// The trading (agent) key.
|
||||
ui.add_space(dp(6.0));
|
||||
match &h.agent {
|
||||
Some(a) => {
|
||||
let days = (a.valid_until.saturating_sub(now_ms())) / (24 * 3600 * 1000);
|
||||
let expiry = chrono::DateTime::from_timestamp_millis(a.valid_until as i64)
|
||||
.map(|t| t.with_timezone(&chrono::Local).format("%d %b %Y").to_string())
|
||||
.unwrap_or_default();
|
||||
ui.horizontal_wrapped(|ui| {
|
||||
ui.label(
|
||||
RichText::new(format!(
|
||||
"Trading key {} · {}",
|
||||
tr_hl::trader::agent_base_name(&a.name),
|
||||
masked(&a.address)
|
||||
))
|
||||
.font(mono(12.0))
|
||||
.color(text()),
|
||||
);
|
||||
note(ui, &format!("· expires {expiry} · key in {}", self.hl.store.describe()));
|
||||
});
|
||||
if days < 7 {
|
||||
ui.label(
|
||||
RichText::new(format!(
|
||||
"The trading key expires in {days} days: revoke it and approve a new one."
|
||||
))
|
||||
.font(ui_font(12.0))
|
||||
.color(amber()),
|
||||
);
|
||||
}
|
||||
if self.hl.key.is_none() && !self.hl.key_loading {
|
||||
ui.label(
|
||||
RichText::new("This device has no key for this trading key.")
|
||||
.font(ui_font(12.0))
|
||||
.color(amber()),
|
||||
);
|
||||
}
|
||||
ui.horizontal_wrapped(|ui| {
|
||||
if h.wallet.is_some()
|
||||
&& ui
|
||||
.add_enabled(!busy && !signing_in, egui::Button::new("Revoke on Hyperliquid"))
|
||||
.on_hover_text(
|
||||
"The wallet replaces this key on Hyperliquid with one nobody holds: one Turnkey signature, after a sign-in if needed",
|
||||
)
|
||||
.clicked()
|
||||
{
|
||||
self.wallet_revoke();
|
||||
}
|
||||
if ui
|
||||
.add_enabled(!busy, egui::Button::new("Delete Key Here"))
|
||||
.on_hover_text("Terminal can't sign with it any more; it stays approved on Hyperliquid until it expires or is revoked")
|
||||
.clicked()
|
||||
{
|
||||
self.hl_delete_agent_key();
|
||||
}
|
||||
});
|
||||
}
|
||||
None if h.wallet.is_some() => {
|
||||
let go = ui
|
||||
.add_enabled(
|
||||
!busy && !signing_in && self.hl.store_error.is_none(),
|
||||
egui::Button::new(RichText::new("Approve Trading Key").strong()),
|
||||
)
|
||||
.on_hover_text("Signs in to Turnkey first if there's no session")
|
||||
.clicked();
|
||||
if go {
|
||||
self.wallet_approve();
|
||||
}
|
||||
note(
|
||||
ui,
|
||||
"Terminal makes a trading key on this device, and your wallet signs approveAgent for it once on Turnkey (one signature). It is named terminal-<device> and valid 180 days. It can trade but not withdraw. The wallet itself never signs orders.",
|
||||
);
|
||||
}
|
||||
None => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn sign_in_button(&mut self, ui: &mut Ui, signing_in: bool, label: &str) {
|
||||
let b = egui::Button::new(RichText::new(label).strong().color(Color32::BLACK)).fill(amber());
|
||||
if ui.add_enabled(!signing_in && self.hl.busy.is_none(), b).clicked() {
|
||||
self.wallet_sign_in(None);
|
||||
}
|
||||
}
|
||||
|
||||
fn stream_dot(&self, ui: &mut Ui) {
|
||||
let (dot, status) = if self.hl.connected {
|
||||
(theme::up(), self.hl.stream_status.clone())
|
||||
} else if self.hl.stream_status.is_empty() {
|
||||
(amber(), "Connecting…".to_string())
|
||||
} else {
|
||||
(amber(), self.hl.stream_status.clone())
|
||||
};
|
||||
let (r, _) = ui.allocate_exact_size(egui::vec2(10.0, 10.0), egui::Sense::hover());
|
||||
ui.painter().circle_filled(r.center(), 4.0, dot);
|
||||
ui.label(RichText::new(status).font(ui_font(11.5)).color(muted()));
|
||||
if let Some(u) = &self.hl.user {
|
||||
ui.label(
|
||||
RichText::new(format!("· value {}", money(u.account_value(), "USD")))
|
||||
.font(ui_font(11.5))
|
||||
.color(muted()),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// The Deposit window and the backup warning, when open.
|
||||
pub(super) fn wallet_windows(&mut self, ctx: &egui::Context) {
|
||||
if self.hl.wallet.deposit {
|
||||
self.deposit_window(ctx);
|
||||
}
|
||||
if self.hl.wallet.backup_prompt {
|
||||
self.backup_dialog(ctx);
|
||||
}
|
||||
}
|
||||
|
||||
fn deposit_window(&mut self, ctx: &egui::Context) {
|
||||
let Some(w) = self.wallet().cloned() else {
|
||||
self.hl.wallet.deposit = false;
|
||||
return;
|
||||
};
|
||||
let mut close = false;
|
||||
let modal = egui::Modal::new(egui::Id::new("wallet-deposit")).show(ctx, |ui| {
|
||||
ui.set_width(dp(440.0));
|
||||
ui.horizontal(|ui| {
|
||||
testnet_badge(ui);
|
||||
ui.label(RichText::new("Deposit").font(ui_font(16.0)).strong());
|
||||
});
|
||||
ui.add_space(dp(4.0));
|
||||
ui.label(
|
||||
RichText::new(
|
||||
"Hyperliquid testnet only. Send test USDC on Hyperliquid testnet to this address. Never send real funds: this build can't reach mainnet.",
|
||||
)
|
||||
.font(ui_font(12.0))
|
||||
.color(amber()),
|
||||
);
|
||||
ui.add_space(dp(8.0));
|
||||
ui.vertical_centered(|ui| {
|
||||
qr_code(ui, &w.address, dp(196.0)).on_hover_text("Your wallet's address");
|
||||
});
|
||||
ui.add_space(dp(8.0));
|
||||
ui.horizontal(|ui| {
|
||||
ui.label(RichText::new(&w.address).font(mono(12.0)).color(text()));
|
||||
if ui.button("Copy").clicked() {
|
||||
ui.ctx().copy_text(w.address.clone());
|
||||
self.trade_toast("Wallet address copied", true);
|
||||
}
|
||||
});
|
||||
ui.add_space(dp(6.0));
|
||||
let user = self.hl.user.clone();
|
||||
egui::Grid::new("wallet-balances").num_columns(2).spacing([dp(24.0), dp(4.0)]).show(ui, |ui| {
|
||||
let row = |ui: &mut Ui, k: &str, v: String| {
|
||||
ui.label(RichText::new(k).font(ui_font(12.0)).color(muted()));
|
||||
ui.label(RichText::new(v).font(mono(12.5)).color(text()));
|
||||
ui.end_row();
|
||||
};
|
||||
let perp = |f: fn(&tr_hl::stream::UserState) -> f64| {
|
||||
user.as_deref().map(|u| money(f(u), "USD")).unwrap_or_else(|| "loading…".into())
|
||||
};
|
||||
row(ui, "Perp account value", perp(|u| u.account_value()));
|
||||
row(ui, "Withdrawable", perp(|u| u.withdrawable()));
|
||||
let spot = match &self.hl.wallet.spot {
|
||||
Some((_, Ok(s))) if s.hold > 0.0 => format!("{} ({} on hold)", money(s.total, "USD"), money(s.hold, "USD")),
|
||||
Some((_, Ok(s))) => money(s.total, "USD"),
|
||||
Some((_, Err(e))) => format!("unavailable ({})", tr_hl::mask_addresses(e)),
|
||||
None => "loading…".into(),
|
||||
};
|
||||
row(ui, "Spot USDC", spot);
|
||||
});
|
||||
ui.add_space(dp(6.0));
|
||||
ui.label(
|
||||
RichText::new("Test USDC comes from Hyperliquid's testnet faucet or a transfer from another testnet account.")
|
||||
.font(ui_font(11.5))
|
||||
.color(muted()),
|
||||
);
|
||||
ui.add_space(dp(6.0));
|
||||
if ui.button("Close").clicked() {
|
||||
close = true;
|
||||
}
|
||||
});
|
||||
if close || modal.should_close() {
|
||||
self.hl.wallet.deposit = false;
|
||||
}
|
||||
}
|
||||
|
||||
fn backup_dialog(&mut self, ctx: &egui::Context) {
|
||||
let mut choice = None;
|
||||
let modal = egui::Modal::new(egui::Id::new("wallet-backup")).show(ctx, |ui| {
|
||||
ui.set_width(dp(450.0));
|
||||
ui.horizontal(|ui| {
|
||||
testnet_badge(ui);
|
||||
ui.label(RichText::new("Back up your wallet?").font(ui_font(16.0)).strong());
|
||||
});
|
||||
ui.add_space(dp(4.0));
|
||||
for line in [
|
||||
"Your recovery phrase controls the wallet. Anyone who has it can take everything in it.",
|
||||
"You'll sign in again on terminal.rasmic.xyz, and Turnkey shows the phrase in its own secure frame there. Terminal and Goshen never see it and can't recover it for you.",
|
||||
"Write it down offline. Never type it into a website, and never share it, not even with support.",
|
||||
] {
|
||||
ui.label(RichText::new(format!("• {line}")).font(ui_font(12.0)).color(text()));
|
||||
}
|
||||
ui.add_space(dp(6.0));
|
||||
ui.checkbox(&mut self.hl.wallet.backup_ack, "I'm somewhere private, and I understand the above");
|
||||
ui.add_space(dp(6.0));
|
||||
ui.horizontal(|ui| {
|
||||
if ui.button("Cancel").clicked() {
|
||||
choice = Some(false);
|
||||
}
|
||||
let on = self.hl.wallet.backup_ack;
|
||||
let b = if on {
|
||||
egui::Button::new(RichText::new("Open the Backup Page").strong().color(Color32::BLACK)).fill(amber())
|
||||
} else {
|
||||
egui::Button::new(RichText::new("Open the Backup Page").strong())
|
||||
};
|
||||
if ui.add_enabled(on, b).clicked() {
|
||||
choice = Some(true);
|
||||
}
|
||||
});
|
||||
});
|
||||
if choice.is_none() && modal.should_close() {
|
||||
choice = Some(false);
|
||||
}
|
||||
if let Some(go) = choice {
|
||||
self.hl.wallet.backup_prompt = false;
|
||||
self.hl.wallet.backup_ack = false;
|
||||
if go {
|
||||
self.wallet_backup();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -319,6 +319,54 @@ impl TerminalApp {
|
||||
})
|
||||
}
|
||||
|
||||
/// HIP-3 perps search can offer: those the server lists as live, or the linked ones until it
|
||||
/// has answered.
|
||||
pub(super) fn perp_candidates(&self) -> Vec<&str> {
|
||||
match &self.live_perps {
|
||||
Some(v) => v.iter().map(String::as_str).collect(),
|
||||
None => tr_core::perps::LINKS.iter().map(|l| l.coin).collect(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the Terminal server lists the HIP-3 perp `coin` as live.
|
||||
pub(super) fn perp_is_live(&self, coin: &str) -> bool {
|
||||
self.live_perps.as_ref().is_some_and(|v| v.iter().any(|x| x == coin))
|
||||
}
|
||||
|
||||
/// Where a pane on `key` can switch to: a perp's linked instrument, or an instrument's live
|
||||
/// perp. Links to perps show only once the server has listed them live.
|
||||
pub(super) fn switch_target(&self, key: &str) -> Option<&'static str> {
|
||||
tr_core::perps::switch_target(key, |c| self.perp_is_live(c))
|
||||
}
|
||||
|
||||
/// `PERP`: from a security to its HIP-3 perp, or from a perp back to its security.
|
||||
pub(super) fn open_perp(&mut self, key: Option<String>) {
|
||||
use tr_core::perps;
|
||||
let Some(key) = key.map(|k| tr_data::security(&k).key()) else {
|
||||
self.status = "PERP needs a security, for example AAPL PERP".into();
|
||||
return;
|
||||
};
|
||||
if perps::is_perp(&key) {
|
||||
match perps::link_for(&key) {
|
||||
Some(l) => self.open_security(l.underlying),
|
||||
None => self.status = format!("{key} is a HIP-3 perp; no listed instrument is linked to it"),
|
||||
}
|
||||
return;
|
||||
}
|
||||
let Some(l) = perps::perp_for(&key) else {
|
||||
self.status = format!("No HIP-3 perp is linked to {key}");
|
||||
return;
|
||||
};
|
||||
// Until the server has answered, try anyway: the pane says why if it can't load.
|
||||
if self.live_perps.is_some() && !self.perp_is_live(l.coin) {
|
||||
self.status = format!("{} isn't live on the Terminal server right now", l.coin);
|
||||
return;
|
||||
}
|
||||
self.open_security(l.coin);
|
||||
self.status =
|
||||
format!("{} ({}) references {}. It is a perp, not {key}.", l.coin, perps::badge(l.coin), l.reference);
|
||||
}
|
||||
|
||||
/// The focused security if it has listed options (US equity, ETF, index), else AAPL.
|
||||
pub(super) fn optionable_symbol(&self) -> String {
|
||||
use tr_core::AssetClass as C;
|
||||
@@ -954,8 +1002,8 @@ impl TerminalApp {
|
||||
let dir = if downloads.is_dir() { downloads } else { self.store.dir.join("exports") };
|
||||
dir.join(format!("{}.json", crate::layouts::Store::file_stem(&name)))
|
||||
});
|
||||
self.store.export(&name, &path)?;
|
||||
Ok(format!("Exported {name:?} to {}", path.display()))
|
||||
let to = self.store.export(&name, &path)?;
|
||||
Ok(format!("Exported {name:?} to {to}"))
|
||||
}
|
||||
LayoutOp::Import(path) => {
|
||||
let name = self.store.import(&path)?;
|
||||
@@ -964,9 +1012,7 @@ impl TerminalApp {
|
||||
Ok(format!("Imported {} as {name:?}", path.display()))
|
||||
}
|
||||
LayoutOp::OpenFolder => {
|
||||
std::fs::create_dir_all(&self.store.dir)?;
|
||||
let opener = if cfg!(target_os = "macos") { "open" } else { "xdg-open" };
|
||||
std::process::Command::new(opener).arg(&self.store.dir).spawn()?;
|
||||
crate::storage::reveal(&self.store.dir)?;
|
||||
Ok(format!("Opened {}", self.store.dir.display()))
|
||||
}
|
||||
})();
|
||||
@@ -1447,7 +1493,7 @@ impl TerminalApp {
|
||||
let Some(geom) = self.panes[i].window else { continue };
|
||||
let title = self.panes[i].display_title();
|
||||
let builder = ViewportBuilder::default()
|
||||
.with_title(format!("{title} — terminal-rs"))
|
||||
.with_title(format!("{title} — Terminal"))
|
||||
.with_inner_size([geom.w, geom.h])
|
||||
.with_position([geom.x, geom.y])
|
||||
.with_min_inner_size([320.0, 220.0]);
|
||||
|
||||
+123
-21
@@ -97,6 +97,8 @@ pub struct ChartState {
|
||||
paging: bool,
|
||||
exhausted: bool,
|
||||
error: Option<String>,
|
||||
/// Why the bars shown, from the disk cache, couldn't be refreshed.
|
||||
stale: Option<String>,
|
||||
built: Vec<BuiltIndicator>,
|
||||
compare_line: Option<LineRef>,
|
||||
compare_scale: f64,
|
||||
@@ -106,8 +108,8 @@ pub struct ChartState {
|
||||
hover_marker: Option<usize>,
|
||||
/// Resolved security for `symbol`.
|
||||
sec: Security,
|
||||
last_live: Option<std::time::Instant>,
|
||||
last_indicator_req: Option<std::time::Instant>,
|
||||
last_live: Option<web_time::Instant>,
|
||||
last_indicator_req: Option<web_time::Instant>,
|
||||
/// App-level actions requested from this chart (context menu), drained by the app.
|
||||
pub actions: Vec<Action>,
|
||||
/// False while a pane is being dragged over the workspace: no crosshair or hover cards.
|
||||
@@ -115,6 +117,9 @@ pub struct ChartState {
|
||||
/// The workspace drew this chart's controls in the pane's tab strip, so skip the
|
||||
/// header row and give the plot the space.
|
||||
pub controls_in_strip: bool,
|
||||
/// What `PERP` would switch this chart to (its live HIP-3 perp, or a perp's listed
|
||||
/// instrument), set by the app each frame.
|
||||
pub switch_to: Option<&'static str>,
|
||||
}
|
||||
|
||||
impl ChartState {
|
||||
@@ -148,6 +153,7 @@ impl ChartState {
|
||||
paging: false,
|
||||
exhausted: false,
|
||||
error: None,
|
||||
stale: None,
|
||||
built: Vec::new(),
|
||||
compare_line: None,
|
||||
compare_scale: 1.0,
|
||||
@@ -161,6 +167,7 @@ impl ChartState {
|
||||
actions: Vec::new(),
|
||||
hover_enabled: true,
|
||||
controls_in_strip: false,
|
||||
switch_to: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -175,6 +182,7 @@ impl ChartState {
|
||||
self.paging = false;
|
||||
self.exhausted = false;
|
||||
self.error = None;
|
||||
self.stale = None;
|
||||
self.viewport = None;
|
||||
self.manual_scale = None;
|
||||
self.glide = None;
|
||||
@@ -397,6 +405,7 @@ impl ChartState {
|
||||
prepended: usize,
|
||||
from_cache: bool,
|
||||
exhausted: bool,
|
||||
full: bool,
|
||||
live: bool,
|
||||
w: &Worker,
|
||||
) {
|
||||
@@ -404,7 +413,7 @@ impl ChartState {
|
||||
return;
|
||||
}
|
||||
if live {
|
||||
self.last_live = Some(std::time::Instant::now());
|
||||
self.last_live = Some(web_time::Instant::now());
|
||||
}
|
||||
let old_len = self.bars.len();
|
||||
let new_len = bars.len();
|
||||
@@ -421,9 +430,10 @@ impl ChartState {
|
||||
self.bars = bars;
|
||||
self.gpu = Some(gpu);
|
||||
self.paging = false;
|
||||
self.exhausted = exhausted || self.exhausted && prepended == 0;
|
||||
self.exhausted = end_of_history(self.exhausted, exhausted, prepended, full);
|
||||
if !from_cache {
|
||||
self.loading = false;
|
||||
self.stale = None;
|
||||
}
|
||||
self.error = None;
|
||||
match self.viewport.as_mut() {
|
||||
@@ -439,24 +449,44 @@ impl ChartState {
|
||||
}
|
||||
self.apply_range();
|
||||
// Live ticks only move the last bar: refresh indicators at most every 1.5 s and
|
||||
// leave the compare series alone.
|
||||
// leave the compare series alone. (A live feed filling a gap arrives as a refresh.)
|
||||
let due = self.last_indicator_req.is_none_or(|t| t.elapsed().as_millis() >= 1500);
|
||||
if !live || due {
|
||||
self.request_indicators(w);
|
||||
self.last_indicator_req = Some(std::time::Instant::now());
|
||||
self.last_indicator_req = Some(web_time::Instant::now());
|
||||
}
|
||||
if !live {
|
||||
self.request_compare(w);
|
||||
}
|
||||
}
|
||||
|
||||
/// The range row's status: errors, loading, or how many bars are shown and from where.
|
||||
fn status_text(&self) -> String {
|
||||
match (&self.error, self.loading, self.paging) {
|
||||
(Some(e), _, _) => format!("ERR {e}"),
|
||||
(_, true, _) => "loading…".into(),
|
||||
(_, _, true) => "loading history…".into(),
|
||||
_ => format!(
|
||||
"{} {}bars{}{}{}",
|
||||
self.bars.len(),
|
||||
if self.stale.is_some() { "cached " } else { "" },
|
||||
self.stale.as_deref().map(|e| format!(" · {e}")).unwrap_or_default(),
|
||||
if self.log { " LOG" } else { "" },
|
||||
if self.magnet { " MAGNET" } else { "" }
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn on_history_error(&mut self, generation: u64, error: String) {
|
||||
if generation == self.generation {
|
||||
let paging = std::mem::take(&mut self.paging);
|
||||
self.loading = false;
|
||||
self.paging = false;
|
||||
self.exhausted = true;
|
||||
if self.bars.is_empty() {
|
||||
self.error = Some(error);
|
||||
} else if !paging {
|
||||
// The refresh after painting from the disk cache failed.
|
||||
self.stale = Some(error);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -561,6 +591,9 @@ impl ChartState {
|
||||
);
|
||||
hui.spacing_mut().item_spacing.x = 6.0;
|
||||
hui.label(RichText::new(&self.sec.ticker).font(mono(12.0)).color(amber()));
|
||||
if self.sec.class == AssetClass::Perp {
|
||||
hui.label(RichText::new(tr_core::perps::badge(&self.symbol)).font(ui_font(11.0)).color(theme::perp()));
|
||||
}
|
||||
if hui.available_width() > Self::controls_width(&hui) {
|
||||
clicked |= self.controls(&mut hui, w);
|
||||
} else {
|
||||
@@ -613,24 +646,18 @@ impl ChartState {
|
||||
}
|
||||
rx = lr.right();
|
||||
}
|
||||
let status = match (&self.error, self.loading, self.paging) {
|
||||
(Some(e), _, _) => format!("ERR {e}"),
|
||||
(_, true, _) => "loading…".into(),
|
||||
(_, _, true) => "loading history…".into(),
|
||||
_ => format!(
|
||||
"{} bars{}{}",
|
||||
self.bars.len(),
|
||||
if self.log { " LOG" } else { "" },
|
||||
if self.magnet { " MAGNET" } else { "" }
|
||||
),
|
||||
};
|
||||
painter.text(
|
||||
let status_color = if self.error.is_some() || self.stale.is_some() { theme::down() } else { muted() };
|
||||
let status = painter.text(
|
||||
pos2(range_row.right() - 6.0, range_row.center().y),
|
||||
Align2::RIGHT_CENTER,
|
||||
status,
|
||||
self.status_text(),
|
||||
mono(10.5),
|
||||
muted(),
|
||||
status_color,
|
||||
);
|
||||
if let Some(target) = self.switch_to {
|
||||
let room = status.left() - 12.0 - (rx + 10.0);
|
||||
clicked |= self.switch_chip(ui, &painter, pos2(rx + 10.0, range_row.center().y), room, target);
|
||||
}
|
||||
|
||||
let body = Rect::from_min_max(pos2(rect.left(), header.bottom()), pos2(rect.right(), range_row.top()));
|
||||
let plot_all = Rect::from_min_max(body.min, pos2(body.right() - AXIS_W, body.bottom() - TIME_H));
|
||||
@@ -640,6 +667,11 @@ impl ChartState {
|
||||
|
||||
let Some(gpu) = self.gpu.clone() else {
|
||||
let msg = self.error.clone().unwrap_or_else(|| format!("Loading {} {}…", self.symbol, self.tf));
|
||||
if self.sec.class == AssetClass::Perp {
|
||||
let badge = tr_core::perps::badge(&self.symbol);
|
||||
let below = plot_all.center() + vec2(0.0, 20.0);
|
||||
painter.text(below, Align2::CENTER_CENTER, badge, ui_font(11.5), theme::perp());
|
||||
}
|
||||
painter.text(plot_all.center(), Align2::CENTER_CENTER, msg, mono(13.0), amber_dim());
|
||||
return clicked;
|
||||
};
|
||||
@@ -1085,6 +1117,36 @@ impl ChartState {
|
||||
}
|
||||
}
|
||||
|
||||
/// The range row's link between a security and its HIP-3 perp, drawn when it fits in
|
||||
/// `room`. Clicking it (or ⇧P) loads the other one here.
|
||||
fn switch_chip(&mut self, ui: &Ui, painter: &egui::Painter, left: Pos2, room: f32, target: &str) -> bool {
|
||||
let to_perp = tr_core::perps::is_perp(target);
|
||||
let label = if to_perp { format!("⇄ {target} PERP") } else { format!("⇄ {target}") };
|
||||
let g = painter.layout_no_wrap(label, mono(11.0), theme::perp());
|
||||
if g.size().x + 10.0 > room {
|
||||
return false;
|
||||
}
|
||||
let r = Rect::from_min_size(pos2(left.x, left.y - RANGE_H / 2.0 + 2.0), vec2(g.size().x + 10.0, RANGE_H - 4.0));
|
||||
let resp = ui.interact(r, ui.id().with((self.id, "perp-switch")), Sense::click());
|
||||
let fill = if resp.hovered() { theme::control_hover() } else { theme::control() };
|
||||
painter.rect_filled(r, 3.0, fill);
|
||||
painter.galley(pos2(r.left() + 5.0, r.center().y - g.size().y / 2.0), g, theme::perp());
|
||||
let tip = match tr_core::perps::link_for(if to_perp { target } else { &self.symbol }) {
|
||||
Some(l) if to_perp => format!(
|
||||
"Open {target}, the HIP-3 perp on Hyperliquid's {} dex (⇧P). It references {}. A perp's price \
|
||||
can differ from the listed instrument's, and it trades when that market is closed.",
|
||||
tr_core::perps::venue(target).unwrap_or("xyz"),
|
||||
l.reference
|
||||
),
|
||||
_ => format!("Open {target}, the listed instrument this perp references (⇧P)"),
|
||||
};
|
||||
let resp = resp.on_hover_text(tip);
|
||||
if resp.clicked() {
|
||||
self.actions.push(Action::OpenChart(target.to_string()));
|
||||
}
|
||||
resp.clicked()
|
||||
}
|
||||
|
||||
fn paint_legend(&self, ui: &Ui, price_rect: Rect, i: usize, sub_rects: &[(Rect, usize, PriceScale)]) {
|
||||
let Some(b) = self.bars.get(i) else { return };
|
||||
let p = ui.painter_at(price_rect);
|
||||
@@ -1100,6 +1162,9 @@ impl ChartState {
|
||||
*x += g.size().x + 8.0;
|
||||
};
|
||||
put(format!("{} · {}", self.sec.ticker, self.tf), amber(), &mut x);
|
||||
if self.sec.class == AssetClass::Perp {
|
||||
put(tr_core::perps::badge(&self.symbol), theme::perp(), &mut x);
|
||||
}
|
||||
for (k, val) in [("O", b.open), ("H", b.high), ("L", b.low), ("C", b.close)] {
|
||||
put(format!("{k}{val:.dec$}"), col, &mut x);
|
||||
}
|
||||
@@ -1435,10 +1500,47 @@ fn hover_card(ui: &Ui, at: Pos2, t: &Trade) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the chart has reached the start of its history after a response. A full load decides
|
||||
/// it, since it may come from another source than the bars before it. A page decides it too. Live
|
||||
/// updates and refreshes keep what the chart knew.
|
||||
fn end_of_history(was: bool, exhausted: bool, prepended: usize, full: bool) -> bool {
|
||||
if full { exhausted } else { exhausted || was && prepended == 0 }
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Bars from the disk cache whose refresh failed say so, so a perp whose server is down
|
||||
/// never passes for live. A failed page of older bars leaves the shown ones as they are.
|
||||
#[test]
|
||||
fn cached_bars_that_could_not_refresh_say_why() {
|
||||
let down = "the Terminal server is not reachable";
|
||||
let mut c = ChartState::new(1, "xyz:AAPL", Timeframe::M15);
|
||||
c.on_history_error(c.generation, down.into());
|
||||
assert_eq!(c.status_text(), format!("ERR {down}"), "with nothing to show, the error is the message");
|
||||
c.error = None;
|
||||
c.bars = Arc::new((0..3).map(|i| Bar::new(i * 900, 1.0, 1.0, 1.0, 1.0, 1.0)).collect());
|
||||
c.on_history_error(c.generation, down.into());
|
||||
assert_eq!(c.status_text(), format!("3 cached bars · {down}"));
|
||||
c.on_history_error(c.generation + 1, "another load".into());
|
||||
assert_eq!(c.status_text(), format!("3 cached bars · {down}"), "other generations are ignored");
|
||||
let mut p = ChartState::new(2, "xyz:AAPL", Timeframe::M15);
|
||||
p.bars = c.bars.clone();
|
||||
p.paging = true;
|
||||
p.on_history_error(p.generation, down.into());
|
||||
assert_eq!(p.status_text(), "3 bars", "a failed page doesn't make the shown bars stale");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_full_load_from_a_new_source_can_page_again() {
|
||||
assert!(!end_of_history(true, false, 0, true), "a reload from a source with more history");
|
||||
assert!(end_of_history(false, true, 0, true));
|
||||
assert!(end_of_history(true, false, 0, false), "a live update keeps the end");
|
||||
assert!(!end_of_history(true, false, 5, false), "a page that added bars");
|
||||
assert!(end_of_history(false, true, 0, false), "an empty page");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn glide_pans_linearly_and_zooms_evenly() {
|
||||
let from = Viewport { right: 100.0, bar_px: 2.0, width_px: 800.0 };
|
||||
|
||||
+55
-3
@@ -2,6 +2,8 @@
|
||||
//!
|
||||
//! Examples: `AAPL`, `AAPL US EQUITY <GO>`, `5`, `1W`, `RSI`, `SMA 50`, `ICHIMOKU`, `IND MOMENTUM`,
|
||||
//! `CMP MSFT`, `AAPL CONG`, `CONG`, `POL PELOSI`, `TRACK PELOSI`, `LAYOUT 4`, `FIB`, `HELP`.
|
||||
//! Hyperliquid HIP-3 perps go by their venue key (`xyz:AAPL`), and `AAPL PERP` opens the perp
|
||||
//! linked to a security.
|
||||
|
||||
use tr_chart::drawings::Tool;
|
||||
use tr_chart::scene::ChartType;
|
||||
@@ -275,6 +277,11 @@ pub enum Function {
|
||||
Trade(TradeArgs),
|
||||
/// Switch between the demo account and live accounts (`DEMO`, `MODE LIVE`); `true` = live.
|
||||
AccountMode(bool),
|
||||
/// `KILL`: cancel every open Hyperliquid order and turn Hyperliquid orders off (confirmed).
|
||||
Kill,
|
||||
/// `AAPL PERP`: open the HIP-3 perp linked to a security. Alone, or on a perp, it switches
|
||||
/// the focused pane between the two.
|
||||
Perp,
|
||||
}
|
||||
|
||||
fn num<T: std::str::FromStr>(t: Option<&&str>) -> Option<T> {
|
||||
@@ -358,6 +365,7 @@ fn parse_function(head: &str, rest: &[&str]) -> Option<Function> {
|
||||
"BUY" => Function::Trade(TradeArgs::parse(Some(true), rest)?),
|
||||
"SELL" => Function::Trade(TradeArgs::parse(Some(false), rest)?),
|
||||
"TRADE" | "TICKET" | "EMSX" => Function::Trade(TradeArgs::parse(None, rest)?),
|
||||
"KILL" if rest.is_empty() => Function::Kill,
|
||||
"DEMO" | "PAPER" if rest.is_empty() => Function::AccountMode(false),
|
||||
"MODE" => match words().join(" ").as_str() {
|
||||
"DEMO" | "PAPER" => Function::AccountMode(false),
|
||||
@@ -365,6 +373,7 @@ fn parse_function(head: &str, rest: &[&str]) -> Option<Function> {
|
||||
_ => return None,
|
||||
},
|
||||
"RANGE" => Function::Range(Range::parse(rest.first()?)?),
|
||||
"PERP" if rest.is_empty() => Function::Perp,
|
||||
// Timeframes win where spellings overlap (`1D`, `1M`); otherwise try a range (`5D`, `YTD`),
|
||||
// then the indicator catalog (`RSI`, `BB 20 2.5`).
|
||||
_ => {
|
||||
@@ -418,9 +427,20 @@ pub fn hint(input: &str) -> String {
|
||||
match parse(input) {
|
||||
None if input.trim().is_empty() => "Type a ticker and press <GO> (Enter). HELP for functions.".into(),
|
||||
None => "Unrecognised command".into(),
|
||||
Some(Command::Symbol(s)) => match Security::parse(&s).and_then(|x| x.name()) {
|
||||
Some(name) => format!("Load {s} — {name}"),
|
||||
None => format!("Load {s}"),
|
||||
Some(Command::Symbol(s)) => {
|
||||
let badge = tr_core::perps::is_perp(&s).then(|| tr_core::perps::badge(&s));
|
||||
match (Security::parse(&s).and_then(|x| x.name()), badge) {
|
||||
(Some(name), Some(b)) => format!("Load {s} — {name} · {b}"),
|
||||
(None, Some(b)) => format!("Load {s} · {b}"),
|
||||
(Some(name), None) => format!("Load {s} — {name}"),
|
||||
(None, None) => format!("Load {s}"),
|
||||
}
|
||||
}
|
||||
Some(Command::SymbolFunction(s, Function::Perp)) => match tr_core::perps::counterpart(&s) {
|
||||
Some(other) if tr_core::perps::is_perp(&s) => format!("{s}: switch to {other}"),
|
||||
Some(perp) => format!("{s}: open its HIP-3 perp {perp} ({})", tr_core::perps::badge(perp)),
|
||||
None if tr_core::perps::is_perp(&s) => format!("{s}: no listed instrument is linked to it"),
|
||||
None => format!("{s}: no HIP-3 perp is linked to it"),
|
||||
},
|
||||
Some(Command::SymbolFunction(s, f)) => format!("{s}: {}", describe(&f)),
|
||||
Some(Command::Function(f)) => describe(&f),
|
||||
@@ -534,6 +554,10 @@ fn describe(f: &Function) -> String {
|
||||
}
|
||||
Function::AccountMode(false) => "Switch to the DEMO account (paper trading)".into(),
|
||||
Function::AccountMode(true) => "Switch to LIVE brokerage accounts (asks first)".into(),
|
||||
Function::Kill => {
|
||||
"Kill switch: cancel every open Hyperliquid testnet order, then turn Hyperliquid orders off".into()
|
||||
}
|
||||
Function::Perp => "Switch between the security and its HIP-3 perp (PERP · HL xyz)".into(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -694,6 +718,10 @@ mod tests {
|
||||
assert_eq!(t("SELL ALL TSLA 5"), None);
|
||||
assert_eq!(parse("DEMO"), Some(Command::Function(Function::AccountMode(false))));
|
||||
assert_eq!(parse("MODE LIVE"), Some(Command::Function(Function::AccountMode(true))));
|
||||
assert_eq!(parse("KILL"), Some(Command::Function(Function::Kill)));
|
||||
assert_eq!(parse("kill"), Some(Command::Function(Function::Kill)));
|
||||
let perp = t("BUY xyz:AAPL 0.5 @226.4").unwrap();
|
||||
assert_eq!((perp.symbol.as_deref(), perp.units, perp.limit), (Some("xyz:AAPL"), Some(0.5), Some(226.4)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -713,6 +741,30 @@ mod tests {
|
||||
assert_eq!(parse("AAPL WHATEVER"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hip3_perps() {
|
||||
let sym = |s: &str| Some(Command::Symbol(s.into()));
|
||||
assert_eq!(parse("xyz:aapl"), sym("xyz:AAPL"));
|
||||
assert_eq!(parse("XYZ:AAPL <GO>"), sym("xyz:AAPL"));
|
||||
assert_eq!(parse("aapl"), sym("AAPL US Equity"), "a ticker is still the listed stock");
|
||||
assert_eq!(
|
||||
parse("xyz:AAPL 5"),
|
||||
Some(Command::SymbolFunction("xyz:AAPL".into(), Function::Timeframe(Timeframe::M5)))
|
||||
);
|
||||
assert_eq!(parse("xyz:GOLD OB"), Some(Command::SymbolFunction("xyz:GOLD".into(), Function::OrderBook(None))));
|
||||
// `PERP` after a security asks for its perp; alone it switches the focused pane.
|
||||
let perp = |s: &str| Some(Command::SymbolFunction(s.into(), Function::Perp));
|
||||
assert_eq!(parse("AAPL PERP"), perp("AAPL US Equity"));
|
||||
assert_eq!(parse("aapl <equity> perp"), perp("AAPL US Equity"));
|
||||
assert_eq!(parse("EURUSD CRNCY PERP"), perp("EURUSD Curncy"));
|
||||
assert_eq!(parse("PERP"), Some(Command::Function(Function::Perp)));
|
||||
assert_eq!(hint("xyz:aapl"), "Load xyz:AAPL — Apple Inc perp · PERP · HL xyz");
|
||||
assert_eq!(hint("xyz:SKHX"), "Load xyz:SKHX · PERP · HL xyz");
|
||||
assert_eq!(hint("AAPL PERP"), "AAPL US Equity: open its HIP-3 perp xyz:AAPL (PERP · HL xyz)");
|
||||
assert_eq!(hint("xyz:AAPL PERP"), "xyz:AAPL: switch to AAPL US Equity");
|
||||
assert_eq!(hint("NDX INDEX PERP"), "NDX Index: no HIP-3 perp is linked to it");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hints() {
|
||||
assert_eq!(hint("5"), "Interval 5 minutes");
|
||||
|
||||
@@ -9,6 +9,10 @@ use tr_congress::{Chamber, Party, Trade, TxType};
|
||||
use crate::theme::{self, amber, mono, muted, text, ui_font};
|
||||
use crate::worker::Profile;
|
||||
|
||||
/// Shown instead of Congress data in the browser build, which can't fetch disclosures yet.
|
||||
pub const WEB_NOTE: &str =
|
||||
"Congress data needs the desktop app for now: browsers can't reach Senate eFD or the House Clerk.";
|
||||
|
||||
/// What a click inside a panel asks the app to do.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub enum Action {
|
||||
@@ -192,7 +196,12 @@ pub fn feed(ui: &mut Ui, trades: &[Trade], filter: &mut FeedFilter, status: &str
|
||||
header(ui, "FILED TRADED LAG POLITICIAN PTY C TYPE TICKER AMOUNT OWNER ASSET");
|
||||
let rows: Vec<&Trade> = trades.iter().filter(|t| filter.matches(t)).collect();
|
||||
if rows.is_empty() {
|
||||
plain(ui, "No disclosures yet. Ingest runs in the background (REFRESH to force).", muted());
|
||||
let empty = if cfg!(target_arch = "wasm32") {
|
||||
WEB_NOTE
|
||||
} else {
|
||||
"No disclosures yet. Ingest runs in the background (REFRESH to force)."
|
||||
};
|
||||
plain(ui, empty, muted());
|
||||
}
|
||||
let row_h = 16.0;
|
||||
ScrollArea::both().id_salt("feed").auto_shrink([false, false]).show_rows(ui, row_h, rows.len(), |ui, range| {
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
+17
-28
@@ -1,6 +1,6 @@
|
||||
//! Named, saved workspace layouts: the dock tree, each pane's kind and settings, link
|
||||
//! groups and popped-out windows. Stored one JSON file per layout in
|
||||
//! `<data dir>/layouts/` (`~/Library/Application Support/terminal-rs/layouts` on macOS),
|
||||
//! `<data dir>/layouts/` (`~/Library/Application Support/terminal/layouts` on macOS),
|
||||
//! so they can be shared, hand-edited, imported and exported.
|
||||
|
||||
use std::collections::HashSet;
|
||||
@@ -16,6 +16,7 @@ use crate::command::IndicatorSpec;
|
||||
use crate::congress_view::FeedFilter;
|
||||
use crate::dock::{Node, PaneId};
|
||||
use crate::layout::Layout;
|
||||
use crate::storage;
|
||||
|
||||
pub const FORMAT_VERSION: u32 = 1;
|
||||
|
||||
@@ -276,16 +277,6 @@ pub fn chart_settings(symbol: &str, d: &crate::prefs::PanelDefaults) -> PaneSett
|
||||
}
|
||||
}
|
||||
|
||||
/// Write via a temp file and rename, so a crash never leaves a half-written file.
|
||||
pub fn write_atomic(path: &Path, bytes: &[u8]) -> std::io::Result<()> {
|
||||
if let Some(dir) = path.parent() {
|
||||
std::fs::create_dir_all(dir)?;
|
||||
}
|
||||
let tmp = path.with_extension("tmp");
|
||||
std::fs::write(&tmp, bytes)?;
|
||||
std::fs::rename(tmp, path)
|
||||
}
|
||||
|
||||
/// The layouts folder and its files.
|
||||
pub struct Store {
|
||||
pub dir: PathBuf,
|
||||
@@ -314,19 +305,17 @@ impl Store {
|
||||
/// Saved layout names, sorted case-insensitively. Names come from the documents, so a
|
||||
/// file copied in under any file name keeps its display name.
|
||||
pub fn list(&self) -> Vec<String> {
|
||||
let mut names: Vec<String> = std::fs::read_dir(&self.dir)
|
||||
let mut names: Vec<String> = storage::list(&self.dir)
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.flatten()
|
||||
.filter(|e| e.path().extension().is_some_and(|x| x == "json"))
|
||||
.filter_map(|e| {
|
||||
let bytes = std::fs::read(e.path()).ok()?;
|
||||
.filter(|p| p.extension().is_some_and(|x| x == "json"))
|
||||
.filter_map(|p| {
|
||||
let bytes = storage::read(&p).ok()?;
|
||||
let doc: serde_json::Value = serde_json::from_slice(&bytes).ok()?;
|
||||
Some(
|
||||
doc["name"]
|
||||
.as_str()
|
||||
.map(str::to_string)
|
||||
.unwrap_or_else(|| e.path().file_stem().unwrap_or_default().to_string_lossy().into_owned()),
|
||||
.unwrap_or_else(|| p.file_stem().unwrap_or_default().to_string_lossy().into_owned()),
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
@@ -343,12 +332,12 @@ impl Store {
|
||||
/// that name (e.g. dropped into the folder by hand).
|
||||
fn find_file(&self, name: &str) -> Option<PathBuf> {
|
||||
let p = self.path(name);
|
||||
if p.exists() {
|
||||
if storage::exists(&p) {
|
||||
return Some(p);
|
||||
}
|
||||
std::fs::read_dir(&self.dir).ok()?.flatten().map(|e| e.path()).find(|p| {
|
||||
storage::list(&self.dir).into_iter().find(|p| {
|
||||
p.extension().is_some_and(|x| x == "json")
|
||||
&& std::fs::read(p)
|
||||
&& storage::read(p)
|
||||
.ok()
|
||||
.and_then(|b| serde_json::from_slice::<serde_json::Value>(&b).ok())
|
||||
.is_some_and(|v| v["name"].as_str().is_some_and(|n| n.eq_ignore_ascii_case(name)))
|
||||
@@ -357,7 +346,7 @@ impl Store {
|
||||
|
||||
pub fn load(&self, name: &str) -> Result<LayoutDoc> {
|
||||
let path = self.find_file(name).with_context(|| format!("no saved layout named {name:?}"))?;
|
||||
let mut doc = LayoutDoc::from_json(&std::fs::read(&path)?).with_context(|| format!("{}", path.display()))?;
|
||||
let mut doc = LayoutDoc::from_json(&storage::read(&path)?).with_context(|| format!("{}", path.display()))?;
|
||||
if doc.name.trim().is_empty() {
|
||||
doc.name = name.to_string();
|
||||
}
|
||||
@@ -366,13 +355,13 @@ impl Store {
|
||||
|
||||
pub fn save(&self, doc: &LayoutDoc) -> Result<PathBuf> {
|
||||
let path = self.path(&doc.name);
|
||||
write_atomic(&path, &doc.to_json()).with_context(|| format!("writing {}", path.display()))?;
|
||||
storage::write_atomic(&path, &doc.to_json()).with_context(|| format!("writing {}", path.display()))?;
|
||||
Ok(path)
|
||||
}
|
||||
|
||||
pub fn delete(&self, name: &str) -> Result<()> {
|
||||
let path = self.find_file(name).with_context(|| format!("no saved layout named {name:?}"))?;
|
||||
std::fs::remove_file(path)?;
|
||||
storage::remove(&path)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -411,16 +400,16 @@ impl Store {
|
||||
(2..).map(|i| format!("{base} {i}")).find(|n| !self.exists(n)).expect("unbounded")
|
||||
}
|
||||
|
||||
/// Write a layout to an arbitrary path.
|
||||
pub fn export(&self, name: &str, to: &Path) -> Result<()> {
|
||||
/// Write a layout to an arbitrary path (a download in the browser). Returns where it went.
|
||||
pub fn export(&self, name: &str, to: &Path) -> Result<String> {
|
||||
let doc = self.load(name)?;
|
||||
write_atomic(to, &doc.to_json()).with_context(|| format!("writing {}", to.display()))
|
||||
storage::export(to, &doc.to_json()).with_context(|| format!("writing {}", to.display()))
|
||||
}
|
||||
|
||||
/// Copy a layout file into the store (renaming on conflict). Returns its name.
|
||||
pub fn import(&self, from: &Path) -> Result<String> {
|
||||
let mut doc =
|
||||
LayoutDoc::from_json(&std::fs::read(from).with_context(|| format!("reading {}", from.display()))?)?;
|
||||
LayoutDoc::from_json(&storage::import(from).with_context(|| format!("reading {}", from.display()))?)?;
|
||||
if doc.name.trim().is_empty() {
|
||||
doc.name = from.file_stem().unwrap_or_default().to_string_lossy().into_owned();
|
||||
}
|
||||
|
||||
+52
-17
@@ -1,4 +1,4 @@
|
||||
//! terminal-rs: a keyboard-driven, Bloomberg-style market terminal with GPU charts and
|
||||
//! Terminal: a keyboard-driven, Bloomberg-style market terminal with GPU charts and
|
||||
//! congressional (STOCK Act) trade tracking.
|
||||
|
||||
mod anim;
|
||||
@@ -7,18 +7,65 @@ mod chart_view;
|
||||
mod command;
|
||||
mod congress_view;
|
||||
mod dock;
|
||||
mod hl_worker;
|
||||
mod layout;
|
||||
mod layouts;
|
||||
mod markets_view;
|
||||
mod palette;
|
||||
mod prefs;
|
||||
mod storage;
|
||||
mod theme;
|
||||
mod themes;
|
||||
mod wallet_worker;
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
mod web;
|
||||
mod widgets;
|
||||
mod worker;
|
||||
|
||||
use std::time::Instant;
|
||||
use app::TerminalApp;
|
||||
use web_time::Instant;
|
||||
|
||||
/// Build the terminal once eframe has a GPU: register the chart renderer, start the worker
|
||||
/// and restore the saved workspace. Shared by the desktop and browser entry points.
|
||||
fn create_app(
|
||||
cc: &eframe::CreationContext<'_>,
|
||||
provider: tr_data::Provider,
|
||||
msaa: u32,
|
||||
t0: Instant,
|
||||
) -> Result<TerminalApp, Box<dyn std::error::Error + Send + Sync>> {
|
||||
let rs = cc.wgpu_render_state.as_ref().ok_or("Terminal requires the wgpu renderer")?;
|
||||
// Charts read their series from storage buffers in the vertex shader. WebGL2 has none,
|
||||
// so there chart panes keep their axes and labels but draw no series.
|
||||
let charts = rs.adapter.get_downlevel_capabilities().flags.contains(eframe::wgpu::DownlevelFlags::VERTEX_STORAGE);
|
||||
if charts {
|
||||
rs.renderer.write().callback_resources.insert(tr_chart::gpu::ChartRenderer::new(
|
||||
&rs.device,
|
||||
rs.target_format,
|
||||
msaa,
|
||||
));
|
||||
}
|
||||
let name = provider.name().to_string();
|
||||
let worker = worker::Worker::spawn(cc.egui_ctx.clone(), provider)?;
|
||||
let info = rs.adapter.get_info();
|
||||
tracing::info!("window + GPU ready in {:?} (adapter: {}, backend: {:?})", t0.elapsed(), info.name, info.backend);
|
||||
let mut app = TerminalApp::new(&cc.egui_ctx, worker, name);
|
||||
if !charts {
|
||||
tracing::warn!("this GPU backend has no vertex storage buffers; charts are off");
|
||||
app.set_status(if cfg!(target_arch = "wasm32") {
|
||||
"Charts need WebGPU. This browser fell back to WebGL2, so they are off.".into()
|
||||
} else {
|
||||
"This GPU backend has no vertex storage buffers, so charts are off.".into()
|
||||
});
|
||||
}
|
||||
Ok(app)
|
||||
}
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
fn main() {
|
||||
web::start();
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn main() -> eframe::Result {
|
||||
let t0 = Instant::now();
|
||||
tracing_subscriber::fmt()
|
||||
@@ -36,7 +83,7 @@ fn main() -> eframe::Result {
|
||||
.unwrap_or(if cfg!(target_os = "macos") { 4 } else { 1 });
|
||||
let mut viewport = egui::ViewportBuilder::default()
|
||||
.with_title("Terminal")
|
||||
.with_app_id("terminal-rs")
|
||||
.with_app_id("terminal")
|
||||
.with_icon(eframe::icon_data::from_png_bytes(include_bytes!("../assets/icon.png")).expect("app icon"))
|
||||
.with_inner_size([1600.0, 960.0])
|
||||
.with_min_inner_size([900.0, 560.0]);
|
||||
@@ -56,20 +103,8 @@ fn main() -> eframe::Result {
|
||||
..Default::default()
|
||||
};
|
||||
eframe::run_native(
|
||||
"terminal-rs",
|
||||
"terminal",
|
||||
options,
|
||||
Box::new(move |cc| {
|
||||
let rs = cc.wgpu_render_state.as_ref().ok_or("terminal-rs requires the wgpu renderer")?;
|
||||
rs.renderer.write().callback_resources.insert(tr_chart::gpu::ChartRenderer::new(
|
||||
&rs.device,
|
||||
rs.target_format,
|
||||
u32::from(msaa),
|
||||
));
|
||||
let provider = tr_data::from_env();
|
||||
let name = provider.name().to_string();
|
||||
let worker = worker::Worker::spawn(cc.egui_ctx.clone(), provider)?;
|
||||
tracing::info!("window + GPU ready in {:?} (adapter: {})", t0.elapsed(), rs.adapter.get_info().name);
|
||||
Ok(Box::new(app::TerminalApp::new(&cc.egui_ctx, worker, name)))
|
||||
}),
|
||||
Box::new(move |cc| Ok(Box::new(create_app(cc, tr_data::from_env(), u32::from(msaa), t0)?))),
|
||||
)
|
||||
}
|
||||
+29
-6
@@ -261,12 +261,22 @@ pub fn curve(ui: &mut Ui, curves: Option<&YieldCurves>) -> Vec<Action> {
|
||||
out
|
||||
}
|
||||
|
||||
/// Merge catalog matches with network hits, catalog first, without duplicates.
|
||||
pub fn merged_hits(query: &str, network: &[SearchHit]) -> Vec<SearchHit> {
|
||||
/// A search hit for a HIP-3 perp, labelled with its venue.
|
||||
pub fn perp_hit(key: &str) -> SearchHit {
|
||||
let name = tr_core::perps::link_for(key).map_or_else(
|
||||
|| format!("Hyperliquid {} perp", tr_core::perps::venue(key).unwrap_or("HIP-3")),
|
||||
|l| l.name.to_string(),
|
||||
);
|
||||
SearchHit { key: key.to_string(), name, class: AssetClass::Perp, exchange: Some(tr_core::perps::badge(key)) }
|
||||
}
|
||||
|
||||
/// Merge catalog matches, then HIP-3 perps among `perps`, then network hits, without duplicates.
|
||||
pub fn merged_hits(query: &str, network: &[SearchHit], perps: &[&str]) -> Vec<SearchHit> {
|
||||
let mut out: Vec<SearchHit> = catalog::search(query, 30)
|
||||
.into_iter()
|
||||
.map(|l| SearchHit { key: l.key(), name: l.name.to_string(), class: l.class, exchange: None })
|
||||
.collect();
|
||||
out.extend(tr_core::perps::search(query, perps.iter().copied(), 10).into_iter().map(perp_hit));
|
||||
for h in network {
|
||||
if !out.iter().any(|o| o.key == h.key) {
|
||||
out.push(h.clone());
|
||||
@@ -284,11 +294,12 @@ pub fn class_color(c: AssetClass) -> Color32 {
|
||||
AssetClass::Crypto => Color32::from_rgb(255, 150, 60),
|
||||
AssetClass::Govt => Color32::from_rgb(190, 160, 255),
|
||||
AssetClass::Cmdty => Color32::from_rgb(230, 200, 90),
|
||||
AssetClass::Perp => theme::perp(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Security search panel (`SECF <query>`).
|
||||
pub fn search(ui: &mut Ui, query: &mut String, network: &[SearchHit]) -> (Vec<Action>, bool) {
|
||||
/// Security search panel (`SECF <query>`). `perps` are the HIP-3 perps it can offer.
|
||||
pub fn search(ui: &mut Ui, query: &mut String, network: &[SearchHit], perps: &[&str]) -> (Vec<Action>, bool) {
|
||||
let mut out = Vec::new();
|
||||
let mut changed = false;
|
||||
ui.horizontal(|ui| {
|
||||
@@ -306,7 +317,7 @@ pub fn search(ui: &mut Ui, query: &mut String, network: &[SearchHit]) -> (Vec<Ac
|
||||
.color(amber())
|
||||
.background_color(theme::header()),
|
||||
);
|
||||
let hits = merged_hits(query, network);
|
||||
let hits = merged_hits(query, network, perps);
|
||||
if hits.is_empty() && !query.trim().is_empty() {
|
||||
ui.label(RichText::new("No matches.").font(mono(12.0)).color(muted()));
|
||||
}
|
||||
@@ -374,8 +385,20 @@ mod tests {
|
||||
exchange: None,
|
||||
},
|
||||
];
|
||||
let m = merged_hits("bitcoin", &net);
|
||||
let m = merged_hits("bitcoin", &net, &[]);
|
||||
assert_eq!(m.iter().filter(|h| h.key == "BTCUSD Curncy").count(), 1);
|
||||
assert!(m.iter().any(|h| h.key == "BITO US Equity"));
|
||||
}
|
||||
|
||||
/// The listed instrument comes first; its perp follows, labelled as one.
|
||||
#[test]
|
||||
fn search_offers_perps_after_listed_instruments() {
|
||||
let m = merged_hits("aapl", &[], &["xyz:AAPL", "xyz:GOLD"]);
|
||||
let keys: Vec<&str> = m.iter().map(|h| h.key.as_str()).collect();
|
||||
assert_eq!(keys, ["AAPL US Equity", "xyz:AAPL"]);
|
||||
assert_eq!((m[1].class, m[1].name.as_str()), (AssetClass::Perp, "Apple Inc perp"));
|
||||
assert_eq!(m[1].exchange.as_deref(), Some("PERP · HL xyz"));
|
||||
assert_eq!(perp_hit("xyz:SKHX").name, "Hyperliquid xyz perp");
|
||||
assert!(merged_hits("aapl", &[], &[]).iter().all(|h| h.class != AssetClass::Perp));
|
||||
}
|
||||
}
|
||||
+2
-2
@@ -183,7 +183,7 @@ pub struct Palette {
|
||||
impl Palette {
|
||||
/// Load recent picks from `path` (missing or unreadable files start empty).
|
||||
pub fn load(path: &Path) -> Self {
|
||||
let recents = std::fs::read(path).ok().and_then(|b| serde_json::from_slice(&b).ok()).unwrap_or_default();
|
||||
let recents = crate::storage::read(path).ok().and_then(|b| serde_json::from_slice(&b).ok()).unwrap_or_default();
|
||||
Self { recents, path: Some(path.to_path_buf()), ..Default::default() }
|
||||
}
|
||||
|
||||
@@ -230,7 +230,7 @@ impl Palette {
|
||||
self.recents.truncate(MAX_RECENTS);
|
||||
if let Some(path) = &self.path
|
||||
&& let Ok(json) = serde_json::to_vec_pretty(&self.recents)
|
||||
&& let Err(e) = crate::layouts::write_atomic(path, &json)
|
||||
&& let Err(e) = crate::storage::write_atomic(path, &json)
|
||||
{
|
||||
tracing::warn!("saving palette recents: {e}");
|
||||
}
|
||||
|
||||
+83
-3
@@ -216,6 +216,59 @@ pub struct TradingPrefs {
|
||||
pub max_order_value: Option<f64>,
|
||||
/// Account the ticket preselects.
|
||||
pub default_account: Option<String>,
|
||||
/// Hyperliquid (testnet only in this build), with its own opt-in.
|
||||
pub hl: HlPrefs,
|
||||
}
|
||||
|
||||
/// Hyperliquid order entry. Separate from SnapTrade's `enabled`: turning one on never turns on
|
||||
/// the other. Holds only public data: the agent's private key lives in the OS secret store.
|
||||
#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct HlPrefs {
|
||||
/// Orders may be signed and sent (after the acknowledgement dialog). Off by default, and
|
||||
/// turned off again by the kill switch.
|
||||
pub enabled: bool,
|
||||
/// Always testnet: any stored value (`"mainnet"` included) reads as testnet.
|
||||
pub network: tr_hl::Network,
|
||||
/// The account (master address) shown in the portfolio panes, read-only until an agent
|
||||
/// is approved.
|
||||
pub master: Option<String>,
|
||||
/// The approved agent's public details (never its key).
|
||||
pub agent: Option<HlAgent>,
|
||||
/// The built-in wallet, once signed in (its address is `master`). Public details only:
|
||||
/// Turnkey holds the wallet's key, and the session lives in the OS secret store.
|
||||
pub wallet: Option<HlWallet>,
|
||||
/// Keep a `scheduleCancel` about a minute ahead while the app is open, so open orders are
|
||||
/// cancelled if Terminal disappears. Opt-in: it cancels orders placed elsewhere too.
|
||||
pub dead_mans_switch: bool,
|
||||
/// Web only: keep the agent key in this browser (encrypted with a key the page can't
|
||||
/// export) instead of only for the session.
|
||||
pub remember_on_browser: bool,
|
||||
}
|
||||
|
||||
/// The user's built-in wallet on Turnkey, as Terminal remembers it.
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct HlWallet {
|
||||
/// Lower-case `0x…`: the Hyperliquid account.
|
||||
pub address: String,
|
||||
/// The user's Turnkey sub-organization.
|
||||
pub organization_id: String,
|
||||
/// Turnkey's name for the user (`whoami`), shown so a wrong account is noticed.
|
||||
#[serde(default)]
|
||||
pub user: String,
|
||||
}
|
||||
|
||||
/// An approved API wallet, as Terminal remembers it.
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct HlAgent {
|
||||
/// Lower-case `0x…`.
|
||||
pub address: String,
|
||||
/// `terminal-<device> valid_until <ms>`.
|
||||
pub name: String,
|
||||
/// Expiry, Unix milliseconds.
|
||||
pub valid_until: u64,
|
||||
#[serde(default)]
|
||||
pub network: tr_hl::Network,
|
||||
}
|
||||
|
||||
/// Every rebindable action.
|
||||
@@ -233,6 +286,7 @@ pub enum Action {
|
||||
Orders,
|
||||
Buy,
|
||||
Sell,
|
||||
TogglePerp,
|
||||
NextPane,
|
||||
PrevPane,
|
||||
ToggleSidebar,
|
||||
@@ -276,7 +330,7 @@ pub enum Action {
|
||||
}
|
||||
|
||||
impl Action {
|
||||
pub const ALL: [Action; 52] = [
|
||||
pub const ALL: [Action; 53] = [
|
||||
Action::Help,
|
||||
Action::Congress,
|
||||
Action::Politicians,
|
||||
@@ -289,6 +343,7 @@ impl Action {
|
||||
Action::Orders,
|
||||
Action::Buy,
|
||||
Action::Sell,
|
||||
Action::TogglePerp,
|
||||
Action::NextPane,
|
||||
Action::PrevPane,
|
||||
Action::ToggleSidebar,
|
||||
@@ -345,6 +400,7 @@ impl Action {
|
||||
Action::Orders => "Brokerage orders".into(),
|
||||
Action::Buy => "Buy the current security".into(),
|
||||
Action::Sell => "Sell the current security".into(),
|
||||
Action::TogglePerp => "Switch between a security and its HIP-3 perp".into(),
|
||||
Action::NextPane => "Next pane".into(),
|
||||
Action::PrevPane => "Previous pane".into(),
|
||||
Action::ToggleSidebar => "Toggle sidebar".into(),
|
||||
@@ -585,6 +641,7 @@ impl Default for Keymap {
|
||||
// Shift keeps plain typing free for the type-anywhere command line.
|
||||
bind(Action::Buy, "Shift+B");
|
||||
bind(Action::Sell, "Shift+S");
|
||||
bind(Action::TogglePerp, "Shift+P");
|
||||
bind(Action::NextPane, "Tab");
|
||||
bind(Action::PrevPane, "Shift+Tab");
|
||||
bind(Action::ToggleSidebar, if mac { "Ctrl+Cmd+S" } else { "Ctrl+Shift+S" });
|
||||
@@ -684,7 +741,8 @@ impl Default for Prefs {
|
||||
|
||||
impl Prefs {
|
||||
pub fn load(path: &std::path::Path) -> Prefs {
|
||||
let mut p: Prefs = std::fs::read(path).ok().and_then(|b| serde_json::from_slice(&b).ok()).unwrap_or_default();
|
||||
let mut p: Prefs =
|
||||
crate::storage::read(path).ok().and_then(|b| serde_json::from_slice(&b).ok()).unwrap_or_default();
|
||||
p.migrate();
|
||||
p.hotkeys = p.hotkeys.with_defaults();
|
||||
p.appearance.font_scale = p.appearance.font_scale.clamp(0.7, 1.6);
|
||||
@@ -756,7 +814,7 @@ impl Prefs {
|
||||
}
|
||||
|
||||
pub fn save(&self, path: &std::path::Path) -> std::io::Result<()> {
|
||||
crate::layouts::write_atomic(path, &serde_json::to_vec_pretty(self).map_err(std::io::Error::other)?)
|
||||
crate::storage::write_atomic(path, &serde_json::to_vec_pretty(self).map_err(std::io::Error::other)?)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -840,6 +898,28 @@ mod tests {
|
||||
std::fs::remove_dir_all(&dir).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hyperliquid_prefs_never_select_mainnet() {
|
||||
// A hand-edited or future file asking for mainnet still loads as testnet, and the rest
|
||||
// of the preferences survive.
|
||||
let json = r#"{"appearance":{"theme":"Nord"},"trading":{"enabled":true,"hl":{"enabled":true,"network":"mainnet",
|
||||
"master":"0xabc","agent":{"address":"0xdef","name":"terminal-mac valid_until 1","valid_until":1,"network":"Mainnet"}}}}"#;
|
||||
let p: Prefs = serde_json::from_str(json).unwrap();
|
||||
assert_eq!(p.trading.hl.network, tr_hl::Network::Testnet);
|
||||
assert_eq!(p.trading.hl.agent.as_ref().unwrap().network, tr_hl::Network::Testnet);
|
||||
assert!(p.trading.hl.enabled && p.trading.enabled);
|
||||
assert_eq!(p.appearance.theme, "Nord", "the rest of the file still loads");
|
||||
for v in ["\"MAINNET\"", "\"a\"", "7", "null", "[]"] {
|
||||
let p: Prefs = serde_json::from_str(&format!(r#"{{"trading":{{"hl":{{"network":{v}}}}}}}"#)).unwrap();
|
||||
assert_eq!(p.trading.hl.network, tr_hl::Network::Testnet, "{v}");
|
||||
}
|
||||
// Off by default, opt-in switches off, nothing secret in the file.
|
||||
let d = Prefs::default().trading.hl;
|
||||
assert!(!d.enabled && !d.dead_mans_switch && !d.remember_on_browser && d.agent.is_none());
|
||||
let saved = serde_json::to_string(&p).unwrap();
|
||||
assert!(saved.contains(r#""network":"Testnet""#) && !saved.contains("ainnet"), "{saved}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn v1_prefs_migrate_layout_bindings_to_alt() {
|
||||
// A v1 file: layouts on ⌘1…, one customised, no panel actions yet.
|
||||
|
||||
@@ -0,0 +1,469 @@
|
||||
//! Where preferences, settings, layouts, themes, palette history and the demo account are
|
||||
//! kept: files under the data directory on the desktop, `localStorage` entries keyed by path
|
||||
//! in the browser. Every saved document is UTF-8 text (JSON or TOML).
|
||||
//!
|
||||
//! [`export`], [`import`] and [`reveal`] are for files the user names: on the desktop they are
|
||||
//! ordinary files and folders; in the browser an export is a download, and there is no import
|
||||
//! or folder yet.
|
||||
|
||||
use std::io;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
pub use imp::*;
|
||||
|
||||
/// Saved documents keyed by path, as the browser keeps them.
|
||||
#[cfg(any(target_arch = "wasm32", test))]
|
||||
trait KeyStore {
|
||||
fn keys(&self) -> Vec<String>;
|
||||
fn get(&self, key: &str) -> Option<String>;
|
||||
/// False when the store refuses the write (full or blocked).
|
||||
fn set(&mut self, key: &str, value: &str) -> bool;
|
||||
fn remove(&mut self, key: &str);
|
||||
}
|
||||
|
||||
/// Copies every key under `old/` to the same path under `new/`, once. Returns whether to use
|
||||
/// `new`. Keys already under `new/` are never overwritten, and the old keys stay as they are. A
|
||||
/// marker key records that the copy finished, so a file deleted afterwards doesn't come back on
|
||||
/// the next load. If the store refuses a write, the copies made so far are removed and this
|
||||
/// returns false, so the page keeps its complete old set and the next load tries again.
|
||||
#[cfg(any(target_arch = "wasm32", test))]
|
||||
fn copy_keys(store: &mut impl KeyStore, old: &str, new: &str) -> bool {
|
||||
let marker = marker(new);
|
||||
if store.get(&marker).is_some() {
|
||||
return true;
|
||||
}
|
||||
let keys: Vec<(String, String)> = store
|
||||
.keys()
|
||||
.into_iter()
|
||||
.filter_map(|k| Some((format!("{new}/{}", k.strip_prefix(old)?.strip_prefix('/')?), k)))
|
||||
.collect();
|
||||
if keys.is_empty() {
|
||||
return true;
|
||||
}
|
||||
let mut copied = Vec::new();
|
||||
let mut ok = true;
|
||||
for (to, from) in keys {
|
||||
if store.get(&to).is_some() {
|
||||
continue;
|
||||
}
|
||||
let Some(value) = store.get(&from) else { continue };
|
||||
if !store.set(&to, &value) {
|
||||
ok = false;
|
||||
break;
|
||||
}
|
||||
copied.push(to);
|
||||
}
|
||||
if ok && store.set(&marker, old) {
|
||||
tracing::info!("copied {} saved files from {old}/ to {new}/", copied.len());
|
||||
return true;
|
||||
}
|
||||
for k in &copied {
|
||||
store.remove(k);
|
||||
}
|
||||
tracing::warn!("browser storage refused copying saved files from {old}/ to {new}/; using {old}/ for now");
|
||||
false
|
||||
}
|
||||
|
||||
/// The key [`copy_keys`] sets once everything under `new/` is in place.
|
||||
#[cfg(any(target_arch = "wasm32", test))]
|
||||
fn marker(new: &str) -> String {
|
||||
format!("{new}/.migrated")
|
||||
}
|
||||
|
||||
/// Where a page that kept the old prefix (because [`copy_keys`] failed) should read and write
|
||||
/// `key`. Once another tab has finished the copy, keys under `old` map to `new`, so this page's
|
||||
/// saves land where later loads look instead of under keys they no longer read.
|
||||
#[cfg(any(target_arch = "wasm32", test))]
|
||||
fn redirect(store: &impl KeyStore, old: &str, new: &str, key: String) -> String {
|
||||
match key.strip_prefix(old) {
|
||||
Some(rest) if (rest.is_empty() || rest.starts_with('/')) && store.get(&marker(new)).is_some() => {
|
||||
format!("{new}{rest}")
|
||||
}
|
||||
_ => key,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
mod imp {
|
||||
use super::*;
|
||||
|
||||
/// Moves the data folder from `old` (its name before the app was renamed) to `new` the first
|
||||
/// time, and leaves a link at the old path so builds that still use the old name find the
|
||||
/// same files. Returns the folder to use: `new`, or `old` if it couldn't be moved. Never
|
||||
/// replaces an existing `new`.
|
||||
pub fn migrate(old: &Path, new: &Path) -> PathBuf {
|
||||
if new.exists() || !old.is_dir() {
|
||||
return new.to_path_buf();
|
||||
}
|
||||
if let Err(e) = std::fs::rename(old, new) {
|
||||
// Another copy of the app may have moved it first.
|
||||
if new.is_dir() {
|
||||
return new.to_path_buf();
|
||||
}
|
||||
tracing::warn!("could not move {} to {} ({e}); using it where it is", old.display(), new.display());
|
||||
return old.to_path_buf();
|
||||
}
|
||||
tracing::info!("moved {} to {}", old.display(), new.display());
|
||||
#[cfg(unix)]
|
||||
{
|
||||
// Name the sibling rather than repeating `new`, which may be relative (an unset HOME
|
||||
// or a relative XDG_DATA_HOME) and would then resolve from the link's folder.
|
||||
let target = match new.file_name() {
|
||||
Some(name) if old.parent() == new.parent() => PathBuf::from(name),
|
||||
_ => std::path::absolute(new).unwrap_or_else(|_| new.to_path_buf()),
|
||||
};
|
||||
if let Err(e) = std::os::unix::fs::symlink(&target, old) {
|
||||
tracing::warn!("linking {} to {}: {e}", old.display(), target.display());
|
||||
}
|
||||
}
|
||||
new.to_path_buf()
|
||||
}
|
||||
|
||||
pub fn read(path: &Path) -> io::Result<Vec<u8>> {
|
||||
std::fs::read(path)
|
||||
}
|
||||
|
||||
/// Write via a temp file and rename, so a crash never leaves a half-written file.
|
||||
pub fn write_atomic(path: &Path, bytes: &[u8]) -> io::Result<()> {
|
||||
if let Some(dir) = path.parent() {
|
||||
std::fs::create_dir_all(dir)?;
|
||||
}
|
||||
let tmp = path.with_extension("tmp");
|
||||
std::fs::write(&tmp, bytes)?;
|
||||
std::fs::rename(tmp, path)
|
||||
}
|
||||
|
||||
/// Entries directly inside `dir` (none if it doesn't exist).
|
||||
pub fn list(dir: &Path) -> Vec<PathBuf> {
|
||||
std::fs::read_dir(dir).into_iter().flatten().flatten().map(|e| e.path()).collect()
|
||||
}
|
||||
|
||||
pub fn exists(path: &Path) -> bool {
|
||||
path.exists()
|
||||
}
|
||||
|
||||
pub fn remove(path: &Path) -> io::Result<()> {
|
||||
std::fs::remove_file(path)
|
||||
}
|
||||
|
||||
pub fn rename(from: &Path, to: &Path) -> io::Result<()> {
|
||||
std::fs::rename(from, to)
|
||||
}
|
||||
|
||||
/// Write a file the user exports. Returns where it went, for the status line.
|
||||
pub fn export(path: &Path, bytes: &[u8]) -> io::Result<String> {
|
||||
write_atomic(path, bytes)?;
|
||||
Ok(path.display().to_string())
|
||||
}
|
||||
|
||||
/// Read a file the user imports.
|
||||
pub fn import(path: &Path) -> io::Result<Vec<u8>> {
|
||||
std::fs::read(path)
|
||||
}
|
||||
|
||||
/// Open a folder in the system file manager, creating it first.
|
||||
pub fn reveal(dir: &Path) -> io::Result<()> {
|
||||
std::fs::create_dir_all(dir)?;
|
||||
let opener = if cfg!(target_os = "macos") { "open" } else { "xdg-open" };
|
||||
std::process::Command::new(opener).arg(dir).spawn().map(|_| ())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
mod imp {
|
||||
use super::*;
|
||||
|
||||
fn store() -> io::Result<web_sys::Storage> {
|
||||
web_sys::window()
|
||||
.and_then(|w| w.local_storage().ok().flatten())
|
||||
.ok_or_else(|| io::Error::new(io::ErrorKind::Unsupported, "browser storage is unavailable"))
|
||||
}
|
||||
|
||||
thread_local! {
|
||||
/// The old and new prefixes while this page keeps the old one (see [`migrate`]).
|
||||
static FALLBACK: std::cell::RefCell<Option<(String, String)>> = const { std::cell::RefCell::new(None) };
|
||||
}
|
||||
|
||||
fn key(path: &Path) -> String {
|
||||
let key = path.to_string_lossy().into_owned();
|
||||
let Some((old, new)) = FALLBACK.with_borrow(Clone::clone) else { return key };
|
||||
match store() {
|
||||
Ok(s) => redirect(&s, &old, &new, key),
|
||||
Err(_) => key,
|
||||
}
|
||||
}
|
||||
|
||||
impl KeyStore for web_sys::Storage {
|
||||
fn keys(&self) -> Vec<String> {
|
||||
(0..self.length().unwrap_or(0)).filter_map(|i| self.key(i).ok().flatten()).collect()
|
||||
}
|
||||
fn get(&self, key: &str) -> Option<String> {
|
||||
self.get_item(key).ok().flatten()
|
||||
}
|
||||
fn set(&mut self, key: &str, value: &str) -> bool {
|
||||
self.set_item(key, value).is_ok()
|
||||
}
|
||||
fn remove(&mut self, key: &str) {
|
||||
let _ = self.remove_item(key);
|
||||
}
|
||||
}
|
||||
|
||||
/// Copies what the page saved under `old/` (its prefix before the app was renamed) to
|
||||
/// `new/`, once (see [`copy_keys`]). Returns the prefix to use. If the copy fails, the page
|
||||
/// keeps `old`, and its keys follow [`redirect`] from then on, in case another tab finishes
|
||||
/// the copy while this one is open.
|
||||
pub fn migrate(old: &Path, new: &Path) -> PathBuf {
|
||||
let Ok(mut s) = store() else { return new.to_path_buf() };
|
||||
let (old, new) = (key(old), key(new));
|
||||
if copy_keys(&mut s, &old, &new) {
|
||||
return PathBuf::from(new);
|
||||
}
|
||||
FALLBACK.with_borrow_mut(|f| *f = Some((old.clone(), new)));
|
||||
PathBuf::from(old)
|
||||
}
|
||||
|
||||
pub fn read(path: &Path) -> io::Result<Vec<u8>> {
|
||||
match store()?.get_item(&key(path)) {
|
||||
Ok(Some(text)) => Ok(text.into_bytes()),
|
||||
Ok(None) => Err(io::ErrorKind::NotFound.into()),
|
||||
Err(_) => Err(io::Error::other("browser storage refused the read")),
|
||||
}
|
||||
}
|
||||
|
||||
/// A single `setItem`, which is already all-or-nothing.
|
||||
pub fn write_atomic(path: &Path, bytes: &[u8]) -> io::Result<()> {
|
||||
let text = std::str::from_utf8(bytes).map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
|
||||
store()?.set_item(&key(path), text).map_err(|_| io::Error::other("browser storage is full or blocked"))
|
||||
}
|
||||
|
||||
/// Keys directly under `dir/`.
|
||||
pub fn list(dir: &Path) -> Vec<PathBuf> {
|
||||
let Ok(s) = store() else { return Vec::new() };
|
||||
let prefix = format!("{}/", key(dir).trim_end_matches('/'));
|
||||
(0..s.length().unwrap_or(0))
|
||||
.filter_map(|i| s.key(i).ok().flatten())
|
||||
.filter(|k| k.strip_prefix(&prefix).is_some_and(|rest| !rest.is_empty() && !rest.contains('/')))
|
||||
.map(PathBuf::from)
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn exists(path: &Path) -> bool {
|
||||
store().ok().and_then(|s| s.get_item(&key(path)).ok().flatten()).is_some()
|
||||
}
|
||||
|
||||
pub fn remove(path: &Path) -> io::Result<()> {
|
||||
if !exists(path) {
|
||||
return Err(io::ErrorKind::NotFound.into());
|
||||
}
|
||||
store()?.remove_item(&key(path)).map_err(|_| io::Error::other("browser storage refused the delete"))
|
||||
}
|
||||
|
||||
pub fn rename(from: &Path, to: &Path) -> io::Result<()> {
|
||||
write_atomic(to, &read(from)?)?;
|
||||
remove(from)
|
||||
}
|
||||
|
||||
/// Download the exported file under its file name. Returns where it went, for the status line.
|
||||
pub fn export(path: &Path, bytes: &[u8]) -> io::Result<String> {
|
||||
use wasm_bindgen::JsCast;
|
||||
let fail = |what: &str| io::Error::other(format!("the browser refused the download ({what})"));
|
||||
let name = path.file_name().map_or_else(|| "export".into(), |n| n.to_string_lossy().into_owned());
|
||||
let doc = web_sys::window().and_then(|w| w.document()).ok_or_else(|| fail("no document"))?;
|
||||
let parts = js_sys::Array::of1(&js_sys::Uint8Array::from(bytes));
|
||||
let blob = web_sys::Blob::new_with_u8_array_sequence(&parts).map_err(|_| fail("blob"))?;
|
||||
let url = web_sys::Url::create_object_url_with_blob(&blob).map_err(|_| fail("object URL"))?;
|
||||
let link: web_sys::HtmlAnchorElement =
|
||||
doc.create_element("a").map_err(|_| fail("link"))?.dyn_into().map_err(|_| fail("link"))?;
|
||||
link.set_href(&url);
|
||||
link.set_download(&name);
|
||||
link.click();
|
||||
let _ = web_sys::Url::revoke_object_url(&url);
|
||||
Ok(format!("your downloads as {name}"))
|
||||
}
|
||||
|
||||
// TODO(web): read imports through a file picker instead of a typed path.
|
||||
pub fn import(_path: &Path) -> io::Result<Vec<u8>> {
|
||||
Err(io::Error::new(io::ErrorKind::Unsupported, "importing files needs the desktop app for now"))
|
||||
}
|
||||
|
||||
pub fn reveal(_dir: &Path) -> io::Result<()> {
|
||||
Err(io::Error::new(io::ErrorKind::Unsupported, "the browser keeps these in its local storage, not a folder"))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(all(test, not(target_arch = "wasm32")))]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn round_trip() {
|
||||
let dir = std::env::temp_dir().join(format!("tr-storage-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
let a = dir.join("sub/a.json");
|
||||
assert!(list(&dir.join("sub")).is_empty());
|
||||
assert_eq!(read(&a).unwrap_err().kind(), io::ErrorKind::NotFound);
|
||||
write_atomic(&a, b"{}").unwrap();
|
||||
assert!(exists(&a));
|
||||
assert_eq!(list(&dir.join("sub")), vec![a.clone()]);
|
||||
let b = dir.join("sub/b.json");
|
||||
rename(&a, &b).unwrap();
|
||||
assert!(!exists(&a));
|
||||
assert_eq!(read(&b).unwrap(), b"{}");
|
||||
remove(&b).unwrap();
|
||||
assert!(list(&dir.join("sub")).is_empty());
|
||||
std::fs::remove_dir_all(&dir).unwrap();
|
||||
}
|
||||
|
||||
fn scratch(name: &str) -> PathBuf {
|
||||
let dir = std::env::temp_dir().join(format!("tr-migrate-{name}-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
dir
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn migrate_moves_the_old_folder_and_links_it() {
|
||||
let root = scratch("move");
|
||||
let (old, new) = (root.join("terminal-rs"), root.join("terminal"));
|
||||
write_atomic(&old.join("layouts/Desk.json"), b"{}").unwrap();
|
||||
assert_eq!(migrate(&old, &new), new);
|
||||
assert_eq!(read(&new.join("layouts/Desk.json")).unwrap(), b"{}");
|
||||
#[cfg(unix)]
|
||||
{
|
||||
assert_eq!(std::fs::read_link(&old).unwrap(), Path::new("terminal"), "relative to the link's folder");
|
||||
assert_eq!(read(&old.join("layouts/Desk.json")).unwrap(), b"{}", "old builds still find the files");
|
||||
}
|
||||
// Later launches find the new folder and leave everything as it is.
|
||||
write_atomic(&new.join("settings.json"), b"[]").unwrap();
|
||||
assert_eq!(migrate(&old, &new), new);
|
||||
assert_eq!(read(&new.join("settings.json")).unwrap(), b"[]");
|
||||
std::fs::remove_dir_all(&root).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn migrate_never_replaces_an_existing_folder() {
|
||||
let root = scratch("keep");
|
||||
let (old, new) = (root.join("terminal-rs"), root.join("terminal"));
|
||||
write_atomic(&old.join("settings.json"), b"old").unwrap();
|
||||
write_atomic(&new.join("settings.json"), b"new").unwrap();
|
||||
assert_eq!(migrate(&old, &new), new);
|
||||
assert_eq!(read(&new.join("settings.json")).unwrap(), b"new");
|
||||
assert_eq!(read(&old.join("settings.json")).unwrap(), b"old", "the old folder is left alone");
|
||||
std::fs::remove_dir_all(&root).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn migrate_without_an_old_folder_creates_nothing() {
|
||||
let root = scratch("fresh");
|
||||
let (old, new) = (root.join("terminal-rs"), root.join("terminal"));
|
||||
assert_eq!(migrate(&old, &new), new);
|
||||
assert!(!old.exists() && !new.exists());
|
||||
std::fs::remove_dir_all(&root).unwrap();
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn migrate_keeps_using_the_old_folder_when_it_cannot_move() {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let root = scratch("stuck");
|
||||
let (old, new) = (root.join("terminal-rs"), root.join("terminal"));
|
||||
write_atomic(&old.join("settings.json"), b"{}").unwrap();
|
||||
std::fs::set_permissions(&root, std::fs::Permissions::from_mode(0o555)).unwrap();
|
||||
if std::fs::create_dir(root.join("probe")).is_ok() {
|
||||
// Running as root, where a read-only folder doesn't stop the move.
|
||||
std::fs::set_permissions(&root, std::fs::Permissions::from_mode(0o755)).unwrap();
|
||||
std::fs::remove_dir_all(&root).unwrap();
|
||||
return;
|
||||
}
|
||||
let used = migrate(&old, &new);
|
||||
std::fs::set_permissions(&root, std::fs::Permissions::from_mode(0o755)).unwrap();
|
||||
assert_eq!(used, old);
|
||||
assert_eq!(read(&old.join("settings.json")).unwrap(), b"{}");
|
||||
std::fs::remove_dir_all(&root).unwrap();
|
||||
}
|
||||
|
||||
/// `localStorage` stand-in: a map that refuses writes once it holds `room` keys.
|
||||
struct Keys {
|
||||
map: std::collections::BTreeMap<String, String>,
|
||||
room: usize,
|
||||
}
|
||||
|
||||
impl Keys {
|
||||
fn new(entries: &[(&str, &str)]) -> Self {
|
||||
Self { map: entries.iter().map(|(k, v)| (k.to_string(), v.to_string())).collect(), room: usize::MAX }
|
||||
}
|
||||
}
|
||||
|
||||
impl KeyStore for Keys {
|
||||
fn keys(&self) -> Vec<String> {
|
||||
self.map.keys().cloned().collect()
|
||||
}
|
||||
fn get(&self, key: &str) -> Option<String> {
|
||||
self.map.get(key).cloned()
|
||||
}
|
||||
fn set(&mut self, key: &str, value: &str) -> bool {
|
||||
if !self.map.contains_key(key) && self.map.len() >= self.room {
|
||||
return false;
|
||||
}
|
||||
self.map.insert(key.into(), value.into());
|
||||
true
|
||||
}
|
||||
fn remove(&mut self, key: &str) {
|
||||
self.map.remove(key);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn copy_keys_copies_once_without_overwriting() {
|
||||
let mut s = Keys::new(&[
|
||||
("terminal-rs/settings.json", "old settings"),
|
||||
("terminal-rs/layouts/Desk.json", "desk"),
|
||||
("terminal/settings.json", "newer settings"),
|
||||
("other-site-key", "untouched"),
|
||||
]);
|
||||
assert!(copy_keys(&mut s, "terminal-rs", "terminal"));
|
||||
assert_eq!(s.get("terminal/settings.json").as_deref(), Some("newer settings"), "newer data wins");
|
||||
assert_eq!(s.get("terminal/layouts/Desk.json").as_deref(), Some("desk"));
|
||||
assert_eq!(s.get("terminal-rs/layouts/Desk.json").as_deref(), Some("desk"), "the old keys stay");
|
||||
assert_eq!(s.get("other-site-key").as_deref(), Some("untouched"));
|
||||
// A layout deleted after the copy doesn't come back on the next load.
|
||||
s.remove("terminal/layouts/Desk.json");
|
||||
assert!(copy_keys(&mut s, "terminal-rs", "terminal"));
|
||||
assert_eq!(s.get("terminal/layouts/Desk.json"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn copy_keys_undoes_a_partial_copy() {
|
||||
let mut s = Keys::new(&[("terminal-rs/a.json", "a"), ("terminal-rs/b.json", "b")]);
|
||||
s.room = 3;
|
||||
assert!(!copy_keys(&mut s, "terminal-rs", "terminal"), "storage is full: keep using the old keys");
|
||||
assert_eq!(s.keys(), ["terminal-rs/a.json", "terminal-rs/b.json"]);
|
||||
s.room = usize::MAX;
|
||||
assert!(copy_keys(&mut s, "terminal-rs", "terminal"));
|
||||
assert_eq!(s.get("terminal/b.json").as_deref(), Some("b"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_page_left_on_the_old_prefix_follows_another_tabs_copy() {
|
||||
let mut s = Keys::new(&[("terminal-rs/settings.json", "a")]);
|
||||
s.room = 1;
|
||||
// Tab A can't copy, so it keeps the old prefix.
|
||||
assert!(!copy_keys(&mut s, "terminal-rs", "terminal"));
|
||||
let key = |s: &Keys, k: &str| redirect(s, "terminal-rs", "terminal", k.into());
|
||||
assert_eq!(key(&s, "terminal-rs/settings.json"), "terminal-rs/settings.json");
|
||||
// Space frees up and tab B finishes the copy; tab A's saves now go where loads read.
|
||||
s.room = usize::MAX;
|
||||
assert!(copy_keys(&mut s, "terminal-rs", "terminal"));
|
||||
assert_eq!(key(&s, "terminal-rs/settings.json"), "terminal/settings.json");
|
||||
assert_eq!(key(&s, "terminal-rs/layouts"), "terminal/layouts");
|
||||
assert_eq!(key(&s, "terminal-rs"), "terminal");
|
||||
assert_eq!(key(&s, "terminal-rsx/a.json"), "terminal-rsx/a.json", "only that prefix");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn copy_keys_with_nothing_saved_writes_nothing() {
|
||||
let mut s = Keys::new(&[]);
|
||||
assert!(copy_keys(&mut s, "terminal-rs", "terminal"));
|
||||
assert!(s.keys().is_empty());
|
||||
}
|
||||
}
|
||||
+46
-1
@@ -53,6 +53,9 @@ thread_local! {
|
||||
static DENSITY: Cell<f32> = const { Cell::new(1.0) };
|
||||
static UI_MONO: Cell<bool> = const { Cell::new(false) };
|
||||
static FONTS: Cell<Option<MonoFont>> = const { Cell::new(None) };
|
||||
/// Fonts fetched after start-up (the browser build's emoji fallbacks).
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
static LATE_FONTS: std::cell::RefCell<Vec<(String, std::sync::Arc<FontData>)>> = const { std::cell::RefCell::new(Vec::new()) };
|
||||
}
|
||||
|
||||
/// Spacing multiplier from the density preference (compact < 1 < comfortable).
|
||||
@@ -138,6 +141,12 @@ pub fn accent_wash() -> Color32 {
|
||||
Color32::from_rgba_unmultiplied(a.r(), a.g(), a.b(), if palette().dark { 70 } else { 45 })
|
||||
}
|
||||
|
||||
/// HIP-3 perp badges and labels: a colour no listed instrument uses, so a perp never reads as
|
||||
/// the stock, future or FX rate it references.
|
||||
pub fn perp() -> Color32 {
|
||||
if palette().dark { Color32::from_rgb(196, 150, 255) } else { Color32::from_rgb(118, 64, 196) }
|
||||
}
|
||||
|
||||
pub fn dem() -> Color32 {
|
||||
if palette().dark { Color32::from_rgb(64, 156, 255) } else { Color32::from_rgb(0, 102, 214) }
|
||||
}
|
||||
@@ -176,13 +185,49 @@ pub fn ui_font(size: f32) -> FontId {
|
||||
pub const CARD_RADIUS: u8 = 10;
|
||||
pub const CONTROL_RADIUS: u8 = 6;
|
||||
|
||||
/// egui's bundled fonts. The browser build starts with only the two text fonts, which keeps
|
||||
/// the emoji fonts out of the download; they arrive later through [`add_late_font`].
|
||||
pub fn base_fonts() -> FontDefinitions {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
let fonts = FontDefinitions::default();
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
let fonts = {
|
||||
use std::sync::Arc;
|
||||
let mut f = FontDefinitions::empty();
|
||||
f.font_data.insert("Hack".into(), Arc::new(FontData::from_static(epaint_default_fonts::HACK_REGULAR)));
|
||||
f.font_data.insert("Ubuntu-Light".into(), Arc::new(FontData::from_static(epaint_default_fonts::UBUNTU_LIGHT)));
|
||||
f.families.insert(FontFamily::Monospace, vec!["Hack".into(), "Ubuntu-Light".into()]);
|
||||
f.families.insert(FontFamily::Proportional, vec!["Ubuntu-Light".into()]);
|
||||
LATE_FONTS.with_borrow(|late| {
|
||||
for (name, data) in late {
|
||||
f.font_data.insert(name.clone(), data.clone());
|
||||
for family in [FontFamily::Proportional, FontFamily::Monospace] {
|
||||
f.families.entry(family).or_default().push(name.clone());
|
||||
}
|
||||
}
|
||||
});
|
||||
f
|
||||
};
|
||||
fonts
|
||||
}
|
||||
|
||||
/// Add a font fetched after start-up as the last fallback of every family.
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
pub fn add_late_font(ctx: &egui::Context, name: &str, data: FontData) {
|
||||
LATE_FONTS.with_borrow_mut(|late| late.push((name.to_owned(), std::sync::Arc::new(data))));
|
||||
// Reinstall with the current monospace choice (or let the first configure pick it up).
|
||||
if let Some(mono) = FONTS.take() {
|
||||
install_fonts(ctx, mono);
|
||||
}
|
||||
}
|
||||
|
||||
/// Use the system UI fonts when present (read from the OS at runtime, not bundled).
|
||||
fn install_fonts(ctx: &egui::Context, mono: MonoFont) {
|
||||
if FONTS.with(Cell::get) == Some(mono) {
|
||||
return;
|
||||
}
|
||||
FONTS.with(|f| f.set(Some(mono)));
|
||||
let mut fonts = FontDefinitions::default();
|
||||
let mut fonts = base_fonts();
|
||||
let ui_paths: &[&str] = &[
|
||||
"/System/Library/Fonts/SFNS.ttf",
|
||||
"/System/Library/Fonts/SFNSText.ttf",
|
||||
|
||||
+20
-12
@@ -10,6 +10,7 @@ use egui::Color32;
|
||||
use serde::{Deserialize, Deserializer, Serialize, Serializer};
|
||||
|
||||
use crate::prefs::{Density, MonoFont, UiFont};
|
||||
use crate::storage;
|
||||
use crate::theme::Palette;
|
||||
|
||||
/// Selection that follows the OS appearance using the configured dark/light pair.
|
||||
@@ -561,11 +562,8 @@ impl ThemeStore {
|
||||
/// built-in or earlier name is listed with a suffix rather than shadowing it.
|
||||
pub fn load_all(&self) -> Vec<Theme> {
|
||||
let mut out = builtins();
|
||||
let mut files: Vec<PathBuf> = std::fs::read_dir(&self.dir)
|
||||
let mut files: Vec<PathBuf> = storage::list(&self.dir)
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.flatten()
|
||||
.map(|e| e.path())
|
||||
.filter(|p| p.extension().is_some_and(|x| x == "toml" || x == "json"))
|
||||
.collect();
|
||||
files.sort();
|
||||
@@ -606,7 +604,7 @@ impl ThemeStore {
|
||||
.clone()
|
||||
.filter(|p| p.extension().is_some_and(|x| x == "toml"))
|
||||
.unwrap_or_else(|| self.path(&theme.name));
|
||||
crate::layouts::write_atomic(&path, theme.to_toml().as_bytes())
|
||||
storage::write_atomic(&path, theme.to_toml().as_bytes())
|
||||
.with_context(|| format!("writing {}", path.display()))?;
|
||||
Ok(path)
|
||||
}
|
||||
@@ -617,23 +615,33 @@ impl ThemeStore {
|
||||
bail!("built-in themes cannot be deleted");
|
||||
}
|
||||
let path = theme.source.clone().unwrap_or_else(|| self.path(&theme.name));
|
||||
if !path.exists() {
|
||||
if !storage::exists(&path) {
|
||||
bail!("no file for theme {:?}", theme.name);
|
||||
}
|
||||
Ok(std::fs::remove_file(path)?)
|
||||
Ok(storage::remove(&path)?)
|
||||
}
|
||||
|
||||
/// Write a theme to `to` as JSON (`.json`) or TOML (anything else).
|
||||
pub fn export(theme: &Theme, to: &Path) -> Result<()> {
|
||||
/// Write a theme to `to` as JSON (`.json`) or TOML (anything else); a download in the
|
||||
/// browser. Returns where it went.
|
||||
pub fn export(theme: &Theme, to: &Path) -> Result<String> {
|
||||
let json = to.extension().is_some_and(|x| x.eq_ignore_ascii_case("json"));
|
||||
let text = if json { theme.to_json() } else { theme.to_toml() };
|
||||
crate::layouts::write_atomic(to, text.as_bytes()).with_context(|| format!("writing {}", to.display()))
|
||||
storage::export(to, text.as_bytes()).with_context(|| format!("writing {}", to.display()))
|
||||
}
|
||||
}
|
||||
|
||||
/// Read a TOML or JSON theme file.
|
||||
/// Read a TOML or JSON theme file from the themes folder.
|
||||
pub fn load_file(path: &Path) -> Result<Theme> {
|
||||
let text = std::fs::read_to_string(path).with_context(|| format!("reading {}", path.display()))?;
|
||||
parse_file(path, storage::read(path).with_context(|| format!("reading {}", path.display()))?)
|
||||
}
|
||||
|
||||
/// Read a theme file the user imports.
|
||||
pub fn import_file(path: &Path) -> Result<Theme> {
|
||||
parse_file(path, storage::import(path).with_context(|| format!("reading {}", path.display()))?)
|
||||
}
|
||||
|
||||
fn parse_file(path: &Path, bytes: Vec<u8>) -> Result<Theme> {
|
||||
let text = String::from_utf8(bytes).with_context(|| format!("{} is not UTF-8 text", path.display()))?;
|
||||
let json = path.extension().is_some_and(|x| x.eq_ignore_ascii_case("json"));
|
||||
let mut t = Theme::parse(&text, json)?;
|
||||
t.source = Some(path.to_path_buf());
|
||||
|
||||
@@ -0,0 +1,404 @@
|
||||
//! The built-in wallet's background work: the desktop sign-in (a loopback listener the
|
||||
//! browser returns to), turning a Turnkey session into a signed-in wallet, the Turnkey
|
||||
//! session in the OS secret store, and `approveAgent` signed by the wallet.
|
||||
//!
|
||||
//! Turnkey signs only here, and only through `tr_hl::signing::sign_approve_agent_with`, which
|
||||
//! hands it the digest of a testnet approval and nothing else. Orders never come this way:
|
||||
//! they are signed by the agent key in `hl_worker`.
|
||||
|
||||
use std::sync::Arc;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
|
||||
use tr_broker::secrets::AgentKeyStore;
|
||||
use tr_data::rt;
|
||||
use tr_hl::model::SpotUsdc;
|
||||
use tr_hl::nonce::now_ms;
|
||||
use tr_hl::signing::sign_approve_agent_with;
|
||||
use tr_hl::{AgentKey, SigningKey, trader};
|
||||
use tr_turnkey::{Session, SessionKey};
|
||||
|
||||
use crate::hl_worker::{HlResponse, approve, revoke};
|
||||
use crate::prefs::HlAgent;
|
||||
|
||||
/// The sign-in that may still finish; starting another or cancelling bumps it.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
static SIGN_IN: AtomicU64 = AtomicU64::new(0);
|
||||
|
||||
/// Stop waiting for the browser (the listener closes within half a second).
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub fn cancel_sign_in() {
|
||||
SIGN_IN.fetch_add(1 << 32, Ordering::SeqCst);
|
||||
}
|
||||
|
||||
pub enum WalletRequest {
|
||||
/// Desktop: listen on 127.0.0.1, say which port, and wait for the browser's session.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
SignIn { id: u64, store: AgentKeyStore, key: SessionKey, state: String },
|
||||
/// Web: a session token for `key` from the sign-in page's message. Check it and read the
|
||||
/// wallet from Turnkey.
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
Finish { id: u64, store: AgentKeyStore, key: SessionKey, token: String },
|
||||
/// The session kept in the secret store, if it hasn't expired.
|
||||
LoadSession { store: AgentKeyStore, account: String },
|
||||
/// Forget the session on this device.
|
||||
EndSession { store: AgentKeyStore, account: String },
|
||||
/// Approve a fresh agent key, signed by the wallet.
|
||||
Approve { store: AgentKeyStore, session: Arc<Session>, master: String, device: String },
|
||||
/// Replace the agent on Hyperliquid (signed by the wallet) and delete its key here.
|
||||
Revoke { store: AgentKeyStore, session: Arc<Session>, master: String, base: String },
|
||||
/// Spot USDC for the Deposit window.
|
||||
Balances { user: String },
|
||||
}
|
||||
|
||||
/// A wallet the user just signed in to.
|
||||
pub struct SignedIn {
|
||||
pub session: Session,
|
||||
/// Lower-case `0x…`.
|
||||
pub address: String,
|
||||
pub user: String,
|
||||
/// `Err`: the session couldn't be kept in the secret store (it lasts until Terminal quits).
|
||||
pub saved: Result<(), String>,
|
||||
}
|
||||
|
||||
pub enum WalletResponse {
|
||||
/// The desktop listener is up: open the sign-in page with this port.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
Listening {
|
||||
id: u64,
|
||||
port: u16,
|
||||
},
|
||||
SignedIn {
|
||||
id: u64,
|
||||
result: Result<Box<SignedIn>, String>,
|
||||
},
|
||||
SessionLoaded {
|
||||
account: String,
|
||||
session: Option<Arc<Session>>,
|
||||
},
|
||||
Balances {
|
||||
user: String,
|
||||
result: Result<SpotUsdc, String>,
|
||||
},
|
||||
/// The wallet didn't sign the approval: nothing was sent, and the new key is dropped.
|
||||
ApproveNotSigned(String),
|
||||
/// The wallet didn't sign the revoke, so nothing changed: the agent key is still here.
|
||||
RevokeNotSigned(String),
|
||||
}
|
||||
|
||||
/// The secret store item for a wallet's Turnkey session.
|
||||
pub fn session_account(address: &str) -> String {
|
||||
format!("turnkey:testnet:{}", address.trim().to_ascii_lowercase())
|
||||
}
|
||||
|
||||
fn now_secs() -> u64 {
|
||||
web_time::SystemTime::now().duration_since(web_time::UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0)
|
||||
}
|
||||
|
||||
/// Turnkey's API (or the local mock that `TR_TURNKEY_API_URL` names, loopback only).
|
||||
fn turnkey() -> Result<tr_turnkey::Client, String> {
|
||||
tr_turnkey::Client::new().map_err(|e| format!("{e:#}"))
|
||||
}
|
||||
|
||||
/// Run one request; answers go through `send`.
|
||||
pub async fn handle(req: WalletRequest, send: impl Fn(HlResponse) + Clone + 'static) {
|
||||
let wallet = |r: WalletResponse| HlResponse::Wallet(Box::new(r));
|
||||
match req {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
WalletRequest::SignIn { id, store, key, state } => {
|
||||
let this = SIGN_IN.fetch_add(1, Ordering::SeqCst) + 1;
|
||||
let cb = match tr_turnkey::callback::Callback::bind().await {
|
||||
Ok(cb) => cb,
|
||||
Err(e) => return send(wallet(WalletResponse::SignedIn { id, result: Err(format!("{e:#}")) })),
|
||||
};
|
||||
send(wallet(WalletResponse::Listening { id, port: cb.port() }));
|
||||
let cancelled = async {
|
||||
while SIGN_IN.load(Ordering::SeqCst) == this {
|
||||
rt::sleep(std::time::Duration::from_millis(400)).await;
|
||||
}
|
||||
};
|
||||
let answer = tokio::select! {
|
||||
a = cb.wait(&state, tr_turnkey::callback::SIGN_IN_TIMEOUT) => a.map_err(|e| format!("{e:#}")),
|
||||
_ = cancelled => Err("Sign-in cancelled.".into()),
|
||||
};
|
||||
let result = match answer {
|
||||
Ok(tr_turnkey::callback::Answer::Session(token)) => match turnkey() {
|
||||
Ok(tk) => finish(&tk, store, key, &token).await,
|
||||
Err(e) => Err(e),
|
||||
},
|
||||
Ok(tr_turnkey::callback::Answer::Failed(e)) => Err(format!("The sign-in page said: {e}")),
|
||||
Err(e) => Err(e),
|
||||
};
|
||||
send(wallet(WalletResponse::SignedIn { id, result }));
|
||||
}
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
WalletRequest::Finish { id, store, key, token } => {
|
||||
let result = match turnkey() {
|
||||
Ok(tk) => finish(&tk, store, key, &token).await,
|
||||
Err(e) => Err(e),
|
||||
};
|
||||
send(wallet(WalletResponse::SignedIn { id, result }));
|
||||
}
|
||||
WalletRequest::LoadSession { store, account } => {
|
||||
let acct = account.clone();
|
||||
let session = rt::blocking(move || {
|
||||
let text = store.load(&acct).ok().flatten()?;
|
||||
match Session::from_stored(&text, now_secs()) {
|
||||
Ok(Some(s)) => Some(Arc::new(s)),
|
||||
_ => {
|
||||
// Expired or unreadable: it can't sign anything, so don't keep it.
|
||||
let _ = store.delete(&acct);
|
||||
None
|
||||
}
|
||||
}
|
||||
})
|
||||
.await
|
||||
.flatten();
|
||||
send(wallet(WalletResponse::SessionLoaded { account, session }));
|
||||
}
|
||||
WalletRequest::EndSession { store, account } => {
|
||||
let _ = rt::blocking(move || store.delete(&account)).await;
|
||||
}
|
||||
WalletRequest::Approve { store, session, master, device } => {
|
||||
let agent = AgentKey::generate();
|
||||
let action = trader::approve_agent(&agent.address(), &device, trader::DEFAULT_AGENT_DAYS, now_ms());
|
||||
let meta = HlAgent {
|
||||
address: action.agent_address.clone(),
|
||||
name: action.agent_name.clone(),
|
||||
valid_until: trader::agent_valid_until(&action.agent_name).unwrap_or(0),
|
||||
network: tr_hl::Network::Testnet,
|
||||
};
|
||||
let key = Arc::new(agent);
|
||||
let signed = match turnkey() {
|
||||
Ok(tk) => wallet_sign(&tk, &session, &master, &action).await,
|
||||
Err(e) => Err(e),
|
||||
};
|
||||
let r = match signed {
|
||||
Ok(body) => match tr_hl::client::Client::new() {
|
||||
Ok(client) => approve(&client, store, master, key, body, meta).await,
|
||||
Err(e) => {
|
||||
HlResponse::Approved { master, agent: meta, key, result: Err(format!("{e:#}")), saved: Ok(()) }
|
||||
}
|
||||
},
|
||||
// The agent key is dropped here; nothing was sent.
|
||||
Err(e) => wallet(WalletResponse::ApproveNotSigned(e)),
|
||||
};
|
||||
send(r);
|
||||
}
|
||||
WalletRequest::Revoke { store, session, master, base } => {
|
||||
let action = trader::revoke_agent(&base, now_ms());
|
||||
let account = crate::app::hl_store_account(&master);
|
||||
let signed = match turnkey() {
|
||||
Ok(tk) => wallet_sign(&tk, &session, &master, &action).await,
|
||||
Err(e) => Err(e),
|
||||
};
|
||||
let r = match (signed, tr_hl::client::Client::new()) {
|
||||
(Ok(body), Ok(client)) => revoke(&client, store, account, Some(body)).await,
|
||||
(Err(e), _) => wallet(WalletResponse::RevokeNotSigned(e)),
|
||||
(_, Err(e)) => wallet(WalletResponse::RevokeNotSigned(format!("{e:#}"))),
|
||||
};
|
||||
send(r);
|
||||
}
|
||||
WalletRequest::Balances { user } => {
|
||||
let result = match (tr_hl::client::Client::new(), tr_hl::key::parse_address(&user)) {
|
||||
(Ok(c), Ok(a)) => c.spot_usdc(&a).await.map_err(|e| format!("{e:#}")),
|
||||
(Err(e), _) | (_, Err(e)) => Err(format!("{e:#}")),
|
||||
};
|
||||
send(wallet(WalletResponse::Balances { user, result }));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The wallet signs `action` (a testnet `approveAgent`) through Turnkey: the `/exchange` body.
|
||||
async fn wallet_sign(
|
||||
tk: &tr_turnkey::Client,
|
||||
session: &Session,
|
||||
master: &str,
|
||||
action: &tr_hl::actions::ApproveAgentAction,
|
||||
) -> Result<serde_json::Value, String> {
|
||||
if session.expired(now_secs()) {
|
||||
return Err("the wallet session expired; sign in again".into());
|
||||
}
|
||||
let main = tr_hl::key::parse_address(master).map_err(|e| format!("{e:#}"))?;
|
||||
let sig = sign_approve_agent_with(action, &main, |d| async move { tk.sign_digest(session, &main, &d).await })
|
||||
.await
|
||||
.map_err(|e| tr_hl::mask_addresses(&format!("{e:#}")))?;
|
||||
Ok(trader::user_signed_body(action, &sig))
|
||||
}
|
||||
|
||||
/// Accept the session for `key`, read the wallet from Turnkey, and keep the session in the
|
||||
/// secret store for the rest of its 15 minutes.
|
||||
async fn finish(
|
||||
tk: &tr_turnkey::Client,
|
||||
store: AgentKeyStore,
|
||||
key: SessionKey,
|
||||
token: &str,
|
||||
) -> Result<Box<SignedIn>, String> {
|
||||
let session = Session::accept(key, token, now_secs()).map_err(|e| format!("{e:#}"))?;
|
||||
let me = tk.whoami(&session).await.map_err(|e| format!("Turnkey: {e:#}"))?;
|
||||
let account = tk.ethereum_account(&session).await.map_err(|e| format!("Turnkey: {e:#}"))?;
|
||||
let address = tr_hl::key::parse_address(&account.address)
|
||||
.map(|a| tr_hl::key::address_hex(&a))
|
||||
.map_err(|e| format!("Turnkey's wallet address: {e:#}"))?;
|
||||
let stored = session.to_stored();
|
||||
let item = session_account(&address);
|
||||
let saved = rt::blocking(move || store.save(&item, &stored).map_err(|e| format!("{e:#}")))
|
||||
.await
|
||||
.unwrap_or_else(|| Err("the key store task failed".into()));
|
||||
Ok(Box::new(SignedIn { session, address, user: me.username, saved }))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use tr_hl::signing::recover_approve_agent;
|
||||
use tr_turnkey::mock::{MockTurnkey, USERNAME};
|
||||
|
||||
use super::*;
|
||||
|
||||
async fn signed_in(mock: &MockTurnkey, store: &AgentKeyStore) -> Box<SignedIn> {
|
||||
let key = SessionKey::generate();
|
||||
let token = mock.mint_session(&key.public_hex(), 600);
|
||||
let tk = tr_turnkey::Client::with_base(&mock.base).unwrap();
|
||||
finish(&tk, store.clone(), key, &token).await.unwrap()
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn signing_in_finds_the_wallet_and_keeps_the_session_in_the_store() {
|
||||
let mock = MockTurnkey::start(0, None).await.unwrap();
|
||||
let store = AgentKeyStore::memory();
|
||||
let s = signed_in(&mock, &store).await;
|
||||
assert_eq!(s.address, mock.address());
|
||||
assert_eq!(s.user, USERNAME);
|
||||
assert!(s.saved.is_ok());
|
||||
let kept = store.load(&session_account(&s.address)).unwrap().expect("the session is in the store");
|
||||
let back = Session::from_stored(&kept, now_secs()).unwrap().unwrap();
|
||||
assert_eq!(back.key.public_hex(), s.session.key.public_hex());
|
||||
// A token for another key is refused before Turnkey is asked anything.
|
||||
let before = mock.requests().len();
|
||||
let tk = tr_turnkey::Client::with_base(&mock.base).unwrap();
|
||||
let theirs = mock.mint_session(&SessionKey::generate().public_hex(), 600);
|
||||
assert!(finish(&tk, store, SessionKey::generate(), &theirs).await.is_err());
|
||||
assert_eq!(mock.requests().len(), before);
|
||||
}
|
||||
|
||||
/// The `/exchange` body the wallet's signature goes into is a testnet approval, and the
|
||||
/// signature recovers the wallet.
|
||||
#[tokio::test]
|
||||
async fn the_wallet_signs_a_testnet_approval_that_recovers_it() {
|
||||
let mock = MockTurnkey::start(0, None).await.unwrap();
|
||||
let store = AgentKeyStore::memory();
|
||||
let s = signed_in(&mock, &store).await;
|
||||
let tk = tr_turnkey::Client::with_base(&mock.base).unwrap();
|
||||
let agent = AgentKey::generate();
|
||||
let action = trader::approve_agent(&agent.address(), "ci", trader::DEFAULT_AGENT_DAYS, now_ms());
|
||||
let body = wallet_sign(&tk, &s.session, &s.address, &action).await.unwrap();
|
||||
assert_eq!(body["action"]["type"], "approveAgent");
|
||||
assert_eq!(body["action"]["hyperliquidChain"], "Testnet");
|
||||
assert_eq!(body["action"]["signatureChainId"], "0x66eee");
|
||||
let sig: tr_hl::signing::WireSignature = serde_json::from_value(body["signature"].clone()).unwrap();
|
||||
assert_eq!(tr_hl::key::address_hex(&recover_approve_agent(&action, &sig).unwrap()), s.address);
|
||||
// 180 days, the longest Hyperliquid allows, so approvals (paid signatures) are rare.
|
||||
let until = trader::agent_valid_until(&action.agent_name).unwrap();
|
||||
assert!(until >= now_ms() + 179 * 86_400_000, "{}", action.agent_name);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn an_expired_session_asks_turnkey_nothing() {
|
||||
let mock = MockTurnkey::start(0, None).await.unwrap();
|
||||
let key = SessionKey::generate();
|
||||
let token = mock.mint_session(&key.public_hex(), 2);
|
||||
let session = Session::accept(key, &token, now_secs()).unwrap();
|
||||
let tk = tr_turnkey::Client::with_base(&mock.base).unwrap();
|
||||
let action = trader::approve_agent(&AgentKey::generate().address(), "ci", 180, now_ms());
|
||||
let late =
|
||||
Session::accept(SessionKey::from_hex(&session.key.to_hex()).unwrap(), &session.token, now_secs()).unwrap();
|
||||
// Two seconds later it has expired.
|
||||
tokio::time::sleep(std::time::Duration::from_millis(2_100)).await;
|
||||
let err = wallet_sign(&tk, &late, &mock.address(), &action).await.unwrap_err();
|
||||
assert!(err.contains("expired"), "{err}");
|
||||
assert!(mock.requests().is_empty());
|
||||
}
|
||||
|
||||
/// An approval the wallet didn't sign is reported as such, not as Hyperliquid's refusal.
|
||||
#[tokio::test]
|
||||
async fn an_approval_the_wallet_did_not_sign_says_so() {
|
||||
let mock = MockTurnkey::start(0, None).await.unwrap();
|
||||
let key = SessionKey::generate();
|
||||
let token = mock.mint_session(&key.public_hex(), 1);
|
||||
let session = Arc::new(Session::accept(key, &token, now_secs()).unwrap());
|
||||
tokio::time::sleep(std::time::Duration::from_millis(1_100)).await;
|
||||
let got = std::sync::Arc::new(std::sync::Mutex::new(None));
|
||||
let sink = got.clone();
|
||||
let req = WalletRequest::Approve {
|
||||
store: AgentKeyStore::memory(),
|
||||
session,
|
||||
master: mock.address(),
|
||||
device: "ci".into(),
|
||||
};
|
||||
handle(req, move |r| {
|
||||
*sink.lock().unwrap() = Some(match r {
|
||||
HlResponse::Wallet(w) => match *w {
|
||||
WalletResponse::ApproveNotSigned(e) => e,
|
||||
_ => "other".into(),
|
||||
},
|
||||
_ => "sent to Hyperliquid".into(),
|
||||
});
|
||||
})
|
||||
.await;
|
||||
assert!(got.lock().unwrap().as_deref().is_some_and(|e| e.contains("expired")), "{:?}", got.lock().unwrap());
|
||||
assert!(mock.requests().is_empty());
|
||||
}
|
||||
|
||||
/// A revoke the wallet didn't sign changes nothing: the agent key stays in the store and
|
||||
/// the app is told so (it keeps the agent until a revoke really happens).
|
||||
#[tokio::test]
|
||||
async fn a_revoke_the_wallet_did_not_sign_keeps_the_agent() {
|
||||
let mock = MockTurnkey::start(0, None).await.unwrap();
|
||||
let store = AgentKeyStore::memory();
|
||||
let master = mock.address();
|
||||
let agent_item = crate::app::hl_store_account(&master);
|
||||
store.save(&agent_item, &AgentKey::generate().to_hex()).unwrap();
|
||||
let key = SessionKey::generate();
|
||||
let token = mock.mint_session(&key.public_hex(), 1);
|
||||
let session = Arc::new(Session::accept(key, &token, now_secs()).unwrap());
|
||||
tokio::time::sleep(std::time::Duration::from_millis(1_100)).await;
|
||||
let got = std::sync::Arc::new(std::sync::Mutex::new(None));
|
||||
let sink = got.clone();
|
||||
let req = WalletRequest::Revoke { store: store.clone(), session, master, base: "terminal-ci".into() };
|
||||
handle(req, move |r| {
|
||||
*sink.lock().unwrap() = Some(match r {
|
||||
HlResponse::Wallet(w) if matches!(*w, WalletResponse::RevokeNotSigned(_)) => "not signed",
|
||||
HlResponse::Revoked(_) => "revoked",
|
||||
_ => "other",
|
||||
});
|
||||
})
|
||||
.await;
|
||||
assert_eq!(*got.lock().unwrap(), Some("not signed"));
|
||||
assert!(store.load(&agent_item).unwrap().is_some(), "the agent key is still here");
|
||||
assert!(mock.requests().is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn an_expired_session_in_the_store_is_deleted_on_load() {
|
||||
let mock = MockTurnkey::start(0, None).await.unwrap();
|
||||
let store = AgentKeyStore::memory();
|
||||
let key = SessionKey::generate();
|
||||
let token = mock.mint_session(&key.public_hex(), 1);
|
||||
let session = Session::accept(key, &token, now_secs()).unwrap();
|
||||
let account = session_account(&mock.address());
|
||||
store.save(&account, &session.to_stored()).unwrap();
|
||||
tokio::time::sleep(std::time::Duration::from_millis(1_100)).await;
|
||||
let got = std::sync::Arc::new(std::sync::Mutex::new(None));
|
||||
let sink = got.clone();
|
||||
handle(WalletRequest::LoadSession { store: store.clone(), account: account.clone() }, move |r| {
|
||||
if let HlResponse::Wallet(w) = r
|
||||
&& let WalletResponse::SessionLoaded { session, .. } = *w
|
||||
{
|
||||
*sink.lock().unwrap() = Some(session.is_some());
|
||||
}
|
||||
})
|
||||
.await;
|
||||
assert_eq!(*got.lock().unwrap(), Some(false));
|
||||
assert!(store.load(&account).unwrap().is_none(), "the expired session was deleted");
|
||||
}
|
||||
}
|
||||
+197
@@ -0,0 +1,197 @@
|
||||
//! Browser entry point and page glue: start eframe's web runner on the page's canvas, log to
|
||||
//! the console, and publish frame timings on `window.terminalPerf` for measurement.
|
||||
//!
|
||||
//! Query flags: `?sample` serves offline sample data only (like `TR_PROVIDER=sample`), and
|
||||
//! `?fps` shows the frame-time readout and repaints continuously (like `TR_FPS`).
|
||||
//!
|
||||
//! Crypto markets come from the Terminal server that `TR_SERVER_URL` named when the page was built
|
||||
//! (scripts/build-web.sh). `?server=off` turns it off, and `?server=ws://localhost:8787/ws` uses a
|
||||
//! server on this machine instead (see `tr_data::web::server_url`).
|
||||
|
||||
use std::cell::Cell;
|
||||
|
||||
use tracing::{Level, Metadata};
|
||||
use tracing_subscriber::fmt::MakeWriter;
|
||||
use tracing_subscriber::layer::SubscriberExt;
|
||||
use tracing_subscriber::util::SubscriberInitExt;
|
||||
use wasm_bindgen::{JsCast, JsValue};
|
||||
use web_time::Instant;
|
||||
|
||||
pub fn start() {
|
||||
init_tracing();
|
||||
let t0 = Instant::now();
|
||||
let query = web_sys::window().and_then(|w| w.location().search().ok()).unwrap_or_default();
|
||||
let offline = has_flag(&query, "sample");
|
||||
let perf = has_flag(&query, "fps");
|
||||
let requested = web_sys::UrlSearchParams::new_with_str(&query).ok().and_then(|p| p.get("server"));
|
||||
let server = tr_data::web::server_url(option_env!("TR_SERVER_URL"), requested.as_deref());
|
||||
if let Some(url) = server.as_deref().filter(|_| !offline) {
|
||||
tracing::info!(
|
||||
"crypto markets come from the Terminal server at {url}, or Binance and Coinbase while it's down"
|
||||
);
|
||||
}
|
||||
wasm_bindgen_futures::spawn_local(async move {
|
||||
let Some(canvas) = canvas() else { return fail("the page has no #terminal canvas") };
|
||||
let started = eframe::WebRunner::new()
|
||||
.start(
|
||||
canvas,
|
||||
eframe::WebOptions::default(),
|
||||
Box::new(move |cc| {
|
||||
// The first frame needs fonts; set them before it (set_fonts applies from the next pass).
|
||||
cc.egui_ctx.set_fonts(crate::theme::base_fonts());
|
||||
wasm_bindgen_futures::spawn_local(load_emoji_fonts(cc.egui_ctx.clone()));
|
||||
let mut app = crate::create_app(cc, tr_data::for_browser(offline, server.as_deref()), 1, t0)?;
|
||||
if perf {
|
||||
app.show_perf();
|
||||
}
|
||||
Ok(Box::new(app))
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
if let Err(e) = started {
|
||||
fail(&format!("{e:?}"));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Fetch egui's emoji fonts, which scripts/build-web.sh puts in `fonts/` next to the page, and
|
||||
/// add them as fallbacks. Until they arrive, emoji and some symbols draw as boxes.
|
||||
async fn load_emoji_fonts(ctx: egui::Context) {
|
||||
let fonts = [("NotoEmoji-Regular", 0.81), ("emoji-icon-font", 0.90)];
|
||||
let fetched = futures::future::join_all(fonts.iter().map(|(name, _)| fetch(format!("fonts/{name}.ttf")))).await;
|
||||
for ((name, scale), bytes) in fonts.into_iter().zip(fetched) {
|
||||
match bytes {
|
||||
Ok(bytes) => {
|
||||
let tweak = egui::FontTweak { scale, ..Default::default() };
|
||||
crate::theme::add_late_font(&ctx, name, egui::FontData::from_owned(bytes).tweak(tweak));
|
||||
}
|
||||
Err(e) => tracing::warn!("font {name}: {e}"),
|
||||
}
|
||||
}
|
||||
ctx.request_repaint();
|
||||
}
|
||||
|
||||
async fn fetch(url: String) -> Result<Vec<u8>, String> {
|
||||
use wasm_bindgen_futures::JsFuture;
|
||||
let window = web_sys::window().ok_or("no window")?;
|
||||
let resp: web_sys::Response = JsFuture::from(window.fetch_with_str(&url))
|
||||
.await
|
||||
.map_err(|e| format!("{e:?}"))?
|
||||
.dyn_into()
|
||||
.map_err(|_| "not a response")?;
|
||||
if !resp.ok() {
|
||||
return Err(format!("HTTP {}", resp.status()));
|
||||
}
|
||||
let buf = JsFuture::from(resp.array_buffer().map_err(|e| format!("{e:?}"))?).await.map_err(|e| format!("{e:?}"))?;
|
||||
Ok(js_sys::Uint8Array::new(&buf).to_vec())
|
||||
}
|
||||
|
||||
fn has_flag(query: &str, flag: &str) -> bool {
|
||||
query.trim_start_matches('?').split('&').any(|p| p.split('=').next() == Some(flag))
|
||||
}
|
||||
|
||||
fn canvas() -> Option<web_sys::HtmlCanvasElement> {
|
||||
web_sys::window()?.document()?.get_element_by_id("terminal")?.dyn_into().ok()
|
||||
}
|
||||
|
||||
/// Show why the app couldn't start in place of the loading message.
|
||||
fn fail(why: &str) {
|
||||
tracing::error!("terminal failed to start: {why}");
|
||||
if let Some(el) = web_sys::window().and_then(|w| w.document()).and_then(|d| d.get_element_by_id("status")) {
|
||||
el.set_text_content(Some(&format!("Terminal couldn't start: {why}")));
|
||||
}
|
||||
}
|
||||
|
||||
/// Per-frame numbers from `TerminalApp::ui` (milliseconds).
|
||||
pub struct FrameStats {
|
||||
/// Smoothed CPU time inside `ui()`.
|
||||
pub ui_ms: f64,
|
||||
/// Smoothed time between frames.
|
||||
pub interval_ms: f64,
|
||||
/// eframe's CPU time for the whole previous frame (UI, tessellation and painting).
|
||||
pub last_cpu_ms: f64,
|
||||
pub frames: u64,
|
||||
}
|
||||
|
||||
thread_local! {
|
||||
static FIRST_FRAME: Cell<bool> = const { Cell::new(true) };
|
||||
}
|
||||
|
||||
pub fn record_frame(s: &FrameStats) {
|
||||
let Some(w) = web_sys::window() else { return };
|
||||
let perf = match js_sys::Reflect::get(&w, &"terminalPerf".into()) {
|
||||
Ok(v) if v.is_object() => v,
|
||||
_ => {
|
||||
let v: JsValue = js_sys::Object::new().into();
|
||||
let _ = js_sys::Reflect::set(&w, &"terminalPerf".into(), &v);
|
||||
v
|
||||
}
|
||||
};
|
||||
let set = |k: &str, v: f64| {
|
||||
let _ = js_sys::Reflect::set(&perf, &k.into(), &v.into());
|
||||
};
|
||||
if FIRST_FRAME.replace(false) {
|
||||
let at = w.performance().map_or(0.0, |p| p.now());
|
||||
tracing::info!("first frame built {at:.0} ms after navigation start");
|
||||
set("firstFrameMs", at);
|
||||
if let Some(el) = w.document().and_then(|d| d.get_element_by_id("status")) {
|
||||
el.remove();
|
||||
}
|
||||
}
|
||||
set("uiMs", s.ui_ms);
|
||||
set("intervalMs", s.interval_ms);
|
||||
set("cpuMs", s.last_cpu_ms);
|
||||
set("frames", s.frames as f64);
|
||||
}
|
||||
|
||||
fn init_tracing() {
|
||||
let filter = tracing_subscriber::filter::Targets::new()
|
||||
.with_default(Level::INFO)
|
||||
.with_target("wgpu_core", Level::WARN)
|
||||
.with_target("wgpu_hal", Level::WARN)
|
||||
.with_target("naga", Level::WARN);
|
||||
let fmt = tracing_subscriber::fmt::layer().with_writer(Console).without_time().with_ansi(false);
|
||||
let _ = tracing_subscriber::registry().with(fmt).with(filter).try_init();
|
||||
}
|
||||
|
||||
/// Sends each formatted log line to the browser console at its level.
|
||||
struct Console;
|
||||
|
||||
struct Line {
|
||||
level: Level,
|
||||
buf: Vec<u8>,
|
||||
}
|
||||
|
||||
impl std::io::Write for Line {
|
||||
fn write(&mut self, bytes: &[u8]) -> std::io::Result<usize> {
|
||||
self.buf.extend_from_slice(bytes);
|
||||
Ok(bytes.len())
|
||||
}
|
||||
|
||||
fn flush(&mut self) -> std::io::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for Line {
|
||||
fn drop(&mut self) {
|
||||
let text = JsValue::from_str(String::from_utf8_lossy(&self.buf).trim_end());
|
||||
match self.level {
|
||||
Level::ERROR => web_sys::console::error_1(&text),
|
||||
Level::WARN => web_sys::console::warn_1(&text),
|
||||
_ => web_sys::console::log_1(&text),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a> MakeWriter<'a> for Console {
|
||||
type Writer = Line;
|
||||
|
||||
fn make_writer(&'a self) -> Line {
|
||||
Line { level: Level::INFO, buf: Vec::new() }
|
||||
}
|
||||
|
||||
fn make_writer_for(&'a self, meta: &Metadata<'_>) -> Line {
|
||||
Line { level: *meta.level(), buf: Vec::new() }
|
||||
}
|
||||
}
|
||||
+484
-61
@@ -1,6 +1,10 @@
|
||||
//! Background worker. All network, SQLite, indicator and LOD-pyramid work runs on a
|
||||
//! tokio runtime; the UI thread only sends [`Request`]s and drains [`Response`]s, so it
|
||||
//! never blocks.
|
||||
//!
|
||||
//! In the browser (`wasm32`) the same requests run as tasks on the page's event loop
|
||||
//! ([`tr_data::rt`]). There is no bar cache or Congress ingest there, and CPU work such as
|
||||
//! indicators runs inline.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::path::PathBuf;
|
||||
@@ -11,12 +15,16 @@ use tokio::sync::Mutex;
|
||||
use tr_chart::gpu::{LineKind, LineRef};
|
||||
use tr_chart::scene::{Overlay, SeriesGpu, line_ref, marker_ref};
|
||||
use tr_congress::db::{Alert, PoliticianSummary};
|
||||
use tr_congress::model::NewTradeAlert;
|
||||
use tr_congress::performance::{self, Performance, TradeReturn};
|
||||
use tr_congress::refresh::{self, HttpSource, NewTradeAlert, RefreshConfig};
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
use tr_congress::refresh::{self, HttpSource, RefreshConfig};
|
||||
use tr_congress::{Db, Politician, Trade};
|
||||
use tr_core::indicators::registry::{Input, Plot, Tone};
|
||||
use tr_core::{Bar, Security, Series, Timeframe};
|
||||
use tr_data::{BarCache, Provider, Quote, SearchHit, YieldCurves};
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
use tr_data::BarCache;
|
||||
use tr_data::{Provider, Quote, SearchHit, YieldCurves, rt};
|
||||
|
||||
use crate::command::IndicatorSpec;
|
||||
use crate::theme;
|
||||
@@ -26,6 +34,12 @@ pub type PaneId = u64;
|
||||
/// Minutes between Congress disclosure checks (`TR_CONGRESS_REFRESH_MIN`; 0 disables).
|
||||
pub const CONGRESS_REFRESH_MIN: u64 = 30;
|
||||
|
||||
/// The least time between a chart's reloads after its provider ended the live feed.
|
||||
const RELOAD_AFTER_FEED_ENDS: Duration = Duration::from_secs(5);
|
||||
|
||||
/// How often a chart retries a HIP-3 perp's history while the Terminal server can't serve it.
|
||||
const PERP_RETRY: Duration = if cfg!(test) { Duration::from_millis(100) } else { Duration::from_secs(10) };
|
||||
|
||||
pub enum Request {
|
||||
/// Load the brokerage portfolio through SnapTrade (`None` = the simulated sample).
|
||||
Portfolio(Option<tr_broker::Credentials>),
|
||||
@@ -60,6 +74,13 @@ pub enum Request {
|
||||
pane: PaneId,
|
||||
symbol: String,
|
||||
depth: usize,
|
||||
/// Echoed in the response, so the pane knows which of its asks was answered.
|
||||
ask: u64,
|
||||
},
|
||||
/// The latest trades, for markets the Terminal server serves.
|
||||
Trades {
|
||||
pane: PaneId,
|
||||
symbol: String,
|
||||
},
|
||||
History {
|
||||
pane: PaneId,
|
||||
@@ -121,8 +142,14 @@ pub enum Request {
|
||||
Forget(PaneId),
|
||||
YieldCurves,
|
||||
Search(String),
|
||||
/// The HIP-3 perps the Terminal server lists as live.
|
||||
PerpMarkets,
|
||||
/// Order entry (see `app::trading`); only sent once trading is enabled.
|
||||
Trade(TradeRequest),
|
||||
/// Hyperliquid testnet: the user stream, reads, the agent key and signed actions.
|
||||
Hl(crate::hl_worker::HlRequest),
|
||||
/// The built-in wallet (Turnkey sign-in and approvals).
|
||||
Wallet(crate::wallet_worker::WalletRequest),
|
||||
}
|
||||
|
||||
/// A trading call through SnapTrade. Previews never place anything; placing takes a preview
|
||||
@@ -217,8 +244,14 @@ pub enum Response {
|
||||
OrderBook {
|
||||
pane: PaneId,
|
||||
symbol: String,
|
||||
ask: u64,
|
||||
book: Result<tr_data::OrderBook, String>,
|
||||
},
|
||||
Trades {
|
||||
pane: PaneId,
|
||||
symbol: String,
|
||||
trades: Result<Vec<tr_data::TradePrint>, String>,
|
||||
},
|
||||
History {
|
||||
pane: PaneId,
|
||||
generation: u64,
|
||||
@@ -230,6 +263,9 @@ pub enum Response {
|
||||
prepended: usize,
|
||||
from_cache: bool,
|
||||
exhausted: bool,
|
||||
/// A full load from the provider. Its `exhausted` replaces the chart's, which may have
|
||||
/// come from the source the chart showed before.
|
||||
full: bool,
|
||||
/// A live-feed update of the latest bar.
|
||||
live: bool,
|
||||
},
|
||||
@@ -254,7 +290,11 @@ pub enum Response {
|
||||
symbol: String,
|
||||
trades: Vec<Trade>,
|
||||
},
|
||||
Quotes(Vec<Quote>),
|
||||
/// Quotes for the keys `asked`, which may leave some out.
|
||||
Quotes {
|
||||
asked: Vec<String>,
|
||||
quotes: Vec<Quote>,
|
||||
},
|
||||
Latest(Vec<Trade>),
|
||||
TickerTrades {
|
||||
pane: PaneId,
|
||||
@@ -268,20 +308,27 @@ pub enum Response {
|
||||
},
|
||||
Politicians(Vec<PoliticianSummary>),
|
||||
Alerts(Vec<Alert>),
|
||||
// Only the desktop's Congress ingest raises these.
|
||||
#[cfg_attr(target_arch = "wasm32", allow(dead_code))]
|
||||
NewAlerts(Vec<NewTradeAlert>),
|
||||
Status(String),
|
||||
#[cfg_attr(target_arch = "wasm32", allow(dead_code))]
|
||||
CongressRefreshed(String),
|
||||
Curves(Box<YieldCurves>),
|
||||
SearchHits {
|
||||
query: String,
|
||||
hits: Vec<SearchHit>,
|
||||
},
|
||||
/// Live HIP-3 perps (`xyz:AAPL`), or why the server couldn't say.
|
||||
PerpMarkets(Result<Vec<String>, String>),
|
||||
Trade(Box<TradeResponse>),
|
||||
Hl(Box<crate::hl_worker::HlResponse>),
|
||||
}
|
||||
|
||||
pub struct Worker {
|
||||
tx: tokio::sync::mpsc::UnboundedSender<Request>,
|
||||
pub rx: mpsc::Receiver<Response>,
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
_rt: tokio::runtime::Runtime,
|
||||
}
|
||||
|
||||
@@ -293,6 +340,10 @@ struct Shared {
|
||||
/// Authoritative bars per chart pane (history, paging and live ticks all merge here),
|
||||
/// tagged with the pane's load generation.
|
||||
series: std::sync::Mutex<SeriesMap>,
|
||||
/// Counts each chart pane's reloads for a changed data source, up once as one starts and again
|
||||
/// when it's done (odd while it runs), so a page of older bars that spans one is dropped.
|
||||
sources: std::sync::Mutex<HashMap<PaneId, u64>>,
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
cache: std::sync::Mutex<Option<BarCache>>,
|
||||
db: Mutex<Db>,
|
||||
out: mpsc::Sender<Response>,
|
||||
@@ -306,22 +357,35 @@ impl Shared {
|
||||
}
|
||||
}
|
||||
|
||||
/// Where saved files live. In the browser this is the key prefix in `localStorage` (see
|
||||
/// [`crate::storage`]). The first call moves files saved under the app's old name, terminal-rs,
|
||||
/// to the new one ([`crate::storage::migrate`]).
|
||||
pub fn data_dir() -> PathBuf {
|
||||
if let Some(d) = std::env::var_os("TR_DATA_DIR") {
|
||||
return PathBuf::from(d);
|
||||
}
|
||||
let home = std::env::var_os("HOME").map(PathBuf::from).unwrap_or_else(|| PathBuf::from("."));
|
||||
if cfg!(target_os = "macos") {
|
||||
home.join("Library/Application Support/terminal-rs")
|
||||
} else {
|
||||
std::env::var_os("XDG_DATA_HOME")
|
||||
.map(PathBuf::from)
|
||||
.unwrap_or_else(|| home.join(".local/share"))
|
||||
.join("terminal-rs")
|
||||
}
|
||||
static DIR: std::sync::OnceLock<PathBuf> = std::sync::OnceLock::new();
|
||||
DIR.get_or_init(|| {
|
||||
// Unit tests never read, write or move the real folder.
|
||||
if cfg!(test) {
|
||||
return std::env::temp_dir().join(format!("tr-test-data-{}", std::process::id()));
|
||||
}
|
||||
let parent = if cfg!(target_arch = "wasm32") {
|
||||
PathBuf::new()
|
||||
} else if let Some(d) = std::env::var_os("TR_DATA_DIR") {
|
||||
return PathBuf::from(d);
|
||||
} else {
|
||||
let home = std::env::var_os("HOME").map(PathBuf::from).unwrap_or_else(|| PathBuf::from("."));
|
||||
if cfg!(target_os = "macos") {
|
||||
home.join("Library/Application Support")
|
||||
} else {
|
||||
std::env::var_os("XDG_DATA_HOME").map(PathBuf::from).unwrap_or_else(|| home.join(".local/share"))
|
||||
}
|
||||
};
|
||||
crate::storage::migrate(&parent.join("terminal-rs"), &parent.join("terminal"))
|
||||
})
|
||||
.clone()
|
||||
}
|
||||
|
||||
impl Worker {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub fn spawn(ctx: egui::Context, provider: Provider) -> anyhow::Result<Self> {
|
||||
let dir = data_dir();
|
||||
std::fs::create_dir_all(&dir)?;
|
||||
@@ -341,6 +405,7 @@ impl Worker {
|
||||
let shared = Arc::new(Shared {
|
||||
provider,
|
||||
series: Default::default(),
|
||||
sources: Default::default(),
|
||||
cache: std::sync::Mutex::new(cache),
|
||||
db: Mutex::new(db),
|
||||
out,
|
||||
@@ -376,11 +441,34 @@ impl Worker {
|
||||
Ok(Self { tx, rx, _rt: rt })
|
||||
}
|
||||
|
||||
/// The browser worker: tasks on the page's event loop, no disk cache, an empty Congress store.
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
pub fn spawn(ctx: egui::Context, provider: Provider) -> anyhow::Result<Self> {
|
||||
let (tx, mut req_rx) = tokio::sync::mpsc::unbounded_channel::<Request>();
|
||||
let (out, rx) = mpsc::channel();
|
||||
let shared = Arc::new(Shared {
|
||||
provider,
|
||||
series: Default::default(),
|
||||
sources: Default::default(),
|
||||
db: Mutex::new(Db::open_in_memory()?),
|
||||
out,
|
||||
ctx,
|
||||
});
|
||||
rt::spawn(async move {
|
||||
while let Some(req) = req_rx.recv().await {
|
||||
let s = shared.clone();
|
||||
rt::spawn(async move { handle(&s, req).await });
|
||||
}
|
||||
});
|
||||
Ok(Self { tx, rx })
|
||||
}
|
||||
|
||||
pub fn send(&self, req: Request) {
|
||||
let _ = self.tx.send(req);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
async fn congress_refresh(s: &Shared) {
|
||||
s.send(Response::Status("Refreshing congressional disclosures (Senate eFD + House Clerk)…".into()));
|
||||
let source = match HttpSource::new() {
|
||||
@@ -399,6 +487,35 @@ async fn congress_refresh(s: &Shared) {
|
||||
}
|
||||
}
|
||||
|
||||
// TODO(web proxy): Senate eFD and the House Clerk don't allow cross-origin requests.
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
async fn congress_refresh(s: &Shared) {
|
||||
s.send(Response::Status(crate::congress_view::WEB_NOTE.into()));
|
||||
}
|
||||
|
||||
/// Bars saved by an earlier session, to paint before the network answers.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn cached_bars(s: &Shared, symbol: &str, tf: Timeframe, limit: usize) -> Option<Vec<Bar>> {
|
||||
s.cache.lock().ok().and_then(|c| c.as_ref().and_then(|c| c.load(symbol, tf, limit).ok()))
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn cache_bars(s: &Shared, symbol: &str, tf: Timeframe, bars: &[Bar]) {
|
||||
if let Ok(mut c) = s.cache.lock()
|
||||
&& let Some(c) = c.as_mut()
|
||||
{
|
||||
let _ = c.store(symbol, tf, bars);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
fn cached_bars(_: &Shared, _: &str, _: Timeframe, _: usize) -> Option<Vec<Bar>> {
|
||||
None
|
||||
}
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
fn cache_bars(_: &Shared, _: &str, _: Timeframe, _: &[Bar]) {}
|
||||
|
||||
fn overlay(values: &[f64], color: egui::Color32, kind: LineKind) -> Overlay {
|
||||
let line = if kind == LineKind::Dots { marker_ref(values) } else { line_ref(values) };
|
||||
Overlay { line, color: theme::rgba(color), kind, scale: 1.0 }
|
||||
@@ -486,8 +603,7 @@ async fn handle(s: &Arc<Shared>, req: Request) {
|
||||
return; // a newer load for this pane is already running
|
||||
}
|
||||
// Paint from the disk cache immediately, then refresh from the network.
|
||||
let cached = s.cache.lock().ok().and_then(|c| c.as_ref().and_then(|c| c.load(&symbol, tf, limit).ok()));
|
||||
if let Some(bars) = cached.filter(|b| !b.is_empty()) {
|
||||
if let Some(bars) = cached_bars(s, &symbol, tf, limit).filter(|b| !b.is_empty()) {
|
||||
let bars = Arc::new(bars);
|
||||
let gpu = build_gpu(bars.clone()).await;
|
||||
s.send(Response::History {
|
||||
@@ -500,44 +616,31 @@ async fn handle(s: &Arc<Shared>, req: Request) {
|
||||
prepended: 0,
|
||||
from_cache: true,
|
||||
exhausted: false,
|
||||
full: false,
|
||||
live: false,
|
||||
});
|
||||
}
|
||||
match s.provider.history(&symbol, tf, None, limit).await {
|
||||
Ok(bars) if !bars.is_empty() => {
|
||||
if let Ok(mut c) = s.cache.lock()
|
||||
&& let Some(c) = c.as_mut()
|
||||
{
|
||||
let _ = c.store(&symbol, tf, &bars);
|
||||
}
|
||||
let exhausted = bars.len() < limit / 2;
|
||||
let bars = Arc::new(bars);
|
||||
if !store_series(s, pane, generation, bars.clone()) {
|
||||
return; // superseded while loading
|
||||
}
|
||||
let gpu = build_gpu(bars.clone()).await;
|
||||
s.send(Response::History {
|
||||
pane,
|
||||
generation,
|
||||
symbol: symbol.clone(),
|
||||
tf,
|
||||
bars,
|
||||
gpu,
|
||||
prepended: 0,
|
||||
from_cache: false,
|
||||
exhausted,
|
||||
live: false,
|
||||
});
|
||||
start_live(s.clone(), pane, generation, symbol, tf);
|
||||
}
|
||||
Ok(bars) if !bars.is_empty() => loaded(s, pane, generation, symbol, tf, limit, bars).await,
|
||||
Ok(_) => s.send(Response::HistoryError { pane, generation, error: format!("no data for {symbol}") }),
|
||||
Err(e) => s.send(Response::HistoryError { pane, generation, error: format!("{e:#}") }),
|
||||
Err(e) => {
|
||||
s.send(Response::HistoryError { pane, generation, error: format!("{e:#}") });
|
||||
if tr_data::is_perp(&symbol) {
|
||||
retry_perp(s.clone(), pane, generation, symbol, tf, limit);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Request::MoreHistory { pane, generation, symbol, tf, existing, limit } => {
|
||||
// Page from the worker's copy (it may hold newer live bars than the UI's).
|
||||
let base = current_series(s, pane, generation).filter(|b| !b.is_empty()).unwrap_or(existing);
|
||||
let Some(first) = base.first().copied() else { return };
|
||||
// A page that overlaps a reload for another source is dropped. The reload answers the
|
||||
// pane, and the chart pages again from the new source.
|
||||
let source = source_switches(s, pane);
|
||||
if source % 2 == 1 {
|
||||
return;
|
||||
}
|
||||
match s.provider.history(&symbol, tf, Some(first.ts), limit).await {
|
||||
Ok(older) => {
|
||||
let older: Vec<Bar> = older.into_iter().filter(|b| b.ts < first.ts).collect();
|
||||
@@ -546,6 +649,9 @@ async fn handle(s: &Arc<Shared>, req: Request) {
|
||||
let mut map = s.series.lock().expect("series lock");
|
||||
let Some((g, cur)) = map.get_mut(&pane).filter(|(g, _)| *g == generation) else { return };
|
||||
let _ = g;
|
||||
if source_switches(s, pane) != source {
|
||||
return;
|
||||
}
|
||||
let mut merged = older;
|
||||
merged.extend_from_slice(cur);
|
||||
*cur = Arc::new(merged);
|
||||
@@ -562,24 +668,26 @@ async fn handle(s: &Arc<Shared>, req: Request) {
|
||||
prepended,
|
||||
from_cache: false,
|
||||
exhausted: prepended == 0,
|
||||
full: false,
|
||||
live: false,
|
||||
});
|
||||
}
|
||||
Err(e) => s.send(Response::HistoryError { pane, generation, error: format!("{e:#}") }),
|
||||
Err(e) if source_switches(s, pane) == source => {
|
||||
s.send(Response::HistoryError { pane, generation, error: format!("{e:#}") })
|
||||
}
|
||||
Err(_) => {}
|
||||
}
|
||||
}
|
||||
Request::Indicators { pane, generation, bars, tf, specs, palette } => {
|
||||
let built = tokio::task::spawn_blocking(move || build_indicators(&bars, tf, &specs, &palette))
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
let built = rt::blocking(move || build_indicators(&bars, tf, &specs, &palette)).await.unwrap_or_default();
|
||||
s.send(Response::Indicators { pane, generation, built });
|
||||
}
|
||||
Request::Compare { pane, generation, symbol, tf, base } => {
|
||||
let limit = base.len().max(100);
|
||||
match s.provider.history(&symbol, tf, None, limit).await {
|
||||
Ok(other) => {
|
||||
let line = tokio::task::spawn_blocking(move || line_ref(&align_compare(&base, &other))).await;
|
||||
if let Ok(line) = line {
|
||||
let line = rt::blocking(move || line_ref(&align_compare(&base, &other))).await;
|
||||
if let Some(line) = line {
|
||||
s.send(Response::Compare { pane, generation, symbol, line });
|
||||
}
|
||||
}
|
||||
@@ -596,8 +704,11 @@ async fn handle(s: &Arc<Shared>, req: Request) {
|
||||
s.send(Response::Markers { pane, symbol, trades });
|
||||
}
|
||||
Request::Quotes(symbols) => match s.provider.quotes(&symbols).await {
|
||||
Ok(q) => s.send(Response::Quotes(q)),
|
||||
Err(e) => s.send(Response::Status(format!("quotes: {e:#}"))),
|
||||
Ok(quotes) => s.send(Response::Quotes { asked: symbols, quotes }),
|
||||
Err(e) => {
|
||||
s.send(Response::Status(format!("quotes: {e:#}")));
|
||||
s.send(Response::Quotes { asked: symbols, quotes: Vec::new() });
|
||||
}
|
||||
},
|
||||
Request::Latest { limit } => {
|
||||
let t = s.db.lock().await.latest_trades(limit).unwrap_or_default();
|
||||
@@ -643,6 +754,10 @@ async fn handle(s: &Arc<Shared>, req: Request) {
|
||||
Request::Forget(pane) => {
|
||||
s.series.lock().expect("series lock").remove(&pane);
|
||||
}
|
||||
Request::PerpMarkets => {
|
||||
let perps = s.provider.perp_markets().await.map_err(|e| format!("{e:#}"));
|
||||
s.send(Response::PerpMarkets(perps));
|
||||
}
|
||||
Request::YieldCurves => match s.provider.yield_curves().await {
|
||||
Ok(c) => s.send(Response::Curves(Box::new(c))),
|
||||
Err(e) => s.send(Response::Status(format!("yield curve: {e:#}"))),
|
||||
@@ -686,15 +801,27 @@ async fn handle(s: &Arc<Shared>, req: Request) {
|
||||
let items = s.provider.news(symbol.as_deref()).await.map_err(|e| format!("{e:#}"));
|
||||
s.send(Response::News { pane, symbol, items });
|
||||
}
|
||||
Request::OrderBook { pane, symbol, depth } => {
|
||||
Request::Trades { pane, symbol } => {
|
||||
let trades = s.provider.trades(&symbol, 40).await.map_err(|e| format!("{e:#}"));
|
||||
s.send(Response::Trades { pane, symbol, trades });
|
||||
}
|
||||
Request::OrderBook { pane, symbol, depth, ask } => {
|
||||
let book = s.provider.order_book(&symbol, depth).await.map_err(|e| format!("{e:#}"));
|
||||
s.send(Response::OrderBook { pane, symbol, book });
|
||||
s.send(Response::OrderBook { pane, symbol, ask, book });
|
||||
}
|
||||
Request::Search(query) => {
|
||||
let hits = s.provider.search(&query).await.unwrap_or_default();
|
||||
s.send(Response::SearchHits { query, hits });
|
||||
}
|
||||
Request::Trade(t) => s.send(Response::Trade(Box::new(trade(t).await))),
|
||||
Request::Hl(r) => {
|
||||
let out = s.clone();
|
||||
crate::hl_worker::handle(r, move |resp| out.send(Response::Hl(Box::new(resp)))).await
|
||||
}
|
||||
Request::Wallet(r) => {
|
||||
let out = s.clone();
|
||||
crate::wallet_worker::handle(r, move |resp| out.send(Response::Hl(Box::new(resp)))).await
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -763,24 +890,108 @@ fn store_series(s: &Shared, pane: PaneId, generation: u64, bars: Arc<Vec<Bar>>)
|
||||
}
|
||||
}
|
||||
|
||||
fn source_switches(s: &Shared, pane: PaneId) -> u64 {
|
||||
s.sources.lock().expect("sources lock").get(&pane).copied().unwrap_or(0)
|
||||
}
|
||||
|
||||
fn current_series(s: &Shared, pane: PaneId, generation: u64) -> Option<Arc<Vec<Bar>>> {
|
||||
s.series.lock().expect("series lock").get(&pane).filter(|(g, _)| *g == generation).map(|(_, b)| b.clone())
|
||||
}
|
||||
|
||||
/// Merge the provider's live feed (if any) into the pane's series, publishing at most
|
||||
/// four updates a second. Stops when the pane reloads, closes or the feed ends.
|
||||
fn start_live(s: Arc<Shared>, pane: PaneId, generation: u64, symbol: String, tf: Timeframe) {
|
||||
/// What one live bar did to a series.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||
struct Applied {
|
||||
changed: bool,
|
||||
/// It went in before the latest bar (a feed filling a gap after an outage), so every
|
||||
/// series aligned to the bars by index (compare lines) has to be rebuilt.
|
||||
inserted: bool,
|
||||
}
|
||||
|
||||
fn apply_live_bar(bars: &mut Vec<Bar>, bar: Bar) -> Applied {
|
||||
let (len, last) = (bars.len(), bars.last().map(|b| b.ts));
|
||||
let changed = Series::apply_live(bars, bar);
|
||||
Applied { changed, inserted: bars.len() > len && bars.last().map(|b| b.ts) == last }
|
||||
}
|
||||
|
||||
/// A full load's bars: cache them, show them and start the live feed, unless a newer load for
|
||||
/// the pane has taken over.
|
||||
async fn loaded(
|
||||
s: &Arc<Shared>,
|
||||
pane: PaneId,
|
||||
generation: u64,
|
||||
symbol: String,
|
||||
tf: Timeframe,
|
||||
limit: usize,
|
||||
bars: Vec<Bar>,
|
||||
) {
|
||||
cache_bars(s, &symbol, tf, &bars);
|
||||
let exhausted = bars.len() < limit / 2;
|
||||
let bars = Arc::new(bars);
|
||||
if !store_series(s, pane, generation, bars.clone()) {
|
||||
return; // superseded while loading
|
||||
}
|
||||
let gpu = build_gpu(bars.clone()).await;
|
||||
s.send(Response::History {
|
||||
pane,
|
||||
generation,
|
||||
symbol: symbol.clone(),
|
||||
tf,
|
||||
bars,
|
||||
gpu,
|
||||
prepended: 0,
|
||||
from_cache: false,
|
||||
exhausted,
|
||||
full: true,
|
||||
live: false,
|
||||
});
|
||||
start_live(s.clone(), pane, generation, symbol, tf, limit);
|
||||
}
|
||||
|
||||
/// A HIP-3 perp has no source but the Terminal server, so nothing else picks up its chart when
|
||||
/// the server is away. Ask again every [`PERP_RETRY`] while the pane still shows this load; the
|
||||
/// chart keeps showing the first failure (or its cached bars) until one works.
|
||||
fn retry_perp(s: Arc<Shared>, pane: PaneId, generation: u64, symbol: String, tf: Timeframe, limit: usize) {
|
||||
rt::spawn(async move {
|
||||
loop {
|
||||
rt::sleep(PERP_RETRY).await;
|
||||
if current_series(&s, pane, generation).is_none() {
|
||||
return;
|
||||
}
|
||||
match s.provider.history(&symbol, tf, None, limit).await {
|
||||
Ok(bars) if !bars.is_empty() => return loaded(&s, pane, generation, symbol, tf, limit, bars).await,
|
||||
Ok(_) => {
|
||||
return s.send(Response::HistoryError { pane, generation, error: format!("no data for {symbol}") });
|
||||
}
|
||||
Err(e) => tracing::debug!("{symbol} history, still failing: {e:#}"),
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
fn start_live(s: Arc<Shared>, pane: PaneId, generation: u64, symbol: String, tf: Timeframe, limit: usize) {
|
||||
let Some(mut feed) = s.provider.live(&symbol, tf) else { return };
|
||||
tokio::spawn(async move {
|
||||
let mut tick = tokio::time::interval(Duration::from_millis(250));
|
||||
rt::spawn(async move {
|
||||
let started = web_time::Instant::now();
|
||||
let mut tick = rt::interval(Duration::from_millis(250));
|
||||
let mut dirty = false;
|
||||
let mut inserted = false;
|
||||
let mut ended = false;
|
||||
loop {
|
||||
tokio::select! {
|
||||
bar = feed.recv() => {
|
||||
let Some(bar) = bar else { break };
|
||||
let Some(bar) = bar else {
|
||||
ended = true;
|
||||
break;
|
||||
};
|
||||
let mut map = s.series.lock().expect("series lock");
|
||||
match map.get_mut(&pane) {
|
||||
Some((g, bars)) if *g == generation => dirty |= Series::apply_live(Arc::make_mut(bars), bar),
|
||||
Some((g, bars)) if *g == generation => {
|
||||
let a = apply_live_bar(Arc::make_mut(bars), bar);
|
||||
dirty |= a.changed;
|
||||
inserted |= a.inserted;
|
||||
}
|
||||
_ => break,
|
||||
}
|
||||
}
|
||||
@@ -804,17 +1015,31 @@ fn start_live(s: Arc<Shared>, pane: PaneId, generation: u64, symbol: String, tf:
|
||||
prepended: 0,
|
||||
from_cache: false,
|
||||
exhausted: false,
|
||||
live: true,
|
||||
full: false,
|
||||
// A bar slotted into a gap shifts everything after it: send it as a
|
||||
// refresh so the chart rebuilds its compare line and indicators.
|
||||
live: !std::mem::take(&mut inserted),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
tracing::debug!("live feed for pane {pane} ({symbol}) stopped");
|
||||
// A provider ends a feed the pane still shows when the chart's source changes (the Terminal
|
||||
// server went away or came back): reload the chart from wherever its data comes from now.
|
||||
if ended {
|
||||
rt::sleep(RELOAD_AFTER_FEED_ENDS.saturating_sub(started.elapsed())).await;
|
||||
if current_series(&s, pane, generation).is_some() {
|
||||
let switch = || *s.sources.lock().expect("sources lock").entry(pane).or_default() += 1;
|
||||
switch();
|
||||
handle(&s, Request::History { pane, generation, symbol, tf, limit }).await;
|
||||
switch();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async fn build_gpu(bars: Arc<Vec<Bar>>) -> SeriesGpu {
|
||||
tokio::task::spawn_blocking(move || SeriesGpu::build(&bars)).await.expect("pyramid build task")
|
||||
rt::blocking(move || SeriesGpu::build(&bars)).await.expect("pyramid build task")
|
||||
}
|
||||
|
||||
fn resolve_politician(db: &Db, query: &str) -> Option<Politician> {
|
||||
@@ -885,6 +1110,20 @@ where
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn bars_filling_a_gap_are_reported_as_inserted() {
|
||||
let b = |ts: i64| Bar::new(ts, 1.0, 1.0, 1.0, 1.0, 1.0);
|
||||
let mut bars = vec![b(0), b(60), b(240)];
|
||||
assert_eq!(apply_live_bar(&mut bars, b(300)), Applied { changed: true, inserted: false });
|
||||
assert_eq!(apply_live_bar(&mut bars, b(300)), Applied { changed: false, inserted: false });
|
||||
assert_eq!(apply_live_bar(&mut bars, b(120)), Applied { changed: true, inserted: true });
|
||||
assert_eq!(
|
||||
apply_live_bar(&mut bars, Bar::new(60, 1.0, 2.0, 1.0, 2.0, 1.0)),
|
||||
Applied { changed: true, inserted: false }
|
||||
);
|
||||
assert_eq!(bars.iter().map(|x| x.ts).collect::<Vec<_>>(), [0, 60, 120, 240, 300]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compare_alignment_carries_last_close() {
|
||||
let base: Vec<Bar> = (0..5).map(|i| Bar::new(i * 10, 100.0, 100.0, 100.0, 100.0 + i as f64, 1.0)).collect();
|
||||
@@ -921,6 +1160,7 @@ mod tests {
|
||||
let s = Shared {
|
||||
provider: std::sync::Arc::new(tr_data::sample::SampleProvider::at(0, 10)),
|
||||
series: Default::default(),
|
||||
sources: Default::default(),
|
||||
cache: std::sync::Mutex::new(None),
|
||||
db: Mutex::new(Db::open_in_memory().unwrap()),
|
||||
out,
|
||||
@@ -935,6 +1175,189 @@ mod tests {
|
||||
assert!(current_series(&s, 1, 1).is_none());
|
||||
}
|
||||
|
||||
/// Bars whose close counts the loads. Reloads wait for `reload`, and older pages for `page`.
|
||||
/// The first chart's live feed ends at once, the way `RemoteMarkets` ends one when the chart's
|
||||
/// source changes.
|
||||
#[derive(Default)]
|
||||
struct SourceChanges {
|
||||
loads: std::sync::atomic::AtomicUsize,
|
||||
open: std::sync::Mutex<Vec<tokio::sync::mpsc::Sender<Bar>>>,
|
||||
reload: tokio::sync::Notify,
|
||||
page: tokio::sync::Notify,
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl tr_data::MarketData for SourceChanges {
|
||||
fn name(&self) -> &str {
|
||||
"test"
|
||||
}
|
||||
|
||||
async fn history(&self, _: &str, _: Timeframe, end: Option<i64>, _: usize) -> anyhow::Result<Vec<Bar>> {
|
||||
if end.is_some() {
|
||||
self.page.notified().await;
|
||||
return Ok(vec![Bar::new(-60, 1.0, 1.0, 1.0, 1.0, 1.0)]);
|
||||
}
|
||||
let n = self.loads.fetch_add(1, std::sync::atomic::Ordering::SeqCst) + 1;
|
||||
if n > 1 {
|
||||
self.reload.notified().await;
|
||||
}
|
||||
Ok(vec![Bar::new(0, 1.0, 1.0, 1.0, n as f64, 1.0)])
|
||||
}
|
||||
|
||||
fn live(&self, _: &str, _: Timeframe) -> Option<tr_data::LiveFeed> {
|
||||
let (tx, rx) = tokio::sync::mpsc::channel(1);
|
||||
if self.loads.load(std::sync::atomic::Ordering::SeqCst) > 1 {
|
||||
self.open.lock().unwrap().push(tx);
|
||||
}
|
||||
Some(rx)
|
||||
}
|
||||
}
|
||||
|
||||
/// A server that's down for the first `down` history requests.
|
||||
struct ServerBack {
|
||||
down: usize,
|
||||
loads: std::sync::atomic::AtomicUsize,
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl tr_data::MarketData for ServerBack {
|
||||
fn name(&self) -> &str {
|
||||
"server"
|
||||
}
|
||||
|
||||
async fn history(&self, _: &str, _: Timeframe, _: Option<i64>, _: usize) -> anyhow::Result<Vec<Bar>> {
|
||||
let n = self.loads.fetch_add(1, std::sync::atomic::Ordering::SeqCst) + 1;
|
||||
anyhow::ensure!(n > self.down, "the Terminal server is not reachable");
|
||||
Ok(vec![Bar::new(0, 1.0, 1.0, 1.0, 7.0, 1.0)])
|
||||
}
|
||||
}
|
||||
|
||||
/// A perp has no other source, so a chart whose first load failed keeps asking while it
|
||||
/// shows the perp, and loads once the server answers. It stops once the pane moves on.
|
||||
fn worker_on(provider: tr_data::Provider) -> (Arc<Shared>, mpsc::Receiver<Response>) {
|
||||
let (out, rx) = mpsc::channel();
|
||||
let s = Shared {
|
||||
provider,
|
||||
series: Default::default(),
|
||||
sources: Default::default(),
|
||||
cache: std::sync::Mutex::new(None),
|
||||
db: Mutex::new(Db::open_in_memory().unwrap()),
|
||||
out,
|
||||
ctx: egui::Context::default(),
|
||||
};
|
||||
(Arc::new(s), rx)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn perp_charts_retry_until_the_server_answers() {
|
||||
let provider = Arc::new(ServerBack { down: 2, loads: Default::default() });
|
||||
let (s, rx) = worker_on(provider.clone());
|
||||
let load = |symbol: &str| Request::History {
|
||||
pane: 1,
|
||||
generation: 1,
|
||||
symbol: symbol.into(),
|
||||
tf: Timeframe::D1,
|
||||
limit: 10,
|
||||
};
|
||||
handle(&s, load("xyz:AAPL")).await;
|
||||
let t0 = std::time::Instant::now();
|
||||
let mut errors = 0;
|
||||
let bars = loop {
|
||||
assert!(t0.elapsed() < Duration::from_secs(10), "no retry after {errors} errors");
|
||||
match rx.try_recv() {
|
||||
Ok(Response::HistoryError { .. }) => errors += 1,
|
||||
Ok(Response::History { bars, full: true, .. }) => break bars,
|
||||
Ok(_) => {}
|
||||
Err(_) => tokio::time::sleep(Duration::from_millis(20)).await,
|
||||
}
|
||||
};
|
||||
assert_eq!((errors, bars.last().map(|b| b.close)), (1, Some(7.0)), "one error shown, then the bars");
|
||||
assert_eq!(provider.loads.load(std::sync::atomic::Ordering::SeqCst), 3);
|
||||
// A chart the user moved on from isn't retried.
|
||||
let provider = Arc::new(ServerBack { down: usize::MAX, loads: Default::default() });
|
||||
let (s, _rx) = worker_on(provider.clone());
|
||||
handle(&s, load("xyz:AAPL")).await;
|
||||
assert!(claim_series(&s, 1, 2), "the pane loads something else");
|
||||
tokio::time::sleep(PERP_RETRY * 3).await;
|
||||
assert_eq!(provider.loads.load(std::sync::atomic::Ordering::SeqCst), 1, "no retries for an old load");
|
||||
}
|
||||
|
||||
/// A worker on `SourceChanges` with pane 1 loading `BTCUSD Curncy`, and its responses.
|
||||
async fn switching_chart() -> (Arc<Shared>, mpsc::Receiver<Response>, Arc<SourceChanges>) {
|
||||
let (out, rx) = mpsc::channel();
|
||||
let provider = Arc::new(SourceChanges::default());
|
||||
let s = Arc::new(Shared {
|
||||
provider: provider.clone(),
|
||||
series: Default::default(),
|
||||
sources: Default::default(),
|
||||
cache: std::sync::Mutex::new(None),
|
||||
db: Mutex::new(Db::open_in_memory().unwrap()),
|
||||
out,
|
||||
ctx: egui::Context::default(),
|
||||
});
|
||||
let symbol = "BTCUSD Curncy".to_string();
|
||||
handle(&s, Request::History { pane: 1, generation: 1, symbol, tf: Timeframe::D1, limit: 10 }).await;
|
||||
(s, rx, provider)
|
||||
}
|
||||
|
||||
/// The last close of each chart update, until there are `n`.
|
||||
async fn closes(rx: &mpsc::Receiver<Response>, n: usize) -> Vec<f64> {
|
||||
let t0 = std::time::Instant::now();
|
||||
let mut closes = Vec::new();
|
||||
while closes.len() < n {
|
||||
assert!(t0.elapsed() < Duration::from_secs(15), "no reload: {closes:?}");
|
||||
match rx.try_recv() {
|
||||
Ok(Response::History { generation: 1, bars, .. }) => closes.push(bars.last().unwrap().close),
|
||||
Ok(_) => {}
|
||||
Err(_) => tokio::time::sleep(Duration::from_millis(50)).await,
|
||||
}
|
||||
}
|
||||
closes
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn charts_reload_when_the_provider_ends_their_live_feed() {
|
||||
let t0 = std::time::Instant::now();
|
||||
let (_s, rx, provider) = switching_chart().await;
|
||||
provider.reload.notify_one();
|
||||
assert_eq!(closes(&rx, 2).await, [1.0, 2.0], "the reload replaced the chart");
|
||||
assert!(t0.elapsed() >= RELOAD_AFTER_FEED_ENDS, "not before the pause");
|
||||
assert_eq!(provider.loads.load(std::sync::atomic::Ordering::SeqCst), 2);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn pages_that_overlap_a_source_switch_are_dropped() {
|
||||
let (s, rx, provider) = switching_chart().await;
|
||||
let more = || Request::MoreHistory {
|
||||
pane: 1,
|
||||
generation: 1,
|
||||
symbol: "BTCUSD Curncy".into(),
|
||||
tf: Timeframe::D1,
|
||||
existing: Arc::new(Vec::new()),
|
||||
limit: 10,
|
||||
};
|
||||
// One page starts before the switch, and its bars arrive after it.
|
||||
let before = tokio::spawn({
|
||||
let (s, req) = (s.clone(), more());
|
||||
async move { handle(&s, req).await }
|
||||
});
|
||||
let t0 = std::time::Instant::now();
|
||||
while source_switches(&s, 1) != 1 {
|
||||
assert!(t0.elapsed() < Duration::from_secs(15), "no reload");
|
||||
tokio::time::sleep(Duration::from_millis(20)).await;
|
||||
}
|
||||
// Another starts while the chart reloads, and gives up at once.
|
||||
let during = tokio::time::timeout(Duration::from_secs(1), handle(&s, more())).await;
|
||||
assert!(during.is_ok(), "a page started during the reload went ahead");
|
||||
provider.reload.notify_one();
|
||||
assert_eq!(closes(&rx, 2).await, [1.0, 2.0]);
|
||||
provider.page.notify_one();
|
||||
before.await.unwrap();
|
||||
let ts: Vec<i64> = current_series(&s, 1, 1).unwrap().iter().map(|b| b.ts).collect();
|
||||
assert_eq!(ts, [0], "the old source's page joined the new source's bars");
|
||||
assert!(rx.try_iter().all(|r| !matches!(r, Response::History { prepended: 1.., .. })));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn join_all_runs_everything() {
|
||||
let rt = tokio::runtime::Builder::new_current_thread().enable_time().build().unwrap();
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="color-scheme" content="dark light" />
|
||||
<title>Terminal</title>
|
||||
<link rel="icon" href="data:," />
|
||||
<script>
|
||||
// The WebGPU build when the browser has WebGPU, else the larger WebGL2 one (no charts).
|
||||
// Start downloading now; the module below cancels this if there is no WebGPU adapter.
|
||||
window.terminalLoad = { build: 'gpu' in navigator ? 'terminal' : 'terminal-webgl', abort: new AbortController() };
|
||||
// Each build's JS and wasm sit in a folder named after a hash of its files, so they can be
|
||||
// cached for good; scripts/build-web.sh fills in these paths.
|
||||
terminalLoad.paths = { terminal: './{terminal}', 'terminal-webgl': './{terminal-webgl}' };
|
||||
terminalLoad.wasm = fetch(`${terminalLoad.paths[terminalLoad.build]}_bg.wasm`, { signal: terminalLoad.abort.signal });
|
||||
document.head.append(Object.assign(document.createElement('link'), { rel: 'modulepreload', href: `${terminalLoad.paths[terminalLoad.build]}.js` }));
|
||||
</script>
|
||||
<style>
|
||||
html,
|
||||
body {
|
||||
margin: 0;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
background: #1e1e20;
|
||||
}
|
||||
@media (prefers-color-scheme: light) {
|
||||
html,
|
||||
body {
|
||||
background: #f5f5f7;
|
||||
}
|
||||
}
|
||||
canvas {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: block;
|
||||
outline: none;
|
||||
}
|
||||
#status {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
margin: 0;
|
||||
font: 13px -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||
color: #8e8e93;
|
||||
pointer-events: none;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<canvas id="terminal"></canvas>
|
||||
<p id="status">Loading Terminal…</p>
|
||||
<script type="module">
|
||||
const load = window.terminalLoad;
|
||||
if (load.build === 'terminal' && !(await navigator.gpu.requestAdapter().catch(() => null))) {
|
||||
load.wasm.catch(() => {}); // the abort below rejects it
|
||||
load.abort.abort();
|
||||
load.build = 'terminal-webgl';
|
||||
load.wasm = fetch(`${load.paths[load.build]}_bg.wasm`);
|
||||
}
|
||||
window.terminalBuild = load.build;
|
||||
try {
|
||||
const { default: init } = await import(`${load.paths[load.build]}.js`);
|
||||
await init({ module_or_path: load.wasm }); // runs the app, which removes #status after its first frame
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
document.getElementById('status').textContent = `Terminal couldn't start: ${e}`;
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -12,15 +12,26 @@ serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
reqwest.workspace = true
|
||||
tracing.workspace = true
|
||||
tokio.workspace = true
|
||||
futures = "0.3"
|
||||
hmac = "0.12"
|
||||
sha2 = "0.10"
|
||||
base64 = "0.23"
|
||||
zeroize = "1"
|
||||
|
||||
[target.'cfg(target_os = "macos")'.dependencies]
|
||||
security-framework = "3"
|
||||
|
||||
# Hyperliquid agent keys on Linux: the Secret Service over D-Bus (pure Rust, no libdbus).
|
||||
[target.'cfg(target_os = "linux")'.dependencies]
|
||||
keyring = { version = "3", default-features = false, features = ["async-secret-service", "tokio", "crypto-rust"] }
|
||||
|
||||
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
|
||||
tokio.workspace = true
|
||||
|
||||
# The browser build never calls SnapTrade (see `SnapTrade::with_base`); the client only compiles.
|
||||
[target.'cfg(target_arch = "wasm32")'.dependencies]
|
||||
tokio = { version = "1", default-features = false, features = ["sync", "time"] }
|
||||
|
||||
[dev-dependencies]
|
||||
tokio = { workspace = true, features = ["io-util", "net"] }
|
||||
reqwest.workspace = true
|
||||
@@ -156,26 +156,33 @@ impl DemoBook {
|
||||
/// the caller can keep it aside instead of overwriting the user's history.
|
||||
pub fn load(path: &Path, now: DateTime<Utc>) -> Result<Self> {
|
||||
match std::fs::read(path) {
|
||||
Ok(bytes) => {
|
||||
let mut b: DemoBook = serde_json::from_slice(&bytes).context("the demo account file is damaged")?;
|
||||
b.version = FILE_VERSION;
|
||||
if !b.cash.is_finite() || !b.starting_cash.is_finite() {
|
||||
anyhow::bail!("the demo account file has invalid balances");
|
||||
}
|
||||
Ok(b)
|
||||
}
|
||||
Ok(bytes) => Self::from_json(&bytes),
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(Self::new(now)),
|
||||
Err(e) => Err(e).context("reading the demo account"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse a saved account, rejecting damaged files.
|
||||
pub fn from_json(bytes: &[u8]) -> Result<Self> {
|
||||
let mut b: DemoBook = serde_json::from_slice(bytes).context("the demo account file is damaged")?;
|
||||
b.version = FILE_VERSION;
|
||||
if !b.cash.is_finite() || !b.starting_cash.is_finite() {
|
||||
anyhow::bail!("the demo account file has invalid balances");
|
||||
}
|
||||
Ok(b)
|
||||
}
|
||||
|
||||
pub fn to_json(&self) -> Result<Vec<u8>> {
|
||||
Ok(serde_json::to_vec_pretty(self)?)
|
||||
}
|
||||
|
||||
/// Write atomically (temporary file, then rename).
|
||||
pub fn save(&self, path: &Path) -> Result<()> {
|
||||
if let Some(dir) = path.parent() {
|
||||
std::fs::create_dir_all(dir)?;
|
||||
}
|
||||
let tmp = path.with_extension("tmp");
|
||||
std::fs::write(&tmp, serde_json::to_vec_pretty(self)?)?;
|
||||
std::fs::write(&tmp, self.to_json()?)?;
|
||||
std::fs::rename(&tmp, path).context("saving the demo account")
|
||||
}
|
||||
|
||||
@@ -335,6 +342,7 @@ impl DemoBook {
|
||||
placed: Some(now),
|
||||
updated: Some(now),
|
||||
underlying: None,
|
||||
market: None,
|
||||
};
|
||||
let id = order.id.clone();
|
||||
self.orders.insert(0, order);
|
||||
@@ -515,6 +523,7 @@ impl DemoBook {
|
||||
currency: "USD".into(),
|
||||
cash_equivalent: false,
|
||||
underlying: None,
|
||||
market: None,
|
||||
}),
|
||||
}
|
||||
} else {
|
||||
@@ -554,6 +563,7 @@ impl DemoBook {
|
||||
currency: "USD".into(),
|
||||
trade_date: Some(now),
|
||||
settlement_date: Some(now),
|
||||
market: None,
|
||||
},
|
||||
);
|
||||
Ok(())
|
||||
|
||||
@@ -46,7 +46,7 @@ pub struct Connection {
|
||||
pub disabled: bool,
|
||||
pub created: Option<DateTime<Utc>>,
|
||||
pub updated: Option<DateTime<Utc>>,
|
||||
/// Whether the connection allows trading (terminal-rs only trades when the user enables it).
|
||||
/// Whether the connection allows trading (Terminal only trades when the user enables it).
|
||||
pub trading: bool,
|
||||
}
|
||||
|
||||
@@ -127,6 +127,10 @@ pub struct Position {
|
||||
pub cash_equivalent: bool,
|
||||
/// For options: the underlying ticker.
|
||||
pub underlying: Option<String>,
|
||||
/// The terminal market key the position prices and charts by, when the source names one
|
||||
/// exactly (`xyz:AAPL` for a Hyperliquid HIP-3 perp). It wins over the kind-based mapping.
|
||||
#[serde(default)]
|
||||
pub market: Option<String>,
|
||||
}
|
||||
|
||||
impl Position {
|
||||
@@ -153,9 +157,13 @@ impl Position {
|
||||
(cost.abs() > 1e-9).then(|| self.pnl_at(price).map(|p| p / cost.abs() * 100.0)).flatten()
|
||||
}
|
||||
|
||||
/// The terminal market key for charting, quotes and Congress lookups, when the
|
||||
/// instrument maps onto one (US-listed equities and ETFs, crypto, option underlyings).
|
||||
/// The terminal market key for charting, quotes and Congress lookups: [`Self::market`]
|
||||
/// when set, else the kind-based mapping (US-listed equities and ETFs, crypto, option
|
||||
/// underlyings).
|
||||
pub fn market_key(&self) -> Option<String> {
|
||||
if let Some(m) = &self.market {
|
||||
return Some(m.clone());
|
||||
}
|
||||
market_key(self.kind, self.underlying.as_deref().unwrap_or(&self.symbol), &self.currency)
|
||||
}
|
||||
|
||||
@@ -261,6 +269,9 @@ pub struct Order {
|
||||
pub placed: Option<DateTime<Utc>>,
|
||||
pub updated: Option<DateTime<Utc>>,
|
||||
pub underlying: Option<String>,
|
||||
/// As [`Position::market`].
|
||||
#[serde(default)]
|
||||
pub market: Option<String>,
|
||||
}
|
||||
|
||||
impl Order {
|
||||
@@ -285,6 +296,9 @@ impl Order {
|
||||
}
|
||||
|
||||
pub fn market_key(&self) -> Option<String> {
|
||||
if let Some(m) = &self.market {
|
||||
return Some(m.clone());
|
||||
}
|
||||
market_key(self.kind, self.underlying.as_deref().unwrap_or(&self.symbol), "USD")
|
||||
}
|
||||
|
||||
@@ -308,10 +322,16 @@ pub struct Activity {
|
||||
pub currency: String,
|
||||
pub trade_date: Option<DateTime<Utc>>,
|
||||
pub settlement_date: Option<DateTime<Utc>>,
|
||||
/// As [`Position::market`].
|
||||
#[serde(default)]
|
||||
pub market: Option<String>,
|
||||
}
|
||||
|
||||
impl Activity {
|
||||
pub fn market_key(&self) -> Option<String> {
|
||||
if let Some(m) = &self.market {
|
||||
return Some(m.clone());
|
||||
}
|
||||
market_key(InstrumentKind::Stock, self.symbol.as_deref()?, &self.currency)
|
||||
}
|
||||
}
|
||||
@@ -372,9 +392,16 @@ impl Portfolio {
|
||||
t.cash += a.cash.unwrap_or(0.0);
|
||||
t.buying_power += a.buying_power.unwrap_or(0.0);
|
||||
}
|
||||
// Futures (Hyperliquid perps) are margined: the account's cash backs them, so their value
|
||||
// is their profit or loss, never their notional.
|
||||
let mut futures_pnl = 0.0;
|
||||
for p in self.positions.iter().filter(|p| selected(&p.account_id) && !p.cash_equivalent) {
|
||||
let live = price_of(p);
|
||||
t.invested += p.market_value_at(live).unwrap_or(0.0);
|
||||
if p.kind == InstrumentKind::Future {
|
||||
futures_pnl += p.pnl_at(live).unwrap_or(0.0);
|
||||
} else {
|
||||
t.invested += p.market_value_at(live).unwrap_or(0.0);
|
||||
}
|
||||
if let (Some(cost), Some(pnl)) = (p.cost(), p.pnl_at(live)) {
|
||||
t.cost += cost;
|
||||
t.pnl += pnl;
|
||||
@@ -392,7 +419,7 @@ impl Portfolio {
|
||||
t.value = if reported.len() == n && n > 0 && price_of_is_trivial(&self.positions, &price_of) {
|
||||
reported.iter().sum()
|
||||
} else {
|
||||
t.cash + t.invested + cash_like
|
||||
t.cash + t.invested + futures_pnl + cash_like
|
||||
};
|
||||
t
|
||||
}
|
||||
@@ -538,6 +565,7 @@ pub fn parse_positions(account_id: &str, v: &Value) -> Vec<Position> {
|
||||
.unwrap_or_else(|| text(&inst["currency"])),
|
||||
cash_equivalent: p["cash_equivalent"].as_bool().unwrap_or(false),
|
||||
underlying,
|
||||
market: None,
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
@@ -597,6 +625,7 @@ pub fn parse_orders(account_id: &str, v: &Value) -> Vec<Order> {
|
||||
placed: time(&o["time_placed"]),
|
||||
updated: time(&o["time_updated"]).or_else(|| time(&o["time_executed"])),
|
||||
underlying,
|
||||
market: None,
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
@@ -623,6 +652,7 @@ pub fn parse_activities(account_id: &str, v: &Value) -> Vec<Activity> {
|
||||
currency: Some(text(&a["currency"]["code"])).filter(|c| !c.is_empty()).unwrap_or("USD".into()),
|
||||
trade_date: time(&a["trade_date"]),
|
||||
settlement_date: time(&a["settlement_date"]),
|
||||
market: None,
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
@@ -645,6 +675,7 @@ mod tests {
|
||||
currency: "USD".into(),
|
||||
cash_equivalent: false,
|
||||
underlying: None,
|
||||
market: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -686,6 +717,24 @@ mod tests {
|
||||
assert_eq!(opt.market_key().as_deref(), Some("AAPL US Equity"), "options chart their underlying");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_exact_market_key_wins_over_the_kind() {
|
||||
// A Hyperliquid HIP-3 perp: a future whose key the source names exactly.
|
||||
let mut perp = pos(InstrumentKind::Future, "xyz:AAPL", -0.5, 320.0, 300.0);
|
||||
assert_eq!(perp.market_key(), None, "futures have no key of their own");
|
||||
perp.market = Some("xyz:AAPL".into());
|
||||
assert_eq!(perp.market_key().as_deref(), Some("xyz:AAPL"), "never AAPL US Equity");
|
||||
let mut btc = pos(InstrumentKind::Future, "BTC", 0.01, 76_000.0, 70_000.0);
|
||||
btc.market = Some("BTCUSD Curncy".into());
|
||||
assert_eq!(btc.market_key().as_deref(), Some("BTCUSD Curncy"));
|
||||
// SnapTrade rows have none and keep today's mapping.
|
||||
assert_eq!(pos(InstrumentKind::Stock, "AAPL", 1.0, 1.0, 1.0).market_key().as_deref(), Some("AAPL US Equity"));
|
||||
// Saved data from before the field existed still loads.
|
||||
let mut v = serde_json::to_value(pos(InstrumentKind::Stock, "AAPL", 1.0, 1.0, 1.0)).unwrap();
|
||||
v.as_object_mut().unwrap().remove("market");
|
||||
assert_eq!(serde_json::from_value::<Position>(v).unwrap().market, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn totals_reprice_with_live_quotes() {
|
||||
let mut pf = Portfolio {
|
||||
@@ -724,6 +773,40 @@ mod tests {
|
||||
assert_eq!(pf.totals(Some("other"), |_| None), Totals::default());
|
||||
}
|
||||
|
||||
/// Greptile #37: a perp is margined, so repricing it moves equity by its P&L, never by its
|
||||
/// notional. 10 long at 190 marked 200 (P&L 100) in an account worth 1,000.
|
||||
#[test]
|
||||
fn futures_reprice_by_pnl_not_notional() {
|
||||
let account = |cash: f64| Account {
|
||||
id: "hl".into(),
|
||||
name: "Hyperliquid".into(),
|
||||
number: String::new(),
|
||||
institution: "Hyperliquid".into(),
|
||||
currency: "USD".into(),
|
||||
total: Some(1000.0),
|
||||
cash: Some(cash),
|
||||
buying_power: Some(200.0),
|
||||
kind: "Perps".into(),
|
||||
status: String::new(),
|
||||
is_paper: true,
|
||||
synced: None,
|
||||
connection_id: String::new(),
|
||||
};
|
||||
let mut long = pos(InstrumentKind::Future, "BTC", 10.0, 200.0, 190.0);
|
||||
long.account_id = "hl".into();
|
||||
// Cash is the collateral: equity less unrealised P&L.
|
||||
let pf = Portfolio { accounts: vec![account(900.0)], positions: vec![long.clone()], ..Default::default() };
|
||||
assert_eq!(pf.totals(None, |_| None).value, 1000.0, "reported equity");
|
||||
assert_eq!(pf.totals(None, |_| Some(200.0)).value, 1000.0, "the same price leaves equity alone");
|
||||
assert_eq!(pf.totals(None, |_| Some(210.0)).value, 1100.0, "+10 × 10");
|
||||
assert_eq!(pf.totals(None, |_| Some(200.0)).invested, 0.0, "perp notional is not invested cash");
|
||||
let mut short = pos(InstrumentKind::Future, "BTC", -10.0, 200.0, 210.0);
|
||||
short.account_id = "hl".into();
|
||||
let pf = Portfolio { accounts: vec![account(900.0)], positions: vec![short], ..Default::default() };
|
||||
assert_eq!(pf.totals(None, |_| Some(200.0)).value, 1000.0);
|
||||
assert_eq!(pf.totals(None, |_| Some(190.0)).value, 1100.0, "a short gains as the price falls");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn occ_symbols() {
|
||||
let o = parse_occ("NVDA 261218C00250000").unwrap();
|
||||
|
||||
@@ -46,6 +46,7 @@ fn position(
|
||||
currency: "USD".into(),
|
||||
cash_equivalent: false,
|
||||
underlying: None,
|
||||
market: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -120,6 +121,7 @@ pub fn portfolio() -> Portfolio {
|
||||
placed: Some(at(days, 14)),
|
||||
updated: Some(at(days, 15)),
|
||||
underlying: None,
|
||||
market: None,
|
||||
};
|
||||
let orders = vec![
|
||||
order("sim-brokerage", "sim-o-1", "NVDA", K::Stock, "BUY", "PENDING", "Limit", 10.0, Some(205.00), None, 0),
|
||||
@@ -162,6 +164,7 @@ pub fn portfolio() -> Portfolio {
|
||||
currency: "USD".into(),
|
||||
trade_date: Some(at(days, 14)),
|
||||
settlement_date: Some(at(days - 1, 0)),
|
||||
market: None,
|
||||
};
|
||||
let activities = vec![
|
||||
act(
|
||||
|
||||
+321
-20
@@ -2,9 +2,12 @@
|
||||
//!
|
||||
//! The partner keys (`clientId`, `consumerKey`) and the per-user `userId`/`userSecret` are
|
||||
//! stored together as one small JSON document: in the macOS Keychain (generic password,
|
||||
//! service `terminal-rs`, account `snaptrade`), or elsewhere in an owner-only (0600) file in
|
||||
//! service `terminal`, account `snaptrade`), or elsewhere in an owner-only (0600) file in
|
||||
//! the app's data directory. `SNAPTRADE_*` environment variables override stored values
|
||||
//! field by field. Nothing is ever written inside the repository.
|
||||
//!
|
||||
//! Before the app was renamed from terminal-rs, the Keychain service was `terminal-rs`; the
|
||||
//! first load moves credentials from there.
|
||||
|
||||
use std::fmt;
|
||||
use std::path::{Path, PathBuf};
|
||||
@@ -12,7 +15,9 @@ use std::path::{Path, PathBuf};
|
||||
use anyhow::{Context, Result};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
pub const KEYCHAIN_SERVICE: &str = "terminal-rs";
|
||||
pub const KEYCHAIN_SERVICE: &str = "terminal";
|
||||
/// The service credentials were saved under before the rename.
|
||||
pub const LEGACY_KEYCHAIN_SERVICE: &str = "terminal-rs";
|
||||
pub const KEYCHAIN_ACCOUNT: &str = "snaptrade";
|
||||
|
||||
/// Environment variables that override stored credentials.
|
||||
@@ -102,8 +107,8 @@ pub fn mask(secret: &str) -> String {
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum Backend {
|
||||
/// macOS Keychain generic password.
|
||||
Keychain,
|
||||
/// macOS Keychain generic password under `service`, moved there from `legacy` on first load.
|
||||
Keychain { service: String, legacy: Option<String> },
|
||||
/// Owner-only JSON file.
|
||||
File(PathBuf),
|
||||
}
|
||||
@@ -119,10 +124,19 @@ impl SecretStore {
|
||||
pub fn platform(data_dir: &Path) -> Self {
|
||||
let file = Backend::File(data_dir.join("secrets").join("snaptrade.json"));
|
||||
let force_file = std::env::var("TR_SECRET_STORE").is_ok_and(|v| v.eq_ignore_ascii_case("file"));
|
||||
let backend = if cfg!(target_os = "macos") && !force_file { Backend::Keychain } else { file };
|
||||
let backend = if cfg!(target_os = "macos") && !force_file {
|
||||
Backend::Keychain { service: KEYCHAIN_SERVICE.into(), legacy: Some(LEGACY_KEYCHAIN_SERVICE.into()) }
|
||||
} else {
|
||||
file
|
||||
};
|
||||
Self { backend }
|
||||
}
|
||||
|
||||
/// The Keychain under `service`, taking over an item left under `legacy`.
|
||||
pub fn keychain(service: &str, legacy: Option<&str>) -> Self {
|
||||
Self { backend: Backend::Keychain { service: service.into(), legacy: legacy.map(Into::into) } }
|
||||
}
|
||||
|
||||
pub fn file(path: impl Into<PathBuf>) -> Self {
|
||||
Self { backend: Backend::File(path.into()) }
|
||||
}
|
||||
@@ -130,14 +144,14 @@ impl SecretStore {
|
||||
/// Where credentials live, for the settings screen.
|
||||
pub fn describe(&self) -> String {
|
||||
match &self.backend {
|
||||
Backend::Keychain => format!("macOS Keychain ({KEYCHAIN_SERVICE} / {KEYCHAIN_ACCOUNT})"),
|
||||
Backend::Keychain { service, .. } => format!("macOS Keychain ({service} / {KEYCHAIN_ACCOUNT})"),
|
||||
Backend::File(p) => format!("{} (owner-only)", p.display()),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn load(&self) -> Result<Option<Credentials>> {
|
||||
let text = match &self.backend {
|
||||
Backend::Keychain => keychain::get()?,
|
||||
Backend::Keychain { service, legacy } => load_keychain(service, legacy.as_deref())?,
|
||||
Backend::File(p) => match std::fs::read_to_string(p) {
|
||||
Ok(t) => Some(t),
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => None,
|
||||
@@ -155,7 +169,7 @@ impl SecretStore {
|
||||
pub fn save(&self, creds: &Credentials) -> Result<()> {
|
||||
let json = serde_json::to_string(&creds.trimmed())?;
|
||||
match &self.backend {
|
||||
Backend::Keychain => keychain::set(&json),
|
||||
Backend::Keychain { service, .. } => keychain::set(service, &json),
|
||||
Backend::File(p) => write_private(p, &json),
|
||||
}
|
||||
}
|
||||
@@ -163,7 +177,11 @@ impl SecretStore {
|
||||
/// Remove stored credentials (environment variables are unaffected).
|
||||
pub fn clear(&self) -> Result<()> {
|
||||
match &self.backend {
|
||||
Backend::Keychain => keychain::delete(),
|
||||
Backend::Keychain { service, legacy } => {
|
||||
keychain::delete(service)?;
|
||||
// The old item too, or the credentials would come back from it on the next load.
|
||||
legacy.as_deref().map_or(Ok(()), keychain::delete)
|
||||
}
|
||||
Backend::File(p) => match std::fs::remove_file(p) {
|
||||
Err(e) if e.kind() != std::io::ErrorKind::NotFound => Err(e.into()),
|
||||
_ => Ok(()),
|
||||
@@ -172,6 +190,24 @@ impl SecretStore {
|
||||
}
|
||||
}
|
||||
|
||||
/// Reads the Keychain item under `service`. If there is none, the item under `legacy` moves
|
||||
/// there: it is deleted only after the copy is written, so a failed move leaves it in place.
|
||||
fn load_keychain(service: &str, legacy: Option<&str>) -> Result<Option<String>> {
|
||||
if let Some(text) = keychain::get(service)? {
|
||||
return Ok(Some(text));
|
||||
}
|
||||
let Some(legacy) = legacy else { return Ok(None) };
|
||||
let Some(text) = keychain::get(legacy)? else { return Ok(None) };
|
||||
match keychain::set(service, &text) {
|
||||
Ok(()) => match keychain::delete(legacy) {
|
||||
Ok(()) => tracing::info!("moved SnapTrade credentials from Keychain service {legacy} to {service}"),
|
||||
Err(e) => tracing::warn!("removing SnapTrade credentials from Keychain service {legacy}: {e:#}"),
|
||||
},
|
||||
Err(e) => tracing::warn!("moving SnapTrade credentials to Keychain service {service}: {e:#}"),
|
||||
}
|
||||
Ok(Some(text))
|
||||
}
|
||||
|
||||
/// Write `text` to `path` readable only by the owner (0700 directory, 0600 file), replacing it
|
||||
/// atomically.
|
||||
fn write_private(path: &Path, text: &str) -> Result<()> {
|
||||
@@ -204,26 +240,38 @@ mod keychain {
|
||||
use anyhow::{Context, Result};
|
||||
use security_framework::passwords::{delete_generic_password, get_generic_password, set_generic_password};
|
||||
|
||||
use super::{KEYCHAIN_ACCOUNT, KEYCHAIN_SERVICE};
|
||||
use super::KEYCHAIN_ACCOUNT;
|
||||
|
||||
/// `errSecItemNotFound`.
|
||||
const NOT_FOUND: i32 = -25300;
|
||||
|
||||
pub fn get() -> Result<Option<String>> {
|
||||
match get_generic_password(KEYCHAIN_SERVICE, KEYCHAIN_ACCOUNT) {
|
||||
pub fn get(service: &str) -> Result<Option<String>> {
|
||||
get_item(service, KEYCHAIN_ACCOUNT)
|
||||
}
|
||||
|
||||
pub fn set(service: &str, value: &str) -> Result<()> {
|
||||
set_item(service, KEYCHAIN_ACCOUNT, value)
|
||||
}
|
||||
|
||||
pub fn delete(service: &str) -> Result<()> {
|
||||
delete_item(service, KEYCHAIN_ACCOUNT)
|
||||
}
|
||||
|
||||
pub fn get_item(service: &str, account: &str) -> Result<Option<String>> {
|
||||
match get_generic_password(service, account) {
|
||||
Ok(bytes) => Ok(Some(String::from_utf8(bytes).context("Keychain item is not UTF-8")?)),
|
||||
Err(e) if e.code() == NOT_FOUND => Ok(None),
|
||||
Err(e) => Err(anyhow::anyhow!("reading the Keychain: {e}")),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn set(value: &str) -> Result<()> {
|
||||
set_generic_password(KEYCHAIN_SERVICE, KEYCHAIN_ACCOUNT, value.as_bytes())
|
||||
pub fn set_item(service: &str, account: &str, value: &str) -> Result<()> {
|
||||
set_generic_password(service, account, value.as_bytes())
|
||||
.map_err(|e| anyhow::anyhow!("writing the Keychain: {e}"))
|
||||
}
|
||||
|
||||
pub fn delete() -> Result<()> {
|
||||
match delete_generic_password(KEYCHAIN_SERVICE, KEYCHAIN_ACCOUNT) {
|
||||
pub fn delete_item(service: &str, account: &str) -> Result<()> {
|
||||
match delete_generic_password(service, account) {
|
||||
Err(e) if e.code() != NOT_FOUND => Err(anyhow::anyhow!("deleting from the Keychain: {e}")),
|
||||
_ => Ok(()),
|
||||
}
|
||||
@@ -234,15 +282,203 @@ mod keychain {
|
||||
mod keychain {
|
||||
use anyhow::{Result, bail};
|
||||
|
||||
pub fn get() -> Result<Option<String>> {
|
||||
pub fn get(_: &str) -> Result<Option<String>> {
|
||||
bail!("the Keychain is only available on macOS")
|
||||
}
|
||||
pub fn set(_: &str) -> Result<()> {
|
||||
pub fn set(_: &str, _: &str) -> Result<()> {
|
||||
bail!("the Keychain is only available on macOS")
|
||||
}
|
||||
pub fn delete() -> Result<()> {
|
||||
pub fn delete(_: &str) -> Result<()> {
|
||||
bail!("the Keychain is only available on macOS")
|
||||
}
|
||||
#[allow(dead_code)]
|
||||
pub fn get_item(_: &str, _: &str) -> Result<Option<String>> {
|
||||
bail!("the Keychain is only available on macOS")
|
||||
}
|
||||
#[allow(dead_code)]
|
||||
pub fn set_item(_: &str, _: &str, _: &str) -> Result<()> {
|
||||
bail!("the Keychain is only available on macOS")
|
||||
}
|
||||
#[allow(dead_code)]
|
||||
pub fn delete_item(_: &str, _: &str) -> Result<()> {
|
||||
bail!("the Keychain is only available on macOS")
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Hyperliquid agent keys ----------------------------------------------------------------
|
||||
|
||||
/// The Keychain service (and Secret Service attribute) for Hyperliquid agent keys, apart
|
||||
/// from the SnapTrade item.
|
||||
pub const HL_KEYCHAIN_SERVICE: &str = "terminal.hyperliquid";
|
||||
|
||||
/// Where Hyperliquid agent (API wallet) keys live: the macOS Keychain, the Linux Secret
|
||||
/// Service, or memory (the browser's session-only keys). Never a file: without a system
|
||||
/// secret store the key has nowhere safe to go, so Hyperliquid trading stays off.
|
||||
///
|
||||
/// Items are named by account, `testnet:<master address>`. Values are the key's hex; this
|
||||
/// type hands them back in buffers that are wiped when dropped, and never logs them.
|
||||
#[derive(Clone)]
|
||||
pub struct AgentKeyStore {
|
||||
backend: KeyBackend,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
enum KeyBackend {
|
||||
Keychain,
|
||||
SecretService,
|
||||
Memory(std::sync::Arc<std::sync::Mutex<std::collections::HashMap<String, zeroize::Zeroizing<String>>>>),
|
||||
Unavailable(String),
|
||||
}
|
||||
|
||||
impl fmt::Debug for AgentKeyStore {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
f.write_str(&self.describe())
|
||||
}
|
||||
}
|
||||
|
||||
impl AgentKeyStore {
|
||||
/// The system store for this platform, or why there is none.
|
||||
pub fn platform() -> Self {
|
||||
let backend = if cfg!(target_os = "macos") {
|
||||
KeyBackend::Keychain
|
||||
} else if cfg!(target_os = "linux") {
|
||||
KeyBackend::SecretService
|
||||
} else {
|
||||
KeyBackend::Unavailable(
|
||||
"Hyperliquid trading keys need the macOS Keychain or a Linux Secret Service, and this platform has neither in this build."
|
||||
.into(),
|
||||
)
|
||||
};
|
||||
Self { backend }
|
||||
}
|
||||
|
||||
/// Keys held only while the app runs (lost on quit or reload).
|
||||
pub fn memory() -> Self {
|
||||
Self { backend: KeyBackend::Memory(Default::default()) }
|
||||
}
|
||||
|
||||
pub fn unavailable(why: impl Into<String>) -> Self {
|
||||
Self { backend: KeyBackend::Unavailable(why.into()) }
|
||||
}
|
||||
|
||||
pub fn describe(&self) -> String {
|
||||
match &self.backend {
|
||||
KeyBackend::Keychain => format!("macOS Keychain ({HL_KEYCHAIN_SERVICE})"),
|
||||
KeyBackend::SecretService => format!("the Secret Service keyring ({HL_KEYCHAIN_SERVICE})"),
|
||||
KeyBackend::Memory(_) => "memory only (gone when Terminal closes)".into(),
|
||||
KeyBackend::Unavailable(_) => "no secret store".into(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn is_memory(&self) -> bool {
|
||||
matches!(self.backend, KeyBackend::Memory(_))
|
||||
}
|
||||
|
||||
/// Whether keys can be stored, and why not. On Linux this asks the Secret Service, so
|
||||
/// call it off the UI thread.
|
||||
pub fn check(&self) -> Result<(), String> {
|
||||
match &self.backend {
|
||||
KeyBackend::Unavailable(why) => Err(why.clone()),
|
||||
KeyBackend::SecretService => secret_service::probe(),
|
||||
_ => Ok(()),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn load(&self, account: &str) -> Result<Option<zeroize::Zeroizing<String>>> {
|
||||
match &self.backend {
|
||||
KeyBackend::Keychain => Ok(keychain::get_item(HL_KEYCHAIN_SERVICE, account)?.map(zeroize::Zeroizing::new)),
|
||||
KeyBackend::SecretService => secret_service::get(account),
|
||||
KeyBackend::Memory(m) => Ok(m.lock().expect("key store").get(account).cloned()),
|
||||
KeyBackend::Unavailable(why) => anyhow::bail!("{why}"),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn save(&self, account: &str, secret: &str) -> Result<()> {
|
||||
match &self.backend {
|
||||
KeyBackend::Keychain => keychain::set_item(HL_KEYCHAIN_SERVICE, account, secret),
|
||||
KeyBackend::SecretService => secret_service::set(account, secret),
|
||||
KeyBackend::Memory(m) => {
|
||||
m.lock().expect("key store").insert(account.into(), zeroize::Zeroizing::new(secret.into()));
|
||||
Ok(())
|
||||
}
|
||||
KeyBackend::Unavailable(why) => anyhow::bail!("{why}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Remove a key. Removing one that isn't there succeeds.
|
||||
pub fn delete(&self, account: &str) -> Result<()> {
|
||||
match &self.backend {
|
||||
KeyBackend::Keychain => keychain::delete_item(HL_KEYCHAIN_SERVICE, account),
|
||||
KeyBackend::SecretService => secret_service::delete(account),
|
||||
KeyBackend::Memory(m) => {
|
||||
m.lock().expect("key store").remove(account);
|
||||
Ok(())
|
||||
}
|
||||
KeyBackend::Unavailable(_) => Ok(()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The Linux Secret Service (GNOME Keyring, KWallet) through `keyring`.
|
||||
#[cfg(target_os = "linux")]
|
||||
mod secret_service {
|
||||
use anyhow::Result;
|
||||
use keyring::{Entry, Error};
|
||||
|
||||
use super::HL_KEYCHAIN_SERVICE;
|
||||
|
||||
fn unavailable(e: Error) -> String {
|
||||
format!(
|
||||
"No Secret Service keyring is available ({e}). Hyperliquid trading keys are never written to a file, so trading stays off until a keyring (GNOME Keyring or KWallet) is running."
|
||||
)
|
||||
}
|
||||
|
||||
pub fn probe() -> Result<(), String> {
|
||||
let e = Entry::new(HL_KEYCHAIN_SERVICE, "probe").map_err(unavailable)?;
|
||||
match e.get_password() {
|
||||
Ok(_) | Err(Error::NoEntry) => Ok(()),
|
||||
Err(err) => Err(unavailable(err)),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get(account: &str) -> Result<Option<zeroize::Zeroizing<String>>> {
|
||||
match Entry::new(HL_KEYCHAIN_SERVICE, account)?.get_password() {
|
||||
Ok(s) => Ok(Some(zeroize::Zeroizing::new(s))),
|
||||
Err(Error::NoEntry) => Ok(None),
|
||||
Err(e) => Err(anyhow::anyhow!(unavailable(e))),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn set(account: &str, secret: &str) -> Result<()> {
|
||||
Entry::new(HL_KEYCHAIN_SERVICE, account)?.set_password(secret).map_err(|e| anyhow::anyhow!(unavailable(e)))
|
||||
}
|
||||
|
||||
pub fn delete(account: &str) -> Result<()> {
|
||||
match Entry::new(HL_KEYCHAIN_SERVICE, account)?.delete_credential() {
|
||||
Ok(()) | Err(Error::NoEntry) => Ok(()),
|
||||
Err(e) => Err(anyhow::anyhow!(unavailable(e))),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
mod secret_service {
|
||||
use anyhow::{Result, bail};
|
||||
|
||||
const WHY: &str = "the Secret Service is only used on Linux";
|
||||
|
||||
pub fn probe() -> Result<(), String> {
|
||||
Err(WHY.into())
|
||||
}
|
||||
pub fn get(_: &str) -> Result<Option<zeroize::Zeroizing<String>>> {
|
||||
bail!(WHY)
|
||||
}
|
||||
pub fn set(_: &str, _: &str) -> Result<()> {
|
||||
bail!(WHY)
|
||||
}
|
||||
pub fn delete(_: &str) -> Result<()> {
|
||||
bail!(WHY)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -253,7 +489,7 @@ mod tests {
|
||||
Credentials {
|
||||
client_id: "PARTNER-ID".into(),
|
||||
consumer_key: "consumer-key-0123456789".into(),
|
||||
user_id: "terminal-rs-1".into(),
|
||||
user_id: "terminal-1".into(),
|
||||
user_secret: "user-secret-abcdef".into(),
|
||||
}
|
||||
}
|
||||
@@ -313,4 +549,69 @@ mod tests {
|
||||
assert_eq!(store.load().unwrap(), None);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn agent_keys_never_fall_back_to_a_file() {
|
||||
let none = AgentKeyStore::unavailable("no keyring here");
|
||||
assert_eq!(none.check(), Err("no keyring here".into()));
|
||||
assert!(none.save("testnet:0xabc", "secret").is_err(), "refused, not written anywhere");
|
||||
assert!(none.load("testnet:0xabc").is_err());
|
||||
assert!(none.delete("testnet:0xabc").is_ok());
|
||||
let platform = AgentKeyStore::platform();
|
||||
if cfg!(target_os = "macos") {
|
||||
assert!(platform.describe().contains("Keychain"));
|
||||
} else if cfg!(target_os = "linux") {
|
||||
assert!(platform.describe().contains("Secret Service"));
|
||||
} else {
|
||||
assert!(platform.check().is_err(), "other platforms have no store, so trading stays off");
|
||||
}
|
||||
let mem = AgentKeyStore::memory();
|
||||
mem.save("testnet:0xabc", "0x11").unwrap();
|
||||
assert_eq!(mem.load("testnet:0xabc").unwrap().as_deref().map(String::as_str), Some("0x11"));
|
||||
mem.delete("testnet:0xabc").unwrap();
|
||||
assert_eq!(mem.load("testnet:0xabc").unwrap(), None);
|
||||
assert!(!format!("{mem:?}").contains("0x11"));
|
||||
}
|
||||
|
||||
/// Writes a throwaway agent key to the login Keychain, so it runs only when asked:
|
||||
/// `cargo test -p tr-broker -- --ignored agent_key`.
|
||||
#[cfg(target_os = "macos")]
|
||||
#[test]
|
||||
#[ignore = "writes to the login Keychain"]
|
||||
fn agent_key_round_trips_through_the_keychain() {
|
||||
let store = AgentKeyStore::platform();
|
||||
let account = format!("test:{}", std::process::id());
|
||||
store.save(&account, "0x00").unwrap();
|
||||
assert_eq!(store.load(&account).unwrap().as_deref().map(String::as_str), Some("0x00"));
|
||||
store.delete(&account).unwrap();
|
||||
assert_eq!(store.load(&account).unwrap(), None);
|
||||
store.delete(&account).unwrap();
|
||||
}
|
||||
|
||||
/// Writes throwaway items (dummy credentials) to the login Keychain, so it runs only when
|
||||
/// asked: `cargo test -p tr-broker -- --ignored keychain`.
|
||||
#[cfg(target_os = "macos")]
|
||||
#[test]
|
||||
#[ignore = "writes to the login Keychain"]
|
||||
fn keychain_moves_credentials_from_the_old_service() {
|
||||
let pid = std::process::id();
|
||||
let (service, legacy) = (format!("terminal-test-{pid}"), format!("terminal-rs-test-{pid}"));
|
||||
let store = SecretStore::keychain(&service, Some(&legacy));
|
||||
let new_only = SecretStore::keychain(&service, None);
|
||||
let old_only = SecretStore::keychain(&legacy, None);
|
||||
old_only.save(&creds()).unwrap();
|
||||
|
||||
assert_eq!(store.load().unwrap(), Some(creds()), "found under the old service");
|
||||
assert_eq!(new_only.load().unwrap(), Some(creds()), "and moved to the new one");
|
||||
assert_eq!(old_only.load().unwrap(), None, "the old item is gone");
|
||||
|
||||
// A stale item under the old service never replaces what the new one holds.
|
||||
old_only.save(&Credentials { client_id: "STALE".into(), ..creds() }).unwrap();
|
||||
assert_eq!(store.load().unwrap(), Some(creds()));
|
||||
|
||||
// Forgetting credentials removes both, so the old item can't bring them back.
|
||||
store.clear().unwrap();
|
||||
assert_eq!(old_only.load().unwrap(), None);
|
||||
assert_eq!(store.load().unwrap(), None);
|
||||
}
|
||||
}
|
||||
@@ -256,18 +256,27 @@ impl SnapTrade {
|
||||
Self::with_base(creds, &base)
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub fn with_base(creds: Credentials, base: &str) -> Result<Self> {
|
||||
if !creds.has_keys() {
|
||||
bail!("SnapTrade client ID and consumer key are not set");
|
||||
}
|
||||
let http = reqwest::Client::builder()
|
||||
.user_agent(concat!("terminal-rs/", env!("CARGO_PKG_VERSION")))
|
||||
.user_agent(concat!("terminal/", env!("CARGO_PKG_VERSION")))
|
||||
.timeout(Duration::from_secs(30))
|
||||
.build()?;
|
||||
let base = Url::parse(base.trim_end_matches('/')).context("invalid SnapTrade base URL")?;
|
||||
Ok(Self { http, base, creds })
|
||||
}
|
||||
|
||||
/// SnapTrade doesn't answer cross-origin requests, so the browser build can't make a client
|
||||
/// (and so never sends an order).
|
||||
/// TODO(web proxy): call SnapTrade through a same-origin proxy that keeps the keys server-side.
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
pub fn with_base(_creds: Credentials, _base: &str) -> Result<Self> {
|
||||
bail!("SnapTrade isn't available in the browser yet; use the desktop app for brokerage accounts")
|
||||
}
|
||||
|
||||
pub fn credentials(&self) -> &Credentials {
|
||||
&self.creds
|
||||
}
|
||||
|
||||
@@ -685,6 +685,7 @@ pub fn simulate_place(pf: &mut Portfolio, preview: &OrderPreview, now: DateTime<
|
||||
placed: Some(now),
|
||||
updated: Some(now),
|
||||
underlying: None,
|
||||
market: None,
|
||||
};
|
||||
if let Some(px) = fill {
|
||||
let signed = if req.side == Side::Buy { units } else { -units };
|
||||
@@ -715,6 +716,7 @@ fn apply_fill(pf: &mut Portfolio, account: &str, symbol: &str, signed: f64, px:
|
||||
currency: "USD".into(),
|
||||
cash_equivalent: false,
|
||||
underlying: None,
|
||||
market: None,
|
||||
});
|
||||
return;
|
||||
};
|
||||
|
||||
@@ -93,7 +93,7 @@ async fn mock(seen: Arc<Mutex<Vec<Seen>>>, failures: Arc<Mutex<Vec<String>>>) ->
|
||||
|| path.starts_with("/connection/")
|
||||
|| path == "/snapTrade/login";
|
||||
if user_scoped
|
||||
&& (param("userId").as_deref() != Some("terminal-rs-test")
|
||||
&& (param("userId").as_deref() != Some("terminal-test")
|
||||
|| param("userSecret").as_deref() != Some("s3cr3t/+="))
|
||||
{
|
||||
fail("missing or wrong user credentials".into());
|
||||
@@ -156,7 +156,7 @@ fn creds() -> Credentials {
|
||||
Credentials {
|
||||
client_id: "TEST-CLIENT".into(),
|
||||
consumer_key: CONSUMER_KEY.into(),
|
||||
user_id: "terminal-rs-test".into(),
|
||||
user_id: "terminal-test".into(),
|
||||
user_secret: "s3cr3t/+=".into(),
|
||||
}
|
||||
}
|
||||
@@ -244,8 +244,8 @@ async fn registers_a_user_opens_the_portal_and_disconnects() {
|
||||
let partner =
|
||||
SnapTrade::with_base(Credentials { user_id: String::new(), user_secret: String::new(), ..creds() }, &base)
|
||||
.unwrap();
|
||||
let (id, secret) = partner.register_user("terminal-rs-test").await.unwrap();
|
||||
assert_eq!((id.as_str(), secret.as_str()), ("terminal-rs-test", "fresh-secret"));
|
||||
let (id, secret) = partner.register_user("terminal-test").await.unwrap();
|
||||
assert_eq!((id.as_str(), secret.as_str()), ("terminal-test", "fresh-secret"));
|
||||
|
||||
let client = SnapTrade::with_base(creds(), &base).unwrap();
|
||||
let url = client.connection_portal_url(true, None).await.unwrap();
|
||||
|
||||
@@ -58,7 +58,7 @@ fn client(base: &str) -> SnapTrade {
|
||||
let creds = Credentials {
|
||||
client_id: "TEST".into(),
|
||||
consumer_key: "key".into(),
|
||||
user_id: "terminal-rs-test".into(),
|
||||
user_id: "terminal-test".into(),
|
||||
user_secret: "s".into(),
|
||||
};
|
||||
SnapTrade::with_base(creds, base).unwrap()
|
||||
|
||||
@@ -80,7 +80,7 @@ async fn mock(seen: Log, failures: Log) -> String {
|
||||
let body: Option<Value> = (!body.is_empty()).then(|| serde_json::from_slice(&body).unwrap());
|
||||
let sig = headers.iter().find(|(k, _)| k == "signature").map(|(_, v)| v.clone()).unwrap_or_default();
|
||||
let ok = sig == signature(CONSUMER_KEY, path, query, body.as_ref())
|
||||
&& param(query, "userId").as_deref() == Some("terminal-rs-test")
|
||||
&& param(query, "userId").as_deref() == Some("terminal-test")
|
||||
&& param(query, "userSecret").as_deref() == Some("s3cr3t");
|
||||
let entry = Seen { method: method.clone(), path: path.to_string(), body: body.clone() };
|
||||
if !ok {
|
||||
@@ -161,7 +161,7 @@ fn client(base: &str) -> SnapTrade {
|
||||
let creds = Credentials {
|
||||
client_id: "TEST-CLIENT".into(),
|
||||
consumer_key: CONSUMER_KEY.into(),
|
||||
user_id: "terminal-rs-test".into(),
|
||||
user_id: "terminal-test".into(),
|
||||
user_secret: "s3cr3t".into(),
|
||||
};
|
||||
SnapTrade::with_base(creds, base).unwrap()
|
||||
|
||||
@@ -6,8 +6,9 @@ edition.workspace = true
|
||||
|
||||
[dependencies]
|
||||
tr-core.workspace = true
|
||||
egui = "0.36"
|
||||
egui-wgpu = "0.36"
|
||||
egui = { version = "0.36", default-features = false }
|
||||
# GPU backends come from the app (eframe), so the browser build can leave out WebGL.
|
||||
egui-wgpu = { version = "0.36", default-features = false }
|
||||
bytemuck = { version = "1", features = ["derive"] }
|
||||
chrono.workspace = true
|
||||
serde.workspace = true
|
||||
@@ -15,6 +16,8 @@ pollster = "0.4"
|
||||
|
||||
[dev-dependencies]
|
||||
criterion.workspace = true
|
||||
# Tests, benches and examples render offscreen on a real backend.
|
||||
egui-wgpu = "0.36"
|
||||
|
||||
[[bench]]
|
||||
name = "frame"
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
//! GPU chart rendering for terminal-rs.
|
||||
//! GPU chart rendering for Terminal.
|
||||
//!
|
||||
//! - [`viewport`]: bar-index/price <-> pixel math, pan and zoom.
|
||||
//! - [`lod`]: level-of-detail pyramids that bound per-frame work to the plot width.
|
||||
|
||||
@@ -1,16 +1,19 @@
|
||||
[package]
|
||||
name = "tr-congress"
|
||||
description = "STOCK Act disclosure ingest (Senate eFD + House Clerk PTRs) for terminal-rs"
|
||||
description = "STOCK Act disclosure ingest (Senate eFD + House Clerk PTRs) for Terminal"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
|
||||
[dependencies]
|
||||
anyhow.workspace = true
|
||||
thiserror.workspace = true
|
||||
chrono.workspace = true
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
regex.workspace = true
|
||||
|
||||
# Ingest and storage: desktop only (the browser build has no SQLite and can't reach the sources).
|
||||
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
|
||||
thiserror.workspace = true
|
||||
serde_json.workspace = true
|
||||
tokio.workspace = true
|
||||
reqwest.workspace = true
|
||||
rusqlite.workspace = true
|
||||
|
||||
@@ -7,6 +7,7 @@ use anyhow::Result;
|
||||
use chrono::{NaiveDate, Utc};
|
||||
use rusqlite::{Connection, OptionalExtension, Row, params};
|
||||
|
||||
pub use crate::model::{Alert, PoliticianSummary};
|
||||
use crate::model::{AmountRange, Chamber, ParsedTrade, Party, Politician, Trade, TxType};
|
||||
|
||||
const SCHEMA: &str = r#"
|
||||
@@ -111,17 +112,6 @@ pub struct FilingRecord {
|
||||
pub is_amendment: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct Alert {
|
||||
pub id: i64,
|
||||
pub politician_id: i64,
|
||||
pub politician: String,
|
||||
pub filing_id: String,
|
||||
pub created_at: i64,
|
||||
pub message: String,
|
||||
pub seen: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
|
||||
pub struct Stats {
|
||||
pub politicians: i64,
|
||||
@@ -130,15 +120,6 @@ pub struct Stats {
|
||||
pub scanned: i64,
|
||||
}
|
||||
|
||||
/// A politician with aggregate trade counts, for list views.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct PoliticianSummary {
|
||||
pub politician: Politician,
|
||||
pub trades: i64,
|
||||
pub last_filed: Option<NaiveDate>,
|
||||
pub tracked: bool,
|
||||
}
|
||||
|
||||
pub struct Db {
|
||||
conn: Connection,
|
||||
}
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
//! The browser build's stand-in for the SQLite store: always empty, because the browser
|
||||
//! can't fetch disclosures yet. It keeps the desktop store's method signatures so callers
|
||||
//! compile unchanged.
|
||||
//!
|
||||
//! TODO(web proxy): fill this from a same-origin proxy for Senate eFD and the House Clerk.
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
use anyhow::{Result, bail};
|
||||
|
||||
pub use crate::model::{Alert, PoliticianSummary};
|
||||
use crate::model::{Politician, Trade};
|
||||
|
||||
pub struct Db;
|
||||
|
||||
impl Db {
|
||||
pub fn open(_path: impl AsRef<Path>) -> Result<Self> {
|
||||
Ok(Self)
|
||||
}
|
||||
|
||||
pub fn open_in_memory() -> Result<Self> {
|
||||
Ok(Self)
|
||||
}
|
||||
|
||||
pub fn latest_trades(&self, _limit: usize) -> Result<Vec<Trade>> {
|
||||
Ok(Vec::new())
|
||||
}
|
||||
|
||||
pub fn trades_for_ticker(&self, _ticker: &str, _limit: usize) -> Result<Vec<Trade>> {
|
||||
Ok(Vec::new())
|
||||
}
|
||||
|
||||
pub fn trades_for_politician(&self, _politician_id: i64) -> Result<Vec<Trade>> {
|
||||
Ok(Vec::new())
|
||||
}
|
||||
|
||||
pub fn politician(&self, _id: i64) -> Result<Option<Politician>> {
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
pub fn search_politicians(&self, _query: &str, _limit: usize) -> Result<Vec<PoliticianSummary>> {
|
||||
Ok(Vec::new())
|
||||
}
|
||||
|
||||
pub fn set_tracked(&self, _politician_id: i64, _tracked: bool) -> Result<()> {
|
||||
bail!("tracking needs congressional data, which the browser build can't load yet")
|
||||
}
|
||||
|
||||
pub fn is_tracked(&self, _politician_id: i64) -> Result<bool> {
|
||||
Ok(false)
|
||||
}
|
||||
|
||||
pub fn alerts(&self, _unseen_only: bool, _limit: usize) -> Result<Vec<Alert>> {
|
||||
Ok(Vec::new())
|
||||
}
|
||||
|
||||
pub fn mark_alerts_seen(&self) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,23 @@
|
||||
//! STOCK Act congressional trade disclosures, ingested directly from the
|
||||
//! official sources: the Senate eFD system and the House Clerk's PTR filings.
|
||||
//!
|
||||
//! The browser build (`wasm32`) has no SQLite and can't reach either site (they don't allow
|
||||
//! cross-origin requests), so there [`db`] is an empty stand-in and there is no ingest.
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub mod db;
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
#[path = "db_web.rs"]
|
||||
pub mod db;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub mod house;
|
||||
pub mod model;
|
||||
pub mod performance;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub mod refresh;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub mod roster;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub mod senate;
|
||||
|
||||
pub use db::Db;
|
||||
|
||||
@@ -243,6 +243,36 @@ pub fn normalize_ticker(raw: &str) -> Option<String> {
|
||||
RE.is_match(&t).then_some(t)
|
||||
}
|
||||
|
||||
/// An alert raised when a tracked politician files new trades.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct Alert {
|
||||
pub id: i64,
|
||||
pub politician_id: i64,
|
||||
pub politician: String,
|
||||
pub filing_id: String,
|
||||
pub created_at: i64,
|
||||
pub message: String,
|
||||
pub seen: bool,
|
||||
}
|
||||
|
||||
/// A politician with aggregate trade counts, for list views.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct PoliticianSummary {
|
||||
pub politician: Politician,
|
||||
pub trades: i64,
|
||||
pub last_filed: Option<NaiveDate>,
|
||||
pub tracked: bool,
|
||||
}
|
||||
|
||||
/// A new filing by a tracked politician, found during a refresh.
|
||||
#[derive(Debug, Clone, Default, PartialEq)]
|
||||
pub struct NewTradeAlert {
|
||||
pub politician_id: i64,
|
||||
pub politician: String,
|
||||
pub filing_id: String,
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
@@ -12,6 +12,7 @@ use tracing::{debug, info, warn};
|
||||
|
||||
use crate::db::{Db, FilingRecord, FilingStatus, NewPolitician};
|
||||
use crate::house::{self, IndexEntry, PdfError};
|
||||
pub use crate::model::NewTradeAlert;
|
||||
use crate::model::{Chamber, Party};
|
||||
use crate::roster::{self, Roster};
|
||||
use crate::senate::{self, ReportKind, ReportRef};
|
||||
@@ -58,14 +59,6 @@ impl RefreshConfig {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, PartialEq)]
|
||||
pub struct NewTradeAlert {
|
||||
pub politician_id: i64,
|
||||
pub politician: String,
|
||||
pub filing_id: String,
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct RefreshReport {
|
||||
pub new_filings: usize,
|
||||
@@ -394,7 +387,7 @@ impl HttpSource {
|
||||
pub fn new() -> Result<Self> {
|
||||
let client = reqwest::Client::builder()
|
||||
.user_agent(concat!(
|
||||
"terminal-rs/",
|
||||
"terminal/",
|
||||
env!("CARGO_PKG_VERSION"),
|
||||
" (STOCK Act disclosure reader; +https://github.com/)"
|
||||
))
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "tr-core"
|
||||
description = "Core market types, timeframes, indicators and sample data for terminal-rs"
|
||||
description = "Core market types, timeframes, indicators and sample data for Terminal"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
|
||||
|
||||
+24
-10
@@ -89,18 +89,24 @@ impl Series {
|
||||
i.checked_sub(1)
|
||||
}
|
||||
|
||||
/// Apply one live bar: absorb it into the last bar if it is the same period, append it
|
||||
/// if newer, ignore it if older. Returns true if the series changed.
|
||||
/// Apply one live bar by its `ts`: absorb it into the bar of the same period, append it if
|
||||
/// newer, or insert it where a period is missing (a feed backfilling a gap after an outage).
|
||||
/// Bars older than the whole series are ignored; paging loads those. Returns true if the
|
||||
/// series changed.
|
||||
pub fn apply_live(bars: &mut Vec<Bar>, live: Bar) -> bool {
|
||||
match bars.last_mut() {
|
||||
Some(last) if last.ts == live.ts => {
|
||||
let before = *last;
|
||||
last.absorb(&live);
|
||||
*last != before
|
||||
if bars.last().is_none_or(|last| last.ts < live.ts) {
|
||||
bars.push(live);
|
||||
return true;
|
||||
}
|
||||
match bars.binary_search_by_key(&live.ts, |b| b.ts) {
|
||||
Ok(i) => {
|
||||
let before = bars[i];
|
||||
bars[i].absorb(&live);
|
||||
bars[i] != before
|
||||
}
|
||||
Some(last) if last.ts > live.ts => false,
|
||||
_ => {
|
||||
bars.push(live);
|
||||
Err(0) => false,
|
||||
Err(i) => {
|
||||
bars.insert(i, live);
|
||||
true
|
||||
}
|
||||
}
|
||||
@@ -148,6 +154,14 @@ mod tests {
|
||||
assert!(!Series::apply_live(&mut v, Bar::new(0, 1.0, 1.0, 1.0, 1.0, 1.0)));
|
||||
assert!(Series::apply_live(&mut v, Bar::new(120, 11.5, 11.6, 11.4, 11.6, 5.0)));
|
||||
assert_eq!(v.len(), 2);
|
||||
// A feed backfilling after an outage: a missing period is inserted, an older bar it
|
||||
// still holds is completed, and nothing older than the series is added.
|
||||
assert!(Series::apply_live(&mut v, Bar::new(240, 12.0, 12.0, 12.0, 12.0, 1.0)));
|
||||
assert!(Series::apply_live(&mut v, Bar::new(180, 11.6, 11.9, 11.5, 11.8, 7.0)));
|
||||
assert!(Series::apply_live(&mut v, Bar::new(120, 11.5, 11.7, 11.4, 11.65, 6.0)));
|
||||
assert_eq!(v.iter().map(|b| b.ts).collect::<Vec<_>>(), [60, 120, 180, 240]);
|
||||
assert_eq!((v[1].high, v[1].close, v[1].volume), (11.7, 11.65, 6.0));
|
||||
assert!(!Series::apply_live(&mut v, Bar::new(180, 11.6, 11.9, 11.5, 11.8, 7.0)), "no change");
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
//! Core market types shared across terminal-rs: bars, timeframes, securities and
|
||||
//! Core market types shared across Terminal: bars, timeframes, securities and
|
||||
//! market keys, the security catalog, technical indicators and sample data.
|
||||
|
||||
pub mod bar;
|
||||
pub mod catalog;
|
||||
pub mod indicators;
|
||||
pub mod options;
|
||||
pub mod perps;
|
||||
pub mod sample;
|
||||
pub mod security;
|
||||
pub mod timeframe;
|
||||
|
||||
@@ -0,0 +1,334 @@
|
||||
//! Hyperliquid HIP-3 perpetuals, such as `xyz:AAPL`: their market keys, the venue label every
|
||||
//! surface shows with them, and the reviewed table that links an instrument the app already
|
||||
//! has (`AAPL US Equity`) to the perp on the same underlying.
|
||||
//!
|
||||
//! A perp is its own instrument. `AAPL US Equity` stays the listed stock, with its own quotes
|
||||
//! and chart; the table only lets that view point at `xyz:AAPL`. Which perps exist, and which
|
||||
//! are live, comes from the Terminal server at run time, so a row here never makes a perp
|
||||
//! appear by itself.
|
||||
//!
|
||||
//! A row needs all of these (docs/architecture/server.md §7.3):
|
||||
//! - the venue's own docs say the perp references the underlying the app key means;
|
||||
//! - the perp is live (not `isDelisted`) on Hyperliquid;
|
||||
//! - the `xyz` dex, unless xyz has no market on that underlying and another dex's match is
|
||||
//! unambiguous;
|
||||
//! - its price tracks the app instrument's.
|
||||
|
||||
/// A perp linked to an app instrument with the same underlying.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct PerpLink {
|
||||
/// The perp's market key, as Hyperliquid names the coin.
|
||||
pub coin: &'static str,
|
||||
/// The app market key with the same underlying.
|
||||
pub underlying: &'static str,
|
||||
/// Display name.
|
||||
pub name: &'static str,
|
||||
/// What the perp references, in the venue's words.
|
||||
pub reference: &'static str,
|
||||
}
|
||||
|
||||
const fn link(coin: &'static str, underlying: &'static str, name: &'static str, reference: &'static str) -> PerpLink {
|
||||
PerpLink { coin, underlying, name, reference }
|
||||
}
|
||||
|
||||
/// Checked on 2026-11-02 against trade.xyz's specification index, Hyperliquid's `meta` (all
|
||||
/// live) and the app instrument's price (within 0.6%; oil differs by its contract month).
|
||||
///
|
||||
/// Left out on purpose: `xyz:XYZ100` and `xyz:JP225` track the Nasdaq-100 and the Nikkei 225
|
||||
/// closely, but the venue's docs name neither index. `xyz:HO` tracks HO1, but the docs describe
|
||||
/// the diesel market under another name and never tie it to `HO`. `xyz:KR200` is the KOSPI 200,
|
||||
/// which the app doesn't have.
|
||||
pub const LINKS: &[PerpLink] = &[
|
||||
link("xyz:AAPL", "AAPL US Equity", "Apple Inc perp", "1 share of Apple Inc. common stock (Nasdaq: AAPL)"),
|
||||
link(
|
||||
"xyz:MSFT",
|
||||
"MSFT US Equity",
|
||||
"Microsoft Corp perp",
|
||||
"1 share of Microsoft Corporation common stock (Nasdaq: MSFT)",
|
||||
),
|
||||
link("xyz:NVDA", "NVDA US Equity", "NVIDIA Corp perp", "1 share of NVIDIA Corporation common stock (Nasdaq: NVDA)"),
|
||||
link(
|
||||
"xyz:AMZN",
|
||||
"AMZN US Equity",
|
||||
"Amazon.com Inc perp",
|
||||
"1 share of Amazon.com, Inc. common stock (Nasdaq: AMZN)",
|
||||
),
|
||||
link(
|
||||
"xyz:GOOGL",
|
||||
"GOOGL US Equity",
|
||||
"Alphabet Inc Class A perp",
|
||||
"1 share of Alphabet Inc. Class A common stock (Nasdaq: GOOGL)",
|
||||
),
|
||||
link(
|
||||
"xyz:META",
|
||||
"META US Equity",
|
||||
"Meta Platforms Inc perp",
|
||||
"1 share of Meta Platforms, Inc. Class A common stock (Nasdaq: META)",
|
||||
),
|
||||
link("xyz:TSLA", "TSLA US Equity", "Tesla Inc perp", "1 share of Tesla, Inc. common stock (Nasdaq: TSLA)"),
|
||||
link(
|
||||
"xyz:LLY",
|
||||
"LLY US Equity",
|
||||
"Eli Lilly & Co perp",
|
||||
"1 share of Eli Lilly and Company common stock (NYSE: LLY)",
|
||||
),
|
||||
link("xyz:AVGO", "AVGO US Equity", "Broadcom Inc perp", "1 share of Broadcom Inc. common stock (Nasdaq: AVGO)"),
|
||||
link(
|
||||
"xyz:AMD",
|
||||
"AMD US Equity",
|
||||
"Advanced Micro Devices perp",
|
||||
"1 share of Advanced Micro Devices, Inc. common stock (Nasdaq: AMD)",
|
||||
),
|
||||
link("xyz:NFLX", "NFLX US Equity", "Netflix Inc perp", "1 share of Netflix, Inc. common stock (Nasdaq: NFLX)"),
|
||||
link(
|
||||
"xyz:COIN",
|
||||
"COIN US Equity",
|
||||
"Coinbase Global Inc perp",
|
||||
"1 share of Coinbase Global, Inc. Class A common stock (Nasdaq: COIN)",
|
||||
),
|
||||
link(
|
||||
"xyz:MSTR",
|
||||
"MSTR US Equity",
|
||||
"Strategy Inc perp",
|
||||
"1 share of Strategy Inc Class A common stock (Nasdaq: MSTR)",
|
||||
),
|
||||
link(
|
||||
"xyz:TLT",
|
||||
"TLT US Equity",
|
||||
"iShares 20+ Year Treasury Bond ETF perp",
|
||||
"1 share of the iShares 20+ Year Treasury Bond ETF (Nasdaq: TLT)",
|
||||
),
|
||||
link(
|
||||
"xyz:SMH",
|
||||
"SMH US Equity",
|
||||
"VanEck Semiconductor ETF perp",
|
||||
"1 share of the VanEck Semiconductor ETF (Nasdaq: SMH)",
|
||||
),
|
||||
link(
|
||||
"xyz:XLE",
|
||||
"XLE US Equity",
|
||||
"Energy Select Sector SPDR perp",
|
||||
"1 share of the Energy Select Sector SPDR ETF (NYSE Arca: XLE)",
|
||||
),
|
||||
link("xyz:SP500", "SPX Index", "S&P 500 perp", "the S&P 500 Index"),
|
||||
link(
|
||||
"xyz:CL",
|
||||
"CL1 Comdty",
|
||||
"WTI crude oil perp",
|
||||
"1 barrel of WTI Light Sweet Crude Oil, from a futures contract it rolls monthly",
|
||||
),
|
||||
link(
|
||||
"xyz:BRENTOIL",
|
||||
"CO1 Comdty",
|
||||
"Brent crude oil perp",
|
||||
"1 barrel of Brent crude oil, from a futures contract it rolls monthly",
|
||||
),
|
||||
link(
|
||||
"xyz:NATGAS",
|
||||
"NG1 Comdty",
|
||||
"Natural gas perp",
|
||||
"1 MMBtu of Henry Hub natural gas, from a futures contract it rolls monthly",
|
||||
),
|
||||
link(
|
||||
"xyz:COPPER",
|
||||
"HG1 Comdty",
|
||||
"Copper perp",
|
||||
"1 pound of high-grade copper, from a futures contract it rolls monthly",
|
||||
),
|
||||
link("xyz:GOLD", "GC1 Comdty", "Gold perp", "the spot price of 1 troy ounce of gold (XAU/USD), not the future"),
|
||||
link(
|
||||
"xyz:SILVER",
|
||||
"SI1 Comdty",
|
||||
"Silver perp",
|
||||
"the spot price of 1 troy ounce of silver (XAG/USD), not the future",
|
||||
),
|
||||
link(
|
||||
"xyz:PLATINUM",
|
||||
"PL1 Comdty",
|
||||
"Platinum perp",
|
||||
"the spot price of 1 troy ounce of platinum (XPT/USD), not the future",
|
||||
),
|
||||
link("xyz:EUR", "EURUSD Curncy", "EUR/USD perp", "the EUR/USD spot rate, US dollars per euro"),
|
||||
link("xyz:GBP", "GBPUSD Curncy", "GBP/USD perp", "the GBP/USD spot rate, US dollars per pound"),
|
||||
link("xyz:JPY", "USDJPY Curncy", "USD/JPY perp", "the USD/JPY spot rate, yen per US dollar"),
|
||||
];
|
||||
|
||||
/// The canonical key for a perp written in any case: `XYZ:aapl` is `xyz:AAPL`. `None` unless
|
||||
/// it looks like `{dex}:{coin}`: a lowercase-able dex name of letters, then letters and digits.
|
||||
pub fn normalize(input: &str) -> Option<String> {
|
||||
let (dex, coin) = input.trim().split_once(':')?;
|
||||
let ok = |s: &str, digits: bool| {
|
||||
!s.is_empty() && s.len() <= 16 && s.chars().all(|c| c.is_ascii_alphabetic() || (digits && c.is_ascii_digit()))
|
||||
};
|
||||
(ok(dex, false) && ok(coin, true)).then(|| format!("{}:{}", dex.to_ascii_lowercase(), coin.to_ascii_uppercase()))
|
||||
}
|
||||
|
||||
/// Whether `key` names a HIP-3 perp.
|
||||
pub fn is_perp(key: &str) -> bool {
|
||||
normalize(key).is_some()
|
||||
}
|
||||
|
||||
/// The dex a perp trades on: `xyz` for `xyz:AAPL`.
|
||||
pub fn venue(key: &str) -> Option<&str> {
|
||||
key.split_once(':').map(|(dex, _)| dex).filter(|_| is_perp(key))
|
||||
}
|
||||
|
||||
/// The label every surface shows next to a perp, so it never passes for the listed
|
||||
/// instrument: `PERP · HL xyz`.
|
||||
pub fn badge(key: &str) -> String {
|
||||
match venue(key) {
|
||||
Some(dex) => format!("PERP · HL {}", dex.to_ascii_lowercase()),
|
||||
None => "PERP · HL".into(),
|
||||
}
|
||||
}
|
||||
|
||||
/// The perp linked to an app market key.
|
||||
pub fn perp_for(key: &str) -> Option<&'static PerpLink> {
|
||||
LINKS.iter().find(|l| l.underlying == key)
|
||||
}
|
||||
|
||||
/// The link for a perp, by its key in any case.
|
||||
pub fn link_for(key: &str) -> Option<&'static PerpLink> {
|
||||
let k = normalize(key)?;
|
||||
LINKS.iter().find(|l| l.coin == k)
|
||||
}
|
||||
|
||||
/// The perps among `keys` that match every word of `query` in their key, coin or linked name,
|
||||
/// best first. `PERP` in the query matches every perp; a yellow key (`GOLD CMDTY`) asks for
|
||||
/// listed instruments, so it matches none.
|
||||
pub fn search<'a>(query: &str, keys: impl IntoIterator<Item = &'a str>, limit: usize) -> Vec<&'a str> {
|
||||
let words: Vec<String> = query.split_whitespace().map(str::to_ascii_uppercase).collect();
|
||||
if words.is_empty() || words.iter().any(|w| crate::YellowKey::parse(w).is_some()) {
|
||||
return Vec::new();
|
||||
}
|
||||
let words: Vec<&String> = words.iter().filter(|w| !matches!(w.as_str(), "PERP" | "PERPS")).collect();
|
||||
let mut scored: Vec<(i32, &'a str)> = keys
|
||||
.into_iter()
|
||||
.filter_map(|key| {
|
||||
let up = key.to_ascii_uppercase();
|
||||
let coin = up.split_once(':').map_or(up.as_str(), |(_, c)| c);
|
||||
let name = link_for(key).map(|l| l.name.to_ascii_uppercase()).unwrap_or_default();
|
||||
let mut score = 1;
|
||||
for w in &words {
|
||||
score += if coin == w.as_str() {
|
||||
1000
|
||||
} else if coin.starts_with(w.as_str()) {
|
||||
600 - 10 * (coin.len() - w.len()) as i32
|
||||
} else if up.starts_with(w.as_str()) {
|
||||
500
|
||||
} else if name.split(|c: char| c.is_whitespace() || c == '/').any(|n| n.starts_with(w.as_str())) {
|
||||
300
|
||||
} else {
|
||||
return None;
|
||||
};
|
||||
}
|
||||
Some((score, key))
|
||||
})
|
||||
.collect();
|
||||
scored.sort_by(|a, b| b.0.cmp(&a.0).then(a.1.cmp(b.1)));
|
||||
scored.into_iter().take(limit).map(|(_, k)| k).collect()
|
||||
}
|
||||
|
||||
/// Where `PERP` takes a pane on `key`: from a linked perp to its instrument, or from an
|
||||
/// instrument to its perp while `live` says the server lists that perp.
|
||||
pub fn switch_target(key: &str, live: impl Fn(&str) -> bool) -> Option<&'static str> {
|
||||
match link_for(key) {
|
||||
Some(l) => Some(l.underlying),
|
||||
None => perp_for(key).map(|l| l.coin).filter(|c| live(c)),
|
||||
}
|
||||
}
|
||||
|
||||
/// The other side of a link: an instrument's perp, or a perp's instrument.
|
||||
pub fn counterpart(key: &str) -> Option<&'static str> {
|
||||
match link_for(key) {
|
||||
Some(l) => Some(l.underlying),
|
||||
None => perp_for(key).map(|l| l.coin),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::{Security, catalog};
|
||||
|
||||
#[test]
|
||||
fn keys_normalise_and_label_their_venue() {
|
||||
assert_eq!(normalize("XYZ:aapl").as_deref(), Some("xyz:AAPL"));
|
||||
assert_eq!(normalize(" xyz:XYZ100 ").as_deref(), Some("xyz:XYZ100"));
|
||||
assert_eq!(normalize("para:10Y").as_deref(), Some("para:10Y"));
|
||||
for not in ["AAPL", "xyz:", ":AAPL", "x1:AAPL", "xyz:AA-PL", "xyz:AAPL:B", "AAPL US Equity"] {
|
||||
assert_eq!(normalize(not), None, "{not}");
|
||||
}
|
||||
assert_eq!(venue("xyz:AAPL"), Some("xyz"));
|
||||
assert_eq!(venue("BTC"), None);
|
||||
assert_eq!(badge("xyz:AAPL"), "PERP · HL xyz");
|
||||
assert_eq!(badge("para:10Y"), "PERP · HL para");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn links_point_at_app_instruments_and_back() {
|
||||
assert_eq!(perp_for("AAPL US Equity").map(|l| l.coin), Some("xyz:AAPL"));
|
||||
assert_eq!(perp_for("SPX Index").map(|l| l.coin), Some("xyz:SP500"));
|
||||
assert_eq!(perp_for("USDJPY Curncy").map(|l| l.coin), Some("xyz:JPY"));
|
||||
assert_eq!(perp_for("CL1 Comdty").map(|l| l.coin), Some("xyz:CL"));
|
||||
assert_eq!(link_for("XYZ:eur").map(|l| l.underlying), Some("EURUSD Curncy"));
|
||||
// Not linked: the docs don't name these indices, or the app lacks the instrument.
|
||||
for unlinked in ["NDX Index", "NKY Index", "HO1 Comdty", "KOSPI Index", "JPM US Equity", "BTCUSD Curncy"] {
|
||||
assert_eq!(perp_for(unlinked), None, "{unlinked}");
|
||||
}
|
||||
assert_eq!(link_for("xyz:XYZ100"), None);
|
||||
assert_eq!(counterpart("AAPL US Equity"), Some("xyz:AAPL"));
|
||||
assert_eq!(counterpart("xyz:aapl"), Some("AAPL US Equity"));
|
||||
assert_eq!(counterpart("xyz:SKHX"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn switching_follows_links_to_live_perps_only() {
|
||||
let live = |c: &str| c == "xyz:AAPL";
|
||||
assert_eq!(switch_target("AAPL US Equity", live), Some("xyz:AAPL"));
|
||||
assert_eq!(switch_target("GC1 Comdty", live), None, "xyz:GOLD isn't listed live here");
|
||||
assert_eq!(switch_target("xyz:GOLD", live), Some("GC1 Comdty"), "back to the instrument always works");
|
||||
assert_eq!(switch_target("XYZ:aapl", |_| false), Some("AAPL US Equity"));
|
||||
assert_eq!(switch_target("xyz:SKHX", |_| true), None, "unlinked");
|
||||
assert_eq!(switch_target("NDX Index", |_| true), None, "no link");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn search_finds_perps_by_coin_key_or_name() {
|
||||
let live = ["xyz:AAPL", "xyz:GOLD", "xyz:SKHX", "xyz:SP500", "para:10Y", "xyz:AMD", "xyz:AMZN"];
|
||||
let s = |q: &str| search(q, live, 10);
|
||||
assert_eq!(s("aapl"), ["xyz:AAPL"]);
|
||||
assert_eq!(s("xyz:aa"), ["xyz:AAPL"]);
|
||||
assert_eq!(s("am")[..2], ["xyz:AMD", "xyz:AMZN"], "coin prefixes, shorter first");
|
||||
assert_eq!(s("apple"), ["xyz:AAPL"], "a linked perp's name");
|
||||
assert_eq!(s("gold perp"), ["xyz:GOLD"]);
|
||||
assert_eq!(s("s&p"), ["xyz:SP500"]);
|
||||
assert_eq!(s("skhx"), ["xyz:SKHX"], "unlinked perps by coin");
|
||||
assert_eq!(s("10y"), ["para:10Y"]);
|
||||
assert_eq!(s("perp").len(), live.len());
|
||||
assert!(s("gold cmdty").is_empty(), "a yellow key asks for the listed instrument");
|
||||
assert!(s("").is_empty() && s("zzzz").is_empty());
|
||||
assert_eq!(search("a", live, 2).len(), 2);
|
||||
}
|
||||
|
||||
/// Every row names a real app instrument, by its canonical key, on one side, and a
|
||||
/// well-formed xyz perp on the other, each at most once.
|
||||
#[test]
|
||||
fn the_table_is_well_formed() {
|
||||
let mut coins: Vec<&str> = LINKS.iter().map(|l| l.coin).collect();
|
||||
let mut keys: Vec<&str> = LINKS.iter().map(|l| l.underlying).collect();
|
||||
coins.sort();
|
||||
coins.dedup();
|
||||
keys.sort();
|
||||
keys.dedup();
|
||||
assert_eq!((coins.len(), keys.len()), (LINKS.len(), LINKS.len()), "one row per perp and per instrument");
|
||||
for l in LINKS {
|
||||
assert_eq!(normalize(l.coin).as_deref(), Some(l.coin), "{} is canonical", l.coin);
|
||||
assert_eq!(venue(l.coin), Some("xyz"), "{} prefers xyz", l.coin);
|
||||
let sec = Security::parse(l.underlying).expect("parses");
|
||||
assert_eq!(sec.key(), l.underlying, "{} is a canonical key", l.underlying);
|
||||
assert!(catalog::by_security(&sec).is_some(), "{} is in the catalog", l.underlying);
|
||||
assert!(l.name.ends_with(" perp") && !l.reference.is_empty());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,12 @@
|
||||
//! Securities across asset classes, addressed with Bloomberg-style market keys:
|
||||
//! `AAPL US Equity`, `SPX Index`, `BTCUSD Curncy`, `EURUSD Curncy`, `US10Y Govt`, `CL1 Comdty`.
|
||||
//! Hyperliquid HIP-3 perps keep the venue's own name, `xyz:AAPL` (see [`crate::perps`]).
|
||||
|
||||
use std::fmt;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::catalog;
|
||||
use crate::{catalog, perps};
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
|
||||
pub enum AssetClass {
|
||||
@@ -16,6 +17,8 @@ pub enum AssetClass {
|
||||
Crypto,
|
||||
Govt,
|
||||
Cmdty,
|
||||
/// A Hyperliquid HIP-3 perpetual (`xyz:AAPL`): its own instrument, never the listed one.
|
||||
Perp,
|
||||
}
|
||||
|
||||
/// The Bloomberg "yellow key" a security lives under.
|
||||
@@ -26,11 +29,14 @@ pub enum YellowKey {
|
||||
Curncy,
|
||||
Govt,
|
||||
Comdty,
|
||||
/// HIP-3 perps, which Bloomberg has no key for. Nothing typed parses to it: a perp is named
|
||||
/// by its venue key (`xyz:AAPL`), and `PERP` after a security is a function.
|
||||
Perp,
|
||||
}
|
||||
|
||||
impl YellowKey {
|
||||
pub const ALL: [YellowKey; 5] =
|
||||
[YellowKey::Equity, YellowKey::Index, YellowKey::Curncy, YellowKey::Govt, YellowKey::Comdty];
|
||||
pub const ALL: [YellowKey; 6] =
|
||||
[YellowKey::Equity, YellowKey::Index, YellowKey::Curncy, YellowKey::Govt, YellowKey::Comdty, YellowKey::Perp];
|
||||
|
||||
/// Accepts `<CRNCY>`, `CURNCY`, `crncy`, `CMDTY`, `COMDTY`, `GOVT`, `INDEX`, `EQUITY`, `EQ`, `ETF`.
|
||||
pub fn parse(s: &str) -> Option<Self> {
|
||||
@@ -52,6 +58,7 @@ impl YellowKey {
|
||||
YellowKey::Curncy => "Curncy",
|
||||
YellowKey::Govt => "Govt",
|
||||
YellowKey::Comdty => "Comdty",
|
||||
YellowKey::Perp => "Perp",
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -64,6 +71,7 @@ impl AssetClass {
|
||||
AssetClass::Fx | AssetClass::Crypto => YellowKey::Curncy,
|
||||
AssetClass::Govt => YellowKey::Govt,
|
||||
AssetClass::Cmdty => YellowKey::Comdty,
|
||||
AssetClass::Perp => YellowKey::Perp,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -76,6 +84,7 @@ impl AssetClass {
|
||||
AssetClass::Crypto => "Crypto",
|
||||
AssetClass::Govt => "Govt",
|
||||
AssetClass::Cmdty => "Comdty",
|
||||
AssetClass::Perp => "Perp",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -87,13 +96,14 @@ impl AssetClass {
|
||||
AssetClass::Fx => 0.08,
|
||||
AssetClass::Crypto => 0.60,
|
||||
AssetClass::Govt => 0.20,
|
||||
AssetClass::Cmdty => 0.30,
|
||||
AssetClass::Cmdty | AssetClass::Perp => 0.30,
|
||||
}
|
||||
}
|
||||
|
||||
/// Exchanges trade crypto around the clock; everything else has sessions.
|
||||
/// Exchanges trade crypto around the clock, and HIP-3 perps too (even while the market they
|
||||
/// reference is closed); everything else has sessions.
|
||||
pub fn trades_24_7(self) -> bool {
|
||||
self == AssetClass::Crypto
|
||||
matches!(self, AssetClass::Crypto | AssetClass::Perp)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -122,9 +132,15 @@ impl Security {
|
||||
Self::new(ticker, AssetClass::Equity)
|
||||
}
|
||||
|
||||
/// Canonical market key, e.g. `AAPL US Equity`, `BTCUSD Curncy`.
|
||||
/// A HIP-3 perp from its venue key in any case (`XYZ:aapl` is `xyz:AAPL`).
|
||||
pub fn perp(key: &str) -> Option<Self> {
|
||||
Some(Self { ticker: perps::normalize(key)?, class: AssetClass::Perp })
|
||||
}
|
||||
|
||||
/// Canonical market key, e.g. `AAPL US Equity`, `BTCUSD Curncy`, `xyz:AAPL`.
|
||||
pub fn key(&self) -> String {
|
||||
match self.class {
|
||||
AssetClass::Perp => self.ticker.clone(),
|
||||
AssetClass::Equity | AssetClass::Etf => format!("{} US Equity", self.ticker),
|
||||
c => format!("{} {}", self.ticker, c.yellow_key().label()),
|
||||
}
|
||||
@@ -134,6 +150,9 @@ impl Security {
|
||||
/// Returns the security and how many input tokens it consumed.
|
||||
pub fn resolve_tokens(tokens: &[&str]) -> Option<(Self, usize)> {
|
||||
let (&head, rest) = tokens.split_first()?;
|
||||
if let Some(perp) = Self::perp(head) {
|
||||
return Some((perp, 1));
|
||||
}
|
||||
// `BTC/USD` is a pair; `BRK/B` is a share class.
|
||||
let up = head.trim().to_ascii_uppercase();
|
||||
let ticker = match up.split_once('/') {
|
||||
@@ -187,7 +206,7 @@ impl Security {
|
||||
AssetClass::Crypto
|
||||
}
|
||||
}
|
||||
Some(YellowKey::Equity) => AssetClass::Equity,
|
||||
Some(YellowKey::Equity) | Some(YellowKey::Perp) => AssetClass::Equity,
|
||||
None if ticker.len() == 6 && is_fiat(&ticker[..3]) && is_fiat(&ticker[3..]) => AssetClass::Fx,
|
||||
None => AssetClass::Equity,
|
||||
};
|
||||
@@ -196,6 +215,9 @@ impl Security {
|
||||
|
||||
/// Display name from the catalog, if known.
|
||||
pub fn name(&self) -> Option<&'static str> {
|
||||
if self.class == AssetClass::Perp {
|
||||
return perps::link_for(&self.ticker).map(|l| l.name);
|
||||
}
|
||||
catalog::lookup(&self.ticker, Some(self.class.yellow_key())).filter(|l| l.class == self.class).map(|l| l.name)
|
||||
}
|
||||
|
||||
@@ -218,6 +240,10 @@ impl Security {
|
||||
AssetClass::Govt => 3,
|
||||
AssetClass::Fx if self.ticker.ends_with("JPY") => 3,
|
||||
AssetClass::Fx => 4,
|
||||
// Hyperliquid quotes five significant figures, at most six decimals.
|
||||
AssetClass::Perp if price.is_finite() && price != 0.0 => {
|
||||
(4 - price.abs().log10().floor() as i64).clamp(0, 6) as usize
|
||||
}
|
||||
_ => match price.abs() {
|
||||
p if p >= 1000.0 => 2,
|
||||
p if p >= 1.0 => 2,
|
||||
@@ -291,6 +317,27 @@ mod tests {
|
||||
assert_eq!(Security::resolve_tokens(&t).unwrap().1, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hip3_perps_are_their_own_instruments() {
|
||||
let p = r("xyz:AAPL");
|
||||
assert_eq!(p, Security { ticker: "xyz:AAPL".into(), class: AssetClass::Perp });
|
||||
assert_eq!(r("XYZ:aapl"), p, "any case");
|
||||
assert_eq!(p.key(), "xyz:AAPL");
|
||||
assert_eq!(r("AAPL"), Security::new("AAPL", AssetClass::Equity), "AAPL stays the listed stock");
|
||||
assert_eq!(r("AAPL US Equity").key(), "AAPL US Equity");
|
||||
assert_eq!(Security::resolve_tokens(&["xyz:AAPL", "5"]).unwrap().1, 1);
|
||||
assert_eq!(p.name(), Some("Apple Inc perp"));
|
||||
assert_eq!(r("xyz:SKHX").name(), None, "unlinked perps have no catalog name");
|
||||
assert_eq!((p.class.label(), p.class.yellow_key().label()), ("Perp", "Perp"));
|
||||
assert!(p.class.trades_24_7() && !p.has_congress_trades() && p.pair().is_none());
|
||||
// Prices show the five significant figures Hyperliquid quotes.
|
||||
assert_eq!(r("xyz:EUR").decimals(1.1208), 4);
|
||||
assert_eq!(r("xyz:JPY").decimals(157.74), 2);
|
||||
assert_eq!(r("xyz:GOLD").decimals(3958.05), 1);
|
||||
assert_eq!(r("xyz:XYZ100").decimals(28390.5), 0);
|
||||
assert_eq!(r("para:ANSEM").decimals(0.041887), 6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keys_and_helpers() {
|
||||
assert_eq!(Security::new("BTCUSD", AssetClass::Crypto).key(), "BTCUSD Curncy");
|
||||
|
||||
+17
-2
@@ -6,19 +6,34 @@ edition.workspace = true
|
||||
|
||||
[dependencies]
|
||||
tr-core.workspace = true
|
||||
tr-proto.workspace = true
|
||||
anyhow.workspace = true
|
||||
chrono.workspace = true
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
tokio.workspace = true
|
||||
reqwest.workspace = true
|
||||
rusqlite.workspace = true
|
||||
tracing.workspace = true
|
||||
async-trait = "0.1"
|
||||
futures = "0.3"
|
||||
web-time = "1"
|
||||
|
||||
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
|
||||
tokio.workspace = true
|
||||
rusqlite.workspace = true
|
||||
tokio-tungstenite = { version = "0.30", default-features = false, features = ["connect", "rustls-tls-webpki-roots"] }
|
||||
rustls = { version = "0.23", default-features = false, features = ["aws_lc_rs", "std"] }
|
||||
|
||||
# The browser build: no threads, SQLite or raw sockets. Fetch and WebSocket come from the
|
||||
# browser, and chrono reads the clock through JavaScript.
|
||||
[target.'cfg(target_arch = "wasm32")'.dependencies]
|
||||
tokio = { version = "1", default-features = false, features = ["sync", "macros"] }
|
||||
chrono = { workspace = true, features = ["wasmbind"] }
|
||||
wasm-bindgen = "0.2"
|
||||
wasm-bindgen-futures = "0.4"
|
||||
js-sys = "0.3"
|
||||
web-sys = { version = "0.3", features = ["Window", "WebSocket", "MessageEvent", "CloseEvent", "Event", "BinaryType"] }
|
||||
send_wrapper = { version = "0.6", features = ["futures"] }
|
||||
|
||||
[dev-dependencies]
|
||||
tokio = { workspace = true, features = ["test-util"] }
|
||||
rustls = { version = "0.23", default-features = false, features = ["aws_lc_rs", "std"] }
|
||||
+36
-32
@@ -6,13 +6,12 @@ use std::time::Duration;
|
||||
|
||||
use anyhow::{Context, Result, anyhow, bail};
|
||||
use chrono::DateTime;
|
||||
use futures::{SinkExt, StreamExt};
|
||||
use serde_json::Value;
|
||||
use tokio::sync::mpsc;
|
||||
use tokio_tungstenite::tungstenite::Message;
|
||||
use tr_core::{Bar, Security, Timeframe, catalog};
|
||||
|
||||
use crate::{LiveFeed, Quote};
|
||||
use crate::ws::Socket;
|
||||
use crate::{LiveFeed, Quote, rt};
|
||||
|
||||
const BINANCE_REST: [&str; 2] = ["https://data-api.binance.vision", "https://api.binance.com"];
|
||||
const BINANCE_WS: [&str; 2] = ["wss://data-stream.binance.vision/ws", "wss://stream.binance.com:9443/ws"];
|
||||
@@ -186,25 +185,37 @@ pub fn parse_coinbase_book(json: &str, symbol: &str, depth: usize) -> Result<cra
|
||||
Ok(b)
|
||||
}
|
||||
|
||||
const TIMEOUT: Duration = Duration::from_secs(20);
|
||||
|
||||
pub struct CryptoProvider {
|
||||
client: reqwest::Client,
|
||||
}
|
||||
|
||||
impl CryptoProvider {
|
||||
pub fn new() -> Result<Self> {
|
||||
let client = reqwest::Client::builder()
|
||||
.user_agent(concat!("terminal-rs/", env!("CARGO_PKG_VERSION")))
|
||||
.timeout(Duration::from_secs(20))
|
||||
.build()?;
|
||||
Ok(Self { client })
|
||||
let client = reqwest::Client::builder();
|
||||
// Browsers set their own User-Agent, and fetch takes its timeout per request.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
let client = client.user_agent(concat!("terminal/", env!("CARGO_PKG_VERSION"))).timeout(TIMEOUT);
|
||||
Ok(Self { client: client.build()? })
|
||||
}
|
||||
|
||||
async fn get(&self, url: &str, query: &[(&str, String)]) -> Result<String> {
|
||||
let resp = self.client.get(url).query(query).send().await?;
|
||||
if !resp.status().is_success() {
|
||||
bail!("GET {url}: HTTP {}", resp.status());
|
||||
}
|
||||
Ok(resp.text().await?)
|
||||
let req = self.client.get(url).query(query);
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
let req = req.timeout(TIMEOUT);
|
||||
let fetch = async move {
|
||||
let resp = req.send().await?;
|
||||
if !resp.status().is_success() {
|
||||
bail!("GET {url}: HTTP {}", resp.status());
|
||||
}
|
||||
Ok(resp.text().await?)
|
||||
};
|
||||
// A browser fetch holds JavaScript values, which are not `Send`. The web build has
|
||||
// one thread, so asserting it is sound and keeps the provider traits shared.
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
let fetch = send_wrapper::SendWrapper::new(fetch);
|
||||
fetch.await
|
||||
}
|
||||
|
||||
async fn binance_history(
|
||||
@@ -363,7 +374,7 @@ impl CryptoProvider {
|
||||
let (tx, rx) = mpsc::channel(256);
|
||||
let stream = format!("{}@kline_{}", binance_symbol(sec).to_ascii_lowercase(), binance_interval(tf));
|
||||
let product = coinbase_product(sec);
|
||||
tokio::spawn(async move {
|
||||
rt::spawn(async move {
|
||||
let mut backoff = 1;
|
||||
let mut attempt = 0usize;
|
||||
while !tx.is_closed() {
|
||||
@@ -378,7 +389,7 @@ impl CryptoProvider {
|
||||
tracing::debug!("live feed {stream}: {e:#}");
|
||||
}
|
||||
attempt += 1;
|
||||
tokio::time::sleep(Duration::from_secs(backoff)).await;
|
||||
rt::sleep(Duration::from_secs(backoff)).await;
|
||||
backoff = (backoff * 2).min(30);
|
||||
}
|
||||
});
|
||||
@@ -387,32 +398,25 @@ impl CryptoProvider {
|
||||
}
|
||||
|
||||
async fn binance_ws(url: &str, tx: &mpsc::Sender<Bar>) -> Result<()> {
|
||||
let (mut ws, _) = tokio_tungstenite::connect_async(url).await.context("binance websocket")?;
|
||||
while let Some(msg) = ws.next().await {
|
||||
match msg? {
|
||||
Message::Text(t) => {
|
||||
if let Some(bar) = parse_binance_ws(t.as_str())
|
||||
&& tx.send(bar).await.is_err()
|
||||
{
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
Message::Close(_) => break,
|
||||
_ => {}
|
||||
let mut ws = Socket::connect(url).await.context("binance websocket")?;
|
||||
while let Some(text) = ws.next().await {
|
||||
if let Some(bar) = parse_binance_ws(&text?)
|
||||
&& tx.send(bar).await.is_err()
|
||||
{
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
bail!("binance websocket closed")
|
||||
}
|
||||
|
||||
async fn coinbase_ws(product: &str, tf: Timeframe, tx: &mpsc::Sender<Bar>) -> Result<()> {
|
||||
let (mut ws, _) = tokio_tungstenite::connect_async(COINBASE_WS).await.context("coinbase websocket")?;
|
||||
let mut ws = Socket::connect(COINBASE_WS).await.context("coinbase websocket")?;
|
||||
let sub = serde_json::json!({"type": "subscribe", "product_ids": [product], "channels": ["ticker"]});
|
||||
ws.send(Message::Text(sub.to_string().into())).await?;
|
||||
ws.send(sub.to_string()).await?;
|
||||
// Aggregate ticks into the current bar; the chart merges partial bars into history.
|
||||
let mut bar: Option<Bar> = None;
|
||||
while let Some(msg) = ws.next().await {
|
||||
let Message::Text(t) = msg? else { continue };
|
||||
let Some((ts, px, size)) = parse_coinbase_ws(t.as_str()) else { continue };
|
||||
while let Some(text) = ws.next().await {
|
||||
let Some((ts, px, size)) = parse_coinbase_ws(&text?) else { continue };
|
||||
let bucket = tf.bucket_start(ts);
|
||||
let b = match bar.as_mut() {
|
||||
Some(b) if b.ts == bucket => {
|
||||
|
||||
+123
-9
@@ -11,14 +11,33 @@
|
||||
//!
|
||||
//! The router is wrapped in [`Fallback`], which serves sample data if a source is
|
||||
//! unreachable, so the terminal always has something to draw.
|
||||
//!
|
||||
//! `TR_SERVER_URL` (`ws://` or `wss://`) opts in to the Terminal data server
|
||||
//! ([`remote::RemoteMarkets`]): crypto markets then come from Hyperliquid perps through the
|
||||
//! server, and everything else, or anything the server can't serve, from the sources above.
|
||||
//!
|
||||
//! The browser build (`wasm32`) can only reach sources that allow cross-origin requests:
|
||||
//! [`for_browser`] serves live crypto through [`web::WebMarkets`] and sample data for the
|
||||
//! rest. It has no SQLite bar cache. When the page was built with a Terminal server URL
|
||||
//! ([`web::server_url`]), crypto comes through the server first, as on the desktop.
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub mod alpaca;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub mod cache;
|
||||
pub mod crypto;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub mod markets;
|
||||
pub mod remote;
|
||||
pub mod rss;
|
||||
pub mod rt;
|
||||
pub mod sample;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub mod treasury;
|
||||
pub mod web;
|
||||
/// The websocket client the providers use (the app's Hyperliquid user stream uses it too).
|
||||
pub mod ws;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub mod yahoo;
|
||||
|
||||
use std::sync::Arc;
|
||||
@@ -29,6 +48,7 @@ use serde::{Deserialize, Serialize};
|
||||
use tokio::sync::mpsc;
|
||||
use tr_core::{AssetClass, Bar, Security, Timeframe, catalog};
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub use cache::BarCache;
|
||||
|
||||
/// Latest quote for a symbol.
|
||||
@@ -137,6 +157,17 @@ impl OrderBook {
|
||||
}
|
||||
}
|
||||
|
||||
/// One trade on a venue's tape.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
|
||||
pub struct TradePrint {
|
||||
/// The venue's trade time (Unix milliseconds).
|
||||
pub ts_ms: i64,
|
||||
pub px: f64,
|
||||
pub sz: f64,
|
||||
/// The buyer was the aggressor.
|
||||
pub buy: bool,
|
||||
}
|
||||
|
||||
/// One listed option.
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct OptionContract {
|
||||
@@ -183,7 +214,9 @@ pub struct OptionChain {
|
||||
}
|
||||
|
||||
/// Live bar updates for one symbol and timeframe: each message is the latest state of
|
||||
/// the current (possibly still forming) bar. The feed stops when the receiver is dropped.
|
||||
/// the current (possibly still forming) bar, or, after an outage, an older bar that fills the
|
||||
/// gap ([`tr_core::Series::apply_live`] slots it in). The feed stops when the receiver is
|
||||
/// dropped.
|
||||
pub type LiveFeed = mpsc::Receiver<Bar>;
|
||||
|
||||
/// Pick curve snapshots from per-date rows: latest, nearest at or before -30d and -365d.
|
||||
@@ -254,6 +287,17 @@ pub trait MarketData: Send + Sync {
|
||||
anyhow::bail!("no public order book for {symbol}")
|
||||
}
|
||||
|
||||
/// The latest `n` trades, oldest first, where a public tape exists.
|
||||
async fn trades(&self, symbol: &str, _n: usize) -> Result<Vec<TradePrint>> {
|
||||
anyhow::bail!("no public trades for {symbol}")
|
||||
}
|
||||
|
||||
/// The HIP-3 perps (`xyz:AAPL`) the Terminal server lists as live right now. An error
|
||||
/// when there is no server or it can't be reached.
|
||||
async fn perp_markets(&self) -> Result<Vec<String>> {
|
||||
anyhow::bail!("no Terminal server")
|
||||
}
|
||||
|
||||
/// The US Treasury curve. The default assembles it from each tenor's daily history.
|
||||
async fn yield_curves(&self) -> Result<YieldCurves> {
|
||||
let tenors = catalog::treasury_curve();
|
||||
@@ -306,8 +350,8 @@ impl<P: MarketData> MarketData for Fallback<P> {
|
||||
async fn history(&self, symbol: &str, tf: Timeframe, end: Option<i64>, limit: usize) -> Result<Vec<Bar>> {
|
||||
match self.primary.history(symbol, tf, end, limit).await {
|
||||
Ok(b) => Ok(b),
|
||||
// Paging older history: never splice sample bars onto real ones.
|
||||
Err(e) if end.is_some() => Err(e),
|
||||
// Paging older history: never splice sample bars onto real ones. Perps never get them.
|
||||
Err(e) if end.is_some() || is_perp(symbol) => Err(e),
|
||||
Err(e) => {
|
||||
tracing::warn!("{} history {symbol} {tf} failed, using sample data: {e:#}", self.name);
|
||||
self.sample.history(symbol, tf, end, limit).await
|
||||
@@ -318,10 +362,11 @@ impl<P: MarketData> MarketData for Fallback<P> {
|
||||
async fn quotes(&self, symbols: &[String]) -> Result<Vec<Quote>> {
|
||||
match self.primary.quotes(symbols).await {
|
||||
// If the primary produced nothing at all (offline), serve sample quotes rather
|
||||
// than an empty panel; partial results are never mixed with synthetic prices.
|
||||
// than an empty panel; partial results are never mixed with synthetic prices. Perps
|
||||
// are never filled in.
|
||||
Ok(mut q) => {
|
||||
let missing: Vec<String> =
|
||||
symbols.iter().filter(|s| !q.iter().any(|x| &x.symbol == *s)).cloned().collect();
|
||||
symbols.iter().filter(|s| !is_perp(s) && !q.iter().any(|x| &x.symbol == *s)).cloned().collect();
|
||||
if !missing.is_empty() && q.is_empty() {
|
||||
q.extend(self.sample.quotes(&missing).await.unwrap_or_default());
|
||||
}
|
||||
@@ -329,7 +374,8 @@ impl<P: MarketData> MarketData for Fallback<P> {
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!("{} quotes failed, using sample data: {e:#}", self.name);
|
||||
self.sample.quotes(symbols).await
|
||||
let listed: Vec<String> = symbols.iter().filter(|s| !is_perp(s)).cloned().collect();
|
||||
self.sample.quotes(&listed).await
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -349,6 +395,7 @@ impl<P: MarketData> MarketData for Fallback<P> {
|
||||
async fn options(&self, symbol: &str, expiration: Option<i64>) -> Result<OptionChain> {
|
||||
match self.primary.options(symbol, expiration).await {
|
||||
Ok(c) => Ok(c),
|
||||
Err(e) if is_perp(symbol) => Err(e),
|
||||
Err(e) => {
|
||||
tracing::warn!("{} options {symbol}: {e:#}; simulating", self.name);
|
||||
self.sample.options(symbol, expiration).await
|
||||
@@ -360,6 +407,7 @@ impl<P: MarketData> MarketData for Fallback<P> {
|
||||
async fn order_book(&self, symbol: &str, depth: usize) -> Result<OrderBook> {
|
||||
match self.primary.order_book(symbol, depth).await {
|
||||
Ok(b) => Ok(b),
|
||||
Err(e) if is_perp(symbol) => Err(e),
|
||||
Err(e) => {
|
||||
tracing::debug!("{} order book {symbol}: {e:#}; simulating", self.name);
|
||||
self.sample.order_book(symbol, depth).await
|
||||
@@ -367,6 +415,15 @@ impl<P: MarketData> MarketData for Fallback<P> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Real trades only: nothing simulates a tape.
|
||||
async fn trades(&self, symbol: &str, n: usize) -> Result<Vec<TradePrint>> {
|
||||
self.primary.trades(symbol, n).await
|
||||
}
|
||||
|
||||
async fn perp_markets(&self) -> Result<Vec<String>> {
|
||||
self.primary.perp_markets().await
|
||||
}
|
||||
|
||||
async fn yield_curves(&self) -> Result<YieldCurves> {
|
||||
match self.primary.yield_curves().await {
|
||||
Ok(c) => Ok(c),
|
||||
@@ -383,7 +440,19 @@ pub fn security(symbol: &str) -> Security {
|
||||
Security::parse(symbol).unwrap_or_else(|| Security::equity(symbol))
|
||||
}
|
||||
|
||||
/// Whether `symbol` is a HIP-3 perp (`xyz:AAPL`).
|
||||
pub fn is_perp(symbol: &str) -> bool {
|
||||
tr_core::perps::is_perp(symbol)
|
||||
}
|
||||
|
||||
/// What a source other than the Terminal server says about a HIP-3 perp. Only the server
|
||||
/// carries them, and nothing stands in for them, not even sample data.
|
||||
pub fn perp_needs_server(symbol: &str) -> anyhow::Error {
|
||||
anyhow::anyhow!("{symbol} is a HIP-3 perp, which only the Terminal server provides")
|
||||
}
|
||||
|
||||
/// Build the provider described in the crate docs from environment variables.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub fn from_env() -> Provider {
|
||||
// reqwest and the websocket client both use rustls; pin one crypto provider.
|
||||
let _ = rustls::crypto::aws_lc_rs::default_provider().install_default();
|
||||
@@ -398,12 +467,39 @@ pub fn from_env() -> Provider {
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
match markets::Markets::new(alpaca) {
|
||||
Ok(m) => Arc::new(Fallback::new(m)),
|
||||
Err(_) => Arc::new(sample::SampleProvider::default().with_live_ticks()),
|
||||
let markets = match markets::Markets::new(alpaca) {
|
||||
Ok(m) => Fallback::new(m),
|
||||
Err(_) => return Arc::new(sample::SampleProvider::default().with_live_ticks()),
|
||||
};
|
||||
match var("TR_SERVER_URL") {
|
||||
Some(url) => {
|
||||
// The URL itself stays out of the logs.
|
||||
tracing::info!("TR_SERVER_URL is set: crypto markets come through the Terminal server");
|
||||
Arc::new(remote::RemoteMarkets::new(url.trim(), markets))
|
||||
}
|
||||
None => Arc::new(markets),
|
||||
}
|
||||
}
|
||||
|
||||
/// The browser build's provider: sample data only when `offline`, otherwise live crypto
|
||||
/// with sample data for everything else. With a Terminal `server` URL, crypto comes through
|
||||
/// that server, and from Binance and Coinbase whenever the server can't serve it.
|
||||
pub fn for_browser(offline: bool, server: Option<&str>) -> Provider {
|
||||
if !offline {
|
||||
match web::WebMarkets::new() {
|
||||
Ok(m) => {
|
||||
let m = Fallback::new(m);
|
||||
return match server {
|
||||
Some(url) => Arc::new(remote::RemoteMarkets::new(url, m)),
|
||||
None => Arc::new(m),
|
||||
};
|
||||
}
|
||||
Err(e) => tracing::warn!("crypto sources unavailable, using sample data: {e:#}"),
|
||||
}
|
||||
}
|
||||
Arc::new(sample::SampleProvider::default().with_live_ticks())
|
||||
}
|
||||
|
||||
/// Keep at most `limit` bars ending at or before `end`.
|
||||
pub(crate) fn clip(mut bars: Vec<Bar>, end: Option<i64>, limit: usize) -> Vec<Bar> {
|
||||
if let Some(end) = end {
|
||||
@@ -474,6 +570,24 @@ mod tests {
|
||||
assert_eq!(p.name(), "broken");
|
||||
}
|
||||
|
||||
/// A HIP-3 perp has no source but the Terminal server, so nothing stands in for it: no
|
||||
/// sample bars, quotes or books when a source fails, and none in offline sample mode.
|
||||
#[tokio::test]
|
||||
async fn perps_never_get_stand_in_data() {
|
||||
assert!(is_perp("xyz:AAPL") && is_perp("XYZ:aapl") && !is_perp("AAPL US Equity") && !is_perp("BTCUSD Curncy"));
|
||||
let p = Fallback::new(Broken);
|
||||
assert!(p.history("xyz:AAPL", Timeframe::D1, None, 10).await.is_err());
|
||||
assert!(p.order_book("xyz:AAPL", 10).await.is_err());
|
||||
let q = p.quotes(&["xyz:AAPL".into(), "AAPL US Equity".into()]).await.unwrap();
|
||||
assert_eq!(q.iter().map(|q| q.symbol.as_str()).collect::<Vec<_>>(), ["AAPL US Equity"]);
|
||||
let s = sample::SampleProvider::default().with_live_ticks();
|
||||
let e = s.history("xyz:AAPL", Timeframe::D1, None, 10).await.unwrap_err();
|
||||
assert!(format!("{e:#}").contains("Terminal server"), "{e:#}");
|
||||
assert!(s.order_book("xyz:AAPL", 10).await.is_err() && s.live("xyz:AAPL", Timeframe::M1).is_none());
|
||||
let q = s.quotes(&["xyz:AAPL".into(), "AAPL US Equity".into()]).await.unwrap();
|
||||
assert_eq!(q.iter().map(|q| q.symbol.as_str()).collect::<Vec<_>>(), ["AAPL US Equity"]);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn default_yield_curve_from_history() {
|
||||
let c = sample::SampleProvider::at(1_790_000_000, 1_000).yield_curves().await.unwrap();
|
||||
|
||||
@@ -6,6 +6,9 @@
|
||||
//! | Govt | Treasury.gov daily curve (FRED fallback); Yahoo `^TNX` etc. intraday | Treasury.gov | — |
|
||||
//! | Equity/ETF | Alpaca if keyed, else Yahoo | Yahoo spark | — |
|
||||
//! | FX/Index/Comdty | Yahoo (`EURUSD=X`, `^GSPC`, `CL=F`) | Yahoo spark | — |
|
||||
//! | HIP-3 perps (`xyz:AAPL`) | — | — | — |
|
||||
//!
|
||||
//! HIP-3 perps come only from the Terminal server ([`crate::remote::RemoteMarkets`]).
|
||||
|
||||
use anyhow::{Result, anyhow, bail};
|
||||
use async_trait::async_trait;
|
||||
@@ -15,7 +18,9 @@ use crate::alpaca::AlpacaProvider;
|
||||
use crate::crypto::CryptoProvider;
|
||||
use crate::treasury::TreasuryProvider;
|
||||
use crate::yahoo::{YahooProvider, yahoo_symbol};
|
||||
use crate::{LiveFeed, MarketData, NewsItem, OptionChain, OrderBook, Quote, SearchHit, YieldCurves, security};
|
||||
use crate::{
|
||||
LiveFeed, MarketData, NewsItem, OptionChain, OrderBook, Quote, SearchHit, YieldCurves, perp_needs_server, security,
|
||||
};
|
||||
|
||||
pub struct Markets {
|
||||
yahoo: YahooProvider,
|
||||
@@ -47,6 +52,7 @@ impl MarketData for Markets {
|
||||
async fn history(&self, symbol: &str, tf: Timeframe, end: Option<i64>, limit: usize) -> Result<Vec<Bar>> {
|
||||
let sec = security(symbol);
|
||||
match sec.class {
|
||||
AssetClass::Perp => Err(perp_needs_server(symbol)),
|
||||
AssetClass::Crypto => self.crypto.history(&sec, tf, end, limit).await,
|
||||
AssetClass::Govt if tf.is_intraday() => match catalog::by_security(&sec).and_then(|l| l.yahoo) {
|
||||
Some(y) => self.yahoo.history(y, tf, end, limit).await,
|
||||
@@ -69,6 +75,7 @@ impl MarketData for Markets {
|
||||
for key in symbols {
|
||||
let sec = security(key);
|
||||
match sec.class {
|
||||
AssetClass::Perp => {}
|
||||
AssetClass::Crypto => crypto.push((key.clone(), sec)),
|
||||
AssetClass::Govt => govt.push((key.clone(), sec)),
|
||||
_ => other.push((key.clone(), sec)),
|
||||
@@ -123,6 +130,11 @@ impl MarketData for Markets {
|
||||
Some(sec) => match sec.class {
|
||||
// Yahoo has no crypto headlines; use CoinDesk's feed.
|
||||
AssetClass::Crypto => return self.crypto.news(&sec).await,
|
||||
// A linked perp shares its underlying's headlines.
|
||||
AssetClass::Perp => match tr_core::perps::link_for(&sec.ticker) {
|
||||
Some(l) => yahoo_symbol(&security(l.underlying)),
|
||||
None => return Ok(Vec::new()),
|
||||
},
|
||||
AssetClass::Govt => catalog::by_security(&sec).and_then(|l| l.yahoo).unwrap_or("^TNX").to_string(),
|
||||
_ => yahoo_symbol(&sec),
|
||||
},
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,132 @@
|
||||
//! Background tasks and timers on either target: tokio on the desktop, the browser's event
|
||||
//! loop on the web (`wasm32`), where there is one thread and no tokio runtime.
|
||||
|
||||
use std::future::Future;
|
||||
use std::time::Duration;
|
||||
|
||||
use web_time::Instant;
|
||||
|
||||
/// Run `fut` in the background.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub fn spawn<F: Future<Output = ()> + Send + 'static>(fut: F) {
|
||||
tokio::spawn(fut);
|
||||
}
|
||||
|
||||
/// Run `fut` in the background.
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
pub fn spawn<F: Future<Output = ()> + 'static>(fut: F) {
|
||||
wasm_bindgen_futures::spawn_local(fut);
|
||||
}
|
||||
|
||||
/// Run CPU-bound work off the async workers; `None` if it panicked. The web has one thread,
|
||||
/// so there it runs inline.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub async fn blocking<T: Send + 'static>(f: impl FnOnce() -> T + Send + 'static) -> Option<T> {
|
||||
tokio::task::spawn_blocking(f).await.ok()
|
||||
}
|
||||
|
||||
/// Run CPU-bound work off the async workers; `None` if it panicked. The web has one thread,
|
||||
/// so there it runs inline.
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
pub async fn blocking<T>(f: impl FnOnce() -> T) -> Option<T> {
|
||||
Some(f())
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub async fn sleep(d: Duration) {
|
||||
tokio::time::sleep(d).await;
|
||||
}
|
||||
|
||||
/// Resolves after `d`, through a `setTimeout` set on the first poll and cleared if the sleep is
|
||||
/// dropped before it fires.
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
pub fn sleep(d: Duration) -> impl Future<Output = ()> + Send {
|
||||
struct Timer(Option<i32>);
|
||||
impl Drop for Timer {
|
||||
fn drop(&mut self) {
|
||||
if let (Some(id), Some(w)) = (self.0, web_sys::window()) {
|
||||
w.clear_timeout_with_handle(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
let ms = d.as_millis().min(i32::MAX as u128) as i32;
|
||||
// A promise isn't `Send`. The web build has one thread, so wrapping it is sound, and the provider
|
||||
// traits' `Send` futures can then wait on it.
|
||||
send_wrapper::SendWrapper::new(async move {
|
||||
let mut timer = Timer(None);
|
||||
let promise = js_sys::Promise::new(&mut |resolve, _| {
|
||||
timer.0 = web_sys::window()
|
||||
.and_then(|w| w.set_timeout_with_callback_and_timeout_and_arguments_0(&resolve, ms).ok());
|
||||
});
|
||||
let _ = wasm_bindgen_futures::JsFuture::from(promise).await;
|
||||
timer.0 = None; // it fired
|
||||
})
|
||||
}
|
||||
|
||||
/// `fut` ran past its deadline.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct Elapsed;
|
||||
|
||||
impl std::fmt::Display for Elapsed {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.write_str("deadline has elapsed")
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for Elapsed {}
|
||||
|
||||
/// Run `fut` for at most `d`.
|
||||
pub async fn timeout<F: Future>(d: Duration, fut: F) -> Result<F::Output, Elapsed> {
|
||||
let (fut, deadline) = (std::pin::pin!(fut), std::pin::pin!(sleep(d)));
|
||||
match futures::future::select(fut, deadline).await {
|
||||
futures::future::Either::Left((out, _)) => Ok(out),
|
||||
futures::future::Either::Right(_) => Err(Elapsed),
|
||||
}
|
||||
}
|
||||
|
||||
/// A periodic timer: the first tick completes at once, later ones every `period`. A late
|
||||
/// tick fires immediately and the next one is a full period after it.
|
||||
pub struct Interval {
|
||||
period: Duration,
|
||||
next: Instant,
|
||||
}
|
||||
|
||||
pub fn interval(period: Duration) -> Interval {
|
||||
Interval { period, next: Instant::now() }
|
||||
}
|
||||
|
||||
impl Interval {
|
||||
/// Wait for the next tick. Dropping the future part-way (in `select!`) loses nothing.
|
||||
pub async fn tick(&mut self) {
|
||||
let now = Instant::now();
|
||||
if self.next > now {
|
||||
sleep(self.next - now).await;
|
||||
}
|
||||
self.next = self.next.max(Instant::now()) + self.period;
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(all(test, not(target_arch = "wasm32")))]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[tokio::test]
|
||||
async fn interval_spaces_ticks() {
|
||||
let mut iv = interval(Duration::from_millis(20));
|
||||
let t0 = Instant::now();
|
||||
iv.tick().await;
|
||||
assert!(t0.elapsed() < Duration::from_millis(15), "first tick is immediate");
|
||||
iv.tick().await;
|
||||
iv.tick().await;
|
||||
assert!(t0.elapsed() >= Duration::from_millis(40));
|
||||
assert_eq!(blocking(|| 6 * 7).await, Some(42));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn timeout_stops_at_the_deadline() {
|
||||
assert_eq!(timeout(Duration::from_secs(5), async { 7 }).await, Ok(7));
|
||||
let t0 = Instant::now();
|
||||
assert_eq!(timeout(Duration::from_millis(20), std::future::pending::<()>()).await, Err(Elapsed));
|
||||
assert!(t0.elapsed() >= Duration::from_millis(20));
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,7 @@ use async_trait::async_trait;
|
||||
use tokio::sync::mpsc;
|
||||
use tr_core::{Bar, Timeframe, sample};
|
||||
|
||||
use crate::{LiveFeed, MarketData, OptionChain, OrderBook, security};
|
||||
use crate::{LiveFeed, MarketData, OptionChain, OrderBook, Quote, is_perp, perp_needs_server, rt, security};
|
||||
|
||||
/// Deterministic synthetic data anchored at a fixed "now" per process, so paging is
|
||||
/// consistent: page N+1 is exactly the bars preceding page N. Levels and volatility
|
||||
@@ -48,25 +48,48 @@ impl MarketData for SampleProvider {
|
||||
"sample"
|
||||
}
|
||||
|
||||
/// HIP-3 perps have no sample data: only the Terminal server provides them.
|
||||
async fn history(&self, symbol: &str, tf: Timeframe, end: Option<i64>, limit: usize) -> Result<Vec<Bar>> {
|
||||
if is_perp(symbol) {
|
||||
return Err(perp_needs_server(symbol));
|
||||
}
|
||||
Ok(crate::clip(self.bars(symbol, tf), end, limit))
|
||||
}
|
||||
|
||||
/// From daily history, like the default, but leaving out perps instead of failing on them.
|
||||
async fn quotes(&self, symbols: &[String]) -> Result<Vec<Quote>> {
|
||||
let mut out = Vec::with_capacity(symbols.len());
|
||||
for s in symbols.iter().filter(|s| !is_perp(s)) {
|
||||
let bars = self.history(s, Timeframe::D1, None, 2).await?;
|
||||
if let Some(last) = bars.last() {
|
||||
let prev = if bars.len() > 1 { bars[bars.len() - 2].close } else { last.open };
|
||||
out.push(Quote { symbol: s.clone(), last: last.close, prev_close: prev, ts: last.ts, spark: vec![] });
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// A synthetic chain priced with Black-Scholes around the last price.
|
||||
async fn options(&self, symbol: &str, expiration: Option<i64>) -> Result<OptionChain> {
|
||||
if is_perp(symbol) {
|
||||
return Err(perp_needs_server(symbol));
|
||||
}
|
||||
let spot = self.bars(symbol, Timeframe::D1).last().map(|b| b.close).unwrap_or(100.0);
|
||||
Ok(simulated_chain(symbol, spot, expiration, chrono::Utc::now().timestamp()))
|
||||
}
|
||||
|
||||
/// A synthetic book around the last price; sizes vary with the clock so it moves.
|
||||
async fn order_book(&self, symbol: &str, depth: usize) -> Result<OrderBook> {
|
||||
if is_perp(symbol) {
|
||||
return Err(perp_needs_server(symbol));
|
||||
}
|
||||
let last = self.bars(symbol, Timeframe::M1).last().map(|b| b.close).unwrap_or(100.0);
|
||||
Ok(simulated_book(symbol, last, depth, chrono::Utc::now().timestamp_millis() as u64 / 500))
|
||||
}
|
||||
|
||||
/// Random-walk ticks on the last bar every 400 ms, rolling into new bars on schedule.
|
||||
fn live(&self, symbol: &str, tf: Timeframe) -> Option<LiveFeed> {
|
||||
if !self.live_ticks {
|
||||
if !self.live_ticks || is_perp(symbol) {
|
||||
return None;
|
||||
}
|
||||
let mut bar = *self.bars(symbol, tf).last()?;
|
||||
@@ -74,8 +97,8 @@ impl MarketData for SampleProvider {
|
||||
let (tx, rx) = mpsc::channel(64);
|
||||
let tick_vol = sec.class.sample_vol() * (0.4 / (365.0 * 86_400.0f64)).sqrt() * 8.0;
|
||||
let mut rng = sample::Rng::new(sample::seed_for(symbol) ^ 0x5eed);
|
||||
tokio::spawn(async move {
|
||||
let mut iv = tokio::time::interval(Duration::from_millis(400));
|
||||
rt::spawn(async move {
|
||||
let mut iv = rt::interval(Duration::from_millis(400));
|
||||
loop {
|
||||
iv.tick().await;
|
||||
let now = chrono::Utc::now().timestamp();
|
||||
|
||||
@@ -113,7 +113,7 @@ pub struct TreasuryProvider {
|
||||
impl TreasuryProvider {
|
||||
pub fn new() -> Result<Self> {
|
||||
let client = reqwest::Client::builder()
|
||||
.user_agent("Mozilla/5.0 (compatible; terminal-rs)")
|
||||
.user_agent("Mozilla/5.0 (compatible; terminal)")
|
||||
.timeout(Duration::from_secs(30))
|
||||
.build()?;
|
||||
Ok(Self { client, years: Mutex::new(HashMap::new()) })
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
//! Market data for the browser build.
|
||||
//!
|
||||
//! A page can only read sources that allow cross-origin requests. Binance and Coinbase do,
|
||||
//! so crypto is live: REST history, quotes and order books, and websocket bars. Yahoo,
|
||||
//! Treasury.gov, Alpaca and CoinDesk do not, so every other asset class comes from the
|
||||
//! sample generator and there are no headlines or network search. HIP-3 perps come only from
|
||||
//! the Terminal server, so here they have no data at all.
|
||||
//!
|
||||
//! When the page has a Terminal server ([`server_url`]), [`crate::for_browser`] puts
|
||||
//! [`crate::remote::RemoteMarkets`] in front of these, so crypto comes from the server and
|
||||
//! from Binance and Coinbase only while the server can't serve it.
|
||||
//!
|
||||
//! TODO(web proxy): route equities, FX, indices, futures, Treasuries and news through a
|
||||
//! same-origin proxy to those sources, then use [`crate::markets::Markets`] here instead.
|
||||
|
||||
use anyhow::Result;
|
||||
use async_trait::async_trait;
|
||||
use tr_core::{AssetClass, Bar, Timeframe};
|
||||
|
||||
use crate::crypto::CryptoProvider;
|
||||
use crate::sample::SampleProvider;
|
||||
use crate::{LiveFeed, MarketData, OrderBook, Quote, YieldCurves, is_perp, perp_needs_server, security};
|
||||
|
||||
pub struct WebMarkets {
|
||||
crypto: CryptoProvider,
|
||||
sample: SampleProvider,
|
||||
}
|
||||
|
||||
impl WebMarkets {
|
||||
pub fn new() -> Result<Self> {
|
||||
Ok(Self { crypto: CryptoProvider::new()?, sample: SampleProvider::default() })
|
||||
}
|
||||
}
|
||||
|
||||
/// The Terminal server a page uses for crypto markets: `built`, the URL set when the page was
|
||||
/// built, unless the page's `?server=` parameter (`requested`) turns it off (`off`) or names a
|
||||
/// server on the same machine, such as `ws://localhost:8787/ws`. Other servers are refused, so a
|
||||
/// link can't point someone's page at a server that isn't ours.
|
||||
pub fn server_url(built: Option<&str>, requested: Option<&str>) -> Option<String> {
|
||||
let built = built.map(str::trim).filter(|u| !u.is_empty()).map(str::to_string);
|
||||
let Some(requested) = requested.map(str::trim) else { return built };
|
||||
if requested.eq_ignore_ascii_case("off") {
|
||||
return None;
|
||||
}
|
||||
if is_local(requested) {
|
||||
return Some(requested.to_string());
|
||||
}
|
||||
tracing::warn!("?server= takes a server on this machine (ws://localhost:8787/ws) or off; ignoring it");
|
||||
built
|
||||
}
|
||||
|
||||
/// A `ws://` or `wss://` URL whose host is this machine. Anything unusual (user info, other
|
||||
/// spellings of the address) counts as not local.
|
||||
fn is_local(url: &str) -> bool {
|
||||
let Some(rest) = url.strip_prefix("ws://").or_else(|| url.strip_prefix("wss://")) else { return false };
|
||||
let authority = rest.split(['/', '?', '#']).next().unwrap_or_default();
|
||||
if authority.contains('@') {
|
||||
return false;
|
||||
}
|
||||
let host = match authority.strip_prefix('[') {
|
||||
Some(v6) => v6.split(']').next().unwrap_or_default(),
|
||||
None => authority.split(':').next().unwrap_or_default(),
|
||||
};
|
||||
host.eq_ignore_ascii_case("localhost") || host == "127.0.0.1" || host == "::1"
|
||||
}
|
||||
|
||||
fn is_crypto(symbol: &str) -> bool {
|
||||
security(symbol).class == AssetClass::Crypto
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl MarketData for WebMarkets {
|
||||
fn name(&self) -> &str {
|
||||
"binance+sample"
|
||||
}
|
||||
|
||||
async fn history(&self, symbol: &str, tf: Timeframe, end: Option<i64>, limit: usize) -> Result<Vec<Bar>> {
|
||||
if is_perp(symbol) {
|
||||
return Err(perp_needs_server(symbol));
|
||||
}
|
||||
if is_crypto(symbol) {
|
||||
self.crypto.history(&security(symbol), tf, end, limit).await
|
||||
} else {
|
||||
self.sample.history(symbol, tf, end, limit).await
|
||||
}
|
||||
}
|
||||
|
||||
async fn quotes(&self, symbols: &[String]) -> Result<Vec<Quote>> {
|
||||
let listed = symbols.iter().filter(|s| !is_perp(s)).cloned();
|
||||
let (crypto, other): (Vec<String>, Vec<String>) = listed.partition(|s| is_crypto(s));
|
||||
let mut out = self.sample.quotes(&other).await?;
|
||||
if !crypto.is_empty() {
|
||||
let secs: Vec<_> = crypto.iter().map(|k| (k.clone(), security(k))).collect();
|
||||
match self.crypto.quotes(&secs).await {
|
||||
Ok(q) => out.extend(q),
|
||||
Err(e) if out.is_empty() => return Err(e),
|
||||
Err(e) => tracing::warn!("crypto quotes: {e:#}"),
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
fn live(&self, symbol: &str, tf: Timeframe) -> Option<LiveFeed> {
|
||||
is_crypto(symbol).then(|| CryptoProvider::live(&security(symbol), tf))
|
||||
}
|
||||
|
||||
async fn order_book(&self, symbol: &str, depth: usize) -> Result<OrderBook> {
|
||||
let sec = security(symbol);
|
||||
if sec.class == AssetClass::Crypto {
|
||||
self.crypto.order_book(symbol, &sec, depth).await
|
||||
} else {
|
||||
anyhow::bail!("no public order book for {} in the browser", sec.key())
|
||||
}
|
||||
}
|
||||
|
||||
async fn yield_curves(&self) -> Result<YieldCurves> {
|
||||
self.sample.yield_curves().await
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[tokio::test]
|
||||
async fn non_crypto_comes_from_sample_data() {
|
||||
let m = WebMarkets::new().unwrap();
|
||||
let bars = m.history("AAPL US Equity", Timeframe::D1, None, 50).await.unwrap();
|
||||
assert_eq!(bars.len(), 50);
|
||||
let q = m.quotes(&["AAPL US Equity".into(), "EURUSD Curncy".into()]).await.unwrap();
|
||||
assert_eq!(q.len(), 2);
|
||||
assert!(m.live("AAPL US Equity", Timeframe::M1).is_none());
|
||||
assert!(m.order_book("AAPL US Equity", 10).await.is_err());
|
||||
assert_eq!(m.yield_curves().await.unwrap().current.points.len(), 11);
|
||||
assert!(is_crypto("BTCUSD Curncy") && !is_crypto("US10Y Govt"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn perps_need_the_server() {
|
||||
let m = WebMarkets::new().unwrap();
|
||||
assert!(m.history("xyz:AAPL", Timeframe::D1, None, 50).await.is_err());
|
||||
assert!(m.order_book("xyz:AAPL", 10).await.is_err());
|
||||
let q = m.quotes(&["xyz:AAPL".into(), "AAPL US Equity".into()]).await.unwrap();
|
||||
assert_eq!(q.iter().map(|q| q.symbol.as_str()).collect::<Vec<_>>(), ["AAPL US Equity"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn server_url_from_the_build_or_a_local_override() {
|
||||
let prod = Some("wss://terminal-gateway.style.dev/ws");
|
||||
assert_eq!(server_url(prod, None).as_deref(), prod);
|
||||
assert_eq!(server_url(Some(" "), None), None, "an empty build setting means no server");
|
||||
assert_eq!(server_url(None, None), None);
|
||||
assert_eq!(server_url(prod, Some("off")), None);
|
||||
for local in ["ws://localhost:8787/ws", "ws://127.0.0.1:9", "wss://[::1]:8787/ws", "ws://LOCALHOST/ws"] {
|
||||
assert_eq!(server_url(prod, Some(local)).as_deref(), Some(local));
|
||||
assert_eq!(server_url(None, Some(local)).as_deref(), Some(local));
|
||||
}
|
||||
for refused in [
|
||||
"wss://evil.example/ws",
|
||||
"ws://localhost.evil.example/ws",
|
||||
"ws://[email protected]/ws",
|
||||
"ws://evil.example:80@localhost/ws",
|
||||
"http://localhost:8787/ws",
|
||||
"",
|
||||
] {
|
||||
assert_eq!(server_url(prod, Some(refused)).as_deref(), prod, "{refused}");
|
||||
assert_eq!(server_url(None, Some(refused)), None, "{refused}");
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn the_server_goes_in_front_of_the_browser_sources() {
|
||||
assert_eq!(crate::for_browser(false, None).name(), "binance+sample");
|
||||
assert_eq!(crate::for_browser(false, Some("ws://127.0.0.1:9/ws")).name(), "server+binance+sample");
|
||||
assert_eq!(crate::for_browser(true, Some("ws://127.0.0.1:9/ws")).name(), "sample");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
//! A websocket client: tokio-tungstenite on the desktop, the browser's `WebSocket` on the web.
|
||||
|
||||
use anyhow::Result;
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub use native::Socket;
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
pub use web::Socket;
|
||||
|
||||
/// A data message. Control frames stay inside the socket.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum Message {
|
||||
Text(String),
|
||||
Binary(Vec<u8>),
|
||||
}
|
||||
|
||||
impl Socket {
|
||||
pub async fn send(&mut self, text: String) -> Result<()> {
|
||||
self.send_message(Message::Text(text)).await
|
||||
}
|
||||
|
||||
/// The next text message, skipping binary ones; `None` once the server closes the connection.
|
||||
pub async fn next(&mut self) -> Option<Result<String>> {
|
||||
loop {
|
||||
match self.next_message().await? {
|
||||
Ok(Message::Text(t)) => return Some(Ok(t)),
|
||||
Ok(Message::Binary(_)) => {}
|
||||
Err(e) => return Some(Err(e)),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
mod native {
|
||||
use anyhow::{Context, Result};
|
||||
use futures::{SinkExt, StreamExt};
|
||||
use tokio_tungstenite::tungstenite::Message as WsMessage;
|
||||
use tokio_tungstenite::{MaybeTlsStream, WebSocketStream};
|
||||
|
||||
use super::Message;
|
||||
|
||||
pub struct Socket(WebSocketStream<MaybeTlsStream<tokio::net::TcpStream>>);
|
||||
|
||||
impl Socket {
|
||||
pub async fn connect(url: &str) -> Result<Self> {
|
||||
let (ws, _) = tokio_tungstenite::connect_async(url).await.with_context(|| format!("websocket {url}"))?;
|
||||
Ok(Self(ws))
|
||||
}
|
||||
|
||||
pub async fn send_message(&mut self, m: Message) -> Result<()> {
|
||||
let m = match m {
|
||||
Message::Text(t) => WsMessage::Text(t.into()),
|
||||
Message::Binary(b) => WsMessage::Binary(b.into()),
|
||||
};
|
||||
Ok(self.0.send(m).await?)
|
||||
}
|
||||
|
||||
/// The next data message; `None` once the server closes the connection.
|
||||
pub async fn next_message(&mut self) -> Option<Result<Message>> {
|
||||
while let Some(msg) = self.0.next().await {
|
||||
match msg {
|
||||
Ok(WsMessage::Text(t)) => return Some(Ok(Message::Text(t.as_str().to_owned()))),
|
||||
Ok(WsMessage::Binary(b)) => return Some(Ok(Message::Binary(b.into()))),
|
||||
Ok(WsMessage::Close(_)) => return None,
|
||||
Ok(_) => {}
|
||||
Err(e) => return Some(Err(e.into())),
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Send a close frame.
|
||||
pub async fn close(&mut self) {
|
||||
let _ = self.0.close(None).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
mod web {
|
||||
use anyhow::{Result, anyhow, bail};
|
||||
use tokio::sync::mpsc;
|
||||
use wasm_bindgen::JsCast;
|
||||
use wasm_bindgen::prelude::*;
|
||||
use web_sys::{BinaryType, CloseEvent, MessageEvent, WebSocket};
|
||||
|
||||
use super::Message;
|
||||
|
||||
enum Event {
|
||||
Open,
|
||||
Message(Message),
|
||||
Error,
|
||||
Closed,
|
||||
}
|
||||
|
||||
/// Browser websocket; its callbacks feed a channel. Closed when dropped.
|
||||
pub struct Socket {
|
||||
ws: WebSocket,
|
||||
rx: mpsc::UnboundedReceiver<Event>,
|
||||
_on_open: Closure<dyn FnMut()>,
|
||||
_on_message: Closure<dyn FnMut(MessageEvent)>,
|
||||
_on_error: Closure<dyn FnMut()>,
|
||||
_on_close: Closure<dyn FnMut(CloseEvent)>,
|
||||
}
|
||||
|
||||
impl Socket {
|
||||
pub async fn connect(url: &str) -> Result<Self> {
|
||||
let ws = WebSocket::new(url).map_err(|e| anyhow!("websocket {url}: {e:?}"))?;
|
||||
// Binary frames arrive as ArrayBuffers rather than Blobs, which can only be read asynchronously.
|
||||
ws.set_binary_type(BinaryType::Arraybuffer);
|
||||
let (tx, rx) = mpsc::unbounded_channel();
|
||||
let t = tx.clone();
|
||||
let on_open = Closure::<dyn FnMut()>::new(move || {
|
||||
let _ = t.send(Event::Open);
|
||||
});
|
||||
let t = tx.clone();
|
||||
let on_message = Closure::<dyn FnMut(MessageEvent)>::new(move |e: MessageEvent| {
|
||||
let data = e.data();
|
||||
let msg = if let Some(text) = data.as_string() {
|
||||
Message::Text(text)
|
||||
} else if let Some(buf) = data.dyn_ref::<js_sys::ArrayBuffer>() {
|
||||
Message::Binary(js_sys::Uint8Array::new(buf).to_vec())
|
||||
} else {
|
||||
return;
|
||||
};
|
||||
let _ = t.send(Event::Message(msg));
|
||||
});
|
||||
let t = tx.clone();
|
||||
let on_error = Closure::<dyn FnMut()>::new(move || {
|
||||
let _ = t.send(Event::Error);
|
||||
});
|
||||
let on_close = Closure::<dyn FnMut(CloseEvent)>::new(move |_| {
|
||||
let _ = tx.send(Event::Closed);
|
||||
});
|
||||
ws.set_onopen(Some(on_open.as_ref().unchecked_ref()));
|
||||
ws.set_onmessage(Some(on_message.as_ref().unchecked_ref()));
|
||||
ws.set_onerror(Some(on_error.as_ref().unchecked_ref()));
|
||||
ws.set_onclose(Some(on_close.as_ref().unchecked_ref()));
|
||||
let mut socket =
|
||||
Self { ws, rx, _on_open: on_open, _on_message: on_message, _on_error: on_error, _on_close: on_close };
|
||||
match socket.rx.recv().await {
|
||||
Some(Event::Open) => Ok(socket),
|
||||
_ => bail!("websocket {url}: connection failed"),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn send_message(&mut self, m: Message) -> Result<()> {
|
||||
match m {
|
||||
Message::Text(t) => self.ws.send_with_str(&t),
|
||||
Message::Binary(b) => self.ws.send_with_u8_array(&b),
|
||||
}
|
||||
.map_err(|e| anyhow!("websocket send: {e:?}"))
|
||||
}
|
||||
|
||||
/// The next data message; `None` once the server closes the connection.
|
||||
pub async fn next_message(&mut self) -> Option<Result<Message>> {
|
||||
match self.rx.recv().await? {
|
||||
Event::Message(m) => Some(Ok(m)),
|
||||
Event::Error => Some(Err(anyhow!("websocket error"))),
|
||||
Event::Open | Event::Closed => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Send a close frame.
|
||||
pub async fn close(&mut self) {
|
||||
let _ = self.ws.close();
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for Socket {
|
||||
fn drop(&mut self) {
|
||||
// Detach the callbacks before their closures are freed.
|
||||
self.ws.set_onopen(None);
|
||||
self.ws.set_onmessage(None);
|
||||
self.ws.set_onerror(None);
|
||||
self.ws.set_onclose(None);
|
||||
let _ = self.ws.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -25,7 +25,7 @@ impl YahooProvider {
|
||||
pub fn new() -> Result<Self> {
|
||||
let client = reqwest::Client::builder()
|
||||
// Yahoo rejects requests without a browser-like user agent.
|
||||
.user_agent("Mozilla/5.0 (compatible; terminal-rs)")
|
||||
.user_agent("Mozilla/5.0 (compatible; terminal)")
|
||||
.timeout(Duration::from_secs(20))
|
||||
.cookie_store(true)
|
||||
.build()?;
|
||||
@@ -412,7 +412,7 @@ pub fn yahoo_symbol(sec: &Security) -> String {
|
||||
Some((b, q)) => format!("{b}-{}", if q == "USDT" { "USD" } else { q }),
|
||||
None => sec.ticker.clone(),
|
||||
},
|
||||
AssetClass::Govt | AssetClass::Cmdty => sec.ticker.clone(),
|
||||
AssetClass::Govt | AssetClass::Cmdty | AssetClass::Perp => sec.ticker.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
[package]
|
||||
name = "tr-hl"
|
||||
description = "Hyperliquid order entry on testnet: L1 and user-signed signing, actions, asset ids, rounding, hard caps, info/exchange clients and the user stream"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
|
||||
[dependencies]
|
||||
anyhow.workspace = true
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
reqwest.workspace = true
|
||||
tracing.workspace = true
|
||||
alloy-primitives = { version = "1", default-features = false, features = ["std", "k256"] }
|
||||
alloy-sol-types = { version = "1", default-features = false, features = ["std", "eip712-serde"] }
|
||||
k256 = { version = "0.13", default-features = false, features = ["std", "ecdsa"] }
|
||||
rand_core = { version = "0.6", features = ["getrandom"] }
|
||||
rmp-serde = "1"
|
||||
zeroize = "1"
|
||||
web-time = "1"
|
||||
futures = "0.3"
|
||||
|
||||
# Key generation on the web reads the browser's crypto.getRandomValues.
|
||||
[target.'cfg(target_arch = "wasm32")'.dependencies]
|
||||
getrandom = { version = "0.2", features = ["js"] }
|
||||
|
||||
[dev-dependencies]
|
||||
serde = { workspace = true, features = ["derive"] }
|
||||
# The built-in wallet's approval, end to end against Turnkey's local stand-in.
|
||||
tr-turnkey = { workspace = true, features = ["mock"] }
|
||||
tokio = { workspace = true, features = ["macros", "rt", "net", "io-util"] }
|
||||
tracing-subscriber.workspace = true
|
||||
@@ -0,0 +1,247 @@
|
||||
//! A live check of this crate's signatures against Hyperliquid **testnet**, with throwaway
|
||||
//! keys. Never run in CI. It only uses the crate's public API, so every order goes through
|
||||
//! the same caps as the app.
|
||||
//!
|
||||
//! ```text
|
||||
//! # keys: a 0600 JSON file outside the repo, {"main": "0x…", "agent": "0x…"}
|
||||
//! cargo run -p tr-hl --example testnet_check -- --generate /tmp/hl-throwaway/keys.json
|
||||
//! cargo run -p tr-hl --example testnet_check -- /tmp/hl-throwaway/keys.json
|
||||
//! ```
|
||||
//!
|
||||
//! It never prints a key or a signature, and masks every address (`0x1234…abcd`). Without
|
||||
//! test USDC the account doesn't exist on Hyperliquid, so the expected answers are business
|
||||
//! rejections. Hyperliquid reports a signature it can't match as "User or API Wallet 0x… does
|
||||
//! not exist", where 0x… is the address it recovered: a match with the signer means the
|
||||
//! signature is right. A deliberately corrupted signature is sent as the control.
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use tr_hl::SigningKey;
|
||||
use tr_hl::actions::{Tif, cloid};
|
||||
use tr_hl::client::{Client, ExchangeResponse, SendError, recovered_address};
|
||||
use tr_hl::key::{AgentKey, MainKey, mask_address};
|
||||
use tr_hl::nonce::{NonceManager, now_ms};
|
||||
use tr_hl::trader::*;
|
||||
|
||||
/// Replace every `0x` + 40 hex digits with its masked form.
|
||||
fn mask(text: &str) -> String {
|
||||
tr_hl::mask_addresses(text)
|
||||
}
|
||||
|
||||
fn generate(path: &Path) -> anyhow::Result<()> {
|
||||
use std::io::Write;
|
||||
use std::os::unix::fs::{OpenOptionsExt, PermissionsExt};
|
||||
let dir = path.parent().unwrap();
|
||||
std::fs::create_dir_all(dir)?;
|
||||
std::fs::set_permissions(dir, std::fs::Permissions::from_mode(0o700))?;
|
||||
let (main, agent) = (AgentKey::generate(), AgentKey::generate());
|
||||
let text = zeroize::Zeroizing::new(json!({"main": *main.to_hex(), "agent": *agent.to_hex()}).to_string());
|
||||
let mut f = std::fs::OpenOptions::new().write(true).create_new(true).mode(0o600).open(path)?;
|
||||
f.write_all(text.as_bytes())?;
|
||||
println!(
|
||||
"wrote throwaway keys (0600) for main {} and agent {}",
|
||||
mask_address(&main.address()),
|
||||
mask_address(&agent.address())
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
struct Check {
|
||||
client: Client,
|
||||
out: Vec<String>,
|
||||
}
|
||||
|
||||
impl Check {
|
||||
fn say(&mut self, s: impl AsRef<str>) {
|
||||
let line = mask(s.as_ref());
|
||||
println!("{line}");
|
||||
self.out.push(line);
|
||||
}
|
||||
|
||||
async fn info(&mut self, label: &str, body: Value) -> Value {
|
||||
match self.client.info(body).await {
|
||||
Ok(v) => {
|
||||
self.say(format!("info {label}: {v}"));
|
||||
v
|
||||
}
|
||||
Err(e) => {
|
||||
self.say(format!("info {label}: ERROR {e:#}"));
|
||||
Value::Null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Send, and say whom Hyperliquid recovered if it names an address.
|
||||
fn report(&mut self, label: &str, signer: &alloy_primitives::Address, r: &Result<ExchangeResponse, SendError>) {
|
||||
match r {
|
||||
Ok(resp) => {
|
||||
self.say(format!("{label}: {}", resp.summary()));
|
||||
if let Some(rec) = resp.first_error().and_then(recovered_address) {
|
||||
let verdict = if &rec == signer { "MATCHES the signer" } else { "does NOT match the signer" };
|
||||
self.say(format!(" recovered {} {verdict} {}", mask_address(&rec), mask_address(signer)));
|
||||
}
|
||||
}
|
||||
Err(e) => self.say(format!("{label}: send error: {e}")),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::main(flavor = "current_thread")]
|
||||
async fn main() -> anyhow::Result<()> {
|
||||
let args: Vec<String> = std::env::args().skip(1).collect();
|
||||
if args.first().map(String::as_str) == Some("--generate") {
|
||||
return generate(Path::new(args.get(1).expect("path to the keys file")));
|
||||
}
|
||||
let path = args.first().expect("path to the keys file");
|
||||
let keys: Value = serde_json::from_str(&zeroize::Zeroizing::new(std::fs::read_to_string(path)?))?;
|
||||
let main = MainKey::from_hex(keys["main"].as_str().unwrap())?;
|
||||
let agent = AgentKey::from_hex(keys["agent"].as_str().unwrap())?;
|
||||
drop(keys);
|
||||
let (m, a) = (main.address(), agent.address());
|
||||
let mut c = Check { client: Client::new()?, out: Vec::new() };
|
||||
c.say(format!("testnet check at {} UTC ms {}; main {m:#x}, agent {a:#x}", chrono_like(now_ms()), now_ms()));
|
||||
|
||||
// 1. Before approving.
|
||||
c.info("userRole(main) before", json!({"type": "userRole", "user": format!("{m:#x}")})).await;
|
||||
c.info("userRole(agent) before", json!({"type": "userRole", "user": format!("{a:#x}")})).await;
|
||||
c.info("extraAgents(main) before", json!({"type": "extraAgents", "user": format!("{m:#x}")})).await;
|
||||
|
||||
// 2. approveAgent, signed by the main key.
|
||||
let approve = approve_agent(&a, "check", DEFAULT_AGENT_DAYS, now_ms());
|
||||
c.say(format!(
|
||||
"approveAgent name {:?}, chain {}, signatureChainId {}",
|
||||
approve.agent_name, approve.hyperliquid_chain, approve.signature_chain_id
|
||||
));
|
||||
let sig = tr_hl::signing::sign_approve_agent(&main, &approve)?;
|
||||
let r = c.client.send_user_signed(&user_signed_body(&approve, &sig)).await;
|
||||
c.report("approveAgent (main key)", &m, &r);
|
||||
|
||||
// 3. After.
|
||||
c.info("userRole(main) after", json!({"type": "userRole", "user": format!("{m:#x}")})).await;
|
||||
c.info("userRole(agent) after", json!({"type": "userRole", "user": format!("{a:#x}")})).await;
|
||||
c.info("extraAgents(main) after", json!({"type": "extraAgents", "user": format!("{m:#x}")})).await;
|
||||
|
||||
// 4. Markets, leverage and testnet mids.
|
||||
let reg = c.client.registry().await?;
|
||||
c.say(format!(
|
||||
"registry: {} markets; xyz is perpDexs index {:?}; BTC id {}; xyz:AAPL id {}",
|
||||
reg.len(),
|
||||
reg.dex_index("xyz"),
|
||||
reg.resolve("BTC").map(|x| x.id).unwrap_or(0),
|
||||
reg.resolve("xyz:AAPL").map(|x| x.id).unwrap_or(0)
|
||||
));
|
||||
let (agent_nonces, main_nonces) = (NonceManager::new(), NonceManager::new());
|
||||
for coin in ["BTC", "xyz:AAPL"] {
|
||||
let asset = reg.resolve(coin).map_err(anyhow::Error::msg)?.clone();
|
||||
let dex = asset.dex.clone();
|
||||
let mid = c.client.mids(&dex).await?.get(coin).copied();
|
||||
let data = c.client.asset_data(&m, coin).await?;
|
||||
let lev = data.as_ref().and_then(|d| d.leverage);
|
||||
c.say(format!("{coin}: testnet mid {mid:?}, account leverage {lev:?}"));
|
||||
|
||||
// 5. Leverage to 3× (agent), as the ticket does before a first order.
|
||||
let s = sign_update_leverage(&agent, &agent_nonces, &asset, !asset.only_isolated, 3, now_ms())?;
|
||||
let r = c.client.send(&s).await;
|
||||
c.report(&format!("updateLeverage {coin} 3× (agent)"), &a, &r);
|
||||
let lev = c.client.asset_data(&m, coin).await?.and_then(|d| d.leverage);
|
||||
c.say(format!("{coin}: account leverage now {lev:?}"));
|
||||
|
||||
// 6. A small limit far below the market, by the agent and by the main key.
|
||||
let mid = mid.expect("testnet mid");
|
||||
let px = mid * 0.6;
|
||||
let size = 12.0 / px;
|
||||
let intent = OrderIntent {
|
||||
coin: coin.into(),
|
||||
is_buy: true,
|
||||
size,
|
||||
kind: OrderKind::Limit { px, tif: Tif::Gtc },
|
||||
reduce_only: false,
|
||||
};
|
||||
let ctx = MarketCtx { mid: Some(mid), leverage: lev };
|
||||
for (who, signer) in [("agent", a), ("main key", m)] {
|
||||
let id = cloid(now_ms() as u128);
|
||||
match plan_order(®, &intent, &ctx, &id) {
|
||||
Ok(plan) => {
|
||||
c.say(format!(
|
||||
"{coin} order plan: BUY {} @ {} (notional ${:.2}), cloid {}",
|
||||
plan.size(),
|
||||
plan.price(),
|
||||
plan.notional(),
|
||||
plan.cloid()
|
||||
));
|
||||
let s = if who == "agent" {
|
||||
sign_order(&agent, &agent_nonces, &plan, &ctx, now_ms())?
|
||||
} else {
|
||||
sign_order(&main, &main_nonces, &plan, &ctx, now_ms())?
|
||||
};
|
||||
let r = c.client.send(&s).await;
|
||||
c.report(&format!("order {coin} limit buy (signed by {who})"), &signer, &r);
|
||||
}
|
||||
Err(refusal) => {
|
||||
c.say(format!("{coin} order (signed by {who}): REFUSED before signing: {refusal}"));
|
||||
// A reduce-only sell far above the market passes the caps whatever the
|
||||
// leverage, and still exercises the order signature.
|
||||
let ro = OrderIntent {
|
||||
is_buy: false,
|
||||
reduce_only: true,
|
||||
kind: OrderKind::Limit { px: mid * 1.4, tif: Tif::Gtc },
|
||||
size: 12.0 / (mid * 1.4),
|
||||
..intent.clone()
|
||||
};
|
||||
let plan = plan_order(®, &ro, &ctx, &id).map_err(|e| anyhow::anyhow!("{e}"))?;
|
||||
c.say(format!(
|
||||
"{coin} reduce-only plan: SELL {} @ {} (notional ${:.2})",
|
||||
plan.size(),
|
||||
plan.price(),
|
||||
plan.notional()
|
||||
));
|
||||
let s = if who == "agent" {
|
||||
sign_order(&agent, &agent_nonces, &plan, &ctx, now_ms())?
|
||||
} else {
|
||||
sign_order(&main, &main_nonces, &plan, &ctx, now_ms())?
|
||||
};
|
||||
let r = c.client.send(&s).await;
|
||||
c.report(&format!("order {coin} reduce-only limit sell (signed by {who})"), &signer, &r);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 7. The control: a damaged agent signature recovers someone else.
|
||||
let noop = sign_noop(&agent, &agent_nonces, now_ms())?;
|
||||
let r = c.client.send(&noop.corrupted()).await;
|
||||
c.report("noop with a CORRUPTED agent signature (control)", &a, &r);
|
||||
|
||||
// 8. Cancel an order that doesn't exist, and a noop.
|
||||
let btc = reg.resolve("BTC").map_err(anyhow::Error::msg)?.clone();
|
||||
let s = sign_cancel(&agent, &agent_nonces, &btc, 1, now_ms())?;
|
||||
let r = c.client.send(&s).await;
|
||||
c.report("cancel BTC oid 1 (agent)", &a, &r);
|
||||
let r = c.client.send(&sign_noop(&agent, &agent_nonces, now_ms())?).await;
|
||||
c.report("noop (agent)", &a, &r);
|
||||
let r = c.client.send(&sign_noop(&main, &main_nonces, now_ms())?).await;
|
||||
c.report("noop (main key)", &m, &r);
|
||||
|
||||
if let Some(out) = args.get(1) {
|
||||
std::fs::write(out, c.out.join("\n") + "\n")?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// `2026-11-03T01:02:03Z` without a date crate.
|
||||
fn chrono_like(ms: u64) -> String {
|
||||
let s = ms / 1000;
|
||||
let (days, rem) = (s / 86_400, s % 86_400);
|
||||
// Civil date from days since 1970-01-01 (Howard Hinnant's algorithm).
|
||||
let z = days as i64 + 719_468;
|
||||
let era = z.div_euclid(146_097);
|
||||
let doe = z - era * 146_097;
|
||||
let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
|
||||
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
|
||||
let mp = (5 * doy + 2) / 153;
|
||||
let d = doy - (153 * mp + 2) / 5 + 1;
|
||||
let mo = if mp < 10 { mp + 3 } else { mp - 9 };
|
||||
let y = yoe + era * 400 + i64::from(mo <= 2);
|
||||
format!("{y:04}-{mo:02}-{d:02}T{:02}:{:02}:{:02}Z", rem / 3600, rem % 3600 / 60, rem % 60)
|
||||
}
|
||||
@@ -0,0 +1,276 @@
|
||||
//! Exchange actions as Hyperliquid hashes them.
|
||||
//!
|
||||
//! L1 actions are msgpack-encoded with named fields **in declaration order**, and that order
|
||||
//! is part of the signature: each struct below lists its fields exactly as the Python SDK
|
||||
//! builds them. Optional fields are left out rather than sent as `null` or `false`
|
||||
//! (Hyperliquid rejects a cancel hashed with `f: false`, for instance). There is no
|
||||
//! `builder` field anywhere: this build never charges a builder fee.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Limit time in force: `Gtc`, `Ioc` or `Alo` (post-only).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
pub enum Tif {
|
||||
Gtc,
|
||||
Ioc,
|
||||
Alo,
|
||||
}
|
||||
|
||||
impl Tif {
|
||||
pub fn label(self) -> &'static str {
|
||||
match self {
|
||||
Tif::Gtc => "GTC",
|
||||
Tif::Ioc => "IOC",
|
||||
Tif::Alo => "Post-only",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Trigger kind: take profit or stop loss.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
pub enum Tpsl {
|
||||
#[serde(rename = "tp")]
|
||||
Tp,
|
||||
#[serde(rename = "sl")]
|
||||
Sl,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
|
||||
pub struct LimitWire {
|
||||
pub tif: Tif,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct TriggerWire {
|
||||
pub is_market: bool,
|
||||
pub trigger_px: String,
|
||||
pub tpsl: Tpsl,
|
||||
}
|
||||
|
||||
/// `{"limit": {...}}` or `{"trigger": {...}}`: one of the two, never both.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
|
||||
pub struct OrderTypeWire {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub limit: Option<LimitWire>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub trigger: Option<TriggerWire>,
|
||||
}
|
||||
|
||||
impl OrderTypeWire {
|
||||
pub fn limit(tif: Tif) -> Self {
|
||||
Self { limit: Some(LimitWire { tif }), trigger: None }
|
||||
}
|
||||
|
||||
pub fn trigger(is_market: bool, trigger_px: String, tpsl: Tpsl) -> Self {
|
||||
Self { limit: None, trigger: Some(TriggerWire { is_market, trigger_px, tpsl }) }
|
||||
}
|
||||
}
|
||||
|
||||
/// One order: asset, is buy, price, size, reduce only, type, client order id.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
|
||||
pub struct OrderWire {
|
||||
pub a: u32,
|
||||
pub b: bool,
|
||||
pub p: String,
|
||||
pub s: String,
|
||||
pub r: bool,
|
||||
pub t: OrderTypeWire,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub c: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
|
||||
pub struct OrderAction {
|
||||
#[serde(rename = "type")]
|
||||
pub kind: &'static str,
|
||||
pub orders: Vec<OrderWire>,
|
||||
pub grouping: &'static str,
|
||||
}
|
||||
|
||||
impl OrderAction {
|
||||
pub(crate) fn new(orders: Vec<OrderWire>) -> Self {
|
||||
Self { kind: "order", orders, grouping: "na" }
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
|
||||
pub struct CancelWire {
|
||||
pub a: u32,
|
||||
pub o: u64,
|
||||
}
|
||||
|
||||
/// Cancel by order id. `f` (fast) is left out: hashed as `false` it is rejected, and `true`
|
||||
/// is refused for trigger orders.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
|
||||
pub struct CancelAction {
|
||||
#[serde(rename = "type")]
|
||||
pub kind: &'static str,
|
||||
pub cancels: Vec<CancelWire>,
|
||||
}
|
||||
|
||||
impl CancelAction {
|
||||
pub fn new(cancels: Vec<CancelWire>) -> Self {
|
||||
Self { kind: "cancel", cancels }
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
|
||||
pub struct CancelByCloidWire {
|
||||
pub asset: u32,
|
||||
pub cloid: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
|
||||
pub struct CancelByCloidAction {
|
||||
#[serde(rename = "type")]
|
||||
pub kind: &'static str,
|
||||
pub cancels: Vec<CancelByCloidWire>,
|
||||
}
|
||||
|
||||
impl CancelByCloidAction {
|
||||
pub fn new(cancels: Vec<CancelByCloidWire>) -> Self {
|
||||
Self { kind: "cancelByCloid", cancels }
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
|
||||
pub struct ModifyAction {
|
||||
#[serde(rename = "type")]
|
||||
pub kind: &'static str,
|
||||
pub oid: u64,
|
||||
pub order: OrderWire,
|
||||
}
|
||||
|
||||
impl ModifyAction {
|
||||
pub(crate) fn new(oid: u64, order: OrderWire) -> Self {
|
||||
Self { kind: "modify", oid, order }
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct UpdateLeverageAction {
|
||||
#[serde(rename = "type")]
|
||||
pub kind: &'static str,
|
||||
pub asset: u32,
|
||||
pub is_cross: bool,
|
||||
pub leverage: u32,
|
||||
}
|
||||
|
||||
impl UpdateLeverageAction {
|
||||
pub(crate) fn new(asset: u32, is_cross: bool, leverage: u32) -> Self {
|
||||
Self { kind: "updateLeverage", asset, is_cross, leverage }
|
||||
}
|
||||
}
|
||||
|
||||
/// The dead man's switch: cancel every open order at `time` (ms), or clear the schedule.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
|
||||
pub struct ScheduleCancelAction {
|
||||
#[serde(rename = "type")]
|
||||
pub kind: &'static str,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub time: Option<u64>,
|
||||
}
|
||||
|
||||
impl ScheduleCancelAction {
|
||||
pub fn new(time: Option<u64>) -> Self {
|
||||
Self { kind: "scheduleCancel", time }
|
||||
}
|
||||
}
|
||||
|
||||
/// Uses up a nonce without doing anything (invalidates an in-flight action).
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
|
||||
pub struct NoopAction {
|
||||
#[serde(rename = "type")]
|
||||
pub kind: &'static str,
|
||||
}
|
||||
|
||||
impl Default for NoopAction {
|
||||
fn default() -> Self {
|
||||
Self { kind: "noop" }
|
||||
}
|
||||
}
|
||||
|
||||
/// Approve an API wallet. A user-signed action (EIP-712 `HyperliquidTransaction:ApproveAgent`),
|
||||
/// signed by the main wallet, so it is JSON on the wire and not msgpack-hashed.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ApproveAgentAction {
|
||||
#[serde(rename = "type")]
|
||||
pub kind: String,
|
||||
pub hyperliquid_chain: String,
|
||||
/// The chain the wallet signs on, as `0x…` hex: always `0x66eee` (testnet).
|
||||
pub signature_chain_id: String,
|
||||
/// Lower-case `0x…`.
|
||||
pub agent_address: String,
|
||||
/// `terminal-<device> valid_until <ms>`: always named, never the unnamed slot.
|
||||
pub agent_name: String,
|
||||
pub nonce: u64,
|
||||
}
|
||||
|
||||
impl ApproveAgentAction {
|
||||
pub fn chain_id(&self) -> anyhow::Result<u64> {
|
||||
let hex = self.signature_chain_id.trim_start_matches("0x");
|
||||
Ok(u64::from_str_radix(hex, 16)?)
|
||||
}
|
||||
}
|
||||
|
||||
/// A 128-bit client order id, `0x` and 32 hex digits.
|
||||
pub fn cloid(n: u128) -> String {
|
||||
format!("0x{n:032x}")
|
||||
}
|
||||
|
||||
/// Whether `s` is a well-formed client order id.
|
||||
pub fn valid_cloid(s: &str) -> bool {
|
||||
s.len() == 34 && s.starts_with("0x") && s[2..].chars().all(|c| c.is_ascii_hexdigit())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn wire_shapes_match_the_exchange_docs() {
|
||||
let o = OrderWire {
|
||||
a: 3,
|
||||
b: true,
|
||||
p: "50000".into(),
|
||||
s: "0.0002".into(),
|
||||
r: false,
|
||||
t: OrderTypeWire::limit(Tif::Gtc),
|
||||
c: Some(cloid(1)),
|
||||
};
|
||||
let json = serde_json::to_string(&OrderAction::new(vec![o.clone()])).unwrap();
|
||||
assert_eq!(
|
||||
json,
|
||||
r#"{"type":"order","orders":[{"a":3,"b":true,"p":"50000","s":"0.0002","r":false,"t":{"limit":{"tif":"Gtc"}},"c":"0x00000000000000000000000000000001"}],"grouping":"na"}"#
|
||||
);
|
||||
assert!(!json.contains("builder"), "no builder fee is ever sent");
|
||||
let trig = OrderTypeWire::trigger(true, "103".into(), Tpsl::Sl);
|
||||
assert_eq!(
|
||||
serde_json::to_string(&trig).unwrap(),
|
||||
r#"{"trigger":{"isMarket":true,"triggerPx":"103","tpsl":"sl"}}"#
|
||||
);
|
||||
let cancel = serde_json::to_string(&CancelAction::new(vec![CancelWire { a: 3, o: 42 }])).unwrap();
|
||||
assert_eq!(cancel, r#"{"type":"cancel","cancels":[{"a":3,"o":42}]}"#, "no `f` field");
|
||||
let by = CancelByCloidAction::new(vec![CancelByCloidWire { asset: 750009, cloid: cloid(7) }]);
|
||||
assert_eq!(
|
||||
serde_json::to_string(&by).unwrap(),
|
||||
r#"{"type":"cancelByCloid","cancels":[{"asset":750009,"cloid":"0x00000000000000000000000000000007"}]}"#
|
||||
);
|
||||
let lev = serde_json::to_string(&UpdateLeverageAction::new(3, true, 3)).unwrap();
|
||||
assert_eq!(lev, r#"{"type":"updateLeverage","asset":3,"isCross":true,"leverage":3}"#);
|
||||
assert_eq!(serde_json::to_string(&ScheduleCancelAction::new(None)).unwrap(), r#"{"type":"scheduleCancel"}"#);
|
||||
assert_eq!(serde_json::to_string(&NoopAction::default()).unwrap(), r#"{"type":"noop"}"#);
|
||||
let m = serde_json::to_string(&ModifyAction::new(9, o)).unwrap();
|
||||
assert!(m.starts_with(r#"{"type":"modify","oid":9,"order":{"a":3,"#), "{m}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cloids_are_128_bit_hex() {
|
||||
assert_eq!(cloid(1), "0x00000000000000000000000000000001");
|
||||
assert!(valid_cloid(&cloid(u128::MAX)));
|
||||
assert!(!valid_cloid("0x1") && !valid_cloid(&format!("0x{}", "g".repeat(32))));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,261 @@
|
||||
//! Asset ids and trading rules per market, read from the network every session.
|
||||
//!
|
||||
//! - A core perp's id is its index in `meta.universe`.
|
||||
//! - A HIP-3 perp's id is `100000 + dex_index × 10000 + index`, where `dex_index` is the dex's
|
||||
//! position in `perpDexs` (index 0 is the core dex) and `index` its place in `meta{dex}`.
|
||||
//!
|
||||
//! Ids differ between networks and change as dexes are deployed, so none is hard-coded
|
||||
//! (testnet's `xyz` is dex 65 today, mainnet's is 1). This build trades the core dex and the
|
||||
//! `xyz` HIP-3 dex only: `para`, `io`, `mkts`, other dexes and spot are refused with a reason.
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use anyhow::{Context, Result, bail};
|
||||
use serde_json::Value;
|
||||
|
||||
/// HIP-3 dexes this build trades.
|
||||
pub const SUPPORTED_DEXES: [&str; 1] = ["xyz"];
|
||||
/// HIP-3 dexes that exist on mainnet but are refused in this build.
|
||||
pub const REFUSED_DEXES: [&str; 3] = ["para", "io", "mkts"];
|
||||
|
||||
/// One perp market.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct Asset {
|
||||
/// Exactly as Hyperliquid names it: `BTC`, `kPEPE`, `xyz:AAPL`.
|
||||
pub coin: String,
|
||||
pub id: u32,
|
||||
/// `""` for the core dex, else the HIP-3 dex (`xyz`).
|
||||
pub dex: String,
|
||||
pub sz_decimals: u32,
|
||||
pub max_leverage: u32,
|
||||
/// Isolated margin only (no cross).
|
||||
pub only_isolated: bool,
|
||||
pub delisted: bool,
|
||||
}
|
||||
|
||||
impl Asset {
|
||||
pub fn is_hip3(&self) -> bool {
|
||||
!self.dex.is_empty()
|
||||
}
|
||||
|
||||
/// `PERP · HL xyz` for HIP-3, `PERP · HL` for the core dex.
|
||||
pub fn label(&self) -> String {
|
||||
if self.is_hip3() { format!("PERP · HL {}", self.dex) } else { "PERP · HL".into() }
|
||||
}
|
||||
|
||||
/// The terminal's market key for charts and quotes: `xyz:AAPL` for a HIP-3 perp, the
|
||||
/// crypto pair (`BTCUSD Curncy`) for a core perp.
|
||||
pub fn app_key(&self) -> String {
|
||||
app_key(&self.coin)
|
||||
}
|
||||
}
|
||||
|
||||
/// [`Asset::app_key`] for a coin name.
|
||||
pub fn app_key(coin: &str) -> String {
|
||||
if coin.contains(':') { coin.to_string() } else { format!("{}USD Curncy", coin.to_ascii_uppercase()) }
|
||||
}
|
||||
|
||||
/// Why a market can't be traded, in words for the ticket.
|
||||
pub fn refusal(coin: &str) -> Option<String> {
|
||||
let c = coin.trim();
|
||||
if c.contains('/') || c.starts_with('@') {
|
||||
return Some(format!("{c} is a spot market. This build trades Hyperliquid perps only (no spot)."));
|
||||
}
|
||||
if let Some((dex, _)) = c.split_once(':') {
|
||||
let dex = dex.to_ascii_lowercase();
|
||||
if REFUSED_DEXES.contains(&dex.as_str()) {
|
||||
return Some(format!(
|
||||
"{c} is on the {dex} HIP-3 dex. This build trades the core Hyperliquid perps and the xyz dex only."
|
||||
));
|
||||
}
|
||||
if !SUPPORTED_DEXES.contains(&dex.as_str()) {
|
||||
return Some(format!("{c}: this build trades the core Hyperliquid perps and the xyz dex only."));
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Every tradable perp on the core and `xyz` dexes, by name.
|
||||
#[derive(Debug, Clone, Default, PartialEq)]
|
||||
pub struct AssetRegistry {
|
||||
by_name: HashMap<String, Asset>,
|
||||
/// `(perpDexs index, name)` of the dexes loaded, core first.
|
||||
dexes: Vec<(usize, String)>,
|
||||
}
|
||||
|
||||
impl AssetRegistry {
|
||||
/// Build from `perpDexs`, the core `meta`, and `meta{dex}` for each supported dex.
|
||||
pub fn from_info(perp_dexs: &Value, core_meta: &Value, dex_metas: &[(&str, &Value)]) -> Result<Self> {
|
||||
let dexes = perp_dexs.as_array().context("perpDexs is not a list")?;
|
||||
let mut reg = Self::default();
|
||||
reg.add(0, "", core_meta)?;
|
||||
for (name, meta) in dex_metas {
|
||||
if !SUPPORTED_DEXES.contains(name) {
|
||||
bail!("the {name} dex is not supported in this build");
|
||||
}
|
||||
let index = dexes
|
||||
.iter()
|
||||
.position(|d| d.get("name").and_then(Value::as_str) == Some(*name))
|
||||
.with_context(|| format!("perpDexs has no {name} dex"))?;
|
||||
reg.add(index, name, meta)?;
|
||||
}
|
||||
Ok(reg)
|
||||
}
|
||||
|
||||
fn add(&mut self, dex_index: usize, dex: &str, meta: &Value) -> Result<()> {
|
||||
let universe = meta.get("universe").and_then(Value::as_array).context("meta has no universe")?;
|
||||
for (i, a) in universe.iter().enumerate() {
|
||||
let Some(coin) = a.get("name").and_then(Value::as_str) else { continue };
|
||||
let id = if dex.is_empty() { i } else { 100_000 + dex_index * 10_000 + i };
|
||||
let int = |k: &str| a.get(k).and_then(Value::as_u64).unwrap_or(0) as u32;
|
||||
let asset = Asset {
|
||||
coin: coin.to_string(),
|
||||
id: u32::try_from(id).context("asset id out of range")?,
|
||||
dex: dex.to_string(),
|
||||
sz_decimals: int("szDecimals"),
|
||||
max_leverage: int("maxLeverage").max(1),
|
||||
only_isolated: a.get("onlyIsolated").and_then(Value::as_bool).unwrap_or(false),
|
||||
delisted: a.get("isDelisted").and_then(Value::as_bool).unwrap_or(false),
|
||||
};
|
||||
self.by_name.insert(coin.to_ascii_lowercase(), asset);
|
||||
}
|
||||
self.dexes.push((dex_index, dex.to_string()));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The market for `coin` (any case: `btc`, `XYZ:aapl`), or why it can't be traded.
|
||||
pub fn resolve(&self, coin: &str) -> Result<&Asset, String> {
|
||||
let c = coin.trim();
|
||||
if c.is_empty() {
|
||||
return Err("Enter a market (BTC, xyz:AAPL).".into());
|
||||
}
|
||||
if let Some(why) = refusal(c) {
|
||||
return Err(why);
|
||||
}
|
||||
match self.by_name.get(&c.to_ascii_lowercase()) {
|
||||
Some(a) if a.delisted => Err(format!("{} is delisted on Hyperliquid.", a.coin)),
|
||||
Some(a) => Ok(a),
|
||||
None => {
|
||||
// `AAPL` on Hyperliquid is the xyz perp, never the stock.
|
||||
let hip3 = format!("xyz:{c}").to_ascii_lowercase();
|
||||
match self.by_name.get(&hip3).filter(|a| !a.delisted) {
|
||||
Some(a) => Err(format!(
|
||||
"{} is not a core Hyperliquid market. Hyperliquid has the perp {} ({}). Trade that instead?",
|
||||
c.to_ascii_uppercase(),
|
||||
a.coin,
|
||||
a.label()
|
||||
)),
|
||||
None => Err(format!("{c} is not a Hyperliquid perp on testnet.")),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get(&self, coin: &str) -> Option<&Asset> {
|
||||
self.by_name.get(&coin.trim().to_ascii_lowercase())
|
||||
}
|
||||
|
||||
/// The loaded dexes, core (`""`) first.
|
||||
pub fn dexes(&self) -> impl Iterator<Item = &str> {
|
||||
self.dexes.iter().map(|(_, d)| d.as_str())
|
||||
}
|
||||
|
||||
/// A dex's position in `perpDexs`.
|
||||
pub fn dex_index(&self, dex: &str) -> Option<usize> {
|
||||
self.dexes.iter().find(|(_, d)| d == dex).map(|(i, _)| *i)
|
||||
}
|
||||
|
||||
pub fn len(&self) -> usize {
|
||||
self.by_name.len()
|
||||
}
|
||||
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.by_name.is_empty()
|
||||
}
|
||||
|
||||
pub fn assets(&self) -> impl Iterator<Item = &Asset> {
|
||||
self.by_name.values()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) mod fixtures {
|
||||
use super::*;
|
||||
|
||||
pub fn json(name: &str) -> Value {
|
||||
let path = format!("{}/tests/fixtures/{name}", env!("CARGO_MANIFEST_DIR"));
|
||||
serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap()
|
||||
}
|
||||
|
||||
/// The registry from testnet responses recorded on 2026-11-02.
|
||||
pub fn registry() -> AssetRegistry {
|
||||
AssetRegistry::from_info(
|
||||
&json("testnet_perp_dexs.json"),
|
||||
&json("testnet_meta_core.json"),
|
||||
&[("xyz", &json("testnet_meta_xyz.json"))],
|
||||
)
|
||||
.unwrap()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::fixtures::*;
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn ids_come_from_perp_dexs_and_meta() {
|
||||
let r = registry();
|
||||
assert_eq!(r.dex_index("xyz"), Some(65), "xyz is dex 65 on testnet (recorded)");
|
||||
let btc = r.resolve("btc").unwrap();
|
||||
assert_eq!((btc.id, btc.sz_decimals, btc.max_leverage, btc.dex.as_str()), (3, 5, 40, ""));
|
||||
let aapl = r.resolve("XYZ:aapl").unwrap();
|
||||
assert_eq!((aapl.id, aapl.sz_decimals, aapl.coin.as_str()), (750009, 3, "xyz:AAPL"));
|
||||
assert_eq!(aapl.label(), "PERP · HL xyz");
|
||||
assert_eq!(aapl.app_key(), "xyz:AAPL");
|
||||
assert_eq!(btc.app_key(), "BTCUSD Curncy");
|
||||
assert!(r.resolve("xyz:COIN").unwrap().only_isolated, "isolated-only HIP-3 markets are flagged");
|
||||
assert_eq!(r.dexes().collect::<Vec<_>>(), ["", "xyz"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_same_market_gets_another_id_on_another_dex_index() {
|
||||
// perpDexs with xyz at index 1, as on mainnet: ids move with it.
|
||||
let dexes = serde_json::json!([null, {"name": "xyz"}]);
|
||||
let r = AssetRegistry::from_info(
|
||||
&dexes,
|
||||
&json("testnet_meta_core.json"),
|
||||
&[("xyz", &json("testnet_meta_xyz.json"))],
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(r.resolve("xyz:AAPL").unwrap().id, 110009);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unsupported_markets_are_refused_with_a_reason() {
|
||||
let r = registry();
|
||||
for (coin, says) in [
|
||||
("para:TOTAL2", "para HIP-3 dex"),
|
||||
("io:OAI", "io HIP-3 dex"),
|
||||
("mkts:US500", "mkts HIP-3 dex"),
|
||||
("abc:XYZ", "xyz dex only"),
|
||||
("HYPE/USDC", "spot"),
|
||||
("@107", "spot"),
|
||||
("AAPL", "Hyperliquid has the perp xyz:AAPL (PERP · HL xyz)"),
|
||||
("NOPE", "not a Hyperliquid perp"),
|
||||
] {
|
||||
let e = r.resolve(coin).unwrap_err();
|
||||
assert!(e.contains(says), "{coin}: {e}");
|
||||
}
|
||||
let delisted = r.assets().find(|a| a.delisted).unwrap().coin.clone();
|
||||
assert!(r.resolve(&delisted).unwrap_err().contains("delisted"));
|
||||
assert!(
|
||||
AssetRegistry::from_info(
|
||||
&json("testnet_perp_dexs.json"),
|
||||
&json("testnet_meta_core.json"),
|
||||
&[("para", &json("testnet_meta_xyz.json"))]
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
//! Hard limits, checked in this crate before anything is signed (the ticket checks them too,
|
||||
//! but the ticket is not the safety): no order over $100 notional, no leverage above 3×, no
|
||||
//! market order more than 1% from the mid. They are constants, not settings: nothing in this
|
||||
//! build can raise them.
|
||||
|
||||
use std::fmt;
|
||||
|
||||
/// The most an order may be worth: size × the higher of its price and the mid.
|
||||
pub const MAX_ORDER_NOTIONAL_USD: f64 = 100.0;
|
||||
/// The most leverage Terminal sets, or places an order under.
|
||||
pub const MAX_LEVERAGE: u32 = 3;
|
||||
/// A market order is an IOC limit this far from the mid, at most.
|
||||
pub const MAX_MARKET_SLIPPAGE: f64 = 0.01;
|
||||
|
||||
/// Why an action was refused before signing.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub enum Refusal {
|
||||
Notional {
|
||||
value: f64,
|
||||
},
|
||||
Leverage {
|
||||
value: u32,
|
||||
},
|
||||
/// The account's leverage on this market isn't known yet, so an order can't be checked.
|
||||
LeverageUnknown {
|
||||
coin: String,
|
||||
},
|
||||
Slippage {
|
||||
pct: f64,
|
||||
},
|
||||
/// No mid price, which a market order is priced from.
|
||||
NoMid {
|
||||
coin: String,
|
||||
},
|
||||
/// Anything else (market not tradable, bad size or price).
|
||||
Invalid(String),
|
||||
}
|
||||
|
||||
impl fmt::Display for Refusal {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
Refusal::Notional { value } => write!(
|
||||
f,
|
||||
"Order value ${value:.2} is over the ${MAX_ORDER_NOTIONAL_USD:.0} limit for this testnet build."
|
||||
),
|
||||
Refusal::Leverage { value } => write!(
|
||||
f,
|
||||
"Leverage {value}× is over the {MAX_LEVERAGE}× limit for this testnet build. Set {MAX_LEVERAGE}× or less first."
|
||||
),
|
||||
Refusal::LeverageUnknown { coin } => {
|
||||
write!(f, "Leverage on {coin} isn't known yet; wait for the account to load.")
|
||||
}
|
||||
Refusal::Slippage { pct } => {
|
||||
write!(f, "Slippage {pct:.2}% is over the {:.0}% limit.", MAX_MARKET_SLIPPAGE * 100.0)
|
||||
}
|
||||
Refusal::NoMid { coin } => write!(f, "No testnet price for {coin} yet; use a limit order or wait."),
|
||||
Refusal::Invalid(m) => f.write_str(m),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for Refusal {}
|
||||
|
||||
pub fn check_notional(value: f64) -> Result<(), Refusal> {
|
||||
if !value.is_finite() || value > MAX_ORDER_NOTIONAL_USD + 1e-9 {
|
||||
return Err(Refusal::Notional { value });
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn check_leverage(value: u32) -> Result<(), Refusal> {
|
||||
if value > MAX_LEVERAGE {
|
||||
return Err(Refusal::Leverage { value });
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn check_slippage(fraction: f64) -> Result<(), Refusal> {
|
||||
if !fraction.is_finite() || fraction < 0.0 || fraction > MAX_MARKET_SLIPPAGE + 1e-12 {
|
||||
return Err(Refusal::Slippage { pct: fraction * 100.0 });
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,386 @@
|
||||
//! `/info` and `/exchange` on testnet, over reqwest (native, and `fetch` in the browser).
|
||||
//!
|
||||
//! The client has no base-URL setting: it always talks to [`crate::network::API_URL`]. (Unit
|
||||
//! tests point it at a local mock server through a `cfg(test)` constructor that the app
|
||||
//! can't call.) Logs carry the action type, nonce, cloid, oid and status, never a request
|
||||
//! body or a signature.
|
||||
|
||||
use alloy_primitives::Address;
|
||||
use anyhow::{Context, Result, anyhow};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::assets::{AssetRegistry, SUPPORTED_DEXES};
|
||||
use crate::key::address_hex;
|
||||
use crate::model::*;
|
||||
use crate::network::Network;
|
||||
use crate::trader::Signed;
|
||||
|
||||
/// One order's or cancel's result.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub enum Status {
|
||||
Resting {
|
||||
oid: u64,
|
||||
},
|
||||
Filled {
|
||||
oid: u64,
|
||||
total_sz: f64,
|
||||
avg_px: f64,
|
||||
},
|
||||
/// A cancel, modify or other action that went through.
|
||||
Success,
|
||||
Error(String),
|
||||
Other(String),
|
||||
}
|
||||
|
||||
/// What `/exchange` said.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct ExchangeResponse {
|
||||
/// `status: "ok"`. Individual orders can still have failed: see `statuses`.
|
||||
pub ok: bool,
|
||||
/// `order`, `cancel`, `default`, …
|
||||
pub kind: Option<String>,
|
||||
pub statuses: Vec<Status>,
|
||||
/// The whole request was refused (`status: "err"`).
|
||||
pub error: Option<String>,
|
||||
}
|
||||
|
||||
impl ExchangeResponse {
|
||||
pub fn parse(v: &Value) -> Self {
|
||||
let ok = v["status"].as_str() == Some("ok");
|
||||
let error = (!ok).then(|| match &v["response"] {
|
||||
Value::String(s) => s.clone(),
|
||||
other => other.to_string(),
|
||||
});
|
||||
let statuses = v["response"]["data"]["statuses"]
|
||||
.as_array()
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.map(|s| {
|
||||
if s.as_str() == Some("success") {
|
||||
Status::Success
|
||||
} else if let Some(e) = s["error"].as_str() {
|
||||
Status::Error(e.to_string())
|
||||
} else if let Some(oid) = s["resting"]["oid"].as_u64() {
|
||||
Status::Resting { oid }
|
||||
} else if let Some(oid) = s["filled"]["oid"].as_u64() {
|
||||
Status::Filled {
|
||||
oid,
|
||||
total_sz: num(&s["filled"]["totalSz"]).unwrap_or(0.0),
|
||||
avg_px: num(&s["filled"]["avgPx"]).unwrap_or(0.0),
|
||||
}
|
||||
} else {
|
||||
Status::Other(s.to_string())
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
Self { ok, kind: v["response"]["type"].as_str().map(str::to_string), statuses, error }
|
||||
}
|
||||
|
||||
/// The first error, whether for the request or for an order in it.
|
||||
pub fn first_error(&self) -> Option<&str> {
|
||||
self.error.as_deref().or_else(|| {
|
||||
self.statuses.iter().find_map(|s| match s {
|
||||
Status::Error(e) => Some(e.as_str()),
|
||||
_ => None,
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/// One line for a toast or a log, addresses masked: `ok (order): resting oid 123`, `err: …`.
|
||||
pub fn summary(&self) -> String {
|
||||
crate::key::mask_addresses(&self.summary_raw())
|
||||
}
|
||||
|
||||
fn summary_raw(&self) -> String {
|
||||
if let Some(e) = &self.error {
|
||||
return format!("err: {e}");
|
||||
}
|
||||
let parts: Vec<String> = self
|
||||
.statuses
|
||||
.iter()
|
||||
.map(|s| match s {
|
||||
Status::Resting { oid } => format!("resting oid {oid}"),
|
||||
Status::Filled { oid, total_sz, avg_px } => format!("filled {total_sz} @ {avg_px} (oid {oid})"),
|
||||
Status::Success => "success".into(),
|
||||
Status::Error(e) => format!("error: {e}"),
|
||||
Status::Other(o) => o.clone(),
|
||||
})
|
||||
.collect();
|
||||
format!(
|
||||
"ok ({}){}",
|
||||
self.kind.as_deref().unwrap_or("?"),
|
||||
if parts.is_empty() { String::new() } else { format!(": {}", parts.join("; ")) }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// `orderStatus`: the order's status, or `None` for `unknownOid`.
|
||||
pub fn parse_order_status(v: &Value) -> Option<String> {
|
||||
match v["status"].as_str() {
|
||||
Some("order") => Some(v["order"]["status"].as_str().unwrap_or("unknown").to_string()),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The address Hyperliquid recovered from a signature it doesn't recognise: "User or API
|
||||
/// Wallet 0x… does not exist." A match with the signer means the signature is right and the
|
||||
/// account is just unknown (never funded); a mismatch means the signing is wrong.
|
||||
pub fn recovered_address(error: &str) -> Option<Address> {
|
||||
let i = error.find("0x")?;
|
||||
crate::key::parse_address(error.get(i..i + 42)?).ok()
|
||||
}
|
||||
|
||||
/// Why a send did not clearly succeed.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub enum SendError {
|
||||
/// Nothing reached Hyperliquid (couldn't connect).
|
||||
NotSent(String),
|
||||
/// Hyperliquid answered with an HTTP error: nothing was done.
|
||||
Rejected { status: u16, body: String },
|
||||
/// It may have arrived: check open orders (by cloid) before sending again.
|
||||
Uncertain(String),
|
||||
}
|
||||
|
||||
impl std::fmt::Display for SendError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
SendError::NotSent(m) => write!(f, "not sent: {m}"),
|
||||
SendError::Rejected { status, body } => write!(f, "rejected (HTTP {status}): {body}"),
|
||||
SendError::Uncertain(m) => write!(f, "status unknown: {m}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for SendError {}
|
||||
|
||||
/// The longest any one request may take, on the desktop and in the browser.
|
||||
pub const REQUEST_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10);
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct Client {
|
||||
http: reqwest::Client,
|
||||
info_url: String,
|
||||
exchange_url: String,
|
||||
timeout: std::time::Duration,
|
||||
}
|
||||
|
||||
impl Client {
|
||||
pub fn new() -> Result<Self> {
|
||||
Self::with_urls(Network::Testnet.info_url(), Network::Testnet.exchange_url())
|
||||
}
|
||||
|
||||
/// Unit tests only: a local mock server.
|
||||
#[cfg(test)]
|
||||
pub(crate) fn mock(base: &str) -> Result<Self> {
|
||||
Self::with_urls(format!("{base}/info"), format!("{base}/exchange"))
|
||||
}
|
||||
|
||||
fn with_urls(info_url: String, exchange_url: String) -> Result<Self> {
|
||||
let b = reqwest::Client::builder();
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
let b = b.user_agent(concat!("terminal/", env!("CARGO_PKG_VERSION")));
|
||||
Ok(Self { http: b.build()?, info_url, exchange_url, timeout: REQUEST_TIMEOUT })
|
||||
}
|
||||
|
||||
/// Every request has its own deadline. The browser's `fetch` has no client-wide timeout,
|
||||
/// so a request that never got an answer would otherwise wait forever.
|
||||
fn post(&self, url: &str) -> reqwest::RequestBuilder {
|
||||
self.http.post(url).timeout(self.timeout)
|
||||
}
|
||||
|
||||
/// Any `/info` query.
|
||||
pub async fn info(&self, body: Value) -> Result<Value> {
|
||||
let kind = body["type"].as_str().unwrap_or("?").to_string();
|
||||
let r = self.post(&self.info_url).json(&body).send().await.with_context(|| format!("info {kind}"))?;
|
||||
let status = r.status();
|
||||
let text = r.text().await.with_context(|| format!("info {kind}"))?;
|
||||
if !status.is_success() {
|
||||
return Err(anyhow!("info {kind}: HTTP {status}: {}", text.chars().take(200).collect::<String>()));
|
||||
}
|
||||
serde_json::from_str(&text).with_context(|| format!("info {kind}: not JSON"))
|
||||
}
|
||||
|
||||
/// The asset ids and rules for the core and `xyz` dexes, read fresh.
|
||||
pub async fn registry(&self) -> Result<AssetRegistry> {
|
||||
let (dexes, core) = futures::join!(self.info(json!({"type": "perpDexs"})), self.info(json!({"type": "meta"})));
|
||||
let (dexes, core) = (dexes?, core?);
|
||||
let mut metas = Vec::new();
|
||||
for dex in SUPPORTED_DEXES {
|
||||
metas.push((dex, self.info(json!({"type": "meta", "dex": dex})).await?));
|
||||
}
|
||||
let refs: Vec<(&str, &Value)> = metas.iter().map(|(d, m)| (*d, m)).collect();
|
||||
AssetRegistry::from_info(&dexes, &core, &refs)
|
||||
}
|
||||
|
||||
pub async fn clearinghouse(&self, user: &Address, dex: &str) -> Result<Clearinghouse> {
|
||||
let v = self.info(json!({"type": "clearinghouseState", "user": address_hex(user), "dex": dex})).await?;
|
||||
Ok(parse_clearinghouse(dex, &v))
|
||||
}
|
||||
|
||||
/// The account's spot USDC (`spotClearinghouseState`), for the Deposit window.
|
||||
pub async fn spot_usdc(&self, user: &Address) -> Result<crate::model::SpotUsdc> {
|
||||
let v = self.info(json!({"type": "spotClearinghouseState", "user": address_hex(user)})).await?;
|
||||
Ok(crate::model::spot_usdc(&v))
|
||||
}
|
||||
|
||||
pub async fn open_orders(&self, user: &Address, dex: &str) -> Result<Vec<OpenOrder>> {
|
||||
let v = self.info(json!({"type": "frontendOpenOrders", "user": address_hex(user), "dex": dex})).await?;
|
||||
Ok(parse_open_orders(&v))
|
||||
}
|
||||
|
||||
pub async fn asset_data(&self, user: &Address, coin: &str) -> Result<Option<AssetData>> {
|
||||
let v = self.info(json!({"type": "activeAssetData", "user": address_hex(user), "coin": coin})).await?;
|
||||
Ok(parse_asset_data(&v))
|
||||
}
|
||||
|
||||
pub async fn mids(&self, dex: &str) -> Result<std::collections::HashMap<String, f64>> {
|
||||
let v = self.info(json!({"type": "allMids", "dex": dex})).await?;
|
||||
Ok(v.as_object().into_iter().flatten().filter_map(|(k, p)| Some((k.clone(), num(p)?))).collect())
|
||||
}
|
||||
|
||||
/// An order's status by client order id: `Some("open" | "filled" | "canceled" | …)`, or
|
||||
/// `None` when Hyperliquid has never seen it (`unknownOid`).
|
||||
pub async fn order_status(&self, user: &Address, cloid: &str) -> Result<Option<String>> {
|
||||
let v = self.info(json!({"type": "orderStatus", "user": address_hex(user), "oid": cloid})).await?;
|
||||
Ok(parse_order_status(&v))
|
||||
}
|
||||
|
||||
/// `missing`, `user`, `agent`, `vault` or `subAccount`.
|
||||
pub async fn user_role(&self, user: &Address) -> Result<String> {
|
||||
let v = self.info(json!({"type": "userRole", "user": address_hex(user)})).await?;
|
||||
Ok(v["role"].as_str().unwrap_or("unknown").to_string())
|
||||
}
|
||||
|
||||
pub async fn extra_agents(&self, user: &Address) -> Result<Vec<ExtraAgent>> {
|
||||
Ok(parse_extra_agents(&self.info(json!({"type": "extraAgents", "user": address_hex(user)})).await?))
|
||||
}
|
||||
|
||||
/// Post a signed L1 action.
|
||||
pub async fn send(&self, signed: &Signed) -> Result<ExchangeResponse, SendError> {
|
||||
tracing::info!(kind = signed.kind, nonce = signed.nonce, "hyperliquid testnet: sending");
|
||||
let r = self.post_exchange(&signed.body()).await;
|
||||
match &r {
|
||||
Ok(resp) => {
|
||||
tracing::info!(kind = signed.kind, nonce = signed.nonce, "hyperliquid testnet: {}", resp.summary())
|
||||
}
|
||||
Err(e) => tracing::warn!(
|
||||
kind = signed.kind,
|
||||
nonce = signed.nonce,
|
||||
"hyperliquid testnet: {}",
|
||||
crate::key::mask_addresses(&e.to_string())
|
||||
),
|
||||
}
|
||||
r
|
||||
}
|
||||
|
||||
/// Post a user-signed action body (`approveAgent`).
|
||||
pub async fn send_user_signed(&self, body: &Value) -> Result<ExchangeResponse, SendError> {
|
||||
let kind = body["action"]["type"].as_str().unwrap_or("?").to_string();
|
||||
tracing::info!(kind, "hyperliquid testnet: sending");
|
||||
let r = self.post_exchange(body).await;
|
||||
if let Ok(resp) = &r {
|
||||
tracing::info!(kind, "hyperliquid testnet: {}", resp.summary());
|
||||
}
|
||||
r
|
||||
}
|
||||
|
||||
async fn post_exchange(&self, body: &Value) -> Result<ExchangeResponse, SendError> {
|
||||
let r = self.post(&self.exchange_url).json(body).send().await.map_err(classify)?;
|
||||
let status = r.status().as_u16();
|
||||
let text = r.text().await.map_err(|e| SendError::Uncertain(e.to_string()))?;
|
||||
if (400..500).contains(&status) {
|
||||
return Err(SendError::Rejected { status, body: text.chars().take(300).collect() });
|
||||
}
|
||||
if status >= 500 {
|
||||
return Err(SendError::Uncertain(format!("HTTP {status}")));
|
||||
}
|
||||
let v: Value =
|
||||
serde_json::from_str(&text).map_err(|_| SendError::Uncertain(format!("unreadable answer: {text}")))?;
|
||||
Ok(ExchangeResponse::parse(&v))
|
||||
}
|
||||
}
|
||||
|
||||
/// A failure before any answer: certainly not sent only if the connection never opened.
|
||||
/// The browser's `fetch` doesn't say, so there every such failure is uncertain.
|
||||
fn classify(e: reqwest::Error) -> SendError {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
if e.is_connect() || e.is_builder() {
|
||||
return SendError::NotSent(e.to_string());
|
||||
}
|
||||
SendError::Uncertain(e.to_string())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Greptile #37 (f636b7c): the browser's `fetch` has no client-wide timeout, so a request
|
||||
/// that never got an answer held the worker's turn and KILL waited behind it. Each request
|
||||
/// now carries its own deadline, which reqwest honours on both targets.
|
||||
#[test]
|
||||
fn every_request_carries_its_own_deadline() {
|
||||
let c = Client::new().unwrap();
|
||||
for url in [&c.info_url, &c.exchange_url] {
|
||||
let req = c.post(url).build().unwrap();
|
||||
assert_eq!(req.timeout(), Some(&REQUEST_TIMEOUT), "{url}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Against a server that takes the request and never answers, the send ends at the deadline
|
||||
/// as uncertain, since it may have arrived, and a read ends with an error.
|
||||
#[tokio::test]
|
||||
async fn a_request_that_gets_no_answer_ends_at_its_deadline() {
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let base = format!("http://{}", listener.local_addr().unwrap());
|
||||
tokio::spawn(async move {
|
||||
let mut held = Vec::new();
|
||||
while let Ok((sock, _)) = listener.accept().await {
|
||||
held.push(sock); // read nothing, answer nothing
|
||||
}
|
||||
});
|
||||
let c = Client { timeout: std::time::Duration::from_millis(300), ..Client::mock(&base).unwrap() };
|
||||
let t0 = std::time::Instant::now();
|
||||
let sent = c.post_exchange(&json!({"action": {"type": "noop"}})).await;
|
||||
assert!(matches!(sent, Err(SendError::Uncertain(_))), "{sent:?}");
|
||||
assert!(c.user_role(&Address::ZERO).await.is_err());
|
||||
let took = t0.elapsed();
|
||||
assert!(took >= std::time::Duration::from_millis(600) && took < std::time::Duration::from_secs(3), "{took:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn responses_parse() {
|
||||
let resting = ExchangeResponse::parse(
|
||||
&json!({"status": "ok", "response": {"type": "order", "data": {"statuses": [{"resting": {"oid": 77738308}}]}}}),
|
||||
);
|
||||
assert_eq!(resting.statuses, [Status::Resting { oid: 77738308 }]);
|
||||
assert_eq!(resting.summary(), "ok (order): resting oid 77738308");
|
||||
let min = ExchangeResponse::parse(
|
||||
&json!({"status": "ok", "response": {"type": "order", "data": {"statuses": [{"error": "Order must have minimum value of $10."}]}}}),
|
||||
);
|
||||
assert!(min.ok && min.first_error() == Some("Order must have minimum value of $10."));
|
||||
let filled = ExchangeResponse::parse(
|
||||
&json!({"status": "ok", "response": {"type": "order", "data": {"statuses": [{"filled": {"totalSz": "0.02", "avgPx": "1891.4", "oid": 77747314}}]}}}),
|
||||
);
|
||||
assert_eq!(filled.statuses, [Status::Filled { oid: 77747314, total_sz: 0.02, avg_px: 1891.4 }]);
|
||||
let cancel = ExchangeResponse::parse(
|
||||
&json!({"status": "ok", "response": {"type": "cancel", "data": {"statuses": ["success"]}}}),
|
||||
);
|
||||
assert_eq!(cancel.statuses, [Status::Success]);
|
||||
let err = ExchangeResponse::parse(
|
||||
&json!({"status": "err", "response": "User or API Wallet 0x5e9ee1089755c3435139848e47e6635505d5a13a does not exist."}),
|
||||
);
|
||||
assert!(!err.ok);
|
||||
assert_eq!(
|
||||
recovered_address(err.first_error().unwrap()).map(|a| address_hex(&a)).as_deref(),
|
||||
Some("0x5e9ee1089755c3435139848e47e6635505d5a13a")
|
||||
);
|
||||
assert_eq!(
|
||||
ExchangeResponse::parse(&json!({"status": "ok", "response": {"type": "default"}})).summary(),
|
||||
"ok (default)"
|
||||
);
|
||||
assert_eq!(recovered_address("Insufficient margin"), None);
|
||||
let found = json!({"status": "order", "order": {"order": {"coin": "BTC", "oid": 1}, "status": "filled", "statusTimestamp": 1}});
|
||||
assert_eq!(parse_order_status(&found).as_deref(), Some("filled"));
|
||||
assert_eq!(parse_order_status(&json!({"status": "unknownOid"})), None);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,230 @@
|
||||
//! Private keys that sign: the agent (API wallet) key Terminal generates and keeps, and a
|
||||
//! main-wallet key for tests and the developer-only `testnet_check` example. The app never
|
||||
//! holds a main-wallet key: its main wallet is the built-in Turnkey wallet.
|
||||
//!
|
||||
//! Neither type is `Clone`, `Serialize` or printable: `Debug` and `Display` show only the
|
||||
//! masked address. The secret scalar lives in a k256 `SigningKey`, which zeroizes itself on
|
||||
//! drop, and the only way out of an [`AgentKey`] is [`AgentKey::to_hex`] for the OS secret
|
||||
//! store, which hands back a buffer that is zeroized when dropped. [`MainKey`] has no way
|
||||
//! out at all.
|
||||
|
||||
use std::fmt;
|
||||
|
||||
use alloy_primitives::{Address, B256};
|
||||
use anyhow::{Result, bail};
|
||||
use k256::ecdsa::SigningKey as EcdsaKey;
|
||||
use zeroize::Zeroizing;
|
||||
|
||||
mod sealed {
|
||||
pub trait Sealed {
|
||||
fn signer(&self) -> &k256::ecdsa::SigningKey;
|
||||
}
|
||||
}
|
||||
|
||||
/// A key this crate can sign with. Sealed: only [`AgentKey`] and [`MainKey`].
|
||||
pub trait SigningKey: sealed::Sealed {
|
||||
fn address(&self) -> Address {
|
||||
Address::from_private_key(self.signer())
|
||||
}
|
||||
}
|
||||
|
||||
/// Sign a 32-byte hash (low-s, with the recovery parity), as Ethereum wallets do.
|
||||
pub(crate) fn sign_hash(key: &impl SigningKey, hash: &B256) -> anyhow::Result<alloy_primitives::Signature> {
|
||||
let (sig, recid) = key.signer().sign_prehash_recoverable(hash.as_slice())?;
|
||||
let (r, s) = sig.split_bytes();
|
||||
Ok(alloy_primitives::Signature::new(
|
||||
alloy_primitives::U256::from_be_slice(&r),
|
||||
alloy_primitives::U256::from_be_slice(&s),
|
||||
recid.is_y_odd(),
|
||||
))
|
||||
}
|
||||
|
||||
fn parse(hex: &str) -> Result<EcdsaKey> {
|
||||
let s = Zeroizing::new(hex.trim().trim_start_matches("0x").to_string());
|
||||
if s.len() != 64 || !s.chars().all(|c| c.is_ascii_hexdigit()) {
|
||||
// Never echo the input: it is a secret, or a typo of one.
|
||||
bail!("a private key is 64 hex digits");
|
||||
}
|
||||
let mut bytes = Zeroizing::new([0u8; 32]);
|
||||
for (i, b) in bytes.iter_mut().enumerate() {
|
||||
*b = u8::from_str_radix(&s[2 * i..2 * i + 2], 16)?;
|
||||
}
|
||||
EcdsaKey::from_slice(bytes.as_slice()).map_err(|_| anyhow::anyhow!("not a valid secp256k1 private key"))
|
||||
}
|
||||
|
||||
/// The API wallet key that signs orders. Generated on this device; approved once by the
|
||||
/// main wallet; stored only in the OS secret store (or browser memory).
|
||||
pub struct AgentKey(EcdsaKey);
|
||||
|
||||
impl AgentKey {
|
||||
/// A fresh random key. Never reuse an agent address: rotation always generates one.
|
||||
pub fn generate() -> Self {
|
||||
Self(EcdsaKey::random(&mut rand_core::OsRng))
|
||||
}
|
||||
|
||||
/// Load a key the secret store kept (`0x`-prefixed or bare hex).
|
||||
pub fn from_hex(hex: &str) -> Result<Self> {
|
||||
parse(hex).map(Self)
|
||||
}
|
||||
|
||||
/// The hex form for the OS secret store. The buffer is wiped when dropped; never log it.
|
||||
pub fn to_hex(&self) -> Zeroizing<String> {
|
||||
let bytes = Zeroizing::new(<[u8; 32]>::from(self.0.to_bytes()));
|
||||
let mut s = Zeroizing::new(String::with_capacity(66));
|
||||
s.push_str("0x");
|
||||
for b in bytes.iter() {
|
||||
s.push_str(&format!("{b:02x}"));
|
||||
}
|
||||
s
|
||||
}
|
||||
}
|
||||
|
||||
impl sealed::Sealed for AgentKey {
|
||||
fn signer(&self) -> &EcdsaKey {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
impl SigningKey for AgentKey {}
|
||||
|
||||
/// A main-wallet key for tests and the `testnet_check` example (throwaway testnet keys from
|
||||
/// a 0600 file). Nothing in the app makes one. It cannot be exported or stored.
|
||||
pub struct MainKey(EcdsaKey);
|
||||
|
||||
impl MainKey {
|
||||
pub fn from_hex(hex: &str) -> Result<Self> {
|
||||
parse(hex).map(Self)
|
||||
}
|
||||
|
||||
/// A fresh random key (tests and the testnet check only).
|
||||
pub fn generate() -> Self {
|
||||
Self(EcdsaKey::random(&mut rand_core::OsRng))
|
||||
}
|
||||
}
|
||||
|
||||
impl sealed::Sealed for MainKey {
|
||||
fn signer(&self) -> &EcdsaKey {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
impl SigningKey for MainKey {}
|
||||
|
||||
/// `0x1234…abcd`: enough to recognise an address without printing it in full.
|
||||
pub fn mask_address(a: &Address) -> String {
|
||||
let s = format!("{a:#x}");
|
||||
format!("{}…{}", &s[..6], &s[s.len() - 4..])
|
||||
}
|
||||
|
||||
/// `text` with every `0x` + 40 hex digits masked (`0x1234…abcd`), for anything shown or
|
||||
/// logged: Hyperliquid's errors name full addresses.
|
||||
pub fn mask_addresses(text: &str) -> String {
|
||||
let b = text.as_bytes();
|
||||
let mut out = String::with_capacity(text.len());
|
||||
let mut i = 0;
|
||||
while i < b.len() {
|
||||
if b[i..].starts_with(b"0x")
|
||||
&& b.len() >= i + 42
|
||||
&& b[i + 2..i + 42].iter().all(u8::is_ascii_hexdigit)
|
||||
&& b.get(i + 42).is_none_or(|c| !c.is_ascii_hexdigit())
|
||||
&& let Ok(a) = parse_address(&text[i..i + 42])
|
||||
{
|
||||
out.push_str(&mask_address(&a));
|
||||
i += 42;
|
||||
continue;
|
||||
}
|
||||
let ch = text[i..].chars().next().expect("on a char boundary");
|
||||
out.push(ch);
|
||||
i += ch.len_utf8();
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Lower-case `0x…` hex, as Hyperliquid wants addresses.
|
||||
pub fn address_hex(a: &Address) -> String {
|
||||
format!("{a:#x}")
|
||||
}
|
||||
|
||||
/// Parse a `0x…` address in any case.
|
||||
pub fn parse_address(s: &str) -> Result<Address> {
|
||||
let t = s.trim();
|
||||
let hex = t.strip_prefix("0x").or_else(|| t.strip_prefix("0X")).unwrap_or(t);
|
||||
if hex.len() != 40 || !hex.chars().all(|c| c.is_ascii_hexdigit()) {
|
||||
bail!("an address is 0x followed by 40 hex digits");
|
||||
}
|
||||
Ok(hex.to_ascii_lowercase().parse::<Address>()?)
|
||||
}
|
||||
|
||||
macro_rules! redacted {
|
||||
($t:ident) => {
|
||||
impl fmt::Debug for $t {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
write!(f, concat!(stringify!($t), "({})"), mask_address(&Address::from_private_key(&self.0)))
|
||||
}
|
||||
}
|
||||
impl fmt::Display for $t {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
fmt::Debug::fmt(self, f)
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
redacted!(AgentKey);
|
||||
redacted!(MainKey);
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// The Python SDK's public test key (signing_test.py), never funded.
|
||||
const SDK_KEY: &str = "0x0123456789012345678901234567890123456789012345678901234567890123";
|
||||
|
||||
#[test]
|
||||
fn keys_round_trip_through_hex() {
|
||||
let k = AgentKey::generate();
|
||||
let back = AgentKey::from_hex(&k.to_hex()).unwrap();
|
||||
assert_eq!(back.address(), k.address());
|
||||
let bare = AgentKey::from_hex(SDK_KEY.trim_start_matches("0x")).unwrap();
|
||||
assert_eq!(bare.address(), AgentKey::from_hex(SDK_KEY).unwrap().address());
|
||||
assert_ne!(AgentKey::generate().address(), AgentKey::generate().address());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn debug_and_display_never_show_the_secret() {
|
||||
let k = AgentKey::from_hex(SDK_KEY).unwrap();
|
||||
let m = MainKey::from_hex(SDK_KEY).unwrap();
|
||||
for s in [format!("{k:?}"), format!("{k}"), format!("{m:?}"), format!("{m}")] {
|
||||
assert!(!s.contains("0123456789012345678901234567890123456789"), "{s}");
|
||||
assert!(s.contains('…'), "{s}");
|
||||
}
|
||||
let addr = k.address();
|
||||
assert!(format!("{k:?}").contains(&mask_address(&addr)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bad_keys_are_refused_without_echoing_them() {
|
||||
for bad in ["", "0x12", "zz23456789012345678901234567890123456789012345678901234567890123"] {
|
||||
assert_eq!(AgentKey::from_hex(bad).unwrap_err().to_string(), "a private key is 64 hex digits");
|
||||
}
|
||||
let zero = AgentKey::from_hex(&format!("0x{}", "0".repeat(64))).unwrap_err().to_string();
|
||||
assert_eq!(zero, "not a valid secp256k1 private key");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn errors_are_masked_for_display() {
|
||||
let e = "User or API Wallet 0x5E9EE1089755C3435139848E47E6635505D5A13A does not exist. Must deposit… User: 0x5e9ee1089755c3435139848e47e6635505d5a13a";
|
||||
assert_eq!(mask_addresses(e), "User or API Wallet 0x5e9e…a13a does not exist. Must deposit… User: 0x5e9e…a13a");
|
||||
let hash = format!("0x{}", "ab".repeat(32));
|
||||
assert_eq!(mask_addresses(&hash), hash, "64-digit hashes are not addresses");
|
||||
assert_eq!(
|
||||
mask_addresses("cloid 0x0000000000000000000000000000002a ×3"),
|
||||
"cloid 0x0000000000000000000000000000002a ×3"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn addresses_parse_and_mask() {
|
||||
let a = parse_address("0x5E9EE1089755C3435139848E47E6635505D5A13A").unwrap();
|
||||
assert_eq!(address_hex(&a), "0x5e9ee1089755c3435139848e47e6635505d5a13a");
|
||||
assert_eq!(mask_address(&a), "0x5e9e…a13a");
|
||||
assert!(parse_address("0x123").is_err() && parse_address("hello").is_err());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
//! Order entry on Hyperliquid, **testnet only**.
|
||||
//!
|
||||
//! - [`network`]: the testnet endpoints, and the only [`Network`] there is.
|
||||
//! - [`key`]: the agent (API wallet) key, and a main-wallet key for tests and the
|
||||
//! developer-only `testnet_check` example, both redacted. The app's main wallet is the
|
||||
//! built-in Turnkey wallet, which signs through [`signing::sign_approve_agent_with`].
|
||||
//! - [`signing`]: L1 phantom-agent and user-signed EIP-712 signatures.
|
||||
//! - [`actions`]: order, cancel, cancelByCloid, modify, updateLeverage, scheduleCancel, noop
|
||||
//! and approveAgent, with the field order Hyperliquid hashes.
|
||||
//!
|
||||
//! Mainnet is not reachable from this crate: there is no mainnet URL, the phantom agent's
|
||||
//! source is always `"b"`, `hyperliquidChain` is always `"Testnet"`, and
|
||||
//! `tests/mainnet_impossible.rs` greps the source for mainnet hosts.
|
||||
|
||||
pub mod actions;
|
||||
pub mod assets;
|
||||
pub mod caps;
|
||||
pub mod client;
|
||||
pub mod key;
|
||||
pub mod model;
|
||||
pub mod network;
|
||||
pub mod nonce;
|
||||
pub mod rounding;
|
||||
pub mod signing;
|
||||
pub mod stream;
|
||||
pub mod trader;
|
||||
|
||||
#[cfg(test)]
|
||||
mod logs_test;
|
||||
#[cfg(test)]
|
||||
mod vectors;
|
||||
|
||||
pub use key::{AgentKey, MainKey, SigningKey, mask_address, mask_addresses};
|
||||
pub use network::Network;
|
||||
@@ -0,0 +1,121 @@
|
||||
//! A whole place-and-cancel cycle against a local mock `/exchange`, with every `tracing`
|
||||
//! line captured: nothing in the logs may look like a private key, a signature or a signed
|
||||
//! payload (any run of 64 or more hex digits).
|
||||
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
|
||||
use crate::assets::fixtures::registry;
|
||||
use crate::client::{Client, Status};
|
||||
use crate::key::AgentKey;
|
||||
use crate::nonce::NonceManager;
|
||||
use crate::trader::*;
|
||||
|
||||
/// Answers `/exchange` like Hyperliquid: a resting order, then a successful cancel.
|
||||
async fn mock_exchange() -> String {
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let addr = listener.local_addr().unwrap();
|
||||
tokio::spawn(async move {
|
||||
loop {
|
||||
let Ok((mut sock, _)) = listener.accept().await else { return };
|
||||
tokio::spawn(async move {
|
||||
let mut buf = Vec::new();
|
||||
let mut chunk = [0u8; 4096];
|
||||
// Read headers and the body (Content-Length).
|
||||
loop {
|
||||
let n = sock.read(&mut chunk).await.unwrap_or(0);
|
||||
if n == 0 {
|
||||
return;
|
||||
}
|
||||
buf.extend_from_slice(&chunk[..n]);
|
||||
let text = String::from_utf8_lossy(&buf).to_string();
|
||||
if let Some(h) = text.find("\r\n\r\n") {
|
||||
let len = text[..h]
|
||||
.lines()
|
||||
.find_map(|l| {
|
||||
l.to_ascii_lowercase()
|
||||
.strip_prefix("content-length:")
|
||||
.map(|v| v.trim().parse::<usize>().unwrap_or(0))
|
||||
})
|
||||
.unwrap_or(0);
|
||||
if buf.len() >= h + 4 + len {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
let req = String::from_utf8_lossy(&buf);
|
||||
let body = if req.contains(r#""type":"cancel""#) {
|
||||
r#"{"status":"ok","response":{"type":"cancel","data":{"statuses":["success"]}}}"#
|
||||
} else {
|
||||
r#"{"status":"ok","response":{"type":"order","data":{"statuses":[{"resting":{"oid":4242}}]}}}"#
|
||||
};
|
||||
let resp = format!(
|
||||
"HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{body}",
|
||||
body.len()
|
||||
);
|
||||
let _ = sock.write_all(resp.as_bytes()).await;
|
||||
});
|
||||
}
|
||||
});
|
||||
format!("http://{addr}")
|
||||
}
|
||||
|
||||
#[derive(Clone, Default)]
|
||||
struct Capture(Arc<Mutex<Vec<u8>>>);
|
||||
|
||||
impl std::io::Write for Capture {
|
||||
fn write(&mut self, b: &[u8]) -> std::io::Result<usize> {
|
||||
self.0.lock().unwrap().extend_from_slice(b);
|
||||
Ok(b.len())
|
||||
}
|
||||
fn flush(&mut self) -> std::io::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn longest_hex_run(s: &str) -> usize {
|
||||
s.split(|c: char| !c.is_ascii_hexdigit()).map(str::len).max().unwrap_or(0)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_place_and_cancel_cycle_logs_no_secrets() {
|
||||
let cap = Capture::default();
|
||||
let writer = cap.clone();
|
||||
let sub =
|
||||
tracing_subscriber::fmt().with_max_level(tracing::Level::TRACE).with_writer(move || writer.clone()).finish();
|
||||
let _guard = tracing::subscriber::set_default(sub);
|
||||
|
||||
let base = mock_exchange().await;
|
||||
let client = Client::mock(&base).unwrap();
|
||||
let reg = registry();
|
||||
let key = AgentKey::generate();
|
||||
let nonces = NonceManager::new();
|
||||
let ctx = MarketCtx { mid: Some(76_662.5), leverage: Some(Leverage { value: 3, cross: true }) };
|
||||
let intent = OrderIntent {
|
||||
coin: "BTC".into(),
|
||||
is_buy: true,
|
||||
size: 0.0002,
|
||||
kind: OrderKind::Limit { px: 50_000.0, tif: crate::actions::Tif::Gtc },
|
||||
reduce_only: false,
|
||||
};
|
||||
let plan = plan_order(®, &intent, &ctx, &crate::actions::cloid(7)).unwrap();
|
||||
let signed = sign_order(&key, &nonces, &plan, &ctx, crate::nonce::now_ms()).unwrap();
|
||||
// Whatever a careless caller might log, too.
|
||||
tracing::info!("key {key:?} / {key} / signed {signed:?} / plan cloid {}", plan.cloid());
|
||||
let placed = client.send(&signed).await.unwrap();
|
||||
assert_eq!(placed.statuses, [Status::Resting { oid: 4242 }]);
|
||||
let cancel = sign_cancel(&key, &nonces, reg.resolve("BTC").unwrap(), 4242, crate::nonce::now_ms()).unwrap();
|
||||
let cancelled = client.send(&cancel).await.unwrap();
|
||||
assert_eq!(cancelled.statuses, [Status::Success]);
|
||||
|
||||
let logs = String::from_utf8(cap.0.lock().unwrap().clone()).unwrap();
|
||||
assert!(logs.contains("resting oid 4242") && logs.contains("success"), "the cycle was logged:\n{logs}");
|
||||
let secret = key.to_hex();
|
||||
assert!(!logs.contains(&secret[2..]), "the private key is in the logs");
|
||||
assert!(
|
||||
!logs.contains(&signed.signature().r[2..]) && !logs.contains(&signed.signature().s[2..]),
|
||||
"a signature is in the logs"
|
||||
);
|
||||
assert!(longest_hex_run(&logs) < 64, "a 64-hex-digit secret-like run is in the logs:\n{logs}");
|
||||
}
|
||||
@@ -0,0 +1,365 @@
|
||||
//! Account data from `/info` and the user stream: positions and margin per dex, open orders,
|
||||
//! fills, funding and per-market leverage. Hyperliquid sends decimals as strings; anything
|
||||
//! missing or malformed becomes `None` or 0 rather than failing the whole message.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::trader::Leverage;
|
||||
|
||||
pub(crate) fn num(v: &Value) -> Option<f64> {
|
||||
match v {
|
||||
Value::String(s) => s.trim().parse().ok(),
|
||||
Value::Number(n) => n.as_f64(),
|
||||
_ => None,
|
||||
}
|
||||
.filter(|x: &f64| x.is_finite())
|
||||
}
|
||||
|
||||
fn text(v: &Value) -> String {
|
||||
v.as_str().unwrap_or_default().to_string()
|
||||
}
|
||||
|
||||
fn leverage(v: &Value) -> Option<Leverage> {
|
||||
let value = v.get("value")?.as_u64()? as u32;
|
||||
Some(Leverage { value, cross: v.get("type").and_then(Value::as_str) == Some("cross") })
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
|
||||
pub struct Margin {
|
||||
pub account_value: f64,
|
||||
pub total_ntl_pos: f64,
|
||||
pub total_margin_used: f64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct PerpPosition {
|
||||
pub coin: String,
|
||||
/// Signed size: negative is short.
|
||||
pub szi: f64,
|
||||
pub entry_px: Option<f64>,
|
||||
pub position_value: f64,
|
||||
pub unrealized_pnl: f64,
|
||||
pub return_on_equity: f64,
|
||||
pub liquidation_px: Option<f64>,
|
||||
pub margin_used: f64,
|
||||
pub leverage: Option<Leverage>,
|
||||
pub cum_funding: f64,
|
||||
}
|
||||
|
||||
impl PerpPosition {
|
||||
/// The mark implied by value and size.
|
||||
pub fn mark(&self) -> Option<f64> {
|
||||
(self.szi.abs() > 0.0).then(|| self.position_value / self.szi.abs())
|
||||
}
|
||||
}
|
||||
|
||||
/// `clearinghouseState` for one dex.
|
||||
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
|
||||
pub struct Clearinghouse {
|
||||
pub dex: String,
|
||||
pub margin: Margin,
|
||||
pub withdrawable: f64,
|
||||
pub positions: Vec<PerpPosition>,
|
||||
pub time: u64,
|
||||
}
|
||||
|
||||
pub fn parse_clearinghouse(dex: &str, v: &Value) -> Clearinghouse {
|
||||
let m = &v["marginSummary"];
|
||||
let positions = v["assetPositions"]
|
||||
.as_array()
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.filter_map(|a| {
|
||||
let p = &a["position"];
|
||||
let coin = text(&p["coin"]);
|
||||
let szi = num(&p["szi"])?;
|
||||
(!coin.is_empty()).then(|| PerpPosition {
|
||||
coin,
|
||||
szi,
|
||||
entry_px: num(&p["entryPx"]),
|
||||
position_value: num(&p["positionValue"]).unwrap_or(0.0),
|
||||
unrealized_pnl: num(&p["unrealizedPnl"]).unwrap_or(0.0),
|
||||
return_on_equity: num(&p["returnOnEquity"]).unwrap_or(0.0),
|
||||
liquidation_px: num(&p["liquidationPx"]),
|
||||
margin_used: num(&p["marginUsed"]).unwrap_or(0.0),
|
||||
leverage: leverage(&p["leverage"]),
|
||||
cum_funding: num(&p["cumFunding"]["allTime"]).unwrap_or(0.0),
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
Clearinghouse {
|
||||
dex: dex.to_string(),
|
||||
margin: Margin {
|
||||
account_value: num(&m["accountValue"]).unwrap_or(0.0),
|
||||
total_ntl_pos: num(&m["totalNtlPos"]).unwrap_or(0.0),
|
||||
total_margin_used: num(&m["totalMarginUsed"]).unwrap_or(0.0),
|
||||
},
|
||||
withdrawable: num(&v["withdrawable"]).unwrap_or(0.0),
|
||||
positions,
|
||||
time: v["time"].as_u64().unwrap_or(0),
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct OpenOrder {
|
||||
pub coin: String,
|
||||
pub is_buy: bool,
|
||||
pub limit_px: f64,
|
||||
pub sz: f64,
|
||||
pub orig_sz: f64,
|
||||
pub oid: u64,
|
||||
pub timestamp: u64,
|
||||
pub cloid: Option<String>,
|
||||
pub reduce_only: bool,
|
||||
/// `Limit`, `Stop Market`, `Take Profit Market`, …
|
||||
pub order_type: String,
|
||||
pub tif: Option<String>,
|
||||
pub trigger_px: Option<f64>,
|
||||
}
|
||||
|
||||
/// `openOrders` / `frontendOpenOrders` rows (the plain form lacks the type fields).
|
||||
pub fn parse_open_orders(v: &Value) -> Vec<OpenOrder> {
|
||||
v.as_array()
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.filter_map(|o| {
|
||||
Some(OpenOrder {
|
||||
coin: Some(text(&o["coin"])).filter(|c| !c.is_empty())?,
|
||||
is_buy: o["side"].as_str() == Some("B"),
|
||||
limit_px: num(&o["limitPx"])?,
|
||||
sz: num(&o["sz"]).unwrap_or(0.0),
|
||||
orig_sz: num(&o["origSz"]).unwrap_or(0.0),
|
||||
oid: o["oid"].as_u64()?,
|
||||
timestamp: o["timestamp"].as_u64().unwrap_or(0),
|
||||
cloid: o["cloid"].as_str().map(str::to_string),
|
||||
reduce_only: o["reduceOnly"].as_bool().unwrap_or(false),
|
||||
order_type: Some(text(&o["orderType"])).filter(|t| !t.is_empty()).unwrap_or_else(|| "Limit".into()),
|
||||
tif: o["tif"].as_str().map(str::to_string),
|
||||
trigger_px: num(&o["triggerPx"]).filter(|p| *p > 0.0 && o["isTrigger"].as_bool() == Some(true)),
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct Fill {
|
||||
pub coin: String,
|
||||
pub px: f64,
|
||||
pub sz: f64,
|
||||
pub is_buy: bool,
|
||||
pub time: u64,
|
||||
/// `Open Long`, `Close Short`, …
|
||||
pub dir: String,
|
||||
pub closed_pnl: f64,
|
||||
/// Includes any builder fee (always none from Terminal).
|
||||
pub fee: f64,
|
||||
pub oid: u64,
|
||||
/// The globally unique trade id.
|
||||
pub tid: u64,
|
||||
}
|
||||
|
||||
pub fn parse_fills(v: &Value) -> Vec<Fill> {
|
||||
v.as_array()
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.filter_map(|f| {
|
||||
Some(Fill {
|
||||
coin: Some(text(&f["coin"])).filter(|c| !c.is_empty())?,
|
||||
px: num(&f["px"])?,
|
||||
sz: num(&f["sz"])?,
|
||||
is_buy: f["side"].as_str() == Some("B"),
|
||||
time: f["time"].as_u64().unwrap_or(0),
|
||||
dir: text(&f["dir"]),
|
||||
closed_pnl: num(&f["closedPnl"]).unwrap_or(0.0),
|
||||
fee: num(&f["fee"]).unwrap_or(0.0),
|
||||
oid: f["oid"].as_u64().unwrap_or(0),
|
||||
tid: f["tid"].as_u64()?,
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct Funding {
|
||||
pub time: u64,
|
||||
pub coin: String,
|
||||
pub usdc: f64,
|
||||
pub szi: f64,
|
||||
pub rate: f64,
|
||||
}
|
||||
|
||||
pub fn parse_fundings(v: &Value) -> Vec<Funding> {
|
||||
v.as_array()
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.filter_map(|f| {
|
||||
Some(Funding {
|
||||
time: f["time"].as_u64()?,
|
||||
coin: text(&f["coin"]),
|
||||
usdc: num(&f["usdc"]).unwrap_or(0.0),
|
||||
szi: num(&f["szi"]).unwrap_or(0.0),
|
||||
rate: num(&f["fundingRate"]).unwrap_or(0.0),
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// `activeAssetData`: the account's leverage and what it can trade on one market.
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct AssetData {
|
||||
pub coin: String,
|
||||
pub leverage: Option<Leverage>,
|
||||
/// `[buy, sell]`.
|
||||
pub max_trade_szs: [f64; 2],
|
||||
pub available_to_trade: [f64; 2],
|
||||
pub mark_px: Option<f64>,
|
||||
}
|
||||
|
||||
pub fn parse_asset_data(v: &Value) -> Option<AssetData> {
|
||||
let pair = |k: &str| [num(&v[k][0]).unwrap_or(0.0), num(&v[k][1]).unwrap_or(0.0)];
|
||||
Some(AssetData {
|
||||
coin: Some(text(&v["coin"])).filter(|c| !c.is_empty())?,
|
||||
leverage: leverage(&v["leverage"]),
|
||||
max_trade_szs: pair("maxTradeSzs"),
|
||||
available_to_trade: pair("availableToTrade"),
|
||||
mark_px: num(&v["markPx"]),
|
||||
})
|
||||
}
|
||||
|
||||
/// A change to one order (`orderUpdates`): `open`, `filled`, `canceled`, `rejected`, …
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct OrderUpdate {
|
||||
pub coin: String,
|
||||
pub oid: u64,
|
||||
pub cloid: Option<String>,
|
||||
pub status: String,
|
||||
pub is_buy: bool,
|
||||
pub limit_px: f64,
|
||||
pub sz: f64,
|
||||
pub time: u64,
|
||||
}
|
||||
|
||||
pub fn parse_order_updates(v: &Value) -> Vec<OrderUpdate> {
|
||||
v.as_array()
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.filter_map(|u| {
|
||||
let o = &u["order"];
|
||||
Some(OrderUpdate {
|
||||
coin: text(&o["coin"]),
|
||||
oid: o["oid"].as_u64()?,
|
||||
cloid: o["cloid"].as_str().map(str::to_string),
|
||||
status: text(&u["status"]),
|
||||
is_buy: o["side"].as_str() == Some("B"),
|
||||
limit_px: num(&o["limitPx"]).unwrap_or(0.0),
|
||||
sz: num(&o["sz"]).unwrap_or(0.0),
|
||||
time: u["statusTimestamp"].as_u64().unwrap_or(0),
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// An approved API wallet (`extraAgents`).
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct ExtraAgent {
|
||||
pub address: String,
|
||||
pub name: String,
|
||||
pub valid_until: Option<u64>,
|
||||
}
|
||||
|
||||
pub fn parse_extra_agents(v: &Value) -> Vec<ExtraAgent> {
|
||||
v.as_array()
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.filter_map(|a| {
|
||||
Some(ExtraAgent {
|
||||
address: a["address"].as_str()?.to_ascii_lowercase(),
|
||||
name: text(&a["name"]),
|
||||
valid_until: a["validUntil"].as_u64(),
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// USDC in the account's spot balances (`spotClearinghouseState`): what a deposit lands in.
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq)]
|
||||
pub struct SpotUsdc {
|
||||
pub total: f64,
|
||||
/// Held by open spot orders.
|
||||
pub hold: f64,
|
||||
}
|
||||
|
||||
pub fn spot_usdc(v: &Value) -> SpotUsdc {
|
||||
v["balances"]
|
||||
.as_array()
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.find(|b| b["coin"] == "USDC")
|
||||
.map(|b| SpotUsdc { total: num(&b["total"]).unwrap_or(0.0), hold: num(&b["hold"]).unwrap_or(0.0) })
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::assets::fixtures::json;
|
||||
|
||||
/// The response shape from Hyperliquid's info-endpoint docs (spot), not a recording.
|
||||
#[test]
|
||||
fn spot_usdc_is_read_from_the_spot_balances() {
|
||||
let v: Value = serde_json::from_str(
|
||||
r#"{"balances":[{"coin":"USDC","token":0,"hold":"1.5","total":"14.625485","entryNtl":"0.0"},
|
||||
{"coin":"PURR","token":1,"hold":"0","total":"2000","entryNtl":"1234.56"}]}"#,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(spot_usdc(&v), SpotUsdc { total: 14.625485, hold: 1.5 });
|
||||
assert_eq!(spot_usdc(&serde_json::json!({"balances": []})), SpotUsdc::default());
|
||||
assert_eq!(spot_usdc(&serde_json::json!({})), SpotUsdc::default());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recorded_testnet_account_parses() {
|
||||
let c = parse_clearinghouse("xyz", &json("testnet_clearinghouse_xyz.json"));
|
||||
assert_eq!(c.positions.len(), 3);
|
||||
let tsla = c.positions.iter().find(|p| p.coin == "xyz:TSLA").unwrap();
|
||||
assert_eq!((tsla.szi, tsla.entry_px), (-1.483, Some(404.349)));
|
||||
assert_eq!(tsla.leverage, Some(Leverage { value: 10, cross: true }));
|
||||
assert!((tsla.mark().unwrap() - 668.92198 / 1.483).abs() < 1e-9);
|
||||
assert!(c.margin.account_value > 0.0 && c.withdrawable > 0.0);
|
||||
let o = parse_open_orders(&json("testnet_frontend_open_orders_xyz.json"));
|
||||
assert_eq!(o.len(), 1);
|
||||
assert_eq!(
|
||||
(o[0].coin.as_str(), o[0].is_buy, o[0].limit_px, o[0].oid),
|
||||
("xyz:AAPL", false, 346.21, 63919996139)
|
||||
);
|
||||
assert_eq!((o[0].tif.as_deref(), o[0].trigger_px), (Some("Alo"), None));
|
||||
assert!(parse_open_orders(&serde_json::json!([{"coin": "BTC"}])).is_empty(), "incomplete rows are skipped");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn streams_and_agents_parse() {
|
||||
let a = parse_asset_data(&serde_json::json!({
|
||||
"coin": "BTC", "leverage": {"type": "isolated", "value": 3},
|
||||
"maxTradeSzs": ["0.1", "0.2"], "availableToTrade": ["10", "20"], "markPx": "76613.0"
|
||||
}))
|
||||
.unwrap();
|
||||
assert_eq!(a.leverage, Some(Leverage { value: 3, cross: false }));
|
||||
assert_eq!((a.max_trade_szs, a.mark_px), ([0.1, 0.2], Some(76613.0)));
|
||||
let u = parse_order_updates(&serde_json::json!([{
|
||||
"order": {"coin": "BTC", "side": "B", "limitPx": "50000", "sz": "0.0002", "oid": 7, "timestamp": 1, "origSz": "0.0002", "cloid": "0x0000000000000000000000000000002a"},
|
||||
"status": "canceled", "statusTimestamp": 5
|
||||
}]));
|
||||
assert_eq!((u[0].status.as_str(), u[0].oid, u[0].is_buy), ("canceled", 7, true));
|
||||
let agents =
|
||||
parse_extra_agents(&serde_json::json!([{"address": "0xABC", "name": "terminal-mac", "validUntil": 9}]));
|
||||
assert_eq!(
|
||||
agents[0],
|
||||
ExtraAgent { address: "0xabc".into(), name: "terminal-mac".into(), valid_until: Some(9) }
|
||||
);
|
||||
let f = parse_fundings(
|
||||
&serde_json::json!([{"time": 1, "coin": "BTC", "usdc": "-0.01", "szi": "0.1", "fundingRate": "0.0001"}]),
|
||||
);
|
||||
assert_eq!(f[0].usdc, -0.01);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
//! The one network this build can reach: Hyperliquid's testnet.
|
||||
//!
|
||||
//! There is no mainnet variant, constant or switch. Everything that names a network (the
|
||||
//! endpoints, the phantom agent's `source`, `hyperliquidChain` in user-signed actions)
|
||||
//! comes from [`Network::Testnet`], so nothing in this crate can address or sign for
|
||||
//! mainnet. A preference file that says `"mainnet"` still reads as testnet (see the
|
||||
//! `Deserialize` impl).
|
||||
|
||||
use serde::{Deserialize, Deserializer, Serialize};
|
||||
|
||||
/// Testnet REST base (`/info`, `/exchange`).
|
||||
pub const API_URL: &str = "https://api.hyperliquid-testnet.xyz";
|
||||
/// Testnet websocket.
|
||||
pub const WS_URL: &str = "wss://api.hyperliquid-testnet.xyz/ws";
|
||||
/// The chain id user-signed actions are signed under (as the Python SDK does). It only
|
||||
/// names the EIP-712 domain the wallet signs in; `hyperliquidChain` picks the network.
|
||||
pub const SIGNATURE_CHAIN_ID: u64 = 0x66eee;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default, Serialize)]
|
||||
pub enum Network {
|
||||
#[default]
|
||||
Testnet,
|
||||
}
|
||||
|
||||
impl Network {
|
||||
pub fn info_url(self) -> String {
|
||||
format!("{API_URL}/info")
|
||||
}
|
||||
|
||||
pub fn exchange_url(self) -> String {
|
||||
format!("{API_URL}/exchange")
|
||||
}
|
||||
|
||||
pub fn ws_url(self) -> &'static str {
|
||||
WS_URL
|
||||
}
|
||||
|
||||
/// The phantom agent's `source` for L1 actions: `"b"` is testnet.
|
||||
pub fn phantom_source(self) -> &'static str {
|
||||
"b"
|
||||
}
|
||||
|
||||
/// `hyperliquidChain` in user-signed actions such as `approveAgent`.
|
||||
pub fn hyperliquid_chain(self) -> &'static str {
|
||||
"Testnet"
|
||||
}
|
||||
|
||||
/// The badge every surface shows.
|
||||
pub fn label(self) -> &'static str {
|
||||
"TESTNET"
|
||||
}
|
||||
}
|
||||
|
||||
/// Any stored value reads as testnet, including `"mainnet"` or garbage: this build has no
|
||||
/// other network. Failing the whole preferences file instead would reset unrelated settings.
|
||||
impl<'de> Deserialize<'de> for Network {
|
||||
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
|
||||
let v = serde_json::Value::deserialize(d)?;
|
||||
if v != serde_json::Value::String("Testnet".into()) {
|
||||
tracing::warn!("Hyperliquid network {v} is not available in this build; using testnet");
|
||||
}
|
||||
Ok(Network::Testnet)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn endpoints_are_the_testnet_hosts() {
|
||||
assert_eq!(API_URL, "https://api.hyperliquid-testnet.xyz");
|
||||
assert_eq!(WS_URL, "wss://api.hyperliquid-testnet.xyz/ws");
|
||||
let n = Network::default();
|
||||
assert_eq!(n.info_url(), "https://api.hyperliquid-testnet.xyz/info");
|
||||
assert_eq!(n.exchange_url(), "https://api.hyperliquid-testnet.xyz/exchange");
|
||||
assert_eq!(n.ws_url(), WS_URL);
|
||||
for url in [n.info_url(), n.exchange_url(), n.ws_url().to_string()] {
|
||||
assert!(url.contains("hyperliquid-testnet.xyz"), "{url}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn signing_names_testnet() {
|
||||
assert_eq!(Network::Testnet.phantom_source(), "b");
|
||||
assert_eq!(Network::Testnet.hyperliquid_chain(), "Testnet");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_stored_value_selects_mainnet() {
|
||||
for json in [r#""Testnet""#, r#""mainnet""#, r#""Mainnet""#, r#""MAINNET""#, r#""a""#, "1", "null", "{}"] {
|
||||
let n: Network = serde_json::from_str(json).unwrap();
|
||||
assert_eq!(n, Network::Testnet, "{json}");
|
||||
}
|
||||
assert_eq!(serde_json::to_string(&Network::Testnet).unwrap(), r#""Testnet""#);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
//! Nonces: one per signed action, strictly increasing per signer, close to the clock.
|
||||
//!
|
||||
//! Hyperliquid keeps the 100 highest nonces per signer and accepts a new one if it is larger
|
||||
//! than the smallest of those and within (now − 2 days, now + 1 day). Using
|
||||
//! `max(last + 1, now_ms)` keeps every nonce unique even when two actions are signed in the
|
||||
//! same millisecond.
|
||||
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
|
||||
/// Milliseconds since the Unix epoch (the browser's clock on the web).
|
||||
pub fn now_ms() -> u64 {
|
||||
web_time::SystemTime::now().duration_since(web_time::UNIX_EPOCH).map_or(0, |d| d.as_millis() as u64)
|
||||
}
|
||||
|
||||
/// The next nonce for one signer. Share one per agent key.
|
||||
#[derive(Debug, Default)]
|
||||
pub struct NonceManager {
|
||||
last: AtomicU64,
|
||||
}
|
||||
|
||||
impl NonceManager {
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
/// `max(last + 1, now)`, atomically.
|
||||
pub fn next(&self, now: u64) -> u64 {
|
||||
let mut cur = self.last.load(Ordering::Relaxed);
|
||||
loop {
|
||||
let n = (cur + 1).max(now);
|
||||
match self.last.compare_exchange_weak(cur, n, Ordering::AcqRel, Ordering::Relaxed) {
|
||||
Ok(_) => return n,
|
||||
Err(seen) => cur = seen,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn nonces_follow_the_clock_and_never_repeat() {
|
||||
let n = NonceManager::new();
|
||||
assert_eq!(n.next(1_000), 1_000);
|
||||
assert_eq!(n.next(1_000), 1_001, "same millisecond");
|
||||
assert_eq!(n.next(900), 1_002, "a clock that steps back never reuses one");
|
||||
assert_eq!(n.next(5_000), 5_000);
|
||||
let shared = std::sync::Arc::new(NonceManager::new());
|
||||
let handles: Vec<_> = (0..8)
|
||||
.map(|_| {
|
||||
let s = shared.clone();
|
||||
std::thread::spawn(move || (0..500).map(|_| s.next(7)).collect::<Vec<_>>())
|
||||
})
|
||||
.collect();
|
||||
let mut all: Vec<u64> = handles.into_iter().flat_map(|h| h.join().unwrap()).collect();
|
||||
all.sort();
|
||||
all.dedup();
|
||||
assert_eq!(all.len(), 4000, "unique across threads");
|
||||
assert!(now_ms() > 1_700_000_000_000);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
//! Tick and lot rules for perps: prices have at most five significant figures and at most
|
||||
//! `6 − szDecimals` decimals (integers are always valid); sizes have at most `szDecimals`
|
||||
//! decimals. Wire strings carry no trailing zeros, as the Python SDK's `float_to_wire`.
|
||||
|
||||
use anyhow::{Result, bail};
|
||||
|
||||
/// Which way to round when a value doesn't fit the grid.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Round {
|
||||
Nearest,
|
||||
/// Towards zero, e.g. a market buy's price cap, so slippage never exceeds the cap.
|
||||
Down,
|
||||
/// Away from zero, e.g. a market sell's price floor.
|
||||
Up,
|
||||
}
|
||||
|
||||
fn round_to(x: f64, decimals: i32, dir: Round) -> f64 {
|
||||
let scale = 10f64.powi(decimals);
|
||||
let v = x * scale;
|
||||
// A hair of tolerance so 226.4 stays 226.4 when the float is 226.39999999.
|
||||
let r = match dir {
|
||||
Round::Nearest => v.round(),
|
||||
Round::Down => (v + 1e-7).floor(),
|
||||
Round::Up => (v - 1e-7).ceil(),
|
||||
};
|
||||
r / scale
|
||||
}
|
||||
|
||||
/// The most decimals a perp price may have at this level.
|
||||
pub fn price_decimals(px: f64, sz_decimals: u32) -> i32 {
|
||||
let max = 6 - sz_decimals.min(6) as i32;
|
||||
// log10 of zero (or of nothing) is not a number of digits.
|
||||
if !px.is_finite() || px == 0.0 {
|
||||
return max;
|
||||
}
|
||||
if px.abs() >= 100_000.0 {
|
||||
return 0;
|
||||
}
|
||||
let sig = 4 - px.abs().log10().floor() as i32;
|
||||
sig.clamp(0, max)
|
||||
}
|
||||
|
||||
/// A valid perp price: five significant figures and `6 − szDecimals` decimals at most.
|
||||
pub fn round_price(px: f64, sz_decimals: u32, dir: Round) -> f64 {
|
||||
if !px.is_finite() || px <= 0.0 {
|
||||
return px;
|
||||
}
|
||||
let d = price_decimals(px, sz_decimals);
|
||||
let r = round_to(px, d, dir);
|
||||
if r <= 0.0 {
|
||||
// Smaller than the smallest tick: the caller refuses a zero price.
|
||||
return 0.0;
|
||||
}
|
||||
// Rounding up can add a digit (99999.5 → 100000); one more pass settles it.
|
||||
round_to(r, price_decimals(r, sz_decimals), dir)
|
||||
}
|
||||
|
||||
/// A valid size: `szDecimals` decimals, rounded down so an order never grows.
|
||||
pub fn round_size(sz: f64, sz_decimals: u32) -> f64 {
|
||||
round_to(sz, sz_decimals as i32, Round::Down)
|
||||
}
|
||||
|
||||
/// Whether `px` is already on the price grid.
|
||||
pub fn price_ok(px: f64, sz_decimals: u32) -> bool {
|
||||
px.is_finite() && px > 0.0 && (round_price(px, sz_decimals, Round::Nearest) - px).abs() <= px.abs() * 1e-12
|
||||
}
|
||||
|
||||
/// `float_to_wire`: eight decimals at most, no trailing zeros, never `-0`. Refuses a value
|
||||
/// that would change by rounding.
|
||||
pub fn to_wire(x: f64) -> Result<String> {
|
||||
if !x.is_finite() {
|
||||
bail!("not a number");
|
||||
}
|
||||
let s = format!("{x:.8}");
|
||||
if (s.parse::<f64>()? - x).abs() >= 1e-12 {
|
||||
bail!("{x} has more than 8 decimals");
|
||||
}
|
||||
let t = s.trim_end_matches('0').trim_end_matches('.');
|
||||
Ok(if t == "-0" || t.is_empty() { "0".into() } else { t.to_string() })
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn prices_keep_five_significant_figures() {
|
||||
// xyz:AAPL (szDecimals 3): three decimals at most.
|
||||
assert_eq!(round_price(226.4, 3, Round::Nearest), 226.4);
|
||||
assert_eq!(round_price(226.456, 3, Round::Nearest), 226.46, "five significant figures");
|
||||
assert_eq!(round_price(4.56789, 3, Round::Nearest), 4.568, "and 6 − 3 = 3 decimals");
|
||||
// BTC (szDecimals 5): one decimal at most, integers always valid.
|
||||
assert_eq!(round_price(76662.57, 5, Round::Nearest), 76663.0);
|
||||
assert_eq!(round_price(123456.7, 5, Round::Nearest), 123457.0, "integers are valid past five figures");
|
||||
assert_eq!(round_price(1234.56, 5, Round::Nearest), 1234.6);
|
||||
// Small prices.
|
||||
assert_eq!(round_price(0.0123456, 0, Round::Nearest), 0.012346);
|
||||
assert_eq!(round_price(0.000123456, 0, Round::Nearest), 0.000123, "six decimals at most");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn directed_rounding_never_overshoots() {
|
||||
assert_eq!(round_price(226.456, 3, Round::Down), 226.45);
|
||||
assert_eq!(round_price(226.451, 3, Round::Up), 226.46);
|
||||
assert_eq!(round_price(99999.6, 0, Round::Up), 100000.0);
|
||||
assert_eq!(round_price(226.4, 3, Round::Down), 226.4, "on-grid values stay put");
|
||||
assert_eq!(round_price(226.4, 3, Round::Up), 226.4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zero_and_tiny_prices_never_panic() {
|
||||
assert_eq!(price_decimals(0.0, 5), 1);
|
||||
assert_eq!(round_price(0.00000001, 5, Round::Nearest), 0.0);
|
||||
assert_eq!(round_price(0.00000001, 5, Round::Up), 0.0, "within the grid's tolerance of zero");
|
||||
assert!(round_price(f64::NAN, 5, Round::Nearest).is_nan());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sizes_round_down_to_lot() {
|
||||
assert_eq!(round_size(0.123456, 5), 0.12345);
|
||||
assert_eq!(round_size(0.5, 3), 0.5);
|
||||
assert_eq!(round_size(0.0009, 3), 0.0, "too small rounds to nothing");
|
||||
assert_eq!(round_size(0.3, 1), 0.3, "0.3 is not 0.2999…");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wire_strings_match_float_to_wire() {
|
||||
assert_eq!(to_wire(100.0).unwrap(), "100");
|
||||
assert_eq!(to_wire(1670.1).unwrap(), "1670.1");
|
||||
assert_eq!(to_wire(0.0147).unwrap(), "0.0147");
|
||||
assert_eq!(to_wire(0.0002).unwrap(), "0.0002");
|
||||
assert_eq!(to_wire(-0.0).unwrap(), "0");
|
||||
assert_eq!(to_wire(0.0).unwrap(), "0");
|
||||
assert!(to_wire(0.123456789).is_err(), "would round");
|
||||
assert!(to_wire(f64::NAN).is_err());
|
||||
assert!(price_ok(226.4, 3) && !price_ok(226.456, 3));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,241 @@
|
||||
//! Signatures, as the Python SDK's `hyperliquid/utils/signing.py` makes them.
|
||||
//!
|
||||
//! **L1 actions** (order, cancel, modify, leverage, schedule cancel, noop): the action is
|
||||
//! msgpack-encoded with named fields, followed by the nonce (8 bytes, big-endian), `0x00`
|
||||
//! (no vault) and, when set, `0x00 ‖ expiresAfter`. Its keccak-256 is the `connectionId` of
|
||||
//! a "phantom agent" `Agent(string source, bytes32 connectionId)`, EIP-712-signed under the
|
||||
//! domain `Exchange` v1, chain 1337. `source` is always testnet's `"b"`
|
||||
//! ([`Network::phantom_source`]); nothing here can produce mainnet's.
|
||||
//!
|
||||
//! **User-signed actions** (`approveAgent`): EIP-712 over the action's own fields under the
|
||||
//! domain `HyperliquidSignTransaction` v1 at `signatureChainId`, with `hyperliquidChain`
|
||||
//! always `"Testnet"`.
|
||||
//!
|
||||
//! L1 signing is crate-private: callers go through [`crate::Trader`], which enforces the
|
||||
//! hard caps before anything is signed.
|
||||
|
||||
use alloy_primitives::{Address, B256, Signature, U256, keccak256};
|
||||
use alloy_sol_types::{SolStruct, eip712_domain, sol};
|
||||
use anyhow::{Context, Result, ensure};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::actions::ApproveAgentAction;
|
||||
use crate::key::{SigningKey, sign_hash};
|
||||
use crate::network::{Network, SIGNATURE_CHAIN_ID};
|
||||
|
||||
sol! {
|
||||
struct Agent {
|
||||
string source;
|
||||
bytes32 connectionId;
|
||||
}
|
||||
}
|
||||
|
||||
/// `{r, s, v}` as Hyperliquid takes it: `0x` hex without leading zeros (as the SDK's
|
||||
/// `to_hex`), and `v` 27 or 28.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct WireSignature {
|
||||
pub r: String,
|
||||
pub s: String,
|
||||
pub v: u8,
|
||||
}
|
||||
|
||||
impl WireSignature {
|
||||
fn from_alloy(sig: &Signature) -> Self {
|
||||
Self { r: format!("{:#x}", sig.r()), s: format!("{:#x}", sig.s()), v: 27 + u8::from(sig.v()) }
|
||||
}
|
||||
|
||||
/// From a wallet's 65-byte `0x` r ‖ s ‖ v hex (`eth_signTypedData_v4`); `v` may be 0/1.
|
||||
pub fn from_rsv_hex(hex: &str) -> Result<Self> {
|
||||
let h = hex.trim().trim_start_matches("0x");
|
||||
ensure!(
|
||||
h.len() == 130 && h.chars().all(|c| c.is_ascii_hexdigit()),
|
||||
"the wallet returned a malformed signature"
|
||||
);
|
||||
let v = u8::from_str_radix(&h[128..130], 16)?;
|
||||
let num = |s: &str| U256::from_str_radix(s, 16).map(|n| format!("{n:#x}"));
|
||||
Ok(Self { r: num(&h[..64])?, s: num(&h[64..128])?, v: if v < 27 { v + 27 } else { v } })
|
||||
}
|
||||
|
||||
fn to_alloy(&self) -> Result<Signature> {
|
||||
let num = |s: &str| U256::from_str_radix(s.trim_start_matches("0x"), 16).context("signature is not hex");
|
||||
ensure!(self.v == 27 || self.v == 28, "signature v must be 27 or 28");
|
||||
Ok(Signature::new(num(&self.r)?, num(&self.s)?, self.v == 28))
|
||||
}
|
||||
}
|
||||
|
||||
/// The L1 `connectionId`: keccak-256 of msgpack(action) ‖ nonce ‖ `0x00` ‖ [`0x00` ‖ expiresAfter].
|
||||
pub fn action_hash<A: Serialize>(action: &A, nonce: u64, expires_after: Option<u64>) -> Result<B256> {
|
||||
let mut data = rmp_serde::to_vec_named(action).context("msgpack-encoding the action")?;
|
||||
data.extend_from_slice(&nonce.to_be_bytes());
|
||||
// No vault or sub-account: this build only trades the signer's own account.
|
||||
data.push(0);
|
||||
if let Some(t) = expires_after {
|
||||
data.push(0);
|
||||
data.extend_from_slice(&t.to_be_bytes());
|
||||
}
|
||||
Ok(keccak256(data))
|
||||
}
|
||||
|
||||
/// The EIP-712 hash of the testnet phantom agent for `connection_id`.
|
||||
pub(crate) fn agent_signing_hash(connection_id: B256) -> B256 {
|
||||
let domain = eip712_domain! {
|
||||
name: "Exchange",
|
||||
version: "1",
|
||||
chain_id: 1337,
|
||||
verifying_contract: Address::ZERO,
|
||||
};
|
||||
let agent = Agent { source: Network::Testnet.phantom_source().into(), connectionId: connection_id };
|
||||
agent.eip712_signing_hash(&domain)
|
||||
}
|
||||
|
||||
/// Sign an L1 action for testnet. Crate-private: see [`crate::Trader`].
|
||||
pub(crate) fn sign_l1<A: Serialize>(
|
||||
key: &impl SigningKey,
|
||||
action: &A,
|
||||
nonce: u64,
|
||||
expires_after: Option<u64>,
|
||||
) -> Result<WireSignature> {
|
||||
let hash = agent_signing_hash(action_hash(action, nonce, expires_after)?);
|
||||
let sig = sign_hash(key, &hash).context("signing")?;
|
||||
Ok(WireSignature::from_alloy(&sig))
|
||||
}
|
||||
|
||||
/// The address that signed an L1 action, as Hyperliquid recovers it. A wrong signature
|
||||
/// recovers some other address rather than failing, which is how Hyperliquid reports it
|
||||
/// ("User or API Wallet 0x… does not exist").
|
||||
pub fn recover_l1<A: Serialize>(
|
||||
action: &A,
|
||||
nonce: u64,
|
||||
expires_after: Option<u64>,
|
||||
sig: &WireSignature,
|
||||
) -> Result<Address> {
|
||||
let hash = agent_signing_hash(action_hash(action, nonce, expires_after)?);
|
||||
Ok(sig.to_alloy()?.recover_address_from_prehash(&hash)?)
|
||||
}
|
||||
|
||||
// ---- user-signed actions -------------------------------------------------------------------
|
||||
|
||||
/// One EIP-712 field value, encoded to 32 bytes.
|
||||
pub(crate) enum Field<'a> {
|
||||
Str(&'a str),
|
||||
Addr(Address),
|
||||
U64(u64),
|
||||
}
|
||||
|
||||
impl Field<'_> {
|
||||
fn encode(&self) -> B256 {
|
||||
match self {
|
||||
Field::Str(s) => keccak256(s.as_bytes()),
|
||||
Field::Addr(a) => a.into_word(),
|
||||
Field::U64(n) => B256::from(U256::from(*n)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The EIP-712 signing hash of a `HyperliquidTransaction:*` user-signed action.
|
||||
pub(crate) fn user_signed_hash(type_string: &str, fields: &[Field<'_>], chain_id: u64) -> B256 {
|
||||
let mut enc = Vec::with_capacity(32 * (fields.len() + 1));
|
||||
enc.extend_from_slice(keccak256(type_string.as_bytes()).as_slice());
|
||||
for f in fields {
|
||||
enc.extend_from_slice(f.encode().as_slice());
|
||||
}
|
||||
let struct_hash = keccak256(&enc);
|
||||
let domain = eip712_domain! {
|
||||
name: "HyperliquidSignTransaction",
|
||||
version: "1",
|
||||
chain_id: chain_id,
|
||||
verifying_contract: Address::ZERO,
|
||||
};
|
||||
let mut msg = Vec::with_capacity(66);
|
||||
msg.extend_from_slice(&[0x19, 0x01]);
|
||||
msg.extend_from_slice(domain.separator().as_slice());
|
||||
msg.extend_from_slice(struct_hash.as_slice());
|
||||
keccak256(msg)
|
||||
}
|
||||
|
||||
const APPROVE_AGENT_TYPE: &str =
|
||||
"HyperliquidTransaction:ApproveAgent(string hyperliquidChain,address agentAddress,string agentName,uint64 nonce)";
|
||||
|
||||
/// The hash the main wallet signs to approve an agent. Refuses anything not addressed to
|
||||
/// testnet: `hyperliquidChain` must be `Testnet` and `signatureChainId` the testnet
|
||||
/// signature chain ([`SIGNATURE_CHAIN_ID`], 0x66eee), never Arbitrum One's 0xa4b1.
|
||||
pub fn approve_agent_hash(a: &ApproveAgentAction) -> Result<B256> {
|
||||
ensure!(a.kind == "approveAgent", "not an approveAgent action");
|
||||
ensure!(a.hyperliquid_chain == Network::Testnet.hyperliquid_chain(), "this build only approves agents on testnet");
|
||||
ensure!(
|
||||
a.chain_id().ok() == Some(SIGNATURE_CHAIN_ID),
|
||||
"this build only signs at the testnet signature chain ({SIGNATURE_CHAIN_ID:#x})"
|
||||
);
|
||||
let agent = crate::key::parse_address(&a.agent_address)?;
|
||||
let fields = [Field::Str(&a.hyperliquid_chain), Field::Addr(agent), Field::Str(&a.agent_name), Field::U64(a.nonce)];
|
||||
Ok(user_signed_hash(APPROVE_AGENT_TYPE, &fields, a.chain_id()?))
|
||||
}
|
||||
|
||||
/// The main wallet signs `approveAgent`.
|
||||
pub fn sign_approve_agent(main: &impl SigningKey, a: &ApproveAgentAction) -> Result<WireSignature> {
|
||||
let sig = sign_hash(main, &approve_agent_hash(a)?).context("signing")?;
|
||||
Ok(WireSignature::from_alloy(&sig))
|
||||
}
|
||||
|
||||
/// A main wallet held elsewhere (the built-in Turnkey wallet) signs `approveAgent`.
|
||||
///
|
||||
/// `sign` gets only the digest of a testnet approval: [`approve_agent_hash`] refuses
|
||||
/// anything else before `sign` is called. The answer is used only if it recovers `main`,
|
||||
/// so a wrong or swapped signature is never sent. This is the only way a remote wallet's
|
||||
/// signature enters this crate; L1 actions (orders, cancels) are signed by the local agent
|
||||
/// key alone.
|
||||
pub async fn sign_approve_agent_with<F, Fut>(a: &ApproveAgentAction, main: &Address, sign: F) -> Result<WireSignature>
|
||||
where
|
||||
F: FnOnce(B256) -> Fut,
|
||||
Fut: std::future::Future<Output = Result<Signature>>,
|
||||
{
|
||||
let digest = approve_agent_hash(a)?;
|
||||
let sig = sign(digest).await.context("the wallet didn't sign")?;
|
||||
let signer = sig.recover_address_from_prehash(&digest).context("the wallet's signature is malformed")?;
|
||||
ensure!(
|
||||
&signer == main,
|
||||
"the signature is not the wallet's ({}); nothing was sent",
|
||||
crate::key::mask_address(&signer)
|
||||
);
|
||||
Ok(WireSignature::from_alloy(&sig))
|
||||
}
|
||||
|
||||
/// Who signed an `approveAgent`.
|
||||
pub fn recover_approve_agent(a: &ApproveAgentAction, sig: &WireSignature) -> Result<Address> {
|
||||
Ok(sig.to_alloy()?.recover_address_from_prehash(&approve_agent_hash(a)?)?)
|
||||
}
|
||||
|
||||
/// The EIP-712 typed data of an approval, as a wallet's `eth_signTypedData_v4` takes it
|
||||
/// (the tests check it names the same domain and message as the digest).
|
||||
pub fn approve_agent_typed_data(a: &ApproveAgentAction) -> Result<serde_json::Value> {
|
||||
approve_agent_hash(a)?;
|
||||
Ok(serde_json::json!({
|
||||
"domain": {
|
||||
"name": "HyperliquidSignTransaction",
|
||||
"version": "1",
|
||||
"chainId": a.chain_id()?,
|
||||
"verifyingContract": "0x0000000000000000000000000000000000000000",
|
||||
},
|
||||
"types": {
|
||||
"HyperliquidTransaction:ApproveAgent": [
|
||||
{"name": "hyperliquidChain", "type": "string"},
|
||||
{"name": "agentAddress", "type": "address"},
|
||||
{"name": "agentName", "type": "string"},
|
||||
{"name": "nonce", "type": "uint64"},
|
||||
],
|
||||
"EIP712Domain": [
|
||||
{"name": "name", "type": "string"},
|
||||
{"name": "version", "type": "string"},
|
||||
{"name": "chainId", "type": "uint256"},
|
||||
{"name": "verifyingContract", "type": "address"},
|
||||
],
|
||||
},
|
||||
"primaryType": "HyperliquidTransaction:ApproveAgent",
|
||||
"message": {
|
||||
"hyperliquidChain": a.hyperliquid_chain,
|
||||
"agentAddress": a.agent_address,
|
||||
"agentName": a.agent_name,
|
||||
"nonce": a.nonce,
|
||||
},
|
||||
}))
|
||||
}
|
||||
@@ -0,0 +1,239 @@
|
||||
//! The user's own data over Hyperliquid's websocket, straight from the client (never through
|
||||
//! Terminal's server): what to subscribe to, and how each message changes [`UserState`].
|
||||
//!
|
||||
//! `clearinghouseState` and `openOrders` arrive whole for one dex each time, so they replace
|
||||
//! that dex's entry. `userFills` and `userFundings` start with a snapshot (`isSnapshot`) and
|
||||
//! then stream additions, de-duplicated by trade id so a reconnect's snapshot never doubles
|
||||
//! a fill. Messages for any other user are ignored.
|
||||
//!
|
||||
//! The socket itself lives with the caller (the app's worker uses `tr_data::ws`), which
|
||||
//! sends [`subscriptions`], [`PING`] about every 30 s, and feeds every text frame to
|
||||
//! [`UserState::apply`].
|
||||
|
||||
use std::collections::{BTreeMap, HashMap};
|
||||
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::model::*;
|
||||
|
||||
/// Keeps the connection open (Hyperliquid closes it after 60 s without traffic).
|
||||
pub const PING: &str = r#"{"method":"ping"}"#;
|
||||
/// Fills kept for `ACTV`.
|
||||
pub const MAX_FILLS: usize = 500;
|
||||
|
||||
/// The subscribe messages for `user` on `dexes` (`""` is the core dex), with leverage for
|
||||
/// `coins` (the ticket's market) and testnet mids per dex.
|
||||
pub fn subscriptions<'a>(user: &str, dexes: impl IntoIterator<Item = &'a str>, coins: &[&str]) -> Vec<String> {
|
||||
let user = user.to_ascii_lowercase();
|
||||
let mut subs = Vec::new();
|
||||
for dex in dexes {
|
||||
subs.push(json!({"type": "clearinghouseState", "user": user, "dex": dex}));
|
||||
subs.push(json!({"type": "openOrders", "user": user, "dex": dex}));
|
||||
subs.push(json!({"type": "allMids", "dex": dex}));
|
||||
}
|
||||
for t in ["orderUpdates", "userFills", "userFundings"] {
|
||||
subs.push(json!({"type": t, "user": user}));
|
||||
}
|
||||
for coin in coins {
|
||||
subs.push(json!({"type": "activeAssetData", "user": user, "coin": coin}));
|
||||
}
|
||||
subs.into_iter().map(|s| json!({"method": "subscribe", "subscription": s}).to_string()).collect()
|
||||
}
|
||||
|
||||
/// One subscription for leverage on another market (the ticket moved on).
|
||||
pub fn asset_data_subscription(user: &str, coin: &str, subscribe: bool) -> String {
|
||||
let method = if subscribe { "subscribe" } else { "unsubscribe" };
|
||||
json!({"method": method, "subscription": {"type": "activeAssetData", "user": user.to_ascii_lowercase(), "coin": coin}})
|
||||
.to_string()
|
||||
}
|
||||
|
||||
/// What a message changed, for toasts and repaints.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub enum Event {
|
||||
Account,
|
||||
Orders,
|
||||
Fills(Vec<Fill>),
|
||||
Fundings,
|
||||
AssetData(String),
|
||||
Mids,
|
||||
OrderUpdates(Vec<OrderUpdate>),
|
||||
}
|
||||
|
||||
/// Everything the panes and the ticket know about one account.
|
||||
#[derive(Debug, Clone, Default, PartialEq)]
|
||||
pub struct UserState {
|
||||
pub clearinghouse: BTreeMap<String, Clearinghouse>,
|
||||
pub orders: BTreeMap<String, Vec<OpenOrder>>,
|
||||
/// Newest first.
|
||||
pub fills: Vec<Fill>,
|
||||
pub fundings: Vec<Funding>,
|
||||
pub asset_data: HashMap<String, AssetData>,
|
||||
/// Testnet mids by coin (`BTC`, `xyz:AAPL`). Never mixed with mainnet quotes.
|
||||
pub mids: HashMap<String, f64>,
|
||||
}
|
||||
|
||||
impl UserState {
|
||||
/// Apply one websocket text frame for `user`. Returns what changed (nothing for acks,
|
||||
/// pongs and other users' data).
|
||||
pub fn apply(&mut self, user: &str, frame: &str) -> Vec<Event> {
|
||||
let Ok(m) = serde_json::from_str::<Value>(frame) else { return Vec::new() };
|
||||
let data = &m["data"];
|
||||
let mine = |d: &Value| d["user"].as_str().is_some_and(|u| u.eq_ignore_ascii_case(user));
|
||||
match m["channel"].as_str() {
|
||||
Some("clearinghouseState") if mine(data) => {
|
||||
let dex = data["dex"].as_str().unwrap_or_default().to_string();
|
||||
self.clearinghouse.insert(dex.clone(), parse_clearinghouse(&dex, &data["clearinghouseState"]));
|
||||
vec![Event::Account]
|
||||
}
|
||||
Some("openOrders") if mine(data) => {
|
||||
let dex = data["dex"].as_str().unwrap_or_default().to_string();
|
||||
self.orders.insert(dex, parse_open_orders(&data["orders"]));
|
||||
vec![Event::Orders]
|
||||
}
|
||||
Some("userFills") if mine(data) => {
|
||||
let fills = parse_fills(&data["fills"]);
|
||||
let snapshot = data["isSnapshot"].as_bool().unwrap_or(false);
|
||||
let new = self.add_fills(fills, snapshot);
|
||||
if snapshot || !new.is_empty() {
|
||||
vec![Event::Fills(if snapshot { Vec::new() } else { new })]
|
||||
} else {
|
||||
Vec::new()
|
||||
}
|
||||
}
|
||||
Some("userFundings") if mine(data) => {
|
||||
let f = parse_fundings(&data["fundings"]);
|
||||
if data["isSnapshot"].as_bool().unwrap_or(false) {
|
||||
self.fundings = f;
|
||||
} else {
|
||||
self.fundings.extend(f);
|
||||
}
|
||||
self.fundings.sort_by(|a, b| b.time.cmp(&a.time).then(a.coin.cmp(&b.coin)));
|
||||
self.fundings.dedup_by(|a, b| a.time == b.time && a.coin == b.coin);
|
||||
vec![Event::Fundings]
|
||||
}
|
||||
Some("activeAssetData") if mine(data) => match parse_asset_data(data) {
|
||||
Some(a) => {
|
||||
let coin = a.coin.clone();
|
||||
self.asset_data.insert(coin.clone(), a);
|
||||
vec![Event::AssetData(coin)]
|
||||
}
|
||||
None => Vec::new(),
|
||||
},
|
||||
Some("allMids") => {
|
||||
let mut any = false;
|
||||
for (k, v) in data["mids"].as_object().into_iter().flatten() {
|
||||
if let Some(px) = num(v).filter(|p| *p > 0.0) {
|
||||
self.mids.insert(k.clone(), px);
|
||||
any = true;
|
||||
}
|
||||
}
|
||||
if any { vec![Event::Mids] } else { Vec::new() }
|
||||
}
|
||||
Some("orderUpdates") => {
|
||||
// Order updates carry no user field; the subscription is for this user only.
|
||||
let u = parse_order_updates(data);
|
||||
if u.is_empty() { Vec::new() } else { vec![Event::OrderUpdates(u)] }
|
||||
}
|
||||
_ => Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Merge fills (a snapshot replaces), newest first, de-duplicated by trade id. Returns
|
||||
/// the ones that are new.
|
||||
fn add_fills(&mut self, fills: Vec<Fill>, snapshot: bool) -> Vec<Fill> {
|
||||
if snapshot {
|
||||
self.fills.clear();
|
||||
}
|
||||
let mut new = Vec::new();
|
||||
for f in fills {
|
||||
if !self.fills.iter().any(|x| x.tid == f.tid && x.coin == f.coin && x.time == f.time) {
|
||||
new.push(f.clone());
|
||||
self.fills.push(f);
|
||||
}
|
||||
}
|
||||
self.fills.sort_by(|a, b| b.time.cmp(&a.time).then(b.tid.cmp(&a.tid)));
|
||||
self.fills.truncate(MAX_FILLS);
|
||||
new
|
||||
}
|
||||
|
||||
/// Every open order on every dex, as `(coin, oid)`: what the kill switch cancels.
|
||||
pub fn open_order_ids(&self) -> Vec<(String, u64)> {
|
||||
self.orders.values().flatten().map(|o| (o.coin.clone(), o.oid)).collect()
|
||||
}
|
||||
|
||||
/// Account value over every dex loaded (standard accounts hold one balance per dex).
|
||||
pub fn account_value(&self) -> f64 {
|
||||
self.clearinghouse.values().map(|c| c.margin.account_value).sum()
|
||||
}
|
||||
|
||||
pub fn withdrawable(&self) -> f64 {
|
||||
self.clearinghouse.values().map(|c| c.withdrawable).sum()
|
||||
}
|
||||
|
||||
pub fn positions(&self) -> impl Iterator<Item = &PerpPosition> {
|
||||
self.clearinghouse.values().flat_map(|c| c.positions.iter())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
const USER: &str = "0x00000000000000000000000000000000000f107e";
|
||||
|
||||
fn recorded() -> Vec<String> {
|
||||
let path = format!("{}/tests/fixtures/testnet_ws_user_stream.jsonl", env!("CARGO_MANIFEST_DIR"));
|
||||
std::fs::read_to_string(path).unwrap().lines().map(str::to_string).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn subscriptions_cover_both_dexes() {
|
||||
let subs = subscriptions("0xABC", ["", "xyz"], &["xyz:AAPL"]);
|
||||
assert_eq!(subs.len(), 3 * 2 + 3 + 1);
|
||||
assert!(subs.iter().any(|s| s.contains(r#""type":"clearinghouseState""#) && s.contains(r#""dex":"xyz""#)));
|
||||
assert!(subs.iter().all(|s| !s.contains("0xABC")), "addresses go lower-case");
|
||||
assert!(asset_data_subscription("0xabc", "BTC", false).starts_with(r#"{"method":"unsubscribe""#));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_recorded_testnet_stream_builds_the_account() {
|
||||
let mut s = UserState::default();
|
||||
let mut events = Vec::new();
|
||||
for m in recorded() {
|
||||
events.extend(s.apply(USER, &m));
|
||||
}
|
||||
assert_eq!(s.clearinghouse.len(), 2, "core and xyz");
|
||||
assert_eq!(s.positions().count(), 3);
|
||||
assert_eq!(s.open_order_ids(), [("xyz:AAPL".to_string(), 63919996139)]);
|
||||
assert_eq!(s.fills.len(), 4);
|
||||
assert!(s.fills.windows(2).all(|w| w[0].time >= w[1].time), "newest first");
|
||||
assert_eq!(s.asset_data["xyz:AAPL"].leverage.map(|l| l.value), Some(20));
|
||||
assert!(s.mids.values().all(|p| *p > 0.0) && !s.mids.is_empty());
|
||||
assert!(s.account_value() > 0.0);
|
||||
assert!(events.contains(&Event::Account) && events.contains(&Event::Orders));
|
||||
// The same data for another user changes nothing.
|
||||
let mut other = UserState::default();
|
||||
for m in recorded().iter().filter(|m| !m.contains("allMids")) {
|
||||
other.apply("0x0000000000000000000000000000000000000001", m);
|
||||
}
|
||||
assert_eq!(other, UserState::default());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_reconnect_snapshot_never_doubles_fills() {
|
||||
let mut s = UserState::default();
|
||||
let fill =
|
||||
|tid: u64, time: u64| json!({"coin": "BTC", "px": "1", "sz": "1", "side": "B", "time": time, "tid": tid});
|
||||
let msg = |snap: bool, fills: Vec<Value>| {
|
||||
json!({"channel": "userFills", "data": {"isSnapshot": snap, "user": USER, "fills": fills}}).to_string()
|
||||
};
|
||||
s.apply(USER, &msg(true, vec![fill(1, 10), fill(2, 20)]));
|
||||
let ev = s.apply(USER, &msg(false, vec![fill(3, 30), fill(2, 20)]));
|
||||
assert_eq!(ev.len(), 1);
|
||||
let Event::Fills(new) = &ev[0] else { panic!("{ev:?}") };
|
||||
assert_eq!(new.iter().map(|f| f.tid).collect::<Vec<_>>(), [3]);
|
||||
s.apply(USER, &msg(true, vec![fill(1, 10), fill(2, 20), fill(3, 30)]));
|
||||
assert_eq!(s.fills.iter().map(|f| f.tid).collect::<Vec<_>>(), [3, 2, 1]);
|
||||
assert!(s.apply(USER, "not json").is_empty() && s.apply(USER, r#"{"channel":"pong"}"#).is_empty());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,707 @@
|
||||
//! Everything that signs an L1 action goes through here, and every check happens here
|
||||
//! first: the market must be tradable, sizes and prices are put on the grid, and the hard
|
||||
//! caps in [`crate::caps`] are enforced before a signature exists.
|
||||
//!
|
||||
//! An order is planned ([`plan_order`]: rounded and checked, for the confirmation), then
|
||||
//! signed ([`sign_order`]: checked again against the market at that moment). A plan can only
|
||||
//! come from [`plan_order`], so nothing unchecked reaches the signer. Every L1 action carries
|
||||
//! `expiresAfter` ≈ now + 30 s, so a signed action that is delayed can't land late.
|
||||
|
||||
use std::fmt;
|
||||
|
||||
use alloy_primitives::Address;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::actions::*;
|
||||
use crate::assets::{Asset, AssetRegistry};
|
||||
use crate::caps::{self, MAX_MARKET_SLIPPAGE, Refusal};
|
||||
use crate::key::{SigningKey, address_hex};
|
||||
use crate::network::{Network, SIGNATURE_CHAIN_ID};
|
||||
use crate::nonce::NonceManager;
|
||||
use crate::rounding::{Round, round_price, round_size, to_wire};
|
||||
use crate::signing::{WireSignature, sign_l1};
|
||||
|
||||
/// How long a signed L1 action stays valid.
|
||||
pub const EXPIRES_AFTER_MS: u64 = 30_000;
|
||||
/// Default agent lifetime: the most Hyperliquid allows, because the built-in wallet's
|
||||
/// approval is a paid Turnkey signature, so it should be rare.
|
||||
pub const DEFAULT_AGENT_DAYS: u64 = 180;
|
||||
pub const MAX_AGENT_DAYS: u64 = 180;
|
||||
const DAY_MS: u64 = 24 * 60 * 60 * 1000;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub enum OrderKind {
|
||||
Limit {
|
||||
px: f64,
|
||||
tif: Tif,
|
||||
},
|
||||
/// An IOC limit [`MAX_MARKET_SLIPPAGE`] through the mid.
|
||||
Market,
|
||||
/// Take profit or stop loss: a market order once `trigger_px` trades.
|
||||
Trigger {
|
||||
trigger_px: f64,
|
||||
tpsl: Tpsl,
|
||||
},
|
||||
}
|
||||
|
||||
/// What the user asked for, before rounding.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct OrderIntent {
|
||||
pub coin: String,
|
||||
pub is_buy: bool,
|
||||
pub size: f64,
|
||||
pub kind: OrderKind,
|
||||
pub reduce_only: bool,
|
||||
}
|
||||
|
||||
/// The account's leverage on one market (`activeAssetData`).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct Leverage {
|
||||
pub value: u32,
|
||||
pub cross: bool,
|
||||
}
|
||||
|
||||
/// The market as the signer sees it now: the testnet mid and the account's leverage.
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq)]
|
||||
pub struct MarketCtx {
|
||||
pub mid: Option<f64>,
|
||||
pub leverage: Option<Leverage>,
|
||||
}
|
||||
|
||||
/// An order rounded and checked. Only [`plan_order`] makes one.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct OrderPlan {
|
||||
asset: Asset,
|
||||
intent: OrderIntent,
|
||||
size: f64,
|
||||
price: f64,
|
||||
trigger: Option<f64>,
|
||||
notional: f64,
|
||||
wire: OrderWire,
|
||||
}
|
||||
|
||||
impl OrderPlan {
|
||||
pub fn asset(&self) -> &Asset {
|
||||
&self.asset
|
||||
}
|
||||
pub fn intent(&self) -> &OrderIntent {
|
||||
&self.intent
|
||||
}
|
||||
/// Size as sent (rounded down to the lot).
|
||||
pub fn size(&self) -> f64 {
|
||||
self.size
|
||||
}
|
||||
/// The limit price sent: the order's limit, or a market order's IOC cap.
|
||||
pub fn price(&self) -> f64 {
|
||||
self.price
|
||||
}
|
||||
pub fn trigger(&self) -> Option<f64> {
|
||||
self.trigger
|
||||
}
|
||||
/// Size × the higher of the price sent and the mid, in USD.
|
||||
pub fn notional(&self) -> f64 {
|
||||
self.notional
|
||||
}
|
||||
pub fn cloid(&self) -> &str {
|
||||
self.wire.c.as_deref().unwrap_or_default()
|
||||
}
|
||||
pub fn wire(&self) -> &OrderWire {
|
||||
&self.wire
|
||||
}
|
||||
/// `Limit GTC`, `Market (IOC, 1% cap)`, `Stop loss at 70000`.
|
||||
pub fn kind_label(&self) -> String {
|
||||
match self.intent.kind {
|
||||
OrderKind::Limit { tif, .. } => format!("Limit {}", tif.label()),
|
||||
OrderKind::Market => format!("Market (IOC, {:.0}% cap)", MAX_MARKET_SLIPPAGE * 100.0),
|
||||
OrderKind::Trigger { tpsl: Tpsl::Tp, .. } => "Take profit (market)".into(),
|
||||
OrderKind::Trigger { tpsl: Tpsl::Sl, .. } => "Stop loss (market)".into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A signed action, ready to post. `Debug` shows what it is, never the signature.
|
||||
#[derive(Clone)]
|
||||
pub struct Signed {
|
||||
pub kind: &'static str,
|
||||
action: Value,
|
||||
pub nonce: u64,
|
||||
signature: WireSignature,
|
||||
pub expires_after: Option<u64>,
|
||||
}
|
||||
|
||||
impl Signed {
|
||||
/// The `/exchange` request body.
|
||||
pub fn body(&self) -> Value {
|
||||
json!({
|
||||
"action": self.action,
|
||||
"nonce": self.nonce,
|
||||
"signature": self.signature,
|
||||
"vaultAddress": Value::Null,
|
||||
"expiresAfter": self.expires_after,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn signature(&self) -> &WireSignature {
|
||||
&self.signature
|
||||
}
|
||||
|
||||
/// The same action with a damaged signature (the live check's control).
|
||||
pub fn corrupted(&self) -> Signed {
|
||||
let mut s = self.clone();
|
||||
let last = s.signature.s.pop().unwrap_or('0');
|
||||
s.signature.s.push(if last == '1' { '2' } else { '1' });
|
||||
s
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Debug for Signed {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
f.debug_struct("Signed")
|
||||
.field("kind", &self.kind)
|
||||
.field("nonce", &self.nonce)
|
||||
.field("expires_after", &self.expires_after)
|
||||
.finish_non_exhaustive()
|
||||
}
|
||||
}
|
||||
|
||||
/// Round and check an order for the confirmation.
|
||||
pub fn plan_order(
|
||||
reg: &AssetRegistry,
|
||||
intent: &OrderIntent,
|
||||
ctx: &MarketCtx,
|
||||
cloid: &str,
|
||||
) -> Result<OrderPlan, Refusal> {
|
||||
let asset = reg.resolve(&intent.coin).map_err(Refusal::Invalid)?.clone();
|
||||
if !valid_cloid(cloid) {
|
||||
return Err(Refusal::Invalid("internal: malformed client order id".into()));
|
||||
}
|
||||
if !intent.size.is_finite() || intent.size <= 0.0 {
|
||||
return Err(Refusal::Invalid("Enter a size greater than zero.".into()));
|
||||
}
|
||||
let size = round_size(intent.size, asset.sz_decimals);
|
||||
if size <= 0.0 {
|
||||
return Err(Refusal::Invalid(format!(
|
||||
"{} {} rounds to zero ({} trades in steps of {}).",
|
||||
intent.size,
|
||||
asset.coin,
|
||||
asset.coin,
|
||||
10f64.powi(-(asset.sz_decimals as i32))
|
||||
)));
|
||||
}
|
||||
let mid = ctx.mid.filter(|m| m.is_finite() && *m > 0.0);
|
||||
// A buy's cap rounds down and a sell's floor up, so the slippage never exceeds the cap.
|
||||
let through = |px: f64| {
|
||||
let (raw, dir) = if intent.is_buy {
|
||||
(px * (1.0 + MAX_MARKET_SLIPPAGE), Round::Down)
|
||||
} else {
|
||||
(px * (1.0 - MAX_MARKET_SLIPPAGE), Round::Up)
|
||||
};
|
||||
round_price(raw, asset.sz_decimals, dir)
|
||||
};
|
||||
let positive = |px: f64, what: &str| {
|
||||
if px.is_finite() && px > 0.0 {
|
||||
Ok(px)
|
||||
} else {
|
||||
Err(Refusal::Invalid(format!("The {what} price must be greater than zero.")))
|
||||
}
|
||||
};
|
||||
let small = |what: &str| {
|
||||
Refusal::Invalid(format!(
|
||||
"The {what} price is too small for {} (it rounds to zero at {} decimals).",
|
||||
asset.coin,
|
||||
crate::rounding::price_decimals(0.0, asset.sz_decimals)
|
||||
))
|
||||
};
|
||||
let (price, trigger, t) = match intent.kind {
|
||||
OrderKind::Limit { px, tif } => {
|
||||
(round_price(positive(px, "limit")?, asset.sz_decimals, Round::Nearest), None, OrderTypeWire::limit(tif))
|
||||
}
|
||||
OrderKind::Market => {
|
||||
let m = mid.ok_or_else(|| Refusal::NoMid { coin: asset.coin.clone() })?;
|
||||
(through(m), None, OrderTypeWire::limit(Tif::Ioc))
|
||||
}
|
||||
OrderKind::Trigger { trigger_px, tpsl } => {
|
||||
let trig = round_price(positive(trigger_px, "trigger")?, asset.sz_decimals, Round::Nearest);
|
||||
if trig <= 0.0 {
|
||||
return Err(small("trigger"));
|
||||
}
|
||||
(through(trig), Some(trig), OrderTypeWire::trigger(true, wire(trig)?, tpsl))
|
||||
}
|
||||
};
|
||||
if price <= 0.0 {
|
||||
return Err(small("limit"));
|
||||
}
|
||||
let order = OrderWire {
|
||||
a: asset.id,
|
||||
b: intent.is_buy,
|
||||
p: wire(price)?,
|
||||
s: wire(size)?,
|
||||
r: intent.reduce_only,
|
||||
t,
|
||||
c: Some(cloid.to_string()),
|
||||
};
|
||||
let plan = OrderPlan { asset, intent: intent.clone(), size, price, trigger, notional: 0.0, wire: order };
|
||||
let notional = check(&plan, ctx)?;
|
||||
Ok(OrderPlan { notional, ..plan })
|
||||
}
|
||||
|
||||
fn wire(x: f64) -> Result<String, Refusal> {
|
||||
to_wire(x).map_err(|e| Refusal::Invalid(e.to_string()))
|
||||
}
|
||||
|
||||
/// The caps, against the market in `ctx`. Returns the notional. Every order needs a current
|
||||
/// mid: a limit through the market fills at the market, so its limit alone can't value it.
|
||||
fn check(plan: &OrderPlan, ctx: &MarketCtx) -> Result<f64, Refusal> {
|
||||
let mid = ctx
|
||||
.mid
|
||||
.filter(|m| m.is_finite() && *m > 0.0)
|
||||
.ok_or_else(|| Refusal::NoMid { coin: plan.asset.coin.clone() })?;
|
||||
// Reducing a position is never blocked by its leverage; anything else needs 3× or less.
|
||||
if !plan.intent.reduce_only {
|
||||
let lev = ctx.leverage.ok_or_else(|| Refusal::LeverageUnknown { coin: plan.asset.coin.clone() })?;
|
||||
caps::check_leverage(lev.value)?;
|
||||
}
|
||||
// Value at the highest of the order's price, its trigger and the mid.
|
||||
let reference = [Some(plan.price), plan.trigger, Some(mid)].into_iter().flatten().fold(0.0, f64::max);
|
||||
let notional = plan.size * reference;
|
||||
caps::check_notional(notional)?;
|
||||
if plan.intent.kind == OrderKind::Market {
|
||||
caps::check_slippage((plan.price / mid - 1.0).abs())?;
|
||||
}
|
||||
Ok(notional)
|
||||
}
|
||||
|
||||
fn signed<A: Serialize>(
|
||||
kind: &'static str,
|
||||
key: &impl SigningKey,
|
||||
nonces: &NonceManager,
|
||||
action: &A,
|
||||
now: u64,
|
||||
) -> Result<Signed, Refusal> {
|
||||
let nonce = nonces.next(now);
|
||||
let expires_after = Some(now + EXPIRES_AFTER_MS);
|
||||
let signature = sign_l1(key, action, nonce, expires_after).map_err(|e| Refusal::Invalid(format!("{e:#}")))?;
|
||||
let action = serde_json::to_value(action).map_err(|e| Refusal::Invalid(e.to_string()))?;
|
||||
Ok(Signed { kind, action, nonce, signature, expires_after })
|
||||
}
|
||||
|
||||
/// Sign a planned order, after checking it again against the market now.
|
||||
pub fn sign_order(
|
||||
key: &impl SigningKey,
|
||||
nonces: &NonceManager,
|
||||
plan: &OrderPlan,
|
||||
ctx: &MarketCtx,
|
||||
now: u64,
|
||||
) -> Result<Signed, Refusal> {
|
||||
check(plan, ctx)?;
|
||||
signed("order", key, nonces, &OrderAction::new(vec![plan.wire.clone()]), now)
|
||||
}
|
||||
|
||||
/// Sign a modify of order `oid` into `plan`, with the same checks as a new order.
|
||||
pub fn sign_modify(
|
||||
key: &impl SigningKey,
|
||||
nonces: &NonceManager,
|
||||
oid: u64,
|
||||
plan: &OrderPlan,
|
||||
ctx: &MarketCtx,
|
||||
now: u64,
|
||||
) -> Result<Signed, Refusal> {
|
||||
check(plan, ctx)?;
|
||||
signed("modify", key, nonces, &ModifyAction::new(oid, plan.wire.clone()), now)
|
||||
}
|
||||
|
||||
/// Cancel one order by id.
|
||||
pub fn sign_cancel(
|
||||
key: &impl SigningKey,
|
||||
nonces: &NonceManager,
|
||||
asset: &Asset,
|
||||
oid: u64,
|
||||
now: u64,
|
||||
) -> Result<Signed, Refusal> {
|
||||
signed("cancel", key, nonces, &CancelAction::new(vec![CancelWire { a: asset.id, o: oid }]), now)
|
||||
}
|
||||
|
||||
/// Cancel one order by client order id.
|
||||
pub fn sign_cancel_by_cloid(
|
||||
key: &impl SigningKey,
|
||||
nonces: &NonceManager,
|
||||
asset: &Asset,
|
||||
cloid: &str,
|
||||
now: u64,
|
||||
) -> Result<Signed, Refusal> {
|
||||
if !valid_cloid(cloid) {
|
||||
return Err(Refusal::Invalid("malformed client order id".into()));
|
||||
}
|
||||
let action = CancelByCloidAction::new(vec![CancelByCloidWire { asset: asset.id, cloid: cloid.into() }]);
|
||||
signed("cancelByCloid", key, nonces, &action, now)
|
||||
}
|
||||
|
||||
/// The kill switch: cancel every order in `orders` (`(coin, oid)`), one action per dex.
|
||||
/// A coin the registry doesn't know (listed after it loaded) is skipped and returned, so one
|
||||
/// stale entry never stops the other cancels.
|
||||
pub fn sign_cancel_all(
|
||||
key: &impl SigningKey,
|
||||
nonces: &NonceManager,
|
||||
reg: &AssetRegistry,
|
||||
orders: &[(String, u64)],
|
||||
now: u64,
|
||||
) -> Result<(Vec<Signed>, Vec<String>), Refusal> {
|
||||
// Delisted markets stay in the registry, so their orders still cancel.
|
||||
let skipped: Vec<String> = orders.iter().filter(|(c, _)| reg.get(c).is_none()).map(|(c, _)| c.clone()).collect();
|
||||
let mut out = Vec::new();
|
||||
for dex in reg.dexes() {
|
||||
let cancels: Vec<CancelWire> = orders
|
||||
.iter()
|
||||
.filter_map(|(coin, oid)| reg.get(coin).filter(|a| a.dex == dex).map(|a| CancelWire { a: a.id, o: *oid }))
|
||||
.collect();
|
||||
if !cancels.is_empty() {
|
||||
out.push(signed("cancel", key, nonces, &CancelAction::new(cancels), now)?);
|
||||
}
|
||||
}
|
||||
Ok((out, skipped))
|
||||
}
|
||||
|
||||
/// Set leverage on one market: 1× to 3× (and no more than the market allows).
|
||||
pub fn sign_update_leverage(
|
||||
key: &impl SigningKey,
|
||||
nonces: &NonceManager,
|
||||
asset: &Asset,
|
||||
cross: bool,
|
||||
leverage: u32,
|
||||
now: u64,
|
||||
) -> Result<Signed, Refusal> {
|
||||
caps::check_leverage(leverage)?;
|
||||
if leverage == 0 || leverage > asset.max_leverage {
|
||||
return Err(Refusal::Invalid(format!("{} allows 1× to {}×.", asset.coin, asset.max_leverage)));
|
||||
}
|
||||
if cross && asset.only_isolated {
|
||||
return Err(Refusal::Invalid(format!("{} is isolated margin only.", asset.coin)));
|
||||
}
|
||||
signed("updateLeverage", key, nonces, &UpdateLeverageAction::new(asset.id, cross, leverage), now)
|
||||
}
|
||||
|
||||
/// The dead man's switch: cancel all open orders at `at` (at least 5 s ahead), or clear it.
|
||||
pub fn sign_schedule_cancel(
|
||||
key: &impl SigningKey,
|
||||
nonces: &NonceManager,
|
||||
at: Option<u64>,
|
||||
now: u64,
|
||||
) -> Result<Signed, Refusal> {
|
||||
if at.is_some_and(|t| t < now + 5_000) {
|
||||
return Err(Refusal::Invalid("A scheduled cancel must be at least 5 seconds ahead.".into()));
|
||||
}
|
||||
signed("scheduleCancel", key, nonces, &ScheduleCancelAction::new(at), now)
|
||||
}
|
||||
|
||||
/// Use up a nonce.
|
||||
pub fn sign_noop(key: &impl SigningKey, nonces: &NonceManager, now: u64) -> Result<Signed, Refusal> {
|
||||
signed("noop", key, nonces, &NoopAction::default(), now)
|
||||
}
|
||||
|
||||
/// `terminal-<device> valid_until <ms>`, the device reduced to 14 lower-case letters,
|
||||
/// digits and dashes.
|
||||
pub fn agent_name(device: &str, valid_until: u64) -> String {
|
||||
let mut d: String = device
|
||||
.chars()
|
||||
.filter(|c| c.is_ascii_alphanumeric() || *c == '-')
|
||||
.map(|c| c.to_ascii_lowercase())
|
||||
.take(14)
|
||||
.collect();
|
||||
if d.is_empty() {
|
||||
d = "device".into();
|
||||
}
|
||||
format!("terminal-{d} valid_until {valid_until}")
|
||||
}
|
||||
|
||||
/// The name an agent was approved under, without its `valid_until` suffix.
|
||||
pub fn agent_base_name(name: &str) -> &str {
|
||||
name.split(" valid_until ").next().unwrap_or(name).trim()
|
||||
}
|
||||
|
||||
/// The expiry in an agent name, if it has one.
|
||||
pub fn agent_valid_until(name: &str) -> Option<u64> {
|
||||
name.split(" valid_until ").nth(1)?.trim().parse().ok()
|
||||
}
|
||||
|
||||
/// An `approveAgent` for `agent`, named for this device, valid `days` (1 to 180).
|
||||
pub fn approve_agent(agent: &Address, device: &str, days: u64, now: u64) -> ApproveAgentAction {
|
||||
let until = now + days.clamp(1, MAX_AGENT_DAYS) * DAY_MS;
|
||||
approve(agent, agent_name(device, until), now)
|
||||
}
|
||||
|
||||
fn approve(agent: &Address, name: String, now: u64) -> ApproveAgentAction {
|
||||
ApproveAgentAction {
|
||||
kind: "approveAgent".into(),
|
||||
hyperliquid_chain: Network::Testnet.hyperliquid_chain().into(),
|
||||
signature_chain_id: format!("{SIGNATURE_CHAIN_ID:#x}"),
|
||||
agent_address: address_hex(agent),
|
||||
agent_name: name,
|
||||
nonce: now,
|
||||
}
|
||||
}
|
||||
|
||||
/// Revoke: approve a fresh address under the same name, then drop its key, so the old agent
|
||||
/// is replaced and nobody holds a key for the new one.
|
||||
pub fn revoke_agent(base_name: &str, now: u64) -> ApproveAgentAction {
|
||||
// The key is dropped (and zeroized) at the end of this statement.
|
||||
let throwaway = crate::key::AgentKey::generate().address();
|
||||
approve(&throwaway, format!("{} valid_until {}", agent_base_name(base_name), now + DAY_MS), now)
|
||||
}
|
||||
|
||||
/// A user-signed action's `/exchange` body.
|
||||
pub fn user_signed_body(action: &ApproveAgentAction, signature: &WireSignature) -> Value {
|
||||
json!({
|
||||
"action": action,
|
||||
"nonce": action.nonce,
|
||||
"signature": signature,
|
||||
"vaultAddress": Value::Null,
|
||||
"expiresAfter": Value::Null,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::assets::fixtures::registry;
|
||||
use crate::caps::{MAX_LEVERAGE, MAX_ORDER_NOTIONAL_USD};
|
||||
use crate::key::AgentKey;
|
||||
use crate::signing::recover_l1;
|
||||
|
||||
const NOW: u64 = 1_793_667_000_000;
|
||||
const CLOID: &str = "0x0000000000000000000000000000002a";
|
||||
|
||||
fn lev(value: u32) -> Option<Leverage> {
|
||||
Some(Leverage { value, cross: true })
|
||||
}
|
||||
|
||||
fn ctx(mid: f64) -> MarketCtx {
|
||||
MarketCtx { mid: Some(mid), leverage: lev(3) }
|
||||
}
|
||||
|
||||
fn limit(coin: &str, buy: bool, size: f64, px: f64) -> OrderIntent {
|
||||
OrderIntent {
|
||||
coin: coin.into(),
|
||||
is_buy: buy,
|
||||
size,
|
||||
kind: OrderKind::Limit { px, tif: Tif::Gtc },
|
||||
reduce_only: false,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_far_limit_is_rounded_and_signed() {
|
||||
let reg = registry();
|
||||
let k = AgentKey::generate();
|
||||
let n = NonceManager::new();
|
||||
// BTC (szDecimals 5) far below a 76,662 mid: 0.000256 BTC → 0.00025 at 50,000 = $12.50.
|
||||
let plan = plan_order(®, &limit("btc", true, 0.000256, 50_000.0), &ctx(76_662.5), CLOID).unwrap();
|
||||
assert_eq!((plan.size(), plan.price(), plan.asset().id), (0.00025, 50_000.0, 3));
|
||||
assert!((plan.notional() - 76_662.5 * 0.00025).abs() < 1e-9, "valued at the higher of price and mid");
|
||||
assert_eq!(plan.wire().p, "50000");
|
||||
assert_eq!(plan.wire().s, "0.00025");
|
||||
assert_eq!(plan.cloid(), CLOID);
|
||||
let s = sign_order(&k, &n, &plan, &ctx(76_662.5), NOW).unwrap();
|
||||
assert_eq!(s.kind, "order");
|
||||
assert_eq!(s.expires_after, Some(NOW + EXPIRES_AFTER_MS));
|
||||
let body = s.body();
|
||||
assert_eq!(body["action"]["orders"][0]["a"], 3);
|
||||
assert!(body["action"].get("builder").is_none(), "never a builder fee");
|
||||
let action = OrderAction::new(vec![plan.wire().clone()]);
|
||||
assert_eq!(recover_l1(&action, s.nonce, s.expires_after, s.signature()).unwrap(), k.address());
|
||||
// xyz:AAPL (750009, szDecimals 3).
|
||||
let aapl = plan_order(®, &limit("xyz:AAPL", true, 0.0512, 250.123), &ctx(322.15), CLOID).unwrap();
|
||||
assert_eq!((aapl.asset().id, aapl.size(), aapl.price()), (750009, 0.051, 250.12));
|
||||
assert!(format!("{s:?}").contains("order") && !format!("{s:?}").contains(&s.signature().r));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn orders_over_100_dollars_are_refused_before_signing() {
|
||||
let reg = registry();
|
||||
// 0.002 BTC at 76,662 is $153: refused even with a limit far below the market.
|
||||
let e = plan_order(®, &limit("BTC", true, 0.002, 50_000.0), &ctx(76_662.5), CLOID).unwrap_err();
|
||||
assert!(matches!(e, Refusal::Notional { .. }), "{e}");
|
||||
assert!(e.to_string().contains("$100"));
|
||||
// A sell limit below the market fills at the market, so the mid counts.
|
||||
let e = plan_order(®, &limit("xyz:AAPL", false, 0.32, 300.0), &ctx(322.15), CLOID).unwrap_err();
|
||||
assert!(matches!(e, Refusal::Notional { .. }), "{e}");
|
||||
// Exactly at the cap is fine.
|
||||
let ok = plan_order(®, &limit("xyz:AAPL", true, 0.4, 250.0), &ctx(250.0), CLOID).unwrap();
|
||||
assert!((ok.notional() - MAX_ORDER_NOTIONAL_USD).abs() < 1e-9);
|
||||
// Checked again at signing, against the market then.
|
||||
let plan = plan_order(®, &limit("xyz:AAPL", true, 0.3, 300.0), &ctx(320.0), CLOID).unwrap();
|
||||
let moved = MarketCtx { mid: Some(400.0), leverage: lev(3) };
|
||||
let e = sign_order(&AgentKey::generate(), &NonceManager::new(), &plan, &moved, NOW).unwrap_err();
|
||||
assert!(matches!(e, Refusal::Notional { .. }), "{e}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn leverage_over_3x_is_refused() {
|
||||
let reg = registry();
|
||||
let at = |l| MarketCtx { mid: Some(76_662.5), leverage: lev(l) };
|
||||
let o = limit("BTC", true, 0.0002, 50_000.0);
|
||||
let e = plan_order(®, &o, &at(20), CLOID).unwrap_err();
|
||||
assert_eq!(e, Refusal::Leverage { value: 20 }, "Hyperliquid's default 20× is over the cap");
|
||||
let unknown = MarketCtx { mid: Some(76_662.5), leverage: None };
|
||||
assert!(matches!(plan_order(®, &o, &unknown, CLOID).unwrap_err(), Refusal::LeverageUnknown { .. }));
|
||||
assert!(plan_order(®, &o, &at(MAX_LEVERAGE), CLOID).is_ok());
|
||||
// Reducing a position is never blocked by its leverage.
|
||||
let close = OrderIntent { reduce_only: true, is_buy: false, ..o.clone() };
|
||||
assert!(plan_order(®, &close, &at(20), CLOID).is_ok());
|
||||
// And Terminal never sets more than 3×.
|
||||
let (k, n) = (AgentKey::generate(), NonceManager::new());
|
||||
let btc = reg.resolve("BTC").unwrap();
|
||||
assert_eq!(sign_update_leverage(&k, &n, btc, true, 4, NOW).unwrap_err(), Refusal::Leverage { value: 4 });
|
||||
assert!(sign_update_leverage(&k, &n, btc, true, 0, NOW).is_err());
|
||||
let s = sign_update_leverage(&k, &n, btc, true, 3, NOW).unwrap();
|
||||
assert_eq!(s.body()["action"], json!({"type": "updateLeverage", "asset": 3, "isCross": true, "leverage": 3}));
|
||||
let isolated_only = reg.resolve("xyz:COIN").unwrap();
|
||||
assert!(sign_update_leverage(&k, &n, isolated_only, true, 2, NOW).is_err(), "cross on an isolated-only market");
|
||||
assert!(sign_update_leverage(&k, &n, isolated_only, false, 2, NOW).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn market_orders_are_ioc_within_1_percent() {
|
||||
let reg = registry();
|
||||
let m = OrderIntent { kind: OrderKind::Market, ..limit("xyz:AAPL", true, 0.1, 0.0) };
|
||||
let p = plan_order(®, &m, &ctx(322.15), CLOID).unwrap();
|
||||
assert_eq!(p.wire().t, OrderTypeWire::limit(Tif::Ioc));
|
||||
assert!(p.price() <= 322.15 * 1.01 && p.price() > 322.15 * 1.009, "{}", p.price());
|
||||
let sell = plan_order(®, &OrderIntent { is_buy: false, ..m.clone() }, &ctx(322.15), CLOID).unwrap();
|
||||
assert!(sell.price() >= 322.15 * 0.99 && sell.price() < 322.15 * 0.991, "{}", sell.price());
|
||||
let no_mid = MarketCtx { mid: None, leverage: lev(3) };
|
||||
assert!(matches!(plan_order(®, &m, &no_mid, CLOID).unwrap_err(), Refusal::NoMid { .. }));
|
||||
// The mid fell more than 1% below the confirmed cap: review again.
|
||||
let fell = MarketCtx { mid: Some(310.0), leverage: lev(3) };
|
||||
let e = sign_order(&AgentKey::generate(), &NonceManager::new(), &p, &fell, NOW).unwrap_err();
|
||||
assert!(matches!(e, Refusal::Slippage { .. }), "{e}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn triggers_are_market_stops_with_a_slippage_cap() {
|
||||
let reg = registry();
|
||||
let sl = OrderIntent {
|
||||
kind: OrderKind::Trigger { trigger_px: 300.0, tpsl: Tpsl::Sl },
|
||||
reduce_only: true,
|
||||
..limit("xyz:AAPL", false, 0.1, 0.0)
|
||||
};
|
||||
let p = plan_order(®, &sl, &ctx(322.15), CLOID).unwrap();
|
||||
assert_eq!(p.wire().t, OrderTypeWire::trigger(true, "300".into(), Tpsl::Sl));
|
||||
assert_eq!(p.trigger(), Some(300.0));
|
||||
assert_eq!(p.price(), 297.0, "the fill limit is 1% through the trigger");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bad_orders_are_refused_with_reasons() {
|
||||
let reg = registry();
|
||||
let c = ctx(76_662.5);
|
||||
for (o, says) in [
|
||||
(limit("para:TOTAL2", true, 1.0, 1.0), "para"),
|
||||
(limit("HYPE/USDC", true, 1.0, 1.0), "spot"),
|
||||
(limit("BTC", true, 0.000001, 50_000.0), "rounds to zero"),
|
||||
(limit("BTC", true, 0.0002, -1.0), "price"),
|
||||
(limit("BTC", true, f64::NAN, 50_000.0), "size"),
|
||||
] {
|
||||
let e = plan_order(®, &o, &c, CLOID).unwrap_err().to_string();
|
||||
assert!(e.contains(says), "{}: {e}", o.coin);
|
||||
}
|
||||
assert!(plan_order(®, &limit("BTC", true, 0.0002, 50_000.0), &c, "42").is_err(), "cloid format");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cancels_schedule_cancel_and_noop() {
|
||||
let reg = registry();
|
||||
let (k, n) = (AgentKey::generate(), NonceManager::new());
|
||||
let aapl = reg.resolve("xyz:AAPL").unwrap();
|
||||
let c = sign_cancel(&k, &n, aapl, 1, NOW).unwrap();
|
||||
assert_eq!(c.body()["action"], json!({"type": "cancel", "cancels": [{"a": 750009, "o": 1}]}));
|
||||
let c2 = sign_cancel_by_cloid(&k, &n, aapl, CLOID, NOW).unwrap();
|
||||
assert_eq!(c2.body()["action"]["cancels"][0]["cloid"], CLOID);
|
||||
assert!(c2.nonce > c.nonce, "nonces increase");
|
||||
let (all, skipped) = sign_cancel_all(
|
||||
&k,
|
||||
&n,
|
||||
®,
|
||||
&[
|
||||
("BTC".into(), 1),
|
||||
("xyz:AAPL".into(), 2),
|
||||
("ETH".into(), 3),
|
||||
("xyz:NEWLY".into(), 9),
|
||||
("xyz:TSLA".into(), 4),
|
||||
],
|
||||
NOW,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(all.len(), 2, "one cancel per dex");
|
||||
assert_eq!(all[0].body()["action"]["cancels"].as_array().unwrap().len(), 2);
|
||||
assert_eq!(
|
||||
all[1].body()["action"]["cancels"].as_array().unwrap().len(),
|
||||
2,
|
||||
"the unknown coin doesn't stop the rest"
|
||||
);
|
||||
assert_eq!(skipped, ["xyz:NEWLY"]);
|
||||
assert!(sign_cancel_all(&k, &n, ®, &[], NOW).unwrap().0.is_empty());
|
||||
assert!(sign_schedule_cancel(&k, &n, Some(NOW + 1_000), NOW).is_err(), "at least 5 s ahead");
|
||||
let sc = sign_schedule_cancel(&k, &n, Some(NOW + 60_000), NOW).unwrap();
|
||||
assert_eq!(sc.body()["action"], json!({"type": "scheduleCancel", "time": NOW + 60_000}));
|
||||
assert_eq!(
|
||||
sign_schedule_cancel(&k, &n, None, NOW).unwrap().body()["action"],
|
||||
json!({"type": "scheduleCancel"})
|
||||
);
|
||||
let noop = sign_noop(&k, &n, NOW).unwrap();
|
||||
assert_eq!(noop.body()["action"], json!({"type": "noop"}));
|
||||
assert_eq!(noop.body()["expiresAfter"], NOW + EXPIRES_AFTER_MS);
|
||||
assert_eq!(noop.body()["vaultAddress"], Value::Null);
|
||||
}
|
||||
|
||||
/// Greptile #37: without a mid, a sell limit below the market was valued at its limit.
|
||||
#[test]
|
||||
fn orders_need_a_current_mid_to_be_valued() {
|
||||
let reg = registry();
|
||||
let no_mid = MarketCtx { mid: None, leverage: lev(3) };
|
||||
let sell = limit("xyz:AAPL", false, 0.32, 300.0);
|
||||
assert!(matches!(plan_order(®, &sell, &no_mid, CLOID).unwrap_err(), Refusal::NoMid { .. }));
|
||||
let plan = plan_order(®, &limit("xyz:AAPL", true, 0.1, 250.0), &ctx(322.15), CLOID).unwrap();
|
||||
let e = sign_order(&AgentKey::generate(), &NonceManager::new(), &plan, &no_mid, NOW).unwrap_err();
|
||||
assert!(matches!(e, Refusal::NoMid { .. }), "checked again at signing: {e}");
|
||||
}
|
||||
|
||||
/// Greptile #37: a price that rounds to zero was signed as "p":"0" (and panicked in debug).
|
||||
#[test]
|
||||
fn prices_that_round_to_zero_are_refused() {
|
||||
let reg = registry();
|
||||
let e = plan_order(®, &limit("BTC", true, 0.001, 0.00000001), &ctx(76_662.5), CLOID).unwrap_err();
|
||||
assert!(e.to_string().contains("too small"), "{e}");
|
||||
let stop = OrderIntent {
|
||||
kind: OrderKind::Trigger { trigger_px: 0.00000001, tpsl: Tpsl::Sl },
|
||||
reduce_only: true,
|
||||
..limit("BTC", false, 0.001, 0.0)
|
||||
};
|
||||
assert!(plan_order(®, &stop, &ctx(76_662.5), CLOID).unwrap_err().to_string().contains("too small"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn agents_are_named_and_expire() {
|
||||
let agent: Address = "0x5e9ee1089755c3435139848e47e6635505d5a13a".parse().unwrap();
|
||||
let a = approve_agent(&agent, "Micky's MacBook Pro", DEFAULT_AGENT_DAYS, NOW);
|
||||
let until = NOW + DEFAULT_AGENT_DAYS * DAY_MS;
|
||||
assert_eq!(a.agent_name, format!("terminal-mickysmacbookp valid_until {until}"));
|
||||
assert_eq!((a.kind.as_str(), a.hyperliquid_chain.as_str(), a.nonce), ("approveAgent", "Testnet", NOW));
|
||||
assert_eq!(a.signature_chain_id, "0x66eee");
|
||||
assert_eq!(a.agent_address, "0x5e9ee1089755c3435139848e47e6635505d5a13a");
|
||||
assert_eq!(agent_base_name(&a.agent_name), "terminal-mickysmacbookp");
|
||||
assert_eq!(agent_valid_until(&a.agent_name), Some(until));
|
||||
let capped = approve_agent(&agent, "", 999, NOW);
|
||||
assert_eq!(agent_valid_until(&capped.agent_name), Some(NOW + MAX_AGENT_DAYS * DAY_MS));
|
||||
assert!(capped.agent_name.starts_with("terminal-device "), "{}", capped.agent_name);
|
||||
assert_eq!(agent_valid_until(&approve_agent(&agent, "x", 0, NOW).agent_name), Some(NOW + DAY_MS));
|
||||
// Revoking reuses the name with an address nobody holds a key for.
|
||||
let r = revoke_agent("terminal-mickysmacbookp", NOW + 5);
|
||||
assert_eq!(agent_base_name(&r.agent_name), "terminal-mickysmacbookp");
|
||||
assert_ne!(r.agent_address, a.agent_address);
|
||||
assert!(crate::key::parse_address(&r.agent_address).is_ok());
|
||||
let body = user_signed_body(&a, &WireSignature { r: "0x1".into(), s: "0x2".into(), v: 27 });
|
||||
assert_eq!(body["nonce"], NOW);
|
||||
assert_eq!(body["action"]["agentName"], a.agent_name);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,287 @@
|
||||
//! Signing pinned to the official Python SDK's published test vectors
|
||||
//! (`hyperliquid-python-sdk` `tests/signing_test.py`), plus an `approveAgent` vector generated
|
||||
//! with SDK 0.24.0's `sign_agent` (the SDK publishes none):
|
||||
//!
|
||||
//! ```text
|
||||
//! python3 -m venv /tmp/hl-sdk-venv && /tmp/hl-sdk-venv/bin/pip install hyperliquid-python-sdk
|
||||
//! /tmp/hl-sdk-venv/bin/python -I /tmp/hl-approve-vector.py # sign_agent(wallet, action, False)
|
||||
//! ```
|
||||
//!
|
||||
//! The library signs only for testnet, so it is checked against the testnet half of each
|
||||
//! vector. The mainnet half is checked here too, by [`mainnet_signature`], a test-only copy
|
||||
//! of the phantom agent with `source: "a"`. It exists only in this test module, to show the
|
||||
//! action hashing (which both networks share) is byte-exact. No library code can build it.
|
||||
|
||||
use alloy_primitives::{Address, B256};
|
||||
use alloy_sol_types::{SolStruct, eip712_domain};
|
||||
use serde::Serialize;
|
||||
|
||||
use crate::actions::*;
|
||||
use crate::key::{AgentKey, SigningKey, sign_hash};
|
||||
use crate::signing::*;
|
||||
|
||||
/// The SDK's public test key. Never funded; never used on a network.
|
||||
const SDK_KEY: &str = "0x0123456789012345678901234567890123456789012345678901234567890123";
|
||||
|
||||
fn key() -> AgentKey {
|
||||
AgentKey::from_hex(SDK_KEY).unwrap()
|
||||
}
|
||||
|
||||
/// TEST ONLY: the mainnet phantom agent (`source: "a"`), to check the SDK's mainnet vectors.
|
||||
fn mainnet_signature(k: &AgentKey, connection_id: B256) -> WireSignature {
|
||||
let domain = eip712_domain! { name: "Exchange", version: "1", chain_id: 1337, verifying_contract: Address::ZERO, };
|
||||
let agent = Agent { source: "a".into(), connectionId: connection_id };
|
||||
let sig = sign_hash(k, &agent.eip712_signing_hash(&domain)).unwrap();
|
||||
WireSignature { r: format!("{:#x}", sig.r()), s: format!("{:#x}", sig.s()), v: 27 + u8::from(sig.v()) }
|
||||
}
|
||||
|
||||
fn sig(r: &str, s: &str, v: u8) -> WireSignature {
|
||||
WireSignature { r: r.into(), s: s.into(), v }
|
||||
}
|
||||
|
||||
/// Check one action against both halves of an SDK vector.
|
||||
fn check<A: Serialize>(action: &A, nonce: u64, mainnet: WireSignature, testnet: WireSignature) {
|
||||
let k = key();
|
||||
assert_eq!(sign_l1(&k, action, nonce, None).unwrap(), testnet, "testnet signature (the library's)");
|
||||
let id = action_hash(action, nonce, None).unwrap();
|
||||
assert_eq!(mainnet_signature(&k, id), mainnet, "mainnet signature (test-only phantom agent)");
|
||||
assert_eq!(recover_l1(action, nonce, None, &testnet).unwrap(), k.address(), "recovers the signer");
|
||||
}
|
||||
|
||||
fn order(a: u32, sz: &str, px: &str, t: OrderTypeWire, c: Option<String>) -> OrderAction {
|
||||
OrderAction::new(vec![OrderWire { a, b: true, p: px.into(), s: sz.into(), r: false, t, c }])
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct Dummy {
|
||||
#[serde(rename = "type")]
|
||||
kind: &'static str,
|
||||
num: u64,
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_phantom_agent_creation_matches_production() {
|
||||
let o = order(4, "0.0147", "1670.1", OrderTypeWire::limit(Tif::Ioc), None);
|
||||
let h = action_hash(&o, 1677777606040, None).unwrap();
|
||||
assert_eq!(format!("{h:#x}"), "0x0fcbeda5ae3c4950a548021552a4fea2226858c4453571bf3f24ba017eac2908");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_l1_action_signing_matches() {
|
||||
// float_to_int_for_hashing(1000) = 1000 × 10^8
|
||||
let action = Dummy { kind: "dummy", num: 100_000_000_000 };
|
||||
check(
|
||||
&action,
|
||||
0,
|
||||
sig(
|
||||
"0x53749d5b30552aeb2fca34b530185976545bb22d0b3ce6f62e31be961a59298",
|
||||
"0x755c40ba9bf05223521753995abb2f73ab3229be8ec921f350cb447e384d8ed8",
|
||||
27,
|
||||
),
|
||||
sig(
|
||||
"0x542af61ef1f429707e3c76c5293c80d01f74ef853e34b76efffcb57e574f9510",
|
||||
"0x17b8b32f086e8cdede991f1e2c529f5dd5297cbe8128500e00cbaf766204a613",
|
||||
28,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_l1_action_signing_order_matches() {
|
||||
check(
|
||||
&order(1, "100", "100", OrderTypeWire::limit(Tif::Gtc), None),
|
||||
0,
|
||||
sig(
|
||||
"0xd65369825a9df5d80099e513cce430311d7d26ddf477f5b3a33d2806b100d78e",
|
||||
"0x2b54116ff64054968aa237c20ca9ff68000f977c93289157748a3162b6ea940e",
|
||||
28,
|
||||
),
|
||||
sig(
|
||||
"0x82b2ba28e76b3d761093aaded1b1cdad4960b3af30212b343fb2e6cdfa4e3d54",
|
||||
"0x6b53878fc99d26047f4d7e8c90eb98955a109f44209163f52d8dc4278cbbd9f5",
|
||||
27,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_l1_action_signing_order_with_cloid_matches() {
|
||||
check(
|
||||
&order(1, "100", "100", OrderTypeWire::limit(Tif::Gtc), Some("0x00000000000000000000000000000001".into())),
|
||||
0,
|
||||
sig(
|
||||
"0x41ae18e8239a56cacbc5dad94d45d0b747e5da11ad564077fcac71277a946e3",
|
||||
"0x3c61f667e747404fe7eea8f90ab0e76cc12ce60270438b2058324681a00116da",
|
||||
27,
|
||||
),
|
||||
sig(
|
||||
"0xeba0664bed2676fc4e5a743bf89e5c7501aa6d870bdb9446e122c9466c5cd16d",
|
||||
"0x7f3e74825c9114bc59086f1eebea2928c190fdfbfde144827cb02b85bbe90988",
|
||||
28,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_l1_action_signing_tpsl_order_matches() {
|
||||
check(
|
||||
&order(1, "100", "100", OrderTypeWire::trigger(true, "103".into(), Tpsl::Sl), None),
|
||||
0,
|
||||
sig(
|
||||
"0x98343f2b5ae8e26bb2587daad3863bc70d8792b09af1841b6fdd530a2065a3f9",
|
||||
"0x6b5bb6bb0633b710aa22b721dd9dee6d083646a5f8e581a20b545be6c1feb405",
|
||||
27,
|
||||
),
|
||||
sig(
|
||||
"0x971c554d917c44e0e1b6cc45d8f9404f32172a9d3b3566262347d0302896a2e4",
|
||||
"0x206257b104788f80450f8e786c329daa589aa0b32ba96948201ae556d5637eac",
|
||||
28,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_schedule_cancel_action() {
|
||||
check(
|
||||
&ScheduleCancelAction::new(None),
|
||||
0,
|
||||
sig(
|
||||
"0x6cdfb286702f5917e76cd9b3b8bf678fcc49aec194c02a73e6d4f16891195df9",
|
||||
"0x6557ac307fa05d25b8d61f21fb8a938e703b3d9bf575f6717ba21ec61261b2a0",
|
||||
27,
|
||||
),
|
||||
sig(
|
||||
"0xc75bb195c3f6a4e06b7d395acc20bbb224f6d23ccff7c6a26d327304e6efaeed",
|
||||
"0x342f8ede109a29f2c0723bd5efb9e9100e3bbb493f8fb5164ee3d385908233df",
|
||||
28,
|
||||
),
|
||||
);
|
||||
check(
|
||||
&ScheduleCancelAction::new(Some(123456789)),
|
||||
0,
|
||||
sig(
|
||||
"0x609cb20c737945d070716dcc696ba030e9976fcf5edad87afa7d877493109d55",
|
||||
"0x16c685d63b5c7a04512d73f183b3d7a00da5406ff1f8aad33f8ae2163bab758b",
|
||||
28,
|
||||
),
|
||||
sig(
|
||||
"0x4e4f2dbd4107c69783e251b7e1057d9f2b9d11cee213441ccfa2be63516dc5bc",
|
||||
"0x706c656b23428c8ba356d68db207e11139ede1670481a9e01ae2dfcdb0e1a678",
|
||||
27,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// The SDK's testnet `test_sign_usd_transfer_action` checks the hand-built EIP-712 encoding
|
||||
/// that `approveAgent` uses (this crate has no transfer action; the vector only exercises
|
||||
/// the encoder).
|
||||
#[test]
|
||||
fn user_signed_encoding_matches_test_sign_usd_transfer_action() {
|
||||
let dest: Address = "0x5e9ee1089755c3435139848e47e6635505d5a13a".parse().unwrap();
|
||||
let fields = [Field::Str("Testnet"), Field::Str(&format!("{dest:#x}")), Field::Str("1"), Field::U64(1687816341423)];
|
||||
let hash = user_signed_hash(
|
||||
"HyperliquidTransaction:UsdSend(string hyperliquidChain,string destination,string amount,uint64 time)",
|
||||
&fields,
|
||||
0x66eee,
|
||||
);
|
||||
let s = sign_hash(&key(), &hash).unwrap();
|
||||
assert_eq!(format!("{:#x}", s.r()), "0x637b37dd731507cdd24f46532ca8ba6eec616952c56218baeff04144e4a77073");
|
||||
assert_eq!(format!("{:#x}", s.s()), "0x11a6a24900e6e314136d2592e2f8d502cd89b7c15b198e1bee043c9589f9fad7");
|
||||
assert_eq!(27 + u8::from(s.v()), 27);
|
||||
}
|
||||
|
||||
/// Generated with the Python SDK 0.24.0 (`sign_agent(wallet, action, is_mainnet=False)`).
|
||||
#[test]
|
||||
fn approve_agent_matches_python_sdk() {
|
||||
let a = ApproveAgentAction {
|
||||
kind: "approveAgent".into(),
|
||||
hyperliquid_chain: "Testnet".into(),
|
||||
signature_chain_id: "0x66eee".into(),
|
||||
agent_address: "0x5e9ee1089755c3435139848e47e6635505d5a13a".into(),
|
||||
agent_name: "terminal-vector valid_until 1700000000000".into(),
|
||||
nonce: 1700000000000,
|
||||
};
|
||||
let main = crate::key::MainKey::from_hex(SDK_KEY).unwrap();
|
||||
let want = sig(
|
||||
"0x8138ea721e1fb5afe9239b21b19ffe1e4690dc2c06b0aaf661aeb9859f598a25",
|
||||
"0x169543c6dbf8c0caa9e6225e6612e5c3b495afbdc840537be74a6fb465cfc321",
|
||||
28,
|
||||
);
|
||||
assert_eq!(sign_approve_agent(&main, &a).unwrap(), want);
|
||||
assert_eq!(format!("{:#x}", main.address()), "0x14791697260e4c9a71f18484c9f997b308e59325");
|
||||
assert_eq!(recover_approve_agent(&a, &want).unwrap(), main.address());
|
||||
// A wallet's r ‖ s ‖ v hex (as eth_signTypedData_v4 returns it) reads as the same signature.
|
||||
let rsv = format!("0x{:0>64}{:0>64}{:02x}", &want.r[2..], &want.s[2..], want.v);
|
||||
assert_eq!(WireSignature::from_rsv_hex(&rsv).unwrap(), want);
|
||||
let zero_one = format!("{}{:02x}", &rsv[..rsv.len() - 2], want.v - 27);
|
||||
assert_eq!(WireSignature::from_rsv_hex(&zero_one).unwrap(), want, "v as 0/1");
|
||||
assert!(WireSignature::from_rsv_hex("0x1234").is_err());
|
||||
// The same fields addressed to mainnet are refused rather than signed.
|
||||
let mainnet = ApproveAgentAction { hyperliquid_chain: "Mainnet".into(), ..a.clone() };
|
||||
assert!(sign_approve_agent(&main, &mainnet).is_err());
|
||||
// The wallet payload names the same domain and message.
|
||||
let td = approve_agent_typed_data(&a).unwrap();
|
||||
assert_eq!(td["domain"]["chainId"], 0x66eee);
|
||||
assert_eq!(td["message"]["hyperliquidChain"], "Testnet");
|
||||
assert_eq!(td["primaryType"], "HyperliquidTransaction:ApproveAgent");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn expires_after_is_part_of_the_hash() {
|
||||
let n = NoopAction::default();
|
||||
let a = action_hash(&n, 5, None).unwrap();
|
||||
let b = action_hash(&n, 5, Some(1_700_000_030_000)).unwrap();
|
||||
assert_ne!(a, b);
|
||||
let s = sign_l1(&key(), &n, 5, Some(1_700_000_030_000)).unwrap();
|
||||
assert_eq!(recover_l1(&n, 5, Some(1_700_000_030_000), &s).unwrap(), key().address());
|
||||
assert_ne!(recover_l1(&n, 5, None, &s).unwrap(), key().address(), "a different expiry recovers someone else");
|
||||
// A corrupted signature recovers a different address instead of failing: the control
|
||||
// for the live check.
|
||||
let mut bad = s.clone();
|
||||
bad.s = format!(
|
||||
"{:#x}",
|
||||
alloy_primitives::U256::from_str_radix(&bad.s[2..], 16).unwrap() ^ alloy_primitives::U256::from(1)
|
||||
);
|
||||
assert_ne!(recover_l1(&n, 5, Some(1_700_000_030_000), &bad).unwrap(), key().address());
|
||||
}
|
||||
|
||||
/// The same approval signed by a main wallet held elsewhere (as Turnkey's `sign_raw_payload`
|
||||
/// with `HASH_FUNCTION_NO_OP` signs the digest it is given) is the Python SDK's signature.
|
||||
#[test]
|
||||
fn a_remote_main_wallet_reproduces_the_approve_agent_vector() {
|
||||
let a = ApproveAgentAction {
|
||||
kind: "approveAgent".into(),
|
||||
hyperliquid_chain: "Testnet".into(),
|
||||
signature_chain_id: "0x66eee".into(),
|
||||
agent_address: "0x5e9ee1089755c3435139848e47e6635505d5a13a".into(),
|
||||
agent_name: "terminal-vector valid_until 1700000000000".into(),
|
||||
nonce: 1700000000000,
|
||||
};
|
||||
let remote = k256::ecdsa::SigningKey::from_slice(&alloy_primitives::hex::decode(SDK_KEY).unwrap()).unwrap();
|
||||
let master = Address::from_private_key(&remote);
|
||||
let signed = futures::executor::block_on(sign_approve_agent_with(&a, &master, |digest: B256| {
|
||||
let (s, recid) = remote.sign_prehash_recoverable(digest.as_slice()).unwrap();
|
||||
async move { Ok(alloy_primitives::Signature::from_signature_and_parity(s, recid.is_y_odd())) }
|
||||
}))
|
||||
.unwrap();
|
||||
let want = sig(
|
||||
"0x8138ea721e1fb5afe9239b21b19ffe1e4690dc2c06b0aaf661aeb9859f598a25",
|
||||
"0x169543c6dbf8c0caa9e6225e6612e5c3b495afbdc840537be74a6fb465cfc321",
|
||||
28,
|
||||
);
|
||||
assert_eq!(signed, want);
|
||||
}
|
||||
|
||||
/// `approveAgent` is only hashed for testnet's signature chain (0x66eee): never Arbitrum One
|
||||
/// (0xa4b1, the chain Hyperliquid's docs show for mainnet), and never an unparseable id.
|
||||
#[test]
|
||||
fn approve_agent_is_hashed_only_at_the_testnet_signature_chain() {
|
||||
let a = crate::trader::approve_agent(&key().address(), "vector", 30, 1_700_000_000_000);
|
||||
assert!(approve_agent_hash(&a).is_ok());
|
||||
for chain in ["0xa4b1", "0x1", "0x66EEE0", "", "zz"] {
|
||||
let other = ApproveAgentAction { signature_chain_id: chain.into(), ..a.clone() };
|
||||
assert!(approve_agent_hash(&other).is_err(), "{chain}");
|
||||
}
|
||||
// The chain id may be written in either case.
|
||||
assert!(approve_agent_hash(&ApproveAgentAction { signature_chain_id: "0x66EEE".into(), ..a }).is_ok());
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
{"BTC": "76662.5", "ETH": "2259.4", "SOL": "93.5475", "HYPE": "90.8835"}
|
||||
@@ -0,0 +1 @@
|
||||
{"xyz:AAPL":"322.15","xyz:ALUMINIUM":"3192.3","xyz:AMD":"518.65","xyz:AMZN":"246.01","xyz:AVGO":"495.0","xyz:BABA":"110.03","xyz:BIRD":"4.1","xyz:BRENTOIL":"99.315","xyz:BX":"120.3","xyz:CL":"94.37","xyz:COIN":"137.51","xyz:COPPER":"6.5408","xyz:CORN":"4.5","xyz:COST":"1040.3","xyz:CRCL":"79.162","xyz:CRWV":"50.63","xyz:DKNG":"21.802","xyz:DXY":"97.15","xyz:EUR":"1.1188","xyz:EWJ":"92.885","xyz:EWY":"152.68","xyz:GME":"24.095","xyz:GOLD":"4251.4","xyz:GOOGL":"308.44","xyz:HIMS":"25.339","xyz:HOOD":"84.06","xyz:HYUNDAI":"205.24","xyz:IBM":"252.49","xyz:INTC":"63.753","xyz:IREN":"43.7","xyz:JP225":"63063.0","xyz:JPY":"157.015","xyz:KIOXIA":"140.0","xyz:KORU":"15.0","xyz:KR200":"883.85","xyz:LITE":"686.0","xyz:LLY":"1402.0","xyz:META":"659.55","xyz:MRVL":"196.4","xyz:MSFT":"508.25","xyz:MSTR":"137.95","xyz:MU":"838.03","xyz:NATGAS":"2.905","xyz:NBIS":"218.0","xyz:NFLX":"68.733","xyz:NOW":"114.0","xyz:NVDA":"207.0","xyz:ORCL":"119.69","xyz:PALLADIUM":"973.05","xyz:PLATINUM":"1452.7","xyz:PLTR":"172.5","xyz:PURRDAT":"9.0","xyz:RIVN":"13.215","xyz:RKLB":"150.0","xyz:SILVER":"49.349","xyz:SKHX":"1036.1","xyz:SMSN":"162.64","xyz:SNDK":"1420.2","xyz:SOFTBANK":"31.0","xyz:SP500":"7544.2","xyz:TSLA":"475.53","xyz:TSM":"400.49","xyz:TTF":"57.0","xyz:URANIUM":"85.0","xyz:URNM":"41.88","xyz:USAR":"8.895","xyz:VIX":"57.344","xyz:WHEAT":"6.33","xyz:XLE":"67.425","xyz:XYZ100":"30368.5"}
|
||||
@@ -0,0 +1 @@
|
||||
{"marginSummary":{"accountValue":"447.432631","totalNtlPos":"1415.28926","totalRawUsd":"1655.425891","totalMarginUsed":"104.210562"},"crossMarginSummary":{"accountValue":"447.432631","totalNtlPos":"1415.28926","totalRawUsd":"1655.425891","totalMarginUsed":"104.210562"},"crossMaintenanceMarginUsed":"52.105281","withdrawable":"339.171412","assetPositions":[{"type":"oneWay","position":{"coin":"xyz:TSLA","szi":"-1.483","leverage":{"type":"cross","value":10},"entryPx":"404.349","positionValue":"668.92198","unrealizedPnl":"-69.27118","returnOnEquity":"-1.1551919884","liquidationPx":"704.9387849597","marginUsed":"66.892198","maxLeverage":10,"cumFunding":{"allTime":"0.0","sinceOpen":"0.0","sinceChange":"0.0"}}},{"type":"oneWay","position":{"coin":"xyz:NVDA","szi":"-3.038","leverage":{"type":"cross","value":20},"entryPx":"218.372","positionValue":"642.71928","unrealizedPnl":"20.696851","returnOnEquity":"0.6239477768","liquidationPx":"338.5136601423","marginUsed":"32.135964","maxLeverage":20,"cumFunding":{"allTime":"0.0","sinceOpen":"0.0","sinceChange":"0.0"}}},{"type":"oneWay","position":{"coin":"xyz:AAPL","szi":"0.316","leverage":{"type":"cross","value":20},"entryPx":"344.82","positionValue":"103.648","unrealizedPnl":"-5.31512","returnOnEquity":"-0.9755814628","liquidationPx":null,"marginUsed":"5.1824","maxLeverage":20,"cumFunding":{"allTime":"0.0","sinceOpen":"0.0","sinceChange":"0.0"}}}],"time":1793667763833}
|
||||
@@ -0,0 +1 @@
|
||||
[{"coin":"xyz:AAPL","side":"A","limitPx":"346.21","sz":"0.789","oid":63919996139,"timestamp":1793214466685,"triggerCondition":"N/A","isTrigger":false,"triggerPx":"0.0","children":[],"isPositionTpsl":false,"reduceOnly":false,"orderType":"Limit","origSz":"0.866","tif":"Alo","cloid":null}]
|
||||
@@ -0,0 +1 @@
|
||||
{"universe":[{"szDecimals":2,"name":"SOL","maxLeverage":10,"marginTableId":10},{"szDecimals":2,"name":"APT","maxLeverage":3,"marginTableId":3},{"szDecimals":2,"name":"ATOM","maxLeverage":10,"marginTableId":55},{"szDecimals":5,"name":"BTC","maxLeverage":40,"marginTableId":54},{"szDecimals":4,"name":"ETH","maxLeverage":25,"marginTableId":53},{"szDecimals":1,"name":"MATIC","maxLeverage":50,"marginTableId":50,"isDelisted":true},{"szDecimals":3,"name":"BNB","maxLeverage":10,"marginTableId":10},{"szDecimals":2,"name":"AVAX","maxLeverage":10,"marginTableId":55},{"szDecimals":0,"name":"GMT","maxLeverage":2,"marginTableId":2},{"szDecimals":1,"name":"DYDX","maxLeverage":20,"marginTableId":20},{"szDecimals":1,"name":"APE","maxLeverage":5,"marginTableId":5},{"szDecimals":1,"name":"OP","maxLeverage":10,"marginTableId":10},{"szDecimals":0,"name":"kPEPE","maxLeverage":10,"marginTableId":10},{"szDecimals":1,"name":"ARB","maxLeverage":10,"marginTableId":55},{"szDecimals":0,"name":"RLB","maxLeverage":50,"marginTableId":50,"isDelisted":true},{"szDecimals":1,"name":"WLD","maxLeverage":10,"marginTableId":10},{"szDecimals":0,"name":"HPOS","maxLeverage":50,"marginTableId":50,"isDelisted":true},{"szDecimals":2,"name":"UNIBOT","maxLeverage":2,"marginTableId":2,"onlyIsolated":true,"isDelisted":true,"marginMode":"strictIsolated"},{"szDecimals":2,"name":"COMP","maxLeverage":5,"marginTableId":5},{"szDecimals":1,"name":"FXS","maxLeverage":3,"marginTableId":3,"isDelisted":true},{"szDecimals":4,"name":"MKR","maxLeverage":10,"marginTableId":55,"isDelisted":true},{"szDecimals":2,"name":"AAVE","maxLeverage":10,"marginTableId":52},{"szDecimals":1,"name":"SNX","maxLeverage":10,"marginTableId":10},{"szDecimals":1,"name":"RNDR","maxLeverage":20,"marginTableId":20,"isDelisted":true},{"szDecimals":1,"name":"LDO","maxLeverage":5,"marginTableId":5},{"szDecimals":1,"name":"SUI","maxLeverage":10,"marginTableId":52},{"szDecimals":1,"name":"INJ","maxLeverage":10,"marginTableId":52},{"szDecimals":1,"name":"STX","maxLeverage":10,"marginTableId":52},{"szDecimals":0,"name":"FTM","maxLeverage":3,"marginTableId":3,"isDelisted":true},{"szDecimals":0,"name":"kSHIB","maxLeverage":10,"marginTableId":52},{"szDecimals":0,"name":"OX","maxLeverage":3,"marginTableId":3,"onlyIsolated":true,"isDelisted":true,"marginMode":"strictIsolated"},{"szDecimals":1,"name":"FRIEND","maxLeverage":3,"marginTableId":3,"onlyIsolated":true,"isDelisted":true,"marginMode":"strictIsolated"},{"szDecimals":1,"name":"ZRO","maxLeverage":10,"marginTableId":55,"onlyIsolated":true,"marginMode":"strictIsolated"},{"szDecimals":0,"name":"BLZ","maxLeverage":5,"marginTableId":5,"isDelisted":true},{"szDecimals":1,"name":"BANANA","maxLeverage":3,"marginTableId":3,"onlyIsolated":true,"marginMode":"strictIsolated"},{"szDecimals":1,"name":"FTT","maxLeverage":3,"marginTableId":3,"isDelisted":true},{"szDecimals":2,"name":"TRB","maxLeverage":5,"marginTableId":5},{"szDecimals":0,"name":"CANTO","maxLeverage":3,"marginTableId":3,"onlyIsolated":true,"isDelisted":true,"marginMode":"strictIsolated"},{"szDecimals":0,"name":"BIGTIME","maxLeverage":3,"marginTableId":3},{"szDecimals":0,"name":"NTRN","maxLeverage":5,"marginTableId":5,"isDelisted":true},{"szDecimals":0,"name":"KAS","maxLeverage":5,"marginTableId":5},{"szDecimals":0,"name":"BLUR","maxLeverage":10,"marginTableId":10},{"szDecimals":1,"name":"TIA","maxLeverage":10,"marginTableId":52},{"szDecimals":2,"name":"BSV","maxLeverage":10,"marginTableId":10},{"szDecimals":1,"name":"TON","maxLeverage":10,"marginTableId":52,"isDelisted":true},{"szDecimals":0,"name":"ADA","maxLeverage":10,"marginTableId":55},{"szDecimals":0,"name":"MINA","maxLeverage":10,"marginTableId":10},{"szDecimals":0,"name":"POLYX","maxLeverage":10,"marginTableId":10},{"szDecimals":1,"name":"GAS","maxLeverage":10,"marginTableId":10},{"szDecimals":0,"name":"AXL","maxLeverage":3,"marginTableId":3,"isDelisted":true},{"szDecimals":0,"name":"PENDLE","maxLeverage":10,"marginTableId":10},{"szDecimals":0,"name":"STG","maxLeverage":10,"marginTableId":10,"isDelisted":true},{"szDecimals":0,"name":"FET","maxLeverage":10,"marginTableId":10},{"szDecimals":0,"name":"STRAX","maxLeverage":10,"marginTableId":10,"isDelisted":true},{"szDecimals":1,"name":"NEAR","maxLeverage":20,"marginTableId":20},{"szDecimals":0,"name":"MEME","maxLeverage":10,"marginTableId":10},{"szDecimals":2,"name":"ORDI","maxLeverage":10,"marginTableId":10},{"szDecimals":1,"name":"BADGER","maxLeverage":10,"marginTableId":10,"isDelisted":true},{"szDecimals":2,"name":"NEO","maxLeverage":20,"marginTableId":20},{"szDecimals":2,"name":"ZEN","maxLeverage":10,"marginTableId":10},{"szDecimals":1,"name":"FIL","maxLeverage":10,"marginTableId":10},{"szDecimals":0,"name":"PYTH","maxLeverage":10,"marginTableId":10},{"szDecimals":1,"name":"RUNE","maxLeverage":10,"marginTableId":55},{"szDecimals":1,"name":"SUSHI","maxLeverage":10,"marginTableId":10},{"szDecimals":2,"name":"ILV","maxLeverage":10,"marginTableId":10,"isDelisted":true},{"szDecimals":0,"name":"MAV"Line truncated
|
||||
@@ -0,0 +1 @@
|
||||
{"universe":[{"szDecimals":4,"name":"xyz:XYZ100","maxLeverage":30,"marginTableId":30,"growthMode":"enabled","lastFeeScaleChangeTime":"2025-11-23T17:21:40.390706535","deployerFeeScale":"1.0"},{"szDecimals":3,"name":"xyz:TSLA","maxLeverage":10,"marginTableId":10,"growthMode":"enabled","lastFeeScaleChangeTime":"2025-11-23T17:21:40.390706535","deployerFeeScale":"1.0"},{"szDecimals":3,"name":"xyz:NVDA","maxLeverage":20,"marginTableId":20,"growthMode":"enabled","lastFeeScaleChangeTime":"2025-11-23T17:21:40.390706535","deployerFeeScale":"1.0"},{"szDecimals":4,"name":"xyz:GOLD","maxLeverage":25,"marginTableId":25,"lastFeeScaleChangeTime":"1970-01-01T00:00:00","deployerFeeScale":"1.0"},{"szDecimals":3,"name":"xyz:HOOD","maxLeverage":10,"marginTableId":10,"onlyIsolated":true,"marginMode":"noCross","growthMode":"enabled","lastFeeScaleChangeTime":"2025-11-23T17:21:40.390706535","deployerFeeScale":"1.0"},{"szDecimals":2,"name":"xyz:INTC","maxLeverage":10,"marginTableId":10,"onlyIsolated":true,"marginMode":"noCross","growthMode":"enabled","lastFeeScaleChangeTime":"2026-02-18T13:53:17.301971811","deployerFeeScale":"1.0"},{"szDecimals":3,"name":"xyz:PLTR","maxLeverage":10,"marginTableId":10,"growthMode":"enabled","lastFeeScaleChangeTime":"2026-02-18T13:53:17.301971811","deployerFeeScale":"1.0"},{"szDecimals":3,"name":"xyz:COIN","maxLeverage":10,"marginTableId":10,"onlyIsolated":true,"marginMode":"noCross","growthMode":"enabled","lastFeeScaleChangeTime":"2026-02-18T13:53:17.301971811","deployerFeeScale":"1.0"},{"szDecimals":3,"name":"xyz:META","maxLeverage":10,"marginTableId":10,"growthMode":"enabled","lastFeeScaleChangeTime":"2026-02-18T13:53:17.301971811","deployerFeeScale":"1.0"},{"szDecimals":3,"name":"xyz:AAPL","maxLeverage":20,"marginTableId":20,"growthMode":"enabled","lastFeeScaleChangeTime":"2026-02-18T13:53:17.301971811","deployerFeeScale":"1.0"},{"szDecimals":3,"name":"xyz:MSFT","maxLeverage":10,"marginTableId":10,"growthMode":"enabled","lastFeeScaleChangeTime":"2026-02-18T13:53:17.301971811","deployerFeeScale":"1.0"},{"szDecimals":3,"name":"xyz:ORCL","maxLeverage":10,"marginTableId":10,"onlyIsolated":true,"marginMode":"noCross","growthMode":"enabled","lastFeeScaleChangeTime":"2026-02-18T13:53:17.301971811","deployerFeeScale":"1.0"},{"szDecimals":3,"name":"xyz:GOOGL","maxLeverage":10,"marginTableId":10,"growthMode":"enabled","lastFeeScaleChangeTime":"2026-02-18T13:53:17.301971811","deployerFeeScale":"1.0"},{"szDecimals":3,"name":"xyz:AMZN","maxLeverage":10,"marginTableId":10,"growthMode":"enabled","lastFeeScaleChangeTime":"2026-02-18T13:53:17.301971811","deployerFeeScale":"1.0"},{"szDecimals":3,"name":"xyz:AMD","maxLeverage":10,"marginTableId":10,"onlyIsolated":true,"marginMode":"noCross","growthMode":"enabled","lastFeeScaleChangeTime":"2026-02-18T13:53:17.301971811","deployerFeeScale":"1.0"},{"szDecimals":3,"name":"xyz:MU","maxLeverage":10,"marginTableId":10,"onlyIsolated":true,"marginMode":"noCross","growthMode":"enabled","lastFeeScaleChangeTime":"2026-01-09T17:10:11.615872064","deployerFeeScale":"1.0"},{"szDecimals":3,"name":"xyz:SNDK","maxLeverage":10,"marginTableId":10,"onlyIsolated":true,"marginMode":"noCross","growthMode":"enabled","lastFeeScaleChangeTime":"2026-02-18T13:53:17.301971811","deployerFeeScale":"1.0"},{"szDecimals":3,"name":"xyz:MSTR","maxLeverage":10,"marginTableId":10,"onlyIsolated":true,"marginMode":"noCross","lastFeeScaleChangeTime":"2026-02-20T12:46:19.276033666","deployerFeeScale":"1.0"},{"szDecimals":3,"name":"xyz:CRCL","maxLeverage":10,"marginTableId":10,"onlyIsolated":true,"marginMode":"noCross","growthMode":"enabled","lastFeeScaleChangeTime":"2026-02-18T13:53:17.301971811","deployerFeeScale":"1.0"},{"szDecimals":3,"name":"xyz:NFLX","maxLeverage":10,"marginTableId":10,"onlyIsolated":true,"marginMode":"noCross","growthMode":"enabled","lastFeeScaleChangeTime":"2026-02-18T13:53:17.301971811","deployerFeeScale":"1.0"},{"szDecimals":4,"name":"xyz:COST","maxLeverage":20,"marginTableId":20,"onlyIsolated":true,"marginMode":"noCross","growthMode":"enabled","lastFeeScaleChangeTime":"2026-02-18T13:53:17.301971811","deployerFeeScale":"1.0"},{"szDecimals":4,"name":"xyz:LLY","maxLeverage":10,"marginTableId":10,"onlyIsolated":true,"marginMode":"noCross","growthMode":"enabled","lastFeeScaleChangeTime":"2026-02-18T13:53:17.301971811","deployerFeeScale":"1.0"},{"szDecimals":3,"name":"xyz:SKHX","maxLeverage":10,"marginTableId":10,"onlyIsolated":true,"marginMode":"noCross","growthMode":"enabled","lastFeeScaleChangeTime":"2026-02-18T13:53:17.301971811","deployerFeeScale":"1.0"},{"szDecimals":3,"name":"xyz:TSM","maxLeverage":10,"marginTableId":10,"onlyIsolated":true,"marginMode":"noCross","growthMode":"enabled","lastFeeScaleChangeTime":"2026-02-18T13:53:17.301971811","deployerFeeScale":"1.0"},{"szDecimals":2,"name":"xyz:JPY","maxLeverage":50,"marginTableId":50,"onlyIsolated":true,"marginMode":"noCross","growthMode":"enabled","lastFeeScaleChangeTime":"2026-02-18T13:53:17.301971811","deployerFeeScale":"1.0"},Line truncated
|
||||
@@ -0,0 +1 @@
|
||||
[null, {"name": "test", "fullName": "test dex"}, {"name": "unit", "fullName": "unit dex"}, {"name": "scam", "fullName": "Scam DEX"}, {"name": "felix", "fullName": "felix"}, {"name": "volmex", "fullName": "volmex"}, {"name": "angry", "fullName": "angry"}, {"name": "TOON", "fullName": "Platoon"}, {"name": "VOLMEX", "fullName": "Volmex Dex"}, {"name": "test21", "fullName": "test21"}, {"name": "pluto", "fullName": "pluto dex"}, {"name": "zigg", "fullName": "zigg"}, {"name": "i<3fl", "fullName": "i love @frank_liquid"}, {"name": "KNETIQ", "fullName": "Kinetiq Exchange"}, {"name": "knetiq", "fullName": "Kinetiq Exchange - KNTQ"}, {"name": "ddot", "fullName": "ddot link"}, {"name": "venone", "fullName": "VentualsOne"}, {"name": "tstdex", "fullName": "New Coin"}, {"name": "vntls", "fullName": "Ventuals"}, {"name": "oracle", "fullName": "oracle test"}, {"name": "hb", "fullName": "hb test"}, {"name": "sports", "fullName": "Sports Player Perpetuals"}, {"name": "aura", "fullName": "aura.money"}, {"name": "hybet", "fullName": "hyperbet"}, {"name": "orange", "fullName": "ORANGE"}, {"name": "sekai", "fullName": "Sekai Perp DEX"}, {"name": "flow", "fullName": "Flow DEX"}, {"name": "x", "fullName": "XYZ"}, {"name": "t", "fullName": "XYZ"}, {"name": "sekaw", "fullName": "red dex"}, {"name": "gpu", "fullName": "Global Compute Exchange"}, {"name": "qwerty", "fullName": "qwerty dex"}, {"name": "merrli", "fullName": "merrli dex"}, {"name": "dex", "fullName": "dex-full-name"}, {"name": "olvb", "fullName": "onlyvibes"}, {"name": "nq", "fullName": "nq"}, {"name": "fx", "fullName": "hyperfx"}, {"name": "nqz", "fullName": "nqz"}, {"name": "btcx", "fullName": "BTC Perp Markets"}, {"name": "woof", "fullName": "woof perpetuals"}, {"name": "shiok", "fullName": "Shiok Lah"}, {"name": "z", "fullName": "XYZ"}, {"name": "hbtwo", "fullName": "Hyperbolic Finance 2"}, {"name": "tndex", "fullName": "test new dex"}, {"name": "rrrrr", "fullName": "rrrrr"}, {"name": "hbhb", "fullName": "Hyperbolic Finance 3"}, {"name": "stocks", "fullName": "redstocks"}, {"name": "nunchi", "fullName": "nunchi"}, {"name": "sqtwr", "fullName": "Squaretower"}, {"name": "y", "fullName": "XYZ"}, {"name": "hypodd", "fullName": "HyperOdd PEX"}, {"name": "strat", "fullName": "Stratium Crypto DEX"}, {"name": "zdex", "fullName": "zdex"}, {"name": "crayon", "fullName": "crayon dex"}, {"name": "walao", "fullName": "walao dex"}, {"name": "slpy", "fullName": "sleepy dex"}, {"name": "tsts", "fullName": "test dex"}, {"name": "mbx", "fullName": "A good dex"}, {"name": "food", "fullName": "A food dex"}, {"name": "meng", "fullName": "Meong meong meong"}, {"name": "rawr", "fullName": "Rawr >w<"}, {"name": "mmmg", "fullName": "Meong2"}, {"name": "msdx", "fullName": "BTC Perp Markets"}, {"name": "wrld", "fullName": "Hyperworld"}, {"name": "hodd", "fullName": "HyperOdd Leveraged Prediction Markets"}, {"name": "xyz", "fullName": "XYZ"}, {"name": "flx", "fullName": "flx"}, {"name": "nmx", "fullName": "NM USDH DEX"}, {"name": "pyth", "fullName": "Pyth Network test 1"}, {"name": "msda", "fullName": "MSD1 BTC Perp"}, {"name": "ho", "fullName": "HyperOdd Prediction Perps"}, {"name": "flsh", "fullName": "HyperFlash"}, {"name": "hyfl", "fullName": "HyperFlash"}, {"name": "csan", "fullName": "computeshield"}, {"name": "sedx", "fullName": "sekai dex try"}, {"name": "rbbt", "fullName": "Rabbit"}, {"name": "rbte", "fullName": "RabbitE"}, {"name": "demo", "fullName": "demo dex"}, {"name": "flxn", "fullName": "flxn"}, {"name": "bsx", "fullName": "BasisX dex"}, {"name": "loh", "fullName": "Lohcrypto DEX by Tovkes"}, {"name": "xvm", "fullName": "EXCHANGE VIRTUAL MACHINE"}, {"name": "dydx", "fullName": "NEXON DEX"}, {"name": "fzx", "fullName": "Felix Exchange"}, {"name": "abdc", "fullName": "America's Best Dance Crew"}, {"name": "fam", "fullName": "Famous Dex"}, {"name": "gato", "fullName": "EL GATO THE MF"}, {"name": "bart", "fullName": "Bart Simpson Dex"}, {"name": "sss", "fullName": "SSS"}, {"name": "owal", "fullName": "OWALA"}, {"name": "gg", "fullName": "hyperliquid blockchain is my bitch"}, {"name": "fdnk", "fullName": "FDNK"}, {"name": "trve", "fullName": "Trove"}, {"name": "odd", "fullName": "HyperOdd Prediction Perps"}, {"name": "trv", "fullName": "Trove"}, {"name": "qwrn", "fullName": "Qwrn"}, {"name": "zzzz", "fullName": "Felix Exchange"}, {"name": "gass", "fullName": "gass Exchange"}, {"name": "whtm", "fullName": "Whtm"}, {"name": "rip", "fullName": "RIP"}, {"name": "sand", "fullName": "Dune Sand DEX"}, {"name": "hmax", "fullName": "hypermax"}, {"name": "slob", "fullName": "she slob on my blob till i hyperliquid"}, {"name": "hyfi", "fullName": "HYFI"}, {"name": "str", "fullName": "Stratium DEX"}, {"name": "trov", "fullName": "Trove"}, {"name": "erez", "fullName": "Erez"}, {"name": "paef", "fullName": "Paef"}, {"name": "mcd", "fullName": "Old MacDonald's Farm"}, {"name": "birb", "fullName": "BIRB BIRB BIRB BIRB BIRB"}, {"name": "plsh", "fullName": "My beloved plushies"}, {"name": "cnst", "fulLine truncated
|
||||
@@ -0,0 +1,8 @@
|
||||
{"channel":"clearinghouseState","data":{"dex":"","user":"0x00000000000000000000000000000000000f107e","clearinghouseState":{"marginSummary":{"accountValue":"0.0","totalNtlPos":"0.0","totalRawUsd":"0.0","totalMarginUsed":"0.0"},"crossMarginSummary":{"accountValue":"0.0","totalNtlPos":"0.0","totalRawUsd":"0.0","totalMarginUsed":"0.0"},"crossMaintenanceMarginUsed":"0.0","withdrawable":"0.0","assetPositions":[],"time":1793667764987}}}
|
||||
{"channel":"clearinghouseState","data":{"dex":"xyz","user":"0x00000000000000000000000000000000000f107e","clearinghouseState":{"marginSummary":{"accountValue":"447.432631","totalNtlPos":"1415.28926","totalRawUsd":"1655.425891","totalMarginUsed":"104.210562"},"crossMarginSummary":{"accountValue":"447.432631","totalNtlPos":"1415.28926","totalRawUsd":"1655.425891","totalMarginUsed":"104.210562"},"crossMaintenanceMarginUsed":"52.105281","withdrawable":"339.171412","assetPositions":[{"type":"oneWay","position":{"coin":"xyz:TSLA","szi":"-1.483","leverage":{"type":"cross","value":10},"entryPx":"404.349","positionValue":"668.92198","unrealizedPnl":"-69.27118","returnOnEquity":"-1.1551919884","liquidationPx":"704.9387849597","marginUsed":"66.892198","maxLeverage":10,"cumFunding":{"allTime":"0.0","sinceOpen":"0.0","sinceChange":"0.0"}}},{"type":"oneWay","position":{"coin":"xyz:NVDA","szi":"-3.038","leverage":{"type":"cross","value":20},"entryPx":"218.372","positionValue":"642.71928","unrealizedPnl":"20.696851","returnOnEquity":"0.6239477768","liquidationPx":"338.5136601423","marginUsed":"32.135964","maxLeverage":20,"cumFunding":{"allTime":"0.0","sinceOpen":"0.0","sinceChange":"0.0"}}},{"type":"oneWay","position":{"coin":"xyz:AAPL","szi":"0.316","leverage":{"type":"cross","value":20},"entryPx":"344.82","positionValue":"103.648","unrealizedPnl":"-5.31512","returnOnEquity":"-0.9755814628","liquidationPx":null,"marginUsed":"5.1824","maxLeverage":20,"cumFunding":{"allTime":"0.0","sinceOpen":"0.0","sinceChange":"0.0"}}}],"time":1793667765068}}}
|
||||
{"channel":"openOrders","data":{"dex":"","user":"0x00000000000000000000000000000000000f107e","orders":[]}}
|
||||
{"channel":"openOrders","data":{"dex":"xyz","user":"0x00000000000000000000000000000000000f107e","orders":[{"coin":"xyz:AAPL","side":"A","limitPx":"346.21","sz":"0.789","oid":63919996139,"timestamp":1793214466685,"triggerCondition":"N/A","isTrigger":false,"triggerPx":"0.0","children":[],"isPositionTpsl":false,"reduceOnly":false,"orderType":"Limit","origSz":"0.866","tif":"Alo","cloid":null}]}}
|
||||
{"channel":"userFills","data":{"isSnapshot":true,"user":"0x00000000000000000000000000000000000f107e","fills":[{"coin":"xyz:TSLA","px":"404.6","sz":"0.03","side":"A","time":1793207760467,"startPosition":"-1.042","dir":"Open Short","closedPnl":"0.0","hash":"0x3bfad0510cfa9bb33d74042cd1bbfe010900e836a7fdba85dfc37ba3cbfe759d","oid":63912181528,"crossed":false,"fee":"0.000364","tid":1069007623150225,"feeToken":"USDC","twapId":null},{"coin":"xyz:TSLA","px":"404.6","sz":"0.03","side":"A","time":1793207763442,"startPosition":"-1.072","dir":"Open Short","closedPnl":"0.0","hash":"0xf5b2dc2fbb4da86bf72c042cd1bc2d010b00f4155640c73e997b87827a418256","oid":63912181528,"crossed":false,"fee":"0.000364","tid":971524471857229,"feeToken":"USDC","twapId":null},{"coin":"xyz:TSLA","px":"404.6","sz":"0.03","side":"A","time":1793207766547,"startPosition":"-1.102","dir":"Open Short","closedPnl":"0.0","hash":"0x1e5e84a6252ddbe31fd8042cd1bc570107009c8bc020fab5c2272ff8e421b5cd","oid":63912181528,"crossed":false,"fee":"0.000364","tid":123747502287917,"feeToken":"USDC","twapId":null},{"coin":"xyz:TSLA","px":"404.6","sz":"0.03","side":"A","time":1793207769432,"startPosition":"-1.132","dir":"Open Short","closedPnl":"0.0","hash":"0x2ad26b5a41f6e0bc2c4c042cd1bc81010a00833fdcf9ff8ece9b16ad00fabaa6","oid":63912181528,"crossed":false,"fee":"0.000364","tid":1077238072313899,"feeToken":"USDC","twapId":null}]}}
|
||||
{"channel":"userFundings","data":{"isSnapshot":true,"user":"0x00000000000000000000000000000000000f107e","fundings":[]}}
|
||||
{"channel":"activeAssetData","data":{"user":"0x00000000000000000000000000000000000f107e","coin":"xyz:AAPL","leverage":{"type":"cross","value":20},"maxTradeSzs":["20.928","20.646"],"availableToTrade":["343.2192","338.5944"],"markPx":"328.0"}}
|
||||
{"channel":"allMids","data":{"dex":"xyz","mids":{"xyz:AAPL":"322.15","xyz:ALUMINIUM":"3192.3","xyz:AMD":"519.2","xyz:AMZN":"246.33","xyz:AVGO":"495.0","xyz:BABA":"110.03"}}}
|
||||
@@ -0,0 +1,232 @@
|
||||
//! This build cannot reach or sign for Hyperliquid mainnet. These tests fail if that changes.
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
use tr_hl::Network;
|
||||
use tr_hl::network::{API_URL, WS_URL};
|
||||
|
||||
/// Mainnet hosts that must never appear in this crate (the testnet hosts are
|
||||
/// `api.hyperliquid-testnet.xyz`, which none of these match).
|
||||
const MAINNET_HOSTS: [&str; 3] = ["api.hyperliquid.xyz", "app.hyperliquid.xyz", "wss://api.hyperliquid.xyz"];
|
||||
|
||||
fn sources(dir: &Path, out: &mut Vec<std::path::PathBuf>) {
|
||||
for e in std::fs::read_dir(dir).unwrap() {
|
||||
let p = e.unwrap().path();
|
||||
if p.is_dir() {
|
||||
sources(&p, out);
|
||||
} else if p.extension().is_some_and(|x| x == "rs" || x == "toml") {
|
||||
out.push(p);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_crate_names_no_mainnet_host() {
|
||||
let root = Path::new(env!("CARGO_MANIFEST_DIR"));
|
||||
let mut files = vec![root.join("Cargo.toml")];
|
||||
for d in ["src", "examples", "tests"] {
|
||||
if root.join(d).exists() {
|
||||
sources(&root.join(d), &mut files);
|
||||
}
|
||||
}
|
||||
assert!(files.len() > 10, "found the sources: {files:?}");
|
||||
let me = file!().rsplit('/').next().unwrap();
|
||||
for f in files {
|
||||
// This file lists the hosts it looks for.
|
||||
if f.file_name().is_some_and(|n| n == me) {
|
||||
continue;
|
||||
}
|
||||
let text = std::fs::read_to_string(&f).unwrap();
|
||||
for host in MAINNET_HOSTS {
|
||||
assert!(!text.contains(host), "{} names the mainnet host {host}", f.display());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_endpoints_are_the_testnet_hosts() {
|
||||
assert_eq!(API_URL, "https://api.hyperliquid-testnet.xyz");
|
||||
assert_eq!(WS_URL, "wss://api.hyperliquid-testnet.xyz/ws");
|
||||
let n = Network::default();
|
||||
assert!(n.info_url().starts_with("https://api.hyperliquid-testnet.xyz/"));
|
||||
assert!(n.exchange_url().starts_with("https://api.hyperliquid-testnet.xyz/"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn there_is_one_network_and_it_signs_as_testnet() {
|
||||
// Adding a variant makes this match non-exhaustive, so the test stops compiling.
|
||||
match Network::default() {
|
||||
Network::Testnet => {}
|
||||
}
|
||||
assert_eq!(Network::Testnet.phantom_source(), "b");
|
||||
assert_eq!(Network::Testnet.hyperliquid_chain(), "Testnet");
|
||||
assert_eq!(Network::Testnet.label(), "TESTNET");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stored_preferences_cannot_select_mainnet() {
|
||||
#[derive(serde::Deserialize)]
|
||||
struct Prefs {
|
||||
network: Network,
|
||||
}
|
||||
for v in ["\"mainnet\"", "\"Mainnet\"", "\"MAINNET\"", "\"testnet\"", "\"arbitrum\"", "0", "true", "null"] {
|
||||
let p: Prefs = serde_json::from_str(&format!(r#"{{"network": {v}}}"#)).unwrap();
|
||||
assert_eq!(p.network, Network::Testnet, "{v}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn signatures_are_testnet_signatures() {
|
||||
use tr_hl::actions::ApproveAgentAction;
|
||||
use tr_hl::signing::{recover_approve_agent, sign_approve_agent};
|
||||
use tr_hl::trader::approve_agent;
|
||||
let main = tr_hl::MainKey::generate();
|
||||
let agent = tr_hl::AgentKey::generate();
|
||||
let a = approve_agent(&tr_hl::SigningKey::address(&agent), "ci", 30, 1_793_667_000_000);
|
||||
assert_eq!(a.hyperliquid_chain, "Testnet");
|
||||
let sig = sign_approve_agent(&main, &a).unwrap();
|
||||
assert_eq!(recover_approve_agent(&a, &sig).unwrap(), tr_hl::SigningKey::address(&main));
|
||||
// Re-addressed to mainnet, the same action can't be signed, and the testnet signature
|
||||
// doesn't recover the signer for it.
|
||||
let mainnet = ApproveAgentAction { hyperliquid_chain: "Mainnet".into(), ..a };
|
||||
assert!(sign_approve_agent(&main, &mainnet).is_err());
|
||||
assert!(recover_approve_agent(&mainnet, &sig).is_err());
|
||||
}
|
||||
|
||||
/// A main wallet held elsewhere (Turnkey) signs a digest this crate hands it. It is only
|
||||
/// ever asked for a testnet `approveAgent` at the testnet signature chain, and its answer is
|
||||
/// used only if it recovers the main wallet.
|
||||
#[test]
|
||||
fn a_remote_main_wallet_signs_only_testnet_approvals() {
|
||||
use std::cell::Cell;
|
||||
use tr_hl::actions::ApproveAgentAction;
|
||||
use tr_hl::signing::{recover_approve_agent, sign_approve_agent_with};
|
||||
use tr_hl::trader::approve_agent;
|
||||
|
||||
// The remote wallet is a plain k256 key here, signing whatever 32 bytes it is given.
|
||||
let main = k256::ecdsa::SigningKey::random(&mut rand_core::OsRng);
|
||||
let master = alloy_primitives::Address::from_private_key(&main);
|
||||
let sign = |key: &k256::ecdsa::SigningKey,
|
||||
digest: alloy_primitives::B256|
|
||||
-> anyhow::Result<alloy_primitives::Signature> {
|
||||
let (sig, recid) = key.sign_prehash_recoverable(digest.as_slice())?;
|
||||
Ok(alloy_primitives::Signature::from_signature_and_parity(sig, recid.is_y_odd()))
|
||||
};
|
||||
let agent = tr_hl::AgentKey::generate();
|
||||
let a = approve_agent(&tr_hl::SigningKey::address(&agent), "ci", 180, 1_793_667_000_000);
|
||||
assert_eq!((a.hyperliquid_chain.as_str(), a.signature_chain_id.as_str()), ("Testnet", "0x66eee"));
|
||||
|
||||
let asked = Cell::new(0);
|
||||
let remote = |digest: alloy_primitives::B256| {
|
||||
asked.set(asked.get() + 1);
|
||||
let sig = sign(&main, digest);
|
||||
async move { sig }
|
||||
};
|
||||
let sig = futures::executor::block_on(sign_approve_agent_with(&a, &master, remote)).unwrap();
|
||||
assert_eq!(recover_approve_agent(&a, &sig).unwrap(), master);
|
||||
assert_eq!(asked.get(), 1);
|
||||
|
||||
// Mainnet, or a mainnet signature chain (Arbitrum One, 0xa4b1), never reaches the signer.
|
||||
for bad in [
|
||||
ApproveAgentAction { hyperliquid_chain: "Mainnet".into(), ..a.clone() },
|
||||
ApproveAgentAction { signature_chain_id: "0xa4b1".into(), ..a.clone() },
|
||||
ApproveAgentAction { kind: "usdSend".into(), ..a.clone() },
|
||||
] {
|
||||
assert!(futures::executor::block_on(sign_approve_agent_with(&bad, &master, remote)).is_err(), "{bad:?}");
|
||||
}
|
||||
assert_eq!(asked.get(), 1, "the remote signer was asked to sign a non-testnet action");
|
||||
|
||||
// A signature from any other key is refused rather than sent.
|
||||
let other = k256::ecdsa::SigningKey::random(&mut rand_core::OsRng);
|
||||
let wrong = |digest: alloy_primitives::B256| {
|
||||
let sig = sign(&other, digest);
|
||||
async move { sig }
|
||||
};
|
||||
let err = futures::executor::block_on(sign_approve_agent_with(&a, &master, wrong)).unwrap_err();
|
||||
assert!(err.to_string().contains("not the wallet's"), "{err}");
|
||||
}
|
||||
|
||||
/// The built-in wallet (Turnkey's API; here its local stand-in) is never sent anything for
|
||||
/// mainnet: the request is refused before it is made.
|
||||
#[tokio::test]
|
||||
async fn the_built_in_wallet_is_never_asked_to_sign_for_mainnet() {
|
||||
use tr_hl::actions::ApproveAgentAction;
|
||||
use tr_hl::signing::sign_approve_agent_with;
|
||||
use tr_hl::trader::approve_agent;
|
||||
|
||||
let mock = tr_turnkey::mock::MockTurnkey::start(0, None).await.unwrap();
|
||||
let key = tr_turnkey::SessionKey::generate();
|
||||
let token = mock.mint_session(&key.public_hex(), 60);
|
||||
let now = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_secs();
|
||||
let session = tr_turnkey::Session::accept(key, &token, now).unwrap();
|
||||
let client = tr_turnkey::Client::with_base(&mock.base).unwrap();
|
||||
let master: alloy_primitives::Address = mock.address().parse().unwrap();
|
||||
let sign = |a: ApproveAgentAction| {
|
||||
let (client, session) = (&client, &session);
|
||||
async move {
|
||||
sign_approve_agent_with(&a, &master, |d| async move { client.sign_digest(session, &master, &d).await })
|
||||
.await
|
||||
}
|
||||
};
|
||||
let a = approve_agent(&tr_hl::SigningKey::address(&tr_hl::AgentKey::generate()), "ci", 180, 1_793_667_000_000);
|
||||
for bad in [
|
||||
ApproveAgentAction { hyperliquid_chain: "Mainnet".into(), ..a.clone() },
|
||||
ApproveAgentAction { signature_chain_id: "0xa4b1".into(), ..a.clone() },
|
||||
] {
|
||||
assert!(sign(bad).await.is_err());
|
||||
}
|
||||
assert!(mock.requests().is_empty(), "the wallet was asked to sign a mainnet action");
|
||||
// The testnet approval goes through and is the only thing signed.
|
||||
assert!(sign(a).await.is_ok());
|
||||
let paths: Vec<String> = mock.requests().into_iter().map(|(p, _)| p).collect();
|
||||
assert_eq!(paths, ["/public/v1/submit/sign_raw_payload"]);
|
||||
}
|
||||
|
||||
/// Code added for the built-in wallet names no mainnet host either: the Turnkey crate, the
|
||||
/// app's wallet modules and the sign-in page.
|
||||
#[test]
|
||||
fn the_wallet_code_names_no_mainnet_host() {
|
||||
let repo = Path::new(env!("CARGO_MANIFEST_DIR")).join("../..");
|
||||
let mut files = Vec::new();
|
||||
for d in [
|
||||
"crates/turnkey/src",
|
||||
"crates/turnkey/tests",
|
||||
"crates/turnkey/examples",
|
||||
"site/src/routes/wallet",
|
||||
"site/src/lib/wallet",
|
||||
] {
|
||||
if repo.join(d).exists() {
|
||||
sources_any(&repo.join(d), &mut files);
|
||||
}
|
||||
}
|
||||
files.push(repo.join("crates/turnkey/Cargo.toml"));
|
||||
for f in [
|
||||
"app/src/app/wallet.rs",
|
||||
"app/src/app/wallet_view.rs",
|
||||
"app/src/app/hl_web.rs",
|
||||
"app/src/app/hl.rs",
|
||||
"app/src/app/hl_view.rs",
|
||||
] {
|
||||
if repo.join(f).exists() {
|
||||
files.push(repo.join(f));
|
||||
}
|
||||
}
|
||||
assert!(files.iter().filter(|f| f.starts_with(repo.join("crates/turnkey"))).count() >= 5, "{files:?}");
|
||||
for f in files {
|
||||
let text = std::fs::read_to_string(&f).unwrap();
|
||||
for host in MAINNET_HOSTS {
|
||||
assert!(!text.contains(host), "{} names the mainnet host {host}", f.display());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn sources_any(dir: &Path, out: &mut Vec<std::path::PathBuf>) {
|
||||
for e in std::fs::read_dir(dir).unwrap() {
|
||||
let p = e.unwrap().path();
|
||||
if p.is_dir() {
|
||||
sources_any(&p, out);
|
||||
} else if p.extension().is_some_and(|x| ["rs", "toml", "ts", "svelte", "js", "html"].iter().any(|e| x == *e)) {
|
||||
out.push(p);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
//! `approveAgent` signed by the built-in wallet, through the Turnkey client, against the
|
||||
//! local Turnkey stand-in (`tr_turnkey::mock`, not Turnkey). The wallet there is the Python
|
||||
//! SDK's public test key, so the signature must be the SDK's own vector.
|
||||
|
||||
use alloy_primitives::Address;
|
||||
use tr_hl::actions::ApproveAgentAction;
|
||||
use tr_hl::signing::{approve_agent_hash, sign_approve_agent_with};
|
||||
use tr_turnkey::mock::MockTurnkey;
|
||||
use tr_turnkey::{Client, Session, SessionKey};
|
||||
|
||||
/// The SDK's public test key (signing_test.py). Never funded; never used on a network.
|
||||
const SDK_KEY: &str = "0x0123456789012345678901234567890123456789012345678901234567890123";
|
||||
|
||||
fn now_secs() -> u64 {
|
||||
std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_secs()
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn the_built_in_wallet_reproduces_the_python_sdk_vector() {
|
||||
let mock = MockTurnkey::start(0, Some(SDK_KEY)).await.unwrap();
|
||||
let key = SessionKey::generate();
|
||||
let session = Session::accept(
|
||||
SessionKey::from_hex(&key.to_hex()).unwrap(),
|
||||
&mock.mint_session(&key.public_hex(), 60),
|
||||
now_secs(),
|
||||
)
|
||||
.unwrap();
|
||||
let client = Client::with_base(&mock.base).unwrap();
|
||||
let master: Address = client.ethereum_account(&session).await.unwrap().address.parse().unwrap();
|
||||
assert_eq!(format!("{master:#x}"), "0x14791697260e4c9a71f18484c9f997b308e59325");
|
||||
|
||||
let a = ApproveAgentAction {
|
||||
kind: "approveAgent".into(),
|
||||
hyperliquid_chain: "Testnet".into(),
|
||||
signature_chain_id: "0x66eee".into(),
|
||||
agent_address: "0x5e9ee1089755c3435139848e47e6635505d5a13a".into(),
|
||||
agent_name: "terminal-vector valid_until 1700000000000".into(),
|
||||
nonce: 1700000000000,
|
||||
};
|
||||
let sig = sign_approve_agent_with(&a, &master, |d| {
|
||||
let (client, session) = (&client, &session);
|
||||
async move { client.sign_digest(session, &master, &d).await }
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(sig.r, "0x8138ea721e1fb5afe9239b21b19ffe1e4690dc2c06b0aaf661aeb9859f598a25");
|
||||
assert_eq!(sig.s, "0x169543c6dbf8c0caa9e6225e6612e5c3b495afbdc840537be74a6fb465cfc321");
|
||||
assert_eq!(sig.v, 28);
|
||||
// Turnkey was handed exactly the testnet approval's digest.
|
||||
let signed: Vec<_> = mock.requests().into_iter().filter(|(p, _)| p.ends_with("/sign_raw_payload")).collect();
|
||||
assert_eq!(signed.len(), 1);
|
||||
let digest = approve_agent_hash(&a).unwrap();
|
||||
assert_eq!(signed[0].1["parameters"]["payload"], alloy_primitives::hex::encode(digest));
|
||||
}
|
||||
Loaded 100 of 208 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user