Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
101 changes: 101 additions & 0 deletions .dev/verify_webdata3_abstraction.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
/**
* Live verification for bloxwap/hyperliquid#82: does the `webData3` WS channel carry the same
* abstraction state as the REST `userAbstraction` info request?
*
* For a sample of active mainnet traders (taken from `recentTrades` — no address list is
* hardcoded), this script reads REST `userAbstraction` and the first `webData3` frame, then
* prints a side-by-side comparison. It answers three questions:
*
* 1. Does the server populate `userState.abstraction` on `webData3` at all?
* 2. Does its value match REST `userAbstraction` for the same account?
* 3. What happens for accounts where REST reports `"default"` — a value the SDK's
* `WebData3Event` type does not model (its union lacks `"default"`)?
*
* A match on all three means the monorepo's `use-live-account-summary.ts` REST backstop is a
* candidate for retirement; a mismatch or an absent field means it stays.
*
* Note: this verifies steady-state equivalence. The issue's second requirement — observing the
* channel across an account abstraction *migration* — is a soak test and cannot be run on demand.
*
* Usage: bun run .dev/verify_webdata3_abstraction.ts
*
* @module
*/

import { HttpTransport, InfoClient, SubscriptionClient, WebSocketTransport } from "../src/mod.ts";
import type { WebData3Event } from "../src/api/subscription/_methods/webData3.ts";

/** Active traders to sample (bounded by the 15-unique-users per-connection limit). */
const SAMPLE_SIZE = 12;
/** How long to wait for a user's first webData3 frame before declaring it absent. */
const FRAME_TIMEOUT_MS = 15_000;

const http = new HttpTransport();
const info = new InfoClient({ transport: http });

// --- 1. Sample active traders from public market data -------------------------------
const trades = await info.recentTrades({ coin: "BTC" });
const users = [...new Set(trades.flatMap((t) => t.users))].slice(0, SAMPLE_SIZE);
console.log(`Sampling ${users.length} active traders from recent BTC trades\n`);

// --- 2. REST reads -------------------------------------------------------------------
const rest = new Map(users.map((user) => [user, info.userAbstraction({ user })]));

// --- 3. First webData3 frame per user -------------------------------------------------
const ws = new WebSocketTransport();
await ws.ready();
const subs = new SubscriptionClient({ transport: ws });

interface WsObservation {
frameReceived: boolean;
abstractionPresent: boolean;
abstraction?: string;
agentAddress?: string | null;
cumLedgerPresent?: boolean;
}

function observe(user: `0x${string}`): Promise<WsObservation> {
return new Promise((resolve) => {
const timer = setTimeout(() => resolve({ frameReceived: false, abstractionPresent: false }), FRAME_TIMEOUT_MS);
subs
.webData3({ user }, (event: WebData3Event) => {
clearTimeout(timer);
const state = event.userState;
resolve({
frameReceived: true,
abstractionPresent: "abstraction" in state,
abstraction: state.abstraction,
agentAddress: state.agentAddress,
cumLedgerPresent: typeof state.cumLedger === "string",
});
})
.catch((error) => {
clearTimeout(timer);
console.error(` webData3 subscribe failed for ${user}: ${error}`);
resolve({ frameReceived: false, abstractionPresent: false });
});
});
}

// --- 4. Side-by-side comparison --------------------------------------------------------
console.log("address REST userAbstraction WS abstraction match");
console.log("─".repeat(100));
let mismatches = 0;
for (const user of users) {
const [restValue, wsObs] = await Promise.all([rest.get(user)!, observe(user)]);
const wsValue = !wsObs.frameReceived ? "(no frame)" : wsObs.abstractionPresent ? wsObs.abstraction : "(field absent)";
const match = wsObs.frameReceived && wsObs.abstractionPresent && wsObs.abstraction === restValue;
if (!match) mismatches++;
console.log(`${user} ${String(restValue).padEnd(19)} ${String(wsValue).padEnd(15)} ${match ? "yes" : "NO"}`);
}

await ws.close();

