feat(mcp,chain,dao): support Koios paid-tier bearer via ALDABRA_KOIOS_BEARER env
Adds optional Authorization: Bearer <token> on every Koios request, sourced from ALDABRA_KOIOS_BEARER env var only — never from the on-disk config.toml, never from CLI args, never hardcoded. Bearers are credentials and the on-disk config dir gets routinely backed up; keeping them env-only guarantees rotations don't leak into snapshots. Wired through three Koios clients: - aldabra-chain::KoiosClient — new with_timeout_and_bearer ctor; legacy new() / with_timeout() route through it with bearer=None. - aldabra-dao::KoiosDaoReader — new with_bearer ctor; ditto. - aldabra-dao::KoiosDiscoveryClient — new with_bearer ctor; ditto. Bearer is set as a default header on the reqwest client builder so every request inherits it without per-call boilerplate. HeaderValue::set_sensitive(true) prevents the value from showing in reqwest's debug-format output. Config wiring (aldabra-mcp::config::Config): - New koios_bearer: Option<String> field. Loaded ONLY from ALDABRA_KOIOS_BEARER env var; absent or empty-string means None. - Startup tracing logs koios_bearer_set: bool — never the value. WalletInner caches the bearer alongside the koios_base so the on-demand KoiosDiscoveryClient (constructed inside dao_discover_scripts) inherits paid-tier auth too. Motivation: 2026-05-08 preprod_test2 bringup tripped Koios free-tier daily quota (5240 req/day, 'Exceeded Tier Limit') mid-deploy. Cobb provided a paid-tier JWT (Aldabra project, exp 2026-06-26). Wiring via env var lets the operator (systemd EnvironmentFile, docker run -e, or k8s Secret) inject it without touching code or config files.
This commit is contained in:
parent
fbc4955c1d
commit
bf860dc99b
6 changed files with 106 additions and 17 deletions
|
|
@ -108,17 +108,43 @@ pub struct KoiosClient {
|
||||||
}
|
}
|
||||||
|
|
||||||
impl KoiosClient {
|
impl KoiosClient {
|
||||||
/// Construct a client with the default 10-second timeout.
|
/// Construct a client with the default 10-second timeout and no
|
||||||
|
/// bearer (public-tier; subject to free-tier daily quotas).
|
||||||
pub fn new(base_url: impl Into<String>) -> Self {
|
pub fn new(base_url: impl Into<String>) -> Self {
|
||||||
Self::with_timeout(base_url, DEFAULT_TIMEOUT)
|
Self::with_timeout_and_bearer(base_url, DEFAULT_TIMEOUT, None)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Construct a client with a custom request timeout.
|
/// Construct a client with a custom request timeout, no bearer.
|
||||||
pub fn with_timeout(base_url: impl Into<String>, timeout: Duration) -> Self {
|
pub fn with_timeout(base_url: impl Into<String>, timeout: Duration) -> Self {
|
||||||
|
Self::with_timeout_and_bearer(base_url, timeout, None)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Construct a client with optional `Authorization: Bearer <token>`
|
||||||
|
/// applied to every request. Used for paid-tier Koios access — the
|
||||||
|
/// JWT comes from the operator-supplied `ALDABRA_KOIOS_BEARER` env
|
||||||
|
/// var (NEVER from the on-disk config, NEVER hardcoded). Pass
|
||||||
|
/// `None` for the free public tier.
|
||||||
|
pub fn with_timeout_and_bearer(
|
||||||
|
base_url: impl Into<String>,
|
||||||
|
timeout: Duration,
|
||||||
|
bearer: Option<&str>,
|
||||||
|
) -> Self {
|
||||||
|
let mut builder = Client::builder().timeout(timeout);
|
||||||
|
if let Some(token) = bearer {
|
||||||
|
// Default header is applied to every request the client
|
||||||
|
// emits — request-level overrides still possible but no
|
||||||
|
// builder code path needs to remember to set it.
|
||||||
|
let mut hdrs = reqwest::header::HeaderMap::new();
|
||||||
|
let value = format!("Bearer {token}");
|
||||||
|
let mut hv = reqwest::header::HeaderValue::from_str(&value)
|
||||||
|
.expect("ALDABRA_KOIOS_BEARER contains invalid header bytes");
|
||||||
|
hv.set_sensitive(true);
|
||||||
|
hdrs.insert(reqwest::header::AUTHORIZATION, hv);
|
||||||
|
builder = builder.default_headers(hdrs);
|
||||||
|
}
|
||||||
Self {
|
Self {
|
||||||
base_url: base_url.into(),
|
base_url: base_url.into(),
|
||||||
http: Client::builder()
|
http: builder
|
||||||
.timeout(timeout)
|
|
||||||
.build()
|
.build()
|
||||||
.expect("reqwest client builds with rustls + json features"),
|
.expect("reqwest client builds with rustls + json features"),
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -63,12 +63,27 @@ pub struct KoiosDiscoveryClient {
|
||||||
|
|
||||||
impl KoiosDiscoveryClient {
|
impl KoiosDiscoveryClient {
|
||||||
pub fn new(base_url: impl Into<String>) -> Self {
|
pub fn new(base_url: impl Into<String>) -> Self {
|
||||||
|
Self::with_bearer(base_url, None)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Same as [`Self::new`] but with an optional `Authorization: Bearer
|
||||||
|
/// <token>` default header for paid-tier Koios access. Bearer comes
|
||||||
|
/// from `ALDABRA_KOIOS_BEARER` env var only — never from disk.
|
||||||
|
pub fn with_bearer(base_url: impl Into<String>, bearer: Option<&str>) -> Self {
|
||||||
|
let mut builder =
|
||||||
|
reqwest::Client::builder().timeout(std::time::Duration::from_secs(30));
|
||||||
|
if let Some(token) = bearer {
|
||||||
|
let mut hdrs = reqwest::header::HeaderMap::new();
|
||||||
|
let value = format!("Bearer {token}");
|
||||||
|
let mut hv = reqwest::header::HeaderValue::from_str(&value)
|
||||||
|
.expect("ALDABRA_KOIOS_BEARER contains invalid header bytes");
|
||||||
|
hv.set_sensitive(true);
|
||||||
|
hdrs.insert(reqwest::header::AUTHORIZATION, hv);
|
||||||
|
builder = builder.default_headers(hdrs);
|
||||||
|
}
|
||||||
Self {
|
Self {
|
||||||
base_url: base_url.into(),
|
base_url: base_url.into(),
|
||||||
http: reqwest::Client::builder()
|
http: builder.build().expect("reqwest client"),
|
||||||
.timeout(std::time::Duration::from_secs(30))
|
|
||||||
.build()
|
|
||||||
.expect("reqwest client"),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -93,12 +93,27 @@ impl KoiosDaoReader {
|
||||||
/// Construct against a Koios base URL (e.g. `https://api.koios.rest/api/v1`
|
/// Construct against a Koios base URL (e.g. `https://api.koios.rest/api/v1`
|
||||||
/// or `https://preprod.koios.rest/api/v1`).
|
/// or `https://preprod.koios.rest/api/v1`).
|
||||||
pub fn new(base_url: impl Into<String>) -> Self {
|
pub fn new(base_url: impl Into<String>) -> Self {
|
||||||
|
Self::with_bearer(base_url, None)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Same as [`Self::new`] but with an optional `Authorization: Bearer
|
||||||
|
/// <token>` default header for paid-tier Koios access. Bearer is
|
||||||
|
/// supplied by the caller from `ALDABRA_KOIOS_BEARER` env var only.
|
||||||
|
pub fn with_bearer(base_url: impl Into<String>, bearer: Option<&str>) -> Self {
|
||||||
|
let mut builder =
|
||||||
|
reqwest::Client::builder().timeout(std::time::Duration::from_secs(30));
|
||||||
|
if let Some(token) = bearer {
|
||||||
|
let mut hdrs = reqwest::header::HeaderMap::new();
|
||||||
|
let value = format!("Bearer {token}");
|
||||||
|
let mut hv = reqwest::header::HeaderValue::from_str(&value)
|
||||||
|
.expect("ALDABRA_KOIOS_BEARER contains invalid header bytes");
|
||||||
|
hv.set_sensitive(true);
|
||||||
|
hdrs.insert(reqwest::header::AUTHORIZATION, hv);
|
||||||
|
builder = builder.default_headers(hdrs);
|
||||||
|
}
|
||||||
Self {
|
Self {
|
||||||
base_url: base_url.into(),
|
base_url: base_url.into(),
|
||||||
http: reqwest::Client::builder()
|
http: builder.build().expect("reqwest client"),
|
||||||
.timeout(std::time::Duration::from_secs(30))
|
|
||||||
.build()
|
|
||||||
.expect("reqwest client"),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,13 @@ use thiserror::Error;
|
||||||
pub struct Config {
|
pub struct Config {
|
||||||
pub network: Network,
|
pub network: Network,
|
||||||
pub koios_base: String,
|
pub koios_base: String,
|
||||||
|
/// Optional Koios bearer token (paid-tier JWT). Sourced from the
|
||||||
|
/// `ALDABRA_KOIOS_BEARER` env var only — never from the TOML file
|
||||||
|
/// or CLI args. Bearers are credentials and must not get persisted
|
||||||
|
/// alongside non-secret config. When set, every Koios request gets
|
||||||
|
/// `Authorization: Bearer <token>` and bypasses the public-tier
|
||||||
|
/// daily quota.
|
||||||
|
pub koios_bearer: Option<String>,
|
||||||
pub account: u32,
|
pub account: u32,
|
||||||
pub index: u32,
|
pub index: u32,
|
||||||
pub data_dir: PathBuf,
|
pub data_dir: PathBuf,
|
||||||
|
|
@ -138,6 +145,13 @@ impl Config {
|
||||||
.or(file_cfg.koios_base)
|
.or(file_cfg.koios_base)
|
||||||
.unwrap_or_else(|| default_koios_for(network).to_string());
|
.unwrap_or_else(|| default_koios_for(network).to_string());
|
||||||
|
|
||||||
|
// Koios bearer is env-only — never sourced from disk. Empty
|
||||||
|
// string is treated as "no bearer" so that an unset systemd
|
||||||
|
// EnvironmentFile entry doesn't accidentally send `Bearer ""`.
|
||||||
|
let koios_bearer = std::env::var("ALDABRA_KOIOS_BEARER")
|
||||||
|
.ok()
|
||||||
|
.filter(|s| !s.trim().is_empty());
|
||||||
|
|
||||||
let account = match std::env::var("ALDABRA_ACCOUNT") {
|
let account = match std::env::var("ALDABRA_ACCOUNT") {
|
||||||
Ok(s) => s
|
Ok(s) => s
|
||||||
.parse::<u32>()
|
.parse::<u32>()
|
||||||
|
|
@ -165,6 +179,7 @@ impl Config {
|
||||||
Ok(Self {
|
Ok(Self {
|
||||||
network,
|
network,
|
||||||
koios_base,
|
koios_base,
|
||||||
|
koios_bearer,
|
||||||
account,
|
account,
|
||||||
index,
|
index,
|
||||||
data_dir,
|
data_dir,
|
||||||
|
|
|
||||||
|
|
@ -52,6 +52,7 @@ async fn run() -> Result<()> {
|
||||||
tracing::info!(
|
tracing::info!(
|
||||||
network = ?cfg.network,
|
network = ?cfg.network,
|
||||||
koios = %cfg.koios_base,
|
koios = %cfg.koios_base,
|
||||||
|
koios_bearer_set = cfg.koios_bearer.is_some(),
|
||||||
account = cfg.account,
|
account = cfg.account,
|
||||||
index = cfg.index,
|
index = cfg.index,
|
||||||
data_dir = %cfg.data_dir.display(),
|
data_dir = %cfg.data_dir.display(),
|
||||||
|
|
@ -131,6 +132,7 @@ async fn run() -> Result<()> {
|
||||||
cfg.network,
|
cfg.network,
|
||||||
address,
|
address,
|
||||||
cfg.koios_base,
|
cfg.koios_base,
|
||||||
|
cfg.koios_bearer,
|
||||||
payment_key,
|
payment_key,
|
||||||
stake_key,
|
stake_key,
|
||||||
cfg.max_send_lovelace,
|
cfg.max_send_lovelace,
|
||||||
|
|
|
||||||
|
|
@ -165,6 +165,12 @@ struct WalletInner {
|
||||||
/// Cached Koios base url so `dao_discover_scripts` can spin up a
|
/// Cached Koios base url so `dao_discover_scripts` can spin up a
|
||||||
/// `KoiosDiscoveryClient` on demand without a re-construction call.
|
/// `KoiosDiscoveryClient` on demand without a re-construction call.
|
||||||
koios_base: String,
|
koios_base: String,
|
||||||
|
/// Cached Koios bearer token so on-demand `KoiosDiscoveryClient`
|
||||||
|
/// (and any future per-call client) inherits the same paid-tier
|
||||||
|
/// auth instead of falling back to free-tier and tripping daily
|
||||||
|
/// quotas. None = public tier. Sourced from env only — never from
|
||||||
|
/// disk; never logged.
|
||||||
|
koios_bearer: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl WalletService {
|
impl WalletService {
|
||||||
|
|
@ -172,22 +178,29 @@ impl WalletService {
|
||||||
network: Network,
|
network: Network,
|
||||||
address: String,
|
address: String,
|
||||||
koios_base: String,
|
koios_base: String,
|
||||||
|
koios_bearer: Option<String>,
|
||||||
payment_key: PaymentKey,
|
payment_key: PaymentKey,
|
||||||
stake_key: StakeKey,
|
stake_key: StakeKey,
|
||||||
max_send_lovelace: u64,
|
max_send_lovelace: u64,
|
||||||
data_dir: PathBuf,
|
data_dir: PathBuf,
|
||||||
) -> Self {
|
) -> Self {
|
||||||
|
let bearer_ref = koios_bearer.as_deref();
|
||||||
Self {
|
Self {
|
||||||
inner: Arc::new(WalletInner {
|
inner: Arc::new(WalletInner {
|
||||||
network,
|
network,
|
||||||
address,
|
address,
|
||||||
chain: KoiosClient::new(koios_base.clone()),
|
chain: KoiosClient::with_timeout_and_bearer(
|
||||||
|
koios_base.clone(),
|
||||||
|
std::time::Duration::from_secs(10),
|
||||||
|
bearer_ref,
|
||||||
|
),
|
||||||
payment_key,
|
payment_key,
|
||||||
stake_key,
|
stake_key,
|
||||||
max_send_lovelace,
|
max_send_lovelace,
|
||||||
dao_store: DaoStore::new(&data_dir),
|
dao_store: DaoStore::new(&data_dir),
|
||||||
dao_reader: KoiosDaoReader::new(koios_base.clone()),
|
dao_reader: KoiosDaoReader::with_bearer(koios_base.clone(), bearer_ref),
|
||||||
koios_base,
|
koios_base,
|
||||||
|
koios_bearer,
|
||||||
}),
|
}),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -2145,8 +2158,11 @@ impl WalletService {
|
||||||
let mut deployers: Vec<&str> = vec![MAINNET_AGORA_SHARED_DEPLOYER];
|
let mut deployers: Vec<&str> = vec![MAINNET_AGORA_SHARED_DEPLOYER];
|
||||||
deployers.extend(extra.iter().map(|s| s.as_str()));
|
deployers.extend(extra.iter().map(|s| s.as_str()));
|
||||||
|
|
||||||
// Use the same Koios base URL as the wallet's chain backend.
|
// Use the same Koios base URL + bearer as the wallet's chain backend.
|
||||||
let client = KoiosDiscoveryClient::new(self.inner.koios_base.clone());
|
let client = KoiosDiscoveryClient::with_bearer(
|
||||||
|
self.inner.koios_base.clone(),
|
||||||
|
self.inner.koios_bearer.as_deref(),
|
||||||
|
);
|
||||||
let report = discover_scripts(&cfg, &client, &deployers)
|
let report = discover_scripts(&cfg, &client, &deployers)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| e.to_string())?;
|
.map_err(|e| e.to_string())?;
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue