diff --git a/.gitignore b/.gitignore index a168fad4f..b14d8ed06 100644 --- a/.gitignore +++ b/.gitignore @@ -67,6 +67,9 @@ miner-runtime/ !deploy/secrets/ deploy/secrets/* !deploy/secrets/README.md +!deploy/secrets/lium/ +deploy/secrets/lium/* +!deploy/secrets/lium/README.md __pycache__/ diff --git a/AGENTS.md b/AGENTS.md index 2bf2814c3..3d612b793 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -90,6 +90,7 @@ Match CI (`.github/workflows/ci.yml`): | Doc authority vs evidence | [`docs/AGENTS.md`](docs/AGENTS.md) | | Component status | [`docs/COMPLETENESS.md`](docs/COMPLETENESS.md) | | Frozen contracts | [`docs/BUNDLE_SPEC.md`](docs/BUNDLE_SPEC.md), [`docs/DESIGN_CHALLENGE.md`](docs/DESIGN_CHALLENGE.md), [`docs/PRISM.md`](docs/PRISM.md) | +| Shared Lium GPU prepay (scaffolding) | [`docs/LIUM_FUNDING.md`](docs/LIUM_FUNDING.md) · crate `lium-funding` · **off** in prod until wallet live (`PRISM_REQUIRE_LIUM_FUNDING=0`) | | Miner HTTP submit | [`docs/external-miner/`](docs/external-miner/) · public: [design-challenge](https://github.com/BaseIntelligence/design-challenge), [prism](https://github.com/BaseIntelligence/prism) | | Threat / operator checklist | [`docs/THREAT_MODEL.md`](docs/THREAT_MODEL.md), [`docs/OPERATOR_SECURITY.md`](docs/OPERATOR_SECURITY.md) | diff --git a/Cargo.lock b/Cargo.lock index 177c1b050..cc270020d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2999,6 +2999,25 @@ version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" +[[package]] +name = "lium-funding" +version = "0.1.0" +dependencies = [ + "async-trait", + "axum", + "hex", + "http-body-util", + "reqwest 0.12.28", + "serde", + "serde_json", + "sha2 0.10.9", + "thiserror 2.0.19", + "tokio", + "tower", + "tracing", + "uuid", +] + [[package]] name = "lock_api" version = "0.4.14" @@ -3746,6 +3765,7 @@ dependencies = [ "db", "hex", "http-body-util", + "lium-funding", "prism-challenge-task", "prism-emit", "prism-lium", @@ -3772,6 +3792,7 @@ name = "prism-challenge-bin" version = "0.1.0" dependencies = [ "assert_cmd", + "async-trait", "axum", "chain", "chain-live", @@ -3780,6 +3801,7 @@ dependencies = [ "clap", "crypto", "db", + "lium-funding", "predicates", "prism-challenge", "prism-lium", diff --git a/bins/prism-challenge/Cargo.toml b/bins/prism-challenge/Cargo.toml index 40462eaf1..22cb9133f 100644 --- a/bins/prism-challenge/Cargo.toml +++ b/bins/prism-challenge/Cargo.toml @@ -18,6 +18,7 @@ challenge-keys = { path = "../../crates/challenge-keys" } chain = { path = "../../crates/chain" } chain-live = { path = "../../crates/chain-live" } clap = { version = "4", features = ["derive", "env"] } +lium-funding = { path = "../../crates/lium-funding" } prism-challenge = { path = "../../crates/prism-challenge" } prism-lium = { path = "../../crates/prism-lium" } prism-recipe = { path = "../../crates/prism-recipe" } @@ -29,6 +30,7 @@ db = { path = "../../crates/db" } telemetry = { path = "../../crates/telemetry" } tokio = { version = "1", features = ["macros", "rt-multi-thread", "net", "signal", "sync", "time"] } trustroot = { path = "../../crates/trustroot" } +async-trait = "0.1" axum = { version = "0.8", default-features = false, features = ["http1", "tokio", "json"] } tracing = "0.1" diff --git a/bins/prism-challenge/src/main.rs b/bins/prism-challenge/src/main.rs index 13618ee31..198b8ff37 100644 --- a/bins/prism-challenge/src/main.rs +++ b/bins/prism-challenge/src/main.rs @@ -22,9 +22,15 @@ use std::sync::atomic::AtomicU64; use std::sync::Arc; use std::time::Duration; +use async_trait::async_trait; use challenge_agentic::{AgenticBackend, OpenRouterAgent, SimAgent}; use challenge_keys::load_challenge_secret; use clap::{Parser, Subcommand}; +use lium_funding::{ + funding_router, EligibilityChecker, EnvTaoOracle, FakeLiumAccount, FakeTaoVerifier, + FundingConfig, FundingError, FundingHttpState, FundingService, HttpLiumAccount, + LiumAccountClient, MemoryFundingStore, PrismFundingPolicy, TaoPaymentVerifier, TaoPriceOracle, +}; use prism_challenge::{ submission_router, AppState, DbPrismStore, MemoryPrismStore, Orchestrator, OrchestratorConfig, PrismStore, CHALLENGE_ID, SCORING_VERSION, @@ -361,6 +367,7 @@ fn build_topmodel() -> Option> { p } +#[allow(clippy::too_many_lines)] async fn cmd_serve(cli: Cli) -> Result<(), String> { let path = resolve_sk_path(cli.challenge_sk_file.as_ref())?; if !path.is_file() { @@ -398,7 +405,13 @@ async fn cmd_serve(cli: Cli) -> Result<(), String> { gating: gating_enabled.then(|| Arc::clone(&gating)), metagraph: gating_enabled.then(|| Arc::clone(&metagraph)), }); - let app = submission_router(Arc::clone(&state)); + let funding = build_funding( + Arc::clone(&store), + gating_enabled.then(|| Arc::clone(&metagraph)), + ); + let app = submission_router(Arc::clone(&state)).merge(funding_router(FundingHttpState { + service: Arc::clone(&funding), + })); // Chain (for epoch/E): live only when endpoint configured; otherwise a // fixed epoch-0 (local sim posture documented in PRISM.md). @@ -454,7 +467,7 @@ async fn cmd_serve(cli: Cli) -> Result<(), String> { Duration::from_secs(cli.gating_watch_secs.max(15)), ); } - let orchestrator = Arc::new(orchestrator); + let orchestrator = Arc::new(orchestrator.with_funding(funding)); spawn_orchestrator(&cli, &orchestrator); let listener = TcpListener::bind(cli.bind) @@ -500,6 +513,78 @@ fn spawn_epoch_feed(chain_ep: &str, state: &Arc) { }); } +/// Prism funding eligibility: metagraph member + zero prior Prism submissions. +struct PrismFundingElig { + store: Arc, + metagraph: Option>, +} + +#[async_trait] +impl EligibilityChecker for PrismFundingElig { + async fn ensure_eligible(&self, hotkey: &str) -> Result<(), FundingError> { + let hk = hotkey.trim().to_ascii_lowercase(); + if let Some(mg) = &self.metagraph { + match mg.snapshot() { + Some(view) if view.contains_hex(&hk) => {} + Some(_) => { + return Err(FundingError::Ineligible( + "hotkey not registered on subnet metagraph".into(), + )); + } + None => { + return Err(FundingError::Ineligible( + "metagraph snapshot unavailable".into(), + )); + } + } + } + let prior = self + .store + .list(None, Some(&hk), 1) + .await + .map_err(|e| FundingError::Store(e.to_string()))?; + if !prior.is_empty() { + return Err(FundingError::Ineligible( + "hotkey already has a Prism submission".into(), + )); + } + Ok(()) + } +} + +fn build_funding( + store: Arc, + metagraph: Option>, +) -> Arc { + // Always mount the funding HTTP surface; rent gate stays off until + // PRISM_REQUIRE_LIUM_FUNDING=1 (see docs/LIUM_FUNDING.md). + let cfg = FundingConfig::from_env(); + let elig: Arc = Arc::new(PrismFundingElig { store, metagraph }); + let policy = Arc::new(PrismFundingPolicy::new(elig).with_economics(cfg.economics)); + let oracle: Arc = Arc::new(EnvTaoOracle { + fallback_usd_per_tao: 400.0, + }); + let payments: Arc = Arc::new(FakeTaoVerifier::default()); + let lium: Arc = load_lium_api_key() + .and_then(|k| HttpLiumAccount::new(k).ok()) + .map_or_else( + || Arc::new(FakeLiumAccount { balance_usd: 0.0 }) as Arc, + |c| Arc::new(c) as Arc, + ); + tracing::info!( + require_funding = cfg.require_funding, + "lium-funding surface enabled (payment verifier=fake until on-chain watcher ships)" + ); + Arc::new(FundingService::new( + cfg, + policy, + Arc::new(MemoryFundingStore::default()), + oracle, + payments, + lium, + )) +} + fn spawn_orchestrator(cli: &Cli, orchestrator: &Arc>) { let permits = cli.max_concurrent_evals.max(1) as usize; let sem = Arc::new(Semaphore::new(permits)); diff --git a/crates/lium-funding/Cargo.toml b/crates/lium-funding/Cargo.toml new file mode 100644 index 000000000..e398f9a29 --- /dev/null +++ b/crates/lium-funding/Cargo.toml @@ -0,0 +1,30 @@ +[package] +name = "lium-funding" +description = "Shared challenge GPU prepay: quote TAO, verify deposit, grant/consume FundingCredit (Lium operator account)" +version.workspace = true +edition.workspace = true +license.workspace = true +repository.workspace = true +rust-version.workspace = true +publish = false + +[dependencies] +async-trait = "0.1" +axum = { version = "0.8", default-features = false, features = ["http1", "tokio", "json", "query"] } +hex = "0.4" +reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "json"] } +serde = { version = "1", features = ["derive"] } +serde_json = "1" +sha2 = "0.10" +thiserror = "2" +tokio = { version = "1", features = ["macros", "rt-multi-thread", "sync", "time"] } +tracing = "0.1" +uuid = { version = "1", features = ["v4"] } + +[dev-dependencies] +tokio = { version = "1", features = ["macros", "rt-multi-thread"] } +tower = { version = "0.5", features = ["util"] } +http-body-util = "0.1" + +[lints] +workspace = true diff --git a/crates/lium-funding/src/config.rs b/crates/lium-funding/src/config.rs new file mode 100644 index 000000000..679ac61b3 --- /dev/null +++ b/crates/lium-funding/src/config.rs @@ -0,0 +1,106 @@ +//! Env knobs for funding (no secrets in process env when avoidable). + +use crate::types::FundingEconomics; + +/// Runtime funding configuration. +#[derive(Debug, Clone)] +pub struct FundingConfig { + /// When false, rent gate is a no-op (default for Prism until wallet live). + pub require_funding: bool, + /// Operator SS58 deposit address (placeholder until secrets wired). + pub deposit_address: String, + /// Quote TTL seconds. + pub quote_ttl_secs: u64, + /// Economics (rate/hours/buffer). + pub economics: FundingEconomics, + /// Optional admin bearer for `/v1/funding/admin/*`. + pub admin_token: Option, +} + +impl Default for FundingConfig { + fn default() -> Self { + Self { + require_funding: false, + deposit_address: "REPLACE_ME_DEPOSIT_SS58".into(), + quote_ttl_secs: 900, + economics: FundingEconomics::prism_default(), + admin_token: None, + } + } +} + +impl FundingConfig { + /// Load from env. Secrets (deposit address file, admin token file) optional. + /// + /// `PRISM_REQUIRE_LIUM_FUNDING` — `1`/`true` enables the rent gate (default off). + #[must_use] + pub fn from_env() -> Self { + let mut cfg = Self::default(); + cfg.require_funding = env_truthy("PRISM_REQUIRE_LIUM_FUNDING"); + if let Ok(addr) = std::env::var("LIUM_FUNDING_DEPOSIT_ADDRESS") { + if !addr.trim().is_empty() { + cfg.deposit_address = addr.trim().to_owned(); + } + } else if let Ok(path) = std::env::var("LIUM_FUNDING_DEPOSIT_ADDRESS_FILE") { + if let Ok(s) = std::fs::read_to_string(&path) { + let t = s.trim(); + if !t.is_empty() { + cfg.deposit_address = t.to_owned(); + } + } + } + if let Ok(v) = std::env::var("LIUM_FUNDING_QUOTE_TTL_SECS") { + if let Ok(n) = v.parse::() { + cfg.quote_ttl_secs = n; + } + } + cfg.economics = economics_from_env(cfg.economics); + cfg.admin_token = + read_secret_env("LIUM_FUNDING_ADMIN_TOKEN", "LIUM_FUNDING_ADMIN_TOKEN_FILE"); + cfg + } +} + +fn economics_from_env(mut e: FundingEconomics) -> FundingEconomics { + if let Some(v) = env_f64("PRISM_FUNDING_RATE_USD_PER_HOUR") + .or_else(|| env_f64("LIUM_FUNDING_RATE_USD_PER_HOUR")) + { + e.rate_usd_per_hour = v; + } + if let Some(v) = env_f64("PRISM_FUNDING_HOURS").or_else(|| env_f64("LIUM_FUNDING_HOURS")) { + e.hours = v; + } + if let Some(v) = env_f64("LIUM_FUNDING_BUFFER") { + e.buffer = v; + } + e +} + +fn env_f64(key: &str) -> Option { + std::env::var(key).ok().and_then(|s| s.parse().ok()) +} + +fn env_truthy(key: &str) -> bool { + matches!( + std::env::var(key).as_deref(), + Ok("1" | "true" | "TRUE" | "yes" | "YES") + ) +} + +fn read_secret_env(env_key: &str, file_key: &str) -> Option { + if let Ok(v) = std::env::var(env_key) { + let t = v.trim(); + if !t.is_empty() { + return Some(t.to_owned()); + } + } + if let Ok(path) = std::env::var(file_key) { + if let Ok(s) = std::fs::read_to_string(path) { + let t = s.trim(); + if !t.is_empty() { + return Some(t.to_owned()); + } + } + } + None +} diff --git a/crates/lium-funding/src/credit.rs b/crates/lium-funding/src/credit.rs new file mode 100644 index 000000000..fdda41e57 --- /dev/null +++ b/crates/lium-funding/src/credit.rs @@ -0,0 +1,174 @@ +//! Funding credit ledger (memory first; DB later). + +use std::collections::HashMap; +use std::sync::Mutex; + +use async_trait::async_trait; + +use crate::error::FundingError; +use crate::types::{CreditState, FundingCredit, FundingDeposit, FundingQuote}; + +/// Persist quotes, deposits, and credits. +#[async_trait] +pub trait FundingStore: Send + Sync { + async fn put_quote(&self, quote: FundingQuote) -> Result<(), FundingError>; + async fn get_quote(&self, quote_id: &str) -> Result, FundingError>; + async fn latest_quote( + &self, + challenge_id: &str, + hotkey: &str, + ) -> Result, FundingError>; + + async fn put_deposit(&self, deposit: FundingDeposit) -> Result<(), FundingError>; + async fn get_deposit(&self, quote_id: &str) -> Result, FundingError>; + + async fn put_credit(&self, credit: FundingCredit) -> Result<(), FundingError>; + async fn get_unspent( + &self, + challenge_id: &str, + hotkey: &str, + ) -> Result, FundingError>; + async fn any_credit( + &self, + challenge_id: &str, + hotkey: &str, + ) -> Result, FundingError>; + async fn consume( + &self, + challenge_id: &str, + hotkey: &str, + now_ms: u64, + ) -> Result; + async fn list_credits(&self) -> Result, FundingError>; +} + +/// In-memory store for tests and local scaffolding. +#[derive(Debug, Default)] +pub struct MemoryFundingStore { + quotes: Mutex>, + deposits: Mutex>, + credits: Mutex>, +} + +#[async_trait] +impl FundingStore for MemoryFundingStore { + async fn put_quote(&self, quote: FundingQuote) -> Result<(), FundingError> { + let mut g = self + .quotes + .lock() + .map_err(|_| FundingError::Store("quotes lock".into()))?; + g.insert(quote.quote_id.clone(), quote); + Ok(()) + } + + async fn get_quote(&self, quote_id: &str) -> Result, FundingError> { + let g = self + .quotes + .lock() + .map_err(|_| FundingError::Store("quotes lock".into()))?; + Ok(g.get(quote_id).cloned()) + } + + async fn latest_quote( + &self, + challenge_id: &str, + hotkey: &str, + ) -> Result, FundingError> { + let g = self + .quotes + .lock() + .map_err(|_| FundingError::Store("quotes lock".into()))?; + Ok(g.values() + .filter(|q| q.challenge_id == challenge_id && q.hotkey == hotkey) + .max_by_key(|q| q.expires_at_ms) + .cloned()) + } + + async fn put_deposit(&self, deposit: FundingDeposit) -> Result<(), FundingError> { + let mut g = self + .deposits + .lock() + .map_err(|_| FundingError::Store("deposits lock".into()))?; + g.insert(deposit.quote_id.clone(), deposit); + Ok(()) + } + + async fn get_deposit(&self, quote_id: &str) -> Result, FundingError> { + let g = self + .deposits + .lock() + .map_err(|_| FundingError::Store("deposits lock".into()))?; + Ok(g.get(quote_id).cloned()) + } + + async fn put_credit(&self, credit: FundingCredit) -> Result<(), FundingError> { + let mut g = self + .credits + .lock() + .map_err(|_| FundingError::Store("credits lock".into()))?; + g.push(credit); + Ok(()) + } + + async fn get_unspent( + &self, + challenge_id: &str, + hotkey: &str, + ) -> Result, FundingError> { + let g = self + .credits + .lock() + .map_err(|_| FundingError::Store("credits lock".into()))?; + Ok(g.iter() + .find(|c| { + c.challenge_id == challenge_id + && c.hotkey == hotkey + && c.state == CreditState::Unspent + }) + .cloned()) + } + + async fn any_credit( + &self, + challenge_id: &str, + hotkey: &str, + ) -> Result, FundingError> { + let g = self + .credits + .lock() + .map_err(|_| FundingError::Store("credits lock".into()))?; + Ok(g.iter() + .filter(|c| c.challenge_id == challenge_id && c.hotkey == hotkey) + .max_by_key(|c| c.created_at_ms) + .cloned()) + } + + async fn consume( + &self, + challenge_id: &str, + hotkey: &str, + now_ms: u64, + ) -> Result { + let mut g = self + .credits + .lock() + .map_err(|_| FundingError::Store("credits lock".into()))?; + let credit = g.iter_mut().find(|c| { + c.challenge_id == challenge_id && c.hotkey == hotkey && c.state == CreditState::Unspent + }); + let Some(c) = credit else { + return Err(FundingError::Credit("no unspent credit".into())); + }; + c.state = CreditState::Spent; + c.spent_at_ms = Some(now_ms); + Ok(c.clone()) + } + + async fn list_credits(&self) -> Result, FundingError> { + let g = self + .credits + .lock() + .map_err(|_| FundingError::Store("credits lock".into()))?; + Ok(g.clone()) + } +} diff --git a/crates/lium-funding/src/error.rs b/crates/lium-funding/src/error.rs new file mode 100644 index 000000000..f13a39fde --- /dev/null +++ b/crates/lium-funding/src/error.rs @@ -0,0 +1,29 @@ +//! Funding errors. + +use thiserror::Error; + +/// Funding subsystem error. +#[derive(Debug, Error)] +pub enum FundingError { + /// Policy rejected the hotkey. + #[error("ineligible: {0}")] + Ineligible(String), + /// Quote math / oracle failure. + #[error("quote: {0}")] + Quote(String), + /// Payment not found / insufficient. + #[error("payment: {0}")] + Payment(String), + /// Credit missing or already spent. + #[error("credit: {0}")] + Credit(String), + /// Lium account / HTTP. + #[error("lium: {0}")] + Lium(String), + /// Misconfiguration. + #[error("config: {0}")] + Config(String), + /// Storage fault. + #[error("store: {0}")] + Store(String), +} diff --git a/crates/lium-funding/src/http.rs b/crates/lium-funding/src/http.rs new file mode 100644 index 000000000..b99d5af70 --- /dev/null +++ b/crates/lium-funding/src/http.rs @@ -0,0 +1,134 @@ +//! Axum funding routes (merge into a challenge router). + +use std::sync::Arc; + +use axum::extract::{Query, State}; +use axum::http::{HeaderMap, StatusCode}; +use axum::response::{IntoResponse, Response}; +use axum::routing::get; +use axum::{Json, Router}; +use serde::Deserialize; +use serde_json::json; + +use crate::error::FundingError; +use crate::service::FundingService; + +/// HTTP state. +#[derive(Clone)] +pub struct FundingHttpState { + /// Service. + pub service: Arc, +} + +/// Funding routes under `/v1/funding/*`. +pub fn funding_router(state: FundingHttpState) -> Router { + Router::new() + .route("/v1/funding/quote", get(get_quote).post(post_quote)) + .route("/v1/funding/status", get(get_status)) + .route("/v1/funding/admin/credits", get(admin_credits)) + .with_state(Arc::new(state)) +} + +#[derive(Debug, Deserialize)] +pub struct QuoteQuery { + pub challenge_id: Option, + pub hotkey: String, +} + +#[derive(Debug, Deserialize)] +pub struct QuoteBody { + pub challenge_id: Option, + pub hotkey: String, +} + +async fn get_quote( + State(st): State>, + Query(q): Query, +) -> Response { + quote_inner(&st, q.challenge_id.as_deref(), &q.hotkey).await +} + +async fn post_quote( + State(st): State>, + Json(body): Json, +) -> Response { + quote_inner(&st, body.challenge_id.as_deref(), &body.hotkey).await +} + +async fn quote_inner(st: &FundingHttpState, challenge_id: Option<&str>, hotkey: &str) -> Response { + if let Err(resp) = check_challenge(st, challenge_id) { + return resp; + } + match st.service.quote(hotkey).await { + Ok(q) => (StatusCode::OK, Json(q)).into_response(), + Err(e) => err_response(e), + } +} + +async fn get_status( + State(st): State>, + Query(q): Query, +) -> Response { + if let Err(resp) = check_challenge(&st, q.challenge_id.as_deref()) { + return resp; + } + match st.service.status(&q.hotkey).await { + Ok(s) => (StatusCode::OK, Json(s)).into_response(), + Err(e) => err_response(e), + } +} + +async fn admin_credits(State(st): State>, headers: HeaderMap) -> Response { + if !admin_ok(&st, &headers) { + return ( + StatusCode::UNAUTHORIZED, + Json(json!({"error":"unauthorized"})), + ) + .into_response(); + } + match st.service.list_credits().await { + Ok(list) => (StatusCode::OK, Json(json!({"credits": list}))).into_response(), + Err(e) => err_response(e), + } +} + +fn check_challenge(st: &FundingHttpState, challenge_id: Option<&str>) -> Result<(), Response> { + if let Some(cid) = challenge_id { + if cid != st.service.challenge_id() { + return Err(( + StatusCode::BAD_REQUEST, + Json(json!({ + "error": format!( + "challenge_id mismatch: got {cid}, expected {}", + st.service.challenge_id() + ) + })), + ) + .into_response()); + } + } + Ok(()) +} + +fn admin_ok(st: &FundingHttpState, headers: &HeaderMap) -> bool { + let Some(expected) = st.service.cfg().admin_token.as_deref() else { + return false; + }; + let Some(auth) = headers.get(axum::http::header::AUTHORIZATION) else { + return false; + }; + let Ok(s) = auth.to_str() else { + return false; + }; + s.strip_prefix("Bearer ").is_some_and(|t| t == expected) +} + +fn err_response(e: FundingError) -> Response { + let code = match &e { + FundingError::Ineligible(_) => StatusCode::FORBIDDEN, + FundingError::Credit(_) | FundingError::Payment(_) => StatusCode::PAYMENT_REQUIRED, + FundingError::Config(_) => StatusCode::INTERNAL_SERVER_ERROR, + _ => StatusCode::BAD_REQUEST, + }; + (code, Json(json!({"error": e.to_string()}))).into_response() +} diff --git a/crates/lium-funding/src/lib.rs b/crates/lium-funding/src/lib.rs new file mode 100644 index 000000000..299f6cd68 --- /dev/null +++ b/crates/lium-funding/src/lib.rs @@ -0,0 +1,146 @@ +//! Shared challenge GPU prepay via operator Lium wallet + TAO deposits. +//! +//! See [`docs/LIUM_FUNDING.md`](../../docs/LIUM_FUNDING.md). + +#![forbid(unsafe_code)] +#![allow(clippy::cast_precision_loss)] +#![allow(clippy::cast_possible_truncation)] +#![allow(clippy::cast_sign_loss)] +#![allow(clippy::missing_errors_doc)] +#![allow(clippy::module_name_repetitions)] +#![allow(clippy::doc_markdown)] +#![allow(clippy::missing_fields_in_debug)] // api_key intentionally redacted +#![allow(clippy::result_large_err)] +#![allow(clippy::needless_pass_by_value)] +#![allow(clippy::assigning_clones)] +#![allow(clippy::field_reassign_with_default)] +#![allow(clippy::redundant_closure_for_method_calls)] + +mod config; +mod credit; +mod error; +mod http; +mod lium_account; +mod oracle; +mod payment; +mod policy; +mod service; +mod types; + +pub use config::FundingConfig; +pub use credit::{FundingStore, MemoryFundingStore}; +pub use error::FundingError; +pub use http::{funding_router, FundingHttpState}; +pub use lium_account::{FakeLiumAccount, HttpLiumAccount, LiumAccountClient, LIUM_API_BASE_URL}; +pub use oracle::{tao_from_usd, EnvTaoOracle, FixedTaoOracle, TaoPriceOracle}; +pub use payment::{funding_memo, FakeTaoVerifier, TaoPaymentVerifier}; +pub use policy::{ + AllowlistEligibility, ChallengeFundingPolicy, EligibilityChecker, OpenFundingPolicy, + PrismFundingPolicy, +}; +pub use service::FundingService; +pub use types::{ + CreditState, FundingCredit, FundingDeposit, FundingEconomics, FundingQuote, FundingStatus, +}; + +/// Crate identity. +#[must_use] +pub fn crate_name() -> &'static str { + "lium-funding" +} + +#[cfg(test)] +mod tests { + #![allow(clippy::unwrap_used)] + #![allow(clippy::expect_used)] + #![allow(clippy::float_cmp)] + + use std::sync::Arc; + + use super::*; + + fn prism_stack( + require: bool, + ) -> ( + Arc, + Arc, + Arc, + ) { + let elig = Arc::new(AllowlistEligibility::default()); + elig.allowed.lock().unwrap().push("hk1".into()); + let payments = Arc::new(FakeTaoVerifier::default()); + let mut cfg = FundingConfig::default(); + cfg.require_funding = require; + cfg.deposit_address = "5TestDepositAddress".into(); + cfg.economics = FundingEconomics::prism_default(); + let policy = Arc::new(PrismFundingPolicy::new( + elig.clone() as Arc + )); + let svc = Arc::new(FundingService::new( + cfg, + policy, + Arc::new(MemoryFundingStore::default()), + Arc::new(FixedTaoOracle { usd_per_tao: 400.0 }), + payments.clone() as Arc, + Arc::new(FakeLiumAccount { balance_usd: 100.0 }), + )); + (svc, payments, elig) + } + + #[test] + fn quote_math_prism_default() { + let e = FundingEconomics::prism_default(); + let usd = e.usd_cost(); + assert!((usd - 4.422).abs() < 1e-9, "usd={usd}"); + let tao = tao_from_usd(usd, 400.0).unwrap(); + assert!((tao - 0.011_055).abs() < 1e-9, "tao={tao}"); + } + + #[tokio::test] + async fn eligibility_rejects_unknown_hotkey() { + let (svc, _, _) = prism_stack(false); + let err = svc.quote("unknown").await.unwrap_err(); + assert!(matches!(err, FundingError::Ineligible(_))); + } + + #[tokio::test] + async fn credit_consume_once() { + let (svc, payments, _) = prism_stack(true); + let q = svc.quote("hk1").await.unwrap(); + payments + .confirm(&q.memo, q.tao_amount, Some("0xabc".into())) + .unwrap(); + let st = svc.confirm_if_paid("hk1").await.unwrap(); + assert!(st.credit.as_ref().unwrap().state == CreditState::Unspent); + svc.before_rent("hk1").await.unwrap(); + svc.consume_on_provision("hk1").await.unwrap(); + let err = svc.before_rent("hk1").await.unwrap_err(); + assert!(matches!(err, FundingError::Credit(_))); + // Second consume fails. + let err = svc.consume_on_provision("hk1").await.unwrap_err(); + assert!(matches!(err, FundingError::Credit(_))); + } + + #[tokio::test] + async fn feature_flag_off_skips_rent_gate() { + let (svc, _, _) = prism_stack(false); + // No credit, but require_funding=false → Ok. + svc.before_rent("hk1").await.unwrap(); + svc.consume_on_provision("hk1").await.unwrap(); + } + + #[tokio::test] + async fn one_funding_per_hotkey() { + let (svc, payments, _) = prism_stack(false); + let q = svc.quote("hk1").await.unwrap(); + payments.confirm(&q.memo, q.tao_amount, None).unwrap(); + let _ = svc.confirm_if_paid("hk1").await.unwrap(); + let err = svc.quote("hk1").await.unwrap_err(); + assert!(matches!(err, FundingError::Ineligible(_))); + } + + #[test] + fn identity() { + assert_eq!(crate_name(), "lium-funding"); + } +} diff --git a/crates/lium-funding/src/lium_account.rs b/crates/lium-funding/src/lium_account.rs new file mode 100644 index 000000000..c2c74b6f8 --- /dev/null +++ b/crates/lium-funding/src/lium_account.rs @@ -0,0 +1,140 @@ +//! Operator Lium account client (balance / optional invoice helpers). +//! +//! Auth: `X-API-Key` against `https://lium.io/api` (same as `prism-lium`). +//! See and OpenAPI +//! `GET /users/me`, `POST /nowpayments/create-invoice`. + +use async_trait::async_trait; +use reqwest::header::{HeaderMap, HeaderValue}; +use serde_json::Value; + +use crate::error::FundingError; + +/// Default Lium API base. +pub const LIUM_API_BASE_URL: &str = "https://lium.io/api"; + +/// Operator-facing Lium account operations used by funding (not pod rent). +#[async_trait] +pub trait LiumAccountClient: Send + Sync { + /// Account USD balance (`GET /users/me` → `balance`). + async fn balance_usd(&self) -> Result; +} + +/// No-op / test account with a fixed balance. +#[derive(Debug, Clone)] +pub struct FakeLiumAccount { + /// Reported USD balance. + pub balance_usd: f64, +} + +#[async_trait] +impl LiumAccountClient for FakeLiumAccount { + async fn balance_usd(&self) -> Result { + Ok(self.balance_usd) + } +} + +/// HTTPS Lium account client. API key never appears in `Debug`. +pub struct HttpLiumAccount { + http: reqwest::Client, + base_url: String, + api_key: String, +} + +impl std::fmt::Debug for HttpLiumAccount { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("HttpLiumAccount") + .field("base_url", &self.base_url) + .field("api_key", &"") + .finish_non_exhaustive() + } +} + +impl HttpLiumAccount { + /// Build against the public Lium API. + /// + /// # Errors + /// Empty key or HTTP client build failure. + pub fn new(api_key: impl Into) -> Result { + Self::with_base_url(api_key, LIUM_API_BASE_URL) + } + + /// Custom base URL (tests). + /// + /// # Errors + /// Empty key or HTTP client build failure. + pub fn with_base_url( + api_key: impl Into, + base_url: impl Into, + ) -> Result { + let api_key = api_key.into(); + if api_key.trim().is_empty() { + return Err(FundingError::Config("empty LIUM_API_KEY".into())); + } + let mut headers = HeaderMap::new(); + let mut hv = HeaderValue::from_str(&api_key) + .map_err(|e| FundingError::Config(format!("api key header: {e}")))?; + hv.set_sensitive(true); + headers.insert("X-API-Key", hv); + headers.insert( + reqwest::header::USER_AGENT, + HeaderValue::from_static("lium-funding/0.1 (base)"), + ); + headers.insert( + reqwest::header::ACCEPT, + HeaderValue::from_static("application/json"), + ); + let http = reqwest::Client::builder() + .default_headers(headers) + .timeout(std::time::Duration::from_secs(30)) + .build() + .map_err(|e| FundingError::Lium(e.to_string()))?; + Ok(Self { + http, + base_url: base_url.into().trim_end_matches('/').to_owned(), + api_key, + }) + } +} + +#[async_trait] +impl LiumAccountClient for HttpLiumAccount { + async fn balance_usd(&self) -> Result { + let url = format!("{}/users/me", self.base_url); + let resp = self + .http + .get(&url) + .send() + .await + .map_err(|e| FundingError::Lium(sanitize(&e.to_string(), &self.api_key)))?; + let status = resp.status(); + let text = resp + .text() + .await + .map_err(|e| FundingError::Lium(sanitize(&e.to_string(), &self.api_key)))?; + if !status.is_success() { + return Err(FundingError::Lium(format!( + "GET /users/me -> {status}: {}", + sanitize(&text, &self.api_key) + ))); + } + let v: Value = + serde_json::from_str(&text).map_err(|e| FundingError::Lium(format!("json: {e}")))?; + v.get("balance") + .and_then(|x| x.as_f64()) + .or_else(|| { + v.get("balance") + .and_then(|x| x.as_str()) + .and_then(|s| s.parse().ok()) + }) + .ok_or_else(|| FundingError::Lium("users/me missing balance".into())) + } +} + +fn sanitize(msg: &str, key: &str) -> String { + if key.is_empty() { + msg.to_owned() + } else { + msg.replace(key, "") + } +} diff --git a/crates/lium-funding/src/oracle.rs b/crates/lium-funding/src/oracle.rs new file mode 100644 index 000000000..f8ff964ba --- /dev/null +++ b/crates/lium-funding/src/oracle.rs @@ -0,0 +1,67 @@ +//! TAO/USD price oracle (pluggable; fixed/env for testnet first). + +use async_trait::async_trait; + +use crate::error::FundingError; + +/// USD price of one TAO. +#[async_trait] +pub trait TaoPriceOracle: Send + Sync { + /// Current USD per 1 TAO. + async fn tao_usd_price(&self) -> Result; +} + +/// Constant price (tests / local). +#[derive(Debug, Clone)] +pub struct FixedTaoOracle { + /// USD per TAO. + pub usd_per_tao: f64, +} + +#[async_trait] +impl TaoPriceOracle for FixedTaoOracle { + async fn tao_usd_price(&self) -> Result { + if self.usd_per_tao <= 0.0 || !self.usd_per_tao.is_finite() { + return Err(FundingError::Quote("invalid fixed TAO/USD".into())); + } + Ok(self.usd_per_tao) + } +} + +/// Read `LIUM_FUNDING_TAO_USD` (or constructor value) each call. +#[derive(Debug, Clone)] +pub struct EnvTaoOracle { + /// Fallback when env unset. + pub fallback_usd_per_tao: f64, +} + +#[async_trait] +impl TaoPriceOracle for EnvTaoOracle { + async fn tao_usd_price(&self) -> Result { + let v = std::env::var("LIUM_FUNDING_TAO_USD") + .ok() + .and_then(|s| s.parse::().ok()) + .unwrap_or(self.fallback_usd_per_tao); + if v <= 0.0 || !v.is_finite() { + return Err(FundingError::Quote( + "LIUM_FUNDING_TAO_USD missing or invalid".into(), + )); + } + Ok(v) + } +} + +/// `usd_cost / tao_usd_price`. +pub fn tao_from_usd(usd_cost: f64, tao_usd_price: f64) -> Result { + if usd_cost < 0.0 || !usd_cost.is_finite() { + return Err(FundingError::Quote("usd_cost invalid".into())); + } + if tao_usd_price <= 0.0 || !tao_usd_price.is_finite() { + return Err(FundingError::Quote("tao_usd_price invalid".into())); + } + let tao = usd_cost / tao_usd_price; + if !tao.is_finite() || tao <= 0.0 { + return Err(FundingError::Quote("tao_amount invalid".into())); + } + Ok(tao) +} diff --git a/crates/lium-funding/src/payment.rs b/crates/lium-funding/src/payment.rs new file mode 100644 index 000000000..13ea8306a --- /dev/null +++ b/crates/lium-funding/src/payment.rs @@ -0,0 +1,77 @@ +//! TAO deposit verification (fake/testnet first; live watcher later). + +use std::collections::HashMap; +use std::sync::Mutex; + +use async_trait::async_trait; + +use crate::error::FundingError; +use crate::types::FundingDeposit; + +/// Observe TAO payments to the operator deposit address. +#[async_trait] +pub trait TaoPaymentVerifier: Send + Sync { + /// Check whether `deposit` has been satisfied on-chain (or fake). + async fn check_payment(&self, deposit: &FundingDeposit) + -> Result; +} + +/// In-memory fake: tests call [`FakeTaoVerifier::confirm`] to simulate payment. +#[derive(Debug, Default)] +pub struct FakeTaoVerifier { + /// memo → observed TAO (+ optional tx). + paid: Mutex)>>, +} + +impl FakeTaoVerifier { + /// Record a fake payment for `memo`. + /// + /// # Errors + /// Lock poisoned. + pub fn confirm( + &self, + memo: &str, + tao: f64, + tx_hash: Option, + ) -> Result<(), FundingError> { + let mut g = self + .paid + .lock() + .map_err(|_| FundingError::Store("payment lock".into()))?; + g.insert(memo.to_owned(), (tao, tx_hash)); + Ok(()) + } +} + +#[async_trait] +impl TaoPaymentVerifier for FakeTaoVerifier { + async fn check_payment( + &self, + deposit: &FundingDeposit, + ) -> Result { + let g = self + .paid + .lock() + .map_err(|_| FundingError::Store("payment lock".into()))?; + let mut out = deposit.clone(); + if let Some((tao, tx)) = g.get(&deposit.memo) { + out.observed_tao = *tao; + out.tx_hash = tx.clone(); + // Small underpay tolerance for float dust. + out.confirmed = *tao + 1e-9 >= deposit.expected_tao; + } + Ok(out) + } +} + +/// Build a stable memo: `basefund:::`. +#[must_use] +pub fn funding_memo(challenge_id: &str, hotkey: &str, quote_id: &str) -> String { + let hk = truncate(hotkey, 16); + let q = truncate(quote_id, 12); + format!("basefund:{challenge_id}:{hk}:{q}") +} + +fn truncate(s: &str, n: usize) -> String { + s.chars().take(n).collect() +} diff --git a/crates/lium-funding/src/policy.rs b/crates/lium-funding/src/policy.rs new file mode 100644 index 000000000..dbaabbb57 --- /dev/null +++ b/crates/lium-funding/src/policy.rs @@ -0,0 +1,124 @@ +//! Pluggable per-challenge funding policy. + +use async_trait::async_trait; + +use crate::error::FundingError; +use crate::types::FundingEconomics; + +/// Challenge-specific eligibility + economics. +#[async_trait] +pub trait ChallengeFundingPolicy: Send + Sync { + /// Stable challenge id (`prism`, `design`, …). + fn challenge_id(&self) -> &str; + + /// Quote economics. + fn economics(&self) -> FundingEconomics; + + /// Reject ineligible hotkeys before issuing a quote. + async fn ensure_eligible(&self, hotkey: &str) -> Result<(), FundingError>; + + /// When true, at most one unspent/spent credit history blocks new quotes + /// after a credit was already granted (one-funding-per-hotkey). + fn one_funding_per_hotkey(&self) -> bool { + true + } +} + +/// Always-eligible policy for tests / Design scaffolding. +#[derive(Debug, Clone)] +pub struct OpenFundingPolicy { + /// Challenge id. + pub challenge_id: String, + /// Economics. + pub economics: FundingEconomics, +} + +#[async_trait] +impl ChallengeFundingPolicy for OpenFundingPolicy { + fn challenge_id(&self) -> &str { + &self.challenge_id + } + + fn economics(&self) -> FundingEconomics { + self.economics + } + + async fn ensure_eligible(&self, _hotkey: &str) -> Result<(), FundingError> { + Ok(()) + } +} + +/// Hotkey membership + prior-submission checks injected by the challenge. +#[async_trait] +pub trait EligibilityChecker: Send + Sync { + /// Return `Ok(())` when the hotkey may receive funding. + async fn ensure_eligible(&self, hotkey: &str) -> Result<(), FundingError>; +} + +/// Prism policy: economics defaults + injected eligibility. +#[derive(Clone)] +pub struct PrismFundingPolicy { + economics: FundingEconomics, + eligibility: std::sync::Arc, +} + +impl PrismFundingPolicy { + /// Prism defaults with a custom eligibility checker. + #[must_use] + pub fn new(eligibility: std::sync::Arc) -> Self { + Self { + economics: FundingEconomics::prism_default(), + eligibility, + } + } + + /// Override economics (env knobs). + #[must_use] + pub fn with_economics(mut self, economics: FundingEconomics) -> Self { + self.economics = economics; + self + } +} + +#[async_trait] +impl ChallengeFundingPolicy for PrismFundingPolicy { + fn challenge_id(&self) -> &'static str { + "prism" + } + + fn economics(&self) -> FundingEconomics { + self.economics + } + + async fn ensure_eligible(&self, hotkey: &str) -> Result<(), FundingError> { + self.eligibility.ensure_eligible(hotkey).await + } + + fn one_funding_per_hotkey(&self) -> bool { + true + } +} + +/// Test checker: allowlist of hotkeys. +#[derive(Debug, Default)] +pub struct AllowlistEligibility { + /// Allowed hotkeys. + pub allowed: std::sync::Mutex>, +} + +#[async_trait] +impl EligibilityChecker for AllowlistEligibility { + async fn ensure_eligible(&self, hotkey: &str) -> Result<(), FundingError> { + let g = self + .allowed + .lock() + .map_err(|_| FundingError::Store("eligibility lock".into()))?; + if g.iter().any(|h| h == hotkey) { + Ok(()) + } else { + Err(FundingError::Ineligible( + "hotkey not registered / not eligible".into(), + )) + } + } +} diff --git a/crates/lium-funding/src/service.rs b/crates/lium-funding/src/service.rs new file mode 100644 index 000000000..ed003cb10 --- /dev/null +++ b/crates/lium-funding/src/service.rs @@ -0,0 +1,229 @@ +//! Quote → expect payment → confirm → grant credit → consume. + +use std::sync::Arc; +use std::time::{SystemTime, UNIX_EPOCH}; + +use crate::config::FundingConfig; +use crate::credit::FundingStore; +use crate::error::FundingError; +use crate::lium_account::LiumAccountClient; +use crate::oracle::{tao_from_usd, TaoPriceOracle}; +use crate::payment::{funding_memo, TaoPaymentVerifier}; +use crate::policy::ChallengeFundingPolicy; +use crate::types::{CreditState, FundingCredit, FundingDeposit, FundingQuote, FundingStatus}; + +/// Shared funding service (challenge-agnostic). +pub struct FundingService { + cfg: FundingConfig, + policy: Arc, + store: Arc, + oracle: Arc, + payments: Arc, + lium: Arc, +} + +impl FundingService { + /// Construct. + #[must_use] + pub fn new( + cfg: FundingConfig, + policy: Arc, + store: Arc, + oracle: Arc, + payments: Arc, + lium: Arc, + ) -> Self { + Self { + cfg, + policy, + store, + oracle, + payments, + lium, + } + } + + /// Config accessor. + #[must_use] + pub const fn cfg(&self) -> &FundingConfig { + &self.cfg + } + + /// Bound challenge id from the active policy. + #[must_use] + pub fn challenge_id(&self) -> &str { + self.policy.challenge_id() + } + + /// Issue a quote after eligibility checks. + pub async fn quote(&self, hotkey: &str) -> Result { + let challenge_id = self.policy.challenge_id().to_owned(); + self.policy.ensure_eligible(hotkey).await?; + if self.policy.one_funding_per_hotkey() { + if let Some(c) = self.store.any_credit(&challenge_id, hotkey).await? { + if c.state != CreditState::Void { + return Err(FundingError::Ineligible( + "hotkey already funded for this challenge".into(), + )); + } + } + } + let econ = self.cfg.economics; + let usd = econ.usd_cost(); + let tao_usd = self.oracle.tao_usd_price().await?; + let tao_amount = tao_from_usd(usd, tao_usd)?; + let quote_id = uuid::Uuid::new_v4().to_string(); + let memo = funding_memo(&challenge_id, hotkey, "e_id); + let now = now_ms(); + let quote = FundingQuote { + quote_id: quote_id.clone(), + challenge_id: challenge_id.clone(), + hotkey: hotkey.to_owned(), + usd_cost: usd, + rate_usd_per_hour: econ.rate_usd_per_hour, + hours: econ.hours, + buffer: econ.buffer, + tao_usd_price: tao_usd, + tao_amount, + deposit_address: self.cfg.deposit_address.clone(), + memo: memo.clone(), + expires_at_ms: now.saturating_add(self.cfg.quote_ttl_secs.saturating_mul(1000)), + }; + self.store.put_quote(quote.clone()).await?; + let deposit = FundingDeposit { + quote_id, + challenge_id, + hotkey: hotkey.to_owned(), + expected_tao: tao_amount, + observed_tao: 0.0, + deposit_address: self.cfg.deposit_address.clone(), + memo, + tx_hash: None, + confirmed: false, + }; + self.store.put_deposit(deposit).await?; + Ok(quote) + } + + /// Refresh payment status; grant credit when confirmed. + pub async fn confirm_if_paid(&self, hotkey: &str) -> Result { + let challenge_id = self.policy.challenge_id().to_owned(); + let quote = self.store.latest_quote(&challenge_id, hotkey).await?; + let Some(quote) = quote else { + let credit = self.store.get_unspent(&challenge_id, hotkey).await?; + let rent_allowed = self.rent_allowed_inner(&challenge_id, hotkey).await?; + return Ok(FundingStatus { + challenge_id, + hotkey: hotkey.to_owned(), + quote: None, + deposit: None, + credit, + rent_allowed, + }); + }; + let Some(mut deposit) = self.store.get_deposit("e.quote_id).await? else { + return Err(FundingError::Payment("deposit missing for quote".into())); + }; + if !deposit.confirmed { + deposit = self.payments.check_payment(&deposit).await?; + self.store.put_deposit(deposit.clone()).await?; + if deposit.confirmed { + // Optional operator Lium balance probe (informational; not a hard gate). + let _ = self.lium.balance_usd().await; + if self + .store + .get_unspent(&challenge_id, hotkey) + .await? + .is_none() + { + let credit = FundingCredit { + credit_id: uuid::Uuid::new_v4().to_string(), + quote_id: quote.quote_id.clone(), + challenge_id: challenge_id.clone(), + hotkey: hotkey.to_owned(), + usd_cost: quote.usd_cost, + tao_paid: deposit.observed_tao, + state: CreditState::Unspent, + created_at_ms: now_ms(), + spent_at_ms: None, + }; + self.store.put_credit(credit).await?; + } + } + } + let credit = self.store.any_credit(&challenge_id, hotkey).await?; + Ok(FundingStatus { + rent_allowed: self.rent_allowed_inner(&challenge_id, hotkey).await?, + challenge_id, + hotkey: hotkey.to_owned(), + quote: Some(quote), + deposit: Some(deposit), + credit, + }) + } + + /// Status without forcing a payment check. + pub async fn status(&self, hotkey: &str) -> Result { + self.confirm_if_paid(hotkey).await + } + + /// Rent gate: no-op when `require_funding` is false. + pub async fn before_rent(&self, hotkey: &str) -> Result<(), FundingError> { + if !self.cfg.require_funding { + return Ok(()); + } + let challenge_id = self.policy.challenge_id(); + match self.store.get_unspent(challenge_id, hotkey).await? { + Some(_) => Ok(()), + None => Err(FundingError::Credit( + "unspent funding credit required before Lium rent".into(), + )), + } + } + + /// Consume credit after successful provision (no-op when gate off and no credit). + pub async fn consume_on_provision(&self, hotkey: &str) -> Result<(), FundingError> { + if !self.cfg.require_funding { + // Still consume if a credit exists (keeps ledger honest in tests with flag off). + if self + .store + .get_unspent(self.policy.challenge_id(), hotkey) + .await? + .is_none() + { + return Ok(()); + } + } + let _ = self + .store + .consume(self.policy.challenge_id(), hotkey, now_ms()) + .await?; + Ok(()) + } + + /// Admin list. + pub async fn list_credits(&self) -> Result, FundingError> { + self.store.list_credits().await + } + + async fn rent_allowed_inner( + &self, + challenge_id: &str, + hotkey: &str, + ) -> Result { + if !self.cfg.require_funding { + return Ok(true); + } + Ok(self + .store + .get_unspent(challenge_id, hotkey) + .await? + .is_some()) + } +} + +fn now_ms() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map_or(0, |d| d.as_millis() as u64) +} diff --git a/crates/lium-funding/src/types.rs b/crates/lium-funding/src/types.rs new file mode 100644 index 000000000..fc62bd9bc --- /dev/null +++ b/crates/lium-funding/src/types.rs @@ -0,0 +1,136 @@ +//! Shared funding types. + +use serde::{Deserialize, Serialize}; + +/// USD economics for a challenge GPU prepay quote. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct FundingEconomics { + /// GPU USD per hour. + pub rate_usd_per_hour: f64, + /// Billable hours (e.g. Prism train cap). + pub hours: f64, + /// Fractional buffer on top of USD cost (default 0.10). + pub buffer: f64, +} + +impl FundingEconomics { + /// Prism defaults: $0.67/h × 6h × 1.10. + #[must_use] + pub const fn prism_default() -> Self { + Self { + rate_usd_per_hour: 0.67, + hours: 6.0, + buffer: 0.10, + } + } + + /// `rate * hours * (1 + buffer)`. + #[must_use] + pub fn usd_cost(&self) -> f64 { + self.rate_usd_per_hour * self.hours * (1.0 + self.buffer) + } +} + +/// Lifecycle of a funding credit. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum CreditState { + /// Payment confirmed; unused. + Unspent, + /// Consumed on successful pod provision. + Spent, + /// Quote expired or superseded. + Void, +} + +/// Miner-facing quote + deposit instructions. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct FundingQuote { + /// Opaque quote id. + pub quote_id: String, + /// Challenge id (`prism`, …). + pub challenge_id: String, + /// Miner hotkey (ss58 or hex as provided). + pub hotkey: String, + /// USD cost after buffer. + pub usd_cost: f64, + /// Economics used. + pub rate_usd_per_hour: f64, + /// Hours used. + pub hours: f64, + /// Buffer used. + pub buffer: f64, + /// Oracle USD per 1 TAO at quote time. + pub tao_usd_price: f64, + /// TAO the miner must send. + pub tao_amount: f64, + /// Operator deposit address (SS58). + pub deposit_address: String, + /// On-chain memo / reference. + pub memo: String, + /// Unix ms when the quote expires. + pub expires_at_ms: u64, +} + +/// Expected / observed deposit. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct FundingDeposit { + /// Quote id this deposit satisfies. + pub quote_id: String, + /// Challenge id. + pub challenge_id: String, + /// Hotkey. + pub hotkey: String, + /// Expected TAO. + pub expected_tao: f64, + /// Observed TAO (0 until confirmed). + pub observed_tao: f64, + /// Deposit address. + pub deposit_address: String, + /// Memo. + pub memo: String, + /// Optional extrinsic / tx hash. + pub tx_hash: Option, + /// Confirmed. + pub confirmed: bool, +} + +/// Granted credit after payment confirmation. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct FundingCredit { + /// Credit id. + pub credit_id: String, + /// Quote that produced this credit. + pub quote_id: String, + /// Challenge. + pub challenge_id: String, + /// Hotkey. + pub hotkey: String, + /// USD covered. + pub usd_cost: f64, + /// TAO paid. + pub tao_paid: f64, + /// State. + pub state: CreditState, + /// Created unix ms. + pub created_at_ms: u64, + /// Spent unix ms (if any). + pub spent_at_ms: Option, +} + +/// Miner status projection. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct FundingStatus { + /// Challenge. + pub challenge_id: String, + /// Hotkey. + pub hotkey: String, + /// Latest open quote (if any). + pub quote: Option, + /// Latest deposit tracking. + pub deposit: Option, + /// Active unspent credit (if any). + pub credit: Option, + /// Whether the rent gate would pass when require-funding is on. + pub rent_allowed: bool, +} diff --git a/crates/prism-challenge/Cargo.toml b/crates/prism-challenge/Cargo.toml index b8de9fb86..18cf80646 100644 --- a/crates/prism-challenge/Cargo.toml +++ b/crates/prism-challenge/Cargo.toml @@ -21,6 +21,7 @@ db = { path = "../db" } hex = "0.4" prism-challenge-task = { path = "../prism-challenge-task" } prism-emit = { path = "../prism-emit" } +lium-funding = { path = "../lium-funding" } prism-lium = { path = "../prism-lium" } prism-pipeline = { path = "../prism-pipeline" } prism-recipe = { path = "../prism-recipe" } diff --git a/crates/prism-challenge/src/orchestrator.rs b/crates/prism-challenge/src/orchestrator.rs index f332d8564..c3d3ca7a2 100644 --- a/crates/prism-challenge/src/orchestrator.rs +++ b/crates/prism-challenge/src/orchestrator.rs @@ -24,6 +24,7 @@ use challenge_agentic::{ }; use challenge_common::{expected_set_at_chain, GatewayClient, PinnedBlockHash}; use crypto::KEY_LEN; +use lium_funding::FundingService; use prism_emit::EpochEmitter; use prism_lium::{EvalJobBackend, InstanceSpec}; use prism_pipeline::gating_key; @@ -98,6 +99,8 @@ pub struct Orchestrator { emitter: EpochEmitter, gating: Option>, topmodel: Option>, + /// Optional Lium prepay gate (`PRISM_REQUIRE_LIUM_FUNDING`; default off). + funding: Option>, } impl Orchestrator { @@ -125,6 +128,7 @@ impl Orchestrator { emitter, gating: None, topmodel: None, + funding: None, } } @@ -145,6 +149,13 @@ impl Orchestrator { self } + /// Attach shared Lium funding gate (feature-flagged inside the service). + #[must_use] + pub fn with_funding(mut self, funding: Arc) -> Self { + self.funding = Some(funding); + self + } + /// Config getter (API views). #[must_use] pub const fn cfg(&self) -> &OrchestratorConfig { @@ -587,6 +598,12 @@ impl Orchestrator { ) -> Result<(prism_lium::RemoteExecResult, prism_lium::EvalReceipt), String> { self.to_stage(id, Stage::Provisioning).await?; + if let Some(f) = &self.funding { + f.before_rent(&row.miner_hotkey) + .await + .map_err(|e| format!("funding: {e}"))?; + } + let spec = InstanceSpec { name: format!("prism-{}", &id[..12]), max_lifetime_hours: self.cfg.max_lifetime_hours, @@ -606,6 +623,11 @@ impl Orchestrator { .provision(&spec) .await .map_err(|e| format!("provision: {e}"))?; + if let Some(f) = &self.funding { + if let Err(e) = f.consume_on_provision(&row.miner_hotkey).await { + warn!(error = %e, "funding credit consume failed after provision"); + } + } let pod_id = inst.id.clone(); let _ = self .store diff --git a/deploy/secrets/README.md b/deploy/secrets/README.md index cceeeeed8..0decb805e 100644 --- a/deploy/secrets/README.md +++ b/deploy/secrets/README.md @@ -37,7 +37,7 @@ chmod 0400 deploy/secrets/design/annotator_tokens deploy/secrets/openrouter/api_ ## Other -- `lium/` — prism Lium API + SSH keys (see prism runbook) +- `lium/` — Lium API key + funding deposit wallet placeholders (see [`lium/README.md`](lium/README.md) and [`docs/LIUM_FUNDING.md`](../../docs/LIUM_FUNDING.md)); also prism SSH keys (prism runbook) - `wallets/` — btcli wallet trees for gateway owner / validator hotkeys - `github/token` — prism-challenge top-model publisher: fine-grained GitHub token with **contents:write** on `BaseIntelligence/prism` only. Read via diff --git a/deploy/secrets/lium/README.md b/deploy/secrets/lium/README.md new file mode 100644 index 000000000..fc29b74e4 --- /dev/null +++ b/deploy/secrets/lium/README.md @@ -0,0 +1,42 @@ +# Lium secrets (NEVER commit secret bytes) + +Operator Lium account + GPU funding deposit wallet for shared challenge prepay +([`docs/LIUM_FUNDING.md`](../../../docs/LIUM_FUNDING.md)). + +Host files MUST be mode **0400**, owner uid **65532** (`base`), same as other +`deploy/secrets/` material. Bind-mount into the master challenge containers only. + +## Placeholders (create empty files locally; fill via age) + +| Path | Used by | Notes | +|------|---------|-------| +| `lium/api_key` | `prism-challenge` / funding | Lium `X-API-Key` for operator account (`LIUM_API_KEY_FILE=/run/base/lium/api_key`). Same key as pod rent. | +| `lium/deposit_ss58` | funding quote | Operator **deposit coldkey SS58** miners pay TAO to (`LIUM_FUNDING_DEPOSIT_ADDRESS_FILE`). | +| `lium/deposit_coldkey` | operator only | Bittensor coldkey material for the deposit wallet — **never** mount into containers unless a future watcher needs it; prefer host-side watcher. | +| `lium/deposit_hotkey` | operator / `lium fund` | Hotkey used when sweeping TAO into Lium via CLI `lium fund` (operator runbook). | +| `lium/funding_admin_token` | funding admin routes | Bearer for `GET /v1/funding/admin/credits` (`LIUM_FUNDING_ADMIN_TOKEN_FILE`). | + +```bash +mkdir -p deploy/secrets/lium +touch deploy/secrets/lium/api_key \ + deploy/secrets/lium/deposit_ss58 \ + deploy/secrets/lium/deposit_coldkey \ + deploy/secrets/lium/deposit_hotkey \ + deploy/secrets/lium/funding_admin_token +chown -R 65532:65532 deploy/secrets/lium +chmod 0400 deploy/secrets/lium/* +``` + +## Env knobs (non-secret) + +| Env | Default | Meaning | +|-----|---------|---------| +| `PRISM_REQUIRE_LIUM_FUNDING` | `0` | When `1`, orchestrator refuses Lium rent without an unspent credit | +| `PRISM_FUNDING_RATE_USD_PER_HOUR` | `0.67` | Prism GPU USD/h | +| `PRISM_FUNDING_HOURS` | `6` | Prism billable hours | +| `LIUM_FUNDING_BUFFER` | `0.10` | +10% buffer | +| `LIUM_FUNDING_TAO_USD` | (oracle) | Fixed/oracle USD per TAO for quotes | +| `LIUM_FUNDING_QUOTE_TTL_SECS` | `900` | Quote lifetime | + +**Do not enable `PRISM_REQUIRE_LIUM_FUNDING` in prod** until the deposit wallet, +live oracle, and on-chain watcher are validated on staging. diff --git a/docs/AGENTS.md b/docs/AGENTS.md index 1d3aa8afe..cd3470555 100644 --- a/docs/AGENTS.md +++ b/docs/AGENTS.md @@ -23,6 +23,7 @@ When a spike or evidence report conflicts with a frozen spec or runbook, the nor | [`runbooks/measurement-repin-socket-proxy.md`](runbooks/measurement-repin-socket-proxy.md) | Socket-proxy measurement re-pin | | [`runbooks/design-enable-and-emission.md`](runbooks/design-enable-and-emission.md) | Design keygen + emission unlock | | [`runbooks/prism-enable-lium-and-emission.md`](runbooks/prism-enable-lium-and-emission.md) | Prism Lium + emission | +| [`LIUM_FUNDING.md`](LIUM_FUNDING.md) | Shared challenge GPU TAO prepay (scaffolding; Prism first) | Deploy topology and CI lanes: [`../deploy/README.md`](../deploy/README.md) and [`../deploy/AGENTS.md`](../deploy/AGENTS.md). Repo-wide agent contract: [`../AGENTS.md`](../AGENTS.md). diff --git a/docs/COMPLETENESS.md b/docs/COMPLETENESS.md index 965ca9e2f..82fc580f5 100644 --- a/docs/COMPLETENESS.md +++ b/docs/COMPLETENESS.md @@ -104,6 +104,7 @@ Agent/operator contracts: root [`AGENTS.md`](../AGENTS.md), [`deploy/AGENTS.md`] | design rating / elimination | done | Integer Elo (K=32), bottom 20% / 4-round cooldown, exact-E leaves. | | design API | done | Harness/quota/runs/viewer/annotate/ops on `:8093`. | | prism Lium backend | done | `PRISM_FORCE_SIM=false` in staging; the binary logs `eval_backend=lium`. API key is mounted from a file so it never appears in `docker inspect`. | +| lium-funding (shared GPU prepay) | scaffolding | Crate `lium-funding` + Prism `/v1/funding/*` + orchestrator hook; `PRISM_REQUIRE_LIUM_FUNDING` default off. Live oracle, on-chain deposit watcher, and prod wallet still TODO — see [`LIUM_FUNDING.md`](LIUM_FUNDING.md). | | prism orchestration | done | DB-backed claim/execute/review/similarity/score state machine (`prism_submission` + append-only `prism_stage_event`), pre-pod screens (copy gate + static cheat + AST similarity) before Lium rent, sweeper (7h grace), boot recovery, epoch-close batched D24 leaf emission with **WTA** (`prism-emit` outbox: `emitted_epoch` watermark + `prism_emit_cursor` + positive-score carry + `apply_wta`, migration 0012). `PRISM_MAX_CONCURRENT_EVALS` default/prod = 8. | | prism recipe v1 | done | `prism-recipe` contract, fineweb-edu pinned shard (URL + SHA-256, harness re-verifies), 6h train / 7h pod caps, baseline sources, recipe pin hex on the API. | | prism LLM review | done | `prism-review` quality + similarity prompts (versioned), OpenRouter client (key file only, never env), deterministic sim fallback; anti-copy forces `Copied`/`Suspicious` → Score 0. | diff --git a/docs/LIUM_FUNDING.md b/docs/LIUM_FUNDING.md new file mode 100644 index 000000000..87165342e --- /dev/null +++ b/docs/LIUM_FUNDING.md @@ -0,0 +1,163 @@ +# Lium funding — shared challenge GPU prepay + +**Status:** scaffolding on branch `lium-funding` (not enabled in prod). +**challenge-agnostic crate:** `lium-funding` +**First consumer:** Prism (`challenge_id = prism`) + +Miners prepay **TAO** covering the operator’s Lium GPU rental cost (USD market +price converted to TAO, plus a buffer). Funds land in an **operator-controlled +Lium wallet / deposit address**; the challenge grants a one-shot +`FundingCredit` before any pod rent. + +This is **not** miner-side Lium accounts. The BASE master still rents pods with +`LIUM_API_KEY` as today; funding is a **reimbursement + eligibility ledger** +shared by challenges that opt in. + +## Flow + +```mermaid +sequenceDiagram + participant M as Miner hotkey + participant C as Challenge API + participant F as lium-funding + participant Chain as Bittensor + participant L as Lium account + + M->>C: GET/POST /v1/funding/quote + C->>F: policy.eligible + quote(USD→TAO) + F-->>M: deposit_address, tao_amount, memo, quote_id + M->>Chain: transfer TAO (+ memo) + M->>C: GET /v1/funding/status (or poll) + F->>Chain: verify payment (testnet: fake watcher) + F->>L: optional credit/balance check (operator account) + F-->>M: FundingCredit (unspent) + Note over C: later submission + orchestrator + C->>F: require_unspent_credit (if require-funding) + C->>L: rent pod + C->>F: consume credit on successful provision +``` + +1. **Quote** — miner supplies `challenge_id` + hotkey. Policy checks eligibility. +2. **Pay** — miner sends the quoted TAO to the operator deposit address, with a + memo keyed by `(challenge_id, hotkey, quote_id)`. +3. **Confirm** — payment verifier watches the deposit address (or accepts a + testnet fake). Optionally reconcile operator Lium USD balance via + `GET /users/me` (`X-API-Key`). +4. **Credit** — grant `FundingCredit` (one unspent credit per hotkey when the + policy sets `one_funding_per_hotkey`). +5. **Rent gate** — before Lium `provision`, challenge calls + `require_unspent_credit`. On successful provision, **consume** the credit + (consume-once). + +## Pricing + +```text +usd_cost = rate_usd_per_hour * hours * (1 + buffer) +tao_amount = usd_cost / tao_usd_price +``` + +| Knob | Env (shared / Prism) | Default | +|------|----------------------|---------| +| `rate_usd_per_hour` | `LIUM_FUNDING_RATE_USD_PER_HOUR` / `PRISM_FUNDING_RATE_USD_PER_HOUR` | `0.67` (Prism) | +| `hours` | `LIUM_FUNDING_HOURS` / `PRISM_FUNDING_HOURS` | `6` (Prism train cap) | +| `buffer` | `LIUM_FUNDING_BUFFER` | `0.10` (+10%) | +| Require gate | `PRISM_REQUIRE_LIUM_FUNDING` | `0` (off) | + +**Prism example:** \(0.67 \times 6 \times 1.10 = 4.422\) USD → convert at live TAO/USD. + +### Price oracle (assumptions) + +Scaffolding uses a pluggable `TaoPriceOracle`: + +| Backend | When | Notes | +|---------|------|--------| +| `FixedTaoOracle` | tests / local | Constant USD per TAO | +| `EnvTaoOracle` | staging | `LIUM_FUNDING_TAO_USD` | +| Live (TODO) | prod | Prefer a Bittensor-friendly source (e.g. subnet/TAO spot from a documented + public API or on-chain derived price). **Do not** silently fall back to stale + cache without an explicit max-age; fail closed on quote if oracle is stale. + +Assumptions for go-live (document when wiring the live oracle): + +- Quote currency is **TAO** on the Finney/testnet the deposit wallet uses. +- Oracle returns **USD per 1 TAO**; division yields TAO to send. +- Quotes have a short TTL (`LIUM_FUNDING_QUOTE_TTL_SECS`, default 900). + +## Per-challenge policy + +```rust +trait ChallengeFundingPolicy { + fn challenge_id(&self) -> &str; + fn economics(&self) -> FundingEconomics; // rate, hours, buffer + async fn ensure_eligible(&self, hotkey: &str) -> Result<(), FundingError>; + fn one_funding_per_hotkey(&self) -> bool; +} +``` + +| Challenge | Eligibility (pluggable) | Economics | +|-----------|-------------------------|-----------| +| **Prism** | Hotkey **in metagraph** AND **zero prior Prism submissions** (store count / gating `open` never registered) | `$0.67/h × 6h × 1.10` | +| Design / others | Opt-in later with their own checker | Challenge-specific | + +## Lium integration + +Operator account uses the same REST surface as `prism-lium` +(`https://lium.io/api`, `X-API-Key`): + +| Concern | Lium surface | Our wrapper | +|---------|--------------|-------------| +| Account balance (USD) | `GET /users/me` → `balance` | `LiumAccountClient::balance_usd` | +| Stablecoin top-up (agents) | CLI `lium topup` / `POST /nowpayments/create-invoice` | Optional; **miner path is TAO→deposit**, not USDT | +| TAO fund from btcli wallet | CLI `lium fund -w … -a …` | Operator runbook (not miner-facing) | +| Pod rent | existing `prism-lium` | Unchanged; gated by credit | + +Docs: [CLI quickstart](https://docs.lium.io/developers/cli/quickstart), +[`lium fund`](https://docs.lium.io/developers/cli/reference/fund.md), +[`lium topup`](https://docs.lium.io/developers/cli/reference/topup.md), +[AI agents](https://docs.lium.io/developers/agents.md), +OpenAPI `https://lium.io/api/openapi.json`. + +## HTTP surface (Prism hosts for now) + +| Route | Who | Purpose | +|-------|-----|---------| +| `POST /v1/funding/quote` | miner | Body `{challenge_id, hotkey}` → quote + deposit | +| `GET /v1/funding/quote` | miner | Query `challenge_id` + `hotkey` (same) | +| `GET /v1/funding/status` | miner | Query `challenge_id` + `hotkey` → credit/deposit state | +| `GET /v1/funding/admin/credits` | admin bearer | List credits (scaffold) | + +Admin bearer: file/env `LIUM_FUNDING_ADMIN_TOKEN` (never bake into images). + +## Security + +- **Never** bake Lium API keys, deposit coldkeys, or hotkeys into images or + compose. Use age + files under `deploy/secrets/lium/` (see README there). +- Digest-only deploy pins when this ships to staging/prod (same as rest of BASE). +- Deposit watching and oracle calls are master-only (same host as challenges). +- Redact API keys in logs/`Debug` (mirror `prism-lium`). +- **Do not** set `PRISM_REQUIRE_LIUM_FUNDING=1` until the deposit wallet, oracle, + and watcher are live — default **off** so prod is not bricked. + +## Prism wiring + +- Policy defaults: registered metagraph member + no prior Prism submission; + economics `0.67 × 6 × 1.10`. +- Orchestrator: after pre-pod screens, **before** `EvalJobBackend::provision`, + call `FundingGate::before_rent`. On success, `consume` after provision Ok. +- Feature flag: `PRISM_REQUIRE_LIUM_FUNDING=0` (default). When off, gate is a + no-op (existing tests / prod unchanged). + +## Enable later (go-live checklist) + +1. Create operator Lium account + API key; store under `deploy/secrets/lium/`. +2. Create Bittensor deposit coldkey/hotkey; document SS58; age-encrypt. +3. Wire live `TaoPriceOracle` + on-chain deposit watcher (memo parse). +4. Operator funds Lium (`lium fund` / topup) so rents do not fail on balance. +5. Staging: flag on, quote→pay(fake/testnet)→credit→submit→rent→consume. +6. Only then set `PRISM_REQUIRE_LIUM_FUNDING=1` in prod compose env. + +## Related + +- [`PRISM.md`](PRISM.md) — Prism challenge +- [`runbooks/prism-enable-lium-and-emission.md`](runbooks/prism-enable-lium-and-emission.md) +- Crate: `crates/lium-funding/` diff --git a/docs/PRISM.md b/docs/PRISM.md index 6497a9a30..c36fe99cd 100644 --- a/docs/PRISM.md +++ b/docs/PRISM.md @@ -5,7 +5,8 @@ **recipe_version:** `1.2.0` (telemetry hooks 1.1.0 + architecture registry / training-only submissions 1.2.0) **port:** `8092` **emission_share_bps:** `5000` (equal split with `design`; sum `10000`) -**GPU path:** master-centralized **Lium** (no Phala CVM) +**GPU path:** master-centralized **Lium** (no Phala CVM) +**GPU prepay (scaffolding):** shared [`LIUM_FUNDING.md`](LIUM_FUNDING.md) — quote/status under `/v1/funding/*`; rent gate `PRISM_REQUIRE_LIUM_FUNDING` default **off** ## What it is @@ -221,6 +222,7 @@ audit-only for the bpb score (coherence gate, never a grader). |-------|------| | `prism-challenge-task` | Identity constants / domains | | `prism-lium` | Lium REST client, real recipe exec over SSH, `SimLiumBackend`, `EvalReceipt` | +| `lium-funding` | Shared TAO prepay quote/credit ledger; Prism policy + orchestrator rent gate (flagged) | | `prism-recipe` | Contract validation, dataset pin, harness, baseline sources | | `prism-pipeline` | Intake contract (validation, `arch_id` rules, gating keys) + eval pipeline | | `prism-review` | OpenRouter LLM (quality + arch-only similarity) + deterministic sim fallback | diff --git a/docs/external-miner/prism.md b/docs/external-miner/prism.md index 8e72c4866..782f5defa 100644 --- a/docs/external-miner/prism.md +++ b/docs/external-miner/prism.md @@ -35,6 +35,15 @@ registry. Published archs and their best bpb: `GET /v1/architectures`. Evaluation runs on operator-rented Lium GPU pods (or `SimLiumBackend` in CI). You do **not** deploy a miner CVM. +## GPU funding (optional / rolling out) + +When the operator enables TAO prepay, request a quote before your first +submission (`POST`/`GET /v1/funding/quote?challenge_id=prism&hotkey=…`), send +the quoted TAO to the deposit address (memo included), then poll +`GET /v1/funding/status`. Eligibility: registered hotkey with **no prior** +Prism submission. Normative design: [`../LIUM_FUNDING.md`](../LIUM_FUNDING.md). +Until `PRISM_REQUIRE_LIUM_FUNDING=1`, funding is informational scaffolding only. + ## Submit ```bash diff --git a/docs/runbooks/prism-enable-lium-and-emission.md b/docs/runbooks/prism-enable-lium-and-emission.md index dff4c6c9f..bdd0ee2f2 100644 --- a/docs/runbooks/prism-enable-lium-and-emission.md +++ b/docs/runbooks/prism-enable-lium-and-emission.md @@ -8,6 +8,9 @@ 4. Run inventory probe → single rent smoke → terminate → `verify_terminated`. 5. Prod default is `PRISM_MAX_CONCURRENT_EVALS=8` (orchestrator worker count / semaphore). Dial down only if the Lium lease pool cannot absorb the load. +6. **Miner TAO prepay** (optional): see [`../LIUM_FUNDING.md`](../LIUM_FUNDING.md). + Keep `PRISM_REQUIRE_LIUM_FUNDING=0` until deposit wallet + oracle + watcher + are validated; secrets placeholders under `deploy/secrets/lium/`. ## Emission ceremony (shared with design)