console.log(`\n${"─".repeat(100)}`);
if (mismatches === 0) {
console.log("RESULT: webData3.userState.abstraction matched REST userAbstraction for every sampled account.");
console.log("The REST backstop is a retirement candidate (migration soak test still outstanding).");
} else {
console.log(`RESULT: ${mismatches}/${users.length} accounts diverged — the REST backstop must stay.`);
process.exit(1);
}
104 changes: 104 additions & 0 deletions .dev/verify_webdata3_abstraction_migration.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
/**
* Live migration soak test for the webData3 abstraction lane (bloxwap/hyperliquid#82 follow-up).
*
* The steady-state check (`.dev/verify_webdata3_abstraction.ts`) proved webData3's
* `userState.abstraction` matches REST `userAbstraction` value-for-value, with the field ABSENT
* in the default state. What it could not prove is the migration case: does the channel push the
* NEW model when an account flips abstraction, and how fast?
*
* This script answers it on testnet with a throwaway wallet:
* 1. fresh account → REST says "default", first webData3 frame omits the field;
* 2. `userSetAbstraction("unifiedAccount")` → expect a webData3 frame carrying
* `abstraction: "unifiedAccount"`, and REST agreeing;
* 3. flip back to "disabled" → expect `abstraction: "disabled"` on both.
*
* A pass means the monorepo's REST `userAbstraction` read can be retired outright (no soak
* caveat left): the channel delivers the initial state, steady-state changes, and migrations.
*
* Usage: bun run .dev/verify_webdata3_abstraction_migration.ts
*
* @module
*/

import { generatePrivateKey, privateKeyToAccount } from "viem/accounts";
import { ExchangeClient, HttpTransport, InfoClient, SubscriptionClient, WebSocketTransport } from "../src/mod.ts";
import type { WebData3Event } from "../src/api/subscription/_methods/webData3.ts";

const FRAME_TIMEOUT_MS = 30_000;

const wallet = privateKeyToAccount(generatePrivateKey());
const user = wallet.address;
console.log(`Throwaway testnet wallet: ${user}\n`);

const http = new HttpTransport({ isTestnet: true });
const info = new InfoClient({ transport: http });
const exchange = new ExchangeClient({ transport: http, wallet });

const ws = new WebSocketTransport({ isTestnet: true });
await ws.ready();
const subs = new SubscriptionClient({ transport: ws });

/** Resolves with the next webData3 frame whose `abstraction` equals `want` ("absent" matches a missing field). */
function awaitAbstraction(want: string | "absent"): Promise<{ event: WebData3Event; latencyMs: number }> {
return new Promise((resolve, reject) => {
const started = performance.now();
const timer = setTimeout(() => reject(new Error(`timed out waiting for abstraction=${want}`)), FRAME_TIMEOUT_MS);
subs
.webData3({ user }, (event) => {
const value = "abstraction" in event.userState ? event.userState.abstraction : "absent";
console.log(` frame: abstraction=${value} (t+${((performance.now() - started) / 1000).toFixed(1)}s)`);
if (value === want) {
clearTimeout(timer);
resolve({ event, latencyMs: performance.now() - started });
}
})
.catch(reject);
});
}

let failures = 0;
function check(label: string, ok: boolean): void {
console.log(`${ok ? "PASS" : "FAIL"} ${label}`);
if (!ok) failures++;
}

// --- 1. Fresh account: REST "default", WS field absent --------------------------------
const initialRest = await info.userAbstraction({ user });
check(`fresh account: REST userAbstraction = "default" (got "${initialRest}")`, initialRest === "default");

const firstFrame = await awaitAbstraction("absent");
check("fresh account: first webData3 frame omits `abstraction`", true);

// --- 2. Migrate to unifiedAccount --------------------------------------------------------
console.log('\nuserSetAbstraction("unifiedAccount")…');
const flip1 = await exchange.userSetAbstraction({ user, abstraction: "unifiedAccount" });
check(`action accepted (status "${flip1.status}")`, flip1.status === "ok");

const [migrated] = await Promise.all([
awaitAbstraction("unifiedAccount"),
// REST read after the WS frame lands keeps the comparison honest without racing the action.
]);
check("webData3 pushed the migration (abstraction = unifiedAccount)", true);
console.log(` migration latency (action → WS frame): ${(migrated.latencyMs / 1000).toFixed(2)}s`);

const restAfterFlip = await info.userAbstraction({ user });
check(`REST agrees after migration (got "${restAfterFlip}")`, restAfterFlip === "unifiedAccount");

