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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 29 additions & 1 deletion SETTLEMENT_IMPLEMENTATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -139,7 +139,13 @@ pub struct BalanceCreditedEvent {
- Creates empty developer balances and global pool
- Panic: "settlement contract already initialized"

2. **`receive_payment(env, caller, amount, to_pool, developer)`**
4. **`set_usdc_token(env, caller, usdc_address)`**
- Configures the USDC token contract address for withdrawals
- Authorization: Current admin only
- Validation: Token address cannot be the contract itself
- Panic: "unauthorized: caller is not admin" or "invalid config: usdc_token cannot be the contract itself"

5. **`receive_payment(env, caller, amount, to_pool, developer)`**
- **Access Control**: Only vault or admin can call
- **Validation**: Amount must be positive
- **Pool Credit**: If `to_pool=true`, credits global pool
Expand All @@ -148,6 +154,14 @@ pub struct BalanceCreditedEvent {
- `PaymentReceivedEvent` for all payments
- `BalanceCreditedEvent` for developer credits

6. **`withdraw_developer_balance(env, developer, amount)`**
- **Access Control**: Only the developer may call
- **Validation**: Amount must be positive and cannot exceed tracked balance
- **Token Flow**: Transfers USDC from the settlement contract to the developer
- **State Update**: Deducts the withdrawn amount from the tracked balance using checked arithmetic
- **Events**:
- `DeveloperWithdrawEvent` after transfer succeeds

3. **Query Functions**
- `get_admin()`, `get_vault()`, `get_global_pool()`
- `get_developer_balance(developer)`
Expand Down Expand Up @@ -258,6 +272,20 @@ CalloraSettlement::receive_payment(
);
```

### Developer Withdrawal

```rust
// Configure USDC if not already configured by admin
CalloraSettlement::set_usdc_token(env, admin_address, usdc_contract_address);

// Developer withdraws their available tracked balance
CalloraSettlement::withdraw_developer_balance(
env,
developer_address,
withdrawal_amount,
);
```

## Gas Optimization

### Efficient Operations
Expand Down
86 changes: 84 additions & 2 deletions contracts/settlement/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ pub enum StorageKey {
DeveloperIndex,
DeveloperBalance(Address),
GlobalPool,
Usdc,
}

/// Developer balance record in settlement contract
Expand Down Expand Up @@ -337,8 +338,8 @@ impl CalloraSettlement {
env.events().publish(
(Symbol::new(&env, "balance_credited"), dev.clone()),
BalanceCreditedEvent {
developer: dev,
amount,
developer: dev.clone(),
amount: amount,
new_balance,
},
);
Expand Down Expand Up @@ -392,6 +393,87 @@ impl CalloraSettlement {
.unwrap_or(0)
}

/// Configure the USDC token contract address.
///
/// Only the current admin may set the on-chain USDC token address that this
/// contract will use to execute withdrawals.
pub fn set_usdc_token(env: Env, caller: Address, usdc_address: Address) {
caller.require_auth();
let current_admin = Self::get_admin(env.clone());
if caller != current_admin {
panic!("unauthorized: caller is not admin");
}
if usdc_address == env.current_contract_address() {
panic!("invalid config: usdc_token cannot be the contract itself");
}
env.storage()
.instance()
.set(&StorageKey::Usdc, &usdc_address);
}

fn get_usdc_token(env: Env) -> Result<Address, SettlementError> {
env.storage()
.instance()
.get(&StorageKey::Usdc)
.ok_or(SettlementError::UsdcTokenNotConfigured)
}

/// Withdraw developer balance as USDC to the requesting developer.
///
/// Requires the developer to authorize the request and the requested amount
/// to be positive and covered by the tracked developer balance.
pub fn withdraw_developer_balance(
env: Env,
developer: Address,
amount: i128,
) -> Result<(), SettlementError> {
developer.require_auth();
if amount <= 0 {
return Err(SettlementError::AmountNotPositive);
}

let current_balance: i128 = env
.storage()
.persistent()
.get(&StorageKey::DeveloperBalance(developer.clone()))
.unwrap_or(0);
if amount > current_balance {
return Err(SettlementError::InsufficientDeveloperBalance);
}

let new_balance = current_balance
.checked_sub(amount)
.ok_or(SettlementError::DeveloperBalanceUnderflow)?;

let usdc_address = Self::get_usdc_token(env.clone())?;
let usdc = token::Client::new(&env, &usdc_address);
let contract_address = env.current_contract_address();

if usdc.balance(&contract_address) < amount {
return Err(SettlementError::InsufficientContractBalance);
}

usdc.transfer(&contract_address, &developer, &amount);

env.storage()
.persistent()
.set(&StorageKey::DeveloperBalance(developer.clone()), &new_balance);
env.storage()
.persistent()
.extend_ttl(&StorageKey::DeveloperBalance(developer.clone()), 50000, 50000);

env.events().publish(
(Symbol::new(&env, "developer_withdraw"), developer.clone()),
DeveloperWithdrawEvent {
developer,
amount,
remaining_balance: new_balance,
},
);

Ok(())
}

/// Get all developer balances (admin only)
///
/// **CRITICAL**: Uses developer index for iteration; order is based on index insertion order.
Expand Down
105 changes: 105 additions & 0 deletions contracts/settlement/src/test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -251,6 +251,111 @@ mod settlement_tests {
assert_eq!(client.get_developer_balance(&stranger), 0i128);
}

#[test]
fn test_withdraw_developer_balance_succeeds_exact_balance() {
let env = Env::default();
env.mock_all_auths();
let admin = Address::generate(&env);
let vault = Address::generate(&env);
let developer = Address::generate(&env);
let addr = env.register(CalloraSettlement, ());
let client = CalloraSettlementClient::new(&env, &addr);
let (usdc_address, _, usdc_admin_client) = create_usdc(&env, &admin);

client.init(&admin, &vault);
client.set_usdc_token(&admin, &usdc_address);
client.receive_payment(&vault, &100i128, &false, &Some(developer.clone()));
usdc_admin_client.mint(&addr, &100i128);

let result = client.try_withdraw_developer_balance(&developer, &100i128);
assert!(result.is_ok());
assert_eq!(client.get_developer_balance(&developer), 0i128);
assert_eq!(token::Client::new(&env, &usdc_address).balance(&addr), 0i128);
assert_eq!(token::Client::new(&env, &usdc_address).balance(&developer), 100i128);
}

#[test]
fn test_withdraw_developer_balance_rejects_overdraw() {
let env = Env::default();
env.mock_all_auths();
let admin = Address::generate(&env);
let vault = Address::generate(&env);
let developer = Address::generate(&env);
let addr = env.register(CalloraSettlement, ());
let client = CalloraSettlementClient::new(&env, &addr);
let (usdc_address, _, usdc_admin_client) = create_usdc(&env, &admin);

client.init(&admin, &vault);
client.set_usdc_token(&admin, &usdc_address);
client.receive_payment(&vault, &100i128, &false, &Some(developer.clone()));
usdc_admin_client.mint(&addr, &100i128);

let result = client.try_withdraw_developer_balance(&developer, &101i128);
assert!(result.is_err());
assert_eq!(client.get_developer_balance(&developer), 100i128);
}

#[test]
fn test_withdraw_developer_balance_rejects_non_positive_amount() {
let env = Env::default();
env.mock_all_auths();
let admin = Address::generate(&env);
let vault = Address::generate(&env);
let developer = Address::generate(&env);
let addr = env.register(CalloraSettlement, ());
let client = CalloraSettlementClient::new(&env, &addr);

client.init(&admin, &vault);

let zero_result = client.try_withdraw_developer_balance(&developer, &0i128);
let negative_result = client.try_withdraw_developer_balance(&developer, &-1i128);

assert!(zero_result.is_err());
assert!(negative_result.is_err());
}

#[test]
fn test_withdraw_developer_balance_emits_event() {
use soroban_sdk::testutils::Events as _;
use soroban_sdk::{IntoVal, Symbol};

let env = Env::default();
env.mock_all_auths();
let admin = Address::generate(&env);
let vault = Address::generate(&env);
let developer = Address::generate(&env);
let addr = env.register(CalloraSettlement, ());
let client = CalloraSettlementClient::new(&env, &addr);
let (usdc_address, _, usdc_admin_client) = create_usdc(&env, &admin);

client.init(&admin, &vault);
client.set_usdc_token(&admin, &usdc_address);
client.receive_payment(&vault, &200i128, &false, &Some(developer.clone()));
usdc_admin_client.mint(&addr, &200i128);

let result = client.try_withdraw_developer_balance(&developer, &200i128);
assert!(result.is_ok());

let events = env.events().all();
let ev = events
.iter()
.find(|e| {
!e.1.is_empty() && {
let t: Symbol = e.1.get(0).unwrap().into_val(&env);
t == Symbol::new(&env, "developer_withdraw")
}
})
.expect("expected developer_withdraw event");

let topic1: Address = ev.1.get(1).unwrap().into_val(&env);
assert_eq!(topic1, developer);

let data: crate::DeveloperWithdrawEvent = ev.2.into_val(&env);
assert_eq!(data.developer, developer);
assert_eq!(data.amount, 200i128);
assert_eq!(data.remaining_balance, 0i128);
}

#[test]
fn test_get_all_developer_balances() {
let env = Env::default();
Expand Down
Loading