MagicBlock ephemeral-rollup credit metering + x402-over-ER#93
Open
mizuki0x wants to merge 185 commits into
Open
MagicBlock ephemeral-rollup credit metering + x402-over-ER#93mizuki0x wants to merge 185 commits into
mizuki0x wants to merge 185 commits into
Conversation
…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.
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.
0a519ba to
5937d29
Compare
…r reproducible verification
… MagicBlock rollup) + one-shot run
…(idle/lifetime/stall undelegate, TEE-capable)
…t (folded per consume) + migrate_credit_account for the upgrade path
…redit-account migration
…ote and relay it into a signed attestation
…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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Adds a MagicBlock Ephemeral Rollup settlement path: agents meter and pay per call by running
consume_creditsin an ER (gasless, ~ms), with the credit balance reconciling exactly to L1. Token custody, staking, slashing, and governance stay on mainnet; only the program-ownedu64credit balance delegates to the rollup.What's here
On-chain (settlement program)
delegate_credits/commit_credits/undelegate_creditsplus the#[ephemeral]undelegation callback, behind a newephemeralfeature.programs/settlement-ephemeralthatinclude!s the settlement source with the feature on. It is excluded from the workspace so itsephemeral-rollups-sdkgraph (which pins the solana runtime to 2.2.20) resolves in its own lock and never perturbs thelitesvm 0.6test 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:EphemeralSignerimplements theSignertrait by metering.build_paymentsubmits aconsume_creditsto 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'spay_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
ephemeral-rollups-sdkwithdefault-features = falsebreaks the SDK build (utils.rsreferencessolana_system_interface, which sits behind the default feature). Keep defaults and addanchor-compat.anchor-compatletsanchor-langsplit into 0.31.1 + 0.32.1 under a fresh lock; pin withcargo update -p anchor-lang --precise 0.31.1.litesvm 0.6(2.2.1, pre thesolana-feature-set->agave-feature-setrename). Solved by the isolated-crate split above.Verification (devnet)
Live on devnet (program
cov9UDyp..., EU ER validator), not just compiled or mocked:Client+EphemeralSigner-> facilitator -> ER:402 -> ER consume -> 200, balance -2 exactly, reconciled to L1.PayX402op drivesop_respond-> ER signer sidecar ->consume_creditsin the ER -> the facilitator ->200, with a settlement receipt recorded. Covered bypay_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-facilitator5; settlement litesvm suite unchanged.Daemon integration (landed + live-verified)
The full loop is wired into
covenantd:covenant-x402-er-signersidecar (incovenant-x402,solanafeature): stdinPaymentRequirements->consume_creditsin the ER viaEphemeralSigner-> stdoutx-paymentheader.X402Config::signer_for(network)routessolana-er:*providers to the ER sidecar (envCOVENANT_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.COVENANT_X402_ER_PROVIDERS(a JSON array); each is advertised to agents as aner.<slug>tool and routes through the same x402 pay path (gated bytool.call.er.<slug>, recording budget/receipt/audit).#[ignore]tests):pay_x402_settles_through_the_er_signer_live(raw op) ander_provider_tool_settles_in_the_er_live(an agent calling a registereder.*tool).