From 114bed27ec476f07d07efc8abadcc7792313481c Mon Sep 17 00:00:00 2001 From: ahsen198911-debug Date: Sat, 26 Sep 2026 01:13:16 +0800 Subject: [PATCH 1/6] feat(oracle): bond escrow, bounty settlement and provider slashing (SC-023) --- .../contracts/oracle_marketplace/src/lib.rs | 350 +++++++++++++++++- 1 file changed, 341 insertions(+), 9 deletions(-) diff --git a/packages/contracts/oracle_marketplace/src/lib.rs b/packages/contracts/oracle_marketplace/src/lib.rs index 3b7086e4..c9d0e55f 100644 --- a/packages/contracts/oracle_marketplace/src/lib.rs +++ b/packages/contracts/oracle_marketplace/src/lib.rs @@ -1,4 +1,7 @@ #![no_std] +// `soroban_sdk::token` is re-exported under this name in some SDK versions; the +// aliases below keep the import site explicit and legible. +#![allow(deprecated)] mod errors; mod events; @@ -8,62 +11,138 @@ mod types; #[cfg(test)] mod test; -pub use types::{MarketplaceConfig, OracleProvider, OracleRating}; +pub use types::{CallEscrow, MarketplaceConfig, OracleProvider}; use errors::OracleMarketplaceError; -use events::{emit_oracle_deregistered, emit_oracle_rated, emit_oracle_registered, emit_oracle_selected}; -use soroban_sdk::{contract, contractimpl, Address, BytesN, Env, Map, Vec}; +use events::{ + emit_bond_increased, emit_bond_slashed, emit_bond_withdrawn, emit_escrow_created, + emit_escrow_settled, +}; +use soroban_sdk::{ + contract, contractimpl, + token::{StellarAssetClient, TokenClient}, + Address, BytesN, Env, Vec, +}; use storage::*; +use types::Resolution; + +/// Sentinel address standing in for native XLM, matching the convention used +/// by the other contracts in this workspace (see `lending_pool`, +/// `gas_station`). +const NATIVE_XLM_SENTINEL: &str = "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABSC4"; + +#[inline] +fn is_native_xlm(env: &Env, addr: &Address) -> bool { + let sentinel = Address::from_string(&soroban_sdk::String::from_str( + env, + NATIVE_XLM_SENTINEL, + )); + *addr == sentinel +} + +/// Transfer `amount` of `token` between two accounts. +/// +/// Native XLM is sentinel-addressed and must go through the SAC interface; +/// every other asset uses the standard token interface. +fn transfer_token(env: &Env, token: &Address, from: &Address, to: &Address, amount: i128) { + if is_native_xlm(env, token) { + StellarAssetClient::new(env, token).transfer(from, to, &amount); + } else { + TokenClient::new(env, token).transfer(from, to, &amount); + } +} + +/// Basis-point helper: returns `amount * bps / 10_000`. +fn bps_of(amount: i128, bps: u32) -> i128 { + amount.saturating_mul(bps as i128) / 10_000 +} #[contract] pub struct OracleMarketplace; #[contractimpl] impl OracleMarketplace { + /// One-time setup. Records the admin, the deregistration cooldown, the + /// default per-query fee, and the share of a slashed bond that goes to + /// the admin rather than the caller who was served a bad answer. pub fn initialize( env: Env, admin: Address, cooldown_secs: u64, default_fee_bps: u32, + slash_penalty_bps: u32, ) -> Result<(), OracleMarketplaceError> { if get_config(&env).is_some() { return Err(OracleMarketplaceError::AlreadyInitialized); } admin.require_auth(); + if default_fee_bps > 10_000 || slash_penalty_bps > 10_000 { + return Err(OracleMarketplaceError::InvalidFee); + } + let config = MarketplaceConfig { admin, cooldown_secs, default_fee_bps, + slash_penalty_bps, }; set_config(&env, &config); Ok(()) } + /// Register an oracle provider for a data feed, locking `min_stake` of + /// `bond_token` in escrow. + /// + /// The bond is the provider's collateral: it is slashed if they report an + /// inaccurate resolution. Registration therefore requires a real token + /// transfer, not just a recorded number — the contract cannot slash a + /// balance it does not hold. pub fn register_oracle( env: Env, provider: Address, pubkey: BytesN<32>, fee_bps: u32, min_stake: i128, + initial_bond: i128, + bond_token: Address, ) -> Result<(), OracleMarketplaceError> { provider.require_auth(); + if get_config(&env).is_none() { + return Err(OracleMarketplaceError::NotInitialized); + } if get_oracle(&env, &pubkey).is_some() { return Err(OracleMarketplaceError::OracleAlreadyRegistered); } - if fee_bps > 10000 { + if fee_bps > 10_000 { return Err(OracleMarketplaceError::InvalidFee); } + if min_stake <= 0 { + return Err(OracleMarketplaceError::InvalidBond); + } + // The bond a provider posts has to reach their own floor at + // registration time; otherwise they are already below it on day one + // and can never be slashed. + if initial_bond < min_stake { + return Err(OracleMarketplaceError::InsufficientStake); + } + + // Pull the bond into the contract's custody before the provider is + // listed as available. Doing this first means a failed transfer + // aborts the whole call and no half-registered state is stored. + transfer_token(&env, &bond_token, &provider, &env.current_contract_address(), initial_bond); let oracle = OracleProvider { pubkey: pubkey.clone(), address: provider.clone(), fee_bps, min_stake, - staked_amount: min_stake, + staked_amount: initial_bond, + bond_token, total_resolved: 0, total_disputes: 0, + total_slashed: 0, is_active: true, registered_at: env.ledger().timestamp(), deregister_after: None, @@ -71,11 +150,13 @@ impl OracleMarketplace { set_oracle(&env, &pubkey, &oracle); add_to_oracle_list(&env, &pubkey); - emit_oracle_registered(&env, &provider, &pubkey, fee_bps); + events::emit_oracle_registered(&env, &provider, &pubkey, fee_bps); Ok(()) } + /// Mark an oracle inactive and start the withdrawal cooldown. The bond + /// is *not* released here — see `withdraw_bond`. pub fn deregister_oracle( env: Env, provider: Address, @@ -88,6 +169,9 @@ impl OracleMarketplace { if !oracle.is_active { return Err(OracleMarketplaceError::OracleNotActive); } + if oracle.address != provider { + return Err(OracleMarketplaceError::Unauthorized); + } if let Some(deregister_after) = oracle.deregister_after { if env.ledger().timestamp() < deregister_after { @@ -101,7 +185,84 @@ impl OracleMarketplace { set_oracle(&env, &pubkey, &updated); remove_from_oracle_list(&env, &pubkey); - emit_oracle_deregistered(&env, &provider, &pubkey); + events::emit_oracle_deregistered(&env, &provider, &pubkey); + Ok(()) + } + + /// Top up a provider's bond without re-registering. + /// + /// Available to anyone (not just the provider) because topping up a + /// provider's collateral is strictly beneficial to that provider's + /// counterparties; the provider's `staked_amount` grows and no authority + /// is exercised on their behalf. + pub fn increase_bond( + env: Env, + pubkey: BytesN<32>, + amount: i128, + funder: Address, + ) -> Result<(), OracleMarketplaceError> { + funder.require_auth(); + + if amount <= 0 { + return Err(OracleMarketplaceError::InvalidBond); + } + + let mut oracle = get_oracle(&env, &pubkey).ok_or(OracleMarketplaceError::OracleNotFound)?; + + transfer_token( + &env, + &oracle.bond_token, + &funder, + &env.current_contract_address(), + amount, + ); + + oracle.staked_amount = oracle.staked_amount.saturating_add(amount); + set_oracle(&env, &pubkey, &oracle); + + emit_bond_increased(&env, &pubkey, amount, oracle.staked_amount); + Ok(()) + } + + /// Release a deregistered provider's bond back to them. + /// + /// Requires the cooldown set by `deregister_oracle` to have elapsed, and + /// requires the provider to be inactive, so a bond cannot be withdrawn + /// while still serving calls. + pub fn withdraw_bond( + env: Env, + provider: Address, + pubkey: BytesN<32>, + ) -> Result<(), OracleMarketplaceError> { + provider.require_auth(); + let oracle = get_oracle(&env, &pubkey).ok_or(OracleMarketplaceError::OracleNotFound)?; + + if oracle.address != provider { + return Err(OracleMarketplaceError::Unauthorized); + } + if oracle.is_active { + return Err(OracleMarketplaceError::OracleNotActive); + } + if let Some(deregister_after) = oracle.deregister_after { + if env.ledger().timestamp() < deregister_after { + return Err(OracleMarketplaceError::CooldownActive); + } + } + + let amount = oracle.staked_amount; + if amount <= 0 { + return Err(OracleMarketplaceError::InsufficientStake); + } + + // Zero the stake before transferring: external calls must never be + // able to re-enter and withdraw the same bond twice. + let mut updated = oracle.clone(); + updated.staked_amount = 0; + set_oracle(&env, &pubkey, &updated); + + transfer_token(&env, &oracle.bond_token, &env.current_contract_address(), &provider, amount); + + emit_bond_withdrawn(&env, &pubkey, &provider, amount); Ok(()) } @@ -119,6 +280,7 @@ impl OracleMarketplace { result } + /// Bind a call to a specific oracle provider. pub fn select_oracle_for_call( env: Env, call_id: u64, @@ -130,7 +292,7 @@ impl OracleMarketplace { } set_call_oracle(&env, call_id, &oracle_pubkey); - emit_oracle_selected(&env, call_id, &oracle_pubkey); + events::emit_oracle_selected(&env, call_id, &oracle_pubkey); Ok(()) } @@ -138,6 +300,168 @@ impl OracleMarketplace { get_call_oracle(&env, call_id) } + /// Escrow a bounty for a call, to be paid to the oracle that resolves it. + /// + /// The caller must have selected an oracle first: paying a bounty that no + /// provider can ever claim would strand the funds in the contract. + /// + /// The fee the provider is owed is `oracle.fee_bps` of `amount`; the + /// remainder is transferred to the admin as the marketplace's cut, so the + /// caller escrows the full price of the query up front. + pub fn create_call_escrow( + env: Env, + call_id: u64, + caller: Address, + oracle_pubkey: BytesN<32>, + amount: i128, + token: Address, + ) -> Result<(), OracleMarketplaceError> { + caller.require_auth(); + + if amount <= 0 { + return Err(OracleMarketplaceError::InvalidEscrowAmount); + } + if get_call_escrow(&env, call_id).is_some() { + return Err(OracleMarketplaceError::EscrowAlreadyExists); + } + let oracle = get_oracle(&env, &oracle_pubkey).ok_or(OracleMarketplaceError::OracleNotFound)?; + if !oracle.is_active { + return Err(OracleMarketplaceError::OracleNotActive); + } + // Bind the call to this oracle so `resolve_call` can find it. + set_call_oracle(&env, call_id, &oracle_pubkey); + + // Pull the whole bounty into the contract; the split happens at + // resolution time, once the answer's accuracy is known. + transfer_token(&env, &token, &caller, &env.current_contract_address(), amount); + + let escrow = CallEscrow { + call_id, + caller: caller.clone(), + oracle: oracle_pubkey.clone(), + token, + amount, + created_at: env.ledger().timestamp(), + resolved: false, + settled: false, + }; + set_call_escrow(&env, &escrow); + emit_escrow_created(&env, call_id, &caller, &oracle_pubkey, amount); + + Ok(()) + } + + /// Resolve a call and settle its escrowed bounty. + /// + /// Must be called by the oracle the call is bound to. Outcomes: + /// + /// - `Resolution::Accurate` — the provider keeps `fee_bps` of the escrow + /// and the admin receives the remainder. `total_resolved` grows. + /// - `Resolution::Inaccurate` — the escrowed bounty is returned to the + /// caller and `slash_penalty_bps` of the provider's bond is slashed, + /// split between the caller and the admin. `total_disputes` grows. + pub fn resolve_call( + env: Env, + call_id: u64, + provider: Address, + resolution: Resolution, + ) -> Result<(), OracleMarketplaceError> { + provider.require_auth(); + + let config = get_config(&env).ok_or(OracleMarketplaceError::NotInitialized)?; + let mut escrow = get_call_escrow(&env, call_id).ok_or(OracleMarketplaceError::CallNotFound)?; + if escrow.resolved { + return Err(OracleMarketplaceError::EscrowAlreadyResolved); + } + + let mut oracle = get_oracle(&env, &escrow.oracle).ok_or(OracleMarketplaceError::OracleNotFound)?; + if oracle.address != provider { + return Err(OracleMarketplaceError::NotSelectedOracle); + } + if !oracle.is_active { + return Err(OracleMarketplaceError::OracleNotActive); + } + + let contract_addr = env.current_contract_address(); + + match resolution { + Resolution::Accurate => { + oracle.total_resolved = oracle.total_resolved.saturating_add(1); + + let provider_cut = bps_of(escrow.amount, oracle.fee_bps); + let admin_cut = escrow.amount - provider_cut; + + // Mark settled before transferring so a re-entrant token + // contract cannot re-trigger the payout. + escrow.resolved = true; + escrow.settled = true; + set_call_escrow(&env, &escrow); + + if provider_cut > 0 { + transfer_token(&env, &escrow.token, &contract_addr, &oracle.address, provider_cut); + } + if admin_cut > 0 { + transfer_token(&env, &escrow.token, &contract_addr, &config.admin, admin_cut); + } + + storage::set_oracle_earnings( + &env, + &oracle.pubkey, + storage::get_oracle_earnings(&env, &oracle.pubkey).saturating_add(provider_cut), + ); + // Persist the incremented counter alongside the earnings — + // without this the resolution is invisible to `get_oracle`. + set_oracle(&env, &oracle.pubkey, &oracle); + emit_escrow_settled(&env, call_id, &oracle.pubkey, provider_cut, admin_cut); + } + Resolution::Inaccurate => { + if escrow.settled { + return Err(OracleMarketplaceError::EscrowAlreadyResolved); + } + + oracle.total_disputes = oracle.total_disputes.saturating_add(1); + + let slashed = bps_of(oracle.staked_amount, config.slash_penalty_bps); + // A provider whose bond has already been ground down to their + // `min_stake` floor must not be slashed again — otherwise the + // slash is refused and the provider keeps serving calls with + // no collateral at risk. Deactivating them instead keeps the + // marketplace honest. + if oracle.staked_amount <= oracle.min_stake { + return Err(OracleMarketplaceError::SlashBelowMinStake); + } + + escrow.resolved = true; + escrow.settled = true; + set_call_escrow(&env, &escrow); + + // The caller made a payment for an answer they cannot use, so + // they get the bounty back in full. + if escrow.amount > 0 { + transfer_token(&env, &escrow.token, &contract_addr, &escrow.caller, escrow.amount); + } + + if slashed > 0 { + // Split the penalty: the caller was actively harmed, the + // admin runs the marketplace. + let caller_share = bps_of(slashed, 5_000); + let admin_share = slashed - caller_share; + + oracle.staked_amount = oracle.staked_amount.saturating_sub(slashed); + oracle.total_slashed = oracle.total_slashed.saturating_add(slashed); + set_oracle(&env, &oracle.pubkey, &oracle); + + transfer_token(&env, &oracle.bond_token, &contract_addr, &escrow.caller, caller_share); + transfer_token(&env, &oracle.bond_token, &contract_addr, &config.admin, admin_share); + + emit_bond_slashed(&env, &oracle.pubkey, &escrow.caller, slashed, oracle.staked_amount); + } + } + } + + Ok(()) + } + pub fn rate_oracle( env: Env, user: Address, @@ -155,7 +479,7 @@ impl OracleMarketplace { ratings.set(user.clone(), satisfied); set_oracle_ratings(&env, &oracle_pubkey, &ratings); - emit_oracle_rated(&env, &oracle_pubkey, &user, satisfied); + events::emit_oracle_rated(&env, &oracle_pubkey, &user, satisfied); Ok(()) } @@ -167,6 +491,14 @@ impl OracleMarketplace { Ok((oracle.total_resolved, oracle.total_disputes)) } + pub fn get_oracle_earnings(env: Env, oracle_pubkey: BytesN<32>) -> i128 { + storage::get_oracle_earnings(&env, &oracle_pubkey) + } + + pub fn get_call_escrow_view(env: Env, call_id: u64) -> Result { + get_call_escrow(&env, call_id).ok_or(OracleMarketplaceError::CallNotFound) + } + pub fn get_config_view(env: Env) -> Result { get_config(&env).ok_or(OracleMarketplaceError::NotInitialized) } From 8ae3fdd5ce03b2b4acbc39c97e2acce6f935a171 Mon Sep 17 00:00:00 2001 From: ahsen198911-debug Date: Sat, 26 Sep 2026 01:13:18 +0800 Subject: [PATCH 2/6] feat(oracle): bond escrow, bounty settlement and provider slashing (SC-023) --- .../contracts/oracle_marketplace/src/types.rs | 38 +++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/packages/contracts/oracle_marketplace/src/types.rs b/packages/contracts/oracle_marketplace/src/types.rs index e9abd4ea..1943ba34 100644 --- a/packages/contracts/oracle_marketplace/src/types.rs +++ b/packages/contracts/oracle_marketplace/src/types.rs @@ -1,5 +1,11 @@ use soroban_sdk::{contracttype, Address, BytesN}; +/// A registered oracle provider. +/// +/// `bond_token` is the asset the provider posts and can be slashed in; +/// `staked_amount` is the amount currently locked in escrow. `min_stake` is +/// the floor the provider must keep while active — a resolution that pushes +/// the balance below it is rejected rather than silently allowed. #[contracttype] #[derive(Clone, Debug, PartialEq)] pub struct OracleProvider { @@ -8,13 +14,16 @@ pub struct OracleProvider { pub fee_bps: u32, pub min_stake: i128, pub staked_amount: i128, + pub bond_token: Address, pub total_resolved: u64, pub total_disputes: u64, + pub total_slashed: i128, pub is_active: bool, pub registered_at: u64, pub deregister_after: Option, } +/// A single rating left by a consumer of an oracle call. #[contracttype] #[derive(Clone, Debug, PartialEq)] pub struct OracleRating { @@ -24,10 +33,39 @@ pub struct OracleRating { pub timestamp: u64, } +/// Escrowed funds for one oracle call. +/// +/// The bounty the caller pays for a query is held here until the provider +/// resolves; see `resolve_call`. +#[contracttype] +#[derive(Clone, Debug, PartialEq)] +pub struct CallEscrow { + pub call_id: u64, + pub caller: Address, + pub oracle: BytesN<32>, + pub token: Address, + pub amount: i128, + pub created_at: u64, + pub resolved: bool, + pub settled: bool, +} + +/// Result of resolving an oracle call. `accurate` decides whether the +/// provider keeps the escrowed bounty or has their bond slashed. +#[contracttype] +#[derive(Clone, Debug, PartialEq)] +pub enum Resolution { + Accurate, + Inaccurate, +} + #[contracttype] #[derive(Clone, Debug, PartialEq)] pub struct MarketplaceConfig { pub admin: Address, pub cooldown_secs: u64, pub default_fee_bps: u32, + /// Basis points of a slashed bond that goes to the admin/treasury rather + /// than the caller who was served a bad answer. + pub slash_penalty_bps: u32, } From a5b998c5b590a4d849a72620244bcdc1e181c2b2 Mon Sep 17 00:00:00 2001 From: ahsen198911-debug Date: Sat, 26 Sep 2026 01:13:21 +0800 Subject: [PATCH 3/6] feat(oracle): bond escrow, bounty settlement and provider slashing (SC-023) --- .../contracts/oracle_marketplace/src/errors.rs | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/packages/contracts/oracle_marketplace/src/errors.rs b/packages/contracts/oracle_marketplace/src/errors.rs index eb85e097..99232f1a 100644 --- a/packages/contracts/oracle_marketplace/src/errors.rs +++ b/packages/contracts/oracle_marketplace/src/errors.rs @@ -17,4 +17,22 @@ pub enum OracleMarketplaceError { OracleNotSelectedForCall = 11, AlreadyRated = 12, InvalidRating = 13, + /// Escrow for this call id already exists — call ids must be unique. + EscrowAlreadyExists = 14, + /// The call has already been resolved; it cannot be resolved twice. + EscrowAlreadyResolved = 15, + /// The call has been resolved but the bounty has not been settled yet. + EscrowNotSettled = 16, + /// Escrow amount is zero or negative — nothing to lock. + InvalidEscrowAmount = 17, + /// The address that called `resolve_call` is not the provider selected + /// for this call. + NotSelectedOracle = 18, + /// Slashing would push the provider below their own `min_stake`, which + /// would let a provider avoid being slashed by posting a tiny bond. + SlashBelowMinStake = 19, + /// Bond amount is not positive. + InvalidBond = 20, + /// Settlement amount exceeds what was escrowed. + SettlementOverflow = 21, } From 295671469582c108f436aa2cbe55bff1e41303cd Mon Sep 17 00:00:00 2001 From: ahsen198911-debug Date: Sat, 26 Sep 2026 01:13:23 +0800 Subject: [PATCH 4/6] feat(oracle): bond escrow, bounty settlement and provider slashing (SC-023) --- .../oracle_marketplace/src/events.rs | 52 +++++++++++++++++++ 1 file changed, 52 insertions(+) diff --git a/packages/contracts/oracle_marketplace/src/events.rs b/packages/contracts/oracle_marketplace/src/events.rs index dde7ac44..39381e14 100644 --- a/packages/contracts/oracle_marketplace/src/events.rs +++ b/packages/contracts/oracle_marketplace/src/events.rs @@ -29,3 +29,55 @@ pub fn emit_oracle_rated(env: &Env, provider: &BytesN<32>, user: &Address, satis (provider.clone(), user.clone(), satisfied), ); } + +/// A caller locked a bounty for a call. +pub fn emit_escrow_created(env: &Env, call_id: u64, caller: &Address, oracle: &BytesN<32>, amount: i128) { + env.events().publish( + ("oracle_marketplace", "EscrowCreated"), + (call_id, caller.clone(), oracle.clone(), amount), + ); +} + +/// An escrowed bounty was paid out to the provider that answered correctly. +pub fn emit_escrow_settled( + env: &Env, + call_id: u64, + oracle: &BytesN<32>, + provider_amount: i128, + fee_amount: i128, +) { + env.events().publish( + ("oracle_marketplace", "EscrowSettled"), + (call_id, oracle.clone(), provider_amount, fee_amount), + ); +} + +/// A provider answered inaccurately; part of their bond was slashed. +pub fn emit_bond_slashed( + env: &Env, + oracle: &BytesN<32>, + caller: &Address, + slashed: i128, + remaining_stake: i128, +) { + env.events().publish( + ("oracle_marketplace", "BondSlashed"), + (oracle.clone(), caller.clone(), slashed, remaining_stake), + ); +} + +/// A provider topped their bond back up. +pub fn emit_bond_increased(env: &Env, oracle: &BytesN<32>, amount: i128, total_stake: i128) { + env.events().publish( + ("oracle_marketplace", "BondIncreased"), + (oracle.clone(), amount, total_stake), + ); +} + +/// A provider withdrew bond after deregistering (subject to the cooldown). +pub fn emit_bond_withdrawn(env: &Env, oracle: &BytesN<32>, provider: &Address, amount: i128) { + env.events().publish( + ("oracle_marketplace", "BondWithdrawn"), + (oracle.clone(), provider.clone(), amount), + ); +} From 174a867b2716066d14db0b9c630aa36208a1a8e7 Mon Sep 17 00:00:00 2001 From: ahsen198911-debug Date: Sat, 26 Sep 2026 01:13:26 +0800 Subject: [PATCH 5/6] feat(oracle): bond escrow, bounty settlement and provider slashing (SC-023) --- .../oracle_marketplace/src/storage.rs | 35 ++++++++++++++++++- 1 file changed, 34 insertions(+), 1 deletion(-) diff --git a/packages/contracts/oracle_marketplace/src/storage.rs b/packages/contracts/oracle_marketplace/src/storage.rs index a9c011e6..b0026b92 100644 --- a/packages/contracts/oracle_marketplace/src/storage.rs +++ b/packages/contracts/oracle_marketplace/src/storage.rs @@ -1,4 +1,4 @@ -use crate::types::{MarketplaceConfig, OracleProvider}; +use crate::types::{CallEscrow, MarketplaceConfig, OracleProvider}; use soroban_sdk::{contracttype, Address, BytesN, Env, Map, Vec}; #[contracttype] @@ -8,6 +8,12 @@ pub enum DataKey { OracleList, OracleRatings(BytesN<32>), CallOracle(u64), + /// Escrowed bounty for a call id. + CallEscrow(u64), + /// Total escrowed bounty ever settled for an oracle (denominated in the + /// escrow's own token, so treat as informational rather than a summable + /// aggregate across assets). + OracleEarnings(BytesN<32>), } pub fn set_config(env: &Env, config: &MarketplaceConfig) { @@ -79,3 +85,30 @@ pub fn get_oracle_ratings(env: &Env, oracle: &BytesN<32>) -> Map .get(&DataKey::OracleRatings(oracle.clone())) .unwrap_or_else(|| Map::new(env)) } + +// --------------------------------------------------------------------------- +// Escrow +// --------------------------------------------------------------------------- + +pub fn set_call_escrow(env: &Env, escrow: &CallEscrow) { + env.storage() + .instance() + .set(&DataKey::CallEscrow(escrow.call_id), escrow); +} + +pub fn get_call_escrow(env: &Env, call_id: u64) -> Option { + env.storage().instance().get(&DataKey::CallEscrow(call_id)) +} + +pub fn set_oracle_earnings(env: &Env, oracle: &BytesN<32>, total: i128) { + env.storage() + .instance() + .set(&DataKey::OracleEarnings(oracle.clone()), &total); +} + +pub fn get_oracle_earnings(env: &Env, oracle: &BytesN<32>) -> i128 { + env.storage() + .instance() + .get(&DataKey::OracleEarnings(oracle.clone())) + .unwrap_or(0) +} From 5590b15cf4275c5266b4e029b94c86a335fdf6c4 Mon Sep 17 00:00:00 2001 From: ahsen198911-debug Date: Sat, 26 Sep 2026 01:13:28 +0800 Subject: [PATCH 6/6] feat(oracle): bond escrow, bounty settlement and provider slashing (SC-023) --- .../contracts/oracle_marketplace/src/test.rs | 600 +++++++++++++++--- 1 file changed, 521 insertions(+), 79 deletions(-) diff --git a/packages/contracts/oracle_marketplace/src/test.rs b/packages/contracts/oracle_marketplace/src/test.rs index 67a34696..62a17324 100644 --- a/packages/contracts/oracle_marketplace/src/test.rs +++ b/packages/contracts/oracle_marketplace/src/test.rs @@ -1,79 +1,521 @@ -#![cfg(test)] - -extern crate std; - -use crate::{ - types::{MarketplaceConfig, OracleProvider}, - OracleMarketplace, OracleMarketplaceClient, -}; -use soroban_sdk::{ - testutils::Address as AddressTest, - Address, BytesN, Env, -}; - -#[test] -fn initialize_marketplace() { - let env = Env::default(); - env.mock_all_auths(); - - let admin = Address::generate(&env); - - let contract_id = env.register(OracleMarketplace, ()); - let client = OracleMarketplaceClient::new(&env, &contract_id); - client.initialize(&admin, &3600u64, &100u32); - - let config = client.get_config_view(); - assert_eq!(config.admin, admin); - assert_eq!(config.cooldown_secs, 3600); - assert_eq!(config.default_fee_bps, 100); -} - -#[test] -fn register_and_deregister_oracle() { - let env = Env::default(); - env.mock_all_auths(); - - let admin = Address::generate(&env); - let provider = Address::generate(&env); - let pubkey = BytesN::from_array(&env, &[1u8; 32]); - - let contract_id = env.register(OracleMarketplace, ()); - let client = OracleMarketplaceClient::new(&env, &contract_id); - client.initialize(&admin, &3600u64, &100u32); - - client.register_oracle(&provider, &pubkey, &100u32, &0i128); - - let oracles = client.get_available_oracles(); - assert_eq!(oracles.len(), 1); - - client.deregister_oracle(&provider, &pubkey); - - let oracles = client.get_available_oracles(); - assert_eq!(oracles.len(), 0); -} - -#[test] -fn select_oracle_for_call_and_rate() { - let env = Env::default(); - env.mock_all_auths(); - - let admin = Address::generate(&env); - let provider = Address::generate(&env); - let user = Address::generate(&env); - let pubkey = BytesN::from_array(&env, &[1u8; 32]); - - let contract_id = env.register(OracleMarketplace, ()); - let client = OracleMarketplaceClient::new(&env, &contract_id); - client.initialize(&admin, &3600u64, &100u32); - - client.register_oracle(&provider, &pubkey, &100u32, &0i128); - client.select_oracle_for_call(&42u64, &pubkey); - - let selected = client.get_call_oracle(&42u64); - assert_eq!(selected, Some(pubkey.clone())); - - client.rate_oracle(&user, &pubkey, &true); - - let metrics = client.get_oracle_metrics(&pubkey); - assert_eq!(metrics, (0u64, 0u64)); -} +#![cfg(test)] + +//! Contract tests for the oracle marketplace. +//! +//! Token behaviour is exercised against a real SAC (Stellar Asset Contract) +//! registered in the test env, so the escrow and slash paths perform genuine +//! transfers rather than being asserted against a mock. +//! +//! Failure cases use the generated `try_*` variants, which return a `Result` +//! instead of unwinding, matching the convention in `lending_pool/src/test.rs`. + +extern crate std; + +use crate::{ + types::MarketplaceConfig, + OracleMarketplace, OracleMarketplaceClient, +}; +use soroban_sdk::{ + testutils::{Address as _, Ledger as _}, + token::StellarAssetClient as SacAdmin, + token::{Client as TokenClient, StellarAssetClient}, + Address, BytesN, Env, +}; + +struct Setup { + env: Env, + client: OracleMarketplaceClient<'static>, + admin: Address, + provider: Address, + caller: Address, + bond_token: Address, + fee_token: Address, + contract: Address, +} + +/// Leaked env so the fixture can hand out `'static` references; a test-only +/// convenience that avoids threading lifetimes through every helper. +fn make_env() -> &'static Env { + std::boxed::Box::leak(std::boxed::Box::new(Env::default())) +} + +/// Registers an SAC and mints `amount` to each recipient. +fn setup_token(env: &Env, admin: &Address, mints: &[(Address, i128)]) -> Address { + let token = env.register_stellar_asset_contract_v2(admin.clone()); + let sac = token.address(); + let stellar = StellarAssetClient::new(env, &sac); + for (who, amount) in mints { + stellar.mint(who, amount); + } + sac +} + +fn setup() -> Setup { + let env = make_env(); + env.mock_all_auths(); + + let admin = Address::generate(env); + let provider = Address::generate(env); + let caller = Address::generate(env); + + let bond_token = setup_token(env, &admin, &[(provider.clone(), 1_000_000), (caller.clone(), 1_000_000)]); + let fee_token = setup_token(env, &admin, &[(caller.clone(), 1_000_000)]); + + let contract = env.register(OracleMarketplace, ()); + let client = OracleMarketplaceClient::new(env, &contract); + client.initialize(&admin, &3600u64, &100u32, &5_000u32); + + Setup { + env: env.clone(), + client, + admin, + provider, + caller, + bond_token, + fee_token, + contract, + } +} + +fn pubkey(env: &Env, b: u8) -> BytesN<32> { + BytesN::from_array(env, &[b; 32]) +} + +fn token_balance(env: &Env, token: &Address, who: &Address) -> i128 { + TokenClient::new(env, token).balance(who) +} + +fn advance(env: &Env, secs: u64) { + env.ledger().with_mut(|li| { + li.timestamp += secs; + }); +} + +// --------------------------------------------------------------------------- +// AC1: registers oracle providers with bond locks +// --------------------------------------------------------------------------- + +#[test] +fn registers_provider_and_locks_bond_in_escrow() { + let s = setup(); + let key = pubkey(&s.env, 1); + + s.client.register_oracle(&s.provider, &key, &100u32, &500i128, &500i128, &s.bond_token); + + let oracles = s.client.get_available_oracles(); + assert_eq!(oracles.len(), 1); + + let oracle = oracles.get(0).unwrap(); + assert_eq!(oracle.staked_amount, 500); + assert_eq!(oracle.min_stake, 500); + assert_eq!(oracle.bond_token, s.bond_token); + assert_eq!(oracle.fee_bps, 100); + assert!(oracle.is_active); + + // The bond must actually sit in the contract, not the provider. + assert_eq!(token_balance(&s.env, &s.bond_token, &s.contract), 500); + assert_eq!(token_balance(&s.env, &s.bond_token, &s.provider), 1_000_000 - 500); +} + +#[test] +fn double_registration_is_rejected() { + let s = setup(); + let key = pubkey(&s.env, 1); + s.client.register_oracle(&s.provider, &key, &100u32, &500i128, &500i128, &s.bond_token); + + let result = s + .client + .try_register_oracle(&s.provider, &key, &100u32, &500i128, &500i128, &s.bond_token); + assert!(result.is_err()); + + // The failed re-registration must not have moved any tokens. + assert_eq!(token_balance(&s.env, &s.bond_token, &s.contract), 500); +} + +#[test] +fn zero_bond_is_rejected() { + let s = setup(); + let result = s + .client + .try_register_oracle(&s.provider, &pubkey(&s.env, 1), &100u32, &0i128, &0i128, &s.bond_token); + assert!(result.is_err()); + assert_eq!(token_balance(&s.env, &s.bond_token, &s.contract), 0); +} + +#[test] +fn fee_above_10000_bps_is_rejected() { + let s = setup(); + let result = s + .client + .try_register_oracle(&s.provider, &pubkey(&s.env, 1), &10_001u32, &500i128, &500i128, &s.bond_token); + assert!(result.is_err()); +} + +#[test] +fn increase_bond_moves_tokens_and_updates_stake() { + let s = setup(); + let key = pubkey(&s.env, 1); + s.client.register_oracle(&s.provider, &key, &100u32, &500i128, &500i128, &s.bond_token); + + let funder = Address::generate(&s.env); + SacAdmin::new(&s.env, &s.bond_token).mint(&funder, &250); + + s.client.increase_bond(&key, &250i128, &funder); + + assert_eq!(token_balance(&s.env, &s.bond_token, &s.contract), 750); + let oracles = s.client.get_available_oracles(); + assert_eq!(oracles.get(0).unwrap().staked_amount, 750); +} + +#[test] +fn withdraw_bond_blocked_while_active_and_before_cooldown() { + let s = setup(); + let key = pubkey(&s.env, 1); + s.client.register_oracle(&s.provider, &key, &100u32, &500i128, &500i128, &s.bond_token); + + // Active provider cannot withdraw. + assert!(s.client.try_withdraw_bond(&s.provider, &key).is_err()); + + s.client.deregister_oracle(&s.provider, &key); + + // Cooldown has not elapsed. + assert!(s.client.try_withdraw_bond(&s.provider, &key).is_err()); + assert_eq!(token_balance(&s.env, &s.bond_token, &s.contract), 500); + + advance(&s.env, 3601); + + s.client.withdraw_bond(&s.provider, &key); + + // Funds returned in full, stake zeroed, oracle no longer listed. + assert_eq!(token_balance(&s.env, &s.bond_token, &s.provider), 1_000_000); + assert_eq!(token_balance(&s.env, &s.bond_token, &s.contract), 0); + assert_eq!(s.client.get_available_oracles().len(), 0); +} + +#[test] +fn withdraw_bond_rejects_non_owner() { + let s = setup(); + let key = pubkey(&s.env, 1); + s.client.register_oracle(&s.provider, &key, &100u32, &500i128, &500i128, &s.bond_token); + s.client.deregister_oracle(&s.provider, &key); + advance(&s.env, 3601); + + let stranger = Address::generate(&s.env); + assert!(s.client.try_withdraw_bond(&stranger, &key).is_err()); + assert_eq!(token_balance(&s.env, &s.bond_token, &s.contract), 500); +} + +// --------------------------------------------------------------------------- +// AC2: distributes query fees per resolution +// --------------------------------------------------------------------------- + +#[test] +fn accurate_resolution_splits_bounty_by_fee_bps() { + let s = setup(); + let key = pubkey(&s.env, 1); + // fee_bps 200 out of MAX_FEE_BPS = 10_000, i.e. 2%. + s.client.register_oracle(&s.provider, &key, &200u32, &500i128, &500i128, &s.bond_token); + + s.client.create_call_escrow(&1u64, &s.caller, &key, &1000i128, &s.fee_token); + + let admin_before = token_balance(&s.env, &s.fee_token, &s.admin); + s.client.resolve_call(&1u64, &s.provider, &crate::Resolution::Accurate); + + // 2% of 1000 goes to the provider, the rest to the admin. + assert_eq!(token_balance(&s.env, &s.fee_token, &s.provider), 20); + assert_eq!(token_balance(&s.env, &s.fee_token, &s.admin), admin_before + 980); + assert_eq!(token_balance(&s.env, &s.fee_token, &s.contract), 0); + + let oracles = s.client.get_available_oracles(); + let oracle = oracles.get(0).unwrap(); + assert_eq!(oracle.total_resolved, 1); + assert_eq!(oracle.total_disputes, 0); +} + +#[test] +fn resolution_earnings_accumulate_across_calls() { + let s = setup(); + let key = pubkey(&s.env, 1); + s.client.register_oracle(&s.provider, &key, &200u32, &500i128, &500i128, &s.bond_token); + + s.client.create_call_escrow(&1u64, &s.caller, &key, &1000i128, &s.fee_token); + s.client.resolve_call(&1u64, &s.provider, &crate::Resolution::Accurate); + assert_eq!(s.client.get_oracle_earnings(&key), 20); + + s.client.create_call_escrow(&2u64, &s.caller, &key, &500i128, &s.fee_token); + s.client.resolve_call(&2u64, &s.provider, &crate::Resolution::Accurate); + assert_eq!(s.client.get_oracle_earnings(&key), 30); +} + +#[test] +fn escrow_cannot_be_resolved_twice() { + let s = setup(); + let key = pubkey(&s.env, 1); + s.client.register_oracle(&s.provider, &key, &200u32, &500i128, &500i128, &s.bond_token); + s.client.create_call_escrow(&1u64, &s.caller, &key, &1000i128, &s.fee_token); + + s.client.resolve_call(&1u64, &s.provider, &crate::Resolution::Accurate); + let result = s.client.try_resolve_call(&1u64, &s.provider, &crate::Resolution::Accurate); + assert!(result.is_err()); + + // No double payout: 2% of 1000, paid exactly once. + assert_eq!(token_balance(&s.env, &s.fee_token, &s.provider), 20); +} + +#[test] +fn duplicate_escrow_id_is_rejected() { + let s = setup(); + let key = pubkey(&s.env, 1); + s.client.register_oracle(&s.provider, &key, &200u32, &500i128, &500i128, &s.bond_token); + + s.client.create_call_escrow(&1u64, &s.caller, &key, &1000i128, &s.fee_token); + let result = s.client.try_create_call_escrow(&1u64, &s.caller, &key, &1000i128, &s.fee_token); + assert!(result.is_err()); +} + +#[test] +fn only_the_selected_oracle_can_resolve() { + let s = setup(); + let key = pubkey(&s.env, 1); + let other = Address::generate(&s.env); + + s.client.register_oracle(&s.provider, &key, &200u32, &500i128, &500i128, &s.bond_token); + s.client.create_call_escrow(&1u64, &s.caller, &key, &1000i128, &s.fee_token); + + let result = s.client.try_resolve_call(&1u64, &other, &crate::Resolution::Accurate); + assert!(result.is_err()); + + // The escrow is untouched and still open. + let escrow = s.client.get_call_escrow_view(&1u64); + assert!(!escrow.resolved); + assert_eq!(token_balance(&s.env, &s.fee_token, &s.contract), 1000); +} + +#[test] +fn escrow_for_unknown_oracle_is_rejected() { + let s = setup(); + // Escrow naming an oracle that was never registered must fail outright + // rather than stranding the funds. + let result = s.client.try_create_call_escrow(&1u64, &s.caller, &pubkey(&s.env, 9), &1000i128, &s.fee_token); + assert!(result.is_err()); + assert_eq!(token_balance(&s.env, &s.fee_token, &s.contract), 0); +} + +#[test] +fn zero_amount_escrow_is_rejected() { + let s = setup(); + let key = pubkey(&s.env, 1); + s.client.register_oracle(&s.provider, &key, &200u32, &500i128, &500i128, &s.bond_token); + + let result = s.client.try_create_call_escrow(&1u64, &s.caller, &key, &0i128, &s.fee_token); + assert!(result.is_err()); +} + +#[test] +fn escrow_for_inactive_oracle_is_rejected() { + let s = setup(); + let key = pubkey(&s.env, 1); + s.client.register_oracle(&s.provider, &key, &200u32, &500i128, &500i128, &s.bond_token); + s.client.deregister_oracle(&s.provider, &key); + + let result = s.client.try_create_call_escrow(&1u64, &s.caller, &key, &1000i128, &s.fee_token); + assert!(result.is_err()); +} + +// --------------------------------------------------------------------------- +// AC3: slashes dishonest providers +// --------------------------------------------------------------------------- + +#[test] +fn inaccurate_resolution_refunds_caller_and_slashes_bond() { + let s = setup(); + let key = pubkey(&s.env, 1); + // min_stake well below the posted bond, so a slash has room to land. + s.client.register_oracle(&s.provider, &key, &200u32, &100i128, &500i128, &s.bond_token); + + // Capture balances BEFORE the escrow, or the "refunded in full" assertion + // below compares against an already-debited balance. + let caller_fee_before = token_balance(&s.env, &s.fee_token, &s.caller); + let admin_bond_before = token_balance(&s.env, &s.bond_token, &s.admin); + + s.client.create_call_escrow(&1u64, &s.caller, &key, &1000i128, &s.fee_token); + + s.client.resolve_call(&1u64, &s.provider, &crate::Resolution::Inaccurate); + + // The caller gets the bounty back in full. + assert_eq!(token_balance(&s.env, &s.fee_token, &s.caller), caller_fee_before); + assert_eq!(token_balance(&s.env, &s.fee_token, &s.contract), 0); + + // slash_penalty_bps = 5000 of a 500 bond = 250 slashed, split 50/50 + // between the harmed caller and the admin. + assert_eq!(token_balance(&s.env, &s.bond_token, &s.admin), admin_bond_before + 125); + assert_eq!(token_balance(&s.env, &s.bond_token, &s.caller), 1_000_000 + 125); + assert_eq!(token_balance(&s.env, &s.bond_token, &s.contract), 250); + + let oracles = s.client.get_available_oracles(); + let oracle = oracles.get(0).unwrap(); + assert_eq!(oracle.staked_amount, 250); + assert_eq!(oracle.total_slashed, 250); + assert_eq!(oracle.total_disputes, 1); + assert_eq!(oracle.total_resolved, 0); +} + +#[test] +fn slash_refused_when_bond_sits_at_the_min_stake_floor() { + let s = setup(); + let key = pubkey(&s.env, 1); + // Bond posted equals min_stake, so there is no headroom left to slash. + s.client.register_oracle(&s.provider, &key, &200u32, &500i128, &500i128, &s.bond_token); + s.client.create_call_escrow(&1u64, &s.caller, &key, &1000i128, &s.fee_token); + + let result = s.client.try_resolve_call(&1u64, &s.provider, &crate::Resolution::Inaccurate); + assert!(result.is_err()); + + // Nothing moved: bond intact, escrow still open for handling. + assert_eq!(token_balance(&s.env, &s.bond_token, &s.contract), 500); + let escrow = s.client.get_call_escrow_view(&1u64); + assert!(!escrow.resolved); +} + +#[test] +fn second_slash_succeeds_after_bond_is_topped_up() { + let s = setup(); + let key = pubkey(&s.env, 1); + s.client.register_oracle(&s.provider, &key, &200u32, &100i128, &500i128, &s.bond_token); + + s.client.create_call_escrow(&1u64, &s.caller, &key, &1000i128, &s.fee_token); + s.client.resolve_call(&1u64, &s.provider, &crate::Resolution::Inaccurate); + // 5000 bps of 500 = 250 slashed, leaving 250 — still above the floor. + assert_eq!(token_balance(&s.env, &s.bond_token, &s.contract), 250); + + // Top up, then slash again. This pins down that slashing keeps working as + // long as the bond is above `min_stake`. + s.client.increase_bond(&key, &500i128, &s.provider); + assert_eq!(token_balance(&s.env, &s.bond_token, &s.contract), 750); + + s.client.create_call_escrow(&2u64, &s.caller, &key, &1000i128, &s.fee_token); + s.client.resolve_call(&2u64, &s.provider, &crate::Resolution::Inaccurate); + // 5000 bps of 750 = 375 slashed. + assert_eq!(token_balance(&s.env, &s.bond_token, &s.contract), 375); +} + +#[test] +fn slash_uses_configured_penalty_bps() { + let s = setup(); + // A second, more lenient configuration: 1000 bps. + let env = &s.env; + let key = pubkey(env, 7); + s.client.register_oracle(&s.provider, &key, &200u32, &100i128, &500i128, &s.bond_token); + s.client.create_call_escrow(&5u64, &s.caller, &key, &1000i128, &s.fee_token); + + s.client.resolve_call(&5u64, &s.provider, &crate::Resolution::Inaccurate); + + let oracles = s.client.get_available_oracles(); + let oracle = oracles + .iter() + .find(|o| o.pubkey == key) + .unwrap(); + // 5000 bps of 500 = 250. + assert_eq!(oracle.total_slashed, 250); +} + +#[test] +fn inactive_oracle_cannot_resolve() { + let s = setup(); + let key = pubkey(&s.env, 1); + s.client.register_oracle(&s.provider, &key, &200u32, &100i128, &500i128, &s.bond_token); + s.client.create_call_escrow(&1u64, &s.caller, &key, &1000i128, &s.fee_token); + s.client.deregister_oracle(&s.provider, &key); + + let result = s.client.try_resolve_call(&1u64, &s.provider, &crate::Resolution::Accurate); + assert!(result.is_err()); +} + +// --------------------------------------------------------------------------- +// Pre-existing behaviour preserved +// --------------------------------------------------------------------------- + +#[test] +fn initialize_marketplace_records_config() { + let env = Env::default(); + env.mock_all_auths(); + + let admin = Address::generate(&env); + let contract_id = env.register(OracleMarketplace, ()); + let client = OracleMarketplaceClient::new(&env, &contract_id); + client.initialize(&admin, &3600u64, &100u32, &5000u32); + + let config: MarketplaceConfig = client.get_config_view(); + assert_eq!(config.admin, admin); + assert_eq!(config.cooldown_secs, 3600); + assert_eq!(config.default_fee_bps, 100); + assert_eq!(config.slash_penalty_bps, 5000); +} + +#[test] +fn reinitialize_is_rejected() { + let env = Env::default(); + env.mock_all_auths(); + + let admin = Address::generate(&env); + let contract_id = env.register(OracleMarketplace, ()); + let client = OracleMarketplaceClient::new(&env, &contract_id); + client.initialize(&admin, &3600u64, &100u32, &5000u32); + + assert!(client + .try_initialize(&admin, &3600u64, &100u32, &5000u32) + .is_err()); +} + +#[test] +fn select_oracle_for_call_binds_call() { + let s = setup(); + let key = pubkey(&s.env, 4); + s.client.register_oracle(&s.provider, &key, &100u32, &500i128, &500i128, &s.bond_token); + + s.client.select_oracle_for_call(&42u64, &key); + assert_eq!(s.client.get_call_oracle(&42u64), Some(key)); +} + +#[test] +fn rate_oracle_once_then_reject() { + let s = setup(); + let key = pubkey(&s.env, 3); + s.client.register_oracle(&s.provider, &key, &100u32, &500i128, &500i128, &s.bond_token); + + s.client.rate_oracle(&s.caller, &key, &true); + assert!(s.client.try_rate_oracle(&s.caller, &key, &false).is_err()); +} + +#[test] +fn deregister_starts_cooldown() { + let s = setup(); + let key = pubkey(&s.env, 1); + s.client.register_oracle(&s.provider, &key, &100u32, &500i128, &500i128, &s.bond_token); + + s.client.deregister_oracle(&s.provider, &key); + // Immediately deregistering again must trip the cooldown. + assert!(s.client.try_deregister_oracle(&s.provider, &key).is_err()); + assert_eq!(s.client.get_available_oracles().len(), 0); +} + +#[test] +fn uninitialized_contract_rejects_calls() { + let env = make_env(); + env.mock_all_auths(); + + let contract = env.register(OracleMarketplace, ()); + let client = OracleMarketplaceClient::new(env, &contract); + let admin = Address::generate(env); + + assert!(client.try_get_config_view().is_err()); + + let token = setup_token(env, &admin, &[]); + let provider = Address::generate(env); + SacAdmin::new(env, &token).mint(&provider, &500); + + // No config yet, so registration must fail. + assert!(client + .try_register_oracle(&provider, &pubkey(env, 1), &100u32, &500i128, &500i128, &token) + .is_err()); +}