Skip to content

Latest commit

 

History

22 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

Hydra logo

Hydra

Permissionless Solana crank for scheduling instructions with minimum overhead.

Packages

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

Overview

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
  • 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 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.

Compute Units

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-table

Build

Run 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 main hydra-tests suite.
  • 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 — install cargo-nextest and 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 ci

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::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/native
  • examples/anchor
  • examples/pinocchio

Creating a Crank

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",
        }],
    },
);

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 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.

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

Install

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 silicon

npm'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-cranker

Usage

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.

# 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-token

Metrics

When --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 == 0 and hydra_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 > 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

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, in programs/hydra.
  • hydra-ephemeral (eHyd5…) — the ephemeral-rollup crank, in programs/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.

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

Ephemeral Rollup Crank (hydra-ephemeral program)

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_REWARD is 0 (against 10_000 on base) and the crank's priority_tip is the cranker's only incentive. A crank created with priority_tip: 0 pays its cranker nothing, and the flat Close bounty is likewise 0 — 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_min is 0. The crank still holds a plain lamport balance (funded by the sponsor) that funds the cranker rewards, exactly like base; the only Trigger floor is being able to afford the reward.
  • Creation/teardown go through the Magic program. The Magic program materializes the ephemeral account synchronously, so Create allocates the crank (via a Magic CPI signed by the crank PDA) and writes its header + scheduled-ix tail in one instruction. Cancel/Close first drain the crank's leftover lamports to a recipient (the same bounty/refund split as base), then CPI Magic close to deallocate the account and refund the vault rent to the teardown's signer.

Lifecycle: CreateTrigger (+ 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.

Instruction Reference (ephemeral)

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 },
);

Live end-to-end test (tests/e2e)

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-e2e

tests/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.

Releasing

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:

  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

About

Permissionless Solana crank for scheduling instructions with minimum overhead.

Resources

Stars

18 stars

Watchers

0 watching

Forks

Releases

Contributors

Languages