Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion examples/minimal_operator.rs
Original file line number Diff line number Diff line change
Expand Up @@ -124,7 +124,7 @@ async fn main() -> anyhow::Result<()> {
service_id: Some(42),
};
let billing_cfg = BillingConfig {
payment_mode: tangle_inference_core::PaymentMode::Shielded,
payment_rails: tangle_inference_core::PaymentRails::SHIELDED,
billing_required: true,
max_spend_per_request: 1_000_000,
min_credit_balance: 1_000,
Expand Down
62 changes: 44 additions & 18 deletions src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -75,35 +75,61 @@ pub struct ServerConfig {
pub max_per_account_requests: usize,
}

/// Payment mode — which payment provider to use.
/// The payment rails an operator accepts on its billable surfaces.
///
/// A request is served when it carries a valid proof for an ENABLED rail; an
/// empty set is an open (unbilled) endpoint. Rails compose freely — enabling
/// several lets one endpoint take any of them, dispatched per request by proof
/// type (see [`crate::payment::PaymentRouter`]). Adding a new rail is one more
/// flag here plus a `PaymentProvider` impl — never an enum cross-product.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum PaymentMode {
/// No billing — open endpoint.
None,
/// ShieldedCredits (default, backward compatible).
Shielded,
/// Direct ERC-20 transfer verification.
Direct,
/// Accept BOTH rails on the same endpoint, dispatched per request by the
/// payment-proof type: a `SpendAuth` proof settles shielded, a
/// `DirectTransfer` proof settles in plain ERC-20. Requires both a shielded
/// config and a pinned `payment_token_address`.
Both,
pub struct PaymentRails {
/// ShieldedCredits SpendAuth — private, prepaid shielded-pool balance.
#[serde(default)]
pub shielded: bool,
/// Direct ERC-20 transfer — plain USDC, pay-per-call (needs a pinned
/// `payment_token_address`).
#[serde(default)]
pub direct: bool,
}

impl PaymentRails {
pub const NONE: Self = Self {
shielded: false,
direct: false,
};
pub const SHIELDED: Self = Self {
shielded: true,
direct: false,
};
pub const DIRECT: Self = Self {
shielded: false,
direct: true,
};
pub const BOTH: Self = Self {
shielded: true,
direct: true,
};

/// No rail enabled → open, unbilled endpoint.
pub fn is_empty(&self) -> bool {
!self.shielded && !self.direct
}
}

impl Default for PaymentMode {
impl Default for PaymentRails {
fn default() -> Self {
Self::Shielded
Self::SHIELDED
}
}

/// Billing configuration.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BillingConfig {
/// Payment mode: "none", "shielded" (default), or "direct".
/// Which payment rails this operator accepts. Default: shielded only.
/// e.g. `{ shielded = true, direct = true }` to take both.
#[serde(default)]
pub payment_mode: PaymentMode,
pub payment_rails: PaymentRails,

/// Whether billing (spend_auth / direct transfer) is required on every request.
#[serde(default = "default_billing_required")]
Expand Down
4 changes: 2 additions & 2 deletions src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,8 @@ pub use billing::{
pub use metrics::{gather, health_summary, on_chain_metrics, RequestGuard};
#[cfg(feature = "billing")]
pub use payment::{
create_provider, DirectProvider, NoopProvider, PaymentMode, PaymentProof, PaymentProvider,
ShieldedProvider,
create_provider, DirectProvider, NoopProvider, PaymentProof, PaymentProvider, PaymentRails,
PaymentRouter, ShieldedProvider,
};
#[cfg(all(feature = "http", feature = "billing"))]
pub use server::{
Expand Down
156 changes: 116 additions & 40 deletions src/payment.rs
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,17 @@ pub enum PaymentProof {
},
}

impl PaymentProof {
/// Stable identity of the payer, for per-account rate limiting across rails:
/// the shielded commitment, or the direct transfer's sender address.
pub fn payer_id(&self) -> &str {
match self {
PaymentProof::SpendAuth(sa) => &sa.commitment,
PaymentProof::DirectTransfer { from, .. } => from,
}
}
}

/// Result of payment authorization.
#[derive(Debug, Clone)]
pub struct AuthorizationResult {
Expand Down Expand Up @@ -138,7 +149,10 @@ impl UsedTxStore {
.unwrap_or_default();

if path.is_some() {
tracing::info!(count = used.len(), "loaded persisted direct-transfer tx hashes");
tracing::info!(
count = used.len(),
"loaded persisted direct-transfer tx hashes"
);
} else {
tracing::warn!(
"direct_replay_store_path not configured — used tx hashes are in-memory only. \
Expand All @@ -147,7 +161,10 @@ impl UsedTxStore {
}

Self {
inner: tokio::sync::Mutex::new(UsedTxInner { used, in_flight: HashSet::new() }),
inner: tokio::sync::Mutex::new(UsedTxInner {
used,
in_flight: HashSet::new(),
}),
path,
}
}
Expand Down Expand Up @@ -261,7 +278,12 @@ impl DirectProvider {
/// Verify the on-chain ERC-20 transfer named by the proof. Pure read — does
/// no replay bookkeeping (the caller reserves before and commits after), so
/// a transient failure here never burns a legitimate tx hash.
async fn verify_transfer(&self, tx_hash: &str, from: &str, amount: &str) -> anyhow::Result<u64> {
async fn verify_transfer(
&self,
tx_hash: &str,
from: &str,
amount: &str,
) -> anyhow::Result<u64> {
use alloy::providers::{Provider, ProviderBuilder};
let provider = ProviderBuilder::new().connect_http(self.rpc_url.clone());

Expand Down Expand Up @@ -353,7 +375,9 @@ impl DirectProvider {
.parse()
.map_err(|e| anyhow::anyhow!("invalid amount in proof: {e}"))?;
if found_amount < requested {
anyhow::bail!("transferred amount ({found_amount}) is less than requested ({requested})");
anyhow::bail!(
"transferred amount ({found_amount}) is less than requested ({requested})"
);
}

Ok(found_amount)
Expand Down Expand Up @@ -437,54 +461,97 @@ impl PaymentProvider for NoopProvider {
}
}

// ─── CompositeProvider ────────────────────────────────────────────────
// ─── PaymentRouter ────────────────────────────────────────────────────

/// Accept BOTH rails on one endpoint, dispatched by the proof type: a
/// `SpendAuth` proof routes to the shielded provider, a `DirectTransfer` proof
/// to the direct provider. `operator_address` is the same key for both, so a
/// claim/transfer lands in the same place regardless of how the buyer paid.
pub struct CompositeProvider {
shielded: ShieldedProvider,
direct: DirectProvider,
pub use crate::config::PaymentRails;

/// The universal payment provider: holds the operator's enabled rails and
/// dispatches each request to the matching one by proof type. Enabling several
/// rails lets one endpoint accept any of them; a proof for a disabled rail is
/// rejected. Every rail derives its payout from the same operator key, so funds
/// land in the same place regardless of how the buyer paid.
///
/// This is the single, extensible composition point — adding a rail is a new
/// `Option<NewProvider>` field plus a match arm, never an enum cross-product.
pub struct PaymentRouter {
shielded: Option<ShieldedProvider>,
direct: Option<DirectProvider>,
operator_address: Address,
}

impl CompositeProvider {
pub fn new(shielded: ShieldedProvider, direct: DirectProvider) -> Self {
Self { shielded, direct }
impl PaymentRouter {
pub fn build(
rails: PaymentRails,
tangle: &TangleConfig,
billing: &BillingConfig,
) -> anyhow::Result<Self> {
use alloy::signers::local::PrivateKeySigner;
let operator_address = tangle.operator_key.parse::<PrivateKeySigner>()?.address();
Ok(Self {
shielded: rails
.shielded
.then(|| ShieldedProvider::new(tangle, billing))
.transpose()?,
direct: rails
.direct
.then(|| build_direct(tangle, billing))
.transpose()?,
operator_address,
})
}
}

#[async_trait]
impl PaymentProvider for CompositeProvider {
impl PaymentProvider for PaymentRouter {
async fn authorize(&self, proof: &PaymentProof) -> anyhow::Result<u64> {
match proof {
PaymentProof::SpendAuth(_) => self.shielded.authorize(proof).await,
PaymentProof::DirectTransfer { .. } => self.direct.authorize(proof).await,
PaymentProof::SpendAuth(_) => {
self.shielded
.as_ref()
.ok_or_else(|| anyhow::anyhow!("shielded rail not enabled on this operator"))?
.authorize(proof)
.await
}
PaymentProof::DirectTransfer { .. } => {
self.direct
.as_ref()
.ok_or_else(|| anyhow::anyhow!("direct rail not enabled on this operator"))?
.authorize(proof)
.await
}
}
}

async fn settle(&self, proof: &PaymentProof, actual_cost: u64) -> anyhow::Result<()> {
match proof {
PaymentProof::SpendAuth(_) => self.shielded.settle(proof, actual_cost).await,
PaymentProof::DirectTransfer { .. } => self.direct.settle(proof, actual_cost).await,
PaymentProof::SpendAuth(_) => {
self.shielded
.as_ref()
.ok_or_else(|| anyhow::anyhow!("shielded rail not enabled on this operator"))?
.settle(proof, actual_cost)
.await
}
PaymentProof::DirectTransfer { .. } => {
self.direct
.as_ref()
.ok_or_else(|| anyhow::anyhow!("direct rail not enabled on this operator"))?
.settle(proof, actual_cost)
.await
}
}
}

fn operator_address(&self) -> Address {
// Both providers derive from the same operator key.
self.shielded.operator_address()
self.operator_address
}
}

// ─── Factory ──────────────────────────────────────────────────────────

// Re-export PaymentMode from config so consumers can import from payment module
pub use crate::config::PaymentMode;

fn build_direct(tangle: &TangleConfig, billing: &BillingConfig) -> anyhow::Result<DirectProvider> {
if billing.payment_token_address.is_none() {
anyhow::bail!(
"direct payment requires payment_token_address in billing config — \
"direct rail requires payment_token_address in billing config — \
without it, attackers can pay with worthless tokens"
);
}
Expand All @@ -497,20 +564,17 @@ fn build_direct(tangle: &TangleConfig, billing: &BillingConfig) -> anyhow::Resul
)
}

/// Create a payment provider from config.
/// Build the operator's payment provider for the rails it accepts. An empty rail
/// set is an open (unbilled) endpoint.
pub fn create_provider(
mode: PaymentMode,
rails: PaymentRails,
tangle: &TangleConfig,
billing: &BillingConfig,
) -> anyhow::Result<Box<dyn PaymentProvider>> {
match mode {
PaymentMode::None => Ok(Box::new(NoopProvider::new(tangle.operator_key.clone())?)),
PaymentMode::Shielded => Ok(Box::new(ShieldedProvider::new(tangle, billing)?)),
PaymentMode::Direct => Ok(Box::new(build_direct(tangle, billing)?)),
PaymentMode::Both => Ok(Box::new(CompositeProvider::new(
ShieldedProvider::new(tangle, billing)?,
build_direct(tangle, billing)?,
))),
if rails.is_empty() {
Ok(Box::new(NoopProvider::new(tangle.operator_key.clone())?))
} else {
Ok(Box::new(PaymentRouter::build(rails, tangle, billing)?))
}
}

Expand All @@ -528,7 +592,10 @@ mod tests {
// First reserve succeeds.
store.reserve(&tx(1)).await.unwrap();
// Same tx again while in-flight → rejected.
assert!(store.reserve(&tx(1)).await.is_err(), "concurrent reserve must fail");
assert!(
store.reserve(&tx(1)).await.is_err(),
"concurrent reserve must fail"
);
// Commit it; now it's consumed.
store.commit(&tx(1)).await;
assert!(store.is_used(&tx(1)).await);
Expand All @@ -542,7 +609,10 @@ mod tests {
store.reserve(&tx(2)).await.unwrap();
// Verification "failed" → release the reservation.
store.release(&tx(2)).await;
assert!(!store.is_used(&tx(2)).await, "released tx must not be consumed");
assert!(
!store.is_used(&tx(2)).await,
"released tx must not be consumed"
);
// The legitimate payer can retry.
store.reserve(&tx(2)).await.unwrap();
store.commit(&tx(2)).await;
Expand All @@ -564,7 +634,10 @@ mod tests {
// rejects the same tx — the bug this fixes (in-memory store forgot it).
{
let store = UsedTxStore::load(Some(path.clone()));
assert!(store.is_used(&tx(3)).await, "consumed tx must persist across restart");
assert!(
store.is_used(&tx(3)).await,
"consumed tx must persist across restart"
);
assert!(
store.reserve(&tx(3)).await.is_err(),
"replay of a past payment after restart must be rejected"
Expand All @@ -584,6 +657,9 @@ mod tests {
// After restart, a released (never-verified) tx is still spendable.
let store = UsedTxStore::load(Some(path));
assert!(!store.is_used(&tx(4)).await);
assert!(store.reserve(&tx(4)).await.is_ok(), "a never-committed tx must remain usable");
assert!(
store.reserve(&tx(4)).await.is_ok(),
"a never-committed tx must remain usable"
);
}
}
Loading
Loading