diff --git a/docs/ads.md b/docs/ads.md index 6776ec51..bccd81aa 100644 --- a/docs/ads.md +++ b/docs/ads.md @@ -11,7 +11,7 @@ Code: `node/crates/c0mpute-core/src/ads/`. Billing is built (see [Billing and payouts](#billing-and-payouts)): a campaign with a budget pays gateways and shows for verified impressions at -the second price, from the advertiser's own CoinPay wallet. Real payouts are +the second price, through the node's one payment rail. Real payouts are **off by default**. There is no OpenRTB auction. ## Advertisers @@ -89,13 +89,17 @@ the ad. Settlement is built on these chains (next section). ## Billing and payouts -The rail is CoinPay, the settlement adapter c0mpute already names everywhere, -through the `coinpay` CLI the `coinpay` plugin delegates to. Nothing in -c0mpute holds anyone's money; there is no clearing house and no ad server. -Code: `ads/billing.rs`. +Money moves through c0mpute's one payment path, `payments.rs` (shared with +hosting settlement): CoinPay payouts from the operator's own CoinPay business +account, with an idempotent ledger and a per-round cap. Nothing in c0mpute +holds anyone's money; there is no clearing house and no ad server. The fee +and split logic is ads-specific: `ads/billing.rs`. + +Payout addresses are `:
`, where `COIN` is the CoinPay currency +code the payout is made in (`USDC_POL`, `SOL`, ...). 1. **Budget.** `--budget` (total, USD), `--daily-budget`, `--payer - :
` (your paying wallet) and `--funding-ref` (a CoinPay + :
` (your paying wallet) and `--funding-ref` (a CoinPay escrow or payment id as proof of funds) are signed into the campaign record. A campaign without a budget runs unpaid, as before. 2. **Cleared price.** Every impression receipt carries the price it cleared @@ -123,8 +127,11 @@ Code: `ads/billing.rs`. the running total spent), pays each line, and gossips the settlements so gateways learn the budget left and where their next claim starts. A gateway stops serving a campaign once settled spend plus its own - unsettled spend reaches the budget. Lines are idempotent: a paid line is - never sent again, a failed one is retried by the next `settle --pay`. + unsettled spend reaches the budget. Lines are idempotent: each is a + transfer keyed `ads::` in the shared payments + ledger (`payments.json` next to the campaign), so a paid line is never + sent again; a failed one, or one over `[payments] max_usd_per_round`, is + retried by the next `settle --pay`. Split of each paid impression: **5% network fee** (the single fee c0mpute's market and Infernet take; operators keep 95%), **75% to the show** that @@ -139,18 +146,25 @@ min_claim_usd = "1.00" # Advertiser [ads] -payouts = true # allow `c0mpute ads settle --pay` to send money -fee_payout = "SOL:..." # network fee address; without it the fee is owed, not sent +payouts = true # allow `c0mpute ads settle --pay` to send money... +fee_payout = "USDC_POL:0x..." # network fee address; without it the fee is owed, not sent + +[payments] # ...through the node's payment rail, also off by default +enabled = true +rail = "coinpay" +max_usd_per_round = "10" +coinpay.recipient_email = "you@example.org" +# plus a CoinPay business API key with payouts:create in $C0MPUTE_COINPAY_PAYOUT_KEY ``` Shows add `payout = "SOL:..."` to `[ads]` in `show.toml` (or `c0mpute podcasting publish --ads pre --ads-payout SOL:...`). It lives in the signed `c0mpute-ads.json`, so no gateway can redirect a show's money. -What is not proven: `--pay` runs `coinpay wallet send --chain --to -
--amount-fiat --fiat USD` from your wallet. Every test uses -an in-memory rail; real transfers have not been exercised, and they stay off -until `[ads] payouts = true`. A receipt is still a gateway's claim, not +What is not proven: `--pay` calls CoinPay `POST /api/payouts/create` once +per line. Every test uses the in-memory `payments::FakeRail`; real transfers +have not been exercised, and they stay off until both `[ads] payouts = true` +and `[payments] enabled = true`. A receipt is still a gateway's claim, not proof a person heard the ad: caps, dedupe and the self-dealing rules limit what a dishonest gateway can bill, they do not eliminate it. diff --git a/node/crates/c0mpute-cli/src/main.rs b/node/crates/c0mpute-cli/src/main.rs index 5bcae116..c7737d33 100644 --- a/node/crates/c0mpute-cli/src/main.rs +++ b/node/crates/c0mpute-cli/src/main.rs @@ -730,10 +730,10 @@ enum PodcastingCmd { /// Without it (or [ads]) no gateway ever inserts ads. #[arg(long, value_name = "BREAKS")] ads: Option, - /// Where the show's share of ad revenue goes, :
+ /// Where the show's share of ad revenue goes, :
/// (e.g. SOL:9xQe...). Same as `payout` in [ads]. Without it the show /// runs ads unpaid. - #[arg(long, value_name = "CHAIN:ADDRESS")] + #[arg(long, value_name = "COIN:ADDRESS")] ads_payout: Option, /// Build the show folder only; do not publish. #[arg(long)] @@ -765,12 +765,14 @@ enum AdsCmd { json: bool, }, /// Verify gateways' payment claims for a campaign and (with --pay) settle - /// and pay them from your CoinPay wallet. Without --pay it only reports. + /// and pay them through the node's payment rail ([payments]). Without + /// --pay it only reports. Settle { /// Campaign name (local) or id. campaign: String, /// Sign settlements and send payouts. Also needs `[ads] payouts = - /// true` in config.toml: real money leaves your wallet. + /// true` and `[payments] enabled = true` in config.toml: real money + /// moves. #[arg(long)] pay: bool, /// How long to collect claims and receipts over gossip. @@ -818,9 +820,9 @@ struct AdsTargetArgs { /// Most the campaign pays for one UTC day of impressions, USD. #[arg(long)] daily_budget: Option, - /// Your paying CoinPay wallet, :
(claims paying out to it + /// Your paying CoinPay wallet, :
(claims paying out to it /// are refused as self-dealing). - #[arg(long, value_name = "CHAIN:ADDRESS")] + #[arg(long, value_name = "COIN:ADDRESS")] payer: Option, /// A CoinPay escrow or payment id that shows the budget is funded. #[arg(long)] @@ -3306,23 +3308,26 @@ async fn run_ads(cmd: AdsCmd) -> Result<()> { let cfg = Config::load_or_default(&config::default_config_path())?; let fee_payout = cfg.ads.fee_payout.clone().unwrap_or_default(); if !fee_payout.is_empty() && !billing::valid_payout(&fee_payout) { - bail!("[ads] fee_payout must be :
"); + bail!("[ads] fee_payout must be :
, e.g. \"USDC_POL:0x...\""); } - let coinpay; - let rail: Option<&dyn billing::PaymentRail> = if pay { - if !cfg.ads.payouts { + // Real money needs both switches: `[ads] payouts` (this feature) + // and `[payments] enabled` (the node's one payment rail). + let rail: Option> = if pay { + if !cfg.ads.payouts || !cfg.payments.enabled { bail!( - "--pay sends real money from your CoinPay wallet; turn it on with `[ads] payouts = true` in {}", + "--pay sends real money; it needs both `[ads] payouts = true` and `[payments] enabled = true` in {}", config::default_config_path().display() ); } - coinpay = billing::CoinpayCliRail::find() - .context("`coinpay` is not installed: c0mpute update installs it, then `c0mpute coinpay login`")?; - Some(&coinpay) + Some( + c0mpute_core::payments::rail_from_config(&cfg.payments)? + .context("[payments] is enabled but no rail was configured")?, + ) } else { None }; - let (round, ledger) = manage::settle(&record, &fee_payout, rail, wait(wait_secs)).await?; + let (round, ledger) = + manage::settle(&record, &fee_payout, rail.as_deref(), cfg.payments.round_cap(), wait(wait_secs)).await?; if json { let settled: Vec<_> = round .settled diff --git a/node/crates/c0mpute-core/src/ads/billing.rs b/node/crates/c0mpute-core/src/ads/billing.rs index 1a3ab4ce..58a2574e 100644 --- a/node/crates/c0mpute-core/src/ads/billing.rs +++ b/node/crates/c0mpute-core/src/ads/billing.rs @@ -25,7 +25,7 @@ //! caps, refuses self-dealing, and refuses a claim that would overspend. //! 5. **Settle.** The advertiser's node signs a [`Settlement`] (chained per //! campaign, carrying the running total spent), pays each line through a -//! [`PaymentRail`], and gossips the settlement so gateways learn how much +//! shared payments path ([`crate::payments`]), and gossips the settlement so gateways learn how much //! budget is left and where their next claim starts. //! //! Split of every paid impression ([`split`]): the c0mpute network fee is 5%, @@ -35,11 +35,10 @@ //! directly), the gateway is the publisher and gets 95%. //! //! Real money moves only with `[ads] payouts = true` on the advertiser's node -//! and `c0mpute ads settle --pay`; the default rail refuses to pay. +//! **and** `[payments] enabled = true` (the shared rail), and `c0mpute ads +//! settle --pay`. Otherwise settle only reports what it would pay. use std::collections::{BTreeMap, BTreeSet, HashMap}; -use std::path::PathBuf; -use std::sync::Mutex; use anyhow::{Context, Result, bail}; use c0mpute_proto::Hash; @@ -81,7 +80,7 @@ pub struct Budget { pub daily_micros: Option, /// Settlement adapter; "coinpay" is the only one with a rail today. pub rail: String, - /// The advertiser's paying wallet, `:
`. A gateway or show + /// The advertiser's paying wallet, `:
`. A gateway or show /// paid to this address is the advertiser paying itself and is refused. #[serde(default, skip_serializing_if = "Option::is_none")] pub payer: Option, @@ -104,7 +103,7 @@ impl Budget { if let Some(p) = &self.payer && !valid_payout(p) { - bail!("payer must be :
, e.g. SOL:9xQe..."); + bail!("payer must be :
, e.g. SOL:9xQe..."); } if let Some(f) = &self.funding_ref && (f.is_empty() || f.len() > 128 || f.chars().any(|c| c.is_control() || c.is_whitespace())) @@ -119,9 +118,9 @@ fn valid_token(s: &str, max: usize) -> bool { !s.is_empty() && s.len() <= max && s.chars().all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_') } -/// A payout address: `:
`, e.g. `SOL:9xQe...`, `ETH:0xabc...`, -/// `BCH:bitcoincash:q...`. The chain is what `coinpay wallet send --chain` -/// takes. +/// A payout address: `:
`, e.g. `USDC_POL:0xabc...`, +/// `SOL:9xQe...`. The coin is the CoinPay currency code the shared payments +/// rail pays in ([`payee_of`]). pub fn valid_payout(s: &str) -> bool { let Some((chain, addr)) = s.split_once(':') else { return false; @@ -153,7 +152,7 @@ pub struct Claim { pub campaign_id: String, /// Gateway key (hex), the same key that signed the batches. pub gateway: String, - /// Where the gateway's share goes, `:
`. + /// Where the gateway's share goes, `:
`. pub payout: String, pub from_seq: u64, pub to_seq: u64, @@ -269,7 +268,7 @@ pub struct Payout { pub role: Role, /// Recipient key (gateway or show owner); "" for the network fee. pub key: String, - /// `:
`; "" when no network fee address is configured + /// `:
`; "" when no network fee address is configured /// (the fee is owed, not sent). pub address: String, pub micros: u64, @@ -755,30 +754,68 @@ impl Ledger { Ok(s) } - /// Send every unpaid line that has an address. Idempotent per line: a - /// line with a reference is never sent again, and the rail gets - /// `:` as an idempotency key. Returns lines sent. - pub fn pay(&mut self, rail: &dyn PaymentRail, payer: Option<&str>) -> usize { - let mut sent = 0; - for st in self.lines.iter_mut().filter(|l| l.reference.is_none()) { + /// Send every unpaid line that has an address through the shared + /// payments path ([`crate::payments::pay_all`]): one [`Transfer`] per + /// line, keyed `ads::`, so `shared` (the + /// payments ledger) and the rail both refuse to pay a line twice, and + /// the round stops at `cap`. `payer` is who pays (the campaign owner's + /// key). Returns lines sent. + pub async fn pay( + &mut self, + rail: &dyn crate::payments::Rail, + shared: &crate::payments::Ledger, + payer: &str, + cap: crate::payments::Micros, + ) -> usize { + use crate::payments::{Outcome, Transfer}; + let mut transfers = Vec::new(); + let mut index = Vec::new(); + for (i, st) in self.lines.iter_mut().enumerate().filter(|(_, l)| l.reference.is_none()) { let Some(s) = self.settlements.get(st.settlement as usize) else { continue; }; let Some(p) = s.payouts.get(st.line) else { continue; }; - if p.address.is_empty() { - st.error = Some("no address: owed, not sent".into()); + let Some(payee) = payee_of(&p.address) else { + st.error = Some(if p.address.is_empty() { + "no address: owed, not sent".into() + } else { + format!("not a payout address: {}", p.address) + }); continue; - } - let key = format!("{}:{}", s.hash(), st.line); - match rail.pay(payer, &p.address, p.micros, &key) { - Ok(r) => { - st.reference = Some(r); + }; + transfers.push(Transfer { + key: format!("ads:{}:{}", s.hash(), st.line), + payer: payer.to_string(), + payee, + amount: p.micros, + memo: format!("c0mpute ads {} settlement {} line {}", s.campaign_id, s.seq, st.line), + }); + index.push(i); + } + let outcomes = crate::payments::pay_all(rail, shared, &transfers, cap).await; + let by_key: HashMap = outcomes.into_iter().map(|(t, o)| (t.key, o)).collect(); + let entries = shared.entries(); + let mut sent = 0; + for (t, i) in transfers.iter().zip(index) { + let st = &mut self.lines[i]; + match by_key.get(&t.key) { + Some(Outcome::Paid(p)) => { + st.reference = Some(p.rail_ref.clone()); st.error = None; sent += 1; } - Err(e) => st.error = Some(e.to_string()), + // Paid in an earlier round whose ads ledger was not saved. + Some(Outcome::AlreadyPaid) => { + st.reference = Some(entries.get(&t.key).map(|e| e.rail_ref.clone()).unwrap_or_default()); + st.error = None; + } + Some(Outcome::OverBudget) => { + st.error = Some("over this round's payment cap or the payer's balance; next round retries".into()) + } + Some(Outcome::Failed(e)) => st.error = Some(e.clone()), + None => {} } } sent @@ -819,136 +856,20 @@ impl Ledger { } } -// ── rails ──────────────────────────────────────────────────────────────── - -/// Moves money. `pay` sends `micros` USD millionths to `to` -/// (`:
`) and returns the rail's transfer id. -pub trait PaymentRail: Send + Sync { - fn name(&self) -> &str; - fn pay(&self, from: Option<&str>, to: &str, micros: u64, idempotency_key: &str) -> Result; -} - -/// The default: refuses to move money. -pub struct DisabledRail; - -impl PaymentRail for DisabledRail { - fn name(&self) -> &str { - "disabled" - } - fn pay(&self, _: Option<&str>, _: &str, _: u64, _: &str) -> Result { - bail!("payouts are disabled: set `[ads] payouts = true` and pass --pay to send real money") - } -} - -/// An in-memory rail for tests and dry runs: accounts with balances, every -/// transfer logged, idempotency keys honoured, overdrafts refused. -#[derive(Default)] -pub struct FakeRail { - pub balances: Mutex>, - pub transfers: Mutex>, - keys: Mutex>, -} - -impl FakeRail { - pub fn fund(&self, account: &str, micros: u64) { - *self.balances.lock().unwrap().entry(account.to_string()).or_default() += micros; - } - pub fn balance(&self, account: &str) -> u64 { - self.balances.lock().unwrap().get(account).copied().unwrap_or(0) - } -} - -impl PaymentRail for FakeRail { - fn name(&self) -> &str { - "fake" - } - fn pay(&self, from: Option<&str>, to: &str, micros: u64, key: &str) -> Result { - if let Some(r) = self.keys.lock().unwrap().get(key) { - return Ok(r.clone()); - } - let from = from.context("fake rail needs a payer account")?; - let mut bal = self.balances.lock().unwrap(); - let have = bal.get(from).copied().unwrap_or(0); - if have < micros { - bail!("insufficient funds: {from} has {have}, needs {micros}"); - } - bal.insert(from.to_string(), have - micros); - *bal.entry(to.to_string()).or_default() += micros; - let mut t = self.transfers.lock().unwrap(); - let r = format!("fake-{}", t.len()); - t.push((from.to_string(), to.to_string(), micros, r.clone())); - self.keys.lock().unwrap().insert(key.to_string(), r.clone()); - Ok(r) - } -} - -/// The real rail: `coinpay wallet send --chain --to
-/// --amount-fiat --fiat USD` from the advertiser's own CoinPay wallet -/// (the `coinpay` plugin's CLI; it prompts for the wallet password unless -/// one is configured there). Nothing in c0mpute holds the funds. -pub struct CoinpayCliRail { - pub bin: PathBuf, - pub wallet_file: Option, -} - -impl CoinpayCliRail { - /// `coinpay` on PATH (or `~/.c0mpute/bin`). - pub fn find() -> Option { - let bin = crate::ads::stitch::find_tool("coinpay")?; - Some(CoinpayCliRail { bin, wallet_file: None }) - } +// ── payouts ────────────────────────────────────────────────────────────── - /// The argv for one payout (without the binary). - pub fn args(&self, to: &str, micros: u64) -> Result> { - if !valid_payout(to) { - bail!("not a payout address: {to:?}"); - } - let (chain, addr) = to.split_once(':').expect("checked"); - let mut a = vec![ - "wallet".into(), - "send".into(), - "--chain".into(), - chain.into(), - "--to".into(), - addr.into(), - "--amount-fiat".into(), - usd_decimal(micros), - "--fiat".into(), - "USD".into(), - ]; - if let Some(w) = &self.wallet_file { - a.push("--wallet-file".into()); - a.push(w.display().to_string()); - } - Ok(a) - } -} - -impl PaymentRail for CoinpayCliRail { - fn name(&self) -> &str { - "coinpay" - } - fn pay(&self, _from: Option<&str>, to: &str, micros: u64, key: &str) -> Result { - let out = std::process::Command::new(&self.bin) - .args(self.args(to, micros)?) - .stdin(std::process::Stdio::inherit()) - .stderr(std::process::Stdio::inherit()) - .output() - .with_context(|| format!("run {}", self.bin.display()))?; - if !out.status.success() { - bail!("coinpay wallet send failed ({}) for {key}", out.status); - } - let text = String::from_utf8_lossy(&out.stdout); - // The CLI prints the transaction hash; keep the last token that looks - // like one, else the whole (trimmed) output. - let r = text - .split_whitespace() - .rev() - .find(|t| t.len() >= 16 && t.chars().all(|c| c.is_ascii_alphanumeric())) - .map(String::from) - .unwrap_or_else(|| text.trim().chars().take(200).collect()); - Ok(r) +/// `:
` as a payments [`crate::payments::Payee`]: the part +/// before the first `:` is the CoinPay currency code (`SOL`, `USDC_POL`, +/// ...), the rest the wallet address. +pub fn payee_of(payout: &str) -> Option { + if !valid_payout(payout) { + return None; } + let (coin, wallet) = payout.split_once(':')?; + Some(crate::payments::Payee { + wallet: wallet.to_string(), + coin: coin.to_string(), + }) } /// 1_234_500 -> "1.2345" (USD, trailing zeros dropped). @@ -1056,8 +977,8 @@ mod tests { assert_eq!(FEE_BPS + PUBLISHER_BPS + GATEWAY_BPS, BPS); } - #[test] - fn verify_prices_dedupes_and_settles() { + #[tokio::test] + async fn verify_prices_dedupes_and_settles() { let adv = Keypair::generate_ed25519(); let gw = Keypair::generate_ed25519(); let show_owner = crate::hosting::site::owner_hex(&Keypair::generate_ed25519()).unwrap(); @@ -1090,16 +1011,32 @@ mod tests { verify_claim(&c, &bids(&c), &ledger, &claim, &batches, &shows(&show_owner), "", NOW).unwrap_err(), ClaimError::AlreadySettled { next_seq: 3 } ); - // Paying through the fake rail moves exactly the gross. - let rail = FakeRail::default(); - rail.fund("SOL:advertiserwallet1", MICROS); - assert_eq!(ledger.pay(&rail, Some("SOL:advertiserwallet1")), 3); - assert_eq!(rail.balance("SOL:advertiserwallet1"), MICROS - 8_000); - assert_eq!(rail.balance("SOL:showwallet0001"), 6_000); - assert_eq!(rail.balance("SOL:gatewaywallet1"), 1_600); - assert_eq!(rail.balance("SOL:networkfee0001"), 400); - assert_eq!(ledger.pay(&rail, Some("SOL:advertiserwallet1")), 0, "never paid twice"); + // Paying through the shared fake rail moves exactly the gross. + use crate::payments::{FakeRail, Ledger as PayLedger, Rail}; + let rail = FakeRail::new(); + let shared = PayLedger::in_memory(); + rail.fund(&c.owner, MICROS); + // A round cap below the gross pays what fits and leaves the rest owed. + assert_eq!(ledger.pay(&rail, &shared, &c.owner, 6_000).await, 1); + assert_eq!(ledger.unpaid_micros(), 2_000); + assert!(ledger.lines.iter().any(|l| l.error.as_deref().is_some_and(|e| e.contains("cap")))); + assert_eq!(ledger.pay(&rail, &shared, &c.owner, MICROS).await, 2); + assert_eq!(rail.balance(&c.owner).await.unwrap(), Some(MICROS - 8_000)); + assert_eq!(rail.received("showwallet0001"), 6_000); + assert_eq!(rail.received("gatewaywallet1"), 1_600); + assert_eq!(rail.received("networkfee0001"), 400); + assert!(rail.payments().iter().all(|t| t.payee.coin == "SOL" && t.key.starts_with("ads:"))); + assert_eq!(ledger.pay(&rail, &shared, &c.owner, MICROS).await, 0, "never paid twice"); assert_eq!(ledger.unpaid_micros(), 0); + // An ads ledger that lost its references (crash before save) is + // healed from the shared ledger, not paid again. + let mut stale = ledger.clone(); + for l in &mut stale.lines { + l.reference = None; + } + assert_eq!(stale.pay(&rail, &shared, &c.owner, MICROS).await, 0); + assert_eq!(stale.unpaid_micros(), 0); + assert_eq!(rail.payments().len(), 3); // A later batch whose play was already paid in this claim is a duplicate. let mut log = ReceiptLog::open(None); @@ -1123,7 +1060,7 @@ mod tests { let s2 = ledger.settle(&c, v, Some(&adv), NOW).unwrap(); assert_eq!(s2.prev, s.hash()); assert_eq!(s2.spent_micros, 10_000); - ledger.pay(&rail, Some("SOL:advertiserwallet1")); + ledger.pay(&rail, &shared, &c.owner, MICROS).await; assert_eq!(ledger.unpaid_micros(), 100, "network fee owed, not sent"); } @@ -1286,7 +1223,7 @@ mod tests { } #[test] - fn payouts_budget_and_rail_args() { + fn payouts_budget_and_payees() { assert!(valid_payout("SOL:9xQeWvG816bUx9EPjHmaT23yvVM2ZWbrrpZb9PusVFin")); assert!(valid_payout("BCH:bitcoincash:qpm2qsznhks23z7629mms6s4cwef74vcwvy22gdx6a")); assert!(valid_payout("USDC_POL:0x52908400098527886E0F7030069857D2E4169EE7")); @@ -1304,21 +1241,11 @@ mod tests { assert_eq!(usd_decimal(1_234_500), "1.2345"); assert_eq!(usd_decimal(5 * MICROS), "5"); assert_eq!(usd_decimal(1), "0.000001"); - let rail = CoinpayCliRail { - bin: "coinpay".into(), - wallet_file: None, - }; - assert_eq!( - rail.args("SOL:9xQeWvG816bUx9", 2_500_000).unwrap(), - ["wallet", "send", "--chain", "SOL", "--to", "9xQeWvG816bUx9", "--amount-fiat", "2.5", "--fiat", "USD"] - ); - assert!(rail.args("not an address", 1).is_err()); - assert!(DisabledRail.pay(None, "SOL:9xQeWvG816bUx9", 1, "k").is_err()); - let fake = FakeRail::default(); - fake.fund("a", 10); - assert!(fake.pay(Some("a"), "b", 11, "k1").is_err(), "overdraft refused"); - assert_eq!(fake.pay(Some("a"), "b", 10, "k2").unwrap(), "fake-0"); - assert_eq!(fake.pay(Some("a"), "b", 10, "k2").unwrap(), "fake-0", "idempotent"); - assert_eq!(fake.balance("b"), 10); + let p = payee_of("USDC_POL:0xabc0123456789def").unwrap(); + assert_eq!((p.coin.as_str(), p.wallet.as_str()), ("USDC_POL", "0xabc0123456789def")); + assert!(p.valid()); + assert_eq!(payee_of("SOL:9xQeWvG816bUx9").unwrap().coin, "SOL"); + assert!(payee_of("not an address").is_none()); + assert!(payee_of("").is_none()); } } diff --git a/node/crates/c0mpute-core/src/ads/manage.rs b/node/crates/c0mpute-core/src/ads/manage.rs index ab319d0c..381fb31f 100644 --- a/node/crates/c0mpute-core/src/ads/manage.rs +++ b/node/crates/c0mpute-core/src/ads/manage.rs @@ -536,17 +536,26 @@ pub async fn announce_settlements(ads: &Ads, ledger: &Ledger) { } } +/// The shared payments ledger for a local campaign: every transfer key paid +/// through [`crate::payments`], so a line is never paid twice even if the ads +/// ledger was not saved after a payment. +pub fn payments_ledger_path(name: &str) -> PathBuf { + campaign_dir(name).join("payments.json") +} + /// `c0mpute ads settle`: boot a buyer node, collect claims and receipts, -/// verify, and (with `pay`) settle and pay through `rail`. +/// verify, and (with a rail) settle and pay through the shared payments path, +/// at most `cap` this round. pub async fn settle( record: &CampaignRecord, fee_payout: &str, - rail: Option<&dyn billing::PaymentRail>, + rail: Option<&dyn crate::payments::Rail>, + cap: crate::payments::Micros, wait: Duration, ) -> Result<(SettleRound, Ledger)> { - let budget = record + record .budget - .clone() + .as_ref() .context("this campaign has no budget: c0mpute ads campaign update --budget ")?; let path = ledger_path(&record.name); let mut ledger = Ledger::load(&path, &record.campaign_id)?; @@ -562,7 +571,8 @@ pub async fn settle( let round = settle_round(&ads, &h, record, &bids, &mut ledger, owner, fee_payout, wait).await; if let Some(rail) = rail { ledger.save(&path)?; - ledger.pay(rail, budget.payer.as_deref()); + let shared = crate::payments::Ledger::open(payments_ledger_path(&record.name)); + ledger.pay(rail, &shared, &record.owner, cap).await; ledger.save(&path)?; for _ in 0..3 { announce_settlements(&ads, &ledger).await; diff --git a/node/crates/c0mpute-core/src/ads/mod.rs b/node/crates/c0mpute-core/src/ads/mod.rs index ac9ed366..e82a1918 100644 --- a/node/crates/c0mpute-core/src/ads/mod.rs +++ b/node/crates/c0mpute-core/src/ads/mod.rs @@ -133,7 +133,7 @@ pub struct Options { pub ffprobe: Option, /// Absolute origin for URLs in VAST (else from the request's Host). pub public_origin: Option, - /// Where this gateway is paid for the ads it serves, `:
`. + /// Where this gateway is paid for the ads it serves, `:
`. /// Without one it serves and signs receipts but never claims. pub payout: Option, /// Smallest claim worth making, USD millionths. @@ -247,7 +247,7 @@ impl Ads { let payout = opts.payout.filter(|p| { let ok = billing::valid_payout(p); if !ok { - warn!(payout = %p, "ads: payout is not :
; this gateway will not claim"); + warn!(payout = %p, "ads: payout is not :
; this gateway will not claim"); } ok }); diff --git a/node/crates/c0mpute-core/src/ads/stitch.rs b/node/crates/c0mpute-core/src/ads/stitch.rs index 03a72f33..84ae54fe 100644 --- a/node/crates/c0mpute-core/src/ads/stitch.rs +++ b/node/crates/c0mpute-core/src/ads/stitch.rs @@ -49,7 +49,7 @@ pub struct ShowAds { /// Longest creative the show runs, seconds. #[serde(default = "default_max_ad_secs")] pub max_ad_secs: u64, - /// Where the show's share of ad revenue goes, `:
` + /// Where the show's share of ad revenue goes, `:
` /// ([`super::billing`]). Without one the show's impressions are not paid /// (and not charged to advertisers). #[serde(default, skip_serializing_if = "Option::is_none")] @@ -88,7 +88,7 @@ impl ShowAds { if let Some(p) = &s.payout && !super::billing::valid_payout(p) { - bail!("ads payout must be :
"); + bail!("ads payout must be :
"); } Ok(s) } diff --git a/node/crates/c0mpute-core/src/config.rs b/node/crates/c0mpute-core/src/config.rs index 03b8ec86..3f07c94a 100644 --- a/node/crates/c0mpute-core/src/config.rs +++ b/node/crates/c0mpute-core/src/config.rs @@ -77,18 +77,19 @@ pub struct AdsConfig { #[serde(default = "default_true")] pub podcast_insertion: bool, /// Gateway: where this gateway is paid for ads it serves on billed - /// campaigns, `:
` (e.g. "SOL:9xQe..."). Without it the + /// campaigns, `:
` (e.g. "SOL:9xQe..."). Without it the /// gateway serves and signs receipts but never claims payment. #[serde(default)] pub payout: Option, /// Gateway: smallest claim worth making, USD. #[serde(default = "default_min_claim_usd")] pub min_claim_usd: String, - /// Advertiser: let `c0mpute ads settle --pay` send real money from your - /// CoinPay wallet. Off by default: settle only reports what it would pay. + /// Advertiser: let `c0mpute ads settle --pay` send real money through + /// the node's payment rail (also needs `[payments] enabled = true`). Off + /// by default: settle only reports what it would pay. #[serde(default)] pub payouts: bool, - /// Advertiser: where the 5% network fee is sent, `:
`. + /// Advertiser: where the 5% network fee is sent, `:
`. /// Without it the fee is recorded as owed and not sent. #[serde(default)] pub fee_payout: Option, diff --git a/node/crates/c0mpute-core/src/podcast/show.rs b/node/crates/c0mpute-core/src/podcast/show.rs index 72be15e9..ad4f2a0c 100644 --- a/node/crates/c0mpute-core/src/podcast/show.rs +++ b/node/crates/c0mpute-core/src/podcast/show.rs @@ -76,7 +76,7 @@ pub struct AdsMeta { /// Lowest CPM in USD, e.g. "1.50". pub min_cpm: Option, pub max_ad_secs: Option, - /// Where the show's ad revenue goes, `:
` (e.g. + /// Where the show's ad revenue goes, `:
` (e.g. /// "SOL:9xQe..."). Without it the show runs ads unpaid. pub payout: Option, } @@ -118,7 +118,7 @@ impl AdsMeta { } if let Some(p) = &self.payout { if !crate::ads::billing::valid_payout(p.trim()) { - bail!("[ads] payout must be :
, e.g. \"SOL:9xQe...\""); + bail!("[ads] payout must be :
, e.g. \"SOL:9xQe...\""); } s.payout = Some(p.trim().to_string()); } diff --git a/node/crates/c0mpute-core/tests/ads_billing_e2e.rs b/node/crates/c0mpute-core/tests/ads_billing_e2e.rs index 43c2681c..2ef85780 100644 --- a/node/crates/c0mpute-core/tests/ads_billing_e2e.rs +++ b/node/crates/c0mpute-core/tests/ads_billing_e2e.rs @@ -22,7 +22,8 @@ use std::collections::BTreeMap; use std::sync::Arc; use std::time::Duration; -use c0mpute_core::ads::billing::{Budget, ClaimError, FakeRail, Ledger, Role}; +use c0mpute_core::ads::billing::{Budget, ClaimError, Ledger, Role}; +use c0mpute_core::payments::{FakeRail, Ledger as PayLedger, PaymentsConfig, Rail}; use c0mpute_core::ads::campaign::{MICROS, Placement}; use c0mpute_core::ads::manage::{self, CampaignSpec}; use c0mpute_core::ads::receipt::{Event, Receipt}; @@ -252,8 +253,11 @@ async fn funded_campaign_two_gateways_claims_payouts_and_overspend() { let record = manage::record_from_spec(&spec, &adv_owner, creatives, 1).sign(&adv_key).unwrap(); let cid = record.campaign_id.clone(); let bids = BTreeMap::from([(record.seq, record.max_cpm_micros)]); - let rail = FakeRail::default(); - rail.fund(PAYER, MICROS); // the advertiser's wallet holds $1 + let rail = FakeRail::new(); + let shared = PayLedger::in_memory(); + let cap = PaymentsConfig::default().round_cap(); + let got = |payout: &str| rail.received(payout.split_once(':').unwrap().1); + rail.fund(&adv_owner, MICROS); // the advertiser holds $1 on the rail adv_ads.publish_campaign(record.clone()).await.unwrap(); { let (a, r) = (adv_ads.clone(), record.clone()); @@ -365,14 +369,19 @@ async fn funded_campaign_two_gateways_claims_payouts_and_overspend() { assert_eq!((line(&sb, Role::Gateway), line(&sb, Role::Network), line(&sb, Role::Publisher)), (4_600, 400, 3_000)); assert_eq!(sb.payouts.iter().find(|p| p.role == Role::Publisher).unwrap().key, pod_owner); - assert_eq!(ledger.pay(&rail, Some(PAYER)), 5); + assert_eq!(ledger.pay(&rail, &shared, &adv_owner, cap).await, 5); assert_eq!(ledger.unpaid_micros(), 0); - assert_eq!(rail.balance(PAYER), MICROS - 14_000, "budget spent = money moved"); - assert_eq!(rail.balance(GW_A), 5_700); - assert_eq!(rail.balance(GW_B), 4_600); - assert_eq!(rail.balance(SHOW), 3_000); - assert_eq!(rail.balance(FEE), 700); - assert_eq!(ledger.pay(&rail, Some(PAYER)), 0, "nothing is paid twice"); + assert_eq!(rail.balance(&adv_owner).await.unwrap().unwrap(), MICROS - 14_000, "budget spent = money moved"); + assert_eq!(got(GW_A), 5_700); + assert_eq!(got(GW_B), 4_600); + assert_eq!(got(SHOW), 3_000); + assert_eq!(got(FEE), 700); + assert_eq!(ledger.pay(&rail, &shared, &adv_owner, cap).await, 0, "nothing is paid twice"); + // Every line went through the one shared payments path and its ledger. + assert_eq!(shared.entries().len(), 5); + assert!(shared.entries().keys().all(|k| k.starts_with("ads:"))); + // With payments off (the default) there is no rail at all. + assert!(c0mpute_core::payments::rail_from_config(&PaymentsConfig::default()).unwrap().is_none()); // ── 4. gateways learn the budget left; overspend is refused ────────── { @@ -417,10 +426,10 @@ async fn funded_campaign_two_gateways_claims_payouts_and_overspend() { } ); assert_eq!(ledger.spent_micros, budget_total); - ledger.pay(&rail, Some(PAYER)); - assert_eq!(rail.balance(PAYER), MICROS - budget_total, "never more than the budget"); + ledger.pay(&rail, &shared, &adv_owner, cap).await; + assert_eq!(rail.balance(&adv_owner).await.unwrap().unwrap(), MICROS - budget_total, "never more than the budget"); assert_eq!( - rail.balance(GW_A) + rail.balance(GW_B) + rail.balance(SHOW) + rail.balance(FEE), + got(GW_A) + got(GW_B) + got(SHOW) + got(FEE), budget_total ); // The settlement chain links and every node can check it. diff --git a/plugins/ads-manager/module.toml b/plugins/ads-manager/module.toml index 51006e3b..ade7b096 100644 --- a/plugins/ads-manager/module.toml +++ b/plugins/ads-manager/module.toml @@ -42,9 +42,9 @@ version = "0.2.81" kind = "service" category = "applications" status = "partial" -status_note = "Campaigns, selection, receipts and billing work peer to peer and are tested end to end on a fake payment rail (budgets, second-price receipts, gateway claims, verified settlements, payouts and overspend refusal). Only the switch to real money remains: real payouts run `coinpay wallet send` from the advertiser's wallet and stay off until the advertiser sets `[ads] payouts = true` and runs `c0mpute ads settle --pay` (never exercised with real funds; moving third-party money needs Anthony's approval), and the 5% network fee is only owed, not sent, until `[ads] fee_payout` names c0mpute's fee wallet." +status_note = "Campaigns, selection, receipts and billing work peer to peer and are tested end to end on a fake payment rail (budgets, second-price receipts, gateway claims, verified settlements, payouts and overspend refusal). Only the switch to real money remains: real payouts go through the node's one payment rail (`payments.rs`: CoinPay payouts API, key in `$C0MPUTE_COINPAY_PAYOUT_KEY`) and stay off until the advertiser sets both `[ads] payouts = true` and `[payments] enabled = true` and runs `c0mpute ads settle --pay` (never exercised with real funds; moving third-party money needs Anthony's approval), and the 5% network fee is only owed, not sent, until `[ads] fee_payout` names c0mpute's fee wallet." builtin = true -description = "Peer-to-peer ad operations: advertisers sign campaigns with a budget that gossip to every gateway, creatives live on self-healing hosting copies, each gateway picks ads with an explainable second-price rule, gateways sign chained impression receipts and claim payment over them, and the advertiser verifies and pays gateways and shows from its own CoinPay wallet. No ad server, no clearing house." +description = "Peer-to-peer ad operations: advertisers sign campaigns with a budget that gossip to every gateway, creatives live on self-healing hosting copies, each gateway picks ads with an explainable second-price rule, gateways sign chained impression receipts and claim payment over them, and the advertiser verifies and pays gateways and shows through c0mpute's one payment rail. No ad server, no clearing house." author = "Profullstack" license = "MIT" source = "https://github.com/profullstack/c0mpute/tree/master/node/crates/c0mpute-core/src/ads" diff --git a/plugins/ctv-ads/module.toml b/plugins/ctv-ads/module.toml index 7ffc079d..e338e423 100644 --- a/plugins/ctv-ads/module.toml +++ b/plugins/ctv-ads/module.toml @@ -32,7 +32,7 @@ version = "0.2.81" kind = "service" category = "applications" status = "partial" -status_note = "VAST 4.2 / VMAP serving with signed, priced receipts works on every gateway, and gateways are paid per verified impression through ads-manager billing (tested end to end on a fake rail). Not built: OpenRTB bidding and live-stream (SCTE-35) insertion. Remaining switch: real payouts run `coinpay wallet send` from the advertiser's wallet and stay off until the advertiser sets `[ads] payouts = true` and runs `c0mpute ads settle --pay` (never exercised with real funds; moving third-party money needs Anthony's approval), and the 5% network fee is only owed, not sent, until `[ads] fee_payout` names c0mpute's fee wallet." +status_note = "VAST 4.2 / VMAP serving with signed, priced receipts works on every gateway, and gateways are paid per verified impression through ads-manager billing (tested end to end on a fake rail). Not built: OpenRTB bidding and live-stream (SCTE-35) insertion. Remaining switch: real payouts go through the node's one payment rail (`payments.rs`: CoinPay payouts API, key in `$C0MPUTE_COINPAY_PAYOUT_KEY`) and stay off until the advertiser sets both `[ads] payouts = true` and `[payments] enabled = true` and runs `c0mpute ads settle --pay` (never exercised with real funds; moving third-party money needs Anthony's approval), and the 5% network fee is only owed, not sent, until `[ads] fee_payout` names c0mpute's fee wallet." builtin = true description = "Video ads for connected TV and web players: every c0mpute gateway answers VAST 4.2 and VMAP requests from the gossiped campaign index, serves the video creative from hosting copies, and turns impression, quartile and click beacons into signed receipts." author = "Profullstack" diff --git a/plugins/podcast-ads/module.toml b/plugins/podcast-ads/module.toml index aeb00c1d..92787e6a 100644 --- a/plugins/podcast-ads/module.toml +++ b/plugins/podcast-ads/module.toml @@ -20,7 +20,7 @@ # impression receipt per ad. No ffmpeg, no fill or any failure: the clean # episode, with x-c0mpute-ads saying why. # -# Payout: a show names `payout = ":
"` in [ads] (or +# Payout: a show names `payout = ":
"` in [ads] (or # --ads-payout); it is inside the signed opt-in, so no gateway can redirect # it. Each paid impression on the show pays it 75% of the cleared price # (ads-manager billing). A show without a payout runs ads unpaid. @@ -35,7 +35,7 @@ version = "0.2.81" kind = "service" category = "applications" status = "partial" -status_note = "Dynamic ad insertion into opted-in podcasts works on gateways with ffmpeg, and shows that name a payout address earn 75% of each paid impression through ads-manager billing (tested end to end on a fake rail). Not built: a DAAST endpoint. Remaining switch: real payouts run `coinpay wallet send` from the advertiser's wallet and stay off until the advertiser sets `[ads] payouts = true` and runs `c0mpute ads settle --pay` (never exercised with real funds; moving third-party money needs Anthony's approval), and the 5% network fee is only owed, not sent, until `[ads] fee_payout` names c0mpute's fee wallet." +status_note = "Dynamic ad insertion into opted-in podcasts works on gateways with ffmpeg, and shows that name a payout address earn 75% of each paid impression through ads-manager billing (tested end to end on a fake rail). Not built: a DAAST endpoint. Remaining switch: real payouts go through the node's one payment rail (`payments.rs`: CoinPay payouts API, key in `$C0MPUTE_COINPAY_PAYOUT_KEY`) and stay off until the advertiser sets both `[ads] payouts = true` and `[payments] enabled = true` and runs `c0mpute ads settle --pay` (never exercised with real funds; moving third-party money needs Anthony's approval), and the 5% network fee is only owed, not sent, until `[ads] fee_payout` names c0mpute's fee wallet." builtin = true description = "Dynamic ad insertion for c0mpute-hosted podcasts: shows opt in with a signed flag (and payout address) in their own site, any gateway with ffmpeg splices a matching audio ad before, inside or after the episode, and the show is paid for each verified impression." author = "Profullstack"