Skip to content

MagicBlock ephemeral-rollup credit metering + x402-over-ER#93

Open
mizuki0x wants to merge 185 commits into
mainfrom
feat/magicblock-er
Open

MagicBlock ephemeral-rollup credit metering + x402-over-ER#93
mizuki0x wants to merge 185 commits into
mainfrom
feat/magicblock-er

Conversation

@mizuki0x

@mizuki0x mizuki0x commented Jun 17, 2026

Copy link
Copy Markdown
Contributor

Adds a MagicBlock Ephemeral Rollup settlement path: agents meter and pay per call by running consume_credits in an ER (gasless, ~ms), with the credit balance reconciling exactly to L1. Token custody, staking, slashing, and governance stay on mainnet; only the program-owned u64 credit balance delegates to the rollup.

What's here

On-chain (settlement program)

  • delegate_credits / commit_credits / undelegate_credits plus the #[ephemeral] undelegation callback, behind a new ephemeral feature.
  • Shipped as an isolated crate programs/settlement-ephemeral that include!s the settlement source with the feature on. It is excluded from the workspace so its ephemeral-rollups-sdk graph (which pins the solana runtime to 2.2.20) resolves in its own lock and never perturbs the litesvm 0.6 test deps the rest of the workspace pins to 2.2.1. The L1-only program and its tests are unchanged.

x402-over-ER (the agent-payment path)

  • covenant-x402: EphemeralSigner implements the Signer trait by metering. build_payment submits a consume_credits to the pinned ER validator and returns the signature as the x402 proof; receipt_hash = sha256(nonce) binds each payment to its 402 challenge. Drops into the daemon's pay_and_record() unchanged.
  • covenant-x402-facilitator: the verifier side. Gates a paid endpoint and verifies the ER consume (program, amount >= price, receipt_hash == sha256(nonce), single-use nonce) by reading the on-chain tx, not the client envelope. No solana-sdk, no on-chain SDK.

Three gotchas worth knowing

  1. ephemeral-rollups-sdk with default-features = false breaks the SDK build (utils.rs references solana_system_interface, which sits behind the default feature). Keep defaults and add anchor-compat.
  2. anchor-compat lets anchor-lang split into 0.31.1 + 0.32.1 under a fresh lock; pin with cargo update -p anchor-lang --precise 0.31.1.
  3. The ER SDK forces the solana runtime crates to 2.2.20, incompatible with litesvm 0.6 (2.2.1, pre the solana-feature-set -> agave-feature-set rename). Solved by the isolated-crate split above.

Verification (devnet)

