use std::env;
use std::fmt;
use std::path::PathBuf;
use mpp::PrivateKeySigner;
use polyc_crypto::sensitive::Sensitive;
use polyc_wallet_delegation::keystore::KeystoreError;
use crate::{MODERATO_CHAIN_ID, MODERATO_RPC_URL};
const ENV_SIGNER_KEY: &str = "TEMPO_SIGNER_KEY";
const ENV_FEE_PAYER_KEY: &str = "TEMPO_FEE_PAYER_KEY";
const ENV_WALLET_KEYS_PATH: &str = "TEMPO_WALLET_KEYS_PATH";
const ENV_TEMPO_HOME: &str = "TEMPO_HOME";
const ENV_CURRENCY: &str = "TEMPO_CURRENCY";
const ENV_CURRENCY_DECIMALS: &str = "TEMPO_CURRENCY_DECIMALS";
const MAX_CURRENCY_DECIMALS: u32 = 38;
const ENV_ALLOW_RAW_SIGNER: &str = "POLYCHROME_ALLOW_RAW_SIGNER";
const ENV_SELF_SETTLE: &str = "TEMPO_SELF_SETTLE";
const ENV_RPC_URL: &str = "TEMPO_RPC_URL";
const ENV_CHAIN_ID: &str = "TEMPO_CHAIN_ID";
const ENV_RECIPIENT: &str = "TEMPO_RECIPIENT";
const ENV_REALM: &str = "TEMPO_REALM";
const ENV_HMAC_SECRET: &str = "TEMPO_HMAC_SECRET";
const ENV_EXPLORER_URL: &str = "TEMPO_EXPLORER_URL";
const ENV_MAX_SPEND: &str = "TEMPO_MAX_SPEND";
const ENV_PAID_HOST_ALLOWLIST: &str = "TEMPO_PAID_HOST_ALLOWLIST";
const ENV_PRESIGN_PER_TX_CAP: &str = "TEMPO_PRESIGN_PER_TX_CAP";
const ENV_PRESIGN_PER_SESSION_CAP: &str = "TEMPO_PRESIGN_PER_SESSION_CAP";
const ENV_MANDATE_ISSUER_PUBKEY: &str = "TEMPO_MANDATE_ISSUER_PUBKEY";
const ENV_CONVERSATION_BUDGET: &str = "TEMPO_CONVERSATION_BUDGET";
const DEFAULT_CONVERSATION_BUDGET: &str = "1.00";
pub const SIGNER_ENV_VARS: &[&str] = &[
ENV_SIGNER_KEY,
ENV_WALLET_KEYS_PATH,
ENV_TEMPO_HOME,
ENV_FEE_PAYER_KEY,
];
const DEFAULT_REALM: &str = "polychrome";
const DEFAULT_EXPLORER_URL: &str = crate::MODERATO_EXPLORER_URL;
pub struct PaymentsConfig {
signer: Option<PrivateKeySigner>,
fee_payer_key: Option<PrivateKeySigner>,
pub rpc_url: String,
pub chain_id: u64,
pub recipient: Option<String>,
pub realm: String,
hmac_secret: Option<Sensitive<String>>,
pub explorer_url: String,
pub max_spend: Option<String>,
pub keys_path: Option<PathBuf>,
pub currency: Option<String>,
pub currency_decimals: u32,
pub allow_raw_signer: bool,
pub self_settle: bool,
pub paid_host_allowlist: Option<Vec<String>>,
presign_per_tx_base_units: Option<u128>,
presign_per_session_base_units: Option<u128>,
mandate_issuer_public_key: Option<Vec<u8>>,
conversation_budget_base_units: u128,
}
impl PaymentsConfig {
pub fn from_env() -> Result<Self, PaymentsConfigError> {
Self::from_lookup(|key| env::var(key).ok())
}
pub fn from_lookup<F>(lookup: F) -> Result<Self, PaymentsConfigError>
where
F: Fn(&str) -> Option<String>,
{
let get = |key: &str| -> Option<String> {
lookup(key).and_then(|v| {
let t = v.trim();
if t.is_empty() {
None
} else {
Some(t.to_string())
}
})
};
let signer =
parse_optional_signer(get(ENV_SIGNER_KEY), PaymentsConfigError::InvalidSignerKey)?;
let fee_payer_key = parse_optional_signer(
get(ENV_FEE_PAYER_KEY),
PaymentsConfigError::InvalidFeePayerKey,
)?;
let rpc_url = get(ENV_RPC_URL).unwrap_or_else(|| MODERATO_RPC_URL.to_string());
let chain_id = match get(ENV_CHAIN_ID) {
Some(s) => s
.parse::<u64>()
.map_err(|e| PaymentsConfigError::InvalidChainId(e.to_string()))?,
None => MODERATO_CHAIN_ID,
};
let recipient = get(ENV_RECIPIENT);
let realm = get(ENV_REALM).unwrap_or_else(|| DEFAULT_REALM.to_string());
let hmac_secret = get(ENV_HMAC_SECRET).map(Sensitive::new);
let explorer_url =
get(ENV_EXPLORER_URL).unwrap_or_else(|| DEFAULT_EXPLORER_URL.to_string());
let keys_path = get(ENV_WALLET_KEYS_PATH).map(PathBuf::from).or_else(|| {
get(ENV_TEMPO_HOME).map(|home| PathBuf::from(home).join("wallet").join("keys.toml"))
});
let currency = get(ENV_CURRENCY);
let currency_decimals = match get(ENV_CURRENCY_DECIMALS) {
Some(s) => {
let d = s
.trim()
.parse::<u32>()
.map_err(|e| PaymentsConfigError::InvalidCurrencyDecimals(e.to_string()))?;
if d > MAX_CURRENCY_DECIMALS {
return Err(PaymentsConfigError::InvalidCurrencyDecimals(format!(
"{d} exceeds the maximum settlement-token decimals ({MAX_CURRENCY_DECIMALS})"
)));
}
d
}
None => crate::amount::DEFAULT_DECIMALS,
};
let max_spend = validate_max_spend(get(ENV_MAX_SPEND), currency_decimals)?;
let allow_raw_signer = get(ENV_ALLOW_RAW_SIGNER).is_some_and(|v| is_truthy(&v));
let self_settle = get(ENV_SELF_SETTLE).is_some_and(|v| is_truthy(&v));
let paid_host_allowlist = get(ENV_PAID_HOST_ALLOWLIST).map(|raw| {
raw.split(',')
.map(|h| h.trim().to_ascii_lowercase())
.filter(|h| !h.is_empty())
.collect::<Vec<String>>()
});
let parse_presign = |env: &str| -> Result<Option<u128>, PaymentsConfigError> {
get(env).map_or(Ok(None), |raw| {
crate::amount::dollars_to_base_units(&raw, currency_decimals)
.map(Some)
.ok_or_else(|| {
PaymentsConfigError::InvalidPresignCap(format!(
"{env} value {raw:?} is not a valid dollar amount"
))
})
})
};
let presign_per_tx_base_units = parse_presign(ENV_PRESIGN_PER_TX_CAP)?;
let presign_per_session_base_units = parse_presign(ENV_PRESIGN_PER_SESSION_CAP)?;
let conversation_budget_base_units =
parse_conversation_budget(get(ENV_CONVERSATION_BUDGET), currency_decimals)?;
let mandate_issuer_public_key = parse_mandate_issuer_key(get(ENV_MANDATE_ISSUER_PUBKEY))?;
Ok(Self {
signer,
fee_payer_key,
rpc_url,
chain_id,
recipient,
realm,
hmac_secret,
explorer_url,
max_spend,
keys_path,
currency,
currency_decimals,
allow_raw_signer,
self_settle,
paid_host_allowlist,
presign_per_tx_base_units,
presign_per_session_base_units,
mandate_issuer_public_key,
conversation_budget_base_units,
})
}
#[must_use]
pub const fn conversation_budget_base_units(&self) -> u128 {
self.conversation_budget_base_units
}
#[must_use]
pub fn default_max_spend_base_units(&self) -> u128 {
self.max_spend
.as_deref()
.and_then(|s| crate::amount::dollars_to_base_units(s, self.currency_decimals))
.unwrap_or_else(|| {
crate::amount::dollars_to_base_units(
crate::proxy::DEFAULT_CAP,
self.currency_decimals,
)
.expect("default cap parses")
})
}
#[must_use]
pub const fn presign_spend_cap(&self) -> polyc_spend_policy::presign::SpendCap {
polyc_spend_policy::presign::SpendCap::new(
self.presign_per_tx_base_units,
self.presign_per_session_base_units,
)
}
#[must_use]
pub fn mandate_issuer_public_key(&self) -> Option<&[u8]> {
self.mandate_issuer_public_key.as_deref()
}
#[must_use]
pub const fn signer(&self) -> Option<&PrivateKeySigner> {
self.signer.as_ref()
}
#[must_use]
pub const fn fee_payer_signer(&self) -> Option<&PrivateKeySigner> {
self.fee_payer_key.as_ref()
}
#[must_use]
pub const fn has_signer_source(&self) -> bool {
self.keys_path.is_some() || self.signer().is_some()
}
#[must_use]
pub fn hmac_secret(&self) -> Option<&str> {
self.hmac_secret.as_ref().map(|s| s.expose().as_str())
}
pub fn require_recipient(&self) -> Result<&str, PaymentsConfigError> {
self.recipient
.as_deref()
.ok_or(PaymentsConfigError::MissingRecipient)
}
pub fn require_hmac_secret(&self) -> Result<&str, PaymentsConfigError> {
self.hmac_secret
.as_ref()
.map(|s| s.expose().as_str())
.ok_or(PaymentsConfigError::MissingHmacSecret)
}
fn to_outbound_config(&self) -> polyc_payments_client::config::OutboundConfig {
polyc_payments_client::config::OutboundConfig::from_parts(
polyc_payments_client::config::OutboundConfigParts {
signer: self.signer.clone(),
rpc_url: self.rpc_url.clone(),
chain_id: self.chain_id,
explorer_url: self.explorer_url.clone(),
max_spend: self.max_spend.clone(),
keys_path: self.keys_path.clone(),
currency: self.currency.clone(),
currency_decimals: self.currency_decimals,
allow_raw_signer: self.allow_raw_signer,
},
)
}
pub async fn resolve_client(
&self,
source: polyc_payments_client::resolver::KeySource<'_>,
now_unix: u64,
) -> Result<polyc_payments_client::outbound::PaymentsClient, PaymentsConfigError> {
self.to_outbound_config()
.resolve_client(source, now_unix)
.await
.map_err(Into::into)
}
pub async fn resolve_outbound_client(
&self,
now_unix: u64,
) -> Result<polyc_payments_client::outbound::PaymentsClient, PaymentsConfigError> {
self.to_outbound_config()
.resolve_outbound_client(now_unix)
.await
.map_err(Into::into)
}
pub async fn resolve_keychain_status(
&self,
keys_toml: &str,
currency: &str,
now_unix: u64,
) -> Result<polyc_payments_client::outbound::KeychainRegistrationStatus, PaymentsConfigError>
{
self.to_outbound_config()
.resolve_keychain_status(keys_toml, currency, now_unix)
.await
.map_err(Into::into)
}
}
impl From<polyc_payments_client::config::OutboundConfigError> for PaymentsConfigError {
fn from(e: polyc_payments_client::config::OutboundConfigError) -> Self {
use polyc_payments_client::config::OutboundConfigError as E;
match e {
E::InvalidSignerKey(s) => Self::InvalidSignerKey(s),
E::InvalidChainId(s) => Self::InvalidChainId(s),
E::InvalidCurrencyDecimals(s) => Self::InvalidCurrencyDecimals(s),
E::MissingCurrency => Self::MissingCurrency,
E::InvalidKeysFile(s) => Self::InvalidKeysFile(s),
E::KeySelection(k) => Self::KeySelection(k),
E::RawSignerNotAllowed => Self::RawSignerNotAllowed,
E::MissingSigner => Self::MissingSigner,
E::Client(s) => Self::Client(s),
}
}
}
impl From<polyc_payments_server::config::InboundConfigError> for PaymentsConfigError {
fn from(e: polyc_payments_server::config::InboundConfigError) -> Self {
use polyc_payments_server::config::InboundConfigError as E;
match e {
E::InvalidChainId(s) => Self::InvalidChainId(s),
E::InvalidFeePayerKey(s) => Self::InvalidFeePayerKey(s),
E::MissingRecipient => Self::MissingRecipient,
E::MissingHmacSecret => Self::MissingHmacSecret,
E::MissingCurrency(chain_id) => Self::InboundMissingCurrency(chain_id),
}
}
}
impl fmt::Debug for PaymentsConfig {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("PaymentsConfig")
.field("signer", &self.signer.as_ref().map(|_| "<redacted>"))
.field(
"fee_payer_key",
&self.fee_payer_key.as_ref().map(|_| "<redacted>"),
)
.field("rpc_url", &self.rpc_url)
.field("chain_id", &self.chain_id)
.field("recipient", &self.recipient)
.field("realm", &self.realm)
.field("explorer_url", &self.explorer_url)
.field("max_spend", &self.max_spend)
.field("keys_path", &self.keys_path)
.field("currency", &self.currency)
.field("currency_decimals", &self.currency_decimals)
.field("allow_raw_signer", &self.allow_raw_signer)
.field("self_settle", &self.self_settle)
.field("paid_host_allowlist", &self.paid_host_allowlist)
.field("presign_per_tx_base_units", &self.presign_per_tx_base_units)
.field(
"presign_per_session_base_units",
&self.presign_per_session_base_units,
)
.field(
"mandate_issuer_public_key",
&self.mandate_issuer_public_key.as_ref().map(hex::encode),
)
.field(
"conversation_budget_base_units",
&self.conversation_budget_base_units,
)
.field(
"hmac_secret",
&self.hmac_secret.as_ref().map(|_| "<redacted>"),
)
.finish()
}
}
fn parse_optional_signer(
raw: Option<String>,
on_err: impl FnOnce(String) -> PaymentsConfigError,
) -> Result<Option<PrivateKeySigner>, PaymentsConfigError> {
match raw {
Some(r) => Ok(Some(r.parse().map_err(
|e: <PrivateKeySigner as std::str::FromStr>::Err| on_err(e.to_string()),
)?)),
None => Ok(None),
}
}
fn validate_max_spend(
raw: Option<String>,
decimals: u32,
) -> Result<Option<String>, PaymentsConfigError> {
if let Some(dollars) = &raw
&& crate::amount::dollars_to_base_units(dollars, decimals).is_none()
{
return Err(PaymentsConfigError::InvalidMaxSpend(format!(
"{ENV_MAX_SPEND} value {dollars:?} is not a valid dollar amount"
)));
}
Ok(raw)
}
fn parse_mandate_issuer_key(raw: Option<String>) -> Result<Option<Vec<u8>>, PaymentsConfigError> {
raw.map(|raw| {
hex::decode(&raw).map_err(|e| {
PaymentsConfigError::InvalidMandateIssuerKey(format!(
"{ENV_MANDATE_ISSUER_PUBKEY} is not valid hex: {e}"
))
})
})
.transpose()
}
fn parse_conversation_budget(
raw: Option<String>,
decimals: u32,
) -> Result<u128, PaymentsConfigError> {
let dollars = raw.unwrap_or_else(|| DEFAULT_CONVERSATION_BUDGET.to_owned());
crate::amount::dollars_to_base_units(&dollars, decimals).ok_or_else(|| {
PaymentsConfigError::InvalidConversationBudget(format!(
"{ENV_CONVERSATION_BUDGET} value {dollars:?} is not a valid dollar amount"
))
})
}
fn is_truthy(v: &str) -> bool {
matches!(
v.trim().to_ascii_lowercase().as_str(),
"1" | "true" | "yes" | "on"
)
}
#[derive(Debug, thiserror::Error)]
pub enum PaymentsConfigError {
#[error("TEMPO_SIGNER_KEY is not set")]
MissingSignerKey,
#[error("TEMPO_SIGNER_KEY is invalid: {0}")]
InvalidSignerKey(String),
#[error("TEMPO_FEE_PAYER_KEY is invalid: {0}")]
InvalidFeePayerKey(String),
#[error("TEMPO_CHAIN_ID is invalid: {0}")]
InvalidChainId(String),
#[error("pre-signing spend cap is invalid: {0}")]
InvalidPresignCap(String),
#[error("conversation budget is invalid: {0}")]
InvalidConversationBudget(String),
#[error("TEMPO_MAX_SPEND is invalid: {0}")]
InvalidMaxSpend(String),
#[error("mandate issuer key is invalid: {0}")]
InvalidMandateIssuerKey(String),
#[error("TEMPO_CURRENCY_DECIMALS is invalid: {0}")]
InvalidCurrencyDecimals(String),
#[error("TEMPO_RECIPIENT is not set")]
MissingRecipient,
#[error("TEMPO_HMAC_SECRET is not set")]
MissingHmacSecret,
#[error("TEMPO_CURRENCY is required for keychain signing but is not set")]
MissingCurrency,
#[error(
"no TEMPO_CURRENCY configured and chain id {0} has no known default settlement token; set TEMPO_CURRENCY explicitly"
)]
InboundMissingCurrency(u64),
#[error("keys.toml could not be read: {0}")]
InvalidKeysFile(String),
#[error("keys.toml selection failed: {0}")]
KeySelection(#[from] KeystoreError),
#[error(
"raw TEMPO_SIGNER_KEY signing is disabled; set POLYCHROME_ALLOW_RAW_SIGNER=1 to allow it (prefer a keychain keys.toml)"
)]
RawSignerNotAllowed,
#[error("no outbound signer configured (set TEMPO_WALLET_KEYS_PATH or TEMPO_SIGNER_KEY)")]
MissingSigner,
#[error("outbound client build failed: {0}")]
Client(String),
}
#[cfg(test)]
mod tests {
use super::*;
use std::collections::HashMap;
const TEST_KEY_0X: &str = "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80";
const TEST_KEY_BARE: &str = "ac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80";
const TEST_ADDR: &str = "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266";
fn map_lookup(pairs: &[(&str, &str)]) -> impl Fn(&str) -> Option<String> {
let map: HashMap<String, String> = pairs
.iter()
.map(|(k, v)| ((*k).to_string(), (*v).to_string()))
.collect();
move |key: &str| map.get(key).cloned()
}
fn signing_value_for(var: &str) -> &'static str {
match var {
ENV_SIGNER_KEY | ENV_FEE_PAYER_KEY => TEST_KEY_0X,
ENV_WALLET_KEYS_PATH => "/tmp/polychrome-test/keys.toml",
ENV_TEMPO_HOME => "/tmp/polychrome-test-home",
other => panic!("no test value for signer-bearing var {other}"),
}
}
fn config_holds_signing_material(cfg: &PaymentsConfig) -> bool {
cfg.signer().is_some() || cfg.fee_payer_signer().is_some() || cfg.keys_path.is_some()
}
#[test]
fn signer_env_vars_covers_every_signing_key_source() {
let config_signer_vars = [
ENV_SIGNER_KEY, ENV_FEE_PAYER_KEY, ENV_WALLET_KEYS_PATH, ENV_TEMPO_HOME, ];
for var in config_signer_vars {
assert!(
SIGNER_ENV_VARS.contains(&var),
"{var} carries signing material but is missing from SIGNER_ENV_VARS \
(the harness boot tripwire) — the untrusted sandbox could hold a signer"
);
let cfg = PaymentsConfig::from_lookup(map_lookup(&[(var, signing_value_for(var))]))
.expect("config builds with a signer-bearing var set");
assert!(
config_holds_signing_material(&cfg),
"{var} is listed as signer-bearing but the config shows no signing material"
);
}
for var in SIGNER_ENV_VARS {
assert!(
config_signer_vars.contains(var),
"SIGNER_ENV_VARS lists {var}, which is not a classified signing-key source"
);
}
}
#[test]
fn config_paid_host_allowlist_parses_fail_closed() {
let cfg = PaymentsConfig::from_lookup(map_lookup(&[])).expect("config builds");
assert!(cfg.paid_host_allowlist.is_none());
let cfg = PaymentsConfig::from_lookup(map_lookup(&[(
ENV_PAID_HOST_ALLOWLIST,
" API.Foo.com, data.bar.io ,,",
)]))
.expect("config builds");
assert_eq!(
cfg.paid_host_allowlist.as_deref(),
Some(&["api.foo.com".to_string(), "data.bar.io".to_string()][..])
);
let cfg = PaymentsConfig::from_lookup(map_lookup(&[(ENV_PAID_HOST_ALLOWLIST, " , ,")]))
.expect("config builds");
assert_eq!(cfg.paid_host_allowlist.as_deref(), Some(&[][..]));
}
#[test]
fn config_presign_caps_parse_to_base_units_and_fail_closed() {
let cfg = PaymentsConfig::from_lookup(map_lookup(&[])).expect("config builds");
assert!(cfg.presign_spend_cap().is_unlimited());
let cfg = PaymentsConfig::from_lookup(map_lookup(&[
(ENV_PRESIGN_PER_TX_CAP, "0.50"),
(ENV_PRESIGN_PER_SESSION_CAP, "5.00"),
]))
.expect("config builds");
let cap = cfg.presign_spend_cap();
assert_eq!(cap.per_tx(), Some(500_000));
assert_eq!(cap.per_session(), Some(5_000_000));
assert!(matches!(
PaymentsConfig::from_lookup(map_lookup(&[(ENV_PRESIGN_PER_TX_CAP, "not-a-number")])),
Err(PaymentsConfigError::InvalidPresignCap(_))
));
}
#[test]
fn config_conversation_budget_defaults_and_parses_to_base_units() {
let cfg = PaymentsConfig::from_lookup(map_lookup(&[])).expect("config builds");
assert_eq!(cfg.conversation_budget_base_units(), 1_000_000);
let cfg = PaymentsConfig::from_lookup(map_lookup(&[(ENV_CONVERSATION_BUDGET, "2.50")]))
.expect("config builds");
assert_eq!(cfg.conversation_budget_base_units(), 2_500_000);
assert!(matches!(
PaymentsConfig::from_lookup(map_lookup(&[(ENV_CONVERSATION_BUDGET, "not-a-number")])),
Err(PaymentsConfigError::InvalidConversationBudget(_))
));
}
#[test]
fn config_default_max_spend_falls_back_to_the_built_in_ceiling() {
let cfg = PaymentsConfig::from_lookup(map_lookup(&[])).expect("config builds");
assert_eq!(cfg.default_max_spend_base_units(), 100_000);
let cfg = PaymentsConfig::from_lookup(map_lookup(&[(ENV_MAX_SPEND, "0.25")]))
.expect("config builds");
assert_eq!(cfg.default_max_spend_base_units(), 250_000);
}
#[test]
fn config_load_fails_closed_on_an_unparseable_default_max_spend() {
assert!(matches!(
PaymentsConfig::from_lookup(map_lookup(&[(ENV_MAX_SPEND, "not-a-number")])),
Err(PaymentsConfigError::InvalidMaxSpend(_))
));
let overflowing = "999999999999999999999999999999999999999999999999999999";
assert!(matches!(
PaymentsConfig::from_lookup(map_lookup(&[(ENV_MAX_SPEND, overflowing)])),
Err(PaymentsConfigError::InvalidMaxSpend(_))
));
}
#[test]
fn the_conversation_budget_accessor_is_the_only_ceiling_reader() {
let cfg = PaymentsConfig::from_lookup(map_lookup(&[(ENV_CONVERSATION_BUDGET, "3.00")]))
.expect("config builds");
assert_eq!(cfg.conversation_budget_base_units(), 3_000_000);
}
#[test]
fn config_mandate_issuer_key_parses_and_fails_closed() {
let cfg = PaymentsConfig::from_lookup(map_lookup(&[])).expect("config builds");
assert!(cfg.mandate_issuer_public_key().is_none());
let cfg = PaymentsConfig::from_lookup(map_lookup(&[(
ENV_MANDATE_ISSUER_PUBKEY,
"deadbeef00112233",
)]))
.expect("config builds");
assert_eq!(
cfg.mandate_issuer_public_key(),
Some(&[0xde, 0xad, 0xbe, 0xef, 0x00, 0x11, 0x22, 0x33][..])
);
assert!(matches!(
PaymentsConfig::from_lookup(map_lookup(&[(ENV_MANDATE_ISSUER_PUBKEY, "not-hex")])),
Err(PaymentsConfigError::InvalidMandateIssuerKey(_))
));
}
#[test]
fn config_currency_decimals_bounded_to_mpp_ceiling() {
let cfg = PaymentsConfig::from_lookup(map_lookup(&[])).expect("config builds");
assert_eq!(cfg.currency_decimals, crate::amount::DEFAULT_DECIMALS);
let cfg = PaymentsConfig::from_lookup(map_lookup(&[(ENV_CURRENCY_DECIMALS, "38")]))
.expect("38 is the max and must parse");
assert_eq!(cfg.currency_decimals, MAX_CURRENCY_DECIMALS);
assert!(matches!(
PaymentsConfig::from_lookup(map_lookup(&[(ENV_CURRENCY_DECIMALS, "39")])),
Err(PaymentsConfigError::InvalidCurrencyDecimals(_))
));
assert!(matches!(
PaymentsConfig::from_lookup(map_lookup(&[(ENV_CURRENCY_DECIMALS, "abc")])),
Err(PaymentsConfigError::InvalidCurrencyDecimals(_))
));
}
#[test]
fn config_from_env_reads_signer_key() {
let cfg = PaymentsConfig::from_lookup(map_lookup(&[(ENV_SIGNER_KEY, TEST_KEY_0X)]))
.expect("0x key should parse");
assert_eq!(format!("{}", cfg.signer().unwrap().address()), TEST_ADDR);
let cfg = PaymentsConfig::from_lookup(map_lookup(&[(ENV_SIGNER_KEY, TEST_KEY_BARE)]))
.expect("bare key should parse");
assert_eq!(format!("{}", cfg.signer().unwrap().address()), TEST_ADDR);
let padded = format!(" {TEST_KEY_0X}\n");
let cfg = PaymentsConfig::from_lookup(map_lookup(&[(ENV_SIGNER_KEY, &padded)]))
.expect("whitespace-padded key should parse");
assert_eq!(format!("{}", cfg.signer().unwrap().address()), TEST_ADDR);
}
#[test]
fn config_signer_key_is_optional_but_validated() {
let cfg = PaymentsConfig::from_lookup(map_lookup(&[])).expect("unset signer is allowed");
assert!(cfg.signer().is_none());
let cfg = PaymentsConfig::from_lookup(map_lookup(&[(ENV_SIGNER_KEY, " ")]))
.expect("blank signer is allowed");
assert!(cfg.signer().is_none());
match PaymentsConfig::from_lookup(map_lookup(&[(ENV_SIGNER_KEY, "not-hex")])) {
Err(PaymentsConfigError::InvalidSignerKey(_)) => {}
other => panic!("expected InvalidSignerKey, got {other:?}"),
}
}
#[test]
fn config_defaults_to_moderato() {
let cfg = PaymentsConfig::from_lookup(map_lookup(&[(ENV_SIGNER_KEY, TEST_KEY_0X)]))
.expect("defaults");
assert_eq!(cfg.rpc_url, MODERATO_RPC_URL);
assert_eq!(cfg.chain_id, MODERATO_CHAIN_ID);
let cfg = PaymentsConfig::from_lookup(map_lookup(&[
(ENV_SIGNER_KEY, TEST_KEY_0X),
(ENV_RPC_URL, "https://rpc.example.test"),
(ENV_CHAIN_ID, "12345"),
]))
.expect("overrides");
assert_eq!(cfg.rpc_url, "https://rpc.example.test");
assert_eq!(cfg.chain_id, 12345);
}
#[test]
fn config_reads_optional_max_spend() {
let cfg = PaymentsConfig::from_lookup(map_lookup(&[(ENV_SIGNER_KEY, TEST_KEY_0X)]))
.expect("defaults");
assert!(cfg.max_spend.is_none());
let cfg = PaymentsConfig::from_lookup(map_lookup(&[
(ENV_SIGNER_KEY, TEST_KEY_0X),
(ENV_MAX_SPEND, "0.25"),
]))
.expect("max_spend");
assert_eq!(cfg.max_spend.as_deref(), Some("0.25"));
let cfg = PaymentsConfig::from_lookup(map_lookup(&[
(ENV_SIGNER_KEY, TEST_KEY_0X),
(ENV_MAX_SPEND, " "),
]))
.expect("blank max_spend");
assert!(
cfg.max_spend.is_none(),
"blank max_spend is treated as absent"
);
}
#[test]
fn config_reads_recipient_and_realm() {
let cfg = PaymentsConfig::from_lookup(map_lookup(&[(ENV_SIGNER_KEY, TEST_KEY_0X)]))
.expect("defaults");
assert_eq!(cfg.realm, "polychrome");
assert!(cfg.recipient.is_none());
assert!(cfg.hmac_secret().is_none());
assert!(matches!(
cfg.require_recipient(),
Err(PaymentsConfigError::MissingRecipient)
));
assert!(matches!(
cfg.require_hmac_secret(),
Err(PaymentsConfigError::MissingHmacSecret)
));
let cfg = PaymentsConfig::from_lookup(map_lookup(&[
(ENV_SIGNER_KEY, TEST_KEY_0X),
(ENV_RECIPIENT, "0xabc0000000000000000000000000000000000def"),
(ENV_REALM, "custom-realm"),
(ENV_HMAC_SECRET, "super-secret-hmac"),
]))
.expect("populated");
assert_eq!(
cfg.require_recipient().unwrap(),
"0xabc0000000000000000000000000000000000def"
);
assert_eq!(cfg.realm, "custom-realm");
assert_eq!(cfg.require_hmac_secret().unwrap(), "super-secret-hmac");
}
#[test]
fn config_explorer_url_defaults_to_moderato_testnet() {
let cfg = PaymentsConfig::from_lookup(map_lookup(&[(ENV_SIGNER_KEY, TEST_KEY_0X)]))
.expect("defaults");
assert_eq!(cfg.explorer_url, "https://explore.testnet.tempo.xyz");
}
#[test]
fn config_explorer_url_override_is_honored() {
let cfg = PaymentsConfig::from_lookup(map_lookup(&[
(ENV_SIGNER_KEY, TEST_KEY_0X),
(ENV_EXPLORER_URL, "https://explorer.example.test"),
]))
.expect("override");
assert_eq!(cfg.explorer_url, "https://explorer.example.test");
}
#[test]
fn signer_key_never_logged() {
let cfg = PaymentsConfig::from_lookup(map_lookup(&[
(ENV_SIGNER_KEY, TEST_KEY_0X),
(ENV_HMAC_SECRET, "super-secret-hmac"),
]))
.expect("config");
let dbg = format!("{cfg:?}");
assert!(
!dbg.contains(TEST_KEY_BARE),
"Debug leaked signer key: {dbg}"
);
assert!(!dbg.contains(TEST_KEY_0X), "Debug leaked signer key: {dbg}");
assert!(
!dbg.contains("super-secret-hmac"),
"Debug leaked hmac secret: {dbg}"
);
assert!(
!dbg.contains(TEST_ADDR),
"Debug leaked wallet address: {dbg}"
);
assert!(
!dbg.contains("signer_address"),
"Debug must not include a signer_address field: {dbg}"
);
assert!(dbg.contains("<redacted>"), "Debug missing redaction: {dbg}");
}
}