Gate scoped BTCPay connect to sandbox + non-mainnet
Slices 3-4 of agent-payment-connect: a scoped key carrying the a-la-carte payment_providers:write scope may connect a BTCPay provider, but only on a sandbox daemon (KEYSAT_SANDBOX_MODE) and only for a non-mainnet (regtest/testnet/signet) store. Master may connect any network; disconnect and production/mainnet reconnect stay master-only. A credential that can repoint settlement is a fund-redirection key, so the gate is deliberately narrow and fails closed. - require_provider_connect: outer gate (sandbox flag) at start_connect - btcpay/network.rs classify_address_network + client::fetch_onchain_network: resolve the store network at finish_connect, fail-closed to mainnet on any ambiguity (no on-chain method, non-2xx, non-JSON, unknown prefix), before any webhook/persist side effect - initiator carried across the OAuth round-trip via btcpay_authorize_state (migration 0025: scoped_initiator + initiator_actor_hash); scoped connects are audited - the GET callback now returns the error's HTTP status (was a misleading 200 on a denied connect) - openapi.rs documents the BTCPay connect/callback/status/disconnect paths and the key-creation scopes field Validated end-to-end against a live regtest BTCPay. Full suite green; adds gate + network unit/integration tests.
This commit is contained in:
@@ -366,3 +366,87 @@ pub async fn list_payment_methods(
|
||||
.cloned()
|
||||
.unwrap_or_default())
|
||||
}
|
||||
|
||||
/// Resolve the Bitcoin **network** a store settles on, for the scoped
|
||||
/// payment-connect gate (`plans/agent-payment-connect-scope.md` §6.1).
|
||||
///
|
||||
/// Lists the store's payment methods, finds the on-chain BTC method
|
||||
/// (`paymentMethodId` is `BTC-CHAIN` on BTCPay 2.x, `BTC` on 1.x — never
|
||||
/// hardcode), fetches a receive address, and classifies the address prefix.
|
||||
///
|
||||
/// Returns:
|
||||
/// - `Ok(Some(network))` when positively determined;
|
||||
/// - `Ok(None)` when it **cannot** be determined (no on-chain method, no
|
||||
/// address, Lightning-only store, BTCPay not yet synced → `503`, or an
|
||||
/// unrecognized prefix). The caller MUST fail closed (treat `None` as
|
||||
/// mainnet and deny the scoped connect).
|
||||
///
|
||||
/// The address endpoint requires `btcpay.store.canmodifystoresettings`, which
|
||||
/// the daemon's authorize flow already requests (see `REQUESTED_PERMISSIONS`).
|
||||
pub async fn fetch_onchain_network(
|
||||
base_url: &str,
|
||||
api_key: &str,
|
||||
store_id: &str,
|
||||
) -> Result<Option<super::network::BitcoinNetwork>> {
|
||||
// Any failure to enumerate methods → undetermined → caller fails closed.
|
||||
// Swallow the error here (uniform with the non-2xx wallet/address branch
|
||||
// below) and log a body-free reason at warn; detail only at debug so an
|
||||
// upstream error body never lands in normal logs on this sensitive path.
|
||||
let methods = match list_payment_methods(base_url, api_key, store_id).await {
|
||||
Ok(m) => m,
|
||||
Err(e) => {
|
||||
tracing::warn!(
|
||||
store = %store_id,
|
||||
"fetch_onchain_network: could not list payment methods; network undetermined"
|
||||
);
|
||||
tracing::debug!(error = %format!("{e:#}"), "btcpay list-payment-methods error detail");
|
||||
return Ok(None);
|
||||
}
|
||||
};
|
||||
// Find the on-chain BTC method. Lightning ids (`BTC-LN`,
|
||||
// `BTC_LightningLike`, …) are deliberately excluded.
|
||||
let Some(pmid) = methods.iter().find_map(|m| {
|
||||
let id = m.get("paymentMethodId").and_then(|v| v.as_str())?;
|
||||
match id.to_ascii_uppercase().as_str() {
|
||||
"BTC-CHAIN" | "BTC" => Some(id.to_string()),
|
||||
_ => None,
|
||||
}
|
||||
}) else {
|
||||
return Ok(None); // no on-chain BTC method → undetermined → fail closed
|
||||
};
|
||||
|
||||
// `pmid` is BTCPay-supplied; percent-encode it as a path segment so a
|
||||
// hostile/buggy server returning an odd id can't corrupt the URL (it would
|
||||
// only ever 4xx → Ok(None) → deny anyway, but keep the request well-formed).
|
||||
let url = format!(
|
||||
"{}/api/v1/stores/{store_id}/payment-methods/{}/wallet/address",
|
||||
base_url.trim_end_matches('/'),
|
||||
urlencoding::encode(&pmid),
|
||||
);
|
||||
let resp = Client::builder()
|
||||
.timeout(std::time::Duration::from_secs(15))
|
||||
.build()?
|
||||
.get(&url)
|
||||
.header("Authorization", format!("token {api_key}"))
|
||||
.send()
|
||||
.await
|
||||
.context("calling BTCPay wallet/address")?;
|
||||
if !resp.status().is_success() {
|
||||
// 503 (BTCPay not synced / on-chain service down), 404/422 (no wallet),
|
||||
// 403 (insufficient perms) — none let us positively determine the
|
||||
// network, so report undetermined and let the caller fail closed.
|
||||
return Ok(None);
|
||||
}
|
||||
// A 2xx with a non-JSON body (misconfigured BTCPay) is likewise "can't
|
||||
// determine" → Ok(None). Parsing via Ok(None) instead of `?` also keeps any
|
||||
// body snippet reqwest attaches to a parse error out of warn-level logs.
|
||||
let body: serde_json::Value = match resp.json().await {
|
||||
Ok(v) => v,
|
||||
Err(e) => {
|
||||
tracing::debug!(error = %format!("{e:#}"), "btcpay wallet/address: non-JSON body; network undetermined");
|
||||
return Ok(None);
|
||||
}
|
||||
};
|
||||
let address = body.get("address").and_then(|v| v.as_str()).unwrap_or("");
|
||||
Ok(super::network::classify_address_network(address))
|
||||
}
|
||||
|
||||
@@ -79,23 +79,47 @@ pub async fn save(pool: &SqlitePool, cfg: &BtcpayConfig) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// An in-flight authorize round-trip, recovered at callback time. `Default`
|
||||
/// (no profile, `scoped_initiator = false`) is the back-compat reading of a
|
||||
/// pre-0025 / NULL row: "master connect to the default profile" — the only
|
||||
/// kind that existed before scoped connect.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct AuthorizeState {
|
||||
/// Merchant profile the resulting provider row attaches to (migration
|
||||
/// 0022). None → "the default profile".
|
||||
pub merchant_profile_id: Option<String>,
|
||||
/// True when a *scoped* key (not the master key) started the connect
|
||||
/// (migration 0025). The callback applies the non-mainnet network gate
|
||||
/// only for scoped initiators.
|
||||
pub scoped_initiator: bool,
|
||||
/// sha256 of the initiating credential — for the callback's audit row.
|
||||
pub initiator_actor_hash: Option<String>,
|
||||
}
|
||||
|
||||
/// Record a new in-flight authorize state token. `merchant_profile_id`
|
||||
/// (multi-provider model, migration 0022) names which merchant profile
|
||||
/// the resulting provider row should attach to when the callback fires
|
||||
/// — None falls back to "the default profile" at consume-time.
|
||||
/// `scoped_initiator` / `actor_hash` (migration 0025) carry who started the
|
||||
/// connect so the callback can apply the network gate + attribute the audit.
|
||||
pub async fn record_authorize_state(
|
||||
pool: &SqlitePool,
|
||||
token: &str,
|
||||
merchant_profile_id: Option<&str>,
|
||||
scoped_initiator: bool,
|
||||
actor_hash: Option<&str>,
|
||||
) -> Result<()> {
|
||||
let now = Utc::now().to_rfc3339();
|
||||
sqlx::query(
|
||||
"INSERT INTO btcpay_authorize_state (state_token, merchant_profile_id, created_at) \
|
||||
VALUES (?, ?, ?)",
|
||||
"INSERT INTO btcpay_authorize_state \
|
||||
(state_token, merchant_profile_id, created_at, scoped_initiator, initiator_actor_hash) \
|
||||
VALUES (?, ?, ?, ?, ?)",
|
||||
)
|
||||
.bind(token)
|
||||
.bind(merchant_profile_id)
|
||||
.bind(&now)
|
||||
.bind(scoped_initiator as i64)
|
||||
.bind(actor_hash)
|
||||
.execute(pool)
|
||||
.await
|
||||
.context("recording btcpay authorize state")?;
|
||||
@@ -109,17 +133,18 @@ pub async fn record_authorize_state(
|
||||
}
|
||||
|
||||
/// Validate that `token` was issued recently and has not been consumed.
|
||||
/// Consumes (deletes) the token on success so a replay fails, and
|
||||
/// returns the `merchant_profile_id` recorded at start-connect time so
|
||||
/// the callback knows which profile to attach the new provider to.
|
||||
/// Consumes (deletes) the token on success so a replay fails, and returns the
|
||||
/// recorded `AuthorizeState` (profile + initiator) so the callback knows which
|
||||
/// profile to attach to and whether to apply the scoped network gate.
|
||||
pub async fn consume_authorize_state(
|
||||
pool: &SqlitePool,
|
||||
token: &str,
|
||||
) -> Result<Option<String>> {
|
||||
) -> Result<AuthorizeState> {
|
||||
use sqlx::Row;
|
||||
let cutoff = (Utc::now() - chrono::Duration::minutes(30)).to_rfc3339();
|
||||
let row = sqlx::query(
|
||||
"SELECT state_token, merchant_profile_id FROM btcpay_authorize_state \
|
||||
"SELECT merchant_profile_id, scoped_initiator, initiator_actor_hash \
|
||||
FROM btcpay_authorize_state \
|
||||
WHERE state_token = ? AND created_at >= ?",
|
||||
)
|
||||
.bind(token)
|
||||
@@ -130,11 +155,19 @@ pub async fn consume_authorize_state(
|
||||
let Some(row) = row else {
|
||||
return Err(anyhow!("unknown or expired authorize state token"));
|
||||
};
|
||||
let merchant_profile_id: Option<String> = row.try_get("merchant_profile_id").ok().flatten();
|
||||
let state = AuthorizeState {
|
||||
merchant_profile_id: row.try_get("merchant_profile_id").ok().flatten(),
|
||||
// Tolerant read: a NULL/absent column reads as 0 (master) — fail toward
|
||||
// the *less*-restrictive master path is acceptable here because the
|
||||
// column only exists to ADD the scoped restriction; a pre-0025 token
|
||||
// could only ever have been a master connect.
|
||||
scoped_initiator: row.try_get::<i64, _>("scoped_initiator").unwrap_or(0) != 0,
|
||||
initiator_actor_hash: row.try_get("initiator_actor_hash").ok().flatten(),
|
||||
};
|
||||
|
||||
sqlx::query("DELETE FROM btcpay_authorize_state WHERE state_token = ?")
|
||||
.bind(token)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
Ok(merchant_profile_id)
|
||||
Ok(state)
|
||||
}
|
||||
|
||||
@@ -8,4 +8,5 @@
|
||||
|
||||
pub mod client;
|
||||
pub mod config;
|
||||
pub mod network;
|
||||
pub mod webhook;
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
//! Bitcoin network classification from an address string.
|
||||
//!
|
||||
//! Used by the agent-payment-connect gate (`plans/agent-payment-connect-scope.md`
|
||||
//! §6.1): a *scoped* key may connect a BTCPay store only when its target network
|
||||
//! is non-mainnet. Greenfield's `GET /api/v1/server/info` carries no chain-type
|
||||
//! field, so we determine the network from a **network-encoding artifact** — the
|
||||
//! store's on-chain receive address — and classify by its prefix.
|
||||
//!
|
||||
//! Validated against a live regtest BTCPay 2.x: `wallet/address` returns a
|
||||
//! `bcrt1…` address on regtest (see `onboarding-harness/stage2/btcpay-regtest/`).
|
||||
//!
|
||||
//! **Fail-closed:** an unrecognized / empty address yields `None`; the caller
|
||||
//! MUST treat `None` as mainnet (deny the scoped connect). Never assume
|
||||
//! non-mainnet from absence of evidence.
|
||||
|
||||
/// The Bitcoin network a BTCPay store settles on. Only the mainnet-vs-rest
|
||||
/// distinction gates the scoped connect, but the specific non-mainnet variant
|
||||
/// is kept for audit/logging.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum BitcoinNetwork {
|
||||
Mainnet,
|
||||
/// testnet3 — shares the `tb1` HRP and `m`/`n`/`2` base58 versions with signet.
|
||||
Testnet,
|
||||
/// Signet — indistinguishable from testnet by address alone (`tb1`), so the
|
||||
/// address classifier never yields this; reserved for a future
|
||||
/// derivation-scheme-based path. Kept distinct because it is a real,
|
||||
/// non-mainnet network the gate must allow.
|
||||
Signet,
|
||||
Regtest,
|
||||
}
|
||||
|
||||
impl BitcoinNetwork {
|
||||
pub fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
BitcoinNetwork::Mainnet => "mainnet",
|
||||
BitcoinNetwork::Testnet => "testnet",
|
||||
BitcoinNetwork::Signet => "signet",
|
||||
BitcoinNetwork::Regtest => "regtest",
|
||||
}
|
||||
}
|
||||
|
||||
/// The only question the connect gate actually asks.
|
||||
pub fn is_mainnet(self) -> bool {
|
||||
matches!(self, BitcoinNetwork::Mainnet)
|
||||
}
|
||||
}
|
||||
|
||||
/// Classify a Bitcoin address by its network-encoding prefix. Returns `None`
|
||||
/// when the prefix is unrecognized or the string is empty — the caller
|
||||
/// **fails closed** (treats `None` as mainnet).
|
||||
///
|
||||
/// bech32/bech32m HRP: `bcrt1…`=regtest, `tb1…`=testnet/signet, `bc1…`=mainnet.
|
||||
/// Legacy base58: `1`/`3`=mainnet, `m`/`n`/`2`=test/regtest (the `tb1`/base58
|
||||
/// test versions are shared by testnet, signet, and regtest — all non-mainnet,
|
||||
/// which is all the gate needs; only the bech32 `bcrt1` HRP pins regtest
|
||||
/// specifically).
|
||||
pub fn classify_address_network(addr: &str) -> Option<BitcoinNetwork> {
|
||||
let s = addr.trim();
|
||||
if s.is_empty() {
|
||||
return None;
|
||||
}
|
||||
// bech32/bech32m — HRP is case-insensitive. Check `bcrt1` before `bc1`
|
||||
// (it is not a prefix of the others, but order makes the intent explicit).
|
||||
let lower = s.to_ascii_lowercase();
|
||||
if lower.starts_with("bcrt1") {
|
||||
return Some(BitcoinNetwork::Regtest);
|
||||
}
|
||||
if lower.starts_with("tb1") {
|
||||
// testnet and signet share the `tb` HRP and are indistinguishable from
|
||||
// the address alone. Both non-mainnet; report Testnet.
|
||||
return Some(BitcoinNetwork::Testnet);
|
||||
}
|
||||
if lower.starts_with("bc1") {
|
||||
return Some(BitcoinNetwork::Mainnet);
|
||||
}
|
||||
// Legacy base58check — version byte encoded in the leading character.
|
||||
// Only classify when the whole string is a *plausible* base58 address
|
||||
// (correct alphabet + length): otherwise arbitrary text that merely begins
|
||||
// with `n`/`m`/`2` (e.g. "not-an-address") would be mis-read as non-mainnet
|
||||
// and the gate would fail OPEN. Junk falls through to `None` (fail closed).
|
||||
// Case-sensitive, so classify off the original string.
|
||||
if (26..=35).contains(&s.len()) && s.chars().all(is_base58) {
|
||||
return match s.chars().next() {
|
||||
Some('1') | Some('3') => Some(BitcoinNetwork::Mainnet),
|
||||
Some('m') | Some('n') | Some('2') => Some(BitcoinNetwork::Testnet),
|
||||
_ => None,
|
||||
};
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Base58 alphabet membership (Bitcoin's: omits `0`, `O`, `I`, `l`).
|
||||
fn is_base58(c: char) -> bool {
|
||||
matches!(c, '1'..='9' | 'A'..='H' | 'J'..='N' | 'P'..='Z' | 'a'..='k' | 'm'..='z')
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn bech32_prefixes() {
|
||||
// The exact address the live regtest BTCPay 2.x returned.
|
||||
assert_eq!(
|
||||
classify_address_network("bcrt1qwsh9ua5qeutshvrhz474uduwqlw8gfukfpc8vt"),
|
||||
Some(BitcoinNetwork::Regtest)
|
||||
);
|
||||
assert_eq!(
|
||||
classify_address_network("tb1qw508d6qejxtdg4y5r3zarvary0c5xw7kxpjzsx"),
|
||||
Some(BitcoinNetwork::Testnet)
|
||||
);
|
||||
assert_eq!(
|
||||
classify_address_network("bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4"),
|
||||
Some(BitcoinNetwork::Mainnet)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bech32_is_case_insensitive() {
|
||||
assert_eq!(
|
||||
classify_address_network("BCRT1QWSH9UA5QEUTSHVRHZ474UDUWQLW8GFUKFPC8VT"),
|
||||
Some(BitcoinNetwork::Regtest)
|
||||
);
|
||||
assert_eq!(
|
||||
classify_address_network("BC1QW508D6QEJXTDG4Y5R3ZARVARY0C5XW7KV8F3T4"),
|
||||
Some(BitcoinNetwork::Mainnet)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_base58() {
|
||||
assert_eq!(classify_address_network("1BvBMSEYstWetqTFn5Au4m4GFg7xJaNVN2"), Some(BitcoinNetwork::Mainnet)); // P2PKH
|
||||
assert_eq!(classify_address_network("3J98t1WpEZ73CNmQviecrnyiWrnqRhWNLy"), Some(BitcoinNetwork::Mainnet)); // P2SH
|
||||
assert_eq!(classify_address_network("mipcBbFg9gMiCh81Kj8tqqdgoZub1ZJRfn"), Some(BitcoinNetwork::Testnet)); // testnet P2PKH
|
||||
assert_eq!(classify_address_network("n2ZNV88uQbede7C5M5jzi6SyG4GVuPpng6"), Some(BitcoinNetwork::Testnet));
|
||||
assert_eq!(classify_address_network("2MzQwSSnBHWHqSAqtTVQ6v47XtaisrJa1Vc"), Some(BitcoinNetwork::Testnet)); // test P2SH
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fail_closed_on_unknown_or_empty() {
|
||||
assert_eq!(classify_address_network(""), None);
|
||||
assert_eq!(classify_address_network(" "), None);
|
||||
assert_eq!(classify_address_network("not-an-address"), None);
|
||||
assert_eq!(classify_address_network("ltc1qxyz"), None); // not bitcoin
|
||||
assert_eq!(classify_address_network("zzz"), None);
|
||||
// The dangerous direction: a base58-length, all-base58 string that does
|
||||
// NOT begin with a version char (1/3/m/n/2) must stay None, never be
|
||||
// mis-read as non-mainnet. (And a real mainnet address always begins
|
||||
// with 1/3/bc1, so it can never fall into the non-mainnet arms.)
|
||||
assert_eq!(classify_address_network("bQ8vZ2mN4pR7sT1uW3xY5zA6dE9fG"), None); // 29 chars, starts 'b'
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_mainnet_only_true_for_mainnet() {
|
||||
assert!(BitcoinNetwork::Mainnet.is_mainnet());
|
||||
assert!(!BitcoinNetwork::Testnet.is_mainnet());
|
||||
assert!(!BitcoinNetwork::Signet.is_mainnet());
|
||||
assert!(!BitcoinNetwork::Regtest.is_mainnet());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user