Skip to main content

qualia_core_db/services/swarm/
settlement.rs

1//! Escrow + settlement — the money side, gated on verification, and **incapable of
2//! moving funds itself**.
3//!
4//! An [`Escrow`] is a small state machine over the agreed price. It is funded
5//! (`Offered → Held`), then settled (`Held → ReleasedToProvider | RefundedToRequester`)
6//! **only** by a verification verdict. On `Verified` it emits a
7//! [`MicropaymentInstruction`] addressed to the provider; on `Rejected` it emits a
8//! refund and **no** provider payment. Emitting an instruction is not the same as
9//! executing it: the actual transfer is performed by the separate
10//! [`crate::ilp_dispatcher`] rail, under human authorisation. Nothing here touches a
11//! wallet, a connector, or the network.
12//!
13//! The money arithmetic (fair-price cap, energy viability) reuses
14//! [`crate::modalities::value_flow`] — the single source of truth — rather than
15//! re-implementing it.
16
17use super::verify::VerificationVerdict;
18use super::SwarmError;
19use crate::modalities::value_flow::{commons_cost, eroi_viable};
20use crate::rpc::MicropaymentInstruction;
21
22/// Escrow lifecycle. A job is paid only by passing left-to-right through `Verified`.
23#[derive(Debug, Clone, Copy, PartialEq, Eq)]
24pub enum EscrowState {
25    /// Price agreed, funds not yet committed.
26    Offered,
27    /// Funds notionally locked pending verification (tracked here, not moved).
28    Held,
29    /// Verified → a payment instruction to the provider was emitted.
30    ReleasedToProvider,
31    /// Rejected (or failed) → the hold returns to the requester; provider unpaid.
32    RefundedToRequester,
33}
34
35/// What settlement produced. A `Pay` carries an instruction for the existing ILP rail;
36/// a `Refund` carries nothing payable.
37#[derive(Debug, Clone, PartialEq)]
38pub enum SettlementOutcome {
39    /// Provider is owed payment — hand this to [`crate::ilp_dispatcher`] to execute.
40    Pay(MicropaymentInstruction),
41    /// No provider payment; the held amount returns to the requester.
42    Refund {
43        amount_micro_units: u64,
44        reason: &'static str,
45    },
46}
47
48/// An escrow for one paid job.
49#[derive(Debug, Clone, PartialEq)]
50pub struct Escrow {
51    pub job_id: u64,
52    pub requester_did: u64,
53    pub provider_did: u64,
54    pub amount_micro_units: u64,
55    /// Where the provider is paid if verified (an ILP payment pointer / address).
56    pub provider_ilp: String,
57    pub use_nym: bool,
58    pub state: EscrowState,
59}
60
61impl Escrow {
62    /// Open an escrow in the `Offered` state for an agreed price.
63    pub fn offer(
64        job_id: u64,
65        requester_did: u64,
66        provider_did: u64,
67        amount_micro_units: u64,
68        provider_ilp: impl Into<String>,
69        use_nym: bool,
70    ) -> Self {
71        Self {
72            job_id,
73            requester_did,
74            provider_did,
75            amount_micro_units,
76            provider_ilp: provider_ilp.into(),
77            use_nym,
78            state: EscrowState::Offered,
79        }
80    }
81
82    /// Commit the funds to escrow (`Offered → Held`). Tracked only — no transfer.
83    pub fn hold(&mut self) -> Result<(), SwarmError> {
84        if self.state != EscrowState::Offered {
85            return Err(SwarmError::InvalidEscrowState);
86        }
87        self.state = EscrowState::Held;
88        Ok(())
89    }
90
91    /// Settle the escrow according to a verification verdict. Must be `Held`.
92    ///
93    /// * `Verified` → `ReleasedToProvider`, returns the provider payment instruction.
94    /// * `Rejected` → `RefundedToRequester`, returns a refund (no provider payment).
95    ///
96    /// This is the only path to a provider payment, and it is impossible to reach
97    /// without a `Verified` verdict.
98    pub fn settle(
99        &mut self,
100        verdict: VerificationVerdict,
101    ) -> Result<SettlementOutcome, SwarmError> {
102        if self.state != EscrowState::Held {
103            return Err(SwarmError::InvalidEscrowState);
104        }
105        match verdict {
106            VerificationVerdict::Verified { .. } => {
107                self.state = EscrowState::ReleasedToProvider;
108                Ok(SettlementOutcome::Pay(MicropaymentInstruction {
109                    recipient_label: format!("swarm-provider:{:016x}", self.provider_did),
110                    ilp_address: self.provider_ilp.clone(),
111                    amount_micro_cents: self.amount_micro_units,
112                    use_nym: self.use_nym,
113                }))
114            }
115            VerificationVerdict::Rejected { reason } => {
116                self.state = EscrowState::RefundedToRequester;
117                Ok(SettlementOutcome::Refund {
118                    amount_micro_units: self.amount_micro_units,
119                    reason,
120                })
121            }
122        }
123    }
124}
125
126/// Fair price for a paid job: the audited production (energy) cost plus a **capped**
127/// ROI margin (the extraction guard), via [`commons_cost`]. The price never exceeds
128/// `production_cost × (1 + max_roi%)`.
129pub fn price_paid_job(production_cost: u64, roi_cap_percent: u64, max_roi_percent: u64) -> u64 {
130    commons_cost(production_cost, roi_cap_percent, max_roi_percent)
131}
132
133/// The **solar-excess viability gate**: a paid job should only be dispatched to an
134/// energy-supplier node if the value returned justifies the energy spent — E-ROI at or
135/// above `min_ratio`. Below the floor the job is net-extractive and must be refused.
136/// Reuses [`eroi_viable`] (the thermodynamic cost cap).
137pub fn energy_viable(value_returned: u64, energy_invested: u64, min_ratio: f32) -> bool {
138    eroi_viable(value_returned, energy_invested, min_ratio)
139}
140
141#[cfg(test)]
142mod tests {
143    use super::*;
144
145    fn held_escrow() -> Escrow {
146        let mut e = Escrow::offer(0xB0B_u64, 1, 2, 500, "$ilp.solar.node/pay", false);
147        e.hold().unwrap();
148        e
149    }
150
151    #[test]
152    fn verified_releases_a_payment_instruction() {
153        let mut e = held_escrow();
154        let out = e
155            .settle(VerificationVerdict::Verified { confidence: 0.999 })
156            .unwrap();
157        assert_eq!(e.state, EscrowState::ReleasedToProvider);
158        match out {
159            SettlementOutcome::Pay(instr) => {
160                assert_eq!(instr.amount_micro_cents, 500);
161                assert_eq!(instr.ilp_address, "$ilp.solar.node/pay");
162            }
163            _ => panic!("verified must produce a payment instruction"),
164        }
165    }
166
167    #[test]
168    fn rejected_refunds_and_pays_no_provider() {
169        let mut e = held_escrow();
170        let out = e
171            .settle(VerificationVerdict::Rejected {
172                reason: "A·B ≠ C (Freivalds)",
173            })
174            .unwrap();
175        assert_eq!(e.state, EscrowState::RefundedToRequester);
176        assert!(matches!(
177            out,
178            SettlementOutcome::Refund {
179                amount_micro_units: 500,
180                ..
181            }
182        ));
183    }
184
185    #[test]
186    fn cannot_settle_before_holding() {
187        let mut e = Escrow::offer(1, 1, 2, 500, "$ilp/x", false); // still Offered
188        assert_eq!(
189            e.settle(VerificationVerdict::Verified { confidence: 1.0 })
190                .unwrap_err(),
191            SwarmError::InvalidEscrowState
192        );
193    }
194
195    #[test]
196    fn cannot_double_settle() {
197        let mut e = held_escrow();
198        e.settle(VerificationVerdict::Verified { confidence: 1.0 })
199            .unwrap();
200        // A second settle is now an invalid transition (already released).
201        assert!(e
202            .settle(VerificationVerdict::Verified { confidence: 1.0 })
203            .is_err());
204    }
205
206    #[test]
207    fn price_is_roi_capped() {
208        // 1000 energy cost, asked 50% ROI, cap 20% → 1200.
209        assert_eq!(price_paid_job(1000, 50, 20), 1200);
210    }
211
212    #[test]
213    fn energy_gate_refuses_net_extractive_jobs() {
214        assert!(energy_viable(300, 100, 2.0)); // 3× return
215        assert!(!energy_viable(150, 100, 2.0)); // 1.5× < floor → refuse
216    }
217}