Live on devnet (program cov9UDyp..., EU ER validator), not just compiled or mocked:

  • Credit metering: delegate -> N x consume in ER -> undelegate -> exact L1 reconciliation. N=100: 75.5 ms/op, ~0.0003 SOL for the whole session (roughly constant in N, since the consumes are gasless).
  • x402-over-ER, full Rust loop: real Client + EphemeralSigner -> facilitator -> ER: 402 -> ER consume -> 200, balance -2 exactly, reconciled to L1.
  • Mixed-account txs (writable set spanning a delegated and a non-delegated account) are rejected on router/ER/L1, never split.
  • Full daemon path: a PayX402 op drives op_respond -> ER signer sidecar -> consume_credits in the ER -> the facilitator -> 200, with a settlement receipt recorded. Covered by pay_x402_settles_through_the_er_signer_live (#[ignore], green on devnet).

Tests: covenant-x402 +4 (incl. a wiremock integration test), 59 existing still green; covenant-x402-facilitator 5; settlement litesvm suite unchanged.

Daemon integration (landed + live-verified)

The full loop is wired into covenantd:

  • covenant-x402-er-signer sidecar (in covenant-x402, solana feature): stdin PaymentRequirements -> consume_credits in the ER via EphemeralSigner -> stdout x-payment header.
  • X402Config::signer_for(network) routes solana-er:* providers to the ER sidecar (env COVENANT_X402_ER_SIGNER_BINARY + COVENANT_X402_ER_{KEYPAIR,PROGRAM,RPC}); everything else uses the default SPL signer. The funding key stays in the sidecar's address space, never the daemon.
  • covenantd x402 tests 12/12 green including the selection test.
  • Provider registration: operators register ER-settled providers via COVENANT_X402_ER_PROVIDERS (a JSON array); each is advertised to agents as an er.<slug> tool and routes through the same x402 pay path (gated by tool.call.er.<slug>, recording budget/receipt/audit).
  • Both daemon paths are live-verified on devnet (#[ignore] tests): pay_x402_settles_through_the_er_signer_live (raw op) and er_provider_tool_settles_in_the_er_live (an agent calling a registered er.* tool).

covenant added 30 commits June 11, 2026 23:04
…growth

The covenant CLI (covenant/src/main.rs) and daemon (covenantd/src/lib.rs)
grew with the zauth and metaplex integrations, shifting the line numbers
docs/ipc-and-http-gateway.md cites for envelope emitters, CLI verbs, and
type-level pin tests. Realign 594 citation tokens to their current source
positions and sync two type-level-pin guards whose hardcoded anchors moved
(intent_result.text, memory_compaction_plan.expected_receipt_changes).

Also:
- register covenant-zauth (Payments) and covenant-metaplex (Settlement) in
  the crate-groups table
- refresh README status metrics (27 crates, 2593 tests, 390 live)
- add covenant-metaplex tool-gating coverage (allowlist filtering,
  reads/writes-disabled and unknown-name resolution, identity.register URI
  rejection, DAS read arms and error-result mapping); format pre-existing
  crate drift

Restores validate.sh --scripts to green (216/216 line-ref guards).
…-load guards

The minting-key sidecar had no tests. Add pure-logic coverage for its guards and IO leaves: assert_pinned (rejects a mismatched id and a matching-but-unallowlisted id, pinning both bail clauses), parse_pubkey, truncate's char-boundary cap, and load_keypair's missing-file / non-array / wrong-length error arms. Ephemeral keypairs and tempfiles only; no key or RPC. Also normalizes pre-existing rustfmt drift in the crate, which sits in its own workspace outside the main fmt gate.
…and cost estimate

Pin build()'s pure-logic contracts: append/bind requests with an existing asset bail before signing; a malformed root hash and a plain-http registration URI are rejected by the shared validators; each variant emits exactly two instructions; and est_lamports matches the production formula (payload-byte cost for attest, identity-PDA + URI-byte cost for register, including the covenant://agent fallback). Ephemeral payer keypairs, no RPC.
…back

Pin the x402 payment gate's multi-accept contract: a first match with an unpinned payTo is rejected outright even when a later accept is pinned (the anti-hostile-402 defense), while an over-cap match is skipped so a later within-cap accept on the same pair still wins. Also pin to_payment_requirements coercing an unparseable amount to 0 while preserving the raw string.
Pure rustfmt reflow of pre-existing drift in the crate, normalized while running the crate fmt gate for the select() coverage. No behavior change.
… gate

from_env() turns COVENANT_METAPLEX_* env vars into the profile that gates
read/write tool advertisement and the per-action lamport cap, and the
private truthy() decides the master enable switch — neither had coverage.

Pin truthy() against the canonical affirmatives plus case/whitespace
folding and the near-miss rejections (2, maybe, t, enable) a loose
contains/starts_with would wrongly accept, and drive from_env() through
unset-defaults, empty-as-unset cluster fallback, a truthy enable, a
cluster override, the cap parse-or-zero fallback, and the allowlist
comma-split with per-entry trim and empty-filter. Env mutation is
confined to one test with per-scenario snapshot/restore.
The WriteTool tests only checked that a valid call was !is_error and
never inspected the SignerRequest build_request hands to the signer, so
the request the minting key signs was unverified.

Add a recording MetaplexSigner that captures the built request and drive
both write tools through tool.call(). Pin: attest/identity always set
asset None even when a caller passes one (v1 mints fresh, never appends
to a caller-controlled asset); an operator-configured collection
overrides the caller arg, and the arg is honoured only when config is
empty; identity.register leaves registration_uri None on omission. Also
exercise the das.get_asset_proof read-dispatch arm, the one read slug
never reached through tool.call().
…ge header

Two malformed-input rejection arms on the x402 pay path had no coverage.
select() parses a matching, pinned accept's amount to run the per-call
cap check; a non-numeric amount must hard-fail (DecodeChallenge), never
coerce to zero and get signed for the way to_payment_requirements would.
decode_from_headers rejects a payment-required header whose bytes are not
visible ASCII before any base64/json decode. Pin both, including the
"amount:" and "not ascii" discriminators.
collection_from_env had no coverage and could not get any: testing it
meant mutating COVENANT_METAPLEX_COLLECTION, and std::env::set_var races
with sibling tests that read the environment via temp_dir().

Extract the pure parse into parse_collection(Option<String>) — behaviour
unchanged — and unit-test it without touching process env: an absent or
empty value is no pin (not an error), a valid base58 string parses, and a
malformed one is a hard error tagged with the env var name.
validate_registration_uri sanitizes the URI written on-chain. Two arms
were unpinned: the is_control() half of the reject check (only whitespace
was tested, never a NUL/ESC/DEL byte — a render-injection risk once
inscribed), and the 200-byte cap edge (only gross overflow was tested).
Add a non-whitespace control-char rejection and an inclusive 200/201-byte
boundary test.
The DAS read client was only tested for the empty-endpoint short-circuit;
the JSON-RPC envelope handling that decides success vs error was uncovered.
Add wiremock (already a workspace dev-dep via covenant-zauth) and pin: a
result body returns the value, an error body surfaces DasError::Rpc (never
silently read as an empty success), an explicit null error is not an error,
a missing result is Null, and a non-JSON body is DasError::Transport.
list_passes_query_params only covered verified=true with offset=None, so
the pagination offset branch and the verified=false stringification were
unexercised — a dropped offset would silently pin every call to page zero.
Add a wiremock test that sends verified=false and offset=40 and matches on
both, so an omitted or mis-keyed param fails the request.
The DAS envelope tests only mocked responses and matched on POST, so the
outbound method names and param shapes were unverified — a typo in
"ownerAddress" or "getAssetsByOwner" would pass every test yet break against
real DAS providers. Add wiremock body_json matchers asserting the exact
request envelope for getAsset, getAssetProof, getAssetsByOwner, and the
searchAssets verbatim passthrough, so a renamed method or key fails locally.
challenge::select only honours the exact x402 scheme and a conjunctive
(network, asset) match, but the scheme arm and the asset arm had no
coverage. Add tests that a non-exact scheme is skipped (not counted as a
chain-asset match, and not allowed to shadow a later exact accept) and
that a wrong asset on the right network is no match.
…defaults

AttestationPayload is written verbatim into an on-chain MPL Core AppData
plugin and read back as JSON by DAS consumers, so its camelCase keys are
a wire contract the symmetric round-trip test does not pin. Assert the
exact six camelCase keys (no snake_case, no extras), and that a minimal
{action, payload} AttestAuditRoot deserializes with asset and collection
defaulting to None.
…idation

WriteTool::build_request refuses bad attest input before any signer
round-trip, but the recordedAt required-integer arm and str_arg's
empty-string branch had no coverage. Assert a missing, stringified, or
fractional recordedAt is refused, and that an empty releaseTarget is
rejected like an absent one — neither reaches an on-chain attestation.
build() for attest resolves a caller-supplied collection before the env
fallback, but only the env path was tested. Assert a malformed request
collection errors with the 'request collection' context (distinct from
the COVENANT_METAPLEX_COLLECTION tag), and a valid one builds the same
two instructions at the same payload-sized cost as the no-collection case.
DAS conveys errors in a JSON-RPC error body, so rpc() reads the body
regardless of HTTP status and only tags the transport error with the
status when the body is not JSON. Every test used status 200. Assert a
503 HTML body surfaces a Transport error carrying the status, and a 500
with a JSON-RPC error body still surfaces as an Rpc error.
The register path derives the ["agent_identity", asset] PDA under the
MPL Agent Identity program and wires it into register_identity_v1, but
only instruction count and cost were tested. Assert the derived PDA
(from a hardcoded seed, so a const drift fails) appears in the register
instruction accounts, and that create and register share the minted asset.
Config::from_env resolution of COVENANT_SAP_WORKER_TIMEOUT_SECS and
COVENANT_SAP_WORKER_CMD was unexercised. Pin both: timeout parse+trim,
zero/non-numeric fallback to the 30s safety default (the unbounded-wait
guard), command whitespace-split with run collapse, blank-override
empty-filter fallback, and both unset defaults.
parse_params and parse_body distil Hyre's OpenAPI into agent-facing tool
params and body fields, but only happy paths were covered. Pin the
header/cookie drop arm, the missing-name/missing-in skips, the
required/description defaults, and parse_body's empty fallbacks for a
non-JSON content type, a schema with no properties, and an absent
required array (all fields default optional).
arg_str fills Hyre path/query/body slots from JSON tool arguments, but
every test passed only string values. Pin the Number and Bool coercions
(a numeric id or boolean flag reaches the wire as its string form) and
the empty-string/structured/null/absent cases that read as a missing
argument, so a blank or non-scalar value never lands in a request slot.
out_amount_u64 and other_amount_threshold_u64 had only success coverage.
Pin the parse-failure arms: a non-numeric outAmount and an over-range
otherAmountThreshold must each return an Err whose context names the
offending field, so a garbled Jupiter quote fails loudly instead of
panicking or swapping against a bogus amount.
…ction

ManifestDiff::between flags metadata_changed from a five-operand OR, but
only the name operand was tested. Pin description, agent_id, agent_uri,
and x402_endpoint as independent drift sources, plus the None-manifest-
description normalization against non-empty on-chain text, so a changed
or cleared field always triggers update_agent instead of leaving a stale
on-chain identity record.
mizuki0x added 14 commits June 16, 2026 21:54
x402-demo.mjs: a paid http endpoint settled by gasless consume_credits in the er
instead of an spl transfer. real 402 boundary + a verifying facilitator that checks
the er signature (program, credit account, amount, receipt_hash == sha256(nonce),
anti-replay). delegate once -> K gasless paid calls -> undelegate -> reconcile. ran
live on devnet: 402 -> er pay -> 200 at ~71ms/call steady-state, exact l1 reconcile.

production path noted in README: same settlement as an EphemeralSigner Signer impl
inside covenant-x402 (build_payment runs the er consume) so pay_and_record is unchanged.
experiment-mixed.mjs: a tx spanning a delegated account + a real L1 system
transfer is rejected on router/ER/L1, never split or partially applied; on L1 the
delegated account fails with AccountOwnedByWrongProgram. confirms delegated and
non-delegated writes must be separate txs (our design already does this).
implements the Signer trait by metering: builds + submits consume_credits to the
pinned ER validator and returns the tx signature as the x402 proof, instead of an
spl transfer. receipt_hash = sha256(nonce) binds the payment to the 402 challenge
(nonce added to PaymentExtra). gated by the solana feature, no on-chain ER sdk dep
(just builds a tx + json-rpc to the ER), so no lock churn. the daemon's
pay_and_record path works unchanged. 4 tests incl a wiremock integration test;
existing 59 still green. the consume it emits is byte-identical to what ran live
in the node x402 demo. facilitator that accepts ER proofs is the remaining piece.
examples/ephemeral_live.rs drives the signer against a real ER validator;
spike/er-session.mjs does delegate/undelegate/balance. ran it: signer submitted a
consume_credits to devnet-eu, confirmed, returned the x402 envelope, ER balance
dropped by exactly the amount, reconciled to L1 on undelegate. closes the
verification gap (was unit + wiremock only).
new crate gating a paid endpoint on ER-settled payments. GET /paid -> 402 with a
single-use nonce (shaped as covenant-x402 PaymentRequirements so its Client parses
it); with x-payment, fetches the consume on the ER and checks program, amount >=
price, receipt_hash == sha256(nonce), and nonce single-use (anti-replay). reads the
credit account from the on-chain tx, not the envelope. no solana-sdk / no on-chain
sdk (json-rpc + base58), zero lock impact. 5 verifier tests.

live-verified the FULL rust loop on devnet: real covenant-x402 Client +
EphemeralSigner -> this facilitator -> EU ER validator: 402 -> ER consume -> 200,
balance -2 exactly, reconciled to l1 (examples/live_loop.rs).
- covenant-x402-er-signer sidecar: stdin PaymentRequirements -> consume_credits in
  the ER via EphemeralSigner -> stdout x-payment header (matches SubprocessSigner).
- X402Config gains an optional er_signer_binary/er_signer_env + signer_for(network):
  solana-er:* networks route to the ER sidecar, everything else to the default SPL
  signer. funding key stays in the sidecar, never the daemon.
- dispatch call site uses config.signer_for(&network); main reads
  COVENANT_X402_ER_SIGNER_BINARY + COVENANT_X402_ER_{KEYPAIR,PROGRAM,RPC}.
- PaymentExtra gained nonce (for exact-er); fixed literals in hyre + zauth.

covenantd x402 tests 12/12 green incl. the selection test; hyre/zauth build.
pay_x402_settles_through_the_er_signer_live (#[ignore], runs on devnet): drives a
real PayX402 op through op_respond -> pay_x402 -> X402Config::signer_for -> the ER
signer sidecar subprocess -> consume_credits in the ER -> the facilitator -> 200,
and asserts a settlement receipt is recorded. closes the last unverified link
(daemon SubprocessSigner <-> er-signer <-> live ER). passed green on devnet.

verified the whole chain end to end as an outsider would: documented build cmds,
spike + x402 node demo (exact reconcile), the rust live_loop, cargo build
--workspace, and this daemon test. all green.
operators register ER providers via COVENANT_X402_ER_PROVIDERS (json array of
{slug, endpoint, per_call_cap, ...}); each is advertised to agents as an er.<slug>
tool. CallTool er.<slug> routes through call_tool -> er_tool_call -> the x402 pay
path, where the solana-er:* network selects the ER signer sidecar; budget debit,
settlement receipt, audit, and the tool.call.<name> capability are all reused.

new er_provider module (registry + tool specs + resolve), Server.er_providers +
with_er_providers builder, list_tools/call_tool wiring, env loader in main. 2 unit
tests + a live #[ignore] test (er_provider_tool_settles_in_the_er_live) green on
devnet: agent calls registered er.quote -> ER consume -> facilitator -> 200 +
receipt. workspace build green.
Dockerfile.facilitator (multi-stage rust build of covenant-x402-facilitator) +
render.yaml web service covenant-x402-er-facilitator (docker, frankfurt, devnet
PROGRAM/ER/PRICE, healthCheckPath /health). add /health route to the facilitator
and a repo .dockerignore so build contexts stay lean. validated: docker image
builds, /health -> 200, /paid -> 402 challenge.
remote-check.mjs pays a deployed x402 ER facilitator end to end (GET /paid -> 402
-> consume_credits in the ER -> retry with x-payment -> 200); uses sha256(nonce
string) to match the rust facilitator. ran green against the live service
https://covenant-x402-er-facilitator.onrender.com. README documents the deployed
URL + how to verify.
issued nonces now carry an issue time, are TTL-evicted (300s) on every request,
and capped at MAX_OUTSTANDING_NONCES (503 over the cap) so unpaid challenges can't
grow memory without bound. expired nonces fail closed on redemption. +unit test.
OPERATING.md: facilitator -> daemon env (er signer + COVENANT_X402_ER_PROVIDERS) ->
grant tool.call.er.<slug> -> delegate the credit account -> agent calls er.<slug> ->
settles in the ER -> top-up. documents the live deployed facilitator + how to verify,
and surfaces the delegation-lifecycle automation decision (manual/keeper/daemon).
hitting the bare domain (incl. the custom domain) was a 404 because only /health
and /paid had routes. add a root handler returning a small json descriptor (service,
endpoints, program, network, price) so the root shows what it is.
@mizuki0x mizuki0x force-pushed the feat/magicblock-er branch from 0a519ba to 5937d29 Compare June 23, 2026 20:11
mizuki0x added 15 commits June 23, 2026 23:52
…(idle/lifetime/stall undelegate, TEE-capable)
…t (folded per consume) + migrate_credit_account for the upgrade path
…nance-chained actions -> slash citing the provenance root)
…s on-chain provenance_root, read from the seed-bound credit account (no caller-supplied reason)
…into the TEE attestation (proves the agent's record came from a genuine attested enclave) with attest/verify API
…fig migrations + amd64 verified-build workflow
…z + lto + strip) to shrink the deployable .so
…lve/discover); drop wrong non-ephemeral build-settlement workflow
…erified ER, provenance-proven, bonded and slashed live on mainnet
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants