tidepool-server 0.4.0

Tidepool HTTP + WebSocket JSON-RPC server. Wraps the service layer (cNFT indexing, DAS handlers, passthrough proxy) in an axum front-end.
Documentation
<div align="center">

<img src="./assets/logo.png" alt="Tidepool logo" width="180" />

# Tidepool

**Helius DAS, locally. Built on Surfpool.**

A Helius-compatible local dev environment for Solana โ€” DAS, compressed NFTs, WebSocket subscriptions, Enhanced Transactions, and webhooks, all from a single Rust binary you run next to your validator. Your production `helius-sdk` integration works offline, without a key, without cost.

[![crates.io](https://img.shields.io/crates/v/tidepool-cli?label=tidepool-cli)](https://crates.io/crates/tidepool-cli)
[![npm](https://img.shields.io/npm/v/@vibestackmd/tidepool?label=%40vibestackmd%2Ftidepool)](https://www.npmjs.com/package/@vibestackmd/tidepool)
[![CI](https://github.com/vibestackmd/tidepool/actions/workflows/ci.yml/badge.svg)](https://github.com/vibestackmd/tidepool/actions/workflows/ci.yml)
[![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](./LICENSE)
[![MSRV 1.77](https://img.shields.io/badge/MSRV-1.77-orange.svg)](./Cargo.toml)

Every release ships from GitHub Actions via OIDC trusted publishing โ€” crates.io and npm both โ€” with signed sigstore provenance on the npm tarball.

</div>

---

## Why

Three things you'll notice in the first five minutes.

**๐ŸŒŠ &nbsp; Local DAS, including compressed NFTs.** &nbsp; `getAsset`, `getAssetBatch`, `getAssetProof`, the `getAssetsBy*` family, `searchAssets`. MplCore, Token Metadata (both Token and Token-2022), and Bubblegum cNFTs โ€” all resolved locally from real on-chain bytes. cNFTs go through a full Bubblegum indexer that replays every tree-mutating instruction; authoritative state comes from the noop-CPI `LeafSchemaEvent`, so proofs match on-chain even after a `setAndVerifyCollection`.

**โšก &nbsp; `confirmTransaction()` works against a single Tidepool endpoint.** &nbsp; Surfpool now ships native WebSocket subscriptions, but on a separate port (8900 by default). Tidepool reverse-proxies WS through its own port so clients keep one base URL across HTTP, REST, and WS. Every `helius-sdk` method that composes "send, wait, assert" โ€” `sendSmartTransaction`, `broadcastTransaction`, `pollTransactionConfirmation` โ€” just works.

**๐Ÿงช &nbsp; Plugs into MSW / Nock / undici for tests.** &nbsp; Import `@vibestackmd/tidepool` from npm, plug `handleJsonRpcBody` into whichever mock-HTTP layer your team uses. Your test suite gets deterministic Helius responses without standing up a validator.

---

## Quickstart

Three ways to consume it. Pick one.

### As a binary

```bash
# Terminal 1
surfpool start

# Terminal 2
cargo install tidepool-cli
tidepool start \
  --port 8897 \
  --upstream http://127.0.0.1:8899 \
  --index-tree <your-cNFT-merkle-tree>
```

```ts
// Your app
import { Connection } from "@solana/web3.js";
const connection = new Connection("http://localhost:8897", "confirmed");
```

### As a Rust library

```toml
# Cargo.toml
[dependencies]
tidepool-rpc = "0.1"
```

```rust
use tidepool_rpc::cnft::{apply_event, CnftEvent, MemoryCnftStore};
use tidepool_rpc::das::{get_asset, get_asset_proof};
```

Full example in [`examples/rust-integration/`](examples/rust-integration/).

### As a Node / JS test integration

```bash
npm install @vibestackmd/tidepool msw vitest
```

```ts
import { HeliusContext, handleJsonRpcBody } from "@vibestackmd/tidepool";
import { http, HttpResponse, passthrough } from "msw";
import { setupServer } from "msw/node";

const ctx = new HeliusContext();

setupServer(
  http.post("http://127.0.0.1:8899/", async ({ request }) => {
    const response = await handleJsonRpcBody(ctx, await request.text());
    return response ? HttpResponse.json(JSON.parse(response)) : passthrough();
  }),
).listen();
```

Full runnable vitest setup in [`examples/msw-integration/`](examples/msw-integration/).

### As a drop-in for `helius-sdk`

Point the same client at Tidepool โ€” one URL swap, every transport works. JSON-RPC, REST (`/v0/โ€ฆ`), and WebSocket all resolve against Tidepool because we mirror Helius's transport split exactly.

```ts
import { Helius } from "helius-sdk";

const helius = new Helius(
  process.env.HELIUS_API_KEY ?? "local-dev",
  process.env.NODE_ENV === "development"
    ? { url: "http://localhost:8897", restUrl: "http://localhost:8897" }
    : undefined, // prod: default to mainnet.helius-rpc.com + api.helius.xyz
);

await helius.rpc.getAsset({ id: mintPubkey });             // JSON-RPC
await helius.enhanced.getTransactions([signature]);        // REST
await helius.ws.signatureNotifications(signature);         // WS proxied to upstream
```

The `restUrl` + `url` split assumes a small PR landing in [`helius-labs/helius-sdk`](https://github.com/helius-labs/helius-sdk) to make the REST base URL configurable. Until it merges, JSON-RPC + WS work today; REST needs the SDK's internal base URL overridden via whatever escape hatch your SDK version provides.

---

## How it works

Tidepool sits between your app and Surfpool. Requests for methods we own (DAS, cNFT proofs, enhanced tx, webhooks) are served from local state; everything else is forwarded to Surfpool unchanged. The WS port is a thin reverse proxy onto Surfpool's native subscriptions.

- **Uncompressed `getAsset`** fetches the account from the upstream, runs it through a pluggable decoder (`mpl-core` / `mpl-token-metadata`), then fetches the off-chain JSON at the asset's `uri` and folds image / description / attributes / files into the response โ€” matching real Helius. Off-chain fetch supports `http(s)://` and `file://` (for locally-seeded dev metadata), fails soft (a blocked or slow fetch degrades to on-chain fields, never errors), and is cached per asset. Disable with `--no-offchain-metadata` for hermetic runs. The cache populates as a side effect so `searchAssets`, `getAssetsByOwner`, and the other secondary-index queries work immediately.
- **Compressed `getAsset` / `getAssetProof`** resolve from a local Bubblegum indexer: `getSignaturesForAddress` walks the tree, `getTransaction` pulls each candidate tx, inner Bubblegum + noop CPIs are parsed for authoritative leaf state. Trees are registered via `--index-tree` at startup or `tidepool_indexTree` at runtime.
- **Everything unknown** falls through to the upstream unchanged. Standard Solana RPC (`getSlot`, `sendTransaction`, `getProgramAccounts`, etc.) works with zero code on our side.

### Why Surfpool as the upstream?

Tidepool works with any standard Solana RPC โ€” `solana-test-validator` with `--clone`, real devnet, a self-hosted node. **Surfpool is recommended** because its mainnet-forking means any real account you ask about "just works" without pre-declaring it. That's what makes the dev-loop feel magic instead of tedious. Tidepool's WS reverse proxy specifically targets Surfpool's native subscription endpoint (default `ws://upstream:8900`), so against other validators the WS port may have nothing useful to forward to.

---

## Supported methods

Full live truth: `POST {"method":"tidepool_info"}` returns the complete manifest. Every entry is classified `EXACT`, `LOCAL_INDEX`, `BEST_EFFORT`, `SHIM`, `SDK_WRAPPER`, `PLANNED`, or `SKIPPED`.

| Method | Status | Notes |
|---|---|---|
| `getAsset` / `getAssetBatch` | โœ… LOCAL_INDEX | MplCore, Token Metadata (incl. Token-2022), cNFTs |
| `getAssetProof` / `getAssetProofBatch` | โœ… LOCAL_INDEX | Requires tree registered via `--index-tree` or runtime method |
| `getAssetsByOwner` / `Authority` / `Creator` / `Group` | โœ… LOCAL_INDEX | Cache-backed secondary indexes |
| `searchAssets` | โœ… LOCAL_INDEX | Multi-filter AND, smallest-index-first narrowing |
| `getNftEditions` | โœ… LOCAL_INDEX | Lazy edition-PDA indexing; master + print editions |
| `signatureSubscribe` / `accountSubscribe` / `logsSubscribe` (+ `Unsubscribe`) | โœ… EXACT | Reverse-proxied to Surfpool's native WS. All filters Surfpool supports work, including `logsSubscribe` with `{ mentions: [pubkey] }`. |
| `getPriorityFeeEstimate` | โœ… BEST_EFFORT | Local percentile ladder over `getRecentPrioritizationFees` |
| `helius-sdk` composed methods | โœ… SDK_WRAPPER | Send / broadcast / confirm / staking โ€” all work transparently |
| `getBalances` (REST) | โœ… SHIM | `GET /v0/addresses/{addr}/balances` |
| `getTransactions` / `getTransactionsByAddress` (REST) | โœ… SHIM | Enhanced Transactions parsers on `/v0/transactions` and `/v0/addresses/{addr}/transactions` |
| `getTransactionsForAddress` (JSON-RPC) | โœ… BEST_EFFORT | Combined sig fetch + tx + classify. `limit`, `paginationToken`, `minSlot`, `maxSlot`, `status` filters. History limited to what the upstream has streamed. |
| `getTransfersByAddress` (JSON-RPC) | โœ… BEST_EFFORT | Parsed SOL + SPL transfer history per wallet. `mint`, `direction`, `limit`, `sort`, `paginationToken`. Same history caveat. SPL `decimals` defaults to 0 pending token-info cache. |
| `createWebhook` family (REST) | โœ… SHIM | Polling-simulator on `/v0/webhooks` + `/v0/webhooks/{id}` โ€” full CRUD |
| Everything else | โœ… Passthrough | Forwarded to the upstream unchanged |

### Transports

Tidepool matches Helius's transport split exactly โ€” a method lives where Helius puts it and nowhere else, so you can't write local-dev code that'd fail against production.

| Transport | Where | Methods |
|---|---|---|
| JSON-RPC | `POST /` | DAS (`getAsset*`), Bubblegum control (`tidepool_*`), standard RPC passthrough |
| REST | `/v0/โ€ฆ` | Wallet (`getBalances`), Enhanced Transactions, Webhooks CRUD |
| WebSocket | `ws://host:port+1` | `signatureSubscribe`, `accountSubscribe`, `logsSubscribe`, `*Unsubscribe` |
| SDK Wrapper | n/a | `sendSmartTransaction`, `broadcastTransaction`, `pollTransactionConfirmation`, stake/unstake |

`tidepool_info` returns a `transport` field per method so tooling can introspect without guessing.

---

## Compressed NFTs

cNFTs live as leaves in a Bubblegum merkle tree, not as standalone accounts. Tidepool ships a local indexer that replays every tree-mutating instruction into an in-memory (or SQLite-backed) store, from which `getAsset` / `getAssetProof` serve directly.

Register a tree:

```bash
# At startup
tidepool start --index-tree <merkle-tree>

# Or at runtime (in a vitest setup file, CI script, etc.)
curl -X POST http://localhost:8897 \
  -H "Content-Type: application/json" \
  -d '{"jsonrpc":"2.0","id":1,"method":"tidepool_indexTree","params":{"tree":"<merkle-tree>"}}'
```

**Tracked instructions:** `createTree`, `mintV1` / `mintV2`, `mintToCollectionV1`, `transfer` / `transferV2`, `burn` / `burnV2`, `delegate` / `delegateV2`, `verifyCreator` / `verifyCreatorV2`, `unverifyCreator`, `verifyCollection`, `unverifyCollection`, `setAndVerifyCollection` / `setCollectionV2`, `updateMetadata` / `updateMetadataV2`. For hash-dependent ixs, authoritative state comes from the noop `LeafSchemaEvent` CPI โ€” proofs stay correct through multi-step flows. Covers both SPL-NOOP (V1) and MPL-NOOP (V2) noop programs.

---

## Persistence

Default is **in-memory** โ€” state is lost on restart. Two flags turn that off, shaped after [Surfpool's own persistence UX](https://github.com/solana-foundation/surfpool) so the two tools feel familiar.

```bash
# Single SQLite file holds cNFT index + DAS cache + webhook registry
tidepool start --db ./tidepool.sqlite

# Preload snapshot(s) at boot; repeatable
tidepool start --snapshot ./trees/foo.json --snapshot ./trees/bar.json
```

`tidepool_exportTreeSnapshot` dumps a tree's indexed state at runtime; commit it to your repo and every fresh boot can `--snapshot` that file to skip re-paging tx history.

---

## Examples

- [`examples/msw-integration/`](examples/msw-integration/) โ€” vitest + MSW + Tidepool, three runnable tests
- [`examples/rust-integration/`](examples/rust-integration/) โ€” cargo example composing the service layer directly
- [`examples/README.md`](examples/) โ€” all consumer patterns indexed

## Workspace layout

| Crate | Purpose |
|---|---|
| `tidepool-core` | Pure primitives: keccak, merkle math, LeafSchemaV1 hashing, proof compute/verify. Zero Solana deps โ€” WASM-ready. |
| `tidepool-rpc` | Service layer: cNFT state machine, DAS handlers, cache, decoders, upstream trait. |
| `tidepool-server` | axum HTTP + WS front-end. Method-enum dispatch. `HttpUpstream` via reqwest. |
| `tidepool-cli` | `tidepool` binary. clap-derive args + env-var overlay. |
| `tidepool-node` | napi-rs bridge โ†’ the `@vibestackmd/tidepool` npm package. |

Library consumers pull `tidepool-rpc`. Binary users `cargo install tidepool-cli`. JS users `npm install @vibestackmd/tidepool`. Server builders compose `tidepool-rpc` + `tidepool-server::HttpUpstream` themselves.

---

## FAQ

<details>
<summary><b>Is this production-ready?</b></summary>

No. It's a local development tool. Ship to real Helius in production.
</details>

<details>
<summary><b>Does this replace Helius?</b></summary>

No. It lets you develop against Helius's API locally so your production integration has a tight dev loop.
</details>

<details>
<summary><b>Is this endorsed by Helius or Surfpool?</b></summary>

Community tool, no official endorsement. Both are great companies and you should use them.
</details>

<details>
<summary><b>Why not just hit real Helius in dev?</b></summary>

You'd burn rate limits, pollute prod monitoring, require internet on CI, and can't test without an API key. Tidepool is the answer to "I want the dev loop to be instant + offline."
</details>

<details>
<summary><b>Can I use this with `solana-test-validator` or `litesvm`?</b></summary>

`solana-test-validator` works โ€” point `--upstream` at it, clone mainnet accounts via `--clone`. `litesvm` is in-process-only, so there's no RPC endpoint for Tidepool to proxy. Use Surfpool for the magic, test-validator for the boring-but-predictable case.
</details>

<details>
<summary><b>Why Rust?</b></summary>

The previous version was TypeScript (v0.6, preserved at that tag). The Rust rewrite earned: drop-in official `mpl-core` / `mpl-token-metadata` / `mpl-bubblegum` crates (no Codama pipeline), exhaustive-match method dispatch (compile-time safety for adding new handlers), type-level noop-required-vs-optional enforcement on cNFT events, binary distribution via `cargo install`. The napi-rs bridge means JS consumers still get the test-integration story via `npm install @vibestackmd/tidepool` โ€” one Rust core, two consumption ecosystems.
</details>

<details>
<summary><b>Does the WS proxy work over compressed transactions?</b></summary>

Surfpool's native `signatureSubscribe` resolves any signature the validator has seen โ€” compressed + uncompressed identically. Tidepool just forwards frames; whatever Surfpool tracks is what clients see.
</details>

---

## Roadmap

- โœ… **v0.1.x** โ€” Rust rewrite shipped. MplCore / Token Metadata / cNFT decoders, full DAS surface, WS polyfills (`signatureSubscribe`, `accountSubscribe`, `logsSubscribe`), axum server, CLI binary, napi bridge, REST transport, webhooks CRUD, Enhanced Transactions. End-to-end OIDC release pipeline (crates.io + multi-platform npm).
- โœ… **v0.2.0** โ€” Surfpool catch-up. WS polyfills replaced by a thin reverse proxy onto Surfpool's native subscription endpoint (v1.1+). Upstream refs updated to `solana-foundation/surfpool`. ~1,000 lines of polling polyfill deleted.
- **0.3** โ€” Helius catch-up. `getTransactionsForAddress`, `getTransfersByAddress`, `getProgramAccountsV2`, `getTokenAccountsByOwnerV2`, Wallet REST API stubs, optionally an MCP server variant mirroring Helius for Agents.
- **0.4** โ€” USD pricing pass-through, Token Metadata owner-resolution polish across all interfaces.
- **1.0** โ€” API freeze. Library crates (`tidepool-core`, `tidepool-rpc`, `tidepool-server`) become stable surfaces; right now they're publish-as-required-for-the-CLI, not promised.
- **Maybe** โ€” LaserStream-compatible event emitter, Dragon's Mouth (Yellowstone gRPC) polyfill.

## Versions

- **v0.1.0 โ€“ v0.6.0** (TypeScript) โ€” original implementation, preserved at git tags `v0.1.0` through `v0.6.0`. Not on any registry. No longer maintained.
- **v0.1.0+ (Rust, this codebase)** โ€” distributed as `tidepool-cli` on crates.io and `@vibestackmd/tidepool` on npm. Versions are lockstep across both registries. `tidepool-cli` is the supported entry point; the other crates are internal until 1.0.

## Related

- [Surfpool](https://github.com/solana-foundation/surfpool) โ€” the local Solana validator Tidepool runs on top of
- [Helius DAS](https://www.helius.dev/docs/api-reference/das) โ€” the production API Tidepool mimics
- [Metaplex MplCore](https://developers.metaplex.com/core), [Bubblegum](https://developers.metaplex.com/bubblegum) โ€” the asset standards
- [mpl-core](https://crates.io/crates/mpl-core), [mpl-token-metadata](https://crates.io/crates/mpl-token-metadata), [mpl-bubblegum](https://crates.io/crates/mpl-bubblegum) โ€” the official Rust crates Tidepool uses

---

<div align="center">

**MIT** ยท [LICENSE](./LICENSE)

</div>