diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..3569079 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,128 @@ +name: CI + +on: + pull_request: + push: + branches: [main] + +concurrency: + group: ci-${{ github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + +jobs: + changes: + runs-on: ubuntu-latest + permissions: + contents: read + pull-requests: read + outputs: + typescript: ${{ steps.filter.outputs.typescript }} + python: ${{ steps.filter.outputs.python }} + rust: ${{ steps.filter.outputs.rust }} + go: ${{ steps.filter.outputs.go }} + steps: + - uses: actions/checkout@v4 + - uses: dorny/paths-filter@v3 + id: filter + with: + filters: | + typescript: + - 'typescript/**' + - '.github/workflows/ci.yml' + python: + - 'python/**' + - '.github/workflows/ci.yml' + rust: + - 'rust/**' + - '.github/workflows/ci.yml' + go: + - 'go/**' + - '.github/workflows/ci.yml' + + typescript: + needs: changes + if: needs.changes.outputs.typescript == 'true' + runs-on: ubuntu-latest + defaults: + run: + working-directory: typescript + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 20 + cache: npm + cache-dependency-path: typescript/package-lock.json + - run: npm ci + - run: npm run typecheck + - run: npm test + - run: npm run build + + python: + needs: changes + if: needs.changes.outputs.python == 'true' + runs-on: ubuntu-latest + defaults: + run: + working-directory: python + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: '3.12' + - run: pip install -e ".[dev]" + - run: pytest + + rust: + needs: changes + if: needs.changes.outputs.rust == 'true' + runs-on: ubuntu-latest + defaults: + run: + working-directory: rust + steps: + - uses: actions/checkout@v4 + - run: sudo apt-get update && sudo apt-get install -y protobuf-compiler + - uses: dtolnay/rust-toolchain@stable + - uses: Swatinem/rust-cache@v2 + with: + workspaces: rust + - run: cargo build + - run: cargo test + + go: + needs: changes + if: needs.changes.outputs.go == 'true' + runs-on: ubuntu-latest + defaults: + run: + working-directory: go + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 + with: + go-version-file: go/go.mod + cache-dependency-path: go/go.sum + - run: go build ./... + - run: go test ./... + + # Single required status check: always runs, passes when every language job + # either succeeded or was skipped (not touched by the PR). Make THIS job the + # required check in branch protection — the per-language jobs are skipped on + # PRs that don't touch them and would otherwise hang as "pending" forever. + ci-ok: + needs: [changes, typescript, python, rust, go] + if: always() + runs-on: ubuntu-latest + steps: + - name: Check job results + run: | + results="${{ needs.typescript.result }} ${{ needs.python.result }} ${{ needs.rust.result }} ${{ needs.go.result }} ${{ needs.changes.result }}" + echo "Job results: $results" + for r in $results; do + if [ "$r" = "failure" ] || [ "$r" = "cancelled" ]; then + echo "::error::A CI job failed or was cancelled." + exit 1 + fi + done + echo "All CI jobs passed or were skipped." diff --git a/.github/workflows/publish-go.yml b/.github/workflows/publish-go.yml new file mode 100644 index 0000000..f2ad0cb --- /dev/null +++ b/.github/workflows/publish-go.yml @@ -0,0 +1,32 @@ +name: Publish Go + +# Go modules are published by the tag itself — the Go module proxy serves +# go/vX.Y.Z directly from GitHub. This workflow is just a release gate. +on: + push: + tags: + - 'go/v*' + +jobs: + release-gate: + runs-on: ubuntu-latest + permissions: + contents: read + defaults: + run: + working-directory: go + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 + with: + go-version-file: go/go.mod + cache-dependency-path: go/go.sum + - run: go build ./... + - run: go test ./... + + - name: Release info + run: | + VERSION="${GITHUB_REF_NAME#go/}" + echo "Go module released. Users can install it with:" + echo + echo " go get github.com/quiknode-labs/hyperliquid-sdk/go@$VERSION" diff --git a/.github/workflows/publish-python.yml b/.github/workflows/publish-python.yml new file mode 100644 index 0000000..11c18d5 --- /dev/null +++ b/.github/workflows/publish-python.yml @@ -0,0 +1,48 @@ +name: Publish Python + +on: + push: + tags: + - 'python/v*' + +jobs: + publish: + runs-on: ubuntu-latest + environment: pypi + permissions: + contents: read + id-token: write # PyPI Trusted Publishing + defaults: + run: + working-directory: python + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-python@v5 + with: + python-version: '3.12' + + - name: Assert tag matches pyproject.toml version + run: | + TAG_VERSION="${GITHUB_REF_NAME#python/v}" + PROJECT_VERSION="$(python -c "import tomllib; print(tomllib.load(open('pyproject.toml','rb'))['project']['version'])")" + if [ "$TAG_VERSION" != "$PROJECT_VERSION" ]; then + echo "::error::Tag version ($TAG_VERSION) does not match python/pyproject.toml version ($PROJECT_VERSION)." + echo "Bump pyproject.toml or re-tag, then push again." + exit 1 + fi + echo "Version check OK: $PROJECT_VERSION" + + - run: pip install -e ".[dev]" + - run: pytest + + - name: Build sdist and wheel + run: | + rm -rf dist + pip install build + python -m build + + - name: Publish to PyPI + uses: pypa/gh-action-pypi-publish@release/v1 + with: + packages-dir: python/dist/ diff --git a/.github/workflows/publish-rust.yml b/.github/workflows/publish-rust.yml new file mode 100644 index 0000000..c96d6a8 --- /dev/null +++ b/.github/workflows/publish-rust.yml @@ -0,0 +1,37 @@ +name: Publish Rust + +on: + push: + tags: + - 'rust/v*' + +jobs: + publish: + runs-on: ubuntu-latest + permissions: + contents: read + defaults: + run: + working-directory: rust + steps: + - uses: actions/checkout@v4 + - run: sudo apt-get update && sudo apt-get install -y protobuf-compiler + - uses: dtolnay/rust-toolchain@stable + - uses: Swatinem/rust-cache@v2 + with: + workspaces: rust + + - name: Assert tag matches Cargo.toml version + run: | + TAG_VERSION="${GITHUB_REF_NAME#rust/v}" + CRATE_VERSION="$(cargo metadata --no-deps --format-version 1 | jq -r '.packages[0].version')" + if [ "$TAG_VERSION" != "$CRATE_VERSION" ]; then + echo "::error::Tag version ($TAG_VERSION) does not match rust/Cargo.toml version ($CRATE_VERSION)." + echo "Bump Cargo.toml or re-tag, then push again." + exit 1 + fi + echo "Version check OK: $CRATE_VERSION" + + - run: cargo test + + - run: cargo publish --token ${{ secrets.CARGO_REGISTRY_TOKEN }} diff --git a/.github/workflows/publish-typescript.yml b/.github/workflows/publish-typescript.yml new file mode 100644 index 0000000..d10c101 --- /dev/null +++ b/.github/workflows/publish-typescript.yml @@ -0,0 +1,46 @@ +name: Publish TypeScript + +on: + push: + tags: + - 'typescript/v*' + +jobs: + publish: + runs-on: ubuntu-latest + permissions: + contents: read + id-token: write # npm --provenance + defaults: + run: + working-directory: typescript + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: 20 + cache: npm + cache-dependency-path: typescript/package-lock.json + registry-url: https://registry.npmjs.org + + - name: Assert tag matches package.json version + run: | + TAG_VERSION="${GITHUB_REF_NAME#typescript/v}" + PKG_VERSION="$(node -p "require('./package.json').version")" + if [ "$TAG_VERSION" != "$PKG_VERSION" ]; then + echo "::error::Tag version ($TAG_VERSION) does not match typescript/package.json version ($PKG_VERSION)." + echo "Bump package.json or re-tag, then push again." + exit 1 + fi + echo "Version check OK: $PKG_VERSION" + + - run: npm ci + - run: npm run typecheck + - run: npm test + - run: npm run build + + # prepublishOnly re-runs the build; that's fine. + - run: npm publish --provenance --access public + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} diff --git a/.gitignore b/.gitignore index 9a9120e..f2ec772 100644 --- a/.gitignore +++ b/.gitignore @@ -54,3 +54,28 @@ Thumbs.db .env .env.local *.pem + +# Environment (all variants) +.env.* +!.env.example + +# Coverage / build info +coverage/ +*.tsbuildinfo +.nyc_output/ + +# Go binaries (no extension; built with `go build` at go/ root or in an example dir) +go/evm_example +go/full_demo +go/grpc_streaming +go/hypercore_example +go/info_example +go/place_order +go/trading_example +go/websocket_streaming + +# Claude Code local settings +.claude/settings.local.json + +# Internal release runbook (kept local, not published) +.github/RELEASING.md diff --git a/go/README.md b/go/README.md index 5652313..39fcebb 100644 --- a/go/README.md +++ b/go/README.md @@ -374,6 +374,25 @@ stream.RawEvents(func(b map[string]any) { fmt.Printf("Events block: %v\n", b) }) +// Subscribe to pre-consensus mempool transactions (nil coins = all) +stream.MempoolTxs([]string{"BTC", "ETH"}, func(tx map[string]any) { + fmt.Printf("Mempool tx: %v\n", tx) +}) + +// Subscribe to derived priority action streams +// (events carry server-enriched fields: coin, market_type, sz_decimals) +stream.OrderPriority(func(a map[string]any) { + fmt.Printf("Order priority: %v\n", a) +}) +stream.GossipPriority(func(a map[string]any) { + fmt.Printf("Gossip priority: %v\n", a) +}) + +// Start a stream from a specific block number +stream.Trades([]string{"BTC"}, func(t map[string]any) { + fmt.Printf("Trade: %v\n", t) +}, hyperliquid.StreamWithStartBlock(12345678)) + // Run in background if err := stream.Start(); err != nil { log.Fatal(err) @@ -404,6 +423,50 @@ stream.Run() | `RawEvents(callback)` | - | Raw system event blocks with `events: [...]` | | `WriterActions(callback)` | - | Writer actions | | `RawWriterActions(callback)` | - | Raw writer action blocks | +| `MempoolTxs(coins, callback)` | coins: `[]string` (nil = all) | Pre-consensus mempool transactions | +| `RawMempoolTxs(coins, callback)` | coins: `[]string` (nil = all) | Raw mempool transaction blocks | +| `OrderPriority(callback)` | - | Derived order/write priority actions (enriched with `coin`, `market_type`, `sz_decimals`) | +| `RawOrderPriority(callback)` | - | Raw order priority action blocks | +| `GossipPriority(callback)` | - | Derived gossip/read priority bid actions (enriched with `coin`, `market_type`, `sz_decimals`; does not measure delivery latency) | +| `RawGossipPriority(callback)` | - | Raw gossip priority action blocks | +| `BboBook(coins, callback)` | coins: `[]string` (nil = all) | Best bid/offer changes only | +| `L2BookDiff(coins, callback, opts...)` | coins: `[]string` (nil = all), opts: `L2BookOption` | Incremental L2 price-level changes (`sz=0` = level removed) | +| `L4BookUpdates(coins, callback)` | coins: `[]string` (nil = all) | Typed L4 order-level changes | +| `TpslUpdates(coins, callback)` | coins: `[]string` (nil = all perps) | Trigger/TP-SL order add/remove events | +| `L2BookPacked(coin, callback, opts...)` | coin: `string`, opts: `L2BookOption` | Fixed-point L2 fast path | +| `BboBookPacked(coins, callback)` | coins: `[]string` (nil = all) | Fixed-point BBO fast path | +| `L4BookBytes(coin, callback)` | coin: `string` | L4 fast path; diffs as undecoded JSON bytes | +| `StreamBytes(streamType, callback, opts...)` | streamType: `string`, opts: `StreamOption` | Low-level raw-bytes fast path for any generic stream | + +Generic streams accept `StreamOption`s: `StreamWithStartBlock(block)` starts the +stream from a specific block number (**note:** not yet supported server-side — +streams currently always begin at the live tip; the option is wired through so +resume works automatically once server support ships); +`StreamWithCoins(...)`/`StreamWithUsers(...)` set filters for `StreamBytes`. +`Orders`/`RawOrders` keep their legacy `users ...string` signature; use +`OrdersWithOptions(coins, users, callback, opts...)` / +`RawOrdersWithOptions(...)` to combine user filters with `StreamOption`s. `L2BookOption`s: `L2BookNLevels(n)`, +`L2BookNSigFigs(n)`, `L2BookMantissa(m)`, and `L2BookSkipInitialSnapshot()` +(L2BookDiff only). + +**Packed streams** (`L2BookPacked`, `BboBookPacked`): prices and sizes are +`uint64` fixed-point integers scaled by 1e8. + +**Raw bytes fast path** (`StreamBytes`): the callback receives +`(blockNumber, timestamp uint64, data []byte)` with the payload bytes left +undecoded — no JSON parsing is performed: + +```go +stream.StreamBytes("MEMPOOL_TXS", func(blockNumber, timestamp uint64, data []byte) { + // data is the raw JSON payload +}, hyperliquid.StreamWithCoins("BTC")) +``` + +**L4 stream contract** (applies to `L4Book`, `L4BookBytes`, and `L4BookUpdates`): +the server may send an unsolicited full snapshot at any time after subscribe +(e.g. after ALO queue-priority anchored insertions). Clients MUST discard local +book state and replace it with any snapshot received mid-stream. `insertBefore` +is never sent to clients. ### L4 Order Book (Critical for Trading) @@ -531,6 +594,39 @@ sdk.CancelAll("BTC") // Just BTC orders // Dead-man's switch sdk.ScheduleCancel(timestampMs) + +// Fast cancel: emits the "f": true flag on the cancel action +sdk.Cancel(oid, "BTC", hyperliquid.CancelWithFast()) +sdk.CancelByCloid("0xmycloid...", "BTC", hyperliquid.CancelWithFast()) +``` + +### Vault Trading & Action TTL + +Orders, cancels, modifies, and closes accept two optional exchange-level +fields. `vaultAddress` acts on behalf of a vault (or subaccount); +`expiresAfter` is a millisecond timestamp after which the action is rejected. +Both are signed into the action hash and are never sent unless set. + +```go +vault := "0xVault..." +deadline := time.Now().Add(30 * time.Second).UnixMilli() + +// One-line orders +sdk.Buy("BTC", hyperliquid.WithSize(0.001), hyperliquid.WithPrice(65000), + hyperliquid.WithVaultAddress(vault), hyperliquid.WithExpiresAfter(deadline)) + +// Fluent builder +order := hyperliquid.Order().Buy("BTC").Size(0.001).Price(65000).GTC(). + VaultAddress(vault).ExpiresAfter(deadline) +sdk.PlaceOrder(order) + +// Cancels, modifies, closes +sdk.Cancel(oid, "BTC", hyperliquid.CancelWithVaultAddress(vault), hyperliquid.CancelWithExpiresAfter(deadline)) +sdk.Modify(oid, "BTC", "buy", "61000", "0.001", hyperliquid.ModifyWithVaultAddress(vault)) +sdk.ClosePosition("BTC", hyperliquid.CloseWithVaultAddress(vault)) // closes the vault's position + +// Trigger orders +sdk.StopLoss("BTC", 0.001, 60000, hyperliquid.TriggerWithVaultAddress(vault)) ``` ### Position Management @@ -995,6 +1091,8 @@ hyperliquid.NewGRPCStream(endpoint, &hyperliquid.GRPCStreamConfig{ ## Examples +See [examples/grpc_mempool](examples/grpc_mempool/main.go) for a runnable gRPC mempool streaming example with a coin filter. + See the [hyperliquid-examples](https://github.com/quiknode-labs/hyperliquid-examples) repository for 44 complete, runnable examples covering trading, streaming, market data, and more. Learn more at [Hyperliquidapi.com](https://hyperliquidapi.com/). diff --git a/go/evm_example b/go/evm_example deleted file mode 100755 index b119ffd..0000000 Binary files a/go/evm_example and /dev/null differ diff --git a/go/full_demo b/go/full_demo deleted file mode 100755 index d165914..0000000 Binary files a/go/full_demo and /dev/null differ diff --git a/go/grpc_streaming b/go/grpc_streaming deleted file mode 100755 index 1ff15be..0000000 Binary files a/go/grpc_streaming and /dev/null differ diff --git a/go/hypercore_example b/go/hypercore_example deleted file mode 100755 index 230fe4f..0000000 Binary files a/go/hypercore_example and /dev/null differ diff --git a/go/hyperliquid/buildsignsend_test.go b/go/hyperliquid/buildsignsend_test.go index 59dc3e2..3ed8758 100644 --- a/go/hyperliquid/buildsignsend_test.go +++ b/go/hyperliquid/buildsignsend_test.go @@ -40,6 +40,30 @@ func newTestSDK(exchangeURL string, signer Signer) *SDK { } } +// newRecordingExchange is like newFakeExchange but records every decoded +// request body in order (build first, then send), so tests can assert the +// exact wire payloads produced by buildSignSend. +func newRecordingExchange(t *testing.T, requests *[]map[string]any) *httptest.Server { + t.Helper() + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + body, _ := io.ReadAll(r.Body) + var req map[string]any + _ = json.Unmarshal(body, &req) + *requests = append(*requests, req) + + w.Header().Set("Content-Type", "application/json") + if _, isSend := req["signature"]; isSend { + _ = json.NewEncoder(w).Encode(map[string]any{"status": "ok"}) + return + } + _ = json.NewEncoder(w).Encode(map[string]any{"hash": "0xabc123", "nonce": 42}) + })) +} + +func okSigner(context.Context, string) (*Signature, error) { + return &Signature{R: "0x1", S: "0x2", V: 27}, nil +} + func TestBuildSignSend_SignerReceivesDeadlineContext(t *testing.T) { srv := newFakeExchange(t, "0xabc123", 42) defer srv.Close() @@ -150,6 +174,191 @@ func TestValidateSignerSignature(t *testing.T) { } } +func TestBuildSignSend_VaultAddressAndExpiresAfterInBuildAndSend(t *testing.T) { + var requests []map[string]any + srv := newRecordingExchange(t, &requests) + defer srv.Close() + + s := newTestSDK(srv.URL, okSigner) + vault := "0x1234567890123456789012345678901234567890" + var expires int64 = 1750000000000 + _, err := s.buildSignSendParams(map[string]any{"type": "order"}, &exchangeParams{ + vaultAddress: vault, + expiresAfter: &expires, + }) + if err != nil { + t.Fatalf("buildSignSendParams returned error: %v", err) + } + if len(requests) != 2 { + t.Fatalf("expected 2 requests (build, send), got %d", len(requests)) + } + + for i, name := range []string{"build", "send"} { + req := requests[i] + if got, _ := req["vaultAddress"].(string); got != vault { + t.Fatalf("%s payload vaultAddress = %v, want %q", name, req["vaultAddress"], vault) + } + if got, _ := req["expiresAfter"].(float64); int64(got) != expires { + t.Fatalf("%s payload expiresAfter = %v, want %d", name, req["expiresAfter"], expires) + } + } +} + +func TestBuildSignSend_OptionalFieldsAbsentWhenUnset(t *testing.T) { + var requests []map[string]any + srv := newRecordingExchange(t, &requests) + defer srv.Close() + + s := newTestSDK(srv.URL, okSigner) + if _, err := s.buildSignSend(map[string]any{"type": "order"}, nil); err != nil { + t.Fatalf("buildSignSend returned error: %v", err) + } + if len(requests) != 2 { + t.Fatalf("expected 2 requests (build, send), got %d", len(requests)) + } + + for i, name := range []string{"build", "send"} { + for _, key := range []string{"vaultAddress", "expiresAfter"} { + if _, present := requests[i][key]; present { + t.Fatalf("%s payload contains %q when unset; must never be emitted", name, key) + } + } + } +} + +func TestCancel_FastFlagEmittedOnlyWhenSet(t *testing.T) { + var requests []map[string]any + srv := newRecordingExchange(t, &requests) + defer srv.Close() + + s := newTestSDK(srv.URL, okSigner) + + // Without the fast option: no "f" key in the cancel action. + if _, err := s.Cancel(7, ""); err != nil { + t.Fatalf("Cancel returned error: %v", err) + } + action, _ := requests[0]["action"].(map[string]any) + if action == nil { + t.Fatal("build payload missing action") + } + if _, present := action["f"]; present { + t.Fatal(`cancel action contains "f" without CancelWithFast; must never be emitted`) + } + + // With the fast option: "f": true alongside cancels. + requests = nil + if _, err := s.Cancel(7, "", CancelWithFast()); err != nil { + t.Fatalf("Cancel returned error: %v", err) + } + action, _ = requests[0]["action"].(map[string]any) + if action == nil { + t.Fatal("build payload missing action") + } + if fast, _ := action["f"].(bool); !fast { + t.Fatalf(`cancel action "f" = %v, want true`, action["f"]) + } + if _, ok := action["cancels"].([]any); !ok { + t.Fatalf("cancel action lost its cancels entries: %v", action) + } +} + +func TestCancelByCloid_FastAndVaultOptions(t *testing.T) { + var requests []map[string]any + srv := newRecordingExchange(t, &requests) + defer srv.Close() + + s := newTestSDK(srv.URL, okSigner) + vault := "0x1234567890123456789012345678901234567890" + + // Numeric asset avoids the markets lookup in resolveAssetIndex. + _, err := s.CancelByCloid("0x00000000000000000000000000000001", "0", + CancelWithFast(), CancelWithVaultAddress(vault), CancelWithExpiresAfter(1750000000000)) + if err != nil { + t.Fatalf("CancelByCloid returned error: %v", err) + } + + action, _ := requests[0]["action"].(map[string]any) + if fast, _ := action["f"].(bool); !fast { + t.Fatalf(`cancelByCloid action "f" = %v, want true`, action["f"]) + } + for i, name := range []string{"build", "send"} { + if got, _ := requests[i]["vaultAddress"].(string); got != vault { + t.Fatalf("%s payload vaultAddress = %v, want %q", name, requests[i]["vaultAddress"], vault) + } + if got, _ := requests[i]["expiresAfter"].(float64); int64(got) != 1750000000000 { + t.Fatalf("%s payload expiresAfter = %v, want 1750000000000", name, requests[i]["expiresAfter"]) + } + } +} + +func TestModify_VaultAddressAndExpiresAfter(t *testing.T) { + var requests []map[string]any + srv := newRecordingExchange(t, &requests) + defer srv.Close() + + s := newTestSDK(srv.URL, okSigner) + vault := "0x1234567890123456789012345678901234567890" + + _, err := s.Modify(9, "BTC", "buy", "67000", "0.001", + ModifyWithVaultAddress(vault), ModifyWithExpiresAfter(1750000000000)) + if err != nil { + t.Fatalf("Modify returned error: %v", err) + } + for i, name := range []string{"build", "send"} { + if got, _ := requests[i]["vaultAddress"].(string); got != vault { + t.Fatalf("%s payload vaultAddress = %v, want %q", name, requests[i]["vaultAddress"], vault) + } + if got, _ := requests[i]["expiresAfter"].(float64); int64(got) != 1750000000000 { + t.Fatalf("%s payload expiresAfter = %v, want 1750000000000", name, requests[i]["expiresAfter"]) + } + } +} + +func TestPlaceOrder_BuilderVaultAddressAndExpiresAfter(t *testing.T) { + var requests []map[string]any + srv := newRecordingExchange(t, &requests) + defer srv.Close() + + s := newTestSDK(srv.URL, okSigner) + vault := "0x1234567890123456789012345678901234567890" + + order := Order().Buy("BTC").Size(0.001).Price(67000).GTC(). + VaultAddress(vault).ExpiresAfter(1750000000000) + if _, err := s.PlaceOrder(order); err != nil { + t.Fatalf("PlaceOrder returned error: %v", err) + } + for i, name := range []string{"build", "send"} { + if got, _ := requests[i]["vaultAddress"].(string); got != vault { + t.Fatalf("%s payload vaultAddress = %v, want %q", name, requests[i]["vaultAddress"], vault) + } + if got, _ := requests[i]["expiresAfter"].(float64); int64(got) != 1750000000000 { + t.Fatalf("%s payload expiresAfter = %v, want 1750000000000", name, requests[i]["expiresAfter"]) + } + } +} + +func TestClosePosition_VaultAddressSetsUser(t *testing.T) { + var requests []map[string]any + srv := newRecordingExchange(t, &requests) + defer srv.Close() + + s := newTestSDK(srv.URL, okSigner) + vault := "0x1234567890123456789012345678901234567890" + + if _, err := s.ClosePosition("BTC", CloseWithVaultAddress(vault)); err != nil { + t.Fatalf("ClosePosition returned error: %v", err) + } + + buildReq := requests[0] + if got, _ := buildReq["vaultAddress"].(string); got != vault { + t.Fatalf("build payload vaultAddress = %v, want %q", buildReq["vaultAddress"], vault) + } + action, _ := buildReq["action"].(map[string]any) + if got, _ := action["user"].(string); got != vault { + t.Fatalf("closePosition action.user = %v, want vault %q (backend queries this position)", action["user"], vault) + } +} + func TestSignerErrorUnwrap(t *testing.T) { sentinel := errors.New("boom") err := SignerError(sentinel) @@ -168,3 +377,30 @@ func TestSignerErrorUnwrap(t *testing.T) { t.Fatalf("Unwrap() on a causeless Error = %v, want nil", plain.Unwrap()) } } + +func TestStopLoss_TriggerWithGroupingIsApplied(t *testing.T) { + var requests []map[string]any + srv := newRecordingExchange(t, &requests) + defer srv.Close() + + s := newTestSDK(srv.URL, okSigner) + + // Without the option: grouping stays "na". + if _, err := s.StopLoss("BTC", 0.001, 50000); err != nil { + t.Fatalf("StopLoss returned error: %v", err) + } + action, _ := requests[0]["action"].(map[string]any) + if got, _ := action["grouping"].(string); got != string(OrderGroupingNA) { + t.Fatalf("default grouping = %v, want %q", action["grouping"], OrderGroupingNA) + } + + // With TriggerWithGrouping: the chosen grouping reaches the action. + requests = nil + if _, err := s.StopLoss("BTC", 0.001, 50000, TriggerWithGrouping(OrderGroupingPositionTPSL)); err != nil { + t.Fatalf("StopLoss with grouping returned error: %v", err) + } + action, _ = requests[0]["action"].(map[string]any) + if got, _ := action["grouping"].(string); got != string(OrderGroupingPositionTPSL) { + t.Fatalf("grouping = %v, want %q (TriggerWithGrouping must not be discarded)", action["grouping"], OrderGroupingPositionTPSL) + } +} diff --git a/go/hyperliquid/grpc_stream.go b/go/hyperliquid/grpc_stream.go index dc2659d..76d3f3a 100644 --- a/go/hyperliquid/grpc_stream.go +++ b/go/hyperliquid/grpc_stream.go @@ -66,14 +66,42 @@ type GRPCStream struct { } type grpcSubscription struct { - streamType string - callback func(map[string]any) - coins []string - users []string - coin string - nSigFigs *int - nLevels int - raw bool + streamType string + callback func(map[string]any) + bytesCallback func(blockNumber uint64, timestamp uint64, data []byte) + coins []string + users []string + coin string + nSigFigs *int + nLevels int + mantissa *uint64 + skipInitialSnapshot bool + startBlock uint64 + raw bool +} + +// StreamOption configures a generic gRPC data stream subscription. +type StreamOption func(*grpcSubscription) + +// StreamWithStartBlock requests the stream to start from a specific block number. +func StreamWithStartBlock(block uint64) StreamOption { + return func(s *grpcSubscription) { + s.startBlock = block + } +} + +// StreamWithCoins filters the stream by coins (used with StreamBytes). +func StreamWithCoins(coins ...string) StreamOption { + return func(s *grpcSubscription) { + s.coins = coins + } +} + +// StreamWithUsers filters the stream by users (used with StreamBytes). +func StreamWithUsers(users ...string) StreamOption { + return func(s *grpcSubscription) { + s.users = users + } } const ( @@ -242,163 +270,233 @@ func (s *GRPCStream) connect() error { return nil } -// Trades subscribes to trade stream. -func (s *GRPCStream) Trades(coins []string, callback func(map[string]any)) *GRPCStream { +// addSubscription applies options to a subscription and registers it. +func (s *GRPCStream) addSubscription(sub grpcSubscription, opts []StreamOption) *GRPCStream { + for _, opt := range opts { + opt(&sub) + } s.subMu.Lock() - s.subscriptions = append(s.subscriptions, grpcSubscription{ + s.subscriptions = append(s.subscriptions, sub) + s.subMu.Unlock() + return s +} + +// Trades subscribes to trade stream. +func (s *GRPCStream) Trades(coins []string, callback func(map[string]any), opts ...StreamOption) *GRPCStream { + return s.addSubscription(grpcSubscription{ streamType: "TRADES", callback: callback, coins: coins, - }) - s.subMu.Unlock() - return s + }, opts) } // RawTrades subscribes to raw trade blocks. -func (s *GRPCStream) RawTrades(coins []string, callback func(map[string]any)) *GRPCStream { - s.subMu.Lock() - s.subscriptions = append(s.subscriptions, grpcSubscription{ +func (s *GRPCStream) RawTrades(coins []string, callback func(map[string]any), opts ...StreamOption) *GRPCStream { + return s.addSubscription(grpcSubscription{ streamType: "TRADES", callback: callback, coins: coins, raw: true, - }) - s.subMu.Unlock() - return s + }, opts) } // Orders subscribes to order stream. +// The trailing users variadic predates StreamOption; use OrdersWithOptions to +// combine user filters with options such as StreamWithStartBlock. func (s *GRPCStream) Orders(coins []string, callback func(map[string]any), users ...string) *GRPCStream { - s.subMu.Lock() - s.subscriptions = append(s.subscriptions, grpcSubscription{ + return s.OrdersWithOptions(coins, users, callback) +} + +// OrdersWithOptions subscribes to the order stream with user filters and +// StreamOptions (e.g. StreamWithStartBlock). +func (s *GRPCStream) OrdersWithOptions(coins []string, users []string, callback func(map[string]any), opts ...StreamOption) *GRPCStream { + return s.addSubscription(grpcSubscription{ streamType: "ORDERS", callback: callback, coins: coins, users: users, - }) - s.subMu.Unlock() - return s + }, opts) } // RawOrders subscribes to raw order blocks. +// The trailing users variadic predates StreamOption; use RawOrdersWithOptions to +// combine user filters with options such as StreamWithStartBlock. func (s *GRPCStream) RawOrders(coins []string, callback func(map[string]any), users ...string) *GRPCStream { - s.subMu.Lock() - s.subscriptions = append(s.subscriptions, grpcSubscription{ + return s.RawOrdersWithOptions(coins, users, callback) +} + +// RawOrdersWithOptions subscribes to raw order blocks with user filters and +// StreamOptions (e.g. StreamWithStartBlock). +func (s *GRPCStream) RawOrdersWithOptions(coins []string, users []string, callback func(map[string]any), opts ...StreamOption) *GRPCStream { + return s.addSubscription(grpcSubscription{ streamType: "ORDERS", callback: callback, coins: coins, users: users, raw: true, - }) - s.subMu.Unlock() - return s + }, opts) } // BookUpdates subscribes to order book updates. -func (s *GRPCStream) BookUpdates(coins []string, callback func(map[string]any)) *GRPCStream { - s.subMu.Lock() - s.subscriptions = append(s.subscriptions, grpcSubscription{ +func (s *GRPCStream) BookUpdates(coins []string, callback func(map[string]any), opts ...StreamOption) *GRPCStream { + return s.addSubscription(grpcSubscription{ streamType: "BOOK_UPDATES", callback: callback, coins: coins, - }) - s.subMu.Unlock() - return s + }, opts) } // RawBookUpdates subscribes to raw order book update blocks. -func (s *GRPCStream) RawBookUpdates(coins []string, callback func(map[string]any)) *GRPCStream { - s.subMu.Lock() - s.subscriptions = append(s.subscriptions, grpcSubscription{ +func (s *GRPCStream) RawBookUpdates(coins []string, callback func(map[string]any), opts ...StreamOption) *GRPCStream { + return s.addSubscription(grpcSubscription{ streamType: "BOOK_UPDATES", callback: callback, coins: coins, raw: true, - }) - s.subMu.Unlock() - return s + }, opts) } // TWAP subscribes to TWAP execution stream. -func (s *GRPCStream) TWAP(coins []string, callback func(map[string]any)) *GRPCStream { - s.subMu.Lock() - s.subscriptions = append(s.subscriptions, grpcSubscription{ +func (s *GRPCStream) TWAP(coins []string, callback func(map[string]any), opts ...StreamOption) *GRPCStream { + return s.addSubscription(grpcSubscription{ streamType: "TWAP", callback: callback, coins: coins, - }) - s.subMu.Unlock() - return s + }, opts) } // RawTWAP subscribes to raw TWAP execution blocks. -func (s *GRPCStream) RawTWAP(coins []string, callback func(map[string]any)) *GRPCStream { - s.subMu.Lock() - s.subscriptions = append(s.subscriptions, grpcSubscription{ +func (s *GRPCStream) RawTWAP(coins []string, callback func(map[string]any), opts ...StreamOption) *GRPCStream { + return s.addSubscription(grpcSubscription{ streamType: "TWAP", callback: callback, coins: coins, raw: true, - }) - s.subMu.Unlock() - return s + }, opts) } // Events subscribes to system events. -func (s *GRPCStream) Events(callback func(map[string]any)) *GRPCStream { - s.subMu.Lock() - s.subscriptions = append(s.subscriptions, grpcSubscription{ +func (s *GRPCStream) Events(callback func(map[string]any), opts ...StreamOption) *GRPCStream { + return s.addSubscription(grpcSubscription{ streamType: "EVENTS", callback: callback, - }) - s.subMu.Unlock() - return s + }, opts) } // RawEvents subscribes to raw system event blocks. -func (s *GRPCStream) RawEvents(callback func(map[string]any)) *GRPCStream { - s.subMu.Lock() - s.subscriptions = append(s.subscriptions, grpcSubscription{ +func (s *GRPCStream) RawEvents(callback func(map[string]any), opts ...StreamOption) *GRPCStream { + return s.addSubscription(grpcSubscription{ streamType: "EVENTS", callback: callback, raw: true, - }) - s.subMu.Unlock() - return s + }, opts) } // Blocks subscribes to block data. func (s *GRPCStream) Blocks(callback func(map[string]any)) *GRPCStream { - s.subMu.Lock() - s.subscriptions = append(s.subscriptions, grpcSubscription{ + return s.addSubscription(grpcSubscription{ streamType: "BLOCKS", callback: callback, - }) - s.subMu.Unlock() - return s + }, nil) } // WriterActions subscribes to writer actions. -func (s *GRPCStream) WriterActions(callback func(map[string]any)) *GRPCStream { - s.subMu.Lock() - s.subscriptions = append(s.subscriptions, grpcSubscription{ +func (s *GRPCStream) WriterActions(callback func(map[string]any), opts ...StreamOption) *GRPCStream { + return s.addSubscription(grpcSubscription{ streamType: "WRITER_ACTIONS", callback: callback, - }) - s.subMu.Unlock() - return s + }, opts) } // RawWriterActions subscribes to raw writer action blocks. -func (s *GRPCStream) RawWriterActions(callback func(map[string]any)) *GRPCStream { - s.subMu.Lock() - s.subscriptions = append(s.subscriptions, grpcSubscription{ +func (s *GRPCStream) RawWriterActions(callback func(map[string]any), opts ...StreamOption) *GRPCStream { + return s.addSubscription(grpcSubscription{ streamType: "WRITER_ACTIONS", callback: callback, raw: true, - }) - s.subMu.Unlock() - return s + }, opts) +} + +// MempoolTxs subscribes to pre-consensus mempool transactions. +// Pass nil/empty coins for all coins; the server applies the coin filter +// with OR logic across values for this stream type. +func (s *GRPCStream) MempoolTxs(coins []string, callback func(map[string]any), opts ...StreamOption) *GRPCStream { + return s.addSubscription(grpcSubscription{ + streamType: "MEMPOOL_TXS", + callback: callback, + coins: coins, + }, opts) +} + +// RawMempoolTxs subscribes to raw pre-consensus mempool transaction blocks. +// Pass nil/empty coins for all coins. +func (s *GRPCStream) RawMempoolTxs(coins []string, callback func(map[string]any), opts ...StreamOption) *GRPCStream { + return s.addSubscription(grpcSubscription{ + streamType: "MEMPOOL_TXS", + callback: callback, + coins: coins, + raw: true, + }, opts) +} + +// OrderPriority subscribes to derived order/write priority actions +// (from mempool and confirmed replica data). Events carry server-enriched +// fields: coin, market_type, sz_decimals. +func (s *GRPCStream) OrderPriority(callback func(map[string]any), opts ...StreamOption) *GRPCStream { + return s.addSubscription(grpcSubscription{ + streamType: "ORDER_PRIORITY", + callback: callback, + }, opts) +} + +// RawOrderPriority subscribes to raw order/write priority action blocks. +func (s *GRPCStream) RawOrderPriority(callback func(map[string]any), opts ...StreamOption) *GRPCStream { + return s.addSubscription(grpcSubscription{ + streamType: "ORDER_PRIORITY", + callback: callback, + raw: true, + }, opts) +} + +// GossipPriority subscribes to derived gossip/read priority bid actions +// (does not measure delivery latency). Events carry server-enriched +// fields: coin, market_type, sz_decimals. +func (s *GRPCStream) GossipPriority(callback func(map[string]any), opts ...StreamOption) *GRPCStream { + return s.addSubscription(grpcSubscription{ + streamType: "GOSSIP_PRIORITY", + callback: callback, + }, opts) +} + +// RawGossipPriority subscribes to raw gossip/read priority bid action blocks. +func (s *GRPCStream) RawGossipPriority(callback func(map[string]any), opts ...StreamOption) *GRPCStream { + return s.addSubscription(grpcSubscription{ + streamType: "GOSSIP_PRIORITY", + callback: callback, + raw: true, + }, opts) +} + +// StreamBytes subscribes to the raw-bytes fast path (StreamDataBytes RPC) for +// any generic stream type ("TRADES", "ORDERS", "BOOK_UPDATES", "TWAP", +// "EVENTS", "BLOCKS", "WRITER_ACTIONS", "MEMPOOL_TXS", "ORDER_PRIORITY", +// "GOSSIP_PRIORITY"). The callback receives the undecoded payload bytes — no +// JSON parsing is performed. Use StreamWithCoins/StreamWithUsers/ +// StreamWithStartBlock to filter. +func (s *GRPCStream) StreamBytes(streamType string, callback func(blockNumber uint64, timestamp uint64, data []byte), opts ...StreamOption) *GRPCStream { + // Fail fast on unknown stream types: without this check a typo would map + // to proto enum 0 (UNKNOWN) and be sent to the server silently. + if _, ok := grpcStreamTypeMap[streamType]; !ok { + err := fmt.Errorf("unknown stream type %q for StreamBytes; valid types: TRADES, ORDERS, BOOK_UPDATES, TWAP, EVENTS, BLOCKS, WRITER_ACTIONS, MEMPOOL_TXS, ORDER_PRIORITY, GOSSIP_PRIORITY", streamType) + if s.config.OnError != nil { + s.config.OnError(err) + } + return s + } + return s.addSubscription(grpcSubscription{ + streamType: streamType, + bytesCallback: callback, + }, opts) } // L2Book subscribes to Level 2 order book updates. @@ -435,32 +533,176 @@ func L2BookNSigFigs(n int) L2BookOption { } } +// L2BookMantissa sets the mantissa for price bucketing (1, 2, or 5). +func L2BookMantissa(m uint64) L2BookOption { + return func(s *grpcSubscription) { + s.mantissa = &m + } +} + +// L2BookSkipInitialSnapshot skips the initial per-coin snapshot (L2BookDiff +// only). It applies to the first connect only: after a reconnect the snapshot +// is always requested so the local book can resync. +func L2BookSkipInitialSnapshot() L2BookOption { + return func(s *grpcSubscription) { + s.skipInitialSnapshot = true + } +} + // L4Book subscribes to Level 4 order book updates (individual orders). +// +// Note: the server may send an unsolicited full snapshot at any time after +// subscribe; discard local book state and replace it with any snapshot +// received mid-stream. func (s *GRPCStream) L4Book(coin string, callback func(map[string]any)) *GRPCStream { - s.subMu.Lock() - s.subscriptions = append(s.subscriptions, grpcSubscription{ + return s.addSubscription(grpcSubscription{ streamType: "L4_BOOK", callback: callback, coin: coin, - }) - s.subMu.Unlock() - return s + }, nil) } -func (s *GRPCStream) streamData(sub grpcSubscription) { - defer s.wg.Done() +// BboBook subscribes to best bid/offer updates. Empty/nil coins = all coins. +// Emits only when the best bid or ask changes for a coin. +func (s *GRPCStream) BboBook(coins []string, callback func(map[string]any)) *GRPCStream { + return s.addSubscription(grpcSubscription{ + streamType: "BBO_BOOK", + callback: callback, + coins: coins, + }, nil) +} + +// L2BookDiff subscribes to incremental L2 price-level changes. +// Empty/nil coins = all coins. Changed levels with sz=0 mean the level was removed. +func (s *GRPCStream) L2BookDiff(coins []string, callback func(map[string]any), opts ...L2BookOption) *GRPCStream { + sub := grpcSubscription{ + streamType: "L2_BOOK_DIFF", + callback: callback, + coins: coins, + nLevels: 20, + } + for _, opt := range opts { + opt(&sub) + } + return s.addSubscription(sub, nil) +} + +// L4BookUpdates subscribes to typed L4 order book updates. +// Empty/nil coins = all coins. +// +// Note: updates with snapshot=true carry a full reset snapshot; discard local +// book state and replace it whenever one arrives mid-stream. +func (s *GRPCStream) L4BookUpdates(coins []string, callback func(map[string]any)) *GRPCStream { + return s.addSubscription(grpcSubscription{ + streamType: "L4_BOOK_UPDATES", + callback: callback, + coins: coins, + }, nil) +} + +// TpslUpdates subscribes to trigger/TP-SL order updates. +// Empty/nil coins = all perp coins. +func (s *GRPCStream) TpslUpdates(coins []string, callback func(map[string]any)) *GRPCStream { + return s.addSubscription(grpcSubscription{ + streamType: "TPSL_UPDATES", + callback: callback, + coins: coins, + }, nil) +} + +// L2BookPacked subscribes to the fixed-point L2 book fast path. +// Prices/sizes are uint64 fixed-point integers scaled by 1e8. +func (s *GRPCStream) L2BookPacked(coin string, callback func(map[string]any), opts ...L2BookOption) *GRPCStream { + sub := grpcSubscription{ + streamType: "L2_BOOK_PACKED", + callback: callback, + coin: coin, + nLevels: 20, + } + for _, opt := range opts { + opt(&sub) + } + return s.addSubscription(sub, nil) +} + +// BboBookPacked subscribes to the fixed-point BBO fast path. +// Empty/nil coins = all coins. Prices/sizes are uint64 fixed-point integers +// scaled by 1e8. +func (s *GRPCStream) BboBookPacked(coins []string, callback func(map[string]any)) *GRPCStream { + return s.addSubscription(grpcSubscription{ + streamType: "BBO_BOOK_PACKED", + callback: callback, + coins: coins, + }, nil) +} - streamTypeMap := map[string]pb.StreamType{ - "TRADES": pb.StreamType_TRADES, - "ORDERS": pb.StreamType_ORDERS, - "BOOK_UPDATES": pb.StreamType_BOOK_UPDATES, - "TWAP": pb.StreamType_TWAP, - "EVENTS": pb.StreamType_EVENTS, - "BLOCKS": pb.StreamType_BLOCKS, - "WRITER_ACTIONS": pb.StreamType_WRITER_ACTIONS, +// L4BookBytes subscribes to the L4 book fast path: diffs are delivered as +// undecoded JSON bytes ({order_statuses, book_diffs}) in the "data" field +// instead of being parsed. +// +// Note: the server may send an unsolicited full snapshot at any time after +// subscribe; discard local book state and replace it with any snapshot +// received mid-stream. +func (s *GRPCStream) L4BookBytes(coin string, callback func(map[string]any)) *GRPCStream { + return s.addSubscription(grpcSubscription{ + streamType: "L4_BOOK_BYTES", + callback: callback, + coin: coin, + }, nil) +} + +// grpcStreamTypeMap maps generic stream type names to proto enum values. +var grpcStreamTypeMap = map[string]pb.StreamType{ + "TRADES": pb.StreamType_TRADES, + "ORDERS": pb.StreamType_ORDERS, + "BOOK_UPDATES": pb.StreamType_BOOK_UPDATES, + "TWAP": pb.StreamType_TWAP, + "EVENTS": pb.StreamType_EVENTS, + "BLOCKS": pb.StreamType_BLOCKS, + "WRITER_ACTIONS": pb.StreamType_WRITER_ACTIONS, + "MEMPOOL_TXS": pb.StreamType_MEMPOOL_TXS, + "ORDER_PRIORITY": pb.StreamType_ORDER_PRIORITY, + "GOSSIP_PRIORITY": pb.StreamType_GOSSIP_PRIORITY, +} + +// buildSubscribeRequest builds the StreamSubscribe request for a generic data +// subscription (StreamData / StreamDataBytes). On the first connect the +// user's original startBlock is sent (0 = unset, tip-following). On +// reconnects, if the user set a startBlock, the cursor advances past the +// highest block already delivered so processed blocks are not replayed; an +// unset startBlock stays unset so tip-following semantics are unchanged. +func buildSubscribeRequest(sub grpcSubscription, isReconnect bool, lastSeenBlock uint64) *pb.SubscribeRequest { + startBlock := sub.startBlock + if isReconnect && startBlock != 0 { + startBlock = max(startBlock, lastSeenBlock+1) } + req := &pb.SubscribeRequest{ + Request: &pb.SubscribeRequest_Subscribe{ + Subscribe: &pb.StreamSubscribe{ + StreamType: grpcStreamTypeMap[sub.streamType], + StartBlock: startBlock, + Filters: make(map[string]*pb.FilterValues), + }, + }, + } + + if len(sub.coins) > 0 { + req.GetSubscribe().Filters["coin"] = &pb.FilterValues{Values: sub.coins} + } + if len(sub.users) > 0 { + req.GetSubscribe().Filters["user"] = &pb.FilterValues{Values: sub.users} + } + + return req +} + +func (s *GRPCStream) streamData(sub grpcSubscription) { + defer s.wg.Done() + retryDelay := time.Second + firstConnect := true + var lastSeenBlock uint64 for s.running.Load() { s.connMu.RLock() @@ -494,21 +736,7 @@ func (s *GRPCStream) streamData(sub grpcSubscription) { } // Send initial subscription request - req := &pb.SubscribeRequest{ - Request: &pb.SubscribeRequest_Subscribe{ - Subscribe: &pb.StreamSubscribe{ - StreamType: streamTypeMap[sub.streamType], - Filters: make(map[string]*pb.FilterValues), - }, - }, - } - - if len(sub.coins) > 0 { - req.GetSubscribe().Filters["coin"] = &pb.FilterValues{Values: sub.coins} - } - if len(sub.users) > 0 { - req.GetSubscribe().Filters["user"] = &pb.FilterValues{Values: sub.users} - } + req := buildSubscribeRequest(sub, !firstConnect, lastSeenBlock) if err := stream.Send(req); err != nil { if s.running.Load() && s.config.OnError != nil { @@ -523,8 +751,10 @@ func (s *GRPCStream) streamData(sub grpcSubscription) { } } - // Reset retry delay on successful connection + // Reset retry delay on successful subscription; subsequent attempts + // are reconnects retryDelay = time.Second + firstConnect = false // Start ping goroutine pingDone := make(chan struct{}) @@ -565,6 +795,12 @@ func (s *GRPCStream) streamData(sub grpcSubscription) { if err := json.Unmarshal([]byte(data.Data), &parsed); err != nil { continue } + // Advance the resume cursor only after the payload decoded, so a + // block that failed to parse is re-requested on reconnect + // instead of being skipped permanently. + if data.BlockNumber > lastSeenBlock { + lastSeenBlock = data.BlockNumber + } if sub.raw { parsed["_block_number"] = data.BlockNumber @@ -614,6 +850,113 @@ func (s *GRPCStream) streamData(sub grpcSubscription) { } } +// streamDataBytes handles the StreamDataBytes fast path: payload bytes are +// passed through to the callback without JSON decoding. +func (s *GRPCStream) streamDataBytes(sub grpcSubscription) { + defer s.wg.Done() + + retryDelay := time.Second + firstConnect := true + var lastSeenBlock uint64 + + for s.running.Load() { + s.connMu.RLock() + stub := s.streamingStub + s.connMu.RUnlock() + + if stub == nil { + select { + case <-s.ctx.Done(): + return + case <-time.After(time.Second): + continue + } + } + + ctx := metadata.NewOutgoingContext(s.ctx, s.getMetadata()) + + stream, err := stub.StreamDataBytes(ctx) + if err != nil { + if s.running.Load() && s.config.OnError != nil { + s.config.OnError(err) + } + // Just retry with backoff, don't reconnect the whole connection + select { + case <-s.ctx.Done(): + return + case <-time.After(retryDelay): + retryDelay = min(retryDelay*2, 30*time.Second) + continue + } + } + + // Send initial subscription request + req := buildSubscribeRequest(sub, !firstConnect, lastSeenBlock) + + if err := stream.Send(req); err != nil { + if s.running.Load() && s.config.OnError != nil { + s.config.OnError(err) + } + select { + case <-s.ctx.Done(): + return + case <-time.After(retryDelay): + retryDelay = min(retryDelay*2, 30*time.Second) + continue + } + } + + // Reset retry delay on successful subscription; subsequent attempts + // are reconnects + retryDelay = time.Second + firstConnect = false + + // Start ping goroutine + pingDone := make(chan struct{}) + go func() { + ticker := time.NewTicker(30 * time.Second) + defer ticker.Stop() + for { + select { + case <-pingDone: + return + case <-s.ctx.Done(): + return + case <-ticker.C: + pingReq := &pb.SubscribeRequest{ + Request: &pb.SubscribeRequest_Ping{ + Ping: &pb.Ping{Timestamp: time.Now().UnixMilli()}, + }, + } + stream.Send(pingReq) + } + } + }() + + // Handle responses + for s.running.Load() { + resp, err := stream.Recv() + if err != nil { + close(pingDone) + if s.running.Load() && s.config.OnError != nil { + s.config.OnError(err) + } + // Just break and retry, don't reconnect + break + } + + if data := resp.GetData(); data != nil { + sub.bytesCallback(data.BlockNumber, data.Timestamp, data.Data) + // Cursor advances only after delivery so reconnects never skip + // an undelivered block. + if data.BlockNumber > lastSeenBlock { + lastSeenBlock = data.BlockNumber + } + } + } + } +} + func (s *GRPCStream) streamBlocks(sub grpcSubscription) { defer s.wg.Done() @@ -699,6 +1042,10 @@ func (s *GRPCStream) streamL2Book(sub grpcSubscription) { nSigFigs := uint32(*sub.nSigFigs) req.NSigFigs = &nSigFigs } + if sub.mantissa != nil { + mantissa := *sub.mantissa + req.Mantissa = &mantissa + } stream, err := stub.StreamL2Book(ctx, req) if err != nil { @@ -798,23 +1145,7 @@ func (s *GRPCStream) streamL4Book(sub grpcSubscription) { var data map[string]any if snapshot := update.GetSnapshot(); snapshot != nil { - bids := make([]map[string]any, len(snapshot.Bids)) - for i, order := range snapshot.Bids { - bids[i] = l4OrderToMap(order) - } - asks := make([]map[string]any, len(snapshot.Asks)) - for i, order := range snapshot.Asks { - asks[i] = l4OrderToMap(order) - } - - data = map[string]any{ - "type": "snapshot", - "coin": snapshot.Coin, - "time": snapshot.Time, - "height": snapshot.Height, - "bids": bids, - "asks": asks, - } + data = l4SnapshotToMap(snapshot) } else if diff := update.GetDiff(); diff != nil { var diffData map[string]any json.Unmarshal([]byte(diff.Data), &diffData) @@ -859,6 +1190,335 @@ func l4OrderToMap(order *pb.L4Order) map[string]any { return m } +func l4SnapshotToMap(snapshot *pb.L4BookSnapshot) map[string]any { + bids := make([]map[string]any, len(snapshot.Bids)) + for i, order := range snapshot.Bids { + bids[i] = l4OrderToMap(order) + } + asks := make([]map[string]any, len(snapshot.Asks)) + for i, order := range snapshot.Asks { + asks[i] = l4OrderToMap(order) + } + + return map[string]any{ + "type": "snapshot", + "coin": snapshot.Coin, + "time": snapshot.Time, + "height": snapshot.Height, + "bids": bids, + "asks": asks, + } +} + +func l2LevelToArr(level *pb.L2Level) []any { + return []any{level.Px, level.Sz, level.N} +} + +func l2LevelsToArrs(levels []*pb.L2Level) [][]any { + arrs := make([][]any, len(levels)) + for i, level := range levels { + arrs[i] = l2LevelToArr(level) + } + return arrs +} + +func l2LevelPackedToArr(level *pb.L2LevelPacked) []any { + return []any{level.Px, level.Sz, level.N} +} + +func l2LevelsPackedToArrs(levels []*pb.L2LevelPacked) [][]any { + arrs := make([][]any, len(levels)) + for i, level := range levels { + arrs[i] = l2LevelPackedToArr(level) + } + return arrs +} + +func bboBookUpdateToMap(update *pb.BboBookUpdate) map[string]any { + m := map[string]any{ + "coin": update.Coin, + "time": update.Time, + "block_number": update.BlockNumber, + "bid": nil, + "ask": nil, + } + if update.Bid != nil { + m["bid"] = l2LevelToArr(update.Bid) + } + if update.Ask != nil { + m["ask"] = l2LevelToArr(update.Ask) + } + return m +} + +func bboBookPackedUpdateToMap(update *pb.BboBookPackedUpdate) map[string]any { + m := map[string]any{ + "coin": update.Coin, + "time": update.Time, + "block_number": update.BlockNumber, + "bid": nil, + "ask": nil, + } + if update.Bid != nil { + m["bid"] = l2LevelPackedToArr(update.Bid) + } + if update.Ask != nil { + m["ask"] = l2LevelPackedToArr(update.Ask) + } + return m +} + +func l2BookDiffUpdateToMap(update *pb.L2BookDiffUpdate) map[string]any { + diffs := make([]map[string]any, len(update.Diffs)) + for i, diff := range update.Diffs { + diffs[i] = map[string]any{ + "coin": diff.Coin, + "seq": diff.Seq, + "prev_seq": diff.PrevSeq, + "bids": l2LevelsToArrs(diff.Bids), + "asks": l2LevelsToArrs(diff.Asks), + "snapshot": diff.Snapshot, + } + } + + return map[string]any{ + "time": update.Time, + "height": update.Height, + "snapshot": update.Snapshot, + "diffs": diffs, + } +} + +func l2BookPackedUpdateToMap(update *pb.L2BookPackedUpdate) map[string]any { + return map[string]any{ + "coin": update.Coin, + "time": update.Time, + "block_number": update.BlockNumber, + "bids": l2LevelsPackedToArrs(update.Bids), + "asks": l2LevelsPackedToArrs(update.Asks), + } +} + +func l4BookUpdatesUpdateToMap(update *pb.L4BookUpdatesUpdate) map[string]any { + diffs := make([]map[string]any, len(update.Diffs)) + for i, diff := range update.Diffs { + diffs[i] = map[string]any{ + "diff_type": diff.DiffType.String(), + "coin": diff.Coin, + "oid": diff.Oid, + "user": diff.User, + "side": diff.Side, + "px": diff.Px, + "sz": diff.Sz, + } + } + + return map[string]any{ + "time": update.Time, + "height": update.Height, + "snapshot": update.Snapshot, + "diffs": diffs, + } +} + +func tpslUpdatesUpdateToMap(update *pb.TpslUpdatesUpdate) map[string]any { + diffs := make([]map[string]any, len(update.Diffs)) + for i, diff := range update.Diffs { + diffs[i] = map[string]any{ + "diff_type": diff.DiffType.String(), + "oid": diff.Oid, + "coin": diff.Coin, + "user": diff.User, + "side": diff.Side, + "trigger_px": diff.TriggerPx, + "limit_px": diff.LimitPx, + "sz": diff.Sz, + "trigger_condition": diff.TriggerCondition, + "order_type": diff.OrderType, + "is_position_tpsl": diff.IsPositionTpsl, + "reduce_only": diff.ReduceOnly, + "timestamp": diff.Timestamp, + "reason": diff.Reason, + } + } + + return map[string]any{ + "time": update.Time, + "height": update.Height, + "snapshot": update.Snapshot, + "diffs": diffs, + } +} + +// l4BookBytesUpdateToMap maps an L4BookBytesUpdate oneof to the snapshot/diff +// shape used by L4Book, keeping the diff payload as undecoded JSON bytes. +func l4BookBytesUpdateToMap(update *pb.L4BookBytesUpdate) map[string]any { + if snapshot := update.GetSnapshot(); snapshot != nil { + return l4SnapshotToMap(snapshot) + } + if diff := update.GetDiff(); diff != nil { + return map[string]any{ + "type": "diff", + "time": diff.Time, + "height": diff.Height, + "data": diff.Data, // JSON bytes, not decoded + } + } + return nil +} + +// runOrderbookStream runs a server-streaming order book RPC with the same +// retry/backoff behavior as the other stream handlers. Updates are converted +// by handle and skipped when it returns nil. +func runOrderbookStream[T any]( + s *GRPCStream, + callback func(map[string]any), + open func(ctx context.Context, stub pb.OrderBookStreamingClient) (grpc.ServerStreamingClient[T], error), + handle func(*T) map[string]any, +) { + defer s.wg.Done() + + retryDelay := time.Second + + for s.running.Load() { + s.connMu.RLock() + stub := s.orderbookStub + s.connMu.RUnlock() + + if stub == nil { + select { + case <-s.ctx.Done(): + return + case <-time.After(time.Second): + continue + } + } + + ctx := metadata.NewOutgoingContext(s.ctx, s.getMetadata()) + + stream, err := open(ctx, stub) + if err != nil { + if s.running.Load() && s.config.OnError != nil { + s.config.OnError(err) + } + select { + case <-s.ctx.Done(): + return + case <-time.After(retryDelay): + retryDelay = min(retryDelay*2, 30*time.Second) + continue + } + } + + // Reset retry delay on successful connection + retryDelay = time.Second + + for s.running.Load() { + update, err := stream.Recv() + if err != nil { + if s.running.Load() && s.config.OnError != nil { + s.config.OnError(err) + } + break + } + + if data := handle(update); data != nil { + callback(data) + } + } + } +} + +func (s *GRPCStream) streamBboBook(sub grpcSubscription) { + runOrderbookStream(s, sub.callback, + func(ctx context.Context, stub pb.OrderBookStreamingClient) (grpc.ServerStreamingClient[pb.BboBookUpdate], error) { + return stub.StreamBboBook(ctx, &pb.BboBookRequest{Coins: sub.coins}) + }, bboBookUpdateToMap) +} + +func (s *GRPCStream) streamBboBookPacked(sub grpcSubscription) { + runOrderbookStream(s, sub.callback, + func(ctx context.Context, stub pb.OrderBookStreamingClient) (grpc.ServerStreamingClient[pb.BboBookPackedUpdate], error) { + return stub.StreamBboBookPacked(ctx, &pb.BboBookRequest{Coins: sub.coins}) + }, bboBookPackedUpdateToMap) +} + +// buildL2BookDiffRequest builds the StreamL2BookDiff request. The user's +// skipInitialSnapshot preference only applies to the first connect; on +// reconnects the snapshot is always requested so the consumer can resync the +// local book after the gap. +func buildL2BookDiffRequest(sub grpcSubscription, isReconnect bool) *pb.L2BookDiffRequest { + req := &pb.L2BookDiffRequest{ + Coins: sub.coins, + NLevels: uint32(sub.nLevels), + SkipInitialSnapshot: sub.skipInitialSnapshot && !isReconnect, + } + if sub.nSigFigs != nil { + nSigFigs := uint32(*sub.nSigFigs) + req.NSigFigs = &nSigFigs + } + if sub.mantissa != nil { + mantissa := *sub.mantissa + req.Mantissa = &mantissa + } + return req +} + +func (s *GRPCStream) streamL2BookDiff(sub grpcSubscription) { + firstConnect := true + + runOrderbookStream(s, sub.callback, + func(ctx context.Context, stub pb.OrderBookStreamingClient) (grpc.ServerStreamingClient[pb.L2BookDiffUpdate], error) { + req := buildL2BookDiffRequest(sub, !firstConnect) + stream, err := stub.StreamL2BookDiff(ctx, req) + if err == nil { + firstConnect = false + } + return stream, err + }, l2BookDiffUpdateToMap) +} + +func (s *GRPCStream) streamL2BookPacked(sub grpcSubscription) { + req := &pb.L2BookRequest{ + Coin: sub.coin, + NLevels: uint32(sub.nLevels), + } + if sub.nSigFigs != nil { + nSigFigs := uint32(*sub.nSigFigs) + req.NSigFigs = &nSigFigs + } + if sub.mantissa != nil { + mantissa := *sub.mantissa + req.Mantissa = &mantissa + } + + runOrderbookStream(s, sub.callback, + func(ctx context.Context, stub pb.OrderBookStreamingClient) (grpc.ServerStreamingClient[pb.L2BookPackedUpdate], error) { + return stub.StreamL2BookPacked(ctx, req) + }, l2BookPackedUpdateToMap) +} + +func (s *GRPCStream) streamL4BookUpdates(sub grpcSubscription) { + runOrderbookStream(s, sub.callback, + func(ctx context.Context, stub pb.OrderBookStreamingClient) (grpc.ServerStreamingClient[pb.L4BookUpdatesUpdate], error) { + return stub.StreamL4BookUpdates(ctx, &pb.L4BookUpdatesRequest{Coins: sub.coins}) + }, l4BookUpdatesUpdateToMap) +} + +func (s *GRPCStream) streamTpslUpdates(sub grpcSubscription) { + runOrderbookStream(s, sub.callback, + func(ctx context.Context, stub pb.OrderBookStreamingClient) (grpc.ServerStreamingClient[pb.TpslUpdatesUpdate], error) { + return stub.StreamTpslUpdates(ctx, &pb.TpslUpdatesRequest{Coins: sub.coins}) + }, tpslUpdatesUpdateToMap) +} + +func (s *GRPCStream) streamL4BookBytes(sub grpcSubscription) { + runOrderbookStream(s, sub.callback, + func(ctx context.Context, stub pb.OrderBookStreamingClient) (grpc.ServerStreamingClient[pb.L4BookBytesUpdate], error) { + return stub.StreamL4BookBytes(ctx, &pb.L4BookRequest{Coin: sub.coin}) + }, l4BookBytesUpdateToMap) +} + func (s *GRPCStream) handleReconnect() { if !s.running.Load() { return @@ -914,6 +1574,10 @@ func (s *GRPCStream) startStreams() { for _, sub := range subs { s.wg.Add(1) + if sub.bytesCallback != nil { + go s.streamDataBytes(sub) + continue + } switch sub.streamType { case "L2_BOOK": go s.streamL2Book(sub) @@ -921,6 +1585,20 @@ func (s *GRPCStream) startStreams() { go s.streamL4Book(sub) case "BLOCKS": go s.streamBlocks(sub) + case "BBO_BOOK": + go s.streamBboBook(sub) + case "BBO_BOOK_PACKED": + go s.streamBboBookPacked(sub) + case "L2_BOOK_DIFF": + go s.streamL2BookDiff(sub) + case "L2_BOOK_PACKED": + go s.streamL2BookPacked(sub) + case "L4_BOOK_UPDATES": + go s.streamL4BookUpdates(sub) + case "TPSL_UPDATES": + go s.streamTpslUpdates(sub) + case "L4_BOOK_BYTES": + go s.streamL4BookBytes(sub) default: go s.streamData(sub) } diff --git a/go/hyperliquid/grpc_stream_test.go b/go/hyperliquid/grpc_stream_test.go new file mode 100644 index 0000000..86d5e93 --- /dev/null +++ b/go/hyperliquid/grpc_stream_test.go @@ -0,0 +1,517 @@ +package hyperliquid + +import ( + "testing" + + pb "github.com/quiknode-labs/hyperliquid-sdk/go/hyperliquid/proto" +) + +// Test that the new StreamType enum values match the proto contract. +func TestGRPCStreamTypeEnumValues(t *testing.T) { + if pb.StreamType_MEMPOOL_TXS != 8 { + t.Errorf("StreamType_MEMPOOL_TXS = %d, want 8", pb.StreamType_MEMPOOL_TXS) + } + if pb.StreamType_ORDER_PRIORITY != 9 { + t.Errorf("StreamType_ORDER_PRIORITY = %d, want 9", pb.StreamType_ORDER_PRIORITY) + } + if pb.StreamType_GOSSIP_PRIORITY != 10 { + t.Errorf("StreamType_GOSSIP_PRIORITY = %d, want 10", pb.StreamType_GOSSIP_PRIORITY) + } +} + +// Test the stream type name -> proto enum map, including the new types. +func TestGRPCStreamTypeMap(t *testing.T) { + want := map[string]pb.StreamType{ + "TRADES": pb.StreamType_TRADES, + "ORDERS": pb.StreamType_ORDERS, + "BOOK_UPDATES": pb.StreamType_BOOK_UPDATES, + "TWAP": pb.StreamType_TWAP, + "EVENTS": pb.StreamType_EVENTS, + "BLOCKS": pb.StreamType_BLOCKS, + "WRITER_ACTIONS": pb.StreamType_WRITER_ACTIONS, + "MEMPOOL_TXS": pb.StreamType_MEMPOOL_TXS, + "ORDER_PRIORITY": pb.StreamType_ORDER_PRIORITY, + "GOSSIP_PRIORITY": pb.StreamType_GOSSIP_PRIORITY, + } + if len(grpcStreamTypeMap) != len(want) { + t.Errorf("grpcStreamTypeMap has %d entries, want %d", len(grpcStreamTypeMap), len(want)) + } + for name, streamType := range want { + if got, ok := grpcStreamTypeMap[name]; !ok || got != streamType { + t.Errorf("grpcStreamTypeMap[%q] = %v (present=%v), want %v", name, got, ok, streamType) + } + } +} + +func noopCallback(map[string]any) {} + +// Test that the new generic stream helpers register the expected subscriptions. +func TestGRPCNewGenericSubscriptions(t *testing.T) { + s := NewGRPCStream("https://x.quiknode.pro/token", nil) + + s.MempoolTxs([]string{"BTC", "ETH"}, noopCallback) + s.RawMempoolTxs(nil, noopCallback) + s.OrderPriority(noopCallback) + s.RawOrderPriority(noopCallback) + s.GossipPriority(noopCallback) + s.RawGossipPriority(noopCallback) + + subs := s.subscriptions + if len(subs) != 6 { + t.Fatalf("got %d subscriptions, want 6", len(subs)) + } + + if subs[0].streamType != "MEMPOOL_TXS" || subs[0].raw { + t.Errorf("MempoolTxs registered %q raw=%v, want MEMPOOL_TXS raw=false", subs[0].streamType, subs[0].raw) + } + if len(subs[0].coins) != 2 || subs[0].coins[0] != "BTC" { + t.Errorf("MempoolTxs coins = %v, want [BTC ETH]", subs[0].coins) + } + if subs[1].streamType != "MEMPOOL_TXS" || !subs[1].raw || len(subs[1].coins) != 0 { + t.Errorf("RawMempoolTxs registered %q raw=%v coins=%v", subs[1].streamType, subs[1].raw, subs[1].coins) + } + if subs[2].streamType != "ORDER_PRIORITY" || subs[2].raw { + t.Errorf("OrderPriority registered %q raw=%v", subs[2].streamType, subs[2].raw) + } + if subs[3].streamType != "ORDER_PRIORITY" || !subs[3].raw { + t.Errorf("RawOrderPriority registered %q raw=%v", subs[3].streamType, subs[3].raw) + } + if subs[4].streamType != "GOSSIP_PRIORITY" || subs[4].raw { + t.Errorf("GossipPriority registered %q raw=%v", subs[4].streamType, subs[4].raw) + } + if subs[5].streamType != "GOSSIP_PRIORITY" || !subs[5].raw { + t.Errorf("RawGossipPriority registered %q raw=%v", subs[5].streamType, subs[5].raw) + } +} + +// Test that StreamWithStartBlock is recorded and plumbed into StreamSubscribe. +func TestGRPCStartBlockOption(t *testing.T) { + s := NewGRPCStream("https://x.quiknode.pro/token", nil) + s.Trades([]string{"BTC"}, noopCallback, StreamWithStartBlock(12345)) + s.MempoolTxs(nil, noopCallback, StreamWithStartBlock(999)) + + if s.subscriptions[0].startBlock != 12345 { + t.Errorf("Trades startBlock = %d, want 12345", s.subscriptions[0].startBlock) + } + if s.subscriptions[1].startBlock != 999 { + t.Errorf("MempoolTxs startBlock = %d, want 999", s.subscriptions[1].startBlock) + } + + req := buildSubscribeRequest(s.subscriptions[0], false, 0) + sub := req.GetSubscribe() + if sub == nil { + t.Fatal("expected subscribe request") + } + if sub.StreamType != pb.StreamType_TRADES { + t.Errorf("StreamType = %v, want TRADES", sub.StreamType) + } + if sub.StartBlock != 12345 { + t.Errorf("StartBlock = %d, want 12345", sub.StartBlock) + } + if coins := sub.Filters["coin"]; coins == nil || len(coins.Values) != 1 || coins.Values[0] != "BTC" { + t.Errorf("Filters[coin] = %v, want [BTC]", coins) + } +} + +// Test that OrdersWithOptions/RawOrdersWithOptions accept StreamOptions and +// plumb both user filters and startBlock into the subscribe request. +func TestGRPCOrdersWithOptionsStartBlock(t *testing.T) { + s := NewGRPCStream("https://x.quiknode.pro/token", nil) + s.OrdersWithOptions([]string{"BTC"}, []string{"0xabc"}, noopCallback, StreamWithStartBlock(4242)) + s.RawOrdersWithOptions(nil, nil, noopCallback, StreamWithStartBlock(777)) + + req := buildSubscribeRequest(s.subscriptions[0], false, 0) + sub := req.GetSubscribe() + if sub == nil { + t.Fatal("expected subscribe request") + } + if sub.StreamType != pb.StreamType_ORDERS { + t.Errorf("StreamType = %v, want ORDERS", sub.StreamType) + } + if sub.StartBlock != 4242 { + t.Errorf("StartBlock = %d, want 4242", sub.StartBlock) + } + if users := sub.Filters["user"]; users == nil || len(users.Values) != 1 || users.Values[0] != "0xabc" { + t.Errorf("Filters[user] = %v, want [0xabc]", users) + } + if !s.subscriptions[1].raw { + t.Error("RawOrdersWithOptions subscription not marked raw") + } + if s.subscriptions[1].startBlock != 777 { + t.Errorf("RawOrders startBlock = %d, want 777", s.subscriptions[1].startBlock) + } + + // The legacy variadic signatures still work and stay option-free. + s.Orders([]string{"ETH"}, noopCallback, "0xdef") + if s.subscriptions[2].startBlock != 0 { + t.Errorf("legacy Orders startBlock = %d, want 0", s.subscriptions[2].startBlock) + } +} + +// Test reconnect start_block semantics: the first connect sends the user's +// original startBlock, reconnects resume past the highest block already +// delivered, and an unset startBlock stays unset (tip-following). +func TestGRPCSubscribeRequestReconnectStartBlock(t *testing.T) { + s := NewGRPCStream("https://x.quiknode.pro/token", nil) + s.Trades([]string{"BTC"}, noopCallback, StreamWithStartBlock(1000)) + s.Trades([]string{"ETH"}, noopCallback) // no startBlock + + withStart := s.subscriptions[0] + unset := s.subscriptions[1] + + // First connect uses the original startBlock. + if got := buildSubscribeRequest(withStart, false, 0).GetSubscribe().StartBlock; got != 1000 { + t.Errorf("first connect StartBlock = %d, want 1000", got) + } + + // Reconnect after delivering blocks resumes past the last seen block. + if got := buildSubscribeRequest(withStart, true, 5000).GetSubscribe().StartBlock; got != 5001 { + t.Errorf("reconnect StartBlock = %d, want 5001", got) + } + + // Reconnect before any block was delivered keeps the original. + if got := buildSubscribeRequest(withStart, true, 0).GetSubscribe().StartBlock; got != 1000 { + t.Errorf("reconnect (no data seen) StartBlock = %d, want 1000", got) + } + + // Reconnect where last seen is still below the original keeps the original. + if got := buildSubscribeRequest(withStart, true, 500).GetSubscribe().StartBlock; got != 1000 { + t.Errorf("reconnect (behind original) StartBlock = %d, want 1000", got) + } + + // Unset startBlock stays unset on reconnect — no cursor is introduced. + if got := buildSubscribeRequest(unset, true, 5000).GetSubscribe().StartBlock; got != 0 { + t.Errorf("reconnect (unset) StartBlock = %d, want 0", got) + } +} + +// Test that skip_initial_snapshot only applies to the first L2BookDiff +// connect: reconnect requests always ask for the snapshot to resync. +func TestGRPCL2BookDiffRequestReconnectSnapshot(t *testing.T) { + s := NewGRPCStream("https://x.quiknode.pro/token", nil) + s.L2BookDiff([]string{"BTC"}, noopCallback, L2BookNSigFigs(5), L2BookMantissa(2), L2BookSkipInitialSnapshot()) + s.L2BookDiff([]string{"ETH"}, noopCallback) + + skip := s.subscriptions[0] + noSkip := s.subscriptions[1] + + // First connect honors the user's skipInitialSnapshot. + if !buildL2BookDiffRequest(skip, false).SkipInitialSnapshot { + t.Error("first connect SkipInitialSnapshot = false, want true") + } + if buildL2BookDiffRequest(noSkip, false).SkipInitialSnapshot { + t.Error("first connect (no option) SkipInitialSnapshot = true, want false") + } + + // Reconnects always request the snapshot. + if buildL2BookDiffRequest(skip, true).SkipInitialSnapshot { + t.Error("reconnect SkipInitialSnapshot = true, want false") + } + if buildL2BookDiffRequest(noSkip, true).SkipInitialSnapshot { + t.Error("reconnect (no option) SkipInitialSnapshot = true, want false") + } + + // Other request fields are still plumbed on reconnect. + req := buildL2BookDiffRequest(skip, true) + if len(req.Coins) != 1 || req.Coins[0] != "BTC" || req.NLevels != 20 { + t.Errorf("reconnect request coins=%v nLevels=%d, want [BTC] 20", req.Coins, req.NLevels) + } + if req.NSigFigs == nil || *req.NSigFigs != 5 { + t.Errorf("reconnect request NSigFigs = %v, want 5", req.NSigFigs) + } + if req.Mantissa == nil || *req.Mantissa != 2 { + t.Errorf("reconnect request Mantissa = %v, want 2", req.Mantissa) + } +} + +// Test that the mempool coin filter uses the generic filters map. +func TestGRPCMempoolCoinFilter(t *testing.T) { + s := NewGRPCStream("https://x.quiknode.pro/token", nil) + s.MempoolTxs([]string{"BTC", "ETH"}, noopCallback) + s.MempoolTxs(nil, noopCallback) + + req := buildSubscribeRequest(s.subscriptions[0], false, 0) + sub := req.GetSubscribe() + if sub.StreamType != pb.StreamType_MEMPOOL_TXS { + t.Errorf("StreamType = %v, want MEMPOOL_TXS", sub.StreamType) + } + coins := sub.Filters["coin"] + if coins == nil || len(coins.Values) != 2 || coins.Values[0] != "BTC" || coins.Values[1] != "ETH" { + t.Errorf("Filters[coin] = %v, want [BTC ETH]", coins) + } + + // No coins = unfiltered + req = buildSubscribeRequest(s.subscriptions[1], false, 0) + if _, ok := req.GetSubscribe().Filters["coin"]; ok { + t.Error("expected no coin filter for unfiltered mempool subscription") + } +} + +// Test that the new orderbook helpers register the expected subscriptions. +func TestGRPCNewOrderbookSubscriptions(t *testing.T) { + s := NewGRPCStream("https://x.quiknode.pro/token", nil) + + s.BboBook([]string{"BTC"}, noopCallback) + s.L2BookDiff([]string{"BTC", "ETH"}, noopCallback, L2BookNLevels(50), L2BookNSigFigs(5), L2BookMantissa(2), L2BookSkipInitialSnapshot()) + s.L4BookUpdates(nil, noopCallback) + s.TpslUpdates([]string{"SOL"}, noopCallback) + s.L2BookPacked("BTC", noopCallback, L2BookNLevels(10)) + s.BboBookPacked(nil, noopCallback) + s.L4BookBytes("ETH", noopCallback) + + subs := s.subscriptions + if len(subs) != 7 { + t.Fatalf("got %d subscriptions, want 7", len(subs)) + } + + if subs[0].streamType != "BBO_BOOK" || len(subs[0].coins) != 1 { + t.Errorf("BboBook registered %q coins=%v", subs[0].streamType, subs[0].coins) + } + + diff := subs[1] + if diff.streamType != "L2_BOOK_DIFF" { + t.Errorf("L2BookDiff streamType = %q", diff.streamType) + } + if diff.nLevels != 50 { + t.Errorf("L2BookDiff nLevels = %d, want 50", diff.nLevels) + } + if diff.nSigFigs == nil || *diff.nSigFigs != 5 { + t.Errorf("L2BookDiff nSigFigs = %v, want 5", diff.nSigFigs) + } + if diff.mantissa == nil || *diff.mantissa != 2 { + t.Errorf("L2BookDiff mantissa = %v, want 2", diff.mantissa) + } + if !diff.skipInitialSnapshot { + t.Error("L2BookDiff skipInitialSnapshot = false, want true") + } + + if subs[2].streamType != "L4_BOOK_UPDATES" || len(subs[2].coins) != 0 { + t.Errorf("L4BookUpdates registered %q coins=%v", subs[2].streamType, subs[2].coins) + } + if subs[3].streamType != "TPSL_UPDATES" || len(subs[3].coins) != 1 { + t.Errorf("TpslUpdates registered %q coins=%v", subs[3].streamType, subs[3].coins) + } + if subs[4].streamType != "L2_BOOK_PACKED" || subs[4].coin != "BTC" || subs[4].nLevels != 10 { + t.Errorf("L2BookPacked registered %q coin=%q nLevels=%d", subs[4].streamType, subs[4].coin, subs[4].nLevels) + } + if subs[5].streamType != "BBO_BOOK_PACKED" { + t.Errorf("BboBookPacked registered %q", subs[5].streamType) + } + if subs[6].streamType != "L4_BOOK_BYTES" || subs[6].coin != "ETH" { + t.Errorf("L4BookBytes registered %q coin=%q", subs[6].streamType, subs[6].coin) + } +} + +// Test the StreamBytes low-level subscription. +func TestGRPCStreamBytesSubscription(t *testing.T) { + s := NewGRPCStream("https://x.quiknode.pro/token", nil) + s.StreamBytes("MEMPOOL_TXS", func(blockNumber, timestamp uint64, data []byte) {}, + StreamWithCoins("BTC"), StreamWithUsers("0xabc"), StreamWithStartBlock(7)) + + if len(s.subscriptions) != 1 { + t.Fatalf("got %d subscriptions, want 1", len(s.subscriptions)) + } + sub := s.subscriptions[0] + if sub.bytesCallback == nil { + t.Fatal("bytesCallback not set") + } + if sub.streamType != "MEMPOOL_TXS" { + t.Errorf("streamType = %q, want MEMPOOL_TXS", sub.streamType) + } + + req := buildSubscribeRequest(sub, false, 0) + pbSub := req.GetSubscribe() + if pbSub.StreamType != pb.StreamType_MEMPOOL_TXS { + t.Errorf("StreamType = %v, want MEMPOOL_TXS", pbSub.StreamType) + } + if pbSub.StartBlock != 7 { + t.Errorf("StartBlock = %d, want 7", pbSub.StartBlock) + } + if coins := pbSub.Filters["coin"]; coins == nil || coins.Values[0] != "BTC" { + t.Errorf("Filters[coin] = %v, want [BTC]", coins) + } + if users := pbSub.Filters["user"]; users == nil || users.Values[0] != "0xabc" { + t.Errorf("Filters[user] = %v, want [0xabc]", users) + } +} + +// Test BBO update conversion, including absent bid/ask. +func TestGRPCBboBookUpdateToMap(t *testing.T) { + update := &pb.BboBookUpdate{ + Coin: "BTC", + Time: 1000, + BlockNumber: 42, + Bid: &pb.L2Level{Px: "65000", Sz: "1.5", N: 3}, + } + + m := bboBookUpdateToMap(update) + if m["coin"] != "BTC" || m["time"] != uint64(1000) || m["block_number"] != uint64(42) { + t.Errorf("unexpected header fields: %v", m) + } + bid, ok := m["bid"].([]any) + if !ok || bid[0] != "65000" || bid[1] != "1.5" || bid[2] != uint32(3) { + t.Errorf("bid = %v, want [65000 1.5 3]", m["bid"]) + } + if m["ask"] != nil { + t.Errorf("ask = %v, want nil", m["ask"]) + } +} + +// Test L2 diff conversion. +func TestGRPCL2BookDiffUpdateToMap(t *testing.T) { + update := &pb.L2BookDiffUpdate{ + Time: 2000, + Height: 100, + Snapshot: true, + Diffs: []*pb.L2CoinDiff{ + { + Coin: "ETH", + Seq: 5, + PrevSeq: 4, + Bids: []*pb.L2Level{{Px: "4000", Sz: "0", N: 0}}, + Asks: []*pb.L2Level{{Px: "4001", Sz: "2", N: 1}}, + Snapshot: false, + }, + }, + } + + m := l2BookDiffUpdateToMap(update) + if m["time"] != uint64(2000) || m["height"] != uint64(100) || m["snapshot"] != true { + t.Errorf("unexpected header fields: %v", m) + } + diffs := m["diffs"].([]map[string]any) + if len(diffs) != 1 { + t.Fatalf("got %d diffs, want 1", len(diffs)) + } + d := diffs[0] + if d["coin"] != "ETH" || d["seq"] != uint64(5) || d["prev_seq"] != uint64(4) { + t.Errorf("unexpected diff fields: %v", d) + } + bids := d["bids"].([][]any) + if bids[0][1] != "0" { // sz=0 level = removed + t.Errorf("bids = %v, want removed level with sz=0", bids) + } +} + +// Test typed L4 updates conversion. +func TestGRPCL4BookUpdatesUpdateToMap(t *testing.T) { + update := &pb.L4BookUpdatesUpdate{ + Time: 3000, + Height: 200, + Snapshot: true, + Diffs: []*pb.L4OrderDiff{ + { + DiffType: pb.L4OrderDiffType_L4_ORDER_DIFF_TYPE_NEW, + Coin: "BTC", + Oid: 777, + User: "0xuser", + Side: "B", + Px: "65000", + Sz: "0.5", + }, + }, + } + + m := l4BookUpdatesUpdateToMap(update) + if m["snapshot"] != true { + t.Error("snapshot = false, want true") + } + diffs := m["diffs"].([]map[string]any) + d := diffs[0] + if d["diff_type"] != "L4_ORDER_DIFF_TYPE_NEW" || d["oid"] != uint64(777) || d["side"] != "B" { + t.Errorf("unexpected diff fields: %v", d) + } +} + +// Test TP/SL updates conversion. +func TestGRPCTpslUpdatesUpdateToMap(t *testing.T) { + update := &pb.TpslUpdatesUpdate{ + Time: 4000, + Height: 300, + Diffs: []*pb.TpslOrderDiff{ + { + DiffType: pb.TpslDiffType_TPSL_DIFF_TYPE_REMOVE, + Oid: 888, + Coin: "ETH", + User: "0xuser", + Side: "A", + TriggerPx: "3900", + Reason: "filled", + }, + }, + } + + m := tpslUpdatesUpdateToMap(update) + diffs := m["diffs"].([]map[string]any) + d := diffs[0] + if d["diff_type"] != "TPSL_DIFF_TYPE_REMOVE" || d["oid"] != uint64(888) || d["reason"] != "filled" { + t.Errorf("unexpected diff fields: %v", d) + } +} + +// Test packed conversions preserve uint64 fixed-point values (scaled by 1e8). +func TestGRPCPackedUpdateToMap(t *testing.T) { + update := &pb.L2BookPackedUpdate{ + Coin: "BTC", + Time: 5000, + BlockNumber: 400, + Bids: []*pb.L2LevelPacked{{Px: 6500000000000, Sz: 150000000, N: 2}}, + } + + m := l2BookPackedUpdateToMap(update) + bids := m["bids"].([][]any) + if bids[0][0] != uint64(6500000000000) || bids[0][1] != uint64(150000000) || bids[0][2] != uint32(2) { + t.Errorf("bids = %v, want fixed-point uint64 values", bids) + } + + bbo := bboBookPackedUpdateToMap(&pb.BboBookPackedUpdate{ + Coin: "BTC", + Ask: &pb.L2LevelPacked{Px: 6500100000000, Sz: 200000000, N: 1}, + }) + if bbo["bid"] != nil { + t.Errorf("bid = %v, want nil", bbo["bid"]) + } + ask := bbo["ask"].([]any) + if ask[0] != uint64(6500100000000) { + t.Errorf("ask = %v, want fixed-point px", ask) + } +} + +// Test L4 bytes conversion: snapshot mirrors L4Book, diff keeps raw JSON bytes. +func TestGRPCL4BookBytesUpdateToMap(t *testing.T) { + snapshot := &pb.L4BookBytesUpdate{ + Update: &pb.L4BookBytesUpdate_Snapshot{ + Snapshot: &pb.L4BookSnapshot{ + Coin: "BTC", + Time: 6000, + Height: 500, + Bids: []*pb.L4Order{{User: "0xuser", Coin: "BTC", Side: "B", LimitPx: "65000", Sz: "1", Oid: 1}}, + }, + }, + } + + m := l4BookBytesUpdateToMap(snapshot) + if m["type"] != "snapshot" || m["coin"] != "BTC" { + t.Errorf("unexpected snapshot map: %v", m) + } + bids := m["bids"].([]map[string]any) + if bids[0]["oid"] != uint64(1) { + t.Errorf("bids = %v", bids) + } + + raw := []byte(`{"order_statuses":[],"book_diffs":[]}`) + diff := &pb.L4BookBytesUpdate{ + Update: &pb.L4BookBytesUpdate_Diff{ + Diff: &pb.L4BookBytesDiff{Time: 6001, Height: 501, Data: raw}, + }, + } + + m = l4BookBytesUpdateToMap(diff) + if m["type"] != "diff" || m["height"] != uint64(501) { + t.Errorf("unexpected diff map: %v", m) + } + data, ok := m["data"].([]byte) + if !ok || string(data) != string(raw) { + t.Errorf("data = %v, want undecoded JSON bytes", m["data"]) + } +} diff --git a/go/hyperliquid/order.go b/go/hyperliquid/order.go index fbdc1b8..e0fd3ae 100644 --- a/go/hyperliquid/order.go +++ b/go/hyperliquid/order.go @@ -17,16 +17,18 @@ import ( // // Post-only with reduce_only // Order().Buy("BTC").Size(0.01).Price(65000).ALO().ReduceOnly() type OrderBuilder struct { - asset string - side Side - size string - price string - tif TIF - reduceOnly bool - notional float64 - cloid string - priorityFee *uint64 - slippage *float64 + asset string + side Side + size string + price string + tif TIF + reduceOnly bool + notional float64 + cloid string + priorityFee *uint64 + slippage *float64 + vaultAddress string + expiresAfter *int64 } // Order creates a new order builder. @@ -144,6 +146,23 @@ func (o *OrderBuilder) Slippage(slippage float64) *OrderBuilder { return o } +// VaultAddress places the order on behalf of a vault (or subaccount). +// It is sent as the top-level vaultAddress exchange field and folded into +// the signed action hash by the build endpoint. Never emitted when unset. +func (o *OrderBuilder) VaultAddress(vaultAddress string) *OrderBuilder { + o.vaultAddress = vaultAddress + return o +} + +// ExpiresAfter sets the action TTL as a millisecond timestamp after which +// the exchange rejects the order. It is sent as the top-level expiresAfter +// exchange field and folded into the signed action hash by the build +// endpoint. Never emitted when unset. +func (o *OrderBuilder) ExpiresAfter(expiresAfterMs int64) *OrderBuilder { + o.expiresAfter = &expiresAfterMs + return o +} + // Asset returns the order's asset. func (o *OrderBuilder) Asset() string { return o.asset @@ -189,6 +208,16 @@ func (o *OrderBuilder) GetSlippage() *float64 { return o.slippage } +// GetVaultAddress returns the vault address if set, else empty string. +func (o *OrderBuilder) GetVaultAddress() string { + return o.vaultAddress +} + +// GetExpiresAfter returns the action TTL (ms timestamp) if set, else nil. +func (o *OrderBuilder) GetExpiresAfter() *int64 { + return o.expiresAfter +} + // SetSize sets the computed size (used internally for notional orders). func (o *OrderBuilder) SetSize(size string) { o.size = size @@ -286,5 +315,11 @@ func (o *OrderBuilder) String() string { if o.slippage != nil { s += fmt.Sprintf(".Slippage(%g)", *o.slippage) } + if o.vaultAddress != "" { + s += fmt.Sprintf(".VaultAddress(%q)", o.vaultAddress) + } + if o.expiresAfter != nil { + s += fmt.Sprintf(".ExpiresAfter(%d)", *o.expiresAfter) + } return s } diff --git a/go/hyperliquid/proto/orderbook.pb.go b/go/hyperliquid/proto/orderbook.pb.go index 219b36a..f14a6a0 100644 --- a/go/hyperliquid/proto/orderbook.pb.go +++ b/go/hyperliquid/proto/orderbook.pb.go @@ -8,7 +8,7 @@ // Code generated by protoc-gen-go. DO NOT EDIT. // versions: // protoc-gen-go v1.36.8 -// protoc v6.32.1 +// protoc v4.25.3 // source: orderbook.proto package proto @@ -28,13 +28,114 @@ const ( _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) ) +type L4OrderDiffType int32 + +const ( + L4OrderDiffType_L4_ORDER_DIFF_TYPE_UNSPECIFIED L4OrderDiffType = 0 + L4OrderDiffType_L4_ORDER_DIFF_TYPE_NEW L4OrderDiffType = 1 + L4OrderDiffType_L4_ORDER_DIFF_TYPE_UPDATE L4OrderDiffType = 2 + L4OrderDiffType_L4_ORDER_DIFF_TYPE_REMOVE L4OrderDiffType = 3 +) + +// Enum value maps for L4OrderDiffType. +var ( + L4OrderDiffType_name = map[int32]string{ + 0: "L4_ORDER_DIFF_TYPE_UNSPECIFIED", + 1: "L4_ORDER_DIFF_TYPE_NEW", + 2: "L4_ORDER_DIFF_TYPE_UPDATE", + 3: "L4_ORDER_DIFF_TYPE_REMOVE", + } + L4OrderDiffType_value = map[string]int32{ + "L4_ORDER_DIFF_TYPE_UNSPECIFIED": 0, + "L4_ORDER_DIFF_TYPE_NEW": 1, + "L4_ORDER_DIFF_TYPE_UPDATE": 2, + "L4_ORDER_DIFF_TYPE_REMOVE": 3, + } +) + +func (x L4OrderDiffType) Enum() *L4OrderDiffType { + p := new(L4OrderDiffType) + *p = x + return p +} + +func (x L4OrderDiffType) String() string { + return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x)) +} + +func (L4OrderDiffType) Descriptor() protoreflect.EnumDescriptor { + return file_orderbook_proto_enumTypes[0].Descriptor() +} + +func (L4OrderDiffType) Type() protoreflect.EnumType { + return &file_orderbook_proto_enumTypes[0] +} + +func (x L4OrderDiffType) Number() protoreflect.EnumNumber { + return protoreflect.EnumNumber(x) +} + +// Deprecated: Use L4OrderDiffType.Descriptor instead. +func (L4OrderDiffType) EnumDescriptor() ([]byte, []int) { + return file_orderbook_proto_rawDescGZIP(), []int{0} +} + +type TpslDiffType int32 + +const ( + TpslDiffType_TPSL_DIFF_TYPE_UNSPECIFIED TpslDiffType = 0 + TpslDiffType_TPSL_DIFF_TYPE_ADD TpslDiffType = 1 + TpslDiffType_TPSL_DIFF_TYPE_REMOVE TpslDiffType = 2 +) + +// Enum value maps for TpslDiffType. +var ( + TpslDiffType_name = map[int32]string{ + 0: "TPSL_DIFF_TYPE_UNSPECIFIED", + 1: "TPSL_DIFF_TYPE_ADD", + 2: "TPSL_DIFF_TYPE_REMOVE", + } + TpslDiffType_value = map[string]int32{ + "TPSL_DIFF_TYPE_UNSPECIFIED": 0, + "TPSL_DIFF_TYPE_ADD": 1, + "TPSL_DIFF_TYPE_REMOVE": 2, + } +) + +func (x TpslDiffType) Enum() *TpslDiffType { + p := new(TpslDiffType) + *p = x + return p +} + +func (x TpslDiffType) String() string { + return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x)) +} + +func (TpslDiffType) Descriptor() protoreflect.EnumDescriptor { + return file_orderbook_proto_enumTypes[1].Descriptor() +} + +func (TpslDiffType) Type() protoreflect.EnumType { + return &file_orderbook_proto_enumTypes[1] +} + +func (x TpslDiffType) Number() protoreflect.EnumNumber { + return protoreflect.EnumNumber(x) +} + +// Deprecated: Use TpslDiffType.Descriptor instead. +func (TpslDiffType) EnumDescriptor() ([]byte, []int) { + return file_orderbook_proto_rawDescGZIP(), []int{1} +} + // Request parameters for L2 book streaming. type L2BookRequest struct { state protoimpl.MessageState `protogen:"open.v1"` - Coin string `protobuf:"bytes,1,opt,name=coin,proto3" json:"coin,omitempty"` - NLevels uint32 `protobuf:"varint,2,opt,name=n_levels,json=nLevels,proto3" json:"n_levels,omitempty"` - NSigFigs *uint32 `protobuf:"varint,3,opt,name=n_sig_figs,json=nSigFigs,proto3,oneof" json:"n_sig_figs,omitempty"` - Mantissa *uint64 `protobuf:"varint,4,opt,name=mantissa,proto3,oneof" json:"mantissa,omitempty"` + Coin string `protobuf:"bytes,1,opt,name=coin,proto3" json:"coin,omitempty"` // Symbol (e.g. "BTC", "ETH") + NLevels uint32 `protobuf:"varint,2,opt,name=n_levels,json=nLevels,proto3" json:"n_levels,omitempty"` // Max number of price levels (default 20, max 100) + NSigFigs *uint32 `protobuf:"varint,3,opt,name=n_sig_figs,json=nSigFigs,proto3,oneof" json:"n_sig_figs,omitempty"` // Significance figures for price bucketing (2-5) + Mantissa *uint64 `protobuf:"varint,4,opt,name=mantissa,proto3,oneof" json:"mantissa,omitempty"` // Mantissa for bucketing (1, 2, or 5) unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -101,10 +202,10 @@ func (x *L2BookRequest) GetMantissa() uint64 { type L2BookUpdate struct { state protoimpl.MessageState `protogen:"open.v1"` Coin string `protobuf:"bytes,1,opt,name=coin,proto3" json:"coin,omitempty"` - Time uint64 `protobuf:"varint,2,opt,name=time,proto3" json:"time,omitempty"` + Time uint64 `protobuf:"varint,2,opt,name=time,proto3" json:"time,omitempty"` // Block timestamp (milliseconds) BlockNumber uint64 `protobuf:"varint,3,opt,name=block_number,json=blockNumber,proto3" json:"block_number,omitempty"` - Bids []*L2Level `protobuf:"bytes,4,rep,name=bids,proto3" json:"bids,omitempty"` - Asks []*L2Level `protobuf:"bytes,5,rep,name=asks,proto3" json:"asks,omitempty"` + Bids []*L2Level `protobuf:"bytes,4,rep,name=bids,proto3" json:"bids,omitempty"` // Aggregated bid levels (best first) + Asks []*L2Level `protobuf:"bytes,5,rep,name=asks,proto3" json:"asks,omitempty"` // Aggregated ask levels (best first) unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -177,9 +278,9 @@ func (x *L2BookUpdate) GetAsks() []*L2Level { // A single aggregated price level. type L2Level struct { state protoimpl.MessageState `protogen:"open.v1"` - Px string `protobuf:"bytes,1,opt,name=px,proto3" json:"px,omitempty"` - Sz string `protobuf:"bytes,2,opt,name=sz,proto3" json:"sz,omitempty"` - N uint32 `protobuf:"varint,3,opt,name=n,proto3" json:"n,omitempty"` + Px string `protobuf:"bytes,1,opt,name=px,proto3" json:"px,omitempty"` // Price as decimal string + Sz string `protobuf:"bytes,2,opt,name=sz,proto3" json:"sz,omitempty"` // Total size as decimal string + N uint32 `protobuf:"varint,3,opt,name=n,proto3" json:"n,omitempty"` // Number of individual orders at this level unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -235,28 +336,29 @@ func (x *L2Level) GetN() uint32 { return 0 } -// Request parameters for L4 book streaming. -type L4BookRequest struct { +type L2LevelPacked struct { state protoimpl.MessageState `protogen:"open.v1"` - Coin string `protobuf:"bytes,1,opt,name=coin,proto3" json:"coin,omitempty"` + Px uint64 `protobuf:"fixed64,1,opt,name=px,proto3" json:"px,omitempty"` // Price scaled by 1e8 + Sz uint64 `protobuf:"fixed64,2,opt,name=sz,proto3" json:"sz,omitempty"` // Size scaled by 1e8 + N uint32 `protobuf:"varint,3,opt,name=n,proto3" json:"n,omitempty"` // Number of individual orders at this level unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } -func (x *L4BookRequest) Reset() { - *x = L4BookRequest{} +func (x *L2LevelPacked) Reset() { + *x = L2LevelPacked{} mi := &file_orderbook_proto_msgTypes[3] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } -func (x *L4BookRequest) String() string { +func (x *L2LevelPacked) String() string { return protoimpl.X.MessageStringOf(x) } -func (*L4BookRequest) ProtoMessage() {} +func (*L2LevelPacked) ProtoMessage() {} -func (x *L4BookRequest) ProtoReflect() protoreflect.Message { +func (x *L2LevelPacked) ProtoReflect() protoreflect.Message { mi := &file_orderbook_proto_msgTypes[3] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) @@ -268,44 +370,57 @@ func (x *L4BookRequest) ProtoReflect() protoreflect.Message { return mi.MessageOf(x) } -// Deprecated: Use L4BookRequest.ProtoReflect.Descriptor instead. -func (*L4BookRequest) Descriptor() ([]byte, []int) { +// Deprecated: Use L2LevelPacked.ProtoReflect.Descriptor instead. +func (*L2LevelPacked) Descriptor() ([]byte, []int) { return file_orderbook_proto_rawDescGZIP(), []int{3} } -func (x *L4BookRequest) GetCoin() string { +func (x *L2LevelPacked) GetPx() uint64 { if x != nil { - return x.Coin + return x.Px } - return "" + return 0 } -// An L4 book update: either a full snapshot or an incremental diff. -type L4BookUpdate struct { - state protoimpl.MessageState `protogen:"open.v1"` - // Types that are valid to be assigned to Update: - // - // *L4BookUpdate_Snapshot - // *L4BookUpdate_Diff - Update isL4BookUpdate_Update `protobuf_oneof:"update"` +func (x *L2LevelPacked) GetSz() uint64 { + if x != nil { + return x.Sz + } + return 0 +} + +func (x *L2LevelPacked) GetN() uint32 { + if x != nil { + return x.N + } + return 0 +} + +type L2BookPackedUpdate struct { + state protoimpl.MessageState `protogen:"open.v1"` + Coin string `protobuf:"bytes,1,opt,name=coin,proto3" json:"coin,omitempty"` + Time uint64 `protobuf:"varint,2,opt,name=time,proto3" json:"time,omitempty"` + BlockNumber uint64 `protobuf:"varint,3,opt,name=block_number,json=blockNumber,proto3" json:"block_number,omitempty"` + Bids []*L2LevelPacked `protobuf:"bytes,4,rep,name=bids,proto3" json:"bids,omitempty"` + Asks []*L2LevelPacked `protobuf:"bytes,5,rep,name=asks,proto3" json:"asks,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } -func (x *L4BookUpdate) Reset() { - *x = L4BookUpdate{} +func (x *L2BookPackedUpdate) Reset() { + *x = L2BookPackedUpdate{} mi := &file_orderbook_proto_msgTypes[4] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } -func (x *L4BookUpdate) String() string { +func (x *L2BookPackedUpdate) String() string { return protoimpl.X.MessageStringOf(x) } -func (*L4BookUpdate) ProtoMessage() {} +func (*L2BookPackedUpdate) ProtoMessage() {} -func (x *L4BookUpdate) ProtoReflect() protoreflect.Message { +func (x *L2BookPackedUpdate) ProtoReflect() protoreflect.Message { mi := &file_orderbook_proto_msgTypes[4] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) @@ -317,79 +432,118 @@ func (x *L4BookUpdate) ProtoReflect() protoreflect.Message { return mi.MessageOf(x) } -// Deprecated: Use L4BookUpdate.ProtoReflect.Descriptor instead. -func (*L4BookUpdate) Descriptor() ([]byte, []int) { +// Deprecated: Use L2BookPackedUpdate.ProtoReflect.Descriptor instead. +func (*L2BookPackedUpdate) Descriptor() ([]byte, []int) { return file_orderbook_proto_rawDescGZIP(), []int{4} } -func (x *L4BookUpdate) GetUpdate() isL4BookUpdate_Update { +func (x *L2BookPackedUpdate) GetCoin() string { if x != nil { - return x.Update + return x.Coin } - return nil + return "" } -func (x *L4BookUpdate) GetSnapshot() *L4BookSnapshot { +func (x *L2BookPackedUpdate) GetTime() uint64 { if x != nil { - if x, ok := x.Update.(*L4BookUpdate_Snapshot); ok { - return x.Snapshot - } + return x.Time + } + return 0 +} + +func (x *L2BookPackedUpdate) GetBlockNumber() uint64 { + if x != nil { + return x.BlockNumber + } + return 0 +} + +func (x *L2BookPackedUpdate) GetBids() []*L2LevelPacked { + if x != nil { + return x.Bids } return nil } -func (x *L4BookUpdate) GetDiff() *L4BookDiff { +func (x *L2BookPackedUpdate) GetAsks() []*L2LevelPacked { if x != nil { - if x, ok := x.Update.(*L4BookUpdate_Diff); ok { - return x.Diff - } + return x.Asks } return nil } -type isL4BookUpdate_Update interface { - isL4BookUpdate_Update() +// Request parameters for best bid/offer streaming. +type BboBookRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + Coins []string `protobuf:"bytes,1,rep,name=coins,proto3" json:"coins,omitempty"` // Empty means all coins + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } -type L4BookUpdate_Snapshot struct { - Snapshot *L4BookSnapshot `protobuf:"bytes,1,opt,name=snapshot,proto3,oneof"` +func (x *BboBookRequest) Reset() { + *x = BboBookRequest{} + mi := &file_orderbook_proto_msgTypes[5] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) } -type L4BookUpdate_Diff struct { - Diff *L4BookDiff `protobuf:"bytes,2,opt,name=diff,proto3,oneof"` +func (x *BboBookRequest) String() string { + return protoimpl.X.MessageStringOf(x) } -func (*L4BookUpdate_Snapshot) isL4BookUpdate_Update() {} +func (*BboBookRequest) ProtoMessage() {} -func (*L4BookUpdate_Diff) isL4BookUpdate_Update() {} +func (x *BboBookRequest) ProtoReflect() protoreflect.Message { + mi := &file_orderbook_proto_msgTypes[5] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} -// Full L4 order book snapshot for a single coin. -type L4BookSnapshot struct { +// Deprecated: Use BboBookRequest.ProtoReflect.Descriptor instead. +func (*BboBookRequest) Descriptor() ([]byte, []int) { + return file_orderbook_proto_rawDescGZIP(), []int{5} +} + +func (x *BboBookRequest) GetCoins() []string { + if x != nil { + return x.Coins + } + return nil +} + +// Best bid/offer update for a coin. +type BboBookUpdate struct { state protoimpl.MessageState `protogen:"open.v1"` Coin string `protobuf:"bytes,1,opt,name=coin,proto3" json:"coin,omitempty"` - Time uint64 `protobuf:"varint,2,opt,name=time,proto3" json:"time,omitempty"` - Height uint64 `protobuf:"varint,3,opt,name=height,proto3" json:"height,omitempty"` - Bids []*L4Order `protobuf:"bytes,4,rep,name=bids,proto3" json:"bids,omitempty"` - Asks []*L4Order `protobuf:"bytes,5,rep,name=asks,proto3" json:"asks,omitempty"` + Time uint64 `protobuf:"varint,2,opt,name=time,proto3" json:"time,omitempty"` // Block timestamp (milliseconds) + BlockNumber uint64 `protobuf:"varint,3,opt,name=block_number,json=blockNumber,proto3" json:"block_number,omitempty"` + Bid *L2Level `protobuf:"bytes,4,opt,name=bid,proto3" json:"bid,omitempty"` // Absent if no bid + Ask *L2Level `protobuf:"bytes,5,opt,name=ask,proto3" json:"ask,omitempty"` // Absent if no ask unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } -func (x *L4BookSnapshot) Reset() { - *x = L4BookSnapshot{} - mi := &file_orderbook_proto_msgTypes[5] +func (x *BboBookUpdate) Reset() { + *x = BboBookUpdate{} + mi := &file_orderbook_proto_msgTypes[6] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } -func (x *L4BookSnapshot) String() string { +func (x *BboBookUpdate) String() string { return protoimpl.X.MessageStringOf(x) } -func (*L4BookSnapshot) ProtoMessage() {} +func (*BboBookUpdate) ProtoMessage() {} -func (x *L4BookSnapshot) ProtoReflect() protoreflect.Message { - mi := &file_orderbook_proto_msgTypes[5] +func (x *BboBookUpdate) ProtoReflect() protoreflect.Message { + mi := &file_orderbook_proto_msgTypes[6] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -400,71 +554,72 @@ func (x *L4BookSnapshot) ProtoReflect() protoreflect.Message { return mi.MessageOf(x) } -// Deprecated: Use L4BookSnapshot.ProtoReflect.Descriptor instead. -func (*L4BookSnapshot) Descriptor() ([]byte, []int) { - return file_orderbook_proto_rawDescGZIP(), []int{5} +// Deprecated: Use BboBookUpdate.ProtoReflect.Descriptor instead. +func (*BboBookUpdate) Descriptor() ([]byte, []int) { + return file_orderbook_proto_rawDescGZIP(), []int{6} } -func (x *L4BookSnapshot) GetCoin() string { +func (x *BboBookUpdate) GetCoin() string { if x != nil { return x.Coin } return "" } -func (x *L4BookSnapshot) GetTime() uint64 { +func (x *BboBookUpdate) GetTime() uint64 { if x != nil { return x.Time } return 0 } -func (x *L4BookSnapshot) GetHeight() uint64 { +func (x *BboBookUpdate) GetBlockNumber() uint64 { if x != nil { - return x.Height + return x.BlockNumber } return 0 } -func (x *L4BookSnapshot) GetBids() []*L4Order { +func (x *BboBookUpdate) GetBid() *L2Level { if x != nil { - return x.Bids + return x.Bid } return nil } -func (x *L4BookSnapshot) GetAsks() []*L4Order { +func (x *BboBookUpdate) GetAsk() *L2Level { if x != nil { - return x.Asks + return x.Ask } return nil } -// Incremental L4 book diff: raw order statuses and book diffs for one block. -type L4BookDiff struct { +type BboBookPackedUpdate struct { state protoimpl.MessageState `protogen:"open.v1"` - Time uint64 `protobuf:"varint,1,opt,name=time,proto3" json:"time,omitempty"` - Height uint64 `protobuf:"varint,2,opt,name=height,proto3" json:"height,omitempty"` - Data string `protobuf:"bytes,3,opt,name=data,proto3" json:"data,omitempty"` + Coin string `protobuf:"bytes,1,opt,name=coin,proto3" json:"coin,omitempty"` + Time uint64 `protobuf:"varint,2,opt,name=time,proto3" json:"time,omitempty"` + BlockNumber uint64 `protobuf:"varint,3,opt,name=block_number,json=blockNumber,proto3" json:"block_number,omitempty"` + Bid *L2LevelPacked `protobuf:"bytes,4,opt,name=bid,proto3" json:"bid,omitempty"` + Ask *L2LevelPacked `protobuf:"bytes,5,opt,name=ask,proto3" json:"ask,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } -func (x *L4BookDiff) Reset() { - *x = L4BookDiff{} - mi := &file_orderbook_proto_msgTypes[6] +func (x *BboBookPackedUpdate) Reset() { + *x = BboBookPackedUpdate{} + mi := &file_orderbook_proto_msgTypes[7] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } -func (x *L4BookDiff) String() string { +func (x *BboBookPackedUpdate) String() string { return protoimpl.X.MessageStringOf(x) } -func (*L4BookDiff) ProtoMessage() {} +func (*BboBookPackedUpdate) ProtoMessage() {} -func (x *L4BookDiff) ProtoReflect() protoreflect.Message { - mi := &file_orderbook_proto_msgTypes[6] +func (x *BboBookPackedUpdate) ProtoReflect() protoreflect.Message { + mi := &file_orderbook_proto_msgTypes[7] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -475,69 +630,73 @@ func (x *L4BookDiff) ProtoReflect() protoreflect.Message { return mi.MessageOf(x) } -// Deprecated: Use L4BookDiff.ProtoReflect.Descriptor instead. -func (*L4BookDiff) Descriptor() ([]byte, []int) { - return file_orderbook_proto_rawDescGZIP(), []int{6} +// Deprecated: Use BboBookPackedUpdate.ProtoReflect.Descriptor instead. +func (*BboBookPackedUpdate) Descriptor() ([]byte, []int) { + return file_orderbook_proto_rawDescGZIP(), []int{7} } -func (x *L4BookDiff) GetTime() uint64 { +func (x *BboBookPackedUpdate) GetCoin() string { + if x != nil { + return x.Coin + } + return "" +} + +func (x *BboBookPackedUpdate) GetTime() uint64 { if x != nil { return x.Time } return 0 } -func (x *L4BookDiff) GetHeight() uint64 { +func (x *BboBookPackedUpdate) GetBlockNumber() uint64 { if x != nil { - return x.Height + return x.BlockNumber } return 0 } -func (x *L4BookDiff) GetData() string { +func (x *BboBookPackedUpdate) GetBid() *L2LevelPacked { if x != nil { - return x.Data + return x.Bid } - return "" + return nil } -// A single L4 order with full details. -type L4Order struct { - state protoimpl.MessageState `protogen:"open.v1"` - User string `protobuf:"bytes,1,opt,name=user,proto3" json:"user,omitempty"` - Coin string `protobuf:"bytes,2,opt,name=coin,proto3" json:"coin,omitempty"` - Side string `protobuf:"bytes,3,opt,name=side,proto3" json:"side,omitempty"` - LimitPx string `protobuf:"bytes,4,opt,name=limit_px,json=limitPx,proto3" json:"limit_px,omitempty"` - Sz string `protobuf:"bytes,5,opt,name=sz,proto3" json:"sz,omitempty"` - Oid uint64 `protobuf:"varint,6,opt,name=oid,proto3" json:"oid,omitempty"` - Timestamp uint64 `protobuf:"varint,7,opt,name=timestamp,proto3" json:"timestamp,omitempty"` - TriggerCondition string `protobuf:"bytes,8,opt,name=trigger_condition,json=triggerCondition,proto3" json:"trigger_condition,omitempty"` - IsTrigger bool `protobuf:"varint,9,opt,name=is_trigger,json=isTrigger,proto3" json:"is_trigger,omitempty"` - TriggerPx string `protobuf:"bytes,10,opt,name=trigger_px,json=triggerPx,proto3" json:"trigger_px,omitempty"` - IsPositionTpsl bool `protobuf:"varint,11,opt,name=is_position_tpsl,json=isPositionTpsl,proto3" json:"is_position_tpsl,omitempty"` - ReduceOnly bool `protobuf:"varint,12,opt,name=reduce_only,json=reduceOnly,proto3" json:"reduce_only,omitempty"` - OrderType string `protobuf:"bytes,13,opt,name=order_type,json=orderType,proto3" json:"order_type,omitempty"` - Tif *string `protobuf:"bytes,14,opt,name=tif,proto3,oneof" json:"tif,omitempty"` - Cloid *string `protobuf:"bytes,15,opt,name=cloid,proto3,oneof" json:"cloid,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache +func (x *BboBookPackedUpdate) GetAsk() *L2LevelPacked { + if x != nil { + return x.Ask + } + return nil } -func (x *L4Order) Reset() { - *x = L4Order{} - mi := &file_orderbook_proto_msgTypes[7] +// Request parameters for L2 diff streaming. +type L2BookDiffRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + Coins []string `protobuf:"bytes,1,rep,name=coins,proto3" json:"coins,omitempty"` // Empty means all coins + NLevels uint32 `protobuf:"varint,2,opt,name=n_levels,json=nLevels,proto3" json:"n_levels,omitempty"` // Max tracked levels per side (default 20, max 100) + NSigFigs *uint32 `protobuf:"varint,3,opt,name=n_sig_figs,json=nSigFigs,proto3,oneof" json:"n_sig_figs,omitempty"` // Significance figures for price bucketing (2-5) + Mantissa *uint64 `protobuf:"varint,4,opt,name=mantissa,proto3,oneof" json:"mantissa,omitempty"` // Mantissa for bucketing (1, 2, or 5) + SkipInitialSnapshot bool `protobuf:"varint,5,opt,name=skip_initial_snapshot,json=skipInitialSnapshot,proto3" json:"skip_initial_snapshot,omitempty"` // If false, first update per coin contains current levels + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *L2BookDiffRequest) Reset() { + *x = L2BookDiffRequest{} + mi := &file_orderbook_proto_msgTypes[8] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } -func (x *L4Order) String() string { +func (x *L2BookDiffRequest) String() string { return protoimpl.X.MessageStringOf(x) } -func (*L4Order) ProtoMessage() {} +func (*L2BookDiffRequest) ProtoMessage() {} -func (x *L4Order) ProtoReflect() protoreflect.Message { - mi := &file_orderbook_proto_msgTypes[7] +func (x *L2BookDiffRequest) ProtoReflect() protoreflect.Message { + mi := &file_orderbook_proto_msgTypes[8] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -548,34 +707,891 @@ func (x *L4Order) ProtoReflect() protoreflect.Message { return mi.MessageOf(x) } -// Deprecated: Use L4Order.ProtoReflect.Descriptor instead. -func (*L4Order) Descriptor() ([]byte, []int) { - return file_orderbook_proto_rawDescGZIP(), []int{7} +// Deprecated: Use L2BookDiffRequest.ProtoReflect.Descriptor instead. +func (*L2BookDiffRequest) Descriptor() ([]byte, []int) { + return file_orderbook_proto_rawDescGZIP(), []int{8} } -func (x *L4Order) GetUser() string { +func (x *L2BookDiffRequest) GetCoins() []string { if x != nil { - return x.User + return x.Coins } - return "" + return nil } -func (x *L4Order) GetCoin() string { +func (x *L2BookDiffRequest) GetNLevels() uint32 { if x != nil { - return x.Coin + return x.NLevels } - return "" + return 0 } -func (x *L4Order) GetSide() string { - if x != nil { - return x.Side +func (x *L2BookDiffRequest) GetNSigFigs() uint32 { + if x != nil && x.NSigFigs != nil { + return *x.NSigFigs } - return "" + return 0 } -func (x *L4Order) GetLimitPx() string { - if x != nil { +func (x *L2BookDiffRequest) GetMantissa() uint64 { + if x != nil && x.Mantissa != nil { + return *x.Mantissa + } + return 0 +} + +func (x *L2BookDiffRequest) GetSkipInitialSnapshot() bool { + if x != nil { + return x.SkipInitialSnapshot + } + return false +} + +// Batch of L2 price-level changes for one processed block. +type L2BookDiffUpdate struct { + state protoimpl.MessageState `protogen:"open.v1"` + Time uint64 `protobuf:"varint,1,opt,name=time,proto3" json:"time,omitempty"` // Block timestamp (milliseconds) + Height uint64 `protobuf:"varint,2,opt,name=height,proto3" json:"height,omitempty"` // Block height + Snapshot bool `protobuf:"varint,3,opt,name=snapshot,proto3" json:"snapshot,omitempty"` // True when carrying initial levels for any coin + Diffs []*L2CoinDiff `protobuf:"bytes,4,rep,name=diffs,proto3" json:"diffs,omitempty"` // Only coins with changed levels + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *L2BookDiffUpdate) Reset() { + *x = L2BookDiffUpdate{} + mi := &file_orderbook_proto_msgTypes[9] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *L2BookDiffUpdate) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*L2BookDiffUpdate) ProtoMessage() {} + +func (x *L2BookDiffUpdate) ProtoReflect() protoreflect.Message { + mi := &file_orderbook_proto_msgTypes[9] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use L2BookDiffUpdate.ProtoReflect.Descriptor instead. +func (*L2BookDiffUpdate) Descriptor() ([]byte, []int) { + return file_orderbook_proto_rawDescGZIP(), []int{9} +} + +func (x *L2BookDiffUpdate) GetTime() uint64 { + if x != nil { + return x.Time + } + return 0 +} + +func (x *L2BookDiffUpdate) GetHeight() uint64 { + if x != nil { + return x.Height + } + return 0 +} + +func (x *L2BookDiffUpdate) GetSnapshot() bool { + if x != nil { + return x.Snapshot + } + return false +} + +func (x *L2BookDiffUpdate) GetDiffs() []*L2CoinDiff { + if x != nil { + return x.Diffs + } + return nil +} + +// Incremental L2 changes for one coin. +type L2CoinDiff struct { + state protoimpl.MessageState `protogen:"open.v1"` + Coin string `protobuf:"bytes,1,opt,name=coin,proto3" json:"coin,omitempty"` + Seq uint64 `protobuf:"varint,2,opt,name=seq,proto3" json:"seq,omitempty"` // Per-coin sequence in this stream + PrevSeq uint64 `protobuf:"varint,3,opt,name=prev_seq,json=prevSeq,proto3" json:"prev_seq,omitempty"` + Bids []*L2Level `protobuf:"bytes,4,rep,name=bids,proto3" json:"bids,omitempty"` // Changed bid levels; sz=0 means removed + Asks []*L2Level `protobuf:"bytes,5,rep,name=asks,proto3" json:"asks,omitempty"` // Changed ask levels; sz=0 means removed + Snapshot bool `protobuf:"varint,6,opt,name=snapshot,proto3" json:"snapshot,omitempty"` // True when bids/asks carry initial levels + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *L2CoinDiff) Reset() { + *x = L2CoinDiff{} + mi := &file_orderbook_proto_msgTypes[10] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *L2CoinDiff) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*L2CoinDiff) ProtoMessage() {} + +func (x *L2CoinDiff) ProtoReflect() protoreflect.Message { + mi := &file_orderbook_proto_msgTypes[10] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use L2CoinDiff.ProtoReflect.Descriptor instead. +func (*L2CoinDiff) Descriptor() ([]byte, []int) { + return file_orderbook_proto_rawDescGZIP(), []int{10} +} + +func (x *L2CoinDiff) GetCoin() string { + if x != nil { + return x.Coin + } + return "" +} + +func (x *L2CoinDiff) GetSeq() uint64 { + if x != nil { + return x.Seq + } + return 0 +} + +func (x *L2CoinDiff) GetPrevSeq() uint64 { + if x != nil { + return x.PrevSeq + } + return 0 +} + +func (x *L2CoinDiff) GetBids() []*L2Level { + if x != nil { + return x.Bids + } + return nil +} + +func (x *L2CoinDiff) GetAsks() []*L2Level { + if x != nil { + return x.Asks + } + return nil +} + +func (x *L2CoinDiff) GetSnapshot() bool { + if x != nil { + return x.Snapshot + } + return false +} + +// Request parameters for L4 book streaming. +type L4BookRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + Coin string `protobuf:"bytes,1,opt,name=coin,proto3" json:"coin,omitempty"` // Symbol (e.g. "BTC", "ETH") + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *L4BookRequest) Reset() { + *x = L4BookRequest{} + mi := &file_orderbook_proto_msgTypes[11] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *L4BookRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*L4BookRequest) ProtoMessage() {} + +func (x *L4BookRequest) ProtoReflect() protoreflect.Message { + mi := &file_orderbook_proto_msgTypes[11] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use L4BookRequest.ProtoReflect.Descriptor instead. +func (*L4BookRequest) Descriptor() ([]byte, []int) { + return file_orderbook_proto_rawDescGZIP(), []int{11} +} + +func (x *L4BookRequest) GetCoin() string { + if x != nil { + return x.Coin + } + return "" +} + +// An L4 book update: either a full snapshot or an incremental diff. +type L4BookUpdate struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Types that are valid to be assigned to Update: + // + // *L4BookUpdate_Snapshot + // *L4BookUpdate_Diff + Update isL4BookUpdate_Update `protobuf_oneof:"update"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *L4BookUpdate) Reset() { + *x = L4BookUpdate{} + mi := &file_orderbook_proto_msgTypes[12] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *L4BookUpdate) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*L4BookUpdate) ProtoMessage() {} + +func (x *L4BookUpdate) ProtoReflect() protoreflect.Message { + mi := &file_orderbook_proto_msgTypes[12] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use L4BookUpdate.ProtoReflect.Descriptor instead. +func (*L4BookUpdate) Descriptor() ([]byte, []int) { + return file_orderbook_proto_rawDescGZIP(), []int{12} +} + +func (x *L4BookUpdate) GetUpdate() isL4BookUpdate_Update { + if x != nil { + return x.Update + } + return nil +} + +func (x *L4BookUpdate) GetSnapshot() *L4BookSnapshot { + if x != nil { + if x, ok := x.Update.(*L4BookUpdate_Snapshot); ok { + return x.Snapshot + } + } + return nil +} + +func (x *L4BookUpdate) GetDiff() *L4BookDiff { + if x != nil { + if x, ok := x.Update.(*L4BookUpdate_Diff); ok { + return x.Diff + } + } + return nil +} + +type isL4BookUpdate_Update interface { + isL4BookUpdate_Update() +} + +type L4BookUpdate_Snapshot struct { + Snapshot *L4BookSnapshot `protobuf:"bytes,1,opt,name=snapshot,proto3,oneof"` // Full snapshot (sent on subscribe) +} + +type L4BookUpdate_Diff struct { + Diff *L4BookDiff `protobuf:"bytes,2,opt,name=diff,proto3,oneof"` // Incremental diff (sent per block) +} + +func (*L4BookUpdate_Snapshot) isL4BookUpdate_Update() {} + +func (*L4BookUpdate_Diff) isL4BookUpdate_Update() {} + +// Full L4 order book snapshot for a single coin. +type L4BookSnapshot struct { + state protoimpl.MessageState `protogen:"open.v1"` + Coin string `protobuf:"bytes,1,opt,name=coin,proto3" json:"coin,omitempty"` + Time uint64 `protobuf:"varint,2,opt,name=time,proto3" json:"time,omitempty"` + Height uint64 `protobuf:"varint,3,opt,name=height,proto3" json:"height,omitempty"` + Bids []*L4Order `protobuf:"bytes,4,rep,name=bids,proto3" json:"bids,omitempty"` + Asks []*L4Order `protobuf:"bytes,5,rep,name=asks,proto3" json:"asks,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *L4BookSnapshot) Reset() { + *x = L4BookSnapshot{} + mi := &file_orderbook_proto_msgTypes[13] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *L4BookSnapshot) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*L4BookSnapshot) ProtoMessage() {} + +func (x *L4BookSnapshot) ProtoReflect() protoreflect.Message { + mi := &file_orderbook_proto_msgTypes[13] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use L4BookSnapshot.ProtoReflect.Descriptor instead. +func (*L4BookSnapshot) Descriptor() ([]byte, []int) { + return file_orderbook_proto_rawDescGZIP(), []int{13} +} + +func (x *L4BookSnapshot) GetCoin() string { + if x != nil { + return x.Coin + } + return "" +} + +func (x *L4BookSnapshot) GetTime() uint64 { + if x != nil { + return x.Time + } + return 0 +} + +func (x *L4BookSnapshot) GetHeight() uint64 { + if x != nil { + return x.Height + } + return 0 +} + +func (x *L4BookSnapshot) GetBids() []*L4Order { + if x != nil { + return x.Bids + } + return nil +} + +func (x *L4BookSnapshot) GetAsks() []*L4Order { + if x != nil { + return x.Asks + } + return nil +} + +// Incremental L4 book diff: raw order statuses and book diffs for one block. +// Sent as JSON to preserve the original node data format. +type L4BookDiff struct { + state protoimpl.MessageState `protogen:"open.v1"` + Time uint64 `protobuf:"varint,1,opt,name=time,proto3" json:"time,omitempty"` + Height uint64 `protobuf:"varint,2,opt,name=height,proto3" json:"height,omitempty"` + Data string `protobuf:"bytes,3,opt,name=data,proto3" json:"data,omitempty"` // JSON-encoded {order_statuses, book_diffs} + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *L4BookDiff) Reset() { + *x = L4BookDiff{} + mi := &file_orderbook_proto_msgTypes[14] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *L4BookDiff) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*L4BookDiff) ProtoMessage() {} + +func (x *L4BookDiff) ProtoReflect() protoreflect.Message { + mi := &file_orderbook_proto_msgTypes[14] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use L4BookDiff.ProtoReflect.Descriptor instead. +func (*L4BookDiff) Descriptor() ([]byte, []int) { + return file_orderbook_proto_rawDescGZIP(), []int{14} +} + +func (x *L4BookDiff) GetTime() uint64 { + if x != nil { + return x.Time + } + return 0 +} + +func (x *L4BookDiff) GetHeight() uint64 { + if x != nil { + return x.Height + } + return 0 +} + +func (x *L4BookDiff) GetData() string { + if x != nil { + return x.Data + } + return "" +} + +type L4BookBytesUpdate struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Types that are valid to be assigned to Update: + // + // *L4BookBytesUpdate_Snapshot + // *L4BookBytesUpdate_Diff + Update isL4BookBytesUpdate_Update `protobuf_oneof:"update"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *L4BookBytesUpdate) Reset() { + *x = L4BookBytesUpdate{} + mi := &file_orderbook_proto_msgTypes[15] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *L4BookBytesUpdate) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*L4BookBytesUpdate) ProtoMessage() {} + +func (x *L4BookBytesUpdate) ProtoReflect() protoreflect.Message { + mi := &file_orderbook_proto_msgTypes[15] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use L4BookBytesUpdate.ProtoReflect.Descriptor instead. +func (*L4BookBytesUpdate) Descriptor() ([]byte, []int) { + return file_orderbook_proto_rawDescGZIP(), []int{15} +} + +func (x *L4BookBytesUpdate) GetUpdate() isL4BookBytesUpdate_Update { + if x != nil { + return x.Update + } + return nil +} + +func (x *L4BookBytesUpdate) GetSnapshot() *L4BookSnapshot { + if x != nil { + if x, ok := x.Update.(*L4BookBytesUpdate_Snapshot); ok { + return x.Snapshot + } + } + return nil +} + +func (x *L4BookBytesUpdate) GetDiff() *L4BookBytesDiff { + if x != nil { + if x, ok := x.Update.(*L4BookBytesUpdate_Diff); ok { + return x.Diff + } + } + return nil +} + +type isL4BookBytesUpdate_Update interface { + isL4BookBytesUpdate_Update() +} + +type L4BookBytesUpdate_Snapshot struct { + Snapshot *L4BookSnapshot `protobuf:"bytes,1,opt,name=snapshot,proto3,oneof"` // Full snapshot (sent on subscribe) +} + +type L4BookBytesUpdate_Diff struct { + Diff *L4BookBytesDiff `protobuf:"bytes,2,opt,name=diff,proto3,oneof"` // Incremental diff bytes +} + +func (*L4BookBytesUpdate_Snapshot) isL4BookBytesUpdate_Update() {} + +func (*L4BookBytesUpdate_Diff) isL4BookBytesUpdate_Update() {} + +type L4BookBytesDiff struct { + state protoimpl.MessageState `protogen:"open.v1"` + Time uint64 `protobuf:"varint,1,opt,name=time,proto3" json:"time,omitempty"` + Height uint64 `protobuf:"varint,2,opt,name=height,proto3" json:"height,omitempty"` + Data []byte `protobuf:"bytes,3,opt,name=data,proto3" json:"data,omitempty"` // JSON-encoded {order_statuses, book_diffs} + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *L4BookBytesDiff) Reset() { + *x = L4BookBytesDiff{} + mi := &file_orderbook_proto_msgTypes[16] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *L4BookBytesDiff) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*L4BookBytesDiff) ProtoMessage() {} + +func (x *L4BookBytesDiff) ProtoReflect() protoreflect.Message { + mi := &file_orderbook_proto_msgTypes[16] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use L4BookBytesDiff.ProtoReflect.Descriptor instead. +func (*L4BookBytesDiff) Descriptor() ([]byte, []int) { + return file_orderbook_proto_rawDescGZIP(), []int{16} +} + +func (x *L4BookBytesDiff) GetTime() uint64 { + if x != nil { + return x.Time + } + return 0 +} + +func (x *L4BookBytesDiff) GetHeight() uint64 { + if x != nil { + return x.Height + } + return 0 +} + +func (x *L4BookBytesDiff) GetData() []byte { + if x != nil { + return x.Data + } + return nil +} + +// Request parameters for typed L4 order updates. +type L4BookUpdatesRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + Coins []string `protobuf:"bytes,1,rep,name=coins,proto3" json:"coins,omitempty"` // Empty means all coins + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *L4BookUpdatesRequest) Reset() { + *x = L4BookUpdatesRequest{} + mi := &file_orderbook_proto_msgTypes[17] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *L4BookUpdatesRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*L4BookUpdatesRequest) ProtoMessage() {} + +func (x *L4BookUpdatesRequest) ProtoReflect() protoreflect.Message { + mi := &file_orderbook_proto_msgTypes[17] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use L4BookUpdatesRequest.ProtoReflect.Descriptor instead. +func (*L4BookUpdatesRequest) Descriptor() ([]byte, []int) { + return file_orderbook_proto_rawDescGZIP(), []int{17} +} + +func (x *L4BookUpdatesRequest) GetCoins() []string { + if x != nil { + return x.Coins + } + return nil +} + +// Typed L4 order book update batch for one processed block. +type L4BookUpdatesUpdate struct { + state protoimpl.MessageState `protogen:"open.v1"` + Time uint64 `protobuf:"varint,1,opt,name=time,proto3" json:"time,omitempty"` + Height uint64 `protobuf:"varint,2,opt,name=height,proto3" json:"height,omitempty"` + Diffs []*L4OrderDiff `protobuf:"bytes,3,rep,name=diffs,proto3" json:"diffs,omitempty"` + Snapshot bool `protobuf:"varint,4,opt,name=snapshot,proto3" json:"snapshot,omitempty"` // True when diffs carry a full reset snapshot + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *L4BookUpdatesUpdate) Reset() { + *x = L4BookUpdatesUpdate{} + mi := &file_orderbook_proto_msgTypes[18] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *L4BookUpdatesUpdate) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*L4BookUpdatesUpdate) ProtoMessage() {} + +func (x *L4BookUpdatesUpdate) ProtoReflect() protoreflect.Message { + mi := &file_orderbook_proto_msgTypes[18] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use L4BookUpdatesUpdate.ProtoReflect.Descriptor instead. +func (*L4BookUpdatesUpdate) Descriptor() ([]byte, []int) { + return file_orderbook_proto_rawDescGZIP(), []int{18} +} + +func (x *L4BookUpdatesUpdate) GetTime() uint64 { + if x != nil { + return x.Time + } + return 0 +} + +func (x *L4BookUpdatesUpdate) GetHeight() uint64 { + if x != nil { + return x.Height + } + return 0 +} + +func (x *L4BookUpdatesUpdate) GetDiffs() []*L4OrderDiff { + if x != nil { + return x.Diffs + } + return nil +} + +func (x *L4BookUpdatesUpdate) GetSnapshot() bool { + if x != nil { + return x.Snapshot + } + return false +} + +// A single typed L4 order-level change. +type L4OrderDiff struct { + state protoimpl.MessageState `protogen:"open.v1"` + DiffType L4OrderDiffType `protobuf:"varint,1,opt,name=diff_type,json=diffType,proto3,enum=hyperliquid.L4OrderDiffType" json:"diff_type,omitempty"` + Coin string `protobuf:"bytes,2,opt,name=coin,proto3" json:"coin,omitempty"` + Oid uint64 `protobuf:"varint,3,opt,name=oid,proto3" json:"oid,omitempty"` + User string `protobuf:"bytes,4,opt,name=user,proto3" json:"user,omitempty"` + Side string `protobuf:"bytes,5,opt,name=side,proto3" json:"side,omitempty"` // "A" (Ask) or "B" (Bid) + Px string `protobuf:"bytes,6,opt,name=px,proto3" json:"px,omitempty"` + Sz string `protobuf:"bytes,7,opt,name=sz,proto3" json:"sz,omitempty"` // New/current size for new/update + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *L4OrderDiff) Reset() { + *x = L4OrderDiff{} + mi := &file_orderbook_proto_msgTypes[19] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *L4OrderDiff) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*L4OrderDiff) ProtoMessage() {} + +func (x *L4OrderDiff) ProtoReflect() protoreflect.Message { + mi := &file_orderbook_proto_msgTypes[19] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use L4OrderDiff.ProtoReflect.Descriptor instead. +func (*L4OrderDiff) Descriptor() ([]byte, []int) { + return file_orderbook_proto_rawDescGZIP(), []int{19} +} + +func (x *L4OrderDiff) GetDiffType() L4OrderDiffType { + if x != nil { + return x.DiffType + } + return L4OrderDiffType_L4_ORDER_DIFF_TYPE_UNSPECIFIED +} + +func (x *L4OrderDiff) GetCoin() string { + if x != nil { + return x.Coin + } + return "" +} + +func (x *L4OrderDiff) GetOid() uint64 { + if x != nil { + return x.Oid + } + return 0 +} + +func (x *L4OrderDiff) GetUser() string { + if x != nil { + return x.User + } + return "" +} + +func (x *L4OrderDiff) GetSide() string { + if x != nil { + return x.Side + } + return "" +} + +func (x *L4OrderDiff) GetPx() string { + if x != nil { + return x.Px + } + return "" +} + +func (x *L4OrderDiff) GetSz() string { + if x != nil { + return x.Sz + } + return "" +} + +// A single L4 order with full details. +type L4Order struct { + state protoimpl.MessageState `protogen:"open.v1"` + User string `protobuf:"bytes,1,opt,name=user,proto3" json:"user,omitempty"` // Ethereum address + Coin string `protobuf:"bytes,2,opt,name=coin,proto3" json:"coin,omitempty"` + Side string `protobuf:"bytes,3,opt,name=side,proto3" json:"side,omitempty"` // "A" (Ask) or "B" (Bid) + LimitPx string `protobuf:"bytes,4,opt,name=limit_px,json=limitPx,proto3" json:"limit_px,omitempty"` // Limit price as decimal string + Sz string `protobuf:"bytes,5,opt,name=sz,proto3" json:"sz,omitempty"` // Size as decimal string + Oid uint64 `protobuf:"varint,6,opt,name=oid,proto3" json:"oid,omitempty"` // Unique order ID + Timestamp uint64 `protobuf:"varint,7,opt,name=timestamp,proto3" json:"timestamp,omitempty"` // When order entered the book (ms) + TriggerCondition string `protobuf:"bytes,8,opt,name=trigger_condition,json=triggerCondition,proto3" json:"trigger_condition,omitempty"` // "N/A", "Triggered", etc. + IsTrigger bool `protobuf:"varint,9,opt,name=is_trigger,json=isTrigger,proto3" json:"is_trigger,omitempty"` + TriggerPx string `protobuf:"bytes,10,opt,name=trigger_px,json=triggerPx,proto3" json:"trigger_px,omitempty"` + IsPositionTpsl bool `protobuf:"varint,11,opt,name=is_position_tpsl,json=isPositionTpsl,proto3" json:"is_position_tpsl,omitempty"` + ReduceOnly bool `protobuf:"varint,12,opt,name=reduce_only,json=reduceOnly,proto3" json:"reduce_only,omitempty"` + OrderType string `protobuf:"bytes,13,opt,name=order_type,json=orderType,proto3" json:"order_type,omitempty"` // "Limit", "Market", etc. + Tif *string `protobuf:"bytes,14,opt,name=tif,proto3,oneof" json:"tif,omitempty"` // Time-in-force: "Gtc", "Ioc", "Alo" + Cloid *string `protobuf:"bytes,15,opt,name=cloid,proto3,oneof" json:"cloid,omitempty"` // Client order ID + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *L4Order) Reset() { + *x = L4Order{} + mi := &file_orderbook_proto_msgTypes[20] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *L4Order) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*L4Order) ProtoMessage() {} + +func (x *L4Order) ProtoReflect() protoreflect.Message { + mi := &file_orderbook_proto_msgTypes[20] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use L4Order.ProtoReflect.Descriptor instead. +func (*L4Order) Descriptor() ([]byte, []int) { + return file_orderbook_proto_rawDescGZIP(), []int{20} +} + +func (x *L4Order) GetUser() string { + if x != nil { + return x.User + } + return "" +} + +func (x *L4Order) GetCoin() string { + if x != nil { + return x.Coin + } + return "" +} + +func (x *L4Order) GetSide() string { + if x != nil { + return x.Side + } + return "" +} + +func (x *L4Order) GetLimitPx() string { + if x != nil { return x.LimitPx } return "" @@ -658,6 +1674,269 @@ func (x *L4Order) GetCloid() string { return "" } +// Request parameters for TP/SL trigger order updates. +type TpslUpdatesRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + Coins []string `protobuf:"bytes,1,rep,name=coins,proto3" json:"coins,omitempty"` // Empty means all perp coins + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *TpslUpdatesRequest) Reset() { + *x = TpslUpdatesRequest{} + mi := &file_orderbook_proto_msgTypes[21] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *TpslUpdatesRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*TpslUpdatesRequest) ProtoMessage() {} + +func (x *TpslUpdatesRequest) ProtoReflect() protoreflect.Message { + mi := &file_orderbook_proto_msgTypes[21] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use TpslUpdatesRequest.ProtoReflect.Descriptor instead. +func (*TpslUpdatesRequest) Descriptor() ([]byte, []int) { + return file_orderbook_proto_rawDescGZIP(), []int{21} +} + +func (x *TpslUpdatesRequest) GetCoins() []string { + if x != nil { + return x.Coins + } + return nil +} + +// TP/SL trigger order update batch for one processed block. +type TpslUpdatesUpdate struct { + state protoimpl.MessageState `protogen:"open.v1"` + Time uint64 `protobuf:"varint,1,opt,name=time,proto3" json:"time,omitempty"` + Height uint64 `protobuf:"varint,2,opt,name=height,proto3" json:"height,omitempty"` + Diffs []*TpslOrderDiff `protobuf:"bytes,3,rep,name=diffs,proto3" json:"diffs,omitempty"` + Snapshot bool `protobuf:"varint,4,opt,name=snapshot,proto3" json:"snapshot,omitempty"` // True when diffs carry currently-open trigger orders + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *TpslUpdatesUpdate) Reset() { + *x = TpslUpdatesUpdate{} + mi := &file_orderbook_proto_msgTypes[22] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *TpslUpdatesUpdate) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*TpslUpdatesUpdate) ProtoMessage() {} + +func (x *TpslUpdatesUpdate) ProtoReflect() protoreflect.Message { + mi := &file_orderbook_proto_msgTypes[22] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use TpslUpdatesUpdate.ProtoReflect.Descriptor instead. +func (*TpslUpdatesUpdate) Descriptor() ([]byte, []int) { + return file_orderbook_proto_rawDescGZIP(), []int{22} +} + +func (x *TpslUpdatesUpdate) GetTime() uint64 { + if x != nil { + return x.Time + } + return 0 +} + +func (x *TpslUpdatesUpdate) GetHeight() uint64 { + if x != nil { + return x.Height + } + return 0 +} + +func (x *TpslUpdatesUpdate) GetDiffs() []*TpslOrderDiff { + if x != nil { + return x.Diffs + } + return nil +} + +func (x *TpslUpdatesUpdate) GetSnapshot() bool { + if x != nil { + return x.Snapshot + } + return false +} + +// A trigger order add/remove event. +type TpslOrderDiff struct { + state protoimpl.MessageState `protogen:"open.v1"` + DiffType TpslDiffType `protobuf:"varint,1,opt,name=diff_type,json=diffType,proto3,enum=hyperliquid.TpslDiffType" json:"diff_type,omitempty"` + Oid uint64 `protobuf:"varint,2,opt,name=oid,proto3" json:"oid,omitempty"` + Coin string `protobuf:"bytes,3,opt,name=coin,proto3" json:"coin,omitempty"` + User string `protobuf:"bytes,4,opt,name=user,proto3" json:"user,omitempty"` + Side string `protobuf:"bytes,5,opt,name=side,proto3" json:"side,omitempty"` // "A" (Ask) or "B" (Bid) + TriggerPx string `protobuf:"bytes,6,opt,name=trigger_px,json=triggerPx,proto3" json:"trigger_px,omitempty"` + LimitPx string `protobuf:"bytes,7,opt,name=limit_px,json=limitPx,proto3" json:"limit_px,omitempty"` + Sz string `protobuf:"bytes,8,opt,name=sz,proto3" json:"sz,omitempty"` + TriggerCondition string `protobuf:"bytes,9,opt,name=trigger_condition,json=triggerCondition,proto3" json:"trigger_condition,omitempty"` + OrderType string `protobuf:"bytes,10,opt,name=order_type,json=orderType,proto3" json:"order_type,omitempty"` + IsPositionTpsl bool `protobuf:"varint,11,opt,name=is_position_tpsl,json=isPositionTpsl,proto3" json:"is_position_tpsl,omitempty"` + ReduceOnly bool `protobuf:"varint,12,opt,name=reduce_only,json=reduceOnly,proto3" json:"reduce_only,omitempty"` + Timestamp uint64 `protobuf:"varint,13,opt,name=timestamp,proto3" json:"timestamp,omitempty"` // Order creation timestamp (milliseconds) + Reason string `protobuf:"bytes,14,opt,name=reason,proto3" json:"reason,omitempty"` // Present on remove; mirrors node status + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *TpslOrderDiff) Reset() { + *x = TpslOrderDiff{} + mi := &file_orderbook_proto_msgTypes[23] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *TpslOrderDiff) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*TpslOrderDiff) ProtoMessage() {} + +func (x *TpslOrderDiff) ProtoReflect() protoreflect.Message { + mi := &file_orderbook_proto_msgTypes[23] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use TpslOrderDiff.ProtoReflect.Descriptor instead. +func (*TpslOrderDiff) Descriptor() ([]byte, []int) { + return file_orderbook_proto_rawDescGZIP(), []int{23} +} + +func (x *TpslOrderDiff) GetDiffType() TpslDiffType { + if x != nil { + return x.DiffType + } + return TpslDiffType_TPSL_DIFF_TYPE_UNSPECIFIED +} + +func (x *TpslOrderDiff) GetOid() uint64 { + if x != nil { + return x.Oid + } + return 0 +} + +func (x *TpslOrderDiff) GetCoin() string { + if x != nil { + return x.Coin + } + return "" +} + +func (x *TpslOrderDiff) GetUser() string { + if x != nil { + return x.User + } + return "" +} + +func (x *TpslOrderDiff) GetSide() string { + if x != nil { + return x.Side + } + return "" +} + +func (x *TpslOrderDiff) GetTriggerPx() string { + if x != nil { + return x.TriggerPx + } + return "" +} + +func (x *TpslOrderDiff) GetLimitPx() string { + if x != nil { + return x.LimitPx + } + return "" +} + +func (x *TpslOrderDiff) GetSz() string { + if x != nil { + return x.Sz + } + return "" +} + +func (x *TpslOrderDiff) GetTriggerCondition() string { + if x != nil { + return x.TriggerCondition + } + return "" +} + +func (x *TpslOrderDiff) GetOrderType() string { + if x != nil { + return x.OrderType + } + return "" +} + +func (x *TpslOrderDiff) GetIsPositionTpsl() bool { + if x != nil { + return x.IsPositionTpsl + } + return false +} + +func (x *TpslOrderDiff) GetReduceOnly() bool { + if x != nil { + return x.ReduceOnly + } + return false +} + +func (x *TpslOrderDiff) GetTimestamp() uint64 { + if x != nil { + return x.Timestamp + } + return 0 +} + +func (x *TpslOrderDiff) GetReason() string { + if x != nil { + return x.Reason + } + return "" +} + var File_orderbook_proto protoreflect.FileDescriptor const file_orderbook_proto_rawDesc = "" + @@ -680,7 +1959,53 @@ const file_orderbook_proto_rawDesc = "" + "\aL2Level\x12\x0e\n" + "\x02px\x18\x01 \x01(\tR\x02px\x12\x0e\n" + "\x02sz\x18\x02 \x01(\tR\x02sz\x12\f\n" + - "\x01n\x18\x03 \x01(\rR\x01n\"#\n" + + "\x01n\x18\x03 \x01(\rR\x01n\"=\n" + + "\rL2LevelPacked\x12\x0e\n" + + "\x02px\x18\x01 \x01(\x06R\x02px\x12\x0e\n" + + "\x02sz\x18\x02 \x01(\x06R\x02sz\x12\f\n" + + "\x01n\x18\x03 \x01(\rR\x01n\"\xbf\x01\n" + + "\x12L2BookPackedUpdate\x12\x12\n" + + "\x04coin\x18\x01 \x01(\tR\x04coin\x12\x12\n" + + "\x04time\x18\x02 \x01(\x04R\x04time\x12!\n" + + "\fblock_number\x18\x03 \x01(\x04R\vblockNumber\x12.\n" + + "\x04bids\x18\x04 \x03(\v2\x1a.hyperliquid.L2LevelPackedR\x04bids\x12.\n" + + "\x04asks\x18\x05 \x03(\v2\x1a.hyperliquid.L2LevelPackedR\x04asks\"&\n" + + "\x0eBboBookRequest\x12\x14\n" + + "\x05coins\x18\x01 \x03(\tR\x05coins\"\xaa\x01\n" + + "\rBboBookUpdate\x12\x12\n" + + "\x04coin\x18\x01 \x01(\tR\x04coin\x12\x12\n" + + "\x04time\x18\x02 \x01(\x04R\x04time\x12!\n" + + "\fblock_number\x18\x03 \x01(\x04R\vblockNumber\x12&\n" + + "\x03bid\x18\x04 \x01(\v2\x14.hyperliquid.L2LevelR\x03bid\x12&\n" + + "\x03ask\x18\x05 \x01(\v2\x14.hyperliquid.L2LevelR\x03ask\"\xbc\x01\n" + + "\x13BboBookPackedUpdate\x12\x12\n" + + "\x04coin\x18\x01 \x01(\tR\x04coin\x12\x12\n" + + "\x04time\x18\x02 \x01(\x04R\x04time\x12!\n" + + "\fblock_number\x18\x03 \x01(\x04R\vblockNumber\x12,\n" + + "\x03bid\x18\x04 \x01(\v2\x1a.hyperliquid.L2LevelPackedR\x03bid\x12,\n" + + "\x03ask\x18\x05 \x01(\v2\x1a.hyperliquid.L2LevelPackedR\x03ask\"\xd8\x01\n" + + "\x11L2BookDiffRequest\x12\x14\n" + + "\x05coins\x18\x01 \x03(\tR\x05coins\x12\x19\n" + + "\bn_levels\x18\x02 \x01(\rR\anLevels\x12!\n" + + "\n" + + "n_sig_figs\x18\x03 \x01(\rH\x00R\bnSigFigs\x88\x01\x01\x12\x1f\n" + + "\bmantissa\x18\x04 \x01(\x04H\x01R\bmantissa\x88\x01\x01\x122\n" + + "\x15skip_initial_snapshot\x18\x05 \x01(\bR\x13skipInitialSnapshotB\r\n" + + "\v_n_sig_figsB\v\n" + + "\t_mantissa\"\x89\x01\n" + + "\x10L2BookDiffUpdate\x12\x12\n" + + "\x04time\x18\x01 \x01(\x04R\x04time\x12\x16\n" + + "\x06height\x18\x02 \x01(\x04R\x06height\x12\x1a\n" + + "\bsnapshot\x18\x03 \x01(\bR\bsnapshot\x12-\n" + + "\x05diffs\x18\x04 \x03(\v2\x17.hyperliquid.L2CoinDiffR\x05diffs\"\xbd\x01\n" + + "\n" + + "L2CoinDiff\x12\x12\n" + + "\x04coin\x18\x01 \x01(\tR\x04coin\x12\x10\n" + + "\x03seq\x18\x02 \x01(\x04R\x03seq\x12\x19\n" + + "\bprev_seq\x18\x03 \x01(\x04R\aprevSeq\x12(\n" + + "\x04bids\x18\x04 \x03(\v2\x14.hyperliquid.L2LevelR\x04bids\x12(\n" + + "\x04asks\x18\x05 \x03(\v2\x14.hyperliquid.L2LevelR\x04asks\x12\x1a\n" + + "\bsnapshot\x18\x06 \x01(\bR\bsnapshot\"#\n" + "\rL4BookRequest\x12\x12\n" + "\x04coin\x18\x01 \x01(\tR\x04coin\"\x82\x01\n" + "\fL4BookUpdate\x129\n" + @@ -697,7 +2022,30 @@ const file_orderbook_proto_rawDesc = "" + "L4BookDiff\x12\x12\n" + "\x04time\x18\x01 \x01(\x04R\x04time\x12\x16\n" + "\x06height\x18\x02 \x01(\x04R\x06height\x12\x12\n" + - "\x04data\x18\x03 \x01(\tR\x04data\"\xb9\x03\n" + + "\x04data\x18\x03 \x01(\tR\x04data\"\x8c\x01\n" + + "\x11L4BookBytesUpdate\x129\n" + + "\bsnapshot\x18\x01 \x01(\v2\x1b.hyperliquid.L4BookSnapshotH\x00R\bsnapshot\x122\n" + + "\x04diff\x18\x02 \x01(\v2\x1c.hyperliquid.L4BookBytesDiffH\x00R\x04diffB\b\n" + + "\x06update\"Q\n" + + "\x0fL4BookBytesDiff\x12\x12\n" + + "\x04time\x18\x01 \x01(\x04R\x04time\x12\x16\n" + + "\x06height\x18\x02 \x01(\x04R\x06height\x12\x12\n" + + "\x04data\x18\x03 \x01(\fR\x04data\",\n" + + "\x14L4BookUpdatesRequest\x12\x14\n" + + "\x05coins\x18\x01 \x03(\tR\x05coins\"\x8d\x01\n" + + "\x13L4BookUpdatesUpdate\x12\x12\n" + + "\x04time\x18\x01 \x01(\x04R\x04time\x12\x16\n" + + "\x06height\x18\x02 \x01(\x04R\x06height\x12.\n" + + "\x05diffs\x18\x03 \x03(\v2\x18.hyperliquid.L4OrderDiffR\x05diffs\x12\x1a\n" + + "\bsnapshot\x18\x04 \x01(\bR\bsnapshot\"\xb6\x01\n" + + "\vL4OrderDiff\x129\n" + + "\tdiff_type\x18\x01 \x01(\x0e2\x1c.hyperliquid.L4OrderDiffTypeR\bdiffType\x12\x12\n" + + "\x04coin\x18\x02 \x01(\tR\x04coin\x12\x10\n" + + "\x03oid\x18\x03 \x01(\x04R\x03oid\x12\x12\n" + + "\x04user\x18\x04 \x01(\tR\x04user\x12\x12\n" + + "\x04side\x18\x05 \x01(\tR\x04side\x12\x0e\n" + + "\x02px\x18\x06 \x01(\tR\x02px\x12\x0e\n" + + "\x02sz\x18\a \x01(\tR\x02sz\"\xb9\x03\n" + "\aL4Order\x12\x12\n" + "\x04user\x18\x01 \x01(\tR\x04user\x12\x12\n" + "\x04coin\x18\x02 \x01(\tR\x04coin\x12\x12\n" + @@ -720,10 +2068,52 @@ const file_orderbook_proto_rawDesc = "" + "\x03tif\x18\x0e \x01(\tH\x00R\x03tif\x88\x01\x01\x12\x19\n" + "\x05cloid\x18\x0f \x01(\tH\x01R\x05cloid\x88\x01\x01B\x06\n" + "\x04_tifB\b\n" + - "\x06_cloid2\xa6\x01\n" + + "\x06_cloid\"*\n" + + "\x12TpslUpdatesRequest\x12\x14\n" + + "\x05coins\x18\x01 \x03(\tR\x05coins\"\x8d\x01\n" + + "\x11TpslUpdatesUpdate\x12\x12\n" + + "\x04time\x18\x01 \x01(\x04R\x04time\x12\x16\n" + + "\x06height\x18\x02 \x01(\x04R\x06height\x120\n" + + "\x05diffs\x18\x03 \x03(\v2\x1a.hyperliquid.TpslOrderDiffR\x05diffs\x12\x1a\n" + + "\bsnapshot\x18\x04 \x01(\bR\bsnapshot\"\xac\x03\n" + + "\rTpslOrderDiff\x126\n" + + "\tdiff_type\x18\x01 \x01(\x0e2\x19.hyperliquid.TpslDiffTypeR\bdiffType\x12\x10\n" + + "\x03oid\x18\x02 \x01(\x04R\x03oid\x12\x12\n" + + "\x04coin\x18\x03 \x01(\tR\x04coin\x12\x12\n" + + "\x04user\x18\x04 \x01(\tR\x04user\x12\x12\n" + + "\x04side\x18\x05 \x01(\tR\x04side\x12\x1d\n" + + "\n" + + "trigger_px\x18\x06 \x01(\tR\ttriggerPx\x12\x19\n" + + "\blimit_px\x18\a \x01(\tR\alimitPx\x12\x0e\n" + + "\x02sz\x18\b \x01(\tR\x02sz\x12+\n" + + "\x11trigger_condition\x18\t \x01(\tR\x10triggerCondition\x12\x1d\n" + + "\n" + + "order_type\x18\n" + + " \x01(\tR\torderType\x12(\n" + + "\x10is_position_tpsl\x18\v \x01(\bR\x0eisPositionTpsl\x12\x1f\n" + + "\vreduce_only\x18\f \x01(\bR\n" + + "reduceOnly\x12\x1c\n" + + "\ttimestamp\x18\r \x01(\x04R\ttimestamp\x12\x16\n" + + "\x06reason\x18\x0e \x01(\tR\x06reason*\x8f\x01\n" + + "\x0fL4OrderDiffType\x12\"\n" + + "\x1eL4_ORDER_DIFF_TYPE_UNSPECIFIED\x10\x00\x12\x1a\n" + + "\x16L4_ORDER_DIFF_TYPE_NEW\x10\x01\x12\x1d\n" + + "\x19L4_ORDER_DIFF_TYPE_UPDATE\x10\x02\x12\x1d\n" + + "\x19L4_ORDER_DIFF_TYPE_REMOVE\x10\x03*a\n" + + "\fTpslDiffType\x12\x1e\n" + + "\x1aTPSL_DIFF_TYPE_UNSPECIFIED\x10\x00\x12\x16\n" + + "\x12TPSL_DIFF_TYPE_ADD\x10\x01\x12\x19\n" + + "\x15TPSL_DIFF_TYPE_REMOVE\x10\x022\xfd\x05\n" + "\x12OrderBookStreaming\x12G\n" + "\fStreamL2Book\x12\x1a.hyperliquid.L2BookRequest\x1a\x19.hyperliquid.L2BookUpdate0\x01\x12G\n" + - "\fStreamL4Book\x12\x1a.hyperliquid.L4BookRequest\x1a\x19.hyperliquid.L4BookUpdate0\x01B?Z=github.com/quiknode-labs/hyperliquid-sdk/go/hyperliquid/protob\x06proto3" + "\fStreamL4Book\x12\x1a.hyperliquid.L4BookRequest\x1a\x19.hyperliquid.L4BookUpdate0\x01\x12J\n" + + "\rStreamBboBook\x12\x1b.hyperliquid.BboBookRequest\x1a\x1a.hyperliquid.BboBookUpdate0\x01\x12S\n" + + "\x10StreamL2BookDiff\x12\x1e.hyperliquid.L2BookDiffRequest\x1a\x1d.hyperliquid.L2BookDiffUpdate0\x01\x12\\\n" + + "\x13StreamL4BookUpdates\x12!.hyperliquid.L4BookUpdatesRequest\x1a .hyperliquid.L4BookUpdatesUpdate0\x01\x12V\n" + + "\x11StreamTpslUpdates\x12\x1f.hyperliquid.TpslUpdatesRequest\x1a\x1e.hyperliquid.TpslUpdatesUpdate0\x01\x12S\n" + + "\x12StreamL2BookPacked\x12\x1a.hyperliquid.L2BookRequest\x1a\x1f.hyperliquid.L2BookPackedUpdate0\x01\x12V\n" + + "\x13StreamBboBookPacked\x12\x1b.hyperliquid.BboBookRequest\x1a .hyperliquid.BboBookPackedUpdate0\x01\x12Q\n" + + "\x11StreamL4BookBytes\x12\x1a.hyperliquid.L4BookRequest\x1a\x1e.hyperliquid.L4BookBytesUpdate0\x01B?Z=github.com/quiknode-labs/hyperliquid-sdk/go/hyperliquid/protob\x06proto3" var ( file_orderbook_proto_rawDescOnce sync.Once @@ -737,33 +2127,81 @@ func file_orderbook_proto_rawDescGZIP() []byte { return file_orderbook_proto_rawDescData } -var file_orderbook_proto_msgTypes = make([]protoimpl.MessageInfo, 8) +var file_orderbook_proto_enumTypes = make([]protoimpl.EnumInfo, 2) +var file_orderbook_proto_msgTypes = make([]protoimpl.MessageInfo, 24) var file_orderbook_proto_goTypes = []any{ - (*L2BookRequest)(nil), // 0: hyperliquid.L2BookRequest - (*L2BookUpdate)(nil), // 1: hyperliquid.L2BookUpdate - (*L2Level)(nil), // 2: hyperliquid.L2Level - (*L4BookRequest)(nil), // 3: hyperliquid.L4BookRequest - (*L4BookUpdate)(nil), // 4: hyperliquid.L4BookUpdate - (*L4BookSnapshot)(nil), // 5: hyperliquid.L4BookSnapshot - (*L4BookDiff)(nil), // 6: hyperliquid.L4BookDiff - (*L4Order)(nil), // 7: hyperliquid.L4Order + (L4OrderDiffType)(0), // 0: hyperliquid.L4OrderDiffType + (TpslDiffType)(0), // 1: hyperliquid.TpslDiffType + (*L2BookRequest)(nil), // 2: hyperliquid.L2BookRequest + (*L2BookUpdate)(nil), // 3: hyperliquid.L2BookUpdate + (*L2Level)(nil), // 4: hyperliquid.L2Level + (*L2LevelPacked)(nil), // 5: hyperliquid.L2LevelPacked + (*L2BookPackedUpdate)(nil), // 6: hyperliquid.L2BookPackedUpdate + (*BboBookRequest)(nil), // 7: hyperliquid.BboBookRequest + (*BboBookUpdate)(nil), // 8: hyperliquid.BboBookUpdate + (*BboBookPackedUpdate)(nil), // 9: hyperliquid.BboBookPackedUpdate + (*L2BookDiffRequest)(nil), // 10: hyperliquid.L2BookDiffRequest + (*L2BookDiffUpdate)(nil), // 11: hyperliquid.L2BookDiffUpdate + (*L2CoinDiff)(nil), // 12: hyperliquid.L2CoinDiff + (*L4BookRequest)(nil), // 13: hyperliquid.L4BookRequest + (*L4BookUpdate)(nil), // 14: hyperliquid.L4BookUpdate + (*L4BookSnapshot)(nil), // 15: hyperliquid.L4BookSnapshot + (*L4BookDiff)(nil), // 16: hyperliquid.L4BookDiff + (*L4BookBytesUpdate)(nil), // 17: hyperliquid.L4BookBytesUpdate + (*L4BookBytesDiff)(nil), // 18: hyperliquid.L4BookBytesDiff + (*L4BookUpdatesRequest)(nil), // 19: hyperliquid.L4BookUpdatesRequest + (*L4BookUpdatesUpdate)(nil), // 20: hyperliquid.L4BookUpdatesUpdate + (*L4OrderDiff)(nil), // 21: hyperliquid.L4OrderDiff + (*L4Order)(nil), // 22: hyperliquid.L4Order + (*TpslUpdatesRequest)(nil), // 23: hyperliquid.TpslUpdatesRequest + (*TpslUpdatesUpdate)(nil), // 24: hyperliquid.TpslUpdatesUpdate + (*TpslOrderDiff)(nil), // 25: hyperliquid.TpslOrderDiff } var file_orderbook_proto_depIdxs = []int32{ - 2, // 0: hyperliquid.L2BookUpdate.bids:type_name -> hyperliquid.L2Level - 2, // 1: hyperliquid.L2BookUpdate.asks:type_name -> hyperliquid.L2Level - 5, // 2: hyperliquid.L4BookUpdate.snapshot:type_name -> hyperliquid.L4BookSnapshot - 6, // 3: hyperliquid.L4BookUpdate.diff:type_name -> hyperliquid.L4BookDiff - 7, // 4: hyperliquid.L4BookSnapshot.bids:type_name -> hyperliquid.L4Order - 7, // 5: hyperliquid.L4BookSnapshot.asks:type_name -> hyperliquid.L4Order - 0, // 6: hyperliquid.OrderBookStreaming.StreamL2Book:input_type -> hyperliquid.L2BookRequest - 3, // 7: hyperliquid.OrderBookStreaming.StreamL4Book:input_type -> hyperliquid.L4BookRequest - 1, // 8: hyperliquid.OrderBookStreaming.StreamL2Book:output_type -> hyperliquid.L2BookUpdate - 4, // 9: hyperliquid.OrderBookStreaming.StreamL4Book:output_type -> hyperliquid.L4BookUpdate - 8, // [8:10] is the sub-list for method output_type - 6, // [6:8] is the sub-list for method input_type - 6, // [6:6] is the sub-list for extension type_name - 6, // [6:6] is the sub-list for extension extendee - 0, // [0:6] is the sub-list for field type_name + 4, // 0: hyperliquid.L2BookUpdate.bids:type_name -> hyperliquid.L2Level + 4, // 1: hyperliquid.L2BookUpdate.asks:type_name -> hyperliquid.L2Level + 5, // 2: hyperliquid.L2BookPackedUpdate.bids:type_name -> hyperliquid.L2LevelPacked + 5, // 3: hyperliquid.L2BookPackedUpdate.asks:type_name -> hyperliquid.L2LevelPacked + 4, // 4: hyperliquid.BboBookUpdate.bid:type_name -> hyperliquid.L2Level + 4, // 5: hyperliquid.BboBookUpdate.ask:type_name -> hyperliquid.L2Level + 5, // 6: hyperliquid.BboBookPackedUpdate.bid:type_name -> hyperliquid.L2LevelPacked + 5, // 7: hyperliquid.BboBookPackedUpdate.ask:type_name -> hyperliquid.L2LevelPacked + 12, // 8: hyperliquid.L2BookDiffUpdate.diffs:type_name -> hyperliquid.L2CoinDiff + 4, // 9: hyperliquid.L2CoinDiff.bids:type_name -> hyperliquid.L2Level + 4, // 10: hyperliquid.L2CoinDiff.asks:type_name -> hyperliquid.L2Level + 15, // 11: hyperliquid.L4BookUpdate.snapshot:type_name -> hyperliquid.L4BookSnapshot + 16, // 12: hyperliquid.L4BookUpdate.diff:type_name -> hyperliquid.L4BookDiff + 22, // 13: hyperliquid.L4BookSnapshot.bids:type_name -> hyperliquid.L4Order + 22, // 14: hyperliquid.L4BookSnapshot.asks:type_name -> hyperliquid.L4Order + 15, // 15: hyperliquid.L4BookBytesUpdate.snapshot:type_name -> hyperliquid.L4BookSnapshot + 18, // 16: hyperliquid.L4BookBytesUpdate.diff:type_name -> hyperliquid.L4BookBytesDiff + 21, // 17: hyperliquid.L4BookUpdatesUpdate.diffs:type_name -> hyperliquid.L4OrderDiff + 0, // 18: hyperliquid.L4OrderDiff.diff_type:type_name -> hyperliquid.L4OrderDiffType + 25, // 19: hyperliquid.TpslUpdatesUpdate.diffs:type_name -> hyperliquid.TpslOrderDiff + 1, // 20: hyperliquid.TpslOrderDiff.diff_type:type_name -> hyperliquid.TpslDiffType + 2, // 21: hyperliquid.OrderBookStreaming.StreamL2Book:input_type -> hyperliquid.L2BookRequest + 13, // 22: hyperliquid.OrderBookStreaming.StreamL4Book:input_type -> hyperliquid.L4BookRequest + 7, // 23: hyperliquid.OrderBookStreaming.StreamBboBook:input_type -> hyperliquid.BboBookRequest + 10, // 24: hyperliquid.OrderBookStreaming.StreamL2BookDiff:input_type -> hyperliquid.L2BookDiffRequest + 19, // 25: hyperliquid.OrderBookStreaming.StreamL4BookUpdates:input_type -> hyperliquid.L4BookUpdatesRequest + 23, // 26: hyperliquid.OrderBookStreaming.StreamTpslUpdates:input_type -> hyperliquid.TpslUpdatesRequest + 2, // 27: hyperliquid.OrderBookStreaming.StreamL2BookPacked:input_type -> hyperliquid.L2BookRequest + 7, // 28: hyperliquid.OrderBookStreaming.StreamBboBookPacked:input_type -> hyperliquid.BboBookRequest + 13, // 29: hyperliquid.OrderBookStreaming.StreamL4BookBytes:input_type -> hyperliquid.L4BookRequest + 3, // 30: hyperliquid.OrderBookStreaming.StreamL2Book:output_type -> hyperliquid.L2BookUpdate + 14, // 31: hyperliquid.OrderBookStreaming.StreamL4Book:output_type -> hyperliquid.L4BookUpdate + 8, // 32: hyperliquid.OrderBookStreaming.StreamBboBook:output_type -> hyperliquid.BboBookUpdate + 11, // 33: hyperliquid.OrderBookStreaming.StreamL2BookDiff:output_type -> hyperliquid.L2BookDiffUpdate + 20, // 34: hyperliquid.OrderBookStreaming.StreamL4BookUpdates:output_type -> hyperliquid.L4BookUpdatesUpdate + 24, // 35: hyperliquid.OrderBookStreaming.StreamTpslUpdates:output_type -> hyperliquid.TpslUpdatesUpdate + 6, // 36: hyperliquid.OrderBookStreaming.StreamL2BookPacked:output_type -> hyperliquid.L2BookPackedUpdate + 9, // 37: hyperliquid.OrderBookStreaming.StreamBboBookPacked:output_type -> hyperliquid.BboBookPackedUpdate + 17, // 38: hyperliquid.OrderBookStreaming.StreamL4BookBytes:output_type -> hyperliquid.L4BookBytesUpdate + 30, // [30:39] is the sub-list for method output_type + 21, // [21:30] is the sub-list for method input_type + 21, // [21:21] is the sub-list for extension type_name + 21, // [21:21] is the sub-list for extension extendee + 0, // [0:21] is the sub-list for field type_name } func init() { file_orderbook_proto_init() } @@ -772,23 +2210,29 @@ func file_orderbook_proto_init() { return } file_orderbook_proto_msgTypes[0].OneofWrappers = []any{} - file_orderbook_proto_msgTypes[4].OneofWrappers = []any{ + file_orderbook_proto_msgTypes[8].OneofWrappers = []any{} + file_orderbook_proto_msgTypes[12].OneofWrappers = []any{ (*L4BookUpdate_Snapshot)(nil), (*L4BookUpdate_Diff)(nil), } - file_orderbook_proto_msgTypes[7].OneofWrappers = []any{} + file_orderbook_proto_msgTypes[15].OneofWrappers = []any{ + (*L4BookBytesUpdate_Snapshot)(nil), + (*L4BookBytesUpdate_Diff)(nil), + } + file_orderbook_proto_msgTypes[20].OneofWrappers = []any{} type x struct{} out := protoimpl.TypeBuilder{ File: protoimpl.DescBuilder{ GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: unsafe.Slice(unsafe.StringData(file_orderbook_proto_rawDesc), len(file_orderbook_proto_rawDesc)), - NumEnums: 0, - NumMessages: 8, + NumEnums: 2, + NumMessages: 24, NumExtensions: 0, NumServices: 1, }, GoTypes: file_orderbook_proto_goTypes, DependencyIndexes: file_orderbook_proto_depIdxs, + EnumInfos: file_orderbook_proto_enumTypes, MessageInfos: file_orderbook_proto_msgTypes, }.Build() File_orderbook_proto = out.File diff --git a/go/hyperliquid/proto/orderbook.proto b/go/hyperliquid/proto/orderbook.proto index e4a406c..f0c1f78 100644 --- a/go/hyperliquid/proto/orderbook.proto +++ b/go/hyperliquid/proto/orderbook.proto @@ -21,42 +21,127 @@ service OrderBookStreaming { // Subscribe to L4 full order book for a coin. // Sends an initial L4 snapshot, then incremental diffs per block. rpc StreamL4Book (L4BookRequest) returns (stream L4BookUpdate); + + // Subscribe to top-of-book changes. Empty coins means all coins. + // Emits only when the best bid or ask changes for a coin. + rpc StreamBboBook (BboBookRequest) returns (stream BboBookUpdate); + + // Subscribe to incremental L2 price-level changes. Empty coins means all coins. + rpc StreamL2BookDiff (L2BookDiffRequest) returns (stream L2BookDiffUpdate); + + // Subscribe to typed L4 order book updates. Empty coins means all coins. + rpc StreamL4BookUpdates (L4BookUpdatesRequest) returns (stream L4BookUpdatesUpdate); + + // Subscribe to trigger/TP-SL order updates. Empty coins means all perp coins. + rpc StreamTpslUpdates (TpslUpdatesRequest) returns (stream TpslUpdatesUpdate); + + // Fast-path L2 stream. Prices/sizes are fixed-point integers scaled by 1e8. + rpc StreamL2BookPacked (L2BookRequest) returns (stream L2BookPackedUpdate); + + // Fast-path BBO stream. Prices/sizes are fixed-point integers scaled by 1e8. + rpc StreamBboBookPacked (BboBookRequest) returns (stream BboBookPackedUpdate); + + // Fast-path L4 stream. Diff payload is JSON bytes instead of a protobuf string. + rpc StreamL4BookBytes (L4BookRequest) returns (stream L4BookBytesUpdate); } // Request parameters for L2 book streaming. message L2BookRequest { - string coin = 1; - uint32 n_levels = 2; - optional uint32 n_sig_figs = 3; - optional uint64 mantissa = 4; + string coin = 1; // Symbol (e.g. "BTC", "ETH") + uint32 n_levels = 2; // Max number of price levels (default 20, max 100) + optional uint32 n_sig_figs = 3; // Significance figures for price bucketing (2-5) + optional uint64 mantissa = 4; // Mantissa for bucketing (1, 2, or 5) } // An L2 book update: full snapshot of aggregated price levels at a point in time. message L2BookUpdate { string coin = 1; - uint64 time = 2; + uint64 time = 2; // Block timestamp (milliseconds) uint64 block_number = 3; - repeated L2Level bids = 4; - repeated L2Level asks = 5; + repeated L2Level bids = 4; // Aggregated bid levels (best first) + repeated L2Level asks = 5; // Aggregated ask levels (best first) } // A single aggregated price level. message L2Level { - string px = 1; - string sz = 2; - uint32 n = 3; + string px = 1; // Price as decimal string + string sz = 2; // Total size as decimal string + uint32 n = 3; // Number of individual orders at this level +} + +message L2LevelPacked { + fixed64 px = 1; // Price scaled by 1e8 + fixed64 sz = 2; // Size scaled by 1e8 + uint32 n = 3; // Number of individual orders at this level +} + +message L2BookPackedUpdate { + string coin = 1; + uint64 time = 2; + uint64 block_number = 3; + repeated L2LevelPacked bids = 4; + repeated L2LevelPacked asks = 5; +} + +// Request parameters for best bid/offer streaming. +message BboBookRequest { + repeated string coins = 1; // Empty means all coins +} + +// Best bid/offer update for a coin. +message BboBookUpdate { + string coin = 1; + uint64 time = 2; // Block timestamp (milliseconds) + uint64 block_number = 3; + L2Level bid = 4; // Absent if no bid + L2Level ask = 5; // Absent if no ask +} + +message BboBookPackedUpdate { + string coin = 1; + uint64 time = 2; + uint64 block_number = 3; + L2LevelPacked bid = 4; + L2LevelPacked ask = 5; +} + +// Request parameters for L2 diff streaming. +message L2BookDiffRequest { + repeated string coins = 1; // Empty means all coins + uint32 n_levels = 2; // Max tracked levels per side (default 20, max 100) + optional uint32 n_sig_figs = 3; // Significance figures for price bucketing (2-5) + optional uint64 mantissa = 4; // Mantissa for bucketing (1, 2, or 5) + bool skip_initial_snapshot = 5; // If false, first update per coin contains current levels +} + +// Batch of L2 price-level changes for one processed block. +message L2BookDiffUpdate { + uint64 time = 1; // Block timestamp (milliseconds) + uint64 height = 2; // Block height + bool snapshot = 3; // True when carrying initial levels for any coin + repeated L2CoinDiff diffs = 4; // Only coins with changed levels +} + +// Incremental L2 changes for one coin. +message L2CoinDiff { + string coin = 1; + uint64 seq = 2; // Per-coin sequence in this stream + uint64 prev_seq = 3; + repeated L2Level bids = 4; // Changed bid levels; sz=0 means removed + repeated L2Level asks = 5; // Changed ask levels; sz=0 means removed + bool snapshot = 6; // True when bids/asks carry initial levels } // Request parameters for L4 book streaming. message L4BookRequest { - string coin = 1; + string coin = 1; // Symbol (e.g. "BTC", "ETH") } // An L4 book update: either a full snapshot or an incremental diff. message L4BookUpdate { oneof update { - L4BookSnapshot snapshot = 1; - L4BookDiff diff = 2; + L4BookSnapshot snapshot = 1; // Full snapshot (sent on subscribe) + L4BookDiff diff = 2; // Incremental diff (sent per block) } } @@ -70,27 +155,109 @@ message L4BookSnapshot { } // Incremental L4 book diff: raw order statuses and book diffs for one block. +// Sent as JSON to preserve the original node data format. message L4BookDiff { uint64 time = 1; uint64 height = 2; - string data = 3; + string data = 3; // JSON-encoded {order_statuses, book_diffs} +} + +message L4BookBytesUpdate { + oneof update { + L4BookSnapshot snapshot = 1; // Full snapshot (sent on subscribe) + L4BookBytesDiff diff = 2; // Incremental diff bytes + } +} + +message L4BookBytesDiff { + uint64 time = 1; + uint64 height = 2; + bytes data = 3; // JSON-encoded {order_statuses, book_diffs} +} + +// Request parameters for typed L4 order updates. +message L4BookUpdatesRequest { + repeated string coins = 1; // Empty means all coins +} + +enum L4OrderDiffType { + L4_ORDER_DIFF_TYPE_UNSPECIFIED = 0; + L4_ORDER_DIFF_TYPE_NEW = 1; + L4_ORDER_DIFF_TYPE_UPDATE = 2; + L4_ORDER_DIFF_TYPE_REMOVE = 3; +} + +// Typed L4 order book update batch for one processed block. +message L4BookUpdatesUpdate { + uint64 time = 1; + uint64 height = 2; + repeated L4OrderDiff diffs = 3; + bool snapshot = 4; // True when diffs carry a full reset snapshot +} + +// A single typed L4 order-level change. +message L4OrderDiff { + L4OrderDiffType diff_type = 1; + string coin = 2; + uint64 oid = 3; + string user = 4; + string side = 5; // "A" (Ask) or "B" (Bid) + string px = 6; + string sz = 7; // New/current size for new/update } // A single L4 order with full details. message L4Order { - string user = 1; + string user = 1; // Ethereum address string coin = 2; - string side = 3; - string limit_px = 4; - string sz = 5; - uint64 oid = 6; - uint64 timestamp = 7; - string trigger_condition = 8; + string side = 3; // "A" (Ask) or "B" (Bid) + string limit_px = 4; // Limit price as decimal string + string sz = 5; // Size as decimal string + uint64 oid = 6; // Unique order ID + uint64 timestamp = 7; // When order entered the book (ms) + string trigger_condition = 8; // "N/A", "Triggered", etc. bool is_trigger = 9; string trigger_px = 10; bool is_position_tpsl = 11; bool reduce_only = 12; - string order_type = 13; - optional string tif = 14; - optional string cloid = 15; + string order_type = 13; // "Limit", "Market", etc. + optional string tif = 14; // Time-in-force: "Gtc", "Ioc", "Alo" + optional string cloid = 15; // Client order ID +} + +// Request parameters for TP/SL trigger order updates. +message TpslUpdatesRequest { + repeated string coins = 1; // Empty means all perp coins +} + +enum TpslDiffType { + TPSL_DIFF_TYPE_UNSPECIFIED = 0; + TPSL_DIFF_TYPE_ADD = 1; + TPSL_DIFF_TYPE_REMOVE = 2; +} + +// TP/SL trigger order update batch for one processed block. +message TpslUpdatesUpdate { + uint64 time = 1; + uint64 height = 2; + repeated TpslOrderDiff diffs = 3; + bool snapshot = 4; // True when diffs carry currently-open trigger orders +} + +// A trigger order add/remove event. +message TpslOrderDiff { + TpslDiffType diff_type = 1; + uint64 oid = 2; + string coin = 3; + string user = 4; + string side = 5; // "A" (Ask) or "B" (Bid) + string trigger_px = 6; + string limit_px = 7; + string sz = 8; + string trigger_condition = 9; + string order_type = 10; + bool is_position_tpsl = 11; + bool reduce_only = 12; + uint64 timestamp = 13; // Order creation timestamp (milliseconds) + string reason = 14; // Present on remove; mirrors node status } diff --git a/go/hyperliquid/proto/orderbook_grpc.pb.go b/go/hyperliquid/proto/orderbook_grpc.pb.go index 91ea5c2..2d36b4b 100644 --- a/go/hyperliquid/proto/orderbook_grpc.pb.go +++ b/go/hyperliquid/proto/orderbook_grpc.pb.go @@ -8,7 +8,7 @@ // Code generated by protoc-gen-go-grpc. DO NOT EDIT. // versions: // - protoc-gen-go-grpc v1.5.1 -// - protoc v6.32.1 +// - protoc v4.25.3 // source: orderbook.proto package proto @@ -26,8 +26,15 @@ import ( const _ = grpc.SupportPackageIsVersion9 const ( - OrderBookStreaming_StreamL2Book_FullMethodName = "/hyperliquid.OrderBookStreaming/StreamL2Book" - OrderBookStreaming_StreamL4Book_FullMethodName = "/hyperliquid.OrderBookStreaming/StreamL4Book" + OrderBookStreaming_StreamL2Book_FullMethodName = "/hyperliquid.OrderBookStreaming/StreamL2Book" + OrderBookStreaming_StreamL4Book_FullMethodName = "/hyperliquid.OrderBookStreaming/StreamL4Book" + OrderBookStreaming_StreamBboBook_FullMethodName = "/hyperliquid.OrderBookStreaming/StreamBboBook" + OrderBookStreaming_StreamL2BookDiff_FullMethodName = "/hyperliquid.OrderBookStreaming/StreamL2BookDiff" + OrderBookStreaming_StreamL4BookUpdates_FullMethodName = "/hyperliquid.OrderBookStreaming/StreamL4BookUpdates" + OrderBookStreaming_StreamTpslUpdates_FullMethodName = "/hyperliquid.OrderBookStreaming/StreamTpslUpdates" + OrderBookStreaming_StreamL2BookPacked_FullMethodName = "/hyperliquid.OrderBookStreaming/StreamL2BookPacked" + OrderBookStreaming_StreamBboBookPacked_FullMethodName = "/hyperliquid.OrderBookStreaming/StreamBboBookPacked" + OrderBookStreaming_StreamL4BookBytes_FullMethodName = "/hyperliquid.OrderBookStreaming/StreamL4BookBytes" ) // OrderBookStreamingClient is the client API for OrderBookStreaming service. @@ -44,6 +51,21 @@ type OrderBookStreamingClient interface { // Subscribe to L4 full order book for a coin. // Sends an initial L4 snapshot, then incremental diffs per block. StreamL4Book(ctx context.Context, in *L4BookRequest, opts ...grpc.CallOption) (grpc.ServerStreamingClient[L4BookUpdate], error) + // Subscribe to top-of-book changes. Empty coins means all coins. + // Emits only when the best bid or ask changes for a coin. + StreamBboBook(ctx context.Context, in *BboBookRequest, opts ...grpc.CallOption) (grpc.ServerStreamingClient[BboBookUpdate], error) + // Subscribe to incremental L2 price-level changes. Empty coins means all coins. + StreamL2BookDiff(ctx context.Context, in *L2BookDiffRequest, opts ...grpc.CallOption) (grpc.ServerStreamingClient[L2BookDiffUpdate], error) + // Subscribe to typed L4 order book updates. Empty coins means all coins. + StreamL4BookUpdates(ctx context.Context, in *L4BookUpdatesRequest, opts ...grpc.CallOption) (grpc.ServerStreamingClient[L4BookUpdatesUpdate], error) + // Subscribe to trigger/TP-SL order updates. Empty coins means all perp coins. + StreamTpslUpdates(ctx context.Context, in *TpslUpdatesRequest, opts ...grpc.CallOption) (grpc.ServerStreamingClient[TpslUpdatesUpdate], error) + // Fast-path L2 stream. Prices/sizes are fixed-point integers scaled by 1e8. + StreamL2BookPacked(ctx context.Context, in *L2BookRequest, opts ...grpc.CallOption) (grpc.ServerStreamingClient[L2BookPackedUpdate], error) + // Fast-path BBO stream. Prices/sizes are fixed-point integers scaled by 1e8. + StreamBboBookPacked(ctx context.Context, in *BboBookRequest, opts ...grpc.CallOption) (grpc.ServerStreamingClient[BboBookPackedUpdate], error) + // Fast-path L4 stream. Diff payload is JSON bytes instead of a protobuf string. + StreamL4BookBytes(ctx context.Context, in *L4BookRequest, opts ...grpc.CallOption) (grpc.ServerStreamingClient[L4BookBytesUpdate], error) } type orderBookStreamingClient struct { @@ -92,6 +114,139 @@ func (c *orderBookStreamingClient) StreamL4Book(ctx context.Context, in *L4BookR // This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name. type OrderBookStreaming_StreamL4BookClient = grpc.ServerStreamingClient[L4BookUpdate] +func (c *orderBookStreamingClient) StreamBboBook(ctx context.Context, in *BboBookRequest, opts ...grpc.CallOption) (grpc.ServerStreamingClient[BboBookUpdate], error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + stream, err := c.cc.NewStream(ctx, &OrderBookStreaming_ServiceDesc.Streams[2], OrderBookStreaming_StreamBboBook_FullMethodName, cOpts...) + if err != nil { + return nil, err + } + x := &grpc.GenericClientStream[BboBookRequest, BboBookUpdate]{ClientStream: stream} + if err := x.ClientStream.SendMsg(in); err != nil { + return nil, err + } + if err := x.ClientStream.CloseSend(); err != nil { + return nil, err + } + return x, nil +} + +// This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name. +type OrderBookStreaming_StreamBboBookClient = grpc.ServerStreamingClient[BboBookUpdate] + +func (c *orderBookStreamingClient) StreamL2BookDiff(ctx context.Context, in *L2BookDiffRequest, opts ...grpc.CallOption) (grpc.ServerStreamingClient[L2BookDiffUpdate], error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + stream, err := c.cc.NewStream(ctx, &OrderBookStreaming_ServiceDesc.Streams[3], OrderBookStreaming_StreamL2BookDiff_FullMethodName, cOpts...) + if err != nil { + return nil, err + } + x := &grpc.GenericClientStream[L2BookDiffRequest, L2BookDiffUpdate]{ClientStream: stream} + if err := x.ClientStream.SendMsg(in); err != nil { + return nil, err + } + if err := x.ClientStream.CloseSend(); err != nil { + return nil, err + } + return x, nil +} + +// This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name. +type OrderBookStreaming_StreamL2BookDiffClient = grpc.ServerStreamingClient[L2BookDiffUpdate] + +func (c *orderBookStreamingClient) StreamL4BookUpdates(ctx context.Context, in *L4BookUpdatesRequest, opts ...grpc.CallOption) (grpc.ServerStreamingClient[L4BookUpdatesUpdate], error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + stream, err := c.cc.NewStream(ctx, &OrderBookStreaming_ServiceDesc.Streams[4], OrderBookStreaming_StreamL4BookUpdates_FullMethodName, cOpts...) + if err != nil { + return nil, err + } + x := &grpc.GenericClientStream[L4BookUpdatesRequest, L4BookUpdatesUpdate]{ClientStream: stream} + if err := x.ClientStream.SendMsg(in); err != nil { + return nil, err + } + if err := x.ClientStream.CloseSend(); err != nil { + return nil, err + } + return x, nil +} + +// This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name. +type OrderBookStreaming_StreamL4BookUpdatesClient = grpc.ServerStreamingClient[L4BookUpdatesUpdate] + +func (c *orderBookStreamingClient) StreamTpslUpdates(ctx context.Context, in *TpslUpdatesRequest, opts ...grpc.CallOption) (grpc.ServerStreamingClient[TpslUpdatesUpdate], error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + stream, err := c.cc.NewStream(ctx, &OrderBookStreaming_ServiceDesc.Streams[5], OrderBookStreaming_StreamTpslUpdates_FullMethodName, cOpts...) + if err != nil { + return nil, err + } + x := &grpc.GenericClientStream[TpslUpdatesRequest, TpslUpdatesUpdate]{ClientStream: stream} + if err := x.ClientStream.SendMsg(in); err != nil { + return nil, err + } + if err := x.ClientStream.CloseSend(); err != nil { + return nil, err + } + return x, nil +} + +// This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name. +type OrderBookStreaming_StreamTpslUpdatesClient = grpc.ServerStreamingClient[TpslUpdatesUpdate] + +func (c *orderBookStreamingClient) StreamL2BookPacked(ctx context.Context, in *L2BookRequest, opts ...grpc.CallOption) (grpc.ServerStreamingClient[L2BookPackedUpdate], error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + stream, err := c.cc.NewStream(ctx, &OrderBookStreaming_ServiceDesc.Streams[6], OrderBookStreaming_StreamL2BookPacked_FullMethodName, cOpts...) + if err != nil { + return nil, err + } + x := &grpc.GenericClientStream[L2BookRequest, L2BookPackedUpdate]{ClientStream: stream} + if err := x.ClientStream.SendMsg(in); err != nil { + return nil, err + } + if err := x.ClientStream.CloseSend(); err != nil { + return nil, err + } + return x, nil +} + +// This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name. +type OrderBookStreaming_StreamL2BookPackedClient = grpc.ServerStreamingClient[L2BookPackedUpdate] + +func (c *orderBookStreamingClient) StreamBboBookPacked(ctx context.Context, in *BboBookRequest, opts ...grpc.CallOption) (grpc.ServerStreamingClient[BboBookPackedUpdate], error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + stream, err := c.cc.NewStream(ctx, &OrderBookStreaming_ServiceDesc.Streams[7], OrderBookStreaming_StreamBboBookPacked_FullMethodName, cOpts...) + if err != nil { + return nil, err + } + x := &grpc.GenericClientStream[BboBookRequest, BboBookPackedUpdate]{ClientStream: stream} + if err := x.ClientStream.SendMsg(in); err != nil { + return nil, err + } + if err := x.ClientStream.CloseSend(); err != nil { + return nil, err + } + return x, nil +} + +// This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name. +type OrderBookStreaming_StreamBboBookPackedClient = grpc.ServerStreamingClient[BboBookPackedUpdate] + +func (c *orderBookStreamingClient) StreamL4BookBytes(ctx context.Context, in *L4BookRequest, opts ...grpc.CallOption) (grpc.ServerStreamingClient[L4BookBytesUpdate], error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + stream, err := c.cc.NewStream(ctx, &OrderBookStreaming_ServiceDesc.Streams[8], OrderBookStreaming_StreamL4BookBytes_FullMethodName, cOpts...) + if err != nil { + return nil, err + } + x := &grpc.GenericClientStream[L4BookRequest, L4BookBytesUpdate]{ClientStream: stream} + if err := x.ClientStream.SendMsg(in); err != nil { + return nil, err + } + if err := x.ClientStream.CloseSend(); err != nil { + return nil, err + } + return x, nil +} + +// This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name. +type OrderBookStreaming_StreamL4BookBytesClient = grpc.ServerStreamingClient[L4BookBytesUpdate] + // OrderBookStreamingServer is the server API for OrderBookStreaming service. // All implementations must embed UnimplementedOrderBookStreamingServer // for forward compatibility. @@ -106,6 +261,21 @@ type OrderBookStreamingServer interface { // Subscribe to L4 full order book for a coin. // Sends an initial L4 snapshot, then incremental diffs per block. StreamL4Book(*L4BookRequest, grpc.ServerStreamingServer[L4BookUpdate]) error + // Subscribe to top-of-book changes. Empty coins means all coins. + // Emits only when the best bid or ask changes for a coin. + StreamBboBook(*BboBookRequest, grpc.ServerStreamingServer[BboBookUpdate]) error + // Subscribe to incremental L2 price-level changes. Empty coins means all coins. + StreamL2BookDiff(*L2BookDiffRequest, grpc.ServerStreamingServer[L2BookDiffUpdate]) error + // Subscribe to typed L4 order book updates. Empty coins means all coins. + StreamL4BookUpdates(*L4BookUpdatesRequest, grpc.ServerStreamingServer[L4BookUpdatesUpdate]) error + // Subscribe to trigger/TP-SL order updates. Empty coins means all perp coins. + StreamTpslUpdates(*TpslUpdatesRequest, grpc.ServerStreamingServer[TpslUpdatesUpdate]) error + // Fast-path L2 stream. Prices/sizes are fixed-point integers scaled by 1e8. + StreamL2BookPacked(*L2BookRequest, grpc.ServerStreamingServer[L2BookPackedUpdate]) error + // Fast-path BBO stream. Prices/sizes are fixed-point integers scaled by 1e8. + StreamBboBookPacked(*BboBookRequest, grpc.ServerStreamingServer[BboBookPackedUpdate]) error + // Fast-path L4 stream. Diff payload is JSON bytes instead of a protobuf string. + StreamL4BookBytes(*L4BookRequest, grpc.ServerStreamingServer[L4BookBytesUpdate]) error mustEmbedUnimplementedOrderBookStreamingServer() } @@ -122,6 +292,27 @@ func (UnimplementedOrderBookStreamingServer) StreamL2Book(*L2BookRequest, grpc.S func (UnimplementedOrderBookStreamingServer) StreamL4Book(*L4BookRequest, grpc.ServerStreamingServer[L4BookUpdate]) error { return status.Errorf(codes.Unimplemented, "method StreamL4Book not implemented") } +func (UnimplementedOrderBookStreamingServer) StreamBboBook(*BboBookRequest, grpc.ServerStreamingServer[BboBookUpdate]) error { + return status.Errorf(codes.Unimplemented, "method StreamBboBook not implemented") +} +func (UnimplementedOrderBookStreamingServer) StreamL2BookDiff(*L2BookDiffRequest, grpc.ServerStreamingServer[L2BookDiffUpdate]) error { + return status.Errorf(codes.Unimplemented, "method StreamL2BookDiff not implemented") +} +func (UnimplementedOrderBookStreamingServer) StreamL4BookUpdates(*L4BookUpdatesRequest, grpc.ServerStreamingServer[L4BookUpdatesUpdate]) error { + return status.Errorf(codes.Unimplemented, "method StreamL4BookUpdates not implemented") +} +func (UnimplementedOrderBookStreamingServer) StreamTpslUpdates(*TpslUpdatesRequest, grpc.ServerStreamingServer[TpslUpdatesUpdate]) error { + return status.Errorf(codes.Unimplemented, "method StreamTpslUpdates not implemented") +} +func (UnimplementedOrderBookStreamingServer) StreamL2BookPacked(*L2BookRequest, grpc.ServerStreamingServer[L2BookPackedUpdate]) error { + return status.Errorf(codes.Unimplemented, "method StreamL2BookPacked not implemented") +} +func (UnimplementedOrderBookStreamingServer) StreamBboBookPacked(*BboBookRequest, grpc.ServerStreamingServer[BboBookPackedUpdate]) error { + return status.Errorf(codes.Unimplemented, "method StreamBboBookPacked not implemented") +} +func (UnimplementedOrderBookStreamingServer) StreamL4BookBytes(*L4BookRequest, grpc.ServerStreamingServer[L4BookBytesUpdate]) error { + return status.Errorf(codes.Unimplemented, "method StreamL4BookBytes not implemented") +} func (UnimplementedOrderBookStreamingServer) mustEmbedUnimplementedOrderBookStreamingServer() {} func (UnimplementedOrderBookStreamingServer) testEmbeddedByValue() {} @@ -165,6 +356,83 @@ func _OrderBookStreaming_StreamL4Book_Handler(srv interface{}, stream grpc.Serve // This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name. type OrderBookStreaming_StreamL4BookServer = grpc.ServerStreamingServer[L4BookUpdate] +func _OrderBookStreaming_StreamBboBook_Handler(srv interface{}, stream grpc.ServerStream) error { + m := new(BboBookRequest) + if err := stream.RecvMsg(m); err != nil { + return err + } + return srv.(OrderBookStreamingServer).StreamBboBook(m, &grpc.GenericServerStream[BboBookRequest, BboBookUpdate]{ServerStream: stream}) +} + +// This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name. +type OrderBookStreaming_StreamBboBookServer = grpc.ServerStreamingServer[BboBookUpdate] + +func _OrderBookStreaming_StreamL2BookDiff_Handler(srv interface{}, stream grpc.ServerStream) error { + m := new(L2BookDiffRequest) + if err := stream.RecvMsg(m); err != nil { + return err + } + return srv.(OrderBookStreamingServer).StreamL2BookDiff(m, &grpc.GenericServerStream[L2BookDiffRequest, L2BookDiffUpdate]{ServerStream: stream}) +} + +// This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name. +type OrderBookStreaming_StreamL2BookDiffServer = grpc.ServerStreamingServer[L2BookDiffUpdate] + +func _OrderBookStreaming_StreamL4BookUpdates_Handler(srv interface{}, stream grpc.ServerStream) error { + m := new(L4BookUpdatesRequest) + if err := stream.RecvMsg(m); err != nil { + return err + } + return srv.(OrderBookStreamingServer).StreamL4BookUpdates(m, &grpc.GenericServerStream[L4BookUpdatesRequest, L4BookUpdatesUpdate]{ServerStream: stream}) +} + +// This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name. +type OrderBookStreaming_StreamL4BookUpdatesServer = grpc.ServerStreamingServer[L4BookUpdatesUpdate] + +func _OrderBookStreaming_StreamTpslUpdates_Handler(srv interface{}, stream grpc.ServerStream) error { + m := new(TpslUpdatesRequest) + if err := stream.RecvMsg(m); err != nil { + return err + } + return srv.(OrderBookStreamingServer).StreamTpslUpdates(m, &grpc.GenericServerStream[TpslUpdatesRequest, TpslUpdatesUpdate]{ServerStream: stream}) +} + +// This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name. +type OrderBookStreaming_StreamTpslUpdatesServer = grpc.ServerStreamingServer[TpslUpdatesUpdate] + +func _OrderBookStreaming_StreamL2BookPacked_Handler(srv interface{}, stream grpc.ServerStream) error { + m := new(L2BookRequest) + if err := stream.RecvMsg(m); err != nil { + return err + } + return srv.(OrderBookStreamingServer).StreamL2BookPacked(m, &grpc.GenericServerStream[L2BookRequest, L2BookPackedUpdate]{ServerStream: stream}) +} + +// This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name. +type OrderBookStreaming_StreamL2BookPackedServer = grpc.ServerStreamingServer[L2BookPackedUpdate] + +func _OrderBookStreaming_StreamBboBookPacked_Handler(srv interface{}, stream grpc.ServerStream) error { + m := new(BboBookRequest) + if err := stream.RecvMsg(m); err != nil { + return err + } + return srv.(OrderBookStreamingServer).StreamBboBookPacked(m, &grpc.GenericServerStream[BboBookRequest, BboBookPackedUpdate]{ServerStream: stream}) +} + +// This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name. +type OrderBookStreaming_StreamBboBookPackedServer = grpc.ServerStreamingServer[BboBookPackedUpdate] + +func _OrderBookStreaming_StreamL4BookBytes_Handler(srv interface{}, stream grpc.ServerStream) error { + m := new(L4BookRequest) + if err := stream.RecvMsg(m); err != nil { + return err + } + return srv.(OrderBookStreamingServer).StreamL4BookBytes(m, &grpc.GenericServerStream[L4BookRequest, L4BookBytesUpdate]{ServerStream: stream}) +} + +// This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name. +type OrderBookStreaming_StreamL4BookBytesServer = grpc.ServerStreamingServer[L4BookBytesUpdate] + // OrderBookStreaming_ServiceDesc is the grpc.ServiceDesc for OrderBookStreaming service. // It's only intended for direct use with grpc.RegisterService, // and not to be introspected or modified (even as a copy) @@ -183,6 +451,41 @@ var OrderBookStreaming_ServiceDesc = grpc.ServiceDesc{ Handler: _OrderBookStreaming_StreamL4Book_Handler, ServerStreams: true, }, + { + StreamName: "StreamBboBook", + Handler: _OrderBookStreaming_StreamBboBook_Handler, + ServerStreams: true, + }, + { + StreamName: "StreamL2BookDiff", + Handler: _OrderBookStreaming_StreamL2BookDiff_Handler, + ServerStreams: true, + }, + { + StreamName: "StreamL4BookUpdates", + Handler: _OrderBookStreaming_StreamL4BookUpdates_Handler, + ServerStreams: true, + }, + { + StreamName: "StreamTpslUpdates", + Handler: _OrderBookStreaming_StreamTpslUpdates_Handler, + ServerStreams: true, + }, + { + StreamName: "StreamL2BookPacked", + Handler: _OrderBookStreaming_StreamL2BookPacked_Handler, + ServerStreams: true, + }, + { + StreamName: "StreamBboBookPacked", + Handler: _OrderBookStreaming_StreamBboBookPacked_Handler, + ServerStreams: true, + }, + { + StreamName: "StreamL4BookBytes", + Handler: _OrderBookStreaming_StreamL4BookBytes_Handler, + ServerStreams: true, + }, }, Metadata: "orderbook.proto", } diff --git a/go/hyperliquid/proto/streaming.pb.go b/go/hyperliquid/proto/streaming.pb.go index 88a90cb..5723698 100644 --- a/go/hyperliquid/proto/streaming.pb.go +++ b/go/hyperliquid/proto/streaming.pb.go @@ -1,7 +1,7 @@ // Code generated by protoc-gen-go. DO NOT EDIT. // versions: // protoc-gen-go v1.36.8 -// protoc v6.32.1 +// protoc v4.25.3 // source: streaming.proto package proto @@ -21,41 +21,49 @@ const ( _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) ) -// --- Data Types --- type StreamType int32 const ( - StreamType_UNKNOWN StreamType = 0 - StreamType_TRADES StreamType = 1 - StreamType_ORDERS StreamType = 2 - StreamType_BOOK_UPDATES StreamType = 3 - StreamType_TWAP StreamType = 4 - StreamType_EVENTS StreamType = 5 - StreamType_BLOCKS StreamType = 6 - StreamType_WRITER_ACTIONS StreamType = 7 + StreamType_UNKNOWN StreamType = 0 + StreamType_TRADES StreamType = 1 + StreamType_ORDERS StreamType = 2 + StreamType_BOOK_UPDATES StreamType = 3 + StreamType_TWAP StreamType = 4 + StreamType_EVENTS StreamType = 5 + StreamType_BLOCKS StreamType = 6 + StreamType_WRITER_ACTIONS StreamType = 7 + StreamType_MEMPOOL_TXS StreamType = 8 // Pre-consensus mempool transactions + StreamType_ORDER_PRIORITY StreamType = 9 // Derived order/write priority actions from mempool and confirmed replica data + StreamType_GOSSIP_PRIORITY StreamType = 10 // Derived gossip/read priority bid actions; does not measure delivery latency ) // Enum value maps for StreamType. var ( StreamType_name = map[int32]string{ - 0: "UNKNOWN", - 1: "TRADES", - 2: "ORDERS", - 3: "BOOK_UPDATES", - 4: "TWAP", - 5: "EVENTS", - 6: "BLOCKS", - 7: "WRITER_ACTIONS", + 0: "UNKNOWN", + 1: "TRADES", + 2: "ORDERS", + 3: "BOOK_UPDATES", + 4: "TWAP", + 5: "EVENTS", + 6: "BLOCKS", + 7: "WRITER_ACTIONS", + 8: "MEMPOOL_TXS", + 9: "ORDER_PRIORITY", + 10: "GOSSIP_PRIORITY", } StreamType_value = map[string]int32{ - "UNKNOWN": 0, - "TRADES": 1, - "ORDERS": 2, - "BOOK_UPDATES": 3, - "TWAP": 4, - "EVENTS": 5, - "BLOCKS": 6, - "WRITER_ACTIONS": 7, + "UNKNOWN": 0, + "TRADES": 1, + "ORDERS": 2, + "BOOK_UPDATES": 3, + "TWAP": 4, + "EVENTS": 5, + "BLOCKS": 6, + "WRITER_ACTIONS": 7, + "MEMPOOL_TXS": 8, + "ORDER_PRIORITY": 9, + "GOSSIP_PRIORITY": 10, } ) @@ -86,7 +94,6 @@ func (StreamType) EnumDescriptor() ([]byte, []int) { return file_streaming_proto_rawDescGZIP(), []int{0} } -// --- Requests --- type SubscribeRequest struct { state protoimpl.MessageState `protogen:"open.v1"` // Types that are valid to be assigned to Request: @@ -172,6 +179,7 @@ func (*SubscribeRequest_Ping) isSubscribeRequest_Request() {} type StreamSubscribe struct { state protoimpl.MessageState `protogen:"open.v1"` StreamType StreamType `protobuf:"varint,1,opt,name=stream_type,json=streamType,proto3,enum=hyperliquid.StreamType" json:"stream_type,omitempty"` + StartBlock uint64 `protobuf:"varint,2,opt,name=start_block,json=startBlock,proto3" json:"start_block,omitempty"` // Generic filters - field name to allowed values // Recursively searches each event for matching field/value pairs // Example: {"coin": ["ETH", "BTC"], "user": ["0x123..."], "type": ["deposit"]} @@ -220,6 +228,13 @@ func (x *StreamSubscribe) GetStreamType() StreamType { return StreamType_UNKNOWN } +func (x *StreamSubscribe) GetStartBlock() uint64 { + if x != nil { + return x.StartBlock + } + return 0 +} + func (x *StreamSubscribe) GetFilters() map[string]*FilterValues { if x != nil { return x.Filters @@ -323,7 +338,6 @@ func (x *Ping) GetTimestamp() int64 { return 0 } -// --- Responses --- type SubscribeUpdate struct { state protoimpl.MessageState `protogen:"open.v1"` // Types that are valid to be assigned to Update: @@ -406,6 +420,88 @@ func (*SubscribeUpdate_Data) isSubscribeUpdate_Update() {} func (*SubscribeUpdate_Pong) isSubscribeUpdate_Update() {} +type SubscribeBytesUpdate struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Types that are valid to be assigned to Update: + // + // *SubscribeBytesUpdate_Data + // *SubscribeBytesUpdate_Pong + Update isSubscribeBytesUpdate_Update `protobuf_oneof:"update"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *SubscribeBytesUpdate) Reset() { + *x = SubscribeBytesUpdate{} + mi := &file_streaming_proto_msgTypes[5] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *SubscribeBytesUpdate) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*SubscribeBytesUpdate) ProtoMessage() {} + +func (x *SubscribeBytesUpdate) ProtoReflect() protoreflect.Message { + mi := &file_streaming_proto_msgTypes[5] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use SubscribeBytesUpdate.ProtoReflect.Descriptor instead. +func (*SubscribeBytesUpdate) Descriptor() ([]byte, []int) { + return file_streaming_proto_rawDescGZIP(), []int{5} +} + +func (x *SubscribeBytesUpdate) GetUpdate() isSubscribeBytesUpdate_Update { + if x != nil { + return x.Update + } + return nil +} + +func (x *SubscribeBytesUpdate) GetData() *StreamBytesResponse { + if x != nil { + if x, ok := x.Update.(*SubscribeBytesUpdate_Data); ok { + return x.Data + } + } + return nil +} + +func (x *SubscribeBytesUpdate) GetPong() *Pong { + if x != nil { + if x, ok := x.Update.(*SubscribeBytesUpdate_Pong); ok { + return x.Pong + } + } + return nil +} + +type isSubscribeBytesUpdate_Update interface { + isSubscribeBytesUpdate_Update() +} + +type SubscribeBytesUpdate_Data struct { + Data *StreamBytesResponse `protobuf:"bytes,1,opt,name=data,proto3,oneof"` +} + +type SubscribeBytesUpdate_Pong struct { + Pong *Pong `protobuf:"bytes,2,opt,name=pong,proto3,oneof"` +} + +func (*SubscribeBytesUpdate_Data) isSubscribeBytesUpdate_Update() {} + +func (*SubscribeBytesUpdate_Pong) isSubscribeBytesUpdate_Update() {} + type StreamResponse struct { state protoimpl.MessageState `protogen:"open.v1"` BlockNumber uint64 `protobuf:"varint,1,opt,name=block_number,json=blockNumber,proto3" json:"block_number,omitempty"` @@ -418,7 +514,7 @@ type StreamResponse struct { func (x *StreamResponse) Reset() { *x = StreamResponse{} - mi := &file_streaming_proto_msgTypes[5] + mi := &file_streaming_proto_msgTypes[6] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -430,7 +526,7 @@ func (x *StreamResponse) String() string { func (*StreamResponse) ProtoMessage() {} func (x *StreamResponse) ProtoReflect() protoreflect.Message { - mi := &file_streaming_proto_msgTypes[5] + mi := &file_streaming_proto_msgTypes[6] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -443,7 +539,7 @@ func (x *StreamResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use StreamResponse.ProtoReflect.Descriptor instead. func (*StreamResponse) Descriptor() ([]byte, []int) { - return file_streaming_proto_rawDescGZIP(), []int{5} + return file_streaming_proto_rawDescGZIP(), []int{6} } func (x *StreamResponse) GetBlockNumber() uint64 { @@ -467,6 +563,67 @@ func (x *StreamResponse) GetData() string { return "" } +type StreamBytesResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + BlockNumber uint64 `protobuf:"varint,1,opt,name=block_number,json=blockNumber,proto3" json:"block_number,omitempty"` + Timestamp uint64 `protobuf:"varint,2,opt,name=timestamp,proto3" json:"timestamp,omitempty"` // Server ingress timestamp + // Raw payload bytes. Fast-path clients should prefer this over StreamResponse.data. + Data []byte `protobuf:"bytes,3,opt,name=data,proto3" json:"data,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *StreamBytesResponse) Reset() { + *x = StreamBytesResponse{} + mi := &file_streaming_proto_msgTypes[7] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *StreamBytesResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*StreamBytesResponse) ProtoMessage() {} + +func (x *StreamBytesResponse) ProtoReflect() protoreflect.Message { + mi := &file_streaming_proto_msgTypes[7] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use StreamBytesResponse.ProtoReflect.Descriptor instead. +func (*StreamBytesResponse) Descriptor() ([]byte, []int) { + return file_streaming_proto_rawDescGZIP(), []int{7} +} + +func (x *StreamBytesResponse) GetBlockNumber() uint64 { + if x != nil { + return x.BlockNumber + } + return 0 +} + +func (x *StreamBytesResponse) GetTimestamp() uint64 { + if x != nil { + return x.Timestamp + } + return 0 +} + +func (x *StreamBytesResponse) GetData() []byte { + if x != nil { + return x.Data + } + return nil +} + type Block struct { state protoimpl.MessageState `protogen:"open.v1"` DataJson string `protobuf:"bytes,1,opt,name=data_json,json=dataJson,proto3" json:"data_json,omitempty"` @@ -476,7 +633,7 @@ type Block struct { func (x *Block) Reset() { *x = Block{} - mi := &file_streaming_proto_msgTypes[6] + mi := &file_streaming_proto_msgTypes[8] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -488,7 +645,7 @@ func (x *Block) String() string { func (*Block) ProtoMessage() {} func (x *Block) ProtoReflect() protoreflect.Message { - mi := &file_streaming_proto_msgTypes[6] + mi := &file_streaming_proto_msgTypes[8] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -501,7 +658,7 @@ func (x *Block) ProtoReflect() protoreflect.Message { // Deprecated: Use Block.ProtoReflect.Descriptor instead. func (*Block) Descriptor() ([]byte, []int) { - return file_streaming_proto_rawDescGZIP(), []int{6} + return file_streaming_proto_rawDescGZIP(), []int{8} } func (x *Block) GetDataJson() string { @@ -520,7 +677,7 @@ type Pong struct { func (x *Pong) Reset() { *x = Pong{} - mi := &file_streaming_proto_msgTypes[7] + mi := &file_streaming_proto_msgTypes[9] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -532,7 +689,7 @@ func (x *Pong) String() string { func (*Pong) ProtoMessage() {} func (x *Pong) ProtoReflect() protoreflect.Message { - mi := &file_streaming_proto_msgTypes[7] + mi := &file_streaming_proto_msgTypes[9] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -545,7 +702,7 @@ func (x *Pong) ProtoReflect() protoreflect.Message { // Deprecated: Use Pong.ProtoReflect.Descriptor instead. func (*Pong) Descriptor() ([]byte, []int) { - return file_streaming_proto_rawDescGZIP(), []int{7} + return file_streaming_proto_rawDescGZIP(), []int{9} } func (x *Pong) GetTimestamp() int64 { @@ -564,7 +721,7 @@ type Timestamp struct { func (x *Timestamp) Reset() { *x = Timestamp{} - mi := &file_streaming_proto_msgTypes[8] + mi := &file_streaming_proto_msgTypes[10] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -576,7 +733,7 @@ func (x *Timestamp) String() string { func (*Timestamp) ProtoMessage() {} func (x *Timestamp) ProtoReflect() protoreflect.Message { - mi := &file_streaming_proto_msgTypes[8] + mi := &file_streaming_proto_msgTypes[10] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -589,7 +746,7 @@ func (x *Timestamp) ProtoReflect() protoreflect.Message { // Deprecated: Use Timestamp.ProtoReflect.Descriptor instead. func (*Timestamp) Descriptor() ([]byte, []int) { - return file_streaming_proto_rawDescGZIP(), []int{8} + return file_streaming_proto_rawDescGZIP(), []int{10} } func (x *Timestamp) GetTimestamp() int64 { @@ -608,7 +765,7 @@ type PingRequest struct { func (x *PingRequest) Reset() { *x = PingRequest{} - mi := &file_streaming_proto_msgTypes[9] + mi := &file_streaming_proto_msgTypes[11] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -620,7 +777,7 @@ func (x *PingRequest) String() string { func (*PingRequest) ProtoMessage() {} func (x *PingRequest) ProtoReflect() protoreflect.Message { - mi := &file_streaming_proto_msgTypes[9] + mi := &file_streaming_proto_msgTypes[11] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -633,7 +790,7 @@ func (x *PingRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use PingRequest.ProtoReflect.Descriptor instead. func (*PingRequest) Descriptor() ([]byte, []int) { - return file_streaming_proto_rawDescGZIP(), []int{9} + return file_streaming_proto_rawDescGZIP(), []int{11} } func (x *PingRequest) GetCount() int32 { @@ -652,7 +809,7 @@ type PingResponse struct { func (x *PingResponse) Reset() { *x = PingResponse{} - mi := &file_streaming_proto_msgTypes[10] + mi := &file_streaming_proto_msgTypes[12] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -664,7 +821,7 @@ func (x *PingResponse) String() string { func (*PingResponse) ProtoMessage() {} func (x *PingResponse) ProtoReflect() protoreflect.Message { - mi := &file_streaming_proto_msgTypes[10] + mi := &file_streaming_proto_msgTypes[12] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -677,7 +834,7 @@ func (x *PingResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use PingResponse.ProtoReflect.Descriptor instead. func (*PingResponse) Descriptor() ([]byte, []int) { - return file_streaming_proto_rawDescGZIP(), []int{10} + return file_streaming_proto_rawDescGZIP(), []int{12} } func (x *PingResponse) GetCount() int32 { @@ -695,10 +852,12 @@ const file_streaming_proto_rawDesc = "" + "\x10SubscribeRequest\x12<\n" + "\tsubscribe\x18\x01 \x01(\v2\x1c.hyperliquid.StreamSubscribeH\x00R\tsubscribe\x12'\n" + "\x04ping\x18\x03 \x01(\v2\x11.hyperliquid.PingH\x00R\x04pingB\t\n" + - "\arequestJ\x04\b\x02\x10\x03\"\x88\x02\n" + + "\arequestJ\x04\b\x02\x10\x03\"\xa9\x02\n" + "\x0fStreamSubscribe\x128\n" + "\vstream_type\x18\x01 \x01(\x0e2\x17.hyperliquid.StreamTypeR\n" + - "streamType\x12C\n" + + "streamType\x12\x1f\n" + + "\vstart_block\x18\x02 \x01(\x04R\n" + + "startBlock\x12C\n" + "\afilters\x18\x03 \x03(\v2).hyperliquid.StreamSubscribe.FiltersEntryR\afilters\x12\x1f\n" + "\vfilter_name\x18\x04 \x01(\tR\n" + "filterName\x1aU\n" + @@ -712,11 +871,19 @@ const file_streaming_proto_rawDesc = "" + "\x0fSubscribeUpdate\x121\n" + "\x04data\x18\x01 \x01(\v2\x1b.hyperliquid.StreamResponseH\x00R\x04data\x12'\n" + "\x04pong\x18\x02 \x01(\v2\x11.hyperliquid.PongH\x00R\x04pongB\b\n" + + "\x06update\"\x81\x01\n" + + "\x14SubscribeBytesUpdate\x126\n" + + "\x04data\x18\x01 \x01(\v2 .hyperliquid.StreamBytesResponseH\x00R\x04data\x12'\n" + + "\x04pong\x18\x02 \x01(\v2\x11.hyperliquid.PongH\x00R\x04pongB\b\n" + "\x06update\"e\n" + "\x0eStreamResponse\x12!\n" + "\fblock_number\x18\x01 \x01(\x04R\vblockNumber\x12\x1c\n" + "\ttimestamp\x18\x02 \x01(\x04R\ttimestamp\x12\x12\n" + - "\x04data\x18\x03 \x01(\tR\x04data\"$\n" + + "\x04data\x18\x03 \x01(\tR\x04data\"j\n" + + "\x13StreamBytesResponse\x12!\n" + + "\fblock_number\x18\x01 \x01(\x04R\vblockNumber\x12\x1c\n" + + "\ttimestamp\x18\x02 \x01(\x04R\ttimestamp\x12\x12\n" + + "\x04data\x18\x03 \x01(\fR\x04data\"$\n" + "\x05Block\x12\x1b\n" + "\tdata_json\x18\x01 \x01(\tR\bdataJson\"$\n" + "\x04Pong\x12\x1c\n" + @@ -726,7 +893,7 @@ const file_streaming_proto_rawDesc = "" + "\vPingRequest\x12\x14\n" + "\x05count\x18\x01 \x01(\x05R\x05count\"$\n" + "\fPingResponse\x12\x14\n" + - "\x05count\x18\x01 \x01(\x05R\x05count*y\n" + + "\x05count\x18\x01 \x01(\x05R\x05count*\xb3\x01\n" + "\n" + "StreamType\x12\v\n" + "\aUNKNOWN\x10\x00\x12\n" + @@ -740,10 +907,15 @@ const file_streaming_proto_rawDesc = "" + "\x06EVENTS\x10\x05\x12\n" + "\n" + "\x06BLOCKS\x10\x06\x12\x12\n" + - "\x0eWRITER_ACTIONS\x10\a2\x97\x01\n" + + "\x0eWRITER_ACTIONS\x10\a\x12\x0f\n" + + "\vMEMPOOL_TXS\x10\b\x12\x12\n" + + "\x0eORDER_PRIORITY\x10\t\x12\x13\n" + + "\x0fGOSSIP_PRIORITY\x10\n" + + "2\xf0\x01\n" + "\tStreaming\x12M\n" + "\n" + - "StreamData\x12\x1d.hyperliquid.SubscribeRequest\x1a\x1c.hyperliquid.SubscribeUpdate(\x010\x01\x12;\n" + + "StreamData\x12\x1d.hyperliquid.SubscribeRequest\x1a\x1c.hyperliquid.SubscribeUpdate(\x010\x01\x12W\n" + + "\x0fStreamDataBytes\x12\x1d.hyperliquid.SubscribeRequest\x1a!.hyperliquid.SubscribeBytesUpdate(\x010\x01\x12;\n" + "\x04Ping\x12\x18.hyperliquid.PingRequest\x1a\x19.hyperliquid.PingResponse2N\n" + "\x0eBlockStreaming\x12<\n" + "\fStreamBlocks\x12\x16.hyperliquid.Timestamp\x1a\x12.hyperliquid.Block0\x01B?Z=github.com/quiknode-labs/hyperliquid-sdk/go/hyperliquid/protob\x06proto3" @@ -761,41 +933,47 @@ func file_streaming_proto_rawDescGZIP() []byte { } var file_streaming_proto_enumTypes = make([]protoimpl.EnumInfo, 1) -var file_streaming_proto_msgTypes = make([]protoimpl.MessageInfo, 12) +var file_streaming_proto_msgTypes = make([]protoimpl.MessageInfo, 14) var file_streaming_proto_goTypes = []any{ - (StreamType)(0), // 0: hyperliquid.StreamType - (*SubscribeRequest)(nil), // 1: hyperliquid.SubscribeRequest - (*StreamSubscribe)(nil), // 2: hyperliquid.StreamSubscribe - (*FilterValues)(nil), // 3: hyperliquid.FilterValues - (*Ping)(nil), // 4: hyperliquid.Ping - (*SubscribeUpdate)(nil), // 5: hyperliquid.SubscribeUpdate - (*StreamResponse)(nil), // 6: hyperliquid.StreamResponse - (*Block)(nil), // 7: hyperliquid.Block - (*Pong)(nil), // 8: hyperliquid.Pong - (*Timestamp)(nil), // 9: hyperliquid.Timestamp - (*PingRequest)(nil), // 10: hyperliquid.PingRequest - (*PingResponse)(nil), // 11: hyperliquid.PingResponse - nil, // 12: hyperliquid.StreamSubscribe.FiltersEntry + (StreamType)(0), // 0: hyperliquid.StreamType + (*SubscribeRequest)(nil), // 1: hyperliquid.SubscribeRequest + (*StreamSubscribe)(nil), // 2: hyperliquid.StreamSubscribe + (*FilterValues)(nil), // 3: hyperliquid.FilterValues + (*Ping)(nil), // 4: hyperliquid.Ping + (*SubscribeUpdate)(nil), // 5: hyperliquid.SubscribeUpdate + (*SubscribeBytesUpdate)(nil), // 6: hyperliquid.SubscribeBytesUpdate + (*StreamResponse)(nil), // 7: hyperliquid.StreamResponse + (*StreamBytesResponse)(nil), // 8: hyperliquid.StreamBytesResponse + (*Block)(nil), // 9: hyperliquid.Block + (*Pong)(nil), // 10: hyperliquid.Pong + (*Timestamp)(nil), // 11: hyperliquid.Timestamp + (*PingRequest)(nil), // 12: hyperliquid.PingRequest + (*PingResponse)(nil), // 13: hyperliquid.PingResponse + nil, // 14: hyperliquid.StreamSubscribe.FiltersEntry } var file_streaming_proto_depIdxs = []int32{ 2, // 0: hyperliquid.SubscribeRequest.subscribe:type_name -> hyperliquid.StreamSubscribe 4, // 1: hyperliquid.SubscribeRequest.ping:type_name -> hyperliquid.Ping 0, // 2: hyperliquid.StreamSubscribe.stream_type:type_name -> hyperliquid.StreamType - 12, // 3: hyperliquid.StreamSubscribe.filters:type_name -> hyperliquid.StreamSubscribe.FiltersEntry - 6, // 4: hyperliquid.SubscribeUpdate.data:type_name -> hyperliquid.StreamResponse - 8, // 5: hyperliquid.SubscribeUpdate.pong:type_name -> hyperliquid.Pong - 3, // 6: hyperliquid.StreamSubscribe.FiltersEntry.value:type_name -> hyperliquid.FilterValues - 1, // 7: hyperliquid.Streaming.StreamData:input_type -> hyperliquid.SubscribeRequest - 10, // 8: hyperliquid.Streaming.Ping:input_type -> hyperliquid.PingRequest - 9, // 9: hyperliquid.BlockStreaming.StreamBlocks:input_type -> hyperliquid.Timestamp - 5, // 10: hyperliquid.Streaming.StreamData:output_type -> hyperliquid.SubscribeUpdate - 11, // 11: hyperliquid.Streaming.Ping:output_type -> hyperliquid.PingResponse - 7, // 12: hyperliquid.BlockStreaming.StreamBlocks:output_type -> hyperliquid.Block - 10, // [10:13] is the sub-list for method output_type - 7, // [7:10] is the sub-list for method input_type - 7, // [7:7] is the sub-list for extension type_name - 7, // [7:7] is the sub-list for extension extendee - 0, // [0:7] is the sub-list for field type_name + 14, // 3: hyperliquid.StreamSubscribe.filters:type_name -> hyperliquid.StreamSubscribe.FiltersEntry + 7, // 4: hyperliquid.SubscribeUpdate.data:type_name -> hyperliquid.StreamResponse + 10, // 5: hyperliquid.SubscribeUpdate.pong:type_name -> hyperliquid.Pong + 8, // 6: hyperliquid.SubscribeBytesUpdate.data:type_name -> hyperliquid.StreamBytesResponse + 10, // 7: hyperliquid.SubscribeBytesUpdate.pong:type_name -> hyperliquid.Pong + 3, // 8: hyperliquid.StreamSubscribe.FiltersEntry.value:type_name -> hyperliquid.FilterValues + 1, // 9: hyperliquid.Streaming.StreamData:input_type -> hyperliquid.SubscribeRequest + 1, // 10: hyperliquid.Streaming.StreamDataBytes:input_type -> hyperliquid.SubscribeRequest + 12, // 11: hyperliquid.Streaming.Ping:input_type -> hyperliquid.PingRequest + 11, // 12: hyperliquid.BlockStreaming.StreamBlocks:input_type -> hyperliquid.Timestamp + 5, // 13: hyperliquid.Streaming.StreamData:output_type -> hyperliquid.SubscribeUpdate + 6, // 14: hyperliquid.Streaming.StreamDataBytes:output_type -> hyperliquid.SubscribeBytesUpdate + 13, // 15: hyperliquid.Streaming.Ping:output_type -> hyperliquid.PingResponse + 9, // 16: hyperliquid.BlockStreaming.StreamBlocks:output_type -> hyperliquid.Block + 13, // [13:17] is the sub-list for method output_type + 9, // [9:13] is the sub-list for method input_type + 9, // [9:9] is the sub-list for extension type_name + 9, // [9:9] is the sub-list for extension extendee + 0, // [0:9] is the sub-list for field type_name } func init() { file_streaming_proto_init() } @@ -811,13 +989,17 @@ func file_streaming_proto_init() { (*SubscribeUpdate_Data)(nil), (*SubscribeUpdate_Pong)(nil), } + file_streaming_proto_msgTypes[5].OneofWrappers = []any{ + (*SubscribeBytesUpdate_Data)(nil), + (*SubscribeBytesUpdate_Pong)(nil), + } type x struct{} out := protoimpl.TypeBuilder{ File: protoimpl.DescBuilder{ GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: unsafe.Slice(unsafe.StringData(file_streaming_proto_rawDesc), len(file_streaming_proto_rawDesc)), NumEnums: 1, - NumMessages: 12, + NumMessages: 14, NumExtensions: 0, NumServices: 2, }, diff --git a/go/hyperliquid/proto/streaming.proto b/go/hyperliquid/proto/streaming.proto index 22c3299..a24c96e 100644 --- a/go/hyperliquid/proto/streaming.proto +++ b/go/hyperliquid/proto/streaming.proto @@ -1,4 +1,5 @@ syntax = "proto3"; + package hyperliquid; option go_package = "github.com/quiknode-labs/hyperliquid-sdk/go/hyperliquid/proto"; @@ -6,6 +7,7 @@ option go_package = "github.com/quiknode-labs/hyperliquid-sdk/go/hyperliquid/pro service Streaming { // Bi-directional streaming rpc StreamData (stream SubscribeRequest) returns (stream SubscribeUpdate); + rpc StreamDataBytes (stream SubscribeRequest) returns (stream SubscribeBytesUpdate); rpc Ping (PingRequest) returns (PingResponse); } @@ -14,7 +16,9 @@ service BlockStreaming { rpc StreamBlocks (Timestamp) returns (stream Block); } + // --- Requests --- + message SubscribeRequest { oneof request { StreamSubscribe subscribe = 1; @@ -25,6 +29,7 @@ message SubscribeRequest { message StreamSubscribe { StreamType stream_type = 1; + uint64 start_block = 2; // Generic filters - field name to allowed values // Recursively searches each event for matching field/value pairs @@ -44,6 +49,7 @@ message FilterValues { message Ping { int64 timestamp = 1; } // --- Responses --- + message SubscribeUpdate { oneof update { StreamResponse data = 1; @@ -51,14 +57,31 @@ message SubscribeUpdate { } } +message SubscribeBytesUpdate { + oneof update { + StreamBytesResponse data = 1; + Pong pong = 2; + } +} + message StreamResponse { uint64 block_number = 1; uint64 timestamp = 2; // Server ingress timestamp + // Raw JSON data from the file (Exact replica of source) string data = 3; } +message StreamBytesResponse { + uint64 block_number = 1; + uint64 timestamp = 2; // Server ingress timestamp + + // Raw payload bytes. Fast-path clients should prefer this over StreamResponse.data. + bytes data = 3; +} + // --- Data Types --- + enum StreamType { UNKNOWN = 0; TRADES = 1; @@ -68,12 +91,16 @@ enum StreamType { EVENTS = 5; BLOCKS = 6; WRITER_ACTIONS = 7; + MEMPOOL_TXS = 8; // Pre-consensus mempool transactions + ORDER_PRIORITY = 9; // Derived order/write priority actions from mempool and confirmed replica data + GOSSIP_PRIORITY = 10; // Derived gossip/read priority bid actions; does not measure delivery latency } message Block { - string data_json = 1; + string data_json = 1; } + message Pong { int64 timestamp = 1; } message Timestamp { int64 timestamp = 1; } message PingRequest { int32 count = 1; } diff --git a/go/hyperliquid/proto/streaming_grpc.pb.go b/go/hyperliquid/proto/streaming_grpc.pb.go index 4d0d1b2..2df6683 100644 --- a/go/hyperliquid/proto/streaming_grpc.pb.go +++ b/go/hyperliquid/proto/streaming_grpc.pb.go @@ -1,7 +1,7 @@ // Code generated by protoc-gen-go-grpc. DO NOT EDIT. // versions: // - protoc-gen-go-grpc v1.5.1 -// - protoc v6.32.1 +// - protoc v4.25.3 // source: streaming.proto package proto @@ -19,8 +19,9 @@ import ( const _ = grpc.SupportPackageIsVersion9 const ( - Streaming_StreamData_FullMethodName = "/hyperliquid.Streaming/StreamData" - Streaming_Ping_FullMethodName = "/hyperliquid.Streaming/Ping" + Streaming_StreamData_FullMethodName = "/hyperliquid.Streaming/StreamData" + Streaming_StreamDataBytes_FullMethodName = "/hyperliquid.Streaming/StreamDataBytes" + Streaming_Ping_FullMethodName = "/hyperliquid.Streaming/Ping" ) // StreamingClient is the client API for Streaming service. @@ -29,6 +30,7 @@ const ( type StreamingClient interface { // Bi-directional streaming StreamData(ctx context.Context, opts ...grpc.CallOption) (grpc.BidiStreamingClient[SubscribeRequest, SubscribeUpdate], error) + StreamDataBytes(ctx context.Context, opts ...grpc.CallOption) (grpc.BidiStreamingClient[SubscribeRequest, SubscribeBytesUpdate], error) Ping(ctx context.Context, in *PingRequest, opts ...grpc.CallOption) (*PingResponse, error) } @@ -53,6 +55,19 @@ func (c *streamingClient) StreamData(ctx context.Context, opts ...grpc.CallOptio // This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name. type Streaming_StreamDataClient = grpc.BidiStreamingClient[SubscribeRequest, SubscribeUpdate] +func (c *streamingClient) StreamDataBytes(ctx context.Context, opts ...grpc.CallOption) (grpc.BidiStreamingClient[SubscribeRequest, SubscribeBytesUpdate], error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + stream, err := c.cc.NewStream(ctx, &Streaming_ServiceDesc.Streams[1], Streaming_StreamDataBytes_FullMethodName, cOpts...) + if err != nil { + return nil, err + } + x := &grpc.GenericClientStream[SubscribeRequest, SubscribeBytesUpdate]{ClientStream: stream} + return x, nil +} + +// This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name. +type Streaming_StreamDataBytesClient = grpc.BidiStreamingClient[SubscribeRequest, SubscribeBytesUpdate] + func (c *streamingClient) Ping(ctx context.Context, in *PingRequest, opts ...grpc.CallOption) (*PingResponse, error) { cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) out := new(PingResponse) @@ -69,6 +84,7 @@ func (c *streamingClient) Ping(ctx context.Context, in *PingRequest, opts ...grp type StreamingServer interface { // Bi-directional streaming StreamData(grpc.BidiStreamingServer[SubscribeRequest, SubscribeUpdate]) error + StreamDataBytes(grpc.BidiStreamingServer[SubscribeRequest, SubscribeBytesUpdate]) error Ping(context.Context, *PingRequest) (*PingResponse, error) mustEmbedUnimplementedStreamingServer() } @@ -83,6 +99,9 @@ type UnimplementedStreamingServer struct{} func (UnimplementedStreamingServer) StreamData(grpc.BidiStreamingServer[SubscribeRequest, SubscribeUpdate]) error { return status.Errorf(codes.Unimplemented, "method StreamData not implemented") } +func (UnimplementedStreamingServer) StreamDataBytes(grpc.BidiStreamingServer[SubscribeRequest, SubscribeBytesUpdate]) error { + return status.Errorf(codes.Unimplemented, "method StreamDataBytes not implemented") +} func (UnimplementedStreamingServer) Ping(context.Context, *PingRequest) (*PingResponse, error) { return nil, status.Errorf(codes.Unimplemented, "method Ping not implemented") } @@ -114,6 +133,13 @@ func _Streaming_StreamData_Handler(srv interface{}, stream grpc.ServerStream) er // This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name. type Streaming_StreamDataServer = grpc.BidiStreamingServer[SubscribeRequest, SubscribeUpdate] +func _Streaming_StreamDataBytes_Handler(srv interface{}, stream grpc.ServerStream) error { + return srv.(StreamingServer).StreamDataBytes(&grpc.GenericServerStream[SubscribeRequest, SubscribeBytesUpdate]{ServerStream: stream}) +} + +// This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name. +type Streaming_StreamDataBytesServer = grpc.BidiStreamingServer[SubscribeRequest, SubscribeBytesUpdate] + func _Streaming_Ping_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { in := new(PingRequest) if err := dec(in); err != nil { @@ -151,6 +177,12 @@ var Streaming_ServiceDesc = grpc.ServiceDesc{ ServerStreams: true, ClientStreams: true, }, + { + StreamName: "StreamDataBytes", + Handler: _Streaming_StreamDataBytes_Handler, + ServerStreams: true, + ClientStreams: true, + }, }, Metadata: "streaming.proto", } diff --git a/go/hyperliquid/sdk.go b/go/hyperliquid/sdk.go index bb0d2a0..d42d476 100644 --- a/go/hyperliquid/sdk.go +++ b/go/hyperliquid/sdk.go @@ -360,14 +360,16 @@ func (s *SDK) NewEVMStream(config *EVMStreamConfig) *EVMStream { type OrderOption func(*orderParams) type orderParams struct { - size string - notional float64 - price string - tif TIF - reduceOnly bool - grouping OrderGrouping - slippage *float64 - priorityFee *uint64 + size string + notional float64 + price string + tif TIF + reduceOnly bool + grouping OrderGrouping + slippage *float64 + priorityFee *uint64 + vaultAddress string + expiresAfter *int64 } // WithSize sets the order size in asset units. @@ -429,6 +431,25 @@ func WithPriorityFee(priorityFee uint64) OrderOption { } } +// WithVaultAddress places the order on behalf of a vault (or subaccount). +// The address is sent as the top-level vaultAddress exchange field and is +// folded into the signed action hash by the build endpoint. +func WithVaultAddress(vaultAddress string) OrderOption { + return func(p *orderParams) { + p.vaultAddress = vaultAddress + } +} + +// WithExpiresAfter sets the action TTL as a millisecond timestamp after which +// the order is rejected by the exchange. It is sent as the top-level +// expiresAfter exchange field and is folded into the signed action hash by +// the build endpoint. +func WithExpiresAfter(expiresAfterMs int64) OrderOption { + return func(p *orderParams) { + p.expiresAfter = &expiresAfterMs + } +} + // Buy places a buy order. func (s *SDK) Buy(asset any, opts ...OrderOption) (*PlacedOrder, error) { return s.placeOrder(asset, SideBuy, opts...) @@ -483,7 +504,12 @@ func (s *SDK) PlaceOrder(order *OrderBuilder) (*PlacedOrder, error) { order.SetSize(NewDecimal(size).String()) } - return s.executeOrder(order, OrderGroupingNA, order.GetSlippage(), order.GetPriorityFee()) + return s.executeOrder(order, OrderGroupingNA, &exchangeParams{ + slippage: order.GetSlippage(), + priorityFee: order.GetPriorityFee(), + vaultAddress: order.GetVaultAddress(), + expiresAfter: order.GetExpiresAfter(), + }) } func (s *SDK) placeOrder(assetInput any, side Side, opts ...OrderOption) (*PlacedOrder, error) { @@ -529,29 +555,28 @@ func (s *SDK) placeOrder(assetInput any, side Side, opts ...OrderOption) (*Place } order.reduceOnly = params.reduceOnly - return s.executeOrder(order, params.grouping, params.slippage, params.priorityFee) + return s.executeOrder(order, params.grouping, &exchangeParams{ + slippage: params.slippage, + priorityFee: params.priorityFee, + vaultAddress: params.vaultAddress, + expiresAfter: params.expiresAfter, + }) } -func (s *SDK) executeOrder(order *OrderBuilder, grouping OrderGrouping, slippage *float64, priorityFee *uint64) (*PlacedOrder, error) { +func (s *SDK) executeOrder(order *OrderBuilder, grouping OrderGrouping, params *exchangeParams) (*PlacedOrder, error) { action := order.ToAction() - if grouping != OrderGroupingNA && priorityFee != nil { + if grouping != OrderGroupingNA && params.priorityFee != nil { return nil, ValidationError("priorityFee cannot be combined with TP/SL grouping"). WithGuidance("Use priorityFee on standalone IOC/market orders, or omit grouping.") } - if err := s.validatePredictionOrder(order.Asset(), order.GetSize(), order.GetPrice(), order.GetTIF() == TIFMarket, order.GetSide() == SideBuy, priorityFee); err != nil { + if err := s.validatePredictionOrder(order.Asset(), order.GetSize(), order.GetPrice(), order.GetTIF() == TIFMarket, order.GetSide() == SideBuy, params.priorityFee); err != nil { return nil, err } if grouping != OrderGroupingNA { action["grouping"] = string(grouping) } - var result map[string]any - var err error - if priorityFee != nil { - result, err = s.buildSignSend(action, slippage, *priorityFee) - } else { - result, err = s.buildSignSend(action, slippage) - } + result, err := s.buildSignSendParams(action, params) if err != nil { return nil, err } @@ -570,7 +595,7 @@ func (s *SDK) StopLoss(asset string, size any, triggerPrice any, opts ...Trigger for _, opt := range opts { opt(order) } - return s.executeTriggerOrder(order, OrderGroupingNA) + return s.executeTriggerOrder(order, order.GetGrouping()) } // TakeProfit places a take-profit trigger order. @@ -579,7 +604,7 @@ func (s *SDK) TakeProfit(asset string, size any, triggerPrice any, opts ...Trigg for _, opt := range opts { opt(order) } - return s.executeTriggerOrder(order, OrderGroupingNA) + return s.executeTriggerOrder(order, order.GetGrouping()) } // SL is an alias for StopLoss. @@ -612,7 +637,23 @@ func TriggerWithSide(side Side) TriggerOrderOption { // TriggerWithGrouping sets the grouping for trigger order. func TriggerWithGrouping(grouping OrderGrouping) TriggerOrderOption { return func(t *TriggerOrderBuilder) { - // Grouping is passed to executeTriggerOrder + t.Grouping(grouping) + } +} + +// TriggerWithVaultAddress places the trigger order on behalf of a vault +// (or subaccount). See WithVaultAddress. +func TriggerWithVaultAddress(vaultAddress string) TriggerOrderOption { + return func(t *TriggerOrderBuilder) { + t.VaultAddress(vaultAddress) + } +} + +// TriggerWithExpiresAfter sets the action TTL (ms timestamp) for the trigger +// order. See WithExpiresAfter. +func TriggerWithExpiresAfter(expiresAfterMs int64) TriggerOrderOption { + return func(t *TriggerOrderBuilder) { + t.ExpiresAfter(expiresAfterMs) } } @@ -627,7 +668,10 @@ func (s *SDK) executeTriggerOrder(order *TriggerOrderBuilder, grouping OrderGrou } action := order.ToAction(grouping) - result, err := s.buildSignSend(action, nil) + result, err := s.buildSignSendParams(action, &exchangeParams{ + vaultAddress: order.GetVaultAddress(), + expiresAfter: order.GetExpiresAfter(), + }) if err != nil { return nil, err } @@ -640,12 +684,55 @@ func (s *SDK) executeTriggerOrder(order *TriggerOrderBuilder, grouping OrderGrou // ORDER MANAGEMENT // ═══════════════════════════════════════════════════════════════════════════════ +type cancelParams struct { + fast bool + vaultAddress string + expiresAfter *int64 +} + +// CancelOption is an option for order cancellation. +type CancelOption func(*cancelParams) + +// CancelWithFast requests fast cancellation: the cancel action carries the +// "f": true flag (only emitted when set, matching backend semantics). +func CancelWithFast() CancelOption { + return func(p *cancelParams) { + p.fast = true + } +} + +// CancelWithVaultAddress cancels on behalf of a vault (or subaccount). +// See WithVaultAddress. +func CancelWithVaultAddress(vaultAddress string) CancelOption { + return func(p *cancelParams) { + p.vaultAddress = vaultAddress + } +} + +// CancelWithExpiresAfter sets the action TTL (ms timestamp) for the cancel. +// See WithExpiresAfter. +func CancelWithExpiresAfter(expiresAfterMs int64) CancelOption { + return func(p *cancelParams) { + p.expiresAfter = &expiresAfterMs + } +} + +func applyCancelOptions(opts []CancelOption) *cancelParams { + params := &cancelParams{} + for _, opt := range opts { + opt(params) + } + return params +} + // Cancel cancels an order by OID. -func (s *SDK) Cancel(oid int64, asset string) (map[string]any, error) { +func (s *SDK) Cancel(oid int64, asset string, opts ...CancelOption) (map[string]any, error) { if oid <= 0 { return nil, ValidationError(fmt.Sprintf("oid must be a positive integer, got: %d", oid)) } + params := applyCancelOptions(opts) + assetIdx := 0 if asset != "" { idx, err := s.resolveAssetIndex(asset) @@ -660,13 +747,23 @@ func (s *SDK) Cancel(oid int64, asset string) (map[string]any, error) { map[string]any{"a": assetIdx, "o": oid}, }, } + if params.fast { + action["f"] = true + } - return s.buildSignSend(action, nil) + return s.buildSignSendParams(action, &exchangeParams{ + vaultAddress: params.vaultAddress, + expiresAfter: params.expiresAfter, + }) } // CancelAll cancels all open orders, optionally filtered by asset. -func (s *SDK) CancelAll(asset string) (map[string]any, error) { - orders, err := s.OpenOrders("") +func (s *SDK) CancelAll(asset string, opts ...CancelOption) (map[string]any, error) { + params := applyCancelOptions(opts) + + // When cancelling on behalf of a vault, enumerate the vault's open orders, + // not the wallet's. + orders, err := s.OpenOrders(params.vaultAddress) if err != nil { return nil, err } @@ -698,11 +795,20 @@ func (s *SDK) CancelAll(asset string) (map[string]any, error) { } } - return s.buildSignSend(cancelAction, nil) + if params.fast { + cancelAction["f"] = true + } + + return s.buildSignSendParams(cancelAction, &exchangeParams{ + vaultAddress: params.vaultAddress, + expiresAfter: params.expiresAfter, + }) } // CancelByCloid cancels an order by client order ID. -func (s *SDK) CancelByCloid(cloid string, asset string) (map[string]any, error) { +func (s *SDK) CancelByCloid(cloid string, asset string, opts ...CancelOption) (map[string]any, error) { + params := applyCancelOptions(opts) + assetIdx, err := s.resolveAssetIndex(asset) if err != nil { return nil, err @@ -714,8 +820,14 @@ func (s *SDK) CancelByCloid(cloid string, asset string) (map[string]any, error) map[string]any{"asset": assetIdx, "cloid": cloid}, }, } + if params.fast { + action["f"] = true + } - return s.buildSignSend(action, nil) + return s.buildSignSendParams(action, &exchangeParams{ + vaultAddress: params.vaultAddress, + expiresAfter: params.expiresAfter, + }) } // ScheduleCancel schedules cancellation of all orders after a delay. @@ -771,7 +883,10 @@ func (s *SDK) Modify(oid int64, asset, side, price, size string, opts ...ModifyO }, } - result, err := s.buildSignSend(action, nil) + result, err := s.buildSignSendParams(action, &exchangeParams{ + vaultAddress: params.vaultAddress, + expiresAfter: params.expiresAfter, + }) if err != nil { return nil, err } @@ -785,8 +900,10 @@ func (s *SDK) Modify(oid int64, asset, side, price, size string, opts ...ModifyO } type modifyParams struct { - tif TIF - reduceOnly bool + tif TIF + reduceOnly bool + vaultAddress string + expiresAfter *int64 } // ModifyOption is an option for order modification. @@ -806,11 +923,29 @@ func ModifyWithReduceOnly() ModifyOption { } } +// ModifyWithVaultAddress modifies on behalf of a vault (or subaccount). +// See WithVaultAddress. +func ModifyWithVaultAddress(vaultAddress string) ModifyOption { + return func(p *modifyParams) { + p.vaultAddress = vaultAddress + } +} + +// ModifyWithExpiresAfter sets the action TTL (ms timestamp) for the modify. +// See WithExpiresAfter. +func ModifyWithExpiresAfter(expiresAfterMs int64) ModifyOption { + return func(p *modifyParams) { + p.expiresAfter = &expiresAfterMs + } +} + // CloseOption is an option for closing a position. type CloseOption func(*closeParams) type closeParams struct { - slippage *float64 + slippage *float64 + vaultAddress string + expiresAfter *int64 } // CloseWithSlippage overrides the default slippage for this close. @@ -821,6 +956,22 @@ func CloseWithSlippage(slippage float64) CloseOption { } } +// CloseWithVaultAddress closes a vault (or subaccount) position. +// See WithVaultAddress. +func CloseWithVaultAddress(vaultAddress string) CloseOption { + return func(p *closeParams) { + p.vaultAddress = vaultAddress + } +} + +// CloseWithExpiresAfter sets the action TTL (ms timestamp) for the close. +// See WithExpiresAfter. +func CloseWithExpiresAfter(expiresAfterMs int64) CloseOption { + return func(p *closeParams) { + p.expiresAfter = &expiresAfterMs + } +} + // ClosePosition closes an open position completely. func (s *SDK) ClosePosition(asset string, opts ...CloseOption) (*PlacedOrder, error) { s.requireWallet() @@ -830,13 +981,24 @@ func (s *SDK) ClosePosition(asset string, opts ...CloseOption) (*PlacedOrder, er opt(params) } + // The backend queries action.user's position to size the close; when + // trading on behalf of a vault, the position belongs to the vault. + user := s.Address() + if params.vaultAddress != "" { + user = params.vaultAddress + } + action := map[string]any{ "type": "closePosition", "asset": asset, - "user": s.Address(), + "user": user, } - result, err := s.buildSignSend(action, params.slippage) + result, err := s.buildSignSendParams(action, &exchangeParams{ + slippage: params.slippage, + vaultAddress: params.vaultAddress, + expiresAfter: params.expiresAfter, + }) if err != nil { return nil, err } @@ -1105,7 +1267,28 @@ func validateSignerSignature(sig *Signature) error { return nil } +// exchangeParams carries the optional top-level exchange-request fields +// (slippage, priorityFee, vaultAddress, expiresAfter). vaultAddress and +// expiresAfter participate in the action hash the build endpoint returns, +// so they must be present in the build payload AND repeated verbatim in the +// send payload for signature recovery to succeed. They are only emitted when +// set, keeping the wire format unchanged for callers that don't use them. +type exchangeParams struct { + slippage *float64 + priorityFee *uint64 + vaultAddress string + expiresAfter *int64 +} + func (s *SDK) buildSignSend(action map[string]any, slippage *float64, priorityFee ...uint64) (map[string]any, error) { + params := &exchangeParams{slippage: slippage} + if len(priorityFee) > 0 { + params.priorityFee = &priorityFee[0] + } + return s.buildSignSendParams(action, params) +} + +func (s *SDK) buildSignSendParams(action map[string]any, params *exchangeParams) (map[string]any, error) { s.requireWallet() // Each HTTP call below relies on the client's own per-request timeout @@ -1117,14 +1300,20 @@ func (s *SDK) buildSignSend(action map[string]any, slippage *float64, priorityFe // Step 1: Build buildPayload := map[string]any{"action": action} effectiveSlippage := s.config.Slippage - if slippage != nil { - effectiveSlippage = *slippage + if params.slippage != nil { + effectiveSlippage = *params.slippage } if effectiveSlippage > 0 { buildPayload["slippage"] = effectiveSlippage } - if len(priorityFee) > 0 { - buildPayload["priorityFee"] = priorityFee[0] + if params.priorityFee != nil { + buildPayload["priorityFee"] = *params.priorityFee + } + if params.vaultAddress != "" { + buildPayload["vaultAddress"] = params.vaultAddress + } + if params.expiresAfter != nil { + buildPayload["expiresAfter"] = *params.expiresAfter } buildResult, err := s.http.Post(ctx, s.exchangeURL, buildPayload) if err != nil { @@ -1183,11 +1372,19 @@ func (s *SDK) buildSignSend(action map[string]any, slippage *float64, priorityFe } // Step 3: Send + // vaultAddress/expiresAfter were folded into the hash at build time, so + // they must be repeated here for the worker to recover the signer. sendPayload := map[string]any{ "action": finalAction, "nonce": int64(nonce), "signature": sig, } + if params.vaultAddress != "" { + sendPayload["vaultAddress"] = params.vaultAddress + } + if params.expiresAfter != nil { + sendPayload["expiresAfter"] = *params.expiresAfter + } return s.http.Post(ctx, s.exchangeURL, sendPayload) } diff --git a/go/hyperliquid/trigger_order.go b/go/hyperliquid/trigger_order.go index 96b61de..908e510 100644 --- a/go/hyperliquid/trigger_order.go +++ b/go/hyperliquid/trigger_order.go @@ -17,15 +17,18 @@ import ( // // Take profit: sell when price rises to 80000 // TriggerOrder().TakeProfit("BTC").Size(0.001).TriggerPrice(80000).Market() type TriggerOrderBuilder struct { - asset string - tpsl TpSl - side Side - size string - triggerPx string - limitPx string - isMarket bool - reduceOnly bool - cloid string + asset string + tpsl TpSl + side Side + size string + triggerPx string + limitPx string + isMarket bool + reduceOnly bool + cloid string + vaultAddress string + expiresAfter *int64 + grouping OrderGrouping } // TriggerOrder creates a new trigger order builder. @@ -118,6 +121,23 @@ func (t *TriggerOrderBuilder) CLOID(cloid string) *TriggerOrderBuilder { return t } +// VaultAddress places the trigger order on behalf of a vault (or subaccount). +// It is sent as the top-level vaultAddress exchange field and folded into +// the signed action hash by the build endpoint. Never emitted when unset. +func (t *TriggerOrderBuilder) VaultAddress(vaultAddress string) *TriggerOrderBuilder { + t.vaultAddress = vaultAddress + return t +} + +// ExpiresAfter sets the action TTL as a millisecond timestamp after which +// the exchange rejects the order. It is sent as the top-level expiresAfter +// exchange field and folded into the signed action hash by the build +// endpoint. Never emitted when unset. +func (t *TriggerOrderBuilder) ExpiresAfter(expiresAfterMs int64) *TriggerOrderBuilder { + t.expiresAfter = &expiresAfterMs + return t +} + // Asset returns the order's asset. func (t *TriggerOrderBuilder) Asset() string { return t.asset @@ -143,6 +163,31 @@ func (t *TriggerOrderBuilder) GetLimitPrice() string { return t.limitPx } +// GetVaultAddress returns the vault address if set, else empty string. +func (t *TriggerOrderBuilder) GetVaultAddress() string { + return t.vaultAddress +} + +// GetExpiresAfter returns the action TTL (ms timestamp) if set, else nil. +func (t *TriggerOrderBuilder) GetExpiresAfter() *int64 { + return t.expiresAfter +} + +// Grouping sets the order grouping (e.g. normalTpsl/positionTpsl) used when +// the order is placed. +func (t *TriggerOrderBuilder) Grouping(grouping OrderGrouping) *TriggerOrderBuilder { + t.grouping = grouping + return t +} + +// GetGrouping returns the grouping if set, else OrderGroupingNA. +func (t *TriggerOrderBuilder) GetGrouping() OrderGrouping { + if t.grouping == "" { + return OrderGroupingNA + } + return t.grouping +} + // Validate validates the trigger order before sending. func (t *TriggerOrderBuilder) Validate() error { if t.asset == "" { diff --git a/go/info_example b/go/info_example deleted file mode 100755 index 6b460a7..0000000 Binary files a/go/info_example and /dev/null differ diff --git a/go/place_order b/go/place_order deleted file mode 100755 index d872138..0000000 Binary files a/go/place_order and /dev/null differ diff --git a/go/trading_example b/go/trading_example deleted file mode 100755 index c1b9fec..0000000 Binary files a/go/trading_example and /dev/null differ diff --git a/go/websocket_streaming b/go/websocket_streaming deleted file mode 100755 index d5c81b4..0000000 Binary files a/go/websocket_streaming and /dev/null differ diff --git a/python/README.md b/python/README.md index fc5f775..7da2ad5 100644 --- a/python/README.md +++ b/python/README.md @@ -327,6 +327,15 @@ sdk.grpc.blocks(lambda b: print(f"Block: {b}")) # Subscribe to raw event blocks sdk.grpc.raw_events(lambda b: print(f"Events block: {b}")) +# Subscribe to pre-consensus mempool transactions (optional coin filter) +sdk.grpc.mempool_txs(lambda t: print(f"Mempool: {t}"), coins=["BTC"]) + +# Subscribe to top-of-book changes (empty coins = all coins) +sdk.grpc.bbo_book(lambda b: print(f"BBO: {b}"), coins=["BTC", "ETH"]) + +# Subscribe to incremental L2 diffs (sz == "0" means level removed) +sdk.grpc.l2_book_diff(lambda d: print(f"L2 diff: {d}"), coins=["BTC"]) + # Run in background sdk.grpc.start() # ... do other work ... @@ -355,6 +364,35 @@ sdk.grpc.run() | `raw_events(callback)` | - | Raw system event blocks with `events: [...]` | | `writer_actions(callback)` | - | Writer actions | | `raw_writer_actions(callback)` | - | Raw writer action blocks | +| `mempool_txs(callback, coins=None)` | coins: `List[str]` (optional) | Pre-consensus mempool transactions (coin filter optional; None = all) | +| `raw_mempool_txs(callback, coins=None)` | coins: `List[str]` (optional) | Raw mempool transaction blocks | +| `order_priority(callback)` | - | Derived order/write priority actions (events carry server-enriched `coin`, `market_type`, `sz_decimals`) | +| `raw_order_priority(callback)` | - | Raw order priority blocks | +| `gossip_priority(callback)` | - | Derived gossip/read priority bid actions (events carry server-enriched `coin`, `market_type`, `sz_decimals`) | +| `raw_gossip_priority(callback)` | - | Raw gossip priority blocks | +| `bbo_book(callback, coins=None)` | coins: `List[str]` (optional) | Top-of-book (best bid/offer) changes; emits only when BBO changes | +| `l2_book_diff(callback, coins=None, n_levels=20, n_sig_figs=None, mantissa=None, skip_initial_snapshot=False)` | coins: `List[str]` (optional) | Incremental L2 price-level changes; `sz == "0"` means level removed | +| `l4_book_updates(callback, coins=None)` | coins: `List[str]` (optional) | Typed L4 order diffs (new/update/remove) | +| `tpsl_updates(callback, coins=None)` | coins: `List[str]` (optional) | Trigger/TP-SL order add/remove updates | +| `l2_book_packed(coin, callback, n_sig_figs=None, n_levels=20, mantissa=None)` | coin: `str` | Fast-path L2 book; px/sz are u64 fixed-point scaled by 1e8 | +| `bbo_book_packed(callback, coins=None)` | coins: `List[str]` (optional) | Fast-path BBO; px/sz are u64 fixed-point scaled by 1e8 | +| `l4_book_bytes(coin, callback)` | coin: `str` | Fast-path L4 book; diff payload is raw JSON `bytes` | +| `stream_bytes(stream_type, callback, coins=None, users=None, start_block=None)` | stream_type: `str` | Low-level bytes fast path for any stream type; callback gets `{"block_number", "timestamp", "data": bytes}` | + +All `StreamData`-based helpers (trades, orders, events, mempool_txs, ...) also accept an +optional `start_block` keyword to resume streaming from a specific block number. + +> **Note:** `start_block` is not yet supported server-side — streams currently always +> begin at the live tip. The parameter is wired through the SDK so existing code picks +> up resume behavior automatically once server support ships. + +**L4 contract note** (applies to `l4_book`, `l4_book_bytes`, `l4_book_updates`): the server +may send an unsolicited full snapshot at any time after subscribe (e.g. after ALO +queue-priority anchored insertions). Clients MUST discard local book state and replace it +with any snapshot received mid-stream. `insertBefore` is never sent to clients. + +**Packed streams** (`l2_book_packed`, `bbo_book_packed`): prices and sizes are u64 +fixed-point integers scaled by 1e8 (divide by `1e8` for the decimal value). ### L4 Order Book (Critical for Trading) @@ -459,6 +497,29 @@ sdk.cancel_all("BTC") # Just BTC orders # Dead-man's switch import time sdk.schedule_cancel(int(time.time() * 1000) + 60000) + +# Fast cancel — request expedited cancellation +sdk.cancel(order.oid, "BTC", fast=True) +sdk.cancel_by_cloid("0xmycloid...", "BTC", fast=True) +sdk.cancel_all(fast=True) +``` + +### Vault Trading & Action TTL + +Orders, modifies, cancels, and `close_position` accept two optional +exchange-level fields. Both are signed into the action, and neither is sent +unless you set it: + +```python +# Trade on behalf of a vault (or subaccount) you operate +sdk.buy("BTC", size=0.001, price=65000, vault_address="0xVault...") +sdk.cancel(oid, "BTC", vault_address="0xVault...") +sdk.close_position("BTC", vault_address="0xVault...") + +# Action TTL — reject the action if not executed by this ms timestamp +import time +sdk.buy("BTC", size=0.001, price=65000, + expires_after=int(time.time() * 1000) + 60_000) ``` ### Position Management diff --git a/python/hyperliquid_sdk/client.py b/python/hyperliquid_sdk/client.py index fc1a9f8..502373f 100644 --- a/python/hyperliquid_sdk/client.py +++ b/python/hyperliquid_sdk/client.py @@ -423,6 +423,8 @@ def buy( grouping: OrderGrouping = OrderGrouping.NA, slippage: Optional[float] = None, priority_fee: Optional[Union[int, str]] = None, + vault_address: Optional[str] = None, + expires_after: Optional[int] = None, ) -> PlacedOrder: """ Place a buy order. @@ -438,6 +440,8 @@ def buy( slippage: Slippage tolerance for market orders (e.g. 0.05 = 5%). Overrides the SDK default for this call only. priority_fee: Optional Hyperliquid order priority p. p=10000 is 1 bp. + vault_address: Trade on behalf of a vault/subaccount (42-char hex). + expires_after: Action TTL — reject if not executed by this ms timestamp. Returns: PlacedOrder with oid, status, and cancel/modify methods @@ -447,6 +451,7 @@ def buy( sdk.buy("ETH", notional=100, tif="market") sdk.buy("ETH", notional=100, tif="market", slippage=0.05) sdk.buy("HYPE", size=0.3, tif="market", priority_fee=10000) + sdk.buy("BTC", size=0.001, price=67000, vault_address="0xVault...") """ return self._place_order( asset=asset, @@ -459,6 +464,8 @@ def buy( grouping=grouping, slippage=slippage, priority_fee=priority_fee, + vault_address=vault_address, + expires_after=expires_after, ) def sell( @@ -473,6 +480,8 @@ def sell( grouping: OrderGrouping = OrderGrouping.NA, slippage: Optional[float] = None, priority_fee: Optional[Union[int, str]] = None, + vault_address: Optional[str] = None, + expires_after: Optional[int] = None, ) -> PlacedOrder: """ Place a sell order. @@ -488,6 +497,8 @@ def sell( slippage: Slippage tolerance for market orders (e.g. 0.05 = 5%). Overrides the SDK default for this call only. priority_fee: Optional Hyperliquid order priority p. p=10000 is 1 bp. + vault_address: Trade on behalf of a vault/subaccount (42-char hex). + expires_after: Action TTL — reject if not executed by this ms timestamp. Returns: PlacedOrder with oid, status, and cancel/modify methods @@ -503,6 +514,8 @@ def sell( grouping=grouping, slippage=slippage, priority_fee=priority_fee, + vault_address=vault_address, + expires_after=expires_after, ) # Aliases for perp traders @@ -517,6 +530,8 @@ def market_buy( notional: Optional[float] = None, slippage: Optional[float] = None, priority_fee: Optional[Union[int, str]] = None, + vault_address: Optional[str] = None, + expires_after: Optional[int] = None, ) -> PlacedOrder: """ Market buy — executes immediately at best available price. @@ -528,6 +543,8 @@ def market_buy( slippage: Slippage tolerance as decimal (e.g. 0.05 = 5%). Default: SDK-level setting (3%). Range: 0.1%-10%. priority_fee: Optional Hyperliquid order priority p. p=10000 is 1 bp. + vault_address: Trade on behalf of a vault/subaccount (42-char hex). + expires_after: Action TTL — reject if not executed by this ms timestamp. Example: sdk.market_buy("BTC", size=0.001) @@ -542,6 +559,8 @@ def market_buy( tif="market", slippage=slippage, priority_fee=priority_fee, + vault_address=vault_address, + expires_after=expires_after, ) def market_sell( @@ -552,6 +571,8 @@ def market_sell( notional: Optional[float] = None, slippage: Optional[float] = None, priority_fee: Optional[Union[int, str]] = None, + vault_address: Optional[str] = None, + expires_after: Optional[int] = None, ) -> PlacedOrder: """ Market sell — executes immediately at best available price. @@ -563,6 +584,8 @@ def market_sell( slippage: Slippage tolerance as decimal (e.g. 0.05 = 5%). Default: SDK-level setting (3%). Range: 0.1%-10%. priority_fee: Optional Hyperliquid order priority p. p=10000 is 1 bp. + vault_address: Trade on behalf of a vault/subaccount (42-char hex). + expires_after: Action TTL — reject if not executed by this ms timestamp. """ return self.sell( asset, @@ -571,14 +594,24 @@ def market_sell( tif="market", slippage=slippage, priority_fee=priority_fee, + vault_address=vault_address, + expires_after=expires_after, ) - def order(self, order: Order) -> PlacedOrder: + def order( + self, + order: Order, + *, + vault_address: Optional[str] = None, + expires_after: Optional[int] = None, + ) -> PlacedOrder: """ Place an order using the fluent Order builder. Args: order: Order built with Order.buy() or Order.sell() + vault_address: Trade on behalf of a vault/subaccount (42-char hex). + expires_after: Action TTL — reject if not executed by this ms timestamp. Example: sdk.order(Order.buy("BTC").size(0.001).price(67000).gtc()) @@ -604,7 +637,11 @@ def order(self, order: Order) -> PlacedOrder: size = round(order._notional / mid, sz_decimals) order._size = str(size) - return self._execute_order(order) + return self._execute_order( + order, + vault_address=vault_address, + expires_after=expires_after, + ) # ═══════════════════════════════════════════════════════════════════════════ # TRIGGER ORDERS (Stop Loss / Take Profit) @@ -620,6 +657,8 @@ def stop_loss( side: Side = Side.SELL, reduce_only: bool = True, grouping: OrderGrouping = OrderGrouping.NA, + vault_address: Optional[str] = None, + expires_after: Optional[int] = None, ) -> PlacedOrder: """ Place a stop-loss trigger order. @@ -636,6 +675,8 @@ def stop_loss( side: Order side when triggered (default: SELL for closing longs) reduce_only: Only reduce position, don't open new one (default: True) grouping: Order grouping for TP/SL attachment + vault_address: Trade on behalf of a vault/subaccount (42-char hex). + expires_after: Action TTL — reject if not executed by this ms timestamp. Returns: PlacedOrder with trigger order details @@ -659,7 +700,12 @@ def stop_loss( trigger.market() trigger.reduce_only(reduce_only) - return self._execute_trigger_order(trigger, grouping) + return self._execute_trigger_order( + trigger, + grouping, + vault_address=vault_address, + expires_after=expires_after, + ) def take_profit( self, @@ -671,6 +717,8 @@ def take_profit( side: Side = Side.SELL, reduce_only: bool = True, grouping: OrderGrouping = OrderGrouping.NA, + vault_address: Optional[str] = None, + expires_after: Optional[int] = None, ) -> PlacedOrder: """ Place a take-profit trigger order. @@ -687,6 +735,8 @@ def take_profit( side: Order side when triggered (default: SELL for closing longs) reduce_only: Only reduce position, don't open new one (default: True) grouping: Order grouping for TP/SL attachment + vault_address: Trade on behalf of a vault/subaccount (42-char hex). + expires_after: Action TTL — reject if not executed by this ms timestamp. Returns: PlacedOrder with trigger order details @@ -710,7 +760,12 @@ def take_profit( trigger.market() trigger.reduce_only(reduce_only) - return self._execute_trigger_order(trigger, grouping) + return self._execute_trigger_order( + trigger, + grouping, + vault_address=vault_address, + expires_after=expires_after, + ) # Aliases for convenience sl = stop_loss @@ -720,6 +775,9 @@ def trigger_order( self, trigger: TriggerOrder, grouping: OrderGrouping = OrderGrouping.NA, + *, + vault_address: Optional[str] = None, + expires_after: Optional[int] = None, ) -> PlacedOrder: """ Place a trigger order using the fluent TriggerOrder builder. @@ -727,22 +785,35 @@ def trigger_order( Args: trigger: TriggerOrder built with TriggerOrder.stop_loss() or .take_profit() grouping: Order grouping for TP/SL attachment + vault_address: Trade on behalf of a vault/subaccount (42-char hex). + expires_after: Action TTL — reject if not executed by this ms timestamp. Example: order = TriggerOrder.stop_loss("BTC").size(0.001).trigger_price(60000).market() sdk.trigger_order(order) """ - return self._execute_trigger_order(trigger, grouping) + return self._execute_trigger_order( + trigger, + grouping, + vault_address=vault_address, + expires_after=expires_after, + ) def _execute_trigger_order( self, trigger: TriggerOrder, grouping: OrderGrouping = OrderGrouping.NA, + vault_address: Optional[str] = None, + expires_after: Optional[int] = None, ) -> PlacedOrder: """Execute a trigger order through build→sign→send.""" trigger.validate() action = trigger.to_action(grouping) - result = self._build_sign_send(action) + result = self._build_sign_send( + action, + vault_address=vault_address, + expires_after=expires_after, + ) # Build a pseudo Order for PlacedOrder.from_response order = Order(asset=trigger.asset, side=trigger.side) @@ -1619,6 +1690,9 @@ def close_position( self, asset: str, slippage: Optional[float] = None, + *, + vault_address: Optional[str] = None, + expires_after: Optional[int] = None, ) -> PlacedOrder: """ Close an open position completely. @@ -1629,6 +1703,8 @@ def close_position( asset: Asset to close ("BTC", "ETH") slippage: Slippage tolerance as decimal (e.g. 0.05 = 5%). Default: SDK-level setting (3%). Range: 0.1%-10%. + vault_address: Close on behalf of a vault/subaccount (42-char hex). + expires_after: Action TTL — reject if not executed by this ms timestamp. Returns: PlacedOrder for the closing trade @@ -1637,13 +1713,20 @@ def close_position( sdk.close_position("BTC") sdk.close_position("BTC", slippage=0.05) # 5% slippage """ + # The worker sizes the close from action.user, so a vault close must + # target the vault's position, not the wallet's. action = { "type": "closePosition", "asset": asset, - "user": self.address, + "user": vault_address or self.address, } - result = self._build_sign_send(action, slippage=slippage) + result = self._build_sign_send( + action, + slippage=slippage, + vault_address=vault_address, + expires_after=expires_after, + ) # Build a pseudo PlacedOrder from the response order = Order.sell(asset) # Direction determined by API @@ -1662,6 +1745,10 @@ def cancel( self, oid: int, asset: Optional[Union[str, int]] = None, + *, + fast: bool = False, + vault_address: Optional[str] = None, + expires_after: Optional[int] = None, ) -> dict: """ Cancel an order by OID. @@ -1669,6 +1756,9 @@ def cancel( Args: oid: Order ID to cancel asset: Asset name (str) or index (int). Optional but speeds up cancellation. + fast: Request fast cancellation (adds the "f" flag to the cancel action). + vault_address: Cancel on behalf of a vault/subaccount (42-char hex). + expires_after: Action TTL — reject if not executed by this ms timestamp. Returns: Exchange response @@ -1688,19 +1778,39 @@ def cancel( "type": "cancel", "cancels": [{"a": asset_idx, "o": oid}], } - return self._build_sign_send(cancel_action) + # Only emit "f" when true — the backend strips f:false anyway, + # and omitting it keeps wire compat with existing servers. + if fast: + cancel_action["f"] = True + return self._build_sign_send( + cancel_action, + vault_address=vault_address, + expires_after=expires_after, + ) - def cancel_all(self, asset: Optional[str] = None) -> dict: + def cancel_all( + self, + asset: Optional[str] = None, + *, + fast: bool = False, + vault_address: Optional[str] = None, + expires_after: Optional[int] = None, + ) -> dict: """ Cancel all open orders. Args: asset: Only cancel orders for this asset (optional) + fast: Request fast cancellation (adds the "f" flag to the cancel action). + vault_address: Cancel on behalf of a vault/subaccount (42-char hex). + expires_after: Action TTL — reject if not executed by this ms timestamp. Returns: Exchange response """ - orders = self.open_orders() + # When cancelling on behalf of a vault, enumerate the vault's open + # orders, not the wallet's. + orders = self.open_orders(vault_address) if not orders["orders"]: return {"message": "No orders to cancel"} @@ -1717,12 +1827,22 @@ def cancel_all(self, asset: Optional[str] = None) -> dict: if not cancel_action: return {"message": "No orders to cancel"} - return self._build_sign_send(cancel_action) + if fast: + cancel_action = {**cancel_action, "f": True} + return self._build_sign_send( + cancel_action, + vault_address=vault_address, + expires_after=expires_after, + ) def cancel_by_cloid( self, cloid: str, asset: Union[str, int], + *, + fast: bool = False, + vault_address: Optional[str] = None, + expires_after: Optional[int] = None, ) -> dict: """ Cancel an order by client order ID (cloid). @@ -1730,6 +1850,9 @@ def cancel_by_cloid( Args: cloid: Client order ID (hex string, e.g., "0x...") asset: Asset name (str) or index (int) + fast: Request fast cancellation (adds the "f" flag to the cancel action). + vault_address: Cancel on behalf of a vault/subaccount (42-char hex). + expires_after: Action TTL — reject if not executed by this ms timestamp. Returns: Exchange response @@ -1741,11 +1864,20 @@ def cancel_by_cloid( "type": "cancelByCloid", "cancels": [{"asset": asset_idx, "cloid": cloid}], } - return self._build_sign_send(cancel_action) + if fast: + cancel_action["f"] = True + return self._build_sign_send( + cancel_action, + vault_address=vault_address, + expires_after=expires_after, + ) def schedule_cancel( self, time: Optional[int] = None, + *, + vault_address: Optional[str] = None, + expires_after: Optional[int] = None, ) -> dict: """ Schedule cancellation of all orders after a delay. @@ -1755,6 +1887,8 @@ def schedule_cancel( Args: time: Unix timestamp (ms) when to cancel. If None, cancels the scheduled cancel. + vault_address: Schedule on behalf of a vault/subaccount (42-char hex). + expires_after: Action TTL — reject if not executed by this ms timestamp. Returns: Exchange response @@ -1769,7 +1903,11 @@ def schedule_cancel( cancel_action: dict = {"type": "scheduleCancel"} if time is not None: cancel_action["time"] = time - return self._build_sign_send(cancel_action) + return self._build_sign_send( + cancel_action, + vault_address=vault_address, + expires_after=expires_after, + ) def modify( self, @@ -1781,6 +1919,8 @@ def modify( *, tif: Union[str, TIF] = TIF.GTC, reduce_only: bool = False, + vault_address: Optional[str] = None, + expires_after: Optional[int] = None, ) -> PlacedOrder: """ Modify an existing order. @@ -1793,6 +1933,8 @@ def modify( size: New size tif: Time in force (default: GTC) reduce_only: Reduce only flag + vault_address: Modify on behalf of a vault/subaccount (42-char hex). + expires_after: Action TTL — reject if not executed by this ms timestamp. Returns: PlacedOrder with new details @@ -1841,7 +1983,11 @@ def modify( }], } - result = self._build_sign_send(modify_action) + result = self._build_sign_send( + modify_action, + vault_address=vault_address, + expires_after=expires_after, + ) order = Order(asset=asset, side=Side.BUY if side == "buy" else Side.SELL) order._price = price @@ -2311,6 +2457,8 @@ def _place_order( grouping: OrderGrouping = OrderGrouping.NA, slippage: Optional[float] = None, priority_fee: Optional[Union[int, str]] = None, + vault_address: Optional[str] = None, + expires_after: Optional[int] = None, ) -> PlacedOrder: """Internal order placement logic.""" asset = self._asset_name(asset) @@ -2362,6 +2510,8 @@ def _place_order( grouping, slippage=slippage, priority_fee=priority_fee, + vault_address=vault_address, + expires_after=expires_after, ) def _execute_order( @@ -2370,6 +2520,8 @@ def _execute_order( grouping: OrderGrouping = OrderGrouping.NA, slippage: Optional[float] = None, priority_fee: Optional[Union[int, str]] = None, + vault_address: Optional[str] = None, + expires_after: Optional[int] = None, ) -> PlacedOrder: """Execute an order through build→sign→send.""" action = order.to_action() @@ -2396,6 +2548,8 @@ def _execute_order( action, slippage=slippage, priority_fee=effective_priority_fee, + vault_address=vault_address, + expires_after=expires_after, ) return PlacedOrder.from_response( result.get("exchangeResponse", {}), @@ -2416,6 +2570,8 @@ def _build_sign_send( action: dict, slippage: Optional[float] = None, priority_fee: Optional[Union[int, str]] = None, + vault_address: Optional[str] = None, + expires_after: Optional[int] = None, ) -> dict: """ The magic ceremony — build, sign, send in one call. @@ -2423,6 +2579,11 @@ def _build_sign_send( 1. Build: POST action to get hash (with slippage for market orders) 2. Sign: Sign the hash locally 3. Send: POST action + nonce + signature + + vaultAddress and expiresAfter are top-level exchange-request fields. + They are folded into the action hash by the build step, so they must + ride BOTH payloads: build (so the signed hash covers them) and send + (so the worker recovers the signer and forwards them to Hyperliquid). """ self._require_wallet() @@ -2432,6 +2593,11 @@ def _build_sign_send( normalized_priority_fee = self._normalize_priority_fee(priority_fee) if normalized_priority_fee is not None: build_payload["priorityFee"] = normalized_priority_fee + normalized_expires_after = self._normalize_expires_after(expires_after) + if vault_address is not None: + build_payload["vaultAddress"] = vault_address + if normalized_expires_after is not None: + build_payload["expiresAfter"] = normalized_expires_after build_result = self._exchange(build_payload) @@ -2450,6 +2616,10 @@ def _build_sign_send( "nonce": build_result["nonce"], "signature": sig, } + if vault_address is not None: + send_payload["vaultAddress"] = vault_address + if normalized_expires_after is not None: + send_payload["expiresAfter"] = normalized_expires_after return self._exchange(send_payload) @@ -2552,6 +2722,27 @@ def _normalize_priority_fee( ) return value + def _normalize_expires_after( + self, + expires_after: Optional[Union[int, str]], + ) -> Optional[int]: + """Normalize optional action TTL expiresAfter (ms timestamp).""" + if expires_after is None: + return None + try: + value = int(expires_after) + except (TypeError, ValueError) as exc: + raise ValidationError( + "expires_after must be an unsigned integer (ms timestamp)", + guidance="Use expires_after=int(time.time() * 1000) + 60000.", + ) from exc + if value < 0: + raise ValidationError( + "expires_after must be an unsigned integer (ms timestamp)", + guidance="Use expires_after=int(time.time() * 1000) + 60000.", + ) + return value + def _sign_hash(self, hash_hex: str) -> dict: """Sign a hash with the external signer if configured, else the wallet. diff --git a/python/hyperliquid_sdk/grpc_stream.py b/python/hyperliquid_sdk/grpc_stream.py index 533312a..61eaa9e 100644 --- a/python/hyperliquid_sdk/grpc_stream.py +++ b/python/hyperliquid_sdk/grpc_stream.py @@ -40,7 +40,13 @@ PingRequest, Timestamp, L2BookRequest, + L2BookDiffRequest, L4BookRequest, + L4BookUpdatesRequest, + L4OrderDiffType, + BboBookRequest, + TpslUpdatesRequest, + TpslDiffType, StreamingStub, BlockStreamingStub, OrderBookStreamingStub, @@ -63,6 +69,9 @@ class GRPCStreamType(str, Enum): EVENTS = "EVENTS" BLOCKS = "BLOCKS" WRITER_ACTIONS = "WRITER_ACTIONS" + MEMPOOL_TXS = "MEMPOOL_TXS" + ORDER_PRIORITY = "ORDER_PRIORITY" + GOSSIP_PRIORITY = "GOSSIP_PRIORITY" class ConnectionState(str, Enum): @@ -82,6 +91,20 @@ class ConnectionState(str, Enum): "EVENTS": 5, # ProtoStreamType.EVENTS "BLOCKS": 6, # ProtoStreamType.BLOCKS "WRITER_ACTIONS": 7, # ProtoStreamType.WRITER_ACTIONS + "MEMPOOL_TXS": 8, # ProtoStreamType.MEMPOOL_TXS + "ORDER_PRIORITY": 9, # ProtoStreamType.ORDER_PRIORITY + "GOSSIP_PRIORITY": 10, # ProtoStreamType.GOSSIP_PRIORITY +} + +# Orderbook stream types (internal) -> OrderBookStreaming RPC method names +_ORDERBOOK_RPC_MAP = { + "BBO_BOOK": "StreamBboBook", + "L2_BOOK_DIFF": "StreamL2BookDiff", + "L4_BOOK_UPDATES": "StreamL4BookUpdates", + "TPSL_UPDATES": "StreamTpslUpdates", + "L2_BOOK_PACKED": "StreamL2BookPacked", + "BBO_BOOK_PACKED": "StreamBboBookPacked", + "L4_BOOK_BYTES": "StreamL4BookBytes", } @@ -103,8 +126,18 @@ class GRPCStream: - twap: Time-weighted average price execution - events: System events (funding, liquidations) - blocks: Block data + - mempool_txs: Pre-consensus mempool transactions + - order_priority: Derived order/write priority actions + - gossip_priority: Derived gossip/read priority bid actions - l2_book: Level 2 order book (aggregated price levels) - l4_book: Level 4 order book (individual orders) + - bbo_book: Top-of-book (best bid/offer) changes + - l2_book_diff: Incremental L2 price-level changes + - l4_book_updates: Typed L4 order diffs (new/update/remove) + - tpsl_updates: Trigger/TP-SL order add/remove updates + - l2_book_packed / bbo_book_packed: Fast-path fixed-point (1e8) variants + - l4_book_bytes: Fast-path L4 with JSON-bytes diffs + - stream_bytes: Low-level bytes fast path for any stream type Examples: stream = GRPCStream("https://your-endpoint.hype-mainnet.quiknode.pro/TOKEN") @@ -281,6 +314,10 @@ def _add_subscription( n_sig_figs: Optional[int] = None, n_levels: int = 20, raw: bool = False, + start_block: Optional[int] = None, + mantissa: Optional[int] = None, + skip_initial_snapshot: bool = False, + bytes_stream_type: Optional[str] = None, ) -> None: """Add a subscription to be started when run() is called.""" with self._lock: @@ -296,12 +333,25 @@ def _add_subscription( sub["coin"] = coin if n_sig_figs is not None: sub["n_sig_figs"] = n_sig_figs + if start_block is not None: + sub["start_block"] = start_block + if mantissa is not None: + sub["mantissa"] = mantissa + if bytes_stream_type is not None: + sub["bytes_stream_type"] = bytes_stream_type + sub["skip_initial_snapshot"] = skip_initial_snapshot sub["n_levels"] = n_levels sub["raw"] = raw self._subscriptions.append(sub) - def trades(self, coins: List[str], callback: Callable[[Dict[str, Any]], None]) -> "GRPCStream": + def trades( + self, + coins: List[str], + callback: Callable[[Dict[str, Any]], None], + *, + start_block: Optional[int] = None, + ) -> "GRPCStream": """ Subscribe to trade stream. @@ -310,19 +360,29 @@ def trades(self, coins: List[str], callback: Callable[[Dict[str, Any]], None]) - Args: coins: List of coin symbols ["BTC", "ETH"] callback: Function called for each trade + start_block: Optional block number to start streaming from """ - self._add_subscription(GRPCStreamType.TRADES.value, callback, coins=coins) + self._add_subscription(GRPCStreamType.TRADES.value, callback, coins=coins, start_block=start_block) return self - def raw_trades(self, coins: List[str], callback: Callable[[Dict[str, Any]], None]) -> "GRPCStream": + def raw_trades( + self, + coins: List[str], + callback: Callable[[Dict[str, Any]], None], + *, + start_block: Optional[int] = None, + ) -> "GRPCStream": """ Subscribe to raw trade blocks. Args: coins: List of coin symbols ["BTC", "ETH"] callback: Function called for each raw trade block + start_block: Optional block number to start streaming from """ - self._add_subscription(GRPCStreamType.TRADES.value, callback, coins=coins, raw=True) + self._add_subscription( + GRPCStreamType.TRADES.value, callback, coins=coins, raw=True, start_block=start_block + ) return self def orders( @@ -331,6 +391,7 @@ def orders( callback: Callable[[Dict[str, Any]], None], *, users: Optional[List[str]] = None, + start_block: Optional[int] = None, ) -> "GRPCStream": """ Subscribe to order stream. @@ -341,8 +402,11 @@ def orders( coins: List of coin symbols ["BTC", "ETH"] callback: Function called for each order update users: Optional list of user addresses to filter + start_block: Optional block number to start streaming from """ - self._add_subscription(GRPCStreamType.ORDERS.value, callback, coins=coins, users=users) + self._add_subscription( + GRPCStreamType.ORDERS.value, callback, coins=coins, users=users, start_block=start_block + ) return self def raw_orders( @@ -351,6 +415,7 @@ def raw_orders( callback: Callable[[Dict[str, Any]], None], *, users: Optional[List[str]] = None, + start_block: Optional[int] = None, ) -> "GRPCStream": """ Subscribe to raw order blocks. @@ -359,72 +424,121 @@ def raw_orders( coins: List of coin symbols ["BTC", "ETH"] callback: Function called for each raw order block users: Optional list of user addresses to filter + start_block: Optional block number to start streaming from """ - self._add_subscription(GRPCStreamType.ORDERS.value, callback, coins=coins, users=users, raw=True) + self._add_subscription( + GRPCStreamType.ORDERS.value, callback, coins=coins, users=users, raw=True, start_block=start_block + ) return self - def book_updates(self, coins: List[str], callback: Callable[[Dict[str, Any]], None]) -> "GRPCStream": + def book_updates( + self, + coins: List[str], + callback: Callable[[Dict[str, Any]], None], + *, + start_block: Optional[int] = None, + ) -> "GRPCStream": """ Subscribe to order book updates. Args: coins: List of coin symbols ["BTC", "ETH"] callback: Function called for each book update + start_block: Optional block number to start streaming from """ - self._add_subscription(GRPCStreamType.BOOK_UPDATES.value, callback, coins=coins) + self._add_subscription( + GRPCStreamType.BOOK_UPDATES.value, callback, coins=coins, start_block=start_block + ) return self - def raw_book_updates(self, coins: List[str], callback: Callable[[Dict[str, Any]], None]) -> "GRPCStream": + def raw_book_updates( + self, + coins: List[str], + callback: Callable[[Dict[str, Any]], None], + *, + start_block: Optional[int] = None, + ) -> "GRPCStream": """ Subscribe to raw order book update blocks. Args: coins: List of coin symbols ["BTC", "ETH"] callback: Function called for each raw book update block + start_block: Optional block number to start streaming from """ - self._add_subscription(GRPCStreamType.BOOK_UPDATES.value, callback, coins=coins, raw=True) + self._add_subscription( + GRPCStreamType.BOOK_UPDATES.value, callback, coins=coins, raw=True, start_block=start_block + ) return self - def twap(self, coins: List[str], callback: Callable[[Dict[str, Any]], None]) -> "GRPCStream": + def twap( + self, + coins: List[str], + callback: Callable[[Dict[str, Any]], None], + *, + start_block: Optional[int] = None, + ) -> "GRPCStream": """ Subscribe to TWAP execution stream. Args: coins: List of coin symbols ["BTC", "ETH"] callback: Function called for each TWAP update + start_block: Optional block number to start streaming from """ - self._add_subscription(GRPCStreamType.TWAP.value, callback, coins=coins) + self._add_subscription(GRPCStreamType.TWAP.value, callback, coins=coins, start_block=start_block) return self - def raw_twap(self, coins: List[str], callback: Callable[[Dict[str, Any]], None]) -> "GRPCStream": + def raw_twap( + self, + coins: List[str], + callback: Callable[[Dict[str, Any]], None], + *, + start_block: Optional[int] = None, + ) -> "GRPCStream": """ Subscribe to raw TWAP execution blocks. Args: coins: List of coin symbols ["BTC", "ETH"] callback: Function called for each raw TWAP block + start_block: Optional block number to start streaming from """ - self._add_subscription(GRPCStreamType.TWAP.value, callback, coins=coins, raw=True) + self._add_subscription( + GRPCStreamType.TWAP.value, callback, coins=coins, raw=True, start_block=start_block + ) return self - def events(self, callback: Callable[[Dict[str, Any]], None]) -> "GRPCStream": + def events( + self, + callback: Callable[[Dict[str, Any]], None], + *, + start_block: Optional[int] = None, + ) -> "GRPCStream": """ Subscribe to system events (funding, liquidations, governance). Args: callback: Function called for each event + start_block: Optional block number to start streaming from """ - self._add_subscription(GRPCStreamType.EVENTS.value, callback) + self._add_subscription(GRPCStreamType.EVENTS.value, callback, start_block=start_block) return self - def raw_events(self, callback: Callable[[Dict[str, Any]], None]) -> "GRPCStream": + def raw_events( + self, + callback: Callable[[Dict[str, Any]], None], + *, + start_block: Optional[int] = None, + ) -> "GRPCStream": """ Subscribe to raw system event blocks. Args: callback: Function called for each raw event block + start_block: Optional block number to start streaming from """ - self._add_subscription(GRPCStreamType.EVENTS.value, callback, raw=True) + self._add_subscription(GRPCStreamType.EVENTS.value, callback, raw=True, start_block=start_block) return self def blocks(self, callback: Callable[[Dict[str, Any]], None]) -> "GRPCStream": @@ -437,24 +551,183 @@ def blocks(self, callback: Callable[[Dict[str, Any]], None]) -> "GRPCStream": self._add_subscription(GRPCStreamType.BLOCKS.value, callback) return self - def writer_actions(self, callback: Callable[[Dict[str, Any]], None]) -> "GRPCStream": + def writer_actions( + self, + callback: Callable[[Dict[str, Any]], None], + *, + start_block: Optional[int] = None, + ) -> "GRPCStream": """ Subscribe to writer actions (HyperCore <-> HyperEVM asset transfers). Args: callback: Function called for each writer action + start_block: Optional block number to start streaming from """ - self._add_subscription(GRPCStreamType.WRITER_ACTIONS.value, callback) + self._add_subscription(GRPCStreamType.WRITER_ACTIONS.value, callback, start_block=start_block) return self - def raw_writer_actions(self, callback: Callable[[Dict[str, Any]], None]) -> "GRPCStream": + def raw_writer_actions( + self, + callback: Callable[[Dict[str, Any]], None], + *, + start_block: Optional[int] = None, + ) -> "GRPCStream": """ Subscribe to raw writer action blocks. Args: callback: Function called for each raw writer action block + start_block: Optional block number to start streaming from + """ + self._add_subscription( + GRPCStreamType.WRITER_ACTIONS.value, callback, raw=True, start_block=start_block + ) + return self + + def mempool_txs( + self, + callback: Callable[[Dict[str, Any]], None], + *, + coins: Optional[List[str]] = None, + start_block: Optional[int] = None, + ) -> "GRPCStream": + """ + Subscribe to pre-consensus mempool transactions. + + Args: + callback: Function called for each mempool transaction event + coins: Optional list of coin symbols to filter ["BTC", "ETH"] (None = all) + start_block: Optional block number to start streaming from + """ + self._add_subscription( + GRPCStreamType.MEMPOOL_TXS.value, callback, coins=coins, start_block=start_block + ) + return self + + def raw_mempool_txs( + self, + callback: Callable[[Dict[str, Any]], None], + *, + coins: Optional[List[str]] = None, + start_block: Optional[int] = None, + ) -> "GRPCStream": """ - self._add_subscription(GRPCStreamType.WRITER_ACTIONS.value, callback, raw=True) + Subscribe to raw pre-consensus mempool transaction blocks. + + Args: + callback: Function called for each raw mempool block + coins: Optional list of coin symbols to filter ["BTC", "ETH"] (None = all) + start_block: Optional block number to start streaming from + """ + self._add_subscription( + GRPCStreamType.MEMPOOL_TXS.value, callback, coins=coins, raw=True, start_block=start_block + ) + return self + + def order_priority( + self, + callback: Callable[[Dict[str, Any]], None], + *, + start_block: Optional[int] = None, + ) -> "GRPCStream": + """ + Subscribe to derived order/write priority actions (from mempool and confirmed replica data). + + Events carry server-enriched fields: coin, market_type, sz_decimals. + + Args: + callback: Function called for each order priority event + start_block: Optional block number to start streaming from + """ + self._add_subscription(GRPCStreamType.ORDER_PRIORITY.value, callback, start_block=start_block) + return self + + def raw_order_priority( + self, + callback: Callable[[Dict[str, Any]], None], + *, + start_block: Optional[int] = None, + ) -> "GRPCStream": + """ + Subscribe to raw order priority blocks. + + Args: + callback: Function called for each raw order priority block + start_block: Optional block number to start streaming from + """ + self._add_subscription( + GRPCStreamType.ORDER_PRIORITY.value, callback, raw=True, start_block=start_block + ) + return self + + def gossip_priority( + self, + callback: Callable[[Dict[str, Any]], None], + *, + start_block: Optional[int] = None, + ) -> "GRPCStream": + """ + Subscribe to derived gossip/read priority bid actions (does not measure delivery latency). + + Events carry server-enriched fields: coin, market_type, sz_decimals. + + Args: + callback: Function called for each gossip priority event + start_block: Optional block number to start streaming from + """ + self._add_subscription(GRPCStreamType.GOSSIP_PRIORITY.value, callback, start_block=start_block) + return self + + def raw_gossip_priority( + self, + callback: Callable[[Dict[str, Any]], None], + *, + start_block: Optional[int] = None, + ) -> "GRPCStream": + """ + Subscribe to raw gossip priority blocks. + + Args: + callback: Function called for each raw gossip priority block + start_block: Optional block number to start streaming from + """ + self._add_subscription( + GRPCStreamType.GOSSIP_PRIORITY.value, callback, raw=True, start_block=start_block + ) + return self + + def stream_bytes( + self, + stream_type: str, + callback: Callable[[Dict[str, Any]], None], + *, + coins: Optional[List[str]] = None, + users: Optional[List[str]] = None, + start_block: Optional[int] = None, + ) -> "GRPCStream": + """ + Subscribe to the low-level bytes fast path (StreamDataBytes RPC). + + The payload is NOT parsed: the callback receives + {"block_number": int, "timestamp": int, "data": bytes} for each update. + Fast-path clients should prefer this over the JSON string streams. + + Args: + stream_type: Stream type name (e.g. "TRADES", GRPCStreamType.ORDERS, ...) + callback: Function called for each raw bytes update + coins: Optional list of coin symbols to filter + users: Optional list of user addresses to filter + start_block: Optional block number to start streaming from + """ + self._add_subscription( + "STREAM_BYTES", + callback, + coins=coins, + users=users, + start_block=start_block, + bytes_stream_type=str(GRPCStreamType(stream_type).value), + ) return self def l2_book( @@ -481,6 +754,10 @@ def l4_book(self, coin: str, callback: Callable[[Dict[str, Any]], None]) -> "GRP """ Subscribe to Level 4 order book updates (individual orders). + Note: the server may send an unsolicited full snapshot at any time after + subscribe (e.g. after ALO queue-priority anchored insertions). Clients MUST + discard local book state and replace it with any snapshot received mid-stream. + Args: coin: Coin symbol ("BTC") callback: Function called for each book update @@ -488,12 +765,201 @@ def l4_book(self, coin: str, callback: Callable[[Dict[str, Any]], None]) -> "GRP self._add_subscription("L4_BOOK", callback, coin=coin) return self + def bbo_book( + self, + callback: Callable[[Dict[str, Any]], None], + *, + coins: Optional[List[str]] = None, + ) -> "GRPCStream": + """ + Subscribe to top-of-book (best bid/offer) changes. + + Emits only when the best bid or ask changes for a coin. + Fields: coin, time, block_number, bid ([px, sz, n] or None), ask ([px, sz, n] or None) + + Args: + callback: Function called for each BBO update + coins: Optional list of coin symbols (None = all coins) + """ + self._add_subscription("BBO_BOOK", callback, coins=coins) + return self + + def l2_book_diff( + self, + callback: Callable[[Dict[str, Any]], None], + *, + coins: Optional[List[str]] = None, + n_levels: int = 20, + n_sig_figs: Optional[int] = None, + mantissa: Optional[int] = None, + skip_initial_snapshot: bool = False, + ) -> "GRPCStream": + """ + Subscribe to incremental L2 price-level changes. + + Each update carries only changed levels per coin; a level with sz == "0" + means the level was removed. Unless skip_initial_snapshot is True, the + first update per coin contains the current levels (snapshot=True). + + Args: + callback: Function called for each diff batch + coins: Optional list of coin symbols (None = all coins) + n_levels: Max tracked levels per side (default: 20, max 100) + n_sig_figs: Optional significant figures for price bucketing (2-5) + mantissa: Optional mantissa for bucketing (1, 2, or 5) + skip_initial_snapshot: Skip the initial per-coin snapshot (default: False) + """ + self._add_subscription( + "L2_BOOK_DIFF", + callback, + coins=coins, + n_levels=n_levels, + n_sig_figs=n_sig_figs, + mantissa=mantissa, + skip_initial_snapshot=skip_initial_snapshot, + ) + return self + + def l4_book_updates( + self, + callback: Callable[[Dict[str, Any]], None], + *, + coins: Optional[List[str]] = None, + ) -> "GRPCStream": + """ + Subscribe to typed L4 order book updates (new/update/remove diffs per block). + + Note: the server may send an unsolicited full snapshot batch at any time + (snapshot=True); clients MUST discard local book state and rebuild from it. + + Args: + callback: Function called for each update batch + coins: Optional list of coin symbols (None = all coins) + """ + self._add_subscription("L4_BOOK_UPDATES", callback, coins=coins) + return self + + def tpsl_updates( + self, + callback: Callable[[Dict[str, Any]], None], + *, + coins: Optional[List[str]] = None, + ) -> "GRPCStream": + """ + Subscribe to trigger/TP-SL order add/remove updates. + + Args: + callback: Function called for each update batch + coins: Optional list of coin symbols (None = all perp coins) + """ + self._add_subscription("TPSL_UPDATES", callback, coins=coins) + return self + + def l2_book_packed( + self, + coin: str, + callback: Callable[[Dict[str, Any]], None], + *, + n_sig_figs: Optional[int] = None, + n_levels: int = 20, + mantissa: Optional[int] = None, + ) -> "GRPCStream": + """ + Subscribe to fast-path L2 order book updates with fixed-point integers. + + Prices/sizes are uint64 fixed-point integers scaled by 1e8. + + Args: + coin: Coin symbol ("BTC") + callback: Function called for each book update + n_sig_figs: Optional number of significant figures for price aggregation + n_levels: Number of price levels to return (default: 20) + mantissa: Optional mantissa for bucketing (1, 2, or 5) + """ + self._add_subscription( + "L2_BOOK_PACKED", callback, coin=coin, n_sig_figs=n_sig_figs, n_levels=n_levels, mantissa=mantissa + ) + return self + + def bbo_book_packed( + self, + callback: Callable[[Dict[str, Any]], None], + *, + coins: Optional[List[str]] = None, + ) -> "GRPCStream": + """ + Subscribe to fast-path top-of-book changes with fixed-point integers. + + Prices/sizes are uint64 fixed-point integers scaled by 1e8. + + Args: + callback: Function called for each BBO update + coins: Optional list of coin symbols (None = all coins) + """ + self._add_subscription("BBO_BOOK_PACKED", callback, coins=coins) + return self + + def l4_book_bytes(self, coin: str, callback: Callable[[Dict[str, Any]], None]) -> "GRPCStream": + """ + Subscribe to fast-path Level 4 order book updates (diff payload as JSON bytes). + + Diff updates carry the raw JSON payload as bytes (not parsed). Snapshots are + delivered as structured dicts like l4_book. The server may send an unsolicited + full snapshot at any time; clients MUST discard local book state and replace + it with any snapshot received mid-stream. + + Args: + coin: Coin symbol ("BTC") + callback: Function called for each book update + """ + self._add_subscription("L4_BOOK_BYTES", callback, coin=coin) + return self + + def _build_subscribe_request( + self, + sub: Dict[str, Any], + stream_type: Optional[str] = None, + *, + reconnect: bool = False, + ) -> SubscribeRequest: + """Build the SubscribeRequest for a StreamData/StreamDataBytes subscription. + + On reconnect, a user-set start_block is advanced past the highest block + already delivered so already-processed blocks are not replayed. An unset + start_block stays unset (live-tip semantics are preserved). + """ + request = SubscribeRequest() + request.subscribe.stream_type = _STREAM_TYPE_MAP.get( + stream_type or sub.get("stream_type"), 0 + ) + + # Resume from a specific block (0 = live tip) + start_block = sub.get("start_block") + if start_block: + if reconnect: + # Don't replay blocks already delivered on a previous connection. + start_block = max(start_block, sub.get("_last_seen_block", 0) + 1) + request.subscribe.start_block = start_block + + # Add filters + coins = sub.get("coins") + if coins: + filter_values = FilterValues() + filter_values.values.extend(coins) + request.subscribe.filters["coin"].CopyFrom(filter_values) + + users = sub.get("users") + if users: + filter_values = FilterValues() + filter_values.values.extend(users) + request.subscribe.filters["user"].CopyFrom(filter_values) + + return request + def _stream_data(self, sub: Dict[str, Any]) -> None: """Stream data using bidirectional StreamData RPC.""" - stream_type = sub.get("stream_type") callback = sub["callback"] - coins = sub.get("coins") - users = sub.get("users") + first_connect = True while self._running and not self._stop_event.is_set(): try: @@ -502,25 +968,13 @@ def _stream_data(self, sub: Dict[str, Any]) -> None: continue metadata = self._get_metadata() + initial_request = self._build_subscribe_request(sub, reconnect=not first_connect) + first_connect = False # Build request generator def request_generator() -> Iterator[SubscribeRequest]: # Send initial subscription request - request = SubscribeRequest() - request.subscribe.stream_type = _STREAM_TYPE_MAP.get(stream_type, 0) - - # Add filters - if coins: - filter_values = FilterValues() - filter_values.values.extend(coins) - request.subscribe.filters["coin"].CopyFrom(filter_values) - - if users: - filter_values = FilterValues() - filter_values.values.extend(users) - request.subscribe.filters["user"].CopyFrom(filter_values) - - yield request + yield initial_request # Keep sending pings to maintain connection while self._running and not self._stop_event.is_set(): @@ -538,9 +992,14 @@ def request_generator() -> Iterator[SubscribeRequest]: break if response.HasField('data'): + block_number = response.data.block_number try: data = json.loads(response.data.data) - block_number = response.data.block_number + # Advance the resume cursor only after the payload + # parsed, so a corrupt block is re-requested on + # reconnect instead of skipped permanently. + if block_number > sub.get("_last_seen_block", 0): + sub["_last_seen_block"] = block_number timestamp = response.data.timestamp if sub.get("raw"): @@ -859,6 +1318,313 @@ def _l4_order_to_dict(self, order) -> Dict[str, Any]: "cloid": order.cloid if order.HasField('cloid') else None, } + def _stream_data_bytes(self, sub: Dict[str, Any]) -> None: + """Stream raw payload bytes using bidirectional StreamDataBytes RPC.""" + callback = sub["callback"] + first_connect = True + + while self._running and not self._stop_event.is_set(): + try: + if not self._streaming_stub: + time.sleep(1) + continue + + metadata = self._get_metadata() + initial_request = self._build_subscribe_request( + sub, sub.get("bytes_stream_type"), reconnect=not first_connect + ) + first_connect = False + + # Build request generator + def request_generator() -> Iterator[SubscribeRequest]: + # Send initial subscription request + yield initial_request + + # Keep sending pings to maintain connection + while self._running and not self._stop_event.is_set(): + time.sleep(30) + ping_request = SubscribeRequest() + ping_request.ping.timestamp = int(time.time() * 1000) + yield ping_request + + # Create bidirectional stream + stream = self._streaming_stub.StreamDataBytes(request_generator(), metadata=metadata) + + # Handle responses + for response in stream: + if not self._running or self._stop_event.is_set(): + break + + if response.HasField('data'): + block_number = response.data.block_number + # Fast path: hand the payload bytes through unparsed + self._safe_callback(callback, { + "block_number": block_number, + "timestamp": response.data.timestamp, + "data": response.data.data, + }) + # Cursor advances only after delivery so reconnects + # never skip an undelivered block. + if block_number > sub.get("_last_seen_block", 0): + sub["_last_seen_block"] = block_number + elif response.HasField('pong'): + logger.debug(f"Pong received: {response.pong.timestamp}") + + except grpc.RpcError as e: + if not self._running: + break + + error = HyperliquidError( + f"gRPC error: {e.code()} - {e.details()}", + code="GRPC_ERROR", + raw={"code": str(e.code()), "details": e.details()}, + ) + + if self._on_error: + try: + self._on_error(error) + except Exception: + pass + + if self._reconnect_enabled and self._running: + self._handle_reconnect() + else: + break + + except Exception as e: + if not self._running: + break + + if self._on_error: + try: + self._on_error(e) + except Exception: + pass + + if self._reconnect_enabled and self._running: + self._handle_reconnect() + else: + break + + def _build_orderbook_request(self, sub: Dict[str, Any], *, reconnect: bool = False) -> Any: + """Build the request message for an OrderBookStreaming subscription. + + On reconnect, skip_initial_snapshot is forced to False so the server + resends the snapshot and the consumer can resync its local book. + """ + stream_type = sub.get("stream_type") + coins = sub.get("coins") or [] + + if stream_type in ("BBO_BOOK", "BBO_BOOK_PACKED"): + return BboBookRequest(coins=coins) + + if stream_type == "L2_BOOK_DIFF": + # skip_initial_snapshot only applies to the first connection. + skip_initial_snapshot = False if reconnect else sub.get("skip_initial_snapshot", False) + request = L2BookDiffRequest( + coins=coins, + n_levels=sub.get("n_levels", 20), + skip_initial_snapshot=skip_initial_snapshot, + ) + if sub.get("n_sig_figs") is not None: + request.n_sig_figs = sub["n_sig_figs"] + if sub.get("mantissa") is not None: + request.mantissa = sub["mantissa"] + return request + + if stream_type == "L4_BOOK_UPDATES": + return L4BookUpdatesRequest(coins=coins) + + if stream_type == "TPSL_UPDATES": + return TpslUpdatesRequest(coins=coins) + + if stream_type == "L2_BOOK_PACKED": + request = L2BookRequest(coin=sub.get("coin"), n_levels=sub.get("n_levels", 20)) + if sub.get("n_sig_figs") is not None: + request.n_sig_figs = sub["n_sig_figs"] + if sub.get("mantissa") is not None: + request.mantissa = sub["mantissa"] + return request + + if stream_type == "L4_BOOK_BYTES": + return L4BookRequest(coin=sub.get("coin")) + + raise ValueError(f"Unknown orderbook stream type: {stream_type}") + + def _bbo_update_to_dict(self, update) -> Dict[str, Any]: + """Convert BboBookUpdate/BboBookPackedUpdate protobuf to dict.""" + return { + "coin": update.coin, + "time": update.time, + "block_number": update.block_number, + "bid": [update.bid.px, update.bid.sz, update.bid.n] if update.HasField('bid') else None, + "ask": [update.ask.px, update.ask.sz, update.ask.n] if update.HasField('ask') else None, + } + + def _orderbook_update_to_dict(self, stream_type: str, update) -> Optional[Dict[str, Any]]: + """Convert an OrderBookStreaming update protobuf to dict.""" + if stream_type in ("BBO_BOOK", "BBO_BOOK_PACKED"): + return self._bbo_update_to_dict(update) + + if stream_type == "L2_BOOK_DIFF": + return { + "time": update.time, + "height": update.height, + "snapshot": update.snapshot, + "diffs": [ + { + "coin": diff.coin, + "seq": diff.seq, + "prev_seq": diff.prev_seq, + "snapshot": diff.snapshot, + "bids": [[level.px, level.sz, level.n] for level in diff.bids], + "asks": [[level.px, level.sz, level.n] for level in diff.asks], + } + for diff in update.diffs + ], + } + + if stream_type == "L4_BOOK_UPDATES": + return { + "time": update.time, + "height": update.height, + "snapshot": update.snapshot, + "diffs": [ + { + "diff_type": L4OrderDiffType.Name(diff.diff_type), + "coin": diff.coin, + "oid": diff.oid, + "user": diff.user, + "side": diff.side, + "px": diff.px, + "sz": diff.sz, + } + for diff in update.diffs + ], + } + + if stream_type == "TPSL_UPDATES": + return { + "time": update.time, + "height": update.height, + "snapshot": update.snapshot, + "diffs": [ + { + "diff_type": TpslDiffType.Name(diff.diff_type), + "oid": diff.oid, + "coin": diff.coin, + "user": diff.user, + "side": diff.side, + "trigger_px": diff.trigger_px, + "limit_px": diff.limit_px, + "sz": diff.sz, + "trigger_condition": diff.trigger_condition, + "order_type": diff.order_type, + "is_position_tpsl": diff.is_position_tpsl, + "reduce_only": diff.reduce_only, + "timestamp": diff.timestamp, + "reason": diff.reason, + } + for diff in update.diffs + ], + } + + if stream_type == "L2_BOOK_PACKED": + return { + "coin": update.coin, + "time": update.time, + "block_number": update.block_number, + "bids": [[level.px, level.sz, level.n] for level in update.bids], + "asks": [[level.px, level.sz, level.n] for level in update.asks], + } + + if stream_type == "L4_BOOK_BYTES": + if update.HasField('snapshot'): + snapshot = update.snapshot + return { + "type": "snapshot", + "coin": snapshot.coin, + "time": snapshot.time, + "height": snapshot.height, + "bids": [self._l4_order_to_dict(o) for o in snapshot.bids], + "asks": [self._l4_order_to_dict(o) for o in snapshot.asks], + } + if update.HasField('diff'): + diff = update.diff + return { + "type": "diff", + "time": diff.time, + "height": diff.height, + "data": diff.data, # Raw JSON bytes (not parsed) + } + return None + + return None + + def _stream_orderbook(self, sub: Dict[str, Any]) -> None: + """Stream order book data using the newer OrderBookStreaming RPCs.""" + callback = sub["callback"] + stream_type = sub.get("stream_type") + first_connect = True + + while self._running and not self._stop_event.is_set(): + try: + if not self._orderbook_stub: + time.sleep(1) + continue + + metadata = self._get_metadata() + request = self._build_orderbook_request(sub, reconnect=not first_connect) + first_connect = False + rpc = getattr(self._orderbook_stub, _ORDERBOOK_RPC_MAP[stream_type]) + + # Create stream + stream = rpc(request, metadata=metadata) + + for update in stream: + if not self._running or self._stop_event.is_set(): + break + + data = self._orderbook_update_to_dict(stream_type, update) + if data is not None: + self._safe_callback(callback, data) + + except grpc.RpcError as e: + if not self._running: + break + + error = HyperliquidError( + f"gRPC error: {e.code()} - {e.details()}", + code="GRPC_ERROR", + raw={"code": str(e.code()), "details": e.details()}, + ) + + if self._on_error: + try: + self._on_error(error) + except Exception: + pass + + if self._reconnect_enabled and self._running: + self._handle_reconnect() + else: + break + + except Exception as e: + if not self._running: + break + + if self._on_error: + try: + self._on_error(e) + except Exception: + pass + + if self._reconnect_enabled and self._running: + self._handle_reconnect() + else: + break + def _handle_reconnect(self) -> None: """Handle reconnection with exponential backoff.""" if not self._running: @@ -929,6 +1695,18 @@ def _start_streams(self) -> None: args=(sub,), daemon=True, ) + elif stream_type in _ORDERBOOK_RPC_MAP: + thread = threading.Thread( + target=self._stream_orderbook, + args=(sub,), + daemon=True, + ) + elif stream_type == "STREAM_BYTES": + thread = threading.Thread( + target=self._stream_data_bytes, + args=(sub,), + daemon=True, + ) elif stream_type == "BLOCKS": thread = threading.Thread( target=self._stream_blocks, diff --git a/python/hyperliquid_sdk/proto/__init__.py b/python/hyperliquid_sdk/proto/__init__.py index e1fcd5f..112b993 100644 --- a/python/hyperliquid_sdk/proto/__init__.py +++ b/python/hyperliquid_sdk/proto/__init__.py @@ -5,8 +5,10 @@ StreamType, SubscribeRequest, SubscribeUpdate, + SubscribeBytesUpdate, StreamSubscribe, StreamResponse, + StreamBytesResponse, FilterValues, Ping, Pong, @@ -27,11 +29,29 @@ L2BookRequest, L2BookUpdate, L2Level, + L2LevelPacked, + L2BookPackedUpdate, + L2BookDiffRequest, + L2BookDiffUpdate, + L2CoinDiff, + BboBookRequest, + BboBookUpdate, + BboBookPackedUpdate, L4BookRequest, L4BookUpdate, L4BookSnapshot, L4BookDiff, + L4BookBytesUpdate, + L4BookBytesDiff, + L4BookUpdatesRequest, + L4BookUpdatesUpdate, + L4OrderDiff, + L4OrderDiffType, L4Order, + TpslUpdatesRequest, + TpslUpdatesUpdate, + TpslOrderDiff, + TpslDiffType, ) from .orderbook_pb2_grpc import ( @@ -44,8 +64,10 @@ "StreamType", "SubscribeRequest", "SubscribeUpdate", + "SubscribeBytesUpdate", "StreamSubscribe", "StreamResponse", + "StreamBytesResponse", "FilterValues", "Ping", "Pong", @@ -62,11 +84,29 @@ "L2BookRequest", "L2BookUpdate", "L2Level", + "L2LevelPacked", + "L2BookPackedUpdate", + "L2BookDiffRequest", + "L2BookDiffUpdate", + "L2CoinDiff", + "BboBookRequest", + "BboBookUpdate", + "BboBookPackedUpdate", "L4BookRequest", "L4BookUpdate", "L4BookSnapshot", "L4BookDiff", + "L4BookBytesUpdate", + "L4BookBytesDiff", + "L4BookUpdatesRequest", + "L4BookUpdatesUpdate", + "L4OrderDiff", + "L4OrderDiffType", "L4Order", + "TpslUpdatesRequest", + "TpslUpdatesUpdate", + "TpslOrderDiff", + "TpslDiffType", # Orderbook stubs "OrderBookStreamingStub", "OrderBookStreamingServicer", diff --git a/python/hyperliquid_sdk/proto/orderbook.proto b/python/hyperliquid_sdk/proto/orderbook.proto index 93373ed..f0c1f78 100644 --- a/python/hyperliquid_sdk/proto/orderbook.proto +++ b/python/hyperliquid_sdk/proto/orderbook.proto @@ -8,6 +8,8 @@ syntax = "proto3"; package hyperliquid; +option go_package = "github.com/quiknode-labs/hyperliquid-sdk/go/hyperliquid/proto"; + // Service for streaming real-time order book data. // L2 provides aggregated price levels (px, total_sz, order_count). // L4 provides individual order details (user, trigger info, timestamps). @@ -19,42 +21,127 @@ service OrderBookStreaming { // Subscribe to L4 full order book for a coin. // Sends an initial L4 snapshot, then incremental diffs per block. rpc StreamL4Book (L4BookRequest) returns (stream L4BookUpdate); + + // Subscribe to top-of-book changes. Empty coins means all coins. + // Emits only when the best bid or ask changes for a coin. + rpc StreamBboBook (BboBookRequest) returns (stream BboBookUpdate); + + // Subscribe to incremental L2 price-level changes. Empty coins means all coins. + rpc StreamL2BookDiff (L2BookDiffRequest) returns (stream L2BookDiffUpdate); + + // Subscribe to typed L4 order book updates. Empty coins means all coins. + rpc StreamL4BookUpdates (L4BookUpdatesRequest) returns (stream L4BookUpdatesUpdate); + + // Subscribe to trigger/TP-SL order updates. Empty coins means all perp coins. + rpc StreamTpslUpdates (TpslUpdatesRequest) returns (stream TpslUpdatesUpdate); + + // Fast-path L2 stream. Prices/sizes are fixed-point integers scaled by 1e8. + rpc StreamL2BookPacked (L2BookRequest) returns (stream L2BookPackedUpdate); + + // Fast-path BBO stream. Prices/sizes are fixed-point integers scaled by 1e8. + rpc StreamBboBookPacked (BboBookRequest) returns (stream BboBookPackedUpdate); + + // Fast-path L4 stream. Diff payload is JSON bytes instead of a protobuf string. + rpc StreamL4BookBytes (L4BookRequest) returns (stream L4BookBytesUpdate); } // Request parameters for L2 book streaming. message L2BookRequest { - string coin = 1; - uint32 n_levels = 2; - optional uint32 n_sig_figs = 3; - optional uint64 mantissa = 4; + string coin = 1; // Symbol (e.g. "BTC", "ETH") + uint32 n_levels = 2; // Max number of price levels (default 20, max 100) + optional uint32 n_sig_figs = 3; // Significance figures for price bucketing (2-5) + optional uint64 mantissa = 4; // Mantissa for bucketing (1, 2, or 5) } // An L2 book update: full snapshot of aggregated price levels at a point in time. message L2BookUpdate { string coin = 1; - uint64 time = 2; + uint64 time = 2; // Block timestamp (milliseconds) uint64 block_number = 3; - repeated L2Level bids = 4; - repeated L2Level asks = 5; + repeated L2Level bids = 4; // Aggregated bid levels (best first) + repeated L2Level asks = 5; // Aggregated ask levels (best first) } // A single aggregated price level. message L2Level { - string px = 1; - string sz = 2; - uint32 n = 3; + string px = 1; // Price as decimal string + string sz = 2; // Total size as decimal string + uint32 n = 3; // Number of individual orders at this level +} + +message L2LevelPacked { + fixed64 px = 1; // Price scaled by 1e8 + fixed64 sz = 2; // Size scaled by 1e8 + uint32 n = 3; // Number of individual orders at this level +} + +message L2BookPackedUpdate { + string coin = 1; + uint64 time = 2; + uint64 block_number = 3; + repeated L2LevelPacked bids = 4; + repeated L2LevelPacked asks = 5; +} + +// Request parameters for best bid/offer streaming. +message BboBookRequest { + repeated string coins = 1; // Empty means all coins +} + +// Best bid/offer update for a coin. +message BboBookUpdate { + string coin = 1; + uint64 time = 2; // Block timestamp (milliseconds) + uint64 block_number = 3; + L2Level bid = 4; // Absent if no bid + L2Level ask = 5; // Absent if no ask +} + +message BboBookPackedUpdate { + string coin = 1; + uint64 time = 2; + uint64 block_number = 3; + L2LevelPacked bid = 4; + L2LevelPacked ask = 5; +} + +// Request parameters for L2 diff streaming. +message L2BookDiffRequest { + repeated string coins = 1; // Empty means all coins + uint32 n_levels = 2; // Max tracked levels per side (default 20, max 100) + optional uint32 n_sig_figs = 3; // Significance figures for price bucketing (2-5) + optional uint64 mantissa = 4; // Mantissa for bucketing (1, 2, or 5) + bool skip_initial_snapshot = 5; // If false, first update per coin contains current levels +} + +// Batch of L2 price-level changes for one processed block. +message L2BookDiffUpdate { + uint64 time = 1; // Block timestamp (milliseconds) + uint64 height = 2; // Block height + bool snapshot = 3; // True when carrying initial levels for any coin + repeated L2CoinDiff diffs = 4; // Only coins with changed levels +} + +// Incremental L2 changes for one coin. +message L2CoinDiff { + string coin = 1; + uint64 seq = 2; // Per-coin sequence in this stream + uint64 prev_seq = 3; + repeated L2Level bids = 4; // Changed bid levels; sz=0 means removed + repeated L2Level asks = 5; // Changed ask levels; sz=0 means removed + bool snapshot = 6; // True when bids/asks carry initial levels } // Request parameters for L4 book streaming. message L4BookRequest { - string coin = 1; + string coin = 1; // Symbol (e.g. "BTC", "ETH") } // An L4 book update: either a full snapshot or an incremental diff. message L4BookUpdate { oneof update { - L4BookSnapshot snapshot = 1; - L4BookDiff diff = 2; + L4BookSnapshot snapshot = 1; // Full snapshot (sent on subscribe) + L4BookDiff diff = 2; // Incremental diff (sent per block) } } @@ -68,27 +155,109 @@ message L4BookSnapshot { } // Incremental L4 book diff: raw order statuses and book diffs for one block. +// Sent as JSON to preserve the original node data format. message L4BookDiff { uint64 time = 1; uint64 height = 2; - string data = 3; + string data = 3; // JSON-encoded {order_statuses, book_diffs} +} + +message L4BookBytesUpdate { + oneof update { + L4BookSnapshot snapshot = 1; // Full snapshot (sent on subscribe) + L4BookBytesDiff diff = 2; // Incremental diff bytes + } +} + +message L4BookBytesDiff { + uint64 time = 1; + uint64 height = 2; + bytes data = 3; // JSON-encoded {order_statuses, book_diffs} +} + +// Request parameters for typed L4 order updates. +message L4BookUpdatesRequest { + repeated string coins = 1; // Empty means all coins +} + +enum L4OrderDiffType { + L4_ORDER_DIFF_TYPE_UNSPECIFIED = 0; + L4_ORDER_DIFF_TYPE_NEW = 1; + L4_ORDER_DIFF_TYPE_UPDATE = 2; + L4_ORDER_DIFF_TYPE_REMOVE = 3; +} + +// Typed L4 order book update batch for one processed block. +message L4BookUpdatesUpdate { + uint64 time = 1; + uint64 height = 2; + repeated L4OrderDiff diffs = 3; + bool snapshot = 4; // True when diffs carry a full reset snapshot +} + +// A single typed L4 order-level change. +message L4OrderDiff { + L4OrderDiffType diff_type = 1; + string coin = 2; + uint64 oid = 3; + string user = 4; + string side = 5; // "A" (Ask) or "B" (Bid) + string px = 6; + string sz = 7; // New/current size for new/update } // A single L4 order with full details. message L4Order { - string user = 1; + string user = 1; // Ethereum address string coin = 2; - string side = 3; - string limit_px = 4; - string sz = 5; - uint64 oid = 6; - uint64 timestamp = 7; - string trigger_condition = 8; + string side = 3; // "A" (Ask) or "B" (Bid) + string limit_px = 4; // Limit price as decimal string + string sz = 5; // Size as decimal string + uint64 oid = 6; // Unique order ID + uint64 timestamp = 7; // When order entered the book (ms) + string trigger_condition = 8; // "N/A", "Triggered", etc. bool is_trigger = 9; string trigger_px = 10; bool is_position_tpsl = 11; bool reduce_only = 12; - string order_type = 13; - optional string tif = 14; - optional string cloid = 15; + string order_type = 13; // "Limit", "Market", etc. + optional string tif = 14; // Time-in-force: "Gtc", "Ioc", "Alo" + optional string cloid = 15; // Client order ID +} + +// Request parameters for TP/SL trigger order updates. +message TpslUpdatesRequest { + repeated string coins = 1; // Empty means all perp coins +} + +enum TpslDiffType { + TPSL_DIFF_TYPE_UNSPECIFIED = 0; + TPSL_DIFF_TYPE_ADD = 1; + TPSL_DIFF_TYPE_REMOVE = 2; +} + +// TP/SL trigger order update batch for one processed block. +message TpslUpdatesUpdate { + uint64 time = 1; + uint64 height = 2; + repeated TpslOrderDiff diffs = 3; + bool snapshot = 4; // True when diffs carry currently-open trigger orders +} + +// A trigger order add/remove event. +message TpslOrderDiff { + TpslDiffType diff_type = 1; + uint64 oid = 2; + string coin = 3; + string user = 4; + string side = 5; // "A" (Ask) or "B" (Bid) + string trigger_px = 6; + string limit_px = 7; + string sz = 8; + string trigger_condition = 9; + string order_type = 10; + bool is_position_tpsl = 11; + bool reduce_only = 12; + uint64 timestamp = 13; // Order creation timestamp (milliseconds) + string reason = 14; // Present on remove; mirrors node status } diff --git a/python/hyperliquid_sdk/proto/orderbook_pb2.py b/python/hyperliquid_sdk/proto/orderbook_pb2.py index aaa4f05..b61eb69 100644 --- a/python/hyperliquid_sdk/proto/orderbook_pb2.py +++ b/python/hyperliquid_sdk/proto/orderbook_pb2.py @@ -24,29 +24,66 @@ -DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\x0forderbook.proto\x12\x0bhyperliquid\"{\n\rL2BookRequest\x12\x0c\n\x04\x63oin\x18\x01 \x01(\t\x12\x10\n\x08n_levels\x18\x02 \x01(\r\x12\x17\n\nn_sig_figs\x18\x03 \x01(\rH\x00\x88\x01\x01\x12\x15\n\x08mantissa\x18\x04 \x01(\x04H\x01\x88\x01\x01\x42\r\n\x0b_n_sig_figsB\x0b\n\t_mantissa\"\x88\x01\n\x0cL2BookUpdate\x12\x0c\n\x04\x63oin\x18\x01 \x01(\t\x12\x0c\n\x04time\x18\x02 \x01(\x04\x12\x14\n\x0c\x62lock_number\x18\x03 \x01(\x04\x12\"\n\x04\x62ids\x18\x04 \x03(\x0b\x32\x14.hyperliquid.L2Level\x12\"\n\x04\x61sks\x18\x05 \x03(\x0b\x32\x14.hyperliquid.L2Level\",\n\x07L2Level\x12\n\n\x02px\x18\x01 \x01(\t\x12\n\n\x02sz\x18\x02 \x01(\t\x12\t\n\x01n\x18\x03 \x01(\r\"\x1d\n\rL4BookRequest\x12\x0c\n\x04\x63oin\x18\x01 \x01(\t\"r\n\x0cL4BookUpdate\x12/\n\x08snapshot\x18\x01 \x01(\x0b\x32\x1b.hyperliquid.L4BookSnapshotH\x00\x12\'\n\x04\x64iff\x18\x02 \x01(\x0b\x32\x17.hyperliquid.L4BookDiffH\x00\x42\x08\n\x06update\"\x84\x01\n\x0eL4BookSnapshot\x12\x0c\n\x04\x63oin\x18\x01 \x01(\t\x12\x0c\n\x04time\x18\x02 \x01(\x04\x12\x0e\n\x06height\x18\x03 \x01(\x04\x12\"\n\x04\x62ids\x18\x04 \x03(\x0b\x32\x14.hyperliquid.L4Order\x12\"\n\x04\x61sks\x18\x05 \x03(\x0b\x32\x14.hyperliquid.L4Order\"8\n\nL4BookDiff\x12\x0c\n\x04time\x18\x01 \x01(\x04\x12\x0e\n\x06height\x18\x02 \x01(\x04\x12\x0c\n\x04\x64\x61ta\x18\x03 \x01(\t\"\xaf\x02\n\x07L4Order\x12\x0c\n\x04user\x18\x01 \x01(\t\x12\x0c\n\x04\x63oin\x18\x02 \x01(\t\x12\x0c\n\x04side\x18\x03 \x01(\t\x12\x10\n\x08limit_px\x18\x04 \x01(\t\x12\n\n\x02sz\x18\x05 \x01(\t\x12\x0b\n\x03oid\x18\x06 \x01(\x04\x12\x11\n\ttimestamp\x18\x07 \x01(\x04\x12\x19\n\x11trigger_condition\x18\x08 \x01(\t\x12\x12\n\nis_trigger\x18\t \x01(\x08\x12\x12\n\ntrigger_px\x18\n \x01(\t\x12\x18\n\x10is_position_tpsl\x18\x0b \x01(\x08\x12\x13\n\x0breduce_only\x18\x0c \x01(\x08\x12\x12\n\norder_type\x18\r \x01(\t\x12\x10\n\x03tif\x18\x0e \x01(\tH\x00\x88\x01\x01\x12\x12\n\x05\x63loid\x18\x0f \x01(\tH\x01\x88\x01\x01\x42\x06\n\x04_tifB\x08\n\x06_cloid2\xa6\x01\n\x12OrderBookStreaming\x12G\n\x0cStreamL2Book\x12\x1a.hyperliquid.L2BookRequest\x1a\x19.hyperliquid.L2BookUpdate0\x01\x12G\n\x0cStreamL4Book\x12\x1a.hyperliquid.L4BookRequest\x1a\x19.hyperliquid.L4BookUpdate0\x01\x62\x06proto3') +DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\x0forderbook.proto\x12\x0bhyperliquid\"{\n\rL2BookRequest\x12\x0c\n\x04\x63oin\x18\x01 \x01(\t\x12\x10\n\x08n_levels\x18\x02 \x01(\r\x12\x17\n\nn_sig_figs\x18\x03 \x01(\rH\x00\x88\x01\x01\x12\x15\n\x08mantissa\x18\x04 \x01(\x04H\x01\x88\x01\x01\x42\r\n\x0b_n_sig_figsB\x0b\n\t_mantissa\"\x88\x01\n\x0cL2BookUpdate\x12\x0c\n\x04\x63oin\x18\x01 \x01(\t\x12\x0c\n\x04time\x18\x02 \x01(\x04\x12\x14\n\x0c\x62lock_number\x18\x03 \x01(\x04\x12\"\n\x04\x62ids\x18\x04 \x03(\x0b\x32\x14.hyperliquid.L2Level\x12\"\n\x04\x61sks\x18\x05 \x03(\x0b\x32\x14.hyperliquid.L2Level\",\n\x07L2Level\x12\n\n\x02px\x18\x01 \x01(\t\x12\n\n\x02sz\x18\x02 \x01(\t\x12\t\n\x01n\x18\x03 \x01(\r\"2\n\rL2LevelPacked\x12\n\n\x02px\x18\x01 \x01(\x06\x12\n\n\x02sz\x18\x02 \x01(\x06\x12\t\n\x01n\x18\x03 \x01(\r\"\x9a\x01\n\x12L2BookPackedUpdate\x12\x0c\n\x04\x63oin\x18\x01 \x01(\t\x12\x0c\n\x04time\x18\x02 \x01(\x04\x12\x14\n\x0c\x62lock_number\x18\x03 \x01(\x04\x12(\n\x04\x62ids\x18\x04 \x03(\x0b\x32\x1a.hyperliquid.L2LevelPacked\x12(\n\x04\x61sks\x18\x05 \x03(\x0b\x32\x1a.hyperliquid.L2LevelPacked\"\x1f\n\x0e\x42\x62oBookRequest\x12\r\n\x05\x63oins\x18\x01 \x03(\t\"\x87\x01\n\rBboBookUpdate\x12\x0c\n\x04\x63oin\x18\x01 \x01(\t\x12\x0c\n\x04time\x18\x02 \x01(\x04\x12\x14\n\x0c\x62lock_number\x18\x03 \x01(\x04\x12!\n\x03\x62id\x18\x04 \x01(\x0b\x32\x14.hyperliquid.L2Level\x12!\n\x03\x61sk\x18\x05 \x01(\x0b\x32\x14.hyperliquid.L2Level\"\x99\x01\n\x13\x42\x62oBookPackedUpdate\x12\x0c\n\x04\x63oin\x18\x01 \x01(\t\x12\x0c\n\x04time\x18\x02 \x01(\x04\x12\x14\n\x0c\x62lock_number\x18\x03 \x01(\x04\x12\'\n\x03\x62id\x18\x04 \x01(\x0b\x32\x1a.hyperliquid.L2LevelPacked\x12\'\n\x03\x61sk\x18\x05 \x01(\x0b\x32\x1a.hyperliquid.L2LevelPacked\"\x9f\x01\n\x11L2BookDiffRequest\x12\r\n\x05\x63oins\x18\x01 \x03(\t\x12\x10\n\x08n_levels\x18\x02 \x01(\r\x12\x17\n\nn_sig_figs\x18\x03 \x01(\rH\x00\x88\x01\x01\x12\x15\n\x08mantissa\x18\x04 \x01(\x04H\x01\x88\x01\x01\x12\x1d\n\x15skip_initial_snapshot\x18\x05 \x01(\x08\x42\r\n\x0b_n_sig_figsB\x0b\n\t_mantissa\"j\n\x10L2BookDiffUpdate\x12\x0c\n\x04time\x18\x01 \x01(\x04\x12\x0e\n\x06height\x18\x02 \x01(\x04\x12\x10\n\x08snapshot\x18\x03 \x01(\x08\x12&\n\x05\x64iffs\x18\x04 \x03(\x0b\x32\x17.hyperliquid.L2CoinDiff\"\x93\x01\n\nL2CoinDiff\x12\x0c\n\x04\x63oin\x18\x01 \x01(\t\x12\x0b\n\x03seq\x18\x02 \x01(\x04\x12\x10\n\x08prev_seq\x18\x03 \x01(\x04\x12\"\n\x04\x62ids\x18\x04 \x03(\x0b\x32\x14.hyperliquid.L2Level\x12\"\n\x04\x61sks\x18\x05 \x03(\x0b\x32\x14.hyperliquid.L2Level\x12\x10\n\x08snapshot\x18\x06 \x01(\x08\"\x1d\n\rL4BookRequest\x12\x0c\n\x04\x63oin\x18\x01 \x01(\t\"r\n\x0cL4BookUpdate\x12/\n\x08snapshot\x18\x01 \x01(\x0b\x32\x1b.hyperliquid.L4BookSnapshotH\x00\x12\'\n\x04\x64iff\x18\x02 \x01(\x0b\x32\x17.hyperliquid.L4BookDiffH\x00\x42\x08\n\x06update\"\x84\x01\n\x0eL4BookSnapshot\x12\x0c\n\x04\x63oin\x18\x01 \x01(\t\x12\x0c\n\x04time\x18\x02 \x01(\x04\x12\x0e\n\x06height\x18\x03 \x01(\x04\x12\"\n\x04\x62ids\x18\x04 \x03(\x0b\x32\x14.hyperliquid.L4Order\x12\"\n\x04\x61sks\x18\x05 \x03(\x0b\x32\x14.hyperliquid.L4Order\"8\n\nL4BookDiff\x12\x0c\n\x04time\x18\x01 \x01(\x04\x12\x0e\n\x06height\x18\x02 \x01(\x04\x12\x0c\n\x04\x64\x61ta\x18\x03 \x01(\t\"|\n\x11L4BookBytesUpdate\x12/\n\x08snapshot\x18\x01 \x01(\x0b\x32\x1b.hyperliquid.L4BookSnapshotH\x00\x12,\n\x04\x64iff\x18\x02 \x01(\x0b\x32\x1c.hyperliquid.L4BookBytesDiffH\x00\x42\x08\n\x06update\"=\n\x0fL4BookBytesDiff\x12\x0c\n\x04time\x18\x01 \x01(\x04\x12\x0e\n\x06height\x18\x02 \x01(\x04\x12\x0c\n\x04\x64\x61ta\x18\x03 \x01(\x0c\"%\n\x14L4BookUpdatesRequest\x12\r\n\x05\x63oins\x18\x01 \x03(\t\"n\n\x13L4BookUpdatesUpdate\x12\x0c\n\x04time\x18\x01 \x01(\x04\x12\x0e\n\x06height\x18\x02 \x01(\x04\x12\'\n\x05\x64iffs\x18\x03 \x03(\x0b\x32\x18.hyperliquid.L4OrderDiff\x12\x10\n\x08snapshot\x18\x04 \x01(\x08\"\x8d\x01\n\x0bL4OrderDiff\x12/\n\tdiff_type\x18\x01 \x01(\x0e\x32\x1c.hyperliquid.L4OrderDiffType\x12\x0c\n\x04\x63oin\x18\x02 \x01(\t\x12\x0b\n\x03oid\x18\x03 \x01(\x04\x12\x0c\n\x04user\x18\x04 \x01(\t\x12\x0c\n\x04side\x18\x05 \x01(\t\x12\n\n\x02px\x18\x06 \x01(\t\x12\n\n\x02sz\x18\x07 \x01(\t\"\xaf\x02\n\x07L4Order\x12\x0c\n\x04user\x18\x01 \x01(\t\x12\x0c\n\x04\x63oin\x18\x02 \x01(\t\x12\x0c\n\x04side\x18\x03 \x01(\t\x12\x10\n\x08limit_px\x18\x04 \x01(\t\x12\n\n\x02sz\x18\x05 \x01(\t\x12\x0b\n\x03oid\x18\x06 \x01(\x04\x12\x11\n\ttimestamp\x18\x07 \x01(\x04\x12\x19\n\x11trigger_condition\x18\x08 \x01(\t\x12\x12\n\nis_trigger\x18\t \x01(\x08\x12\x12\n\ntrigger_px\x18\n \x01(\t\x12\x18\n\x10is_position_tpsl\x18\x0b \x01(\x08\x12\x13\n\x0breduce_only\x18\x0c \x01(\x08\x12\x12\n\norder_type\x18\r \x01(\t\x12\x10\n\x03tif\x18\x0e \x01(\tH\x00\x88\x01\x01\x12\x12\n\x05\x63loid\x18\x0f \x01(\tH\x01\x88\x01\x01\x42\x06\n\x04_tifB\x08\n\x06_cloid\"#\n\x12TpslUpdatesRequest\x12\r\n\x05\x63oins\x18\x01 \x03(\t\"n\n\x11TpslUpdatesUpdate\x12\x0c\n\x04time\x18\x01 \x01(\x04\x12\x0e\n\x06height\x18\x02 \x01(\x04\x12)\n\x05\x64iffs\x18\x03 \x03(\x0b\x32\x1a.hyperliquid.TpslOrderDiff\x12\x10\n\x08snapshot\x18\x04 \x01(\x08\"\xa7\x02\n\rTpslOrderDiff\x12,\n\tdiff_type\x18\x01 \x01(\x0e\x32\x19.hyperliquid.TpslDiffType\x12\x0b\n\x03oid\x18\x02 \x01(\x04\x12\x0c\n\x04\x63oin\x18\x03 \x01(\t\x12\x0c\n\x04user\x18\x04 \x01(\t\x12\x0c\n\x04side\x18\x05 \x01(\t\x12\x12\n\ntrigger_px\x18\x06 \x01(\t\x12\x10\n\x08limit_px\x18\x07 \x01(\t\x12\n\n\x02sz\x18\x08 \x01(\t\x12\x19\n\x11trigger_condition\x18\t \x01(\t\x12\x12\n\norder_type\x18\n \x01(\t\x12\x18\n\x10is_position_tpsl\x18\x0b \x01(\x08\x12\x13\n\x0breduce_only\x18\x0c \x01(\x08\x12\x11\n\ttimestamp\x18\r \x01(\x04\x12\x0e\n\x06reason\x18\x0e \x01(\t*\x8f\x01\n\x0fL4OrderDiffType\x12\"\n\x1eL4_ORDER_DIFF_TYPE_UNSPECIFIED\x10\x00\x12\x1a\n\x16L4_ORDER_DIFF_TYPE_NEW\x10\x01\x12\x1d\n\x19L4_ORDER_DIFF_TYPE_UPDATE\x10\x02\x12\x1d\n\x19L4_ORDER_DIFF_TYPE_REMOVE\x10\x03*a\n\x0cTpslDiffType\x12\x1e\n\x1aTPSL_DIFF_TYPE_UNSPECIFIED\x10\x00\x12\x16\n\x12TPSL_DIFF_TYPE_ADD\x10\x01\x12\x19\n\x15TPSL_DIFF_TYPE_REMOVE\x10\x02\x32\xfd\x05\n\x12OrderBookStreaming\x12G\n\x0cStreamL2Book\x12\x1a.hyperliquid.L2BookRequest\x1a\x19.hyperliquid.L2BookUpdate0\x01\x12G\n\x0cStreamL4Book\x12\x1a.hyperliquid.L4BookRequest\x1a\x19.hyperliquid.L4BookUpdate0\x01\x12J\n\rStreamBboBook\x12\x1b.hyperliquid.BboBookRequest\x1a\x1a.hyperliquid.BboBookUpdate0\x01\x12S\n\x10StreamL2BookDiff\x12\x1e.hyperliquid.L2BookDiffRequest\x1a\x1d.hyperliquid.L2BookDiffUpdate0\x01\x12\\\n\x13StreamL4BookUpdates\x12!.hyperliquid.L4BookUpdatesRequest\x1a .hyperliquid.L4BookUpdatesUpdate0\x01\x12V\n\x11StreamTpslUpdates\x12\x1f.hyperliquid.TpslUpdatesRequest\x1a\x1e.hyperliquid.TpslUpdatesUpdate0\x01\x12S\n\x12StreamL2BookPacked\x12\x1a.hyperliquid.L2BookRequest\x1a\x1f.hyperliquid.L2BookPackedUpdate0\x01\x12V\n\x13StreamBboBookPacked\x12\x1b.hyperliquid.BboBookRequest\x1a .hyperliquid.BboBookPackedUpdate0\x01\x12Q\n\x11StreamL4BookBytes\x12\x1a.hyperliquid.L4BookRequest\x1a\x1e.hyperliquid.L4BookBytesUpdate0\x01\x42?Z=github.com/quiknode-labs/hyperliquid-sdk/go/hyperliquid/protob\x06proto3') _globals = globals() _builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, _globals) _builder.BuildTopDescriptorsAndMessages(DESCRIPTOR, 'orderbook_pb2', _globals) if not _descriptor._USE_C_DESCRIPTORS: - DESCRIPTOR._loaded_options = None + _globals['DESCRIPTOR']._loaded_options = None + _globals['DESCRIPTOR']._serialized_options = b'Z=github.com/quiknode-labs/hyperliquid-sdk/go/hyperliquid/proto' + _globals['_L4ORDERDIFFTYPE']._serialized_start=2876 + _globals['_L4ORDERDIFFTYPE']._serialized_end=3019 + _globals['_TPSLDIFFTYPE']._serialized_start=3021 + _globals['_TPSLDIFFTYPE']._serialized_end=3118 _globals['_L2BOOKREQUEST']._serialized_start=32 _globals['_L2BOOKREQUEST']._serialized_end=155 _globals['_L2BOOKUPDATE']._serialized_start=158 _globals['_L2BOOKUPDATE']._serialized_end=294 _globals['_L2LEVEL']._serialized_start=296 _globals['_L2LEVEL']._serialized_end=340 - _globals['_L4BOOKREQUEST']._serialized_start=342 - _globals['_L4BOOKREQUEST']._serialized_end=371 - _globals['_L4BOOKUPDATE']._serialized_start=373 - _globals['_L4BOOKUPDATE']._serialized_end=487 - _globals['_L4BOOKSNAPSHOT']._serialized_start=490 - _globals['_L4BOOKSNAPSHOT']._serialized_end=622 - _globals['_L4BOOKDIFF']._serialized_start=624 - _globals['_L4BOOKDIFF']._serialized_end=680 - _globals['_L4ORDER']._serialized_start=683 - _globals['_L4ORDER']._serialized_end=986 - _globals['_ORDERBOOKSTREAMING']._serialized_start=989 - _globals['_ORDERBOOKSTREAMING']._serialized_end=1155 + _globals['_L2LEVELPACKED']._serialized_start=342 + _globals['_L2LEVELPACKED']._serialized_end=392 + _globals['_L2BOOKPACKEDUPDATE']._serialized_start=395 + _globals['_L2BOOKPACKEDUPDATE']._serialized_end=549 + _globals['_BBOBOOKREQUEST']._serialized_start=551 + _globals['_BBOBOOKREQUEST']._serialized_end=582 + _globals['_BBOBOOKUPDATE']._serialized_start=585 + _globals['_BBOBOOKUPDATE']._serialized_end=720 + _globals['_BBOBOOKPACKEDUPDATE']._serialized_start=723 + _globals['_BBOBOOKPACKEDUPDATE']._serialized_end=876 + _globals['_L2BOOKDIFFREQUEST']._serialized_start=879 + _globals['_L2BOOKDIFFREQUEST']._serialized_end=1038 + _globals['_L2BOOKDIFFUPDATE']._serialized_start=1040 + _globals['_L2BOOKDIFFUPDATE']._serialized_end=1146 + _globals['_L2COINDIFF']._serialized_start=1149 + _globals['_L2COINDIFF']._serialized_end=1296 + _globals['_L4BOOKREQUEST']._serialized_start=1298 + _globals['_L4BOOKREQUEST']._serialized_end=1327 + _globals['_L4BOOKUPDATE']._serialized_start=1329 + _globals['_L4BOOKUPDATE']._serialized_end=1443 + _globals['_L4BOOKSNAPSHOT']._serialized_start=1446 + _globals['_L4BOOKSNAPSHOT']._serialized_end=1578 + _globals['_L4BOOKDIFF']._serialized_start=1580 + _globals['_L4BOOKDIFF']._serialized_end=1636 + _globals['_L4BOOKBYTESUPDATE']._serialized_start=1638 + _globals['_L4BOOKBYTESUPDATE']._serialized_end=1762 + _globals['_L4BOOKBYTESDIFF']._serialized_start=1764 + _globals['_L4BOOKBYTESDIFF']._serialized_end=1825 + _globals['_L4BOOKUPDATESREQUEST']._serialized_start=1827 + _globals['_L4BOOKUPDATESREQUEST']._serialized_end=1864 + _globals['_L4BOOKUPDATESUPDATE']._serialized_start=1866 + _globals['_L4BOOKUPDATESUPDATE']._serialized_end=1976 + _globals['_L4ORDERDIFF']._serialized_start=1979 + _globals['_L4ORDERDIFF']._serialized_end=2120 + _globals['_L4ORDER']._serialized_start=2123 + _globals['_L4ORDER']._serialized_end=2426 + _globals['_TPSLUPDATESREQUEST']._serialized_start=2428 + _globals['_TPSLUPDATESREQUEST']._serialized_end=2463 + _globals['_TPSLUPDATESUPDATE']._serialized_start=2465 + _globals['_TPSLUPDATESUPDATE']._serialized_end=2575 + _globals['_TPSLORDERDIFF']._serialized_start=2578 + _globals['_TPSLORDERDIFF']._serialized_end=2873 + _globals['_ORDERBOOKSTREAMING']._serialized_start=3121 + _globals['_ORDERBOOKSTREAMING']._serialized_end=3886 # @@protoc_insertion_point(module_scope) diff --git a/python/hyperliquid_sdk/proto/orderbook_pb2_grpc.py b/python/hyperliquid_sdk/proto/orderbook_pb2_grpc.py index 737b052..7b489d1 100644 --- a/python/hyperliquid_sdk/proto/orderbook_pb2_grpc.py +++ b/python/hyperliquid_sdk/proto/orderbook_pb2_grpc.py @@ -47,6 +47,41 @@ def __init__(self, channel): request_serializer=orderbook__pb2.L4BookRequest.SerializeToString, response_deserializer=orderbook__pb2.L4BookUpdate.FromString, _registered_method=True) + self.StreamBboBook = channel.unary_stream( + '/hyperliquid.OrderBookStreaming/StreamBboBook', + request_serializer=orderbook__pb2.BboBookRequest.SerializeToString, + response_deserializer=orderbook__pb2.BboBookUpdate.FromString, + _registered_method=True) + self.StreamL2BookDiff = channel.unary_stream( + '/hyperliquid.OrderBookStreaming/StreamL2BookDiff', + request_serializer=orderbook__pb2.L2BookDiffRequest.SerializeToString, + response_deserializer=orderbook__pb2.L2BookDiffUpdate.FromString, + _registered_method=True) + self.StreamL4BookUpdates = channel.unary_stream( + '/hyperliquid.OrderBookStreaming/StreamL4BookUpdates', + request_serializer=orderbook__pb2.L4BookUpdatesRequest.SerializeToString, + response_deserializer=orderbook__pb2.L4BookUpdatesUpdate.FromString, + _registered_method=True) + self.StreamTpslUpdates = channel.unary_stream( + '/hyperliquid.OrderBookStreaming/StreamTpslUpdates', + request_serializer=orderbook__pb2.TpslUpdatesRequest.SerializeToString, + response_deserializer=orderbook__pb2.TpslUpdatesUpdate.FromString, + _registered_method=True) + self.StreamL2BookPacked = channel.unary_stream( + '/hyperliquid.OrderBookStreaming/StreamL2BookPacked', + request_serializer=orderbook__pb2.L2BookRequest.SerializeToString, + response_deserializer=orderbook__pb2.L2BookPackedUpdate.FromString, + _registered_method=True) + self.StreamBboBookPacked = channel.unary_stream( + '/hyperliquid.OrderBookStreaming/StreamBboBookPacked', + request_serializer=orderbook__pb2.BboBookRequest.SerializeToString, + response_deserializer=orderbook__pb2.BboBookPackedUpdate.FromString, + _registered_method=True) + self.StreamL4BookBytes = channel.unary_stream( + '/hyperliquid.OrderBookStreaming/StreamL4BookBytes', + request_serializer=orderbook__pb2.L4BookRequest.SerializeToString, + response_deserializer=orderbook__pb2.L4BookBytesUpdate.FromString, + _registered_method=True) class OrderBookStreamingServicer(object): @@ -71,6 +106,56 @@ def StreamL4Book(self, request, context): context.set_details('Method not implemented!') raise NotImplementedError('Method not implemented!') + def StreamBboBook(self, request, context): + """Subscribe to top-of-book changes. Empty coins means all coins. + Emits only when the best bid or ask changes for a coin. + """ + context.set_code(grpc.StatusCode.UNIMPLEMENTED) + context.set_details('Method not implemented!') + raise NotImplementedError('Method not implemented!') + + def StreamL2BookDiff(self, request, context): + """Subscribe to incremental L2 price-level changes. Empty coins means all coins. + """ + context.set_code(grpc.StatusCode.UNIMPLEMENTED) + context.set_details('Method not implemented!') + raise NotImplementedError('Method not implemented!') + + def StreamL4BookUpdates(self, request, context): + """Subscribe to typed L4 order book updates. Empty coins means all coins. + """ + context.set_code(grpc.StatusCode.UNIMPLEMENTED) + context.set_details('Method not implemented!') + raise NotImplementedError('Method not implemented!') + + def StreamTpslUpdates(self, request, context): + """Subscribe to trigger/TP-SL order updates. Empty coins means all perp coins. + """ + context.set_code(grpc.StatusCode.UNIMPLEMENTED) + context.set_details('Method not implemented!') + raise NotImplementedError('Method not implemented!') + + def StreamL2BookPacked(self, request, context): + """Fast-path L2 stream. Prices/sizes are fixed-point integers scaled by 1e8. + """ + context.set_code(grpc.StatusCode.UNIMPLEMENTED) + context.set_details('Method not implemented!') + raise NotImplementedError('Method not implemented!') + + def StreamBboBookPacked(self, request, context): + """Fast-path BBO stream. Prices/sizes are fixed-point integers scaled by 1e8. + """ + context.set_code(grpc.StatusCode.UNIMPLEMENTED) + context.set_details('Method not implemented!') + raise NotImplementedError('Method not implemented!') + + def StreamL4BookBytes(self, request, context): + """Fast-path L4 stream. Diff payload is JSON bytes instead of a protobuf string. + """ + context.set_code(grpc.StatusCode.UNIMPLEMENTED) + context.set_details('Method not implemented!') + raise NotImplementedError('Method not implemented!') + def add_OrderBookStreamingServicer_to_server(servicer, server): rpc_method_handlers = { @@ -84,6 +169,41 @@ def add_OrderBookStreamingServicer_to_server(servicer, server): request_deserializer=orderbook__pb2.L4BookRequest.FromString, response_serializer=orderbook__pb2.L4BookUpdate.SerializeToString, ), + 'StreamBboBook': grpc.unary_stream_rpc_method_handler( + servicer.StreamBboBook, + request_deserializer=orderbook__pb2.BboBookRequest.FromString, + response_serializer=orderbook__pb2.BboBookUpdate.SerializeToString, + ), + 'StreamL2BookDiff': grpc.unary_stream_rpc_method_handler( + servicer.StreamL2BookDiff, + request_deserializer=orderbook__pb2.L2BookDiffRequest.FromString, + response_serializer=orderbook__pb2.L2BookDiffUpdate.SerializeToString, + ), + 'StreamL4BookUpdates': grpc.unary_stream_rpc_method_handler( + servicer.StreamL4BookUpdates, + request_deserializer=orderbook__pb2.L4BookUpdatesRequest.FromString, + response_serializer=orderbook__pb2.L4BookUpdatesUpdate.SerializeToString, + ), + 'StreamTpslUpdates': grpc.unary_stream_rpc_method_handler( + servicer.StreamTpslUpdates, + request_deserializer=orderbook__pb2.TpslUpdatesRequest.FromString, + response_serializer=orderbook__pb2.TpslUpdatesUpdate.SerializeToString, + ), + 'StreamL2BookPacked': grpc.unary_stream_rpc_method_handler( + servicer.StreamL2BookPacked, + request_deserializer=orderbook__pb2.L2BookRequest.FromString, + response_serializer=orderbook__pb2.L2BookPackedUpdate.SerializeToString, + ), + 'StreamBboBookPacked': grpc.unary_stream_rpc_method_handler( + servicer.StreamBboBookPacked, + request_deserializer=orderbook__pb2.BboBookRequest.FromString, + response_serializer=orderbook__pb2.BboBookPackedUpdate.SerializeToString, + ), + 'StreamL4BookBytes': grpc.unary_stream_rpc_method_handler( + servicer.StreamL4BookBytes, + request_deserializer=orderbook__pb2.L4BookRequest.FromString, + response_serializer=orderbook__pb2.L4BookBytesUpdate.SerializeToString, + ), } generic_handler = grpc.method_handlers_generic_handler( 'hyperliquid.OrderBookStreaming', rpc_method_handlers) @@ -151,3 +271,192 @@ def StreamL4Book(request, timeout, metadata, _registered_method=True) + + @staticmethod + def StreamBboBook(request, + target, + options=(), + channel_credentials=None, + call_credentials=None, + insecure=False, + compression=None, + wait_for_ready=None, + timeout=None, + metadata=None): + return grpc.experimental.unary_stream( + request, + target, + '/hyperliquid.OrderBookStreaming/StreamBboBook', + orderbook__pb2.BboBookRequest.SerializeToString, + orderbook__pb2.BboBookUpdate.FromString, + options, + channel_credentials, + insecure, + call_credentials, + compression, + wait_for_ready, + timeout, + metadata, + _registered_method=True) + + @staticmethod + def StreamL2BookDiff(request, + target, + options=(), + channel_credentials=None, + call_credentials=None, + insecure=False, + compression=None, + wait_for_ready=None, + timeout=None, + metadata=None): + return grpc.experimental.unary_stream( + request, + target, + '/hyperliquid.OrderBookStreaming/StreamL2BookDiff', + orderbook__pb2.L2BookDiffRequest.SerializeToString, + orderbook__pb2.L2BookDiffUpdate.FromString, + options, + channel_credentials, + insecure, + call_credentials, + compression, + wait_for_ready, + timeout, + metadata, + _registered_method=True) + + @staticmethod + def StreamL4BookUpdates(request, + target, + options=(), + channel_credentials=None, + call_credentials=None, + insecure=False, + compression=None, + wait_for_ready=None, + timeout=None, + metadata=None): + return grpc.experimental.unary_stream( + request, + target, + '/hyperliquid.OrderBookStreaming/StreamL4BookUpdates', + orderbook__pb2.L4BookUpdatesRequest.SerializeToString, + orderbook__pb2.L4BookUpdatesUpdate.FromString, + options, + channel_credentials, + insecure, + call_credentials, + compression, + wait_for_ready, + timeout, + metadata, + _registered_method=True) + + @staticmethod + def StreamTpslUpdates(request, + target, + options=(), + channel_credentials=None, + call_credentials=None, + insecure=False, + compression=None, + wait_for_ready=None, + timeout=None, + metadata=None): + return grpc.experimental.unary_stream( + request, + target, + '/hyperliquid.OrderBookStreaming/StreamTpslUpdates', + orderbook__pb2.TpslUpdatesRequest.SerializeToString, + orderbook__pb2.TpslUpdatesUpdate.FromString, + options, + channel_credentials, + insecure, + call_credentials, + compression, + wait_for_ready, + timeout, + metadata, + _registered_method=True) + + @staticmethod + def StreamL2BookPacked(request, + target, + options=(), + channel_credentials=None, + call_credentials=None, + insecure=False, + compression=None, + wait_for_ready=None, + timeout=None, + metadata=None): + return grpc.experimental.unary_stream( + request, + target, + '/hyperliquid.OrderBookStreaming/StreamL2BookPacked', + orderbook__pb2.L2BookRequest.SerializeToString, + orderbook__pb2.L2BookPackedUpdate.FromString, + options, + channel_credentials, + insecure, + call_credentials, + compression, + wait_for_ready, + timeout, + metadata, + _registered_method=True) + + @staticmethod + def StreamBboBookPacked(request, + target, + options=(), + channel_credentials=None, + call_credentials=None, + insecure=False, + compression=None, + wait_for_ready=None, + timeout=None, + metadata=None): + return grpc.experimental.unary_stream( + request, + target, + '/hyperliquid.OrderBookStreaming/StreamBboBookPacked', + orderbook__pb2.BboBookRequest.SerializeToString, + orderbook__pb2.BboBookPackedUpdate.FromString, + options, + channel_credentials, + insecure, + call_credentials, + compression, + wait_for_ready, + timeout, + metadata, + _registered_method=True) + + @staticmethod + def StreamL4BookBytes(request, + target, + options=(), + channel_credentials=None, + call_credentials=None, + insecure=False, + compression=None, + wait_for_ready=None, + timeout=None, + metadata=None): + return grpc.experimental.unary_stream( + request, + target, + '/hyperliquid.OrderBookStreaming/StreamL4BookBytes', + orderbook__pb2.L4BookRequest.SerializeToString, + orderbook__pb2.L4BookBytesUpdate.FromString, + options, + channel_credentials, + insecure, + call_credentials, + compression, + wait_for_ready, + timeout, + metadata, + _registered_method=True) diff --git a/python/hyperliquid_sdk/proto/streaming.proto b/python/hyperliquid_sdk/proto/streaming.proto index 764b4f7..a24c96e 100644 --- a/python/hyperliquid_sdk/proto/streaming.proto +++ b/python/hyperliquid_sdk/proto/streaming.proto @@ -1,9 +1,13 @@ syntax = "proto3"; + package hyperliquid; +option go_package = "github.com/quiknode-labs/hyperliquid-sdk/go/hyperliquid/proto"; + service Streaming { // Bi-directional streaming rpc StreamData (stream SubscribeRequest) returns (stream SubscribeUpdate); + rpc StreamDataBytes (stream SubscribeRequest) returns (stream SubscribeBytesUpdate); rpc Ping (PingRequest) returns (PingResponse); } @@ -12,7 +16,9 @@ service BlockStreaming { rpc StreamBlocks (Timestamp) returns (stream Block); } + // --- Requests --- + message SubscribeRequest { oneof request { StreamSubscribe subscribe = 1; @@ -23,6 +29,7 @@ message SubscribeRequest { message StreamSubscribe { StreamType stream_type = 1; + uint64 start_block = 2; // Generic filters - field name to allowed values // Recursively searches each event for matching field/value pairs @@ -42,6 +49,7 @@ message FilterValues { message Ping { int64 timestamp = 1; } // --- Responses --- + message SubscribeUpdate { oneof update { StreamResponse data = 1; @@ -49,14 +57,31 @@ message SubscribeUpdate { } } +message SubscribeBytesUpdate { + oneof update { + StreamBytesResponse data = 1; + Pong pong = 2; + } +} + message StreamResponse { uint64 block_number = 1; uint64 timestamp = 2; // Server ingress timestamp + // Raw JSON data from the file (Exact replica of source) string data = 3; } +message StreamBytesResponse { + uint64 block_number = 1; + uint64 timestamp = 2; // Server ingress timestamp + + // Raw payload bytes. Fast-path clients should prefer this over StreamResponse.data. + bytes data = 3; +} + // --- Data Types --- + enum StreamType { UNKNOWN = 0; TRADES = 1; @@ -66,12 +91,16 @@ enum StreamType { EVENTS = 5; BLOCKS = 6; WRITER_ACTIONS = 7; + MEMPOOL_TXS = 8; // Pre-consensus mempool transactions + ORDER_PRIORITY = 9; // Derived order/write priority actions from mempool and confirmed replica data + GOSSIP_PRIORITY = 10; // Derived gossip/read priority bid actions; does not measure delivery latency } message Block { - string data_json = 1; + string data_json = 1; } + message Pong { int64 timestamp = 1; } message Timestamp { int64 timestamp = 1; } message PingRequest { int32 count = 1; } diff --git a/python/hyperliquid_sdk/proto/streaming_pb2.py b/python/hyperliquid_sdk/proto/streaming_pb2.py index 6aa2e56..3de9638 100644 --- a/python/hyperliquid_sdk/proto/streaming_pb2.py +++ b/python/hyperliquid_sdk/proto/streaming_pb2.py @@ -24,43 +24,48 @@ -DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\x0fstreaming.proto\x12\x0bhyperliquid\"y\n\x10SubscribeRequest\x12\x31\n\tsubscribe\x18\x01 \x01(\x0b\x32\x1c.hyperliquid.StreamSubscribeH\x00\x12!\n\x04ping\x18\x03 \x01(\x0b\x32\x11.hyperliquid.PingH\x00\x42\t\n\x07requestJ\x04\x08\x02\x10\x03\"\xdb\x01\n\x0fStreamSubscribe\x12,\n\x0bstream_type\x18\x01 \x01(\x0e\x32\x17.hyperliquid.StreamType\x12:\n\x07\x66ilters\x18\x03 \x03(\x0b\x32).hyperliquid.StreamSubscribe.FiltersEntry\x12\x13\n\x0b\x66ilter_name\x18\x04 \x01(\t\x1aI\n\x0c\x46iltersEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12(\n\x05value\x18\x02 \x01(\x0b\x32\x19.hyperliquid.FilterValues:\x02\x38\x01\"\x1e\n\x0c\x46ilterValues\x12\x0e\n\x06values\x18\x01 \x03(\t\"\x19\n\x04Ping\x12\x11\n\ttimestamp\x18\x01 \x01(\x03\"k\n\x0fSubscribeUpdate\x12+\n\x04\x64\x61ta\x18\x01 \x01(\x0b\x32\x1b.hyperliquid.StreamResponseH\x00\x12!\n\x04pong\x18\x02 \x01(\x0b\x32\x11.hyperliquid.PongH\x00\x42\x08\n\x06update\"G\n\x0eStreamResponse\x12\x14\n\x0c\x62lock_number\x18\x01 \x01(\x04\x12\x11\n\ttimestamp\x18\x02 \x01(\x04\x12\x0c\n\x04\x64\x61ta\x18\x03 \x01(\t\"\x1a\n\x05\x42lock\x12\x11\n\tdata_json\x18\x01 \x01(\t\"\x19\n\x04Pong\x12\x11\n\ttimestamp\x18\x01 \x01(\x03\"\x1e\n\tTimestamp\x12\x11\n\ttimestamp\x18\x01 \x01(\x03\"\x1c\n\x0bPingRequest\x12\r\n\x05\x63ount\x18\x01 \x01(\x05\"\x1d\n\x0cPingResponse\x12\r\n\x05\x63ount\x18\x01 \x01(\x05*y\n\nStreamType\x12\x0b\n\x07UNKNOWN\x10\x00\x12\n\n\x06TRADES\x10\x01\x12\n\n\x06ORDERS\x10\x02\x12\x10\n\x0c\x42OOK_UPDATES\x10\x03\x12\x08\n\x04TWAP\x10\x04\x12\n\n\x06\x45VENTS\x10\x05\x12\n\n\x06\x42LOCKS\x10\x06\x12\x12\n\x0eWRITER_ACTIONS\x10\x07\x32\x97\x01\n\tStreaming\x12M\n\nStreamData\x12\x1d.hyperliquid.SubscribeRequest\x1a\x1c.hyperliquid.SubscribeUpdate(\x01\x30\x01\x12;\n\x04Ping\x12\x18.hyperliquid.PingRequest\x1a\x19.hyperliquid.PingResponse2N\n\x0e\x42lockStreaming\x12<\n\x0cStreamBlocks\x12\x16.hyperliquid.Timestamp\x1a\x12.hyperliquid.Block0\x01\x62\x06proto3') +DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\x0fstreaming.proto\x12\x0bhyperliquid\"y\n\x10SubscribeRequest\x12\x31\n\tsubscribe\x18\x01 \x01(\x0b\x32\x1c.hyperliquid.StreamSubscribeH\x00\x12!\n\x04ping\x18\x03 \x01(\x0b\x32\x11.hyperliquid.PingH\x00\x42\t\n\x07requestJ\x04\x08\x02\x10\x03\"\xf0\x01\n\x0fStreamSubscribe\x12,\n\x0bstream_type\x18\x01 \x01(\x0e\x32\x17.hyperliquid.StreamType\x12\x13\n\x0bstart_block\x18\x02 \x01(\x04\x12:\n\x07\x66ilters\x18\x03 \x03(\x0b\x32).hyperliquid.StreamSubscribe.FiltersEntry\x12\x13\n\x0b\x66ilter_name\x18\x04 \x01(\t\x1aI\n\x0c\x46iltersEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12(\n\x05value\x18\x02 \x01(\x0b\x32\x19.hyperliquid.FilterValues:\x02\x38\x01\"\x1e\n\x0c\x46ilterValues\x12\x0e\n\x06values\x18\x01 \x03(\t\"\x19\n\x04Ping\x12\x11\n\ttimestamp\x18\x01 \x01(\x03\"k\n\x0fSubscribeUpdate\x12+\n\x04\x64\x61ta\x18\x01 \x01(\x0b\x32\x1b.hyperliquid.StreamResponseH\x00\x12!\n\x04pong\x18\x02 \x01(\x0b\x32\x11.hyperliquid.PongH\x00\x42\x08\n\x06update\"u\n\x14SubscribeBytesUpdate\x12\x30\n\x04\x64\x61ta\x18\x01 \x01(\x0b\x32 .hyperliquid.StreamBytesResponseH\x00\x12!\n\x04pong\x18\x02 \x01(\x0b\x32\x11.hyperliquid.PongH\x00\x42\x08\n\x06update\"G\n\x0eStreamResponse\x12\x14\n\x0c\x62lock_number\x18\x01 \x01(\x04\x12\x11\n\ttimestamp\x18\x02 \x01(\x04\x12\x0c\n\x04\x64\x61ta\x18\x03 \x01(\t\"L\n\x13StreamBytesResponse\x12\x14\n\x0c\x62lock_number\x18\x01 \x01(\x04\x12\x11\n\ttimestamp\x18\x02 \x01(\x04\x12\x0c\n\x04\x64\x61ta\x18\x03 \x01(\x0c\"\x1a\n\x05\x42lock\x12\x11\n\tdata_json\x18\x01 \x01(\t\"\x19\n\x04Pong\x12\x11\n\ttimestamp\x18\x01 \x01(\x03\"\x1e\n\tTimestamp\x12\x11\n\ttimestamp\x18\x01 \x01(\x03\"\x1c\n\x0bPingRequest\x12\r\n\x05\x63ount\x18\x01 \x01(\x05\"\x1d\n\x0cPingResponse\x12\r\n\x05\x63ount\x18\x01 \x01(\x05*\xb3\x01\n\nStreamType\x12\x0b\n\x07UNKNOWN\x10\x00\x12\n\n\x06TRADES\x10\x01\x12\n\n\x06ORDERS\x10\x02\x12\x10\n\x0c\x42OOK_UPDATES\x10\x03\x12\x08\n\x04TWAP\x10\x04\x12\n\n\x06\x45VENTS\x10\x05\x12\n\n\x06\x42LOCKS\x10\x06\x12\x12\n\x0eWRITER_ACTIONS\x10\x07\x12\x0f\n\x0bMEMPOOL_TXS\x10\x08\x12\x12\n\x0eORDER_PRIORITY\x10\t\x12\x13\n\x0fGOSSIP_PRIORITY\x10\n2\xf0\x01\n\tStreaming\x12M\n\nStreamData\x12\x1d.hyperliquid.SubscribeRequest\x1a\x1c.hyperliquid.SubscribeUpdate(\x01\x30\x01\x12W\n\x0fStreamDataBytes\x12\x1d.hyperliquid.SubscribeRequest\x1a!.hyperliquid.SubscribeBytesUpdate(\x01\x30\x01\x12;\n\x04Ping\x12\x18.hyperliquid.PingRequest\x1a\x19.hyperliquid.PingResponse2N\n\x0e\x42lockStreaming\x12<\n\x0cStreamBlocks\x12\x16.hyperliquid.Timestamp\x1a\x12.hyperliquid.Block0\x01\x42?Z=github.com/quiknode-labs/hyperliquid-sdk/go/hyperliquid/protob\x06proto3') _globals = globals() _builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, _globals) _builder.BuildTopDescriptorsAndMessages(DESCRIPTOR, 'streaming_pb2', _globals) if not _descriptor._USE_C_DESCRIPTORS: - DESCRIPTOR._loaded_options = None + _globals['DESCRIPTOR']._loaded_options = None + _globals['DESCRIPTOR']._serialized_options = b'Z=github.com/quiknode-labs/hyperliquid-sdk/go/hyperliquid/proto' _globals['_STREAMSUBSCRIBE_FILTERSENTRY']._loaded_options = None _globals['_STREAMSUBSCRIBE_FILTERSENTRY']._serialized_options = b'8\001' - _globals['_STREAMTYPE']._serialized_start=766 - _globals['_STREAMTYPE']._serialized_end=887 + _globals['_STREAMTYPE']._serialized_start=985 + _globals['_STREAMTYPE']._serialized_end=1164 _globals['_SUBSCRIBEREQUEST']._serialized_start=32 _globals['_SUBSCRIBEREQUEST']._serialized_end=153 _globals['_STREAMSUBSCRIBE']._serialized_start=156 - _globals['_STREAMSUBSCRIBE']._serialized_end=375 - _globals['_STREAMSUBSCRIBE_FILTERSENTRY']._serialized_start=302 - _globals['_STREAMSUBSCRIBE_FILTERSENTRY']._serialized_end=375 - _globals['_FILTERVALUES']._serialized_start=377 - _globals['_FILTERVALUES']._serialized_end=407 - _globals['_PING']._serialized_start=409 - _globals['_PING']._serialized_end=434 - _globals['_SUBSCRIBEUPDATE']._serialized_start=436 - _globals['_SUBSCRIBEUPDATE']._serialized_end=543 - _globals['_STREAMRESPONSE']._serialized_start=545 - _globals['_STREAMRESPONSE']._serialized_end=616 - _globals['_BLOCK']._serialized_start=618 - _globals['_BLOCK']._serialized_end=644 - _globals['_PONG']._serialized_start=646 - _globals['_PONG']._serialized_end=671 - _globals['_TIMESTAMP']._serialized_start=673 - _globals['_TIMESTAMP']._serialized_end=703 - _globals['_PINGREQUEST']._serialized_start=705 - _globals['_PINGREQUEST']._serialized_end=733 - _globals['_PINGRESPONSE']._serialized_start=735 - _globals['_PINGRESPONSE']._serialized_end=764 - _globals['_STREAMING']._serialized_start=890 - _globals['_STREAMING']._serialized_end=1041 - _globals['_BLOCKSTREAMING']._serialized_start=1043 - _globals['_BLOCKSTREAMING']._serialized_end=1121 + _globals['_STREAMSUBSCRIBE']._serialized_end=396 + _globals['_STREAMSUBSCRIBE_FILTERSENTRY']._serialized_start=323 + _globals['_STREAMSUBSCRIBE_FILTERSENTRY']._serialized_end=396 + _globals['_FILTERVALUES']._serialized_start=398 + _globals['_FILTERVALUES']._serialized_end=428 + _globals['_PING']._serialized_start=430 + _globals['_PING']._serialized_end=455 + _globals['_SUBSCRIBEUPDATE']._serialized_start=457 + _globals['_SUBSCRIBEUPDATE']._serialized_end=564 + _globals['_SUBSCRIBEBYTESUPDATE']._serialized_start=566 + _globals['_SUBSCRIBEBYTESUPDATE']._serialized_end=683 + _globals['_STREAMRESPONSE']._serialized_start=685 + _globals['_STREAMRESPONSE']._serialized_end=756 + _globals['_STREAMBYTESRESPONSE']._serialized_start=758 + _globals['_STREAMBYTESRESPONSE']._serialized_end=834 + _globals['_BLOCK']._serialized_start=836 + _globals['_BLOCK']._serialized_end=862 + _globals['_PONG']._serialized_start=864 + _globals['_PONG']._serialized_end=889 + _globals['_TIMESTAMP']._serialized_start=891 + _globals['_TIMESTAMP']._serialized_end=921 + _globals['_PINGREQUEST']._serialized_start=923 + _globals['_PINGREQUEST']._serialized_end=951 + _globals['_PINGRESPONSE']._serialized_start=953 + _globals['_PINGRESPONSE']._serialized_end=982 + _globals['_STREAMING']._serialized_start=1167 + _globals['_STREAMING']._serialized_end=1407 + _globals['_BLOCKSTREAMING']._serialized_start=1409 + _globals['_BLOCKSTREAMING']._serialized_end=1487 # @@protoc_insertion_point(module_scope) diff --git a/python/hyperliquid_sdk/proto/streaming_pb2_grpc.py b/python/hyperliquid_sdk/proto/streaming_pb2_grpc.py index cfbba51..e0caa15 100644 --- a/python/hyperliquid_sdk/proto/streaming_pb2_grpc.py +++ b/python/hyperliquid_sdk/proto/streaming_pb2_grpc.py @@ -39,6 +39,11 @@ def __init__(self, channel): request_serializer=streaming__pb2.SubscribeRequest.SerializeToString, response_deserializer=streaming__pb2.SubscribeUpdate.FromString, _registered_method=True) + self.StreamDataBytes = channel.stream_stream( + '/hyperliquid.Streaming/StreamDataBytes', + request_serializer=streaming__pb2.SubscribeRequest.SerializeToString, + response_deserializer=streaming__pb2.SubscribeBytesUpdate.FromString, + _registered_method=True) self.Ping = channel.unary_unary( '/hyperliquid.Streaming/Ping', request_serializer=streaming__pb2.PingRequest.SerializeToString, @@ -56,6 +61,12 @@ def StreamData(self, request_iterator, context): context.set_details('Method not implemented!') raise NotImplementedError('Method not implemented!') + def StreamDataBytes(self, request_iterator, context): + """Missing associated documentation comment in .proto file.""" + context.set_code(grpc.StatusCode.UNIMPLEMENTED) + context.set_details('Method not implemented!') + raise NotImplementedError('Method not implemented!') + def Ping(self, request, context): """Missing associated documentation comment in .proto file.""" context.set_code(grpc.StatusCode.UNIMPLEMENTED) @@ -70,6 +81,11 @@ def add_StreamingServicer_to_server(servicer, server): request_deserializer=streaming__pb2.SubscribeRequest.FromString, response_serializer=streaming__pb2.SubscribeUpdate.SerializeToString, ), + 'StreamDataBytes': grpc.stream_stream_rpc_method_handler( + servicer.StreamDataBytes, + request_deserializer=streaming__pb2.SubscribeRequest.FromString, + response_serializer=streaming__pb2.SubscribeBytesUpdate.SerializeToString, + ), 'Ping': grpc.unary_unary_rpc_method_handler( servicer.Ping, request_deserializer=streaming__pb2.PingRequest.FromString, @@ -113,6 +129,33 @@ def StreamData(request_iterator, metadata, _registered_method=True) + @staticmethod + def StreamDataBytes(request_iterator, + target, + options=(), + channel_credentials=None, + call_credentials=None, + insecure=False, + compression=None, + wait_for_ready=None, + timeout=None, + metadata=None): + return grpc.experimental.stream_stream( + request_iterator, + target, + '/hyperliquid.Streaming/StreamDataBytes', + streaming__pb2.SubscribeRequest.SerializeToString, + streaming__pb2.SubscribeBytesUpdate.FromString, + options, + channel_credentials, + insecure, + call_credentials, + compression, + wait_for_ready, + timeout, + metadata, + _registered_method=True) + @staticmethod def Ping(request, target, diff --git a/python/tests/test_sdk.py b/python/tests/test_sdk.py index a12ee39..013fdd9 100644 --- a/python/tests/test_sdk.py +++ b/python/tests/test_sdk.py @@ -376,6 +376,218 @@ def fake_exchange(body): assert placed.oid == 1 +class TestVaultFastCancelExpiresAfter: + """vaultAddress / fast cancel "f" flag / expiresAfter threading. + + vaultAddress and expiresAfter are top-level exchange-request fields that + the worker folds into the action hash at build time, so they must appear + in BOTH the build payload (signed hash covers them) and the send payload + (signature recovery + forwarded to Hyperliquid). The fast flag "f" lives + inside the cancel action itself and is only emitted when true. + """ + + @staticmethod + def _sdk_with_fake_exchange(): + from hyperliquid_sdk import HyperliquidSDK + + sdk = HyperliquidSDK(private_key="0x" + "11" * 32, auto_approve=False) + calls = [] + + def fake_exchange(body): + calls.append(body) + if "signature" not in body: + return { + "hash": "0x" + "00" * 32, + "nonce": 123, + "action": body["action"], + } + return { + "success": True, + "exchangeResponse": { + "response": { + "data": { + "statuses": [{"resting": {"oid": 7}}] + } + } + }, + } + + sdk._exchange = fake_exchange + return sdk, calls + + def test_order_threads_vault_and_expires_to_build_and_send(self): + sdk, calls = self._sdk_with_fake_exchange() + vault = "0x" + "ab" * 20 + expiry = 1752900000000 + + placed = sdk.buy( + "BTC", size=0.001, price=67000, tif="gtc", + vault_address=vault, expires_after=expiry, + ) + + build, send = calls + # Build: fields must be present so the returned hash covers them. + assert build["vaultAddress"] == vault + assert build["expiresAfter"] == expiry + # Send: fields must ride again for signature recovery + forwarding. + assert send["vaultAddress"] == vault + assert send["expiresAfter"] == expiry + assert placed.oid == 7 + + def test_new_fields_never_emitted_when_unset(self): + sdk, calls = self._sdk_with_fake_exchange() + + sdk.buy("BTC", size=0.001, price=67000, tif="gtc") + sdk.cancel(7) + + for body in calls: + assert "vaultAddress" not in body + assert "expiresAfter" not in body + assert "f" not in body["action"] + + def test_cancel_fast_flag_emitted_only_when_true(self): + sdk, calls = self._sdk_with_fake_exchange() + + sdk.cancel(42, fast=True) + assert calls[0]["action"] == { + "type": "cancel", + "cancels": [{"a": 0, "o": 42}], + "f": True, + } + + calls.clear() + sdk.cancel(42, fast=False) + assert "f" not in calls[0]["action"] + + def test_cancel_by_cloid_fast_and_vault(self): + sdk, calls = self._sdk_with_fake_exchange() + vault = "0x" + "cd" * 20 + + sdk.cancel_by_cloid("0x" + "01" * 16, 3, fast=True, vault_address=vault) + + build, send = calls + assert build["action"]["type"] == "cancelByCloid" + assert build["action"]["f"] is True + assert build["vaultAddress"] == vault + assert send["vaultAddress"] == vault + + def test_cancel_all_fast_sets_flag(self): + sdk, calls = self._sdk_with_fake_exchange() + all_action = {"type": "cancel", "cancels": [{"a": 0, "o": 1}, {"a": 1, "o": 2}]} + open_orders_users = [] + + def fake_open_orders(user=None, **kwargs): + open_orders_users.append(user) + return { + "orders": [{"oid": 1}, {"oid": 2}], + "cancelActions": {"all": all_action}, + } + + sdk.open_orders = fake_open_orders + + sdk.cancel_all(fast=True) + + assert calls[0]["action"]["f"] is True + # The worker-provided action dict must not be mutated in place. + assert "f" not in all_action + # Without a vault, the wallet's own orders are enumerated. + assert open_orders_users == [None] + + def test_cancel_all_enumerates_vault_orders(self): + sdk, calls = self._sdk_with_fake_exchange() + vault = "0x" + "ab" * 20 + open_orders_users = [] + + def fake_open_orders(user=None, **kwargs): + open_orders_users.append(user) + return { + "orders": [{"oid": 1}], + "cancelActions": {"all": {"type": "cancel", "cancels": [{"a": 0, "o": 1}]}}, + } + + sdk.open_orders = fake_open_orders + + sdk.cancel_all(vault_address=vault) + + # The vault's open orders must be enumerated, not the wallet's. + assert open_orders_users == [vault] + assert calls[0]["vaultAddress"] == vault + + def test_close_position_uses_vault_user(self): + sdk, calls = self._sdk_with_fake_exchange() + vault = "0x" + "cd" * 20 + + sdk.close_position("BTC", vault_address=vault) + + # The worker sizes the close from action.user — must be the vault. + assert calls[0]["action"]["user"] == vault + assert calls[0]["vaultAddress"] == vault + + def test_modify_threads_vault_and_expires(self): + sdk, calls = self._sdk_with_fake_exchange() + vault = "0x" + "ef" * 20 + expiry = 1752900000000 + + sdk.modify( + 9, "BTC", "buy", "67000", "0.001", + vault_address=vault, expires_after=expiry, + ) + + build, send = calls + assert build["action"]["type"] == "batchModify" + assert build["vaultAddress"] == vault + assert build["expiresAfter"] == expiry + assert send["vaultAddress"] == vault + assert send["expiresAfter"] == expiry + + def test_close_position_threads_vault_and_expires(self): + sdk, calls = self._sdk_with_fake_exchange() + vault = "0x" + "12" * 20 + + sdk.close_position("BTC", vault_address=vault, expires_after=1752900000000) + + build, send = calls + assert build["action"]["type"] == "closePosition" + assert build["vaultAddress"] == vault + assert send["vaultAddress"] == vault + assert send["expiresAfter"] == 1752900000000 + + def test_schedule_cancel_threads_vault_and_expires(self): + sdk, calls = self._sdk_with_fake_exchange() + vault = "0x" + "34" * 20 + + sdk.schedule_cancel(1752900060000, vault_address=vault, expires_after=1752900000000) + + build, send = calls + assert build["action"] == {"type": "scheduleCancel", "time": 1752900060000} + assert build["vaultAddress"] == vault + assert send["expiresAfter"] == 1752900000000 + + def test_trigger_order_threads_vault_and_expires(self): + sdk, calls = self._sdk_with_fake_exchange() + vault = "0x" + "56" * 20 + + sdk.stop_loss( + "BTC", size=0.001, trigger_price=60000, + vault_address=vault, expires_after=1752900000000, + ) + + build, send = calls + assert build["vaultAddress"] == vault + assert build["expiresAfter"] == 1752900000000 + assert send["vaultAddress"] == vault + + def test_expires_after_rejects_invalid_values(self): + from hyperliquid_sdk.errors import ValidationError + + sdk, _ = self._sdk_with_fake_exchange() + + with pytest.raises(ValidationError, match="expires_after"): + sdk.cancel(1, expires_after=-5) + with pytest.raises(ValidationError, match="expires_after"): + sdk.cancel(1, expires_after="soon") + + # Test 3: Info API class TestInfoAPI: """Test Info API methods work. @@ -548,6 +760,9 @@ def test_grpc_stream_types_enum(self): assert GRPCStreamType.TWAP.value == "TWAP" assert GRPCStreamType.EVENTS.value == "EVENTS" assert GRPCStreamType.BLOCKS.value == "BLOCKS" + assert GRPCStreamType.MEMPOOL_TXS.value == "MEMPOOL_TXS" + assert GRPCStreamType.ORDER_PRIORITY.value == "ORDER_PRIORITY" + assert GRPCStreamType.GOSSIP_PRIORITY.value == "GOSSIP_PRIORITY" def test_grpc_stream_initialization(self): """Test GRPCStream can be initialized.""" @@ -580,6 +795,470 @@ def test_grpc_subscriptions(self): assert len(stream._subscriptions) == 4 +class TestGRPCNewStreams: + """Test the newer gRPC stream types and orderbook RPCs.""" + + def _stream(self): + from hyperliquid_sdk import GRPCStream + + return GRPCStream("https://test.quiknode.pro/TOKEN", reconnect=False) + + def test_stream_type_map_matches_proto(self): + """Test _STREAM_TYPE_MAP matches the generated proto enum, incl. 8/9/10.""" + from hyperliquid_sdk import proto + from hyperliquid_sdk.grpc_stream import _STREAM_TYPE_MAP + + for name, value in _STREAM_TYPE_MAP.items(): + assert proto.StreamType.Value(name) == value + + assert _STREAM_TYPE_MAP["MEMPOOL_TXS"] == 8 + assert _STREAM_TYPE_MAP["ORDER_PRIORITY"] == 9 + assert _STREAM_TYPE_MAP["GOSSIP_PRIORITY"] == 10 + + def test_new_stream_subscriptions(self): + """Test new subscription helpers register subscriptions.""" + stream = self._stream() + + stream.mempool_txs(lambda x: None, coins=["BTC"]) + stream.raw_mempool_txs(lambda x: None) + stream.order_priority(lambda x: None) + stream.raw_order_priority(lambda x: None) + stream.gossip_priority(lambda x: None) + stream.raw_gossip_priority(lambda x: None) + stream.bbo_book(lambda x: None, coins=["BTC"]) + stream.l2_book_diff(lambda x: None, coins=["BTC"]) + stream.l4_book_updates(lambda x: None) + stream.tpsl_updates(lambda x: None) + stream.l2_book_packed("BTC", lambda x: None) + stream.bbo_book_packed(lambda x: None) + stream.l4_book_bytes("BTC", lambda x: None) + stream.stream_bytes("TRADES", lambda x: None, coins=["BTC"]) + + assert len(stream._subscriptions) == 14 + assert stream._subscriptions[0]["stream_type"] == "MEMPOOL_TXS" + assert stream._subscriptions[0]["coins"] == ["BTC"] + assert stream._subscriptions[1]["raw"] is True + assert stream._subscriptions[2]["stream_type"] == "ORDER_PRIORITY" + assert stream._subscriptions[4]["stream_type"] == "GOSSIP_PRIORITY" + assert stream._subscriptions[13]["stream_type"] == "STREAM_BYTES" + assert stream._subscriptions[13]["bytes_stream_type"] == "TRADES" + + def test_subscribe_request_start_block_and_filters(self): + """Test start_block and coin/user filters are plumbed into StreamSubscribe.""" + stream = self._stream() + stream.trades(["BTC", "ETH"], lambda x: None, start_block=12345) + stream.orders(["ETH"], lambda x: None, users=["0xabc"]) + stream.mempool_txs(lambda x: None, coins=["BTC"]) + + req = stream._build_subscribe_request(stream._subscriptions[0]) + assert req.subscribe.stream_type == 1 + assert req.subscribe.start_block == 12345 + assert list(req.subscribe.filters["coin"].values) == ["BTC", "ETH"] + + req = stream._build_subscribe_request(stream._subscriptions[1]) + assert req.subscribe.start_block == 0 + assert list(req.subscribe.filters["user"].values) == ["0xabc"] + + req = stream._build_subscribe_request(stream._subscriptions[2]) + assert req.subscribe.stream_type == 8 + assert list(req.subscribe.filters["coin"].values) == ["BTC"] + + def test_stream_bytes_request(self): + """Test stream_bytes builds a SubscribeRequest for the requested stream type.""" + stream = self._stream() + stream.stream_bytes("ORDERS", lambda x: None, users=["0xabc"], start_block=99) + + sub = stream._subscriptions[0] + req = stream._build_subscribe_request(sub, sub["bytes_stream_type"]) + assert req.subscribe.stream_type == 2 + assert req.subscribe.start_block == 99 + assert list(req.subscribe.filters["user"].values) == ["0xabc"] + + def test_orderbook_request_builders(self): + """Test orderbook subscription options map to the right request messages.""" + from hyperliquid_sdk import proto + + stream = self._stream() + stream.bbo_book(lambda x: None, coins=["BTC"]) + stream.l2_book_diff( + lambda x: None, + coins=["BTC", "ETH"], + n_levels=50, + n_sig_figs=5, + mantissa=2, + skip_initial_snapshot=True, + ) + stream.l4_book_updates(lambda x: None) + stream.tpsl_updates(lambda x: None, coins=["SOL"]) + stream.l2_book_packed("BTC", lambda x: None, n_sig_figs=4, n_levels=10) + stream.bbo_book_packed(lambda x: None) + stream.l4_book_bytes("ETH", lambda x: None) + + subs = stream._subscriptions + + req = stream._build_orderbook_request(subs[0]) + assert isinstance(req, proto.BboBookRequest) + assert list(req.coins) == ["BTC"] + + req = stream._build_orderbook_request(subs[1]) + assert isinstance(req, proto.L2BookDiffRequest) + assert list(req.coins) == ["BTC", "ETH"] + assert req.n_levels == 50 + assert req.n_sig_figs == 5 + assert req.mantissa == 2 + assert req.skip_initial_snapshot is True + + req = stream._build_orderbook_request(subs[2]) + assert isinstance(req, proto.L4BookUpdatesRequest) + assert list(req.coins) == [] + + req = stream._build_orderbook_request(subs[3]) + assert isinstance(req, proto.TpslUpdatesRequest) + assert list(req.coins) == ["SOL"] + + req = stream._build_orderbook_request(subs[4]) + assert isinstance(req, proto.L2BookRequest) + assert req.coin == "BTC" + assert req.n_sig_figs == 4 + assert req.n_levels == 10 + + req = stream._build_orderbook_request(subs[5]) + assert isinstance(req, proto.BboBookRequest) + assert list(req.coins) == [] + + req = stream._build_orderbook_request(subs[6]) + assert isinstance(req, proto.L4BookRequest) + assert req.coin == "ETH" + + def test_bbo_update_conversion(self): + """Test BboBookUpdate conversion (absent bid/ask -> None).""" + from hyperliquid_sdk import proto + + stream = self._stream() + + update = proto.BboBookUpdate( + coin="BTC", + time=1000, + block_number=42, + bid=proto.L2Level(px="50000", sz="1.5", n=3), + ) + data = stream._orderbook_update_to_dict("BBO_BOOK", update) + assert data["coin"] == "BTC" + assert data["bid"] == ["50000", "1.5", 3] + assert data["ask"] is None + + def test_l2_book_diff_conversion(self): + """Test L2BookDiffUpdate conversion (sz=0 level = removed).""" + from hyperliquid_sdk import proto + + stream = self._stream() + + update = proto.L2BookDiffUpdate( + time=1000, + height=42, + snapshot=False, + diffs=[ + proto.L2CoinDiff( + coin="BTC", + seq=7, + prev_seq=6, + bids=[proto.L2Level(px="50000", sz="0", n=0)], + asks=[proto.L2Level(px="50001", sz="2", n=1)], + ) + ], + ) + data = stream._orderbook_update_to_dict("L2_BOOK_DIFF", update) + assert data["height"] == 42 + assert data["snapshot"] is False + assert data["diffs"][0]["coin"] == "BTC" + assert data["diffs"][0]["seq"] == 7 + assert data["diffs"][0]["prev_seq"] == 6 + assert data["diffs"][0]["bids"] == [["50000", "0", 0]] + assert data["diffs"][0]["asks"] == [["50001", "2", 1]] + + def test_l4_book_updates_conversion(self): + """Test L4BookUpdatesUpdate conversion with typed diffs.""" + from hyperliquid_sdk import proto + + stream = self._stream() + + update = proto.L4BookUpdatesUpdate( + time=1000, + height=42, + snapshot=True, + diffs=[ + proto.L4OrderDiff( + diff_type=proto.L4OrderDiffType.L4_ORDER_DIFF_TYPE_NEW, + coin="BTC", + oid=123, + user="0xabc", + side="B", + px="50000", + sz="1", + ) + ], + ) + data = stream._orderbook_update_to_dict("L4_BOOK_UPDATES", update) + assert data["snapshot"] is True + assert data["diffs"][0]["diff_type"] == "L4_ORDER_DIFF_TYPE_NEW" + assert data["diffs"][0]["oid"] == 123 + + def test_tpsl_updates_conversion(self): + """Test TpslUpdatesUpdate conversion with typed diffs.""" + from hyperliquid_sdk import proto + + stream = self._stream() + + update = proto.TpslUpdatesUpdate( + time=1000, + height=42, + diffs=[ + proto.TpslOrderDiff( + diff_type=proto.TpslDiffType.TPSL_DIFF_TYPE_REMOVE, + oid=55, + coin="ETH", + user="0xdef", + side="A", + trigger_px="3000", + reason="filled", + ) + ], + ) + data = stream._orderbook_update_to_dict("TPSL_UPDATES", update) + assert data["diffs"][0]["diff_type"] == "TPSL_DIFF_TYPE_REMOVE" + assert data["diffs"][0]["oid"] == 55 + assert data["diffs"][0]["reason"] == "filled" + + def test_packed_conversion(self): + """Test packed L2/BBO conversion keeps fixed-point integers.""" + from hyperliquid_sdk import proto + + stream = self._stream() + + update = proto.L2BookPackedUpdate( + coin="BTC", + time=1000, + block_number=42, + bids=[proto.L2LevelPacked(px=5000000000000, sz=150000000, n=3)], + ) + data = stream._orderbook_update_to_dict("L2_BOOK_PACKED", update) + assert data["bids"] == [[5000000000000, 150000000, 3]] + + update = proto.BboBookPackedUpdate( + coin="BTC", + time=1000, + block_number=42, + ask=proto.L2LevelPacked(px=5000100000000, sz=200000000, n=1), + ) + data = stream._orderbook_update_to_dict("BBO_BOOK_PACKED", update) + assert data["bid"] is None + assert data["ask"] == [5000100000000, 200000000, 1] + + def test_l4_book_bytes_conversion(self): + """Test L4BookBytesUpdate conversion (diff payload stays raw bytes).""" + from hyperliquid_sdk import proto + + stream = self._stream() + + update = proto.L4BookBytesUpdate( + diff=proto.L4BookBytesDiff(time=1000, height=42, data=b'{"order_statuses":[]}') + ) + data = stream._orderbook_update_to_dict("L4_BOOK_BYTES", update) + assert data["type"] == "diff" + assert data["data"] == b'{"order_statuses":[]}' + + update = proto.L4BookBytesUpdate( + snapshot=proto.L4BookSnapshot(coin="BTC", time=1000, height=42) + ) + data = stream._orderbook_update_to_dict("L4_BOOK_BYTES", update) + assert data["type"] == "snapshot" + assert data["coin"] == "BTC" + + def test_stubs_have_new_rpcs(self): + """Test generated stubs expose the new RPC methods.""" + from hyperliquid_sdk import proto + + class _FakeChannel: + def unary_unary(self, *args, **kwargs): + return lambda *a, **k: None + + def unary_stream(self, *args, **kwargs): + return lambda *a, **k: None + + def stream_stream(self, *args, **kwargs): + return lambda *a, **k: None + + streaming = proto.StreamingStub(_FakeChannel()) + assert hasattr(streaming, "StreamDataBytes") + + orderbook = proto.OrderBookStreamingStub(_FakeChannel()) + for rpc in ( + "StreamBboBook", + "StreamL2BookDiff", + "StreamL4BookUpdates", + "StreamTpslUpdates", + "StreamL2BookPacked", + "StreamBboBookPacked", + "StreamL4BookBytes", + ): + assert hasattr(orderbook, rpc) + + def test_start_streams_dispatch(self): + """Test _start_streams routes new stream types to the right workers.""" + from hyperliquid_sdk.grpc_stream import _ORDERBOOK_RPC_MAP + + assert set(_ORDERBOOK_RPC_MAP) == { + "BBO_BOOK", + "L2_BOOK_DIFF", + "L4_BOOK_UPDATES", + "TPSL_UPDATES", + "L2_BOOK_PACKED", + "BBO_BOOK_PACKED", + "L4_BOOK_BYTES", + } + + +class TestGRPCReconnectSemantics: + """Test reconnect request semantics (start_block resume, snapshot resync).""" + + def _stream(self): + from hyperliquid_sdk import GRPCStream + + return GRPCStream("https://test.quiknode.pro/TOKEN", reconnect=False) + + def test_reconnect_resumes_after_last_seen_block(self): + """Reconnect requests advance a user-set start_block past delivered blocks.""" + stream = self._stream() + stream.trades(["BTC"], lambda x: None, start_block=100) + sub = stream._subscriptions[0] + + # First connect: the original start_block is sent verbatim. + req = stream._build_subscribe_request(sub) + assert req.subscribe.start_block == 100 + + # After blocks were delivered, a reconnect resumes past the last one. + sub["_last_seen_block"] = 250 + req = stream._build_subscribe_request(sub, reconnect=True) + assert req.subscribe.start_block == 251 + + # First-connect requests still use the original start_block. + req = stream._build_subscribe_request(sub) + assert req.subscribe.start_block == 100 + + def test_reconnect_never_rewinds_before_original_start_block(self): + """Reconnect uses max(original, last_seen + 1).""" + stream = self._stream() + stream.trades(["BTC"], lambda x: None, start_block=100) + sub = stream._subscriptions[0] + + sub["_last_seen_block"] = 50 + req = stream._build_subscribe_request(sub, reconnect=True) + assert req.subscribe.start_block == 100 + + # Reconnect before any block was delivered also keeps the original. + del sub["_last_seen_block"] + req = stream._build_subscribe_request(sub, reconnect=True) + assert req.subscribe.start_block == 100 + + def test_unset_start_block_stays_unset_on_reconnect(self): + """Tip-following subscriptions never start sending a cursor.""" + stream = self._stream() + stream.trades(["BTC"], lambda x: None) + sub = stream._subscriptions[0] + + sub["_last_seen_block"] = 250 + req = stream._build_subscribe_request(sub, reconnect=True) + assert req.subscribe.start_block == 0 + + def test_bytes_path_reconnect_resumes_after_last_seen_block(self): + """The StreamDataBytes path applies the same reconnect resume rule.""" + stream = self._stream() + stream.stream_bytes("ORDERS", lambda x: None, start_block=99) + sub = stream._subscriptions[0] + + req = stream._build_subscribe_request(sub, sub["bytes_stream_type"]) + assert req.subscribe.stream_type == 2 + assert req.subscribe.start_block == 99 + + sub["_last_seen_block"] = 200 + req = stream._build_subscribe_request(sub, sub["bytes_stream_type"], reconnect=True) + assert req.subscribe.stream_type == 2 + assert req.subscribe.start_block == 201 + + def test_stream_data_tracks_last_seen_block_and_resumes(self): + """_stream_data records delivered blocks and resumes past them on reconnect.""" + stream = self._stream() + stream.trades(["BTC"], lambda x: None, start_block=100) + sub = stream._subscriptions[0] + + requests = [] + + class _FakeData: + def __init__(self, block_number): + self.block_number = block_number + self.timestamp = 1 + self.data = '{"events": []}' + + class _FakeResponse: + def __init__(self, block_number): + self.data = _FakeData(block_number) + + def HasField(self, name): + return name == "data" + + class _FakeStub: + def __init__(self): + self.calls = 0 + + def StreamData(self, request_iterator, metadata=None): + self.calls += 1 + requests.append(next(request_iterator)) + if self.calls == 1: + # Deliver out-of-order blocks, then end the stream (disconnect). + return iter([_FakeResponse(200), _FakeResponse(180)]) + stream._running = False + return iter([]) + + stream._streaming_stub = _FakeStub() + stream._running = True + stream._stream_data(sub) + + assert len(requests) == 2 + # First connect uses the original start_block. + assert requests[0].subscribe.start_block == 100 + # Reconnect resumes past the HIGHEST delivered block (200), not the last (180). + assert requests[1].subscribe.start_block == 201 + + def test_l2_book_diff_skip_snapshot_only_on_first_connect(self): + """skip_initial_snapshot applies to the first connect; reconnects resync.""" + stream = self._stream() + stream.l2_book_diff(lambda x: None, coins=["BTC"], skip_initial_snapshot=True) + sub = stream._subscriptions[0] + + # First connect honors the user's setting. + req = stream._build_orderbook_request(sub) + assert req.skip_initial_snapshot is True + + # Reconnect forces a snapshot so the local book can resync. + req = stream._build_orderbook_request(sub, reconnect=True) + assert req.skip_initial_snapshot is False + + # The user's setting is not clobbered for later inspection. + assert sub["skip_initial_snapshot"] is True + + def test_l2_book_diff_default_snapshot_unchanged_on_reconnect(self): + """Default (snapshot-on) subscriptions still get a snapshot on reconnect.""" + stream = self._stream() + stream.l2_book_diff(lambda x: None, coins=["BTC"]) + sub = stream._subscriptions[0] + + req = stream._build_orderbook_request(sub) + assert req.skip_initial_snapshot is False + + req = stream._build_orderbook_request(sub, reconnect=True) + assert req.skip_initial_snapshot is False + + # Test 6: Error handling class TestErrorHandling: """Test error classes work correctly.""" diff --git a/rust/README.md b/rust/README.md index e3b8391..ae6a867 100644 --- a/rust/README.md +++ b/rust/README.md @@ -378,6 +378,12 @@ stream.blocks(|b| println!("Block: {:?}", b)); // Subscribe to raw event blocks stream.raw_events(|b| println!("Events block: {:?}", b)); +// Subscribe to best bid/offer changes (empty slice = all coins) +stream.bbo_book(&["BTC"], |b| println!("BBO: {:?}", b)); + +// Subscribe to pre-consensus mempool transactions +stream.mempool_txs(&["BTC"], |tx| println!("Mempool tx: {:?}", tx)); + // Run in background stream.start()?; // ... do other work ... @@ -408,6 +414,28 @@ The SDK automatically connects to port 10000 with your token. | `raw_events(callback)` | - | Raw system event blocks with `events: [...]` | | `writer_actions(callback)` | - | Writer actions | | `raw_writer_actions(callback)` | - | Raw writer action blocks | +| `mempool_txs(coins, callback)` | coins: `&[&str]` (empty = all) | Pre-consensus mempool transactions | +| `raw_mempool_txs(coins, callback)` | coins: `&[&str]` | Raw mempool transaction blocks | +| `order_priority(callback)` | - | Derived order/write priority actions | +| `raw_order_priority(callback)` | - | Raw order priority blocks | +| `gossip_priority(callback)` | - | Derived gossip/read priority bid actions | +| `raw_gossip_priority(callback)` | - | Raw gossip priority blocks | +| `raw_bytes(stream_type, coins, options, callback)` | any data stream type | Low-level `StreamDataBytes` variant; callback receives raw `StreamBytesResponse` (undecoded payload bytes) | +| `bbo_book(coins, callback)` | coins: `&[&str]` (empty = all) | Best bid/offer changes per coin | +| `bbo_book_packed(coins, callback)` | coins: `&[&str]` | BBO with fixed-point px/sz (u64, scaled by 1e8) | +| `l2_book_diff(coins, callback)` | coins: `&[&str]` (empty = all) | Incremental L2 price-level changes (`sz == "0"` = level removed); `l2_book_diff_with_options` takes `GRPCL2BookDiffOptions` (n_levels, n_sig_figs, mantissa, skip_initial_snapshot) | +| `l4_book_updates(coins, callback)` | coins: `&[&str]` (empty = all) | Typed L4 order diffs (new/update/remove) | +| `tpsl_updates(coins, callback)` | coins: `&[&str]` (empty = all perps) | Trigger/TP-SL order add/remove events | +| `l2_book_packed(coin, callback)` | coin: `&str` | Fast-path L2 book with fixed-point px/sz (u64, scaled by 1e8) | +| `l4_book_bytes(coin, callback)` | coin: `&str` | Fast-path L4 book; diffs are JSON bytes on the wire, same payload shape as `l4_book` | + +Notes: + +- `mempool_txs` uses the generic coin filter — pass an empty slice for the unfiltered stream. +- `order_priority` and `gossip_priority` events carry server-enriched fields `coin`, `market_type`, and `sz_decimals`. +- Packed streams (`l2_book_packed`, `bbo_book_packed`) deliver prices and sizes as u64 fixed-point integers scaled by 1e8. +- L4 contract (`l4_book`, `l4_book_bytes`, `l4_book_updates`): the server may send an unsolicited full snapshot at any time after subscribe (e.g. after ALO queue-priority anchored insertions). Clients MUST discard local book state and replace it with any snapshot received mid-stream. `insertBefore` is never sent to clients. +- Data streams accept `*_with_options` variants with `GRPCSubscriptionOptions { start_block }` to resume from a specific Hyperliquid block. **Note:** `start_block` is not yet supported server-side — streams currently always begin at the live tip; the option is wired through so resume works automatically once server support ships. ### L4 Order Book (Critical for Trading) @@ -571,6 +599,46 @@ sdk.cancel_all(Some("BTC")).await?; // Just BTC orders sdk.schedule_cancel(Some(timestamp_ms)).await?; ``` +### Vault Trading, Fast Cancels & Action TTL + +```rust +use hyperliquid_sdk::{CancelOptions, ExchangeOptions, Order}; + +// Trade on behalf of a vault or subaccount. Sent as the top-level +// `vaultAddress` exchange field and signed into the action hash. +sdk.order( + Order::buy("BTC") + .size(0.001) + .price(65000.0) + .gtc() + .vault_address("0x1719884eb866cb12b2287399b15f7db5e7d775ea") +).await?; +sdk.market_buy("HYPE").await.size(0.3).vault_address("0x1719...75ea").await?; + +// Action TTL: reject the action if not executed by this ms timestamp. +// Sent as the top-level `expiresAfter` exchange field (also signed). +sdk.order( + Order::buy("BTC").size(0.001).price(65000.0).gtc().expires_after(now_ms + 60_000) +).await?; + +// Fast cancel: emits `"f": true` on the cancel action. +let fast = CancelOptions { fast: true, ..Default::default() }; +sdk.cancel_with_options(oid, "BTC", &fast).await?; +sdk.cancel_by_cloid_with_options("0xmycloid...", "BTC", &fast).await?; +sdk.cancel_all_with_options(Some("BTC"), &fast).await?; + +// Vault-scoped modify / close (CancelOptions also carries vault_address +// and expires_after for vault-scoped cancels). +let vault = ExchangeOptions { + vault_address: Some("0x1719...75ea".to_string()), + ..Default::default() +}; +sdk.modify_with_options(oid, "BTC", true, 0.001, 61000.0, TIF::Gtc, false, None, &vault).await?; +sdk.close_position_with_options("BTC", None, &vault).await?; +``` + +All three fields are optional and are omitted from the request entirely when unset. + ### Position Management ```rust diff --git a/rust/proto/orderbook.proto b/rust/proto/orderbook.proto index c4ffd0b..f0c1f78 100644 --- a/rust/proto/orderbook.proto +++ b/rust/proto/orderbook.proto @@ -8,7 +8,7 @@ syntax = "proto3"; package hyperliquid; -option go_package = "github.com/quiknode-labs/raptor/hyperliquid-sdk/go/hyperliquid/proto"; +option go_package = "github.com/quiknode-labs/hyperliquid-sdk/go/hyperliquid/proto"; // Service for streaming real-time order book data. // L2 provides aggregated price levels (px, total_sz, order_count). @@ -21,42 +21,127 @@ service OrderBookStreaming { // Subscribe to L4 full order book for a coin. // Sends an initial L4 snapshot, then incremental diffs per block. rpc StreamL4Book (L4BookRequest) returns (stream L4BookUpdate); + + // Subscribe to top-of-book changes. Empty coins means all coins. + // Emits only when the best bid or ask changes for a coin. + rpc StreamBboBook (BboBookRequest) returns (stream BboBookUpdate); + + // Subscribe to incremental L2 price-level changes. Empty coins means all coins. + rpc StreamL2BookDiff (L2BookDiffRequest) returns (stream L2BookDiffUpdate); + + // Subscribe to typed L4 order book updates. Empty coins means all coins. + rpc StreamL4BookUpdates (L4BookUpdatesRequest) returns (stream L4BookUpdatesUpdate); + + // Subscribe to trigger/TP-SL order updates. Empty coins means all perp coins. + rpc StreamTpslUpdates (TpslUpdatesRequest) returns (stream TpslUpdatesUpdate); + + // Fast-path L2 stream. Prices/sizes are fixed-point integers scaled by 1e8. + rpc StreamL2BookPacked (L2BookRequest) returns (stream L2BookPackedUpdate); + + // Fast-path BBO stream. Prices/sizes are fixed-point integers scaled by 1e8. + rpc StreamBboBookPacked (BboBookRequest) returns (stream BboBookPackedUpdate); + + // Fast-path L4 stream. Diff payload is JSON bytes instead of a protobuf string. + rpc StreamL4BookBytes (L4BookRequest) returns (stream L4BookBytesUpdate); } // Request parameters for L2 book streaming. message L2BookRequest { - string coin = 1; - uint32 n_levels = 2; - optional uint32 n_sig_figs = 3; - optional uint64 mantissa = 4; + string coin = 1; // Symbol (e.g. "BTC", "ETH") + uint32 n_levels = 2; // Max number of price levels (default 20, max 100) + optional uint32 n_sig_figs = 3; // Significance figures for price bucketing (2-5) + optional uint64 mantissa = 4; // Mantissa for bucketing (1, 2, or 5) } // An L2 book update: full snapshot of aggregated price levels at a point in time. message L2BookUpdate { string coin = 1; - uint64 time = 2; + uint64 time = 2; // Block timestamp (milliseconds) uint64 block_number = 3; - repeated L2Level bids = 4; - repeated L2Level asks = 5; + repeated L2Level bids = 4; // Aggregated bid levels (best first) + repeated L2Level asks = 5; // Aggregated ask levels (best first) } // A single aggregated price level. message L2Level { - string px = 1; - string sz = 2; - uint32 n = 3; + string px = 1; // Price as decimal string + string sz = 2; // Total size as decimal string + uint32 n = 3; // Number of individual orders at this level +} + +message L2LevelPacked { + fixed64 px = 1; // Price scaled by 1e8 + fixed64 sz = 2; // Size scaled by 1e8 + uint32 n = 3; // Number of individual orders at this level +} + +message L2BookPackedUpdate { + string coin = 1; + uint64 time = 2; + uint64 block_number = 3; + repeated L2LevelPacked bids = 4; + repeated L2LevelPacked asks = 5; +} + +// Request parameters for best bid/offer streaming. +message BboBookRequest { + repeated string coins = 1; // Empty means all coins +} + +// Best bid/offer update for a coin. +message BboBookUpdate { + string coin = 1; + uint64 time = 2; // Block timestamp (milliseconds) + uint64 block_number = 3; + L2Level bid = 4; // Absent if no bid + L2Level ask = 5; // Absent if no ask +} + +message BboBookPackedUpdate { + string coin = 1; + uint64 time = 2; + uint64 block_number = 3; + L2LevelPacked bid = 4; + L2LevelPacked ask = 5; +} + +// Request parameters for L2 diff streaming. +message L2BookDiffRequest { + repeated string coins = 1; // Empty means all coins + uint32 n_levels = 2; // Max tracked levels per side (default 20, max 100) + optional uint32 n_sig_figs = 3; // Significance figures for price bucketing (2-5) + optional uint64 mantissa = 4; // Mantissa for bucketing (1, 2, or 5) + bool skip_initial_snapshot = 5; // If false, first update per coin contains current levels +} + +// Batch of L2 price-level changes for one processed block. +message L2BookDiffUpdate { + uint64 time = 1; // Block timestamp (milliseconds) + uint64 height = 2; // Block height + bool snapshot = 3; // True when carrying initial levels for any coin + repeated L2CoinDiff diffs = 4; // Only coins with changed levels +} + +// Incremental L2 changes for one coin. +message L2CoinDiff { + string coin = 1; + uint64 seq = 2; // Per-coin sequence in this stream + uint64 prev_seq = 3; + repeated L2Level bids = 4; // Changed bid levels; sz=0 means removed + repeated L2Level asks = 5; // Changed ask levels; sz=0 means removed + bool snapshot = 6; // True when bids/asks carry initial levels } // Request parameters for L4 book streaming. message L4BookRequest { - string coin = 1; + string coin = 1; // Symbol (e.g. "BTC", "ETH") } // An L4 book update: either a full snapshot or an incremental diff. message L4BookUpdate { oneof update { - L4BookSnapshot snapshot = 1; - L4BookDiff diff = 2; + L4BookSnapshot snapshot = 1; // Full snapshot (sent on subscribe) + L4BookDiff diff = 2; // Incremental diff (sent per block) } } @@ -70,27 +155,109 @@ message L4BookSnapshot { } // Incremental L4 book diff: raw order statuses and book diffs for one block. +// Sent as JSON to preserve the original node data format. message L4BookDiff { uint64 time = 1; uint64 height = 2; - string data = 3; + string data = 3; // JSON-encoded {order_statuses, book_diffs} +} + +message L4BookBytesUpdate { + oneof update { + L4BookSnapshot snapshot = 1; // Full snapshot (sent on subscribe) + L4BookBytesDiff diff = 2; // Incremental diff bytes + } +} + +message L4BookBytesDiff { + uint64 time = 1; + uint64 height = 2; + bytes data = 3; // JSON-encoded {order_statuses, book_diffs} +} + +// Request parameters for typed L4 order updates. +message L4BookUpdatesRequest { + repeated string coins = 1; // Empty means all coins +} + +enum L4OrderDiffType { + L4_ORDER_DIFF_TYPE_UNSPECIFIED = 0; + L4_ORDER_DIFF_TYPE_NEW = 1; + L4_ORDER_DIFF_TYPE_UPDATE = 2; + L4_ORDER_DIFF_TYPE_REMOVE = 3; +} + +// Typed L4 order book update batch for one processed block. +message L4BookUpdatesUpdate { + uint64 time = 1; + uint64 height = 2; + repeated L4OrderDiff diffs = 3; + bool snapshot = 4; // True when diffs carry a full reset snapshot +} + +// A single typed L4 order-level change. +message L4OrderDiff { + L4OrderDiffType diff_type = 1; + string coin = 2; + uint64 oid = 3; + string user = 4; + string side = 5; // "A" (Ask) or "B" (Bid) + string px = 6; + string sz = 7; // New/current size for new/update } // A single L4 order with full details. message L4Order { - string user = 1; + string user = 1; // Ethereum address string coin = 2; - string side = 3; - string limit_px = 4; - string sz = 5; - uint64 oid = 6; - uint64 timestamp = 7; - string trigger_condition = 8; + string side = 3; // "A" (Ask) or "B" (Bid) + string limit_px = 4; // Limit price as decimal string + string sz = 5; // Size as decimal string + uint64 oid = 6; // Unique order ID + uint64 timestamp = 7; // When order entered the book (ms) + string trigger_condition = 8; // "N/A", "Triggered", etc. bool is_trigger = 9; string trigger_px = 10; bool is_position_tpsl = 11; bool reduce_only = 12; - string order_type = 13; - optional string tif = 14; - optional string cloid = 15; + string order_type = 13; // "Limit", "Market", etc. + optional string tif = 14; // Time-in-force: "Gtc", "Ioc", "Alo" + optional string cloid = 15; // Client order ID +} + +// Request parameters for TP/SL trigger order updates. +message TpslUpdatesRequest { + repeated string coins = 1; // Empty means all perp coins +} + +enum TpslDiffType { + TPSL_DIFF_TYPE_UNSPECIFIED = 0; + TPSL_DIFF_TYPE_ADD = 1; + TPSL_DIFF_TYPE_REMOVE = 2; +} + +// TP/SL trigger order update batch for one processed block. +message TpslUpdatesUpdate { + uint64 time = 1; + uint64 height = 2; + repeated TpslOrderDiff diffs = 3; + bool snapshot = 4; // True when diffs carry currently-open trigger orders +} + +// A trigger order add/remove event. +message TpslOrderDiff { + TpslDiffType diff_type = 1; + uint64 oid = 2; + string coin = 3; + string user = 4; + string side = 5; // "A" (Ask) or "B" (Bid) + string trigger_px = 6; + string limit_px = 7; + string sz = 8; + string trigger_condition = 9; + string order_type = 10; + bool is_position_tpsl = 11; + bool reduce_only = 12; + uint64 timestamp = 13; // Order creation timestamp (milliseconds) + string reason = 14; // Present on remove; mirrors node status } diff --git a/rust/proto/streaming.proto b/rust/proto/streaming.proto index 7b8cb56..a24c96e 100644 --- a/rust/proto/streaming.proto +++ b/rust/proto/streaming.proto @@ -1,11 +1,13 @@ syntax = "proto3"; + package hyperliquid; -option go_package = "github.com/quiknode-labs/raptor/hyperliquid-sdk/go/hyperliquid/proto"; +option go_package = "github.com/quiknode-labs/hyperliquid-sdk/go/hyperliquid/proto"; service Streaming { // Bi-directional streaming rpc StreamData (stream SubscribeRequest) returns (stream SubscribeUpdate); + rpc StreamDataBytes (stream SubscribeRequest) returns (stream SubscribeBytesUpdate); rpc Ping (PingRequest) returns (PingResponse); } @@ -14,7 +16,9 @@ service BlockStreaming { rpc StreamBlocks (Timestamp) returns (stream Block); } + // --- Requests --- + message SubscribeRequest { oneof request { StreamSubscribe subscribe = 1; @@ -25,8 +29,6 @@ message SubscribeRequest { message StreamSubscribe { StreamType stream_type = 1; - - // Optional block number to resume the stream from. uint64 start_block = 2; // Generic filters - field name to allowed values @@ -47,6 +49,7 @@ message FilterValues { message Ping { int64 timestamp = 1; } // --- Responses --- + message SubscribeUpdate { oneof update { StreamResponse data = 1; @@ -54,14 +57,31 @@ message SubscribeUpdate { } } +message SubscribeBytesUpdate { + oneof update { + StreamBytesResponse data = 1; + Pong pong = 2; + } +} + message StreamResponse { uint64 block_number = 1; uint64 timestamp = 2; // Server ingress timestamp + // Raw JSON data from the file (Exact replica of source) string data = 3; } +message StreamBytesResponse { + uint64 block_number = 1; + uint64 timestamp = 2; // Server ingress timestamp + + // Raw payload bytes. Fast-path clients should prefer this over StreamResponse.data. + bytes data = 3; +} + // --- Data Types --- + enum StreamType { UNKNOWN = 0; TRADES = 1; @@ -71,12 +91,16 @@ enum StreamType { EVENTS = 5; BLOCKS = 6; WRITER_ACTIONS = 7; + MEMPOOL_TXS = 8; // Pre-consensus mempool transactions + ORDER_PRIORITY = 9; // Derived order/write priority actions from mempool and confirmed replica data + GOSSIP_PRIORITY = 10; // Derived gossip/read priority bid actions; does not measure delivery latency } message Block { - string data_json = 1; + string data_json = 1; } + message Pong { int64 timestamp = 1; } message Timestamp { int64 timestamp = 1; } message PingRequest { int32 count = 1; } diff --git a/rust/src/client.rs b/rust/src/client.rs index c154c03..f6f6248 100644 --- a/rust/src/client.rs +++ b/rust/src/client.rs @@ -553,6 +553,22 @@ impl HyperliquidSDKInner { action: &Value, slippage: Option, priority_fee: Option, + ) -> Result { + self.build_action_with_params(action, slippage, priority_fee, &ExchangeOptions::default()) + .await + } + + /// Build an action with the full set of optional exchange-body parameters. + /// + /// `vaultAddress` and `expiresAfter` are top-level exchange-body fields that + /// the worker folds into the action hash, so they must be present at build + /// time for the returned hash to cover them. Omitted entirely when unset. + pub async fn build_action_with_params( + &self, + action: &Value, + slippage: Option, + priority_fee: Option, + opts: &ExchangeOptions, ) -> Result { let url = self.exchange_url(); @@ -568,6 +584,7 @@ impl HyperliquidSDKInner { } body["slippage"] = json!(s); } + apply_exchange_options(&mut body, opts); let response = self .http_client @@ -612,14 +629,31 @@ impl HyperliquidSDKInner { action: &Value, nonce: u64, signature: &Signature, + ) -> Result { + self.send_action_with_params(action, nonce, signature, &ExchangeOptions::default()) + .await + } + + /// Send a signed action with the full set of optional exchange-body parameters. + /// + /// `vaultAddress`/`expiresAfter` must match what was passed at build time: + /// the worker recovers the signer from the hash that includes them and + /// forwards them upstream. Omitted entirely when unset. + pub async fn send_action_with_params( + &self, + action: &Value, + nonce: u64, + signature: &Signature, + opts: &ExchangeOptions, ) -> Result { let url = self.exchange_url(); - let body = json!({ + let mut body = json!({ "action": action, "nonce": nonce, "signature": signature, }); + apply_exchange_options(&mut body, opts); let response = self .http_client @@ -669,6 +703,23 @@ impl HyperliquidSDKInner { action: &Value, slippage: Option, priority_fee: Option, + ) -> Result { + self.build_sign_send_with_params(action, slippage, priority_fee, &ExchangeOptions::default()) + .await + } + + /// Build, sign, and send an action with the full set of optional + /// exchange-body parameters (`vaultAddress`, `expiresAfter`). + /// + /// The options are threaded into both the build request (so the worker + /// includes them in the signed hash) and the send request (so signature + /// recovery matches and the worker forwards them upstream). + pub async fn build_sign_send_with_params( + &self, + action: &Value, + slippage: Option, + priority_fee: Option, + opts: &ExchangeOptions, ) -> Result { let signer = self .signer @@ -676,17 +727,15 @@ impl HyperliquidSDKInner { .ok_or_else(|| Error::ConfigError("No private key or signer configured".to_string()))?; // Resolve effective slippage: per-call override > constructor default > omit - let effective_slippage = slippage.or_else(|| { - if self.slippage > 0.0 { - Some(self.slippage) - } else { - None - } + let effective_slippage = slippage.or(if self.slippage > 0.0 { + Some(self.slippage) + } else { + None }); // Step 1: Build let build_result = self - .build_action_with_priority(action, effective_slippage, priority_fee) + .build_action_with_params(action, effective_slippage, priority_fee, opts) .await?; // Step 2: Sign @@ -706,7 +755,7 @@ impl HyperliquidSDKInner { }; // Step 3: Send - self.send_action(&build_result.action, build_result.nonce, &signature) + self.send_action_with_params(&build_result.action, build_result.nonce, &signature, opts) .await } @@ -828,19 +877,29 @@ impl HyperliquidSDKInner { /// Cancel an order by OID pub async fn cancel_by_oid(&self, oid: u64, asset: &str) -> Result { + self.cancel_by_oid_with_options(oid, asset, &CancelOptions::default()) + .await + } + + /// Cancel an order by OID with optional fast flag, vault address, and TTL + pub async fn cancel_by_oid_with_options( + &self, + oid: u64, + asset: &str, + opts: &CancelOptions, + ) -> Result { let asset_index = self .resolve_asset(asset) .ok_or_else(|| Error::ValidationError(format!("Unknown asset: {}", asset)))?; - let action = json!({ - "type": "cancel", - "cancels": [{ - "a": asset_index, - "o": oid, - }] - }); + let action = cancel_action_json( + "cancel", + vec![json!({"a": asset_index, "o": oid})], + opts.fast, + ); - self.build_sign_send(&action, None).await + self.build_sign_send_with_params(&action, None, None, &opts.exchange_options()) + .await } /// Modify an order by OID @@ -952,6 +1011,29 @@ fn all_decimal_digits(s: &str) -> bool { !s.is_empty() && s.bytes().all(|b| b.is_ascii_digit()) } +/// Apply optional top-level exchange-body fields (`vaultAddress`, `expiresAfter`). +/// +/// Fields are never emitted when unset, preserving wire compat with existing +/// servers. +fn apply_exchange_options(body: &mut Value, opts: &ExchangeOptions) { + if let Some(ref vault_address) = opts.vault_address { + body["vaultAddress"] = json!(vault_address); + } + if let Some(expires_after) = opts.expires_after { + body["expiresAfter"] = json!(expires_after); + } +} + +/// Build a cancel-family action, emitting the fast flag (`"f": true`) only when +/// set — the backend strips `f: false`, so false and unset are the same wire shape. +fn cancel_action_json(cancel_type: &str, cancels: Vec, fast: bool) -> Value { + let mut action = json!({"type": cancel_type, "cancels": cancels}); + if fast { + action["f"] = json!(true); + } + action +} + /// Validate a signature returned by an external `HyperliquidSigner`. /// /// The callback is user-supplied and may return a semantically invalid @@ -1096,6 +1178,69 @@ mod client_tests { assert_eq!(err.code(), crate::ErrorCode::SignerFailed); } + // ── Optional exchange-body params (vaultAddress / expiresAfter / fast) ── + + /// Unset options leave the body untouched — no `vaultAddress`/`expiresAfter` + /// keys at all (wire compat with existing servers). + #[test] + fn apply_exchange_options_omits_unset_fields() { + let mut body = json!({"action": {"type": "noop"}}); + let before = body.clone(); + apply_exchange_options(&mut body, &ExchangeOptions::default()); + assert_eq!(body, before); + } + + #[test] + fn apply_exchange_options_sets_fields_when_present() { + let mut body = json!({"action": {"type": "noop"}}); + let opts = ExchangeOptions { + vault_address: Some("0x1719884eb866cb12b2287399b15f7db5e7d775ea".to_string()), + expires_after: Some(1_752_000_000_000), + }; + apply_exchange_options(&mut body, &opts); + assert_eq!( + body["vaultAddress"], + json!("0x1719884eb866cb12b2287399b15f7db5e7d775ea") + ); + assert_eq!(body["expiresAfter"], json!(1_752_000_000_000u64)); + } + + /// The fast flag is emitted on the cancel ACTION itself (sibling of + /// `cancels`), and only when true. + #[test] + fn cancel_action_json_fast_flag() { + let plain = cancel_action_json("cancel", vec![json!({"a": 5, "o": 123})], false); + assert_eq!(plain, json!({"type": "cancel", "cancels": [{"a": 5, "o": 123}]})); + assert!(plain.get("f").is_none()); + + let fast = cancel_action_json("cancel", vec![json!({"a": 5, "o": 123})], true); + assert_eq!( + fast, + json!({"type": "cancel", "cancels": [{"a": 5, "o": 123}], "f": true}) + ); + + let cloid_fast = cancel_action_json( + "cancelByCloid", + vec![json!({"asset": 5, "cloid": "0x11111111111111111111111111111111"})], + true, + ); + assert_eq!(cloid_fast["type"], json!("cancelByCloid")); + assert_eq!(cloid_fast["f"], json!(true)); + } + + /// CancelOptions carries its vault/TTL subset into ExchangeOptions. + #[test] + fn cancel_options_exchange_options_subset() { + let opts = CancelOptions { + fast: true, + vault_address: Some("0x1719884eb866cb12b2287399b15f7db5e7d775ea".to_string()), + expires_after: Some(42), + }; + let ex = opts.exchange_options(); + assert_eq!(ex.vault_address.as_deref(), Some("0x1719884eb866cb12b2287399b15f7db5e7d775ea")); + assert_eq!(ex.expires_after, Some(42)); + } + #[test] fn hype_to_wei_uses_decimal_string_arithmetic() { assert_eq!(hype_to_wei(0.001).unwrap(), 100_000); @@ -1285,6 +1430,7 @@ pub struct HyperliquidSDK { impl HyperliquidSDK { /// Create a new SDK builder + #[allow(clippy::new_ret_no_self)] pub fn new() -> HyperliquidSDKBuilder { HyperliquidSDKBuilder::new() } @@ -1540,8 +1686,20 @@ impl HyperliquidSDK { tif: TIF, ) -> Result { let asset = asset.into(); - self.place_order(&asset, Side::Buy, size, Some(price), tif, false, false, false, None, None) - .await + self.place_order( + &asset, + Side::Buy, + size, + Some(price), + tif, + false, + false, + false, + None, + None, + &ExchangeOptions::default(), + ) + .await } /// Place a limit sell order @@ -1553,8 +1711,20 @@ impl HyperliquidSDK { tif: TIF, ) -> Result { let asset = asset.into(); - self.place_order(&asset, Side::Sell, size, Some(price), tif, false, false, false, None, None) - .await + self.place_order( + &asset, + Side::Sell, + size, + Some(price), + tif, + false, + false, + false, + None, + None, + &ExchangeOptions::default(), + ) + .await } /// Place an order using the fluent builder @@ -1600,6 +1770,7 @@ impl HyperliquidSDK { order.get_notional().is_some() && order.get_size().is_none(), None, // use constructor-level default slippage order.get_priority_fee(), + &order.exchange_options(), ) .await } @@ -1677,7 +1848,10 @@ impl HyperliquidSDK { "grouping": "na", }); - let response = self.inner.build_sign_send(&action, None).await?; + let response = self + .inner + .build_sign_send_with_params(&action, None, None, &order.exchange_options()) + .await?; Ok(PlacedOrder::from_response( response, @@ -1726,6 +1900,7 @@ impl HyperliquidSDK { /// For market orders (`is_market = true`), uses the human-readable format /// (`asset`, `side`, `size`, `tif: "market"`) and delegates price computation /// to the worker. For limit orders, uses the wire format (`a`, `b`, `p`, `s`). + #[allow(clippy::too_many_arguments)] async fn place_order( &self, asset: &str, @@ -1738,6 +1913,7 @@ impl HyperliquidSDK { size_from_notional: bool, slippage: Option, priority_fee: Option, + opts: &ExchangeOptions, ) -> Result { if is_prediction_asset(asset) && priority_fee.is_some() { return Err(Error::ValidationError( @@ -1848,7 +2024,7 @@ impl HyperliquidSDK { let response = self .inner - .build_sign_send_with_priority(&action, effective_slippage, priority_fee) + .build_sign_send_with_params(&action, effective_slippage, priority_fee, opts) .await?; Ok(PlacedOrder::from_response( @@ -1868,6 +2044,7 @@ impl HyperliquidSDK { /// Modify an existing order /// /// The order is identified by OID, which is included in the returned order. + #[allow(clippy::too_many_arguments)] pub async fn modify( &self, oid: u64, @@ -1878,6 +2055,34 @@ impl HyperliquidSDK { tif: TIF, reduce_only: bool, cloid: Option<&str>, + ) -> Result { + self.modify_with_options( + oid, + asset, + is_buy, + size, + price, + tif, + reduce_only, + cloid, + &ExchangeOptions::default(), + ) + .await + } + + /// Modify an existing order with optional vault address and TTL + #[allow(clippy::too_many_arguments)] + pub async fn modify_with_options( + &self, + oid: u64, + asset: &str, + is_buy: bool, + size: f64, + price: f64, + tif: TIF, + reduce_only: bool, + cloid: Option<&str>, + opts: &ExchangeOptions, ) -> Result { let asset_idx = self .inner @@ -1923,7 +2128,10 @@ impl HyperliquidSDK { }] }); - let response = self.inner.build_sign_send(&action, None).await?; + let response = self + .inner + .build_sign_send_with_params(&action, None, None, opts) + .await?; Ok(PlacedOrder::from_response( response, @@ -1940,15 +2148,46 @@ impl HyperliquidSDK { self.inner.cancel_by_oid(oid, asset).await } + /// Cancel an order by OID with optional fast flag, vault address, and TTL + pub async fn cancel_with_options( + &self, + oid: u64, + asset: &str, + opts: &CancelOptions, + ) -> Result { + self.inner.cancel_by_oid_with_options(oid, asset, opts).await + } + /// Cancel all orders (optionally for a specific asset) pub async fn cancel_all(&self, asset: Option<&str>) -> Result { + self.cancel_all_with_options(asset, &CancelOptions::default()) + .await + } + + /// Cancel all orders with optional fast flag, vault address, and TTL + pub async fn cancel_all_with_options( + &self, + asset: Option<&str>, + opts: &CancelOptions, + ) -> Result { // Ensure we have an address configured if self.inner.address.is_none() { return Err(Error::ConfigError("No address configured".to_string())); } - // Get open orders - let open_orders = self.open_orders().await?; + // Get open orders. When cancelling on behalf of a vault, enumerate the + // vault's open orders, not the wallet's. + let open_orders = match opts.vault_address.as_deref() { + Some(vault) => { + self.inner + .query_info(&json!({ + "type": "openOrders", + "user": vault, + })) + .await? + } + None => self.open_orders().await?, + }; let cancels: Vec = open_orders .as_array() @@ -1973,12 +2212,11 @@ impl HyperliquidSDK { return Ok(json!({"status": "ok", "message": "No orders to cancel"})); } - let action = json!({ - "type": "cancel", - "cancels": cancels, - }); + let action = cancel_action_json("cancel", cancels, opts.fast); - self.inner.build_sign_send(&action, None).await + self.inner + .build_sign_send_with_params(&action, None, None, &opts.exchange_options()) + .await } /// Close position for an asset @@ -1987,18 +2225,38 @@ impl HyperliquidSDK { /// the `closePosition` action type. Optionally accepts a per-call slippage /// override. pub async fn close_position(&self, asset: &str, slippage: Option) -> Result { + self.close_position_with_options(asset, slippage, &ExchangeOptions::default()) + .await + } + + /// Close position with optional vault address and TTL + pub async fn close_position_with_options( + &self, + asset: &str, + slippage: Option, + opts: &ExchangeOptions, + ) -> Result { let address = self .inner .address .ok_or_else(|| Error::ConfigError("No address configured".to_string()))?; + // The worker sizes the close from action.user, so a vault close must + // target the vault's position, not the wallet's. + let user = match opts.vault_address.as_deref() { + Some(vault) => vault.to_string(), + None => format!("{:?}", address), + }; let action = json!({ "type": "closePosition", "asset": asset, - "user": format!("{:?}", address), + "user": user, }); - let response = self.inner.build_sign_send(&action, slippage).await?; + let response = self + .inner + .build_sign_send_with_params(&action, slippage, None, opts) + .await?; // Build pseudo PlacedOrder — actual fill data is extracted from exchangeResponse // by PlacedOrder::from_response (matching TS/Python pattern) @@ -2640,6 +2898,7 @@ impl HyperliquidSDK { } /// Transfer tokens to HyperEVM with custom data payload + #[allow(clippy::too_many_arguments)] pub async fn send_to_evm_with_data( &self, token: &str, @@ -2718,18 +2977,32 @@ impl HyperliquidSDK { /// Cancel an order by client order ID (cloid) pub async fn cancel_by_cloid(&self, cloid: &str, asset: &str) -> Result { + self.cancel_by_cloid_with_options(cloid, asset, &CancelOptions::default()) + .await + } + + /// Cancel an order by cloid with optional fast flag, vault address, and TTL + pub async fn cancel_by_cloid_with_options( + &self, + cloid: &str, + asset: &str, + opts: &CancelOptions, + ) -> Result { let asset_idx = self .inner .metadata .resolve_asset(asset) .ok_or_else(|| Error::ValidationError(format!("Unknown asset: {}", asset)))?; - let action = json!({ - "type": "cancelByCloid", - "cancels": [{"asset": asset_idx, "cloid": cloid}], - }); + let action = cancel_action_json( + "cancelByCloid", + vec![json!({"asset": asset_idx, "cloid": cloid})], + opts.fast, + ); - self.inner.build_sign_send(&action, None).await + self.inner + .build_sign_send_with_params(&action, None, None, &opts.exchange_options()) + .await } /// Schedule cancellation of all orders after a delay (dead-man's switch) @@ -2773,6 +3046,8 @@ pub struct MarketOrderBuilder { slippage: Option, reduce_only: bool, priority_fee: Option, + vault_address: Option, + expires_after: Option, } impl MarketOrderBuilder { @@ -2786,6 +3061,8 @@ impl MarketOrderBuilder { slippage: None, reduce_only: false, priority_fee: None, + vault_address: None, + expires_after: None, } } @@ -2824,6 +3101,24 @@ impl MarketOrderBuilder { self } + /// Trade on behalf of a vault or subaccount + /// + /// Sent as the top-level `vaultAddress` exchange field, which the worker + /// folds into the signed action hash. + pub fn vault_address(mut self, vault_address: impl Into) -> Self { + self.vault_address = Some(vault_address.into()); + self + } + + /// Set action TTL: millisecond timestamp after which the action is rejected + /// + /// Sent as the top-level `expiresAfter` exchange field, which the worker + /// folds into the signed action hash. + pub fn expires_after(mut self, expires_after: u64) -> Self { + self.expires_after = Some(expires_after); + self + } + /// Execute the market order /// /// Uses the human-readable format (`asset`, `side`, `size`, `tif: "market"`) @@ -2895,9 +3190,13 @@ impl MarketOrderBuilder { "orders": [order_spec], }); + let opts = ExchangeOptions { + vault_address: self.vault_address.clone(), + expires_after: self.expires_after, + }; let response = self .inner - .build_sign_send_with_priority(&action, self.slippage, self.priority_fee) + .build_sign_send_with_params(&action, self.slippage, self.priority_fee, &opts) .await?; Ok(PlacedOrder::from_response( diff --git a/rust/src/grpc.rs b/rust/src/grpc.rs index 8c32bf7..bdd19f5 100644 --- a/rust/src/grpc.rs +++ b/rust/src/grpc.rs @@ -14,10 +14,15 @@ //! stream.start().await?; //! ``` +// tonic interceptors must return `Result, tonic::Status>`, and +// `Status` is large by clippy's default threshold; the signature is not ours +// to change. +#![allow(clippy::result_large_err)] + use parking_lot::RwLock; use serde_json::Value; use std::collections::HashMap; -use std::sync::atomic::{AtomicBool, AtomicU32, Ordering}; +use std::sync::atomic::{AtomicBool, AtomicU32, AtomicU64, Ordering}; use std::sync::Arc; use std::time::Duration; use tokio::sync::mpsc; @@ -48,8 +53,9 @@ use proto::block_streaming_client::BlockStreamingClient; use proto::order_book_streaming_client::OrderBookStreamingClient; use proto::streaming_client::StreamingClient; use proto::{ - FilterValues, L2BookRequest, L4BookRequest, Ping, PingRequest, StreamSubscribe, - SubscribeRequest, Timestamp, + BboBookRequest, FilterValues, L2BookDiffRequest, L2BookRequest, L4BookRequest, + L4BookUpdatesRequest, Ping, PingRequest, StreamBytesResponse, StreamSubscribe, + SubscribeRequest, Timestamp, TpslUpdatesRequest, }; // ══════════════════════════════════════════════════════════════════════════════ @@ -63,6 +69,11 @@ const RECONNECT_BACKOFF_FACTOR: f64 = 2.0; const KEEPALIVE_TIME: Duration = Duration::from_secs(30); const KEEPALIVE_TIMEOUT: Duration = Duration::from_secs(10); +type ValueCallbackMap = HashMap>; +type BytesCallbackMap = HashMap>; +/// Per-subscription high-water mark of delivered `block_number`s (0 = none seen). +type LastSeenBlockMap = HashMap>; + // ══════════════════════════════════════════════════════════════════════════════ // gRPC Stream Types // ══════════════════════════════════════════════════════════════════════════════ @@ -77,8 +88,18 @@ pub enum GRPCStreamType { Events, Blocks, WriterActions, + MempoolTxs, + OrderPriority, + GossipPriority, L2Book, L4Book, + BboBook, + L2BookDiff, + L4BookUpdates, + TpslUpdates, + L2BookPacked, + BboBookPacked, + L4BookBytes, } impl GRPCStreamType { @@ -92,13 +113,23 @@ impl GRPCStreamType { GRPCStreamType::Events => "events", GRPCStreamType::Blocks => "blocks", GRPCStreamType::WriterActions => "writer_actions", + GRPCStreamType::MempoolTxs => "mempool_txs", + GRPCStreamType::OrderPriority => "order_priority", + GRPCStreamType::GossipPriority => "gossip_priority", GRPCStreamType::L2Book => "l2_book", GRPCStreamType::L4Book => "l4_book", + GRPCStreamType::BboBook => "bbo_book", + GRPCStreamType::L2BookDiff => "l2_book_diff", + GRPCStreamType::L4BookUpdates => "l4_book_updates", + GRPCStreamType::TpslUpdates => "tpsl_updates", + GRPCStreamType::L2BookPacked => "l2_book_packed", + GRPCStreamType::BboBookPacked => "bbo_book_packed", + GRPCStreamType::L4BookBytes => "l4_book_bytes", } } /// Convert to proto enum value - fn to_proto(&self) -> i32 { + fn to_proto(self) -> i32 { match self { GRPCStreamType::Trades => 1, GRPCStreamType::Orders => 2, @@ -107,8 +138,18 @@ impl GRPCStreamType { GRPCStreamType::Events => 5, GRPCStreamType::Blocks => 6, GRPCStreamType::WriterActions => 7, - GRPCStreamType::L2Book => 0, - GRPCStreamType::L4Book => 0, + GRPCStreamType::MempoolTxs => 8, + GRPCStreamType::OrderPriority => 9, + GRPCStreamType::GossipPriority => 10, + GRPCStreamType::L2Book + | GRPCStreamType::L4Book + | GRPCStreamType::BboBook + | GRPCStreamType::L2BookDiff + | GRPCStreamType::L4BookUpdates + | GRPCStreamType::TpslUpdates + | GRPCStreamType::L2BookPacked + | GRPCStreamType::BboBookPacked + | GRPCStreamType::L4BookBytes => 0, } } } @@ -154,6 +195,7 @@ impl Default for GRPCStreamConfig { // gRPC Subscription Info // ══════════════════════════════════════════════════════════════════════════════ +#[derive(Clone)] struct GRPCSubscriptionInfo { stream_type: GRPCStreamType, coins: Vec, @@ -161,8 +203,11 @@ struct GRPCSubscriptionInfo { coin: Option, n_levels: Option, n_sig_figs: Option, + mantissa: Option, + skip_initial_snapshot: bool, start_block: Option, raw: bool, + bytes: bool, } /// Options for resumable gRPC data subscriptions. @@ -172,6 +217,30 @@ pub struct GRPCSubscriptionOptions { pub start_block: Option, } +/// Options for gRPC L2 book diff subscriptions. +#[derive(Debug, Clone, Copy)] +pub struct GRPCL2BookDiffOptions { + /// Max tracked levels per side (default 20, max 100). + pub n_levels: u32, + /// Significant figures for price bucketing (2-5). + pub n_sig_figs: Option, + /// Mantissa for bucketing (1, 2, or 5). + pub mantissa: Option, + /// If false, the first update per coin contains the current levels. + pub skip_initial_snapshot: bool, +} + +impl Default for GRPCL2BookDiffOptions { + fn default() -> Self { + Self { + n_levels: 20, + n_sig_figs: None, + mantissa: None, + skip_initial_snapshot: false, + } + } +} + // ══════════════════════════════════════════════════════════════════════════════ // gRPC Stream // ══════════════════════════════════════════════════════════════════════════════ @@ -186,7 +255,9 @@ pub struct GRPCStream { reconnect_attempts: Arc, subscription_id: Arc, subscriptions: Arc>>, - callbacks: Arc>>>, + callbacks: Arc>, + bytes_callbacks: Arc>, + last_seen_blocks: Arc>, on_error: Option>, on_close: Option>, on_connect: Option>, @@ -216,6 +287,8 @@ impl GRPCStream { subscription_id: Arc::new(AtomicU32::new(0)), subscriptions: Arc::new(RwLock::new(HashMap::new())), callbacks: Arc::new(RwLock::new(HashMap::new())), + bytes_callbacks: Arc::new(RwLock::new(HashMap::new())), + last_seen_blocks: Arc::new(RwLock::new(HashMap::new())), on_error: None, on_close: None, on_connect: None, @@ -332,8 +405,11 @@ impl GRPCStream { coin: None, n_levels: None, n_sig_figs: None, + mantissa: None, + skip_initial_snapshot: false, start_block: None, raw: true, + bytes: false, }, ); self.callbacks.write().insert(id, Box::new(callback)); @@ -364,8 +440,11 @@ impl GRPCStream { coin: None, n_levels: None, n_sig_figs: None, + mantissa: None, + skip_initial_snapshot: false, start_block: options.start_block, raw: false, + bytes: false, }, ); self.callbacks.write().insert(id, Box::new(callback)); @@ -399,8 +478,11 @@ impl GRPCStream { coin: None, n_levels: None, n_sig_figs: None, + mantissa: None, + skip_initial_snapshot: false, start_block: None, raw: true, + bytes: false, }, ); self.callbacks.write().insert(id, Box::new(callback)); @@ -431,8 +513,11 @@ impl GRPCStream { coin: None, n_levels: None, n_sig_figs: None, + mantissa: None, + skip_initial_snapshot: false, start_block: options.start_block, raw: false, + bytes: false, }, ); self.callbacks.write().insert(id, Box::new(callback)); @@ -466,8 +551,11 @@ impl GRPCStream { coin: None, n_levels: None, n_sig_figs: None, + mantissa: None, + skip_initial_snapshot: false, start_block: None, raw: true, + bytes: false, }, ); self.callbacks.write().insert(id, Box::new(callback)); @@ -498,8 +586,11 @@ impl GRPCStream { coin: None, n_levels: None, n_sig_figs: None, + mantissa: None, + skip_initial_snapshot: false, start_block: options.start_block, raw: false, + bytes: false, }, ); self.callbacks.write().insert(id, Box::new(callback)); @@ -539,8 +630,11 @@ impl GRPCStream { coin: Some(coin.to_string()), n_levels: Some(n_levels), n_sig_figs, + mantissa: None, + skip_initial_snapshot: false, start_block: None, raw: false, + bytes: false, }, ); self.callbacks.write().insert(id, Box::new(callback)); @@ -566,8 +660,11 @@ impl GRPCStream { coin: Some(coin.to_string()), n_levels: None, n_sig_figs: None, + mantissa: None, + skip_initial_snapshot: false, start_block: None, raw: false, + bytes: false, }, ); self.callbacks.write().insert(id, Box::new(callback)); @@ -593,8 +690,11 @@ impl GRPCStream { coin: None, n_levels: None, n_sig_figs: None, + mantissa: None, + skip_initial_snapshot: false, start_block: None, raw: false, + bytes: false, }, ); self.callbacks.write().insert(id, Box::new(callback)); @@ -628,8 +728,11 @@ impl GRPCStream { coin: None, n_levels: None, n_sig_figs: None, + mantissa: None, + skip_initial_snapshot: false, start_block: None, raw: true, + bytes: false, }, ); self.callbacks.write().insert(id, Box::new(callback)); @@ -660,8 +763,11 @@ impl GRPCStream { coin: None, n_levels: None, n_sig_figs: None, + mantissa: None, + skip_initial_snapshot: false, start_block: options.start_block, raw: false, + bytes: false, }, ); self.callbacks.write().insert(id, Box::new(callback)); @@ -699,8 +805,11 @@ impl GRPCStream { coin: None, n_levels: None, n_sig_figs: None, + mantissa: None, + skip_initial_snapshot: false, start_block: options.start_block, raw: false, + bytes: false, }, ); self.callbacks.write().insert(id, Box::new(callback)); @@ -726,8 +835,11 @@ impl GRPCStream { coin: None, n_levels: None, n_sig_figs: None, + mantissa: None, + skip_initial_snapshot: false, start_block: None, raw: true, + bytes: false, }, ); self.callbacks.write().insert(id, Box::new(callback)); @@ -761,8 +873,11 @@ impl GRPCStream { coin: None, n_levels: None, n_sig_figs: None, + mantissa: None, + skip_initial_snapshot: false, start_block: None, raw: true, + bytes: false, }, ); self.callbacks.write().insert(id, Box::new(callback)); @@ -792,8 +907,11 @@ impl GRPCStream { coin: None, n_levels: None, n_sig_figs: None, + mantissa: None, + skip_initial_snapshot: false, start_block: options.start_block, raw: false, + bytes: false, }, ); self.callbacks.write().insert(id, Box::new(callback)); @@ -804,547 +922,1786 @@ impl GRPCStream { } } - /// Unsubscribe - pub fn unsubscribe(&mut self, subscription: &GRPCSubscription) { - self.subscriptions.write().remove(&subscription.id); - self.callbacks.write().remove(&subscription.id); + /// Subscribe to pre-consensus mempool transactions. + /// Pass an empty `coins` slice for the unfiltered stream. + pub fn mempool_txs(&mut self, coins: &[&str], callback: F) -> GRPCSubscription + where + F: Fn(Value) + Send + Sync + 'static, + { + self.mempool_txs_with_options(coins, GRPCSubscriptionOptions::default(), callback) } - // ────────────────────────────────────────────────────────────────────────── - // Lifecycle - // ────────────────────────────────────────────────────────────────────────── + /// Subscribe to raw mempool transaction blocks. + pub fn raw_mempool_txs(&mut self, coins: &[&str], callback: F) -> GRPCSubscription + where + F: Fn(Value) + Send + Sync + 'static, + { + let id = self.next_subscription_id(); + self.subscriptions.write().insert( + id, + GRPCSubscriptionInfo { + stream_type: GRPCStreamType::MempoolTxs, + coins: coins.iter().map(|s| s.to_string()).collect(), + users: vec![], + coin: None, + n_levels: None, + n_sig_figs: None, + mantissa: None, + skip_initial_snapshot: false, + start_block: None, + raw: true, + bytes: false, + }, + ); + self.callbacks.write().insert(id, Box::new(callback)); - /// Start the stream in background (non-blocking) - pub fn start(&mut self) -> Result<()> { - if self.running.load(Ordering::SeqCst) { - return Ok(()); + GRPCSubscription { + id, + stream_type: GRPCStreamType::MempoolTxs, } + } - self.running.store(true, Ordering::SeqCst); - - let (stop_tx, stop_rx) = mpsc::channel(1); - self.stop_tx = Some(stop_tx); - - let host = self.host.clone(); - let token = self.token.clone(); - let state = self.state.clone(); - let running = self.running.clone(); - let reconnect_attempts = self.reconnect_attempts.clone(); - let subscriptions = self.subscriptions.clone(); - let callbacks = self.callbacks.clone(); - let config = self.config.clone(); - let on_error = self.on_error.clone(); - let on_close = self.on_close.clone(); - let on_connect = self.on_connect.clone(); - let on_reconnect = self.on_reconnect.clone(); - let on_state_change = self.on_state_change.clone(); + /// Subscribe to mempool transactions with options. + pub fn mempool_txs_with_options( + &mut self, + coins: &[&str], + options: GRPCSubscriptionOptions, + callback: F, + ) -> GRPCSubscription + where + F: Fn(Value) + Send + Sync + 'static, + { + let id = self.next_subscription_id(); + self.subscriptions.write().insert( + id, + GRPCSubscriptionInfo { + stream_type: GRPCStreamType::MempoolTxs, + coins: coins.iter().map(|s| s.to_string()).collect(), + users: vec![], + coin: None, + n_levels: None, + n_sig_figs: None, + mantissa: None, + skip_initial_snapshot: false, + start_block: options.start_block, + raw: false, + bytes: false, + }, + ); + self.callbacks.write().insert(id, Box::new(callback)); - tokio::spawn(async move { - Self::run_loop( - host, - token, - state, - running, - reconnect_attempts, - subscriptions, - callbacks, - config, - on_error, - on_close, - on_connect, - on_reconnect, - on_state_change, - stop_rx, - ) - .await; - }); + GRPCSubscription { + id, + stream_type: GRPCStreamType::MempoolTxs, + } + } - Ok(()) + /// Subscribe to derived order/write priority actions. + /// Events carry server-enriched fields `coin`, `market_type`, and `sz_decimals`. + pub fn order_priority(&mut self, callback: F) -> GRPCSubscription + where + F: Fn(Value) + Send + Sync + 'static, + { + self.order_priority_with_options(GRPCSubscriptionOptions::default(), callback) } - /// Run the stream (blocking) - pub async fn run(&mut self) -> Result<()> { - self.start()?; + /// Subscribe to raw order priority blocks. + pub fn raw_order_priority(&mut self, callback: F) -> GRPCSubscription + where + F: Fn(Value) + Send + Sync + 'static, + { + let id = self.next_subscription_id(); + self.subscriptions.write().insert( + id, + GRPCSubscriptionInfo { + stream_type: GRPCStreamType::OrderPriority, + coins: vec![], + users: vec![], + coin: None, + n_levels: None, + n_sig_figs: None, + mantissa: None, + skip_initial_snapshot: false, + start_block: None, + raw: true, + bytes: false, + }, + ); + self.callbacks.write().insert(id, Box::new(callback)); - while self.running.load(Ordering::SeqCst) { - sleep(Duration::from_millis(100)).await; + GRPCSubscription { + id, + stream_type: GRPCStreamType::OrderPriority, } - - Ok(()) } - /// Stop the stream - pub fn stop(&mut self) { - self.running.store(false, Ordering::SeqCst); - if let Some(tx) = self.stop_tx.take() { - let _ = tx.try_send(()); - } - self.set_state(ConnectionState::Disconnected); + /// Subscribe to order priority actions with options. + pub fn order_priority_with_options( + &mut self, + options: GRPCSubscriptionOptions, + callback: F, + ) -> GRPCSubscription + where + F: Fn(Value) + Send + Sync + 'static, + { + let id = self.next_subscription_id(); + self.subscriptions.write().insert( + id, + GRPCSubscriptionInfo { + stream_type: GRPCStreamType::OrderPriority, + coins: vec![], + users: vec![], + coin: None, + n_levels: None, + n_sig_figs: None, + mantissa: None, + skip_initial_snapshot: false, + start_block: options.start_block, + raw: false, + bytes: false, + }, + ); + self.callbacks.write().insert(id, Box::new(callback)); - if let Some(ref cb) = self.on_close { - cb(); + GRPCSubscription { + id, + stream_type: GRPCStreamType::OrderPriority, } } - /// Ping the server - pub async fn ping(&self) -> bool { - if self.host.is_empty() { - return false; - } + /// Subscribe to derived gossip/read priority bid actions. + /// Events carry server-enriched fields `coin`, `market_type`, and `sz_decimals`. + pub fn gossip_priority(&mut self, callback: F) -> GRPCSubscription + where + F: Fn(Value) + Send + Sync + 'static, + { + self.gossip_priority_with_options(GRPCSubscriptionOptions::default(), callback) + } - let target = format!("https://{}:{}", self.host, GRPC_PORT); + /// Subscribe to raw gossip priority blocks. + pub fn raw_gossip_priority(&mut self, callback: F) -> GRPCSubscription + where + F: Fn(Value) + Send + Sync + 'static, + { + let id = self.next_subscription_id(); + self.subscriptions.write().insert( + id, + GRPCSubscriptionInfo { + stream_type: GRPCStreamType::GossipPriority, + coins: vec![], + users: vec![], + coin: None, + n_levels: None, + n_sig_figs: None, + mantissa: None, + skip_initial_snapshot: false, + start_block: None, + raw: true, + bytes: false, + }, + ); + self.callbacks.write().insert(id, Box::new(callback)); - let channel = match Channel::from_shared(target) - .unwrap() - .tls_config(ClientTlsConfig::new().with_native_roots()) - .unwrap() - .connect() - .await - { - Ok(c) => c, - Err(_) => return false, - }; + GRPCSubscription { + id, + stream_type: GRPCStreamType::GossipPriority, + } + } - let token: MetadataValue<_> = self.token.parse().unwrap(); - let mut client = StreamingClient::with_interceptor(channel, move |mut req: Request<()>| { - req.metadata_mut().insert("x-token", token.clone()); - Ok(req) - }); + /// Subscribe to gossip priority actions with options. + pub fn gossip_priority_with_options( + &mut self, + options: GRPCSubscriptionOptions, + callback: F, + ) -> GRPCSubscription + where + F: Fn(Value) + Send + Sync + 'static, + { + let id = self.next_subscription_id(); + self.subscriptions.write().insert( + id, + GRPCSubscriptionInfo { + stream_type: GRPCStreamType::GossipPriority, + coins: vec![], + users: vec![], + coin: None, + n_levels: None, + n_sig_figs: None, + mantissa: None, + skip_initial_snapshot: false, + start_block: options.start_block, + raw: false, + bytes: false, + }, + ); + self.callbacks.write().insert(id, Box::new(callback)); - match client.ping(PingRequest { count: 1 }).await { - Ok(resp) => resp.into_inner().count == 1, - Err(_) => false, + GRPCSubscription { + id, + stream_type: GRPCStreamType::GossipPriority, } } - #[allow(clippy::too_many_arguments)] - async fn run_loop( - host: String, - token: String, - state: Arc>, - running: Arc, - reconnect_attempts: Arc, - subscriptions: Arc>>, - callbacks: Arc>>>, - config: GRPCStreamConfig, - on_error: Option>, - on_close: Option>, - _on_connect: Option>, - on_reconnect: Option>, + /// Subscribe to the low-level bytes variant of a data stream (`StreamDataBytes` RPC). + /// The callback receives raw [`StreamBytesResponse`] messages (block number, + /// server ingress timestamp, and the undecoded payload bytes). Only data + /// stream types (trades, orders, ..., gossip_priority) are valid here. + pub fn raw_bytes( + &mut self, + stream_type: GRPCStreamType, + coins: &[&str], + options: GRPCSubscriptionOptions, + callback: F, + ) -> GRPCSubscription + where + F: Fn(StreamBytesResponse) + Send + Sync + 'static, + { + let id = self.next_subscription_id(); + self.subscriptions.write().insert( + id, + GRPCSubscriptionInfo { + stream_type, + coins: coins.iter().map(|s| s.to_string()).collect(), + users: vec![], + coin: None, + n_levels: None, + n_sig_figs: None, + mantissa: None, + skip_initial_snapshot: false, + start_block: options.start_block, + raw: true, + bytes: true, + }, + ); + self.bytes_callbacks.write().insert(id, Box::new(callback)); + + GRPCSubscription { id, stream_type } + } + + /// Subscribe to best bid/offer updates. Empty `coins` means all coins. + pub fn bbo_book(&mut self, coins: &[&str], callback: F) -> GRPCSubscription + where + F: Fn(Value) + Send + Sync + 'static, + { + let id = self.next_subscription_id(); + self.subscriptions.write().insert( + id, + GRPCSubscriptionInfo { + stream_type: GRPCStreamType::BboBook, + coins: coins.iter().map(|s| s.to_string()).collect(), + users: vec![], + coin: None, + n_levels: None, + n_sig_figs: None, + mantissa: None, + skip_initial_snapshot: false, + start_block: None, + raw: false, + bytes: false, + }, + ); + self.callbacks.write().insert(id, Box::new(callback)); + + GRPCSubscription { + id, + stream_type: GRPCStreamType::BboBook, + } + } + + /// Subscribe to best bid/offer updates with fixed-point prices/sizes + /// (scaled by 1e8). Empty `coins` means all coins. + pub fn bbo_book_packed(&mut self, coins: &[&str], callback: F) -> GRPCSubscription + where + F: Fn(Value) + Send + Sync + 'static, + { + let id = self.next_subscription_id(); + self.subscriptions.write().insert( + id, + GRPCSubscriptionInfo { + stream_type: GRPCStreamType::BboBookPacked, + coins: coins.iter().map(|s| s.to_string()).collect(), + users: vec![], + coin: None, + n_levels: None, + n_sig_figs: None, + mantissa: None, + skip_initial_snapshot: false, + start_block: None, + raw: false, + bytes: false, + }, + ); + self.callbacks.write().insert(id, Box::new(callback)); + + GRPCSubscription { + id, + stream_type: GRPCStreamType::BboBookPacked, + } + } + + /// Subscribe to incremental L2 price-level changes. Empty `coins` means all coins. + pub fn l2_book_diff(&mut self, coins: &[&str], callback: F) -> GRPCSubscription + where + F: Fn(Value) + Send + Sync + 'static, + { + self.l2_book_diff_with_options(coins, GRPCL2BookDiffOptions::default(), callback) + } + + /// Subscribe to incremental L2 price-level changes with options. + /// A level with `sz == "0"` means the level was removed. + pub fn l2_book_diff_with_options( + &mut self, + coins: &[&str], + options: GRPCL2BookDiffOptions, + callback: F, + ) -> GRPCSubscription + where + F: Fn(Value) + Send + Sync + 'static, + { + let id = self.next_subscription_id(); + self.subscriptions.write().insert( + id, + GRPCSubscriptionInfo { + stream_type: GRPCStreamType::L2BookDiff, + coins: coins.iter().map(|s| s.to_string()).collect(), + users: vec![], + coin: None, + n_levels: Some(options.n_levels), + n_sig_figs: options.n_sig_figs, + mantissa: options.mantissa, + skip_initial_snapshot: options.skip_initial_snapshot, + start_block: None, + raw: false, + bytes: false, + }, + ); + self.callbacks.write().insert(id, Box::new(callback)); + + GRPCSubscription { + id, + stream_type: GRPCStreamType::L2BookDiff, + } + } + + /// Subscribe to typed L4 order book updates. Empty `coins` means all coins. + pub fn l4_book_updates(&mut self, coins: &[&str], callback: F) -> GRPCSubscription + where + F: Fn(Value) + Send + Sync + 'static, + { + let id = self.next_subscription_id(); + self.subscriptions.write().insert( + id, + GRPCSubscriptionInfo { + stream_type: GRPCStreamType::L4BookUpdates, + coins: coins.iter().map(|s| s.to_string()).collect(), + users: vec![], + coin: None, + n_levels: None, + n_sig_figs: None, + mantissa: None, + skip_initial_snapshot: false, + start_block: None, + raw: false, + bytes: false, + }, + ); + self.callbacks.write().insert(id, Box::new(callback)); + + GRPCSubscription { + id, + stream_type: GRPCStreamType::L4BookUpdates, + } + } + + /// Subscribe to trigger/TP-SL order updates. Empty `coins` means all perp coins. + pub fn tpsl_updates(&mut self, coins: &[&str], callback: F) -> GRPCSubscription + where + F: Fn(Value) + Send + Sync + 'static, + { + let id = self.next_subscription_id(); + self.subscriptions.write().insert( + id, + GRPCSubscriptionInfo { + stream_type: GRPCStreamType::TpslUpdates, + coins: coins.iter().map(|s| s.to_string()).collect(), + users: vec![], + coin: None, + n_levels: None, + n_sig_figs: None, + mantissa: None, + skip_initial_snapshot: false, + start_block: None, + raw: false, + bytes: false, + }, + ); + self.callbacks.write().insert(id, Box::new(callback)); + + GRPCSubscription { + id, + stream_type: GRPCStreamType::TpslUpdates, + } + } + + /// Subscribe to fast-path L2 order book updates with fixed-point + /// prices/sizes (scaled by 1e8). + pub fn l2_book_packed(&mut self, coin: &str, callback: F) -> GRPCSubscription + where + F: Fn(Value) + Send + Sync + 'static, + { + self.l2_book_packed_with_options(coin, 20, None, callback) + } + + /// Subscribe to fast-path L2 order book updates with options. + pub fn l2_book_packed_with_options( + &mut self, + coin: &str, + n_levels: u32, + n_sig_figs: Option, + callback: F, + ) -> GRPCSubscription + where + F: Fn(Value) + Send + Sync + 'static, + { + let id = self.next_subscription_id(); + self.subscriptions.write().insert( + id, + GRPCSubscriptionInfo { + stream_type: GRPCStreamType::L2BookPacked, + coins: vec![], + users: vec![], + coin: Some(coin.to_string()), + n_levels: Some(n_levels), + n_sig_figs, + mantissa: None, + skip_initial_snapshot: false, + start_block: None, + raw: false, + bytes: false, + }, + ); + self.callbacks.write().insert(id, Box::new(callback)); + + GRPCSubscription { + id, + stream_type: GRPCStreamType::L2BookPacked, + } + } + + /// Subscribe to the fast-path L4 order book stream. Same payload shape as + /// `l4_book`, but diffs are transported as JSON bytes on the wire. + pub fn l4_book_bytes(&mut self, coin: &str, callback: F) -> GRPCSubscription + where + F: Fn(Value) + Send + Sync + 'static, + { + let id = self.next_subscription_id(); + self.subscriptions.write().insert( + id, + GRPCSubscriptionInfo { + stream_type: GRPCStreamType::L4BookBytes, + coins: vec![], + users: vec![], + coin: Some(coin.to_string()), + n_levels: None, + n_sig_figs: None, + mantissa: None, + skip_initial_snapshot: false, + start_block: None, + raw: false, + bytes: false, + }, + ); + self.callbacks.write().insert(id, Box::new(callback)); + + GRPCSubscription { + id, + stream_type: GRPCStreamType::L4BookBytes, + } + } + + /// Unsubscribe + pub fn unsubscribe(&mut self, subscription: &GRPCSubscription) { + self.subscriptions.write().remove(&subscription.id); + self.callbacks.write().remove(&subscription.id); + self.bytes_callbacks.write().remove(&subscription.id); + self.last_seen_blocks.write().remove(&subscription.id); + } + + // ────────────────────────────────────────────────────────────────────────── + // Lifecycle + // ────────────────────────────────────────────────────────────────────────── + + /// Start the stream in background (non-blocking) + pub fn start(&mut self) -> Result<()> { + if self.running.load(Ordering::SeqCst) { + return Ok(()); + } + + self.running.store(true, Ordering::SeqCst); + + let (stop_tx, stop_rx) = mpsc::channel(1); + self.stop_tx = Some(stop_tx); + + let host = self.host.clone(); + let token = self.token.clone(); + let state = self.state.clone(); + let running = self.running.clone(); + let reconnect_attempts = self.reconnect_attempts.clone(); + let subscriptions = self.subscriptions.clone(); + let callbacks = self.callbacks.clone(); + let bytes_callbacks = self.bytes_callbacks.clone(); + let last_seen_blocks = self.last_seen_blocks.clone(); + let config = self.config.clone(); + let on_error = self.on_error.clone(); + let on_close = self.on_close.clone(); + let on_connect = self.on_connect.clone(); + let on_reconnect = self.on_reconnect.clone(); + let on_state_change = self.on_state_change.clone(); + + tokio::spawn(async move { + Self::run_loop( + host, + token, + state, + running, + reconnect_attempts, + subscriptions, + callbacks, + bytes_callbacks, + last_seen_blocks, + config, + on_error, + on_close, + on_connect, + on_reconnect, + on_state_change, + stop_rx, + ) + .await; + }); + + Ok(()) + } + + /// Run the stream (blocking) + pub async fn run(&mut self) -> Result<()> { + self.start()?; + + while self.running.load(Ordering::SeqCst) { + sleep(Duration::from_millis(100)).await; + } + + Ok(()) + } + + /// Stop the stream + pub fn stop(&mut self) { + self.running.store(false, Ordering::SeqCst); + if let Some(tx) = self.stop_tx.take() { + let _ = tx.try_send(()); + } + self.set_state(ConnectionState::Disconnected); + + if let Some(ref cb) = self.on_close { + cb(); + } + } + + /// Ping the server + pub async fn ping(&self) -> bool { + if self.host.is_empty() { + return false; + } + + let target = format!("https://{}:{}", self.host, GRPC_PORT); + + let channel = match Channel::from_shared(target) + .unwrap() + .tls_config(ClientTlsConfig::new().with_native_roots()) + .unwrap() + .connect() + .await + { + Ok(c) => c, + Err(_) => return false, + }; + + let token: MetadataValue<_> = self.token.parse().unwrap(); + let mut client = StreamingClient::with_interceptor(channel, move |mut req: Request<()>| { + req.metadata_mut().insert("x-token", token.clone()); + Ok(req) + }); + + match client.ping(PingRequest { count: 1 }).await { + Ok(resp) => resp.into_inner().count == 1, + Err(_) => false, + } + } + + #[allow(clippy::too_many_arguments)] + async fn run_loop( + host: String, + token: String, + state: Arc>, + running: Arc, + reconnect_attempts: Arc, + subscriptions: Arc>>, + callbacks: Arc>, + bytes_callbacks: Arc>, + last_seen_blocks: Arc>, + config: GRPCStreamConfig, + on_error: Option>, + on_close: Option>, + _on_connect: Option>, + on_reconnect: Option>, on_state_change: Option>, mut stop_rx: mpsc::Receiver<()>, ) { let mut backoff = INITIAL_RECONNECT_DELAY; + // False only for the very first connection attempt of this run; + // reconnects adjust start_block / snapshot semantics (see + // build_subscribe_request and build_l2_book_diff_request). + let mut is_reconnect = false; + + while running.load(Ordering::SeqCst) { + // Check for stop signal + if stop_rx.try_recv().is_ok() { + break; + } + + // Update state + { + let mut s = state.write(); + if *s == ConnectionState::Reconnecting { + if let Some(ref cb) = on_reconnect { + cb(reconnect_attempts.load(Ordering::SeqCst)); + } + } + *s = ConnectionState::Connecting; + } + if let Some(ref cb) = on_state_change { + cb(ConnectionState::Connecting); + } + + // Try to connect and stream + let result = Self::connect_and_stream( + &host, + &token, + &subscriptions, + &callbacks, + &bytes_callbacks, + &last_seen_blocks, + &running, + is_reconnect, + &mut stop_rx, + ) + .await; + is_reconnect = true; + + if let Err(e) = result { + if let Some(ref cb) = on_error { + cb(e.to_string()); + } + } + + if !running.load(Ordering::SeqCst) { + break; + } + + if !config.reconnect { + break; + } + + let attempts = reconnect_attempts.fetch_add(1, Ordering::SeqCst) + 1; + if let Some(max) = config.max_reconnect_attempts { + if attempts >= max { + break; + } + } + + { + *state.write() = ConnectionState::Reconnecting; + } + if let Some(ref cb) = on_state_change { + cb(ConnectionState::Reconnecting); + } + + // Wait before reconnecting + tokio::select! { + _ = sleep(backoff) => {} + _ = stop_rx.recv() => { break; } + } + + backoff = Duration::from_secs_f64( + (backoff.as_secs_f64() * RECONNECT_BACKOFF_FACTOR) + .min(MAX_RECONNECT_DELAY.as_secs_f64()), + ); + } + + { + *state.write() = ConnectionState::Disconnected; + } + if let Some(ref cb) = on_state_change { + cb(ConnectionState::Disconnected); + } + if let Some(ref cb) = on_close { + cb(); + } + } + + #[allow(clippy::too_many_arguments)] + async fn connect_and_stream( + host: &str, + token: &str, + subscriptions: &Arc>>, + callbacks: &Arc>, + bytes_callbacks: &Arc>, + last_seen_blocks: &Arc>, + running: &Arc, + is_reconnect: bool, + stop_rx: &mut mpsc::Receiver<()>, + ) -> Result<()> { + if host.is_empty() { + return Err(crate::error::Error::ConfigError( + "No gRPC endpoint configured".to_string(), + )); + } + + let target = format!("https://{}:{}", host, GRPC_PORT); + + // Create channel with TLS using native system root certificates + // (like Python's ssl_channel_credentials() and TypeScript's grpc.credentials.createSsl()) + let channel = Channel::from_shared(target) + .map_err(|e| crate::error::Error::NetworkError(e.to_string()))? + .tls_config(ClientTlsConfig::new().with_native_roots()) + .map_err(|e: tonic::transport::Error| crate::error::Error::NetworkError(e.to_string()))? + .connect() + .await + .map_err(|e| crate::error::Error::NetworkError(format!("Failed to connect: {}", e)))?; + + // Get subscriptions snapshot + let subs: Vec<(u32, GRPCSubscriptionInfo)> = { + let guard = subscriptions.read(); + guard.iter().map(|(k, v)| (*k, v.clone())).collect() + }; + + // Start each subscription stream + let mut handles = Vec::new(); + for (sub_id, sub_info) in subs { + let channel = channel.clone(); + let token = token.to_string(); + let callbacks = callbacks.clone(); + let bytes_callbacks = bytes_callbacks.clone(); + let running = running.clone(); + // Per-subscription highest-block cursor, shared across reconnects so + // resumable streams don't replay already-delivered blocks. + let last_seen = { + let mut map = last_seen_blocks.write(); + map.entry(sub_id) + .or_insert_with(|| Arc::new(AtomicU64::new(0))) + .clone() + }; + + let handle = tokio::spawn(async move { + match sub_info.stream_type { + GRPCStreamType::L2Book => { + Self::stream_l2_book( + channel, &token, sub_id, &sub_info, &callbacks, &running, + ) + .await + } + GRPCStreamType::L4Book => { + Self::stream_l4_book( + channel, &token, sub_id, &sub_info, &callbacks, &running, + ) + .await + } + GRPCStreamType::BboBook => { + Self::stream_bbo_book( + channel, &token, sub_id, &sub_info, &callbacks, &running, + ) + .await + } + GRPCStreamType::BboBookPacked => { + Self::stream_bbo_book_packed( + channel, &token, sub_id, &sub_info, &callbacks, &running, + ) + .await + } + GRPCStreamType::L2BookDiff => { + Self::stream_l2_book_diff( + channel, + &token, + sub_id, + &sub_info, + &callbacks, + &running, + is_reconnect, + ) + .await + } + GRPCStreamType::L4BookUpdates => { + Self::stream_l4_book_updates( + channel, &token, sub_id, &sub_info, &callbacks, &running, + ) + .await + } + GRPCStreamType::TpslUpdates => { + Self::stream_tpsl_updates( + channel, &token, sub_id, &sub_info, &callbacks, &running, + ) + .await + } + GRPCStreamType::L2BookPacked => { + Self::stream_l2_book_packed( + channel, &token, sub_id, &sub_info, &callbacks, &running, + ) + .await + } + GRPCStreamType::L4BookBytes => { + Self::stream_l4_book_bytes( + channel, &token, sub_id, &sub_info, &callbacks, &running, + ) + .await + } + GRPCStreamType::Blocks => { + Self::stream_blocks(channel, &token, sub_id, &callbacks, &running).await + } + _ if sub_info.bytes => { + Self::stream_data_bytes( + channel, + &token, + sub_id, + &sub_info, + &bytes_callbacks, + &running, + &last_seen, + is_reconnect, + ) + .await + } + _ => { + Self::stream_data( + channel, + &token, + sub_id, + &sub_info, + &callbacks, + &running, + &last_seen, + is_reconnect, + ) + .await + } + } + }); + handles.push(handle); + } + + // Wait for stop signal or any stream to end + loop { + tokio::select! { + _ = stop_rx.recv() => { break; } + _ = sleep(Duration::from_secs(1)) => { + if !running.load(Ordering::SeqCst) { + break; + } + // Check if any handles finished + let mut all_done = true; + for h in &handles { + if !h.is_finished() { + all_done = false; + break; + } + } + if all_done && !handles.is_empty() { + break; + } + } + } + } + + let mut stream_error = None; + for handle in handles { + match handle.await { + Ok(Err(e)) if stream_error.is_none() => stream_error = Some(e), + Ok(_) => {} + Err(e) if stream_error.is_none() => { + stream_error = Some(crate::error::Error::NetworkError(format!( + "gRPC stream task failed: {e}" + ))); + } + Err(_) => {} + } + } + + if let Some(err) = stream_error { + Err(err) + } else { + Ok(()) + } + } + + /// Build the StreamSubscribe request. On the first connect the user's + /// original `start_block` (or unset) is sent verbatim. On reconnects, if a + /// start_block was set, the cursor advances past the highest block already + /// delivered so transient disconnects don't replay processed blocks. An + /// unset start_block always stays unset (tip-following semantics). + fn build_subscribe_request( + sub_info: &GRPCSubscriptionInfo, + is_reconnect: bool, + last_seen_block: u64, + ) -> SubscribeRequest { + let mut filters = HashMap::new(); + if !sub_info.coins.is_empty() { + filters.insert( + "coin".to_string(), + FilterValues { + values: sub_info.coins.clone(), + }, + ); + } + if !sub_info.users.is_empty() { + filters.insert( + "user".to_string(), + FilterValues { + values: sub_info.users.clone(), + }, + ); + } + + let start_block = match sub_info.start_block { + Some(orig) if is_reconnect && last_seen_block > 0 => { + orig.max(last_seen_block.saturating_add(1)) + } + Some(orig) => orig, + None => 0, + }; + + SubscribeRequest { + request: Some(proto::subscribe_request::Request::Subscribe( + StreamSubscribe { + stream_type: sub_info.stream_type.to_proto(), + start_block, + filters, + filter_name: String::new(), + }, + )), + } + } + + #[allow(clippy::too_many_arguments)] + async fn stream_data( + channel: Channel, + token: &str, + sub_id: u32, + sub_info: &GRPCSubscriptionInfo, + callbacks: &Arc>, + running: &Arc, + last_seen: &Arc, + is_reconnect: bool, + ) -> Result<()> { + let token_value: MetadataValue<_> = token.parse().unwrap(); + let mut client = StreamingClient::with_interceptor(channel, move |mut req: Request<()>| { + req.metadata_mut().insert("x-token", token_value.clone()); + Ok(req) + }); + + // Build subscribe request + let subscribe_req = + Self::build_subscribe_request(sub_info, is_reconnect, last_seen.load(Ordering::Relaxed)); + + // Create bidirectional stream + let (tx, rx) = tokio::sync::mpsc::channel(16); + let outbound = tokio_stream::wrappers::ReceiverStream::new(rx); + + // Send initial subscribe + if tx.send(subscribe_req).await.is_err() { + return Ok(()); + } + + // Start ping task + let tx_ping = tx.clone(); + let running_ping = running.clone(); + tokio::spawn(async move { + loop { + sleep(Duration::from_secs(30)).await; + if !running_ping.load(Ordering::SeqCst) { + break; + } + let ping_req = SubscribeRequest { + request: Some(proto::subscribe_request::Request::Ping(Ping { + timestamp: chrono::Utc::now().timestamp_millis(), + })), + }; + if tx_ping.send(ping_req).await.is_err() { + break; + } + } + }); + + // Call StreamData + let response = match client.stream_data(outbound).await { + Ok(r) => r, + Err(e) => { + tracing::error!("StreamData error: {}", e); + if is_permanent_stream_error(&e) { + running.store(false, Ordering::SeqCst); + } + return Err(crate::error::Error::NetworkError(format!( + "StreamData error: {e}" + ))); + } + }; + + let mut inbound = response.into_inner(); + + while running.load(Ordering::SeqCst) { + match inbound.message().await { + Ok(Some(update)) => { + if let Some(proto::subscribe_update::Update::Data(data)) = update.update { + // Parse the JSON data. The resume cursor advances only + // after a successful parse, so a corrupt block is + // re-requested on reconnect instead of skipped. + if let Ok(parsed) = serde_json::from_str::(&data.data) { + last_seen.fetch_max(data.block_number, Ordering::Relaxed); + if sub_info.raw { + let mut data_with_meta = + parsed.as_object().cloned().unwrap_or_default(); + data_with_meta.insert( + "_block_number".to_string(), + Value::Number(data.block_number.into()), + ); + data_with_meta.insert( + "_timestamp".to_string(), + Value::Number(data.timestamp.into()), + ); + + if let Some(cb) = callbacks.read().get(&sub_id) { + cb(Value::Object(data_with_meta)); + } + continue; + } + + // Extract events if present + if let Some(events) = parsed.get("events").and_then(|e| e.as_array()) { + let mut emitted_events = false; + for (index, event) in events.iter().enumerate() { + let mut user: Option = None; + let mut event_data = None; + + if let Some(arr) = event.as_array() { + if arr.len() >= 2 { + user = Some(arr[0].clone()); + event_data = arr[1].as_object(); + } + } else { + event_data = event.as_object(); + } + + if let Some(event_data) = event_data { + let mut data_with_meta = serde_json::Map::new(); + for (k, v) in event_data { + data_with_meta.insert(k.clone(), v.clone()); + } + data_with_meta.insert( + "_block_number".to_string(), + Value::Number(data.block_number.into()), + ); + data_with_meta.insert( + "_timestamp".to_string(), + Value::Number(data.timestamp.into()), + ); + data_with_meta.insert( + "_event_index".to_string(), + Value::Number((index as u64).into()), + ); + if let Some(user) = user { + data_with_meta.insert("_user".to_string(), user); + } + + if let Some(cb) = callbacks.read().get(&sub_id) { + cb(Value::Object(data_with_meta)); + } + emitted_events = true; + } + } + if !emitted_events { + let mut data_with_meta = + parsed.as_object().cloned().unwrap_or_default(); + data_with_meta.insert( + "_block_number".to_string(), + Value::Number(data.block_number.into()), + ); + data_with_meta.insert( + "_timestamp".to_string(), + Value::Number(data.timestamp.into()), + ); + + if let Some(cb) = callbacks.read().get(&sub_id) { + cb(Value::Object(data_with_meta)); + } + } + } else { + // No events, return raw data + let mut data_with_meta = + parsed.as_object().cloned().unwrap_or_default(); + data_with_meta.insert( + "_block_number".to_string(), + Value::Number(data.block_number.into()), + ); + data_with_meta.insert( + "_timestamp".to_string(), + Value::Number(data.timestamp.into()), + ); + + if let Some(cb) = callbacks.read().get(&sub_id) { + cb(Value::Object(data_with_meta)); + } + } + } + } + } + Ok(None) => break, + Err(e) => { + tracing::error!("Stream error: {}", e); + if is_permanent_stream_error(&e) { + running.store(false, Ordering::SeqCst); + } + return Err(crate::error::Error::NetworkError(format!( + "Stream error: {e}" + ))); + } + } + } + + Ok(()) + } + + #[allow(clippy::too_many_arguments)] + async fn stream_data_bytes( + channel: Channel, + token: &str, + sub_id: u32, + sub_info: &GRPCSubscriptionInfo, + bytes_callbacks: &Arc>, + running: &Arc, + last_seen: &Arc, + is_reconnect: bool, + ) -> Result<()> { + let token_value: MetadataValue<_> = token.parse().unwrap(); + let mut client = StreamingClient::with_interceptor(channel, move |mut req: Request<()>| { + req.metadata_mut().insert("x-token", token_value.clone()); + Ok(req) + }); + + // Build subscribe request + let subscribe_req = + Self::build_subscribe_request(sub_info, is_reconnect, last_seen.load(Ordering::Relaxed)); + + // Create bidirectional stream + let (tx, rx) = tokio::sync::mpsc::channel(16); + let outbound = tokio_stream::wrappers::ReceiverStream::new(rx); + + // Send initial subscribe + if tx.send(subscribe_req).await.is_err() { + return Ok(()); + } + + // Start ping task + let tx_ping = tx.clone(); + let running_ping = running.clone(); + tokio::spawn(async move { + loop { + sleep(Duration::from_secs(30)).await; + if !running_ping.load(Ordering::SeqCst) { + break; + } + let ping_req = SubscribeRequest { + request: Some(proto::subscribe_request::Request::Ping(Ping { + timestamp: chrono::Utc::now().timestamp_millis(), + })), + }; + if tx_ping.send(ping_req).await.is_err() { + break; + } + } + }); + + // Call StreamDataBytes + let response = match client.stream_data_bytes(outbound).await { + Ok(r) => r, + Err(e) => { + tracing::error!("StreamDataBytes error: {}", e); + if is_permanent_stream_error(&e) { + running.store(false, Ordering::SeqCst); + } + return Err(crate::error::Error::NetworkError(format!( + "StreamDataBytes error: {e}" + ))); + } + }; + + let mut inbound = response.into_inner(); while running.load(Ordering::SeqCst) { - // Check for stop signal - if stop_rx.try_recv().is_ok() { - break; + match inbound.message().await { + Ok(Some(update)) => { + if let Some(proto::subscribe_bytes_update::Update::Data(data)) = update.update + { + let block_number = data.block_number; + if let Some(cb) = bytes_callbacks.read().get(&sub_id) { + cb(data); + } + // Cursor advances only after delivery so reconnects + // never skip an undelivered block. + last_seen.fetch_max(block_number, Ordering::Relaxed); + } + } + Ok(None) => break, + Err(e) => { + tracing::error!("Bytes stream error: {}", e); + if is_permanent_stream_error(&e) { + running.store(false, Ordering::SeqCst); + } + return Err(crate::error::Error::NetworkError(format!( + "Bytes stream error: {e}" + ))); + } } + } - // Update state - { - let mut s = state.write(); - if *s == ConnectionState::Reconnecting { - if let Some(ref cb) = on_reconnect { - cb(reconnect_attempts.load(Ordering::SeqCst)); - } + Ok(()) + } + + async fn stream_blocks( + channel: Channel, + token: &str, + sub_id: u32, + callbacks: &Arc>, + running: &Arc, + ) -> Result<()> { + let token_value: MetadataValue<_> = token.parse().unwrap(); + let mut client = + BlockStreamingClient::with_interceptor(channel, move |mut req: Request<()>| { + req.metadata_mut().insert("x-token", token_value.clone()); + Ok(req) + }); + + let request = Timestamp { + timestamp: chrono::Utc::now().timestamp_millis(), + }; + + let response = match client.stream_blocks(request).await { + Ok(r) => r, + Err(e) => { + tracing::error!("StreamBlocks error: {}", e); + if is_permanent_stream_error(&e) { + running.store(false, Ordering::SeqCst); } - *s = ConnectionState::Connecting; + return Err(crate::error::Error::NetworkError(format!( + "StreamBlocks error: {e}" + ))); } - if let Some(ref cb) = on_state_change { - cb(ConnectionState::Connecting); + }; + + let mut stream = response.into_inner(); + + while running.load(Ordering::SeqCst) { + match stream.message().await { + Ok(Some(block)) => { + if let Ok(data) = serde_json::from_str::(&block.data_json) { + if let Some(cb) = callbacks.read().get(&sub_id) { + cb(data); + } + } + } + Ok(None) => break, + Err(e) => { + tracing::error!("Block stream error: {}", e); + if is_permanent_stream_error(&e) { + running.store(false, Ordering::SeqCst); + } + return Err(crate::error::Error::NetworkError(format!( + "Block stream error: {e}" + ))); + } } + } - // Try to connect and stream - let result = Self::connect_and_stream( - &host, - &token, - &subscriptions, - &callbacks, - &running, - &mut stop_rx, - ) - .await; + Ok(()) + } - if let Err(e) = result { - if let Some(ref cb) = on_error { - cb(e.to_string()); + async fn stream_l2_book( + channel: Channel, + token: &str, + sub_id: u32, + sub_info: &GRPCSubscriptionInfo, + callbacks: &Arc>, + running: &Arc, + ) -> Result<()> { + let token_value: MetadataValue<_> = token.parse().unwrap(); + let mut client = + OrderBookStreamingClient::with_interceptor(channel, move |mut req: Request<()>| { + req.metadata_mut().insert("x-token", token_value.clone()); + Ok(req) + }); + + let request = L2BookRequest { + coin: sub_info.coin.clone().unwrap_or_default(), + n_levels: sub_info.n_levels.unwrap_or(20), + n_sig_figs: sub_info.n_sig_figs, + mantissa: None, + }; + + let response = match client.stream_l2_book(request).await { + Ok(r) => r, + Err(e) => { + tracing::error!("StreamL2Book error: {}", e); + if is_permanent_stream_error(&e) { + running.store(false, Ordering::SeqCst); } + return Err(crate::error::Error::NetworkError(format!( + "StreamL2Book error: {e}" + ))); } + }; - if !running.load(Ordering::SeqCst) { - break; + let mut stream = response.into_inner(); + + while running.load(Ordering::SeqCst) { + match stream.message().await { + Ok(Some(update)) => { + let bids: Vec = update + .bids + .iter() + .map(|l| serde_json::json!([l.px, l.sz, l.n])) + .collect(); + let asks: Vec = update + .asks + .iter() + .map(|l| serde_json::json!([l.px, l.sz, l.n])) + .collect(); + + let data = serde_json::json!({ + "coin": update.coin, + "time": update.time, + "block_number": update.block_number, + "bids": bids, + "asks": asks, + }); + + if let Some(cb) = callbacks.read().get(&sub_id) { + cb(data); + } + } + Ok(None) => break, + Err(e) => { + tracing::error!("L2 book stream error: {}", e); + if is_permanent_stream_error(&e) { + running.store(false, Ordering::SeqCst); + } + return Err(crate::error::Error::NetworkError(format!( + "L2 book stream error: {e}" + ))); + } } + } - if !config.reconnect { - break; + Ok(()) + } + + async fn stream_l4_book( + channel: Channel, + token: &str, + sub_id: u32, + sub_info: &GRPCSubscriptionInfo, + callbacks: &Arc>, + running: &Arc, + ) -> Result<()> { + let token_value: MetadataValue<_> = token.parse().unwrap(); + let mut client = + OrderBookStreamingClient::with_interceptor(channel, move |mut req: Request<()>| { + req.metadata_mut().insert("x-token", token_value.clone()); + Ok(req) + }); + + let request = L4BookRequest { + coin: sub_info.coin.clone().unwrap_or_default(), + }; + + let response = match client.stream_l4_book(request).await { + Ok(r) => r, + Err(e) => { + tracing::error!("StreamL4Book error: {}", e); + if is_permanent_stream_error(&e) { + running.store(false, Ordering::SeqCst); + } + return Err(crate::error::Error::NetworkError(format!( + "StreamL4Book error: {e}" + ))); } + }; - let attempts = reconnect_attempts.fetch_add(1, Ordering::SeqCst) + 1; - if let Some(max) = config.max_reconnect_attempts { - if attempts >= max { - break; + let mut stream = response.into_inner(); + + while running.load(Ordering::SeqCst) { + match stream.message().await { + Ok(Some(update)) => { + let Some(update) = update.update else { + continue; + }; + let data = match update { + proto::l4_book_update::Update::Snapshot(snapshot) => { + let bids: Vec = + snapshot.bids.iter().map(l4_order_to_json).collect(); + let asks: Vec = + snapshot.asks.iter().map(l4_order_to_json).collect(); + + serde_json::json!({ + "type": "snapshot", + "coin": snapshot.coin, + "time": snapshot.time, + "height": snapshot.height, + "bids": bids, + "asks": asks, + }) + } + proto::l4_book_update::Update::Diff(diff) => { + let diff_data: Value = + serde_json::from_str(&diff.data).unwrap_or(Value::Null); + serde_json::json!({ + "type": "diff", + "time": diff.time, + "height": diff.height, + "data": diff_data, + }) + } + }; + + if let Some(cb) = callbacks.read().get(&sub_id) { + cb(data); + } + } + Ok(None) => break, + Err(e) => { + tracing::error!("L4 book stream error: {}", e); + if is_permanent_stream_error(&e) { + running.store(false, Ordering::SeqCst); + } + return Err(crate::error::Error::NetworkError(format!( + "L4 book stream error: {e}" + ))); } } - - { - *state.write() = ConnectionState::Reconnecting; - } - if let Some(ref cb) = on_state_change { - cb(ConnectionState::Reconnecting); - } - - // Wait before reconnecting - tokio::select! { - _ = sleep(backoff) => {} - _ = stop_rx.recv() => { break; } - } - - backoff = Duration::from_secs_f64( - (backoff.as_secs_f64() * RECONNECT_BACKOFF_FACTOR) - .min(MAX_RECONNECT_DELAY.as_secs_f64()), - ); } - { - *state.write() = ConnectionState::Disconnected; - } - if let Some(ref cb) = on_state_change { - cb(ConnectionState::Disconnected); - } - if let Some(ref cb) = on_close { - cb(); - } + Ok(()) } - async fn connect_and_stream( - host: &str, + async fn stream_bbo_book( + channel: Channel, token: &str, - subscriptions: &Arc>>, - callbacks: &Arc>>>, + sub_id: u32, + sub_info: &GRPCSubscriptionInfo, + callbacks: &Arc>, running: &Arc, - stop_rx: &mut mpsc::Receiver<()>, ) -> Result<()> { - if host.is_empty() { - return Err(crate::error::Error::ConfigError( - "No gRPC endpoint configured".to_string(), - )); - } - - let target = format!("https://{}:{}", host, GRPC_PORT); + let token_value: MetadataValue<_> = token.parse().unwrap(); + let mut client = + OrderBookStreamingClient::with_interceptor(channel, move |mut req: Request<()>| { + req.metadata_mut().insert("x-token", token_value.clone()); + Ok(req) + }); - // Create channel with TLS using native system root certificates - // (like Python's ssl_channel_credentials() and TypeScript's grpc.credentials.createSsl()) - let channel = Channel::from_shared(target) - .map_err(|e| crate::error::Error::NetworkError(e.to_string()))? - .tls_config(ClientTlsConfig::new().with_native_roots()) - .map_err(|e: tonic::transport::Error| crate::error::Error::NetworkError(e.to_string()))? - .connect() - .await - .map_err(|e| crate::error::Error::NetworkError(format!("Failed to connect: {}", e)))?; + let request = BboBookRequest { + coins: sub_info.coins.clone(), + }; - // Get subscriptions snapshot - let subs: Vec<(u32, GRPCSubscriptionInfo)> = { - let guard = subscriptions.read(); - guard - .iter() - .map(|(k, v)| { - ( - *k, - GRPCSubscriptionInfo { - stream_type: v.stream_type, - coins: v.coins.clone(), - users: v.users.clone(), - coin: v.coin.clone(), - n_levels: v.n_levels, - n_sig_figs: v.n_sig_figs, - start_block: v.start_block, - raw: v.raw, - }, - ) - }) - .collect() + let response = match client.stream_bbo_book(request).await { + Ok(r) => r, + Err(e) => { + tracing::error!("StreamBboBook error: {}", e); + if is_permanent_stream_error(&e) { + running.store(false, Ordering::SeqCst); + } + return Err(crate::error::Error::NetworkError(format!( + "StreamBboBook error: {e}" + ))); + } }; - // Start each subscription stream - let mut handles = Vec::new(); - for (sub_id, sub_info) in subs { - let channel = channel.clone(); - let token = token.to_string(); - let callbacks = callbacks.clone(); - let running = running.clone(); + let mut stream = response.into_inner(); - let handle = tokio::spawn(async move { - match sub_info.stream_type { - GRPCStreamType::L2Book => { - Self::stream_l2_book( - channel, &token, sub_id, &sub_info, &callbacks, &running, - ) - .await - } - GRPCStreamType::L4Book => { - Self::stream_l4_book( - channel, &token, sub_id, &sub_info, &callbacks, &running, - ) - .await - } - GRPCStreamType::Blocks => { - Self::stream_blocks(channel, &token, sub_id, &callbacks, &running).await - } - _ => { - Self::stream_data(channel, &token, sub_id, &sub_info, &callbacks, &running) - .await - } - } - }); - handles.push(handle); - } + while running.load(Ordering::SeqCst) { + match stream.message().await { + Ok(Some(update)) => { + let data = serde_json::json!({ + "coin": update.coin, + "time": update.time, + "block_number": update.block_number, + "bid": update.bid.map(|l| serde_json::json!([l.px, l.sz, l.n])), + "ask": update.ask.map(|l| serde_json::json!([l.px, l.sz, l.n])), + }); - // Wait for stop signal or any stream to end - loop { - tokio::select! { - _ = stop_rx.recv() => { break; } - _ = sleep(Duration::from_secs(1)) => { - if !running.load(Ordering::SeqCst) { - break; - } - // Check if any handles finished - let mut all_done = true; - for h in &handles { - if !h.is_finished() { - all_done = false; - break; - } - } - if all_done && !handles.is_empty() { - break; + if let Some(cb) = callbacks.read().get(&sub_id) { + cb(data); } } - } - } - - let mut stream_error = None; - for handle in handles { - match handle.await { - Ok(Err(e)) if stream_error.is_none() => stream_error = Some(e), - Ok(_) => {} - Err(e) if stream_error.is_none() => { - stream_error = Some(crate::error::Error::NetworkError(format!( - "gRPC stream task failed: {e}" + Ok(None) => break, + Err(e) => { + tracing::error!("BBO book stream error: {}", e); + if is_permanent_stream_error(&e) { + running.store(false, Ordering::SeqCst); + } + return Err(crate::error::Error::NetworkError(format!( + "BBO book stream error: {e}" ))); } - Err(_) => {} } } - if let Some(err) = stream_error { - Err(err) - } else { - Ok(()) - } + Ok(()) } - async fn stream_data( + async fn stream_bbo_book_packed( channel: Channel, token: &str, sub_id: u32, sub_info: &GRPCSubscriptionInfo, - callbacks: &Arc>>>, + callbacks: &Arc>, running: &Arc, ) -> Result<()> { let token_value: MetadataValue<_> = token.parse().unwrap(); - let mut client = StreamingClient::with_interceptor(channel, move |mut req: Request<()>| { - req.metadata_mut().insert("x-token", token_value.clone()); - Ok(req) - }); + let mut client = + OrderBookStreamingClient::with_interceptor(channel, move |mut req: Request<()>| { + req.metadata_mut().insert("x-token", token_value.clone()); + Ok(req) + }); - // Build subscribe request - let mut filters = HashMap::new(); - if !sub_info.coins.is_empty() { - filters.insert( - "coin".to_string(), - FilterValues { - values: sub_info.coins.clone(), - }, - ); - } - if !sub_info.users.is_empty() { - filters.insert( - "user".to_string(), - FilterValues { - values: sub_info.users.clone(), - }, - ); - } + let request = BboBookRequest { + coins: sub_info.coins.clone(), + }; - let subscribe_req = SubscribeRequest { - request: Some(proto::subscribe_request::Request::Subscribe( - StreamSubscribe { - stream_type: sub_info.stream_type.to_proto(), - start_block: sub_info.start_block.unwrap_or_default(), - filters, - filter_name: String::new(), - }, - )), + let response = match client.stream_bbo_book_packed(request).await { + Ok(r) => r, + Err(e) => { + tracing::error!("StreamBboBookPacked error: {}", e); + if is_permanent_stream_error(&e) { + running.store(false, Ordering::SeqCst); + } + return Err(crate::error::Error::NetworkError(format!( + "StreamBboBookPacked error: {e}" + ))); + } }; - // Create bidirectional stream - let (tx, rx) = tokio::sync::mpsc::channel(16); - let outbound = tokio_stream::wrappers::ReceiverStream::new(rx); + let mut stream = response.into_inner(); - // Send initial subscribe - if tx.send(subscribe_req).await.is_err() { - return Ok(()); - } + while running.load(Ordering::SeqCst) { + match stream.message().await { + Ok(Some(update)) => { + let data = serde_json::json!({ + "coin": update.coin, + "time": update.time, + "block_number": update.block_number, + "bid": update.bid.map(|l| serde_json::json!([l.px, l.sz, l.n])), + "ask": update.ask.map(|l| serde_json::json!([l.px, l.sz, l.n])), + }); - // Start ping task - let tx_ping = tx.clone(); - let running_ping = running.clone(); - tokio::spawn(async move { - loop { - sleep(Duration::from_secs(30)).await; - if !running_ping.load(Ordering::SeqCst) { - break; + if let Some(cb) = callbacks.read().get(&sub_id) { + cb(data); + } } - let ping_req = SubscribeRequest { - request: Some(proto::subscribe_request::Request::Ping(Ping { - timestamp: chrono::Utc::now().timestamp_millis(), - })), - }; - if tx_ping.send(ping_req).await.is_err() { - break; + Ok(None) => break, + Err(e) => { + tracing::error!("Packed BBO book stream error: {}", e); + if is_permanent_stream_error(&e) { + running.store(false, Ordering::SeqCst); + } + return Err(crate::error::Error::NetworkError(format!( + "Packed BBO book stream error: {e}" + ))); } } - }); + } + + Ok(()) + } + + #[allow(clippy::too_many_arguments)] + async fn stream_l2_book_diff( + channel: Channel, + token: &str, + sub_id: u32, + sub_info: &GRPCSubscriptionInfo, + callbacks: &Arc>, + running: &Arc, + is_reconnect: bool, + ) -> Result<()> { + let token_value: MetadataValue<_> = token.parse().unwrap(); + let mut client = + OrderBookStreamingClient::with_interceptor(channel, move |mut req: Request<()>| { + req.metadata_mut().insert("x-token", token_value.clone()); + Ok(req) + }); - // Call StreamData - let response = match client.stream_data(outbound).await { + let request = L2BookDiffRequest { + coins: sub_info.coins.clone(), + n_levels: sub_info.n_levels.unwrap_or(20), + n_sig_figs: sub_info.n_sig_figs, + mantissa: sub_info.mantissa, + // skip_initial_snapshot only applies to the first connect: after a + // reconnect the snapshot is required so the local book can resync. + skip_initial_snapshot: if is_reconnect { + false + } else { + sub_info.skip_initial_snapshot + }, + }; + + let response = match client.stream_l2_book_diff(request).await { Ok(r) => r, Err(e) => { - tracing::error!("StreamData error: {}", e); + tracing::error!("StreamL2BookDiff error: {}", e); if is_permanent_stream_error(&e) { running.store(false, Ordering::SeqCst); } return Err(crate::error::Error::NetworkError(format!( - "StreamData error: {e}" + "StreamL2BookDiff error: {e}" ))); } }; - let mut inbound = response.into_inner(); + let mut stream = response.into_inner(); while running.load(Ordering::SeqCst) { - match inbound.message().await { + match stream.message().await { Ok(Some(update)) => { - if let Some(proto::subscribe_update::Update::Data(data)) = update.update { - // Parse the JSON data - if let Ok(parsed) = serde_json::from_str::(&data.data) { - if sub_info.raw { - let mut data_with_meta = - parsed.as_object().cloned().unwrap_or_default(); - data_with_meta.insert( - "_block_number".to_string(), - Value::Number(data.block_number.into()), - ); - data_with_meta.insert( - "_timestamp".to_string(), - Value::Number(data.timestamp.into()), - ); + let diffs: Vec = update + .diffs + .iter() + .map(|d| { + let bids: Vec = d + .bids + .iter() + .map(|l| serde_json::json!([l.px, l.sz, l.n])) + .collect(); + let asks: Vec = d + .asks + .iter() + .map(|l| serde_json::json!([l.px, l.sz, l.n])) + .collect(); + serde_json::json!({ + "coin": d.coin, + "seq": d.seq, + "prev_seq": d.prev_seq, + "bids": bids, + "asks": asks, + "snapshot": d.snapshot, + }) + }) + .collect(); - if let Some(cb) = callbacks.read().get(&sub_id) { - cb(Value::Object(data_with_meta)); - } - continue; - } + let data = serde_json::json!({ + "time": update.time, + "height": update.height, + "snapshot": update.snapshot, + "diffs": diffs, + }); - // Extract events if present - if let Some(events) = parsed.get("events").and_then(|e| e.as_array()) { - let mut emitted_events = false; - for (index, event) in events.iter().enumerate() { - let mut user: Option = None; - let mut event_data = None; + if let Some(cb) = callbacks.read().get(&sub_id) { + cb(data); + } + } + Ok(None) => break, + Err(e) => { + tracing::error!("L2 book diff stream error: {}", e); + if is_permanent_stream_error(&e) { + running.store(false, Ordering::SeqCst); + } + return Err(crate::error::Error::NetworkError(format!( + "L2 book diff stream error: {e}" + ))); + } + } + } - if let Some(arr) = event.as_array() { - if arr.len() >= 2 { - user = Some(arr[0].clone()); - event_data = arr[1].as_object(); - } - } else { - event_data = event.as_object(); - } + Ok(()) + } - if let Some(event_data) = event_data { - let mut data_with_meta = serde_json::Map::new(); - for (k, v) in event_data { - data_with_meta.insert(k.clone(), v.clone()); - } - data_with_meta.insert( - "_block_number".to_string(), - Value::Number(data.block_number.into()), - ); - data_with_meta.insert( - "_timestamp".to_string(), - Value::Number(data.timestamp.into()), - ); - data_with_meta.insert( - "_event_index".to_string(), - Value::Number((index as u64).into()), - ); - if let Some(user) = user { - data_with_meta.insert("_user".to_string(), user); - } + async fn stream_l4_book_updates( + channel: Channel, + token: &str, + sub_id: u32, + sub_info: &GRPCSubscriptionInfo, + callbacks: &Arc>, + running: &Arc, + ) -> Result<()> { + let token_value: MetadataValue<_> = token.parse().unwrap(); + let mut client = + OrderBookStreamingClient::with_interceptor(channel, move |mut req: Request<()>| { + req.metadata_mut().insert("x-token", token_value.clone()); + Ok(req) + }); - if let Some(cb) = callbacks.read().get(&sub_id) { - cb(Value::Object(data_with_meta)); - } - emitted_events = true; - } - } - if !emitted_events { - let mut data_with_meta = - parsed.as_object().cloned().unwrap_or_default(); - data_with_meta.insert( - "_block_number".to_string(), - Value::Number(data.block_number.into()), - ); - data_with_meta.insert( - "_timestamp".to_string(), - Value::Number(data.timestamp.into()), - ); + let request = L4BookUpdatesRequest { + coins: sub_info.coins.clone(), + }; - if let Some(cb) = callbacks.read().get(&sub_id) { - cb(Value::Object(data_with_meta)); - } - } - } else { - // No events, return raw data - let mut data_with_meta = - parsed.as_object().cloned().unwrap_or_default(); - data_with_meta.insert( - "_block_number".to_string(), - Value::Number(data.block_number.into()), - ); - data_with_meta.insert( - "_timestamp".to_string(), - Value::Number(data.timestamp.into()), - ); + let response = match client.stream_l4_book_updates(request).await { + Ok(r) => r, + Err(e) => { + tracing::error!("StreamL4BookUpdates error: {}", e); + if is_permanent_stream_error(&e) { + running.store(false, Ordering::SeqCst); + } + return Err(crate::error::Error::NetworkError(format!( + "StreamL4BookUpdates error: {e}" + ))); + } + }; - if let Some(cb) = callbacks.read().get(&sub_id) { - cb(Value::Object(data_with_meta)); - } - } - } + let mut stream = response.into_inner(); + + while running.load(Ordering::SeqCst) { + match stream.message().await { + Ok(Some(update)) => { + let diffs: Vec = update + .diffs + .iter() + .map(|d| { + serde_json::json!({ + "diff_type": l4_order_diff_type_str(d.diff_type), + "coin": d.coin, + "oid": d.oid, + "user": d.user, + "side": d.side, + "px": d.px, + "sz": d.sz, + }) + }) + .collect(); + + let data = serde_json::json!({ + "time": update.time, + "height": update.height, + "snapshot": update.snapshot, + "diffs": diffs, + }); + + if let Some(cb) = callbacks.read().get(&sub_id) { + cb(data); } } Ok(None) => break, Err(e) => { - tracing::error!("Stream error: {}", e); + tracing::error!("L4 book updates stream error: {}", e); if is_permanent_stream_error(&e) { running.store(false, Ordering::SeqCst); } return Err(crate::error::Error::NetworkError(format!( - "Stream error: {e}" + "L4 book updates stream error: {e}" ))); } } @@ -1353,33 +2710,34 @@ impl GRPCStream { Ok(()) } - async fn stream_blocks( + async fn stream_tpsl_updates( channel: Channel, token: &str, sub_id: u32, - callbacks: &Arc>>>, + sub_info: &GRPCSubscriptionInfo, + callbacks: &Arc>, running: &Arc, ) -> Result<()> { let token_value: MetadataValue<_> = token.parse().unwrap(); let mut client = - BlockStreamingClient::with_interceptor(channel, move |mut req: Request<()>| { + OrderBookStreamingClient::with_interceptor(channel, move |mut req: Request<()>| { req.metadata_mut().insert("x-token", token_value.clone()); Ok(req) }); - let request = Timestamp { - timestamp: chrono::Utc::now().timestamp_millis(), + let request = TpslUpdatesRequest { + coins: sub_info.coins.clone(), }; - let response = match client.stream_blocks(request).await { + let response = match client.stream_tpsl_updates(request).await { Ok(r) => r, Err(e) => { - tracing::error!("StreamBlocks error: {}", e); + tracing::error!("StreamTpslUpdates error: {}", e); if is_permanent_stream_error(&e) { running.store(false, Ordering::SeqCst); } return Err(crate::error::Error::NetworkError(format!( - "StreamBlocks error: {e}" + "StreamTpslUpdates error: {e}" ))); } }; @@ -1388,21 +2746,49 @@ impl GRPCStream { while running.load(Ordering::SeqCst) { match stream.message().await { - Ok(Some(block)) => { - if let Ok(data) = serde_json::from_str::(&block.data_json) { - if let Some(cb) = callbacks.read().get(&sub_id) { - cb(data); - } + Ok(Some(update)) => { + let diffs: Vec = update + .diffs + .iter() + .map(|d| { + serde_json::json!({ + "diff_type": tpsl_diff_type_str(d.diff_type), + "oid": d.oid, + "coin": d.coin, + "user": d.user, + "side": d.side, + "trigger_px": d.trigger_px, + "limit_px": d.limit_px, + "sz": d.sz, + "trigger_condition": d.trigger_condition, + "order_type": d.order_type, + "is_position_tpsl": d.is_position_tpsl, + "reduce_only": d.reduce_only, + "timestamp": d.timestamp, + "reason": d.reason, + }) + }) + .collect(); + + let data = serde_json::json!({ + "time": update.time, + "height": update.height, + "snapshot": update.snapshot, + "diffs": diffs, + }); + + if let Some(cb) = callbacks.read().get(&sub_id) { + cb(data); } } Ok(None) => break, Err(e) => { - tracing::error!("Block stream error: {}", e); + tracing::error!("TPSL updates stream error: {}", e); if is_permanent_stream_error(&e) { running.store(false, Ordering::SeqCst); } return Err(crate::error::Error::NetworkError(format!( - "Block stream error: {e}" + "TPSL updates stream error: {e}" ))); } } @@ -1411,12 +2797,12 @@ impl GRPCStream { Ok(()) } - async fn stream_l2_book( + async fn stream_l2_book_packed( channel: Channel, token: &str, sub_id: u32, sub_info: &GRPCSubscriptionInfo, - callbacks: &Arc>>>, + callbacks: &Arc>, running: &Arc, ) -> Result<()> { let token_value: MetadataValue<_> = token.parse().unwrap(); @@ -1433,15 +2819,15 @@ impl GRPCStream { mantissa: None, }; - let response = match client.stream_l2_book(request).await { + let response = match client.stream_l2_book_packed(request).await { Ok(r) => r, Err(e) => { - tracing::error!("StreamL2Book error: {}", e); + tracing::error!("StreamL2BookPacked error: {}", e); if is_permanent_stream_error(&e) { running.store(false, Ordering::SeqCst); } return Err(crate::error::Error::NetworkError(format!( - "StreamL2Book error: {e}" + "StreamL2BookPacked error: {e}" ))); } }; @@ -1476,12 +2862,12 @@ impl GRPCStream { } Ok(None) => break, Err(e) => { - tracing::error!("L2 book stream error: {}", e); + tracing::error!("Packed L2 book stream error: {}", e); if is_permanent_stream_error(&e) { running.store(false, Ordering::SeqCst); } return Err(crate::error::Error::NetworkError(format!( - "L2 book stream error: {e}" + "Packed L2 book stream error: {e}" ))); } } @@ -1490,12 +2876,12 @@ impl GRPCStream { Ok(()) } - async fn stream_l4_book( + async fn stream_l4_book_bytes( channel: Channel, token: &str, sub_id: u32, sub_info: &GRPCSubscriptionInfo, - callbacks: &Arc>>>, + callbacks: &Arc>, running: &Arc, ) -> Result<()> { let token_value: MetadataValue<_> = token.parse().unwrap(); @@ -1509,15 +2895,15 @@ impl GRPCStream { coin: sub_info.coin.clone().unwrap_or_default(), }; - let response = match client.stream_l4_book(request).await { + let response = match client.stream_l4_book_bytes(request).await { Ok(r) => r, Err(e) => { - tracing::error!("StreamL4Book error: {}", e); + tracing::error!("StreamL4BookBytes error: {}", e); if is_permanent_stream_error(&e) { running.store(false, Ordering::SeqCst); } return Err(crate::error::Error::NetworkError(format!( - "StreamL4Book error: {e}" + "StreamL4BookBytes error: {e}" ))); } }; @@ -1531,7 +2917,7 @@ impl GRPCStream { continue; }; let data = match update { - proto::l4_book_update::Update::Snapshot(snapshot) => { + proto::l4_book_bytes_update::Update::Snapshot(snapshot) => { let bids: Vec = snapshot.bids.iter().map(l4_order_to_json).collect(); let asks: Vec = @@ -1546,9 +2932,9 @@ impl GRPCStream { "asks": asks, }) } - proto::l4_book_update::Update::Diff(diff) => { + proto::l4_book_bytes_update::Update::Diff(diff) => { let diff_data: Value = - serde_json::from_str(&diff.data).unwrap_or(Value::Null); + serde_json::from_slice(&diff.data).unwrap_or(Value::Null); serde_json::json!({ "type": "diff", "time": diff.time, @@ -1564,12 +2950,12 @@ impl GRPCStream { } Ok(None) => break, Err(e) => { - tracing::error!("L4 book stream error: {}", e); + tracing::error!("L4 book bytes stream error: {}", e); if is_permanent_stream_error(&e) { running.store(false, Ordering::SeqCst); } return Err(crate::error::Error::NetworkError(format!( - "L4 book stream error: {e}" + "L4 book bytes stream error: {e}" ))); } } @@ -1610,6 +2996,23 @@ fn parse_endpoint(url: &str) -> (String, String) { (host, token) } +fn l4_order_diff_type_str(diff_type: i32) -> &'static str { + match proto::L4OrderDiffType::try_from(diff_type) { + Ok(proto::L4OrderDiffType::New) => "new", + Ok(proto::L4OrderDiffType::Update) => "update", + Ok(proto::L4OrderDiffType::Remove) => "remove", + _ => "unspecified", + } +} + +fn tpsl_diff_type_str(diff_type: i32) -> &'static str { + match proto::TpslDiffType::try_from(diff_type) { + Ok(proto::TpslDiffType::Add) => "add", + Ok(proto::TpslDiffType::Remove) => "remove", + _ => "unspecified", + } +} + fn l4_order_to_json(order: &proto::L4Order) -> Value { serde_json::json!({ "user": order.user, @@ -1629,3 +3032,181 @@ fn l4_order_to_json(order: &proto::L4Order) -> Value { "cloid": order.cloid, }) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn stream_type_proto_values() { + assert_eq!(GRPCStreamType::Trades.to_proto(), 1); + assert_eq!(GRPCStreamType::Orders.to_proto(), 2); + assert_eq!(GRPCStreamType::BookUpdates.to_proto(), 3); + assert_eq!(GRPCStreamType::Twap.to_proto(), 4); + assert_eq!(GRPCStreamType::Events.to_proto(), 5); + assert_eq!(GRPCStreamType::Blocks.to_proto(), 6); + assert_eq!(GRPCStreamType::WriterActions.to_proto(), 7); + assert_eq!(GRPCStreamType::MempoolTxs.to_proto(), 8); + assert_eq!(GRPCStreamType::OrderPriority.to_proto(), 9); + assert_eq!(GRPCStreamType::GossipPriority.to_proto(), 10); + + // Order book streams go through dedicated RPCs, not StreamData. + for pseudo in [ + GRPCStreamType::L2Book, + GRPCStreamType::L4Book, + GRPCStreamType::BboBook, + GRPCStreamType::L2BookDiff, + GRPCStreamType::L4BookUpdates, + GRPCStreamType::TpslUpdates, + GRPCStreamType::L2BookPacked, + GRPCStreamType::BboBookPacked, + GRPCStreamType::L4BookBytes, + ] { + assert_eq!(pseudo.to_proto(), 0); + } + } + + #[test] + fn stream_type_names() { + assert_eq!(GRPCStreamType::MempoolTxs.as_str(), "mempool_txs"); + assert_eq!(GRPCStreamType::OrderPriority.as_str(), "order_priority"); + assert_eq!(GRPCStreamType::GossipPriority.as_str(), "gossip_priority"); + assert_eq!(GRPCStreamType::BboBook.as_str(), "bbo_book"); + assert_eq!(GRPCStreamType::L2BookDiff.as_str(), "l2_book_diff"); + assert_eq!(GRPCStreamType::L4BookUpdates.as_str(), "l4_book_updates"); + assert_eq!(GRPCStreamType::TpslUpdates.as_str(), "tpsl_updates"); + assert_eq!(GRPCStreamType::L2BookPacked.as_str(), "l2_book_packed"); + assert_eq!(GRPCStreamType::BboBookPacked.as_str(), "bbo_book_packed"); + assert_eq!(GRPCStreamType::L4BookBytes.as_str(), "l4_book_bytes"); + } + + #[test] + fn mempool_txs_registers_coin_filter_subscription() { + let mut stream = GRPCStream::new(None); + let sub = stream.mempool_txs(&["BTC", "ETH"], |_| {}); + + assert_eq!(sub.stream_type, GRPCStreamType::MempoolTxs); + let subs = stream.subscriptions.read(); + let info = subs.get(&sub.id).unwrap(); + assert_eq!(info.coins, vec!["BTC".to_string(), "ETH".to_string()]); + assert!(!info.raw); + assert!(!info.bytes); + + // Coin filter is carried via the generic filters map. + let req = GRPCStream::build_subscribe_request(info, false, 0); + let Some(proto::subscribe_request::Request::Subscribe(sub_msg)) = req.request else { + panic!("expected subscribe request"); + }; + assert_eq!(sub_msg.stream_type, 8); + assert_eq!( + sub_msg.filters.get("coin").unwrap().values, + vec!["BTC".to_string(), "ETH".to_string()] + ); + } + + #[test] + fn start_block_is_plumbed_into_subscribe_request() { + let mut stream = GRPCStream::new(None); + let sub = stream.trades_with_options( + &["BTC"], + GRPCSubscriptionOptions { + start_block: Some(123_456), + }, + |_| {}, + ); + + let subs = stream.subscriptions.read(); + let info = subs.get(&sub.id).unwrap(); + assert_eq!(info.start_block, Some(123_456)); + + let req = GRPCStream::build_subscribe_request(info, false, 0); + let Some(proto::subscribe_request::Request::Subscribe(sub_msg)) = req.request else { + panic!("expected subscribe request"); + }; + assert_eq!(sub_msg.start_block, 123_456); + } + + #[test] + fn reconnect_advances_start_block_cursor() { + let mut stream = GRPCStream::new(None); + let sub = stream.trades_with_options( + &["BTC"], + GRPCSubscriptionOptions { + start_block: Some(100), + }, + |_| {}, + ); + + let subs = stream.subscriptions.read(); + let info = subs.get(&sub.id).unwrap(); + + let extract = |req: SubscribeRequest| -> u64 { + let Some(proto::subscribe_request::Request::Subscribe(sub_msg)) = req.request else { + panic!("expected subscribe request"); + }; + sub_msg.start_block + }; + + // First connect: the user's original start_block, even if data flowed before. + assert_eq!(extract(GRPCStream::build_subscribe_request(info, false, 500)), 100); + // Reconnect after seeing block 500: resume past it. + assert_eq!(extract(GRPCStream::build_subscribe_request(info, true, 500)), 501); + // Reconnect before any data: keep the original. + assert_eq!(extract(GRPCStream::build_subscribe_request(info, true, 0)), 100); + // Reconnect where the original is still ahead of last-seen: keep the original. + assert_eq!(extract(GRPCStream::build_subscribe_request(info, true, 50)), 100); + } + + #[test] + fn reconnect_keeps_unset_start_block_unset() { + let mut stream = GRPCStream::new(None); + let sub = stream.trades(&["BTC"], |_| {}); + + let subs = stream.subscriptions.read(); + let info = subs.get(&sub.id).unwrap(); + assert_eq!(info.start_block, None); + + // Tip-following subscriptions never grow a cursor on reconnect. + let req = GRPCStream::build_subscribe_request(info, true, 12_345); + let Some(proto::subscribe_request::Request::Subscribe(sub_msg)) = req.request else { + panic!("expected subscribe request"); + }; + assert_eq!(sub_msg.start_block, 0); + } + + #[test] + fn raw_bytes_registers_bytes_subscription() { + let mut stream = GRPCStream::new(None); + let sub = stream.raw_bytes( + GRPCStreamType::Trades, + &["BTC"], + GRPCSubscriptionOptions::default(), + |_| {}, + ); + + let subs = stream.subscriptions.read(); + let info = subs.get(&sub.id).unwrap(); + assert!(info.bytes); + assert!(stream.bytes_callbacks.read().contains_key(&sub.id)); + } + + #[test] + fn l2_book_diff_options_defaults() { + let options = GRPCL2BookDiffOptions::default(); + assert_eq!(options.n_levels, 20); + assert_eq!(options.n_sig_figs, None); + assert_eq!(options.mantissa, None); + assert!(!options.skip_initial_snapshot); + } + + #[test] + fn diff_type_strings() { + assert_eq!(l4_order_diff_type_str(1), "new"); + assert_eq!(l4_order_diff_type_str(2), "update"); + assert_eq!(l4_order_diff_type_str(3), "remove"); + assert_eq!(l4_order_diff_type_str(0), "unspecified"); + assert_eq!(tpsl_diff_type_str(1), "add"); + assert_eq!(tpsl_diff_type_str(2), "remove"); + assert_eq!(tpsl_diff_type_str(0), "unspecified"); + } +} diff --git a/rust/src/lib.rs b/rust/src/lib.rs index ffb7aec..ba536b6 100644 --- a/rust/src/lib.rs +++ b/rust/src/lib.rs @@ -68,6 +68,7 @@ pub use types::{ Chain, Side, TIF, TpSl, OrderGrouping, Signature, OrderRequest, OrderTypePlacement, TimeInForce, Action, ActionRequest, PredictionMarket, PredictionMarketFilter, PredictionSide, + ExchangeOptions, CancelOptions, }; pub use order::{Order, TriggerOrder, PlacedOrder}; pub use error::{Error, ErrorCode, Result}; @@ -79,7 +80,7 @@ pub use evm::EVM; pub use stream::Stream; pub use evm_stream::{EVMStream, EVMSubscriptionType, EVMConnectionState}; -pub use grpc::{GRPCStream, GRPCSubscriptionOptions}; +pub use grpc::{GRPCL2BookDiffOptions, GRPCStream, GRPCStreamType, GRPCSubscriptionOptions}; // Re-export serde_json::Value for convenience since many API methods return it pub use serde_json::Value; diff --git a/rust/src/order.rs b/rust/src/order.rs index ac317fa..aa7f6b5 100644 --- a/rust/src/order.rs +++ b/rust/src/order.rs @@ -21,7 +21,7 @@ use std::str::FromStr; use std::sync::Arc; use crate::types::{ - Cloid, OrderRequest, OrderTypePlacement, Side, TIF, TimeInForce, TpSl, + Cloid, ExchangeOptions, OrderRequest, OrderTypePlacement, Side, TIF, TimeInForce, TpSl, }; // ══════════════════════════════════════════════════════════════════════════════ @@ -40,6 +40,8 @@ pub struct Order { reduce_only: bool, cloid: Option, priority_fee: Option, + vault_address: Option, + expires_after: Option, } impl Order { @@ -74,6 +76,8 @@ impl Order { reduce_only: false, cloid: None, priority_fee: None, + vault_address: None, + expires_after: None, } } @@ -179,6 +183,24 @@ impl Order { self } + /// Trade on behalf of a vault or subaccount + /// + /// Sent as the top-level `vaultAddress` exchange field, which the worker + /// folds into the signed action hash. + pub fn vault_address(mut self, vault_address: impl Into) -> Self { + self.vault_address = Some(vault_address.into()); + self + } + + /// Set action TTL: millisecond timestamp after which the action is rejected + /// + /// Sent as the top-level `expiresAfter` exchange field, which the worker + /// folds into the signed action hash. + pub fn expires_after(mut self, expires_after: u64) -> Self { + self.expires_after = Some(expires_after); + self + } + // ────────────────────────────────────────────────────────────────────────── // Getters // ────────────────────────────────────────────────────────────────────────── @@ -233,6 +255,24 @@ impl Order { self.priority_fee } + /// Get the vault address (if set) + pub fn get_vault_address(&self) -> Option<&str> { + self.vault_address.as_deref() + } + + /// Get the action TTL (if set) + pub fn get_expires_after(&self) -> Option { + self.expires_after + } + + /// The vault/TTL options for this order, for threading into build/send + pub fn exchange_options(&self) -> ExchangeOptions { + ExchangeOptions { + vault_address: self.vault_address.clone(), + expires_after: self.expires_after, + } + } + // ────────────────────────────────────────────────────────────────────────── // Conversion // ────────────────────────────────────────────────────────────────────────── @@ -298,6 +338,8 @@ pub struct TriggerOrder { is_market: bool, reduce_only: bool, cloid: Option, + vault_address: Option, + expires_after: Option, } impl TriggerOrder { @@ -332,6 +374,8 @@ impl TriggerOrder { is_market: true, reduce_only: true, // Default to reduce-only cloid: None, + vault_address: None, + expires_after: None, } } @@ -405,6 +449,24 @@ impl TriggerOrder { self } + /// Trade on behalf of a vault or subaccount + /// + /// Sent as the top-level `vaultAddress` exchange field, which the worker + /// folds into the signed action hash. + pub fn vault_address(mut self, vault_address: impl Into) -> Self { + self.vault_address = Some(vault_address.into()); + self + } + + /// Set action TTL: millisecond timestamp after which the action is rejected + /// + /// Sent as the top-level `expiresAfter` exchange field, which the worker + /// folds into the signed action hash. + pub fn expires_after(mut self, expires_after: u64) -> Self { + self.expires_after = Some(expires_after); + self + } + // ────────────────────────────────────────────────────────────────────────── // Getters // ────────────────────────────────────────────────────────────────────────── @@ -414,6 +476,24 @@ impl TriggerOrder { &self.asset } + /// Get the vault address (if set) + pub fn get_vault_address(&self) -> Option<&str> { + self.vault_address.as_deref() + } + + /// Get the action TTL (if set) + pub fn get_expires_after(&self) -> Option { + self.expires_after + } + + /// The vault/TTL options for this order, for threading into build/send + pub fn exchange_options(&self) -> ExchangeOptions { + ExchangeOptions { + vault_address: self.vault_address.clone(), + expires_after: self.expires_after, + } + } + /// Get the trigger type pub fn get_tpsl(&self) -> TpSl { self.tpsl diff --git a/rust/src/signing.rs b/rust/src/signing.rs index 1d3e006..8342735 100644 --- a/rust/src/signing.rs +++ b/rust/src/signing.rs @@ -186,6 +186,24 @@ mod tests { assert_ne!(hash_no_vault, hash_with_vault); } + #[test] + fn test_rmp_hash_with_expires_after() { + #[derive(Serialize)] + struct TestAction { + value: u64, + } + + let action = TestAction { value: 42 }; + let hash_no_expiry = rmp_hash(&action, 1000, None, None).unwrap(); + let hash_with_expiry = rmp_hash(&action, 1000, None, Some(1_752_000_000_000)).unwrap(); + // Expiry should change the hash + assert_ne!(hash_no_expiry, hash_with_expiry); + + // Different expiry values hash differently + let hash_other_expiry = rmp_hash(&action, 1000, None, Some(1_752_000_000_001)).unwrap(); + assert_ne!(hash_with_expiry, hash_other_expiry); + } + #[tokio::test] async fn test_local_signer_address_matches_key() { let key = PrivateKeySigner::random(); diff --git a/rust/src/types.rs b/rust/src/types.rs index 125c5c9..3bf974e 100644 --- a/rust/src/types.rs +++ b/rust/src/types.rs @@ -471,6 +471,14 @@ pub struct Cancel { #[serde(rename_all = "camelCase")] pub struct BatchCancel { pub cancels: Vec, + /// Fast cancel flag (`"f": true` on the wire; omitted unless true) + #[serde( + rename = "f", + default, + skip_serializing_if = "is_not_true_flag", + deserialize_with = "deserialize_true_only_flag" + )] + pub fast: Option, } /// Cancel by client order ID @@ -487,6 +495,25 @@ pub struct CancelByCloid { #[serde(rename_all = "camelCase")] pub struct BatchCancelCloid { pub cancels: Vec, + /// Fast cancel flag (`"f": true` on the wire; omitted unless true) + #[serde( + rename = "f", + default, + skip_serializing_if = "is_not_true_flag", + deserialize_with = "deserialize_true_only_flag" + )] + pub fast: Option, +} + +fn is_not_true_flag(value: &Option) -> bool { + !matches!(value, Some(true)) +} + +fn deserialize_true_only_flag<'de, D>(deserializer: D) -> Result, D::Error> +where + D: serde::Deserializer<'de>, +{ + Option::::deserialize(deserializer).map(|value| value.filter(|flag| *flag)) } /// Schedule cancel (dead-man's switch) @@ -849,6 +876,44 @@ pub struct ActionRequest { pub expires_after: Option, } +// ══════════════════════════════════════════════════════════════════════════════ +// Exchange Options +// ══════════════════════════════════════════════════════════════════════════════ + +/// Optional per-action trading parameters for order/modify/closePosition actions. +/// +/// Both fields are sent as top-level exchange-body fields (`vaultAddress`, +/// `expiresAfter` on the wire) so the worker folds them into the signed action +/// hash. Neither field is emitted when unset. +#[derive(Debug, Clone, Default)] +pub struct ExchangeOptions { + /// Trade on behalf of a vault or subaccount + pub vault_address: Option, + /// Action TTL: millisecond timestamp after which the action is rejected + pub expires_after: Option, +} + +/// Optional per-call cancel parameters. +#[derive(Debug, Clone, Default)] +pub struct CancelOptions { + /// Fast cancel: emits `"f": true` on the cancel action (omitted when false) + pub fast: bool, + /// Cancel on behalf of a vault or subaccount + pub vault_address: Option, + /// Action TTL: millisecond timestamp after which the action is rejected + pub expires_after: Option, +} + +impl CancelOptions { + /// The vault/TTL subset of these options, for threading into build/send + pub fn exchange_options(&self) -> ExchangeOptions { + ExchangeOptions { + vault_address: self.vault_address.clone(), + expires_after: self.expires_after, + } + } +} + // ══════════════════════════════════════════════════════════════════════════════ // Builder // ══════════════════════════════════════════════════════════════════════════════ @@ -986,3 +1051,84 @@ where let s = s.strip_prefix("0x").unwrap_or(&s); U256::from_str_radix(s, 16).map_err(serde::de::Error::custom) } + +#[cfg(test)] +mod tests { + use super::*; + + fn batch_cancel(fast: Option) -> BatchCancel { + BatchCancel { + cancels: vec![Cancel { asset: 5, oid: 123 }], + fast, + } + } + + /// The fast flag is only emitted when true — unset and explicit false both + /// produce the legacy wire shape (wire compat with existing servers). + #[test] + fn test_batch_cancel_fast_flag_serialization() { + let unset = serde_json::to_string(&batch_cancel(None)).unwrap(); + assert_eq!(unset, r#"{"cancels":[{"a":5,"o":123}]}"#); + + let explicit_false = serde_json::to_string(&batch_cancel(Some(false))).unwrap(); + assert_eq!(explicit_false, unset); + + let fast = serde_json::to_string(&batch_cancel(Some(true))).unwrap(); + assert_eq!(fast, r#"{"cancels":[{"a":5,"o":123}],"f":true}"#); + } + + /// Deserialization strips `f: false` (matching the backend, which only + /// keeps the flag when true). + #[test] + fn test_batch_cancel_fast_flag_deserialization() { + let with_true: BatchCancel = + serde_json::from_str(r#"{"cancels":[{"a":5,"o":123}],"f":true}"#).unwrap(); + assert_eq!(with_true.fast, Some(true)); + + let with_false: BatchCancel = + serde_json::from_str(r#"{"cancels":[{"a":5,"o":123}],"f":false}"#).unwrap(); + assert_eq!(with_false.fast, None); + + let without: BatchCancel = + serde_json::from_str(r#"{"cancels":[{"a":5,"o":123}]}"#).unwrap(); + assert_eq!(without.fast, None); + } + + /// Same behavior for the cloid variant. + #[test] + fn test_batch_cancel_cloid_fast_flag_serialization() { + let entry = CancelByCloid { + asset: 5, + cloid: B128::repeat_byte(0x11), + }; + let unset = serde_json::to_string(&BatchCancelCloid { + cancels: vec![entry.clone()], + fast: None, + }) + .unwrap(); + assert!(!unset.contains("\"f\""), "got: {unset}"); + + let fast = serde_json::to_string(&BatchCancelCloid { + cancels: vec![entry], + fast: Some(true), + }) + .unwrap(); + assert!(fast.ends_with(r#"],"f":true}"#), "got: {fast}"); + } + + /// The fast flag participates in the signed action payload: the MessagePack + /// hash of a cancel action changes when `f: true` is set. + #[test] + fn test_fast_flag_changes_action_hash() { + let plain = Action::Cancel(batch_cancel(None)); + let fast = Action::Cancel(batch_cancel(Some(true))); + + let hash_plain = plain.hash(1000, None, None).unwrap(); + let hash_fast = fast.hash(1000, None, None).unwrap(); + assert_ne!(hash_plain, hash_fast); + + // Explicit false hashes identically to unset (it is skipped entirely). + let explicit_false = Action::Cancel(batch_cancel(Some(false))); + assert_eq!(explicit_false.hash(1000, None, None).unwrap(), hash_plain); + } +} diff --git a/typescript/README.md b/typescript/README.md index d869313..ab119cb 100644 --- a/typescript/README.md +++ b/typescript/README.md @@ -308,6 +308,18 @@ sdk.grpc.blocks((b) => console.log(`Block: ${JSON.stringify(b)}`)); // Subscribe to raw event blocks sdk.grpc.rawEvents((b) => console.log(`Events block: ${JSON.stringify(b)}`)); +// Subscribe to pre-consensus mempool transactions (optional coin filter) +sdk.grpc.mempoolTxs(["BTC"], (tx) => console.log(`Mempool: ${JSON.stringify(tx)}`)); + +// Subscribe to best bid/offer changes for all coins +sdk.grpc.bboBook((b) => console.log(`BBO: ${JSON.stringify(b)}`)); + +// Subscribe to incremental L2 changes (sz=0 means the level was removed) +sdk.grpc.l2BookDiff((d) => console.log(`L2 diff: ${JSON.stringify(d)}`), { coins: ["BTC"] }); + +// Replay a stream from a specific block +sdk.grpc.trades(["BTC"], (t) => console.log(t), { startBlock: 750000000 }); + // Start streaming await sdk.grpc.start(); @@ -335,6 +347,28 @@ sdk.grpc.stop(); | `rawEvents(callback)` | - | Raw system event blocks with `events: [...]` | | `writerActions(callback)` | - | Writer actions | | `rawWriterActions(callback)` | - | Raw writer action blocks | +| `mempoolTxs(coins?, callback)` | coins?: `string[]` | Pre-consensus mempool transactions (optional coin filter) | +| `rawMempoolTxs(coins?, callback)` | coins?: `string[]` | Raw mempool transaction blocks | +| `orderPriority(callback)` | - | Derived order/write priority actions | +| `rawOrderPriority(callback)` | - | Raw order priority blocks | +| `gossipPriority(callback)` | - | Derived gossip/read priority bid actions | +| `rawGossipPriority(callback)` | - | Raw gossip priority blocks | +| `streamDataBytes(streamType, callback, options)` | streamType: `GRPCStreamType`, coins?/users?/startBlock? | Raw-bytes fast path of the generic stream (no JSON decoding) | +| `bboBook(coins?, callback)` | coins?: `string[]` | Best bid/offer changes (empty coins = all) | +| `l2BookDiff(callback, options)` | coins?, nLevels?, nSigFigs?, mantissa?, skipInitialSnapshot? | Incremental L2 price-level changes (sz=0 level = removed) | +| `l4BookUpdates(coins?, callback)` | coins?: `string[]` | Typed L4 order-level changes | +| `tpslUpdates(coins?, callback)` | coins?: `string[]` | Trigger/TP-SL order updates (empty coins = all perp coins) | +| `l2BookPacked(coin, callback, options)` | coin: `string`, nSigFigs?, nLevels?, mantissa? | Fixed-point L2 fast path | +| `bboBookPacked(coins?, callback)` | coins?: `string[]` | Fixed-point BBO fast path | +| `l4BookBytes(coin, callback)` | coin: `string` | L4 fast path; diffs arrive as JSON bytes | + +**Priority streams:** `orderPriority` / `gossipPriority` events carry server-enriched fields `coin`, `market_type`, and `sz_decimals`. + +**Replay from a block:** the generic data streams accept `{ startBlock }` in their trailing options object, e.g. `sdk.grpc.trades(["BTC"], cb, { startBlock: 750000000 })`. **Note:** `startBlock` is not yet supported server-side — streams currently always begin at the live tip; the option is wired through so resume works automatically once server support ships. + +**Packed streams:** in `l2BookPacked` / `bboBookPacked`, `px` and `sz` are u64 fixed-point integers scaled by 1e8. + +**L4 snapshot contract** (applies to `l4Book`, `l4BookBytes`, `l4BookUpdates`): the server may send an unsolicited full snapshot at any time after subscribe (e.g. after ALO queue-priority anchored insertions). Clients MUST discard local book state and replace it with any snapshot received mid-stream. `insertBefore` is never sent to clients. ### L4 Order Book (Critical for Trading) @@ -439,6 +473,14 @@ await order.cancel(); await sdk.cancelAll(); await sdk.cancelAll("BTC"); // Just BTC orders +// Fast cancel (wire flag f: true, only sent when set) +await sdk.cancel(order.oid!, "BTC", { fast: true }); +await sdk.cancelByCloid("0xmycloid...", "BTC", { fast: true }); + +// Action TTL — the exchange rejects the action after this ms timestamp +await sdk.buy("BTC", { size: 0.001, price: 60000, tif: "gtc", expiresAfter: Date.now() + 60000 }); +await sdk.cancel(order.oid!, "BTC", { expiresAfter: Date.now() + 10000 }); + // Dead-man's switch await sdk.scheduleCancel(Date.now() + 60000); ``` @@ -669,6 +711,12 @@ await sdk.withdraw("0x...", 100); const HLP_VAULT = "0xdfc24b077bc1425ad1dea75bcb6f8158e10df303"; await sdk.vaultDeposit(HLP_VAULT, 100); await sdk.vaultWithdraw(HLP_VAULT, 50); + +// Trade on behalf of a vault or subaccount you lead +const MY_VAULT = "0x..."; +await sdk.buy("BTC", { size: 0.001, price: 65000, vaultAddress: MY_VAULT }); +await sdk.cancel(oid, "BTC", { vaultAddress: MY_VAULT }); +await sdk.closePosition("BTC", { vaultAddress: MY_VAULT }); ``` ### Staking @@ -704,6 +752,15 @@ const priorityOrder = await sdk.order( .market() .priorityFee(10000) ); + +const vaultOrder = await sdk.order( + Order.buy("BTC") + .size(0.001) + .price(65000) + .gtc() + .vaultAddress("0x...") + .expiresAfter(Date.now() + 60000) +); ``` --- diff --git a/typescript/src/client.ts b/typescript/src/client.ts index 3516a01..5086b10 100644 --- a/typescript/src/client.ts +++ b/typescript/src/client.ts @@ -564,6 +564,8 @@ export class HyperliquidSDK { * @param options.reduceOnly - Close position only, no new exposure * @param options.grouping - Order grouping for TP/SL attachment * @param options.priorityFee - Optional Hyperliquid order priority p. p=10000 is 1 bp. + * @param options.vaultAddress - Trade on behalf of a vault or subaccount + * @param options.expiresAfter - Action TTL as a ms timestamp; rejected after this time * * @returns PlacedOrder with oid, status, and cancel/modify methods */ @@ -579,6 +581,10 @@ export class HyperliquidSDK { priorityFee?: number | string; /** Slippage tolerance for market orders (e.g. 0.05 = 5%). Overrides SDK default. */ slippage?: number; + /** Vault or subaccount address to trade on behalf of. */ + vaultAddress?: string; + /** Action TTL as a ms timestamp; the exchange rejects it after this time. */ + expiresAfter?: number; } = {} ): Promise { return this._placeOrder({ @@ -592,6 +598,8 @@ export class HyperliquidSDK { grouping: options.grouping ?? OrderGrouping.NA, slippage: options.slippage, priorityFee: options.priorityFee, + vaultAddress: options.vaultAddress, + expiresAfter: options.expiresAfter, }); } @@ -610,6 +618,10 @@ export class HyperliquidSDK { priorityFee?: number | string; /** Slippage tolerance for market orders (e.g. 0.05 = 5%). Overrides SDK default. */ slippage?: number; + /** Vault or subaccount address to trade on behalf of. */ + vaultAddress?: string; + /** Action TTL as a ms timestamp; the exchange rejects it after this time. */ + expiresAfter?: number; } = {} ): Promise { return this._placeOrder({ @@ -623,6 +635,8 @@ export class HyperliquidSDK { grouping: options.grouping ?? OrderGrouping.NA, slippage: options.slippage, priorityFee: options.priorityFee, + vaultAddress: options.vaultAddress, + expiresAfter: options.expiresAfter, }); } @@ -636,7 +650,7 @@ export class HyperliquidSDK { */ async marketBuy( asset: AssetInput, - options: { size?: number | string; notional?: number; slippage?: number; priorityFee?: number | string } = {} + options: { size?: number | string; notional?: number; slippage?: number; priorityFee?: number | string; vaultAddress?: string; expiresAfter?: number } = {} ): Promise { return this.buy(asset, { ...options, tif: 'market' }); } @@ -647,7 +661,7 @@ export class HyperliquidSDK { */ async marketSell( asset: AssetInput, - options: { size?: number | string; notional?: number; slippage?: number; priorityFee?: number | string } = {} + options: { size?: number | string; notional?: number; slippage?: number; priorityFee?: number | string; vaultAddress?: string; expiresAfter?: number } = {} ): Promise { return this.sell(asset, { ...options, tif: 'market' }); } @@ -696,6 +710,8 @@ export class HyperliquidSDK { side?: Side; reduceOnly?: boolean; grouping?: OrderGrouping; + vaultAddress?: string; + expiresAfter?: number; } ): Promise { const trigger = TriggerOrder.stopLoss(asset, { side: options.side ?? Side.SELL }); @@ -708,7 +724,10 @@ export class HyperliquidSDK { } trigger.reduceOnly(options.reduceOnly ?? true); - return this._executeTriggerOrder(trigger, options.grouping ?? OrderGrouping.NA); + return this._executeTriggerOrder(trigger, options.grouping ?? OrderGrouping.NA, { + vaultAddress: options.vaultAddress, + expiresAfter: options.expiresAfter, + }); } /** @@ -723,6 +742,8 @@ export class HyperliquidSDK { side?: Side; reduceOnly?: boolean; grouping?: OrderGrouping; + vaultAddress?: string; + expiresAfter?: number; } ): Promise { const trigger = TriggerOrder.takeProfit(asset, { side: options.side ?? Side.SELL }); @@ -735,7 +756,10 @@ export class HyperliquidSDK { } trigger.reduceOnly(options.reduceOnly ?? true); - return this._executeTriggerOrder(trigger, options.grouping ?? OrderGrouping.NA); + return this._executeTriggerOrder(trigger, options.grouping ?? OrderGrouping.NA, { + vaultAddress: options.vaultAddress, + expiresAfter: options.expiresAfter, + }); } // Aliases @@ -747,18 +771,23 @@ export class HyperliquidSDK { */ async triggerOrder( trigger: TriggerOrder, - grouping: OrderGrouping = OrderGrouping.NA + grouping: OrderGrouping = OrderGrouping.NA, + options: { vaultAddress?: string; expiresAfter?: number } = {} ): Promise { - return this._executeTriggerOrder(trigger, grouping); + return this._executeTriggerOrder(trigger, grouping, options); } private async _executeTriggerOrder( trigger: TriggerOrder, - grouping: OrderGrouping = OrderGrouping.NA + grouping: OrderGrouping = OrderGrouping.NA, + options: { vaultAddress?: string; expiresAfter?: number } = {} ): Promise { trigger.validate(); const action = trigger.toAction(grouping); - const result = await this._buildSignSend(action); + const result = await this._buildSignSend(action, undefined, undefined, { + vaultAddress: options.vaultAddress, + expiresAfter: options.expiresAfter, + }); const order = new Order(trigger.asset, trigger.side); order['_size'] = trigger['_size']; @@ -1381,14 +1410,22 @@ export class HyperliquidSDK { /** * Close an open position completely. */ - async closePosition(asset: string, options: { slippage?: number } = {}): Promise { + async closePosition( + asset: string, + options: { slippage?: number; vaultAddress?: string; expiresAfter?: number } = {} + ): Promise { const action = { type: 'closePosition', asset, - user: this.address, + // When closing on behalf of a vault, the position belongs to the vault — + // the worker queries action.user to size the close. + user: options.vaultAddress ?? this.address, }; - const result = await this._buildSignSend(action, options.slippage); + const result = await this._buildSignSend(action, options.slippage, undefined, { + vaultAddress: options.vaultAddress, + expiresAfter: options.expiresAfter, + }); const order = Order.sell(asset); order['_size'] = '0'; @@ -1405,10 +1442,15 @@ export class HyperliquidSDK { /** * Cancel an order by OID. + * + * @param options.fast - Request fast cancellation (wire flag `f: true`; only sent when true) + * @param options.vaultAddress - Cancel on behalf of a vault or subaccount + * @param options.expiresAfter - Action TTL as a ms timestamp */ async cancel( oid: number, - asset?: string | number + asset?: string | number, + options: { fast?: boolean; vaultAddress?: string; expiresAfter?: number } = {} ): Promise> { if (!Number.isInteger(oid) || oid <= 0) { throw new ValidationError(`oid must be a positive integer, got: ${oid}`); @@ -1419,53 +1461,91 @@ export class HyperliquidSDK { assetIdx = typeof asset === 'string' ? await this._resolveAssetIndex(asset) : asset; } - const cancelAction = { + const cancelAction: Record = { type: 'cancel', cancels: [{ a: assetIdx, o: oid }], }; - return this._buildSignSend(cancelAction); + // The exchange only accepts f:true — false is stripped server-side, so + // never emit it. + if (options.fast === true) { + cancelAction.f = true; + } + return this._buildSignSend(cancelAction, undefined, undefined, { + vaultAddress: options.vaultAddress, + expiresAfter: options.expiresAfter, + }); } /** * Cancel all open orders. + * + * @param options.fast - Request fast cancellation (wire flag `f: true`; only sent when true) + * @param options.vaultAddress - Cancel on behalf of a vault or subaccount + * @param options.expiresAfter - Action TTL as a ms timestamp */ - async cancelAll(asset?: string): Promise> { - const orders = await this.openOrders(); + async cancelAll( + asset?: string, + options: { fast?: boolean; vaultAddress?: string; expiresAfter?: number } = {} + ): Promise> { + // When cancelling on behalf of a vault, enumerate the vault's open orders, + // not the wallet's. + const orders = await this.openOrders(options.vaultAddress); if (!orders.orders || (orders.orders as unknown[]).length === 0) { return { message: 'No orders to cancel' }; } + const sendCancel = (action: Record): Promise> => { + if (options.fast === true) { + action = { ...action, f: true }; + } + return this._buildSignSend(action, undefined, undefined, { + vaultAddress: options.vaultAddress, + expiresAfter: options.expiresAfter, + }); + }; + if (asset) { const cancelActions = orders.cancelActions as Record; const byAsset = cancelActions?.byAsset as Record; if (!byAsset?.[asset]) { return { message: `No ${asset} orders to cancel` }; } - return this._buildSignSend(byAsset[asset] as Record); + return sendCancel(byAsset[asset] as Record); } else { const cancelActions = orders.cancelActions as Record; if (!cancelActions?.all) { return { message: 'No orders to cancel' }; } - return this._buildSignSend(cancelActions.all as Record); + return sendCancel(cancelActions.all as Record); } } /** * Cancel an order by client order ID (cloid). + * + * @param options.fast - Request fast cancellation (wire flag `f: true`; only sent when true) + * @param options.vaultAddress - Cancel on behalf of a vault or subaccount + * @param options.expiresAfter - Action TTL as a ms timestamp */ async cancelByCloid( cloid: string, - asset: string | number + asset: string | number, + options: { fast?: boolean; vaultAddress?: string; expiresAfter?: number } = {} ): Promise> { const assetIdx = typeof asset === 'string' ? await this._resolveAssetIndex(asset) : asset; - const cancelAction = { + const cancelAction: Record = { type: 'cancelByCloid', cancels: [{ asset: assetIdx, cloid }], }; - return this._buildSignSend(cancelAction); + if (options.fast === true) { + cancelAction.f = true; + } + return this._buildSignSend(cancelAction, undefined, undefined, { + vaultAddress: options.vaultAddress, + expiresAfter: options.expiresAfter, + }); } /** @@ -1481,6 +1561,9 @@ export class HyperliquidSDK { /** * Modify an existing order. + * + * @param options.vaultAddress - Modify on behalf of a vault or subaccount + * @param options.expiresAfter - Action TTL as a ms timestamp */ async modify( oid: number, @@ -1488,7 +1571,7 @@ export class HyperliquidSDK { side: Side | string, price: string, size: string, - options: { tif?: TIF | string; reduceOnly?: boolean } = {} + options: { tif?: TIF | string; reduceOnly?: boolean; vaultAddress?: string; expiresAfter?: number } = {} ): Promise { if (!Number.isInteger(oid) || oid <= 0) { throw new ValidationError(`oid must be a positive integer, got: ${oid}`); @@ -1525,7 +1608,10 @@ export class HyperliquidSDK { }], }; - const result = await this._buildSignSend(modifyAction); + const result = await this._buildSignSend(modifyAction, undefined, undefined, { + vaultAddress: options.vaultAddress, + expiresAfter: options.expiresAfter, + }); const order = new Order(asset, sideStr === 'buy' ? Side.BUY : Side.SELL); order['_price'] = price; @@ -1948,6 +2034,8 @@ export class HyperliquidSDK { grouping: OrderGrouping; slippage?: number; priorityFee?: number | string; + vaultAddress?: string; + expiresAfter?: number; }): Promise { const asset = assetToString(params.asset); const order = new Order(asset, params.side); @@ -1990,17 +2078,28 @@ export class HyperliquidSDK { order['_reduceOnly'] = true; } - return this._executeOrder(order, params.grouping, params.slippage, params.priorityFee); + return this._executeOrder( + order, + params.grouping, + params.slippage, + params.priorityFee, + params.vaultAddress, + params.expiresAfter + ); } private async _executeOrder( order: Order, grouping: OrderGrouping = OrderGrouping.NA, slippage?: number, - priorityFee?: number | string + priorityFee?: number | string, + vaultAddress?: string, + expiresAfter?: number ): Promise { const action = order.toAction(); const effectivePriorityFee = priorityFee ?? order.getPriorityFee(); + const effectiveVaultAddress = vaultAddress ?? order.getVaultAddress() ?? undefined; + const effectiveExpiresAfter = expiresAfter ?? order.getExpiresAfter() ?? undefined; if (grouping !== OrderGrouping.NA && effectivePriorityFee !== null) { throw new ValidationError('priorityFee cannot be combined with TP/SL grouping', { guidance: 'Use priorityFee on standalone IOC/market orders, or omit grouping.', @@ -2017,7 +2116,10 @@ export class HyperliquidSDK { if (grouping !== OrderGrouping.NA) { action.grouping = grouping; // enum value is already the string (e.g., 'na', 'normalTpsl') } - const result = await this._buildSignSend(action, slippage, effectivePriorityFee ?? undefined); + const result = await this._buildSignSend(action, slippage, effectivePriorityFee ?? undefined, { + vaultAddress: effectiveVaultAddress, + expiresAfter: effectiveExpiresAfter, + }); return PlacedOrder.fromResponse( (result as Record).exchangeResponse as Record ?? {}, order, @@ -2037,7 +2139,8 @@ export class HyperliquidSDK { private async _buildSignSend( action: Record, slippage?: number, - priorityFee?: number | string + priorityFee?: number | string, + options: { vaultAddress?: string; expiresAfter?: number } = {} ): Promise> { this._requireWallet(); @@ -2062,6 +2165,17 @@ export class HyperliquidSDK { if (normalizedPriorityFee !== undefined) { buildPayload.priorityFee = normalizedPriorityFee; } + // vaultAddress and expiresAfter are request-level fields that the worker + // folds into the action hash at build time, so they must be present in + // BOTH the build payload (hash) and the send payload (signer recovery + + // exchange body). Omitted entirely when unset for wire compatibility. + const normalizedExpiresAfter = this._normalizeExpiresAfter(options.expiresAfter); + if (options.vaultAddress !== undefined) { + buildPayload.vaultAddress = options.vaultAddress; + } + if (normalizedExpiresAfter !== undefined) { + buildPayload.expiresAfter = normalizedExpiresAfter; + } const buildResult = await this._exchange(buildPayload); if (!buildResult.hash) { @@ -2102,11 +2216,17 @@ export class HyperliquidSDK { } // Step 3: Send - const sendPayload = { + const sendPayload: Record = { action: buildResult.action ?? action, nonce: buildResult.nonce, signature: sig, }; + if (options.vaultAddress !== undefined) { + sendPayload.vaultAddress = options.vaultAddress; + } + if (normalizedExpiresAfter !== undefined) { + sendPayload.expiresAfter = normalizedExpiresAfter; + } return this._exchange(sendPayload); } @@ -2225,6 +2345,18 @@ export class HyperliquidSDK { return value; } + private _normalizeExpiresAfter(expiresAfter?: number): number | undefined { + if (expiresAfter === undefined || expiresAfter === null) { + return undefined; + } + if (!Number.isInteger(expiresAfter) || expiresAfter <= 0) { + throw new ValidationError('expiresAfter must be a positive integer (ms timestamp)', { + guidance: 'Use expiresAfter: Date.now() + 60_000 for a 1-minute TTL.', + }); + } + return expiresAfter; + } + private _signHash(hashHex: string): Signature { const hashBytes = Buffer.from(hashHex.replace(/^0x/, ''), 'hex'); const sig = this._wallet!.signingKey.sign(hashBytes); diff --git a/typescript/src/grpc-stream.ts b/typescript/src/grpc-stream.ts index e4418f7..cf8c8d4 100644 --- a/typescript/src/grpc-stream.ts +++ b/typescript/src/grpc-stream.ts @@ -24,6 +24,9 @@ export enum GRPCStreamType { EVENTS = 'EVENTS', BLOCKS = 'BLOCKS', WRITER_ACTIONS = 'WRITER_ACTIONS', + MEMPOOL_TXS = 'MEMPOOL_TXS', + ORDER_PRIORITY = 'ORDER_PRIORITY', + GOSSIP_PRIORITY = 'GOSSIP_PRIORITY', } export enum ConnectionState { @@ -46,6 +49,9 @@ export interface GRPCStreamOptions { type Callback = (data: Record) => void; +/** Callback for the StreamDataBytes fast path: payload bytes are NOT JSON-decoded. */ +type BytesCallback = (data: { block_number: number; timestamp: number; data: Uint8Array }) => void; + interface Subscription { streamType: string; callback: Callback; @@ -54,7 +60,15 @@ interface Subscription { coin?: string; nSigFigs?: number; nLevels?: number; + mantissa?: number; + skipInitialSnapshot?: boolean; + startBlock?: number; raw?: boolean; + bytes?: boolean; + /** Internal: highest block_number seen on this subscription (reconnect resume cursor). */ + lastSeenBlock?: number; + /** Internal: whether this subscription's stream has been started at least once. */ + connectedOnce?: boolean; } // Stream type enum values matching proto @@ -66,6 +80,9 @@ const STREAM_TYPE_MAP: Record = { EVENTS: 5, BLOCKS: 6, WRITER_ACTIONS: 7, + MEMPOOL_TXS: 8, + ORDER_PRIORITY: 9, + GOSSIP_PRIORITY: 10, }; /** @@ -85,8 +102,17 @@ const STREAM_TYPE_MAP: Record = { * - twap: Time-weighted average price execution * - events: System events (funding, liquidations) * - blocks: Block data + * - mempool_txs: Pre-consensus mempool transactions + * - order_priority: Derived order/write priority actions + * - gossip_priority: Derived gossip/read priority bid actions * - l2_book: Level 2 order book (aggregated price levels) * - l4_book: Level 4 order book (individual orders) + * - bbo_book: Top-of-book (best bid/offer) changes + * - l2_book_diff: Incremental L2 price-level changes + * - l4_book_updates: Typed L4 order-level changes + * - tpsl_updates: Trigger/TP-SL order updates + * - l2_book_packed / bbo_book_packed: Fixed-point fast paths (px/sz scaled by 1e8) + * - l4_book_bytes: L4 fast path with JSON-bytes diffs */ export class GRPCStream { static readonly GRPC_PORT = 10000; @@ -200,7 +226,11 @@ export class GRPCStream { coin?: string; nSigFigs?: number; nLevels?: number; + mantissa?: number; + skipInitialSnapshot?: boolean; + startBlock?: number; raw?: boolean; + bytes?: boolean; } = {} ): void { this._subscriptions.push({ @@ -211,87 +241,91 @@ export class GRPCStream { coin: options.coin, nSigFigs: options.nSigFigs, nLevels: options.nLevels ?? 20, + mantissa: options.mantissa, + skipInitialSnapshot: options.skipInitialSnapshot, + startBlock: options.startBlock, raw: options.raw ?? false, + bytes: options.bytes ?? false, }); } /** * Subscribe to trade stream. */ - trades(coins: string[], callback: Callback): GRPCStream { - this._addSubscription(GRPCStreamType.TRADES, callback, { coins }); + trades(coins: string[], callback: Callback, options: { startBlock?: number } = {}): GRPCStream { + this._addSubscription(GRPCStreamType.TRADES, callback, { coins, startBlock: options.startBlock }); return this; } /** * Subscribe to raw trade blocks. */ - rawTrades(coins: string[], callback: Callback): GRPCStream { - this._addSubscription(GRPCStreamType.TRADES, callback, { coins, raw: true }); + rawTrades(coins: string[], callback: Callback, options: { startBlock?: number } = {}): GRPCStream { + this._addSubscription(GRPCStreamType.TRADES, callback, { coins, startBlock: options.startBlock, raw: true }); return this; } /** * Subscribe to order stream. */ - orders(coins: string[], callback: Callback, options: { users?: string[] } = {}): GRPCStream { - this._addSubscription(GRPCStreamType.ORDERS, callback, { coins, users: options.users }); + orders(coins: string[], callback: Callback, options: { users?: string[]; startBlock?: number } = {}): GRPCStream { + this._addSubscription(GRPCStreamType.ORDERS, callback, { coins, users: options.users, startBlock: options.startBlock }); return this; } /** * Subscribe to raw order blocks. */ - rawOrders(coins: string[], callback: Callback, options: { users?: string[] } = {}): GRPCStream { - this._addSubscription(GRPCStreamType.ORDERS, callback, { coins, users: options.users, raw: true }); + rawOrders(coins: string[], callback: Callback, options: { users?: string[]; startBlock?: number } = {}): GRPCStream { + this._addSubscription(GRPCStreamType.ORDERS, callback, { coins, users: options.users, startBlock: options.startBlock, raw: true }); return this; } /** * Subscribe to order book updates. */ - bookUpdates(coins: string[], callback: Callback): GRPCStream { - this._addSubscription(GRPCStreamType.BOOK_UPDATES, callback, { coins }); + bookUpdates(coins: string[], callback: Callback, options: { startBlock?: number } = {}): GRPCStream { + this._addSubscription(GRPCStreamType.BOOK_UPDATES, callback, { coins, startBlock: options.startBlock }); return this; } /** * Subscribe to raw order book update blocks. */ - rawBookUpdates(coins: string[], callback: Callback): GRPCStream { - this._addSubscription(GRPCStreamType.BOOK_UPDATES, callback, { coins, raw: true }); + rawBookUpdates(coins: string[], callback: Callback, options: { startBlock?: number } = {}): GRPCStream { + this._addSubscription(GRPCStreamType.BOOK_UPDATES, callback, { coins, startBlock: options.startBlock, raw: true }); return this; } /** * Subscribe to TWAP execution stream. */ - twap(coins: string[], callback: Callback): GRPCStream { - this._addSubscription(GRPCStreamType.TWAP, callback, { coins }); + twap(coins: string[], callback: Callback, options: { startBlock?: number } = {}): GRPCStream { + this._addSubscription(GRPCStreamType.TWAP, callback, { coins, startBlock: options.startBlock }); return this; } /** * Subscribe to raw TWAP execution blocks. */ - rawTwap(coins: string[], callback: Callback): GRPCStream { - this._addSubscription(GRPCStreamType.TWAP, callback, { coins, raw: true }); + rawTwap(coins: string[], callback: Callback, options: { startBlock?: number } = {}): GRPCStream { + this._addSubscription(GRPCStreamType.TWAP, callback, { coins, startBlock: options.startBlock, raw: true }); return this; } /** * Subscribe to system events (funding, liquidations, governance). */ - events(callback: Callback): GRPCStream { - this._addSubscription(GRPCStreamType.EVENTS, callback); + events(callback: Callback, options: { startBlock?: number } = {}): GRPCStream { + this._addSubscription(GRPCStreamType.EVENTS, callback, { startBlock: options.startBlock }); return this; } /** * Subscribe to raw system event blocks. */ - rawEvents(callback: Callback): GRPCStream { - this._addSubscription(GRPCStreamType.EVENTS, callback, { raw: true }); + rawEvents(callback: Callback, options: { startBlock?: number } = {}): GRPCStream { + this._addSubscription(GRPCStreamType.EVENTS, callback, { startBlock: options.startBlock, raw: true }); return this; } @@ -306,16 +340,109 @@ export class GRPCStream { /** * Subscribe to writer actions (HyperCore <-> HyperEVM asset transfers). */ - writerActions(callback: Callback): GRPCStream { - this._addSubscription(GRPCStreamType.WRITER_ACTIONS, callback); + writerActions(callback: Callback, options: { startBlock?: number } = {}): GRPCStream { + this._addSubscription(GRPCStreamType.WRITER_ACTIONS, callback, { startBlock: options.startBlock }); return this; } /** * Subscribe to raw writer action blocks. */ - rawWriterActions(callback: Callback): GRPCStream { - this._addSubscription(GRPCStreamType.WRITER_ACTIONS, callback, { raw: true }); + rawWriterActions(callback: Callback, options: { startBlock?: number } = {}): GRPCStream { + this._addSubscription(GRPCStreamType.WRITER_ACTIONS, callback, { startBlock: options.startBlock, raw: true }); + return this; + } + + /** + * Subscribe to pre-consensus mempool transactions. + * Optional coin filter (server applies OR across values); omit coins for all. + */ + mempoolTxs(callback: Callback): GRPCStream; + mempoolTxs(coins: string[], callback: Callback, options?: { startBlock?: number }): GRPCStream; + mempoolTxs( + coinsOrCallback: string[] | Callback, + callback?: Callback, + options: { startBlock?: number } = {} + ): GRPCStream { + const coins = Array.isArray(coinsOrCallback) ? coinsOrCallback : undefined; + const cb = Array.isArray(coinsOrCallback) ? (callback as Callback) : coinsOrCallback; + this._addSubscription(GRPCStreamType.MEMPOOL_TXS, cb, { coins, startBlock: options.startBlock }); + return this; + } + + /** + * Subscribe to raw pre-consensus mempool transaction blocks. + */ + rawMempoolTxs(callback: Callback): GRPCStream; + rawMempoolTxs(coins: string[], callback: Callback, options?: { startBlock?: number }): GRPCStream; + rawMempoolTxs( + coinsOrCallback: string[] | Callback, + callback?: Callback, + options: { startBlock?: number } = {} + ): GRPCStream { + const coins = Array.isArray(coinsOrCallback) ? coinsOrCallback : undefined; + const cb = Array.isArray(coinsOrCallback) ? (callback as Callback) : coinsOrCallback; + this._addSubscription(GRPCStreamType.MEMPOOL_TXS, cb, { coins, startBlock: options.startBlock, raw: true }); + return this; + } + + /** + * Subscribe to derived order/write priority actions (from mempool and confirmed replica data). + * Events carry server-enriched fields: coin, market_type, sz_decimals. + */ + orderPriority(callback: Callback, options: { startBlock?: number } = {}): GRPCStream { + this._addSubscription(GRPCStreamType.ORDER_PRIORITY, callback, { startBlock: options.startBlock }); + return this; + } + + /** + * Subscribe to raw order/write priority action blocks. + */ + rawOrderPriority(callback: Callback, options: { startBlock?: number } = {}): GRPCStream { + this._addSubscription(GRPCStreamType.ORDER_PRIORITY, callback, { startBlock: options.startBlock, raw: true }); + return this; + } + + /** + * Subscribe to derived gossip/read priority bid actions (does not measure delivery latency). + * Events carry server-enriched fields: coin, market_type, sz_decimals. + */ + gossipPriority(callback: Callback, options: { startBlock?: number } = {}): GRPCStream { + this._addSubscription(GRPCStreamType.GOSSIP_PRIORITY, callback, { startBlock: options.startBlock }); + return this; + } + + /** + * Subscribe to raw gossip/read priority bid action blocks. + */ + rawGossipPriority(callback: Callback, options: { startBlock?: number } = {}): GRPCStream { + this._addSubscription(GRPCStreamType.GOSSIP_PRIORITY, callback, { startBlock: options.startBlock, raw: true }); + return this; + } + + /** + * Subscribe to the raw-bytes fast path of the generic data stream (StreamDataBytes). + * The callback receives { block_number, timestamp, data } where data is the + * undecoded payload bytes — no JSON parsing is performed. + */ + streamDataBytes( + streamType: GRPCStreamType, + callback: BytesCallback, + options: { coins?: string[]; users?: string[]; startBlock?: number } = {} + ): GRPCStream { + // Fail fast for plain-JS callers: an unknown type would otherwise map to + // proto enum 0 (UNKNOWN) and be sent to the server silently. + if (!(streamType in STREAM_TYPE_MAP)) { + throw new Error( + `Unknown stream type "${streamType}" for streamDataBytes; valid types: ${Object.keys(STREAM_TYPE_MAP).join(', ')}` + ); + } + this._addSubscription(streamType, callback as unknown as Callback, { + coins: options.coins, + users: options.users, + startBlock: options.startBlock, + bytes: true, + }); return this; } @@ -337,12 +464,124 @@ export class GRPCStream { /** * Subscribe to Level 4 order book updates (individual orders). + * + * Note: the server may send an unsolicited full snapshot at any time after + * subscribe; discard local book state and replace it with any snapshot + * received mid-stream. */ l4Book(coin: string, callback: Callback): GRPCStream { this._addSubscription('L4_BOOK', callback, { coin }); return this; } + /** + * Subscribe to best bid/offer updates. Omitted/empty coins = all coins. + * Emits only when the best bid or ask changes for a coin. + */ + bboBook(callback: Callback): GRPCStream; + bboBook(coins: string[], callback: Callback): GRPCStream; + bboBook(coinsOrCallback: string[] | Callback, callback?: Callback): GRPCStream { + const coins = Array.isArray(coinsOrCallback) ? coinsOrCallback : undefined; + const cb = Array.isArray(coinsOrCallback) ? (callback as Callback) : coinsOrCallback; + this._addSubscription('BBO_BOOK', cb, { coins }); + return this; + } + + /** + * Subscribe to incremental L2 price-level changes. Omitted/empty coins = all coins. + * Changed levels with sz=0 mean the level was removed. + */ + l2BookDiff( + callback: Callback, + options: { + coins?: string[]; + nSigFigs?: number; + nLevels?: number; + mantissa?: number; + skipInitialSnapshot?: boolean; + } = {} + ): GRPCStream { + this._addSubscription('L2_BOOK_DIFF', callback, { + coins: options.coins, + nSigFigs: options.nSigFigs, + nLevels: options.nLevels ?? 20, + mantissa: options.mantissa, + skipInitialSnapshot: options.skipInitialSnapshot, + }); + return this; + } + + /** + * Subscribe to typed L4 order book updates. Omitted/empty coins = all coins. + * + * Note: updates with snapshot=true carry a full reset snapshot; discard + * local book state and replace it whenever one arrives mid-stream. + */ + l4BookUpdates(callback: Callback): GRPCStream; + l4BookUpdates(coins: string[], callback: Callback): GRPCStream; + l4BookUpdates(coinsOrCallback: string[] | Callback, callback?: Callback): GRPCStream { + const coins = Array.isArray(coinsOrCallback) ? coinsOrCallback : undefined; + const cb = Array.isArray(coinsOrCallback) ? (callback as Callback) : coinsOrCallback; + this._addSubscription('L4_BOOK_UPDATES', cb, { coins }); + return this; + } + + /** + * Subscribe to trigger/TP-SL order updates. Omitted/empty coins = all perp coins. + */ + tpslUpdates(callback: Callback): GRPCStream; + tpslUpdates(coins: string[], callback: Callback): GRPCStream; + tpslUpdates(coinsOrCallback: string[] | Callback, callback?: Callback): GRPCStream { + const coins = Array.isArray(coinsOrCallback) ? coinsOrCallback : undefined; + const cb = Array.isArray(coinsOrCallback) ? (callback as Callback) : coinsOrCallback; + this._addSubscription('TPSL_UPDATES', cb, { coins }); + return this; + } + + /** + * Subscribe to the fixed-point L2 book fast path. + * Prices/sizes are u64 fixed-point integers scaled by 1e8. + */ + l2BookPacked( + coin: string, + callback: Callback, + options: { nSigFigs?: number; nLevels?: number; mantissa?: number } = {} + ): GRPCStream { + this._addSubscription('L2_BOOK_PACKED', callback, { + coin, + nSigFigs: options.nSigFigs, + nLevels: options.nLevels ?? 20, + mantissa: options.mantissa, + }); + return this; + } + + /** + * Subscribe to the fixed-point BBO fast path. Omitted/empty coins = all coins. + * Prices/sizes are u64 fixed-point integers scaled by 1e8. + */ + bboBookPacked(callback: Callback): GRPCStream; + bboBookPacked(coins: string[], callback: Callback): GRPCStream; + bboBookPacked(coinsOrCallback: string[] | Callback, callback?: Callback): GRPCStream { + const coins = Array.isArray(coinsOrCallback) ? coinsOrCallback : undefined; + const cb = Array.isArray(coinsOrCallback) ? (callback as Callback) : coinsOrCallback; + this._addSubscription('BBO_BOOK_PACKED', cb, { coins }); + return this; + } + + /** + * Subscribe to the L4 book fast path: diffs are delivered as undecoded JSON + * bytes ({order_statuses, book_diffs}) instead of a protobuf string. + * + * Note: the server may send an unsolicited full snapshot at any time after + * subscribe; discard local book state and replace it with any snapshot + * received mid-stream. + */ + l4BookBytes(coin: string, callback: Callback): GRPCStream { + this._addSubscription('L4_BOOK_BYTES', callback, { coin }); + return this; + } + /** * Connect and create gRPC clients. */ @@ -415,6 +654,47 @@ export class GRPCStream { } } + /** + * Build the initial StreamSubscribe request for a data subscription. + */ + private _buildSubscribeRequest(sub: Subscription): { + subscribe: { stream_type: number; filters: Record; start_block?: number }; + } { + const subscribe: { stream_type: number; filters: Record; start_block?: number } = { + stream_type: STREAM_TYPE_MAP[sub.streamType] || 0, + filters: {}, + }; + + if (sub.startBlock !== undefined) { + // On reconnect, resume after the last delivered block instead of + // replaying the original startBlock. First connect sends it verbatim. + subscribe.start_block = + sub.connectedOnce && sub.lastSeenBlock !== undefined + ? Math.max(sub.startBlock, sub.lastSeenBlock + 1) + : sub.startBlock; + } + if (sub.coins && sub.coins.length > 0) { + subscribe.filters['coin'] = { values: sub.coins }; + } + if (sub.users && sub.users.length > 0) { + subscribe.filters['user'] = { values: sub.users }; + } + + return { subscribe }; + } + + /** + * Track the highest block_number seen on a subscription so reconnects can + * resume after it. Handles int64 fields decoded as strings (longs: String). + */ + private _trackLastSeenBlock(sub: Subscription, blockNumber: unknown): void { + const block = typeof blockNumber === 'number' ? blockNumber : Number(blockNumber); + if (!Number.isFinite(block)) return; + if (sub.lastSeenBlock === undefined || block > sub.lastSeenBlock) { + sub.lastSeenBlock = block; + } + } + /** * Start streaming for a data subscription (trades, orders, etc.). */ @@ -428,21 +708,8 @@ export class GRPCStream { this._activeStreams.push(stream); // Send initial subscription request - const subscribeRequest = { - subscribe: { - stream_type: STREAM_TYPE_MAP[sub.streamType] || 0, - filters: {} as Record, - }, - }; - - if (sub.coins && sub.coins.length > 0) { - subscribeRequest.subscribe.filters['coin'] = { values: sub.coins }; - } - if (sub.users && sub.users.length > 0) { - subscribeRequest.subscribe.filters['user'] = { values: sub.users }; - } - - stream.write(subscribeRequest); + stream.write(this._buildSubscribeRequest(sub)); + sub.connectedOnce = true; // Set up ping interval const pingInterval = setInterval(() => { @@ -461,6 +728,11 @@ export class GRPCStream { if (response.data) { try { const parsed = JSON.parse(response.data.data); + // Advance the resume cursor only after the payload parsed, so a + // corrupt block is re-requested on reconnect instead of skipped. + if (sub.startBlock !== undefined) { + this._trackLastSeenBlock(sub, response.data.block_number); + } const blockNumber = response.data.block_number; const timestamp = response.data.timestamp; @@ -557,6 +829,75 @@ export class GRPCStream { }); } + /** + * Start streaming raw bytes for a data subscription (StreamDataBytes fast path). + * Payload bytes are passed through to the callback without JSON decoding. + */ + private _streamDataBytes(sub: Subscription): void { + if (!this._streamingClient || this._stopRequested) return; + + const metadata = this._getMetadata(); + + // Create bidirectional stream + const stream = this._streamingClient.StreamDataBytes(metadata); + this._activeStreams.push(stream); + + // Send initial subscription request + stream.write(this._buildSubscribeRequest(sub)); + sub.connectedOnce = true; + + // Set up ping interval + const pingInterval = setInterval(() => { + if (this._running && !this._stopRequested) { + try { + stream.write({ ping: { timestamp: Date.now() } }); + } catch { + // Stream might be closed + } + } + }, 30000); + this._pingIntervals.push(pingInterval); + + // Handle incoming data + stream.on('data', (response: { data?: { block_number: number; timestamp: number; data: Uint8Array }; pong?: { timestamp: number } }) => { + if (response.data) { + try { + sub.callback(response.data as unknown as Record); + } catch { + // Ignore callback errors + } + // Cursor advances only after delivery so reconnects never skip an + // undelivered block. + if (sub.startBlock !== undefined) { + this._trackLastSeenBlock(sub, response.data.block_number); + } + } + }); + + stream.on('error', (err: Error) => { + clearInterval(pingInterval); + if (this._running && !this._stopRequested) { + if (this._onError) { + try { + this._onError(err); + } catch { + // Ignore + } + } + if (this._reconnectEnabled) { + this._scheduleReconnect(); + } + } + }); + + stream.on('end', () => { + clearInterval(pingInterval); + if (this._running && !this._stopRequested && this._reconnectEnabled) { + this._scheduleReconnect(); + } + }); + } + /** * Start streaming blocks. */ @@ -725,6 +1066,86 @@ export class GRPCStream { }); } + /** + * Start a server-streaming order book RPC (BBO, diffs, packed, bytes, TP/SL). + * Updates are forwarded to the callback as decoded, unless a transform is given. + */ + private _streamOrderbookRpc( + sub: Subscription, + rpcName: string, + request: Record, + transform?: (update: Record) => Record | null + ): void { + if (!this._orderbookClient || this._stopRequested) return; + + const metadata = this._getMetadata(); + const stream = this._orderbookClient[rpcName](request, metadata); + this._activeStreams.push(stream); + sub.connectedOnce = true; + + stream.on('data', (update: Record) => { + const data = transform ? transform(update) : update; + if (data === null) return; + try { + sub.callback(data); + } catch { + // Ignore callback errors + } + }); + + stream.on('error', (err: Error) => { + if (this._running && !this._stopRequested) { + if (this._onError) { + try { + this._onError(err); + } catch { + // Ignore + } + } + if (this._reconnectEnabled) { + this._scheduleReconnect(); + } + } + }); + + stream.on('end', () => { + if (this._running && !this._stopRequested && this._reconnectEnabled) { + this._scheduleReconnect(); + } + }); + } + + /** + * Map an L4BookBytesUpdate oneof to the snapshot/diff shape used by l4Book, + * keeping the diff payload as undecoded JSON bytes. + */ + private _l4BytesToObject(update: Record): Record | null { + const u = update as { + snapshot?: { coin: string; time: number; height: number; bids: L4Order[]; asks: L4Order[] }; + diff?: { time: number; height: number; data: Uint8Array }; + }; + + if (u.snapshot) { + return { + type: 'snapshot', + coin: u.snapshot.coin, + time: u.snapshot.time, + height: u.snapshot.height, + bids: u.snapshot.bids.map(this._l4OrderToObject), + asks: u.snapshot.asks.map(this._l4OrderToObject), + }; + } + if (u.diff) { + return { + type: 'diff', + time: u.diff.time, + height: u.diff.height, + data: u.diff.data, // JSON bytes, not decoded + }; + } + return null; + } + private _l4OrderToObject(order: L4Order): Record { return { user: order.user, @@ -797,6 +1218,10 @@ export class GRPCStream { private _startStreams(): void { for (const sub of this._subscriptions) { + if (sub.bytes) { + this._streamDataBytes(sub); + continue; + } switch (sub.streamType) { case 'L2_BOOK': this._streamL2Book(sub); @@ -807,6 +1232,52 @@ export class GRPCStream { case 'BLOCKS': this._streamBlocks(sub); break; + case 'BBO_BOOK': + this._streamOrderbookRpc(sub, 'StreamBboBook', { coins: sub.coins ?? [] }); + break; + case 'L2_BOOK_DIFF': { + const request: Record = { + coins: sub.coins ?? [], + n_levels: sub.nLevels || 20, + // Honor the user's skipInitialSnapshot only on the first connect; + // on reconnect always request the snapshot so the book can resync. + skip_initial_snapshot: sub.connectedOnce ? false : sub.skipInitialSnapshot ?? false, + }; + if (sub.nSigFigs !== undefined) { + request.n_sig_figs = sub.nSigFigs; + } + if (sub.mantissa !== undefined) { + request.mantissa = sub.mantissa; + } + this._streamOrderbookRpc(sub, 'StreamL2BookDiff', request); + break; + } + case 'L4_BOOK_UPDATES': + this._streamOrderbookRpc(sub, 'StreamL4BookUpdates', { coins: sub.coins ?? [] }); + break; + case 'TPSL_UPDATES': + this._streamOrderbookRpc(sub, 'StreamTpslUpdates', { coins: sub.coins ?? [] }); + break; + case 'L2_BOOK_PACKED': { + const request: Record = { + coin: sub.coin || '', + n_levels: sub.nLevels || 20, + }; + if (sub.nSigFigs !== undefined) { + request.n_sig_figs = sub.nSigFigs; + } + if (sub.mantissa !== undefined) { + request.mantissa = sub.mantissa; + } + this._streamOrderbookRpc(sub, 'StreamL2BookPacked', request); + break; + } + case 'BBO_BOOK_PACKED': + this._streamOrderbookRpc(sub, 'StreamBboBookPacked', { coins: sub.coins ?? [] }); + break; + case 'L4_BOOK_BYTES': + this._streamOrderbookRpc(sub, 'StreamL4BookBytes', { coin: sub.coin || '' }, (u) => this._l4BytesToObject(u)); + break; default: this._streamData(sub); break; diff --git a/typescript/src/order.ts b/typescript/src/order.ts index c7e36f0..fa89274 100644 --- a/typescript/src/order.ts +++ b/typescript/src/order.ts @@ -54,6 +54,8 @@ export class Order { private _notional: number | null = null; private _cloid: string | null = null; private _priorityFee: number | string | null = null; + private _vaultAddress: string | null = null; + private _expiresAfter: number | null = null; /** @internal */ constructor(asset: AssetInput, side: Side) { @@ -179,6 +181,28 @@ export class Order { return this; } + /** + * Trade on behalf of a vault or subaccount. + * + * Sent as the request-level vaultAddress field and folded into the + * signed action hash. + */ + vaultAddress(address: string): Order { + this._vaultAddress = address; + return this; + } + + /** + * Set an action TTL as a ms timestamp. + * + * The exchange rejects the action after this time. Sent as the + * request-level expiresAfter field and folded into the signed action hash. + */ + expiresAfter(timestampMs: number): Order { + this._expiresAfter = timestampMs; + return this; + } + // ═══════════════ GETTERS ═══════════════ getSize(): string | null { @@ -209,6 +233,14 @@ export class Order { return this._priorityFee; } + getVaultAddress(): string | null { + return this._vaultAddress; + } + + getExpiresAfter(): number | null { + return this._expiresAfter; + } + isMarket(): boolean { return this._tif === TIF.MARKET; } @@ -300,6 +332,14 @@ export class Order { } } + if (this._expiresAfter !== null) { + if (!Number.isInteger(this._expiresAfter) || this._expiresAfter <= 0) { + throw new ValidationError('expiresAfter must be a positive integer (ms timestamp)', { + guidance: 'Use .expiresAfter(Date.now() + 60_000) for a 1-minute TTL.', + }); + } + } + // Validate price is positive for limit orders if (this._price !== null) { const priceVal = parseFloat(this._price); @@ -326,6 +366,8 @@ export class Order { if (this._tif !== TIF.IOC) parts.push(`.${this._tif.toLowerCase()}()`); if (this._reduceOnly) parts.push('.reduceOnly()'); if (this._priorityFee !== null) parts.push(`.priorityFee(${this._priorityFee})`); + if (this._vaultAddress !== null) parts.push(`.vaultAddress('${this._vaultAddress}')`); + if (this._expiresAfter !== null) parts.push(`.expiresAfter(${this._expiresAfter})`); return parts.join(''); } } diff --git a/typescript/src/proto/orderbook.proto b/typescript/src/proto/orderbook.proto index 93373ed..f0c1f78 100644 --- a/typescript/src/proto/orderbook.proto +++ b/typescript/src/proto/orderbook.proto @@ -8,6 +8,8 @@ syntax = "proto3"; package hyperliquid; +option go_package = "github.com/quiknode-labs/hyperliquid-sdk/go/hyperliquid/proto"; + // Service for streaming real-time order book data. // L2 provides aggregated price levels (px, total_sz, order_count). // L4 provides individual order details (user, trigger info, timestamps). @@ -19,42 +21,127 @@ service OrderBookStreaming { // Subscribe to L4 full order book for a coin. // Sends an initial L4 snapshot, then incremental diffs per block. rpc StreamL4Book (L4BookRequest) returns (stream L4BookUpdate); + + // Subscribe to top-of-book changes. Empty coins means all coins. + // Emits only when the best bid or ask changes for a coin. + rpc StreamBboBook (BboBookRequest) returns (stream BboBookUpdate); + + // Subscribe to incremental L2 price-level changes. Empty coins means all coins. + rpc StreamL2BookDiff (L2BookDiffRequest) returns (stream L2BookDiffUpdate); + + // Subscribe to typed L4 order book updates. Empty coins means all coins. + rpc StreamL4BookUpdates (L4BookUpdatesRequest) returns (stream L4BookUpdatesUpdate); + + // Subscribe to trigger/TP-SL order updates. Empty coins means all perp coins. + rpc StreamTpslUpdates (TpslUpdatesRequest) returns (stream TpslUpdatesUpdate); + + // Fast-path L2 stream. Prices/sizes are fixed-point integers scaled by 1e8. + rpc StreamL2BookPacked (L2BookRequest) returns (stream L2BookPackedUpdate); + + // Fast-path BBO stream. Prices/sizes are fixed-point integers scaled by 1e8. + rpc StreamBboBookPacked (BboBookRequest) returns (stream BboBookPackedUpdate); + + // Fast-path L4 stream. Diff payload is JSON bytes instead of a protobuf string. + rpc StreamL4BookBytes (L4BookRequest) returns (stream L4BookBytesUpdate); } // Request parameters for L2 book streaming. message L2BookRequest { - string coin = 1; - uint32 n_levels = 2; - optional uint32 n_sig_figs = 3; - optional uint64 mantissa = 4; + string coin = 1; // Symbol (e.g. "BTC", "ETH") + uint32 n_levels = 2; // Max number of price levels (default 20, max 100) + optional uint32 n_sig_figs = 3; // Significance figures for price bucketing (2-5) + optional uint64 mantissa = 4; // Mantissa for bucketing (1, 2, or 5) } // An L2 book update: full snapshot of aggregated price levels at a point in time. message L2BookUpdate { string coin = 1; - uint64 time = 2; + uint64 time = 2; // Block timestamp (milliseconds) uint64 block_number = 3; - repeated L2Level bids = 4; - repeated L2Level asks = 5; + repeated L2Level bids = 4; // Aggregated bid levels (best first) + repeated L2Level asks = 5; // Aggregated ask levels (best first) } // A single aggregated price level. message L2Level { - string px = 1; - string sz = 2; - uint32 n = 3; + string px = 1; // Price as decimal string + string sz = 2; // Total size as decimal string + uint32 n = 3; // Number of individual orders at this level +} + +message L2LevelPacked { + fixed64 px = 1; // Price scaled by 1e8 + fixed64 sz = 2; // Size scaled by 1e8 + uint32 n = 3; // Number of individual orders at this level +} + +message L2BookPackedUpdate { + string coin = 1; + uint64 time = 2; + uint64 block_number = 3; + repeated L2LevelPacked bids = 4; + repeated L2LevelPacked asks = 5; +} + +// Request parameters for best bid/offer streaming. +message BboBookRequest { + repeated string coins = 1; // Empty means all coins +} + +// Best bid/offer update for a coin. +message BboBookUpdate { + string coin = 1; + uint64 time = 2; // Block timestamp (milliseconds) + uint64 block_number = 3; + L2Level bid = 4; // Absent if no bid + L2Level ask = 5; // Absent if no ask +} + +message BboBookPackedUpdate { + string coin = 1; + uint64 time = 2; + uint64 block_number = 3; + L2LevelPacked bid = 4; + L2LevelPacked ask = 5; +} + +// Request parameters for L2 diff streaming. +message L2BookDiffRequest { + repeated string coins = 1; // Empty means all coins + uint32 n_levels = 2; // Max tracked levels per side (default 20, max 100) + optional uint32 n_sig_figs = 3; // Significance figures for price bucketing (2-5) + optional uint64 mantissa = 4; // Mantissa for bucketing (1, 2, or 5) + bool skip_initial_snapshot = 5; // If false, first update per coin contains current levels +} + +// Batch of L2 price-level changes for one processed block. +message L2BookDiffUpdate { + uint64 time = 1; // Block timestamp (milliseconds) + uint64 height = 2; // Block height + bool snapshot = 3; // True when carrying initial levels for any coin + repeated L2CoinDiff diffs = 4; // Only coins with changed levels +} + +// Incremental L2 changes for one coin. +message L2CoinDiff { + string coin = 1; + uint64 seq = 2; // Per-coin sequence in this stream + uint64 prev_seq = 3; + repeated L2Level bids = 4; // Changed bid levels; sz=0 means removed + repeated L2Level asks = 5; // Changed ask levels; sz=0 means removed + bool snapshot = 6; // True when bids/asks carry initial levels } // Request parameters for L4 book streaming. message L4BookRequest { - string coin = 1; + string coin = 1; // Symbol (e.g. "BTC", "ETH") } // An L4 book update: either a full snapshot or an incremental diff. message L4BookUpdate { oneof update { - L4BookSnapshot snapshot = 1; - L4BookDiff diff = 2; + L4BookSnapshot snapshot = 1; // Full snapshot (sent on subscribe) + L4BookDiff diff = 2; // Incremental diff (sent per block) } } @@ -68,27 +155,109 @@ message L4BookSnapshot { } // Incremental L4 book diff: raw order statuses and book diffs for one block. +// Sent as JSON to preserve the original node data format. message L4BookDiff { uint64 time = 1; uint64 height = 2; - string data = 3; + string data = 3; // JSON-encoded {order_statuses, book_diffs} +} + +message L4BookBytesUpdate { + oneof update { + L4BookSnapshot snapshot = 1; // Full snapshot (sent on subscribe) + L4BookBytesDiff diff = 2; // Incremental diff bytes + } +} + +message L4BookBytesDiff { + uint64 time = 1; + uint64 height = 2; + bytes data = 3; // JSON-encoded {order_statuses, book_diffs} +} + +// Request parameters for typed L4 order updates. +message L4BookUpdatesRequest { + repeated string coins = 1; // Empty means all coins +} + +enum L4OrderDiffType { + L4_ORDER_DIFF_TYPE_UNSPECIFIED = 0; + L4_ORDER_DIFF_TYPE_NEW = 1; + L4_ORDER_DIFF_TYPE_UPDATE = 2; + L4_ORDER_DIFF_TYPE_REMOVE = 3; +} + +// Typed L4 order book update batch for one processed block. +message L4BookUpdatesUpdate { + uint64 time = 1; + uint64 height = 2; + repeated L4OrderDiff diffs = 3; + bool snapshot = 4; // True when diffs carry a full reset snapshot +} + +// A single typed L4 order-level change. +message L4OrderDiff { + L4OrderDiffType diff_type = 1; + string coin = 2; + uint64 oid = 3; + string user = 4; + string side = 5; // "A" (Ask) or "B" (Bid) + string px = 6; + string sz = 7; // New/current size for new/update } // A single L4 order with full details. message L4Order { - string user = 1; + string user = 1; // Ethereum address string coin = 2; - string side = 3; - string limit_px = 4; - string sz = 5; - uint64 oid = 6; - uint64 timestamp = 7; - string trigger_condition = 8; + string side = 3; // "A" (Ask) or "B" (Bid) + string limit_px = 4; // Limit price as decimal string + string sz = 5; // Size as decimal string + uint64 oid = 6; // Unique order ID + uint64 timestamp = 7; // When order entered the book (ms) + string trigger_condition = 8; // "N/A", "Triggered", etc. bool is_trigger = 9; string trigger_px = 10; bool is_position_tpsl = 11; bool reduce_only = 12; - string order_type = 13; - optional string tif = 14; - optional string cloid = 15; + string order_type = 13; // "Limit", "Market", etc. + optional string tif = 14; // Time-in-force: "Gtc", "Ioc", "Alo" + optional string cloid = 15; // Client order ID +} + +// Request parameters for TP/SL trigger order updates. +message TpslUpdatesRequest { + repeated string coins = 1; // Empty means all perp coins +} + +enum TpslDiffType { + TPSL_DIFF_TYPE_UNSPECIFIED = 0; + TPSL_DIFF_TYPE_ADD = 1; + TPSL_DIFF_TYPE_REMOVE = 2; +} + +// TP/SL trigger order update batch for one processed block. +message TpslUpdatesUpdate { + uint64 time = 1; + uint64 height = 2; + repeated TpslOrderDiff diffs = 3; + bool snapshot = 4; // True when diffs carry currently-open trigger orders +} + +// A trigger order add/remove event. +message TpslOrderDiff { + TpslDiffType diff_type = 1; + uint64 oid = 2; + string coin = 3; + string user = 4; + string side = 5; // "A" (Ask) or "B" (Bid) + string trigger_px = 6; + string limit_px = 7; + string sz = 8; + string trigger_condition = 9; + string order_type = 10; + bool is_position_tpsl = 11; + bool reduce_only = 12; + uint64 timestamp = 13; // Order creation timestamp (milliseconds) + string reason = 14; // Present on remove; mirrors node status } diff --git a/typescript/src/proto/streaming.proto b/typescript/src/proto/streaming.proto index 764b4f7..a24c96e 100644 --- a/typescript/src/proto/streaming.proto +++ b/typescript/src/proto/streaming.proto @@ -1,9 +1,13 @@ syntax = "proto3"; + package hyperliquid; +option go_package = "github.com/quiknode-labs/hyperliquid-sdk/go/hyperliquid/proto"; + service Streaming { // Bi-directional streaming rpc StreamData (stream SubscribeRequest) returns (stream SubscribeUpdate); + rpc StreamDataBytes (stream SubscribeRequest) returns (stream SubscribeBytesUpdate); rpc Ping (PingRequest) returns (PingResponse); } @@ -12,7 +16,9 @@ service BlockStreaming { rpc StreamBlocks (Timestamp) returns (stream Block); } + // --- Requests --- + message SubscribeRequest { oneof request { StreamSubscribe subscribe = 1; @@ -23,6 +29,7 @@ message SubscribeRequest { message StreamSubscribe { StreamType stream_type = 1; + uint64 start_block = 2; // Generic filters - field name to allowed values // Recursively searches each event for matching field/value pairs @@ -42,6 +49,7 @@ message FilterValues { message Ping { int64 timestamp = 1; } // --- Responses --- + message SubscribeUpdate { oneof update { StreamResponse data = 1; @@ -49,14 +57,31 @@ message SubscribeUpdate { } } +message SubscribeBytesUpdate { + oneof update { + StreamBytesResponse data = 1; + Pong pong = 2; + } +} + message StreamResponse { uint64 block_number = 1; uint64 timestamp = 2; // Server ingress timestamp + // Raw JSON data from the file (Exact replica of source) string data = 3; } +message StreamBytesResponse { + uint64 block_number = 1; + uint64 timestamp = 2; // Server ingress timestamp + + // Raw payload bytes. Fast-path clients should prefer this over StreamResponse.data. + bytes data = 3; +} + // --- Data Types --- + enum StreamType { UNKNOWN = 0; TRADES = 1; @@ -66,12 +91,16 @@ enum StreamType { EVENTS = 5; BLOCKS = 6; WRITER_ACTIONS = 7; + MEMPOOL_TXS = 8; // Pre-consensus mempool transactions + ORDER_PRIORITY = 9; // Derived order/write priority actions from mempool and confirmed replica data + GOSSIP_PRIORITY = 10; // Derived gossip/read priority bid actions; does not measure delivery latency } message Block { - string data_json = 1; + string data_json = 1; } + message Pong { int64 timestamp = 1; } message Timestamp { int64 timestamp = 1; } message PingRequest { int32 count = 1; } diff --git a/typescript/src/types.ts b/typescript/src/types.ts index 68adfe0..cd3d178 100644 --- a/typescript/src/types.ts +++ b/typescript/src/types.ts @@ -157,6 +157,10 @@ export interface OrderOptions { cloid?: string; grouping?: OrderGrouping; priorityFee?: number | string; + /** Vault or subaccount address to trade on behalf of. */ + vaultAddress?: string; + /** Action TTL as a ms timestamp; the exchange rejects it after this time. */ + expiresAfter?: number; } export interface TriggerOrderOptions { @@ -771,6 +775,9 @@ export enum GRPCStreamType { EVENTS = 5, BLOCKS = 6, WRITER_ACTIONS = 7, + MEMPOOL_TXS = 8, + ORDER_PRIORITY = 9, + GOSSIP_PRIORITY = 10, } export enum EVMSubscriptionType { @@ -859,6 +866,150 @@ export interface BlockMessage { resps: unknown[]; } +// ─── gRPC order book stream payloads (wire field names, as delivered by GRPCStream) ─── + +/** A single aggregated price level. */ +export interface L2Level { + px: string; + sz: string; + n: number; +} + +/** Fixed-point price level: px/sz are u64 fixed-point integers scaled by 1e8. */ +export interface L2LevelPacked { + px: string; + sz: string; + n: number; +} + +/** Best bid/offer update for a coin (bboBook). bid/ask absent when the side is empty. */ +export interface BboBookMessage { + coin: string; + time: number; + block_number: number; + bid: L2Level | null; + ask: L2Level | null; +} + +/** Fixed-point best bid/offer update (bboBookPacked). px/sz scaled by 1e8. */ +export interface BboBookPackedMessage { + coin: string; + time: number; + block_number: number; + bid: L2LevelPacked | null; + ask: L2LevelPacked | null; +} + +/** Fixed-point L2 book snapshot (l2BookPacked). px/sz scaled by 1e8. */ +export interface L2BookPackedMessage { + coin: string; + time: number; + block_number: number; + bids: L2LevelPacked[]; + asks: L2LevelPacked[]; +} + +/** Incremental L2 changes for one coin. Levels with sz=0 were removed. */ +export interface L2CoinDiff { + coin: string; + seq: number; + prev_seq: number; + bids: L2Level[]; + asks: L2Level[]; + snapshot: boolean; +} + +/** Batch of L2 price-level changes for one processed block (l2BookDiff). */ +export interface L2BookDiffMessage { + time: number; + height: number; + snapshot: boolean; + diffs: L2CoinDiff[]; +} + +export enum L4OrderDiffType { + UNSPECIFIED = 0, + NEW = 1, + UPDATE = 2, + REMOVE = 3, +} + +/** A single typed L4 order-level change. */ +export interface L4OrderDiff { + diff_type: L4OrderDiffType; + coin: string; + oid: number; + user: string; + side: string; // "A" (Ask) or "B" (Bid) + px: string; + sz: string; +} + +/** + * Typed L4 order book update batch for one processed block (l4BookUpdates). + * When snapshot=true the diffs carry a full reset snapshot: discard local + * book state and replace it. + */ +export interface L4BookUpdatesMessage { + time: number; + height: number; + diffs: L4OrderDiff[]; + snapshot: boolean; +} + +export enum TpslDiffType { + UNSPECIFIED = 0, + ADD = 1, + REMOVE = 2, +} + +/** A trigger order add/remove event. */ +export interface TpslOrderDiff { + diff_type: TpslDiffType; + oid: number; + coin: string; + user: string; + side: string; // "A" (Ask) or "B" (Bid) + trigger_px: string; + limit_px: string; + sz: string; + trigger_condition: string; + order_type: string; + is_position_tpsl: boolean; + reduce_only: boolean; + timestamp: number; + reason: string; // Present on remove; mirrors node status +} + +/** TP/SL trigger order update batch for one processed block (tpslUpdates). */ +export interface TpslUpdatesMessage { + time: number; + height: number; + diffs: TpslOrderDiff[]; + snapshot: boolean; +} + +/** + * L4 book fast-path update (l4BookBytes): either a full snapshot or a diff + * whose data is undecoded JSON bytes ({order_statuses, book_diffs}). + */ +export interface L4BookBytesMessage { + type: 'snapshot' | 'diff'; + time: number; + height: number; + coin?: string; // snapshot only + bids?: Array>; // snapshot only + asks?: Array>; // snapshot only + data?: Uint8Array; // diff only; JSON bytes, not decoded +} + +/** Raw-bytes generic stream payload (streamDataBytes). data is not JSON-decoded. */ +export interface StreamBytesMessage { + block_number: number; + timestamp: number; + data: Uint8Array; +} + // ═══════════════════════════════════════════════════════════════════════════ // TRADING RESULT TYPES // ═══════════════════════════════════════════════════════════════════════════ diff --git a/typescript/test/exchange-params.test.ts b/typescript/test/exchange-params.test.ts new file mode 100644 index 0000000..5d83948 --- /dev/null +++ b/typescript/test/exchange-params.test.ts @@ -0,0 +1,285 @@ +/** + * Tests for request-level trading params: vaultAddress, expiresAfter, and the + * fast cancel flag. + * + * The worker folds vaultAddress/expiresAfter into the action hash at build + * time, so both must appear in the build payload AND the send payload; the + * fast flag is action-level (`f: true`, never emitted when false/unset). + * Mirrors the fetch-mocking pattern of signer.test.ts. + */ + +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { HyperliquidSDK } from '../src/client'; +import { Order } from '../src/order'; +import { ValidationError } from '../src/errors'; + +const HASH = '0x' + '00'.repeat(32); +const VAULT = '0x1234567890123456789012345678901234567890'; +const EXPIRES = 1_800_000_000_000; + +/** Mock fetch: build (no signature in body) returns hash+nonce; send returns ok. */ +function mockExchangeFetch(): { calls: any[]; restore: () => void } { + const calls: any[] = []; + const fn = vi.fn(async (_url: string, init: any) => { + const body = JSON.parse(init.body); + calls.push(body); + const payload = + 'signature' in body + ? { status: 'ok' } + : { hash: HASH, nonce: 123, action: body.action }; + return { ok: true, status: 200, json: async () => payload } as any; + }); + const original = globalThis.fetch; + globalThis.fetch = fn as any; + return { calls, restore: () => { globalThis.fetch = original; } }; +} + +function makeSdk(): HyperliquidSDK { + return new HyperliquidSDK('https://x.quiknode.pro/T', { + signer: () => ({ r: '0xaa', s: '0xbb', v: 27 }), + signerAddress: '0xabcDEF1234567890abcdef1234567890aBcDef12', + autoApprove: false, + }); +} + +afterEach(() => { + delete process.env.PRIVATE_KEY; +}); + +describe('vaultAddress / expiresAfter threading', () => { + it('sends both fields in the build AND send payloads for cancel', async () => { + const sdk = makeSdk(); + const { calls, restore } = mockExchangeFetch(); + try { + await sdk.cancel(42, 0, { vaultAddress: VAULT, expiresAfter: EXPIRES }); + } finally { + restore(); + } + + expect(calls).toHaveLength(2); + // Build payload — the worker hashes these alongside the action. + expect(calls[0].vaultAddress).toBe(VAULT); + expect(calls[0].expiresAfter).toBe(EXPIRES); + expect('signature' in calls[0]).toBe(false); + // Send payload — signer recovery + forwarded exchange body. + expect(calls[1].vaultAddress).toBe(VAULT); + expect(calls[1].expiresAfter).toBe(EXPIRES); + expect('signature' in calls[1]).toBe(true); + }); + + it('never emits the fields when unset (wire compat)', async () => { + const sdk = makeSdk(); + const { calls, restore } = mockExchangeFetch(); + try { + await sdk.cancel(42, 0); + } finally { + restore(); + } + + for (const call of calls) { + expect('vaultAddress' in call).toBe(false); + expect('expiresAfter' in call).toBe(false); + } + }); + + it('threads buy() options into both exchange payloads', async () => { + const sdk = makeSdk(); + const { calls, restore } = mockExchangeFetch(); + try { + await sdk.buy('BTC', { + size: 0.001, + price: 50000, + tif: 'gtc', + vaultAddress: VAULT, + expiresAfter: EXPIRES, + }); + } finally { + restore(); + } + + expect(calls).toHaveLength(2); + expect(calls[0].vaultAddress).toBe(VAULT); + expect(calls[0].expiresAfter).toBe(EXPIRES); + expect(calls[1].vaultAddress).toBe(VAULT); + expect(calls[1].expiresAfter).toBe(EXPIRES); + }); + + it('threads Order builder .vaultAddress()/.expiresAfter() through order()', async () => { + const sdk = makeSdk(); + const order = Order.buy('BTC') + .size(0.001) + .price(50000) + .gtc() + .vaultAddress(VAULT) + .expiresAfter(EXPIRES); + + const { calls, restore } = mockExchangeFetch(); + try { + await sdk.order(order); + } finally { + restore(); + } + + expect(calls[0].vaultAddress).toBe(VAULT); + expect(calls[0].expiresAfter).toBe(EXPIRES); + expect(calls[1].vaultAddress).toBe(VAULT); + expect(calls[1].expiresAfter).toBe(EXPIRES); + }); + + it('threads modify() options into both exchange payloads', async () => { + const sdk = makeSdk(); + const { calls, restore } = mockExchangeFetch(); + try { + await sdk.modify(42, 'BTC', 'buy', '50000', '0.001', { + vaultAddress: VAULT, + expiresAfter: EXPIRES, + }); + } finally { + restore(); + } + + expect(calls[0].vaultAddress).toBe(VAULT); + expect(calls[0].expiresAfter).toBe(EXPIRES); + expect(calls[1].vaultAddress).toBe(VAULT); + expect(calls[1].expiresAfter).toBe(EXPIRES); + }); + + it('closePosition() targets the vault position and threads the fields', async () => { + const sdk = makeSdk(); + const { calls, restore } = mockExchangeFetch(); + try { + await sdk.closePosition('BTC', { vaultAddress: VAULT, expiresAfter: EXPIRES }); + } finally { + restore(); + } + + // The worker queries action.user to size the close — must be the vault. + expect(calls[0].action.user).toBe(VAULT); + expect(calls[0].vaultAddress).toBe(VAULT); + expect(calls[0].expiresAfter).toBe(EXPIRES); + expect(calls[1].vaultAddress).toBe(VAULT); + expect(calls[1].expiresAfter).toBe(EXPIRES); + }); + + it('cancelAll() enumerates the vault orders, not the wallet', async () => { + const sdk = makeSdk(); + const openOrdersUsers: Array = []; + sdk.openOrders = async (user?: string) => { + openOrdersUsers.push(user); + return { + orders: [{ oid: 1 }], + cancelActions: { all: { type: 'cancel', cancels: [{ a: 0, o: 1 }] } }, + }; + }; + const { calls, restore } = mockExchangeFetch(); + try { + await sdk.cancelAll(undefined, { vaultAddress: VAULT }); + } finally { + restore(); + } + + expect(openOrdersUsers).toEqual([VAULT]); + expect(calls[0].vaultAddress).toBe(VAULT); + }); + + it('stopLoss() threads vaultAddress and expiresAfter into both payloads', async () => { + const sdk = makeSdk(); + const { calls, restore } = mockExchangeFetch(); + try { + await sdk.stopLoss('BTC', { + size: 0.001, + triggerPrice: 50000, + vaultAddress: VAULT, + expiresAfter: EXPIRES, + }); + } finally { + restore(); + } + + expect(calls[0].vaultAddress).toBe(VAULT); + expect(calls[0].expiresAfter).toBe(EXPIRES); + expect(calls[1].vaultAddress).toBe(VAULT); + expect(calls[1].expiresAfter).toBe(EXPIRES); + }); + + it('rejects a non-integer expiresAfter before any network call', async () => { + const sdk = makeSdk(); + const { calls, restore } = mockExchangeFetch(); + let err: unknown; + try { + err = await sdk.cancel(42, 0, { expiresAfter: 1.5 }).catch((e: unknown) => e); + } finally { + restore(); + } + + expect(err).toBeInstanceOf(ValidationError); + expect(calls).toHaveLength(0); + }); +}); + +describe('fast cancel flag', () => { + it('emits action-level f: true on cancel when fast is set', async () => { + const sdk = makeSdk(); + const { calls, restore } = mockExchangeFetch(); + try { + await sdk.cancel(42, 0, { fast: true }); + } finally { + restore(); + } + + expect(calls[0].action).toEqual({ + type: 'cancel', + cancels: [{ a: 0, o: 42 }], + f: true, + }); + expect(calls[1].action.f).toBe(true); + }); + + it.each([ + ['unset', {}], + ['false', { fast: false }], + ])('never emits f when fast is %s (backend strips f:false)', async (_label, opts) => { + const sdk = makeSdk(); + const { calls, restore } = mockExchangeFetch(); + try { + await sdk.cancel(42, 0, opts as { fast?: boolean }); + } finally { + restore(); + } + + for (const call of calls) { + expect('f' in call.action).toBe(false); + } + }); + + it('emits f: true on cancelByCloid when fast is set', async () => { + const sdk = makeSdk(); + const cloid = '0x' + '12'.repeat(16); + const { calls, restore } = mockExchangeFetch(); + try { + await sdk.cancelByCloid(cloid, 0, { fast: true }); + } finally { + restore(); + } + + expect(calls[0].action).toEqual({ + type: 'cancelByCloid', + cancels: [{ asset: 0, cloid }], + f: true, + }); + }); + + it('omits f on cancelByCloid when fast is unset', async () => { + const sdk = makeSdk(); + const { calls, restore } = mockExchangeFetch(); + try { + await sdk.cancelByCloid('0x' + '12'.repeat(16), 0); + } finally { + restore(); + } + + for (const call of calls) { + expect('f' in call.action).toBe(false); + } + }); +}); diff --git a/typescript/test/grpc-stream.test.ts b/typescript/test/grpc-stream.test.ts new file mode 100644 index 0000000..dff509a --- /dev/null +++ b/typescript/test/grpc-stream.test.ts @@ -0,0 +1,417 @@ +/** + * Tests for the gRPC stream client's new streams (mempool/priority, start_block, + * StreamDataBytes, and the 2026 order book RPCs). + * + * No network: the grpc clients are replaced with fakes that capture the RPC + * name + request and let tests emit updates. Private members are reached via + * `as any`, mirroring signer.test.ts. + */ + +import { describe, it, expect, afterEach } from 'vitest'; +import { GRPCStream, GRPCStreamType } from '../src/grpc-stream'; +import { GRPCStreamType as ProtoStreamType } from '../src/types'; + +const ENDPOINT = 'https://x.hype-mainnet.quiknode.pro/TOKEN'; + +type Handler = (arg?: unknown) => void; + +/** Fake gRPC stream: records write() calls and lets tests emit events. */ +function fakeStream() { + const writes: any[] = []; + const handlers: Record = {}; + return { + writes, + write: (msg: any) => writes.push(msg), + on(event: string, fn: Handler) { + (handlers[event] = handlers[event] ?? []).push(fn); + return this; + }, + emit(event: string, arg?: unknown) { + for (const fn of handlers[event] ?? []) fn(arg); + }, + cancel: () => {}, + }; +} + +/** Fake client whose every RPC returns a fresh fake stream and records calls. */ +function fakeClient(rpcNames: string[]) { + const calls: Array<{ rpc: string; request: any; stream: ReturnType }> = []; + const client: Record = {}; + for (const rpc of rpcNames) { + client[rpc] = (requestOrMetadata: any, _metadata?: any) => { + const stream = fakeStream(); + // Bidi RPCs are called with metadata only; server-streaming with (request, metadata). + const request = _metadata === undefined ? undefined : requestOrMetadata; + calls.push({ rpc, request, stream }); + return stream; + }; + } + return { calls, client }; +} + +/** Wire fakes into a GRPCStream and run _startStreams without connecting. */ +function startWithFakes(stream: GRPCStream) { + const streaming = fakeClient(['StreamData', 'StreamDataBytes']); + const orderbook = fakeClient([ + 'StreamL2Book', + 'StreamL4Book', + 'StreamBboBook', + 'StreamL2BookDiff', + 'StreamL4BookUpdates', + 'StreamTpslUpdates', + 'StreamL2BookPacked', + 'StreamBboBookPacked', + 'StreamL4BookBytes', + ]); + const blocks = fakeClient(['StreamBlocks']); + const s = stream as any; + s._streamingClient = streaming.client; + s._orderbookClient = orderbook.client; + s._blockClient = blocks.client; + s._running = true; + s._startStreams(); + return { streaming, orderbook, blocks }; +} + +let active: GRPCStream | null = null; + +afterEach(() => { + // Clear ping intervals created by data streams. + active?.stop(); + active = null; +}); + +function makeStream(): GRPCStream { + active = new GRPCStream(ENDPOINT, { reconnect: false }); + return active; +} + +describe('stream type maps', () => { + it('proto enum includes the new stream types 8/9/10', () => { + expect(ProtoStreamType.MEMPOOL_TXS).toBe(8); + expect(ProtoStreamType.ORDER_PRIORITY).toBe(9); + expect(ProtoStreamType.GOSSIP_PRIORITY).toBe(10); + }); + + it('client enum includes the new stream types', () => { + expect(GRPCStreamType.MEMPOOL_TXS).toBe('MEMPOOL_TXS'); + expect(GRPCStreamType.ORDER_PRIORITY).toBe('ORDER_PRIORITY'); + expect(GRPCStreamType.GOSSIP_PRIORITY).toBe('GOSSIP_PRIORITY'); + }); +}); + +describe('generic data streams', () => { + it('mempoolTxs(coins) subscribes with stream_type 8 and a coin filter', () => { + const s = makeStream().mempoolTxs(['BTC', 'ETH'], () => {}); + const { streaming } = startWithFakes(s); + + expect(streaming.calls).toHaveLength(1); + expect(streaming.calls[0].rpc).toBe('StreamData'); + const req = streaming.calls[0].stream.writes[0]; + expect(req.subscribe.stream_type).toBe(8); + expect(req.subscribe.filters).toEqual({ coin: { values: ['BTC', 'ETH'] } }); + }); + + it('mempoolTxs(callback) subscribes unfiltered', () => { + const s = makeStream().mempoolTxs(() => {}); + const { streaming } = startWithFakes(s); + + const req = streaming.calls[0].stream.writes[0]; + expect(req.subscribe.stream_type).toBe(8); + expect(req.subscribe.filters).toEqual({}); + }); + + it('orderPriority and gossipPriority subscribe with stream_type 9 and 10', () => { + const s = makeStream().orderPriority(() => {}).gossipPriority(() => {}); + const { streaming } = startWithFakes(s); + + expect(streaming.calls.map((c) => c.stream.writes[0].subscribe.stream_type)).toEqual([9, 10]); + }); + + it('plumbs startBlock into StreamSubscribe.start_block', () => { + const s = makeStream().trades(['BTC'], () => {}, { startBlock: 750_000_000 }); + const { streaming } = startWithFakes(s); + + const req = streaming.calls[0].stream.writes[0]; + expect(req.subscribe.start_block).toBe(750_000_000); + }); + + it('omits start_block when startBlock is not given', () => { + const s = makeStream().trades(['BTC'], () => {}); + const { streaming } = startWithFakes(s); + + const req = streaming.calls[0].stream.writes[0]; + expect('start_block' in req.subscribe).toBe(false); + }); + + it('streamDataBytes throws on an unknown stream type instead of sending enum 0', () => { + expect(() => + makeStream().streamDataBytes('TRADEZ' as never, () => {}) + ).toThrow(/Unknown stream type "TRADEZ"/); + }); + + it('streamDataBytes uses the StreamDataBytes RPC and passes bytes through undecoded', () => { + const received: any[] = []; + const s = makeStream().streamDataBytes(GRPCStreamType.MEMPOOL_TXS, (d) => received.push(d), { + coins: ['BTC'], + startBlock: 42, + }); + const { streaming } = startWithFakes(s); + + expect(streaming.calls).toHaveLength(1); + expect(streaming.calls[0].rpc).toBe('StreamDataBytes'); + const req = streaming.calls[0].stream.writes[0]; + expect(req.subscribe.stream_type).toBe(8); + expect(req.subscribe.start_block).toBe(42); + expect(req.subscribe.filters).toEqual({ coin: { values: ['BTC'] } }); + + const payload = Buffer.from('{"not":"parsed"}'); + streaming.calls[0].stream.emit('data', { + data: { block_number: 7, timestamp: 1234, data: payload }, + }); + expect(received).toHaveLength(1); + expect(received[0].block_number).toBe(7); + expect(received[0].timestamp).toBe(1234); + expect(received[0].data).toBe(payload); // exact bytes, no JSON decoding + }); +}); + +describe('reconnect start_block resume', () => { + it('sends the original start_block on first connect and last_seen + 1 on reconnect', () => { + const s = makeStream().trades(['BTC'], () => {}, { startBlock: 100 }); + const { streaming } = startWithFakes(s); + + expect(streaming.calls[0].stream.writes[0].subscribe.start_block).toBe(100); + + streaming.calls[0].stream.emit('data', { data: { block_number: 150, timestamp: 1, data: '{}' } }); + streaming.calls[0].stream.emit('data', { data: { block_number: 149, timestamp: 2, data: '{}' } }); + + // What the reconnect loop runs after _cleanup() + _connect(). + (s as any)._startStreams(); + expect(streaming.calls).toHaveLength(2); + expect(streaming.calls[1].stream.writes[0].subscribe.start_block).toBe(151); + }); + + it('never rewinds below the original start_block on reconnect', () => { + const s = makeStream().trades(['BTC'], () => {}, { startBlock: 100 }); + const { streaming } = startWithFakes(s); + + streaming.calls[0].stream.emit('data', { data: { block_number: 50, timestamp: 1, data: '{}' } }); + + (s as any)._startStreams(); + expect(streaming.calls[1].stream.writes[0].subscribe.start_block).toBe(100); + }); + + it('keeps the original start_block when no data arrived before the reconnect', () => { + const s = makeStream().trades(['BTC'], () => {}, { startBlock: 100 }); + const { streaming } = startWithFakes(s); + + (s as any)._startStreams(); + expect(streaming.calls[1].stream.writes[0].subscribe.start_block).toBe(100); + }); + + it('does not add start_block on reconnect when the user never set one', () => { + const s = makeStream().trades(['BTC'], () => {}); + const { streaming } = startWithFakes(s); + + streaming.calls[0].stream.emit('data', { data: { block_number: 150, timestamp: 1, data: '{}' } }); + + (s as any)._startStreams(); + expect('start_block' in streaming.calls[1].stream.writes[0].subscribe).toBe(false); + }); + + it('applies the same resume rule on the StreamDataBytes path', () => { + const s = makeStream().streamDataBytes(GRPCStreamType.MEMPOOL_TXS, () => {}, { startBlock: 42 }); + const { streaming } = startWithFakes(s); + + expect(streaming.calls[0].rpc).toBe('StreamDataBytes'); + expect(streaming.calls[0].stream.writes[0].subscribe.start_block).toBe(42); + + streaming.calls[0].stream.emit('data', { + data: { block_number: 60, timestamp: 1, data: Buffer.from('{}') }, + }); + + (s as any)._startStreams(); + expect(streaming.calls[1].stream.writes[0].subscribe.start_block).toBe(61); + }); + + it('resumes from string block_number fields (int64 decoded with longs: String)', () => { + const s = makeStream().trades(['BTC'], () => {}, { startBlock: 100 }); + const { streaming } = startWithFakes(s); + + streaming.calls[0].stream.emit('data', { data: { block_number: '200', timestamp: 1, data: '{}' } }); + + (s as any)._startStreams(); + expect(streaming.calls[1].stream.writes[0].subscribe.start_block).toBe(201); + }); +}); + +describe('order book streams', () => { + it('bboBook defaults to all coins and forwards updates as-is', () => { + const received: any[] = []; + const s = makeStream().bboBook((d) => received.push(d)); + const { orderbook } = startWithFakes(s); + + expect(orderbook.calls).toHaveLength(1); + expect(orderbook.calls[0].rpc).toBe('StreamBboBook'); + expect(orderbook.calls[0].request).toEqual({ coins: [] }); + + const update = { + coin: 'BTC', + time: 1, + block_number: 2, + bid: { px: '100', sz: '1', n: 3 }, + ask: null, + }; + orderbook.calls[0].stream.emit('data', update); + expect(received).toEqual([update]); + }); + + it('bboBook(coins) passes the coin list', () => { + const s = makeStream().bboBook(['BTC'], () => {}); + const { orderbook } = startWithFakes(s); + expect(orderbook.calls[0].request).toEqual({ coins: ['BTC'] }); + }); + + it('l2BookDiff maps all options onto the request', () => { + const s = makeStream().l2BookDiff(() => {}, { + coins: ['BTC', 'ETH'], + nLevels: 50, + nSigFigs: 5, + mantissa: 2, + skipInitialSnapshot: true, + }); + const { orderbook } = startWithFakes(s); + + expect(orderbook.calls[0].rpc).toBe('StreamL2BookDiff'); + expect(orderbook.calls[0].request).toEqual({ + coins: ['BTC', 'ETH'], + n_levels: 50, + n_sig_figs: 5, + mantissa: 2, + skip_initial_snapshot: true, + }); + }); + + it('l2BookDiff omits optional bucketing fields by default', () => { + const s = makeStream().l2BookDiff(() => {}); + const { orderbook } = startWithFakes(s); + expect(orderbook.calls[0].request).toEqual({ + coins: [], + n_levels: 20, + skip_initial_snapshot: false, + }); + }); + + it('l4BookUpdates and tpslUpdates use the coins request shape', () => { + const s = makeStream().l4BookUpdates(['BTC'], () => {}).tpslUpdates(() => {}); + const { orderbook } = startWithFakes(s); + + expect(orderbook.calls.map((c) => [c.rpc, c.request])).toEqual([ + ['StreamL4BookUpdates', { coins: ['BTC'] }], + ['StreamTpslUpdates', { coins: [] }], + ]); + }); + + it('l2BookPacked builds an L2BookRequest with optional bucketing', () => { + const s = makeStream().l2BookPacked('BTC', () => {}, { nSigFigs: 4, mantissa: 5, nLevels: 10 }); + const { orderbook } = startWithFakes(s); + + expect(orderbook.calls[0].rpc).toBe('StreamL2BookPacked'); + expect(orderbook.calls[0].request).toEqual({ + coin: 'BTC', + n_levels: 10, + n_sig_figs: 4, + mantissa: 5, + }); + }); + + it('bboBookPacked uses the coins request shape', () => { + const s = makeStream().bboBookPacked(['ETH'], () => {}); + const { orderbook } = startWithFakes(s); + expect(orderbook.calls[0].rpc).toBe('StreamBboBookPacked'); + expect(orderbook.calls[0].request).toEqual({ coins: ['ETH'] }); + }); + + it('l4BookBytes maps snapshots like l4Book and keeps diff bytes undecoded', () => { + const received: any[] = []; + const s = makeStream().l4BookBytes('BTC', (d) => received.push(d)); + const { orderbook } = startWithFakes(s); + + expect(orderbook.calls[0].rpc).toBe('StreamL4BookBytes'); + expect(orderbook.calls[0].request).toEqual({ coin: 'BTC' }); + + const order = { + user: '0xabc', + coin: 'BTC', + side: 'B', + limit_px: '100', + sz: '1', + oid: 5, + timestamp: 9, + trigger_condition: 'N/A', + is_trigger: false, + trigger_px: '0', + is_position_tpsl: false, + reduce_only: false, + order_type: 'Limit', + tif: 'Gtc', + cloid: undefined, + }; + orderbook.calls[0].stream.emit('data', { + snapshot: { coin: 'BTC', time: 1, height: 2, bids: [order], asks: [] }, + }); + const bytes = Buffer.from('{"order_statuses":[],"book_diffs":[]}'); + orderbook.calls[0].stream.emit('data', { + diff: { time: 3, height: 4, data: bytes }, + }); + + expect(received).toHaveLength(2); + expect(received[0].type).toBe('snapshot'); + expect(received[0].coin).toBe('BTC'); + expect(received[0].bids[0].oid).toBe(5); + expect(received[1]).toEqual({ type: 'diff', time: 3, height: 4, data: bytes }); + }); + + it('honors skipInitialSnapshot on the first connect but forces false on reconnect', () => { + const s = makeStream().l2BookDiff(() => {}, { coins: ['BTC'], skipInitialSnapshot: true }); + const { orderbook } = startWithFakes(s); + + expect(orderbook.calls[0].request.skip_initial_snapshot).toBe(true); + + // What the reconnect loop runs after _cleanup() + _connect(). + (s as any)._startStreams(); + expect(orderbook.calls).toHaveLength(2); + expect(orderbook.calls[1].request).toEqual({ + coins: ['BTC'], + n_levels: 20, + skip_initial_snapshot: false, + }); + }); + + it('keeps skip_initial_snapshot false on reconnect when the user did not skip', () => { + const s = makeStream().l2BookDiff(() => {}); + const { orderbook } = startWithFakes(s); + + expect(orderbook.calls[0].request.skip_initial_snapshot).toBe(false); + + (s as any)._startStreams(); + expect(orderbook.calls[1].request.skip_initial_snapshot).toBe(false); + }); + + it('l2Book still maps levels to [px, sz, n] tuples (regression)', () => { + const received: any[] = []; + const s = makeStream().l2Book('BTC', (d) => received.push(d)); + const { orderbook } = startWithFakes(s); + + expect(orderbook.calls[0].rpc).toBe('StreamL2Book'); + orderbook.calls[0].stream.emit('data', { + coin: 'BTC', + time: 1, + block_number: 2, + bids: [{ px: '100', sz: '1', n: 3 }], + asks: [], + }); + expect(received[0].bids).toEqual([['100', '1', 3]]); + }); +});