diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..a1e5414 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,118 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + +# Cancel superseded runs on the same ref to save runner minutes. +concurrency: + group: ci-${{ github.ref }} + cancel-in-progress: true + +env: + CARGO_TERM_COLOR: always + RUSTFLAGS: -D warnings + # Pinned Solana/Agave toolchain that ships `cargo build-sbf`. + SOLANA_VERSION: stable + +jobs: + # --------------------------------------------------------------------------- + # Formatting — covers all root workspace members, including `tests/e2e`. + # --------------------------------------------------------------------------- + fmt: + name: rustfmt + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Install Rust (rustfmt) + uses: dtolnay/rust-toolchain@master + with: + toolchain: 1.91.1 + components: rustfmt + + - name: Check formatting + run: make fmt-check + + # --------------------------------------------------------------------------- + # Default feature set: lint + tests with the `ephemeral` feature OFF. + # --------------------------------------------------------------------------- + default: + name: lint + test (default) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Install Rust (clippy) + uses: dtolnay/rust-toolchain@master + with: + toolchain: 1.91.1 + components: clippy,rustfmt + + - name: Install Solana toolchain (build-sbf) + run: | + sh -c "$(curl -sSfL https://release.anza.xyz/${SOLANA_VERSION}/install)" + echo "$HOME/.local/share/solana/install/active_release/bin" >> "$GITHUB_PATH" + + - name: Install cargo-nextest + uses: taiki-e/install-action@nextest + + - uses: Swatinem/rust-cache@v2 + + - name: Default CI checks + run: make ci + + # --------------------------------------------------------------------------- + # Live end-to-end: boots the real three-process stack (mb-test-validator + + # ephemeral-validator + hydra-cranker) and asserts ephemeral cranks fire on + # schedule. The validators ship as the `@magicblock-labs/ephemeral-validator` + # npm package (mb-test-validator wraps `solana-test-validator`, which the Anza + # toolchain provides). The `tests/e2e` crate is its own `[workspace]`. + # --------------------------------------------------------------------------- + e2e: + name: e2e (ephemeral cranks, live validators) + runs-on: ubuntu-latest + env: + # Pin to the version the test was developed against. + EPHEMERAL_VALIDATOR_VERSION: 0.13.3 + steps: + - uses: actions/checkout@v4 + + - name: Install Rust (clippy) + uses: dtolnay/rust-toolchain@master + with: + toolchain: 1.91.1 + components: clippy + + - name: Install Solana toolchain (build-sbf + solana-test-validator) + run: | + sh -c "$(curl -sSfL https://release.anza.xyz/${SOLANA_VERSION}/install)" + echo "$HOME/.local/share/solana/install/active_release/bin" >> "$GITHUB_PATH" + + - name: Install Node + uses: actions/setup-node@v4 + with: + node-version: 20 + + - name: Install MagicBlock validators (mb-test-validator + ephemeral-validator) + run: | + npm install -g "@magicblock-labs/ephemeral-validator@${EPHEMERAL_VALIDATOR_VERSION}" + mb-test-validator --version + ephemeral-validator --version + + - uses: Swatinem/rust-cache@v2 + with: + workspaces: | + . + tests/e2e + + - name: Clippy — e2e crate + run: make lint-e2e + + - name: Test — live ephemeral cranks + run: | + sudo prlimit --pid $$ --nofile=1048576:1048576 + sudo sysctl fs.inotify.max_user_instances=1280 + sudo sysctl fs.inotify.max_user_watches=655360 + make test-e2e diff --git a/.gitignore b/.gitignore index 99e44a6..c9fc12c 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,4 @@ -/target +target/ # Anchor example is its own workspace with its own target dir. /examples/anchor/target Cargo.lock diff --git a/Cargo.toml b/Cargo.toml index f3c843a..7ec701b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,9 +3,11 @@ resolver = "2" default-members = ["programs/hydra"] members = [ "programs/hydra", + "programs/hydra-ephemeral", "crates/hydra-api", "crates/hydra-cranker", "tests", + "tests/e2e", "tests/programs/noop", "examples/native", "examples/pinocchio", @@ -22,21 +24,32 @@ license = "MIT" repository = "https://github.com/magicblock-labs/hydra" [workspace.dependencies] -pinocchio = { version = "0.11", features = ["cpi"] } -pinocchio-system = "0.6" +pinocchio = { version = "0.10", features = ["cpi"] } +pinocchio-system = "0.5" pinocchio-log = "0.5" -solana-address = { version = "2.6", features = ["decode", "curve25519"] } +# CPI helpers for MagicBlock ephemeral accounts (used by the ephemeral-crank +# instructions). Tracks pinocchio 0.10, which is why the program is pinned to +# 0.10 rather than 0.11. +ephemeral-rollups-pinocchio = "0.15" +solana-address = { version = "2", features = ["decode", "curve25519"] } solana-define-syscall = "5.0" hydra-api = { path = "crates/hydra-api" } # tests only: mollusk-svm = "=0.12.1-agave-4.0" mollusk-svm-programs-memo = "=0.12.1-agave-4.0" -solana-svm-log-collector = { version = "4.0.0-beta", features = ["agave-unstable-api"] } +solana-svm-log-collector = { version = "4.0.0-beta", features = [ + "agave-unstable-api", +] } solana-logger = "3" -solana-account = "3.0" solana-instruction = "3.0" solana-pubkey = "4.0" +solana-account = "3.0" +solana-keypair = "3.1" +solana-message = "3.0.1" +solana-signer = "3.0" +solana-transaction = "3.0" +solana-hash = "3.1.0" solana-system-interface = { version = "2.0", features = ["bincode"] } [profile.release] @@ -47,5 +60,4 @@ overflow-checks = true [workspace.lints.rust] unexpected_cfgs = { level = "warn", check-cfg = [ 'cfg(target_os, values("solana"))', - 'cfg(feature, values("create-account-allow-prefund", "custom-alloc", "custom-panic", "no-entrypoint", "logging"))', ] } diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..41ca2ad --- /dev/null +++ b/Makefile @@ -0,0 +1,108 @@ +# Hydra — build & test commands. +# +# Quickstart: +# make build # compile the on-chain programs (needed before tests) +# make test # run the hydra-tests suite +# make ci # everything the default CI job runs, locally +# +# Run `make` or `make help` for the full list. +# +# Toolchain prerequisites: +# - Rust + `cargo build-sbf` (Solana/Anza toolchain) +# - cargo-nextest -> `make install-tools` +# - anchor CLI -> only for `make build-examples` +# - @magicblock-labs/ephemeral-validator (npm) -> only for `make test-e2e` + +SHELL := /bin/bash +.DEFAULT_GOAL := help + +# Manifests for the crates that live outside the default workspace build. +BASE_MANIFEST := programs/hydra/Cargo.toml +EPHEMERAL_MANIFEST := programs/hydra-ephemeral/Cargo.toml +NOOP_MANIFEST := tests/programs/noop/Cargo.toml +NATIVE_MANIFEST := examples/native/Cargo.toml +PINOCCHIO_MANIFEST := examples/pinocchio/Cargo.toml +ANCHOR_MANIFEST := examples/anchor/Cargo.toml +E2E_MANIFEST := tests/e2e/Cargo.toml + +CLIPPY := --all-targets -- -D warnings + +.PHONY: help +help: ## Show this help + @grep -hE '^[a-zA-Z0-9_-]+:.*?## ' $(MAKEFILE_LIST) \ + | awk 'BEGIN{FS=":.*?## "}{printf " \033[36m%-16s\033[0m %s\n", $$1, $$2}' + +# --------------------------------------------------------------------------- +# Build — on-chain SBF programs (artifacts land in target/deploy/*.so). +# --------------------------------------------------------------------------- +.PHONY: build build-base build-ephemeral build-noop build-examples + +build: build-base build-ephemeral ## build-sbf the base + ephemeral hydra programs + +build-base: build-noop ## build-sbf the base hydra program + cargo build-sbf --manifest-path $(BASE_MANIFEST) + +build-ephemeral: build-noop ## build-sbf the ephemeral-rollup hydra program + cargo build-sbf --manifest-path $(EPHEMERAL_MANIFEST) + +build-noop: ## build-sbf the noop test program (target of scheduled ixs) + cargo build-sbf --manifest-path $(NOOP_MANIFEST) + +build-examples: ## build-sbf the native + pinocchio example programs + cargo build-sbf --manifest-path $(NATIVE_MANIFEST) + cargo build-sbf --manifest-path $(PINOCCHIO_MANIFEST) + cd examples/anchor && anchor build + +# --------------------------------------------------------------------------- +# Format & lint (mirrors the fmt + default CI jobs). +# --------------------------------------------------------------------------- +.PHONY: fmt fmt-check lint lint-e2e lint-ephemeral + +fmt: ## Format the workspace + cargo fmt --all + +fmt-check: ## Check formatting without writing (CI) + cargo fmt --all --check + +lint: ## Clippy the workspace and check the excluded anchor example + cargo clippy --workspace $(CLIPPY) + cargo check --manifest-path $(ANCHOR_MANIFEST) --all-targets + +lint-e2e: ## Clippy only the e2e crate + cargo clippy --manifest-path $(E2E_MANIFEST) $(CLIPPY) + +# --------------------------------------------------------------------------- +# Test. `hydra-tests` and the example mollusk tests load the compiled .so +# files at runtime, so the build targets are prerequisites. +# --------------------------------------------------------------------------- +.PHONY: test test-examples test-e2e test-all bench cu-table + +test: build-base ## Run the hydra-tests suite (unit + integration, via nextest) + cargo nextest run -p hydra-tests + +test-examples: build-base build-examples ## Run the native + pinocchio example mollusk tests + cargo nextest run -p hydra-example-native -p hydra-example-pinocchio + +test-e2e: build-ephemeral build-noop ## Live e2e: spawns validators + cranker (needs the ephemeral-validator npm pkg) + cargo test --manifest-path $(E2E_MANIFEST) -- --ignored --nocapture --test-threads=1 + +test-all: test test-examples test-e2e ## Run hydra-tests, examples, and live e2e + +bench: build-base ## Run the compute-unit benchmarks + cargo bench -p hydra-tests + +cu-table: build-base ## Print the per-instruction CU table (the ignored cu_table test) + cargo test -p hydra-tests cu_table -- --ignored --nocapture + +# --------------------------------------------------------------------------- +# Aggregate / housekeeping. +# --------------------------------------------------------------------------- +.PHONY: ci install-tools clean + +ci: fmt-check lint build test ## Run the default CI job locally (fmt-check + lint + build + test) + +install-tools: ## Install cargo-nextest (Solana/anchor/node toolchains are installed separately) + cargo install cargo-nextest --locked + +clean: ## Remove Cargo build artifacts + cargo clean diff --git a/README.md b/README.md index d93e112..362762f 100644 --- a/README.md +++ b/README.md @@ -81,22 +81,39 @@ payload size (it is a one-time cost dominated by the account-creation syscall). 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 +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 default CI checks locally. +- `make install-tools` — install `cargo-nextest`. +- `make clean` — remove Cargo build artifacts. + ```sh -# Build the on-chain program. -cargo build-sbf --manifest-path programs/hydra/Cargo.toml +# 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 test suite. -cargo test -p hydra-tests +# Run the main test suite. +make test + +# Run the default CI checks locally. +make ci ``` ## Integrating Hydra @@ -121,7 +138,7 @@ Examples: ## Creating a Crank ```rust -use hydra_api::instruction::{self as ix, CreateArgs, ScheduledIx}; +use hydra_api::instruction::{base as ix, CreateArgs, ScheduledIx}; let seed = [0x42u8; 32]; let (crank, _bump) = ix::find_crank_pda(&seed); @@ -231,6 +248,14 @@ hydra-cranker \ --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. +hydra-cranker \ + --keypair ~/.config/solana/cranker.json \ + --rpc-url https://your.rollup.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 \ @@ -258,21 +283,21 @@ 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. | +| 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: @@ -286,6 +311,20 @@ Useful alerts: ## 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](#ephemeral-rollup-crank-hydra-ephemeral-program), 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 | @@ -304,10 +343,105 @@ the crank PDA — no dedicated instruction exists. - `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](https://magicblock.gg) **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 is identical to 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 two differences the ER +forces: + +- **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 + sponsor. + +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 — bounty, 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` exposes both 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`: + +```rust +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 }, +); +``` + +### Build & test + +#### Live end-to-end test (`tests/e2e`) + +`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: + +```sh +npm install -g @magicblock-labs/ephemeral-validator # 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 +``` + +CI runs this as the `e2e` job in `.github/workflows/ci.yml`. + ## 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). +`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: diff --git a/crates/hydra-api/Cargo.toml b/crates/hydra-api/Cargo.toml index d091b3a..17f1663 100644 --- a/crates/hydra-api/Cargo.toml +++ b/crates/hydra-api/Cargo.toml @@ -23,11 +23,18 @@ cpi-native = [ # On-chain CPI helpers for Pinocchio callers. Wraps # `pinocchio::cpi::invoke_signed` with `InstructionView`s built on the # stack (no allocations, usable from `no_std` programs). -cpi-pinocchio = ["pinocchio/cpi"] +cpi-pinocchio = ["pinocchio/cpi", "dep:solana-program-error"] +# Shared on-chain processor building blocks (schedule parsing, tail +# serialization, follow-up verification, sysvar syscalls) consumed by the +# `programs/hydra` and `programs/hydra-ephemeral` program crates. Pulls in +# `solana-define-syscall` for the raw Clock / stack-height reads. +program = ["pinocchio/cpi", "dep:solana-define-syscall"] [dependencies] +ephemeral-rollups-pinocchio = { workspace = true } pinocchio = { workspace = true } solana-address = { workspace = true } +solana-define-syscall = { workspace = true, optional = true } solana-instruction = { workspace = true, optional = true } solana-pubkey = { workspace = true, optional = true } solana-account-info = { version = "3", optional = true } diff --git a/crates/hydra-api/src/consts.rs b/crates/hydra-api/src/consts.rs index e7811bc..8b091c9 100644 --- a/crates/hydra-api/src/consts.rs +++ b/crates/hydra-api/src/consts.rs @@ -4,11 +4,7 @@ /// Seed prefix for the Crank PDA: `[b"crank", seed_bytes]`. pub const CRANK_SEED_PREFIX: &[u8] = b"crank"; -/// Solana base transaction fee (lamports per signature). -pub const BASE_FEE_LAMPORTS: u64 = 5_000; - -/// Flat per-trigger reward paid to the cranker. Equals `2 × base_fee`. -pub const CRANKER_REWARD: u64 = 2 * BASE_FEE_LAMPORTS; +pub const BASE_FEE_LAMPORTS: u64 = 50; /// Max metas a single scheduled ix may declare. pub const MAX_ACCOUNTS: usize = 32; @@ -34,13 +30,6 @@ pub const CRANK_HEADER_SIZE: usize = 120; /// "don't emit `SetComputeUnitLimit`" (inherits the 200 k/ix default). pub const MAX_COMPUTE_UNIT_LIMIT: u32 = 1_400_000; -/// Slots of overdue past `next_exec_slot` after which a crank is considered -/// stuck and `Close` becomes permissionlessly callable. `next_exec_slot` only -/// advances on successful `Trigger`, so a crank whose inner ix deterministically -/// fails (or whose target is paused) would otherwise pin its rent forever. -/// ~10 days at 400 ms/slot: 10 × 86_400 / 0.4 = 2_160_000. -pub const STALENESS_THRESHOLD_SLOTS: u64 = 2_160_000; - /// Per-meta size in both the on-chain template bytes and the instructions /// sysvar wire format: `[1 flag byte][32-byte pubkey]`. pub const SERIALIZED_META_SIZE: usize = 33; @@ -58,3 +47,33 @@ pub mod ix { pub const CANCEL: u8 = 2; pub const CLOSE: u8 = 3; } + +pub mod base { + /// Solana base transaction fee (lamports per signature) on the base layer. + pub const BASE_FEE_LAMPORTS: u64 = 5_000; + /// Flat per-trigger reward paid to the cranker. Equals `2 × base_fee`. + pub const CRANKER_REWARD: u64 = 2 * BASE_FEE_LAMPORTS; + /// Base-layer slot time (milliseconds per slot). + pub const SLOT_FREQUENCY_MS: u64 = 400; + /// Slots of overdue past `next_exec_slot` after which a crank is considered + /// stuck and `Close` becomes permissionlessly callable. `next_exec_slot` only + /// advances on successful `Trigger`, so a crank whose inner ixs deterministically + /// fails (or whose target is paused) would otherwise pin its rent forever. + /// ~10 days + pub const STALENESS_THRESHOLD_SLOTS: u64 = 10 * 86_400_000 / SLOT_FREQUENCY_MS; +} + +pub mod ephemeral { + /// 100x cheaper than the base layer. + pub const BASE_FEE_LAMPORTS: u64 = 50; + /// Flat per-trigger reward paid to the cranker. Equals `2 × base_fee`. + pub const CRANKER_REWARD: u64 = 2 * BASE_FEE_LAMPORTS; + /// Ephemeral-rollup slot time (milliseconds per slot). + pub const SLOT_FREQUENCY_MS: u64 = 50; + /// Slots of overdue past `next_exec_slot` after which a crank is considered + /// stuck and `Close` becomes permissionlessly callable. `next_exec_slot` only + /// advances on successful `Trigger`, so a crank whose inner ixs deterministically + /// fails (or whose target is paused) would otherwise pin its rent forever. + /// ~10 days + pub const STALENESS_THRESHOLD_SLOTS: u64 = 10 * 86_400_000 / SLOT_FREQUENCY_MS; +} diff --git a/crates/hydra-api/src/cpi.rs b/crates/hydra-api/src/cpi.rs index 600e5fa..cc65fca 100644 --- a/crates/hydra-api/src/cpi.rs +++ b/crates/hydra-api/src/cpi.rs @@ -6,129 +6,371 @@ //! //! `Trigger` is not exposed here. It must be sent as a top-level instruction. -#[cfg(feature = "cpi-native")] -pub mod native { - //! CPI wrappers for `solana-program` / Anchor callers. - //! - //! # Example - //! - //! ```ignore - //! use hydra_api::cpi::native as hydra_cpi; - //! use hydra_api::instruction::{CreateArgs, SchedMeta}; - //! - //! // Inside your user-facing instruction's handler: - //! hydra_cpi::create( - //! payer_ai, crank_ai, system_program_ai, - //! &CreateArgs { seed, authority: [0u8; 32], /* ... */ }, - //! )?; - //! ``` - - use solana_account_info::AccountInfo; - use solana_cpi::{invoke, invoke_signed}; - use solana_program_error::ProgramError; - - use crate::instruction as builder; - - #[inline] - pub fn create<'a>( - payer: &AccountInfo<'a>, - crank: &AccountInfo<'a>, - system_program: &AccountInfo<'a>, - args: &builder::CreateArgs<'_>, - ) -> Result<(), ProgramError> { - let ix = builder::create(*payer.key, *crank.key, args); - invoke(&ix, &[payer.clone(), crank.clone(), system_program.clone()]) - } +pub mod base { + #[cfg(feature = "cpi-native")] + pub mod native { + //! CPI wrappers for `solana-program` / Anchor callers. + //! + //! # Example + //! + //! ```ignore + //! use hydra_api::cpi::native as hydra_cpi; + //! use hydra_api::instruction::{CreateArgs, SchedMeta}; + //! + //! // Inside your user-facing instruction's handler: + //! hydra_cpi::create( + //! payer_ai, crank_ai, system_program_ai, + //! &CreateArgs { seed, authority: [0u8; 32], /* ... */ }, + //! )?; + //! ``` + + use solana_account_info::AccountInfo; + use solana_cpi::invoke_signed; + use solana_program_error::ProgramError; + + use crate::instruction::{base as builder, CreateArgs}; + + #[inline] + pub fn create<'a>( + payer: &AccountInfo<'a>, + crank: &AccountInfo<'a>, + system_program: &AccountInfo<'a>, + args: &CreateArgs<'_>, + signer_seeds: &[&[&[u8]]], + ) -> Result<(), ProgramError> { + let ix = builder::create(*payer.key, *crank.key, args); + invoke_signed( + &ix, + &[payer.clone(), crank.clone(), system_program.clone()], + signer_seeds, + ) + } - /// `signer_seeds` is typically `&[&[b"authority_seed", &[bump]]]` when - /// `authority` is a PDA controlled by the integrator program, or - /// `&[]` when it's an EOA. - #[inline] - pub fn cancel<'a>( - authority: &AccountInfo<'a>, - crank: &AccountInfo<'a>, - recipient: &AccountInfo<'a>, - signer_seeds: &[&[&[u8]]], - ) -> Result<(), ProgramError> { - let ix = builder::cancel(*authority.key, *crank.key, *recipient.key); - invoke_signed( - &ix, - &[authority.clone(), crank.clone(), recipient.clone()], - signer_seeds, - ) + /// `signer_seeds` is typically `&[&[b"authority_seed", &[bump]]]` when + /// `authority` is a PDA controlled by the integrator program, or + /// `&[]` when it's an EOA. + #[inline] + pub fn cancel<'a>( + authority: &AccountInfo<'a>, + crank: &AccountInfo<'a>, + recipient: &AccountInfo<'a>, + signer_seeds: &[&[&[u8]]], + ) -> Result<(), ProgramError> { + let ix = builder::cancel(*authority.key, *crank.key, *recipient.key); + invoke_signed( + &ix, + &[authority.clone(), crank.clone(), recipient.clone()], + signer_seeds, + ) + } + + /// `signer_seeds` is typically `&[]` unless the reporter is a PDA + /// owned by the integrator program. + #[inline] + pub fn close<'a>( + reporter: &AccountInfo<'a>, + crank: &AccountInfo<'a>, + recipient: &AccountInfo<'a>, + signer_seeds: &[&[&[u8]]], + ) -> Result<(), ProgramError> { + let ix = builder::close(*reporter.key, *crank.key, *recipient.key); + invoke_signed( + &ix, + &[reporter.clone(), crank.clone(), recipient.clone()], + signer_seeds, + ) + } } - /// `signer_seeds` is typically `&[]` unless the reporter is a PDA - /// owned by the integrator program. - #[inline] - pub fn close<'a>( - reporter: &AccountInfo<'a>, - crank: &AccountInfo<'a>, - recipient: &AccountInfo<'a>, - signer_seeds: &[&[&[u8]]], - ) -> Result<(), ProgramError> { - let ix = builder::close(*reporter.key, *crank.key, *recipient.key); - invoke_signed( - &ix, - &[reporter.clone(), crank.clone(), recipient.clone()], - signer_seeds, - ) + #[cfg(feature = "cpi-pinocchio")] + pub mod pinocchio { + //! CPI wrappers for Pinocchio callers. + //! + //! Build `Create` manually. See `examples/pinocchio/`. + + use pinocchio::{ + cpi::{invoke_signed, Signer}, + instruction::{InstructionAccount, InstructionView}, + AccountView, ProgramResult, + }; + use solana_program_error::ProgramError; + + use crate::instruction as builder; + use crate::{consts::ix as disc, instruction::CREATE_FIXED_PREFIX_LEN}; + + #[inline] + pub fn create( + payer: &AccountView, + crank: &AccountView, + system_program: &AccountView, + args: &builder::CreateArgs<'_>, + signers: &[Signer], + ) -> ProgramResult { + if 1 + CREATE_FIXED_PREFIX_LEN + args.body_len() > N { + return Err(ProgramError::InvalidInstructionData); + } + + let mut data = [0_u8; N]; + let written = args.write_to(&mut data); + + let ix = InstructionView { + program_id: &crate::base::ID, + accounts: &[ + InstructionAccount::writable_signer(payer.address()), + InstructionAccount::writable(crank.address()), + InstructionAccount::readonly(system_program.address()), + ], + data: &data[..written], + }; + + invoke_signed(&ix, &[payer, crank, system_program], signers) + } + + #[inline(always)] + pub fn cancel( + authority: &AccountView, + crank: &AccountView, + recipient: &AccountView, + signers: &[Signer], + ) -> ProgramResult { + let data = [disc::CANCEL]; + let metas = [ + InstructionAccount::readonly_signer(authority.address()), + InstructionAccount::writable(crank.address()), + InstructionAccount::writable(recipient.address()), + ]; + let ix = InstructionView { + program_id: &crate::base::ID, + accounts: &metas, + data: &data, + }; + invoke_signed(&ix, &[authority, crank, recipient], signers) + } + + #[inline(always)] + pub fn close( + reporter: &AccountView, + crank: &AccountView, + recipient: &AccountView, + signers: &[Signer], + ) -> ProgramResult { + let data = [disc::CLOSE]; + let metas = [ + InstructionAccount::writable_signer(reporter.address()), + InstructionAccount::writable(crank.address()), + InstructionAccount::writable(recipient.address()), + ]; + let ix = InstructionView { + program_id: &crate::base::ID, + accounts: &metas, + data: &data, + }; + invoke_signed(&ix, &[reporter, crank, recipient], signers) + } } } -#[cfg(feature = "cpi-pinocchio")] -pub mod pinocchio { - //! CPI wrappers for Pinocchio callers. - //! - //! Build `Create` manually. See `examples/pinocchio/`. - - use pinocchio::{ - cpi::{invoke_signed, Signer}, - instruction::{InstructionAccount, InstructionView}, - AccountView, ProgramResult, - }; - - use crate::consts::ix as disc; - - #[inline(always)] - pub fn cancel( - authority: &AccountView, - crank: &AccountView, - recipient: &AccountView, - signers: &[Signer], - ) -> ProgramResult { - let data = [disc::CANCEL]; - let metas = [ - InstructionAccount::readonly_signer(authority.address()), - InstructionAccount::writable(crank.address()), - InstructionAccount::writable(recipient.address()), - ]; - let ix = InstructionView { - program_id: &crate::ID, - accounts: &metas, - data: &data, - }; - invoke_signed(&ix, &[authority, crank, recipient], signers) +pub mod ephemeral { + #[cfg(feature = "cpi-native")] + pub mod native { + //! CPI wrappers for `solana-program` / Anchor callers. + //! + //! # Example + //! + //! ```ignore + //! use hydra_api::cpi::ephemeral::native as hydra_cpi; + //! use hydra_api::instruction::{CreateArgs, SchedMeta}; + //! + //! // Inside your user-facing instruction's handler: + //! hydra_cpi::create( + //! sponsor_ai, crank_ai, vault_ai, magic_program_ai, + //! &CreateArgs { seed, authority: [0u8; 32], /* ... */ }, + //! )?; + //! ``` + + use solana_account_info::AccountInfo; + use solana_cpi::invoke_signed; + use solana_program_error::ProgramError; + + use crate::instruction::{ephemeral as builder, CreateArgs}; + + #[inline] + pub fn create<'a>( + sponsor: &AccountInfo<'a>, + crank: &AccountInfo<'a>, + vault: &AccountInfo<'a>, + magic_program: &AccountInfo<'a>, + args: &CreateArgs<'_>, + signer_seeds: &[&[&[u8]]], + ) -> Result<(), ProgramError> { + let ix = builder::create(*sponsor.key, *crank.key, args); + invoke_signed( + &ix, + &[ + sponsor.clone(), + crank.clone(), + vault.clone(), + magic_program.clone(), + ], + signer_seeds, + ) + } + + /// `signer_seeds` is typically `&[&[b"authority_seed", &[bump]]]` when + /// `authority` is a PDA controlled by the integrator program, or + /// `&[]` when it's an EOA. + #[inline] + pub fn cancel<'a>( + authority: &AccountInfo<'a>, + crank: &AccountInfo<'a>, + recipient: &AccountInfo<'a>, + vault: &AccountInfo<'a>, + magic_program: &AccountInfo<'a>, + signer_seeds: &[&[&[u8]]], + ) -> Result<(), ProgramError> { + let ix = builder::cancel(*authority.key, *crank.key, *recipient.key); + invoke_signed( + &ix, + &[ + authority.clone(), + crank.clone(), + recipient.clone(), + vault.clone(), + magic_program.clone(), + ], + signer_seeds, + ) + } + + /// `signer_seeds` is typically `&[]` unless the reporter is a PDA + /// owned by the integrator program. + #[inline] + pub fn close<'a>( + reporter: &AccountInfo<'a>, + crank: &AccountInfo<'a>, + recipient: &AccountInfo<'a>, + vault: &AccountInfo<'a>, + magic_program: &AccountInfo<'a>, + signer_seeds: &[&[&[u8]]], + ) -> Result<(), ProgramError> { + let ix = builder::close(*reporter.key, *crank.key, *recipient.key); + invoke_signed( + &ix, + &[ + reporter.clone(), + crank.clone(), + recipient.clone(), + vault.clone(), + magic_program.clone(), + ], + signer_seeds, + ) + } } - #[inline(always)] - pub fn close( - reporter: &AccountView, - crank: &AccountView, - recipient: &AccountView, - signers: &[Signer], - ) -> ProgramResult { - let data = [disc::CLOSE]; - let metas = [ - InstructionAccount::writable_signer(reporter.address()), - InstructionAccount::writable(crank.address()), - InstructionAccount::writable(recipient.address()), - ]; - let ix = InstructionView { - program_id: &crate::ID, - accounts: &metas, - data: &data, + #[cfg(feature = "cpi-pinocchio")] + pub mod pinocchio { + //! CPI wrappers for Pinocchio callers. + //! + //! Build `Create` manually. See `examples/pinocchio/`. + + use pinocchio::{ + cpi::{invoke_signed, Signer}, + instruction::{InstructionAccount, InstructionView}, + AccountView, ProgramResult, }; - invoke_signed(&ix, &[reporter, crank, recipient], signers) + use solana_program_error::ProgramError; + + use crate::{ + consts::ix as disc, + instruction::{CreateArgs, CREATE_FIXED_PREFIX_LEN}, + }; + + #[inline] + pub fn create( + sponsor: &AccountView, + crank: &AccountView, + vault: &AccountView, + magic_program: &AccountView, + args: &CreateArgs<'_>, + signers: &[Signer], + ) -> ProgramResult { + if 1 + CREATE_FIXED_PREFIX_LEN + args.body_len() > N { + return Err(ProgramError::InvalidInstructionData); + } + + let mut data = [0_u8; N]; + let written = args.write_to(&mut data); + + let ix = InstructionView { + program_id: &crate::ephemeral::ID, + data: &data[..written], + accounts: &[ + InstructionAccount::writable_signer(sponsor.address()), + InstructionAccount::writable(crank.address()), + InstructionAccount::writable(vault.address()), + InstructionAccount::readonly(magic_program.address()), + ], + }; + invoke_signed(&ix, &[sponsor, crank, vault, magic_program], signers) + } + + #[inline(always)] + pub fn cancel( + authority: &AccountView, + crank: &AccountView, + recipient: &AccountView, + vault: &AccountView, + magic_program: &AccountView, + signers: &[Signer], + ) -> ProgramResult { + let data = [disc::CANCEL]; + let metas = [ + InstructionAccount::writable_signer(authority.address()), + InstructionAccount::writable(crank.address()), + InstructionAccount::writable(recipient.address()), + InstructionAccount::writable(vault.address()), + InstructionAccount::readonly(magic_program.address()), + ]; + let ix = InstructionView { + program_id: &crate::ephemeral::ID, + accounts: &metas, + data: &data, + }; + invoke_signed( + &ix, + &[authority, crank, recipient, vault, magic_program], + signers, + ) + } + + #[inline(always)] + pub fn close( + reporter: &AccountView, + crank: &AccountView, + recipient: &AccountView, + vault: &AccountView, + magic_program: &AccountView, + signers: &[Signer], + ) -> ProgramResult { + let data = [disc::CLOSE]; + let metas = [ + InstructionAccount::writable_signer(reporter.address()), + InstructionAccount::writable(crank.address()), + InstructionAccount::writable(recipient.address()), + InstructionAccount::writable(vault.address()), + InstructionAccount::readonly(magic_program.address()), + ]; + let ix = InstructionView { + program_id: &crate::ephemeral::ID, + accounts: &metas, + data: &data, + }; + invoke_signed( + &ix, + &[reporter, crank, recipient, vault, magic_program], + signers, + ) + } } } diff --git a/crates/hydra-api/src/instruction.rs b/crates/hydra-api/src/instruction.rs index 833d984..db3dfd1 100644 --- a/crates/hydra-api/src/instruction.rs +++ b/crates/hydra-api/src/instruction.rs @@ -39,6 +39,147 @@ pub const CREATE_FIXED_PREFIX_LEN: usize = 32 + // seed /// `num_accounts: u8`, `data_len: u16 LE`, `program_id: [u8; 32]`. pub const CREATE_IX_HEADER_LEN: usize = 1 + 2 + 32; // = 35 +/// A scheduled-ix meta as it will be stored on-chain. +/// +/// `is_signer` is intentionally absent: scheduled ixs cannot carry +/// signer flags (enforced by `Create`), and the on-chain template +/// stores only the writable bit anyway. +#[derive(Clone, Copy)] +pub struct SchedMeta { + pub pubkey: [u8; 32], + pub is_writable: bool, +} + +impl SchedMeta { + pub fn readonly(pubkey: [u8; 32]) -> Self { + Self { + pubkey, + is_writable: false, + } + } + pub fn writable(pubkey: [u8; 32]) -> Self { + Self { + pubkey, + is_writable: true, + } + } +} + +/// One scheduled instruction template. +pub struct ScheduledIx<'a> { + pub program_id: [u8; 32], + pub metas: &'a [SchedMeta], + pub data: &'a [u8], +} + +/// All the scheduling knobs for `Create`. +/// +/// # Caller responsibilities (the program does NOT check these) +/// +/// `Create` validates the wire format, the signer-flag ban, and a few +/// numeric bounds, but it deliberately does **not** verify that the schedule +/// is actually *crankable*. A schedule that violates any rule below is +/// accepted and stored, but every `Trigger` will fail — the crank is created +/// yet can never fire, stranding its rent. The client is the only party with +/// the full account picture, so these are its responsibility: +/// +/// 1. **Consistent writability per account.** If the same pubkey appears in +/// more than one scheduled ix (or more than once in one ix), it must carry +/// the *same* `is_writable` flag every time. The Solana runtime promotes an +/// account to writable in *every* instruction region of the crank tx if it +/// is writable in any of them; `Trigger` byte-matches the follow-up ix +/// against the stored template, and a promoted `writable` flag can never +/// match a stored `read-only` one. +/// +/// 2. **The crank PDA is writable.** The crank PDA is writable in +/// `Trigger` itself, so the runtime promotes it to writable everywhere. +/// A scheduled program should authenticate the crank by reading +/// the preceding `Trigger` from the instructions sysvar instead — not by +/// listing the crank PDA as one of its own accounts. +/// +/// 3. **Be careful referencing the cranker.** The cranker is the tx fee +/// payer (signer + writable) and is unknown at schedule time, so any meta +/// matching it is promoted and breaks the byte-match. Moreover, the cranker +/// may refuse any cranks that includes its own pubkey as an account. +pub struct CreateArgs<'a> { + pub seed: [u8; 32], + /// All-zeros = unkillable (no cancel authority). + pub authority: [u8; 32], + pub start_slot: u64, + pub interval_slots: u64, + /// `0` on the wire means "infinite"; Hydra stores `u64::MAX` internally. + pub remaining: u64, + pub priority_tip: u64, + /// Compute-unit limit the cranker emits as `SetComputeUnitLimit` + /// right before `Trigger`. `0` = no ix (inherits the 200 k/ix + /// default). Capped at `MAX_COMPUTE_UNIT_LIMIT` (1.4 M) at `Create`. + pub cu_limit: u32, + /// The scheduled instructions, in execution order. Must be non-empty + pub scheduled: &'a [ScheduledIx<'a>], +} + +impl CreateArgs<'_> { + pub fn body_len(&self) -> usize { + self.scheduled + .iter() + .map(|s| CREATE_IX_HEADER_LEN + 33 * s.metas.len() + s.data.len()) + .sum() + } + + pub fn write_to(&self, data: &mut [u8]) -> usize { + let mut off = 0; + + data[off] = ix::CREATE; + off += 1; + + data[off..off + 32].copy_from_slice(&self.seed); + off += 32; + + data[off..off + 32].copy_from_slice(&self.authority); + off += 32; + + data[off..off + 8].copy_from_slice(&self.start_slot.to_le_bytes()); + off += 8; + + data[off..off + 8].copy_from_slice(&self.interval_slots.to_le_bytes()); + off += 8; + + data[off..off + 8].copy_from_slice(&self.remaining.to_le_bytes()); + off += 8; + + data[off..off + 8].copy_from_slice(&self.priority_tip.to_le_bytes()); + off += 8; + + data[off..off + 4].copy_from_slice(&self.cu_limit.to_le_bytes()); + off += 4; + + for s in self.scheduled { + data[off] = s.metas.len() as u8; + off += 1; + + data[off..off + 2].copy_from_slice(&(s.data.len() as u16).to_le_bytes()); + off += 2; + + data[off..off + 32].copy_from_slice(&s.program_id); + off += 32; + + for m in s.metas { + let flag: u8 = if m.is_writable { META_FLAG_WRITABLE } else { 0 }; + data[off] = flag; + off += 1; + + data[off..off + 32].copy_from_slice(&m.pubkey); + off += 32; + } + + data[off..off + s.data.len()].copy_from_slice(s.data); + off += s.data.len(); + } + + off + } +} + // --------------------------------------------------------------------------- // Client builders // --------------------------------------------------------------------------- @@ -52,6 +193,9 @@ mod client { use solana_pubkey::{pubkey, Pubkey}; use crate::consts::{ix, META_FLAG_WRITABLE}; + use crate::instruction::CREATE_FIXED_PREFIX_LEN; + + use super::*; /// Solana's built-in instructions sysvar pubkey. pub const INSTRUCTIONS_SYSVAR_ID: Pubkey = @@ -60,177 +204,164 @@ mod client { /// Solana's system program pubkey. pub const SYSTEM_PROGRAM_ID: Pubkey = pubkey!("11111111111111111111111111111111"); + /// The [`base`] / [`ephemeral`] programs. + pub const BASE_PROGRAM_ID: Pubkey = pubkey!("Hydra17i1feui9deaxu6d1TzSQMRNHeBRkDR1Awy7zea"); + pub const EPHEMERAL_PROGRAM_ID: Pubkey = pubkey!("eHyd5BU8QffvHi4GnXwxrK4WpS7pM2x9UGKHBWii7mf"); + /// Hydra program ID as a `solana_pubkey::Pubkey` (convenience for clients). - pub fn program_id() -> Pubkey { - Pubkey::new_from_array(crate::ID.to_bytes()) + pub fn base_program_id() -> Pubkey { + Pubkey::new_from_array(crate::base::ID.to_bytes()) } - /// Derive `(crank_pda, bump)` using `solana_pubkey::Pubkey`. - pub fn find_crank_pda(seed: &[u8; 32]) -> (Pubkey, u8) { - let (addr, bump) = crate::state::find_crank_pda(seed); - (Pubkey::new_from_array(addr.to_bytes()), bump) + pub fn ephemeral_program_id() -> Pubkey { + Pubkey::new_from_array(crate::ephemeral::ID.to_bytes()) } - /// A scheduled-ix meta as it will be stored on-chain. - /// - /// `is_signer` is intentionally absent: scheduled ixs cannot carry - /// signer flags (enforced by `Create`), and the on-chain template - /// stores only the writable bit anyway. - #[derive(Clone, Copy)] - pub struct SchedMeta { - pub pubkey: Pubkey, - pub is_writable: bool, - } + /// Builders targeting the base-layer Hydra program ([`BASE_PROGRAM_ID`]). + pub mod base { + + use super::*; - impl SchedMeta { - pub fn readonly(pubkey: Pubkey) -> Self { - Self { - pubkey, - is_writable: false, + /// This module's program ID. + pub const PROGRAM_ID: Pubkey = super::BASE_PROGRAM_ID; + + /// Derive `(crank_pda, bump)` under the base program. + pub fn find_crank_pda(seed: &[u8; 32]) -> (Pubkey, u8) { + Pubkey::find_program_address(&[crate::consts::CRANK_SEED_PREFIX, seed], &PROGRAM_ID) + } + + /// Build a `Create` instruction scheduling a single instruction. + pub fn create(payer: Pubkey, crank: Pubkey, args: &CreateArgs<'_>) -> Instruction { + let mut data = vec![0_u8; 1 + CREATE_FIXED_PREFIX_LEN + args.body_len()]; + args.write_to(&mut data); + + Instruction { + program_id: PROGRAM_ID, + accounts: alloc::vec![ + AccountMeta::new(payer, true), + AccountMeta::new(crank, false), + AccountMeta::new_readonly(SYSTEM_PROGRAM_ID, false), + ], + data, } } - pub fn writable(pubkey: Pubkey) -> Self { - Self { - pubkey, - is_writable: true, + + /// Build a `Trigger` instruction. Must be paired in the same tx with the + /// scheduled instruction at `current_ix_index + 1`. + pub fn trigger(crank: Pubkey, cranker: Pubkey) -> Instruction { + Instruction { + program_id: PROGRAM_ID, + accounts: alloc::vec![ + AccountMeta::new(crank, false), + AccountMeta::new(cranker, true), + AccountMeta::new_readonly(INSTRUCTIONS_SYSVAR_ID, false), + ], + data: alloc::vec![ix::TRIGGER], } } - } - /// One scheduled instruction template. - pub struct ScheduledIx<'a> { - pub program_id: Pubkey, - pub metas: &'a [SchedMeta], - pub data: &'a [u8], - } + /// Build a `Cancel` instruction. + pub fn cancel(authority: Pubkey, crank: Pubkey, recipient: Pubkey) -> Instruction { + Instruction { + program_id: PROGRAM_ID, + accounts: alloc::vec![ + AccountMeta::new_readonly(authority, true), + AccountMeta::new(crank, false), + AccountMeta::new(recipient, false), + ], + data: alloc::vec![ix::CANCEL], + } + } - /// All the scheduling knobs for `Create`. - /// - /// # Caller responsibilities (the program does NOT check these) - /// - /// `Create` validates the wire format, the signer-flag ban, and a few - /// numeric bounds, but it deliberately does **not** verify that the schedule - /// is actually *crankable*. A schedule that violates any rule below is - /// accepted and stored, but every `Trigger` will fail — the crank is created - /// yet can never fire, stranding its rent. The client is the only party with - /// the full account picture, so these are its responsibility: - /// - /// 1. **Consistent writability per account.** If the same pubkey appears in - /// more than one scheduled ix (or more than once in one ix), it must carry - /// the *same* `is_writable` flag every time. The Solana runtime promotes an - /// account to writable in *every* instruction region of the crank tx if it - /// is writable in any of them; `Trigger` byte-matches the follow-up ix - /// against the stored template, and a promoted `writable` flag can never - /// match a stored `read-only` one. - /// - /// 2. **The crank PDA is writable.** The crank PDA is writable in - /// `Trigger` itself, so the runtime promotes it to writable everywhere. - /// A scheduled program should authenticate the crank by reading - /// the preceding `Trigger` from the instructions sysvar instead — not by - /// listing the crank PDA as one of its own accounts. - /// - /// 3. **Be careful referencing the cranker.** The cranker is the tx fee - /// payer (signer + writable) and is unknown at schedule time, so any meta - /// matching it is promoted and breaks the byte-match. Moreover, the cranker - /// may refuse any cranks that includes its own pubkey as an account. - pub struct CreateArgs<'a> { - pub seed: [u8; 32], - /// All-zeros = unkillable (no cancel authority). - pub authority: [u8; 32], - pub start_slot: u64, - pub interval_slots: u64, - /// `0` on the wire means "infinite"; Hydra stores `u64::MAX` internally. - pub remaining: u64, - pub priority_tip: u64, - /// Compute-unit limit the cranker emits as `SetComputeUnitLimit` - /// right before `Trigger`. `0` = no ix (inherits the 200 k/ix - /// default). Capped at `MAX_COMPUTE_UNIT_LIMIT` (1.4 M) at `Create`. - pub cu_limit: u32, - /// The scheduled instructions, in execution order. Must be non-empty - pub scheduled: &'a [ScheduledIx<'a>], + /// Build a `Close` instruction (permissionless cleanup). + pub fn close(reporter: Pubkey, crank: Pubkey, recipient: Pubkey) -> Instruction { + Instruction { + program_id: PROGRAM_ID, + accounts: alloc::vec![ + AccountMeta::new(reporter, true), + AccountMeta::new(crank, false), + AccountMeta::new(recipient, false), + ], + data: alloc::vec![ix::CLOSE], + } + } } - /// Build a `Create` instruction. - /// - /// This only serializes the wire format; it does not validate that the - /// schedule is crankable. See [`CreateArgs`] for the rules the caller must - /// uphold (consistent writability, crank/cranker metas) — a - /// schedule that breaks them is accepted on-chain but can never be triggered. - pub fn create(payer: Pubkey, crank: Pubkey, args: &CreateArgs<'_>) -> Instruction { - let body_len: usize = args - .scheduled - .iter() - .map(|s| super::CREATE_IX_HEADER_LEN + 33 * s.metas.len() + s.data.len()) - .sum(); - let mut data = Vec::with_capacity(1 + super::CREATE_FIXED_PREFIX_LEN + body_len); - data.push(ix::CREATE); - data.extend_from_slice(&args.seed); - data.extend_from_slice(&args.authority); - data.extend_from_slice(&args.start_slot.to_le_bytes()); - data.extend_from_slice(&args.interval_slots.to_le_bytes()); - data.extend_from_slice(&args.remaining.to_le_bytes()); - data.extend_from_slice(&args.priority_tip.to_le_bytes()); - data.extend_from_slice(&args.cu_limit.to_le_bytes()); - for s in args.scheduled { - data.push(s.metas.len() as u8); - data.extend_from_slice(&(s.data.len() as u16).to_le_bytes()); - data.extend_from_slice(&s.program_id.to_bytes()); - for m in s.metas { - let flag: u8 = if m.is_writable { META_FLAG_WRITABLE } else { 0 }; - data.push(flag); - data.extend_from_slice(&m.pubkey.to_bytes()); - } - data.extend_from_slice(s.data); + /// Builders targeting the ephemeral-rollup Hydra program + /// ([`EPHEMERAL_PROGRAM_ID`]). + pub mod ephemeral { + use ephemeral_rollups_pinocchio::consts::{EPHEMERAL_VAULT_ID, MAGIC_PROGRAM_ID}; + + use super::*; + + /// This module's program ID. + pub const PROGRAM_ID: Pubkey = super::EPHEMERAL_PROGRAM_ID; + + /// Derive `(crank_pda, bump)` under the ephemeral program. + pub fn find_crank_pda(seed: &[u8; 32]) -> (Pubkey, u8) { + Pubkey::find_program_address(&[crate::consts::CRANK_SEED_PREFIX, seed], &PROGRAM_ID) } - Instruction { - program_id: program_id(), - accounts: alloc::vec![ - AccountMeta::new(payer, true), - AccountMeta::new(crank, false), - AccountMeta::new_readonly(SYSTEM_PROGRAM_ID, false), - ], - data, + /// Build a `Create` instruction + pub fn create(sponsor: Pubkey, crank: Pubkey, args: &CreateArgs<'_>) -> Instruction { + let mut data = vec![0_u8; 1 + super::CREATE_FIXED_PREFIX_LEN + args.body_len()]; + args.write_to(&mut data); + + Instruction { + program_id: PROGRAM_ID, + accounts: alloc::vec![ + AccountMeta::new(sponsor, true), + AccountMeta::new(crank, false), + AccountMeta::new(EPHEMERAL_VAULT_ID, false), + AccountMeta::new_readonly(MAGIC_PROGRAM_ID, false), + ], + data, + } } - } - /// Build a `Trigger` instruction. Must be paired in the same tx with the - /// scheduled instruction at `current_ix_index + 1`. - pub fn trigger(crank: Pubkey, cranker: Pubkey) -> Instruction { - Instruction { - program_id: program_id(), - accounts: alloc::vec![ - AccountMeta::new(crank, false), - AccountMeta::new(cranker, true), - AccountMeta::new_readonly(INSTRUCTIONS_SYSVAR_ID, false), - ], - data: alloc::vec![ix::TRIGGER], + /// Build a `Trigger` instruction. + pub fn trigger(crank: Pubkey, cranker: Pubkey) -> Instruction { + Instruction { + program_id: PROGRAM_ID, + accounts: alloc::vec![ + AccountMeta::new(crank, false), + AccountMeta::new(cranker, true), + AccountMeta::new_readonly(INSTRUCTIONS_SYSVAR_ID, false), + ], + data: alloc::vec![ix::TRIGGER], + } } - } - /// Build a `Cancel` instruction. - pub fn cancel(authority: Pubkey, crank: Pubkey, recipient: Pubkey) -> Instruction { - Instruction { - program_id: program_id(), - accounts: alloc::vec![ - AccountMeta::new_readonly(authority, true), - AccountMeta::new(crank, false), - AccountMeta::new(recipient, false), - ], - data: alloc::vec![ix::CANCEL], + /// Build a `Cancel` instruction. `recipient` receives the crank's drained + /// lamport balance; the vault rent refunds to `authority`. + pub fn cancel(authority: Pubkey, crank: Pubkey, recipient: Pubkey) -> Instruction { + Instruction { + program_id: PROGRAM_ID, + accounts: alloc::vec![ + AccountMeta::new(authority, true), + AccountMeta::new(crank, false), + AccountMeta::new(recipient, false), + AccountMeta::new(EPHEMERAL_VAULT_ID, false), + AccountMeta::new_readonly(MAGIC_PROGRAM_ID, false), + ], + data: alloc::vec![ix::CANCEL], + } } - } - /// Build a `Close` instruction (permissionless cleanup). - pub fn close(reporter: Pubkey, crank: Pubkey, recipient: Pubkey) -> Instruction { - Instruction { - program_id: program_id(), - accounts: alloc::vec![ - AccountMeta::new(reporter, true), - AccountMeta::new(crank, false), - AccountMeta::new(recipient, false), - ], - data: alloc::vec![ix::CLOSE], + /// Build a `Close` instruction. `reporter` keeps the flat bounty (and the + /// vault rent refund); `recipient` receives the remaining balance. + pub fn close(reporter: Pubkey, crank: Pubkey, recipient: Pubkey) -> Instruction { + Instruction { + program_id: PROGRAM_ID, + accounts: alloc::vec![ + AccountMeta::new(reporter, true), + AccountMeta::new(crank, false), + AccountMeta::new(recipient, false), + AccountMeta::new(EPHEMERAL_VAULT_ID, false), + AccountMeta::new_readonly(MAGIC_PROGRAM_ID, false), + ], + data: alloc::vec![ix::CLOSE], + } } } @@ -297,3 +428,5 @@ mod client { #[cfg(feature = "client")] pub use client::*; + +use crate::{ix, META_FLAG_WRITABLE}; diff --git a/crates/hydra-api/src/lib.rs b/crates/hydra-api/src/lib.rs index 3cd23ba..2ce5c4e 100644 --- a/crates/hydra-api/src/lib.rs +++ b/crates/hydra-api/src/lib.rs @@ -10,12 +10,18 @@ pub mod consts; pub mod cpi; pub mod error; pub mod instruction; +#[cfg(feature = "program")] +pub mod program; pub mod state; pub use consts::*; pub use error::HydraError; pub use state::Crank; -use solana_address::declare_id; +pub mod base { + solana_address::declare_id!("Hydra17i1feui9deaxu6d1TzSQMRNHeBRkDR1Awy7zea"); +} -declare_id!("Hydra17i1feui9deaxu6d1TzSQMRNHeBRkDR1Awy7zea"); +pub mod ephemeral { + solana_address::declare_id!("eHyd5BU8QffvHi4GnXwxrK4WpS7pM2x9UGKHBWii7mf"); +} diff --git a/programs/hydra/src/helpers.rs b/crates/hydra-api/src/program/helpers.rs similarity index 95% rename from programs/hydra/src/helpers.rs rename to crates/hydra-api/src/program/helpers.rs index f623828..6576553 100644 --- a/programs/hydra/src/helpers.rs +++ b/crates/hydra-api/src/program/helpers.rs @@ -1,4 +1,4 @@ -//! Tiny utilities that don't fit into any one processor. +//! Tiny syscall utilities shared by the on-chain processors. use pinocchio::error::ProgramError; #[cfg(target_os = "solana")] diff --git a/crates/hydra-api/src/program/mod.rs b/crates/hydra-api/src/program/mod.rs new file mode 100644 index 0000000..88f7bff --- /dev/null +++ b/crates/hydra-api/src/program/mod.rs @@ -0,0 +1,11 @@ +//! On-chain building blocks shared by the base-layer and ephemeral-rollup Hydra +//! programs. Gated behind the `program` feature so client/CPI consumers don't +//! pull the pinocchio-flavoured processor code (or `solana-define-syscall`). +//! +//! Each program crate (`programs/hydra`, `programs/hydra-ephemeral`) keeps only +//! its CPI-funding-model-specific handlers; everything that is identical across +//! the two ledgers — schedule parsing, tail serialization, follow-up +//! verification, the sysvar syscalls — lives here. + +pub mod helpers; +pub mod processor; diff --git a/crates/hydra-api/src/program/processor.rs b/crates/hydra-api/src/program/processor.rs new file mode 100644 index 0000000..d031039 --- /dev/null +++ b/crates/hydra-api/src/program/processor.rs @@ -0,0 +1,489 @@ +//! Shared helpers for the on-chain crank processors (base + ephemeral). +//! +//! These building blocks are program-agnostic: every function that needs the +//! caller's program id takes it as a parameter, so both the base-layer and the +//! ephemeral-rollup programs link the exact same code here. + +use pinocchio::{error::ProgramError, AccountView, Address, ProgramResult}; + +use crate::{ + consts::{ + self, CRANK_SEED_PREFIX, MAX_ACCOUNTS, MAX_COMPUTE_UNIT_LIMIT, MAX_DATA_LEN, + MAX_INSTRUCTIONS, META_FLAG_SIGNER, REMAINING_INFINITE, SERIALIZED_META_SIZE, + }, + instruction::{CREATE_FIXED_PREFIX_LEN, CREATE_IX_HEADER_LEN}, + program::helpers::get_clock_slot, + state::{load_crank, load_crank_mut, Crank}, + HydraError, CRANK_HEADER_SIZE, +}; + +/// `signer` must sign and `crank` must be Hydra-owned — the preamble of every +/// `Cancel` / `Close` path. +pub fn require_signed_crank( + signer: &AccountView, + crank_ai: &AccountView, + program_id: &Address, +) -> ProgramResult { + if !signer.is_signer() { + return Err(ProgramError::MissingRequiredSignature); + } + if !crank_ai.owned_by(program_id) { + return Err(ProgramError::InvalidAccountOwner); + } + Ok(()) +} + +/// Anti-grief: when a crank has a non-zero authority, only that authority may +/// receive the rent refund. Shared by base and ephemeral `Close`. +pub fn require_refund_recipient( + stored_authority: [u8; 32], + recipient: &AccountView, +) -> ProgramResult { + if stored_authority != [0u8; 32] && recipient.address().as_array() != &stored_authority { + return Err(HydraError::UnauthorizedAuthority.into()); + } + Ok(()) +} + +/// Authority-gated close preamble shared by base `Cancel` and `CancelEphemeral`: +/// `authority` signs a Hydra-owned crank and matches its stored (non-zero) +/// authority. +pub fn require_cancel_authority( + authority: &AccountView, + crank_ai: &AccountView, + program_id: &Address, +) -> ProgramResult { + require_signed_crank(authority, crank_ai, program_id)?; + let stored = { + let data = crank_ai.try_borrow()?; + unsafe { load_crank(&data)? }.authority + }; + if stored == [0u8; 32] || authority.address().as_array() != &stored { + return Err(HydraError::UnauthorizedAuthority.into()); + } + Ok(()) +} + +/// The fixed-size scheduling prefix of a `Create` / `CreateEphemeral` payload. +/// `next_exec` / `interval` are slot counts on both ledgers — base-layer slots +/// for base cranks, ephemeral-rollup slots for ephemeral cranks (the ER runs +/// faster, so the same wall-clock interval is more slots). Both `Trigger` +/// handlers compare against the ledger's own clock slot and advance +/// `next_exec_slot` by `interval_slots`; the wire bytes are identical either way. +pub struct CreateHeader { + pub seed: [u8; 32], + pub authority: [u8; 32], + pub next_exec: u64, + pub interval: u64, + pub remaining_wire: u64, + pub priority_tip: u64, + pub cu_limit: u32, +} + +/// Parse + validate the fixed prefix of a `Create` payload. Requires at least +/// the prefix plus one scheduled-ix blob header. +pub fn parse_create_header(data: &[u8]) -> Result { + if data.len() < CREATE_FIXED_PREFIX_LEN + CREATE_IX_HEADER_LEN { + return Err(ProgramError::InvalidInstructionData); + } + // SAFETY: bounds checked above; the reads only require byte alignment. + let header = CreateHeader { + seed: unsafe { *(data.as_ptr() as *const [u8; 32]) }, + authority: unsafe { *(data.as_ptr().add(32) as *const [u8; 32]) }, + next_exec: read_u64_le(data, 64), + interval: read_u64_le(data, 72), + remaining_wire: read_u64_le(data, 80), + priority_tip: read_u64_le(data, 88), + cu_limit: read_u32_le(data, 96), + }; + // `0` opts out of `SetComputeUnitLimit`; non-zero must fit the per-tx ceiling. + if header.cu_limit > MAX_COMPUTE_UNIT_LIMIT { + return Err(HydraError::InvalidSchedule.into()); + } + // A never-advancing infinite crank makes no sense. + if header.remaining_wire == 0 && header.interval == 0 { + return Err(HydraError::InvalidSchedule.into()); + } + Ok(header) +} + +/// Write the parsed prefix + computed fields into a freshly-allocated crank +/// header. `rent_min` is the cached rent floor for base cranks and `0` for +/// ephemeral cranks (which hold no lamports). +#[inline(always)] +pub fn write_header( + state: &mut Crank, + h: &CreateHeader, + bump: u8, + authority_signer: u8, + rent_min: u64, + region_len: u16, +) { + state.authority = h.authority; + state.seed = h.seed; + state.set_next_exec_slot(h.next_exec); + state.set_interval_slots(h.interval); + state.set_remaining(if h.remaining_wire == 0 { + REMAINING_INFINITE + } else { + h.remaining_wire + }); + state.set_priority_tip(h.priority_tip); + state.set_executed(0); + state.set_rent_min(rent_min); + state.set_region_len(region_len); + state.bump = bump; + state.set_cu_limit(h.cu_limit); + state.authority_signer = authority_signer; +} + +/// The scheduled-ix tail must fit `Crank.region_len` (a `u16`). +const MAX_REGION_LEN: usize = u16::MAX as usize; + +/// Measure the exact tail length the scheduled ixs serialize to, validating the +/// per-ix structure, the instruction-count limit, and the `region_len` ceiling +/// in a single pass. Mirrors the byte accounting in [`write_tail`] so the caller +/// can allocate the precise account size up front; `write_tail` then re-validates +/// (incl. signer flags) and writes, yielding the same length. +pub fn measure_region(data: &[u8]) -> Result { + let mut cursor = CREATE_FIXED_PREFIX_LEN; + let mut region_len = 0usize; + let mut num_instructions = 0usize; + + while cursor < data.len() { + let (num_accounts, data_len, next) = parse_ix_header(data, cursor)?; + num_instructions += 1; + if num_instructions > MAX_INSTRUCTIONS { + return Err(HydraError::InvalidSchedule.into()); + } + let metas_len = num_accounts * SERIALIZED_META_SIZE; + // [num_accounts u16][metas][program_id 32][data_len u16][data] + region_len += 2 + metas_len + 32 + 2 + data_len; + cursor = next; + } + + if region_len > MAX_REGION_LEN { + return Err(HydraError::InvalidSchedule.into()); + } + Ok(region_len) +} + +/// Derive the crank PDA from `[CRANK_SEED_PREFIX, seed]` and verify it matches +/// the supplied account, returning the bump for the create CPI's signer seeds. +/// Shared by both `Create` paths. +pub fn derive_crank_pda( + crank_ai: &AccountView, + seed: &[u8; 32], + program_id: &Address, +) -> Result { + let (expected_pda, bump) = + Address::find_program_address(&[CRANK_SEED_PREFIX, seed], program_id); + if crank_ai.address() != &expected_pda { + return Err(ProgramError::InvalidSeeds); + } + Ok(bump) +} + +/// Finalize a freshly-allocated crank account: serialize the scheduled-ix tail +/// and write the header. The account must already be sized to +/// `CRANK_HEADER_SIZE + region_len`; `rent_min` is the cached rent floor for base +/// cranks and `0` for ephemeral cranks (which hold no lamports). Shared by both +/// `Create` paths. +pub fn write_crank( + crank_ai: &AccountView, + data: &[u8], + header: &CreateHeader, + bump: u8, + authority_signer: u8, + rent_min: u64, + region_len: usize, +) -> ProgramResult { + let mut account_data = crank_ai.try_borrow_mut()?; + let buf: &mut [u8] = &mut account_data; + if buf.len() < CRANK_HEADER_SIZE { + return Err(ProgramError::AccountDataTooSmall); + } + let (header_bytes, tail_bytes) = buf.split_at_mut(CRANK_HEADER_SIZE); + + let written = write_tail(tail_bytes, data)?; + if written != region_len { + return Err(HydraError::InvalidSchedule.into()); + } + + // SAFETY: split yields CRANK_HEADER_SIZE bytes; Crank is align-1 (compile-time checked). + let state = unsafe { load_crank_mut(header_bytes)? }; + write_header( + state, + header, + bump, + authority_signer, + rent_min, + region_len as u16, + ); + Ok(()) +} + +/// Validate + serialize the scheduled ixs of a `Create` payload into `tail`, +/// returning the bytes written. Every write is bounds-checked against +/// `tail.len()` so a wrongly-sized account fails cleanly instead of writing out +/// of bounds. +/// +/// Mirrors the tail layout produced by `processor::create` — see that file for +/// the wire format. Kept separate so the audited base path stays untouched. +pub fn write_tail(tail: &mut [u8], data: &[u8]) -> Result { + let cap = tail.len(); + let mut cursor = CREATE_FIXED_PREFIX_LEN; + let mut off = 0usize; + let mut num_instructions = 0usize; + + while cursor < data.len() { + let (num_accounts, data_len, next) = parse_ix_header(data, cursor)?; + num_instructions += 1; + if num_instructions > MAX_INSTRUCTIONS { + return Err(HydraError::InvalidSchedule.into()); + } + let metas_offset = cursor + CREATE_IX_HEADER_LEN; + let metas_len = num_accounts * SERIALIZED_META_SIZE; + let data_offset = metas_offset + metas_len; + // For each meta: reject signer flags (scheduled ixs run top-level and + // can only be signed by real keys, which this program can't produce), + // and fold the account into the writability-conflict tracker. + for account_index in 0..num_accounts { + let meta_offset = metas_offset + account_index * SERIALIZED_META_SIZE; + let meta_flag = data[meta_offset]; + if meta_flag & META_FLAG_SIGNER != 0 { + return Err(HydraError::SignerInScheduledIx.into()); + } + } + + // [num_accounts u16][metas][program_id 32][data_len u16][data] + let blob_len = 2 + metas_len + 32 + 2 + data_len; + if off + blob_len > cap { + return Err(ProgramError::AccountDataTooSmall); + } + tail[off..off + 2].copy_from_slice(&(num_accounts as u16).to_le_bytes()); + off += 2; + tail[off..off + metas_len].copy_from_slice(&data[metas_offset..data_offset]); + off += metas_len; + tail[off..off + 32].copy_from_slice(&data[cursor + 3..cursor + CREATE_IX_HEADER_LEN]); + off += 32; + tail[off..off + 2].copy_from_slice(&(data_len as u16).to_le_bytes()); + off += 2; + tail[off..off + data_len].copy_from_slice(&data[data_offset..next]); + off += data_len; + + cursor = next; + } + + Ok(off) +} + +/// Parse one scheduled-ix blob header at `cursor`, validating limits and bounds. +/// Returns `(num_accounts, data_len, next_cursor)`. +#[inline(always)] +pub fn parse_ix_header(data: &[u8], cursor: usize) -> Result<(usize, usize, usize), ProgramError> { + if cursor + CREATE_IX_HEADER_LEN > data.len() { + return Err(ProgramError::InvalidInstructionData); + } + let num_accounts = data[cursor] as usize; + let data_len = u16::from_le_bytes([data[cursor + 1], data[cursor + 2]]) as usize; + if num_accounts > MAX_ACCOUNTS || data_len > MAX_DATA_LEN { + return Err(HydraError::InvalidSchedule.into()); + } + let metas_len = num_accounts * SERIALIZED_META_SIZE; + let next = cursor + CREATE_IX_HEADER_LEN + metas_len + data_len; + if next > data.len() { + return Err(ProgramError::InvalidInstructionData); + } + Ok((num_accounts, data_len, next)) +} + +/// Parse the instructions sysvar, locate the region for +/// `current_ix_index + 1`, and byte-compare it against the crank's stored tail. +/// +/// Shared with the ephemeral-rollup `TriggerEphemeral` handler — the +/// follow-up binding is identical on both ledgers. +#[inline(always)] +pub fn verify_followup( + sysvar: &AccountView, + crank: &AccountView, + region_len: usize, +) -> ProgramResult { + // SAFETY: we're in a linear entrypoint flow with no outstanding borrows + // on either account. pinocchio's `borrow_unchecked` skips the refcell + // bookkeeping, which saves a handful of CUs per call. + let sv: &[u8] = unsafe { sysvar.borrow_unchecked() }; + let cr: &[u8] = unsafe { crank.borrow_unchecked() }; + + let sv_len = sv.len(); + if sv_len < 4 { + return Err(ProgramError::InvalidAccountData); + } + + // [len-2..len] = current_ix_index (u16 LE) + let current = unsafe { read_u16(sv.as_ptr().add(sv_len - 2)) } as usize; + let target = current + .checked_add(1) + .ok_or(HydraError::MissingFollowupInstruction)?; + + // [0..2] = num_instructions (u16 LE) + let num_ix = unsafe { read_u16(sv.as_ptr()) } as usize; + if target >= num_ix { + return Err(HydraError::MissingFollowupInstruction.into()); + } + + // [2 + 2*target..+2] = offset of instruction `target`'s region. + let off_pos = 2 + 2 * target; + if off_pos + 2 > sv_len { + return Err(ProgramError::InvalidAccountData); + } + let region_start = unsafe { read_u16(sv.as_ptr().add(off_pos)) } as usize; + let region_end = region_start + .checked_add(region_len) + .ok_or(HydraError::MismatchedFollowupIx)?; + if region_end > sv_len.saturating_sub(2) { + return Err(HydraError::MismatchedFollowupIx.into()); + } + + let tail_end = CRANK_HEADER_SIZE + .checked_add(region_len) + .ok_or(ProgramError::InvalidAccountData)?; + if tail_end > cr.len() { + return Err(ProgramError::InvalidAccountData); + } + let tail = &cr[CRANK_HEADER_SIZE..tail_end]; + let sv_region = &sv[region_start..region_end]; + + if sv_region != tail { + return Err(HydraError::MismatchedFollowupIx.into()); + } + Ok(()) +} + +#[inline(always)] +pub fn read_u64_le(data: &[u8], offset: usize) -> u64 { + // SAFETY: the caller ensures `offset + 8 <= data.len()`. + unsafe { u64::from_le_bytes(*(data.as_ptr().add(offset) as *const [u8; 8])) } +} + +#[inline(always)] +pub fn read_u32_le(data: &[u8], offset: usize) -> u32 { + // SAFETY: the caller ensures `offset + 4 <= data.len()`. + unsafe { u32::from_le_bytes(*(data.as_ptr().add(offset) as *const [u8; 4])) } +} + +/// # Safety +/// `p` must point to at least 2 readable bytes; the read is unaligned-safe. +#[inline(always)] +pub unsafe fn read_u16(p: *const u8) -> u16 { + core::ptr::read_unaligned(p as *const u16) +} + +/// Move all lamports out of `src` into `dst`. +#[inline(always)] +pub fn drain_lamports(src: &AccountView, dst: &AccountView) -> ProgramResult { + let amount = src.lamports(); + let new_dst = dst + .lamports() + .checked_add(amount) + .ok_or(ProgramError::ArithmeticOverflow)?; + src.set_lamports(0); + dst.set_lamports(new_dst); + Ok(()) +} + +/// Shared `Cancel`: authority-gated drain of the crank's full balance to +/// `recipient`. +pub fn process_cancel( + authority: &AccountView, + crank_ai: &AccountView, + recipient: &AccountView, + program_id: &Address, +) -> ProgramResult { + require_cancel_authority(authority, crank_ai, program_id)?; + drain_lamports(crank_ai, recipient) +} + +/// Shared `Close`: permissionless cleanup of an exhausted / underfunded / stuck +/// crank. Pays a flat `CRANKER_REWARD` bounty to `reporter`, refunds the +/// remaining balance to `recipient` (anti-grief: bound to the stored authority +/// when set), and zeroes the crank. +pub fn process_close( + reporter: &AccountView, + crank_ai: &AccountView, + recipient: &AccountView, + program_id: &Address, + is_ephemeral: bool, +) -> ProgramResult { + let cranker_reward = if is_ephemeral { + consts::ephemeral::CRANKER_REWARD + } else { + consts::base::CRANKER_REWARD + }; + let staleness_threshold_slots = if is_ephemeral { + consts::ephemeral::STALENESS_THRESHOLD_SLOTS + } else { + consts::base::STALENESS_THRESHOLD_SLOTS + }; + + require_signed_crank(reporter, crank_ai, program_id)?; + + // Snapshot the fields we need from the crank header. + let (stored_authority, remaining, rent_min, priority_tip, next_exec_slot, lamports_now) = { + let data = crank_ai.try_borrow()?; + let state = unsafe { load_crank(&data)? }; + ( + state.authority, + state.remaining(), + state.rent_min(), + state.priority_tip(), + state.next_exec_slot(), + crank_ai.lamports(), + ) + }; + + // Pre-condition: exhausted OR underfunded OR stuck. + let exhausted = remaining == 0; + let next_reward = cranker_reward + .checked_add(priority_tip) + .ok_or(ProgramError::ArithmeticOverflow)?; + let underfunded = lamports_now + < rent_min + .checked_add(next_reward) + .ok_or(ProgramError::ArithmeticOverflow)?; + // `next_exec_slot` only advances on *successful* `Trigger`, so persistent + // failure pins it in the past. `saturating_sub` makes future-scheduled + // cranks (`next_exec_slot > current_slot`) trivially not-stale. + let current_slot = get_clock_slot()?; + let stuck = current_slot.saturating_sub(next_exec_slot) > staleness_threshold_slots; + + if !(exhausted || underfunded || stuck) { + return Err(HydraError::NotClosable.into()); + } + + require_refund_recipient(stored_authority, recipient)?; + + // Flat bounty (2 × base fee) to whoever cranked the cleanup; the balance + // refunds to `recipient`. `min` handles a crank holding less than the + // bounty — reporter gets what's there, recipient gets nothing. + let bounty = cranker_reward.min(lamports_now); + let refund = lamports_now - bounty; + + crank_ai.set_lamports(0); + + let new_reporter = reporter + .lamports() + .checked_add(bounty) + .ok_or(ProgramError::ArithmeticOverflow)?; + reporter.set_lamports(new_reporter); + + // When `recipient` aliases `reporter`, the write above is visible here, so + // adding `refund` on top preserves the sum. Distinct accounts: clean credit. + let new_recipient = recipient + .lamports() + .checked_add(refund) + .ok_or(ProgramError::ArithmeticOverflow)?; + recipient.set_lamports(new_recipient); + + Ok(()) +} diff --git a/crates/hydra-api/src/state.rs b/crates/hydra-api/src/state.rs index a83f0ae..d73429b 100644 --- a/crates/hydra-api/src/state.rs +++ b/crates/hydra-api/src/state.rs @@ -184,9 +184,17 @@ pub unsafe fn load_crank_mut(bytes: &mut [u8]) -> Result<&mut Crank, ProgramErro /// Derive the crank PDA for the given 32-byte seed. #[inline] -pub fn find_crank_pda(seed: &[u8; 32]) -> (solana_address::Address, u8) { +pub fn find_base_crank_pda(seed: &[u8; 32]) -> (solana_address::Address, u8) { solana_address::Address::find_program_address( &[crate::consts::CRANK_SEED_PREFIX, seed.as_ref()], - &crate::ID, + &crate::base::ID, + ) +} + +#[inline] +pub fn find_ephemeral_crank_pda(seed: &[u8; 32]) -> (solana_address::Address, u8) { + solana_address::Address::find_program_address( + &[crate::consts::CRANK_SEED_PREFIX, seed.as_ref()], + &crate::ephemeral::ID, ) } diff --git a/crates/hydra-cranker/src/cache.rs b/crates/hydra-cranker/src/cache.rs index f78beb7..99bbf5a 100644 --- a/crates/hydra-cranker/src/cache.rs +++ b/crates/hydra-cranker/src/cache.rs @@ -10,8 +10,9 @@ use std::{ use solana_pubkey::Pubkey; -use hydra_api::consts::{ - CRANKER_REWARD, CRANK_HEADER_SIZE, SERIALIZED_META_SIZE, STALENESS_THRESHOLD_SLOTS, +use hydra_api::{ + consts::{self, CRANK_HEADER_SIZE, REMAINING_INFINITE}, + SERIALIZED_META_SIZE, }; /// Minimal decoded projection of a Crank account — just the fields we need @@ -25,6 +26,10 @@ pub struct CrankEntry { /// recipient. Non-zero = `Close` must refund the remainder to this pubkey. pub authority: [u8; 32], pub next_exec_slot: u64, + /// Slots between executions. `Trigger` advances `next_exec_slot` by exactly + /// this much on-chain, so the cranker can replay that advance locally the + /// instant it submits — without waiting for the `programSubscribe` echo. + pub interval_slots: u64, pub remaining: u64, pub priority_tip: u64, pub rent_min: u64, @@ -33,6 +38,22 @@ pub struct CrankEntry { pub data: Vec, } +fn cranker_reward(is_ephemeral: bool) -> u64 { + if is_ephemeral { + consts::ephemeral::CRANKER_REWARD + } else { + consts::base::CRANKER_REWARD + } +} + +fn staleness_threshold_slots(is_ephemeral: bool) -> u64 { + if is_ephemeral { + consts::ephemeral::STALENESS_THRESHOLD_SLOTS + } else { + consts::base::STALENESS_THRESHOLD_SLOTS + } +} + impl CrankEntry { /// Decode the header offsets from the raw account bytes. Returns `None` /// if the buffer is too small or malformed. @@ -42,6 +63,7 @@ impl CrankEntry { } let authority: [u8; 32] = data[0..32].try_into().ok()?; let next_exec_slot = u64::from_le_bytes(data[64..72].try_into().ok()?); + let interval_slots = u64::from_le_bytes(data[72..80].try_into().ok()?); let remaining = u64::from_le_bytes(data[80..88].try_into().ok()?); let priority_tip = u64::from_le_bytes(data[88..96].try_into().ok()?); let rent_min = u64::from_le_bytes(data[104..112].try_into().ok()?); @@ -51,6 +73,7 @@ impl CrankEntry { lamports, authority, next_exec_slot, + interval_slots, remaining, priority_tip, rent_min, @@ -61,14 +84,14 @@ impl CrankEntry { /// Mirrors Hydra's on-chain Trigger pre-flight: slot reached, not /// exhausted, enough lamports to cover reward + tip above the rent floor. - pub fn is_eligible(&self, current_slot: u64) -> bool { + pub fn is_eligible(&self, current_slot: u64, is_ephemeral: bool) -> bool { if current_slot < self.next_exec_slot { return false; } if self.remaining == 0 { return false; } - let reward = CRANKER_REWARD.saturating_add(self.priority_tip); + let reward = cranker_reward(is_ephemeral).saturating_add(self.priority_tip); self.lamports >= self.rent_min.saturating_add(reward) } @@ -118,16 +141,16 @@ impl CrankEntry { /// Mirrors on-chain `Close` pre-condition: exhausted OR underfunded OR /// stuck (`current_slot - next_exec_slot > STALENESS_THRESHOLD_SLOTS`). - pub fn is_closable(&self, current_slot: u64) -> bool { + pub fn is_closable(&self, current_slot: u64, is_ephemeral: bool) -> bool { if self.remaining == 0 { return true; } - let next_reward = CRANKER_REWARD.saturating_add(self.priority_tip); + let next_reward = cranker_reward(is_ephemeral).saturating_add(self.priority_tip); if self.lamports < self.rent_min.saturating_add(next_reward) { return true; } // `saturating_sub` keeps future-scheduled cranks trivially not stale. - current_slot.saturating_sub(self.next_exec_slot) > STALENESS_THRESHOLD_SLOTS + current_slot.saturating_sub(self.next_exec_slot) > staleness_threshold_slots(is_ephemeral) } } @@ -146,6 +169,31 @@ pub enum CacheOutcome { Unchanged, } +/// Replay `Trigger`'s on-chain effect on the cached entry the moment we submit, +/// so re-fire timing follows the crank's own `interval_slots` instead of waiting +/// for the `programSubscribe` echo (which lags by an unbounded number of slots). +/// +/// Mirrors `processor/*/trigger.rs`: `next_exec_slot += interval_slots` and +/// `remaining -= 1` (the `u64::MAX` "infinite" sentinel never decrements). Guarded +/// by an equality check on `next_exec_slot`: if a notification already advanced the +/// entry past the snapshot we fired on, that authoritative update wins and we don't +/// double-advance. A late, *pre*-trigger notification can still overwrite this with +/// a stale value — that self-heals, because the next fire then lands as +/// `NotYetExecutable` and the caller's backoff absorbs it. +pub fn advance_after_trigger(cache: &Cache, pubkey: Pubkey, fired_at_next_exec: u64) { + let mut guard = cache.lock().expect("cache poisoned"); + if let Some(e) = guard.get_mut(&pubkey) { + // Only the entry we actually fired on; a newer echo supersedes us. + if e.next_exec_slot != fired_at_next_exec { + return; + } + e.next_exec_slot = e.next_exec_slot.saturating_add(e.interval_slots); + if e.remaining != REMAINING_INFINITE && e.remaining != 0 { + e.remaining -= 1; + } + } +} + /// Apply a single account update to the cache. Removes entries that have /// been closed (zero lamports / empty data) or are no longer well-formed /// Crank accounts; otherwise inserts/updates the decoded entry. @@ -181,8 +229,9 @@ pub fn apply_update(cache: &Cache, pubkey: Pubkey, lamports: u64, data: &[u8]) - #[cfg(test)] mod tests { + use hydra_api::META_FLAG_WRITABLE; + use super::*; - use hydra_api::consts::{CRANK_HEADER_SIZE, META_FLAG_WRITABLE}; /// Build a raw crank buffer (120-byte header + one scheduled ix that lists /// `metas` in the on-chain tail wire layout). @@ -199,6 +248,79 @@ mod tests { data } + fn entry(next_exec_slot: u64, interval_slots: u64, remaining: u64) -> CrankEntry { + CrankEntry { + pubkey: Pubkey::new_unique(), + lamports: u64::MAX, + authority: [0u8; 32], + next_exec_slot, + interval_slots, + remaining, + priority_tip: 0, + rent_min: 0, + cu_limit: 0, + data: Vec::new(), + } + } + + fn seed(cache: &Cache, e: CrankEntry) -> Pubkey { + let pk = e.pubkey; + cache.lock().unwrap().insert(pk, e); + pk + } + + fn snapshot(cache: &Cache, pk: &Pubkey) -> (u64, u64) { + let g = cache.lock().unwrap(); + let e = g.get(pk).unwrap(); + (e.next_exec_slot, e.remaining) + } + + #[test] + fn advance_replays_trigger_effect() { + let cache = new_cache(); + let pk = seed(&cache, entry(100, 5, 3)); + advance_after_trigger(&cache, pk, 100); + assert_eq!( + snapshot(&cache, &pk), + (105, 2), + "next_exec += interval, remaining -= 1" + ); + } + + #[test] + fn advance_is_skipped_when_a_newer_echo_already_moved_the_entry() { + let cache = new_cache(); + // A `programSubscribe` update landed first, advancing the entry to 105. + let pk = seed(&cache, entry(105, 5, 2)); + // We fired on the older snapshot (100); the guard must not double-advance. + advance_after_trigger(&cache, pk, 100); + assert_eq!( + snapshot(&cache, &pk), + (105, 2), + "stale fire must not advance" + ); + } + + #[test] + fn advance_keeps_the_infinite_remaining_sentinel() { + let cache = new_cache(); + let pk = seed(&cache, entry(100, 1, REMAINING_INFINITE)); + advance_after_trigger(&cache, pk, 100); + assert_eq!( + snapshot(&cache, &pk), + (101, REMAINING_INFINITE), + "infinite cranks advance the schedule but never decrement remaining" + ); + } + + #[test] + fn advance_does_not_underflow_an_exhausted_crank() { + let cache = new_cache(); + let pk = seed(&cache, entry(100, 1, 0)); + advance_after_trigger(&cache, pk, 100); + assert_eq!(snapshot(&cache, &pk).1, 0, "remaining == 0 stays 0"); + } + #[test] fn references_account_detects_metas_regardless_of_flag() { let cranker = Pubkey::new_unique(); diff --git a/crates/hydra-cranker/src/fire.rs b/crates/hydra-cranker/src/fire.rs index 6a6bd34..6acffa2 100644 --- a/crates/hydra-cranker/src/fire.rs +++ b/crates/hydra-cranker/src/fire.rs @@ -62,7 +62,12 @@ pub fn fire_trigger( ) -> Result<()> { let scheduled = ix::scheduled_ixs_from_crank(&entry.data) .ok_or_else(|| anyhow!("malformed crank tail for {}", entry.pubkey))?; - let trigger = ix::trigger(entry.pubkey, cranker.pubkey()); + // Same accounts in both programs; only the program ID differs. + let trigger = if crate::mode::is_ephemeral() { + ix::ephemeral::trigger(entry.pubkey, cranker.pubkey()) + } else { + ix::base::trigger(entry.pubkey, cranker.pubkey()) + }; let blockhash = rpc.get_latest_blockhash().map_err(|e| { metrics::metrics() .rpc_errors_total @@ -163,12 +168,18 @@ pub fn fire_close( entry: &CrankEntry, priority_fee_micro_lamports: u64, ) -> Result<()> { + // Refund recipient: the authority if set (anti-grief binds it on-chain), + // otherwise the cranker itself. let recipient = if entry.authority == [0u8; 32] { cranker.pubkey() } else { Pubkey::new_from_array(entry.authority) }; - let close = ix::close(cranker.pubkey(), entry.pubkey, recipient); + let close = if crate::mode::is_ephemeral() { + ix::ephemeral::close(cranker.pubkey(), entry.pubkey, recipient) + } else { + ix::base::close(cranker.pubkey(), entry.pubkey, recipient) + }; let blockhash = rpc.get_latest_blockhash().map_err(|e| { metrics::metrics() .rpc_errors_total diff --git a/crates/hydra-cranker/src/main.rs b/crates/hydra-cranker/src/main.rs index 8dcf310..08a8e00 100644 --- a/crates/hydra-cranker/src/main.rs +++ b/crates/hydra-cranker/src/main.rs @@ -22,10 +22,13 @@ use solana_signer::Signer; /// Consecutive failures at the same `next_exec_slot` before a crank is parked. const MAX_CONSECUTIVE_FAILURES: u32 = 10; -/// Slots to skip a crank after a successful submit. Absorbs the in-flight -/// window where both our cache and the RPC's preflight bank are stale; without -/// it, a second fire within the window lands as `NotYetExecutable` (0x1). -const POST_SUBMIT_COOLDOWN_SLOTS: u64 = 3; +/// Backstop floor between fires of the same crank, on top of the optimistic +/// `next_exec_slot` advance done on every successful submit (see +/// `cache::advance_after_trigger`). That advance is what normally gates re-fire +/// to the crank's own `interval_slots`; this only matters when it can't move the +/// schedule — `interval_slots == 0` ("every slot") cranks — where it caps the +/// blind retry rate at one fire per slot until the `programSubscribe` echo lands. +const POST_SUBMIT_COOLDOWN_SLOTS: u64 = 1; /// Slots between Close attempts on the same crank. Close is one-shot: success /// purges the crank from the cache, so this map only tracks race losers. @@ -51,10 +54,10 @@ mod cache; mod fire; mod grpc; mod metrics; +mod mode; mod watch; use cache::new_cache; -use hydra_api::instruction as ix; #[derive(Parser, Debug)] #[command( @@ -109,6 +112,12 @@ struct Cli { default_value_t = false )] trigger_skip_preflight: bool, + /// Target Hydra's ephemeral-rollup program instead of the base-layer one. + /// Switches the watched program ID, the `Close` account layout, and the + /// funding/eligibility model (ephemeral cranks hold zero lamports). Point + /// `--rpc-url` at a MagicBlock ephemeral validator. + #[arg(long, env = "HYDRA_CRANKER_EPHEMERAL", default_value_t = false)] + ephemeral: bool, /// Run *every* eligible crank, including ones whose scheduled instructions /// reference the cranker's own pubkey. Such cranks can't actually fire — as /// the fee payer the cranker is promoted to signer + writable, so the @@ -155,9 +164,14 @@ fn main() -> Result<()> { .init(); let args = Cli::parse(); + mode::init(args.ephemeral); let cranker = load_keypair(&args.keypair)?; let cranker_pubkey = cranker.pubkey(); log::info!("cranker pubkey = {}", cranker_pubkey); + log::info!( + "mode = {}", + if args.ephemeral { "ephemeral" } else { "base" } + ); if args.run_unsafe { log::warn!("--unsafe: running cranks that reference the cranker's own pubkey"); } @@ -172,7 +186,7 @@ fn main() -> Result<()> { log::info!("rpc = {}", args.rpc_url); log::info!("ws = {}", ws_url); - let program_id = ix::program_id(); + let program_id = mode::program_id(); let cache = new_cache(); let shutdown = Arc::new(AtomicBool::new(false)); // `at_slot` anchors each counter to an observed `next_exec_slot`: once @@ -265,9 +279,9 @@ fn main() -> Result<()> { let mut elig = Vec::new(); let mut clos = Vec::new(); for entry in guard.values() { - if entry.is_closable(slot) { + if entry.is_closable(slot, args.ephemeral) { clos.push(entry.clone()); - } else if entry.is_eligible(slot) { + } else if entry.is_eligible(slot, args.ephemeral) { // A crank that references the cranker's own pubkey can never // fire (the cranker is promoted to signer + writable as the // fee payer) and is unsafe to run — skip unless `--unsafe`. @@ -333,6 +347,10 @@ fn main() -> Result<()> { .with_label_values(&["ok"]) .inc(); last_submit.insert(entry.pubkey, slot); + // Replay `Trigger`'s schedule advance in our cache now, so + // the crank's next fire follows its `interval_slots` instead + // of stalling until the `programSubscribe` echo catches up. + cache::advance_after_trigger(&cache, entry.pubkey, entry.next_exec_slot); // Failure record clears only when the cache observes an // advanced `next_exec_slot`; submit-Ok alone isn't proof // the tx landed. diff --git a/crates/hydra-cranker/src/mode.rs b/crates/hydra-cranker/src/mode.rs new file mode 100644 index 0000000..a525fde --- /dev/null +++ b/crates/hydra-cranker/src/mode.rs @@ -0,0 +1,36 @@ +//! Runtime selection of the target Hydra program. +//! +//! The cranker is a single binary that can drive either the base-layer program +//! or the ephemeral-rollup program; the choice is made once at startup from the +//! `--ephemeral` flag (not a compile-time feature). The base and ephemeral +//! cranks differ in their program ID, their `Close` account layout, and their +//! funding model (ephemeral cranks hold zero lamports), so the hot paths consult +//! this module rather than threading a flag through every call. + +use std::sync::OnceLock; + +use solana_pubkey::Pubkey; + +use hydra_api::instruction as ix; + +static EPHEMERAL: OnceLock = OnceLock::new(); + +/// Record the selected mode. Call once, before any watcher or the trigger loop +/// starts. Later calls are ignored. +pub fn init(ephemeral: bool) { + let _ = EPHEMERAL.set(ephemeral); +} + +/// Whether the cranker targets the ephemeral-rollup program. +pub fn is_ephemeral() -> bool { + EPHEMERAL.get().copied().unwrap_or(false) +} + +/// The program ID the cranker watches and submits to. +pub fn program_id() -> Pubkey { + if is_ephemeral() { + ix::EPHEMERAL_PROGRAM_ID + } else { + ix::BASE_PROGRAM_ID + } +} diff --git a/examples/anchor/Anchor.toml b/examples/anchor/Anchor.toml index 83fb843..722a9d1 100644 --- a/examples/anchor/Anchor.toml +++ b/examples/anchor/Anchor.toml @@ -5,26 +5,17 @@ anchor_version = "1.0.0" resolution = true skip-lint = false -[programs.localnet] -hydra_example_anchor = "Xyj597GykzwSu44muqNHtYs2aKUgm9ydNoHNySTDFs5" - [programs.devnet] hydra_example_anchor = "Xyj597GykzwSu44muqNHtYs2aKUgm9ydNoHNySTDFs5" -[registry] -url = "https://api.apr.dev" +[programs.localnet] +hydra_example_anchor = "rgMuTEnEYWEK3EzqH9MkFthU9shDAPLgZX7dztEbBf4" [provider] -cluster = "Localnet" +cluster = "localnet" wallet = "~/.config/solana/id.json" [scripts] -# The mollusk test lives inside the program crate (tests/mollusk.rs). -# Mollusk runs in-process — no validator, no deploy — so invoke via: -# -# anchor test --skip-local-validator --skip-deploy -# -# (Plain `anchor test` spins up a local validator and tries to deploy, -# which is unnecessary; adding those two flags makes Anchor skip both -# and just run this script.) test = "cargo test --manifest-path programs/hydra-example-anchor/Cargo.toml --test mollusk -- --nocapture" + +[hooks] diff --git a/examples/anchor/programs/hydra-example-anchor/Cargo.toml b/examples/anchor/programs/hydra-example-anchor/Cargo.toml index f118748..e67553a 100644 --- a/examples/anchor/programs/hydra-example-anchor/Cargo.toml +++ b/examples/anchor/programs/hydra-example-anchor/Cargo.toml @@ -17,6 +17,9 @@ no-entrypoint = [] no-idl = [] no-log-ix-name = [] idl-build = ["anchor-lang/idl-build"] +anchor-debug = [] +custom-panic = [] +custom-heap = [] [dependencies] anchor-lang = "1.0" @@ -34,3 +37,8 @@ solana-instruction = "3" solana-pubkey = "4" # Host-side builders + PDA helpers. hydra-api = { path = "../../../../crates/hydra-api", features = ["client"] } + +[lints.rust.unexpected_cfgs] +level = "warn" +priority = 0 +check-cfg = ['cfg(target_os, values("solana"))'] diff --git a/examples/anchor/programs/hydra-example-anchor/src/lib.rs b/examples/anchor/programs/hydra-example-anchor/src/lib.rs index 5f130c5..e9c7fbd 100644 --- a/examples/anchor/programs/hydra-example-anchor/src/lib.rs +++ b/examples/anchor/programs/hydra-example-anchor/src/lib.rs @@ -9,11 +9,11 @@ use anchor_lang::prelude::*; use hydra_api::{ - cpi::native as hydra_cpi, + cpi::base::native as hydra_cpi, instruction::{CreateArgs, ScheduledIx}, }; -declare_id!("Xyj597GykzwSu44muqNHtYs2aKUgm9ydNoHNySTDFs5"); +declare_id!("rgMuTEnEYWEK3EzqH9MkFthU9shDAPLgZX7dztEbBf4"); #[program] pub mod hydra_example_anchor { @@ -37,11 +37,12 @@ pub mod hydra_example_anchor { priority_tip: 1_000, cu_limit: 0, // no on-chain CU override scheduled: &[ScheduledIx { - program_id: target_program_id, + program_id: target_program_id.to_bytes(), metas: &[], data: b"tick", }], }, + &[], ) .map_err(Into::into) } diff --git a/examples/anchor/programs/hydra-example-anchor/tests/mollusk.rs b/examples/anchor/programs/hydra-example-anchor/tests/mollusk.rs index 9d4ad4b..da8764d 100644 --- a/examples/anchor/programs/hydra-example-anchor/tests/mollusk.rs +++ b/examples/anchor/programs/hydra-example-anchor/tests/mollusk.rs @@ -36,7 +36,7 @@ const HYDRA_SO: &str = concat!( ); fn hydra_id() -> Pubkey { - Pubkey::new_from_array(hydra_api::ID.to_bytes()) + Pubkey::new_from_array(hydra_api::base::ID.to_bytes()) } #[test] @@ -63,7 +63,7 @@ fn schedule_creates_crank_via_cpi_into_hydra() { // Derive the crank PDA the example will create. let seed = [0x11u8; 32]; - let (crank_addr, _bump) = hydra_api::state::find_crank_pda(&seed); + let (crank_addr, _bump) = hydra_api::state::find_base_crank_pda(&seed); let crank = Pubkey::new_from_array(crank_addr.to_bytes()); let payer = Pubkey::new_unique(); let target_program_id = Pubkey::new_unique(); diff --git a/examples/native/src/lib.rs b/examples/native/src/lib.rs index 0391859..fe1bf4d 100644 --- a/examples/native/src/lib.rs +++ b/examples/native/src/lib.rs @@ -22,7 +22,7 @@ use solana_program_error::{ProgramError, ProgramResult}; use solana_pubkey::Pubkey; use hydra_api::{ - cpi::native as hydra_cpi, + cpi::base::native as hydra_cpi, instruction::{CreateArgs, ScheduledIx}, }; @@ -55,10 +55,11 @@ pub fn process_instruction( priority_tip: 1_000, cu_limit: 0, // no on-chain CU override scheduled: &[ScheduledIx { - program_id: target_program_id, + program_id: target_program_id.to_bytes(), metas: &[], data: b"tick", }], }, + &[], ) } diff --git a/examples/native/tests/mollusk.rs b/examples/native/tests/mollusk.rs index 4e3e2e5..4df5f5b 100644 --- a/examples/native/tests/mollusk.rs +++ b/examples/native/tests/mollusk.rs @@ -24,7 +24,7 @@ const EXAMPLE_SO: &str = concat!( const HYDRA_SO: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/../../target/deploy/hydra"); fn hydra_id() -> Pubkey { - Pubkey::new_from_array(hydra_api::ID.to_bytes()) + Pubkey::new_from_array(hydra_api::base::ID.to_bytes()) } #[test] @@ -45,7 +45,7 @@ fn schedule_creates_crank_via_cpi_into_hydra() { // Derive the crank PDA. The seed must match what the example hard-codes // into its `CreateArgs` (it just passes through the user-supplied seed). let seed = [0x33u8; 32]; - let (crank_addr, _bump) = hydra_api::state::find_crank_pda(&seed); + let (crank_addr, _bump) = hydra_api::state::find_base_crank_pda(&seed); let crank = Pubkey::new_from_array(crank_addr.to_bytes()); let payer = Pubkey::new_unique(); let target_program_id = Pubkey::new_unique(); diff --git a/examples/pinocchio/src/lib.rs b/examples/pinocchio/src/lib.rs index 536f0a9..82ff66b 100644 --- a/examples/pinocchio/src/lib.rs +++ b/examples/pinocchio/src/lib.rs @@ -5,15 +5,13 @@ #![no_std] use pinocchio::{ - cpi::invoke, - error::ProgramError, - instruction::{InstructionAccount, InstructionView}, - no_allocator, nostd_panic_handler, program_entrypoint, AccountView, Address, ProgramResult, + error::ProgramError, no_allocator, nostd_panic_handler, program_entrypoint, AccountView, + Address, ProgramResult, }; use hydra_api::{ - consts::{ix as disc, MAX_ACCOUNTS, MAX_DATA_LEN}, - instruction::{CREATE_FIXED_PREFIX_LEN, CREATE_IX_HEADER_LEN}, + consts::{MAX_ACCOUNTS, MAX_DATA_LEN}, + instruction::{CreateArgs, ScheduledIx, CREATE_FIXED_PREFIX_LEN, CREATE_IX_HEADER_LEN}, }; program_entrypoint!(process); @@ -27,7 +25,7 @@ const DISC_SCHEDULE: u8 = 0; const CREATE_BUF_MAX: usize = 1 + CREATE_FIXED_PREFIX_LEN + CREATE_IX_HEADER_LEN + 33 * MAX_ACCOUNTS + MAX_DATA_LEN; -pub fn process(_program_id: &Address, accounts: &mut [AccountView], data: &[u8]) -> ProgramResult { +pub fn process(_program_id: &Address, accounts: &[AccountView], data: &[u8]) -> ProgramResult { let [disc_byte, rest @ ..] = data else { return Err(ProgramError::InvalidInstructionData); }; @@ -43,7 +41,9 @@ fn schedule(accounts: &[AccountView], data: &[u8]) -> ProgramResult { if data.len() < 66 { return Err(ProgramError::InvalidInstructionData); } - let seed = &data[0..32]; + let seed: [u8; 32] = data[0..32] + .try_into() + .map_err(|_| ProgramError::InvalidInstructionData)?; let target_program_id = &data[32..64]; let tick_len = u16::from_le_bytes([data[64], data[65]]) as usize; if data.len() < 66 + tick_len || tick_len > MAX_DATA_LEN { @@ -51,48 +51,31 @@ fn schedule(accounts: &[AccountView], data: &[u8]) -> ProgramResult { } let tick_data = &data[66..66 + tick_len]; - let [payer, crank, system_program, hydra_program, ..] = accounts else { + let [payer, crank, system_program, _hydra_program, ..] = accounts else { return Err(ProgramError::NotEnoughAccountKeys); }; // Build Hydra Create data on the stack. - let mut buf = [0u8; CREATE_BUF_MAX]; - let mut cursor = 0usize; - buf[cursor] = disc::CREATE; - cursor += 1; - buf[cursor..cursor + 32].copy_from_slice(seed); - cursor += 32; - buf[cursor..cursor + 32].copy_from_slice(&[0u8; 32]); // no cancel authority - cursor += 32; - buf[cursor..cursor + 8].copy_from_slice(&0u64.to_le_bytes()); // start_slot - cursor += 8; - buf[cursor..cursor + 8].copy_from_slice(&400u64.to_le_bytes()); // interval_slots - cursor += 8; - buf[cursor..cursor + 8].copy_from_slice(&10u64.to_le_bytes()); // remaining - cursor += 8; - buf[cursor..cursor + 8].copy_from_slice(&1_000u64.to_le_bytes()); // priority_tip - cursor += 8; - buf[cursor..cursor + 4].copy_from_slice(&0u32.to_le_bytes()); // cu_limit (omit) - cursor += 4; - buf[cursor] = 0; // num_accounts (single scheduled ix; parsed until data ends) - cursor += 1; - buf[cursor..cursor + 2].copy_from_slice(&(tick_len as u16).to_le_bytes()); - cursor += 2; - buf[cursor..cursor + 32].copy_from_slice(target_program_id); - cursor += 32; - // No metas. - buf[cursor..cursor + tick_len].copy_from_slice(tick_data); - cursor += tick_len; - - let metas = [ - InstructionAccount::writable_signer(payer.address()), - InstructionAccount::writable(crank.address()), - InstructionAccount::readonly(system_program.address()), - ]; - let ix = InstructionView { - program_id: hydra_program.address(), - accounts: &metas, - data: &buf[..cursor], - }; - invoke(&ix, &[payer, crank, system_program]) + hydra_api::cpi::base::pinocchio::create::( + payer, + crank, + system_program, + &CreateArgs { + seed, + authority: [0u8; 32], + start_slot: 0, + interval_slots: 400, + remaining: 10, + priority_tip: 1_000, + cu_limit: 0, + scheduled: &[ScheduledIx { + program_id: target_program_id + .try_into() + .map_err(|_| ProgramError::InvalidInstructionData)?, + metas: &[], + data: tick_data, + }], + }, + &[], + ) } diff --git a/examples/pinocchio/tests/mollusk.rs b/examples/pinocchio/tests/mollusk.rs index 88afc9f..88812dd 100644 --- a/examples/pinocchio/tests/mollusk.rs +++ b/examples/pinocchio/tests/mollusk.rs @@ -27,7 +27,7 @@ const HYDRA_SO: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/../../target/deploy const DISC_SCHEDULE: u8 = 0; fn hydra_id() -> Pubkey { - Pubkey::new_from_array(hydra_api::ID.to_bytes()) + Pubkey::new_from_array(hydra_api::base::ID.to_bytes()) } #[test] @@ -48,7 +48,7 @@ fn schedule_creates_crank_via_cpi_into_hydra() { // Derive the crank PDA from the seed we'll pass in. let seed = [0x55u8; 32]; - let (crank_addr, _bump) = hydra_api::state::find_crank_pda(&seed); + let (crank_addr, _bump) = hydra_api::state::find_base_crank_pda(&seed); let crank = Pubkey::new_from_array(crank_addr.to_bytes()); let payer = Pubkey::new_unique(); let target_program_id = Pubkey::new_unique(); diff --git a/programs/hydra-ephemeral/Cargo.toml b/programs/hydra-ephemeral/Cargo.toml new file mode 100644 index 0000000..d87c573 --- /dev/null +++ b/programs/hydra-ephemeral/Cargo.toml @@ -0,0 +1,33 @@ +[package] +name = "hydra-ephemeral" +version.workspace = true +edition.workspace = true +license.workspace = true +description = "Hydra — permissionless crank for MagicBlock ephemeral rollups" + +[lib] +crate-type = ["cdylib"] + +[features] +default = [] +# Gates all `pinocchio_log::log!` invocations at compile time so release +# builds emit no log strings. +logging = [] +# Skip the on-chain entrypoint when this crate is consumed as a library +# (e.g. by integration tests that already loaded the .so separately). +no-entrypoint = [] + +[dependencies] +pinocchio = { workspace = true } +pinocchio-log = { workspace = true } +# CPI helpers for MagicBlock ephemeral accounts — the funding model that +# distinguishes this program from the base-layer `hydra` crate. +ephemeral-rollups-pinocchio = { workspace = true } +solana-define-syscall = { workspace = true } +hydra-api = { workspace = true, features = ["program"] } + +[package.metadata.solana] +program-id = "eHyd5BU8QffvHi4GnXwxrK4WpS7pM2x9UGKHBWii7mf" + +[lints] +workspace = true diff --git a/programs/hydra-ephemeral/src/entrypoint.rs b/programs/hydra-ephemeral/src/entrypoint.rs new file mode 100644 index 0000000..49ccf37 --- /dev/null +++ b/programs/hydra-ephemeral/src/entrypoint.rs @@ -0,0 +1,74 @@ +//! Program entrypoint and top-level discriminator dispatcher. + +use pinocchio::error::ProgramError; +use pinocchio::{ + no_allocator, nostd_panic_handler, program_entrypoint, AccountView, Address, ProgramResult, +}; + +use hydra_api::{consts::ix, HydraError}; + +use crate::processor; + +program_entrypoint!(process_instruction); +no_allocator!(); +nostd_panic_handler!(); + +pub fn process_instruction( + _program_id: &Address, + accounts: &[AccountView], + instruction_data: &[u8], +) -> ProgramResult { + inner_process_instruction(accounts, instruction_data).inspect_err(log_error) +} + +#[inline(never)] +fn inner_process_instruction(accounts: &[AccountView], instruction_data: &[u8]) -> ProgramResult { + let [discriminator, rest @ ..] = instruction_data else { + return Err(HydraError::InvalidInstruction.into()); + }; + + match *discriminator { + ix::CREATE => { + #[cfg(feature = "logging")] + pinocchio_log::log!("ix: Create"); + processor::create::process(accounts, rest) + } + ix::TRIGGER => { + #[cfg(feature = "logging")] + pinocchio_log::log!("ix: Trigger"); + processor::trigger::process(accounts, rest) + } + ix::CANCEL => { + #[cfg(feature = "logging")] + pinocchio_log::log!("ix: Cancel"); + processor::cancel::process(accounts, rest) + } + ix::CLOSE => { + #[cfg(feature = "logging")] + pinocchio_log::log!("ix: Close"); + processor::close::process(accounts, rest) + } + _ => Err(HydraError::InvalidInstruction.into()), + } +} + +#[cold] +fn log_error(_error: &ProgramError) { + // When `logging` is off, this is a no-op: zero CU on the error path, + // and the raw `ProgramError` propagates to the runtime. When `logging` + // is on, emit the variant's name (including `HydraError::*` for Custom + // values) through the `sol_log_` syscall — no `format!`, no alloc. + #[cfg(feature = "logging")] + { + // `to_str` is an inherent method on `ProgramError` that internally + // calls `HydraError::to_str` (defined in hydra-api::error) for + // `ProgramError::Custom(n)` values. + let msg: &'static str = _error.to_str::(); + #[cfg(target_os = "solana")] + // SAFETY: sol_log_ takes (ptr, len) of a byte buffer the syscall + // reads read-only for the duration of the call. + unsafe { + solana_define_syscall::definitions::sol_log_(msg.as_ptr(), msg.len() as u64); + } + } +} diff --git a/programs/hydra-ephemeral/src/lib.rs b/programs/hydra-ephemeral/src/lib.rs new file mode 100644 index 0000000..86d0077 --- /dev/null +++ b/programs/hydra-ephemeral/src/lib.rs @@ -0,0 +1,8 @@ +#![no_std] + +#[cfg(not(feature = "no-entrypoint"))] +mod entrypoint; + +mod processor; + +pub use hydra_api::ephemeral::*; diff --git a/programs/hydra-ephemeral/src/processor/cancel.rs b/programs/hydra-ephemeral/src/processor/cancel.rs new file mode 100644 index 0000000..213b64c --- /dev/null +++ b/programs/hydra-ephemeral/src/processor/cancel.rs @@ -0,0 +1,28 @@ +//! `Cancel` (disc 2). +//! +//! Authority-gated drain of the crank's lamport balance to `recipient` (shared +//! with base via `process_cancel`), then a Magic `CloseEphemeralAccount` CPI to +//! deallocate the ephemeral account and refund its vault rent to `authority`. +//! +//! Accounts: `[authority(w,s), crank(w), recipient(w), vault(w), magic_program(ro)]`. + +use ephemeral_rollups_pinocchio::ephemeral_accounts::EphemeralAccount; +use pinocchio::{error::ProgramError, AccountView, ProgramResult}; + +use hydra_api::program::processor::process_cancel; + +use crate::processor::common::check_magic_accounts; + +pub fn process(accounts: &[AccountView], _data: &[u8]) -> ProgramResult { + let [authority, crank_ai, recipient, vault, magic_program] = accounts else { + return Err(ProgramError::NotEnoughAccountKeys); + }; + + check_magic_accounts(vault, magic_program)?; + + process_cancel(authority, crank_ai, recipient, &crate::ID)?; + + // Deallocate the now zero-lamport ephemeral account; Magic refunds the vault + // rent to `authority`. + EphemeralAccount::new(authority, crank_ai, vault, magic_program).close() +} diff --git a/programs/hydra-ephemeral/src/processor/close.rs b/programs/hydra-ephemeral/src/processor/close.rs new file mode 100644 index 0000000..a84570c --- /dev/null +++ b/programs/hydra-ephemeral/src/processor/close.rs @@ -0,0 +1,50 @@ +//! `Close` (disc 3). +//! +//! Cleanup of an exhausted / underfunded / stuck ephemeral crank. Pays the +//! cranker bounty to `reporter` and refunds the remaining balance to `recipient` +//! (shared with base via `process_close`), then CPIs Magic +//! `CloseEphemeralAccount` to deallocate the account and refund its vault rent. +//! +//! Unlike the base `Close`, this is only permissionless for *unowned* cranks +//! (`authority == 0`). When a crank carries a non-zero authority, only that +//! authority may close it. The reason is the vault rent: Magic refunds it to the +//! teardown's signer, so a permissionless close would hand an owned crank's rent +//! to an arbitrary `reporter`. Gating owned cranks to their authority keeps the +//! whole teardown — bounty, leftover balance, and vault rent — with the owner, +//! while unowned cranks stay permissionlessly closable by anyone. +//! +//! Accounts: `[reporter(w,s), crank(w), recipient(w), vault(w), magic_program(ro)]`. + +use ephemeral_rollups_pinocchio::ephemeral_accounts::EphemeralAccount; +use pinocchio::{error::ProgramError, AccountView, ProgramResult}; + +use hydra_api::{program::processor::process_close, state::load_crank, HydraError}; + +use crate::processor::common::check_magic_accounts; + +pub fn process(accounts: &[AccountView], _data: &[u8]) -> ProgramResult { + let [reporter, crank_ai, recipient, vault, magic_program] = accounts else { + return Err(ProgramError::NotEnoughAccountKeys); + }; + + check_magic_accounts(vault, magic_program)?; + + // A crank with an authority can only be closed by that authority: Magic + // refunds the vault rent to the teardown's signer, so this keeps an owned + // crank's rent with its owner instead of an arbitrary reporter. Unowned + // cranks (`authority == 0`) stay permissionlessly closable. + let stored_authority = { + let data = crank_ai.try_borrow()?; + unsafe { load_crank(&data)? }.authority + }; + if stored_authority != [0u8; 32] && reporter.address().as_array() != &stored_authority { + return Err(HydraError::UnauthorizedAuthority.into()); + } + + // Closable check + bounty/refund split, zeroes crank (ephemeral economics). + process_close(reporter, crank_ai, recipient, &crate::ID, true)?; + + // Deallocate the now zero-lamport ephemeral account; Magic refunds the vault + // rent to `reporter` (== the authority for owned cranks, per the gate above). + EphemeralAccount::new(reporter, crank_ai, vault, magic_program).close() +} diff --git a/programs/hydra-ephemeral/src/processor/common.rs b/programs/hydra-ephemeral/src/processor/common.rs new file mode 100644 index 0000000..fb94f31 --- /dev/null +++ b/programs/hydra-ephemeral/src/processor/common.rs @@ -0,0 +1,17 @@ +//! Helpers specific to the ephemeral-rollup crank processors. + +use ephemeral_rollups_pinocchio::consts::{EPHEMERAL_VAULT_ID, MAGIC_PROGRAM_ID}; +use pinocchio::{error::ProgramError, AccountView, ProgramResult}; + +/// Reject calls that don't reference the real Magic program + ephemeral vault, +/// so the CPI can't be pointed at an impostor program/vault. +#[inline(always)] +pub(super) fn check_magic_accounts( + vault: &AccountView, + magic_program: &AccountView, +) -> ProgramResult { + if vault.address() != &EPHEMERAL_VAULT_ID || magic_program.address() != &MAGIC_PROGRAM_ID { + return Err(ProgramError::InvalidAccountData); + } + Ok(()) +} diff --git a/programs/hydra-ephemeral/src/processor/create.rs b/programs/hydra-ephemeral/src/processor/create.rs new file mode 100644 index 0000000..1cbe79a --- /dev/null +++ b/programs/hydra-ephemeral/src/processor/create.rs @@ -0,0 +1,67 @@ +//! `Create` (disc 0). +//! +//! Allocates the crank as a MagicBlock ephemeral account (owned by Hydra) via a +//! Magic-program CPI — which materializes it synchronously — then writes the +//! `Crank` header + scheduled-ix tail, all in one instruction. +//! +//! Accounts: `[sponsor(w,s), crank(w), vault(w), magic_program(ro)]`. +//! Data: identical to base `Create`'s body (seed, authority, schedule, scheduled +//! ixs). `sponsor` sets `authority_signer` and pays the per-byte ephemeral rent. + +use ephemeral_rollups_pinocchio::ephemeral_accounts::EphemeralAccount; +use pinocchio::{ + cpi::{Seed, Signer}, + error::ProgramError, + AccountView, ProgramResult, +}; + +use hydra_api::consts::{CRANK_HEADER_SIZE, CRANK_SEED_PREFIX}; +use hydra_api::program::processor::{ + derive_crank_pda, measure_region, parse_create_header, write_crank, +}; + +use crate::processor::common::check_magic_accounts; + +pub fn process(accounts: &[AccountView], data: &[u8]) -> ProgramResult { + let [sponsor, crank_ai, vault, magic_program] = accounts else { + return Err(ProgramError::NotEnoughAccountKeys); + }; + + let header = parse_create_header(data)?; + let authority_signer: u8 = + (sponsor.address().as_array() == &header.authority && sponsor.is_signer()) as u8; + + check_magic_accounts(vault, magic_program)?; + + // Size the account from the scheduled ixs (validates the schedule), then + // allocate it. The exact tail is written below. + let region_len = measure_region(data)?; + let bump = derive_crank_pda(crank_ai, &header.seed, &crate::ID)?; + let data_len = CRANK_HEADER_SIZE + region_len; + + // The crank PDA must sign the create CPI (the ephemeral account is a signer + // on create, to prevent pubkey squatting); Hydra signs it with the seeds. + let bump_arr = [bump]; + let seeds = [ + Seed::from(CRANK_SEED_PREFIX), + Seed::from(header.seed.as_ref()), + Seed::from(&bump_arr), + ]; + let signer = Signer::from(&seeds); + + EphemeralAccount::new(sponsor, crank_ai, vault, magic_program) + .with_signers(&[signer]) + .create(data_len as u32)?; + + // The account is sized to `region_len`, so `write_crank` fills it exactly. + // Ephemeral cranks hold no lamports, so the rent floor is `0`. + write_crank( + crank_ai, + data, + &header, + bump, + authority_signer, + 0, + region_len, + ) +} diff --git a/programs/hydra-ephemeral/src/processor/mod.rs b/programs/hydra-ephemeral/src/processor/mod.rs new file mode 100644 index 0000000..f4af781 --- /dev/null +++ b/programs/hydra-ephemeral/src/processor/mod.rs @@ -0,0 +1,12 @@ +//! Ephemeral-rollup crank lifecycle. +//! +//! Schedule parsing, tail serialization and follow-up verification are shared +//! with the base-layer program via [`hydra_api::program`]; only the MagicBlock +//! ephemeral-account funding model lives here. + +pub mod cancel; +pub mod close; +pub mod create; +pub mod trigger; + +mod common; diff --git a/programs/hydra-ephemeral/src/processor/trigger.rs b/programs/hydra-ephemeral/src/processor/trigger.rs new file mode 100644 index 0000000..28e0241 --- /dev/null +++ b/programs/hydra-ephemeral/src/processor/trigger.rs @@ -0,0 +1,99 @@ +//! `Trigger` (disc 1). +//! +//! Runs a crank's scheduled ixs once, on the ephemeral rollup. Same top-level + +//! instructions-sysvar + memcmp follow-up verification, cranker payout and +//! schedule advance as base `Trigger`. The crank holds its reward budget as a +//! plain lamport balance (funded by the sponsor); `rent_min` is `0` because the +//! ephemeral account's rent lives in the Magic vault, not in the account. +//! +//! Accounts: `[crank(w), cranker(w,s), instructions_sysvar]`. + +use pinocchio::{ + error::ProgramError, sysvars::instructions::INSTRUCTIONS_ID, AccountView, ProgramResult, +}; + +use hydra_api::{ + consts::{ephemeral, REMAINING_INFINITE}, + program::{ + helpers::{get_clock_slot, get_stack_height, TRANSACTION_LEVEL_STACK_HEIGHT}, + processor::verify_followup, + }, + state::{load_crank, load_crank_mut}, + HydraError, +}; + +pub fn process(accounts: &[AccountView], _data: &[u8]) -> ProgramResult { + let [crank_ai, cranker_ai, ix_sysvar_ai] = accounts else { + return Err(ProgramError::NotEnoughAccountKeys); + }; + + if !cranker_ai.is_signer() { + return Err(ProgramError::MissingRequiredSignature); + } + if !crank_ai.owned_by(&crate::ID) { + return Err(ProgramError::InvalidAccountOwner); + } + if ix_sysvar_ai.address() != &INSTRUCTIONS_ID { + return Err(ProgramError::UnsupportedSysvar); + } + if get_stack_height() != TRANSACTION_LEVEL_STACK_HEIGHT { + return Err(HydraError::InvalidInstruction.into()); + } + + let current_slot = get_clock_slot()?; + + let (next_exec_slot, interval_slots, remaining, priority_tip, executed, region_len) = { + let data = crank_ai.try_borrow()?; + let s = unsafe { load_crank(&data)? }; + ( + s.next_exec_slot(), + s.interval_slots(), + s.remaining(), + s.priority_tip(), + s.executed(), + s.region_len(), + ) + }; + + if current_slot < next_exec_slot { + return Err(HydraError::NotYetExecutable.into()); + } + if remaining == 0 { + return Err(HydraError::Exhausted.into()); + } + + // Cranker reward, paid out of the crank's balance — same as base `Trigger`, + // but at the ephemeral-rollup rate. + let reward = ephemeral::CRANKER_REWARD + .checked_add(priority_tip) + .ok_or(ProgramError::ArithmeticOverflow)?; + let new_crank_lamports = crank_ai + .lamports() + .checked_sub(reward) + .ok_or::(HydraError::InsufficientFunds.into())?; + + verify_followup(ix_sysvar_ai, crank_ai, region_len as usize)?; + + // Pay the cranker via direct lamport mutation. + crank_ai.set_lamports(new_crank_lamports); + let new_cranker_lamports = cranker_ai + .lamports() + .checked_add(reward) + .ok_or(ProgramError::ArithmeticOverflow)?; + cranker_ai.set_lamports(new_cranker_lamports); + + let next_slot = next_exec_slot + .checked_add(interval_slots) + .ok_or(ProgramError::ArithmeticOverflow)?; + { + let mut data = crank_ai.try_borrow_mut()?; + let s = unsafe { load_crank_mut(&mut data)? }; + s.set_next_exec_slot(next_slot); + s.set_executed(executed + 1); + if remaining != REMAINING_INFINITE { + s.set_remaining(remaining - 1); + } + } + + Ok(()) +} diff --git a/programs/hydra/Cargo.toml b/programs/hydra/Cargo.toml index d1c5193..0ddbec7 100644 --- a/programs/hydra/Cargo.toml +++ b/programs/hydra/Cargo.toml @@ -20,19 +20,16 @@ no-entrypoint = [] # Each checkpoint costs ~110 CU, so the visible Trigger CU ~doubles — only # enable when profiling. cu-trace = [] -# Uses the SIMD-0312 System Program instruction for prefunded crank PDAs. -# Keep this disabled on mainnet until `create_account_allow_prefund` is active. -create-account-allow-prefund = [] [dependencies] # `unsafe-account-resize` lets `Create` shrink the crank account from its O(1) # over-provisioned size down to the exact tail length (a length-only update, # no syscall), so the scheduled ixs are parsed in a single pass. -pinocchio = { workspace = true, features = ["unsafe-account-resize"] } +pinocchio = { workspace = true } pinocchio-system = { workspace = true } pinocchio-log = { workspace = true } solana-define-syscall = { workspace = true } -hydra-api = { workspace = true } +hydra-api = { workspace = true, features = ["program"] } [package.metadata.solana] program-id = "Hydra17i1feui9deaxu6d1TzSQMRNHeBRkDR1Awy7zea" diff --git a/programs/hydra/src/entrypoint.rs b/programs/hydra/src/entrypoint.rs index eb7b85c..49ccf37 100644 --- a/programs/hydra/src/entrypoint.rs +++ b/programs/hydra/src/entrypoint.rs @@ -15,17 +15,14 @@ nostd_panic_handler!(); pub fn process_instruction( _program_id: &Address, - accounts: &mut [AccountView], + accounts: &[AccountView], instruction_data: &[u8], ) -> ProgramResult { inner_process_instruction(accounts, instruction_data).inspect_err(log_error) } #[inline(never)] -fn inner_process_instruction( - accounts: &mut [AccountView], - instruction_data: &[u8], -) -> ProgramResult { +fn inner_process_instruction(accounts: &[AccountView], instruction_data: &[u8]) -> ProgramResult { let [discriminator, rest @ ..] = instruction_data else { return Err(HydraError::InvalidInstruction.into()); }; diff --git a/programs/hydra/src/lib.rs b/programs/hydra/src/lib.rs index c4d6b0b..ac9a741 100644 --- a/programs/hydra/src/lib.rs +++ b/programs/hydra/src/lib.rs @@ -3,7 +3,6 @@ #[cfg(not(feature = "no-entrypoint"))] mod entrypoint; -mod helpers; mod processor; -pub use hydra_api::ID; +pub use hydra_api::base::*; diff --git a/programs/hydra/src/processor/cancel.rs b/programs/hydra/src/processor/cancel.rs index 14144bc..fefff7c 100644 --- a/programs/hydra/src/processor/cancel.rs +++ b/programs/hydra/src/processor/cancel.rs @@ -2,49 +2,12 @@ use pinocchio::{error::ProgramError, AccountView, ProgramResult}; -use hydra_api::{state::load_crank, HydraError}; +use hydra_api::program::processor::process_cancel; -pub fn process(accounts: &mut [AccountView], _data: &[u8]) -> ProgramResult { +pub fn process(accounts: &[AccountView], _data: &[u8]) -> ProgramResult { let [authority, crank_ai, recipient] = accounts else { return Err(ProgramError::NotEnoughAccountKeys); }; - if !authority.is_signer() { - return Err(ProgramError::MissingRequiredSignature); - } - if !crank_ai.owned_by(&hydra_api::ID) { - return Err(ProgramError::InvalidAccountOwner); - } - - // Read the stored authority and bail if unkillable (all-zeros) or - // doesn't match the signer. - let stored_authority = { - let data = crank_ai.try_borrow()?; - let state = unsafe { load_crank(&data)? }; - state.authority - }; - - if stored_authority == [0u8; 32] { - return Err(HydraError::UnauthorizedAuthority.into()); - } - if authority.address().as_array() != &stored_authority { - return Err(HydraError::UnauthorizedAuthority.into()); - } - - drain_lamports(crank_ai, recipient) -} - -/// Move all lamports out of `src` into `dst`. The runtime zeroes `src.data` -/// and reassigns ownership to the system program at the instruction boundary -/// because `src.lamports == 0` post-write. -#[inline(always)] -pub(super) fn drain_lamports(src: &mut AccountView, dst: &mut AccountView) -> ProgramResult { - let amount = src.lamports(); - let new_dst = dst - .lamports() - .checked_add(amount) - .ok_or(ProgramError::ArithmeticOverflow)?; - src.set_lamports(0); - dst.set_lamports(new_dst); - Ok(()) + process_cancel(authority, crank_ai, recipient, &crate::ID) } diff --git a/programs/hydra/src/processor/close.rs b/programs/hydra/src/processor/close.rs index 2b16f75..5c45cdd 100644 --- a/programs/hydra/src/processor/close.rs +++ b/programs/hydra/src/processor/close.rs @@ -1,91 +1,20 @@ //! `Close` (disc 3) — permissionless cleanup of exhausted / underfunded / //! stuck cranks. A crank is "stuck" when `next_exec_slot` has fallen more //! than `STALENESS_THRESHOLD_SLOTS` behind the current slot, which means no -//! cranker has successfully fired it in ~31 days — almost always because the +//! cranker has successfully fired it in ~10 days — almost always because the //! inner ix deterministically fails. +//! +//! The closable check + bounty/refund payout is shared with the ephemeral +//! program via [`hydra_api::program::processor::process_close`]. use pinocchio::{error::ProgramError, AccountView, ProgramResult}; -use hydra_api::{ - consts::{CRANKER_REWARD, STALENESS_THRESHOLD_SLOTS}, - state::load_crank, - HydraError, -}; +use hydra_api::program::processor::process_close; -use crate::helpers::get_clock_slot; - -pub fn process(accounts: &mut [AccountView], _data: &[u8]) -> ProgramResult { +pub fn process(accounts: &[AccountView], _data: &[u8]) -> ProgramResult { let [reporter, crank_ai, recipient] = accounts else { return Err(ProgramError::NotEnoughAccountKeys); }; - if !reporter.is_signer() { - return Err(ProgramError::MissingRequiredSignature); - } - if !crank_ai.owned_by(&hydra_api::ID) { - return Err(ProgramError::InvalidAccountOwner); - } - - // Snapshot fields we need from the crank header. - let (stored_authority, remaining, rent_min, priority_tip, next_exec_slot, lamports_now) = { - let data = crank_ai.try_borrow()?; - let state = unsafe { load_crank(&data)? }; - ( - state.authority, - state.remaining(), - state.rent_min(), - state.priority_tip(), - state.next_exec_slot(), - crank_ai.lamports(), - ) - }; - - // Pre-condition: exhausted OR underfunded OR stuck. - let exhausted = remaining == 0; - let next_reward = CRANKER_REWARD - .checked_add(priority_tip) - .ok_or(ProgramError::ArithmeticOverflow)?; - let underfunded = lamports_now - < rent_min - .checked_add(next_reward) - .ok_or(ProgramError::ArithmeticOverflow)?; - // `next_exec_slot` only advances on *successful* `Trigger`, so persistent - // failure pins it in the past. `saturating_sub` makes future-scheduled - // cranks (`next_exec_slot > current_slot`) trivially not-stale. - let current_slot = get_clock_slot()?; - let stuck = current_slot.saturating_sub(next_exec_slot) > STALENESS_THRESHOLD_SLOTS; - - if !(exhausted || underfunded || stuck) { - return Err(HydraError::NotClosable.into()); - } - - // Anti-grief: if an authority is set, only they can receive the refund. - // Anyone can still invoke Close, but they can't redirect the rent refund. - if stored_authority != [0u8; 32] && recipient.address().as_array() != &stored_authority { - return Err(HydraError::UnauthorizedAuthority.into()); - } - - // Flat bounty (2 × base fee) to whoever cranked the cleanup; the balance - // refunds to `recipient`. `min` handles a crank holding less than the - // bounty — reporter gets what's there, recipient gets nothing. - let bounty = CRANKER_REWARD.min(lamports_now); - let refund = lamports_now - bounty; - - crank_ai.set_lamports(0); - - let new_reporter = reporter - .lamports() - .checked_add(bounty) - .ok_or(ProgramError::ArithmeticOverflow)?; - reporter.set_lamports(new_reporter); - - // When `recipient` aliases `reporter`, the write above is visible here, so - // adding `refund` on top preserves the sum. Distinct accounts: clean credit. - let new_recipient = recipient - .lamports() - .checked_add(refund) - .ok_or(ProgramError::ArithmeticOverflow)?; - recipient.set_lamports(new_recipient); - - Ok(()) + process_close(reporter, crank_ai, recipient, &crate::ID, false) } diff --git a/programs/hydra/src/processor/common.rs b/programs/hydra/src/processor/common.rs new file mode 100644 index 0000000..06d3ebb --- /dev/null +++ b/programs/hydra/src/processor/common.rs @@ -0,0 +1,14 @@ +//! Raw-pointer field helpers for the base-layer `Trigger` hot path. The lamport +//! drain + close/cancel payout logic is shared with the ephemeral program via +//! [`hydra_api::program::processor`]. + +/// Read/write the 8-byte header fields the `Trigger` hot path touches directly. +#[inline(always)] +pub(super) unsafe fn read_u64(p: *const u8) -> u64 { + core::ptr::read_unaligned(p as *const u64) +} + +#[inline(always)] +pub(super) unsafe fn write_u64(p: *mut u8, v: u64) { + core::ptr::write_unaligned(p as *mut u64, v); +} diff --git a/programs/hydra/src/processor/create.rs b/programs/hydra/src/processor/create.rs index e0e3dd3..9972f4b 100644 --- a/programs/hydra/src/processor/create.rs +++ b/programs/hydra/src/processor/create.rs @@ -22,75 +22,28 @@ use pinocchio::{ cpi::{Seed, Signer}, error::ProgramError, sysvars::{rent::Rent, Sysvar}, - AccountView, Address, ProgramResult, UnsafeResize, + AccountView, ProgramResult, }; -#[cfg(not(feature = "create-account-allow-prefund"))] use pinocchio_system::instructions::{Allocate, Assign, CreateAccount, Transfer}; -#[cfg(feature = "create-account-allow-prefund")] -use pinocchio_system::instructions::{CreateAccountAllowPrefund, Funding}; use hydra_api::{ - consts::{ - ix as _ix, CRANK_HEADER_SIZE, CRANK_SEED_PREFIX, MAX_ACCOUNTS, MAX_COMPUTE_UNIT_LIMIT, - MAX_DATA_LEN, MAX_INSTRUCTIONS, META_FLAG_SIGNER, REMAINING_INFINITE, SERIALIZED_META_SIZE, - }, - instruction::{CREATE_FIXED_PREFIX_LEN, CREATE_IX_HEADER_LEN}, - state::load_crank_mut, - HydraError, + consts::{CRANK_HEADER_SIZE, CRANK_SEED_PREFIX}, + program::processor::{derive_crank_pda, measure_region, parse_create_header, write_crank}, }; -pub fn process(accounts: &mut [AccountView], data: &[u8]) -> ProgramResult { +pub fn process(accounts: &[AccountView], data: &[u8]) -> ProgramResult { let [payer, crank_ai, _system_program] = accounts else { return Err(ProgramError::NotEnoughAccountKeys); }; - // Need at least the scheduling prefix plus one ix blob header. - if data.len() < CREATE_FIXED_PREFIX_LEN + CREATE_IX_HEADER_LEN { - return Err(ProgramError::InvalidInstructionData); - } - - // SAFETY: bounds checked above; Address/u64/u16 only require byte alignment. - let seed: &[u8; 32] = unsafe { &*(data.as_ptr() as *const [u8; 32]) }; - let authority: &[u8; 32] = unsafe { &*(data.as_ptr().add(32) as *const [u8; 32]) }; - let start_slot = read_u64_le(data, 64); - let interval_slots = read_u64_le(data, 72); - let remaining_wire = read_u64_le(data, 80); - let priority_tip = read_u64_le(data, 88); - let cu_limit = read_u32_le(data, 96); - - // `0` is the documented opt-out. Any non-zero value must be within the - // Solana per-tx ceiling or the runtime would reject the cranker's tx. - if cu_limit > MAX_COMPUTE_UNIT_LIMIT { - return Err(HydraError::InvalidSchedule.into()); - } - // A never-advancing infinite crank makes no sense. - if remaining_wire == 0 && interval_slots == 0 { - return Err(HydraError::InvalidSchedule.into()); - } - - let authority_signer: u8 = (payer.address().as_array() == authority) as u8; + let header = parse_create_header(data)?; + let authority_signer: u8 = (payer.address().as_array() == &header.authority) as u8; - // Re-serializing each scheduled ix into the instructions-sysvar tail layout - // widens its `num_accounts` from u8 to u16 — exactly one extra byte per ix — - // so the tail length is `body_len + num_instructions`, which is at most - // `body_len + MAX_INSTRUCTIONS`. Create the account at that O(1) upper bound, - // then validate + serialize the tail in a single pass and shrink to the - // exact length. This avoids a separate pass just to pre-compute the size. - let body_len = data.len() - CREATE_FIXED_PREFIX_LEN; - let upper_region = body_len + MAX_INSTRUCTIONS; - // The exact tail length (`<= upper_region`) is stored in a `u16` header field. - if upper_region > u16::MAX as usize { - return Err(HydraError::InvalidSchedule.into()); - } - - // Derive expected PDA and verify match. - let (expected_pda, bump) = - Address::find_program_address(&[CRANK_SEED_PREFIX, seed.as_ref()], &hydra_api::ID); - if crank_ai.address() != &expected_pda { - return Err(ProgramError::InvalidSeeds); - } - - let total_size = CRANK_HEADER_SIZE + upper_region; + // Size the account from the scheduled ixs (validates the schedule), verify + // the PDA, then allocate at exactly that size. The tail is written below. + let region_len = measure_region(data)?; + let bump = derive_crank_pda(crank_ai, &header.seed, &crate::ID)?; + let total_size = CRANK_HEADER_SIZE + region_len; // One sysvar read serves both CreateAccount funding and the cached floor. let rent = Rent::get()?; @@ -100,177 +53,59 @@ pub fn process(accounts: &mut [AccountView], data: &[u8]) -> ProgramResult { let bump_arr = [bump]; let seeds_arr = [ Seed::from(CRANK_SEED_PREFIX), - Seed::from(seed.as_ref()), + Seed::from(header.seed.as_ref()), Seed::from(&bump_arr), ]; let signers = [Signer::from(&seeds_arr)]; - #[cfg(feature = "create-account-allow-prefund")] - { - let funding_lamports = rent_min.saturating_sub(crank_ai.lamports()); - if funding_lamports == 0 && !payer.is_signer() { - return Err(ProgramError::MissingRequiredSignature); - } - CreateAccountAllowPrefund { + let prefunded = crank_ai.lamports(); + if prefunded == 0 { + // Fresh PDA (the common case): one `CreateAccount` CPI funds, + // allocates, and assigns in a single system-program invocation — + // a third of the CU of the split path below. + CreateAccount { + from: payer, to: crank_ai, + lamports: rent_min, space: total_size as u64, - owner: &hydra_api::ID, - funding: (funding_lamports > 0).then_some(Funding { - from: payer, - lamports: funding_lamports, - }), + owner: &crate::ID, } .invoke_signed(&signers)?; - } - - #[cfg(not(feature = "create-account-allow-prefund"))] - { - let prefunded = crank_ai.lamports(); - if prefunded == 0 { - // Fresh PDA (the common case): one `CreateAccount` CPI funds, - // allocates, and assigns in a single system-program invocation — - // a third of the CU of the split path below. - CreateAccount { + } else { + // Prefunded PDA: `CreateAccount` rejects accounts that already hold + // lamports, so top up the shortfall then allocate + assign. + let funding_lamports = rent_min.saturating_sub(prefunded); + if funding_lamports == 0 && !payer.is_signer() { + return Err(ProgramError::MissingRequiredSignature); + } + if funding_lamports > 0 { + Transfer { from: payer, to: crank_ai, - lamports: rent_min, - space: total_size as u64, - owner: &hydra_api::ID, - } - .invoke_signed(&signers)?; - } else { - // Prefunded PDA: `CreateAccount` rejects accounts that already hold - // lamports, so top up the shortfall then allocate + assign. - let funding_lamports = rent_min.saturating_sub(prefunded); - if funding_lamports == 0 && !payer.is_signer() { - return Err(ProgramError::MissingRequiredSignature); - } - if funding_lamports > 0 { - Transfer { - from: payer, - to: crank_ai, - lamports: funding_lamports, - } - .invoke()?; - } - Allocate { - account: crank_ai, - space: total_size as u64, - } - .invoke_signed(&signers)?; - Assign { - account: crank_ai, - owner: &hydra_api::ID, + lamports: funding_lamports, } - .invoke_signed(&signers)?; + .invoke()?; } - } - - // Validate each scheduled ix and re-serialize it into the tail in the - // instructions-sysvar wire layout, counting and accumulating the exact - // length as we go. The account is over-provisioned to `upper_region`, so - // every write stays in bounds (the tail is at most one byte per ix longer - // than the wire body, and there are at most `MAX_INSTRUCTIONS` of them). - let region_len = { - let mut account_data = crank_ai.try_borrow_mut()?; - let buf: &mut [u8] = &mut account_data; - let (header_bytes, tail_bytes) = buf.split_at_mut(CRANK_HEADER_SIZE); - - let mut cursor = CREATE_FIXED_PREFIX_LEN; - let mut off = 0usize; - let mut num_instructions = 0usize; - - while cursor < data.len() { - if cursor + CREATE_IX_HEADER_LEN > data.len() { - return Err(ProgramError::InvalidInstructionData); - } - num_instructions += 1; - if num_instructions > MAX_INSTRUCTIONS { - return Err(HydraError::InvalidSchedule.into()); - } - let num_accounts = data[cursor] as usize; - let data_len = u16::from_le_bytes([data[cursor + 1], data[cursor + 2]]) as usize; - if num_accounts > MAX_ACCOUNTS || data_len > MAX_DATA_LEN { - return Err(HydraError::InvalidSchedule.into()); - } - let metas_offset = cursor + CREATE_IX_HEADER_LEN; - let metas_len = num_accounts * SERIALIZED_META_SIZE; - let data_offset = metas_offset + metas_len; - let next = data_offset + data_len; - if next > data.len() { - return Err(ProgramError::InvalidInstructionData); - } - // Reject any signer flag — scheduled ixs run top-level, they can - // only be signed by real keys, and this program can't produce a - // signature for a declared pubkey anyway. Writability consistency and - // triggerability are the client's responsibility (see [`hydra_api::instruction::client::CreateArgs`]); - for account_index in 0..num_accounts { - if data[metas_offset + account_index * SERIALIZED_META_SIZE] & META_FLAG_SIGNER != 0 - { - return Err(HydraError::SignerInScheduledIx.into()); - } - } - // Tail blob, instructions-sysvar layout: - // [num_accounts u16][metas][program_id 32][data_len u16][data] - tail_bytes[off..off + 2].copy_from_slice(&(num_accounts as u16).to_le_bytes()); - off += 2; - tail_bytes[off..off + metas_len].copy_from_slice(&data[metas_offset..data_offset]); - off += metas_len; - tail_bytes[off..off + 32] - .copy_from_slice(&data[cursor + 3..cursor + CREATE_IX_HEADER_LEN]); - off += 32; - tail_bytes[off..off + 2].copy_from_slice(&(data_len as u16).to_le_bytes()); - off += 2; - tail_bytes[off..off + data_len].copy_from_slice(&data[data_offset..next]); - off += data_len; - - cursor = next; + Allocate { + account: crank_ai, + space: total_size as u64, } - - // SAFETY: split yields CRANK_HEADER_SIZE bytes; Crank is align-1 (compile-time checked). - let state = unsafe { load_crank_mut(header_bytes)? }; - state.authority = *authority; - state.seed = *seed; - state.set_next_exec_slot(start_slot); - state.set_interval_slots(interval_slots); - state.set_remaining(if remaining_wire == 0 { - REMAINING_INFINITE - } else { - remaining_wire - }); - state.set_priority_tip(priority_tip); - state.set_executed(0); - state.set_rent_min(rent_min); - state.set_region_len(off as u16); - state.bump = bump; - state.set_cu_limit(cu_limit); - state.authority_signer = authority_signer; - - off - }; - - // Trim the over-provisioned account down to its exact size. Shrinking only - // lowers the data-length field — no syscall, never out of bounds — so the - // unchecked variant is safe: `region_len <= upper_region`, the borrow above - // is dropped, and the crank is program-owned after `CreateAccount`. - unsafe { - crank_ai.resize(CRANK_HEADER_SIZE + region_len); + .invoke_signed(&signers)?; + Assign { + account: crank_ai, + owner: &crate::ID, + } + .invoke_signed(&signers)?; } - // Suppress unused-import warnings when `logging` feature is off. - let _ = _ix::CREATE; - - Ok(()) -} - -#[inline(always)] -fn read_u64_le(data: &[u8], offset: usize) -> u64 { - // SAFETY: the caller ensures `offset + 8 <= data.len()`. - unsafe { u64::from_le_bytes(*(data.as_ptr().add(offset) as *const [u8; 8])) } -} - -#[inline(always)] -fn read_u32_le(data: &[u8], offset: usize) -> u32 { - // SAFETY: the caller ensures `offset + 4 <= data.len()`. - unsafe { u32::from_le_bytes(*(data.as_ptr().add(offset) as *const [u8; 4])) } + // The account is sized to `region_len`, so `write_crank` fills it exactly. + write_crank( + crank_ai, + data, + &header, + bump, + authority_signer, + rent_min, + region_len, + ) } diff --git a/programs/hydra/src/processor/mod.rs b/programs/hydra/src/processor/mod.rs index d5637c9..2bf7297 100644 --- a/programs/hydra/src/processor/mod.rs +++ b/programs/hydra/src/processor/mod.rs @@ -1,4 +1,12 @@ +//! Base-layer crank lifecycle. +//! +//! Schedule parsing, tail serialization and follow-up verification are shared +//! with the ephemeral-rollup program via [`hydra_api::program`]; only the +//! System-program funding model lives here. + pub mod cancel; pub mod close; pub mod create; pub mod trigger; + +mod common; diff --git a/programs/hydra/src/processor/trigger.rs b/programs/hydra/src/processor/trigger.rs index ef0dbc7..eedc933 100644 --- a/programs/hydra/src/processor/trigger.rs +++ b/programs/hydra/src/processor/trigger.rs @@ -11,11 +11,16 @@ use pinocchio::{ }; use hydra_api::{ - consts::{CRANKER_REWARD, CRANK_HEADER_SIZE, REMAINING_INFINITE}, + consts::{base, CRANK_HEADER_SIZE, REMAINING_INFINITE}, HydraError, }; -use crate::helpers::{get_clock_slot, get_stack_height, TRANSACTION_LEVEL_STACK_HEIGHT}; +use hydra_api::program::{ + helpers::{get_clock_slot, get_stack_height, TRANSACTION_LEVEL_STACK_HEIGHT}, + processor::{read_u16, verify_followup}, +}; + +use crate::processor::common::{read_u64, write_u64}; // Field offsets inside the 120-byte `Crank` header, kept local to this file // so the raw reads below stay easy to verify against the account layout. @@ -27,7 +32,7 @@ const OFF_EXECUTED: usize = 96; const OFF_RENT_MIN: usize = 104; const OFF_REGION_LEN: usize = 112; -pub fn process(accounts: &mut [AccountView], _data: &[u8]) -> ProgramResult { +pub fn process(accounts: &[AccountView], _data: &[u8]) -> ProgramResult { cu_mark(); // 0 — before any work let [crank_ai, cranker_ai, ix_sysvar_ai] = accounts else { @@ -37,7 +42,7 @@ pub fn process(accounts: &mut [AccountView], _data: &[u8]) -> ProgramResult { if !cranker_ai.is_signer() { return Err(ProgramError::MissingRequiredSignature); } - if !crank_ai.owned_by(&hydra_api::ID) { + if !crank_ai.owned_by(&crate::ID) { return Err(ProgramError::InvalidAccountOwner); } if ix_sysvar_ai.address() != &INSTRUCTIONS_ID { @@ -72,7 +77,7 @@ pub fn process(accounts: &mut [AccountView], _data: &[u8]) -> ProgramResult { return Err(HydraError::Exhausted.into()); } - let reward = CRANKER_REWARD + let reward = base::CRANKER_REWARD .checked_add(hdr.priority_tip) .ok_or(ProgramError::ArithmeticOverflow)?; let new_crank_lamports = crank_ai @@ -105,7 +110,7 @@ pub fn process(accounts: &mut [AccountView], _data: &[u8]) -> ProgramResult { // SAFETY: `crank_ai` is owned by this program and has at least // `CRANK_HEADER_SIZE` bytes (checked above). No other borrow is live. unsafe { - let p = crank_ai.data_mut_ptr(); + let p = crank_ai.data_ptr(); write_u64(p.add(OFF_NEXT_EXEC_SLOT), next_slot); write_u64(p.add(OFF_EXECUTED), hdr.executed + 1); if hdr.remaining != REMAINING_INFINITE { @@ -156,73 +161,3 @@ unsafe fn read_header(p: *const u8) -> Snapshot { region_len: read_u16(p.add(OFF_REGION_LEN)), } } - -/// Parse the instructions sysvar, locate the region for -/// `current_ix_index + 1`, and byte-compare it against the crank's stored tail. -#[inline(always)] -fn verify_followup(sysvar: &AccountView, crank: &AccountView, region_len: usize) -> ProgramResult { - // SAFETY: we're in a linear entrypoint flow with no outstanding borrows - // on either account. pinocchio's `borrow_unchecked` skips the refcell - // bookkeeping, which saves a handful of CUs per call. - let sv: &[u8] = unsafe { sysvar.borrow_unchecked() }; - let cr: &[u8] = unsafe { crank.borrow_unchecked() }; - - let sv_len = sv.len(); - if sv_len < 4 { - return Err(ProgramError::InvalidAccountData); - } - - // [len-2..len] = current_ix_index (u16 LE) - let current = unsafe { read_u16(sv.as_ptr().add(sv_len - 2)) } as usize; - let target = current - .checked_add(1) - .ok_or(HydraError::MissingFollowupInstruction)?; - - // [0..2] = num_instructions (u16 LE) - let num_ix = unsafe { read_u16(sv.as_ptr()) } as usize; - if target >= num_ix { - return Err(HydraError::MissingFollowupInstruction.into()); - } - - // [2 + 2*target..+2] = offset of instruction `target`'s region. - let off_pos = 2 + 2 * target; - if off_pos + 2 > sv_len { - return Err(ProgramError::InvalidAccountData); - } - let region_start = unsafe { read_u16(sv.as_ptr().add(off_pos)) } as usize; - let region_end = region_start - .checked_add(region_len) - .ok_or(HydraError::MismatchedFollowupIx)?; - if region_end > sv_len.saturating_sub(2) { - return Err(HydraError::MismatchedFollowupIx.into()); - } - - let tail_end = CRANK_HEADER_SIZE - .checked_add(region_len) - .ok_or(ProgramError::InvalidAccountData)?; - if tail_end > cr.len() { - return Err(ProgramError::InvalidAccountData); - } - let tail = &cr[CRANK_HEADER_SIZE..tail_end]; - let sv_region = &sv[region_start..region_end]; - - if sv_region != tail { - return Err(HydraError::MismatchedFollowupIx.into()); - } - Ok(()) -} - -#[inline(always)] -unsafe fn read_u64(p: *const u8) -> u64 { - core::ptr::read_unaligned(p as *const u64) -} - -#[inline(always)] -unsafe fn read_u16(p: *const u8) -> u16 { - core::ptr::read_unaligned(p as *const u16) -} - -#[inline(always)] -unsafe fn write_u64(p: *mut u8, v: u64) { - core::ptr::write_unaligned(p as *mut u64, v); -} diff --git a/tests/e2e/Cargo.toml b/tests/e2e/Cargo.toml new file mode 100644 index 0000000..77c1f60 --- /dev/null +++ b/tests/e2e/Cargo.toml @@ -0,0 +1,28 @@ +[package] +name = "hydra-e2e-tests" +version = "0.0.0" +edition = "2021" +publish = false + +[dev-dependencies] +# The ephemeral feature gives us the `eHyd…` program ID and the ephemeral +# `Create`/`Trigger` account layouts that match the deployed program. +# `client` only — the ephemeral builders are reached at runtime via +# `instruction::ephemeral::*`, matching how the cranker selects its program. +hydra-api = { path = "../../crates/hydra-api", features = ["client"] } + +anyhow = "1" +crossbeam-channel = "0.5" + +solana-client = "=4.0.0-beta.7" +solana-pubsub-client = "=4.0.0-beta.7" +solana-rpc-client-api = "=4.0.0-beta.7" +solana-signature = "3" +solana-commitment-config = "3.1" +solana-keypair = "3.1" +solana-signer = "3" +solana-pubkey = "4.0" +solana-instruction = "3.0" +solana-message = "3.1" +solana-transaction = "3.1" +solana-system-interface = { version = "2.0", features = ["bincode"] } diff --git a/tests/e2e/tests/ephemeral_cranks.rs b/tests/e2e/tests/ephemeral_cranks.rs new file mode 100644 index 0000000..ef709f2 --- /dev/null +++ b/tests/e2e/tests/ephemeral_cranks.rs @@ -0,0 +1,1024 @@ +//! Live end-to-end test for Hydra's ephemeral-rollup crank. +//! +//! Unlike the in-process `tests/ephemeral` suite (which drives MagicSVM), this +//! boots the *real* three-process stack and asserts the cranker keeps a handful +//! of ephemeral cranks firing on schedule. Two scenarios cover both discovery +//! paths: cranks created **before** the cranker starts (its bootstrap +//! `getProgramAccounts` scan) and **after** (live `programSubscribe` +//! notifications, with the bootstrap proven empty first). +//! +//! ```text +//! mb-test-validator ── base L1 (delegation + magic programs preloaded), +//! hydra(ephemeral) + noop loaded at genesis +//! ▲ clones programs/accounts on demand +//! │ +//! ephemeral-validator ── the rollup; hosts the ephemeral crank accounts +//! ▲ RPC + WS (slotSubscribe / programSubscribe) +//! │ +//! hydra-cranker ── watches the rollup, fires `Trigger` every interval +//! ``` +//! +//! Execution progress is observed via `logsSubscribe` on the noop program: each +//! crank passes a distinct `u64` id in the scheduled noop's instruction data, +//! and the noop logs `noop-fired:` when it runs. That mirrors how the +//! cranker itself learns about cranks (bootstrap / `programSubscribe`) and +//! fires on slot ticks — the test never polls crank account `executed`. +//! +//! ## Prerequisites +//! +//! Binaries on `PATH` (installed via the `@magicblock-labs/ephemeral-validator` +//! npm package): `mb-test-validator`, `ephemeral-validator`. And the two +//! prebuilt on-chain `.so`s (the rollup clones them from the base): +//! +//! ```sh +//! # from the hydra workspace root +//! cargo build-sbf --manifest-path programs/hydra-ephemeral/Cargo.toml # target/deploy/hydra_ephemeral.so +//! cargo build-sbf --manifest-path tests/programs/noop/Cargo.toml # target/deploy/hydra_noop.so +//! ``` +//! +//! The `hydra-cranker` binary is built automatically by the test (see +//! `build_cranker`); it selects the ephemeral program at runtime via +//! `--ephemeral`. Then, from this crate: +//! +//! ```sh +//! cargo test -- --ignored --nocapture --test-threads=1 +//! ``` +//! +//! The test is `#[ignore]` by default: it spawns external validators, binds +//! local ports, and takes ~10–15s, so it should not run in a plain `cargo test`. + +use std::fs; +use std::io::ErrorKind; +use std::path::{Path, PathBuf}; +use std::process::{Child, Command, Stdio}; +use std::str::FromStr; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::{Arc, Mutex}; +use std::thread::{self, JoinHandle}; +use std::time::{Duration, Instant}; + +use anyhow::{anyhow, bail, Context, Result}; +use crossbeam_channel::RecvTimeoutError; +use hydra_api::consts::ephemeral::CRANKER_REWARD; +use hydra_api::instruction::{self as ix, ScheduledIx}; +use hydra_api::instruction::{ + ephemeral::{self as eph}, + CreateArgs, +}; +use solana_client::rpc_client::RpcClient; +use solana_commitment_config::CommitmentConfig; +use solana_instruction::{AccountMeta, Instruction}; +use solana_keypair::Keypair; +use solana_message::Message; +use solana_pubkey::Pubkey; +use solana_pubsub_client::pubsub_client::PubsubClient; +use solana_rpc_client_api::config::{RpcTransactionLogsConfig, RpcTransactionLogsFilter}; +use solana_signer::Signer; +use solana_system_interface::instruction as system_instruction; +use solana_transaction::Transaction; + +// --- Topology --------------------------------------------------------------- + +/// Base L1 JSON-RPC port. solana-test-validator serves PubSub on `PORT + 1`. +const BASE_RPC_PORT: u16 = 7101; +const BASE_WS_PORT: u16 = BASE_RPC_PORT + 1; +/// Ephemeral rollup RPC port. Aperture serves RPC + WS on the same address, +/// which is exactly what the cranker's `http→ws` URL derivation assumes. +const ER_RPC_PORT: u16 = 7799; + +/// CI runs these ignored tests back-to-back on fixed ports. If a validator +/// wrapper leaves its real child outside our process group, sweep only these +/// e2e process names before the next scenario starts. +const STACK_PROCESS_PATTERN: &str = "[m]b-test-validator|[e]phemeral-validator|[h]ydra-cranker"; + +/// The bundled noop program — its on-chain address is its build keypair's +/// pubkey (`target/deploy/hydra_noop-keypair.json`). Scheduled cranks point at +/// it; it ignores its accounts and data. +const NOOP_ID: &str = "CftjNLnvyBFcEqShc2VRcESCpPnUfDaFub1wgnGCqtHv"; + +const LAMPORTS_PER_SOL: u64 = 1_000_000_000; + +// --- Crank parameters ------------------------------------------------------- + +/// How many cranks to create and watch. +const NUM_CRANKS: usize = 10; +/// Slots between executions. Small so the test stays quick; the rollup runs at +/// ~50 ms/slot, so this is a few seconds per fire. +const INTERVAL_SLOTS: u64 = 1; +/// Executions each crank must reach before the test passes. Proving the crank +/// fires *repeatedly* (not just once) is the point. +const TARGET_EXECUTIONS: u64 = 3; + +/// Prefix emitted by [`tests/programs/noop`] when the scheduled ix carries an +/// 8-byte LE crank id in its instruction data. +const NOOP_FIRED_PREFIX: &str = "noop-fired:"; + +/// Rollup slot time (~50 ms). The e2e crate does not enable `hydra-api/ephemeral`, +/// so `SLOT_FREQUENCY_MS` from that crate reflects base-layer timing. +const ROLLUP_SLOT_MS: u64 = 50; + +/// Overall wall-clock budget for all cranks to reach `TARGET_EXECUTIONS`. +/// Derived from rollup slot timing (not base-layer `SLOT_FREQUENCY_MS`). +const EXEC_DEADLINE: Duration = + Duration::from_millis(ROLLUP_SLOT_MS * INTERVAL_SLOTS * TARGET_EXECUTIONS * 100); + +// --- The tests --------------------------------------------------------------- + +/// When the cranks are created relative to the cranker starting. +#[derive(Clone, Copy, PartialEq)] +enum CreateOrder { + /// Cranks exist before the cranker boots — picked up by its bootstrap + /// `getProgramAccounts` scan. + BeforeCranker, + /// Cranks are created after the cranker is already running with an empty + /// cache — picked up via live `programSubscribe` notifications. + AfterCranker, +} + +// Both tests bind the same fixed local ports and spawn validators, so they must +// not run concurrently. cargo runs tests in a binary on multiple threads, so a +// process-wide lock serializes them (recovering from a poisoned lock if one +// test panics). +static STACK_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(()); + +/// Cranks created *before* the cranker starts → discovered by its bootstrap scan. +#[test] +#[ignore = "spawns live validators + cranker; run with --ignored"] +fn ephemeral_cranks_fire_on_schedule() { + run_scenario(CreateOrder::BeforeCranker); +} + +/// Cranks created *after* the cranker starts → discovered via `programSubscribe`. +#[test] +#[ignore = "spawns live validators + cranker; run with --ignored"] +fn cranker_catches_cranks_created_after_start() { + run_scenario(CreateOrder::AfterCranker); +} + +fn run_scenario(order: CreateOrder) { + let _guard = STACK_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + cleanup_leftover_stack_processes(); + wait_for_ports_free(Duration::from_secs(5)).expect("e2e ports available"); + let tmp = TempDir::new().expect("temp dir"); + let mut stack = Stack { + children: Vec::new(), + tmp, + }; + let result = body(&mut stack, order); + if let Err(ref e) = result { + eprintln!("\n[stack] FAILED: {e:#}\n[stack] dumping validator/cranker logs:"); + for f in ["base.log", "er.log", "cranker.log"] { + dump_log(stack.tmp.path(), f); + } + } + // Tear the stack down (and delete the temp dir) *before* asserting, so a + // panic never leaks child processes. + drop(stack); + cleanup_leftover_stack_processes(); + wait_for_ports_free(Duration::from_secs(5)).expect("e2e ports released"); + result.expect("e2e ephemeral crank test"); +} + +fn body(stack: &mut Stack, order: CreateOrder) -> Result<()> { + let tmp = stack.tmp.path().to_path_buf(); + + // Resolve build artifacts up front so a missing prerequisite fails fast. + let hydra_so = artifact("target/deploy/hydra_ephemeral.so")?; + let noop_so = artifact("target/deploy/hydra_noop.so")?; + // The cranker MUST be the `ephemeral` build (it targets the `eHyd…` program + // and skips the lamport-funding checks). A plain `cargo build -p + // hydra-cranker` overwrites the same path with the base-program build, so we + // (re)build the ephemeral variant here to guarantee the right binary. + let cranker_bin = build_cranker()?; + let hydra_id = ix::EPHEMERAL_PROGRAM_ID.to_string(); + let noop_id = Pubkey::from_str(NOOP_ID).unwrap(); + + // `sponsor` is delegated (signs CreateEphemeral, pays rent); `fee_payer` is + // a plain system wallet that covers tx fees (the delegated sponsor can't be + // a fee payer); `cranker` submits the triggers *and* is credited their + // `CRANKER_REWARD`, so it too must be delegated. + let sponsor = Keypair::new(); + let fee_payer = Keypair::new(); + let cranker = Keypair::new(); + let cranker_kp_path = write_keypair(&tmp, "cranker.json", &cranker)?; + + // 1. Base L1: delegation/magic programs come preloaded by mb-test-validator; + // we add hydra(ephemeral) + noop at genesis so the rollup can clone them. + eprintln!("[stack] starting mb-test-validator on :{BASE_RPC_PORT}"); + let base = spawn( + "mb-test-validator", + &[ + "--reset", + "--quiet", + "--ledger", + tmp.join("base-ledger").to_str().unwrap(), + "--rpc-port", + &BASE_RPC_PORT.to_string(), + "--bind-address", + "127.0.0.1", + "--bpf-program", + &hydra_id, + hydra_so.to_str().unwrap(), + "--bpf-program", + NOOP_ID, + noop_so.to_str().unwrap(), + ], + fs::File::create(tmp.join("base.log"))?, + &[], + )?; + stack.push("mb-test-validator", base); + + let base_rpc = RpcClient::new_with_commitment( + format!("http://127.0.0.1:{BASE_RPC_PORT}"), + CommitmentConfig::confirmed(), + ); + wait_for_rpc(&base_rpc, "base", Duration::from_secs(10))?; + + // 2. Fund the three keypairs on the base, then delegate the sponsor and the + // cranker so the rollup will let their own lamports change: the sponsor + // pays the vault rent inside CreateEphemeral, and the cranker is credited + // each trigger's CRANKER_REWARD (a fee-payer write the rollup only allows + // for a delegated account). The fee_payer stays a plain system wallet. + airdrop(&base_rpc, &sponsor.pubkey(), 100 * LAMPORTS_PER_SOL)?; + airdrop(&base_rpc, &fee_payer.pubkey(), 10 * LAMPORTS_PER_SOL)?; + airdrop(&base_rpc, &cranker.pubkey(), 10 * LAMPORTS_PER_SOL)?; + delegate(&base_rpc, &sponsor, &fee_payer)?; + delegate(&base_rpc, &cranker, &fee_payer)?; + + eprintln!( + "[stack] funded + delegated sponsor {} and cranker {} (fee_payer {})", + sponsor.pubkey(), + cranker.pubkey(), + fee_payer.pubkey() + ); + + // 3. Ephemeral rollup, syncing against the base. It clones the sponsor (as a + // delegated account), the hydra + noop programs, and the magic vault on + // demand from the base. + eprintln!("[stack] starting ephemeral-validator on :{ER_RPC_PORT}"); + let er = spawn( + "ephemeral-validator", + &[ + "--no-tui", + "--reset", + "--lifecycle", + "ephemeral", + "--remotes", + &format!("http://127.0.0.1:{BASE_RPC_PORT}"), + "--remotes", + &format!("ws://127.0.0.1:{BASE_WS_PORT}"), + "--listen", + &format!("127.0.0.1:{ER_RPC_PORT}"), + "--storage", + tmp.join("er-storage").to_str().unwrap(), + ], + fs::File::create(tmp.join("er.log"))?, + &[("RUST_LOG", "warn")], + )?; + stack.push("ephemeral-validator", er); + + let er_rpc = RpcClient::new_with_commitment( + format!("http://127.0.0.1:{ER_RPC_PORT}"), + CommitmentConfig::confirmed(), + ); + wait_for_rpc(&er_rpc, "rollup", Duration::from_secs(10))?; + // Give the rollup's remote-account-provider WS pool a moment to warm up + // before we make it clone accounts from the base. + std::thread::sleep(Duration::from_secs(3)); + + // Subscribe to noop execution logs before any crank can fire so we never + // miss an early trigger while the cranker is still bootstrapping. + let log_watcher = LogFireWatcher::spawn(noop_id)?; + + // 4. Create the cranks and start the cranker, in the order this scenario + // exercises. `BeforeCranker` is the bootstrap path; `AfterCranker` is the + // live `programSubscribe` path (the cranker boots with an empty cache and + // must catch the new cranks from notifications). + let cranks = match order { + CreateOrder::BeforeCranker => { + let cranks = create_cranks(&sponsor, &fee_payer, noop_id)?; + spawn_cranker(stack, &cranker_bin, &cranker_kp_path, &tmp)?; + cranks + } + CreateOrder::AfterCranker => { + spawn_cranker(stack, &cranker_bin, &cranker_kp_path, &tmp)?; + // Let the cranker bootstrap (finding zero cranks) and connect its + // programSubscribe watcher before any crank exists. + std::thread::sleep(Duration::from_millis(500)); + assert_cranker_bootstrapped_empty(&tmp)?; + eprintln!("[stack] cranker is up with an empty cache; creating cranks now"); + create_cranks(&sponsor, &fee_payer, noop_id)? + } + }; + + // 6. Wait until every crank has fired enough times. Each scheduled noop + // carries a distinct id in its ix data; the noop program logs + // `noop-fired:` and the watcher attributes fires from those notifications. + let watch = log_watcher.wait_until(EXEC_DEADLINE)?; + + // 7. Report + assert. + let mut failures = Vec::new(); + for (i, fires) in watch.fires.iter().enumerate() { + eprintln!("[result] crank {i} ({}): fires={fires:?}", cranks[i]); + + let executed = fires.iter().filter(|fire| fire.is_some()).count(); + if executed < TARGET_EXECUTIONS as usize { + failures.push(format!( + "crank {i} only fired {executed}/{TARGET_EXECUTIONS} times within {EXEC_DEADLINE:?}" + )); + } + } + if !failures.is_empty() { + bail!( + "{} crank(s) failed to keep schedule:\n {}", + failures.len(), + failures.join("\n ") + ); + } + + eprintln!("[stack] all {NUM_CRANKS} cranks fired ≥{TARGET_EXECUTIONS}× on schedule"); + Ok(()) +} + +// --- Scenario helpers ------------------------------------------------------- + +/// Fail fast when fixed local ports are still held by a prior run's validators. +fn assert_ports_free() -> Result<()> { + use std::net::TcpListener; + for (port, label) in [ + (BASE_RPC_PORT, "mb-test-validator"), + (ER_RPC_PORT, "ephemeral-validator"), + ] { + if TcpListener::bind(("127.0.0.1", port)).is_err() { + bail!( + "127.0.0.1:{port} is already in use ({label}). \ + A prior e2e run may have left validators running after Ctrl+C. \ + Stop them with: pkill -INT -f 'mb-test-validator|ephemeral-validator|hydra-cranker'" + ); + } + } + Ok(()) +} + +fn wait_for_ports_free(timeout: Duration) -> Result<()> { + let deadline = Instant::now() + timeout; + loop { + match assert_ports_free() { + Ok(()) => return Ok(()), + Err(e) if Instant::now() >= deadline => return Err(e), + Err(_) => thread::sleep(Duration::from_millis(100)), + } + } +} + +fn cleanup_leftover_stack_processes() { + if std::env::var_os("CI").is_none() { + return; + } + + signal_stack_processes("-INT"); + thread::sleep(Duration::from_millis(500)); + signal_stack_processes("-KILL"); +} + +fn signal_stack_processes(signal: &str) { + let _ = Command::new("pkill") + .args([signal, "-f", STACK_PROCESS_PATTERN]) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status(); +} + +/// Create `NUM_CRANKS` cranks on the rollup in parallel, asserting each materialized. +/// Returns their PDAs in index order. +fn create_cranks(sponsor: &Keypair, fee_payer: &Keypair, noop_id: Pubkey) -> Result> { + let rpc_url = format!("http://127.0.0.1:{ER_RPC_PORT}"); + let commitment = CommitmentConfig::confirmed(); + let mut cranks = vec![Pubkey::default(); NUM_CRANKS]; + + std::thread::scope(|scope| { + let mut handles: Vec< + std::thread::ScopedJoinHandle<'_, Result<(usize, Pubkey), anyhow::Error>>, + > = Vec::with_capacity(NUM_CRANKS); + for i in 0..NUM_CRANKS { + let rpc_url = rpc_url.clone(); + handles.push(scope.spawn(move || { + let rpc = RpcClient::new_with_commitment(rpc_url, commitment); + let crank = create_crank(&rpc, sponsor, fee_payer, crank_seed(i), noop_id, i)?; + assert_crank_exists(&rpc, &crank).with_context(|| { + format!("crank {i} ({crank}) was not created on the rollup") + })?; + Ok((i, crank)) + })); + } + for handle in handles { + let (i, crank) = handle + .join() + .map_err(|_| anyhow!("create crank thread panicked"))??; + cranks[i] = crank; + } + Ok::<(), anyhow::Error>(()) + })?; + + eprintln!("[stack] created {NUM_CRANKS} crank(s)"); + Ok(cranks) +} + +/// A distinct 32-byte crank seed for index `i` (LE-encoded, so it stays unique +/// well past 255 cranks). +fn crank_seed(i: usize) -> [u8; 32] { + let mut seed = [0u8; 32]; + seed[..8].copy_from_slice(&(i as u64 + 1).to_le_bytes()); + seed +} + +/// Spawn the cranker against the rollup and register it for teardown. +fn spawn_cranker( + stack: &mut Stack, + cranker_bin: &Path, + cranker_kp_path: &Path, + tmp: &Path, +) -> Result<()> { + eprintln!("[stack] starting hydra-cranker → rollup"); + let cranker = spawn( + cranker_bin.to_str().unwrap(), + &[ + "--rpc-url", + &format!("http://127.0.0.1:{ER_RPC_PORT}"), + // The rollup serves RPC and WebSocket on separate ports (RPC+1), + // unlike the cranker's default same-port `http→ws` derivation. + "--ws-url", + &format!("ws://127.0.0.1:{}", ER_RPC_PORT + 1), + "--keypair", + cranker_kp_path.to_str().unwrap(), + // Target the ephemeral-rollup program (runtime selection, not a + // build feature). + "--ephemeral", + // Triggers must skip preflight: the rollup only clones referenced + // accounts on the real send path, not during preflight simulation. + "--trigger-skip-preflight", + ], + fs::File::create(tmp.join("cranker.log"))?, + &[("RUST_LOG", "info")], + )?; + stack.push("hydra-cranker", cranker); + Ok(()) +} + +/// Assert the cranker's startup bootstrap found zero cranks — proving that any +/// cranks it later fires were discovered via live `programSubscribe` +/// notifications, not the bootstrap scan. The cranker logs +/// `bootstrap: N crank(s) cached` at startup (info level). +fn assert_cranker_bootstrapped_empty(tmp: &Path) -> Result<()> { + let log = tmp.join("cranker.log"); + let deadline = Instant::now() + Duration::from_secs(10); + loop { + let contents = fs::read_to_string(&log).unwrap_or_default(); + if contents.contains("bootstrap: 0 crank(s) cached") { + return Ok(()); + } + // Guard against a cranker that somehow saw cranks at bootstrap. + if let Some(line) = contents.lines().find(|l| l.contains("crank(s) cached")) { + bail!("cranker bootstrap was not empty: {line:?}"); + } + if Instant::now() >= deadline { + bail!("cranker did not log a bootstrap result within 10s"); + } + std::thread::sleep(Duration::from_millis(250)); + } +} + +// --- Sponsor delegation ----------------------------------------------------- + +/// Make `account` an **on-curve delegated** account so the rollup will let its +/// own lamports change during a transaction. On the rollup, an account whose +/// lamports change must be `delegated()` — for the fee payer specifically, a +/// non-delegated write is rejected with `InvalidAccountForFee` +/// (`magicblock-svm/.../access_permissions.rs`); an undelegated escrow does +/// *not* set that flag on the wallet. The supported path is the on-curve +/// delegation flow (see MagicBlock's `oncurve-delegation` example): the wallet +/// `assign`s itself to the delegation program, then is delegated to the +/// validator, in one base-layer transaction. The rollup restores the original +/// (system) owner when it clones the account, so a delegated on-curve wallet is +/// still a valid fee payer there. +/// +/// A separate, system-owned `fee_payer` covers this base-layer transaction fee — +/// on the base, `account` is owned by the delegation program after `assign`, so +/// it can't be the fee payer nor `system_program::transfer`. +fn delegate(base: &RpcClient, account: &Keypair, fee_payer: &Keypair) -> Result<()> { + let dlp = delegation_program_id(); + let system = system_program_id(); + let acct = account.pubkey(); + + // The wallet reassigns its own owner to the delegation program (it signs). + let assign_ix = system_instruction::assign(&acct, &dlp); + + // delegation program `Delegate` (disc 0). PDAs: delegate buffer under the + // *owner* (system) program, record + metadata under the delegation program. + let (buffer, _) = Pubkey::find_program_address(&[b"buffer", acct.as_ref()], &system); + let (record, _) = Pubkey::find_program_address(&[b"delegation", acct.as_ref()], &dlp); + let (metadata, _) = + Pubkey::find_program_address(&[b"delegation-metadata", acct.as_ref()], &dlp); + // Data: disc u64 LE, borsh `{ commit_frequency_ms: u32, seeds: Vec>, + // validator: Option }`. Empty seeds → on-curve account (no PDA + // seed check); validator pinned to the rollup so it adopts the delegation. + let mut data = Vec::new(); + data.extend_from_slice(&0u64.to_le_bytes()); + data.extend_from_slice(&u32::MAX.to_le_bytes()); // commit_frequency_ms + data.extend_from_slice(&0u32.to_le_bytes()); // seeds: empty + data.push(1u8); // validator: Some(..) + data.extend_from_slice(er_validator_identity().as_ref()); + let delegate_ix = Instruction { + program_id: dlp, + accounts: vec![ + AccountMeta::new(fee_payer.pubkey(), true), // payer + AccountMeta::new(acct, true), // delegated account, signs + AccountMeta::new_readonly(system, false), // owner program + AccountMeta::new(buffer, false), // delegate buffer PDA + AccountMeta::new(record, false), // delegation record PDA + AccountMeta::new(metadata, false), // delegation metadata PDA + AccountMeta::new_readonly(system, false), + ], + data, + }; + send( + base, + &[assign_ix, delegate_ix], + &fee_payer.pubkey(), + &[fee_payer, account], + ) + .with_context(|| format!("delegate {acct} (on-curve)")) +} + +// --- Crank helpers ---------------------------------------------------------- + +/// Build + send one `CreateEphemeral` for the crank derived from `seed`, +/// scheduling a single noop. `sponsor` (delegated) signs and pays the rent; +/// `fee_payer` (system-owned) covers the transaction fee. Returns the crank PDA. +fn create_crank( + rpc: &RpcClient, + sponsor: &Keypair, + fee_payer: &Keypair, + seed: [u8; 32], + noop: Pubkey, + crank_index: usize, +) -> Result { + let (crank, _bump) = eph::find_crank_pda(&seed); + let fire_id = crank_fire_id(crank_index); + let sched = ScheduledIx { + program_id: noop.to_bytes(), + metas: &[], + data: &fire_id.to_le_bytes(), + }; + let create = eph::create( + sponsor.pubkey(), + crank, + &CreateArgs { + seed, + authority: [0u8; 32], // no cancel authority → permissionless, runs forever + start_slot: 0, + interval_slots: INTERVAL_SLOTS, + remaining: TARGET_EXECUTIONS, + priority_tip: 0, + cu_limit: 0, + scheduled: std::slice::from_ref(&sched), + }, + ); + let fund_ix = system_instruction::transfer( + &sponsor.pubkey(), + &crank, + CRANKER_REWARD * TARGET_EXECUTIONS, + ); + send( + rpc, + &[create, fund_ix], + &fee_payer.pubkey(), + &[fee_payer, sponsor], + ) + .context("create crank on rollup")?; + Ok(crank) +} + +/// Distinct id encoded in each crank's scheduled noop ix data and echoed in +/// `noop-fired:` logs. +fn crank_fire_id(index: usize) -> u64 { + (index as u64) + 1 +} + +fn crank_index_from_fire_id(id: u64) -> Result { + if id == 0 || id > NUM_CRANKS as u64 { + bail!("unexpected noop fire id {id}"); + } + Ok((id - 1) as usize) +} + +fn assert_crank_exists(rpc: &RpcClient, crank: &Pubkey) -> Result<()> { + rpc.get_account(crank) + .with_context(|| format!("get crank account {crank}"))?; + Ok(()) +} + +#[derive(Clone)] +struct FireWatch { + fires: Vec>>, +} + +/// Background `logsSubscribe` on the noop program. Accumulates `noop-fired:` +/// notifications until [`LogFireWatcher::wait_until`] returns. +struct LogFireWatcher { + started: Instant, + state: Arc>, + shutdown: Arc, + _sub: solana_pubsub_client::pubsub_client::PubsubLogsClientSubscription, + thread: JoinHandle<()>, +} + +impl LogFireWatcher { + fn spawn(noop_id: Pubkey) -> Result { + let ws_url = format!("ws://127.0.0.1:{}", ER_RPC_PORT + 1); + let (sub, rx) = PubsubClient::logs_subscribe( + &ws_url, + RpcTransactionLogsFilter::Mentions(vec![noop_id.to_string()]), + RpcTransactionLogsConfig { + commitment: Some(CommitmentConfig::confirmed()), + }, + ) + .context("logsSubscribe connect")?; + eprintln!("[watch] logsSubscribe connected (noop {noop_id})"); + + let started = Instant::now(); + let shutdown = Arc::new(AtomicBool::new(false)); + let state = Arc::new(Mutex::new(FireWatch { + fires: vec![vec![None; TARGET_EXECUTIONS as usize]; NUM_CRANKS], + })); + let state_bg = state.clone(); + let shutdown_bg = shutdown.clone(); + let thread = thread::spawn(move || loop { + if shutdown_bg.load(Ordering::Relaxed) { + break; + } + match rx.recv_timeout(Duration::from_millis(500)) { + Ok(resp) => { + if resp.value.err.is_some() { + continue; + } + let mut guard = match state_bg.lock() { + Ok(g) => g, + Err(_) => break, + }; + for line in &resp.value.logs { + let Some(id) = parse_noop_fired_log(line) else { + continue; + }; + let Ok(idx) = crank_index_from_fire_id(id) else { + continue; + }; + + for fire in guard.fires[idx].iter_mut() { + if fire.is_none() { + eprintln!("[watch] crank {idx} fired at {:?}", started.elapsed()); + *fire = Some(started.elapsed()); + break; + } + } + } + } + Err(RecvTimeoutError::Timeout) => {} + Err(RecvTimeoutError::Disconnected) => break, + } + }); + + Ok(Self { + started, + state, + shutdown, + _sub: sub, + thread, + }) + } + + fn wait_until(self, deadline: Duration) -> Result { + while self.started.elapsed() < deadline { + let done = { + let guard = self.state.lock().expect("fire watch poisoned"); + guard.fires.iter().flatten().all(|fire| fire.is_some()) + }; + if done { + break; + } + thread::sleep(Duration::from_millis(200)); + } + self.shutdown.store(true, Ordering::Relaxed); + let _ = self.thread.join(); + let watch = self.state.lock().expect("fire watch poisoned").clone(); + // PubsubClient::drop can block sending an unsubscribe over a closing socket. + std::mem::forget(self._sub); + Ok(watch) + } +} + +fn parse_noop_fired_log(line: &str) -> Option { + let rest = line.split_once(NOOP_FIRED_PREFIX)?.1; + rest.trim().parse().ok() +} + +// --- RPC helpers ------------------------------------------------------------ + +/// Send + confirm a transaction with an explicit `fee_payer` and signer set. +/// +/// Always `skip_preflight`: on the rollup, account cloning / delegation +/// adoption happens in the real send path (`chainlink::ensure_transaction_accounts`), +/// not in preflight simulation, so a simulation would wrongly reject the fee +/// payer. We poll the signature for the real outcome instead. +fn send( + rpc: &RpcClient, + ixs: &[Instruction], + fee_payer: &Pubkey, + signers: &[&Keypair], +) -> Result<()> { + // The rollup clones referenced accounts from the base on first use; right + // after startup that can transiently fail (`No clients provided for + // Subscribe`) before its WS client pool is warm. These failures happen at + // submission, before execution, so retrying is safe and idempotent. + let mut last_err = None; + for attempt in 0..8 { + if attempt > 0 { + std::thread::sleep(Duration::from_millis(1500)); + } + match try_send(rpc, ixs, fee_payer, signers) { + Ok(()) => return Ok(()), + Err(e) => { + let msg = format!("{e:#}"); + // A genuine on-chain revert won't get better with retries. + if msg.contains("reverted") { + return Err(e); + } + last_err = Some(e); + } + } + } + Err(last_err.unwrap_or_else(|| anyhow!("send failed"))) +} + +fn try_send( + rpc: &RpcClient, + ixs: &[Instruction], + fee_payer: &Pubkey, + signers: &[&Keypair], +) -> Result<()> { + use solana_rpc_client_api::config::RpcSendTransactionConfig; + let bh = rpc.get_latest_blockhash().context("latest_blockhash")?; + let msg = Message::new(ixs, Some(fee_payer)); + let tx = Transaction::new(signers, msg, bh); + let sig = rpc + .send_transaction_with_config( + &tx, + RpcSendTransactionConfig { + skip_preflight: true, + ..Default::default() + }, + ) + .context("send_transaction")?; + let deadline = Instant::now() + Duration::from_secs(30); + loop { + match rpc.get_signature_status(&sig)? { + Some(Ok(())) => return Ok(()), + Some(Err(e)) => bail!("tx {sig} reverted: {e:?}"), + None if Instant::now() < deadline => std::thread::sleep(Duration::from_millis(250)), + None => bail!("tx {sig} not confirmed within 30s"), + } + } +} + +/// Block until `rpc` reports a slot, or `timeout` elapses. +fn wait_for_rpc(rpc: &RpcClient, label: &str, timeout: Duration) -> Result<()> { + let deadline = Instant::now() + timeout; + let mut last_err = String::new(); + while Instant::now() < deadline { + match rpc.get_slot() { + Ok(slot) if slot > 0 => { + eprintln!("[stack] {label} healthy at slot {slot}"); + return Ok(()); + } + Ok(_) => {} + Err(e) => last_err = e.to_string(), + } + std::thread::sleep(Duration::from_millis(300)); + } + bail!("{label} did not become healthy within {timeout:?}: {last_err}"); +} + +fn airdrop(rpc: &RpcClient, who: &Pubkey, lamports: u64) -> Result<()> { + let sig = rpc + .request_airdrop(who, lamports) + .with_context(|| format!("airdrop to {who}"))?; + let deadline = Instant::now() + Duration::from_secs(30); + while Instant::now() < deadline { + if rpc.confirm_transaction(&sig).unwrap_or(false) { + return Ok(()); + } + std::thread::sleep(Duration::from_millis(300)); + } + bail!("airdrop to {who} not confirmed in time"); +} + +/// MagicBlock delegation program (preloaded on the base by mb-test-validator). +fn delegation_program_id() -> Pubkey { + Pubkey::from_str("DELeGGvXpWV2fqJUhqcF5ZSYMS4JTLjteaAMARRSaeSh").unwrap() +} +fn system_program_id() -> Pubkey { + Pubkey::from_str("11111111111111111111111111111111").unwrap() +} +/// The ephemeral-validator's default identity (from its bundled keypair). The +/// fee-payer escrow must be delegated to this validator for the rollup to adopt +/// it. Logged at rollup startup as "Validator identity". +fn er_validator_identity() -> Pubkey { + Pubkey::from_str("mAGicPQYBMvcYveUZA5F5UNNwyHvfYh5xkLS2Fr1mev").unwrap() +} + +// --- Process orchestration -------------------------------------------------- + +/// Owns the spawned child processes and tears them down (SIGTERM, then SIGKILL) +/// on drop — including on panic, so a failing assertion never leaks validators. +struct Stack { + children: Vec<(String, Child)>, + tmp: TempDir, +} + +impl Stack { + fn push(&mut self, name: &str, child: Child) { + self.children.push((name.to_string(), child)); + } +} + +impl Drop for Stack { + fn drop(&mut self) { + // Reverse order: cranker, then rollup, then base. + for (name, child) in self.children.iter_mut().rev() { + terminate(name, child); + } + } +} + +/// Terminate the child's whole process group. The validators are launched via +/// npm wrappers that re-spawn the real binary as a grandchild and forward only +/// SIGINT — so a plain `kill ` orphans the validator. Each child is +/// spawned as its own process-group leader (`process_group(0)`), so a negative +/// PID signals the wrapper *and* the grandchild. SIGINT first (graceful), then +/// SIGKILL. +fn terminate(name: &str, child: &mut Child) { + let pid = child.id(); + let group = format!("-{pid}"); // negative PID = the process group + // `2>/dev/null` equivalent: a group whose members already exited makes + // `kill` print "No such process" — harmless noise we suppress. + let signal = |sig: &str| { + let _ = Command::new("kill") + .arg(sig) + .arg(&group) + .stderr(Stdio::null()) + .status(); + }; + signal("-INT"); + let deadline = Instant::now() + Duration::from_secs(8); + loop { + match child.try_wait() { + Ok(Some(_)) => break, + Ok(None) if Instant::now() < deadline => std::thread::sleep(Duration::from_millis(100)), + _ => break, + } + } + // Force-kill the whole group regardless, to be sure no grandchild lingers. + signal("-KILL"); + let _ = child.kill(); + let _ = child.wait(); + let _ = name; +} + +/// Spawn a child process, sending stdout/stderr to `log`, with `envs` set. The +/// child leads its own process group so [`terminate`] can signal the whole tree +/// (npm wrapper + the real validator binary it re-spawns). +fn spawn(program: &str, args: &[&str], log: fs::File, envs: &[(&str, &str)]) -> Result { + use std::os::unix::process::CommandExt; + let err_log = log.try_clone().context("clone log handle")?; + let mut cmd = Command::new(program); + cmd.args(args) + .stdin(Stdio::null()) + .stdout(Stdio::from(log)) + .stderr(Stdio::from(err_log)) + .process_group(0); + for (k, v) in envs { + cmd.env(k, v); + } + cmd.spawn().map_err(|e| { + if e.kind() == ErrorKind::NotFound { + anyhow!("`{program}` not found on PATH — see this test's prerequisites") + } else { + anyhow!("failed to spawn `{program}`: {e}") + } + }) +} + +/// Print fee/escrow-relevant lines plus the tail of a log file (best effort). +fn dump_log(dir: &Path, name: &str) { + const KEYWORDS: &[&str] = &[ + "fee", "escrow", "balance", "payer", "invalid", "delegat", "error", "warn", "clon", + ]; + match fs::read_to_string(dir.join(name)) { + Ok(s) => { + let lines: Vec<&str> = s.lines().collect(); + let all_matched: Vec<&str> = lines + .iter() + .filter(|l| { + let low = l.to_lowercase(); + KEYWORDS.iter().any(|k| low.contains(k)) + }) + .copied() + .collect(); + let matched = &all_matched[all_matched.len().saturating_sub(40)..]; + let tail = &lines[lines.len().saturating_sub(15)..]; + eprintln!( + "----- {name} (relevant) -----\n{}\n----- {name} (tail) -----\n{}\n-------------------------", + matched.join("\n"), + tail.join("\n") + ); + } + Err(e) => eprintln!("----- {name}: -----"), + } +} + +// --- Paths ------------------------------------------------------------------ + +/// Hydra workspace root (`tests/e2e` → `../..`). +fn workspace_root() -> PathBuf { + let mut p = PathBuf::from(env!("CARGO_MANIFEST_DIR")); + p.pop(); + p.pop(); + p +} + +fn artifact(rel: &str) -> Result { + let p = workspace_root().join(rel); + if !p.exists() { + bail!( + "missing build artifact {} — see the prerequisites in this file's doc comment", + p.display() + ); + } + Ok(p) +} + +/// Build the `hydra-cranker` binary and return its path. The cranker is a +/// single binary that selects the ephemeral program at runtime (`--ephemeral`), +/// so no special feature is needed — we just ensure it's freshly built. +fn build_cranker() -> Result { + let root = workspace_root(); + eprintln!("[stack] building hydra-cranker"); + let status = Command::new(env!("CARGO")) + .current_dir(&root) + .args(["build", "-p", "hydra-cranker"]) + .status() + .context("spawn cargo build for hydra-cranker")?; + if !status.success() { + bail!("cargo build -p hydra-cranker failed"); + } + artifact("target/debug/hydra-cranker") +} + +/// Write a keypair to a JSON byte-array file (the `--keypair` flag format). +fn write_keypair(dir: &Path, name: &str, kp: &Keypair) -> Result { + let path = dir.join(name); + let bytes = kp.to_bytes(); + let json: Vec = bytes.iter().map(|b| b.to_string()).collect(); + fs::write(&path, format!("[{}]", json.join(","))).context("write keypair file")?; + Ok(path) +} + +/// A self-deleting temp directory for validator ledgers/storage and keypairs. +struct TempDir(PathBuf); + +impl TempDir { + fn new() -> Result { + let mut p = std::env::temp_dir(); + let nanos = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos(); + p.push(format!("hydra-e2e-{nanos}")); + fs::create_dir_all(&p).with_context(|| format!("create temp dir {}", p.display()))?; + Ok(TempDir(p)) + } + fn path(&self) -> &Path { + &self.0 + } +} + +impl Drop for TempDir { + fn drop(&mut self) { + // Keep the dir (logs, ledgers) for post-mortem when debugging. + if std::env::var_os("KEEP_E2E_LOGS").is_some() { + eprintln!("[stack] KEEP_E2E_LOGS set — leaving {}", self.0.display()); + return; + } + let _ = fs::remove_dir_all(&self.0); + } +} diff --git a/tests/lib.rs b/tests/lib.rs index 5e6f82f..1428ae9 100644 --- a/tests/lib.rs +++ b/tests/lib.rs @@ -26,6 +26,12 @@ use hydra_api::{ /// Absolute path to the built `.so` (without extension) — mollusk appends `.so`. pub const HYDRA_SO: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/../target/deploy/hydra"); +/// Absolute path to the built ephemeral `.so` (without extension). +pub const HYDRA_EPHEMERAL_SO: &str = concat!( + env!("CARGO_MANIFEST_DIR"), + "/../target/deploy/hydra_ephemeral" +); + // --------------------------------------------------------------------------- // Helpers (pub so the bench file `benches/compute_units.rs` can reuse them) // --------------------------------------------------------------------------- @@ -40,7 +46,7 @@ pub const NOOP_ID: Pubkey = pubkey!("4sdZFwGE7TkQCJVpfggvfy2ZwGNCfF6hAMJYjZU5HpZ pub const NOOP_SO: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/../target/deploy/hydra_noop"); pub fn hydra_id() -> Pubkey { - Pubkey::new_from_array(hydra_api::ID.to_bytes()) + Pubkey::new_from_array(hydra_api::base::ID.to_bytes()) } pub fn mollusk_with_hydra() -> Mollusk { @@ -50,6 +56,16 @@ pub fn mollusk_with_hydra() -> Mollusk { mollusk } +pub fn eph_id() -> Pubkey { + Pubkey::new_from_array(hydra_api::ephemeral::ID.to_bytes()) +} + +pub fn mollusk_with_hydra_ephemeral() -> Mollusk { + let mut mollusk = Mollusk::new(&eph_id(), HYDRA_EPHEMERAL_SO); + memo::add_program(&mut mollusk); + mollusk +} + /// Load the tiny pinocchio noop alongside hydra. Call this before using /// `NOOP_ID` as a scheduled program target. pub fn load_noop(mollusk: &mut Mollusk) { @@ -57,7 +73,7 @@ pub fn load_noop(mollusk: &mut Mollusk) { } pub fn find_crank(seed: &[u8; 32]) -> (Pubkey, u8) { - let (addr, bump) = hydra_api::state::find_crank_pda(seed); + let (addr, bump) = hydra_api::state::find_base_crank_pda(seed); (Pubkey::new_from_array(addr.to_bytes()), bump) } @@ -89,7 +105,7 @@ pub fn create_ix( priority_tip, cu_limit, &[ScheduledIx { - program_id: sched_program, + program_id: sched_program.to_bytes(), metas: sched_metas, data: sched_data, }], @@ -127,7 +143,7 @@ pub fn create_ix_multi( for s in sched { data.push(s.metas.len() as u8); data.extend_from_slice(&(s.data.len() as u16).to_le_bytes()); - data.extend_from_slice(&s.program_id.to_bytes()); + data.extend_from_slice(&s.program_id); for meta in s.metas { let flag: u8 = if meta.is_writable { META_FLAG_WRITABLE @@ -135,7 +151,7 @@ pub fn create_ix_multi( 0 }; data.push(flag); - data.extend_from_slice(&meta.pubkey.to_bytes()); + data.extend_from_slice(&meta.pubkey); } data.extend_from_slice(s.data); } @@ -317,7 +333,7 @@ pub fn print_cu_table() { let payer_m = Pubkey::new_unique(); let multi_specs: Vec = (0..MULTI_N) .map(|_| ScheduledIx { - program_id: NOOP_ID, + program_id: NOOP_ID.to_bytes(), metas: &[], data: tick, }) @@ -479,7 +495,9 @@ pub fn print_cu_table() { #[cfg(test)] mod tests { use hydra_api::{ - instruction::CreateArgs, state::region_len_for, CRANKER_REWARD, STALENESS_THRESHOLD_SLOTS, + consts::{base, ephemeral}, + instruction::CreateArgs, + state::region_len_for, }; use super::*; @@ -502,7 +520,7 @@ mod tests { let scheduled_data: &[u8] = b"tick"; let cu_limit: u32 = 321_000; - let ix = hydra_api::instruction::create( + let ix = hydra_api::instruction::base::create( payer, crank_pda, &CreateArgs { @@ -514,8 +532,8 @@ mod tests { priority_tip: 1_000, cu_limit, scheduled: &[ScheduledIx { - program_id: memo::ID, - metas: &[SchedMeta::writable(scheduled_meta)], + program_id: memo::ID.to_bytes(), + metas: &[SchedMeta::writable(scheduled_meta.to_bytes())], data: scheduled_data, }], }, @@ -572,7 +590,7 @@ mod tests { 1_000, // priority_tip 0, // cu_limit (0 = omit the ix) memo::ID, - &[SchedMeta::readonly(recipient)], // one read-only account just for content + &[SchedMeta::readonly(recipient.to_bytes())], // one read-only account just for content memo_data, ); @@ -768,7 +786,7 @@ mod tests { assert_eq!( cranker_acct.lamports, - cranker_starting + CRANKER_REWARD + priority_tip, + cranker_starting + base::CRANKER_REWARD + priority_tip, "cranker reward" ); @@ -778,6 +796,105 @@ mod tests { assert_eq!(header.next_exec_slot(), 100, "slot advanced by interval"); } + /// The ephemeral `Trigger` must pay the cranker exactly like the base one. + /// Ephemeral `Create` CPIs the Magic program (unavailable in mollusk), so we + /// mint a valid crank via the base program — the `Crank` layout + region are + /// identical — then re-own it to the ephemeral program and trigger it. + #[test] + fn ephemeral_trigger_pays_cranker() { + let base = mollusk_with_hydra(); + let payer = Pubkey::new_unique(); + let cranker = Pubkey::new_unique(); + let (crank_pda, _bump) = find_crank(&SEED); + let memo_data: &[u8] = b"tick"; + let priority_tip: u64 = 2_500; + + let create = create_ix( + payer, + crank_pda, + SEED, + [0u8; 32], + 0, + 100, + 10, + priority_tip, + 0, + memo::ID, + &[], + memo_data, + ); + let (system_program, system_program_acct) = keyed_account_for_system_program(); + let (memo_id, memo_acct) = memo::keyed_account(); + let create_accounts = vec![ + (payer, Account::new(PAYER_LAMPORTS, 0, &system_program)), + (crank_pda, Account::default()), + (memo_id, memo_acct.clone()), + (system_program, system_program_acct.clone()), + ]; + let after_create = base.process_transaction_instructions(&[create], &create_accounts); + assert!( + after_create.raw_result.is_ok(), + "base create failed: {:?}", + after_create.raw_result + ); + + // Re-own the freshly-minted crank to the ephemeral program and fund it + // as the cranker-reward budget (a sponsor would do this with a transfer). + let mut crank_acct = after_create + .resulting_accounts + .iter() + .find(|(k, _)| k == &crank_pda) + .map(|(_, a)| a.clone()) + .expect("crank after create"); + crank_acct.owner = eph_id(); + crank_acct.lamports += 1_000_000; + + let eph = mollusk_with_hydra_ephemeral(); + let cranker_starting: u64 = 0; + let trigger = hydra_api::instruction::ephemeral::trigger(crank_pda, cranker); + let scheduled = Instruction { + program_id: memo::ID, + accounts: vec![], + data: memo_data.to_vec(), + }; + let trigger_accounts = vec![ + (crank_pda, crank_acct), + (cranker, Account::new(cranker_starting, 0, &system_program)), + (memo_id, memo_acct), + (system_program, system_program_acct), + ]; + + let after = eph.process_transaction_instructions(&[trigger, scheduled], &trigger_accounts); + assert!( + after.raw_result.is_ok(), + "ephemeral trigger failed: {:?}", + after.raw_result + ); + + let cranker_acct = after + .resulting_accounts + .iter() + .find(|(k, _)| k == &cranker) + .map(|(_, a)| a) + .expect("cranker after trigger"); + assert_eq!( + cranker_acct.lamports, + cranker_starting + ephemeral::CRANKER_REWARD + priority_tip, + "ephemeral cranker reward" + ); + + let crank_after = after + .resulting_accounts + .iter() + .find(|(k, _)| k == &crank_pda) + .map(|(_, a)| a) + .expect("crank after trigger"); + let header = decode_header(&crank_after.data); + assert_eq!(header.executed(), 1, "executed++"); + assert_eq!(header.remaining(), 9, "remaining--"); + assert_eq!(header.next_exec_slot(), 100, "slot advanced by interval"); + } + #[test] fn cancel_with_matching_authority_refunds_recipient() { let mollusk = mollusk_with_hydra(); @@ -1113,7 +1230,7 @@ mod tests { #[test] fn close_permissionless_when_stuck() { let mut mollusk = mollusk_with_hydra(); - mollusk.sysvars.clock.slot = STALENESS_THRESHOLD_SLOTS + 1; + mollusk.sysvars.clock.slot = base::STALENESS_THRESHOLD_SLOTS + 1; let payer = Pubkey::new_unique(); let reporter = Pubkey::new_unique(); @@ -1563,12 +1680,12 @@ mod tests { 0, &[ ScheduledIx { - program_id: memo::ID, - metas: &[SchedMeta::writable(acct_a)], + program_id: memo::ID.to_bytes(), + metas: &[SchedMeta::writable(acct_a.to_bytes())], data: b"first", }, ScheduledIx { - program_id: memo::ID, + program_id: memo::ID.to_bytes(), metas: &[], data: b"second", }, @@ -1631,12 +1748,12 @@ mod tests { 0, &[ ScheduledIx { - program_id: memo::ID, + program_id: memo::ID.to_bytes(), metas: &[], data: b"alpha", }, ScheduledIx { - program_id: memo::ID, + program_id: memo::ID.to_bytes(), metas: &[], data: b"beta", }, @@ -1695,7 +1812,7 @@ mod tests { // One Trigger = one reward + one schedule advance, regardless of ix count. assert_eq!( cranker_acct.lamports, - cranker_starting + CRANKER_REWARD + priority_tip, + cranker_starting + base::CRANKER_REWARD + priority_tip, "single reward per trigger" ); let header = decode_header(&crank_acct.data); @@ -1723,12 +1840,12 @@ mod tests { 0, &[ ScheduledIx { - program_id: memo::ID, + program_id: memo::ID.to_bytes(), metas: &[], data: b"alpha", }, ScheduledIx { - program_id: memo::ID, + program_id: memo::ID.to_bytes(), metas: &[], data: b"beta", }, @@ -1784,12 +1901,12 @@ mod tests { 0, &[ ScheduledIx { - program_id: memo::ID, + program_id: memo::ID.to_bytes(), metas: &[], data: b"alpha", }, ScheduledIx { - program_id: memo::ID, + program_id: memo::ID.to_bytes(), metas: &[], data: b"beta", }, @@ -1839,12 +1956,12 @@ mod tests { 0, &[ ScheduledIx { - program_id: memo::ID, + program_id: memo::ID.to_bytes(), metas: &[], data: b"alpha", }, ScheduledIx { - program_id: memo::ID, + program_id: memo::ID.to_bytes(), metas: &[], data: b"beta", }, @@ -1975,7 +2092,7 @@ mod tests { // Exactly MAX_INSTRUCTIONS tiny memos -> ok. let at_max: Vec = (0..MAX_INSTRUCTIONS) .map(|_| ScheduledIx { - program_id: memo::ID, + program_id: memo::ID.to_bytes(), metas: &[], data: b"m", }) @@ -2001,7 +2118,7 @@ mod tests { // One past the cap -> rejected. let over: Vec = (0..MAX_INSTRUCTIONS + 1) .map(|_| ScheduledIx { - program_id: memo::ID, + program_id: memo::ID.to_bytes(), metas: &[], data: b"m", }) @@ -2054,12 +2171,12 @@ mod tests { 0, &[ ScheduledIx { - program_id: NOOP_ID, - metas: &[SchedMeta::writable(writable_acct)], + program_id: NOOP_ID.to_bytes(), + metas: &[SchedMeta::writable(writable_acct.to_bytes())], data: b"one", }, ScheduledIx { - program_id: NOOP_ID, + program_id: NOOP_ID.to_bytes(), metas: &[], data: b"two", }, diff --git a/tests/programs/noop/Cargo.toml b/tests/programs/noop/Cargo.toml index 4e7a1ce..92f7839 100644 --- a/tests/programs/noop/Cargo.toml +++ b/tests/programs/noop/Cargo.toml @@ -15,6 +15,7 @@ custom-heap = [] [dependencies] pinocchio = { workspace = true } +pinocchio-log = { workspace = true } [package.metadata.solana] program-id = "4sdZFwGE7TkQCJVpfggvfy2ZwGNCfF6hAMJYjZU5HpZG" diff --git a/tests/programs/noop/src/lib.rs b/tests/programs/noop/src/lib.rs index b3ab9d7..1101372 100644 --- a/tests/programs/noop/src/lib.rs +++ b/tests/programs/noop/src/lib.rs @@ -3,21 +3,29 @@ //! Consumes only the entrypoint + `Ok(())` overhead (~20 CU), so when the //! `cu_table` test reports `tx CU`, essentially all of it comes from Hydra //! itself instead of being dominated by SPL Memo's ~2.8 k CU. +//! +//! When instruction data is at least 8 bytes, the first 8 (LE `u64`) are logged +//! as `noop-fired:` so live e2e tests can attribute fires via `logsSubscribe` +//! instead of polling crank account state. #![no_std] use pinocchio::{ no_allocator, nostd_panic_handler, program_entrypoint, AccountView, Address, ProgramResult, }; +use pinocchio_log::logger::Logger; program_entrypoint!(process); no_allocator!(); nostd_panic_handler!(); -pub fn process( - _program_id: &Address, - _accounts: &mut [AccountView], - _data: &[u8], -) -> ProgramResult { +pub fn process(_program_id: &Address, _accounts: &[AccountView], data: &[u8]) -> ProgramResult { + if data.len() >= 8 { + let id = u64::from_le_bytes(data[..8].try_into().unwrap()); + let mut logger = Logger::<32>::default(); + logger.append("noop-fired:"); + logger.append(id); + logger.log(); + } Ok(()) }