| Package | Description | Version | Docs |
|---|---|---|---|
hydra |
Pinocchio no_std on-chain program |
0.2.0 |
Overview |
hydra-api |
Shared Rust types, builders, and CPI helpers | 0.2.0 |
Integrating Hydra |
Hydra stores one or more scheduled instructions in a crank PDA and lets anyone trigger them when the schedule is due.
Each trigger transaction places the scheduled instructions immediately after
Trigger:
ix[k] = Hydra.Trigger
ix[k+1] = scheduled instruction 1
ix[k+2] = scheduled instruction 2
…
ix[k+n] = scheduled instruction n
Trigger verifies ix[k+1..=k+n] against the bytes stored in the crank
account. Because the instructions sysvar lays instruction blobs out
contiguously, this verification is a single memcmp regardless of n. If any
scheduled instruction fails, the whole transaction rolls back.
Key constraints:
- scheduled instructions run top-level, not via CPI
- scheduled instructions cannot require signer metas
- the scheduled instructions must be contiguous and in order, right after
Trigger - a crank holds at most
MAX_INSTRUCTIONS(16) scheduled instructions Triggeris top-level only
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 transaction (Trigger followed by the scheduled
instructions); Trigger memcmps ix[k+1..] against the bytes stored on the
crank PDA at Create time (~60 CU), collects the reward, and advances state.
The reward and the schedule advance are flat per Trigger, independent of how
many instructions the crank holds. Solana transaction atomicity handles failure
— if any scheduled instruction reverts, the whole tx reverts and Hydra's payout
/ state advance revert with it. The scheduled instructions themselves run
top-level and get the full CU budget and stack depth.
Measured with logging disabled:
| Instruction | Hydra CU |
|---|---|
Create |
5634 |
Trigger (happy, 1 sibling) |
466 |
Trigger (happy, 3 siblings) |
466 |
Trigger (reject: no follow-up) |
379 |
Cancel |
141 |
Close (reject: healthy) |
270 |
Close (underfunded) |
300 |
Trigger costs the same whether the crank schedules one instruction or many —
the single concatenated memcmp is the entire verification, so adding
instructions adds no Hydra-side CU. Create scales with the total scheduled
payload size (it is a one-time cost dominated by the account-creation syscall).
Reproduce:
make cu-tableRun make help to list the available targets. Common commands:
make build— build the base and ephemeral on-chain programs.make fmt/make fmt-check— format Rust sources or check formatting.make lint— run clippy for the workspace, plus the Anchor example check.make test— run the mainhydra-testssuite.make test-examples— run the native and Pinocchio example tests.make test-e2e— run the live ephemeral-rollup e2e test.make cu-table— reproduce the compute-unit table.make ci— run the full CI job locally (includes the live e2e test).make install-tools— installcargo-nextestand the MagicBlock validators.make clean— remove Cargo build artifacts.
# Show available build, lint, and test targets.
make help
# Build the on-chain programs.
make build
# Build the cranker.
cargo build -p hydra-cranker
# Run the main test suite.
make test
# Run the default CI checks locally.
make ciUse 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::base::native::* |
| Pinocchio CPI | cpi-pinocchio |
hydra_api::cpi::base::pinocchio::* |
Both the builders and the CPI helpers are split by target ledger: base::*
drives the base-layer program, and ephemeral::* (behind the extra ephemeral
feature) drives the ephemeral-rollup
program.
Trigger is not exposed as a CPI helper. It must be sent as a top-level
instruction.
Examples:
examples/nativeexamples/anchorexamples/pinocchio
use hydra_api::instruction::{base as ix, CreateArgs, ScheduledIx};
let seed = [0x42u8; 32];
let (crank, _bump) = ix::find_crank_pda(&payer_pubkey, &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
// One or more scheduled ixs, run top-level in order after `Trigger`.
scheduled: &[ScheduledIx {
program_id: memo::ID,
metas: &[],
data: b"tick",
}],
},
);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 instructions sysvar in the scheduled ix and verify the sibling instructions in both directions.
The crank PDA itself must not be one of the scheduled ix's accounts: it is
writable in Trigger, so the runtime promotes it to writable in every ix region
and the stored read-only/writable template could never match, leaving the crank
un-triggerable. Create does not reject this (nor other un-crankable
schedules) — it is the client builder's responsibility; see
CreateArgs in hydra-api::instruction for the full list of caller rules
(consistent writability per account, no crank/cranker metas, tx lock budget).
The scheduled program instead learns the crank from ix[k-1] via the sysvar.
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.
ix[k-1] = Hydra.Trigger(crank = expected_crank_pda, ...)
ix[k] = your scheduled ix(instructions_sysvar, ...) // crank PDA not an account
In the scheduled program, reject unless:
expected_crank_pda == Pubkey::find_program_address([b"crank", payer, seed], hydra_id)(payer-bound; the legacy[b"crank", seed]derivation is deprecated)- the previous ix program id is
hydra_id - the previous ix discriminator is
Trigger - the previous ix first account is the same crank PDA
The transaction is atomic, so a successful Trigger at ix[k-1] has already
verified the crank is Hydra-owned and due — no separate crank.owner check is
needed.
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.
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.
Each release publishes a prebuilt hydra-cranker binary as a per-platform npm
package, plus the same binaries as GitHub release assets. Pick the package that
matches your machine:
npm install -g @magicblock-labs/hydra-cranker-linux-x64 # linux, x86_64
npm install -g @magicblock-labs/hydra-cranker-linux-arm64 # linux, aarch64
npm install -g @magicblock-labs/hydra-cranker-darwin-x64 # macOS, Intel
npm install -g @magicblock-labs/hydra-cranker-darwin-arm64 # macOS, Apple siliconnpm's os/cpu metadata pins each package to its platform, so listing them all
as optionalDependencies also works — npm installs only the one that applies.
Or build from source. CARGO_PROFILE_RELEASE_LTO=false turns off the thin LTO
the workspace enables for the on-chain programs — on a host build it makes rustc
embed LLVM bitcode that Apple's linker cannot parse, and the macOS link fails.
Point --tag at the release you want, or drop it to build the tip of main:
CARGO_PROFILE_RELEASE_LTO=false \
cargo install --git https://github.com/magicblock-labs/hydra --tag v0.2.0 hydra-crankerThe 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.
# 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
# Against a MagicBlock ephemeral rollup. `--ephemeral` switches the target
# program, the `Close` account layout, and the (zero-lamport) funding model at
# runtime — the same binary drives either program, no rebuild needed.
# `--rpc-url` points at the rollup; `--base-rpc-url` is required alongside it,
# because the cranker delegates its own keypair at startup so the rollup will
# let its balance change as the trigger fee payer, and delegating is a
# base-layer transaction. If the delegation is later released out from under it
# — triggers start failing with `InvalidAccountForFee` — the cranker re-delegates
# itself and carries on. On shutdown it releases that delegation, but only if it
# was the one to take it; a keypair already delegated at startup is left alone,
# since something else owns that delegation.
hydra-cranker \
--keypair ~/.config/solana/cranker.json \
--rpc-url https://your.rollup.example \
--base-rpc-url https://your.rpc.example \
--ephemeral
# With Prometheus metrics at http://0.0.0.0:9100/metrics
# and JSON health at http://0.0.0.0:9100/healthz
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-tokenWhen --prometheus-port <PORT> is set the cranker serves /metrics in
Prometheus text format and /healthz in JSON on 0.0.0.0:<PORT>. All series
are namespaced hydra_cranker_* and pre-initialised so rate() works from
scrape 1.
/healthz returns 200 while the slot stream is fresh and no eligible crank is
parked after repeated failures. It returns 503 before the first slot, when the
last slot sweep is older than 30 seconds, when eligible cranks are parked, or
when triggerable cranks were not attempted on the latest sweep.
| 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. |
triggerable_now |
gauge | — | Eligible cranks after local cooldown/backoff filtering. |
parked_now |
gauge | — | Eligible cranks parked after repeated failures at the same next_exec_slot. |
max_overdue_slots |
gauge | — | Largest current_slot - next_exec_slot among currently eligible cranks. |
triggers_submitted_total |
counter | result={ok,err} |
Triggers submitted. |
closes_submitted_total |
counter | result={ok,err} |
Permissionless Close transactions 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 == 0andhydra_cranker_ws_reconnects_total > 2— not subscribed / flaky endpoint.hydra_cranker_parked_now > 0— at least one eligible crank repeatedly failed and is no longer being retried.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 > 0for >30 s with norate(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.
Hydra ships as two on-chain programs that share the same discriminators
(0–3) and on-chain Crank layout, distinguished by program ID:
hydra(Hydra17…) — the base-layer crank, inprograms/hydra.hydra-ephemeral(eHyd5…) — the ephemeral-rollup crank, inprograms/hydra-ephemeral.
The instruction-parsing, tail-serialization and follow-up-verification logic is
shared between them via [hydra_api::program] (behind the api crate's program
feature); each program only carries its own account-funding model.
The base-layer (hydra) instructions:
| 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.
Triggeris top-level only- scheduled instructions cannot include signer metas
MAX_ACCOUNTS = 32MAX_DATA_LEN = 1024- reward is fixed at
10_000lamports plus the stored priority tip
The separate hydra-ephemeral program (ID eHyd5…, crate
programs/hydra-ephemeral) runs a crank on a
MagicBlock ephemeral rollup (ER), where the crank
lives as a MagicBlock ephemeral account instead of a base-layer PDA. The
base-layer hydra program neither contains this code nor depends on
ephemeral-rollups-pinocchio; the two programs share their schedule/verify
logic through [hydra_api::program].
The economic model follows the same shape as the base crank — the cranker is
paid CRANKER_REWARD + priority_tip out of the crank's lamport balance on
Trigger, and Cancel/Close pay out the leftover balance — with three
differences the ER forces:
- The tip is the whole reward. ER transactions carry no base fee, so
consts::ephemeral::CRANKER_REWARDis0(against10_000on base) and the crank'spriority_tipis the cranker's only incentive. A crank created withpriority_tip: 0pays its cranker nothing, and the flatClosebounty is likewise0— a reporter tearing down an unowned crank is paid by the vault rent refund, not by the crank balance. - Vault rent is separate from the crank balance. The ephemeral account's rent
(a flat per-byte fee) is paid by a sponsor into a shared vault, not held in the
account, so the crank's stored
rent_minis0. The crank still holds a plain lamport balance (funded by the sponsor) that funds the cranker rewards, exactly like base; the onlyTriggerfloor is being able to afford the reward. - Creation/teardown go through the Magic program. The Magic program
materializes the ephemeral account synchronously, so
Createallocates the crank (via a Magic CPI signed by the crank PDA) and writes its header + scheduled-ix tail in one instruction.Cancel/Closefirst drain the crank's leftover lamports to arecipient(the same bounty/refund split as base), then CPI Magiccloseto deallocate the account and refund the vault rent to the teardown's signer.
Lifecycle: Create → Trigger (+ scheduled siblings, run top-level on the ER) →
Cancel (authority-gated) / Close (exhausted, underfunded, or stuck). On
teardown the leftover balance refunds to recipient and the vault rent refunds
to whoever signs the teardown. Because the Magic program refunds the vault rent
to the teardown's signer, Close is only permissionless for unowned cranks
(authority == 0): when a non-zero authority is set, only that authority may
Close the crank (and only that authority may be the recipient), so the whole
teardown — leftover balance and vault rent — stays with the owner rather than an
arbitrary reporter. Unowned cranks stay permissionlessly closable by
anyone. The crank PDA derivation ([b"crank", seed]) and the on-chain Crank
layout are unchanged, so the template / verification model is identical.
Same discriminators as the base program (0–3); the account shapes differ
because creation/close go through the Magic program rather than the System
program.
| Disc | Name | Accounts | Data |
|---|---|---|---|
| 0 | Create |
sponsor(w,s), crank(w), vault(w), magic_program |
schedule payload |
| 1 | Trigger |
crank(w), cranker(w,s), instructions_sysvar |
none |
| 2 | Cancel |
authority(w,s), crank(w), recipient(w), vault(w), magic_program |
none |
| 3 | Close |
reporter(w,s), crank(w), recipient(w), vault(w), magic_program |
none |
vault is the ephemeral rent vault and magic_program is MagicBlock's Magic
program; hydra-api re-exports both under its ephemeral feature as
consts::magic::EPHEMERAL_VAULT_ID / MAGIC_PROGRAM_ID, and the
client-feature builder fills them in. sponsor must
be an account delegated to the ER (it pays the rent and sets authority_signer).
Build an ephemeral Create with the same CreateArgs as base create:
use hydra_api::instruction::{ephemeral as ix, CreateArgs, ScheduledIx};
let (crank, _bump) = ix::find_crank_pda(&seed);
let create = ix::create(
sponsor_pubkey,
crank,
&CreateArgs { seed, authority, start_slot: 0, interval_slots: 50,
remaining: 0, priority_tip: 0, cu_limit: 0, scheduled },
);The mollusk tests stub the rollup out. tests/e2e instead boots the real
three-process stack — mb-test-validator (base L1), ephemeral-validator (the
rollup), and hydra-cranker — creates a few ephemeral cranks, and asserts the
cranker fires each one on schedule.
The validators ship as an npm package; mb-test-validator wraps
solana-test-validator, so the Solana/Anza toolchain must also be installed:
make install-validators # npm install -g the pinned mb-test-validator + ephemeral-validator
# The test is `#[ignore]` (it spawns external validators); run it explicitly.
# `make test-e2e` builds the on-chain artifacts the rollup clones first. The
# hydra-cranker is built automatically by the test and run with `--ephemeral`.
make test-e2etests/e2e is its own workspace (it is excluded from the root one), so
make lint and make test skip it — make lint-e2e clippies it separately.
make test-all and make ci do run it, so both need the validators installed;
CI does the same in its default job.
hydra-api is the only crate published to crates.io (hydra and
hydra-ephemeral are programs, not libraries; hydra-cranker / the examples
are workspace-local).
Release flow:
- Bump
[workspace.package] versionin the rootCargo.toml(e.g.0.1.1). - Commit + tag with a matching
vX.Y.Ztag and push both. - 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).
MIT
