https://github.com/magicblock-labs/hydra
Permissionless Solana crank for scheduling instructions with minimum overhead.
https://github.com/magicblock-labs/hydra
Last synced: 23 days ago
JSON representation
Permissionless Solana crank for scheduling instructions with minimum overhead.
- Host: GitHub
- URL: https://github.com/magicblock-labs/hydra
- Owner: magicblock-labs
- License: mit
- Created: 2026-04-18T14:15:52.000Z (3 months ago)
- Default Branch: main
- Last Pushed: 2026-06-26T16:38:55.000Z (28 days ago)
- Last Synced: 2026-06-26T18:10:42.003Z (28 days ago)
- Language: Rust
- Homepage:
- Size: 1.59 MB
- Stars: 18
- Watchers: 0
- Forks: 0
- Open Issues: 1
-
Metadata Files:
- Readme: README.md
- License: LICENSE
Awesome Lists containing this project
README
Hydra
Permissionless Solana crank for scheduling instructions with minimum overhead.
## Packages
| Package | Description | Version | Docs |
|:--------|:------------|:--------|:-----|
| `hydra` | Pinocchio `no_std` on-chain program | `0.1.0` | [Overview](#overview) |
| `hydra-api` | Shared Rust types, builders, and CPI helpers | `0.1.0` | [Integrating Hydra](#integrating-hydra) |
## Overview
Hydra stores a scheduled instruction in a crank PDA and lets anyone trigger it
when the schedule is due.
Each trigger transaction has two instructions:
```text
ix[k] = Hydra.Trigger
ix[k+1] = scheduled instruction
```
`Trigger` verifies `ix[k+1]` against the bytes stored in the crank account. If
the scheduled instruction fails, the whole transaction rolls back.
Key constraints:
- scheduled instructions run top-level, not via CPI
- scheduled instructions cannot require signer metas
- `Trigger` is top-level only
## Motivation
Hydra is not a general-purpose automation platform. It's a minimal runner
for **permissionless scheduled instructions** — oracle ticks, AMM pokes,
public `crank()` endpoints, settle / liquidation gates that accept any
signer. Other schedulers (Clockwork, Tuktuk, …) dispatch via CPI from
their own program; Hydra instead verifies the scheduled instruction
against an on-chain template at the top level and lets the runtime
execute it as a sibling ix. No CPI frame, no dispatch overhead.
The cranker submits a plain two-instruction transaction; `Trigger`
`memcmp`s `ix[k+1]` against the bytes stored on the crank PDA at
`Create` time (~60 CU), collects the reward, and advances state.
Solana transaction atomicity handles failure — if the scheduled
instruction reverts, the whole tx reverts and Hydra's payout / state
advance revert with it. The scheduled instruction itself runs top-level
and gets the full CU budget and stack depth.
## Compute Units
Measured with `logging` disabled:
| Instruction | Hydra CU |
|---|---:|
| `Create` | 3292 |
| `Trigger` | 464 |
| `Trigger` (reject: no follow-up) | 378 |
| `Cancel` | 128 |
| `Close` (reject: healthy) | 139 |
| `Close` (underfunded) | 150 |
Reproduce:
```sh
cargo build-sbf --manifest-path programs/hydra/Cargo.toml
cargo build-sbf --manifest-path tests/programs/noop/Cargo.toml
cargo test -p hydra-tests cu_table -- --ignored --nocapture
```
## Build
```sh
# Build the on-chain program.
cargo build-sbf --manifest-path programs/hydra/Cargo.toml
# Build the cranker.
cargo build -p hydra-cranker
# Run the test suite.
cargo test -p hydra-tests
```
## Integrating Hydra
Use `hydra-api` from clients or from your own on-chain program.
| Use case | Feature | API |
|---|---|---|
| Host-side client | `client` | `Instruction` builders |
| `solana-program` / Anchor CPI | `cpi-native` | `hydra_api::cpi::native::*` |
| Pinocchio CPI | `cpi-pinocchio` | `hydra_api::cpi::pinocchio::*` |
`Trigger` is not exposed as a CPI helper. It must be sent as a top-level
instruction.
Examples:
- `examples/native`
- `examples/anchor`
- `examples/pinocchio`
## Creating a Crank
```rust
use hydra_api::instruction::{self as ix, CreateArgs};
let seed = [0x42u8; 32];
let (crank, _bump) = ix::find_crank_pda(&seed);
let create = ix::create(
payer_pubkey,
crank,
&CreateArgs {
seed,
authority: [0u8; 32],
start_slot: 0,
interval_slots: 400,
remaining: 0,
priority_tip: 2_500,
cu_limit: 0, // 0 = cranker omits SetComputeUnitLimit; cap 1_400_000
scheduled_program_id: memo::ID,
scheduled_metas: &[],
scheduled_data: b"tick",
},
);
```
## Authenticating a Crank PDA
Scheduled instructions run top-level, so a target program cannot rely on Hydra
CPI signer privileges. If the scheduled ix needs to authenticate a Hydra crank,
include the crank PDA and instructions sysvar in the scheduled ix and verify
the sibling instructions in both directions.
Hydra does the forward check: `Trigger` reads the instructions sysvar and
requires `ix[k+1]` to byte-match the scheduled ix stored in the crank PDA. The
scheduled program can do the reverse check: read the current instruction index,
load `ix[k-1]`, and require it to be Hydra `Trigger` for the same crank PDA.
```text
ix[k-1] = Hydra.Trigger(crank = expected_crank_pda, ...)
ix[k] = your scheduled ix(crank = expected_crank_pda, instructions_sysvar, ...)
```
In the scheduled program, reject unless:
- `expected_crank_pda == Pubkey::find_program_address([b"crank", seed], hydra_id)`
- `crank.owner == hydra_id`
- the previous ix program id is `hydra_id`
- the previous ix discriminator is `Trigger`
- the previous ix first account is the same crank PDA
If the scheduled ix also needs to verify who created the schedule, read the
crank header, for example with `hydra_api::state::load_crank`, and check both
`authority` and `authority_signer`. `authority` is the value supplied at
`Create`; `authority_signer == 1` means the `Create` payer/signer was that
same authority. Require `authority == expected_authority` and
`authority_signer == 1` when scheduler identity matters. If
`authority_signer == 0`, the authority is only stored for cancellation and is
not proof that the authority signed the schedule creation.
## Costs
A crank has two upfront costs and a small per-trigger fee:
| | Amount | What happens to it |
|---|---|---|
| **Rent deposit** | ~0.002 SOL | Locked while the crank lives, refunded on close |
| **Create tx fee** | 5,000 lamports | Standard Solana base fee |
| **Per trigger** | 10,000 lamports + `priority_tip` | Drawn from the crank's balance, paid to the cranker |
The rent deposit scales with the scheduled instruction's size — ~0.002 SOL
for a minimal ix, up to ~0.003 SOL with a handful of accounts and a bit of
data. **You get it back**: `Cancel` refunds 100% to the authority; `Close`
refunds everything minus a 10,000-lamport cleanup bounty (≈99.5 – 99.7% of
the deposit).
Fund future triggers by sending a `system_program::transfer` to the crank
PDA — typically in the same transaction as `Create` — sized to
`runs × (10,000 + priority_tip)`. If the crank runs out of lamports,
`Trigger` stops firing before it can touch the rent deposit, so that
deposit is always recoverable.
## Running the Cranker
The cranker is event-driven and uses WebSocket subscriptions for account and
slot updates. Optionally, a Yellowstone gRPC endpoint can be wired in
alongside the WS subs (`--grpc-url`) for redundancy and lower latency.
```sh
# Devnet
hydra-cranker --keypair ~/.config/solana/cranker.json
# Custom RPC / WebSocket endpoints
hydra-cranker \
--keypair ~/.config/solana/cranker.json \
--rpc-url https://your.rpc.example \
--ws-url wss://your.rpc.example
# With Prometheus metrics at http://0.0.0.0:9100/metrics
hydra-cranker \
--keypair ~/.config/solana/cranker.json \
--prometheus-port 9100
# With a Yellowstone gRPC endpoint **in addition to** the WS subscriptions.
# Account + slot updates flow into the same cache and slot tick channel —
# whichever transport delivers first wins, the other is a redundant backstop.
hydra-cranker \
--keypair ~/.config/solana/cranker.json \
--grpc-url https://your.grpc.example:10000 \
--grpc-x-token your-optional-x-token
```
### Metrics
When `--prometheus-port ` is set the cranker serves `/metrics` in
Prometheus text format on `0.0.0.0:`. All series are namespaced
`hydra_cranker_*` and pre-initialised so `rate()` works from scrape 1.
| Metric | Type | Labels | Meaning |
|---|---|---|---|
| `cranks_cached` | gauge | — | Cranks currently in the in-memory cache. |
| `current_slot` | gauge | — | Last slot observed from `slotSubscribe`. |
| `eligible_now` | gauge | — | Cranks eligible to trigger on the last slot tick. |
| `triggers_submitted_total` | counter | `result={ok,err}` | Triggers submitted. |
| `ws_reconnects_total` | counter | `source={program,slot}` | WS (re)connect attempts. |
| `grpc_reconnects_total` | counter | `source={program,slot}` | Yellowstone gRPC (re)connect attempts (only when `--grpc-url` is set). |
| `cache_events_total` | counter | `kind={insert,update,remove}` | Cache mutations driven by `programSubscribe`. |
| `sweep_duration_seconds` | histogram | — | Wall time per slot-tick sweep (scan + fire). Buckets target sub-10 ms. |
| `rpc_errors_total` | counter | `op={get_program_accounts,get_latest_blockhash,send_transaction}` | RPC call errors, by failing operation. |
Useful alerts:
- `increase(hydra_cranker_current_slot[1m]) < 100` — WS wedged.
- `hydra_cranker_cranks_cached == 0` and `hydra_cranker_ws_reconnects_total > 2` — not subscribed / flaky endpoint.
- `rate(hydra_cranker_triggers_submitted_total{result="err"}[5m]) / rate(hydra_cranker_triggers_submitted_total[5m]) > 0.5` — majority of triggers failing.
- `hydra_cranker_eligible_now > 0` for >30 s with no `rate(triggers_submitted_total[1m])` — have work, not doing it.
- `histogram_quantile(0.99, rate(hydra_cranker_sweep_duration_seconds_bucket[5m])) > 0.05` — sweep p99 > 50 ms, perf regression or cache bloat.
- `rate(hydra_cranker_rpc_errors_total[5m]) > 0.1` — RPC endpoint failing a notable fraction of calls.
## Instruction Reference
| Disc | Name | Accounts | Data |
|---:|---|---|---|
| 0 | `Create` | `payer(w,s), crank(w), system_program` | schedule payload |
| 1 | `Trigger` | `crank(w), cranker(w,s), instructions_sysvar` | none |
| 2 | `Cancel` | `authority(s), crank(w), recipient(w)` | none |
| 3 | `Close` | `reporter(s,w), crank(w), recipient(w)` | none |
To add lamports to a live crank, send a plain `system_program::transfer` to
the crank PDA — no dedicated instruction exists.
## Limits
- `Trigger` is top-level only
- scheduled instructions cannot include signer metas
- `MAX_ACCOUNTS = 32`
- `MAX_DATA_LEN = 1024`
- reward is fixed at `10_000` lamports plus the stored priority tip
## Releasing
`hydra-api` is the only crate published to crates.io (`hydra` is a program,
not a library; `hydra-cranker` / the examples are workspace-local).
Release flow:
1. Bump `[workspace.package] version` in the root `Cargo.toml` (e.g. `0.1.1`).
2. Commit + tag with a matching `vX.Y.Z` tag and push both.
3. Create a GitHub release from that tag.
`.github/workflows/release.yml` triggers on `release: published`, verifies
the tag matches `hydra-api`'s manifest version, dry-runs the package, then
`cargo publish -p hydra-api`. Requires a `CARGO_REGISTRY_TOKEN` repo
secret (a crates.io API token scoped to publish-new + publish-update).
## License
MIT