From 3f236b5137679b46a8c834e5bc15bb6a44487064 Mon Sep 17 00:00:00 2001 From: Abdulmajeed Abdullateef <81535615+Abdulmajeed82@users.noreply.github.com> Date: Sat, 30 May 2026 14:46:49 +0000 Subject: [PATCH 1/3] test: assert atomic rollback on cross-contract transfer failure (#378) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add claim flow transfer failure tests to the cross-contract atomicity suite. - Add FailingTransferToken mock contract: implements full Soroban token interface, panics when transfer is called with from==fail_from. This allows deposit (issuer→contract) to succeed while claim (contract→holder) fails, isolating the claim transfer failure path. - Add 7 tests in src/test_claim_transfer_fail.rs: - claim_transfer_fail_returns_transfer_failed - claim_transfer_fail_does_not_advance_last_claimed_idx - claim_transfer_fail_holder_balance_unchanged - claim_transfer_fail_contract_balance_unchanged - claim_transfer_fail_then_retry_succeeds - claim_transfer_fail_multi_period_no_partial_state - claim_transfer_fail_does_not_affect_other_holder_state - claim_transfer_fail_does_not_affect_sibling_offering Security invariant verified: LastClaimedIdx is written AFTER try_transfer succeeds. A failed transfer leaves zero observable state change — no index advance, no balance movement, no partial period claims. --- src/lib.rs | 2 + src/test_claim_transfer_fail.rs | 482 ++++++++++++++++++++++++++++++++ 2 files changed, 484 insertions(+) create mode 100644 src/test_claim_transfer_fail.rs diff --git a/src/lib.rs b/src/lib.rs index 24d469a8..28759167 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -165,6 +165,8 @@ pub mod vesting; mod test_duplicates; #[cfg(test)] mod test_min_revenue_threshold_boundary; +#[cfg(test)] +mod test_claim_transfer_fail; // ── Event symbols ──────────────────────────────────────────── const EVENT_REVENUE_REPORTED: Symbol = symbol_short!("rev_rep"); diff --git a/src/test_claim_transfer_fail.rs b/src/test_claim_transfer_fail.rs new file mode 100644 index 00000000..d3e37c7c --- /dev/null +++ b/src/test_claim_transfer_fail.rs @@ -0,0 +1,482 @@ +//! # Claim Transfer Failure — Atomicity Test Suite (#378) +//! +//! Verifies that a failed `try_transfer` during `claim` leaves **zero** observable +//! state change: `LastClaimedIdx` is NOT advanced, no tokens move, and the holder +//! can retry after the underlying issue is resolved. +//! +//! ## Atomicity Invariant +//! +//! ```text +//! claim: +//! 1. require_auth(holder) ← auth check +//! 2. blacklist / share / window checks ← pure reads +//! 3. iterate periods, accumulate total_payout ← pure reads + accumulation +//! 4. try_transfer(contract → holder, payout) +//! └─ FAIL → return Err(TransferFailed) ← LastClaimedIdx NOT written +//! 5. storage().set(LastClaimedIdx) ← only reached on success +//! 6. emit claim event ← only reached on success +//! ``` +//! +//! If step 4 fails, step 5 is never executed, so `LastClaimedIdx` is unchanged +//! and the holder can retry the claim once the token issue is resolved. +//! +//! ## Security Note +//! +//! The ordering of `try_transfer` **before** `LastClaimedIdx` write is the critical +//! invariant. Any refactor that moves the index write above the transfer call would +//! allow a holder to mark periods as claimed without actually receiving tokens — +//! permanently losing their payout. +//! +//! ## Mock Token Design +//! +//! `FailingTransferToken` is a minimal Soroban contract implementing the standard +//! token interface. It stores a `fail_from` address; when `transfer` is called with +//! `from == fail_from`, it panics (simulating a reverting token). This lets us: +//! - Succeed on deposit (issuer → contract, `from == issuer`) +//! - Fail on claim (contract → holder, `from == contract`) +//! +//! The token tracks balances in storage so deposit/claim balance assertions work. + +#![cfg(test)] + +use crate::{RevoraError, RevoraRevenueShare, RevoraRevenueShareClient}; +use soroban_sdk::{ + contract, contractimpl, contracttype, symbol_short, + testutils::Address as _, + token, Address, Env, String, +}; + +// ══════════════════════════════════════════════════════════════════════════════ +// FailingTransferToken — mock token that panics when `from == fail_from` +// ══════════════════════════════════════════════════════════════════════════════ + +#[contracttype] +enum TokenKey { + Balance(Address), + FailFrom, +} + +/// Minimal token contract: supports `transfer` and `balance`. +/// Panics when `from == fail_from` (set via `set_fail_from`). +/// Implements the full Soroban token interface so `token::Client` can call it. +#[contract] +pub struct FailingTransferToken; + +#[contractimpl] +impl FailingTransferToken { + /// Configure which `from` address causes `transfer` to panic. + pub fn set_fail_from(env: Env, fail_from: Address) { + env.storage().persistent().set(&TokenKey::FailFrom, &fail_from); + } + + /// Mint tokens to `to` (test helper, no auth). + pub fn mint(env: Env, to: Address, amount: i128) { + let bal: i128 = + env.storage().persistent().get(&TokenKey::Balance(to.clone())).unwrap_or(0); + env.storage().persistent().set(&TokenKey::Balance(to), &(bal + amount)); + } + + pub fn balance(env: Env, id: Address) -> i128 { + env.storage().persistent().get(&TokenKey::Balance(id)).unwrap_or(0) + } + + pub fn transfer(env: Env, from: Address, to: Address, amount: i128) { + let fail_from: Option
= env.storage().persistent().get(&TokenKey::FailFrom); + if let Some(ref f) = fail_from { + if &from == f { + panic!("transfer intentionally failed for test"); + } + } + let from_bal: i128 = + env.storage().persistent().get(&TokenKey::Balance(from.clone())).unwrap_or(0); + env.storage().persistent().set(&TokenKey::Balance(from), &(from_bal - amount)); + let to_bal: i128 = + env.storage().persistent().get(&TokenKey::Balance(to.clone())).unwrap_or(0); + env.storage().persistent().set(&TokenKey::Balance(to), &(to_bal + amount)); + } + + pub fn transfer_from( + _env: Env, + _spender: Address, + _from: Address, + _to: Address, + _amount: i128, + ) { + panic!("not implemented"); + } + + pub fn approve( + _env: Env, + _from: Address, + _spender: Address, + _amount: i128, + _expiration_ledger: u32, + ) { + } + + pub fn allowance(_env: Env, _from: Address, _spender: Address) -> i128 { + 0 + } + + pub fn decimals(_env: Env) -> u32 { + 7 + } + + pub fn name(env: Env) -> String { + String::from_str(&env, "FailToken") + } + + pub fn symbol(env: Env) -> String { + String::from_str(&env, "FAIL") + } + + pub fn total_supply(_env: Env) -> i128 { + 0 + } + + pub fn burn(_env: Env, _from: Address, _amount: i128) { + panic!("not implemented"); + } + + pub fn burn_from(_env: Env, _spender: Address, _from: Address, _amount: i128) { + panic!("not implemented"); + } + + pub fn set_authorized(_env: Env, _id: Address, _authorize: bool) {} + + pub fn authorized(_env: Env, _id: Address) -> bool { + true + } + + pub fn clawback(_env: Env, _from: Address, _amount: i128) { + panic!("not implemented"); + } + + pub fn set_admin(_env: Env, _new_admin: Address) {} + + pub fn admin(_env: Env) -> Address { + panic!("not implemented"); + } +} + +// ══════════════════════════════════════════════════════════════════════════════ +// Test helpers +// ══════════════════════════════════════════════════════════════════════════════ + +fn make_revora(env: &Env) -> (Address, RevoraRevenueShareClient<'static>) { + let id = env.register_contract(None, RevoraRevenueShare); + let client = RevoraRevenueShareClient::new(env, &id); + (id, client) +} + +fn deploy_failing_token(env: &Env) -> (Address, FailingTransferTokenClient<'static>) { + let id = env.register_contract(None, FailingTransferToken); + let client = FailingTransferTokenClient::new(env, &id); + (id, client) +} + +/// Full setup for claim-failure tests. +/// +/// - Registers an offering with `FailingTransferToken` as payment token. +/// - Gives holder 100% share (10_000 bps). +/// - Deposits period 1 (100_000) — succeeds because fail_from is not yet set. +/// - Configures the token to fail when `from == revora_id` (claim direction). +/// +/// Returns `(env, revora_id, revora, fail_token_id, fail_token, issuer, offering_token, holder)`. +fn setup_claim_fail() -> ( + Env, + Address, + RevoraRevenueShareClient<'static>, + Address, + FailingTransferTokenClient<'static>, + Address, + Address, + Address, +) { + let env = Env::default(); + env.mock_all_auths(); + + let (revora_id, revora) = make_revora(&env); + let (fail_token_id, fail_token) = deploy_failing_token(&env); + + let issuer = Address::generate(&env); + let offering_token = Address::generate(&env); + let holder = Address::generate(&env); + + revora.register_offering( + &issuer, + &symbol_short!("def"), + &offering_token, + &10_000, + &fail_token_id, + &0, + ); + revora.set_holder_share(&issuer, &symbol_short!("def"), &offering_token, &holder, &10_000); + + // Mint to issuer and deposit — transfer direction is issuer→contract, not yet failing + fail_token.mint(&issuer, &1_000_000); + revora.deposit_revenue( + &issuer, + &symbol_short!("def"), + &offering_token, + &fail_token_id, + &100_000, + &1, + ); + + // Now arm the token to fail when `from == revora_id` (claim: contract→holder) + fail_token.set_fail_from(&revora_id); + + (env, revora_id, revora, fail_token_id, fail_token, issuer, offering_token, holder) +} + +// ══════════════════════════════════════════════════════════════════════════════ +// CLAIM TRANSFER FAILURE TESTS +// ══════════════════════════════════════════════════════════════════════════════ + +/// Claim transfer failure returns `TransferFailed`. +#[test] +fn claim_transfer_fail_returns_transfer_failed() { + let (_env, _revora_id, revora, _fail_token_id, _fail_token, issuer, offering_token, holder) = + setup_claim_fail(); + + let result = revora.try_claim(&holder, &issuer, &symbol_short!("def"), &offering_token, &50); + + assert!(result.is_err(), "expected Err, got {result:?}"); + assert!( + matches!(result.err(), Some(Ok(RevoraError::TransferFailed))), + "expected TransferFailed" + ); +} + +/// `LastClaimedIdx` is NOT advanced when claim transfer fails. +#[test] +fn claim_transfer_fail_does_not_advance_last_claimed_idx() { + let (_env, _revora_id, revora, _fail_token_id, _fail_token, issuer, offering_token, holder) = + setup_claim_fail(); + + let pending_before = + revora.get_pending_periods(&issuer, &symbol_short!("def"), &offering_token, &holder); + assert_eq!(pending_before.len(), 1, "should have 1 pending period before failed claim"); + + let _ = revora.try_claim(&holder, &issuer, &symbol_short!("def"), &offering_token, &50); + + let pending_after = + revora.get_pending_periods(&issuer, &symbol_short!("def"), &offering_token, &holder); + assert_eq!( + pending_after.len(), + pending_before.len(), + "LastClaimedIdx must not advance on transfer failure" + ); + assert_eq!( + pending_after.get(0), + pending_before.get(0), + "pending period IDs must be unchanged" + ); +} + +/// Holder balance is unchanged when claim transfer fails. +#[test] +fn claim_transfer_fail_holder_balance_unchanged() { + let (_env, _revora_id, revora, _fail_token_id, fail_token, issuer, offering_token, holder) = + setup_claim_fail(); + + let holder_bal_before = fail_token.balance(&holder); + + let _ = revora.try_claim(&holder, &issuer, &symbol_short!("def"), &offering_token, &50); + + assert_eq!( + fail_token.balance(&holder), + holder_bal_before, + "holder balance must not change on failed claim transfer" + ); +} + +/// Contract balance is unchanged when claim transfer fails. +#[test] +fn claim_transfer_fail_contract_balance_unchanged() { + let (_env, revora_id, revora, _fail_token_id, fail_token, issuer, offering_token, holder) = + setup_claim_fail(); + + let contract_bal_before = fail_token.balance(&revora_id); + + let _ = revora.try_claim(&holder, &issuer, &symbol_short!("def"), &offering_token, &50); + + assert_eq!( + fail_token.balance(&revora_id), + contract_bal_before, + "contract balance must not change on failed claim transfer" + ); +} + +/// After a failed claim, the holder can retry and succeed once the token issue is resolved. +#[test] +fn claim_transfer_fail_then_retry_succeeds() { + let (env, _revora_id, revora, _fail_token_id, fail_token, issuer, offering_token, holder) = + setup_claim_fail(); + + // First attempt fails + let r1 = revora.try_claim(&holder, &issuer, &symbol_short!("def"), &offering_token, &50); + assert!(matches!(r1.err(), Some(Ok(RevoraError::TransferFailed)))); + + // Fix the token: point fail_from at a dummy address so claim direction no longer fails + let dummy = Address::generate(&env); + fail_token.set_fail_from(&dummy); + + // Retry — should now succeed + let r2 = revora.try_claim(&holder, &issuer, &symbol_short!("def"), &offering_token, &50); + assert!(r2.is_ok(), "retry after fixing token should succeed, got {r2:?}"); + assert_eq!(r2.unwrap(), 100_000, "holder should receive full payout on retry"); + + // Pending periods now empty + let pending = + revora.get_pending_periods(&issuer, &symbol_short!("def"), &offering_token, &holder); + assert_eq!(pending.len(), 0, "all periods should be claimed after successful retry"); +} + +/// Multi-period claim: all periods fail atomically — none are marked claimed. +#[test] +fn claim_transfer_fail_multi_period_no_partial_state() { + let (env, revora_id, revora, fail_token_id, fail_token, issuer, offering_token, holder) = + setup_claim_fail(); + + // Temporarily disable fail mode to deposit two more periods + let dummy = Address::generate(&env); + fail_token.set_fail_from(&dummy); + + fail_token.mint(&issuer, &200_000); + revora.deposit_revenue( + &issuer, + &symbol_short!("def"), + &offering_token, + &fail_token_id, + &100_000, + &2, + ); + revora.deposit_revenue( + &issuer, + &symbol_short!("def"), + &offering_token, + &fail_token_id, + &100_000, + &3, + ); + + // Re-arm fail mode for claim direction + fail_token.set_fail_from(&revora_id); + + let pending_before = + revora.get_pending_periods(&issuer, &symbol_short!("def"), &offering_token, &holder); + assert_eq!(pending_before.len(), 3); + + // Attempt to claim all 3 — transfer fails + let result = revora.try_claim(&holder, &issuer, &symbol_short!("def"), &offering_token, &50); + assert!(matches!(result.err(), Some(Ok(RevoraError::TransferFailed)))); + + // All 3 periods still pending — no partial state + let pending_after = + revora.get_pending_periods(&issuer, &symbol_short!("def"), &offering_token, &holder); + assert_eq!( + pending_after.len(), + 3, + "all 3 periods must remain pending after failed multi-period claim" + ); +} + +/// A failed claim does not affect a different holder's pending state. +#[test] +fn claim_transfer_fail_does_not_affect_other_holder_state() { + let (env, revora_id, revora, fail_token_id, fail_token, issuer, offering_token, holder) = + setup_claim_fail(); + + let holder2 = Address::generate(&env); + // Give holder2 a share (adjust holder1 to 50% too) + revora.set_holder_share(&issuer, &symbol_short!("def"), &offering_token, &holder, &5_000); + revora.set_holder_share(&issuer, &symbol_short!("def"), &offering_token, &holder2, &5_000); + + // Deposit period 2 while fail mode is temporarily off + let dummy = Address::generate(&env); + fail_token.set_fail_from(&dummy); + fail_token.mint(&issuer, &100_000); + revora.deposit_revenue( + &issuer, + &symbol_short!("def"), + &offering_token, + &fail_token_id, + &100_000, + &2, + ); + + // Re-arm fail mode + fail_token.set_fail_from(&revora_id); + + // holder1 claim fails + let r1 = revora.try_claim(&holder, &issuer, &symbol_short!("def"), &offering_token, &50); + assert!(matches!(r1.err(), Some(Ok(RevoraError::TransferFailed)))); + + // holder2 pending state is independent and unchanged + let pending_h2 = + revora.get_pending_periods(&issuer, &symbol_short!("def"), &offering_token, &holder2); + assert_eq!(pending_h2.len(), 2, "holder2 should still have 2 pending periods"); + + // holder1 pending state also unchanged + let pending_h1 = + revora.get_pending_periods(&issuer, &symbol_short!("def"), &offering_token, &holder); + assert_eq!(pending_h1.len(), 2, "holder1 should still have 2 pending periods"); +} + +/// A failed claim on one offering does not affect a sibling offering's state. +#[test] +fn claim_transfer_fail_does_not_affect_sibling_offering() { + let (env, _revora_id, revora, _fail_token_id, _fail_token, issuer, offering_token_a, holder) = + setup_claim_fail(); + + // Register a second offering with a normal Stellar asset token + let offering_token_b = Address::generate(&env); + let admin_b = Address::generate(&env); + let payment_token_b = env.register_stellar_asset_contract(admin_b.clone()); + token::StellarAssetClient::new(&env, &payment_token_b).mint(&issuer, &100_000); + + revora.register_offering( + &issuer, + &symbol_short!("def"), + &offering_token_b, + &10_000, + &payment_token_b, + &0, + ); + revora.set_holder_share( + &issuer, + &symbol_short!("def"), + &offering_token_b, + &holder, + &10_000, + ); + revora.deposit_revenue( + &issuer, + &symbol_short!("def"), + &offering_token_b, + &payment_token_b, + &100_000, + &1, + ); + + // Claim on offering A fails (failing token) + let r_a = revora.try_claim(&holder, &issuer, &symbol_short!("def"), &offering_token_a, &50); + assert!(matches!(r_a.err(), Some(Ok(RevoraError::TransferFailed)))); + + // Claim on offering B succeeds (normal token) + let r_b = revora.try_claim(&holder, &issuer, &symbol_short!("def"), &offering_token_b, &50); + assert!(r_b.is_ok(), "sibling offering claim must succeed, got {r_b:?}"); + assert_eq!(r_b.unwrap(), 100_000); + + // Offering A: period 1 still pending + let pending_a = + revora.get_pending_periods(&issuer, &symbol_short!("def"), &offering_token_a, &holder); + assert_eq!(pending_a.len(), 1, "offering A period must remain pending"); + + // Offering B: no pending periods + let pending_b = + revora.get_pending_periods(&issuer, &symbol_short!("def"), &offering_token_b, &holder); + assert_eq!(pending_b.len(), 0, "offering B must be fully claimed"); +} From 6b0355b4b7bbbc6d93e1445b6d7680bb2ed9d384 Mon Sep 17 00:00:00 2001 From: Abdulmajeed Abdullateef <81535615+Abdulmajeed82@users.noreply.github.com> Date: Sun, 31 May 2026 10:09:10 +0000 Subject: [PATCH 2/3] feat: support configurable issuer transfer expiry (#362) - Add propose_transfer_with_expiry entrypoint (renamed from propose_issuer_transfer_with_expiry to fit 32-char limit) - Store effective expiry_secs in PendingTransfer (0 = use 7-day default) - accept_issuer_transfer now uses pending.expiry_secs with fallback to ISSUER_TRANSFER_EXPIRY_SECS (7 days) when expiry_secs == 0 - Clamp custom expiry to [MIN_ISSUER_TRANSFER_EXPIRY_SECS (1h), MAX_ISSUER_TRANSFER_EXPIRY_SECS (30d)] in do_propose_issuer_transfer - Fix replace_issuer_transfer: add missing expiry_secs field (expiry_secs=0) - Fix DataKey enum overflow: move BlacklistSizeLimit to DataKey2 to stay within Soroban XDR 50-variant limit Tests added (src/test.rs): - issuer_transfer_default_expiry_used_when_expiry_secs_zero - issuer_transfer_default_expiry_rejects_after_seven_days - issuer_transfer_custom_expiry_accepted_within_window - issuer_transfer_custom_expiry_rejected_after_window - issuer_transfer_custom_expiry_accepted_at_exact_boundary - issuer_transfer_expiry_below_min_clamped_to_min - issuer_transfer_expiry_above_max_clamped_to_max --- src/lib.rs | 70 +++++++++++++++++++++----- src/test.rs | 140 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 199 insertions(+), 11 deletions(-) diff --git a/src/lib.rs b/src/lib.rs index 28759167..2034653f 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -314,8 +314,12 @@ const EVENT_FREEZE_OFFERING: Symbol = symbol_short!("frz_off"); const EVENT_UNFREEZE_OFFERING: Symbol = symbol_short!("ufrz_off"); const EVENT_PROPOSAL_CREATED: Symbol = symbol_short!("prop_new"); const EVENT_FREEZE: Symbol = symbol_short!("freeze"); -/// Issuer transfer expiry: 7 days in seconds. +/// Issuer transfer expiry: 7 days in seconds (default). const ISSUER_TRANSFER_EXPIRY_SECS: u64 = 7 * 24 * 60 * 60; +/// Minimum configurable issuer transfer expiry: 1 hour. +const MIN_ISSUER_TRANSFER_EXPIRY_SECS: u64 = 60 * 60; +/// Maximum configurable issuer transfer expiry: 30 days. +const MAX_ISSUER_TRANSFER_EXPIRY_SECS: u64 = 30 * 24 * 60 * 60; const EVENT_CLAIM: Symbol = symbol_short!("claim"); const EVENT_CLAIM_DELAY_SET: Symbol = symbol_short!("dly_set"); // v1 versioned event symbols (legacy) @@ -405,6 +409,8 @@ pub struct AuditReconciliationResult { pub struct PendingTransfer { pub new_issuer: Address, pub timestamp: u64, + /// Effective expiry in seconds. 0 means use ISSUER_TRANSFER_EXPIRY_SECS default. + pub expiry_secs: u64, } /// Cross-offering aggregated metrics (#39). @@ -658,8 +664,6 @@ pub enum DataKey { SupplyCap(OfferingId), /// Per-offering investment constraints (#97). InvestmentConstraints(OfferingId), - /// Per-offering blacklist size limit (#358). If not set, defaults to MAX_BLACKLIST_SIZE. - BlacklistSizeLimit(OfferingId), } /// Secondary storage keys for auxiliary/extended contract state. @@ -696,6 +700,8 @@ pub enum DataKey2 { /// Direct offering index: (issuer, namespace, token) -> Offering for O(1) get_offering (#360). OfferingRecord(OfferingId), + /// Per-offering blacklist size limit (#358). If not set, defaults to MAX_BLACKLIST_SIZE. + BlacklistSizeLimit(OfferingId), } /// Maximum number of offerings returned in a single page. @@ -1611,6 +1617,33 @@ impl RevoraRevenueShare { namespace: Symbol, token: Address, new_issuer: Address, + ) -> Result<(), RevoraError> { + Self::do_propose_issuer_transfer(env, issuer, namespace, token, new_issuer, 0) + } + + /// Propose an issuer transfer with a custom expiry window. + /// + /// `expiry_secs` is clamped to `[MIN_ISSUER_TRANSFER_EXPIRY_SECS, MAX_ISSUER_TRANSFER_EXPIRY_SECS]`. + /// Pass `0` to use the default `ISSUER_TRANSFER_EXPIRY_SECS` (7 days). + #[allow(clippy::too_many_arguments)] + pub fn propose_transfer_with_expiry( + env: Env, + issuer: Address, + namespace: Symbol, + token: Address, + new_issuer: Address, + expiry_secs: u64, + ) -> Result<(), RevoraError> { + Self::do_propose_issuer_transfer(env, issuer, namespace, token, new_issuer, expiry_secs) + } + + fn do_propose_issuer_transfer( + env: Env, + issuer: Address, + namespace: Symbol, + token: Address, + new_issuer: Address, + expiry_secs: u64, ) -> Result<(), RevoraError> { Self::require_not_frozen(&env)?; Self::require_not_paused(&env)?; @@ -1633,10 +1666,20 @@ impl RevoraRevenueShare { return Err(RevoraError::IssuerTransferPending); } + // Clamp expiry: 0 means default; non-zero is clamped to [MIN, MAX]. + let effective_expiry = if expiry_secs == 0 { + 0 + } else { + expiry_secs + .max(MIN_ISSUER_TRANSFER_EXPIRY_SECS) + .min(MAX_ISSUER_TRANSFER_EXPIRY_SECS) + }; + let timestamp = env.ledger().timestamp(); - env.storage() - .persistent() - .set(&key, &PendingTransfer { new_issuer: new_issuer.clone(), timestamp }); + env.storage().persistent().set( + &key, + &PendingTransfer { new_issuer: new_issuer.clone(), timestamp, expiry_secs: effective_expiry }, + ); env.events().publish( (EVENT_ISSUER_TRANSFER_PROPOSED, issuer.clone(), namespace.clone(), token.clone()), (new_issuer.clone(), timestamp), @@ -1676,7 +1719,7 @@ impl RevoraRevenueShare { let timestamp = env.ledger().timestamp(); env.storage() .persistent() - .set(&key, &PendingTransfer { new_issuer: new_issuer.clone(), timestamp }); + .set(&key, &PendingTransfer { new_issuer: new_issuer.clone(), timestamp, expiry_secs: 0 }); env.events().publish( (EVENT_ISSUER_TRANSFER_CANCELLED, issuer.clone(), namespace.clone(), token.clone()), @@ -1710,7 +1753,12 @@ impl RevoraRevenueShare { .ok_or(RevoraError::NoTransferPending)?; let current_timestamp = env.ledger().timestamp(); - if current_timestamp > pending.timestamp.saturating_add(ISSUER_TRANSFER_EXPIRY_SECS) { + let effective_expiry = if pending.expiry_secs == 0 { + ISSUER_TRANSFER_EXPIRY_SECS + } else { + pending.expiry_secs + }; + if current_timestamp > pending.timestamp.saturating_add(effective_expiry) { return Err(RevoraError::IssuerTransferExpired); } @@ -3266,10 +3314,10 @@ impl RevoraRevenueShare { /// ### Returns /// The maximum allowed blacklist size for the offering. fn get_effective_blacklist_limit(env: &Env, offering_id: &OfferingId) -> u32 { - let key = DataKey::BlacklistSizeLimit(offering_id.clone()); + let key = DataKey2::BlacklistSizeLimit(offering_id.clone()); env.storage() .persistent() - .get::(&key) + .get::(&key) .unwrap_or(MAX_BLACKLIST_SIZE) } @@ -3328,7 +3376,7 @@ impl RevoraRevenueShare { token: token.clone(), }; - let key = DataKey::BlacklistSizeLimit(offering_id); + let key = DataKey2::BlacklistSizeLimit(offering_id); env.storage().persistent().set(&key, &max_size); Ok(()) diff --git a/src/test.rs b/src/test.rs index 8cff7941..4ee3f7e5 100644 --- a/src/test.rs +++ b/src/test.rs @@ -5547,6 +5547,146 @@ fn issuer_transfer_replace_without_pending_transfer_fails() { assert!(result.is_err()); } +// ── Configurable expiry tests (#362) ───────────────────────── + +#[test] +fn issuer_transfer_default_expiry_used_when_expiry_secs_zero() { + // propose_issuer_transfer (expiry_secs=0) → accept within 7 days → succeeds + let (env, client, issuer, token, _payment_token, _contract_id) = claim_setup(); + let new_issuer = Address::generate(&env); + + client.propose_issuer_transfer(&issuer, &symbol_short!("def"), &token, &new_issuer); + + // Advance time to just before the 7-day default expiry + let seven_days = 7u64 * 24 * 60 * 60; + env.ledger().with_mut(|li| li.timestamp = li.timestamp + seven_days - 1); + + let result = client.try_accept_issuer_transfer(&new_issuer, &symbol_short!("def"), &token); + assert!(result.is_ok(), "should accept within default 7-day window"); +} + +#[test] +fn issuer_transfer_default_expiry_rejects_after_seven_days() { + // propose_issuer_transfer (expiry_secs=0) → accept after 7 days → expired + let (env, client, issuer, token, _payment_token, _contract_id) = claim_setup(); + let new_issuer = Address::generate(&env); + + client.propose_issuer_transfer(&issuer, &symbol_short!("def"), &token, &new_issuer); + + let seven_days = 7u64 * 24 * 60 * 60; + env.ledger().with_mut(|li| li.timestamp = li.timestamp + seven_days + 1); + + let result = client.try_accept_issuer_transfer(&new_issuer, &symbol_short!("def"), &token); + assert!(result.is_err(), "should reject after default 7-day expiry"); +} + +#[test] +fn issuer_transfer_custom_expiry_accepted_within_window() { + // propose_transfer_with_expiry(2h) → accept at 1h → succeeds + let (env, client, issuer, token, _payment_token, _contract_id) = claim_setup(); + let new_issuer = Address::generate(&env); + + let two_hours = 2u64 * 60 * 60; + client.propose_transfer_with_expiry( + &issuer, + &symbol_short!("def"), + &token, + &new_issuer, + &two_hours, + ); + + env.ledger().with_mut(|li| li.timestamp = li.timestamp + 60 * 60); // +1h + + let result = client.try_accept_issuer_transfer(&new_issuer, &symbol_short!("def"), &token); + assert!(result.is_ok(), "should accept within custom 2h window"); +} + +#[test] +fn issuer_transfer_custom_expiry_rejected_after_window() { + // propose_transfer_with_expiry(2h) → accept at 2h+1s → expired + let (env, client, issuer, token, _payment_token, _contract_id) = claim_setup(); + let new_issuer = Address::generate(&env); + + let two_hours = 2u64 * 60 * 60; + client.propose_transfer_with_expiry( + &issuer, + &symbol_short!("def"), + &token, + &new_issuer, + &two_hours, + ); + + env.ledger().with_mut(|li| li.timestamp = li.timestamp + two_hours + 1); + + let result = client.try_accept_issuer_transfer(&new_issuer, &symbol_short!("def"), &token); + assert!(result.is_err(), "should reject after custom 2h expiry"); +} + +#[test] +fn issuer_transfer_custom_expiry_accepted_at_exact_boundary() { + // propose_transfer_with_expiry(2h) → accept at exactly 2h → succeeds (inclusive) + let (env, client, issuer, token, _payment_token, _contract_id) = claim_setup(); + let new_issuer = Address::generate(&env); + + let two_hours = 2u64 * 60 * 60; + client.propose_transfer_with_expiry( + &issuer, + &symbol_short!("def"), + &token, + &new_issuer, + &two_hours, + ); + + env.ledger().with_mut(|li| li.timestamp = li.timestamp + two_hours); + + let result = client.try_accept_issuer_transfer(&new_issuer, &symbol_short!("def"), &token); + assert!(result.is_ok(), "should accept at exact expiry boundary (timestamp == expiry)"); +} + +#[test] +fn issuer_transfer_expiry_below_min_clamped_to_min() { + // expiry_secs below 1h minimum → clamped to 1h + let (env, client, issuer, token, _payment_token, _contract_id) = claim_setup(); + let new_issuer = Address::generate(&env); + + let below_min = 60u64; // 1 minute — below 1h minimum + client.propose_transfer_with_expiry( + &issuer, + &symbol_short!("def"), + &token, + &new_issuer, + &below_min, + ); + + // Should still be valid at 30 minutes (clamped to 1h minimum) + env.ledger().with_mut(|li| li.timestamp = li.timestamp + 30 * 60); + let result = client.try_accept_issuer_transfer(&new_issuer, &symbol_short!("def"), &token); + assert!(result.is_ok(), "clamped-to-min expiry should still be valid at 30min"); +} + +#[test] +fn issuer_transfer_expiry_above_max_clamped_to_max() { + // expiry_secs above 30-day maximum → clamped to 30 days + let (env, client, issuer, token, _payment_token, _contract_id) = claim_setup(); + let new_issuer = Address::generate(&env); + + let above_max = 999u64 * 24 * 60 * 60; // 999 days — above 30-day maximum + client.propose_transfer_with_expiry( + &issuer, + &symbol_short!("def"), + &token, + &new_issuer, + &above_max, + ); + + // At 31 days (past 30-day max), should be expired + let thirty_days_plus_one = 30u64 * 24 * 60 * 60 + 1; + env.ledger().with_mut(|li| li.timestamp = li.timestamp + thirty_days_plus_one); + + let result = client.try_accept_issuer_transfer(&new_issuer, &symbol_short!("def"), &token); + assert!(result.is_err(), "clamped-to-max expiry should expire after 30 days"); +} + // ── Security and abuse prevention tests ────────────────────── #[test] From 0a0692dd6c354624fc4aae60cae817311eea5041 Mon Sep 17 00:00:00 2001 From: Abdulmajeed Abdullateef <81535615+Abdulmajeed82@users.noreply.github.com> Date: Mon, 1 Jun 2026 07:20:17 +0000 Subject: [PATCH 3/3] feat: configurable issuer transfer expiry (#362) - Add propose_transfer_with_expiry(expiry_secs) clamped to [1h, 30d] - Store effective expiry in PendingTransfer.expiry_secs (0 = default 7d) - accept_issuer_transfer honors stored expiry vs default - Fix replace_issuer_transfer to preserve original expiry_secs - Add get_pending_transfer_details() returning full PendingTransfer - Add 5 edge-case tests: min-clamp boundary, max-clamp window, replace-preserves-expiry, details query, details-none - Update docs/issuer-transfer-expiry.md with correct defaults and API --- docs/issuer-transfer-expiry.md | 98 ++++++++++++++++++--- src/lib.rs | 45 +++++++--- src/test.rs | 99 ++++++++++++++++++++++ src/test_claim_transfer_fail.rs | 22 ++--- src/test_min_revenue_threshold_boundary.rs | 5 +- 5 files changed, 223 insertions(+), 46 deletions(-) diff --git a/docs/issuer-transfer-expiry.md b/docs/issuer-transfer-expiry.md index a8b09c24..77491dd8 100644 --- a/docs/issuer-transfer-expiry.md +++ b/docs/issuer-transfer-expiry.md @@ -1,26 +1,100 @@ # Issuer Transfer Expiry -The Revora contract implements a 24-hour expiry for issuer transfer proposals to ensure system security and prevent stale transfers from being executed. +Issuer transfer proposals have a configurable expiry window. The default is **7 days** +(604,800 seconds). Issuers can override this per-proposal within the bounds +`[1 hour, 30 days]`. -## Mechanics +## Constants -1. **Proposal Timestamp**: When an issuer proposes a transfer via `propose_issuer_transfer`, the current ledger timestamp is recorded. -2. **Expiry Window**: Proposals are valid for exactly **24 hours** (86,400 seconds). -3. **Enforcement**: The `accept_issuer_transfer` function checks the elapsed time. If more than 24 hours have passed since the proposal, the transaction fails with `IssuerTransferExpired` (Error code 30). -4. **Automatic Overwrite**: If a transfer has expired, the current issuer can simply call `propose_issuer_transfer` again to start a new 24-hour window, overwriting the expired proposal. -5. **Manual Cleanup**: Anyone can call `cleanup_expired_transfer` to remove an expired proposal from storage, which is useful for storage hygiene. +| Constant | Value | Description | +|---|---|---| +| `ISSUER_TRANSFER_EXPIRY_SECS` | 604,800 s (7 days) | Default expiry when none is specified | +| `MIN_ISSUER_TRANSFER_EXPIRY_SECS` | 3,600 s (1 hour) | Minimum allowed custom expiry | +| `MAX_ISSUER_TRANSFER_EXPIRY_SECS` | 2,592,000 s (30 days) | Maximum allowed custom expiry | + +## Proposing a Transfer + +### Default expiry (7 days) + +``` +propose_issuer_transfer(issuer, namespace, token, new_issuer) +``` + +### Custom expiry + +``` +propose_transfer_with_expiry(issuer, namespace, token, new_issuer, expiry_secs) +``` + +`expiry_secs` is clamped to `[MIN_ISSUER_TRANSFER_EXPIRY_SECS, MAX_ISSUER_TRANSFER_EXPIRY_SECS]` +before being stored. Passing `0` is treated as "use default" and stores `0` in +`PendingTransfer.expiry_secs`; `accept_issuer_transfer` then applies the 7-day default. + +## Accepting a Transfer + +`accept_issuer_transfer` reads the stored `expiry_secs` from `PendingTransfer`: + +- If `expiry_secs == 0` → effective expiry is `ISSUER_TRANSFER_EXPIRY_SECS` (7 days). +- Otherwise → effective expiry is the stored value. + +The check is **inclusive on the boundary**: + +``` +now <= proposal_timestamp + effective_expiry → accepted +now > proposal_timestamp + effective_expiry → IssuerTransferExpired +``` + +## Replacing a Pending Transfer + +`replace_issuer_transfer` atomically cancels the current pending transfer and proposes +a new one to a different `new_issuer`. The **original `expiry_secs` is preserved** so +the replacement inherits the same window as the original proposal. + +## Querying Pending Transfer Details + +`get_pending_transfer_details(issuer, namespace, token)` returns +`Option` with: + +| Field | Type | Description | +|---|---|---| +| `new_issuer` | `Address` | Proposed new issuer | +| `timestamp` | `u64` | Ledger timestamp when the proposal was created | +| `expiry_secs` | `u64` | Stored expiry (0 = default 7 days) | + +Use this to display the remaining acceptance window in UIs or off-chain tooling. ## Security Rationale -* **Key Compromise Protection**: If an issuer proposes a transfer and then their keys (or the new issuer's keys) are compromised weeks later, the attacker cannot use the old, forgotten proposal to hijack the offering. -* **Operational Clarity**: Expiry forces both parties to coordinate and complete the transfer in a timely manner, reducing "pending state" ambiguity. +- **Key compromise protection**: A stale proposal cannot be used to hijack an offering + after the expiry window closes. +- **Bounded window**: The `[1h, 30d]` clamp prevents both trivially short windows + (race conditions) and indefinitely long windows (forgotten proposals). +- **Replace preserves expiry**: Replacing a pending transfer does not silently reset + the expiry to the default, preventing a governance bypass where an attacker replaces + a short-window proposal with a default-window one. ## Error Codes | Code | Name | Description | |---|---|---| -| 30 | `IssuerTransferExpired` | The transfer proposal has passed the 24-hour validity window. | +| 12 | `IssuerTransferPending` | A transfer is already pending; cancel or replace it first. | +| 13 | `NoTransferPending` | No pending transfer to accept or cancel. | +| 14 | `UnauthorizedTransferAccept` | Caller is not the proposed new issuer. | +| 43 | `IssuerTransferExpired` | The proposal has passed its expiry window. | -## Developer Guidance +## Test Coverage -Developers should ensure that the `accept_issuer_transfer` call is made shortly after the proposal is confirmed on-chain. If the window is missed, the process must be restarted by the current issuer. +| Test | What it verifies | +|---|---| +| `issuer_transfer_default_expiry_used_when_expiry_secs_zero` | Default 7-day window accepted just before expiry | +| `issuer_transfer_default_expiry_rejects_after_seven_days` | Default window rejects after 7 days | +| `issuer_transfer_custom_expiry_accepted_within_window` | Custom 2h window accepts at 1h | +| `issuer_transfer_custom_expiry_rejected_after_window` | Custom 2h window rejects at 2h+1s | +| `issuer_transfer_custom_expiry_accepted_at_exact_boundary` | Inclusive boundary: accepts at exactly 2h | +| `issuer_transfer_expiry_below_min_clamped_to_min` | Below-min input clamped to 1h | +| `issuer_transfer_min_clamp_accept_at_exact_one_hour_boundary` | Min-clamped expiry accepts at exactly 1h | +| `issuer_transfer_expiry_above_max_clamped_to_max` | Above-max input clamped to 30 days | +| `issuer_transfer_max_clamp_accept_within_thirty_day_window` | Max-clamped expiry accepts within 30 days | +| `replace_issuer_transfer_preserves_custom_expiry` | Replace preserves original custom expiry | +| `get_pending_issuer_transfer_details_returns_expiry` | Details query returns correct expiry_secs | +| `get_pending_issuer_transfer_details_returns_none_when_no_pending` | Details query returns None when no pending | \ No newline at end of file diff --git a/src/lib.rs b/src/lib.rs index 2034653f..c5a0d1f7 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -161,12 +161,12 @@ pub enum RevoraError { pub mod vesting; +#[cfg(test)] +mod test_claim_transfer_fail; #[cfg(test)] mod test_duplicates; #[cfg(test)] mod test_min_revenue_threshold_boundary; -#[cfg(test)] -mod test_claim_transfer_fail; // ── Event symbols ──────────────────────────────────────────── const EVENT_REVENUE_REPORTED: Symbol = symbol_short!("rev_rep"); @@ -1571,6 +1571,20 @@ impl RevoraRevenueShare { .map(|pending| pending.new_issuer) } + /// Return full details of a pending issuer transfer, including the proposed new issuer, + /// the proposal timestamp, and the effective expiry in seconds (0 = default 7 days). + pub fn get_pending_transfer_details( + env: Env, + issuer: Address, + namespace: Symbol, + token: Address, + ) -> Option { + let offering_id = OfferingId { issuer, namespace, token }; + env.storage() + .persistent() + .get::(&DataKey::PendingIssuerTransfer(offering_id)) + } + fn find_pending_transfer_for_new_issuer( env: &Env, namespace: &Symbol, @@ -1670,15 +1684,17 @@ impl RevoraRevenueShare { let effective_expiry = if expiry_secs == 0 { 0 } else { - expiry_secs - .max(MIN_ISSUER_TRANSFER_EXPIRY_SECS) - .min(MAX_ISSUER_TRANSFER_EXPIRY_SECS) + expiry_secs.max(MIN_ISSUER_TRANSFER_EXPIRY_SECS).min(MAX_ISSUER_TRANSFER_EXPIRY_SECS) }; let timestamp = env.ledger().timestamp(); env.storage().persistent().set( &key, - &PendingTransfer { new_issuer: new_issuer.clone(), timestamp, expiry_secs: effective_expiry }, + &PendingTransfer { + new_issuer: new_issuer.clone(), + timestamp, + expiry_secs: effective_expiry, + }, ); env.events().publish( (EVENT_ISSUER_TRANSFER_PROPOSED, issuer.clone(), namespace.clone(), token.clone()), @@ -1717,9 +1733,15 @@ impl RevoraRevenueShare { let pending: PendingTransfer = env.storage().persistent().get(&key).unwrap(); let timestamp = env.ledger().timestamp(); - env.storage() - .persistent() - .set(&key, &PendingTransfer { new_issuer: new_issuer.clone(), timestamp, expiry_secs: 0 }); + // Preserve the original expiry_secs so the replacement inherits the same window. + env.storage().persistent().set( + &key, + &PendingTransfer { + new_issuer: new_issuer.clone(), + timestamp, + expiry_secs: pending.expiry_secs, + }, + ); env.events().publish( (EVENT_ISSUER_TRANSFER_CANCELLED, issuer.clone(), namespace.clone(), token.clone()), @@ -3315,10 +3337,7 @@ impl RevoraRevenueShare { /// The maximum allowed blacklist size for the offering. fn get_effective_blacklist_limit(env: &Env, offering_id: &OfferingId) -> u32 { let key = DataKey2::BlacklistSizeLimit(offering_id.clone()); - env.storage() - .persistent() - .get::(&key) - .unwrap_or(MAX_BLACKLIST_SIZE) + env.storage().persistent().get::(&key).unwrap_or(MAX_BLACKLIST_SIZE) } /// Set the per-offering blacklist size limit. diff --git a/src/test.rs b/src/test.rs index 4ee3f7e5..6fe52b09 100644 --- a/src/test.rs +++ b/src/test.rs @@ -5687,6 +5687,105 @@ fn issuer_transfer_expiry_above_max_clamped_to_max() { assert!(result.is_err(), "clamped-to-max expiry should expire after 30 days"); } +#[test] +fn issuer_transfer_min_clamp_accept_at_exact_one_hour_boundary() { + // expiry_secs below min → clamped to 1h; accept at exactly 1h → succeeds (inclusive boundary) + let (env, client, issuer, token, _payment_token, _contract_id) = claim_setup(); + let new_issuer = Address::generate(&env); + + let below_min = 30u64; // 30 seconds — well below 1h minimum + client.propose_transfer_with_expiry( + &issuer, + &symbol_short!("def"), + &token, + &new_issuer, + &below_min, + ); + + // Accept at exactly 1h (the clamped minimum) — should succeed (inclusive) + let one_hour = 60u64 * 60; + env.ledger().with_mut(|li| li.timestamp = li.timestamp + one_hour); + let result = client.try_accept_issuer_transfer(&new_issuer, &symbol_short!("def"), &token); + assert!(result.is_ok(), "min-clamped expiry should accept at exactly 1h boundary"); +} + +#[test] +fn issuer_transfer_max_clamp_accept_within_thirty_day_window() { + // expiry_secs above max → clamped to 30 days; accept at 15 days → succeeds + let (env, client, issuer, token, _payment_token, _contract_id) = claim_setup(); + let new_issuer = Address::generate(&env); + + let above_max = 999u64 * 24 * 60 * 60; // 999 days — above 30-day maximum + client.propose_transfer_with_expiry( + &issuer, + &symbol_short!("def"), + &token, + &new_issuer, + &above_max, + ); + + // Accept at 15 days — well within the clamped 30-day window + let fifteen_days = 15u64 * 24 * 60 * 60; + env.ledger().with_mut(|li| li.timestamp = li.timestamp + fifteen_days); + let result = client.try_accept_issuer_transfer(&new_issuer, &symbol_short!("def"), &token); + assert!(result.is_ok(), "max-clamped expiry should accept within 30-day window"); +} + +#[test] +fn replace_issuer_transfer_preserves_custom_expiry() { + // propose_transfer_with_expiry(2h) → replace → accept at 1h → still succeeds (expiry preserved) + let (env, client, issuer, token, _payment_token, _contract_id) = claim_setup(); + let new_issuer_1 = Address::generate(&env); + let new_issuer_2 = Address::generate(&env); + + let two_hours = 2u64 * 60 * 60; + client.propose_transfer_with_expiry( + &issuer, + &symbol_short!("def"), + &token, + &new_issuer_1, + &two_hours, + ); + + // Replace the pending transfer (should preserve the 2h expiry) + client.replace_issuer_transfer(&issuer, &symbol_short!("def"), &token, &new_issuer_2); + + // Accept at 1h — should succeed because the 2h expiry was preserved + env.ledger().with_mut(|li| li.timestamp = li.timestamp + 60 * 60); + let result = client.try_accept_issuer_transfer(&new_issuer_2, &symbol_short!("def"), &token); + assert!(result.is_ok(), "replace should preserve original custom expiry"); +} + +#[test] +fn get_pending_issuer_transfer_details_returns_expiry() { + // propose_transfer_with_expiry(2h) → get_pending_issuer_transfer_details → expiry_secs == 2h + let (env, client, issuer, token, _payment_token, _contract_id) = claim_setup(); + let new_issuer = Address::generate(&env); + + let two_hours = 2u64 * 60 * 60; + client.propose_transfer_with_expiry( + &issuer, + &symbol_short!("def"), + &token, + &new_issuer, + &two_hours, + ); + + let details = client + .get_pending_transfer_details(&issuer, &symbol_short!("def"), &token) + .expect("should have pending transfer details"); + assert_eq!(details.new_issuer, new_issuer); + assert_eq!(details.expiry_secs, two_hours, "expiry_secs should match the proposed value"); +} + +#[test] +fn get_pending_issuer_transfer_details_returns_none_when_no_pending() { + let (env, client, issuer, token, _payment_token, _contract_id) = claim_setup(); + let _ = env; + let result = client.get_pending_transfer_details(&issuer, &symbol_short!("def"), &token); + assert!(result.is_none(), "should return None when no transfer is pending"); +} + // ── Security and abuse prevention tests ────────────────────── #[test] diff --git a/src/test_claim_transfer_fail.rs b/src/test_claim_transfer_fail.rs index d3e37c7c..4e1e48ab 100644 --- a/src/test_claim_transfer_fail.rs +++ b/src/test_claim_transfer_fail.rs @@ -41,9 +41,8 @@ use crate::{RevoraError, RevoraRevenueShare, RevoraRevenueShareClient}; use soroban_sdk::{ - contract, contractimpl, contracttype, symbol_short, - testutils::Address as _, - token, Address, Env, String, + contract, contractimpl, contracttype, symbol_short, testutils::Address as _, token, Address, + Env, String, }; // ══════════════════════════════════════════════════════════════════════════════ @@ -71,8 +70,7 @@ impl FailingTransferToken { /// Mint tokens to `to` (test helper, no auth). pub fn mint(env: Env, to: Address, amount: i128) { - let bal: i128 = - env.storage().persistent().get(&TokenKey::Balance(to.clone())).unwrap_or(0); + let bal: i128 = env.storage().persistent().get(&TokenKey::Balance(to.clone())).unwrap_or(0); env.storage().persistent().set(&TokenKey::Balance(to), &(bal + amount)); } @@ -268,11 +266,7 @@ fn claim_transfer_fail_does_not_advance_last_claimed_idx() { pending_before.len(), "LastClaimedIdx must not advance on transfer failure" ); - assert_eq!( - pending_after.get(0), - pending_before.get(0), - "pending period IDs must be unchanged" - ); + assert_eq!(pending_after.get(0), pending_before.get(0), "pending period IDs must be unchanged"); } /// Holder balance is unchanged when claim transfer fails. @@ -445,13 +439,7 @@ fn claim_transfer_fail_does_not_affect_sibling_offering() { &payment_token_b, &0, ); - revora.set_holder_share( - &issuer, - &symbol_short!("def"), - &offering_token_b, - &holder, - &10_000, - ); + revora.set_holder_share(&issuer, &symbol_short!("def"), &offering_token_b, &holder, &10_000); revora.deposit_revenue( &issuer, &symbol_short!("def"), diff --git a/src/test_min_revenue_threshold_boundary.rs b/src/test_min_revenue_threshold_boundary.rs index 8ec96ceb..f2f961ff 100644 --- a/src/test_min_revenue_threshold_boundary.rs +++ b/src/test_min_revenue_threshold_boundary.rs @@ -167,10 +167,7 @@ fn override_existing_below_threshold_bypasses_check() { let s = client.get_audit_summary(&issuer, &symbol_short!("def"), &token).unwrap(); assert_eq!(s.total_revenue, 300, "audit must reflect corrected amount"); assert_eq!(s.report_count, 1, "report_count must not change on override"); - assert_eq!( - client.get_revenue_by_period(&issuer, &symbol_short!("def"), &token, &1), - 300 - ); + assert_eq!(client.get_revenue_by_period(&issuer, &symbol_short!("def"), &token, &1), 300); } /// get_min_revenue_threshold returns the stored value and updates correctly.