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::