// --- 3. Flip back to disabled --------------------------------------------------------------
console.log('\nuserSetAbstraction("disabled")…');
const flip2 = await exchange.userSetAbstraction({ user, abstraction: "disabled" });
check(`action accepted (status "${flip2.status}")`, flip2.status === "ok");

await awaitAbstraction("disabled");
check("webData3 pushed the second migration (abstraction = disabled)", true);

const restFinal = await info.userAbstraction({ user });
check(`REST agrees after second migration (got "${restFinal}")`, restFinal === "disabled");

await ws.close();
console.log(
failures === 0
? "\nRESULT: webData3 covers initial state, steady state, and migrations."
: `\nRESULT: ${failures} check(s) failed.`,
);
process.exit(failures === 0 ? 0 : 1);
15 changes: 11 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
</picture>
<br>
<strong>Blazing fast typescript
<a href="https://hyperliquid.gitbook.io/hyperliquid-docs/for-developers/api">Hyperliquid SDK</a></strong>
<a href="https://bloxwap.gitbook.io/hyperliquid">Hyperliquid SDK</a></strong>
</p>

<p align="center">
Expand All @@ -26,7 +26,8 @@

## Documentation

Browse the [SDK documentation](docs/README.md) for installation, clients, transports, signing, utilities, and guides.
Browse the [SDK documentation](https://bloxwap.gitbook.io/hyperliquid) for installation, clients, transports, signing,
utilities, and guides.

## Installation

Expand Down Expand Up @@ -55,7 +56,7 @@ yarn add @bloxwap/hyperliquid
```

> React Native needs polyfills for the `fastAssetCtxs` subscription and for versions below 0.86 — see the
> [documentation](https://nktkas.gitbook.io/hyperliquid).
> [documentation](https://bloxwap.gitbook.io/hyperliquid).

## Quick Example

Expand Down Expand Up @@ -116,6 +117,12 @@ await exchange.updateLeverage({ asset: 0, isCross: true, leverage: 5 });
await exchange.withdraw3({ destination: "0x...", amount: "1" });
```

For low-latency bots, prefer
[`createFastLocalWallet`](https://bloxwap.gitbook.io/hyperliquid/docs/signing#fast-local-wallet-wasm-secp256k1) (WASM
secp256k1) and install the optional `hash-wasm` package for ambient keccak acceleration. Trusted callers can also pass
`{ skipValidation: true }` — see the
[low-latency recipe](https://bloxwap.gitbook.io/hyperliquid/docs/signing#low-latency-recipe-bots--hft).

### Subscribe

```ts
Expand Down Expand Up @@ -150,4 +157,4 @@ await subs.l2Book({ coin: "ETH" }, (data) => {
> store (Bun auto-loads a local `.env`, which is gitignored in this repo).
> - For trading bots, prefer a Hyperliquid **agent wallet** (API wallet) over the master account key: an agent key can
> trade but cannot withdraw, and it can be revoked without rotating the master key.
> - See [Signing](docs/signing.md) for how wallets, signatures, and nonces work.
> - See [Signing](https://bloxwap.gitbook.io/hyperliquid/docs/signing) for how wallets, signatures, and nonces work.
30 changes: 30 additions & 0 deletions docs/signing.md
Original file line number Diff line number Diff line change
Expand Up @@ -636,6 +636,36 @@ The acceleration needs no code changes:
Unlike `createFastLocalWallet`, the dispatch is ambient: every signing entry point benefits, including wallets you
already create today.

## Low-latency recipe (bots / HFT)

Stack the accelerators when signature latency is on the critical path:

1. **`createFastLocalWallet`** — halves ECDSA (~55 µs vs ~85 µs). ECDSA is ~90% of a single-order `signL1Action`.
2. **`hash-wasm`** — ambient keccak speedup on every L1 hash and Agent digest (install the optional dep; no code change).
3. **`skipValidation: true`** — skip the valibot parse + key canonicalization on trusted, already-canonical wire input
(~3× less non-ECDSA CPU). See [ExchangeClient](clients.md#skipping-validation-unsafe) for the contract.

```ts
import { ExchangeClient, HttpTransport } from "@bloxwap/hyperliquid";
import { createFastLocalWallet } from "@bloxwap/hyperliquid/signing";

// npm i tiny-secp256k1 hash-wasm # optional deps; install explicitly if your package manager skips them
const wallet = await createFastLocalWallet("0x...");
const exchange = new ExchangeClient({ transport: new HttpTransport(), wallet });

// Action must already be in canonical wire form (schema key order, normalized decimals, lowercase hex, defaults filled).
await exchange.order(
{
orders: [{ a: 0, b: true, p: "95000", s: "0.01", r: false, t: { limit: { tif: "Gtc" } } }],
grouping: "na",
},
{ skipValidation: true },
);
```

Without step 3 the first two still apply and are safe for any input. Step 3 is an escape hatch: invalid input is no
longer a client-side `ValidationError` — the server rejects it instead.

## Helpers

These functions work with any supported wallet type:
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@bloxwap/hyperliquid",
"version": "0.1.3",
"version": "0.1.4",
"description": "Blazing fast TypeScript Hyperliquid SDK.",
"license": "MIT",
"type": "module",
Expand Down
43 changes: 37 additions & 6 deletions src/api/exchange/_methods/_base/_semaphore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,24 @@
*
* Replaces `@jsr/std__async`'s `Semaphore(1)`, which was only ever used as a
* single-permit (and itself FIFO) lock.
*
* Waiters sit in a grow-only array with a head index rather than `Array.shift()`:
* under a contended wallet (hundreds of concurrent orders) each release would
* otherwise copy the remaining queue — O(n) per wake-up, O(n²) for a burst.
* Compaction runs only when the head has walked past half the storage, so the
* amortized cost of enqueue/dequeue stays O(1).
*/
class Mutex {
private _locked = false;
private _waiters: (() => void)[] = [];
private _locked: boolean;
private _waiters: (() => void)[];
/** Index of the next waiter to wake; advanced on release, never decremented mid-burst. */
private _head: number;

constructor() {
this._locked = false;
this._waiters = [];
this._head = 0;
}

/**
* Acquires the lock, waiting until it is free.
Expand All @@ -28,9 +42,25 @@ class Mutex {

/** Releases the lock, waking the longest-waiting waiter if any. */
release(): void {
const next = this._waiters.shift();
if (next) next();
else this._locked = false;
if (this._head < this._waiters.length) {
const next = this._waiters[this._head];
// Drop the reference so a long-lived mutex does not pin resolved closures.
this._waiters[this._head++] = undefined as unknown as () => void;
// Compact when half the storage is dead so the array cannot grow without bound
// across many contended bursts on the same key.
if (this._head > 16 && this._head * 2 >= this._waiters.length) {
this._waiters = this._waiters.slice(this._head);
this._head = 0;
}
next();
} else {
this._locked = false;
// Idle: drop any residual storage so a quiet wallet costs nothing.
if (this._waiters.length > 0) {
this._waiters = [];
this._head = 0;
}
}
}
}

Expand All @@ -41,7 +71,7 @@ class Mutex {
* @template V Stored value type.
*/
class RefCountedRegistry<K, V> {
private _map = new Map<K, { value: V; refs: number }>();
private _map: Map<K, { value: V; refs: number }>;
private _factory: () => V;

/**
Expand All @@ -50,6 +80,7 @@ class RefCountedRegistry<K, V> {
* @param factory Factory function used to create a new value when a key is first referenced.
*/
constructor(factory: () => V) {
this._map = new Map();
this._factory = factory;
}

Expand Down
7 changes: 2 additions & 5 deletions src/api/subscription/_methods/activeAssetCtx.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,11 +80,8 @@ export function activeAssetCtx(
return config.transport.subscribe<ActiveAssetCtxEvent>(
payload.type,
payload,
(e) => {
if (e.detail.coin === payload.coin) {
listener(e.detail);
}
},
// Routing delivers only this coin's frames; no post-filter needed.
(e) => listener(e.detail),
options,
);
}
5 changes: 2 additions & 3 deletions src/api/subscription/_methods/activeAssetData.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,10 +78,9 @@ export function activeAssetData(
return config.transport.subscribe<ActiveAssetDataEvent>(
payload.type,
payload,
// Routing keys only by coin; user still needs a local filter (shared coin costs at most one discard).
(e) => {
if (e.detail.coin === payload.coin && e.detail.user === payload.user) {
listener(e.detail);
}
if (e.detail.user === payload.user) listener(e.detail);
},
options,
);
Expand Down
Loading
Loading