Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a12b5d14e4 | ||
|
|
08f54643d9 | ||
|
|
b7efcf5a3a | ||
|
|
5bcf3a50bc | ||
|
|
0690de8c6c | ||
|
|
1b7ab3400d | ||
|
|
94bb329d68 | ||
|
|
dcf11c5755 |
No files matched your search
@@ -93,6 +93,10 @@ jobs:
|
||||
- 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
|
||||
|
||||
@@ -50,7 +50,14 @@ jobs:
|
||||
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"
|
||||
- run: scripts/build-web.sh
|
||||
# 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
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[workspace]
|
||||
resolver = "2"
|
||||
members = ["crates/core", "crates/broker", "crates/congress", "crates/data", "crates/chart", "crates/macos", "crates/proto", "app", "server"]
|
||||
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]
|
||||
@@ -11,6 +11,8 @@ 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" }
|
||||
|
||||
@@ -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
|
||||
@@ -227,6 +232,8 @@ What the browser build can and can't do yet:
|
||||
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/`.
|
||||
|
||||
@@ -13,6 +13,8 @@ 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
|
||||
@@ -23,10 +25,13 @@ serde_json.workspace = true
|
||||
toml.workspace = true
|
||||
tracing.workspace = true
|
||||
futures = "0.3"
|
||||
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"] }
|
||||
@@ -60,6 +65,11 @@ web-sys = { version = "0.3", features = [
|
||||
"Response",
|
||||
"Storage",
|
||||
"Url",
|
||||
"UrlSearchParams",
|
||||
"Window",
|
||||
"console",
|
||||
] }
|
||||
|
||||
[dev-dependencies]
|
||||
async-trait = "0.1"
|
||||
tr-turnkey = { workspace = true, features = ["mock"] }
|
||||
@@ -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,6 +20,8 @@ mod portfolio_view;
|
||||
mod settings_view;
|
||||
mod theming;
|
||||
mod trading;
|
||||
mod wallet;
|
||||
mod wallet_view;
|
||||
mod workspace;
|
||||
|
||||
pub use broker::PosSort;
|
||||
@@ -45,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 {
|
||||
@@ -155,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(),
|
||||
@@ -464,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>,
|
||||
@@ -514,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();
|
||||
@@ -576,6 +637,7 @@ impl TerminalApp {
|
||||
broker,
|
||||
trading: Default::default(),
|
||||
demo,
|
||||
hl: hl::HlState::platform(),
|
||||
worker,
|
||||
provider,
|
||||
panes: Vec::new(),
|
||||
@@ -612,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(),
|
||||
@@ -673,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);
|
||||
@@ -778,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);
|
||||
@@ -932,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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -957,6 +1032,7 @@ impl TerminalApp {
|
||||
prepended,
|
||||
from_cache,
|
||||
exhausted,
|
||||
full,
|
||||
live,
|
||||
} => {
|
||||
let worker = &self.worker;
|
||||
@@ -964,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 } => {
|
||||
@@ -992,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,
|
||||
@@ -1052,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;
|
||||
@@ -1066,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)
|
||||
@@ -1085,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());
|
||||
@@ -1288,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()),
|
||||
}
|
||||
}
|
||||
@@ -1307,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,
|
||||
@@ -1358,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 {
|
||||
@@ -1380,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)
|
||||
@@ -1607,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,
|
||||
@@ -1625,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();
|
||||
|
||||
@@ -154,15 +154,27 @@ 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.
|
||||
@@ -229,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();
|
||||
@@ -810,6 +825,42 @@ mod tests {
|
||||
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");
|
||||
|
||||
@@ -102,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 (label, live) = self.source_chip();
|
||||
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.
|
||||
@@ -134,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();
|
||||
@@ -282,33 +293,6 @@ impl TerminalApp {
|
||||
}
|
||||
}
|
||||
|
||||
/// The data-source chip's label, and whether any of the data is live.
|
||||
fn source_chip(&self) -> (&'static str, bool) {
|
||||
let n = self.provider.to_lowercase();
|
||||
if n == "sample" {
|
||||
("Offline", false)
|
||||
} else if n.contains("sample") {
|
||||
("Crypto live", true)
|
||||
} else {
|
||||
("Live", true)
|
||||
}
|
||||
}
|
||||
|
||||
fn provider_label(&self) -> String {
|
||||
let n = self.provider.to_lowercase();
|
||||
if n == "sample" {
|
||||
"Offline · sample data".into()
|
||||
} 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 if let Some(rest) = self.provider.strip_prefix("server+") {
|
||||
// TR_SERVER_URL is set (tr_data::remote::RemoteMarkets).
|
||||
format!("Live · crypto from Hyperliquid perps through the Terminal server; other markets from {rest}")
|
||||
} 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();
|
||||
@@ -471,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
|
||||
}
|
||||
@@ -582,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() };
|
||||
@@ -610,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())));
|
||||
@@ -670,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());
|
||||
@@ -991,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));
|
||||
}
|
||||
}
|
||||
@@ -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()];
|
||||
|
||||
@@ -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()
|
||||
@@ -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 {
|
||||
@@ -1183,13 +1217,13 @@ 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, 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();
|
||||
}
|
||||
@@ -1198,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();
|
||||
}
|
||||
@@ -1206,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();
|
||||
}
|
||||
|
||||
@@ -81,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> {
|
||||
@@ -116,6 +118,7 @@ impl Ticket {
|
||||
placing: false,
|
||||
error: None,
|
||||
done: None,
|
||||
hl: Default::default(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -127,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);
|
||||
@@ -152,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;
|
||||
@@ -303,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
|
||||
@@ -464,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()));
|
||||
@@ -541,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()),
|
||||
@@ -592,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,
|
||||
@@ -747,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;
|
||||
@@ -926,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)
|
||||
@@ -945,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;
|
||||
@@ -1836,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() {
|
||||
@@ -1851,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)));
|
||||
@@ -1974,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;
|
||||
|
||||
@@ -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,
|
||||
@@ -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,
|
||||
) {
|
||||
@@ -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() {
|
||||
@@ -450,13 +460,33 @@ impl ChartState {
|
||||
}
|
||||
}
|
||||
|
||||
/// 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 };
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -7,6 +7,7 @@ mod chart_view;
|
||||
mod command;
|
||||
mod congress_view;
|
||||
mod dock;
|
||||
mod hl_worker;
|
||||
mod layout;
|
||||
mod layouts;
|
||||
mod markets_view;
|
||||
@@ -15,6 +16,7 @@ mod prefs;
|
||||
mod storage;
|
||||
mod theme;
|
||||
mod themes;
|
||||
mod wallet_worker;
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
mod web;
|
||||
mod widgets;
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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" });
|
||||
@@ -841,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.
|
||||
|
||||
@@ -141,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) }
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,10 @@
|
||||
//!
|
||||
//! 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;
|
||||
|
||||
@@ -19,6 +23,13 @@ pub fn start() {
|
||||
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()
|
||||
@@ -29,7 +40,7 @@ pub fn start() {
|
||||
// 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), 1, t0)?;
|
||||
let mut app = crate::create_app(cc, tr_data::for_browser(offline, server.as_deref()), 1, t0)?;
|
||||
if perf {
|
||||
app.show_perf();
|
||||
}
|
||||
|
||||
@@ -34,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>),
|
||||
@@ -68,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,
|
||||
@@ -129,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
|
||||
@@ -225,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,
|
||||
@@ -238,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,
|
||||
},
|
||||
@@ -262,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,
|
||||
@@ -287,7 +319,10 @@ pub enum Response {
|
||||
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 {
|
||||
@@ -305,6 +340,9 @@ 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>,
|
||||
@@ -367,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,
|
||||
@@ -407,8 +446,14 @@ impl Worker {
|
||||
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(), db: Mutex::new(Db::open_in_memory()?), out, ctx });
|
||||
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();
|
||||
@@ -571,40 +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() => {
|
||||
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,
|
||||
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();
|
||||
@@ -613,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);
|
||||
@@ -629,10 +668,14 @@ 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 } => {
|
||||
@@ -661,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();
|
||||
@@ -708,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:#}"))),
|
||||
@@ -751,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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -828,6 +890,10 @@ 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())
|
||||
}
|
||||
@@ -849,16 +915,76 @@ fn apply_live_bar(bars: &mut Vec<Bar>, bar: Bar) -> Applied {
|
||||
Applied { changed, inserted: bars.len() > len && bars.last().map(|b| b.ts) == last }
|
||||
}
|
||||
|
||||
fn start_live(s: Arc<Shared>, pane: PaneId, generation: u64, symbol: String, tf: Timeframe) {
|
||||
/// 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 };
|
||||
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 => {
|
||||
@@ -889,6 +1015,7 @@ fn start_live(s: Arc<Shared>, pane: PaneId, generation: u64, symbol: String, tf:
|
||||
prepended: 0,
|
||||
from_cache: false,
|
||||
exhausted: false,
|
||||
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),
|
||||
@@ -897,6 +1024,17 @@ fn start_live(s: Arc<Shared>, pane: PaneId, generation: u64, symbol: String, tf:
|
||||
}
|
||||
}
|
||||
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();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1022,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,
|
||||
@@ -1036,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();
|
||||
|
||||
@@ -16,10 +16,15 @@ 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
|
||||
|
||||
|
||||
@@ -342,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);
|
||||
@@ -522,6 +523,7 @@ impl DemoBook {
|
||||
currency: "USD".into(),
|
||||
cash_equivalent: false,
|
||||
underlying: None,
|
||||
market: None,
|
||||
}),
|
||||
}
|
||||
} else {
|
||||
@@ -561,6 +563,7 @@ impl DemoBook {
|
||||
currency: "USD".into(),
|
||||
trade_date: Some(now),
|
||||
settlement_date: Some(now),
|
||||
market: None,
|
||||
},
|
||||
);
|
||||
Ok(())
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -246,20 +246,32 @@ mod keychain {
|
||||
const NOT_FOUND: i32 = -25300;
|
||||
|
||||
pub fn get(service: &str) -> Result<Option<String>> {
|
||||
match get_generic_password(service, KEYCHAIN_ACCOUNT) {
|
||||
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(service: &str, value: &str) -> Result<()> {
|
||||
set_generic_password(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(service: &str) -> Result<()> {
|
||||
match delete_generic_password(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(()),
|
||||
}
|
||||
@@ -279,6 +291,194 @@ mod keychain {
|
||||
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)]
|
||||
@@ -350,6 +550,44 @@ mod tests {
|
||||
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")]
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -5,6 +5,7 @@ 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");
|
||||
|
||||
@@ -6,6 +6,7 @@ edition.workspace = true
|
||||
|
||||
[dependencies]
|
||||
tr-core.workspace = true
|
||||
tr-proto.workspace = true
|
||||
anyhow.workspace = true
|
||||
chrono.workspace = true
|
||||
serde.workspace = true
|
||||
@@ -17,7 +18,6 @@ futures = "0.3"
|
||||
web-time = "1"
|
||||
|
||||
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
|
||||
tr-proto.workspace = true
|
||||
tokio.workspace = true
|
||||
rusqlite.workspace = true
|
||||
tokio-tungstenite = { version = "0.30", default-features = false, features = ["connect", "rustls-tls-webpki-roots"] }
|
||||
|
||||
@@ -18,7 +18,8 @@
|
||||
//!
|
||||
//! 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.
|
||||
//! 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;
|
||||
@@ -27,7 +28,6 @@ pub mod cache;
|
||||
pub mod crypto;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub mod markets;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub mod remote;
|
||||
pub mod rss;
|
||||
pub mod rt;
|
||||
@@ -35,7 +35,8 @@ pub mod sample;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub mod treasury;
|
||||
pub mod web;
|
||||
mod ws;
|
||||
/// 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;
|
||||
|
||||
@@ -156,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 {
|
||||
@@ -275,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();
|
||||
@@ -327,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
|
||||
@@ -339,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());
|
||||
}
|
||||
@@ -350,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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -370,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
|
||||
@@ -381,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
|
||||
@@ -388,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),
|
||||
@@ -404,6 +440,17 @@ 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 {
|
||||
@@ -435,11 +482,18 @@ pub fn from_env() -> Provider {
|
||||
}
|
||||
|
||||
/// The browser build's provider: sample data only when `offline`, otherwise live crypto
|
||||
/// with sample data for everything else.
|
||||
pub fn for_browser(offline: bool) -> Provider {
|
||||
/// 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) => return Arc::new(Fallback::new(m)),
|
||||
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:#}"),
|
||||
}
|
||||
}
|
||||
@@ -516,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),
|
||||
},
|
||||
|
||||
@@ -1,28 +1,33 @@
|
||||
//! A client for the Terminal data server (`tr-gateway`), and [`RemoteMarkets`], which serves
|
||||
//! crypto markets through it (docs/architecture/server.md §4.6).
|
||||
//! crypto markets and Hyperliquid HIP-3 perps through it (docs/architecture/server.md §4.6).
|
||||
//!
|
||||
//! [`Client`] keeps one connection per server, shared by every subscription and request. It
|
||||
//! reconnects with backoff, resubscribes everything (each subscription then gets a fresh
|
||||
//! `Snapshot`), checks each subscription's `seq` and asks for a `Resync` on a gap. A
|
||||
//! subscription the server gave up on because of its upstream is subscribed again, with backoff.
|
||||
//! It runs on either target: tokio on the desktop, the page's event loop in the browser build.
|
||||
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::sync::atomic::{AtomicU32, Ordering};
|
||||
use std::sync::{Arc, Mutex, OnceLock};
|
||||
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
|
||||
use std::time::Duration;
|
||||
|
||||
use anyhow::{Context, Result, anyhow, bail};
|
||||
use async_trait::async_trait;
|
||||
use futures::{SinkExt, StreamExt};
|
||||
use tokio::sync::{mpsc, oneshot, watch};
|
||||
use tokio_tungstenite::tungstenite::Message;
|
||||
use tr_core::{AssetClass, Bar, Timeframe};
|
||||
use tr_proto::{
|
||||
ApiError, BarDelta, ClientInfo, ClientMsg, Encoding, ErrorCode, Frame, MarketKey, PROTOCOL_VERSION, ReqId,
|
||||
ResyncReason, Rpc, RpcResult, ServerMsg, SubId, TopicData, TopicDelta, TopicReq, TopicStatus, UnavailableReason,
|
||||
ApiError, BarDelta, ClientInfo, ClientMsg, Encoding, ErrorCode, Frame, InstrumentKind, MarketKey, PROTOCOL_VERSION,
|
||||
ReqId, ResyncReason, Rpc, RpcResult, ServerMsg, SubId, TopicData, TopicDelta, TopicReq, TopicStatus,
|
||||
UnavailableReason,
|
||||
};
|
||||
use web_time::{Instant, SystemTime, UNIX_EPOCH};
|
||||
|
||||
use crate::{LiveFeed, MarketData, NewsItem, OptionChain, OrderBook, Quote, SearchHit, YieldCurves, rt, security};
|
||||
use crate::ws::{Message, Socket};
|
||||
use crate::{
|
||||
LiveFeed, MarketData, NewsItem, OptionChain, OrderBook, Quote, SearchHit, TradePrint, YieldCurves, is_perp, rt,
|
||||
security,
|
||||
};
|
||||
|
||||
/// What a subscription receives.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
@@ -49,7 +54,12 @@ pub struct Update {
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum ConnState {
|
||||
Connecting,
|
||||
Up { session: u64 },
|
||||
/// Connected. The server numbers the `session`; `connection` counts this client's connections
|
||||
/// from 1.
|
||||
Up {
|
||||
session: u64,
|
||||
connection: u64,
|
||||
},
|
||||
Down(String),
|
||||
}
|
||||
|
||||
@@ -59,7 +69,8 @@ enum Cmd {
|
||||
Request { id: ReqId, req: Rpc, reply: oneshot::Sender<Result<RpcResult, ApiError>> },
|
||||
}
|
||||
|
||||
/// A shared connection to one server. Cheap to clone. Create it inside a tokio runtime.
|
||||
/// A shared connection to one server. Cheap to clone. On the desktop, create it inside a tokio
|
||||
/// runtime.
|
||||
#[derive(Clone)]
|
||||
pub struct Client {
|
||||
cmd: mpsc::UnboundedSender<Cmd>,
|
||||
@@ -103,8 +114,8 @@ pub fn normalize_url(url: &str) -> String {
|
||||
|
||||
fn to_message(f: Frame) -> Message {
|
||||
match f {
|
||||
Frame::Binary(b) => Message::Binary(b.into()),
|
||||
Frame::Text(t) => Message::Text(t.into()),
|
||||
Frame::Binary(b) => Message::Binary(b),
|
||||
Frame::Text(t) => Message::Text(t),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -118,7 +129,7 @@ impl Client {
|
||||
let (cmd, rx) = mpsc::unbounded_channel();
|
||||
let (state_tx, state) = watch::channel(ConnState::Connecting);
|
||||
let info = ClientInfo { name: name.into(), version: env!("CARGO_PKG_VERSION").into() };
|
||||
tokio::spawn(run(normalize_url(url), encoding, info, rx, state_tx));
|
||||
rt::spawn(run(normalize_url(url), encoding, info, rx, state_tx));
|
||||
Client { cmd, next_id: Arc::new(AtomicU32::new(1)), state }
|
||||
}
|
||||
|
||||
@@ -139,7 +150,7 @@ impl Client {
|
||||
}
|
||||
}
|
||||
};
|
||||
tokio::time::timeout(timeout, up).await.is_ok()
|
||||
rt::timeout(timeout, up).await.is_ok()
|
||||
}
|
||||
|
||||
pub fn subscribe(&self, topic: TopicReq) -> Subscription {
|
||||
@@ -158,7 +169,7 @@ impl Client {
|
||||
let id = self.next_id.fetch_add(1, Ordering::Relaxed);
|
||||
let (reply, rx) = oneshot::channel();
|
||||
self.cmd.send(Cmd::Request { id, req, reply }).map_err(|_| err("client stopped"))?;
|
||||
match tokio::time::timeout(Duration::from_secs(30), rx).await {
|
||||
match rt::timeout(Duration::from_secs(30), rx).await {
|
||||
Ok(Ok(r)) => r,
|
||||
Ok(Err(_)) => Err(err("connection lost")),
|
||||
Err(_) => Err(err("request timed out")),
|
||||
@@ -189,21 +200,19 @@ impl SubState {
|
||||
}
|
||||
}
|
||||
|
||||
type Ws = tokio_tungstenite::WebSocketStream<tokio_tungstenite::MaybeTlsStream<tokio::net::TcpStream>>;
|
||||
|
||||
async fn hello(url: &str, enc: Encoding, info: &ClientInfo) -> Result<(Ws, u64)> {
|
||||
let (mut ws, _) = tokio::time::timeout(Duration::from_secs(10), tokio_tungstenite::connect_async(url))
|
||||
async fn hello(url: &str, enc: Encoding, info: &ClientInfo) -> Result<(Socket, u64)> {
|
||||
let mut ws = rt::timeout(Duration::from_secs(10), Socket::connect(url))
|
||||
.await
|
||||
.context("connect timed out")?
|
||||
.context("connect")?;
|
||||
let hello =
|
||||
ClientMsg::Hello { version: PROTOCOL_VERSION, encoding: enc, client: info.clone(), token: None, resume: None };
|
||||
ws.send(to_message(tr_proto::encode(&hello, enc)?)).await?;
|
||||
let welcome = tokio::time::timeout(Duration::from_secs(10), async {
|
||||
while let Some(m) = ws.next().await {
|
||||
ws.send_message(to_message(tr_proto::encode(&hello, enc)?)).await?;
|
||||
let welcome = rt::timeout(Duration::from_secs(10), async {
|
||||
while let Some(m) = ws.next_message().await {
|
||||
match decode(&m?) {
|
||||
Some(Ok(ServerMsg::Welcome { session, .. })) => return Ok(session.0),
|
||||
Some(Ok(ServerMsg::Error { code, message })) => bail!("{code:?}: {message}"),
|
||||
Ok(ServerMsg::Welcome { session, .. }) => return Ok(session.0),
|
||||
Ok(ServerMsg::Error { code, message }) => bail!("{code:?}: {message}"),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
@@ -214,11 +223,10 @@ async fn hello(url: &str, enc: Encoding, info: &ClientInfo) -> Result<(Ws, u64)>
|
||||
Ok((ws, welcome))
|
||||
}
|
||||
|
||||
fn decode(m: &Message) -> Option<Result<ServerMsg, tr_proto::CodecError>> {
|
||||
fn decode(m: &Message) -> Result<ServerMsg, tr_proto::CodecError> {
|
||||
match m {
|
||||
Message::Binary(b) => Some(tr_proto::decode_binary(b)),
|
||||
Message::Text(t) => Some(tr_proto::decode_text(t.as_str())),
|
||||
_ => None,
|
||||
Message::Binary(b) => tr_proto::decode_binary(b),
|
||||
Message::Text(t) => tr_proto::decode_text(t),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -231,11 +239,13 @@ async fn run(
|
||||
) {
|
||||
let mut subs: HashMap<SubId, SubState> = HashMap::new();
|
||||
let mut backoff = Duration::from_millis(500);
|
||||
let mut connection = 0;
|
||||
loop {
|
||||
state.send_replace(ConnState::Connecting);
|
||||
match hello(&url, enc, &info).await {
|
||||
Ok((ws, session)) => {
|
||||
state.send_replace(ConnState::Up { session });
|
||||
connection += 1;
|
||||
state.send_replace(ConnState::Up { session, connection });
|
||||
backoff = Duration::from_millis(500);
|
||||
if !connected(ws, enc, &mut subs, &mut cmds).await {
|
||||
return;
|
||||
@@ -255,7 +265,7 @@ async fn run(
|
||||
}
|
||||
// Back off, still taking commands: subscriptions wait for the next connection,
|
||||
// requests fail now.
|
||||
let sleep = tokio::time::sleep(backoff);
|
||||
let sleep = rt::sleep(backoff);
|
||||
tokio::pin!(sleep);
|
||||
loop {
|
||||
tokio::select! {
|
||||
@@ -280,37 +290,35 @@ async fn run(
|
||||
|
||||
/// Serve one connection. Returns false once every handle to the client is gone.
|
||||
async fn connected(
|
||||
ws: Ws,
|
||||
mut ws: Socket,
|
||||
enc: Encoding,
|
||||
subs: &mut HashMap<SubId, SubState>,
|
||||
cmds: &mut mpsc::UnboundedReceiver<Cmd>,
|
||||
) -> bool {
|
||||
let (mut tx, mut rx) = ws.split();
|
||||
let mut pending: HashMap<ReqId, oneshot::Sender<Result<RpcResult, ApiError>>> = HashMap::new();
|
||||
let mut queue: Vec<ClientMsg> =
|
||||
subs.iter().map(|(id, s)| ClientMsg::Subscribe { id: *id, topic: s.topic.clone() }).collect();
|
||||
let mut ping = tokio::time::interval(Duration::from_secs(15));
|
||||
let mut ping = rt::interval(Duration::from_secs(15));
|
||||
loop {
|
||||
for m in queue.drain(..) {
|
||||
let Ok(f) = tr_proto::encode(&m, enc) else { continue };
|
||||
if tx.send(to_message(f)).await.is_err() {
|
||||
if ws.send_message(to_message(f)).await.is_err() {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
let retry = subs.values().filter_map(|s| s.retry_at).min();
|
||||
tokio::select! {
|
||||
m = rx.next() => {
|
||||
m = ws.next_message() => {
|
||||
let received = SystemTime::now();
|
||||
let Some(Ok(m)) = m else { return true };
|
||||
match decode(&m) {
|
||||
Some(Ok(msg)) => route(msg, received, subs, &mut pending, &mut queue),
|
||||
Some(Err(e)) => tracing::debug!("terminal server sent an unreadable frame: {e}"),
|
||||
None => {}
|
||||
Ok(msg) => route(msg, received, subs, &mut pending, &mut queue),
|
||||
Err(e) => tracing::debug!("terminal server sent an unreadable frame: {e}"),
|
||||
}
|
||||
}
|
||||
cmd = cmds.recv() => match cmd {
|
||||
None => {
|
||||
let _ = tx.close().await;
|
||||
ws.close().await;
|
||||
return false;
|
||||
}
|
||||
Some(Cmd::Subscribe { id, topic, tx: sub }) => {
|
||||
@@ -328,7 +336,7 @@ async fn connected(
|
||||
}
|
||||
},
|
||||
_ = ping.tick() => queue.push(ClientMsg::Ping { t: now_ms() }),
|
||||
_ = async { tokio::time::sleep_until(retry.expect("guarded").into()).await }, if retry.is_some() => {
|
||||
_ = async { rt::sleep(retry.expect("guarded").saturating_duration_since(Instant::now())).await }, if retry.is_some() => {
|
||||
resubscribe_due(subs, &mut queue);
|
||||
}
|
||||
}
|
||||
@@ -408,17 +416,48 @@ fn route(
|
||||
|
||||
// ---- RemoteMarkets ----
|
||||
|
||||
/// What the order book pane shows as the source of a book that came through the server.
|
||||
/// What the order book pane shows as the source of a core perp's book that came through the server.
|
||||
pub const BOOK_SOURCE: &str = "Hyperliquid perp · Terminal server";
|
||||
|
||||
/// A server error for the user, saying it came from the Terminal server.
|
||||
fn server_error(e: &ApiError) -> anyhow::Error {
|
||||
from_server(&e.message)
|
||||
}
|
||||
|
||||
fn from_server(message: &str) -> anyhow::Error {
|
||||
if message.contains("Terminal server") { anyhow!("{message}") } else { anyhow!("Terminal server: {message}") }
|
||||
}
|
||||
|
||||
/// The source line for a book from the server: HIP-3 books name their dex.
|
||||
pub fn book_source(symbol: &str) -> String {
|
||||
match tr_core::perps::venue(symbol) {
|
||||
Some(dex) => format!("Hyperliquid {dex} perp · Terminal server"),
|
||||
None => BOOK_SOURCE.into(),
|
||||
}
|
||||
}
|
||||
|
||||
struct BookFeed {
|
||||
latest: watch::Receiver<Option<Result<OrderBook, String>>>,
|
||||
used: Arc<Mutex<Instant>>,
|
||||
}
|
||||
|
||||
/// Trades a tape subscription keeps for the pane.
|
||||
const TAPE_KEPT: usize = 100;
|
||||
|
||||
/// What a book or tape says while the server is up but its own upstream is down. The last
|
||||
/// values are old by then, so the pane shows this instead, until fresh data arrives.
|
||||
const UPSTREAM_LOST: &str = "the Terminal server lost its Hyperliquid feed";
|
||||
|
||||
struct TapeFeed {
|
||||
latest: watch::Receiver<Option<Result<Vec<TradePrint>, String>>>,
|
||||
used: Arc<Mutex<Instant>>,
|
||||
}
|
||||
|
||||
/// Crypto markets (`BTCUSD Curncy`) through the Terminal server, which backs them with
|
||||
/// Hyperliquid perps; everything else, and anything the server doesn't list, from `inner`.
|
||||
/// The connection opens on first use, inside the worker's runtime.
|
||||
/// Hyperliquid perps; everything else, and any crypto market the server doesn't list, from
|
||||
/// `inner`. HIP-3 perps (`xyz:AAPL`) come only from the server: no other source has them, so
|
||||
/// while it's unreachable they fail rather than fall back. The connection opens on first use,
|
||||
/// inside the worker's runtime.
|
||||
pub struct RemoteMarkets<P> {
|
||||
url: String,
|
||||
client: OnceLock<Client>,
|
||||
@@ -428,7 +467,11 @@ pub struct RemoteMarkets<P> {
|
||||
unknown: Mutex<HashSet<String>>,
|
||||
/// Markets whose latest history came from the server, so paging and the live feed must too.
|
||||
served: Mutex<HashSet<String>>,
|
||||
/// Markets the server serves whose latest history came from `inner` instead, with the
|
||||
/// connection that was up then (0 for none). A newer connection moves their charts back.
|
||||
fallbacks: Mutex<HashMap<String, u64>>,
|
||||
books: Arc<Mutex<HashMap<String, BookFeed>>>,
|
||||
tapes: Arc<Mutex<HashMap<String, TapeFeed>>>,
|
||||
sparks: Mutex<HashMap<String, (Instant, Vec<f64>)>>,
|
||||
}
|
||||
|
||||
@@ -443,16 +486,21 @@ impl<P: MarketData + 'static> RemoteMarkets<P> {
|
||||
unknown: Mutex::default(),
|
||||
served: Mutex::default(),
|
||||
books: Arc::default(),
|
||||
tapes: Arc::default(),
|
||||
sparks: Mutex::default(),
|
||||
fallbacks: Mutex::default(),
|
||||
}
|
||||
}
|
||||
|
||||
fn client(&self) -> &Client {
|
||||
self.client.get_or_init(|| Client::connect(&self.url, Encoding::Postcard, "terminal-desktop"))
|
||||
let name = if cfg!(target_arch = "wasm32") { "terminal-web" } else { "terminal-desktop" };
|
||||
self.client.get_or_init(|| Client::connect(&self.url, Encoding::Postcard, name))
|
||||
}
|
||||
|
||||
fn routed(&self, symbol: &str) -> bool {
|
||||
security(symbol).class == AssetClass::Crypto && !self.unknown.lock().expect("unknown lock").contains(symbol)
|
||||
is_perp(symbol)
|
||||
|| security(symbol).class == AssetClass::Crypto
|
||||
&& !self.unknown.lock().expect("unknown lock").contains(symbol)
|
||||
}
|
||||
|
||||
fn served(&self, symbol: &str) -> bool {
|
||||
@@ -502,7 +550,7 @@ impl<P: MarketData + 'static> RemoteMarkets<P> {
|
||||
}
|
||||
let topic = TopicReq::Quotes { keys: keys.iter().map(MarketKey::new).collect(), max_hz: 1 };
|
||||
let mut sub = client.subscribe(topic);
|
||||
let quotes = tokio::time::timeout(Duration::from_secs(5), async {
|
||||
let quotes = rt::timeout(Duration::from_secs(5), async {
|
||||
while let Some(u) = sub.next().await {
|
||||
match u.event {
|
||||
Event::Snapshot(TopicData::Quotes(q)) => return Ok(q),
|
||||
@@ -530,6 +578,9 @@ impl<P: MarketData + 'static> RemoteMarkets<P> {
|
||||
}
|
||||
|
||||
async fn server_book(&self, symbol: &str, depth: usize) -> Result<OrderBook> {
|
||||
if !self.client().wait_up(Duration::from_secs(3)).await {
|
||||
bail!("the Terminal server is not reachable");
|
||||
}
|
||||
let mut latest = {
|
||||
let mut books = self.books.lock().expect("books lock");
|
||||
match books.get(symbol) {
|
||||
@@ -548,7 +599,7 @@ impl<P: MarketData + 'static> RemoteMarkets<P> {
|
||||
}
|
||||
}
|
||||
};
|
||||
let got = tokio::time::timeout(Duration::from_secs(3), latest.wait_for(Option::is_some))
|
||||
let got = rt::timeout(Duration::from_secs(3), latest.wait_for(Option::is_some))
|
||||
.await
|
||||
.context("no book from the Terminal server")?
|
||||
.map_err(|_| anyhow!("book feed stopped"))?
|
||||
@@ -560,6 +611,84 @@ impl<P: MarketData + 'static> RemoteMarkets<P> {
|
||||
}
|
||||
}
|
||||
|
||||
impl<P: MarketData + 'static> RemoteMarkets<P> {
|
||||
async fn server_trades(&self, symbol: &str, n: usize) -> Result<Vec<TradePrint>> {
|
||||
if !self.client().wait_up(Duration::from_secs(3)).await {
|
||||
bail!("the Terminal server is not reachable");
|
||||
}
|
||||
let mut latest = {
|
||||
let mut tapes = self.tapes.lock().expect("tapes lock");
|
||||
match tapes.get(symbol) {
|
||||
Some(f) => {
|
||||
*f.used.lock().expect("used lock") = Instant::now();
|
||||
f.latest.clone()
|
||||
}
|
||||
None => {
|
||||
let (tx, latest) = watch::channel(None);
|
||||
let used = Arc::new(Mutex::new(Instant::now()));
|
||||
let sub = self.client().subscribe(TopicReq::Trades { key: MarketKey::new(symbol) });
|
||||
rt::spawn(tape_feed(symbol.to_string(), sub, tx, used.clone(), self.tapes.clone()));
|
||||
tapes.insert(symbol.to_string(), TapeFeed { latest: latest.clone(), used });
|
||||
latest
|
||||
}
|
||||
}
|
||||
};
|
||||
let got = rt::timeout(Duration::from_secs(3), latest.wait_for(Option::is_some))
|
||||
.await
|
||||
.context("no trades from the Terminal server")?
|
||||
.map_err(|_| anyhow!("trades feed stopped"))?
|
||||
.clone();
|
||||
let trades = got.expect("waited for Some").map_err(|e| anyhow!(e))?;
|
||||
Ok(trades[trades.len().saturating_sub(n)..].to_vec())
|
||||
}
|
||||
}
|
||||
|
||||
/// Keep one trades subscription per market while the pane keeps asking, with the latest
|
||||
/// [`TAPE_KEPT`] trades, and drop it 30 s after the last ask.
|
||||
async fn tape_feed(
|
||||
symbol: String,
|
||||
mut sub: Subscription,
|
||||
tx: watch::Sender<Option<Result<Vec<TradePrint>, String>>>,
|
||||
used: Arc<Mutex<Instant>>,
|
||||
tapes: Arc<Mutex<HashMap<String, TapeFeed>>>,
|
||||
) {
|
||||
let print =
|
||||
|t: &tr_proto::Trade| TradePrint { ts_ms: t.time_ms, px: t.px, sz: t.sz, buy: t.side == tr_proto::Side::Buy };
|
||||
let mut tape: Vec<TradePrint> = Vec::new();
|
||||
let mut check = rt::interval(Duration::from_secs(5));
|
||||
loop {
|
||||
tokio::select! {
|
||||
u = sub.next() => {
|
||||
let Some(u) = u else { break };
|
||||
match u.event {
|
||||
// A snapshot starts over: after a resync the trades in between may be missing.
|
||||
Event::Snapshot(TopicData::Trades(t)) => tape = t.iter().map(print).collect(),
|
||||
Event::Delta(TopicDelta::Trades(t)) => tape.extend(t.iter().map(print)),
|
||||
Event::Status(TopicStatus::Unavailable { detail, .. }) => {
|
||||
tx.send_replace(Some(Err(detail)));
|
||||
continue;
|
||||
}
|
||||
Event::Status(TopicStatus::Stale { .. }) => {
|
||||
tx.send_replace(Some(Err(UPSTREAM_LOST.into())));
|
||||
continue;
|
||||
}
|
||||
Event::Reconnecting => {
|
||||
tx.send_replace(Some(Err("the Terminal server is not reachable".into())));
|
||||
continue;
|
||||
}
|
||||
_ => continue,
|
||||
}
|
||||
tape.drain(..tape.len().saturating_sub(TAPE_KEPT));
|
||||
tx.send_replace(Some(Ok(tape.clone())));
|
||||
}
|
||||
_ = check.tick() => if used.lock().expect("used lock").elapsed() > Duration::from_secs(30) {
|
||||
break;
|
||||
},
|
||||
}
|
||||
}
|
||||
tapes.lock().expect("tapes lock").remove(&symbol);
|
||||
}
|
||||
|
||||
/// Keep one book subscription per market while the pane keeps asking, and drop it 30 s after
|
||||
/// the last ask.
|
||||
async fn book_feed(
|
||||
@@ -569,7 +698,7 @@ async fn book_feed(
|
||||
used: Arc<Mutex<Instant>>,
|
||||
books: Arc<Mutex<HashMap<String, BookFeed>>>,
|
||||
) {
|
||||
let mut check = tokio::time::interval(Duration::from_secs(5));
|
||||
let mut check = rt::interval(Duration::from_secs(5));
|
||||
loop {
|
||||
tokio::select! {
|
||||
u = sub.next() => {
|
||||
@@ -580,6 +709,15 @@ async fn book_feed(
|
||||
tx.send_replace(Some(Err(detail)));
|
||||
continue;
|
||||
}
|
||||
Event::Status(TopicStatus::Stale { .. }) => {
|
||||
tx.send_replace(Some(Err(UPSTREAM_LOST.into())));
|
||||
continue;
|
||||
}
|
||||
// The last book goes stale while the connection is down; the next snapshot replaces it.
|
||||
Event::Reconnecting => {
|
||||
tx.send_replace(Some(Err("the Terminal server is not reachable".into())));
|
||||
continue;
|
||||
}
|
||||
_ => continue,
|
||||
};
|
||||
let level = |l: &tr_proto::Level| (l.px, l.sz);
|
||||
@@ -587,7 +725,7 @@ async fn book_feed(
|
||||
symbol: symbol.clone(),
|
||||
bids: book.bids.iter().map(level).collect(),
|
||||
asks: book.asks.iter().map(level).collect(),
|
||||
source: BOOK_SOURCE.into(),
|
||||
source: book_source(&symbol),
|
||||
simulated: false,
|
||||
})));
|
||||
}
|
||||
@@ -611,19 +749,24 @@ async fn history_since(client: &Client, key: &MarketKey, tf: Timeframe, from: i6
|
||||
|
||||
type Fetch = std::pin::Pin<Box<dyn Future<Output = Result<Vec<Bar>, ApiError>> + Send>>;
|
||||
|
||||
/// How long a chart's live feed from the server waits for a dropped connection to come back
|
||||
/// before it ends, so the worker reloads the chart from the fallback.
|
||||
const OUTAGE_GRACE: Duration = Duration::from_secs(10);
|
||||
|
||||
/// The server's bar stream, onto the worker's live feed. Live bars go straight through. After an
|
||||
/// outage the stream picks up at the current bar, so the bars from the last one sent up to it
|
||||
/// come from history: one request at a time per feed, retried with backoff, every outstanding
|
||||
/// gap merged into one range. The worker slots those bars into the gap. Everything stops when
|
||||
/// the worker drops the feed.
|
||||
/// gap merged into one range. The worker slots those bars into the gap. A connection that stays
|
||||
/// down past `OUTAGE_GRACE` ends the feed instead. Everything stops when the worker drops the feed.
|
||||
async fn live_bridge(client: Client, key: MarketKey, tf: Timeframe, mut sub: Subscription, tx: mpsc::Sender<Bar>) {
|
||||
// The newest bar sent on, and whether the stream broke since.
|
||||
// The newest bar sent on, whether the stream broke since, and when the connection dropped.
|
||||
let mut last: Option<i64> = None;
|
||||
let mut broken = false;
|
||||
let mut away: Option<Instant> = None;
|
||||
// Bars still to fetch, `[from, until)`, and the range the request in flight covers.
|
||||
let mut pending: Option<(i64, i64)> = None;
|
||||
let mut fetching: Option<((i64, i64), Fetch)> = None;
|
||||
let mut retry_at: Option<tokio::time::Instant> = None;
|
||||
let mut retry_at: Option<Instant> = None;
|
||||
let mut wait = Duration::from_secs(1);
|
||||
let union = |a: Option<(i64, i64)>, b: (i64, i64)| Some(a.map_or(b, |a| (a.0.min(b.0), a.1.max(b.1))));
|
||||
loop {
|
||||
@@ -634,15 +777,25 @@ async fn live_bridge(client: Client, key: MarketKey, tf: Timeframe, mut sub: Sub
|
||||
let (client, key) = (client.clone(), key.clone());
|
||||
fetching = Some((range, Box::pin(async move { history_since(&client, &key, tf, range.0).await })));
|
||||
}
|
||||
let retry_in = retry_at.map(|t| t.saturating_duration_since(Instant::now()));
|
||||
let give_up_in = away.map(|t| OUTAGE_GRACE.saturating_sub(t.elapsed()));
|
||||
tokio::select! {
|
||||
u = sub.next() => {
|
||||
let Some(u) = u else { return };
|
||||
let bars = match u.event {
|
||||
Event::Resync(_) | Event::Reconnecting | Event::Resubscribing => {
|
||||
Event::Resync(_) | Event::Resubscribing => {
|
||||
broken = true;
|
||||
continue;
|
||||
}
|
||||
Event::Snapshot(TopicData::Bars(b)) => b,
|
||||
Event::Reconnecting => {
|
||||
broken = true;
|
||||
away.get_or_insert_with(Instant::now);
|
||||
continue;
|
||||
}
|
||||
Event::Snapshot(TopicData::Bars(b)) => {
|
||||
away = None;
|
||||
b
|
||||
}
|
||||
// A `Closed` bar carries the bar's final values; merging it into the forming
|
||||
// bar the worker holds gives the same bar (the server's forming bars are
|
||||
// full states, never partial ones).
|
||||
@@ -677,14 +830,49 @@ async fn live_bridge(client: Client, key: MarketKey, tf: Timeframe, mut sub: Sub
|
||||
Err(e) => {
|
||||
tracing::debug!("terminal server, backfilling {key}: {e}; retrying in {wait:?}");
|
||||
pending = union(pending, range);
|
||||
retry_at = Some(tokio::time::Instant::now() + wait);
|
||||
retry_at = Some(Instant::now() + wait);
|
||||
wait = (wait * 2).min(Duration::from_secs(30));
|
||||
}
|
||||
}
|
||||
}
|
||||
_ = async { tokio::time::sleep_until(retry_at.expect("guarded")).await }, if retry_at.is_some() => {
|
||||
_ = async { rt::sleep(retry_in.expect("guarded")).await }, if retry_in.is_some() => {
|
||||
retry_at = None;
|
||||
}
|
||||
_ = async { rt::sleep(give_up_in.expect("guarded")).await }, if give_up_in.is_some() => {
|
||||
tracing::debug!("terminal server still unreachable; ending the {key} {tf} feed");
|
||||
return;
|
||||
}
|
||||
_ = tx.closed() => return,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A fallback's live bars for a market the server serves, passed on until a connection newer than
|
||||
/// `after` is up, one the chart didn't fall back on. Then the feed ends, so the worker reloads the
|
||||
/// chart from the server.
|
||||
async fn until_server_up(mut state: watch::Receiver<ConnState>, after: u64, mut feed: LiveFeed, tx: mpsc::Sender<Bar>) {
|
||||
let newer = |s: &ConnState| matches!(s, ConnState::Up { connection, .. } if *connection > after);
|
||||
if newer(&state.borrow_and_update()) {
|
||||
return;
|
||||
}
|
||||
let mut watching = true;
|
||||
loop {
|
||||
tokio::select! {
|
||||
b = feed.recv() => {
|
||||
let Some(b) = b else { return };
|
||||
if tx.send(b).await.is_err() {
|
||||
return;
|
||||
}
|
||||
}
|
||||
changed = state.changed(), if watching => {
|
||||
if changed.is_err() {
|
||||
watching = false;
|
||||
continue;
|
||||
}
|
||||
if newer(&state.borrow_and_update()) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
_ = tx.closed() => return,
|
||||
}
|
||||
}
|
||||
@@ -697,6 +885,20 @@ impl<P: MarketData + 'static> MarketData for RemoteMarkets<P> {
|
||||
}
|
||||
|
||||
async fn history(&self, symbol: &str, tf: Timeframe, end: Option<i64>, limit: usize) -> Result<Vec<Bar>> {
|
||||
if is_perp(symbol) {
|
||||
return match self.server_history(symbol, tf, end, limit).await {
|
||||
Ok(bars) => {
|
||||
self.served.lock().expect("served lock").insert(symbol.to_string());
|
||||
Ok(bars)
|
||||
}
|
||||
Err(e) => {
|
||||
if end.is_none() {
|
||||
self.served.lock().expect("served lock").remove(symbol);
|
||||
}
|
||||
Err(server_error(&e))
|
||||
}
|
||||
};
|
||||
}
|
||||
// Older pages come from wherever the bars already shown came from, so a chart never
|
||||
// splices one venue's bars onto another's.
|
||||
if end.is_some() {
|
||||
@@ -714,6 +916,11 @@ impl<P: MarketData + 'static> MarketData for RemoteMarkets<P> {
|
||||
Ok(_) => {}
|
||||
Err(e) => self.note(symbol, &e),
|
||||
}
|
||||
let connection = match self.client().state() {
|
||||
ConnState::Up { connection, .. } => connection,
|
||||
_ => 0,
|
||||
};
|
||||
self.fallbacks.lock().expect("fallbacks lock").insert(symbol.to_string(), connection);
|
||||
}
|
||||
self.served.lock().expect("served lock").remove(symbol);
|
||||
self.inner.history(symbol, tf, end, limit).await
|
||||
@@ -730,9 +937,11 @@ impl<P: MarketData + 'static> MarketData for RemoteMarkets<P> {
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::debug!("terminal server quotes: {e:#}");
|
||||
other.extend(routed);
|
||||
other.extend(routed.into_iter().filter(|s| !is_perp(s)));
|
||||
}
|
||||
}
|
||||
// Perps the server had no quote for have no other source.
|
||||
other.retain(|s| !is_perp(s));
|
||||
}
|
||||
if !other.is_empty() {
|
||||
match self.inner.quotes(&other).await {
|
||||
@@ -745,8 +954,18 @@ impl<P: MarketData + 'static> MarketData for RemoteMarkets<P> {
|
||||
}
|
||||
|
||||
fn live(&self, symbol: &str, tf: Timeframe) -> Option<LiveFeed> {
|
||||
if !self.served(symbol) && is_perp(symbol) {
|
||||
return None;
|
||||
}
|
||||
if !self.served(symbol) {
|
||||
return self.inner.live(symbol, tf);
|
||||
let feed = self.inner.live(symbol, tf)?;
|
||||
if !self.routed(symbol) {
|
||||
return Some(feed);
|
||||
}
|
||||
let after = self.fallbacks.lock().expect("fallbacks lock").get(symbol).copied().unwrap_or(0);
|
||||
let (tx, rx) = mpsc::channel(256);
|
||||
rt::spawn(until_server_up(self.client().state.clone(), after, feed, tx));
|
||||
return Some(rx);
|
||||
}
|
||||
let (tx, rx) = mpsc::channel(256);
|
||||
let client = self.client().clone();
|
||||
@@ -770,6 +989,9 @@ impl<P: MarketData + 'static> MarketData for RemoteMarkets<P> {
|
||||
}
|
||||
|
||||
async fn order_book(&self, symbol: &str, depth: usize) -> Result<OrderBook> {
|
||||
if is_perp(symbol) {
|
||||
return self.server_book(symbol, depth).await.map_err(|e| from_server(&format!("{e:#}")));
|
||||
}
|
||||
if self.routed(symbol) {
|
||||
match self.server_book(symbol, depth).await {
|
||||
Ok(b) => return Ok(b),
|
||||
@@ -779,6 +1001,26 @@ impl<P: MarketData + 'static> MarketData for RemoteMarkets<P> {
|
||||
self.inner.order_book(symbol, depth).await
|
||||
}
|
||||
|
||||
/// The server's tape for the markets it serves; nothing else has one.
|
||||
async fn trades(&self, symbol: &str, n: usize) -> Result<Vec<TradePrint>> {
|
||||
if !self.routed(symbol) {
|
||||
return self.inner.trades(symbol, n).await;
|
||||
}
|
||||
self.server_trades(symbol, n).await.map_err(|e| from_server(&format!("{e:#}")))
|
||||
}
|
||||
|
||||
/// The server's live HIP-3 perps, from its market list.
|
||||
async fn perp_markets(&self) -> Result<Vec<String>> {
|
||||
match self.client().request(Rpc::Markets).await.map_err(|e| server_error(&e))? {
|
||||
RpcResult::Markets(list) => Ok(list
|
||||
.into_iter()
|
||||
.filter(|m| m.kind == InstrumentKind::Perp && is_perp(m.key.as_str()))
|
||||
.map(|m| m.key.0)
|
||||
.collect()),
|
||||
_ => bail!("Terminal server: unexpected reply"),
|
||||
}
|
||||
}
|
||||
|
||||
async fn yield_curves(&self) -> Result<YieldCurves> {
|
||||
self.inner.yield_curves().await
|
||||
}
|
||||
@@ -860,13 +1102,15 @@ mod tests {
|
||||
/// `Unavailable { Upstream }`, then nothing. Like the real one, it refuses an id that's still
|
||||
/// subscribed, and serves a fresh subscription.
|
||||
async fn older_gateway(listener: tokio::net::TcpListener) {
|
||||
use futures::{SinkExt, StreamExt};
|
||||
use tokio_tungstenite::tungstenite::Message as WsMessage;
|
||||
use tr_proto::{Limits, SessionId};
|
||||
let (tcp, _) = listener.accept().await.unwrap();
|
||||
let mut ws = tokio_tungstenite::accept_async(tcp).await.unwrap();
|
||||
let mut subscribed = HashSet::new();
|
||||
let mut gave_up = false;
|
||||
while let Some(Ok(m)) = ws.next().await {
|
||||
let Message::Binary(b) = m else { continue };
|
||||
let WsMessage::Binary(b) = m else { continue };
|
||||
let reply = match tr_proto::decode_binary::<ClientMsg>(&b).unwrap() {
|
||||
ClientMsg::Hello { .. } => ServerMsg::Welcome {
|
||||
version: PROTOCOL_VERSION,
|
||||
@@ -897,7 +1141,8 @@ mod tests {
|
||||
}
|
||||
_ => continue,
|
||||
};
|
||||
ws.send(to_message(tr_proto::encode(&reply, Encoding::Postcard).unwrap())).await.unwrap();
|
||||
let Frame::Binary(f) = tr_proto::encode(&reply, Encoding::Postcard).unwrap() else { unreachable!() };
|
||||
ws.send(WsMessage::Binary(f.into())).await.unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -37,16 +37,51 @@ pub async fn sleep(d: Duration) {
|
||||
tokio::time::sleep(d).await;
|
||||
}
|
||||
|
||||
/// Resolves after `d`, through `setTimeout`.
|
||||
/// 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 async fn sleep(d: Duration) {
|
||||
let ms = d.as_millis().min(i32::MAX as u128) as i32;
|
||||
let promise = js_sys::Promise::new(&mut |resolve, _| {
|
||||
if let Some(w) = web_sys::window() {
|
||||
let _ = w.set_timeout_with_callback_and_timeout_and_arguments_0(&resolve, ms);
|
||||
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 _ = wasm_bindgen_futures::JsFuture::from(promise).await;
|
||||
}
|
||||
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
|
||||
@@ -86,4 +121,12 @@ mod tests {
|
||||
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, rt, 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()?;
|
||||
|
||||
@@ -3,7 +3,12 @@
|
||||
//! 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.
|
||||
//! 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.
|
||||
@@ -14,7 +19,7 @@ use tr_core::{AssetClass, Bar, Timeframe};
|
||||
|
||||
use crate::crypto::CryptoProvider;
|
||||
use crate::sample::SampleProvider;
|
||||
use crate::{LiveFeed, MarketData, OrderBook, Quote, YieldCurves, security};
|
||||
use crate::{LiveFeed, MarketData, OrderBook, Quote, YieldCurves, is_perp, perp_needs_server, security};
|
||||
|
||||
pub struct WebMarkets {
|
||||
crypto: CryptoProvider,
|
||||
@@ -27,6 +32,38 @@ impl WebMarkets {
|
||||
}
|
||||
}
|
||||
|
||||
/// 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
|
||||
}
|
||||
@@ -38,6 +75,9 @@ impl MarketData for WebMarkets {
|
||||
}
|
||||
|
||||
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 {
|
||||
@@ -46,7 +86,8 @@ impl MarketData for WebMarkets {
|
||||
}
|
||||
|
||||
async fn quotes(&self, symbols: &[String]) -> Result<Vec<Quote>> {
|
||||
let (crypto, other): (Vec<String>, Vec<String>) = symbols.iter().cloned().partition(|s| is_crypto(s));
|
||||
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();
|
||||
@@ -93,4 +134,44 @@ mod tests {
|
||||
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");
|
||||
}
|
||||
}
|
||||
@@ -1,18 +1,45 @@
|
||||
//! A text-message websocket client: tokio-tungstenite on the desktop, the browser's
|
||||
//! `WebSocket` on the web.
|
||||
//! 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;
|
||||
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 {
|
||||
@@ -21,22 +48,32 @@ mod native {
|
||||
Ok(Self(ws))
|
||||
}
|
||||
|
||||
pub async fn send(&mut self, text: String) -> Result<()> {
|
||||
Ok(self.0.send(Message::Text(text.into())).await?)
|
||||
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 text message; `None` once the server closes the connection.
|
||||
pub async fn next(&mut self) -> Option<Result<String>> {
|
||||
/// 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(Message::Text(t)) => return Some(Ok(t.as_str().to_owned())),
|
||||
Ok(Message::Close(_)) => return None,
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -46,11 +83,13 @@ mod web {
|
||||
use tokio::sync::mpsc;
|
||||
use wasm_bindgen::JsCast;
|
||||
use wasm_bindgen::prelude::*;
|
||||
use web_sys::{CloseEvent, MessageEvent, WebSocket};
|
||||
use web_sys::{BinaryType, CloseEvent, MessageEvent, WebSocket};
|
||||
|
||||
use super::Message;
|
||||
|
||||
enum Event {
|
||||
Open,
|
||||
Text(String),
|
||||
Message(Message),
|
||||
Error,
|
||||
Closed,
|
||||
}
|
||||
@@ -68,6 +107,8 @@ mod web {
|
||||
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 || {
|
||||
@@ -75,9 +116,15 @@ mod web {
|
||||
});
|
||||
let t = tx.clone();
|
||||
let on_message = Closure::<dyn FnMut(MessageEvent)>::new(move |e: MessageEvent| {
|
||||
if let Some(text) = e.data().as_string() {
|
||||
let _ = t.send(Event::Text(text));
|
||||
}
|
||||
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 || {
|
||||
@@ -98,18 +145,27 @@ mod web {
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn send(&mut self, text: String) -> Result<()> {
|
||||
self.ws.send_with_str(&text).map_err(|e| anyhow!("websocket send: {e:?}"))
|
||||
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 text message; `None` once the server closes the connection.
|
||||
pub async fn next(&mut self) -> Option<Result<String>> {
|
||||
/// 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::Text(t) => Some(Ok(t)),
|
||||
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 {
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
[package]
|
||||
name = "tr-turnkey"
|
||||
description = "Built-in wallets on Turnkey: the device's session key and request stamps, the few Turnkey calls Terminal makes, and the desktop sign-in's loopback callback"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
|
||||
[features]
|
||||
# An in-process stand-in for Turnkey's API and sign-in page, for tests and screenshots.
|
||||
# Never in a shipped build.
|
||||
mock = ["dep:k256"]
|
||||
|
||||
[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"] }
|
||||
turnkey_api_key_stamper = "0.17.1"
|
||||
p256 = { version = "0.13", default-features = false, features = ["ecdsa", "std"] }
|
||||
base64 = "0.22"
|
||||
hex = "0.4"
|
||||
rand_core = { version = "0.6", features = ["getrandom"] }
|
||||
zeroize = "1"
|
||||
web-time = "1"
|
||||
k256 = { version = "0.13", default-features = false, features = ["std", "ecdsa"], optional = true }
|
||||
|
||||
# The desktop sign-in listens for the browser's redirect on 127.0.0.1.
|
||||
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
|
||||
tokio = { workspace = true, features = ["net", "io-util", "time"] }
|
||||
|
||||
# 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]
|
||||
tokio = { workspace = true, features = ["macros", "rt", "net", "io-util", "time"] }
|
||||
tr-turnkey = { path = ".", features = ["mock"] }
|
||||
@@ -0,0 +1,215 @@
|
||||
//! Desktop sign-in: the browser hands the session back to Terminal by redirecting to
|
||||
//! `http://127.0.0.1:<port>/callback?state=…&session=…` (a loopback redirect, RFC 8252 §7.3).
|
||||
//!
|
||||
//! - The listener binds 127.0.0.1 on a port the OS picks, so nothing off this machine can
|
||||
//! reach it, and it closes after one good answer or [`SIGN_IN_TIMEOUT`].
|
||||
//! - `state` is 256 random bits made for this sign-in. Requests without it (another tab,
|
||||
//! another program poking the port) get an error page and don't end the wait.
|
||||
//! - The session token is bound to this device's key ([`crate::session::Session::accept`]
|
||||
//! checks that), so even an intercepted redirect is useless to anyone else.
|
||||
//! - Only `GET /callback` is answered; requests are read up to 8 KiB.
|
||||
|
||||
use std::time::Duration;
|
||||
|
||||
use anyhow::{Context, Result, anyhow, bail};
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
use tokio::net::{TcpListener, TcpStream};
|
||||
|
||||
use crate::session::same_secret;
|
||||
|
||||
pub use crate::SIGN_IN_TIMEOUT;
|
||||
/// Bad requests tolerated before the sign-in gives up.
|
||||
const MAX_STRAY: usize = 32;
|
||||
const MAX_REQUEST: usize = 8 * 1024;
|
||||
|
||||
/// What the browser came back with.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum Answer {
|
||||
/// The session token for this device's key.
|
||||
Session(String),
|
||||
/// The page reported that the user cancelled or sign-in failed.
|
||||
Failed(String),
|
||||
}
|
||||
|
||||
/// A loopback listener waiting for one sign-in.
|
||||
pub struct Callback {
|
||||
listener: TcpListener,
|
||||
port: u16,
|
||||
}
|
||||
|
||||
impl Callback {
|
||||
/// Listen on 127.0.0.1 at a port the OS picks.
|
||||
pub async fn bind() -> Result<Self> {
|
||||
let listener = TcpListener::bind(("127.0.0.1", 0)).await.context("listening on 127.0.0.1 for the sign-in")?;
|
||||
let port = listener.local_addr()?.port();
|
||||
Ok(Self { listener, port })
|
||||
}
|
||||
|
||||
pub fn port(&self) -> u16 {
|
||||
self.port
|
||||
}
|
||||
|
||||
/// Wait for `GET /callback` carrying `state`, up to `timeout`.
|
||||
pub async fn wait(self, state: &str, timeout: Duration) -> Result<Answer> {
|
||||
tokio::time::timeout(timeout, self.serve(state))
|
||||
.await
|
||||
.map_err(|_| anyhow!("the browser didn't come back within {} minutes; try again", timeout.as_secs() / 60))?
|
||||
}
|
||||
|
||||
async fn serve(self, state: &str) -> Result<Answer> {
|
||||
let mut stray = 0;
|
||||
loop {
|
||||
let (mut sock, _) = self.listener.accept().await?;
|
||||
match read_request(&mut sock).await {
|
||||
Ok(Some(target)) => match judge(&target, state) {
|
||||
Verdict::Done(answer) => {
|
||||
let page = match &answer {
|
||||
Answer::Session(_) => PAGE_DONE,
|
||||
Answer::Failed(_) => PAGE_FAILED,
|
||||
};
|
||||
let _ = respond(&mut sock, "200 OK", page).await;
|
||||
return Ok(answer);
|
||||
}
|
||||
Verdict::NotFound => {
|
||||
let _ = respond(&mut sock, "404 Not Found", PAGE_STRAY).await;
|
||||
}
|
||||
Verdict::WrongState => {
|
||||
let _ = respond(&mut sock, "400 Bad Request", PAGE_STRAY).await;
|
||||
}
|
||||
},
|
||||
Ok(None) | Err(_) => {
|
||||
let _ = respond(&mut sock, "400 Bad Request", PAGE_STRAY).await;
|
||||
}
|
||||
}
|
||||
stray += 1;
|
||||
if stray >= MAX_STRAY {
|
||||
bail!("too many unexpected requests reached the sign-in listener; try again");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum Verdict {
|
||||
Done(Answer),
|
||||
WrongState,
|
||||
NotFound,
|
||||
}
|
||||
|
||||
/// Decide what a request target (`/callback?…`) means for the sign-in with `state`.
|
||||
fn judge(target: &str, state: &str) -> Verdict {
|
||||
let (path, query) = target.split_once('?').unwrap_or((target, ""));
|
||||
if path != "/callback" {
|
||||
return Verdict::NotFound;
|
||||
}
|
||||
let get = |k: &str| query.split('&').find_map(|kv| kv.strip_prefix(k)?.strip_prefix('=')).map(percent_decode);
|
||||
match get("state") {
|
||||
Some(s) if same_secret(&s, state) => {}
|
||||
_ => return Verdict::WrongState,
|
||||
}
|
||||
match (get("session"), get("error")) {
|
||||
(Some(token), None) if !token.is_empty() => Verdict::Done(Answer::Session(token)),
|
||||
(_, Some(e)) => Verdict::Done(Answer::Failed(e.chars().filter(|c| !c.is_control()).take(200).collect())),
|
||||
_ => Verdict::WrongState,
|
||||
}
|
||||
}
|
||||
|
||||
/// The request target of a `GET`, or `None` for anything else.
|
||||
async fn read_request(sock: &mut TcpStream) -> Result<Option<String>> {
|
||||
let mut buf = Vec::with_capacity(1024);
|
||||
let mut chunk = [0u8; 1024];
|
||||
while !buf.windows(4).any(|w| w == b"\r\n\r\n") {
|
||||
let n = tokio::time::timeout(Duration::from_secs(10), sock.read(&mut chunk)).await??;
|
||||
if n == 0 {
|
||||
break;
|
||||
}
|
||||
buf.extend_from_slice(&chunk[..n]);
|
||||
if buf.len() > MAX_REQUEST {
|
||||
return Ok(None);
|
||||
}
|
||||
}
|
||||
let text = String::from_utf8_lossy(&buf);
|
||||
let mut first = text.lines().next().unwrap_or_default().split(' ');
|
||||
match (first.next(), first.next()) {
|
||||
(Some("GET"), Some(target)) => Ok(Some(target.to_string())),
|
||||
_ => Ok(None),
|
||||
}
|
||||
}
|
||||
|
||||
async fn respond(sock: &mut TcpStream, status: &str, body: &str) -> Result<()> {
|
||||
let head = format!(
|
||||
"HTTP/1.1 {status}\r\ncontent-type: text/html; charset=utf-8\r\ncontent-length: {}\r\ncache-control: no-store\r\nreferrer-policy: no-referrer\r\nconnection: close\r\n\r\n",
|
||||
body.len()
|
||||
);
|
||||
sock.write_all(head.as_bytes()).await?;
|
||||
sock.write_all(body.as_bytes()).await?;
|
||||
sock.shutdown().await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// `%XX` and `+` decoding for a query value.
|
||||
pub fn percent_decode(s: &str) -> String {
|
||||
let b = s.as_bytes();
|
||||
let mut out = Vec::with_capacity(b.len());
|
||||
let mut i = 0;
|
||||
while i < b.len() {
|
||||
match b[i] {
|
||||
b'+' => out.push(b' '),
|
||||
b'%' if i + 2 < b.len() => {
|
||||
match u8::from_str_radix(std::str::from_utf8(&b[i + 1..i + 3]).unwrap_or("zz"), 16) {
|
||||
Ok(v) => {
|
||||
out.push(v);
|
||||
i += 2;
|
||||
}
|
||||
Err(_) => out.push(b'%'),
|
||||
}
|
||||
}
|
||||
c => out.push(c),
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
String::from_utf8_lossy(&out).into_owned()
|
||||
}
|
||||
|
||||
const PAGE_DONE: &str = concat!(
|
||||
"<!doctype html><meta charset=utf-8><title>Terminal</title><style>",
|
||||
"body{font:15px -apple-system,system-ui,sans-serif;background:#0b0b0c;color:#e6e6e6;display:grid;place-items:center;height:100vh;margin:0}main{max-width:420px;text-align:center}b{color:#f5a623}",
|
||||
"</style><main><h2>Signed in</h2><p>Your wallet is connected to Terminal. You can close this tab.</p><p><b>TESTNET</b></p></main>"
|
||||
);
|
||||
const PAGE_FAILED: &str = concat!(
|
||||
"<!doctype html><meta charset=utf-8><title>Terminal</title><style>",
|
||||
"body{font:15px -apple-system,system-ui,sans-serif;background:#0b0b0c;color:#e6e6e6;display:grid;place-items:center;height:100vh;margin:0}main{max-width:420px;text-align:center}",
|
||||
"</style><main><h2>Not signed in</h2><p>Terminal got the answer. Go back to it to try again.</p></main>"
|
||||
);
|
||||
const PAGE_STRAY: &str = "<!doctype html><meta charset=utf-8><title>Terminal</title><p>This sign-in link isn't for this Terminal window.</p>";
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn only_the_callback_with_this_state_ends_the_sign_in() {
|
||||
let st = "ab".repeat(32);
|
||||
let ok = format!("/callback?state={st}&session=h.p.s");
|
||||
assert!(matches!(judge(&ok, &st), Verdict::Done(Answer::Session(t)) if t == "h.p.s"));
|
||||
let failed = format!("/callback?error=cancelled+by+user&state={st}");
|
||||
assert!(matches!(judge(&failed, &st), Verdict::Done(Answer::Failed(e)) if e == "cancelled by user"));
|
||||
for bad in [
|
||||
format!("/callback?state={}&session=h.p.s", "cd".repeat(32)),
|
||||
"/callback?session=h.p.s".to_string(),
|
||||
format!("/callback?state={}&session=h.p.s", &st[..63]),
|
||||
format!("/callback?state={st}"),
|
||||
format!("/callback?state={st}&session="),
|
||||
] {
|
||||
assert!(matches!(judge(&bad, &st), Verdict::WrongState), "{bad}");
|
||||
}
|
||||
assert!(matches!(judge(&format!("/other?state={st}&session=x"), &st), Verdict::NotFound));
|
||||
assert!(matches!(judge("/favicon.ico", &st), Verdict::NotFound));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn query_values_are_decoded() {
|
||||
assert_eq!(percent_decode("a%20b+c%2Fd"), "a b c/d");
|
||||
assert_eq!(percent_decode("100%"), "100%");
|
||||
assert_eq!(percent_decode("%zz"), "%zz");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,309 @@
|
||||
//! The Turnkey calls Terminal makes, stamped with the device's session key: who am I, which
|
||||
//! Ethereum account the wallet has, and signing one 32-byte digest.
|
||||
//!
|
||||
//! Requests follow Turnkey's public API (`POST /public/v1/query/*` and
|
||||
//! `/public/v1/submit/*`, a JSON body, and an `X-Stamp` header signed over that exact body),
|
||||
//! as `turnkey_client` 0.17 builds them. That client isn't used directly: it waits with
|
||||
//! tokio timers, which don't run in the browser, and pulls a second `reqwest`.
|
||||
//!
|
||||
//! [`Client::sign_digest`] sends the digest as `alloy-signer-turnkey` does:
|
||||
//! `ACTIVITY_TYPE_SIGN_RAW_PAYLOAD_V2` with `PAYLOAD_ENCODING_HEXADECIMAL` and
|
||||
//! `HASH_FUNCTION_NO_OP`, so Turnkey signs those 32 bytes as they are.
|
||||
|
||||
use std::time::Duration;
|
||||
|
||||
use alloy_primitives::{Address, B256, Signature, U256};
|
||||
use anyhow::{Context, Result, anyhow, bail, ensure};
|
||||
use serde::Deserialize;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::session::Session;
|
||||
|
||||
/// Turnkey's API.
|
||||
pub const API_URL: &str = "https://api.turnkey.com";
|
||||
/// The longest any one request may take, on the desktop and in the browser.
|
||||
pub const REQUEST_TIMEOUT: Duration = Duration::from_secs(15);
|
||||
/// The wallet account Terminal uses: the first Ethereum account, as the sign-up creates it.
|
||||
pub const ETHEREUM_PATH: &str = "m/44'/60'/0'/0/0";
|
||||
|
||||
/// `url` if it is a plain-HTTP loopback address (`http://127.0.0.1:<port>` or
|
||||
/// `http://localhost:<port>`): the only kind of override Terminal accepts, for a local mock.
|
||||
pub fn loopback_only(url: &str) -> Option<String> {
|
||||
let rest = url.trim().strip_prefix("http://")?;
|
||||
let host_port = rest.split('/').next()?;
|
||||
let (host, port) = host_port.rsplit_once(':')?;
|
||||
let port_ok = port.parse::<u16>().is_ok_and(|p| p != 0);
|
||||
((host == "127.0.0.1" || host == "localhost") && port_ok).then(|| url.trim().trim_end_matches('/').to_string())
|
||||
}
|
||||
|
||||
/// Turnkey's API, unless `TR_TURNKEY_API_URL` names a local mock (desktop only).
|
||||
pub fn api_url() -> String {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
if let Some(u) = std::env::var("TR_TURNKEY_API_URL").ok().and_then(|u| loopback_only(&u)) {
|
||||
tracing::warn!("turnkey: using the local mock API named by TR_TURNKEY_API_URL");
|
||||
return u;
|
||||
}
|
||||
API_URL.to_string()
|
||||
}
|
||||
|
||||
/// `whoami`: the user and organization a session belongs to.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct WhoAmI {
|
||||
pub organization_id: String,
|
||||
#[serde(default)]
|
||||
pub organization_name: String,
|
||||
pub user_id: String,
|
||||
#[serde(default)]
|
||||
pub username: String,
|
||||
}
|
||||
|
||||
/// A wallet account (an address Turnkey holds the key for).
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct WalletAccount {
|
||||
pub wallet_id: String,
|
||||
pub address: String,
|
||||
#[serde(default)]
|
||||
pub path: String,
|
||||
#[serde(default)]
|
||||
pub address_format: String,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct Client {
|
||||
http: reqwest::Client,
|
||||
base: String,
|
||||
}
|
||||
|
||||
impl Client {
|
||||
/// Turnkey's API (or a local mock, see [`api_url`]).
|
||||
pub fn new() -> Result<Self> {
|
||||
Self::with_base(&api_url())
|
||||
}
|
||||
|
||||
/// Any base URL (tests point this at the mock).
|
||||
pub fn with_base(base: &str) -> Result<Self> {
|
||||
let b = reqwest::Client::builder();
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
let b =
|
||||
b.user_agent(concat!("terminal/", env!("CARGO_PKG_VERSION"))).redirect(reqwest::redirect::Policy::none());
|
||||
Ok(Self { http: b.build()?, base: base.trim_end_matches('/').to_string() })
|
||||
}
|
||||
|
||||
pub fn base(&self) -> &str {
|
||||
&self.base
|
||||
}
|
||||
|
||||
/// POST `body` to `path`, stamped with the session's key. The stamp signs these exact
|
||||
/// bytes, so the body is serialized once and sent as is.
|
||||
async fn post(&self, s: &Session, path: &str, body: &Value) -> Result<Value> {
|
||||
let text = serde_json::to_string(body)?;
|
||||
let (name, value) = s.key.stamp(text.as_bytes())?;
|
||||
let r = self
|
||||
.http
|
||||
.post(format!("{}{path}", self.base))
|
||||
.timeout(REQUEST_TIMEOUT)
|
||||
.header("content-type", "application/json")
|
||||
.header(name, value)
|
||||
.body(text)
|
||||
.send()
|
||||
.await
|
||||
.with_context(|| format!("turnkey {path}"))?;
|
||||
let status = r.status();
|
||||
let text = r.text().await.with_context(|| format!("turnkey {path}"))?;
|
||||
if !status.is_success() {
|
||||
let msg = serde_json::from_str::<Value>(&text)
|
||||
.ok()
|
||||
.and_then(|v| v["message"].as_str().map(str::to_string))
|
||||
.unwrap_or_else(|| text.chars().take(200).collect());
|
||||
bail!("turnkey {path}: HTTP {}: {msg}", status.as_u16());
|
||||
}
|
||||
serde_json::from_str(&text).with_context(|| format!("turnkey {path}: not JSON"))
|
||||
}
|
||||
|
||||
pub async fn whoami(&self, s: &Session) -> Result<WhoAmI> {
|
||||
let v = self.post(s, "/public/v1/query/whoami", &json!({"organizationId": s.organization_id()})).await?;
|
||||
Ok(serde_json::from_value(v)?)
|
||||
}
|
||||
|
||||
/// The wallet's Ethereum account at [`ETHEREUM_PATH`] (or, failing that, its first
|
||||
/// Ethereum account). Its address is the user's Hyperliquid account.
|
||||
pub async fn ethereum_account(&self, s: &Session) -> Result<WalletAccount> {
|
||||
let org = s.organization_id();
|
||||
let wallets = self.post(s, "/public/v1/query/list_wallets", &json!({"organizationId": org})).await?;
|
||||
let mut accounts = Vec::new();
|
||||
for w in wallets["wallets"].as_array().into_iter().flatten() {
|
||||
let Some(id) = w["walletId"].as_str() else { continue };
|
||||
let v = self
|
||||
.post(s, "/public/v1/query/list_wallet_accounts", &json!({"organizationId": org, "walletId": id}))
|
||||
.await?;
|
||||
let list: Vec<WalletAccount> = serde_json::from_value(v["accounts"].clone()).unwrap_or_default();
|
||||
accounts.extend(list);
|
||||
}
|
||||
pick_ethereum(accounts).ok_or_else(|| anyhow!("the Turnkey wallet has no Ethereum account"))
|
||||
}
|
||||
|
||||
/// Have the wallet at `sign_with` sign `digest` as it is (no hashing, no prefix).
|
||||
pub async fn sign_digest(&self, s: &Session, sign_with: &Address, digest: &B256) -> Result<Signature> {
|
||||
let body = sign_raw_payload_body(s.organization_id(), sign_with, digest, now_ms());
|
||||
let activity = self.submit(s, "/public/v1/submit/sign_raw_payload", &body).await?;
|
||||
let result = &activity["result"]["signRawPayloadResult"];
|
||||
parse_signature(result)
|
||||
}
|
||||
|
||||
/// Submit an activity and return it once Turnkey has completed it.
|
||||
async fn submit(&self, s: &Session, path: &str, body: &Value) -> Result<Value> {
|
||||
let mut v = self.post(s, path, body).await?;
|
||||
// A single-user organization completes at once; ask again a few times if not.
|
||||
for _ in 0..3 {
|
||||
match v["activity"]["status"].as_str() {
|
||||
Some("ACTIVITY_STATUS_PENDING") | Some("ACTIVITY_STATUS_CREATED") => {
|
||||
let id = v["activity"]["id"].as_str().unwrap_or_default().to_string();
|
||||
v = self
|
||||
.post(
|
||||
s,
|
||||
"/public/v1/query/get_activity",
|
||||
&json!({"organizationId": s.organization_id(), "activityId": id}),
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
_ => break,
|
||||
}
|
||||
}
|
||||
let a = &v["activity"];
|
||||
match a["status"].as_str() {
|
||||
Some("ACTIVITY_STATUS_COMPLETED") => Ok(a.clone()),
|
||||
Some("ACTIVITY_STATUS_FAILED") => {
|
||||
bail!("Turnkey refused: {}", a["failure"]["message"].as_str().unwrap_or("no reason given"))
|
||||
}
|
||||
Some("ACTIVITY_STATUS_CONSENSUS_NEEDED") | Some("ACTIVITY_STATUS_AUTHENTICATORS_NEEDED") => {
|
||||
bail!("Turnkey needs another approval for this; nothing was signed")
|
||||
}
|
||||
Some(other) => bail!("Turnkey answered {other}"),
|
||||
None => bail!("Turnkey's answer has no activity"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn now_ms() -> u64 {
|
||||
web_time::SystemTime::now().duration_since(web_time::UNIX_EPOCH).map(|d| d.as_millis() as u64).unwrap_or(0)
|
||||
}
|
||||
|
||||
/// The Ethereum account Terminal uses from a wallet's accounts.
|
||||
pub fn pick_ethereum(accounts: Vec<WalletAccount>) -> Option<WalletAccount> {
|
||||
let eth: Vec<WalletAccount> =
|
||||
accounts.into_iter().filter(|a| a.address_format == "ADDRESS_FORMAT_ETHEREUM").collect();
|
||||
eth.iter().find(|a| a.path == ETHEREUM_PATH).cloned().or_else(|| eth.into_iter().next())
|
||||
}
|
||||
|
||||
/// The `sign_raw_payload` request for a 32-byte digest (what `alloy-signer-turnkey`'s
|
||||
/// `sign_hash` sends): the payload is the digest in lower-case hex without `0x`, and
|
||||
/// `signWith` the checksummed address.
|
||||
pub fn sign_raw_payload_body(org: &str, sign_with: &Address, digest: &B256, timestamp_ms: u64) -> Value {
|
||||
json!({
|
||||
"type": "ACTIVITY_TYPE_SIGN_RAW_PAYLOAD_V2",
|
||||
"timestampMs": timestamp_ms.to_string(),
|
||||
"organizationId": org,
|
||||
"parameters": {
|
||||
"signWith": sign_with.to_string(),
|
||||
"payload": hex::encode(digest),
|
||||
"encoding": "PAYLOAD_ENCODING_HEXADECIMAL",
|
||||
"hashFunction": "HASH_FUNCTION_NO_OP",
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/// `{r, s, v}` from `signRawPayloadResult`: 32-byte hex `r` and `s`, and `v` as one hex byte
|
||||
/// (`00`/`01`, or 27/28).
|
||||
pub fn parse_signature(result: &Value) -> Result<Signature> {
|
||||
let field = |k: &str| -> Result<Vec<u8>> {
|
||||
let s = result[k].as_str().ok_or_else(|| anyhow!("the signature has no {k}"))?;
|
||||
hex::decode(s.trim_start_matches("0x")).map_err(|_| anyhow!("the signature's {k} is not hex"))
|
||||
};
|
||||
let (r, s, v) = (field("r")?, field("s")?, field("v")?);
|
||||
ensure!(r.len() == 32 && s.len() == 32 && v.len() == 1, "the signature has the wrong length");
|
||||
let parity = match v[0] {
|
||||
0 | 27 => false,
|
||||
1 | 28 => true,
|
||||
_ => bail!("the signature's v is not 0, 1, 27 or 28"),
|
||||
};
|
||||
Ok(Signature::new(U256::from_be_slice(&r), U256::from_be_slice(&s), parity))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn only_loopback_overrides_are_accepted() {
|
||||
assert_eq!(loopback_only("http://127.0.0.1:8123").as_deref(), Some("http://127.0.0.1:8123"));
|
||||
assert_eq!(loopback_only("http://localhost:9/x/").as_deref(), Some("http://localhost:9/x"));
|
||||
for bad in [
|
||||
"https://api.turnkey.com",
|
||||
"http://evil.example:80",
|
||||
"http://127.0.0.1.evil.example:80",
|
||||
"http://127.0.0.1",
|
||||
"http://127.0.0.1:0",
|
||||
"http://[email protected]:80",
|
||||
"file:///tmp/x",
|
||||
"",
|
||||
] {
|
||||
assert_eq!(loopback_only(bad), None, "{bad}");
|
||||
}
|
||||
}
|
||||
|
||||
/// The body `alloy-signer-turnkey` 2.5's `sign_hash` would send for the same digest
|
||||
/// (field names from `turnkey_client` 0.17's `SignRawPayloadIntentV2`).
|
||||
#[test]
|
||||
fn the_sign_request_matches_alloy_signer_turnkey() {
|
||||
let addr: Address = "0x14791697260e4c9a71f18484c9f997b308e59325".parse().unwrap();
|
||||
let digest = B256::from([0xab; 32]);
|
||||
let body = sign_raw_payload_body("org-1", &addr, &digest, 1_700_000_000_000);
|
||||
assert_eq!(
|
||||
body,
|
||||
json!({
|
||||
"type": "ACTIVITY_TYPE_SIGN_RAW_PAYLOAD_V2",
|
||||
"timestampMs": "1700000000000",
|
||||
"organizationId": "org-1",
|
||||
"parameters": {
|
||||
"signWith": "0x14791697260E4c9A71f18484C9f997B308e59325",
|
||||
"payload": "ab".repeat(32),
|
||||
"encoding": "PAYLOAD_ENCODING_HEXADECIMAL",
|
||||
"hashFunction": "HASH_FUNCTION_NO_OP",
|
||||
},
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn signatures_parse_with_either_v() {
|
||||
let r = "11".repeat(32);
|
||||
let s = "22".repeat(32);
|
||||
for (v, odd) in [("00", false), ("01", true), ("1b", false), ("1c", true)] {
|
||||
let sig = parse_signature(&json!({"r": r, "s": s, "v": v})).unwrap();
|
||||
assert_eq!(sig.v(), odd, "{v}");
|
||||
assert_eq!(sig.r(), U256::from_be_slice(&[0x11; 32]));
|
||||
}
|
||||
assert!(parse_signature(&json!({"r": r, "s": s, "v": "02"})).is_err());
|
||||
assert!(parse_signature(&json!({"r": "11", "s": s, "v": "00"})).is_err());
|
||||
assert!(parse_signature(&json!({"r": r, "v": "00"})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_ethereum_account_is_the_default_path() {
|
||||
let acct = |path: &str, fmt: &str, addr: &str| WalletAccount {
|
||||
wallet_id: "w".into(),
|
||||
address: addr.into(),
|
||||
path: path.into(),
|
||||
address_format: fmt.into(),
|
||||
};
|
||||
let picked = pick_ethereum(vec![
|
||||
acct("m/44'/501'/0'/0'", "ADDRESS_FORMAT_SOLANA", "So1"),
|
||||
acct("m/44'/60'/0'/0/1", "ADDRESS_FORMAT_ETHEREUM", "0x2"),
|
||||
acct(ETHEREUM_PATH, "ADDRESS_FORMAT_ETHEREUM", "0x1"),
|
||||
]);
|
||||
assert_eq!(picked.unwrap().address, "0x1");
|
||||
assert_eq!(pick_ethereum(vec![acct("m/44'/501'/0'/0'", "ADDRESS_FORMAT_SOLANA", "So1")]), None);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
//! Built-in wallets on [Turnkey](https://turnkey.com), for Hyperliquid **testnet** trading.
|
||||
//!
|
||||
//! Each user has a Turnkey sub-organization with one Ethereum wallet, created when they first
|
||||
//! sign in on Terminal's sign-in page. That wallet is their Hyperliquid account. Turnkey
|
||||
//! signs for it only rarely: approving (or replacing) the device's trading key. Orders are
|
||||
//! signed on the device by that trading key, never by Turnkey.
|
||||
//!
|
||||
//! - [`session`]: the device's P-256 key, which stamps every Turnkey request, and the
|
||||
//! short session Turnkey issues for it.
|
||||
//! - [`client`]: `whoami`, the wallet's Ethereum account, and signing one digest.
|
||||
//! - [`callback`] (desktop): the loopback listener the browser returns the session to.
|
||||
//! - `mock` (feature `mock`): a local stand-in for tests and screenshots.
|
||||
//!
|
||||
//! This crate knows nothing about Hyperliquid. What may be signed is decided by `tr-hl`
|
||||
//! (`signing::sign_approve_agent_with`), which hands it only the digest of a testnet
|
||||
//! `approveAgent`.
|
||||
|
||||
pub mod client;
|
||||
pub mod session;
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub mod callback;
|
||||
#[cfg(all(feature = "mock", not(target_arch = "wasm32")))]
|
||||
pub mod mock;
|
||||
|
||||
pub use client::Client;
|
||||
pub use session::{Session, SessionKey};
|
||||
|
||||
/// How long a sign-in waits for the browser, on the desktop and in the browser build.
|
||||
pub const SIGN_IN_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(600);
|
||||
|
||||
/// Terminal's sign-in page.
|
||||
pub const SIGN_IN_PAGE: &str = "https://terminal.rasmic.xyz/wallet";
|
||||
|
||||
/// The sign-in page, unless `TR_WALLET_SIGN_IN_URL` names a local mock's page (desktop only,
|
||||
/// loopback only, e.g. `http://127.0.0.1:<port>/wallet`).
|
||||
pub fn sign_in_page() -> String {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
if let Some(u) = std::env::var("TR_WALLET_SIGN_IN_URL").ok().and_then(|u| client::loopback_only(&u)) {
|
||||
tracing::warn!("turnkey: using the local mock sign-in page named by TR_WALLET_SIGN_IN_URL");
|
||||
return u;
|
||||
}
|
||||
SIGN_IN_PAGE.to_string()
|
||||
}
|
||||
|
||||
/// The page that signs a desktop in: it returns the session for `public_key` to
|
||||
/// `http://127.0.0.1:<port>/callback` with `state`. Only public values are in the URL, and
|
||||
/// they are in the fragment, which browsers never send to a server or in a `Referer`.
|
||||
pub fn desktop_sign_in_url(page: &str, public_key: &str, state: &str, port: u16) -> String {
|
||||
format!("{page}#flow=desktop&pk={public_key}&state={state}&port={port}")
|
||||
}
|
||||
|
||||
/// The browser build's popup: the page answers with `postMessage` to its opener.
|
||||
pub fn web_sign_in_url(page: &str, public_key: &str, state: &str) -> String {
|
||||
format!("{page}#flow=web&pk={public_key}&state={state}")
|
||||
}
|
||||
|
||||
/// The page in export mode: the user signs in again there, and Turnkey's export iframe shows
|
||||
/// the recovery phrase. Terminal is not involved.
|
||||
pub fn export_url(page: &str) -> String {
|
||||
format!("{page}#mode=export")
|
||||
}
|
||||
@@ -0,0 +1,251 @@
|
||||
//! **Test stand-in, not Turnkey.** A local server with the few Turnkey API routes Terminal
|
||||
//! calls and a pretend sign-in page, for tests and for screenshots. Its JSON follows Turnkey's
|
||||
//! API reference and `turnkey_client` 0.17's types (`whoami`, `list_wallets`,
|
||||
//! `list_wallet_accounts`, `sign_raw_payload` V2); nothing here was recorded from Turnkey.
|
||||
//!
|
||||
//! It checks requests the way Turnkey does for these routes: the `X-Stamp` must be a valid
|
||||
//! P-256 signature over the exact body, by a session key it issued and that hasn't expired,
|
||||
//! for its organization. The wallet is a local secp256k1 key.
|
||||
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use base64::Engine;
|
||||
use base64::prelude::BASE64_URL_SAFE_NO_PAD;
|
||||
use k256::ecdsa::SigningKey as WalletKey;
|
||||
use p256::ecdsa::signature::Verifier;
|
||||
use serde_json::{Value, json};
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
use tokio::net::{TcpListener, TcpStream};
|
||||
|
||||
use crate::callback::percent_decode;
|
||||
use crate::session::{Claims, SESSION_SECONDS, unsigned_token};
|
||||
|
||||
pub const ORG_ID: &str = "00000000-mock-4000-8000-0000000000aa";
|
||||
pub const USER_ID: &str = "00000000-mock-4000-8000-0000000000bb";
|
||||
pub const WALLET_ID: &str = "00000000-mock-4000-8000-0000000000cc";
|
||||
pub const USERNAME: &str = "test user (mock)";
|
||||
|
||||
struct State {
|
||||
wallet: WalletKey,
|
||||
/// Session public keys this mock issued, with their expiry (Unix seconds).
|
||||
sessions: Vec<(String, u64)>,
|
||||
/// Every API request that got past the stamp check: `(path, body)`.
|
||||
requests: Vec<(String, Value)>,
|
||||
}
|
||||
|
||||
/// A running mock. Dropping it doesn't stop the server task; tests just let it end.
|
||||
#[derive(Clone)]
|
||||
pub struct MockTurnkey {
|
||||
pub base: String,
|
||||
state: Arc<Mutex<State>>,
|
||||
}
|
||||
|
||||
fn now_secs() -> u64 {
|
||||
std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0)
|
||||
}
|
||||
|
||||
impl MockTurnkey {
|
||||
/// Start on 127.0.0.1 (port 0: any free port) with a fresh wallet key, or `wallet_hex`.
|
||||
pub async fn start(port: u16, wallet_hex: Option<&str>) -> Result<Self> {
|
||||
let wallet = match wallet_hex {
|
||||
Some(h) => WalletKey::from_slice(&hex::decode(h.trim_start_matches("0x"))?)?,
|
||||
None => WalletKey::random(&mut rand_core::OsRng),
|
||||
};
|
||||
let listener = TcpListener::bind(("127.0.0.1", port)).await?;
|
||||
let base = format!("http://127.0.0.1:{}", listener.local_addr()?.port());
|
||||
let state = Arc::new(Mutex::new(State { wallet, sessions: Vec::new(), requests: Vec::new() }));
|
||||
let me = Self { base, state };
|
||||
let server = me.clone();
|
||||
tokio::spawn(async move {
|
||||
while let Ok((sock, _)) = listener.accept().await {
|
||||
let s = server.clone();
|
||||
tokio::spawn(async move {
|
||||
let _ = s.handle(sock).await;
|
||||
});
|
||||
}
|
||||
});
|
||||
Ok(me)
|
||||
}
|
||||
|
||||
/// The wallet's address (lower-case `0x…`).
|
||||
pub fn address(&self) -> String {
|
||||
let st = self.state.lock().unwrap();
|
||||
format!("{:#x}", alloy_primitives::Address::from_private_key(&st.wallet))
|
||||
}
|
||||
|
||||
/// Issue a session for `public_key` (what the sign-in page's `stamp_login` does).
|
||||
pub fn mint_session(&self, public_key: &str, seconds: u64) -> String {
|
||||
let exp = now_secs() + seconds;
|
||||
self.state.lock().unwrap().sessions.push((public_key.to_ascii_lowercase(), exp));
|
||||
unsigned_token(&Claims {
|
||||
organization_id: ORG_ID.into(),
|
||||
user_id: USER_ID.into(),
|
||||
public_key: public_key.to_ascii_lowercase(),
|
||||
exp,
|
||||
session_type: "SESSION_TYPE_READ_WRITE".into(),
|
||||
})
|
||||
}
|
||||
|
||||
/// The API requests it accepted, in order.
|
||||
pub fn requests(&self) -> Vec<(String, Value)> {
|
||||
self.state.lock().unwrap().requests.clone()
|
||||
}
|
||||
|
||||
async fn handle(&self, mut sock: TcpStream) -> Result<()> {
|
||||
let (method, target, headers, body) = read(&mut sock).await?;
|
||||
let (path, query) = target.split_once('?').unwrap_or((&target, ""));
|
||||
let q = |k: &str| {
|
||||
query
|
||||
.split('&')
|
||||
.find_map(|kv| kv.strip_prefix(k)?.strip_prefix('='))
|
||||
.map(percent_decode)
|
||||
.unwrap_or_default()
|
||||
};
|
||||
match (method.as_str(), path) {
|
||||
("GET", "/wallet/") | ("GET", "/wallet") => {
|
||||
write(&mut sock, "200 OK", "text/html; charset=utf-8", &[], &sign_in_page()).await
|
||||
}
|
||||
("GET", "/wallet/complete") => {
|
||||
let (pk, state, port) = (q("pk"), q("state"), q("port"));
|
||||
let ok = pk.len() == 66 && pk.chars().all(|c| c.is_ascii_hexdigit()) && port.parse::<u16>().is_ok();
|
||||
if !ok {
|
||||
return write(&mut sock, "400 Bad Request", "text/plain", &[], "bad request").await;
|
||||
}
|
||||
let token = self.mint_session(&pk, SESSION_SECONDS);
|
||||
let to = format!("http://127.0.0.1:{port}/callback?state={state}&session={token}");
|
||||
write(&mut sock, "302 Found", "text/plain", &[("location", &to)], "").await
|
||||
}
|
||||
("POST", p) if p.starts_with("/public/v1/") => {
|
||||
let (status, reply) = match self.api(p, &headers, &body) {
|
||||
Ok(v) => ("200 OK", v),
|
||||
Err(e) => ("401 Unauthorized", json!({"code": 16, "message": format!("{e:#}")})),
|
||||
};
|
||||
write(&mut sock, status, "application/json", &[], &reply.to_string()).await
|
||||
}
|
||||
_ => write(&mut sock, "404 Not Found", "text/plain", &[], "not found").await,
|
||||
}
|
||||
}
|
||||
|
||||
fn api(&self, path: &str, headers: &[(String, String)], body: &[u8]) -> Result<Value> {
|
||||
let stamp = headers
|
||||
.iter()
|
||||
.find(|(k, _)| k.eq_ignore_ascii_case("x-stamp"))
|
||||
.map(|(_, v)| v.clone())
|
||||
.context("missing X-Stamp")?;
|
||||
let stamp: Value = serde_json::from_slice(&BASE64_URL_SAFE_NO_PAD.decode(stamp.trim())?)?;
|
||||
anyhow::ensure!(stamp["scheme"] == "SIGNATURE_SCHEME_TK_API_P256", "unsupported stamp scheme");
|
||||
let pk = stamp["publicKey"].as_str().context("stamp has no publicKey")?.to_ascii_lowercase();
|
||||
let key = p256::ecdsa::VerifyingKey::from_sec1_bytes(&hex::decode(&pk)?)?;
|
||||
let sig = p256::ecdsa::Signature::from_der(&hex::decode(stamp["signature"].as_str().unwrap_or_default())?)?;
|
||||
key.verify(body, &sig).context("the stamp doesn't match the body")?;
|
||||
let v: Value = serde_json::from_slice(body)?;
|
||||
let mut st = self.state.lock().unwrap();
|
||||
anyhow::ensure!(
|
||||
st.sessions.iter().any(|(k, exp)| *k == pk && *exp > now_secs()),
|
||||
"could not find a valid session for this public key"
|
||||
);
|
||||
anyhow::ensure!(v["organizationId"] == ORG_ID, "organization mismatch");
|
||||
st.requests.push((path.to_string(), v.clone()));
|
||||
let address = format!("{:#x}", alloy_primitives::Address::from_private_key(&st.wallet));
|
||||
Ok(match path {
|
||||
"/public/v1/query/whoami" => {
|
||||
json!({"organizationId": ORG_ID, "organizationName": "mock sub-organization", "userId": USER_ID, "username": USERNAME})
|
||||
}
|
||||
"/public/v1/query/list_wallets" => json!({"wallets": [{"walletId": WALLET_ID, "walletName": "Terminal"}]}),
|
||||
"/public/v1/query/list_wallet_accounts" => json!({"accounts": [{
|
||||
"walletAccountId": "00000000-mock-4000-8000-0000000000dd",
|
||||
"organizationId": ORG_ID,
|
||||
"walletId": WALLET_ID,
|
||||
"curve": "CURVE_SECP256K1",
|
||||
"pathFormat": "PATH_FORMAT_BIP32",
|
||||
"path": "m/44'/60'/0'/0/0",
|
||||
"addressFormat": "ADDRESS_FORMAT_ETHEREUM",
|
||||
"address": alloy_primitives::Address::from_private_key(&st.wallet).to_string(),
|
||||
}]}),
|
||||
"/public/v1/submit/sign_raw_payload" => {
|
||||
let p = &v["parameters"];
|
||||
anyhow::ensure!(v["type"] == "ACTIVITY_TYPE_SIGN_RAW_PAYLOAD_V2", "unexpected activity type");
|
||||
anyhow::ensure!(p["encoding"] == "PAYLOAD_ENCODING_HEXADECIMAL", "unexpected encoding");
|
||||
anyhow::ensure!(p["hashFunction"] == "HASH_FUNCTION_NO_OP", "unexpected hash function");
|
||||
anyhow::ensure!(
|
||||
p["signWith"].as_str().unwrap_or_default().eq_ignore_ascii_case(&address),
|
||||
"signWith is not this organization's wallet"
|
||||
);
|
||||
let digest = hex::decode(p["payload"].as_str().unwrap_or_default())?;
|
||||
anyhow::ensure!(digest.len() == 32, "the payload is not a 32-byte digest");
|
||||
let (sig, recid) = st.wallet.sign_prehash_recoverable(&digest)?;
|
||||
let (r, s) = sig.split_bytes();
|
||||
json!({"activity": {
|
||||
"id": "00000000-mock-4000-8000-0000000000ee",
|
||||
"organizationId": ORG_ID,
|
||||
"status": "ACTIVITY_STATUS_COMPLETED",
|
||||
"type": "ACTIVITY_TYPE_SIGN_RAW_PAYLOAD_V2",
|
||||
"result": {"signRawPayloadResult": {
|
||||
"r": hex::encode(r),
|
||||
"s": hex::encode(s),
|
||||
"v": format!("{:02x}", u8::from(recid.is_y_odd())),
|
||||
}},
|
||||
}})
|
||||
}
|
||||
_ => anyhow::bail!("this mock doesn't serve {path}"),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
type Request = (String, String, Vec<(String, String)>, Vec<u8>);
|
||||
|
||||
async fn read(sock: &mut TcpStream) -> Result<Request> {
|
||||
let mut buf = Vec::new();
|
||||
let mut chunk = [0u8; 4096];
|
||||
let head_end = loop {
|
||||
let n = sock.read(&mut chunk).await?;
|
||||
anyhow::ensure!(n > 0, "connection closed");
|
||||
buf.extend_from_slice(&chunk[..n]);
|
||||
if let Some(i) = buf.windows(4).position(|w| w == b"\r\n\r\n") {
|
||||
break i + 4;
|
||||
}
|
||||
anyhow::ensure!(buf.len() < 64 * 1024, "headers too large");
|
||||
};
|
||||
let head = String::from_utf8_lossy(&buf[..head_end]).to_string();
|
||||
let mut lines = head.lines();
|
||||
let mut first = lines.next().unwrap_or_default().split(' ');
|
||||
let (method, target) = (first.next().unwrap_or_default().to_string(), first.next().unwrap_or_default().to_string());
|
||||
let headers: Vec<(String, String)> =
|
||||
lines.filter_map(|l| l.split_once(':')).map(|(k, v)| (k.trim().to_string(), v.trim().to_string())).collect();
|
||||
let len = headers
|
||||
.iter()
|
||||
.find(|(k, _)| k.eq_ignore_ascii_case("content-length"))
|
||||
.and_then(|(_, v)| v.parse::<usize>().ok())
|
||||
.unwrap_or(0);
|
||||
let mut body = buf[head_end..].to_vec();
|
||||
while body.len() < len {
|
||||
let n = sock.read(&mut chunk).await?;
|
||||
anyhow::ensure!(n > 0, "connection closed");
|
||||
body.extend_from_slice(&chunk[..n]);
|
||||
}
|
||||
body.truncate(len);
|
||||
Ok((method, target, headers, body))
|
||||
}
|
||||
|
||||
async fn write(sock: &mut TcpStream, status: &str, ctype: &str, extra: &[(&str, &str)], body: &str) -> Result<()> {
|
||||
let mut head = format!("HTTP/1.1 {status}\r\ncontent-type: {ctype}\r\ncontent-length: {}\r\n", body.len());
|
||||
for (k, v) in extra {
|
||||
head.push_str(&format!("{k}: {v}\r\n"));
|
||||
}
|
||||
head.push_str("access-control-allow-origin: *\r\nconnection: close\r\n\r\n");
|
||||
sock.write_all(head.as_bytes()).await?;
|
||||
sock.write_all(body.as_bytes()).await?;
|
||||
sock.shutdown().await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The pretend sign-in page: one button that issues a session for the device's key. Like the
|
||||
/// real page it reads its parameters from the URL fragment.
|
||||
fn sign_in_page() -> String {
|
||||
let style = "body{font:15px -apple-system,system-ui,sans-serif;background:#0b0b0c;color:#e6e6e6;display:grid;place-items:center;height:100vh;margin:0}main{width:420px}.b{background:#f5a623;color:#000;border-radius:9px;padding:2px 8px;font:600 12px ui-monospace,monospace}.m{color:#9a9a9a;font-size:13px}a.go{display:block;margin-top:18px;padding:10px;background:#2a6df4;color:#fff;text-align:center;border-radius:8px;text-decoration:none}input{width:100%;padding:8px;box-sizing:border-box}";
|
||||
let script = "const p=new URLSearchParams(location.hash.slice(1));const ok=/^0[23][0-9a-f]{64}$/.test(p.get('pk')||'')&&/^[0-9a-f]{64}$/.test(p.get('state')||'')&&/^[0-9]{1,5}$/.test(p.get('port')||'');if(p.get('mode')==='export'){document.getElementById('f').innerHTML='<p>Export happens in Turnkey\\'s own iframe on the real page. This mock shows no recovery phrase.</p>'}else if(!ok){document.getElementById('f').textContent='Bad sign-in link.'}else{document.getElementById('go').href='/wallet/complete?pk='+p.get('pk')+'&state='+p.get('state')+'&port='+p.get('port');document.getElementById('k').textContent=p.get('pk').slice(0,10)}";
|
||||
format!(
|
||||
"<!doctype html><meta charset=utf-8><meta name=referrer content=no-referrer><title>Terminal wallet (mock)</title><style>{style}</style><main><h2>Terminal wallet <span class=b>TESTNET</span></h2><p class=m>MOCK TURNKEY (local test server, not Turnkey)</p><div id=f><p class=m>Sign in or create your wallet. The real page sends a code to your email (Turnkey).</p><p>Email<br><input value=\"[email protected]\"></p><a class=go id=go href=#>Continue</a><p class=m>The session is issued for this device's key <span id=k></span>…</p></div></main><script>{script}</script>"
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,271 @@
|
||||
//! The device's Turnkey credential: a P-256 key made here, and the session Turnkey issued for
|
||||
//! its public key.
|
||||
//!
|
||||
//! Sign-in happens in the browser (see `docs/architecture/trading-hyperliquid.md` §4.5).
|
||||
//! The browser never sees this key: it only asks Turnkey for a session bound to the key's
|
||||
//! **public** half (`stamp_login` with `publicKey`), and hands back the session token. The
|
||||
//! token says which organization and user the session belongs to and when it ends; it can't
|
||||
//! authorize anything by itself, because every request must carry a stamp made with the
|
||||
//! private key ([`SessionKey::stamp`]).
|
||||
//!
|
||||
//! The key is redacted in `Debug`, never serialized except through [`StoredSession`] for
|
||||
//! the OS secret store, and its buffers are wiped on drop.
|
||||
|
||||
use std::fmt;
|
||||
|
||||
use anyhow::{Context, Result, bail, ensure};
|
||||
use base64::Engine;
|
||||
use base64::prelude::BASE64_URL_SAFE_NO_PAD;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use turnkey_api_key_stamper::{Stamp, TurnkeyP256ApiKey};
|
||||
use zeroize::Zeroizing;
|
||||
|
||||
/// How long the sign-in page asks Turnkey to keep a session: 15 minutes, Turnkey's default.
|
||||
/// Long enough to approve a trading key; nothing else needs a session.
|
||||
pub const SESSION_SECONDS: u64 = 900;
|
||||
|
||||
/// A P-256 key generated on this device. Turnkey knows only its public half.
|
||||
pub struct SessionKey(TurnkeyP256ApiKey);
|
||||
|
||||
impl SessionKey {
|
||||
pub fn generate() -> Self {
|
||||
Self(TurnkeyP256ApiKey::generate())
|
||||
}
|
||||
|
||||
/// From the hex the secret store kept.
|
||||
pub fn from_hex(hex: &str) -> Result<Self> {
|
||||
let bytes = Zeroizing::new(hex::decode(hex.trim()).map_err(|_| anyhow::anyhow!("a session key is hex"))?);
|
||||
TurnkeyP256ApiKey::from_bytes(bytes.as_slice(), None)
|
||||
.map(Self)
|
||||
.map_err(|_| anyhow::anyhow!("not a valid P-256 private key"))
|
||||
}
|
||||
|
||||
/// The hex form for the secret store. Wiped when dropped; never log it.
|
||||
pub fn to_hex(&self) -> Zeroizing<String> {
|
||||
let bytes = Zeroizing::new(self.0.private_key());
|
||||
Zeroizing::new(hex::encode(bytes.as_slice()))
|
||||
}
|
||||
|
||||
/// The compressed public key as Turnkey names it (66 hex digits).
|
||||
pub fn public_hex(&self) -> String {
|
||||
hex::encode(self.0.compressed_public_key())
|
||||
}
|
||||
|
||||
/// The `X-Stamp` header for a request body.
|
||||
pub fn stamp(&self, body: &[u8]) -> Result<(String, String)> {
|
||||
let h = self.0.stamp(body).map_err(|e| anyhow::anyhow!("stamping the request: {e}"))?;
|
||||
Ok((h.name, h.value))
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Debug for SessionKey {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
let p = self.public_hex();
|
||||
write!(f, "SessionKey({}…{})", &p[..6], &p[p.len() - 4..])
|
||||
}
|
||||
}
|
||||
|
||||
/// What a Turnkey session token says (its JWT payload). Turnkey signs the token, but
|
||||
/// Terminal doesn't rely on that: anything the token claims is checked by Turnkey again on
|
||||
/// every stamped request.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct Claims {
|
||||
/// The user's sub-organization.
|
||||
pub organization_id: String,
|
||||
pub user_id: String,
|
||||
/// The compressed P-256 key the session is bound to.
|
||||
pub public_key: String,
|
||||
/// Expiry, Unix seconds.
|
||||
pub exp: u64,
|
||||
#[serde(default)]
|
||||
pub session_type: String,
|
||||
}
|
||||
|
||||
impl Claims {
|
||||
/// Read a session token's payload. The token's signature is not checked here (see the type).
|
||||
pub fn parse(token: &str) -> Result<Self> {
|
||||
let mut parts = token.trim().split('.');
|
||||
let (Some(_), Some(payload), Some(_), None) = (parts.next(), parts.next(), parts.next(), parts.next()) else {
|
||||
bail!("the session token is not a JWT");
|
||||
};
|
||||
let json = BASE64_URL_SAFE_NO_PAD
|
||||
.decode(payload.trim_end_matches('='))
|
||||
.context("the session token's payload is not base64url")?;
|
||||
serde_json::from_slice(&json).context("the session token's payload is missing fields")
|
||||
}
|
||||
}
|
||||
|
||||
/// A session this device can use: its key, the token, and what the token says.
|
||||
pub struct Session {
|
||||
pub key: SessionKey,
|
||||
pub token: String,
|
||||
pub claims: Claims,
|
||||
}
|
||||
|
||||
impl Session {
|
||||
/// Accept a token from the sign-in page for `key`. Refuses a token bound to any other
|
||||
/// key (another device's, or one a page made up), and one that has already expired.
|
||||
pub fn accept(key: SessionKey, token: &str, now_secs: u64) -> Result<Self> {
|
||||
let claims = Claims::parse(token)?;
|
||||
ensure!(
|
||||
claims.public_key.eq_ignore_ascii_case(&key.public_hex()),
|
||||
"the sign-in answer is for a different key; nothing was saved"
|
||||
);
|
||||
ensure!(claims.exp > now_secs, "the session had already expired; sign in again");
|
||||
ensure!(!claims.organization_id.is_empty(), "the session names no organization");
|
||||
Ok(Self { key, token: token.trim().to_string(), claims })
|
||||
}
|
||||
|
||||
/// A long-lived Turnkey API key used as a session (the live integration tests). It has
|
||||
/// no token and doesn't expire on this side.
|
||||
pub fn for_api_key(key: SessionKey, organization_id: &str) -> Self {
|
||||
let claims = Claims {
|
||||
organization_id: organization_id.to_string(),
|
||||
user_id: String::new(),
|
||||
public_key: key.public_hex(),
|
||||
exp: u64::MAX,
|
||||
session_type: "API_KEY".into(),
|
||||
};
|
||||
Self { key, token: String::new(), claims }
|
||||
}
|
||||
|
||||
pub fn organization_id(&self) -> &str {
|
||||
&self.claims.organization_id
|
||||
}
|
||||
|
||||
/// Seconds left (0 once expired).
|
||||
pub fn remaining(&self, now_secs: u64) -> u64 {
|
||||
self.claims.exp.saturating_sub(now_secs)
|
||||
}
|
||||
|
||||
pub fn expired(&self, now_secs: u64) -> bool {
|
||||
self.remaining(now_secs) == 0
|
||||
}
|
||||
|
||||
/// The form the OS secret store keeps (JSON with the key's hex). Wiped when dropped.
|
||||
pub fn to_stored(&self) -> Zeroizing<String> {
|
||||
let key = self.key.to_hex();
|
||||
Zeroizing::new(
|
||||
serde_json::to_string(&StoredSession { key: key.as_str(), token: &self.token }).expect("plain strings"),
|
||||
)
|
||||
}
|
||||
|
||||
/// Back from the secret store; `None` once expired (the caller deletes it).
|
||||
pub fn from_stored(text: &str, now_secs: u64) -> Result<Option<Self>> {
|
||||
let s: StoredSessionOwned = serde_json::from_str(text).context("the stored session is unreadable")?;
|
||||
let hex = Zeroizing::new(s.key);
|
||||
let key = SessionKey::from_hex(&hex)?;
|
||||
match Self::accept(key, &s.token, now_secs) {
|
||||
Ok(session) => Ok(Some(session)),
|
||||
Err(_) if Claims::parse(&s.token).is_ok_and(|c| c.exp <= now_secs) => Ok(None),
|
||||
Err(e) => Err(e),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Debug for Session {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
f.debug_struct("Session").field("key", &self.key).field("org", &self.claims.organization_id).finish()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct StoredSession<'a> {
|
||||
key: &'a str,
|
||||
token: &'a str,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct StoredSessionOwned {
|
||||
key: String,
|
||||
token: String,
|
||||
}
|
||||
|
||||
/// 32 random bytes as hex: the sign-in's `state`, which the callback must echo.
|
||||
pub fn random_state() -> String {
|
||||
let mut b = [0u8; 32];
|
||||
rand_core::RngCore::fill_bytes(&mut rand_core::OsRng, &mut b);
|
||||
hex::encode(b)
|
||||
}
|
||||
|
||||
/// Equal in constant time (for `state`).
|
||||
pub fn same_secret(a: &str, b: &str) -> bool {
|
||||
a.len() == b.len() && a.bytes().zip(b.bytes()).fold(0u8, |acc, (x, y)| acc | (x ^ y)) == 0
|
||||
}
|
||||
|
||||
/// A session token for tests and the mock: the claims, unsigned.
|
||||
#[cfg(any(test, feature = "mock"))]
|
||||
pub fn unsigned_token(c: &Claims) -> String {
|
||||
let h = BASE64_URL_SAFE_NO_PAD.encode(br#"{"alg":"ES256","typ":"JWT"}"#);
|
||||
let p = BASE64_URL_SAFE_NO_PAD.encode(serde_json::to_vec(c).expect("claims"));
|
||||
format!("{h}.{p}.mock-signature")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn claims(key: &SessionKey, exp: u64) -> Claims {
|
||||
Claims {
|
||||
organization_id: "b0c1e8d6-0000-4000-8000-000000000001".into(),
|
||||
user_id: "u-1".into(),
|
||||
public_key: key.public_hex(),
|
||||
exp,
|
||||
session_type: "SESSION_TYPE_READ_WRITE".into(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_session_is_accepted_only_for_this_key_and_before_it_expires() {
|
||||
let key = SessionKey::generate();
|
||||
let token = unsigned_token(&claims(&key, 2_000));
|
||||
let other = SessionKey::generate();
|
||||
let theirs = unsigned_token(&claims(&other, 2_000));
|
||||
assert!(Session::accept(SessionKey::from_hex(&key.to_hex()).unwrap(), &theirs, 1_000).is_err());
|
||||
assert!(Session::accept(SessionKey::from_hex(&key.to_hex()).unwrap(), &token, 2_000).is_err(), "expired");
|
||||
let s = Session::accept(key, &token, 1_000).unwrap();
|
||||
assert_eq!(s.remaining(1_000), 1_000);
|
||||
assert_eq!(s.organization_id(), "b0c1e8d6-0000-4000-8000-000000000001");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_stored_form_round_trips_and_lapses() {
|
||||
let key = SessionKey::generate();
|
||||
let public = key.public_hex();
|
||||
let token = unsigned_token(&claims(&key, 2_000));
|
||||
let s = Session::accept(key, &token, 1_000).unwrap();
|
||||
let stored = s.to_stored();
|
||||
let back = Session::from_stored(&stored, 1_500).unwrap().unwrap();
|
||||
assert_eq!(back.key.public_hex(), public);
|
||||
assert_eq!(back.token, token);
|
||||
assert!(Session::from_stored(&stored, 2_000).unwrap().is_none(), "expired sessions come back as none");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn debug_never_shows_the_private_key() {
|
||||
let key = SessionKey::generate();
|
||||
let hex = key.to_hex();
|
||||
let token = unsigned_token(&claims(&key, 2_000));
|
||||
let s = Session::accept(SessionKey::from_hex(&hex).unwrap(), &token, 1_000).unwrap();
|
||||
for text in [format!("{key:?}"), format!("{s:?}")] {
|
||||
assert!(!text.contains(hex.as_str()), "{text}");
|
||||
assert!(!text.contains(&hex[..16]), "{text}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tokens_must_be_jwts_with_the_fields() {
|
||||
for bad in ["", "a.b", "a.b.c.d", "x.!!!.y", "x.e30.y"] {
|
||||
assert!(Claims::parse(bad).is_err(), "{bad}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn states_are_random_and_compared_in_full() {
|
||||
let (a, b) = (random_state(), random_state());
|
||||
assert_eq!(a.len(), 64);
|
||||
assert_ne!(a, b);
|
||||
assert!(same_secret(&a, &a.clone()));
|
||||
assert!(!same_secret(&a, &b) && !same_secret(&a, &a[..63]));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
//! The desktop sign-in and the Turnkey calls, end to end against the local mock
|
||||
//! (`tr_turnkey::mock`, a stand-in for Turnkey; see its docs). Live Turnkey checks are at the
|
||||
//! bottom, `#[ignore]`d and skipped unless the environment names a Turnkey organization.
|
||||
|
||||
use std::time::Duration;
|
||||
|
||||
use alloy_primitives::{Address, B256};
|
||||
use tr_turnkey::callback::{Answer, Callback};
|
||||
use tr_turnkey::mock::{MockTurnkey, USERNAME};
|
||||
use tr_turnkey::session::{SESSION_SECONDS, random_state};
|
||||
use tr_turnkey::{Client, Session, SessionKey, desktop_sign_in_url};
|
||||
|
||||
fn now_secs() -> u64 {
|
||||
std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_secs()
|
||||
}
|
||||
|
||||
/// What the browser does: open the page's continue link and follow its redirect to the
|
||||
/// loopback callback.
|
||||
async fn browser_signs_in(mock: &MockTurnkey, pk: &str, state: &str, port: u16) -> reqwest::Response {
|
||||
let url = format!("{}/wallet/complete?pk={pk}&state={state}&port={port}", mock.base);
|
||||
reqwest::get(url).await.unwrap()
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn the_desktop_signs_in_through_the_browser_and_finds_its_wallet() {
|
||||
let mock = MockTurnkey::start(0, None).await.unwrap();
|
||||
let key = SessionKey::generate();
|
||||
let pk = key.public_hex();
|
||||
let state = random_state();
|
||||
let cb = Callback::bind().await.unwrap();
|
||||
let port = cb.port();
|
||||
let url = desktop_sign_in_url(&format!("{}/wallet/", mock.base), &pk, &state, port);
|
||||
assert!(url.contains(&format!("pk={pk}")) && url.contains("flow=desktop"), "{url}");
|
||||
assert!(!url.contains(&key.to_hex()[..]), "the private key never goes in the URL");
|
||||
|
||||
let waiter = tokio::spawn({
|
||||
let state = state.clone();
|
||||
async move { cb.wait(&state, Duration::from_secs(20)).await }
|
||||
});
|
||||
let page = browser_signs_in(&mock, &pk, &state, port).await;
|
||||
assert_eq!(page.status(), 200, "the loopback answered the redirected browser");
|
||||
assert!(page.text().await.unwrap().contains("Signed in"));
|
||||
|
||||
let Answer::Session(token) = waiter.await.unwrap().unwrap() else { panic!("no session") };
|
||||
let session = Session::accept(key, &token, now_secs()).unwrap();
|
||||
assert!(session.remaining(now_secs()) <= SESSION_SECONDS);
|
||||
|
||||
let client = Client::with_base(&mock.base).unwrap();
|
||||
let me = client.whoami(&session).await.unwrap();
|
||||
assert_eq!(me.username, USERNAME);
|
||||
let account = client.ethereum_account(&session).await.unwrap();
|
||||
assert_eq!(account.address.to_ascii_lowercase(), mock.address());
|
||||
assert_eq!(account.path, "m/44'/60'/0'/0/0");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_request_without_this_sign_ins_state_does_not_end_it() {
|
||||
let mock = MockTurnkey::start(0, None).await.unwrap();
|
||||
let key = SessionKey::generate();
|
||||
let state = random_state();
|
||||
let cb = Callback::bind().await.unwrap();
|
||||
let port = cb.port();
|
||||
let waiter = tokio::spawn({
|
||||
let state = state.clone();
|
||||
async move { cb.wait(&state, Duration::from_secs(20)).await }
|
||||
});
|
||||
// Another tab (or program) with the wrong state, and a stray path.
|
||||
let stray = browser_signs_in(&mock, &key.public_hex(), &random_state(), port).await;
|
||||
assert_eq!(stray.status(), 400);
|
||||
let favicon = reqwest::get(format!("http://127.0.0.1:{port}/favicon.ico")).await.unwrap();
|
||||
assert_eq!(favicon.status(), 404);
|
||||
assert!(!waiter.is_finished());
|
||||
// The real one still gets through.
|
||||
browser_signs_in(&mock, &key.public_hex(), &state, port).await;
|
||||
assert!(matches!(waiter.await.unwrap().unwrap(), Answer::Session(_)));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn the_sign_in_gives_up_after_its_timeout() {
|
||||
let cb = Callback::bind().await.unwrap();
|
||||
let err = cb.wait(&random_state(), Duration::from_millis(200)).await.unwrap_err();
|
||||
assert!(err.to_string().contains("didn't come back"), "{err}");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_session_for_another_key_or_one_turnkey_never_issued_is_refused() {
|
||||
let mock = MockTurnkey::start(0, None).await.unwrap();
|
||||
let mine = SessionKey::generate();
|
||||
let theirs = SessionKey::generate();
|
||||
// A page that hands back a session for someone else's key: refused before any request.
|
||||
let token = mock.mint_session(&theirs.public_hex(), 60);
|
||||
assert!(Session::accept(SessionKey::from_hex(&mine.to_hex()).unwrap(), &token, now_secs()).is_err());
|
||||
|
||||
// Turnkey (here the mock) refuses stamps from keys it issued no session for.
|
||||
let unknown = SessionKey::generate();
|
||||
let pk = unknown.public_hex();
|
||||
let fake = tr_turnkey::session::unsigned_token(&tr_turnkey::session::Claims {
|
||||
public_key: pk,
|
||||
..tr_turnkey::session::Claims::parse(&token).unwrap()
|
||||
});
|
||||
let s = Session::accept(unknown, &fake, now_secs()).unwrap();
|
||||
let err = Client::with_base(&mock.base).unwrap().whoami(&s).await.unwrap_err();
|
||||
assert!(err.to_string().contains("HTTP 401"), "{err}");
|
||||
assert!(mock.requests().is_empty());
|
||||
}
|
||||
|
||||
/// `sign_digest` sends what `alloy-signer-turnkey` sends, and the signature recovers the
|
||||
/// wallet.
|
||||
#[tokio::test]
|
||||
async fn the_wallet_signs_exactly_the_digest_it_is_given() {
|
||||
let mock = MockTurnkey::start(0, None).await.unwrap();
|
||||
let key = SessionKey::generate();
|
||||
let token = mock.mint_session(&key.public_hex(), 60);
|
||||
let session = Session::accept(key, &token, now_secs()).unwrap();
|
||||
let client = Client::with_base(&mock.base).unwrap();
|
||||
let wallet: Address = mock.address().parse().unwrap();
|
||||
let digest = B256::from([7u8; 32]);
|
||||
let sig = client.sign_digest(&session, &wallet, &digest).await.unwrap();
|
||||
assert_eq!(sig.recover_address_from_prehash(&digest).unwrap(), wallet);
|
||||
let (path, body) = mock.requests().pop().unwrap();
|
||||
assert_eq!(path, "/public/v1/submit/sign_raw_payload");
|
||||
let mut want = tr_turnkey::client::sign_raw_payload_body(tr_turnkey::mock::ORG_ID, &wallet, &digest, 0);
|
||||
want["timestampMs"] = body["timestampMs"].clone();
|
||||
assert_eq!(body, want);
|
||||
// Another address is not this organization's wallet: Turnkey refuses, nothing is signed.
|
||||
let other = Address::from([1u8; 20]);
|
||||
assert!(client.sign_digest(&session, &other, &digest).await.is_err());
|
||||
}
|
||||
|
||||
// ---- live Turnkey (needs an organization and an API key from Micky) ------------------------
|
||||
|
||||
/// `TURNKEY_ORGANIZATION_ID`, `TURNKEY_API_PRIVATE_KEY` (hex P-256) and
|
||||
/// `TURNKEY_SIGN_WITH` (an Ethereum address in that organization). Skips when unset.
|
||||
fn live() -> Option<(Session, Address)> {
|
||||
let org = std::env::var("TURNKEY_ORGANIZATION_ID").ok()?;
|
||||
let key = SessionKey::from_hex(&std::env::var("TURNKEY_API_PRIVATE_KEY").ok()?).ok()?;
|
||||
let with: Address = std::env::var("TURNKEY_SIGN_WITH").ok()?.parse().ok()?;
|
||||
Some((Session::for_api_key(key, &org), with))
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore = "live Turnkey: set TURNKEY_ORGANIZATION_ID, TURNKEY_API_PRIVATE_KEY, TURNKEY_SIGN_WITH"]
|
||||
async fn live_turnkey_signs_a_digest_that_recovers_the_wallet() {
|
||||
let Some((session, with)) = live() else {
|
||||
eprintln!("skipped: no Turnkey organization in the environment");
|
||||
return;
|
||||
};
|
||||
let client = Client::new().unwrap();
|
||||
client.whoami(&session).await.unwrap();
|
||||
let digest = B256::from([0x42; 32]);
|
||||
let sig = client.sign_digest(&session, &with, &digest).await.unwrap();
|
||||
assert_eq!(sig.recover_address_from_prehash(&digest).unwrap(), with);
|
||||
}
|
||||
@@ -6,6 +6,8 @@ The Terminal data server runs on a Freestyle VM at **https://terminal-gateway.st
|
||||
TR_SERVER_URL=wss://terminal-gateway.style.dev cargo run --release
|
||||
```
|
||||
|
||||
The desktop reads `TR_SERVER_URL` at startup, and the site's deploy workflow reads the repository variable of the same name (`server/README.md`), so moving to another domain changes no code.
|
||||
|
||||
## What runs where
|
||||
|
||||
- One VM, slug `terminal-gateway`, from the `freestyle/ubuntu-sm` snapshot: 2 vCPU, 4 GiB of memory, 16 GB of disk.
|
||||
@@ -36,7 +38,7 @@ A deploy restarts the gateway, which drops every client connection. Clients reco
|
||||
curl -fsS https://terminal-gateway.style.dev/healthz # the process is up
|
||||
curl -fsS https://terminal-gateway.style.dev/readyz # upstream connected, every venue's mids in
|
||||
cli() { cargo run -q --release -p tr-gateway --example cli -- --url wss://terminal-gateway.style.dev "$@"; }
|
||||
cli stats
|
||||
TR_METRICS_TOKEN=... cli stats # the token only when the gateway has one
|
||||
cli watch --quotes BTC --book BTC --trades BTC --bars BTC:1m --secs 30
|
||||
```
|
||||
|
||||
@@ -46,6 +48,17 @@ On the VM, `systemctl status tr-gateway` and `journalctl -u tr-gateway` show the
|
||||
|
||||
2 vCPU and 4 GiB running around the clock come to about $80 a month of Freestyle usage above the free allowance.
|
||||
|
||||
## `/stats` and `/metrics` are public
|
||||
## Settings and the operator token
|
||||
|
||||
Anyone can read `/stats` (JSON) and `/metrics` (Prometheus text) through the public URL right now. They show counts, budgets, latency and the upstream subscriptions, which reveal the markets clients are watching. They carry no client addresses or user data. Micky hasn't decided whether to restrict them, so they stay as they are for now. Restricting them later means serving them on a second port the edge doesn't publish, or checking a token.
|
||||
`/stats` (JSON) and `/metrics` (Prometheus text) show counts, budgets, latency and the upstream subscriptions, which reveal the markets clients are watching. They carry no client addresses or user data. With `TR_METRICS_TOKEN` set they answer 404 to anyone without the token, and `/healthz` and `/readyz` stay public.
|
||||
|
||||
Settings for the service go in `/etc/tr-gateway/env` on the VM, one `NAME=value` a line, readable only by root. `vm-setup.sh` creates the directory but never writes or prints the file, so a deploy keeps it. To create a token on the VM without it crossing the network or a terminal:
|
||||
|
||||
```sh
|
||||
npx -y freestyle@0.2.21 vm exec terminal-gateway --linux-user root -- bash -c \
|
||||
'umask 077; f=/etc/tr-gateway/env; grep -q ^TR_METRICS_TOKEN= "$f" 2>/dev/null || echo "TR_METRICS_TOKEN=$(openssl rand -hex 32)" >> "$f"; systemctl restart tr-gateway'
|
||||
```
|
||||
|
||||
Reading it back for `cli stats` or a scraper prints it, so do that off stream. Without the token, `cli stats` says the server answered 404.
|
||||
|
||||
The unit also caps the service at 3 GiB of memory (reclaiming hard past 2 GiB) and 512 tasks, and runs it with most of systemd's sandboxing on: no capabilities, a read-only system, no devices, no kernel tunables or modules, and only IP, Unix and netlink sockets. `systemd-analyze security` rates it 3.1 (OK), down from 8.3 (exposed) before.
|
||||
@@ -35,6 +35,10 @@ mv /usr/local/bin/tr-gateway.new /usr/local/bin/tr-gateway
|
||||
install -m 0755 target/release/examples/cli /usr/local/bin/tr-cli
|
||||
git rev-parse HEAD > /opt/terminal/DEPLOYED_COMMIT
|
||||
|
||||
# Settings and secrets (TR_METRICS_TOKEN and the like) go in /etc/tr-gateway/env, which only
|
||||
# root reads. This script never writes or prints that file; see README.md.
|
||||
install -d -m 0700 -o root -g root /etc/tr-gateway
|
||||
|
||||
cat > /etc/systemd/system/tr-gateway.service <<'UNIT'
|
||||
[Unit]
|
||||
Description=Terminal data server (tr-gateway)
|
||||
@@ -47,14 +51,38 @@ StartLimitIntervalSec=0
|
||||
User=trgw
|
||||
Environment=TR_GATEWAY_BIND=0.0.0.0:8080
|
||||
Environment=RUST_LOG=info
|
||||
# Optional; read by systemd as root before it drops to trgw. Overrides the lines above.
|
||||
EnvironmentFile=-/etc/tr-gateway/env
|
||||
ExecStart=/usr/local/bin/tr-gateway
|
||||
Restart=always
|
||||
RestartSec=2
|
||||
# SIGTERM closes every session and upstream connection first; that takes a second or two.
|
||||
TimeoutStopSec=15
|
||||
LimitNOFILE=65536
|
||||
# The gateway uses tens of MiB. Past MemoryHigh the kernel reclaims hard; past MemoryMax it's
|
||||
# killed and restarted, before the VM itself runs out.
|
||||
MemoryHigh=2G
|
||||
MemoryMax=3G
|
||||
TasksMax=512
|
||||
UMask=0077
|
||||
NoNewPrivileges=true
|
||||
ProtectSystem=strict
|
||||
ProtectHome=true
|
||||
PrivateTmp=true
|
||||
PrivateDevices=true
|
||||
ProtectKernelTunables=true
|
||||
ProtectKernelModules=true
|
||||
ProtectKernelLogs=true
|
||||
ProtectControlGroups=true
|
||||
ProtectClock=true
|
||||
ProtectHostname=true
|
||||
RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX AF_NETLINK
|
||||
RestrictNamespaces=true
|
||||
RestrictRealtime=true
|
||||
RestrictSUIDSGID=true
|
||||
LockPersonality=true
|
||||
CapabilityBoundingSet=
|
||||
SystemCallArchitectures=native
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
|
||||
@@ -13,11 +13,13 @@ On 2026-10-29 Micky approved starting on Hyperliquid's public API, because a nod
|
||||
|
||||
- `tr-gateway` (`server/`) with `HlPublicProvider`, which implements the `Provider` trait (§3.1) over `wss://api.hyperliquid.xyz/ws` and the public `info` endpoint. Clients speak `tr-proto` (§4) and can't tell providers apart, so `HlNodeProvider` replaces it later with no client or protocol change.
|
||||
- One set of upstream connections serves every client. Mids for the core dex and for every HIP-3 dex with listed markets stay subscribed. The gateway subscribes a market's book, trades and candles only while a client watches it, and drops them 20 s after the last one leaves.
|
||||
- A book takes two `l2Book` subscriptions. The default one has 20 levels a side but arrives only every ~5.4 s (p95 5.6 s); `fast: true` has 5 levels a side and arrives every ~0.5 s (p95 0.9 s) [observed for BTC, 2026-11-02]. The gateway puts the fast levels on top of the default book's deeper ones, so books refresh about every 0.5 s and keep 20 levels. Each watched book counts twice against the subscription budget. If the budget only has room for the default one, the book refreshes every ~5 s as before.
|
||||
- The gateway keeps its own budgets under Hyperliquid's per-IP limits [doc: RL]: at most 8 of 10 connections, 950 of 1000 subscriptions, 1800 of 2000 sent messages a minute and 1100 of 1200 REST weight a minute (defaults 3, 900, 1000 and 800). It refuses a client subscription past the budget, and it never rotates IPs or uses proxies [doc: TOU §3.1.8].
|
||||
- Hyperliquid closes the whole websocket when asked for a coin it doesn't list, with no error message [observed 2026-10-29]. The gateway only subscribes coins that have a live mid, and keeps the permanent mids on a connection of their own.
|
||||
- Hyperliquid closes the whole websocket when asked for a coin it doesn't list, with no error message [observed 2026-10-29]. The gateway only subscribes coins that have a live mid, and keeps the permanent mids on a connection of their own. Delisted markets keep their mids, so the gateway also checks `isDelisted` in `meta` and serves none of them (§7.5).
|
||||
- History comes from `candleSnapshot`, so it stops at the most recent 5000 bars per interval [doc: INFO]. For 1m bars that is about 3.5 days. Node-built candles (§5.3) lift the cap.
|
||||
- It makes read-only public calls only. No `exchange` endpoint, no user subscriptions, no orders.
|
||||
- The desktop opts in with `TR_SERVER_URL` (§4.6). Crypto keys such as `BTCUSD Curncy` then come from Hyperliquid core perps, and the order book pane names the source. Everything else is unchanged. The web build doesn't use the server yet.
|
||||
- The desktop opts in with `TR_SERVER_URL` (§4.6). Crypto keys such as `BTCUSD Curncy` then come from Hyperliquid core perps, and the order book pane names the source. HIP-3 perps such as `xyz:AAPL` come only through the server, as instruments of their own labelled `PERP · HL xyz` (§7). Everything else is unchanged.
|
||||
- The web build uses the server for the same crypto markets. `scripts/build-web.sh` builds in `wss://terminal-gateway.style.dev/ws` unless `TR_SERVER_URL` says otherwise. While the server is unreachable, the page falls back to Binance and Coinbase as before.
|
||||
- It runs on one Freestyle VM (2 vCPU, 4 GiB) at `wss://terminal-gateway.style.dev/ws`, behind Freestyle's TLS edge. `deploy/freestyle/README.md` covers deploys, health checks and cost, which is about $80 a month above the free allowance.
|
||||
|
||||
How facts are marked:
|
||||
@@ -257,13 +259,13 @@ Compression: the browser and tungstenite support for permessage-deflate needs ch
|
||||
| Subscribe → Snapshot for a cold topic | **< 100 ms** plus RTT [target]. Ingest already holds every book. |
|
||||
| Quote fan-out cadence to UI | Default 4 Hz per watchlist. The visible chart's bar and book stream at block speed. [target] |
|
||||
|
||||
For comparison, the public `l2Book` feed is pushed "on each block that is at least 0.5 since last push" [doc: WS-SUB]. Our own builder emits on every block. The docs say node outputs are "faster and more granular data than the API" [doc: LAT], but give no numbers [unverified until we measure]. For North American users, the Tokyo-to-client network hop dominates end to end [unverified RTT; to measure].
|
||||
For comparison, the public `l2Book` feed is pushed "on each block that is at least 0.5 since last push" [doc: WS-SUB]. In practice that holds only for `fast: true` (5 levels a side, ~0.5 s apart). The default 20-level book comes ~5.4 s apart [observed for BTC, 2026-11-02]. Our own builder emits on every block. The docs say node outputs are "faster and more granular data than the API" [doc: LAT], but give no numbers [unverified until we measure]. For North American users, the Tokyo-to-client network hop dominates end to end [unverified RTT; to measure].
|
||||
|
||||
### 4.6 Client integration
|
||||
|
||||
- **`tr-data::remote::RemoteMarkets`** implements `MarketData` over one shared connection. `history` maps to an RPC, `quotes` to a short-lived or shared `Quotes` subscription, and `live` to a `Bars` subscription bridged into the existing `mpsc::Receiver<Bar>`.
|
||||
- **Selection:** `TR_SERVER_URL` (or a settings toggle) picks `RemoteMarkets`. The direct providers stay as the offline and dev fallback (`TR_PROVIDER=sample` keeps working).
|
||||
- **Web build:** `for_browser()` switches to `RemoteMarkets`. This closes the `TODO(web proxy)` stubs in `crates/data/src/web.rs` and `crates/congress/src/db_web.rs`.
|
||||
- **Web build:** `for_browser()` puts `RemoteMarkets` in front of the browser's own sources when the page was built with a server URL (`TR_SERVER_URL`, read by `scripts/build-web.sh`; production uses `wss://terminal-gateway.style.dev/ws`). The client runs on the page's event loop over the browser's `WebSocket`, with postcard in binary frames like the desktop. `?server=ws://localhost:8787/ws` points a page at a server on the same machine, and `?server=off` turns the server off. The server serves crypto and HIP-3 perps so far, so the `TODO(web proxy)` stubs in `crates/data/src/web.rs` and `crates/congress/src/db_web.rs` stay until it serves the rest.
|
||||
|
||||
---
|
||||
|
||||
@@ -363,6 +365,7 @@ Node-built bars cover everything from the first full minute after our node is li
|
||||
- Spot coins are `PURR/USDC` or `@{index}` (for example, HYPE is `@107`). "Some assets may be remapped on user interfaces", for example BTC/USDC in the app is UBTC/USDC on HyperCore [doc: INFO].
|
||||
- Outcome (HIP-4) markets show up as `#<encoding>` keys [doc: ASSET]. I observed 762 `#` keys and 401 `@` keys among the 1399 keys of the default `allMids` [observed]. **Outcomes are out of scope** and are filtered at ingest. They also carry separate legal restrictions [doc: TOU §1.7].
|
||||
- Snapshot on 2026-10-29 [observed]: the core `meta` had 235 entries, 56 flagged `isDelisted`. The `xyz` `meta` had 141 entries, 21 flagged `isDelisted`. `spotMeta` had 347 pairs and 511 tokens. `allPerpMetas` listed per-dex universe sizes of core 235, `xyz` 141, `para` 39, `hyna` 25, `mkts` 24, `km` 23, `cash` 17, `flx` 16, `io` 16, `vntl` 15 and `abcd` 1, for 552 perp entries in total.
|
||||
- Snapshot on 2026-11-02 around 17:50 UTC [observed]: the same 10 HIP-3 dexes listed 321 markets, of which 166 were live: `xyz` 120 of 142, `para` 33 of 40, `io` 12 of 18 and `mkts` 1 of 24. `flx`, `vntl`, `hyna`, `km`, `abcd` and `cash` had nothing live. The core dex had 179 live perps of 235.
|
||||
- **In our system** the registry comes from the node's local info server, which supports `perpDexs`, `meta` and `spotMeta` [doc: NODE]. The public-API observations above are from research and are only for scale.
|
||||
|
||||
### 5.8 Backpressure
|
||||
@@ -483,53 +486,67 @@ All three bare-metal providers above are single-tenant with full root, so we ope
|
||||
|
||||
## 7. Symbol mapping to Bloomberg-style keys
|
||||
|
||||
Decided with Micky on 2026-11-02, which settles open question 9 (§15). The interim gateway and the clients implement it.
|
||||
|
||||
### 7.1 Principles
|
||||
|
||||
1. **A perp is never silently shown as a stock.** If `AAPL US Equity` is backed by `xyz:AAPL`, every surface shows a **`PERP·xyz`** badge, the attribution "trade.xyz perp on Hyperliquid", and the price basis (`mid` or `mark`). Tooltips explain that this is a perpetual futures price, not the exchange-listed share price.
|
||||
2. **Venue-native keys always work.** `xyz:AAPL <GO>`, `BTC-PERP`, `@107` and `HYPE/USDC` resolve directly.
|
||||
3. **The server owns the mapping table** (a versioned config, not code), so we can fix it without a client release.
|
||||
1. **A HIP-3 perp is its own instrument, never the listed one.** `AAPL US Equity` stays the Nasdaq stock, with its Yahoo quote and chart. The perp keeps Hyperliquid's coin name as its market key, `xyz:AAPL` (typed in any case), and its own asset class, `AssetClass::Perp`. Nothing maps `AAPL US Equity` to the perp, on the server or in a client.
|
||||
2. **Every surface labels a perp with its venue:** `PERP · HL xyz`, in a colour no listed instrument uses. That covers chart legends and headers, tab titles (`xyz:AAPL PERP`), the order book and its trades, watchlist and sidebar rows, the palette, security search and command-line suggestions.
|
||||
3. **Links, not substitutions.** A small reviewed table (`crates/core/src/perps.rs`, §7.3) links an app instrument to the perp on the same underlying. A linked instrument's chart shows a `⇄ xyz:AAPL PERP` chip, the perp's chart shows `⇄ AAPL US Equity`, and `AAPL PERP` opens the perp. `PERP` or ⇧P switches the focused pane back and forth. A link shows only while the server lists the perp as live. Yahoo has no book for a stock, so the stock's simulated book offers the perp's book, which the pane then titles as the perp's.
|
||||
4. **Venue keys always work.** Any perp the server lists opens by its key and turns up in search, linked or not (`xyz:SKHX`, `para:10Y`).
|
||||
5. **Perps come only from the server.** Nothing else has them. Yahoo, the browser's own sources, sample mode and the `Fallback` wrapper refuse a perp instead of standing in for it. While the server is unreachable, a perp's chart, book and trades say so, and its watchlist price goes blank rather than show the last one. The same goes for a server that has lost its own Hyperliquid feed. A chart painted from the disk cache says its bars are cached and why they couldn't refresh. A perp chart whose load failed asks again every 10 s while it's on screen, so it comes back with the server.
|
||||
|
||||
### 7.2 Model changes (in `tr-core`)
|
||||
### 7.2 Where it lives
|
||||
|
||||
```rust
|
||||
pub enum InstrumentKind { Spot, Perp, Equity, Etf, Index, Fx, Future, Govt }
|
||||
pub struct VenueRef { provider: ProviderId, venue: String /* "hyperliquid", "xyz" */, symbol: String /* "xyz:AAPL" */ }
|
||||
- `tr-core` (`perps.rs`): key normalisation (`XYZ:aapl` is `xyz:AAPL`), the badge, the link table and perp search. `Security::parse` gives a perp its class, and its price decimals follow Hyperliquid's five significant figures.
|
||||
- `tr-gateway`: lists, resolves and streams live markets only (§7.5). It needs no table. Book, trades and candle subscriptions and `candleSnapshot` take the prefixed coin as is (`l2Book xyz:AAPL`), so HIP-3 markets use the same code and budgets as core ones.
|
||||
- `tr-data` (`RemoteMarkets`): sends perps to the server and nowhere else. `MarketData::perp_markets` reads the server's live list through `Rpc::Markets`, and `MarketData::trades` reads a market's tape.
|
||||
- No protocol change. The design's `Resolution` struct (basis, venue, kind on every quote) isn't needed while a perp's key says what it is.
|
||||
- The table ships with the client, not as server config. Moving it to the server pays off once rows change faster than releases. Until then, the server owns liveness: it can hide a link by not listing the perp, but adding a row needs a release.
|
||||
|
||||
pub struct Resolution {
|
||||
pub key: MarketKey, // what the user typed / the pane is linked to: "AAPL US Equity"
|
||||
pub source: VenueRef, // what actually backs it: hyperliquid / xyz / "xyz:AAPL"
|
||||
pub kind: InstrumentKind, // Perp
|
||||
pub proxy: bool, // true: the source is a different instrument than the key
|
||||
pub basis: PriceBasis, // Mid | Mark | Oracle | Last
|
||||
pub session: Option<SessionCalendar>, // underlying's hours (for "underlying closed" labels)
|
||||
}
|
||||
```
|
||||
### 7.3 The table
|
||||
|
||||
`Security { ticker, class }` stays as it is for back-compat. `Resolution` travels alongside it in `Quote`, `Snapshot` and `SearchHit`.
|
||||
A row needs all of these: trade.xyz's spec index says the perp references the same underlying the app key means [doc: XYZ-SPEC]; the perp is live in `meta`; the `xyz` dex, unless xyz has no market on that underlying and another dex's match is unambiguous; and its price tracks the app instrument's. All 27 rows were checked on 2026-11-02 around 17:50 UTC, during US market hours. Every one was within 0.6% of its Yahoo instrument, and the oil rows differ by their futures contract month.
|
||||
|
||||
### 7.3 Initial mapping table (sketch; every row needs checking against the trade.xyz spec index)
|
||||
| App key | Perp | What the perp references |
|
||||
|---|---|---|
|
||||
| `AAPL`, `MSFT`, `NVDA`, `AMZN`, `GOOGL`, `META`, `TSLA`, `AVGO`, `AMD`, `NFLX`, `COIN`, `MSTR` `US Equity` | `xyz:` same ticker | 1 share of the company's common stock (Nasdaq), "quoted in USD" |
|
||||
| `LLY US Equity` | `xyz:LLY` | 1 share of Eli Lilly common stock (NYSE) |
|
||||
| `TLT`, `SMH`, `XLE US Equity` | `xyz:TLT`, `xyz:SMH`, `xyz:XLE` | 1 share of the ETF (Nasdaq, Nasdaq, NYSE Arca) |
|
||||
| `SPX Index` | `xyz:SP500` | "the S&P 500 Index" |
|
||||
| `CL1 Comdty` | `xyz:CL` | 1 barrel of WTI crude, from a futures contract it rolls monthly (the docs call it "WTIOIL (CL)") [doc: XYZ-COMM] |
|
||||
| `CO1 Comdty` | `xyz:BRENTOIL` | 1 barrel of Brent crude, rolled monthly |
|
||||
| `NG1 Comdty` | `xyz:NATGAS` | 1 MMBtu of Henry Hub natural gas, rolled monthly |
|
||||
| `HG1 Comdty` | `xyz:COPPER` | 1 pound of high-grade copper, rolled monthly |
|
||||
| `GC1`, `SI1`, `PL1 Comdty` | `xyz:GOLD`, `xyz:SILVER`, `xyz:PLATINUM` | the spot price of 1 troy ounce (XAU, XAG, XPT/USD), not the future. The tooltip says so. |
|
||||
| `EURUSD Curncy`, `GBPUSD Curncy` | `xyz:EUR`, `xyz:GBP` | the spot rate, US dollars per euro or pound |
|
||||
| `USDJPY Curncy` | `xyz:JPY` | the spot rate, "Japanese yen per 1 U.S. dollar". No inversion. |
|
||||
|
||||
| App key | Backed by | Kind | Notes |
|
||||
|---|---|---|---|
|
||||
| `AAPL US Equity`, `TSLA`, `NVDA`, `MSFT`, … | `xyz:<TICKER>` | Perp (proxy) | Only for tickers present and not `isDelisted` in `meta{dex:"xyz"}` |
|
||||
| `SPX Index` | `xyz:SP500` | Perp (proxy) | Whether it references the S&P 500 index exactly is [unverified] |
|
||||
| `NDX Index` | `xyz:XYZ100` | Perp (proxy) | XYZ100's exact reference is [unverified] |
|
||||
| `NKY Index`, `KOSPI2 Index` | `xyz:JP225`, `xyz:KR200` | Perp (proxy) | [unverified] references |
|
||||
| `GC1 Comdty`, `SI1`, `HG1`, `CL1`, `CO1`, `NG1` | `xyz:GOLD`, `SILVER`, `COPPER`, `CL`, `BRENTOIL`, `NATGAS` | Perp (proxy) | The futures roll vs spot reference is [unverified] |
|
||||
| `EURUSD Curncy`, `GBPUSD`, `USDJPY` | `xyz:EUR`, `xyz:GBP`, `xyz:JPY` | Perp (proxy) | **The quote direction for JPY is [unverified]**. An inversion could be needed. |
|
||||
| `BTCUSD Curncy`, `ETHUSD`, … | core perp `BTC`, `ETH` (mid) | Perp | An alternative is HL spot (`UBTC/USDC`, remapped) [doc: INFO]. Default open question (§15). |
|
||||
| Other dexes (`flx:`, `km:`, `para:`, …) | venue keys only | Perp | Not mapped to Bloomberg keys in v1. They overlap with xyz (for example `flx:NVDA`) [observed]. |
|
||||
Left unlinked on purpose:
|
||||
|
||||
`perpConciseAnnotations` returns categories such as `stocks` and `commodities` for HIP-3 coins [doc: PERP; observed]. We use it to suggest new rows, never to auto-map them.
|
||||
| Perp | Why |
|
||||
|---|---|
|
||||
| `xyz:XYZ100` | It tracked `^NDX` within 0.04%, but the docs only say "a modified market-capitalization-weighted index of 100 of the largest, most actively-traded, non-financial companies that are listed on a U.S. stock exchange". They never name the Nasdaq-100, so `NDX Index` doesn't link to it. |
|
||||
| `xyz:JP225` | It tracked `^N225` within 0.06%, but the docs describe "a JPY-denominated, price-weighted index of 225 leading Japanese companies" and never name the Nikkei 225. |
|
||||
| `xyz:HO` | It tracked `HO=F` within 0.2%. The docs list New York Harbor ULSD as `DIESEL` and never tie it to the `HO` coin. |
|
||||
| `xyz:KR200` | The KOSPI 200. The app has the KOSPI composite (`KOSPI Index`), a different index. |
|
||||
| `para:10Y`, `para:2Y`, `para:30Y` | They look like Treasury yields in percent. Nobody has checked their semantics. |
|
||||
| Other dexes | The only catalog instruments with a live perp elsewhere were AVGO and SMH on `para` and NVDA on `io`. xyz lists all three, so no row needs another dex. |
|
||||
|
||||
`perpConciseAnnotations` returns categories such as `stocks` and `commodities` for HIP-3 coins [doc: PERP; observed]. Use it to suggest new rows, never to add them.
|
||||
|
||||
### 7.4 Off-hours labelling
|
||||
|
||||
trade.xyz US stock perps take external prices 24/5, Sunday 8:05 PM ET to Friday 8:00 PM ET. That spans pre-market, regular, post-market and an overnight session from Blue Ocean ATS [doc: XYZ-US]. Outside external coverage, the oracle advances through an internal EMA of order-book impact prices, with τ = 30 min [doc: XYZ-ORACLE; changelog XYZ-TAU]. Mark price is bounded to ±(1/max leverage) around a reference price, which is the last external price over weekends [doc: XYZ-BOUNDS]. So the UI shows the session state (`Regular`, `Extended`, `Overnight`, `Closed: perp-only price discovery`) next to proxy quotes. A snapshot of the gap, from `xyz:AAPL` during US hours: `markPx 349.72` vs `oraclePx 349.8` [observed].
|
||||
trade.xyz US stock perps take external prices 24/5, Sunday 8:05 PM ET to Friday 8:00 PM ET. That spans pre-market, regular, post-market and an overnight session from Blue Ocean ATS [doc: XYZ-US]. Outside external coverage, the oracle advances through an internal EMA of order-book impact prices, with τ = 30 min [doc: XYZ-ORACLE; changelog XYZ-TAU]. Mark price is bounded to ±(1/max leverage) around a reference price, which is the last external price over weekends [doc: XYZ-BOUNDS]. The SP500 and XYZ100 perps and the commodities take external prices Sunday 6 PM to Friday 5 PM ET with a daily 5 to 6 PM gap, and the FX perps Sunday 5 PM to Friday 5 PM ET [doc: XYZ-SPEC, XYZ-FX]. A snapshot of the gap, from `xyz:AAPL` during US hours: `markPx 349.72` vs `oraclePx 349.8` [observed].
|
||||
|
||||
What ships: a perp counts as trading around the clock (`AssetClass::trades_24_7`), and a perp's price never appears on the listed instrument's own chart or quote, so a weekend oracle can't pass for a Nasdaq price. Still open: a session label next to the perp (`Regular`, `Extended`, `Overnight`, `Closed: perp-only price discovery`).
|
||||
|
||||
### 7.5 Delistings and churn
|
||||
|
||||
The registry refreshes `perpDexs`, `meta` (per dex) and `spotMeta` from the node's local info server every minute [target]. These calls are local, so there's no rate limit [doc: NODE]. If an instrument turns `isDelisted`, or disappears, the server marks the topic `Delisted`, stops live streams, keeps its history, and notifies users who have it in watchlists or layouts. HIP-3 deployers can also halt and settle a market with `haltTrading`, which can later resume and "recycle" the asset [doc: HIP3]. That means the *same name may come back as a different contract*. History is therefore keyed by `(dex, coin, listing_epoch)`, and a relisting starts a new epoch [target].
|
||||
|
||||
The interim gateway already filters delistings. Hyperliquid keeps publishing a mid for every delisted perp: on 2026-11-02 all 56 delisted core perps and all 155 delisted HIP-3 perps had one [observed]. So mids alone can't say what trades. The registry keeps delisted markets with their flag, and the gateway lists, resolves and streams none of them; their mids never enter the table. A request for one gets `Unavailable { UnknownMarket, "xyz:VIX is delisted" }`. A dex with nothing live (`flx`, `vntl`, `hyna`, `km`, `abcd` and `cash` that day) gets no mids subscription. The registry refreshes every 5 minutes. If one dex's `meta` call fails, that dex keeps what the last refresh listed, so its delisted markets stay delisted. A market delisted while clients watch it loses its book, trades and bars streams: each subscriber gets the same `Unavailable` status, and the upstream subscriptions go.
|
||||
|
||||
---
|
||||
|
||||
## 8. User sync: accounts, documents and conflicts
|
||||
@@ -707,8 +724,8 @@ Egress estimate (per always-on client): a 50-symbol watchlist at 4 Hz × ~30 byt
|
||||
6. **Mark price:** OK to show the node-read mark (`activeAssetData`) and fall back to a labelled estimate if that doesn't work?
|
||||
7. **If HIP-3 books are missing from node output** (the phase 1 test): pause, or allow the public `l2Book` for HIP-3 only, as a stopgap?
|
||||
8. **Default crypto source:** core perp mid (`BTC`) or HL spot (`UBTC/USDC`), and how prominently do we label "perp" for crypto?
|
||||
9. **Proxy mapping:** should `AAPL US Equity` resolve to `xyz:AAPL` by default (with the `PERP` badge), or only when the user types `xyz:AAPL`? And should other dexes (`flx:`, `km:`, …) be mapped when xyz lacks a ticker?
|
||||
10. **Off-hours:** show the perp price over weekends as the "last" price, or freeze the equity quote at the Friday close and show the perp separately?
|
||||
9. **Proxy mapping:** *resolved 2026-11-02 (§7).* `AAPL US Equity` never resolves to `xyz:AAPL`. The perp is its own instrument with its own key and a `PERP · HL xyz` label, linked from the stock by a reviewed table. Other dexes get a row only when xyz lacks the underlying and the match is unambiguous; no row needs one yet.
|
||||
10. **Off-hours:** *partly resolved 2026-11-02.* The equity quote never shows the perp's price, so it stays at the listed market's last price, and the perp shows separately (§7.1). A session label for the perp is still open (§7.4).
|
||||
11. **Auth:** a hosted identity provider (Clerk, WorkOS, Supabase) or self-hosted OAuth in Rust? Which sign-in methods first?
|
||||
12. **Anonymous use:** can signed-out users get live data (today's behaviour), or is sign-in required for the web build?
|
||||
13. **Demo account:** OK to make it server-authoritative and let it fill off-hours at perp prices?
|
||||
@@ -741,6 +758,9 @@ Egress estimate (per always-on client): a 50-symbol watchlist at 4 Hz × ~30 byt
|
||||
- **XYZ-ORACLE**: trade[XYZ] oracle price. https://docs.trade.xyz/perpetuals/mechanics/oracle-price
|
||||
- **XYZ-BOUNDS**: trade[XYZ] discovery bounds. https://docs.trade.xyz/perpetuals/mechanics/discovery-bounds
|
||||
- **XYZ-TAU**: trade[XYZ] oracle time constant updates. https://docs.trade.xyz/perpetuals/changelog/oracle-time-constant-updates
|
||||
- **XYZ-SPEC**: trade[XYZ] specification index (what each market references, read 2026-11-02). https://docs.trade.xyz/perpetuals/specifications-and-schedules/specification-index
|
||||
- **XYZ-COMM**: trade[XYZ] commodities and their contract roll. https://docs.trade.xyz/perpetuals/markets/commodities
|
||||
- **XYZ-FX**: trade[XYZ] FX. https://docs.trade.xyz/perpetuals/markets/fx
|
||||
- **ORACLE**: Oracle. https://hyperliquid.gitbook.io/hyperliquid-docs/hypercore/oracle
|
||||
- **MARK**: Robust price indices. https://hyperliquid.gitbook.io/hyperliquid-docs/trading/robust-price-indices
|
||||
- **FUNDING**: Funding. https://hyperliquid.gitbook.io/hyperliquid-docs/trading/funding
|
||||
|
||||
@@ -0,0 +1,575 @@
|
||||
# Trading on Hyperliquid: design
|
||||
|
||||
Status: **v1 built for testnet** (decisions in §15, as built in §16). Built-in Turnkey wallets replace the external-wallet and pasted-key flows (decided 2026-11-04, §4.5). Mainnet is not reachable from this build; it waits on §12.
|
||||
Author: Grok Bot, for Micky; v1 by Claude. Research date: 2026-11-02 (read-only calls about 23:30 UTC). Times are UTC unless marked.
|
||||
|
||||
Micky asked: *"i wanna enable crypto trading and stocks (via hyperliquid)"*. This doc designs order entry on Hyperliquid from Terminal's desktop app and the WASM web build. That covers crypto (core perps such as `BTC`, and spot such as `HYPE/USDC`) and the HIP-3 equity perps the terminal already shows (`xyz:AAPL`, labelled `PERP · HL xyz`). It fits the order entry that exists today: the demo account, the SnapTrade opt-in, the `PORT`/`POS`/`ORD`/`ACTV` panes and the order ticket.
|
||||
|
||||
**Read §12 (legal) first.** Hyperliquid's Terms of Use name Ontario, Canada as a restricted region. This needs a decision from Micky before any phase touches mainnet, and it affects how testnet can be funded too. Micky's answer for now (§15): build and demo on testnet; legal review before mainnet.
|
||||
|
||||
Evidence tags used below:
|
||||
|
||||
- **[doc: X]** is from Hyperliquid's docs, Terms of Use or SDK source (references at the end).
|
||||
- **[observed]** is from a read-only call or a local build I ran on 2026-11-02. These are snapshots, not guarantees.
|
||||
- **[repo]** is from reading `michaelshimeles/terminal` at `0690de8`.
|
||||
- **[inferred]** and **[unverified]** are my reasoning, to be confirmed on testnet in phase 1.
|
||||
|
||||
---
|
||||
|
||||
## 1. Goals and non-goals
|
||||
|
||||
### Goals
|
||||
|
||||
1. Place, modify and cancel orders on Hyperliquid from the order ticket: core perps, spot, and HIP-3 perps on the `xyz`, `para`, `io` and `mkts` dexes.
|
||||
2. Show the account live: positions, open orders, fills, funding, margin and PnL, in the existing `PORT`/`POS`/`ORD`/`ACTV` panes, from Hyperliquid's user websocket streams.
|
||||
3. Keep custody with the user. Terminal never sees the user's main wallet key, and Terminal's servers never sign anything.
|
||||
4. Ship the same flow on desktop (macOS first) and on the web build at `terminal.rasmic.xyz/app`.
|
||||
5. Be safe by default: off until the user opts in, testnet first, a confirmation on every order, a max-notional guard, a kill switch, and no keys in logs.
|
||||
6. Never present a HIP-3 equity perp as a share.
|
||||
|
||||
### Non-goals (for this design)
|
||||
|
||||
- **No server-side signing or order routing.** `tr-gateway` stays data-only, as `docs/architecture/server.md` §1 already decided ("It never signs, routes or places orders") [repo]. §3 explains why this doc keeps that.
|
||||
- No deposits, withdrawals or transfers in v1. Users fund the account on Hyperliquid themselves. (`usdClassTransfer` between spot and perp may come later, §11.)
|
||||
- No vaults, sub-accounts, multi-sig, staking, TWAP or outcome markets in v1.
|
||||
- No automated or algorithmic trading. Every order starts from a human click or command.
|
||||
- No change to the demo account or to SnapTrade.
|
||||
|
||||
---
|
||||
|
||||
## 2. How Terminal trades today (what this fits into)
|
||||
|
||||
| Piece | Today | Where |
|
||||
|---|---|---|
|
||||
| Account modes | `AccountMode::{Demo, Live}`. Demo is the default: one-click orders filled locally against the terminal's quotes. Live means SnapTrade. The toolbar shows a `DEMO`/`LIVE` badge. | `app/src/prefs.rs`, `app/src/app/chrome.rs` [repo] |
|
||||
| Opt-in | `TradingPrefs { account_mode, enabled, max_order_value, default_account }`. Live orders stay off until Settings › Trading is enabled, behind an acknowledgement dialog ("I understand that orders are real…"). | `app/src/prefs.rs`, `app/src/app/trading.rs` `enable_dialog` [repo] |
|
||||
| Order ticket | `Ticket` (symbol, side, `OrderType::{Market, Limit, Stop, StopLimit}`, `TimeInForce::{Day, Gtc, Fok, Ioc}`, units or dollars, `SELL ALL`). It opens from `BUY`/`SELL`/`TRADE`, ⇧B/⇧S, chart, watchlist and position rows, and the ⌘K palette. | `app/src/app/trading.rs` [repo] |
|
||||
| Live safety rails | (1) off until enabled; (2) an order can only be placed from a broker preview of exactly the current fields; (3) one confirmation showing account, symbol, side, quantity and estimated cost; (4) an optional maximum order value. | `app/src/app/trading.rs` module docs [repo] |
|
||||
| Worker | The UI sends `Request::Trade(TradeRequest::{Preview, Place, Cancel, Portal, Capabilities})` and drains `TradeResponse`, so it never blocks. In the browser the same requests run as tasks on the page's event loop. | `app/src/worker.rs` [repo] |
|
||||
| Broker crate | `tr-broker`: the SnapTrade client, the `Portfolio`/`Account`/`Position`/`Order` model, `DemoBook`, and `SecretStore` (macOS Keychain service `terminal`, otherwise an owner-only JSON file). `Credentials` has a redacting `Debug` and `mask()`. | `crates/broker/src/*.rs` [repo] |
|
||||
| Market data | `tr-gateway` (`wss://gateway.rasmic.xyz/ws`) relays Hyperliquid's public websocket. `tr-proto` `MarketInfo { key, venue, kind: Perp \| Spot }`. HIP-3 perps are instruments of their own, labelled `PERP · HL xyz`, never resolved from `AAPL US Equity`. | `crates/proto/src/lib.rs`, `server/src/hl/`, `server.md` §7 [repo] |
|
||||
|
||||
The design reuses all of this. Hyperliquid becomes a **third account source** next to Demo and SnapTrade, with its own opt-in, so enabling SnapTrade live orders never enables Hyperliquid orders, or the reverse.
|
||||
|
||||
---
|
||||
|
||||
## 3. Custody: who holds the key that signs orders
|
||||
|
||||
### 3.1 What Hyperliquid requires
|
||||
|
||||
- Every order, cancel and modify is an **L1 action** posted to `POST https://api.hyperliquid.xyz/exchange` (testnet: `https://api.hyperliquid-testnet.xyz`) with `action`, `nonce`, `signature`, and optionally `vaultAddress` and `expiresAfter` [doc: EXCH, API]. It can also go over the websocket as a `post` message [doc: WSPOST].
|
||||
- The signer can be the account's own key **or an API wallet ("agent wallet")** that the account approved. API wallets "are only used to sign"; account data is always queried by the master address [doc: NONCE].
|
||||
- **Approving an agent is a user-signed action** (`approveAgent`, EIP-712 domain `HyperliquidSignTransaction`), so the main wallet has to sign it once [doc: EXCH, SDK-SIGN]. Approving a builder fee must be signed by the main wallet, "not an agent/API wallet" [doc: BUILDER]. Transfers and withdrawals (`usdSend`, `spotSend`, `withdraw3`) are user-signed actions of the same kind [doc: EXCH]. I infer that an agent key cannot move funds out [inferred]. The one agent-signable transfer, `agentSendAsset`, requires "Destination must match the source address" [doc: EXCH].
|
||||
|
||||
So a stolen agent key can trade, and lose money by trading, but per the docs it cannot withdraw. That is the key fact behind the recommendation below.
|
||||
|
||||
### 3.2 Options
|
||||
|
||||
**Option A: client-side signing with a local API wallet (recommended).**
|
||||
|
||||
- Terminal generates a fresh secp256k1 key on the device. Its address is approved as a **named** agent by the user's main wallet, with an expiry. The private key never leaves the device. Every order is signed locally and posted straight to Hyperliquid.
|
||||
- **Desktop:** the agent key lives in the OS secret store. That is the macOS Keychain, under its own service (`terminal.hyperliquid`, account `testnet:<master address>`). On Linux it is the Secret Service (GNOME Keyring, KWallet) through the `keyring` crate, with **no file fallback**: without a keyring, Hyperliquid trading stays off and Settings says why (Q6). Other platforms have no store, so trading is off there.
|
||||
- **Web:** orders are signed by the agent key in WASM. The agent key is either **session-only** (memory; lost on reload, re-approve next time) or an **encrypted local key** (wrapped with a non-extractable WebCrypto AES-GCM key and stored in IndexedDB). Default: session-only, with "Remember on this browser" as an explicit choice.
|
||||
- **The main wallet** is the user's built-in Turnkey wallet on both (§4.5). It signs only the rare user-signed actions: approving the agent and replacing it. *(Before 2026-11-04 the web used a browser wallet over EIP-1193 and the desktop a pasted testnet key; both are gone from the app.)*
|
||||
- Why not have the browser wallet sign each order? L1 actions are signed over an EIP-712 domain with `chainId: 1337` ("phantom agent") [doc: SDK-SIGN]. Wallets generally won't sign typed data for a chain they aren't on, and a popup per order is unusable anyway [inferred]. Hyperliquid's own SDK and docs steer trading processes to API wallets [doc: NONCE].
|
||||
|
||||
**Option B: signing on the server.**
|
||||
|
||||
- `tr-gateway`, or a new service, holds each user's agent key (in a KMS or HSM) and signs and posts orders for authenticated users.
|
||||
- For it: one implementation, keys out of browsers, and it is easy to add server-side risk checks.
|
||||
- Against it:
|
||||
1. It reverses a recorded decision. `server.md` §1 says the server "never signs, routes or places orders", partly because of the legal flag [repo].
|
||||
2. Goshen Labs would run trading infrastructure that holds keys able to trade customer accounts. That is a much larger security target, and a regulatory question for an Ontario company (§12).
|
||||
3. Rate limits are per IP: REST weight is 1200/min, and user-specific websocket subscriptions allow at most 10 unique users per IP [doc: RL]. All users behind one server IP would share them.
|
||||
4. It needs sign-in (server.md §8 is still open), per-user secrets at rest, and an audit trail before the first order.
|
||||
5. Compromising the server would compromise every user at once.
|
||||
|
||||
**Option C (fallback inside A): paste an API wallet key.** For users who already made an API wallet elsewhere (for example on Hyperliquid's own API page), Terminal accepts its private key and stores it like an A key. It has the same trust model, but the key was generated outside Terminal. This is offered as a fallback, not the default.
|
||||
|
||||
### 3.3 Recommendation
|
||||
|
||||
**Option A.** Every order is signed on the user's own device by a Terminal-generated API wallet that the user's main wallet approves once (named, with an expiry). The key sits in the macOS Keychain on desktop, and in memory or a WebCrypto-encrypted IndexedDB entry on the web. Neither Terminal's server nor Terminal itself ever holds the main key. A leaked agent key can trade but, per the docs, cannot withdraw. Revocation is one action, and `tr-gateway` stays data-only, so the per-IP limits and the server-side custody and legal exposure of Option B never arise.
|
||||
|
||||
### 3.4 Signing is small enough to own, and it works in WASM
|
||||
|
||||
Hyperliquid warns that hand-rolled signatures fail silently: a wrong signature recovers a different address, with errors like "User or API Wallet … does not exist" [doc: SIGN]. The algorithm is short [doc: SDK-SIGN]:
|
||||
|
||||
1. `msgpack(action)`, with **field order exactly as in the SDK** (order matters), then append `nonce` as 8 big-endian bytes, then `0x00` (no vault) or `0x01 ‖ vault address`, and for `expiresAfter` append `0x00 ‖ expiresAfter` as 8 big-endian bytes. `keccak256` of all of it gives `connectionId`.
|
||||
2. The phantom agent is `{source: "a" (mainnet) | "b" (testnet), connectionId}`.
|
||||
3. EIP-712 sign `Agent(string source, bytes32 connectionId)` under the domain `{name: "Exchange", version: "1", chainId: 1337, verifyingContract: 0x0}`.
|
||||
4. User-signed actions (approveAgent, approveBuilderFee) use the domain `HyperliquidSignTransaction`, version 1, with `chainId = signatureChainId` and `hyperliquidChain: "Mainnet" | "Testnet"` in the message. The Python SDK sets `signatureChainId = 0x66eee`, and the docs' examples show `0xa4b1` (Arbitrum) [doc: SDK-SIGN, EXCH].
|
||||
5. Common pitfalls the docs list: two signing schemes, msgpack field order, trailing zeros in numbers, and upper-case addresses (lowercase everything before signing) [doc: SIGN, TICK].
|
||||
|
||||
**Spike [observed, on the box, not in this PR]:** about 40 lines using `alloy-sol-types` (the `sol!` `Agent` struct and `eip712_domain!`), `alloy-signer-local` 1.8 (k256) and `rmp-serde::to_vec_named` reproduce the Python SDK's published test vector `test_l1_action_signing_matches` exactly: r/s/v for both mainnet (v=27) and testnet (v=28). The same crate **builds for `wasm32-unknown-unknown`** with `getrandom` 0.3 `wasm_js` and 0.2 `js` enabled (rustc 1.99.1). I have not yet run it in a browser.
|
||||
|
||||
### 3.5 Rust SDK options
|
||||
|
||||
| Option | State [observed 2026-11-02] | Fit |
|
||||
|---|---|---|
|
||||
| `hyperliquid-dex/hyperliquid-rust-sdk` (official) | 474★, MIT. Last commit 2025-10-21; crates.io `hyperliquid_rust_sdk` 0.6.0 published 2025-05-16. Depends on `tokio` "full" and `tokio-tungstenite` with `native-tls`. Its info client has no `dex` parameter (grep), so no HIP-3 queries. The current docs page links only the Python SDK and the community Rust and TS SDKs [doc: API]. | Not WASM-friendly (deps) [inferred], no HIP-3, and stale. Use as a reference only. |
|
||||
| `infinitefield/hypersdk` (community; linked from the API docs as the Rust SDK) | 235★, MPL-2.0, pushed 2026-10-26, crates.io 0.3.0 (2026-10-12). alloy 2, rust_decimal, HIP-3 support, multisig. | **Does not build for wasm32** [observed]: even with `default-features = false`, 0.3.0 failed with 5 errors (yawc `TcpWebSocket`, non-`Send` futures, `reqwest::ClientBuilder::timeout`). A good reference and a source of test vectors; possible on desktop only. MPL-2.0 is file-level copyleft (fine to depend on; copying files in carries the licence). |
|
||||
| `alethea` | The only crate by that name on crates.io is a templating library (0.1.0), unrelated to Hyperliquid [observed]. If Micky meant another crate, it needs a pointer (Q9). | Not applicable. |
|
||||
| Own minimal module on `alloy-sol-types` + `alloy-signer-local` + `rmp-serde` | Proven by the spike above. | **Recommended.** One code path for desktop and WASM, our own action structs with explicit field order, and CI pinned to Python SDK test vectors. |
|
||||
|
||||
---
|
||||
|
||||
## 4. User flow
|
||||
|
||||
### 4.1 Turning it on
|
||||
|
||||
1. Settings › Trading › **Hyperliquid** (new section; off by default). This build has **testnet only**: no network selector, no mainnet URL, no switch. Mainnet comes in phase 4, after §12, in a build made for it (§8).
|
||||
2. A first-run acknowledgement like the SnapTrade `enable_dialog` [repo], with Hyperliquid-specific lines: orders are irreversible on-chain transactions; perps are leveraged and can be liquidated; HIP-3 stock markets are perpetual futures, not shares; Hyperliquid's Terms restrict some regions, including Ontario (§12), and you are responsible for whether you may use it.
|
||||
3. **Create wallet / Sign in** (§4.5): the browser opens Terminal's sign-in page, and the user's built-in wallet appears in Settings. Its address is the master address, used for reads and display. "Watch an address" still shows any testnet account read-only, with no key involved, so `PORT`/`POS`/`ORD` light up with live data and no trading.
|
||||
|
||||
### 4.2 Approving the agent (one main-wallet signature)
|
||||
|
||||
1. Terminal generates the agent key locally and stores it (Keychain or browser).
|
||||
2. It builds `approveAgent { hyperliquidChain, signatureChainId, agentAddress, agentName: "terminal-<device> valid_until <ms>", nonce }`.
|
||||
- **Named, never unnamed.** "An account can have 1 unnamed approved wallet and up to 3 named ones", plus 2 more named per sub-account. Approving a new unnamed agent deregisters the existing unnamed one [doc: EXCH, NONCE], which may be the user's Hyperliquid web session or another tool.
|
||||
- **Expiry:** `valid_until`, at most 180 days ahead [doc: EXCH]. Default: 180 days since 2026-11-04, because each approval is a paid Turnkey signature (§4.5); it was 30 (Q5).
|
||||
3. Signing: the built-in Turnkey wallet signs the EIP-712 digest of the approval, at `signatureChainId` 0x66eee with `hyperliquidChain: "Testnet"` (§4.5). Terminal checks that the signature recovers the wallet's address before posting it to `/exchange`. *(Replaced on 2026-11-04: the web's `eth_signTypedData_v4` through a browser wallet and the desktop's pasted testnet key.)*
|
||||
4. Terminal shows: agent address (masked), name, network, expiry, and "Revoke".
|
||||
|
||||
### 4.3 Revoking and rotating
|
||||
|
||||
- The docs list no explicit "revoke agent" action. An agent is deregistered when a new `approveAgent` reuses its **name** (or a new unnamed one is approved), when it expires, or when the account runs out of funds [doc: NONCE].
|
||||
- **Revoke** in Terminal: delete the local key first (Keychain or IndexedDB), then have the built-in wallet sign `approveAgent` with the **same name** and a freshly generated throwaway address whose key is dropped immediately. That costs one Turnkey signature and needs a fresh sign-in; "Delete key here" alone is free.
|
||||
- **Never reuse an agent address.** The docs "strongly" advise it, because once pruned, the nonce set can let "previously signed actions … be replayed" [doc: NONCE]. Rotation always generates a new key.
|
||||
- Terminal warns 7 days before expiry and offers one-click renew (new key, same name).
|
||||
|
||||
### 4.4 Funding
|
||||
|
||||
Terminal moves no funds. The **Deposit** window shows the built-in wallet's address in full, as a QR code and with a Copy button, next to the account's testnet balances (perp account value and withdrawable from `clearinghouseState`, spot USDC from `spotClearinghouseState`), and says it is testnet only. The user sends test USDC there on Hyperliquid testnet (for example a `usdSend` from another testnet account). Account abstraction matters for HIP-3:
|
||||
- In **unified account** mode, "USDC balance is the single source for validator-operated perps, XYZ perps, and spot".
|
||||
- In **standard** mode, each dex has a separate balance [doc: AAM].
|
||||
|
||||
Terminal reads the mode and shows per-dex balances in standard mode. If the HIP-3 dex balance is zero, the ticket explains it instead of failing at submit.
|
||||
|
||||
### 4.5 Built-in wallets (Turnkey), testnet only
|
||||
|
||||
Decided 2026-11-04 (§15). Users never connect MetaMask or any other external wallet, and never paste a private key. Each user gets a wallet that Turnkey holds, and that wallet is their Hyperliquid account. Turnkey references are tagged [tk: X] (list at the end).
|
||||
|
||||
**Who holds what**
|
||||
|
||||
| Key | Where | Signs |
|
||||
|---|---|---|
|
||||
| Wallet (main) key, secp256k1 | Inside Turnkey's enclaves, in the user's own sub-organization. Nobody at Goshen, and no Terminal server, can use it: a parent organization has read access to its sub-organizations and no write access [tk: ORGS]. | Only `approveAgent` (approve, renew, revoke). Never an order. |
|
||||
| Session key, P-256 | Made on the device. Desktop: the OS secret store, like the agent key. Web: memory. | Turnkey API requests (the `X-Stamp` header) for 15 minutes. The session token alone authorizes nothing [tk: SESSIONS]. |
|
||||
| Agent key, secp256k1 | Made on the device, stored as in §3.2 (Keychain, Secret Service, browser memory or the opt-in encrypted IndexedDB copy). | Every L1 action: orders, cancels, modify, leverage, `scheduleCancel`, `noop`. |
|
||||
|
||||
**Sign-in.** The desktop opens the system browser at Terminal's sign-in page, `https://terminal.rasmic.xyz/wallet`. The web build opens the same page in a popup.
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant T as Terminal (desktop or WASM)
|
||||
participant B as Browser: terminal.rasmic.xyz/wallet
|
||||
participant P as Turnkey Auth Proxy
|
||||
participant K as Turnkey API
|
||||
participant H as Hyperliquid testnet
|
||||
T->>T: make session key D, state (256 bits)
|
||||
T->>B: open ?flow=desktop&pk=D.pub&state&port (or a popup on the web)
|
||||
B->>P: email OTP or passkey (@turnkey/core); sign-up creates the sub-org and its wallet
|
||||
P-->>B: browser session for a key B kept by the page
|
||||
B->>K: stamp_login(publicKey = D.pub, 900 s), stamped with B
|
||||
K-->>B: session token bound to D
|
||||
B->>B: log out (drops B)
|
||||
B->>T: 302 to http://127.0.0.1:port/callback?state&session (web: postMessage to the opener)
|
||||
T->>T: check state, token's public_key == D, not expired
|
||||
T->>K: whoami, list_wallets, list_wallet_accounts (stamped with D)
|
||||
T->>T: make agent key A, approveAgent(A, "terminal-<device>", 180 days)
|
||||
T->>K: sign_raw_payload(digest of the testnet approval), stamped with D
|
||||
K-->>T: r, s, v
|
||||
T->>T: the signature must recover the wallet's address
|
||||
T->>H: POST /exchange approveAgent
|
||||
T->>H: orders, signed locally by A
|
||||
```
|
||||
|
||||
1. Terminal makes a fresh P-256 session key D and a 256-bit `state`. On the desktop it listens on `127.0.0.1` at a port the OS picks, for at most 10 minutes.
|
||||
2. The page signs the user in with Turnkey's JS SDK (`@turnkey/core`) through Turnkey's Auth Proxy: an email code, or a passkey. A new user gets a sub-organization with one Ethereum wallet at `m/44'/60'/0'/0/0`; a returning user logs in to theirs [tk: AUTHPROXY].
|
||||
3. The page now has its own short browser session (key B, kept by the SDK). It calls `stamp_login` with `publicKey` set to D's public key. That is the call the SDK's own `refreshSession` makes [tk: SDK-CORE], and it returns a session token bound to D. The page then logs out, so the browser keeps no session.
|
||||
4. The page hands the token back. On the desktop that is a redirect to `http://127.0.0.1:<port>/callback?state=…&session=…`; the page only ever builds that loopback URL from a port number, so it is not an open redirect. On the web it is a `postMessage` to the opener, which checks the message's origin.
|
||||
5. Terminal accepts the token only if `state` matches and the token's `public_key` is D's. It then reads the wallet's address from Turnkey itself (`list_wallets`, `list_wallet_accounts`) with requests stamped by D, never from the URL, and shows the Turnkey user name from `whoami`.
|
||||
|
||||
Why a loopback redirect and not `terminal://`: RFC 8252 recommends the loopback for desktop apps (§7.3). It needs no URL scheme registered with the OS (an `Info.plist` entry on macOS, a `.desktop` file on Linux), and while Terminal is listening no other program can take the port. A custom scheme can be claimed by any app that registers it first. The token in the URL is useless without D's private key, so a redirect that leaks into history or another tab exposes nothing that can sign. A program on the same machine that reads the `open` command's arguments could see `state` and race the browser with a session from its own Turnkey account; Terminal shows the Turnkey user name after sign-in so a swapped account is visible. RFC 8252 treats local malware as out of scope for this reason.
|
||||
|
||||
**What Turnkey signs, and how often.** Turnkey charges per signature: on Pay as You Go, 25 free per month for the whole parent organization, then $0.10 each [tk: PRICING]. Terminal asks it to sign only `approveAgent`:
|
||||
|
||||
- `sign_raw_payload` V2 with `PAYLOAD_ENCODING_HEXADECIMAL` and `HASH_FUNCTION_NO_OP`, over the EIP-712 digest `tr-hl` computes (as `alloy-signer-turnkey` does). The digest is pinned to the Python SDK's vector in tests.
|
||||
- `tr_hl::signing::sign_approve_agent_with` is the only way a remote wallet's signature enters `tr-hl`. It computes the digest of a testnet approval (`hyperliquidChain: "Testnet"`, `signatureChainId: 0x66eee`), refuses anything else before Turnkey is called, and keeps the answer only if it recovers the wallet.
|
||||
- L1 signing takes the sealed `SigningKey` trait, which only the local `AgentKey` (and the test-only `MainKey`) implement. The Turnkey client is not a `SigningKey`, so it cannot sign an order.
|
||||
- Agents approved through the built-in wallet are valid for **180 days** (Hyperliquid's maximum), not 30, because each approval is a paid signature. The 7-day expiry warning stays.
|
||||
|
||||
Budget per user per 180 days: one signature per device (desktop, or a browser with "Remember on this browser" on), plus one per early renewal and one per on-chain revoke. A browser with "remember" off (the default) needs a new approval, and so a new signature, on every visit; Settings says so next to the checkbox. Example: 150 desktop users cost about 25 signatures a month, inside the free tier; 1,000 users about 167 a month, roughly $14. Hyperliquid allows 3 named agents per account, so one wallet can trade from 3 devices at a time [doc: EXCH]. Turnkey doesn't list which other activities (sub-organization creation, OTP, sessions, export) it bills as signatures; its definition, "a secure authorization that executes an onchain action", suggests they aren't, but that is unconfirmed.
|
||||
|
||||
**Sessions.** Sessions last 15 minutes (`stamp_login` with `expirationSeconds: 900`, Turnkey's default). That is enough to approve a trading key, and nothing else needs one. On the desktop the session key and token are kept in the OS secret store under `turnkey:testnet:<address>` so a restart in those 15 minutes doesn't force another sign-in; Terminal deletes the item when the session expires or the user signs out. On the web they live in memory only. Neither is ever logged: `SessionKey` and `Session` print a masked public key only.
|
||||
|
||||
**No Terminal server holds a Turnkey credential.** Creating a sub-organization is an activity the parent organization signs [tk: SUBORG], so some parent-organization key has to sign it. With the **Auth Proxy**, that key is a proxy-scoped API key Turnkey creates in Micky's parent organization when the proxy is turned on, and Turnkey stores and uses it; the page only sends the public config ID (`X-Auth-Proxy-Config-Id`) [tk: AUTHPROXY]. `tr-gateway` doesn't change, and no Vercel function is added. The parent organization, and so the proxy key, can start sign-ins and create sub-organizations, but cannot sign with or change a sub-organization's wallet [tk: ORGS, WAAS].
|
||||
|
||||
Proxy settings Micky needs (Turnkey dashboard, Embedded Wallets › Configuration): allowed origins `https://terminal.rasmic.xyz` only; email OTP and passkeys on, everything else off; session expiration 900 s; the sign-up wallet one Ethereum account at `m/44'/60'/0'/0/0`; captcha (Turnstile) on if sign-up abuse shows up.
|
||||
|
||||
Fallback, only if Micky doesn't want the Auth Proxy (**not built, not deployed**): one small Vercel function on the site, `POST /api/wallet/signup`, holding a parent-organization API key that belongs to a **non-root** user (root users bypass every policy [tk: POLICIES]). Its only policy:
|
||||
|
||||
```json
|
||||
{
|
||||
"policyName": "terminal-signup: sub-organizations and OTP only",
|
||||
"effect": "EFFECT_ALLOW",
|
||||
"consensus": "approvers.any(user, user.id == '<SIGNUP_USER_ID>')",
|
||||
"condition": "activity.type == 'ACTIVITY_TYPE_CREATE_SUB_ORGANIZATION_V8' || activity.type == 'ACTIVITY_TYPE_INIT_OTP_V3' || activity.type == 'ACTIVITY_TYPE_VERIFY_OTP_V2' || activity.type == 'ACTIVITY_TYPE_OTP_LOGIN_V2'"
|
||||
}
|
||||
```
|
||||
|
||||
Everything else is denied by default. The function would rate-limit by IP and by email (the site already uses Upstash Redis), accept only an email or a passkey attestation plus the client's public key, and expose no signing route. Even a stolen copy of that key could not sign for a user's wallet, because the parent has no write access to sub-organizations.
|
||||
|
||||
**Backup and export.** "Back Up Wallet…" warns first (anyone with the phrase controls the wallet; Terminal and Goshen can't restore it; never type it into a website), then opens the sign-in page in export mode. The user signs in again there. The page embeds Turnkey's export iframe (`export.turnkey.com`, `@turnkey/iframe-stamper`), which makes its own target key; the page asks Turnkey to export the wallet to that key, and passes the encrypted bundle to the iframe, which decrypts and shows the recovery phrase [tk: EXPORT]. The phrase exists in plaintext only inside Turnkey's iframe: not in the page's JavaScript, not in the app, not on any Terminal server. The page logs out when the user is done.
|
||||
|
||||
**Mainnet stays impossible.** Nothing in §8's testnet rail changes. The endpoints are still constants, `source` is `"b"`, and `hyperliquidChain` is `"Testnet"`. `approve_agent_hash` now also requires `signatureChainId` 0x66eee, so Turnkey can only be asked for a testnet approval. `crates/hl/tests/mainnet_impossible.rs` checks that a remote wallet is never called for a mainnet or 0xa4b1 approval, that Turnkey's API (its local stand-in) receives nothing for one, and that the Turnkey crate, the app's wallet modules and the sign-in page name no mainnet host.
|
||||
|
||||
**Removed from the app.** The web's EIP-1193 "Connect Wallet" and the desktop's "paste a testnet main key" field. `MainKey` stays in `tr-hl` for tests and the developer-only `testnet_check` example (run from a terminal with a 0600 key file; nothing in the app reaches it). "Watch an address" stays: it is read-only, holds no key and signs nothing, and it lets the panes work before Turnkey is set up.
|
||||
|
||||
---
|
||||
|
||||
## 5. Order routing: perps, spot and HIP-3 dexes
|
||||
|
||||
### 5.1 Asset ids [doc: ASSET, EXCH]
|
||||
|
||||
| Market | Asset id | Example |
|
||||
|---|---|---|
|
||||
| Core perp | index in `meta.universe` | `BTC` = 0 on mainnet |
|
||||
| Spot | `10000 + spotMeta.universe[i].index` | `PURR/USDC` = 10000. HYPE spot is 107 on mainnet and 1035 on testnet: **ids differ by network** |
|
||||
| HIP-3 perp | `100000 + perp_dex_index * 10000 + index_in_meta`, names always `{dex}:{coin}` | see below |
|
||||
|
||||
`perp_dex_index` is the position in the `perpDexs` info response (index 0 is the core dex) [doc: ASSET, PERP]. Observed on 2026-11-02:
|
||||
|
||||
| Network | dex (index) | `xyz:AAPL` | Others |
|
||||
|---|---|---|---|
|
||||
| Mainnet | `xyz` (1), `para` (8), `mkts` (9), `io` (10) | index 9 in `meta{dex:"xyz"}` → **110009** | `para:TOTAL2` → 180000, `mkts:US500` → 190000, `io:OAI` → 200000 |
|
||||
| Testnet | `xyz` (**65**); `para`, `io`, `mkts` absent | index 9 → **750009** | — |
|
||||
|
||||
**Rule: never hard-code asset ids.** `tr-hl` builds an `AssetRegistry` per network from `perpDexs` + `meta{dex}` per dex + `spotMeta`. It caches with a TTL and refreshes on an unknown coin or a reject. It is keyed by the venue-native names `tr-proto` already uses (`BTC`, `xyz:AAPL`, `@107`, `PURR/USDC`) [repo]. `szDecimals`, `maxLeverage`, `onlyIsolated` and `marginMode` come from the same `meta` (for `xyz:AAPL`: `szDecimals` 3, `maxLeverage` 20 [observed]).
|
||||
|
||||
### 5.2 Price and size rules [doc: TICK]
|
||||
|
||||
- Prices: at most 5 significant figures, and at most `6 − szDecimals` decimals for perps or `8 − szDecimals` for spot. Integer prices are always valid.
|
||||
- Sizes are rounded to `szDecimals`. Trailing zeros are stripped before signing.
|
||||
- Minimum order value: $10 [doc: EXCH, error example].
|
||||
|
||||
The ticket rounds as you type and shows the value that will be sent.
|
||||
|
||||
### 5.3 Margin and leverage
|
||||
|
||||
`updateLeverage {asset, isCross, leverage}` [doc: EXCH]. The ticket shows the current leverage and margin mode from `activeAssetData` [doc: WSSUB]. Changing it is its own confirmed action, never a side effect of placing an order. HIP-3 assets may be isolated-only (`onlyIsolated`) [doc: PERP, HIP3]. Terminal respects the meta flag and caps leverage at `min(meta.maxLeverage, user cap)` (§8).
|
||||
|
||||
---
|
||||
|
||||
## 6. Order types and the ticket
|
||||
|
||||
### 6.1 Mapping
|
||||
|
||||
| Ticket | Hyperliquid action | Notes |
|
||||
|---|---|---|
|
||||
| Limit + GTC / IOC / Post-only | `order` with `t: {limit: {tif: "Gtc" \| "Ioc" \| "Alo"}}` | ALO "will be canceled instead of immediately matching" [doc: EXCH] |
|
||||
| Market | `order` with an IOC limit at `mid × (1 ± slippage)` | There is no native market order. The Python SDK's `market_open` uses an IOC limit and a default 5% slippage [doc: SDK-EXCH]. Terminal default: 1%, editable, shown in the confirm. |
|
||||
| Stop / Stop-limit, Take-profit | `order` with `t: {trigger: {isMarket, triggerPx, tpsl: "sl" \| "tp"}}` | |
|
||||
| Bracket (entry + TP + SL) | `order` batch with `grouping: "normalTpsl"`; position TP/SL with `"positionTpsl"` | phase 3 |
|
||||
| Reduce-only | `r: true` | Default on for close and `SELL ALL` from a position row |
|
||||
| Client order id | `c`: 128-bit hex `cloid` | Always set, so an uncertain placement can be reconciled (like `PlaceError::uncertain` today [repo]) |
|
||||
| Modify | `modify` / `batchModify` | Only from `ORD` |
|
||||
| Cancel | `cancel` (by `oid`) or `cancelByCloid` | `f: true` (fast) is rejected for trigger orders [doc: EXCH]. Terminal leaves `f` out and pins the exact bytes with SDK test vectors, since field presence changes the hash [inferred] |
|
||||
| Expiry | `expiresAfter = now + 30 s` on every order | A stale signed order can't land late. Expired actions "consume 5x the usual address-based rate limit" [doc: EXCH], so the window is short but not tiny. |
|
||||
| Not in v1 | `twapOrder`, `trailingStop`, outcomes, vault/sub-account `vaultAddress` | |
|
||||
|
||||
`TimeInForce::Day` and `Fok` have no Hyperliquid equivalent. The ticket hides them for HL accounts.
|
||||
|
||||
### 6.2 The ticket in the Bloomberg-style panes
|
||||
|
||||
The existing `Ticket` window gains an HL variant, chosen by the account, not a second ticket:
|
||||
|
||||
```
|
||||
┌ BUY xyz:AAPL ─ Apple Inc perp ─ PERP · HL xyz ─────────── TESTNET ┐
|
||||
│ Account HL 0x12…ab (unified) Equity $1,024.10 Avail $812 │
|
||||
│ Side [BUY] SELL Type Market Limit Stop TP │
|
||||
│ Size 0.500 AAPL ≈ $113.20 [units | $] │
|
||||
│ Limit 226.40 TIF GTC IOC Post-only │
|
||||
│ Leverage 5× cross (max 20×) Reduce-only [ ] Slippage 1.0% │
|
||||
│ Est. fee $0.05 Liq. px ≈ 184.9 Margin $22.64 │
|
||||
│ ⚠ Perpetual future on Hyperliquid (xyz). Not a share of AAPL. │
|
||||
│ [ Review order ⏎ ] │
|
||||
└────────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
- **Network badge** in the title: `TESTNET` in amber, `MAINNET` in the live red that `LIVE` uses today [repo].
|
||||
- The market line reuses the label code that already renders `PERP · HL xyz` [repo]. HIP-3 rows add a one-line perp warning that cannot be dismissed (§7).
|
||||
- Reference price, fee and liquidation estimates come from the same network the order goes to. On testnet that is testnet `allMids`/`l2Book`, **not** the gateway's mainnet quotes [inferred: testnet prices differ from mainnet].
|
||||
- Commands: `BUY xyz:AAPL 0.5 @226.4`, `SELL BTC 0.01 MKT`, `TRADE HYPE/USDC`, with the same parser as today. A new `KILL` command and the Kill button are covered in §8.
|
||||
|
||||
### 6.3 Confirmation
|
||||
|
||||
Every HL order and cancel-all goes through **one confirmation modal**. There is no one-click (that stays demo-only). It shows:
|
||||
|
||||
- network;
|
||||
- master address (masked) and agent name;
|
||||
- market with its kind (perp/spot/HIP-3 dex);
|
||||
- side, size, price or slippage cap;
|
||||
- notional;
|
||||
- leverage and margin mode;
|
||||
- estimated liquidation price;
|
||||
- fee, including any builder fee;
|
||||
- reduce-only;
|
||||
- `expiresAfter`.
|
||||
|
||||
Editing any field invalidates the confirmation, mirroring today's "preview of exactly the ticket's current fields" rail [repo]. Hyperliquid has no preview endpoint, so Terminal builds the preview locally from `activeAssetData` and `clearinghouseState`. It is labelled as an estimate.
|
||||
|
||||
---
|
||||
|
||||
## 7. HIP-3 stock perps: labelled as perps, not shares
|
||||
|
||||
The rules from `server.md` §7 carry over to trading [repo]:
|
||||
|
||||
1. A HIP-3 market is its own instrument (`xyz:AAPL`). `BUY AAPL` on an HL account **does not** route to `xyz:AAPL`. It errors with a suggestion: "AAPL is a stock. Hyperliquid has the perp xyz:AAPL (PERP · HL xyz). Trade that instead?"
|
||||
2. Everywhere the market appears (ticket, confirm, `POS`, `ORD`, fills, toasts), the `PERP · HL <dex>` label is shown. Positions use `InstrumentKind::Future` (no new variant needed) and show "perp" in the description, never "shares".
|
||||
3. The confirm repeats that it is a leveraged perpetual future with funding, priced by the deployer's oracle, which can diverge from the listed stock (off-hours, opens, earnings, corporate actions). The deployer can halt and settle the market at mark [doc: HIP3]. Cross margin across dexes carries extra risk [doc: HIP3].
|
||||
4. `PORT` totals keep HL perps apart from brokerage share holdings. There is no "AAPL exposure" netting across a share and a perp in v1.
|
||||
|
||||
---
|
||||
|
||||
## 8. Safety
|
||||
|
||||
| Rail | Design |
|
||||
|---|---|
|
||||
| Off by default | `prefs.trading.hl.enabled = false`, separate from SnapTrade's `enabled`. The Hyperliquid account is read-only until enabled with its acknowledgement (§4.1). |
|
||||
| Testnet only (v1) | `Network` has one variant, `Testnet`. The endpoints are the testnet hosts, the phantom agent's `source` is always `"b"` and `hyperliquidChain` always `"Testnet"`; a stored `"network": "mainnet"` reads as testnet. Tests grep `tr-hl` for mainnet hosts. Mainnet needs a later build after §12, with its own acknowledgement. The amber TESTNET badge is on the toolbar, the account, the ticket and every confirmation. |
|
||||
| Main wallet (built-in) | The Turnkey wallet signs only `approveAgent`, through `tr_hl::signing::sign_approve_agent_with`: Turnkey is handed the digest of a testnet approval at 0x66eee and nothing else, and the signature must recover the wallet before it is sent. Turnkey sessions last 15 minutes, are bound to a key made on the device, and never reach a log (§4.5). |
|
||||
| Confirmation | One per order, cancel-all and leverage change (§6.3). Enter confirms; Escape or a click outside cancels, as today [repo]. |
|
||||
| Hard caps | Constants in `tr-hl`, not settings: **$100** per order (size × the higher of the order price and the mid), **3×** leverage, **1%** market slippage. `tr-hl` checks them when the confirm opens, and the worker reads the testnet mid and leverage again and re-checks right before signing. An order is refused while the account's leverage on that market is over 3× (Hyperliquid's default is 20×), so a confirmed "Set 3×" comes first; reduce-only orders are not blocked by leverage. A breach refuses before anything is signed. |
|
||||
| Kill switch | `KILL` or the red KILL button in `ORD` (and in Settings): one confirmation, then Hyperliquid orders turn off in prefs and every open order is cancelled, one `cancel` action per dex (cancels have a higher rate-limit allowance [doc: RL]). KILL never waits for another request. It cancels at once and refuses any order confirmed before it that hasn't been signed, even one still reading the market. If an order was already being sent, KILL cancels again once that order can no longer land. That is when its answer comes, or when its `expiresAfter` has passed if the send got no clear answer. Single cancels and the dead man's switch never wait for orders either. "And flatten" is not in v1. |
|
||||
| Dead man's switch | **Opt-in, off by default** (Q7). While on and trading is enabled, Terminal keeps a `scheduleCancel` about 60 s ahead and refreshes it every 30 s [doc: EXCH]; turning it off clears the schedule. These changes go out one at a time, in the order Terminal made them, and never wait for an order. A newer change replaces one still waiting, and each is signed with a fresh deadline when it goes out. After a change whose send got no clear answer, the next waits until that one can no longer land, so an older timer or clear never lands after a newer change. Constraints: at least 5 s ahead, and at most 10 triggers per day, reset 00:00 UTC [doc: EXCH]. |
|
||||
| Nonces | One agent per device and network (nonces are tracked per signer; "use a separate private key" per session [doc: NONCE]). The nonce is an atomic `max(last + 1, now_ms)`, within (T − 2 days, T + 1 day) [doc: NONCE]. A noop (`{"type": "noop"}`) can invalidate an in-flight nonce [doc: EXCH]. |
|
||||
| Uncertain results | A network error after send means "uncertain", never "failed". Reconcile by `cloid` via `orderStatus` and `orderUpdates` before allowing a resend (same idea as `PlaceError::uncertain` [repo]). Every `/info` and `/exchange` request has its own 10 s deadline, in the browser too (`fetch` has no default timeout); an `/exchange` request that runs out of time is uncertain. |
|
||||
| Never log keys | `AgentKey` is a newtype with redacted `Debug`/`Display` (like `Credentials` and `mask()` [repo]) and is zeroized on drop. It is never serialized into prefs, layouts or the demo JSON. Signed request bodies are not logged; logs carry `cloid`, `oid`, asset and status only. Unit test: a `tracing` capture of a full place/cancel cycle contains no 64-hex-digit secrets. |
|
||||
| Rate limits | Address-based limit: 1 request per 1 USDC traded since inception, plus an initial buffer of 10,000; when limited, 1 request every 10 s [doc: RL]. A human-driven terminal stays far below this. Terminal reads `userRateLimit` [doc: INFO] and surfaces it in Settings, and backs off quietly on 429s, as the SnapTrade capabilities code does [repo]. |
|
||||
|
||||
---
|
||||
|
||||
## 9. Positions, orders, fills and PnL from the user streams
|
||||
|
||||
The client connects **directly** to `wss://api.hyperliquid.xyz/ws` (testnet: `wss://api.hyperliquid-testnet.xyz/ws`) [doc: WS] for the user's own data, not through `tr-gateway`. That keeps user addresses off our server and fits the 10-users-per-IP limit [doc: RL]. The REST endpoints answer CORS preflight with `access-control-allow-origin: *` on mainnet and testnet [observed], so the web build can post `/exchange` and `/info` directly.
|
||||
|
||||
| Subscription [doc: WSSUB] | Feeds |
|
||||
|---|---|
|
||||
| `clearinghouseState {user, dex}` per used dex, or `allDexsClearinghouseState {user}` | `POS` rows (szi, entryPx, positionValue, unrealizedPnl, returnOnEquity, liquidationPx, marginUsed, cumFunding, leverage), `ACCT` (marginSummary, crossMarginSummary, withdrawable) [doc: PERP] |
|
||||
| `spotState {user}` | spot balances (in unified/portfolio-margin modes, "all balances and holds" live in the spot clearinghouse state [doc: AAM]) |
|
||||
| `openOrders {user, dex}` and `orderUpdates {user}` | `ORD` (status, limitPx, sz, origSz, oid, cloid) |
|
||||
| `userFills {user}` | `ACTV` fills, realized PnL (`closedPnl`), fees (`fee`, which includes `builderFee`) [doc: INFO] |
|
||||
| `userEvents {user}` (channel `user`) | fills, funding, liquidations, `nonUserCancel`: toasts |
|
||||
| `userFundings`, `userNonFundingLedgerUpdates` | funding and deposit/transfer history in `ACTV` |
|
||||
| `activeAssetData {user, coin}` | the ticket: leverage, max trade sizes, available to trade (perps only) |
|
||||
| `webData3 {user}` | optional aggregate. The current docs list `webData3`, not `webData2`, and warn that undocumented fields "will be removed" [doc: WSSUB]. Terminal uses the typed streams above. |
|
||||
| `notification {user}` | toasts |
|
||||
|
||||
The streaming endpoints send a snapshot first with `isSnapshot: true` [doc: WSSUB]. After a reconnect, Terminal replaces state from the snapshot and dedupes fills by `(time, coin, tid)` (the docs give that as the globally unique trade id [doc: WSSUB]).
|
||||
|
||||
**Model mapping.** A new `tr-hl` adapter produces the existing `tr_broker::Portfolio`:
|
||||
- `Account { institution: "Hyperliquid", kind: "Perps" | "Spot", is_paper: network == Testnet, number: masked address }`.
|
||||
- `Position { kind: Future | Crypto, units: szi (signed), cost_basis: entryPx, price: mark }`.
|
||||
|
||||
So `PORT`/`POS`/`ORD`/`ACTV` render HL with modest UI change, **but the market link needs real work**:
|
||||
|
||||
- Today `tr_broker::market_key()` returns `None` for `InstrumentKind::Future` [repo: `crates/broker/src/model.rs`].
|
||||
- For `Crypto` it builds a spot-style pair (`BTC` → `BTCUSD`), not a Hyperliquid perp key [repo].
|
||||
- `PortfolioPanes::live_price` looks quotes up only by that key [repo: `app/src/app/broker.rs`].
|
||||
- The position-row Buy/Sell/Close controls are limited to `Stock | Etf` [repo: `app/src/app/portfolio_view.rs`].
|
||||
|
||||
As modelled above, an HL perp row would have no live repricing, no chart link and no trade buttons. The integration therefore adds:
|
||||
|
||||
1. **A venue-native market key on `Position`.** A new optional, serde-defaulted field (`market: Option<String>`) that the HL adapter fills with the exact key the gateway publishes in `MarketInfo.key` (`xyz:AAPL` for HIP-3, the core-perp and spot keys as `tr-proto` names them) [repo]. `Position::market_key()` returns it before falling back to today's kind-based mapping, so SnapTrade and demo rows are unchanged. HIP-3 keys resolve through `Security::perp` (`AssetClass::Perp`) [repo: `crates/core/src/security.rs`], never through the stock path.
|
||||
2. **Repricing from that key, kept separate per network.** Mainnet HL rows reprice through today's path: `live_price` reads the shared quote map, which `broker_periodic` fills from the gateway [repo]. Testnet rows **must not** use that map. Testnet and mainnet use the same market keys (`xyz:AAPL`), so either network's price could overwrite the other's. The plan:
|
||||
- a separate `testnet_quotes` map owned by the HL adapter, filled only from testnet `allMids` (per dex);
|
||||
- `live_price` takes the account's network (from `Account.is_paper` / the HL account's network) and reads only the matching map;
|
||||
- `broker_periodic` requests gateway quotes only for keys from mainnet accounts (and SnapTrade/demo), never for testnet rows;
|
||||
- the ticket's reference price, fee and liquidation estimates use the same network-selected source (§6.2);
|
||||
- tests feed interleaved mainnet and testnet updates for the same key and assert each row and ticket reads only its own network.
|
||||
3. **Row actions.** The `tradable` check in `portfolio_view.rs` also allows rows from an enabled HL account that have a `market`. Close sends a reduce-only order (§6.1), through the same confirmation (§6.3). Chart and key menus use `market`.
|
||||
4. **Tests.** Unit tests for `market_key()` precedence (an HL row with `market` set, a SnapTrade `Future` row still `None`), and `live_price` tests with perp quotes on both networks.
|
||||
|
||||
Unrealized PnL is then repriced from the gateway's live marks on mainnet, or testnet mids on testnet, as the panes already do for SnapTrade rows that have a key [repo]. Realized PnL = Σ `closedPnl` − Σ `fee` over the selected range, with funding shown separately.
|
||||
|
||||
---
|
||||
|
||||
## 10. Code layout (as built in v1; see §16)
|
||||
|
||||
- `crates/hl` (`tr-hl`): `signing` (§3.4), `actions` (order, cancel, cancelByCloid, modify, batchModify, updateLeverage, scheduleCancel, noop, approveAgent, approveBuilderFee, structs with explicit field order), `assets` (the registry, §5.1), `rounding` (§5.2), `client` (`/info` and `/exchange` over `reqwest`, which already compiles for wasm in this workspace [repo]), `stream` (user websocket), `nonce`. No `tokio` "full"; it follows `tr-broker`'s wasm split [repo].
|
||||
- `tr-broker`: `secrets` gains a second store for agent keys (Keychain service `terminal.hyperliquid`). The existing SnapTrade item is untouched.
|
||||
- `tr-broker` `model`: `Position.market: Option<String>` (serde default) and the `market_key()` precedence change from §9. `app/src/app/portfolio_view.rs`: the HL row actions. `app/src/app/broker.rs`: a network-aware `live_price` with a separate testnet quote map, and `broker_periodic` requesting gateway quotes only for mainnet keys (§9).
|
||||
- `app`: `TradingPrefs.hl: HlPrefs { enabled, network, max_order_value, max_leverage, daily_notional_cap, slippage_bps, dead_mans_switch, agent: Option<AgentMeta> }`. `AgentMeta` holds the public address, name, expiry and network, never the key. New `TradeRequest::Hl(HlRequest)` and `TradeResponse::Hl(..)` variants in the worker. The ticket gets an HL variant, and Settings › Trading gets a Hyperliquid section.
|
||||
- `tr-proto` / `tr-gateway`: **no change.** Market data keeps flowing as today. User data and orders never touch the server.
|
||||
- Built-in wallets (§4.5): `crates/turnkey` (`tr-turnkey`: the session key and stamps, the Turnkey client, the desktop loopback callback, and a `mock` feature with a local stand-in for tests and screenshots); `app/src/app/wallet.rs` (sign-in, approval through Turnkey, sessions) and `wallet_view.rs` (the wallet section, Deposit, Back Up); `site/src/routes/wallet/` (the sign-in and export page, `@turnkey/core` and `@turnkey/iframe-stamper`).
|
||||
|
||||
---
|
||||
|
||||
## 11. Testing plan (testnet only, evidence-driven)
|
||||
|
||||
**No orders are placed in this design step**, on mainnet or testnet. From phase 1 on, every trading test runs on **testnet only**, until Micky signs off on §12 and phase 4.
|
||||
|
||||
1. **Unit (CI, offline):**
|
||||
- Signing matches the Python SDK vectors (`test_phantom_agent_creation_matches_production`, `test_l1_action_signing_matches`, `…_order_matches`, `…_order_with_cloid_matches`, `…_matches_with_vault`, `…_tpsl_order_matches`, `test_schedule_cancel_action`, and the user-signed `test_sign_usd_transfer_action`; the SDK has no approve-agent vector, so we generate one with the Python SDK and pin it) [doc: SDK-TEST], natively and under `wasm-bindgen-test`.
|
||||
- msgpack field order, rounding (§5.2) and asset ids from recorded `perpDexs`/`meta` fixtures.
|
||||
- The no-secrets-in-logs test.
|
||||
2. **Recorded fixtures:** JSON from testnet `/info` and websocket snapshots, so parser tests don't need the network. This follows server.md's fixture approach [repo].
|
||||
3. **Testnet integration (manual and scripted, gated by an env flag, never in CI):**
|
||||
- approve agent → place a $10–20 GTC limit far from the market on `BTC` and on testnet `xyz:AAPL` (750009) → confirm it shows in `ORD` → modify → cancel → kill switch with two resting orders → revoke agent and prove the old key is rejected;
|
||||
- then one small IOC fill to see `userFills`, `POS` and PnL.
|
||||
- Testnet lacks `para`, `io` and `mkts` [observed]; those dexes are covered by asset-id fixture tests only, until mainnet.
|
||||
4. **Evidence for each PR:**
|
||||
- **before-and-after screenshots** of the ticket, confirm, `POS`, `ORD`, `ACTV` and Settings › Trading, on desktop and web;
|
||||
- the testnet `oid`s and the explorer links for each action;
|
||||
- the list of commands run. No keys, addresses in full, IPs or `.env` contents appear in PRs or logs.
|
||||
5. **Browser:** the web build signs on testnet from MetaMask and from Rabby (approve agent), and from the WASM agent key (orders).
|
||||
6. **Failure drills:** kill the network mid-order (an uncertain result reconciled by `cloid`), let `expiresAfter` lapse, hit the max-notional guard, use an expired agent, and run the dead man's switch firing.
|
||||
|
||||
**Testnet funding caveat:** the faucet gives 1,000 mock USDC only to addresses that "have deposited on mainnet with the same address" [doc: FAUCET]. For an Ontario resident, that mainnet deposit is itself a ToS question (§12, Q2).
|
||||
|
||||
---
|
||||
|
||||
## 12. Legal and Terms of Use (needs Micky)
|
||||
|
||||
- **Terms of Use, last updated June 15, 2026, §1.6:** "Restricted Persons include: (a) persons or entities who reside in, are located in, are incorporated in, or have a registered office in the United States of America or **Ontario, Canada**; (b) … jurisdictions subject to applicable economic and trade sanctions …; and (c) citizens of Restricted Territories" [doc: TOU].
|
||||
- **§3.1.5** prohibits circumvention: "using technologies such as VPNs, proxies, or other methods to conceal your location" or "making false statements … about your residency" [doc: TOU].
|
||||
- **§1.9:** the user is responsible for compliance, "including … laws governing leveraged or derivative trading" [doc: TOU].
|
||||
- **Scope:** the Terms govern Hyperliquid Corp.'s website Interface at `app.hyperliquid.xyz`. They say the Interface "is not the exclusive means of accessing Hyperliquid" (§1.1) [doc: TOU]. They don't directly address third-party clients or the API. **That is not a green light.** Building a client that lets Ontario or US residents trade where the official interface may not is exactly what needs legal review. The design must not geo-dodge, hide location or tell users how to get around restrictions.
|
||||
- **Micky and Goshen Labs are in Toronto.** Micky personally trading on mainnet, and Goshen shipping order entry to Ontario or US users, are separate questions, and both need advice. Collecting builder fees (§13) could make Terminal look like it is in the business of trading, which matters under Ontario securities law [inferred; not legal advice].
|
||||
- **Proposed product guardrails, whatever the outcome:**
|
||||
- Hyperliquid trading off by default (§8);
|
||||
- a jurisdiction attestation in the enable dialog ("I am not a Restricted Person under Hyperliquid's Terms"), with a link to the Terms;
|
||||
- no circumvention guidance;
|
||||
- an option to compile HL trading out of a build entirely (`hl-trading` cargo feature) if counsel says so.
|
||||
|
||||
---
|
||||
|
||||
## 13. Builder codes (monetisation option) [doc: BUILDER, EXCH]
|
||||
|
||||
- An order may carry `builder: {b: address, f: tenths of a bp}`. For example, `f: 10` = 1 bp. Up to 5 sub-builders can share the fee.
|
||||
- Caps: 0.1% on perps and 1% on spot. Builder fees only apply to fees collected in the quote or collateral asset, so not to the buying side of spot.
|
||||
- The user must first sign `approveBuilderFee {maxFeeRate, builder}` with the **main wallet**, not an agent, and can revoke. A user can have at most 10 active approvals.
|
||||
- The builder address needs at least 100 USDC in perps account value and must be in standard abstraction mode to accrue fees [doc: AAM].
|
||||
- Fees are claimed through referral rewards. Per-day builder fills are published at `stats-data.hyperliquid.xyz/Mainnet/builder_fills/{address}/{YYYYMMDD}.csv.lz4`, with the address lowercase.
|
||||
- **Design:** the builder code is off in v1 (`f` absent). If Micky wants it, it is a separate opt-in screen showing the rate (e.g. 1–5 bp) before the main wallet signs, and the confirm always shows the builder fee. It is subject to §12 (Q1).
|
||||
|
||||
---
|
||||
|
||||
## 14. Phased milestones (small PRs, each proving one thing)
|
||||
|
||||
| Phase | Scope | Exit evidence |
|
||||
|---|---|---|
|
||||
| 0 | This doc, reviewed. | Micky's answers to §15 |
|
||||
| 1 | `tr-hl` signing, actions, assets and rounding, plus SDK vector tests (native + wasm). Read-only: connect an address, then `POS`/`ORD`/`ACTV`/`ACCT` from the user streams (testnet and mainnet reads), including the `Position.market` key so perp rows reprice live and link to charts (§9). | CI green, screenshots of the read-only panes |
|
||||
| 2 | Agent lifecycle on **testnet**: generate, Keychain/IndexedDB storage, approve (web wallet + desktop handoff), expiry warning, revoke. | testnet `extraAgents` before and after; a revoked key is rejected |
|
||||
| 3 | Testnet order entry: limit/market(IOC)/stop/TP, reduce-only, modify, cancel, kill switch, dead man's switch, max-notional and leverage guards, confirm modal, HIP-3 labelling. | §11 drills, before-and-after screenshots, testnet oids |
|
||||
| 4 | Mainnet behind `hl-mainnet` + acknowledgement, **only after §12 is resolved**. Tiny-size canary trades by Micky personally. | Legal sign-off recorded in this doc |
|
||||
| 5 | Optional: builder code, brackets (`normalTpsl`), spot↔perp `usdClassTransfer`, Linux Secret Service. | per feature |
|
||||
|
||||
---
|
||||
|
||||
## 15. Open questions for Micky
|
||||
|
||||
**Decided (2026-11-04).** Micky: built-in wallets only. Users never connect MetaMask or any external wallet, and never paste a private key. As designed in §4.5:
|
||||
|
||||
1. Each user gets a Turnkey sub-organization with one Ethereum wallet, created at first sign-in. Its address is their Hyperliquid account.
|
||||
2. Sign-in happens in the system browser on `terminal.rasmic.xyz/wallet` (email code or passkey through Turnkey's Auth Proxy). The page returns a session bound to a key made on the device: a loopback redirect to `127.0.0.1` on the desktop, `postMessage` on the web.
|
||||
3. The built-in wallet signs only `approveAgent` (approve, renew, revoke), named `terminal-<device>`, valid 180 days, never unnamed and never a reused address. `withdraw3` and `usdSend` are not built.
|
||||
4. The agent key is made on the device and kept as decided on 2026-11-02 (items 3 and 4 below). It signs every L1 action.
|
||||
5. No Terminal server holds a Turnkey credential: the Auth Proxy signs sub-organization creation with a key Turnkey keeps. `tr-gateway` stays data-only. A fallback signup function with a narrowly scoped key is designed in §4.5 but not built.
|
||||
6. Turnkey sessions last 15 minutes and are stored like the agent key (Keychain, Secret Service, browser memory).
|
||||
7. Deposit shows the address, a QR code and the testnet balances. Backup goes through Turnkey's export iframe on the sign-in page.
|
||||
8. The web's EIP-1193 wallet and the desktop's pasted key are removed. "Watch an address" (read-only) stays.
|
||||
9. Everything decided on 2026-11-02 still holds (caps, confirmations, KILL, the opt-in dead man's switch, testnet only), except that agents are now valid 180 days by default instead of 30 (item 3), because each approval is a paid Turnkey signature.
|
||||
|
||||
**Decided (2026-11-02).** Micky: "Build it on testnet now, we'll sort the legal review before mainnet." Defaults for v1:
|
||||
|
||||
1. Signing on the device only (Option A). `tr-gateway` stays data-only; no server change.
|
||||
2. A named API wallet, `terminal-<device> valid_until <ms>`, valid 30 days by default (180 at most; 180 by default since 2026-11-04). Never the unnamed slot, never a reused agent address.
|
||||
3. Desktop keys in the macOS Keychain; on Linux the Secret Service with no file fallback (no keyring means no trading, and Settings says why). Windows: no store in this build, so trading stays off there.
|
||||
4. Web keys for the session only (memory), with an opt-in "remember on this browser" that encrypts with a non-extractable WebCrypto AES-GCM key kept in IndexedDB.
|
||||
5. Perps only: the core dex and the HIP-3 `xyz` dex. No spot; `para`, `io` and `mkts` are refused at the ticket with a reason.
|
||||
6. Hard caps enforced in `tr-hl` before signing, not raisable in this build: $100 per order, 3× leverage, 1% market slippage.
|
||||
7. The dead man's switch is opt-in, off by default.
|
||||
8. One confirmation for every order, modify, cancel-all and leverage change, plus the kill switch (`KILL` and a button in `ORD`).
|
||||
9. No builder fee: the `builder` field is never sent.
|
||||
10. Hyperliquid sits inside LIVE next to the SnapTrade accounts, with an amber TESTNET badge on the account, the ticket, the confirmation and the toolbar.
|
||||
11. Asset ids come from the API per network (`perpDexs` and `meta` per dex), never hard-coded.
|
||||
12. A separate testnet quote map: testnet rows and tickets never read the gateway's mainnet quotes, and the reverse.
|
||||
|
||||
The questions as first asked:
|
||||
|
||||
1. **Legal / ToS (blocking for mainnet):** Hyperliquid's Terms restrict persons in Ontario (§12). Who reviews (a) you personally trading, and (b) Goshen Labs shipping HL order entry to users in Ontario and the US? Until then, is a testnet-only build OK to develop and demo?
|
||||
2. **Testnet funds:** the faucet requires a prior mainnet deposit from the same address. Do you already have a mainnet-funded address you're comfortable using for testnet, or should we find another source of testnet USDC (e.g. a teammate outside restricted regions sending testnet USDC) [unverified that this works]?
|
||||
3. **Custody:** OK with Option A (local API wallet, main wallet signs once, server never signs)? Or do you want Option B for a future hosted or mobile product?
|
||||
4. **Web key storage:** default to session-only agent keys on the web (re-approve each visit), with an opt-in encrypted IndexedDB key? Or remember by default?
|
||||
5. **Agent expiry:** 30 days by default (max 180)?
|
||||
6. **Linux:** require Secret Service (libsecret) for the agent key, or allow today's owner-only file fallback with a warning?
|
||||
7. **Dead man's switch:** on by default while trading is enabled (cancels *all* the account's open orders if Terminal disappears, including ones placed elsewhere), or opt-in?
|
||||
8. **Limits:** defaults of $100 max order notional on testnet, 3× max leverage, 1% market slippage. What should the mainnet defaults be, and should the cap be hard (not editable above a ceiling)?
|
||||
9. **"alethea":** the only `alethea` crate on crates.io is a templating library. Did you mean a different signing crate? (The recommendation is our own small module on alloy, §3.5.)
|
||||
10. **Builder fees:** monetise with a builder code (e.g. 1–5 bp on perps, opt-in, always shown in the confirm), or keep Terminal fee-free? This depends on Q1, since fees may change the regulatory picture.
|
||||
11. **Scope of "stocks":** HIP-3 perps on `xyz` only for v1, or also `para`/`io`/`mkts` (which testnet doesn't have, so they'd launch untested end to end)?
|
||||
12. **Spot:** include HL spot (e.g. `HYPE/USDC`) in v1, or perps only?
|
||||
13. **One account mode or two?** Should the toolbar badge become `DEMO` / `LIVE` / `HL` (three modes), or should HL accounts sit inside `LIVE` next to SnapTrade accounts, with the network badge on each?
|
||||
|
||||
---
|
||||
|
||||
## References
|
||||
|
||||
- **API**: API overview (SDK links, testnet URL). https://hyperliquid.gitbook.io/hyperliquid-docs/for-developers/api
|
||||
- **EXCH**: Exchange endpoint (order, cancel, cancelByCloid, scheduleCancel, modify, batchModify, updateLeverage, approveAgent, approveBuilderFee, noop, expiresAfter). https://hyperliquid.gitbook.io/hyperliquid-docs/for-developers/api/exchange-endpoint
|
||||
- **SIGN**: Signing. https://hyperliquid.gitbook.io/hyperliquid-docs/for-developers/api/signing
|
||||
- **NONCE**: Nonces and API wallets. https://hyperliquid.gitbook.io/hyperliquid-docs/for-developers/api/nonces-and-api-wallets
|
||||
- **ASSET**: Asset IDs. https://hyperliquid.gitbook.io/hyperliquid-docs/for-developers/api/asset-ids
|
||||
- **TICK**: Tick and lot size. https://hyperliquid.gitbook.io/hyperliquid-docs/for-developers/api/tick-and-lot-size
|
||||
- **RL**: Rate limits and user limits. https://hyperliquid.gitbook.io/hyperliquid-docs/for-developers/api/rate-limits-and-user-limits
|
||||
- **INFO**: Info endpoint (userRole, userRateLimit, maxBuilderFee, fills). https://hyperliquid.gitbook.io/hyperliquid-docs/for-developers/api/info-endpoint
|
||||
- **PERP**: Info endpoint, perpetuals (perpDexs, meta, clearinghouseState). https://hyperliquid.gitbook.io/hyperliquid-docs/for-developers/api/info-endpoint/perpetuals
|
||||
- **WS**: Websocket. https://hyperliquid.gitbook.io/hyperliquid-docs/for-developers/api/websocket
|
||||
- **WSSUB**: Websocket subscriptions. https://hyperliquid.gitbook.io/hyperliquid-docs/for-developers/api/websocket/subscriptions
|
||||
- **WSPOST**: Websocket post requests. https://hyperliquid.gitbook.io/hyperliquid-docs/for-developers/api/websocket/post-requests
|
||||
- **BUILDER**: Builder codes. https://hyperliquid.gitbook.io/hyperliquid-docs/trading/builder-codes
|
||||
- **AAM**: Account abstraction modes. https://hyperliquid.gitbook.io/hyperliquid-docs/trading/account-abstraction-modes
|
||||
- **HIP3**: HIP-3 builder-deployed perpetuals. https://hyperliquid.gitbook.io/hyperliquid-docs/hyperliquid-improvement-proposals-hips/hip-3-builder-deployed-perpetuals
|
||||
- **FAUCET**: Testnet faucet. https://hyperliquid.gitbook.io/hyperliquid-docs/onboarding/testnet-faucet
|
||||
- **TOU**: Hyperliquid Terms of Use (last updated June 15, 2026). https://app.hyperliquid.xyz/terms
|
||||
- **SDK-SIGN**: Python SDK `hyperliquid/utils/signing.py`. https://github.com/hyperliquid-dex/hyperliquid-python-sdk/blob/master/hyperliquid/utils/signing.py
|
||||
- **SDK-EXCH**: Python SDK `hyperliquid/exchange.py` (`market_open`, `DEFAULT_SLIPPAGE`, `approve_agent`). https://github.com/hyperliquid-dex/hyperliquid-python-sdk/blob/master/hyperliquid/exchange.py
|
||||
- **SDK-INFO**: Python SDK `hyperliquid/info.py` (`extra_agents`). https://github.com/hyperliquid-dex/hyperliquid-python-sdk/blob/master/hyperliquid/info.py
|
||||
- **SDK-TEST**: Python SDK `tests/signing_test.py`. https://github.com/hyperliquid-dex/hyperliquid-python-sdk/blob/master/tests/signing_test.py
|
||||
- Rust SDKs: https://github.com/hyperliquid-dex/hyperliquid-rust-sdk, https://github.com/infinitefield/hypersdk
|
||||
|
||||
Turnkey (read 2026-11-04):
|
||||
|
||||
- **tk: AUTHPROXY**: Auth Proxy. https://docs.turnkey.com/features/authentication/auth-proxy and https://docs.turnkey.com/api-reference/auth-proxy
|
||||
- **tk: ORGS**: Organizations (parent read access, no write access). https://docs.turnkey.com/features/organizations
|
||||
- **tk: WAAS**: Embedded wallets as a service. https://docs.turnkey.com/solutions/embedded-wallets/embedded-waas
|
||||
- **tk: SUBORG**: Create sub-organization. https://docs.turnkey.com/api-reference/activities/create-sub-organization
|
||||
- **tk: SESSIONS**: Sessions ("The session JWT is only metadata"). https://docs.turnkey.com/features/authentication/sessions/overview
|
||||
- **tk: POLICIES**: Policies and the root quorum. https://docs.turnkey.com/features/policies/overview
|
||||
- **tk: SIGN**: Sign raw payload. https://docs.turnkey.com/api-reference/activities/sign-raw-payload
|
||||
- **tk: EXPORT**: Export wallets. https://docs.turnkey.com/features/wallets/export-wallets
|
||||
- **tk: PRICING**: Pricing. https://www.turnkey.com/pricing
|
||||
- **tk: SDK-CORE**: `@turnkey/core` 2.14.0 (`refreshSession`, `stampLogin`). https://www.npmjs.com/package/@turnkey/core
|
||||
- Rust SDK 0.17.1 (`turnkey_api_key_stamper`, `turnkey_client`). https://github.com/tkhq/rust-sdk; `alloy-signer-turnkey` 2.5.0. https://github.com/alloy-rs/alloy
|
||||
|
||||
### Observations log (read-only, 2026-11-02 ~23:30 UTC)
|
||||
|
||||
- `POST /info {"type":"perpDexs"}` mainnet: index 1 `xyz`, 8 `para`, 9 `mkts`, 10 `io` (11 entries). Testnet: 277 entries, `xyz` at 65, no `para`/`io`/`mkts`.
|
||||
- `meta {dex:"xyz"}`: mainnet 142 assets, `xyz:AAPL` index 9 (`szDecimals` 3, `maxLeverage` 20), collateral token 0. Testnet 70 assets, `xyz:AAPL` index 9.
|
||||
- `OPTIONS /exchange` with `Origin: https://terminal.rasmic.xyz`: mainnet and testnet both return `access-control-allow-origin: *`.
|
||||
- Signing spike: alloy-signer-local 1.8.3 + alloy-sol-types + rmp-serde. Matches SDK vector r/s/v on mainnet and testnet; `cargo build --target wasm32-unknown-unknown` OK.
|
||||
- `hypersdk` 0.3.0 with `default-features = false` on wasm32: 5 compile errors in `hypersdk` itself.
|
||||
|
||||
---
|
||||
|
||||
## 16. v1 as built (testnet)
|
||||
|
||||
- **`crates/hl` (`tr-hl`):** `network` (testnet only), `key` (`AgentKey`, `MainKey`: redacted, zeroized on drop), `signing` (k256 directly, with `alloy-primitives` and `alloy-sol-types`; no alloy signer crates, which pull in the RPC stack), `actions`, `assets`, `rounding`, `caps`, `nonce`, `trader` (the only way to an L1 signature), `client` (reqwest, native and wasm), `model`, `stream` (subscriptions and a reducer; the socket is the app's). It builds for wasm32.
|
||||
- **`tr-broker`:** `AgentKeyStore` (Keychain, Secret Service or memory; never a file), and `market: Option<String>` on `Position`, `Order` and `Activity`, which `market_key()` prefers.
|
||||
- **App:** `app/src/hl_worker.rs` (the user stream, reads, signing after the re-check, the key store off the UI thread), `app/src/app/hl.rs` (state, flows, rows, testnet quotes), `hl_view.rs` (ticket body, confirmations, Settings), `hl_web.rs` (EIP-1193 wallet, IndexedDB key). The ticket's Hyperliquid variant covers market (IOC, 1% cap), limit (GTC, IOC, post-only), stop loss and take profit (reduce-only market triggers), reduce-only and "Set 3×". A dollar amount (`BUY xyz:AAPL $50`) becomes a size at the testnet mid, rounded down to the market's size step. Until the mid is in, or when the amount is under one step, the ticket can't send it.
|
||||
- **Checked on testnet** with throwaway keys and no funds: every signature recovers its signer, and a corrupted one recovers someone else. `approveAgent` needs a deposit first ("Must deposit before performing actions"). The PR that added this build lists the responses.
|
||||
- **Built-in wallets (2026-11-04, §4.5):** `tr-turnkey` and the sign-in page. Checked against a local Turnkey stand-in only (`tr_turnkey::mock`), because there is no Turnkey organization yet: the desktop sign-in through the loopback, stamps, the wallet's address, and `approveAgent` signed through the client, which reproduces the Python SDK's vector when the stand-in's wallet holds the SDK's test key. Live Turnkey tests are `#[ignore]`d and run with `TURNKEY_ORGANIZATION_ID`, `TURNKEY_API_PRIVATE_KEY` and `TURNKEY_SIGN_WITH`.
|
||||
- **Not done in v1:** modify is in `tr-hl` and the worker but has no `ORD` control yet; brackets (`normalTpsl`), "and flatten" on KILL, the desktop browser handoff, spot, other HIP-3 dexes, the daily notional cap, and the expiry warning's one-click renew. Resting orders, fills, positions and P&L can only be checked once the testnet account has test USDC.
|
||||
|
After Width: | Height: | Size: 197 KiB |
|
After Width: | Height: | Size: 218 KiB |
|
After Width: | Height: | Size: 166 KiB |
|
After Width: | Height: | Size: 196 KiB |
|
After Width: | Height: | Size: 198 KiB |
|
After Width: | Height: | Size: 208 KiB |
|
After Width: | Height: | Size: 199 KiB |
|
After Width: | Height: | Size: 95 KiB |
|
After Width: | Height: | Size: 150 KiB |
|
After Width: | Height: | Size: 149 KiB |
|
After Width: | Height: | Size: 61 KiB |
|
After Width: | Height: | Size: 148 KiB |
|
After Width: | Height: | Size: 146 KiB |
|
After Width: | Height: | Size: 117 KiB |