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
51 changes: 39 additions & 12 deletions soroban/contracts/factory/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,7 @@
mod types;

use soroban_sdk::{
contract, contractimpl, symbol_short, vec, Address, BytesN, Env, IntoVal, String, Symbol, Val,
Vec,
contract, contractimpl, symbol_short, vec, Address, BytesN, Env, IntoVal, Symbol, Val, Vec,
};
use types::{DataKey, FactoryError, ListPoolsResponse, PoolRecord, PoolSort};

Expand All @@ -22,7 +21,7 @@ const LEDGERS_PER_DAY: u128 = 17_280;
// standard 7-decimal Stellar asset convention and prevents dust positions.
const MIN_STAKE_AMOUNT: i128 = 1_000_000;
// Minimum lock period in ledgers required to prevent flash-loan-style attacks.
const MIN_LOCK_PERIOD: u32 = 0;
const MIN_LOCK_PERIOD: u32 = 1;

/// Convert a "credits per day" figure into the deployed pool's native
/// "credits per ledger" `credit_rate`.
Expand All @@ -47,7 +46,7 @@ fn daily_rate_to_credit_rate(daily_rate: u128) -> Result<i128, FactoryError> {
if daily_rate == 0 {
return Err(FactoryError::InvalidCreditRate);
}
let per_ledger = (daily_rate + LEDGERS_PER_DAY - 1) / LEDGERS_PER_DAY;
let per_ledger = daily_rate.div_ceil(LEDGERS_PER_DAY);
i128::try_from(per_ledger).map_err(|_| FactoryError::InvalidCreditRate)
}

Expand Down Expand Up @@ -79,6 +78,14 @@ fn bump_admin_pools(env: &Env, admin: &Address) {
);
}

fn bump_wasm_pools(env: &Env, wasm_hash: &BytesN<32>) {
env.storage().persistent().extend_ttl(
&DataKey::PoolsByWasmHash(wasm_hash.clone()),
TTL_THRESHOLD,
TTL_EXTEND_TO,
);
}

