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
3 changes: 1 addition & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,8 @@ name: Soroban Smart Contracts CI

on:
push:
branches: ["main"]
branches: ["**"]
pull_request:
branches: ["main"]

permissions:
contents: read
Expand Down
1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ members = [
"data_migration",
"reporting",
"orchestrator",
"global_config",
"cli",
"scenarios",

Expand Down
1 change: 1 addition & 0 deletions bill_payments/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ remitwise-common = { path = "../remitwise-common" }
[dev-dependencies]
proptest = "1.10.0"
soroban-sdk = { version = "21.0.0", features = ["testutils"] }
global_config = { path = "../global_config" }
testutils = { path = "../testutils" }


105 changes: 100 additions & 5 deletions bill_payments/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,8 @@ use remitwise_common::{
};

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

#[derive(Clone, Debug)]
Expand Down Expand Up @@ -76,6 +76,8 @@ pub enum Error {
BatchTooLarge = 9,
BatchValidationFailed = 10,
InvalidLimit = 11,
/// A config-contract sync was requested but no config contract address is stored.
ConfigContractNotSet = 12,
InvalidDueDate = 12,
InvalidTag = 12,
EmptyTags = 13,
Expand Down Expand Up @@ -122,6 +124,26 @@ pub struct StorageStats {
pub last_updated: u64,
}

// -----------------------------------------------------------------------
// Cross-contract interface: GlobalConfig
// -----------------------------------------------------------------------

/// Mirrors `global_config::ConfigValue` for cross-contract deserialization.
/// The XDR encoding is identical as long as variants appear in the same order.
#[contracttype]
#[derive(Clone, Debug)]
pub enum GlobalConfigValue {
U32(u32),
I128(i128),
Bool(bool),
}

/// Minimal client interface for the GlobalConfig contract.
#[contractclient(name = "GlobalConfigClient")]
pub trait GlobalConfigTrait {
fn get_config(env: Env, key: Symbol) -> Option<GlobalConfigValue>;
}

#[contract]
pub struct BillPayments;

Expand Down Expand Up @@ -175,8 +197,26 @@ impl BillPayments {
Ok(())
}

/// Clamp a caller-supplied limit to [1, MAX_PAGE_LIMIT].
/// Clamp a caller-supplied limit to [1, effective_max_page_limit].
/// A value of 0 is treated as DEFAULT_PAGE_LIMIT.
///
/// The ceiling is the cached value synced from the GlobalConfig contract
/// (key `"max_page_lmt"`), falling back to the hard-coded `MAX_PAGE_LIMIT`
/// when no sync has been performed.
fn clamp_limit(env: &Env, limit: u32) -> u32 {
let max: u32 = env
.storage()
.instance()
.get(&symbol_short!("PG_LIMIT"))
.unwrap_or(MAX_PAGE_LIMIT);
if limit == 0 {
DEFAULT_PAGE_LIMIT.min(max)
} else if limit > max {
max
} else {
limit
}
}

// -----------------------------------------------------------------------
// Pause / upgrade
Expand Down Expand Up @@ -365,6 +405,61 @@ impl BillPayments {
Ok(())
}

// -----------------------------------------------------------------------
// Global config integration
// -----------------------------------------------------------------------

/// Store the address of the deployed GlobalConfig contract.
///
/// Only the upgrade admin may call. Once set, `sync_limits_from_config`
/// can be used to pull limit values into local storage.
pub fn set_config_contract(env: Env, caller: Address, config_id: Address) -> Result<(), Error> {
caller.require_auth();
let admin = Self::get_upgrade_admin(&env).ok_or(Error::Unauthorized)?;
if admin != caller {
return Err(Error::Unauthorized);
}
env.storage()
.instance()
.extend_ttl(INSTANCE_LIFETIME_THRESHOLD, INSTANCE_BUMP_AMOUNT);
env.storage()
.instance()
.set(&symbol_short!("CFG_CTR"), &config_id);
Ok(())
}

/// Read `max_page_lmt` from the configured GlobalConfig contract and cache
/// it locally under `"PG_LIMIT"`.
///
/// After a successful sync, `clamp_limit` uses the cached value instead of
/// the hard-coded `MAX_PAGE_LIMIT` constant. Only the upgrade admin may
/// call.
pub fn sync_limits_from_config(env: Env, caller: Address) -> Result<(), Error> {
caller.require_auth();
let admin = Self::get_upgrade_admin(&env).ok_or(Error::Unauthorized)?;
if admin != caller {
return Err(Error::Unauthorized);
}
let config_id: Address = env
.storage()
.instance()
.get(&symbol_short!("CFG_CTR"))
.ok_or(Error::ConfigContractNotSet)?;

env.storage()
.instance()
.extend_ttl(INSTANCE_LIFETIME_THRESHOLD, INSTANCE_BUMP_AMOUNT);

let config_client = GlobalConfigClient::new(&env, &config_id);
let key = Symbol::new(&env, "max_page_lmt");
if let Some(GlobalConfigValue::U32(limit)) = config_client.get_config(&key) {
env.storage()
.instance()
.set(&symbol_short!("PG_LIMIT"), &limit);
}
Ok(())
}

// -----------------------------------------------------------------------
// Core bill operations
// -----------------------------------------------------------------------
Expand Down Expand Up @@ -1211,7 +1306,7 @@ impl BillPayments {
cursor: u32,
limit: u32,
) -> BillPage {
let limit = Self::clamp_limit(limit);
let limit = Self::clamp_limit(&env, limit);
let bills: Map<u32, Bill> = env
.storage()
.instance()
Expand Down Expand Up @@ -1245,7 +1340,7 @@ impl BillPayments {
cursor: u32,
limit: u32,
) -> BillPage {
let limit = Self::clamp_limit(limit);
let limit = Self::clamp_limit(&env, limit);
let bills: Map<u32, Bill> = env
.storage()
.instance()
Expand Down
201 changes: 201 additions & 0 deletions bill_payments/tests/config_integration_tests.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,201 @@
use bill_payments::{BillPayments, BillPaymentsClient, MAX_PAGE_LIMIT};
use global_config::{ConfigValue, GlobalConfig, GlobalConfigClient};
use soroban_sdk::testutils::{Address as AddressTrait, EnvTestConfig, Ledger, LedgerInfo};
use soroban_sdk::{Address, Env, String, Symbol};

fn make_env() -> Env {
let env = Env::new_with_config(EnvTestConfig {
capture_snapshot_at_drop: false,
});
env.mock_all_auths();
let proto = env.ledger().protocol_version();
env.ledger().set(LedgerInfo {
protocol_version: proto,
sequence_number: 1,
timestamp: 1_700_000_000,
network_id: [0; 32],
base_reserve: 10,
min_temp_entry_ttl: 1,
min_persistent_entry_ttl: 1,
max_entry_ttl: 100_000,
});
env
}

/// Deploy both contracts and wire them together.
/// Returns (bills_client, config_client, upgrade_admin).
fn setup(env: &Env) -> (BillPaymentsClient<'_>, GlobalConfigClient<'_>, Address) {
let bills_id = env.register_contract(None, BillPayments);
let bills = BillPaymentsClient::new(env, &bills_id);

let cfg_id = env.register_contract(None, GlobalConfig);
let cfg = GlobalConfigClient::new(env, &cfg_id);

// Bootstrap the upgrade admin for bill_payments
let upg_admin = Address::generate(env);
bills.set_upgrade_admin(&upg_admin, &upg_admin);

// Initialize global config
let cfg_admin = Address::generate(env);
cfg.initialize(&cfg_admin);

// Wire config into bill_payments
bills.set_config_contract(&upg_admin, &cfg_id);

(bills, cfg, upg_admin)
}

// -----------------------------------------------------------------------
// set_config_contract
// -----------------------------------------------------------------------

#[test]
fn test_set_config_contract_requires_upgrade_admin() {
let env = make_env();
let bills_id = env.register_contract(None, BillPayments);
let bills = BillPaymentsClient::new(&env, &bills_id);
let upg_admin = Address::generate(&env);
bills.set_upgrade_admin(&upg_admin, &upg_admin);

let cfg_id = env.register_contract(None, GlobalConfig);
let stranger = Address::generate(&env);

let result = bills.try_set_config_contract(&stranger, &cfg_id);
assert!(result.is_err());
}

// -----------------------------------------------------------------------
// sync_limits_from_config
// -----------------------------------------------------------------------

#[test]
fn test_sync_without_config_contract_errors() {
let env = make_env();
let bills_id = env.register_contract(None, BillPayments);
let bills = BillPaymentsClient::new(&env, &bills_id);
let upg_admin = Address::generate(&env);
bills.set_upgrade_admin(&upg_admin, &upg_admin);

// No set_config_contract called — sync must fail
let result = bills.try_sync_limits_from_config(&upg_admin);
assert!(result.is_err());
}

#[test]
fn test_sync_reads_max_page_limit_from_config() {
let env = make_env();
let (bills, cfg, upg_admin) = setup(&env);

// Config admin is whoever initialized the config contract
let cfg_admin = cfg.get_admin().unwrap();

// Push a custom limit into global config
cfg.set_config(
&cfg_admin,
&Symbol::new(&env, "max_page_lmt"),
&ConfigValue::U32(30),
);

// Sync to bill_payments
bills.sync_limits_from_config(&upg_admin);

// Create 40 bills then page with limit=50 — should receive at most 30
let owner = Address::generate(&env);
let name = String::from_str(&env, "Test");
for _ in 0..40 {
bills.create_bill(
&owner,
&name,
&100i128,
&1_800_000_000u64,
&false,
&0u32,
&String::from_str(&env, ""),
);
}

let page = bills.get_unpaid_bills(&owner, &0, &50);
assert_eq!(page.count, 30);
assert!(page.next_cursor > 0);
}

#[test]
fn test_sync_missing_key_leaves_default_unchanged() {
let env = make_env();
let (bills, _cfg, upg_admin) = setup(&env);

// Config has no "max_page_lmt" key — sync should not error
bills.sync_limits_from_config(&upg_admin);

// Create 60 bills and page: should still get MAX_PAGE_LIMIT (50)
let owner = Address::generate(&env);
let name = String::from_str(&env, "Bill");
for _ in 0..60 {
bills.create_bill(
&owner,
&name,
&1i128,
&1_800_000_000u64,
&false,
&0u32,
&String::from_str(&env, ""),
);
}

let page = bills.get_unpaid_bills(&owner, &0, &99);
assert_eq!(page.count, MAX_PAGE_LIMIT);
}

#[test]
fn test_sync_can_be_updated() {
let env = make_env();
let (bills, cfg, upg_admin) = setup(&env);

let cfg_admin = cfg.get_admin().unwrap();

// First sync: limit = 10
cfg.set_config(
&cfg_admin,
&Symbol::new(&env, "max_page_lmt"),
&ConfigValue::U32(10),
);
bills.sync_limits_from_config(&upg_admin);

let owner = Address::generate(&env);
let name = String::from_str(&env, "Bill");
for _ in 0..20 {
bills.create_bill(
&owner,
&name,
&1i128,
&1_800_000_000u64,
&false,
&0u32,
&String::from_str(&env, ""),
);
}

let page = bills.get_unpaid_bills(&owner, &0, &50);
assert_eq!(page.count, 10);

// Second sync: limit bumped to 15
cfg.set_config(
&cfg_admin,
&Symbol::new(&env, "max_page_lmt"),
&ConfigValue::U32(15),
);
bills.sync_limits_from_config(&upg_admin);

let page2 = bills.get_unpaid_bills(&owner, &0, &50);
assert_eq!(page2.count, 15);
}

#[test]
fn test_sync_requires_upgrade_admin() {
let env = make_env();
let (bills, _cfg, _upg_admin) = setup(&env);

let stranger = Address::generate(&env);
let result = bills.try_sync_limits_from_config(&stranger);
assert!(result.is_err());
}
Loading
Loading