diff --git a/contracts/invoice-escrow/src/errors.rs b/contracts/invoice-escrow/src/errors.rs index fa9005f..88c551d 100644 --- a/contracts/invoice-escrow/src/errors.rs +++ b/contracts/invoice-escrow/src/errors.rs @@ -52,6 +52,8 @@ pub enum Error { SignatureExpired = 22, /// Funding amount does not meet the required milestone threshold. InvalidMilestoneAmount = 23, + /// Escrow has received partial payment and cannot be cancelled. + EscrowPartiallyFunded = 24, /// Cannot cancel because escrow is not in the correct state. CancelNotAllowed = 24, /// Penalty configuration is invalid (e.g. rate exceeds maximum). diff --git a/contracts/invoice-escrow/src/test.rs b/contracts/invoice-escrow/src/test.rs index bbbb882..63596f8 100644 --- a/contracts/invoice-escrow/src/test.rs +++ b/contracts/invoice-escrow/src/test.rs @@ -2416,6 +2416,7 @@ fn test_cancel_escrow_partially_funded_refunds() { &pt_id.address(), &inv_token_id, &test_commitment(&env, "test_invoice_data"), + &None, &Some(500), ); client.fund_escrow(&invoice_id, &buyer, &500); @@ -3600,7 +3601,11 @@ fn test_set_whitelist_enabled_requires_admin_auth() { let admin = Address::generate(&env); let non_admin = Address::generate(&env); - escrow_client.initialize(&admin, &300); + env.invoke_contract::<()>( + &escrow_id, + &Symbol::new(&env, "initialize"), + soroban_sdk::vec![&env, admin.to_val(), 300u32.into_val(&env)], + ); let result = escrow_client.try_set_whitelist_enabled(&non_admin, &true); assert_eq!(result, Err(Ok(Error::Unauthorized))); @@ -3657,7 +3662,11 @@ fn test_set_buyer_whitelisted_requires_admin_auth() { let admin = Address::generate(&env); let non_admin = Address::generate(&env); let buyer = Address::generate(&env); - escrow_client.initialize(&admin, &300); + env.invoke_contract::<()>( + &escrow_id, + &Symbol::new(&env, "initialize"), + soroban_sdk::vec![&env, admin.to_val(), 300u32.into_val(&env)], + ); let result = escrow_client.try_set_buyer_whitelisted(&non_admin, &buyer, &true); assert_eq!(result, Err(Ok(Error::Unauthorized))); @@ -5214,7 +5223,7 @@ fn test_settlement_at_exact_due_date_state_persistence() { let payment_token_asset = AssetClient::new(&env, &payment_token_id.address()); let inv_token_id = env.register(MockInvoiceToken, ()); - escrow_client.initialize(&admin, &300); + escrow_client.initialize(&admin, &0); let seller = Address::generate(&env); let buyer = Address::generate(&env); diff --git a/contracts/invoice-escrow/test_snapshots/test/test_cancel_escrow_partially_funded_rejected.1.json b/contracts/invoice-escrow/test_snapshots/test/test_cancel_escrow_partially_funded_rejected.1.json index ef85f7f..c93662b 100644 --- a/contracts/invoice-escrow/test_snapshots/test/test_cancel_escrow_partially_funded_rejected.1.json +++ b/contracts/invoice-escrow/test_snapshots/test/test_cancel_escrow_partially_funded_rejected.1.json @@ -107,16 +107,62 @@ { "bytes": "746573745f696e766f6963655f64617461000000000000000000000000000000" }, + "void" + ] + } + }, + "sub_invocations": [] + } + ] + ], + [ + [ + "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAOLZM", + { + "function": { + "contract_fn": { + "contract_address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM", + "function_name": "fund_escrow", + "args": [ + { + "symbol": "INV_PFUND" + }, + { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAOLZM" + }, { - "i128": "500" + "i128": "400" } ] } }, - "sub_invocations": [] + "sub_invocations": [ + { + "function": { + "contract_fn": { + "contract_address": "CDLDVFKHEZ2RVB3NG4UQA4VPD3TSHV6XMHXMHP2BSGCJ2IIWVTOHGDSG", + "function_name": "transfer", + "args": [ + { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAOLZM" + }, + { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM" + }, + { + "i128": "400" + } + ] + } + }, + "sub_invocations": [] + } + ] } ] ], + [], + [], [] ], "ledger": { @@ -227,30 +273,34 @@ "symbol": "funded_amt" }, "val": { - "i128": "0" + "i128": "400" } }, { "key": { "symbol": "funder" }, - "val": "void" + "val": { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAOLZM" + } }, { "key": { "symbol": "funders" }, "val": { - "vec": [] + "vec": [ + { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAOLZM" + } + ] } }, { "key": { "symbol": "funding_milestone" }, - "val": { - "i128": "500" - } + "val": "void" }, { "key": { @@ -316,6 +366,36 @@ }, "live_until": 518400 }, + { + "entry": { + "last_modified_ledger_seq": 0, + "data": { + "contract_data": { + "ext": "v0", + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM", + "key": { + "vec": [ + { + "symbol": "FunderAmount" + }, + { + "symbol": "INV_PFUND" + }, + { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAOLZM" + } + ] + }, + "durability": "persistent", + "val": { + "i128": "400" + } + } + }, + "ext": "v0" + }, + "live_until": 4095 + }, { "entry": { "last_modified_ledger_seq": 0, @@ -371,22 +451,6 @@ }, "val": "void" }, - { - "key": { - "symbol": "penalty_base_bps" - }, - "val": { - "u32": 100 - } - }, - { - "key": { - "symbol": "penalty_rate_per_day_bps" - }, - "val": { - "u32": 10 - } - }, { "key": { "symbol": "whitelist_enabled" @@ -441,7 +505,16 @@ "executable": { "wasm": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" }, - "storage": null + "storage": [ + { + "key": { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAOLZM" + }, + "val": { + "i128": "400" + } + } + ] } } } @@ -490,6 +563,78 @@ }, "live_until": 6311999 }, + { + "entry": { + "last_modified_ledger_seq": 0, + "data": { + "contract_data": { + "ext": "v0", + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAOLZM", + "key": { + "ledger_key_nonce": { + "nonce": "2032731177588607455" + } + }, + "durability": "temporary", + "val": "void" + } + }, + "ext": "v0" + }, + "live_until": 6311999 + }, + { + "entry": { + "last_modified_ledger_seq": 0, + "data": { + "contract_data": { + "ext": "v0", + "contract": "CDLDVFKHEZ2RVB3NG4UQA4VPD3TSHV6XMHXMHP2BSGCJ2IIWVTOHGDSG", + "key": { + "vec": [ + { + "symbol": "Balance" + }, + { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM" + } + ] + }, + "durability": "persistent", + "val": { + "map": [ + { + "key": { + "symbol": "amount" + }, + "val": { + "i128": "400" + } + }, + { + "key": { + "symbol": "authorized" + }, + "val": { + "bool": true + } + }, + { + "key": { + "symbol": "clawback" + }, + "val": { + "bool": false + } + } + ] + } + } + }, + "ext": "v0" + }, + "live_until": 518400 + }, { "entry": { "last_modified_ledger_seq": 0, @@ -515,7 +660,7 @@ "symbol": "amount" }, "val": { - "i128": "1000" + "i128": "600" } }, { diff --git a/contracts/invoice-escrow/test_snapshots/test/test_settlement_at_exact_due_date_state_persistence.1.json b/contracts/invoice-escrow/test_snapshots/test/test_settlement_at_exact_due_date_state_persistence.1.json index 84cdffb..af4fee2 100644 --- a/contracts/invoice-escrow/test_snapshots/test/test_settlement_at_exact_due_date_state_persistence.1.json +++ b/contracts/invoice-escrow/test_snapshots/test/test_settlement_at_exact_due_date_state_persistence.1.json @@ -40,7 +40,7 @@ "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFCT4" }, { - "u32": 300 + "u32": 0 } ] } @@ -501,7 +501,7 @@ "symbol": "fee_bps" }, "val": { - "u32": 300 + "u32": 0 } }, { @@ -767,7 +767,7 @@ "symbol": "amount" }, "val": { - "i128": "60" + "i128": "0" } }, { @@ -871,7 +871,7 @@ "symbol": "amount" }, "val": { - "i128": "1940" + "i128": "2000" } }, { diff --git a/contracts/invoice-token/src/test.rs b/contracts/invoice-token/src/test.rs index 00ec122..23291ec 100644 --- a/contracts/invoice-token/src/test.rs +++ b/contracts/invoice-token/src/test.rs @@ -1415,6 +1415,160 @@ fn test_extend_allowance_fails_after_expiry() { assert_eq!(result, Err(Ok(crate::errors::Error::AllowanceExpired))); } +// ========== Uninitialized Contract Method Rejection Tests ========== + +#[test] +fn test_uninitialized_methods_rejected() { + let env = Env::default(); + env.mock_all_auths(); + let contract_id = env.register(InvoiceToken, ()); + let client = InvoiceTokenClient::new(&env, &contract_id); + + let user = Address::generate(&env); + let spender = Address::generate(&env); + let role = Symbol::new(&env, "admin"); + let amount: i128 = 100; + let expiration = env.ledger().sequence() + 100; + let mut ids = Vec::new(&env); + ids.push_back(user.clone()); + let mut amounts = Vec::new(&env); + amounts.push_back(amount); + + // SEP-41 view functions + assert_eq!(client.try_name(), Err(Ok(crate::errors::Error::NotInit))); + assert_eq!(client.try_symbol(), Err(Ok(crate::errors::Error::NotInit))); + assert_eq!( + client.try_decimals(), + Err(Ok(crate::errors::Error::NotInit)) + ); + assert_eq!( + client.try_total_supply(), + Err(Ok(crate::errors::Error::NotInit)) + ); + assert_eq!( + client.try_balance(&user), + Err(Ok(crate::errors::Error::NotInit)) + ); + assert_eq!( + client.try_allowance(&user, &spender), + Err(Ok(crate::errors::Error::NotInit)) + ); + assert_eq!( + client.try_get_nonce(&user), + Err(Ok(crate::errors::Error::NotInit)) + ); + + // Metadata / admin view functions + assert_eq!( + client.try_invoice_id(), + Err(Ok(crate::errors::Error::NotInit)) + ); + assert_eq!( + client.try_transfer_locked(), + Err(Ok(crate::errors::Error::NotInit)) + ); + assert_eq!(client.try_paused(), Err(Ok(crate::errors::Error::NotInit))); + assert_eq!( + client.try_get_fee_bps(), + Err(Ok(crate::errors::Error::NotInit)) + ); + assert_eq!( + client.try_get_role_admin(&role), + Err(Ok(crate::errors::Error::NotInit)) + ); + assert_eq!( + client.try_has_role(&role, &user), + Err(Ok(crate::errors::Error::NotInit)) + ); + assert_eq!( + client.try_get_token_history(&user), + Err(Ok(crate::errors::Error::NotInit)) + ); + + // Write: transfer / burn / mint functions + assert_eq!( + client.try_transfer(&user, &spender, &amount), + Err(Ok(crate::errors::Error::NotInit)) + ); + assert_eq!( + client.try_approve(&user, &spender, &amount, &expiration), + Err(Ok(crate::errors::Error::NotInit)) + ); + assert_eq!( + client.try_extend_allowance(&user, &spender, &expiration), + Err(Ok(crate::errors::Error::NotInit)) + ); + assert_eq!( + client.try_revoke_approval(&user, &spender), + Err(Ok(crate::errors::Error::NotInit)) + ); + assert_eq!( + client.try_transfer_from(&spender, &user, &user, &amount), + Err(Ok(crate::errors::Error::NotInit)) + ); + assert_eq!( + client.try_burn(&user, &amount), + Err(Ok(crate::errors::Error::NotInit)) + ); + assert_eq!( + client.try_burn_from(&spender, &user, &amount), + Err(Ok(crate::errors::Error::NotInit)) + ); + assert_eq!( + client.try_mint(&user, &amount, &spender), + Err(Ok(crate::errors::Error::NotInit)) + ); + assert_eq!( + client.try_mint_batch(&ids, &amounts, &spender), + Err(Ok(crate::errors::Error::NotInit)) + ); + + // Write: admin / config functions + assert_eq!( + client.try_set_transfer_locked(&user, &false), + Err(Ok(crate::errors::Error::NotInit)) + ); + assert_eq!( + client.try_set_minter(&user), + Err(Ok(crate::errors::Error::NotInit)) + ); + assert_eq!( + client.try_set_decimals(&7u32), + Err(Ok(crate::errors::Error::NotInit)) + ); + assert_eq!( + client.try_set_paused(&true), + Err(Ok(crate::errors::Error::NotInit)) + ); + assert_eq!( + client.try_set_fee_bps(&user, &250), + Err(Ok(crate::errors::Error::NotInit)) + ); + assert_eq!( + client.try_set_role_admin(&user, &role, &spender), + Err(Ok(crate::errors::Error::NotInit)) + ); + assert_eq!( + client.try_grant_role(&user, &role, &spender), + Err(Ok(crate::errors::Error::NotInit)) + ); + assert_eq!( + client.try_revoke_role(&user, &role, &spender), + Err(Ok(crate::errors::Error::NotInit)) + ); + + // State persistence: initialize must still succeed (no AlreadyInit), + // proving that none of the rejected calls wrote to storage. + let admin = Address::generate(&env); + let minter = Address::generate(&env); + let name = SorobanString::from_str(&env, "Invoice INV-001"); + let symbol = SorobanString::from_str(&env, "INV001"); + let invoice_id = Symbol::new(&env, "inv_001"); + assert!(client + .try_initialize(&admin, &name, &symbol, &7u32, &invoice_id, &minter) + .is_ok()); +} + // ========== Storage Key Serialization Tests (Issue #161) ========== /// Helper: assert a StorageKey roundtrips losslessly through Val. diff --git a/contracts/invoice-token/test_snapshots/test/test_uninitialized_methods_rejected.1.json b/contracts/invoice-token/test_snapshots/test/test_uninitialized_methods_rejected.1.json new file mode 100644 index 0000000..95fc664 --- /dev/null +++ b/contracts/invoice-token/test_snapshots/test/test_uninitialized_methods_rejected.1.json @@ -0,0 +1,332 @@ +{ + "generators": { + "address": 5, + "nonce": 0, + "mux_id": 0 + }, + "auth": [ + [], + [], + [], + [], + [], + [], + [], + [], + [], + [], + [], + [], + [], + [], + [], + [], + [], + [], + [], + [], + [], + [], + [], + [], + [], + [], + [], + [], + [], + [], + [], + [], + [] + ], + "ledger": { + "protocol_version": 27, + "sequence_number": 0, + "timestamp": 0, + "network_id": "0000000000000000000000000000000000000000000000000000000000000000", + "base_reserve": 0, + "min_persistent_entry_ttl": 4096, + "min_temp_entry_ttl": 16, + "max_entry_ttl": 6312000, + "ledger_entries": [ + { + "entry": { + "last_modified_ledger_seq": 0, + "data": { + "contract_data": { + "ext": "v0", + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM", + "key": "ledger_key_contract_instance", + "durability": "persistent", + "val": { + "contract_instance": { + "executable": { + "wasm": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + }, + "storage": [ + { + "key": { + "vec": [ + { + "symbol": "Metadata" + } + ] + }, + "val": { + "map": [ + { + "key": { + "symbol": "admin" + }, + "val": { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAITA4" + } + }, + { + "key": { + "symbol": "decimals" + }, + "val": { + "u32": 7 + } + }, + { + "key": { + "symbol": "invoice_id" + }, + "val": { + "symbol": "inv_001" + } + }, + { + "key": { + "symbol": "minter" + }, + "val": { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAK3IM" + } + }, + { + "key": { + "symbol": "name" + }, + "val": { + "string": "Invoice INV-001" + } + }, + { + "key": { + "symbol": "paused" + }, + "val": { + "bool": false + } + }, + { + "key": { + "symbol": "symbol" + }, + "val": { + "string": "INV001" + } + }, + { + "key": { + "symbol": "transfer_locked" + }, + "val": { + "bool": true + } + } + ] + } + }, + { + "key": { + "vec": [ + { + "symbol": "RoleAdmin" + }, + { + "symbol": "admin" + } + ] + }, + "val": { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAITA4" + } + }, + { + "key": { + "vec": [ + { + "symbol": "RoleAdmin" + }, + { + "symbol": "minter" + } + ] + }, + "val": { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAITA4" + } + }, + { + "key": { + "vec": [ + { + "symbol": "RoleAdmin" + }, + { + "symbol": "pauser" + } + ] + }, + "val": { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAITA4" + } + }, + { + "key": { + "vec": [ + { + "symbol": "RoleAdmin" + }, + { + "symbol": "transfer_locker" + } + ] + }, + "val": { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAITA4" + } + }, + { + "key": { + "vec": [ + { + "symbol": "RoleGrant" + }, + { + "symbol": "admin" + }, + { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAITA4" + } + ] + }, + "val": { + "bool": true + } + }, + { + "key": { + "vec": [ + { + "symbol": "RoleGrant" + }, + { + "symbol": "minter" + }, + { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAITA4" + } + ] + }, + "val": { + "bool": true + } + }, + { + "key": { + "vec": [ + { + "symbol": "RoleGrant" + }, + { + "symbol": "minter" + }, + { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAK3IM" + } + ] + }, + "val": { + "bool": true + } + }, + { + "key": { + "vec": [ + { + "symbol": "RoleGrant" + }, + { + "symbol": "pauser" + }, + { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAITA4" + } + ] + }, + "val": { + "bool": true + } + }, + { + "key": { + "vec": [ + { + "symbol": "RoleGrant" + }, + { + "symbol": "transfer_locker" + }, + { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAITA4" + } + ] + }, + "val": { + "bool": true + } + }, + { + "key": { + "vec": [ + { + "symbol": "TotalSupply" + } + ] + }, + "val": { + "i128": "0" + } + } + ] + } + } + } + }, + "ext": "v0" + }, + "live_until": 4095 + }, + { + "entry": { + "last_modified_ledger_seq": 0, + "data": { + "contract_code": { + "ext": "v0", + "hash": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "code": "" + } + }, + "ext": "v0" + }, + "live_until": 4095 + } + ] + }, + "events": [] +} \ No newline at end of file diff --git a/contracts/payment-distributor/src/errors.rs b/contracts/payment-distributor/src/errors.rs index dcb3c6d..71c9368 100644 --- a/contracts/payment-distributor/src/errors.rs +++ b/contracts/payment-distributor/src/errors.rs @@ -39,4 +39,14 @@ pub enum Error { EmptyBatch = 21, /// Batch exceeds the maximum allowed number of entries. BatchTooLarge = 22, + /// Too many fee recipients in fanout. + TooManyFeeRecipients = 23, + /// Invalid fee split configuration. + InvalidFeeSplit = 24, + /// Bonus rate exceeds maximum allowed value (10,000 BPS). + InvalidBonusRate = 25, + /// Too many refund recipients. + TooManyRefundRecipients = 26, + /// Invalid refund weight. + InvalidRefundWeight = 27, } diff --git a/contracts/payment-distributor/src/lib.rs b/contracts/payment-distributor/src/lib.rs index f624c76..53fcfff 100644 --- a/contracts/payment-distributor/src/lib.rs +++ b/contracts/payment-distributor/src/lib.rs @@ -18,7 +18,6 @@ use errors::Error; const ESCROW_STATUS_FUNDED: u32 = 1; const ESCROW_STATUS_SETTLED: u32 = 2; const ESCROW_STATUS_REFUNDED: u32 = 3; -const MAX_FEE_BPS: u32 = 10_000; const MAX_FANOUT_RECIPIENTS: u32 = 10; const MAX_REFUND_RECIPIENTS: u32 = 10; @@ -483,12 +482,18 @@ impl PaymentDistributor { return Err(Error::InsufficientBalance); } - token_client.transfer(&contract_addr, &funder, &refund_amount); + for i in 0..recipients.len() { + let r = recipients.get(i).ok_or(Error::InvalidAmount)?; + let a = recipient_amounts.get(i).ok_or(Error::InvalidAmount)?; + if a > 0 { + token_client.transfer(&contract_addr, &r, &a); + } + } state.refund_distributed = true; storage::set_distribution(&env, &escrow_contract, &invoice_id, &state); - events::refund_distributed(&env, &escrow_contract, &invoice_id, &funder, refund_amount); + events::refund_distributed(&env, &escrow_contract, &invoice_id, &recipients, &recipient_amounts); release_lock(&env); Ok(()) } diff --git a/contracts/payment-distributor/src/types.rs b/contracts/payment-distributor/src/types.rs index 6e1ea27..078dcb5 100644 --- a/contracts/payment-distributor/src/types.rs +++ b/contracts/payment-distributor/src/types.rs @@ -17,6 +17,10 @@ pub enum StorageKey { RoleAdmin(soroban_sdk::Symbol), /// Role grant flag for a given (role, account) pair (Issue #182). RoleGrant(soroban_sdk::Symbol, soroban_sdk::Address), + /// Platform fee tier configuration (Issue #124). + FeeTiers, + /// Investor yield bonus in basis points (Issue #132). + InvestorBonusBps, } #[contracttype] @@ -26,6 +30,14 @@ pub struct DistributionState { pub refund_distributed: bool, } +/// A fee tier configuration: a recipient address and its fee basis points. Issue #124. +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct FeeTier { + pub recipient: Address, + pub fee_bps: u32, +} + /// Maximum allowed fee in basis points (100% = 10,000 BPS). Issue #124. #[allow(dead_code)] pub const MAX_FEE_BPS: u32 = 10_000;