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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions contracts/settlement/src/errors.rs
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,9 @@ use soroban_sdk::contracterror;
/// | 21 | TimelockNotExpired | Migration delay has not elapsed |
/// | 22 | MigrationBalanceChanged | Approved amount is no longer available |
/// | 23 | OverDraft | Withdrawal amount exceeds the developer's balance |
/// | 24 | InvalidClaimWindow | Claim window start > end |
/// | 25 | ClaimWindowClosed | Current timestamp is outside the claim window |
/// | 26 | ReplayDetected | Settlement ledger_seq is not greater than HWM |
#[contracterror]
#[derive(Clone, Copy, Debug, PartialEq)]
#[repr(u32)]
Expand Down Expand Up @@ -60,4 +63,5 @@ pub enum SettlementError {
OverDraft = 23,
InvalidClaimWindow = 24,
ClaimWindowClosed = 25,
ReplayDetected = 26,
}
23 changes: 23 additions & 0 deletions contracts/settlement/src/lib.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
#![no_std]
pub mod archive;
pub mod replay_guard;
use soroban_sdk::{contract, contractimpl, contracttype, Address, Env};

#[contracttype]
Expand Down Expand Up @@ -214,12 +215,26 @@ impl CalloraSettlement {
to_pool: bool,
developer: Option<Address>,
token: Address,
ledger_seq: u32,
) {
caller.require_auth();
Self::require_authorized_caller(env.clone(), caller.clone());
if amount <= 0 {
env.panic_with_error(SettlementError::AmountNotPositive);
}

// Replay guard: reject duplicate / out-of-order settlement claims.
if to_pool {
replay_guard::check_pool(&env, ledger_seq)
.unwrap_or_else(|e| env.panic_with_error(e));
} else {
let dev = developer.clone().unwrap_or_else(|| {
env.panic_with_error(SettlementError::DeveloperRequired)
});
replay_guard::check_developer(&env, &dev, ledger_seq)
.unwrap_or_else(|e| env.panic_with_error(e));
}

let inst = env.storage().instance();
if to_pool {
if developer.is_some() {
Expand Down Expand Up @@ -343,6 +358,7 @@ impl CalloraSettlement {
caller: Address,
items: Vec<(Address, i128)>,
token: Address,
ledger_seq: u32,
) {
caller.require_auth();
Self::require_authorized_caller(env.clone(), caller.clone());
Expand All @@ -357,6 +373,13 @@ impl CalloraSettlement {
assert!(amount > 0, "amount must be positive");
}

// Replay guard: validate ALL developer HWMs before any state change.
for item in items.iter() {
let (dev, _) = item;
replay_guard::check_developer(&env, &dev, ledger_seq)
.unwrap_or_else(|e| { env.panic_with_error(e) });
}

let inst = env.storage().instance();

for item in items.iter() {
Expand Down
237 changes: 237 additions & 0 deletions contracts/settlement/src/replay_guard.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,237 @@
//! Ledger-sequence high-water-mark replay protection.
//!
//! Stores the last-applied ledger sequence per developer (and a global-pool
//! HWM) and rejects any settlement claim at a lower or equal sequence.
//!
//! # Storage
//!
//! * `StorageKey::HighWaterMark(Address)` — persistent storage; TTL bumped on
//! each write to match the developer-balance lifecycle.
//! * `StorageKey::PoolHighWaterMark` — instance storage (lives as long as the
//! contract instance).
//!
//! # Reorg threat model
//!
//! If a chain reorg replays a settlement transaction the vault-provided
//! `ledger_seq` will be the same (or lower) than the stored HWM. The guard
//! rejects the call, preventing a double credit.

use soroban_sdk::{Address, Env};

use crate::{SettlementError, StorageKey};

/// Persistent TTL parameters – kept in lockstep with the developer-balance
/// entry TTL (50 000 ledgers live, 50 000 threshold).
pub const HWM_LIVE: u32 = 50_000;
pub const HWM_THRESHOLD: u32 = 50_000;

/// Validate a settlement claim for `developer`.
///
/// Returns [`SettlementError::ReplayDetected`] when `ledger_seq <= stored`
/// high-water mark. On success the stored mark is raised to `ledger_seq` and
/// the persistent TTL is extended.
pub fn check_developer(env: &Env, developer: &Address, ledger_seq: u32) -> Result<(), SettlementError> {
let key = StorageKey::HighWaterMark(developer.clone());
let stored: u32 = env.storage().persistent().get(&key).unwrap_or(0);

if ledger_seq <= stored {
return Err(SettlementError::ReplayDetected);
}

env.storage().persistent().set(&key, &ledger_seq);
env.storage()
.persistent()
.extend_ttl(&key, HWM_THRESHOLD, HWM_LIVE);

Ok(())
}

/// Validate a settlement claim for the global pool.
///
/// Behaviour is identical to [`check_developer`] but the HWM is stored in
/// instance storage (no per-developer key).
pub fn check_pool(env: &Env, ledger_seq: u32) -> Result<(), SettlementError> {
let key = StorageKey::PoolHighWaterMark;
let stored: u32 = env.storage().instance().get(&key).unwrap_or(0);

if ledger_seq <= stored {
return Err(SettlementError::ReplayDetected);
}

env.storage().instance().set(&key, &ledger_seq);

Ok(())
}

#[cfg(test)]
mod tests {
extern crate std;

use super::*;
use crate::{CalloraSettlement, CalloraSettlementClient};
use soroban_sdk::testutils::Address as _;
use soroban_sdk::Env;

fn setup() -> (Env, Address, Address, Address) {
let env = Env::default();
env.mock_all_auths();
let admin = Address::generate(&env);
let vault = Address::generate(&env);
let addr = env.register(CalloraSettlement, ());
let client = CalloraSettlementClient::new(&env, &addr);
client.init(&admin, &vault);
(env, addr, vault, admin)
}

fn dev(env: &Env) -> Address {
Address::generate(env)
}

fn token(env: &Env) -> Address {
Address::generate(env)
}

/// Normal progression – strictly increasing sequences pass.
#[test]
fn test_hwm_accepts_strictly_increasing() {
let (env, addr, vault, _admin) = setup();
let client = CalloraSettlementClient::new(&env, &addr);
let d = dev(&env);
let t = token(&env);

client.receive_payment(&vault, &100i128, &false, &Some(d.clone()), &t, &10u32);
assert_eq!(client.get_developer_balance(&d, &t), 100);

client.receive_payment(&vault, &200i128, &false, &Some(d.clone()), &t, &20u32);
assert_eq!(client.get_developer_balance(&d, &t), 300);
}

/// Equal ledger_seq is rejected.
#[test]
fn test_hwm_rejects_equal_seq() {
let (env, addr, vault, _admin) = setup();
let client = CalloraSettlementClient::new(&env, &addr);
let d = dev(&env);
let t = token(&env);

client.receive_payment(&vault, &100i128, &false, &Some(d.clone()), &t, &10u32);

let result = client.try_receive_payment(
&vault,
&50i128,
&false,
&Some(d.clone()),
&t,
&10u32,
);
assert!(
result.is_err(),
"equal ledger_seq should be rejected"
);
}

/// Lower ledger_seq is rejected.
#[test]
fn test_hwm_rejects_lower_seq() {
let (env, addr, vault, _admin) = setup();
let client = CalloraSettlementClient::new(&env, &addr);
let d = dev(&env);
let t = token(&env);

client.receive_payment(&vault, &100i128, &false, &Some(d.clone()), &t, &20u32);

let result = client.try_receive_payment(
&vault,
&50i128,
&false,
&Some(d.clone()),
&t,
&5u32,
);
assert!(
result.is_err(),
"lower ledger_seq should be rejected"
);
}

/// Different developers have independent HWMs.
#[test]
fn test_hwm_independent_per_developer() {
let (env, addr, vault, _admin) = setup();
let client = CalloraSettlementClient::new(&env, &addr);
let d1 = dev(&env);
let d2 = dev(&env);
let t = token(&env);

client.receive_payment(&vault, &100i128, &false, &Some(d1.clone()), &t, &10u32);
client.receive_payment(&vault, &200i128, &false, &Some(d2.clone()), &t, &10u32);

assert_eq!(client.get_developer_balance(&d1, &t), 100);
assert_eq!(client.get_developer_balance(&d2, &t), 200);
}

/// Pool payments use their own HWM.
#[test]
fn test_hwm_pool_independent() {
let (env, addr, vault, _admin) = setup();
let client = CalloraSettlementClient::new(&env, &addr);
let t = token(&env);

client.receive_payment(&vault, &1000i128, &true, &None, &t, &10u32);

let result = client.try_receive_payment(&vault, &500i128, &true, &None, &t, &10u32);
assert!(result.is_err(), "equal pool ledger_seq should be rejected");

client.receive_payment(&vault, &500i128, &true, &None, &t, &20u32);
assert_eq!(client.get_global_pool().total_balance, 1500);
}

/// Reorg scenario: same transaction replayed after a reorg that returns to
/// the same ledger sequence is caught by the guard.
#[test]
fn test_hwm_reorg_replay_rejected() {
let (env, addr, vault, _admin) = setup();
let client = CalloraSettlementClient::new(&env, &addr);
let d = dev(&env);
let t = token(&env);

client.receive_payment(&vault, &500i128, &false, &Some(d.clone()), &t, &42u32);
assert_eq!(client.get_developer_balance(&d, &t), 500);

// Reorg replay: same payload at same ledger_seq
let result = client.try_receive_payment(
&vault,
&500i128,
&false,
&Some(d.clone()),
&t,
&42u32,
);
assert!(
result.is_err(),
"reorg replay with same ledger_seq must be rejected"
);

assert_eq!(client.get_developer_balance(&d, &t), 500);
}

/// Batch payments: all developers in the batch must have their HWM
/// checked independently.
#[test]
fn test_hwm_batch_payment() {
let (env, addr, vault, _admin) = setup();
let client = CalloraSettlementClient::new(&env, &addr);
let d1 = dev(&env);
let d2 = dev(&env);
let t = token(&env);

let items = vec![&env, (d1.clone(), 100i128), (d2.clone(), 200i128)];

client.batch_receive_payment(&vault, &items, &t, &10u32);
assert_eq!(client.get_developer_balance(&d1, &t), 100);
assert_eq!(client.get_developer_balance(&d2, &t), 200);

let result = client.try_batch_receive_payment(&vault, &items, &t, &10u32);
assert!(result.is_err(), "batch replay with same seq must be rejected");
}
}
Loading
Loading