Skip to content
Draft
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
8 changes: 8 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

155 changes: 65 additions & 90 deletions contracts/bridge-compat/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -27,8 +27,8 @@
//! - Graceful degradation on provider failures

use soroban_sdk::{
contract, contractclient, contractimpl, contracttype, symbol_short, Address, Bytes, Env,
String, Vec,
contract, contractclient, contracterror, contractimpl, contracttype, symbol_short, Address,
Bytes, Env, String, Vec,
};

// ── Error types ────────────────────────────────────────────────────────────
Expand Down Expand Up @@ -189,7 +189,7 @@ impl BridgeCompat {
env.storage().instance().set(
&DataKey::Limits,
&TransferLimits {
per_transfer_limit: 1_000_000_000_000, // 1M USDC (6 decimals)
per_transfer_limit: 1_000_000_000_000, // 1M USDC (6 decimals)
epoch_volume_limit: 10_000_000_000_000, // 10M USDC per epoch
epoch_duration: 86_400, // 24 hours
},
Expand Down Expand Up @@ -253,10 +253,7 @@ impl BridgeCompat {
.set(&DataKey::ProviderNonce, &provider_id);

// Set as default if first provider
let has_default = env
.storage()
.instance()
.has(&DataKey::DefaultProvider);
let has_default = env.storage().instance().has(&DataKey::DefaultProvider);
if !has_default {
env.storage()
.instance()
Expand All @@ -283,19 +280,13 @@ impl BridgeCompat {
env.storage()
.instance()
.set(&DataKey::Provider(provider_id), &provider);
env.events().publish(
(symbol_short!("brgtog"),),
(provider_id, enabled),
);
env.events()
.publish((symbol_short!("brgtog"),), (provider_id, enabled));
Ok(())
}

/// Update a provider's fee. Admin-only.
pub fn set_provider_fee(
env: Env,
provider_id: u32,
fee_bps: i128,
) -> Result<(), BridgeError> {
pub fn set_provider_fee(env: Env, provider_id: u32, fee_bps: i128) -> Result<(), BridgeError> {
Self::require_admin(&env)?;
if !(0..=BPS_DENOMINATOR).contains(&fee_bps) {
return Err(BridgeError::InvalidAmount);
Expand All @@ -309,10 +300,7 @@ impl BridgeCompat {
}

/// Set the default provider. Admin-only.
pub fn set_default_provider(
env: Env,
provider_id: u32,
) -> Result<(), BridgeError> {
pub fn set_default_provider(env: Env, provider_id: u32) -> Result<(), BridgeError> {
Self::require_admin(&env)?;
let _ = Self::get_provider(&env, provider_id)?; // validate exists
env.storage()
Expand Down Expand Up @@ -344,10 +332,7 @@ impl BridgeCompat {
// ── Transfer limits ────────────────────────────────────────────────────

/// Update transfer limits. Admin-only.
pub fn set_transfer_limits(
env: Env,
limits: TransferLimits,
) -> Result<(), BridgeError> {
pub fn set_transfer_limits(env: Env, limits: TransferLimits) -> Result<(), BridgeError> {
Self::require_admin(&env)?;
env.storage().instance().set(&DataKey::Limits, &limits);
Ok(())
Expand Down Expand Up @@ -432,15 +417,15 @@ impl BridgeCompat {
}

// Check per-transfer limit
let limits: TransferLimits = env
.storage()
.instance()
.get(&DataKey::Limits)
.unwrap_or(TransferLimits {
per_transfer_limit: 1_000_000_000_000,
epoch_volume_limit: 10_000_000_000_000,
epoch_duration: 86_400,
});
let limits: TransferLimits =
env.storage()
.instance()
.get(&DataKey::Limits)
.unwrap_or(TransferLimits {
per_transfer_limit: 1_000_000_000_000,
epoch_volume_limit: 10_000_000_000_000,
epoch_duration: 86_400,
});
if amount > limits.per_transfer_limit {
return Err(BridgeError::TransferLimitExceeded);
}
Expand All @@ -449,11 +434,7 @@ impl BridgeCompat {
Self::check_epoch_volume(&env, amount, &limits)?;

// Check token balance
let token_addr: Address = env
.storage()
.instance()
.get(&DataKey::TokenAsset)
.unwrap();
let token_addr: Address = env.storage().instance().get(&DataKey::TokenAsset).unwrap();
let token_client = soroban_sdk::token::Client::new(&env, &token_addr);
let balance = token_client.balance(&env.current_contract_address());
if balance < amount {
Expand Down Expand Up @@ -520,10 +501,7 @@ impl BridgeCompat {
///
/// In production, this would be triggered by a bridge event listener.
/// For testnet, admin can manually confirm transfers.
pub fn confirm_transfer(
env: Env,
transfer_id: u64,
) -> Result<(), BridgeError> {
pub fn confirm_transfer(env: Env, transfer_id: u64) -> Result<(), BridgeError> {
Self::require_admin(&env)?;

let mut transfer: BridgeTransfer = env
Expand All @@ -543,10 +521,8 @@ impl BridgeCompat {
.instance()
.set(&DataKey::Transfer(transfer_id), &transfer);

env.events().publish(
(symbol_short!("brgdone"),),
(transfer_id, transfer.amount),
);
env.events()
.publish((symbol_short!("brgdone"),), (transfer_id, transfer.amount));

Ok(())
}
Expand All @@ -555,10 +531,7 @@ impl BridgeCompat {
///
/// If a transfer fails (e.g., bridge timeout), the tokens are returned
/// to the sender. Admin-only in testnet; production would use oracle.
pub fn fail_transfer(
env: Env,
transfer_id: u64,
) -> Result<(), BridgeError> {
pub fn fail_transfer(env: Env, transfer_id: u64) -> Result<(), BridgeError> {
Self::require_admin(&env)?;

let mut transfer: BridgeTransfer = env
Expand All @@ -572,11 +545,7 @@ impl BridgeCompat {
}

// Refund the sender
let token_addr: Address = env
.storage()
.instance()
.get(&DataKey::TokenAsset)
.unwrap();
let token_addr: Address = env.storage().instance().get(&DataKey::TokenAsset).unwrap();
let token_client = soroban_sdk::token::Client::new(&env, &token_addr);
token_client.transfer(
&env.current_contract_address(),
Expand Down Expand Up @@ -656,7 +625,9 @@ impl BridgeCompat {
env.storage().instance().set(&DataKey::EpochStart, &now);
}

let new_volume = epoch_volume.checked_add(amount).ok_or(BridgeError::TransferLimitExceeded)?;
let new_volume = epoch_volume
.checked_add(amount)
.ok_or(BridgeError::TransferLimitExceeded)?;
if new_volume > limits.epoch_volume_limit {
return Err(BridgeError::TransferLimitExceeded);
}
Expand All @@ -673,74 +644,78 @@ mod tests {
use super::*;
use soroban_sdk::testutils::Address as _;

/// Register the contract and return a client for it.
///
/// The tests below went through `BridgeCompat::…` directly, which reads
/// instance storage outside a contract invocation. SDK 22 rejects that
/// ("this function is not accessible outside of a contract"), so they now
/// go through the generated client like every other test in the workspace.
fn setup() -> (Env, BridgeCompatClient<'static>) {
let env = Env::default();
env.mock_all_auths();
let id = env.register(BridgeCompat, ());
let client = BridgeCompatClient::new(&env, &id);
(env, client)
}

#[test]
fn test_initialize() {
let env = Env::default();
let (env, client) = setup();
let admin = Address::generate(&env);
let token = Address::generate(&env);

env.mock_all_auths();

BridgeCompat::initialize(env.clone(), admin.clone(), token.clone()).unwrap();
assert_eq!(BridgeCompat::admin(env.clone()), Some(admin));
assert_eq!(BridgeCompat::token(env.clone()), Some(token));
client.initialize(&admin, &token);
assert_eq!(client.admin(), Some(admin));
assert_eq!(client.token(), Some(token));
}

#[test]
fn test_double_initialize_fails() {
let env = Env::default();
let (env, client) = setup();
let admin = Address::generate(&env);
let token = Address::generate(&env);

env.mock_all_auths();

BridgeCompat::initialize(env.clone(), admin.clone(), token.clone()).unwrap();
let result = BridgeCompat::initialize(env.clone(), admin, token);
assert_eq!(result, Err(BridgeError::AlreadyInitialized));
client.initialize(&admin, &token);
let result = client.try_initialize(&admin, &token);
assert_eq!(result, Err(Ok(BridgeError::AlreadyInitialized)));
}

#[test]
fn test_register_provider() {
let env = Env::default();
let (env, client) = setup();
let admin = Address::generate(&env);
let token = Address::generate(&env);
let endpoint = Address::generate(&env);

env.mock_all_auths();

BridgeCompat::initialize(env.clone(), admin, token).unwrap();
client.initialize(&admin, &token);

let chains = Vec::from_array(&env, &[1, 2, 3]);
let id = BridgeCompat::register_provider(
env.clone(),
String::from_str(&env, "Wormhole"),
BridgeProviderKind::Wormhole,
endpoint,
50, // 0.5% fee
1_000_000_000_000,
chains,
)
.unwrap();
let chains = Vec::from_array(&env, [1, 2, 3]);
let id = client.register_provider(
&String::from_str(&env, "Wormhole"),
&BridgeProviderKind::Wormhole,
&endpoint,
&50, // 0.5% fee
&1_000_000_000_000,
&chains,
);

assert_eq!(id, 1);
assert_eq!(BridgeCompat::provider_count(env.clone()), 1);
assert_eq!(client.provider_count(), 1);

let provider = BridgeCompat::provider(env.clone(), id).unwrap();
let provider = client.provider(&id).unwrap();
assert_eq!(provider.name, String::from_str(&env, "Wormhole"));
assert!(provider.enabled);
}

#[test]
fn test_transfer_limits() {
let env = Env::default();
let (env, client) = setup();
let admin = Address::generate(&env);
let token = Address::generate(&env);

env.mock_all_auths();

BridgeCompat::initialize(env.clone(), admin, token).unwrap();
client.initialize(&admin, &token);

let limits = BridgeCompat::transfer_limits(env.clone());
let limits = client.transfer_limits();
assert_eq!(limits.per_transfer_limit, 1_000_000_000_000);
assert_eq!(limits.epoch_volume_limit, 10_000_000_000_000);
assert_eq!(limits.epoch_duration, 86_400);
Expand Down
7 changes: 7 additions & 0 deletions contracts/vault/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,12 @@ edition = "2021"
[lib]
crate-type = ["cdylib", "rlib"]

[features]
# Mirrors the soroban-sdk convention. Exposes test-only contract types (such as
# `benji_strategy`) so integration tests under `tests/` can register a strategy
# contract; it is never enabled for a WASM build.
testutils = []

[dependencies]
soroban-sdk = { workspace = true }
share-price-math = { path = "../share-price-math" }
Expand All @@ -14,3 +20,4 @@ share-price-math = { path = "../share-price-math" }
soroban-sdk = { workspace = true, features = ["testutils"] }
mock-strategy = { path = "../mock-strategy" }
proptest = { version = "1", default-features = false, features = ["std"] }
vault = { path = ".", features = ["testutils"] }
9 changes: 4 additions & 5 deletions contracts/vault/src/deposit_withdraw_props.rs
Original file line number Diff line number Diff line change
Expand Up @@ -382,11 +382,10 @@ proptest! {
let (env, client, _admin, token) = setup();
let user = Address::generate(&env);

// Set a non-zero cooldown
env.storage().instance().set(
&crate::DataKey::WithdrawalCooldown,
&cooldown_secs,
);
// Set a non-zero cooldown. Used to write `DataKey::WithdrawalCooldown`
// straight into storage, which SDK 22 rejects outside a contract
// invocation; the admin entry point is the supported way to do this.
client.set_withdrawal_cooldown(&cooldown_secs);

mint(&env, &token, &user, deposit_amount);
let shares = match client.try_deposit(&user, &deposit_amount) {
Expand Down
21 changes: 14 additions & 7 deletions contracts/vault/src/errors.rs
Original file line number Diff line number Diff line change
Expand Up @@ -150,12 +150,19 @@ pub enum VaultError {
/// missing or non-distinct approver pair and [`VaultError::InvalidAmount`]
/// for a non-positive amount rather than defining dedicated codes.
RescueUnauthorized = 50,

// ── Performance fee switch (51–53) ─────────────────────────────────────
/// Performance fee basis points are outside 0–10000.
InvalidPerformanceFeeBps = 51,
/// Performance incentive pool address is not configured.
PerformanceIncentivePoolNotConfigured = 52,
/// Performance fee switch is in an invalid state for the requested operation.
InvalidPerformanceFeeSwitchState = 53,
//
// The Soroban error-enum spec (`ScSpecUdtErrorEnumV0`) caps an error enum
// at 50 cases, and `#[contracterror]` panics with `LengthExceedsMax` when
// exceeded — which is why codes 1–50 above are the full allocation and
// earlier flows reuse existing codes rather than adding new ones.
//
// The three performance-fee codes exceed that cap, so they reuse existing
// codes with the same semantics, matching how the rescue and oracle flows
// already handle it. Numeric codes 1–50 are unchanged, so the integrator
// contract documented in docs/api/ERROR_CODE_CATALOG.md is unaffected.
//
// InvalidPerformanceFeeBps -> InvalidFeeBps (38)
// PerformanceIncentivePoolNotConfigured -> GovernanceSignersNotConfigured (25)
// InvalidPerformanceFeeSwitchState -> NoPendingWithdrawal (8)
}
Loading
Loading