/// Reject any call that lands on a factory whose state was never seeded.
///
/// `initialize` is the only writer of `DataKey::Admin`, so its presence is the
Expand Down Expand Up @@ -191,7 +198,12 @@ impl Factory {
if env.storage().instance().has(&DataKey::Admin) {
return Err(FactoryError::AlreadyInitialized);
}
if admin == Address::from_string(&soroban_sdk::String::from_str(&env, "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF")) {
if admin
== Address::from_string(&soroban_sdk::String::from_str(
&env,
"GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF",
))
{
return Err(FactoryError::InvalidAdmin);
}
if pool_wasm_hash == BytesN::from_array(&env, &[0u8; 32]) {
Expand Down Expand Up @@ -409,7 +421,11 @@ impl Factory {
let mut next_start_id = scan_end;

let asset_key = DataKey::AssetPools(asset.clone());
if let Some(asset_ids) = env.storage().persistent().get::<DataKey, Vec<u32>>(&asset_key) {
if let Some(asset_ids) = env
.storage()
.persistent()
.get::<DataKey, Vec<u32>>(&asset_key)
{
bump_asset_pools(&env, &asset);
for pool_id in asset_ids.iter() {
if pool_id < start_id {
Expand Down Expand Up @@ -491,6 +507,14 @@ impl Factory {
.unwrap_or_else(|| vec![&env]))
}

/// Return the number of pools created by `admin` (#236).
///
/// Equivalent to `get_pools_by_admin(admin).len()`, exposed directly so
/// callers who only need the count avoid paying for the full ID list.
pub fn get_admin_pool_count(env: Env, admin: Address) -> Result<u32, FactoryError> {
Ok(Self::get_pools_by_admin(env, admin)?.len())
}

/// Refresh TTLs for a range of pool records to prevent archival.
///
/// This permissionless function allows keepers or any caller to proactively
Expand Down Expand Up @@ -557,6 +581,7 @@ impl Factory {
bump_pool(&env, pool_id);
}
}
#[allow(deprecated)]
env.events().publish(
(symbol_short!("factory"), symbol_short!("ttl_ref")),
(start_id, end),
Expand Down Expand Up @@ -667,7 +692,7 @@ impl Factory {
env.storage().persistent().set(&key, &record);

let old_wasm_key = DataKey::PoolsByWasmHash(old_hash.clone());
if let Some(mut old_pool_ids) = env
if let Some(old_pool_ids) = env
.storage()
.persistent()
.get::<DataKey, Vec<u32>>(&old_wasm_key)
Expand All @@ -691,9 +716,10 @@ impl Factory {
env.storage().persistent().set(&new_wasm_key, &new_pool_ids);
bump_wasm_pools(&env, &new_wasm_hash);

env.storage()
.instance()
.set(&DataKey::UpgradeCount, &read_upgrade_count(&env).saturating_add(1));
env.storage().instance().set(
&DataKey::UpgradeCount,
&read_upgrade_count(&env).saturating_add(1),
);

#[allow(deprecated)]
env.events().publish(
Expand Down Expand Up @@ -810,10 +836,10 @@ impl Factory {
/// smallest-diff option that avoids the larger "factory proxies every
/// admin action" design surface.
///
/// The `pool_crtd` event includes `asset`, `credit_rate`,
/// The `pool_crtd` event includes `admin`, `asset`, `credit_rate`,
/// `global_multiplier`, and `min_lock_period` alongside `pool_id` and
/// `pool_address` so off-chain indexers can reconstruct the full pool
/// state without a follow-up RPC call.
/// state — including who created it — without a follow-up RPC call (#233).
///
/// On failure, no event is emitted: a validation failure reverts this
/// invocation, and Soroban discards contract events published by reverted
Expand Down Expand Up @@ -937,6 +963,7 @@ impl Factory {
(
pool_id,
pool_address,
admin,
asset,
credit_rate,
global_multiplier,
Expand Down
73 changes: 59 additions & 14 deletions soroban/contracts/factory/src/test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -347,7 +347,7 @@ fn test_list_pools_returns_first_page() {
assert_eq!(page.records.len(), 10);
assert_eq!(page.next_start_id, 10);
assert_eq!(page.total, 25);
assert_eq!(page.has_more, true);
assert!(page.has_more);

assert_eq!(
page.records
Expand All @@ -371,7 +371,7 @@ fn test_list_pools_returns_second_page() {
assert_eq!(page.records.len(), 10);
assert_eq!(page.next_start_id, 20);
assert_eq!(page.total, 25);
assert_eq!(page.has_more, true);
assert!(page.has_more);
assert_eq!(page.records.get(0).map(|record| record.0), Some(10));
assert_eq!(page.records.get(9).map(|record| record.0), Some(19));
}
Expand All @@ -384,7 +384,7 @@ fn test_list_pools_returns_partial_last_page() {
assert_eq!(page.records.len(), 5);
assert_eq!(page.next_start_id, 25);
assert_eq!(page.total, 25);
assert_eq!(page.has_more, false);
assert!(!page.has_more);
assert_eq!(page.records.get(0).map(|record| record.0), Some(20));
assert_eq!(page.records.get(4).map(|record| record.0), Some(24));
}
Expand All @@ -397,7 +397,7 @@ fn test_list_pools_returns_empty_when_start_is_beyond_count() {
assert_eq!(page.records.len(), 0);
assert_eq!(page.next_start_id, 3);
assert_eq!(page.total, 3);
assert_eq!(page.has_more, false);
assert!(!page.has_more);
}

#[test]
Expand All @@ -408,7 +408,7 @@ fn test_list_pools_caps_limit_at_twenty() {
assert_eq!(page.records.len(), 20);
assert_eq!(page.next_start_id, 20);
assert_eq!(page.total, 25);
assert_eq!(page.has_more, true);
assert!(page.has_more);
assert_eq!(page.records.get(19).map(|record| record.0), Some(19));
}

Expand All @@ -420,13 +420,13 @@ fn test_list_pools_has_more_flag_accuracy() {
let page1 = t.client.list_pools(&0u32, &3u32);
assert_eq!(page1.records.len(), 3);
assert_eq!(page1.next_start_id, 3);
assert_eq!(page1.has_more, true);
assert!(page1.has_more);

// Next page (start 3, limit 3 of 5) -> has_more is false (reaches end: 5)
let page2 = t.client.list_pools(&page1.next_start_id, &3u32);
assert_eq!(page2.records.len(), 2);
assert_eq!(page2.next_start_id, 5);
assert_eq!(page2.has_more, false);
assert!(!page2.has_more);
}

// ── get_pools_by_asset ────────────────────────────────────────────────────────
Expand Down Expand Up @@ -556,7 +556,13 @@ fn test_upgrade_pool_hot_swaps_registered_pool_without_changing_factory_hash() {
symbol_short!("factory").into_val(&t.env),
symbol_short!("pool_upg").into_val(&t.env),
],
(pool_id, pool_addr.clone(), t.wasm_hash.clone(), new_wasm_hash.clone()).into_val(&t.env),
(
pool_id,
pool_addr.clone(),
t.wasm_hash.clone(),
new_wasm_hash.clone()
)
.into_val(&t.env),
)
]
);
Expand Down Expand Up @@ -953,7 +959,8 @@ fn test_get_pools_by_asset_range_custom_scan_limit() {
assert_eq!(page1.next_start_id, 50);

// Second call scanning 50..100 should find the match at 90
let page2 = client.get_pools_by_asset_range(&sparse_asset, &page1.next_start_id, &50u32, &20u32);
let page2 =
client.get_pools_by_asset_range(&sparse_asset, &page1.next_start_id, &50u32, &20u32);
assert_eq!(page2.records.len(), 1);
assert_eq!(page2.records.get(0).unwrap().0, 90);
assert_eq!(page2.next_start_id, 100);
Expand Down Expand Up @@ -1282,7 +1289,18 @@ fn test_create_pool_emits_pool_crtd_event_with_payload() {
symbol_short!("factory").into_val(&t.env),
symbol_short!("pool_crtd").into_val(&t.env),
],
(id, expected_address, asset, 300i128, 2u32, 30u32, 5_184_000u128, t.wasm_hash.clone()).into_val(&t.env),
(
id,
expected_address,
t.admin.clone(),
asset,
300i128,
2u32,
30u32,
5_184_000u128,
t.wasm_hash.clone()
)
.into_val(&t.env),
)
]
);
Expand Down Expand Up @@ -1370,10 +1388,10 @@ fn test_create_pool_initializes_real_farming_pool_atomically() {
#[test]
fn test_pause_pool_creation_prevents_create_pool() {
let t = setup();
assert_eq!(t.client.is_pool_creation_paused(), false);
assert!(!t.client.is_pool_creation_paused());

t.client.pause_pool_creation();
assert_eq!(t.client.is_pool_creation_paused(), true);
assert!(t.client.is_pool_creation_paused());

let asset = Address::generate(&t.env);
let result = t
Expand All @@ -1387,10 +1405,10 @@ fn test_pause_pool_creation_prevents_create_pool() {
fn test_unpause_pool_creation_allows_create_pool() {
let t = setup();
t.client.pause_pool_creation();
assert_eq!(t.client.is_pool_creation_paused(), true);
assert!(t.client.is_pool_creation_paused());

t.client.unpause_pool_creation();
assert_eq!(t.client.is_pool_creation_paused(), false);
assert!(!t.client.is_pool_creation_paused());

let asset = Address::generate(&t.env);
let pool_id = t
Expand Down Expand Up @@ -1478,3 +1496,30 @@ fn test_get_pools_by_admin_returns_created_pools() {
let pools = t.client.get_pools_by_admin(&t.admin);
assert_eq!(pools, vec![&t.env, id1, id2]);
}

#[test]
fn test_get_admin_pool_count_tracks_pools_created_by_admin() {
let t = setup();
assert_eq!(t.client.get_admin_pool_count(&t.admin), 0);

let asset1 = Address::generate(&t.env);
t.client
.create_pool(&asset1, &1_728_000u128, &2u32, &10u64, &0i128);
assert_eq!(t.client.get_admin_pool_count(&t.admin), 1);

let asset2 = Address::generate(&t.env);
t.client
.create_pool(&asset2, &3_456_000u128, &2u32, &20u64, &0i128);
assert_eq!(t.client.get_admin_pool_count(&t.admin), 2);

let other_admin = Address::generate(&t.env);
assert_eq!(t.client.get_admin_pool_count(&other_admin), 0);
}

#[test]
fn test_get_admin_pool_count_uninitialized_returns_not_initialized() {
let (_env, client) = setup_uninitialized();
let admin = Address::generate(&_env);
let result = client.try_get_admin_pool_count(&admin);
assert!(matches!(result, Err(Ok(FactoryError::NotInitialized))));
}
4 changes: 4 additions & 0 deletions soroban/contracts/factory/src/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,10 @@ pub enum DataKey {
UpgradeCount,
/// List of pool IDs for a specific asset.
AssetPools(Address),
/// List of pool IDs created by a specific admin.
PoolsByAdmin(Address),
/// List of pool IDs currently running a specific WASM hash.
PoolsByWasmHash(BytesN<32>),
}

/// On-chain record for a registered farming pool.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -483,6 +483,37 @@
},
"live_until": 1036800
},
{
"entry": {
"last_modified_ledger_seq": 0,
"data": {
"contract_data": {
"ext": "v0",
"contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAK3IM",
"key": {
"vec": [
{
"symbol": "PoolsByAdmin"
},
{
"address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM"
}
]
},
"durability": "persistent",
"val": {
"vec": [
{
"u32": 0
}
]
}
}
},
"ext": "v0"
},
"live_until": 1036800
},
{
"entry": {
"last_modified_ledger_seq": 0,
Expand Down
Loading