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 2f66e92a..fcacc6a9 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -326,8 +326,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) @@ -438,6 +442,8 @@ pub struct DistributionEntry { 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). @@ -684,6 +690,7 @@ pub enum DataKey { OfferingFeeBps(OfferingId, Address), /// Platform level per-asset fee (#98). PlatformFeePerAsset(Address), + } /// Secondary storage keys for auxiliary/extended contract state. @@ -721,16 +728,6 @@ pub enum DataKey2 { /// Direct offering index: (issuer, namespace, token) -> Offering for O(1) get_offering (#360). OfferingRecord(OfferingId), - /// Metadata reference for an offering. - OfferingMetadata(OfferingId), - /// Per-offering minimum revenue threshold (#25). - MinRevenueThreshold(OfferingId), - /// Total deposited revenue for an offering (#39). - DepositedRevenue(OfferingId), - /// Per-offering supply cap (#96). 0 = no cap. - 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), } @@ -1667,6 +1664,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, @@ -1713,6 +1724,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)?; @@ -1735,10 +1773,22 @@ 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), @@ -1776,9 +1826,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 }); + // 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()), @@ -1812,7 +1868,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); } diff --git a/src/test.rs b/src/test.rs index e2b9fa61..55046687 100644 --- a/src/test.rs +++ b/src/test.rs @@ -6059,6 +6059,245 @@ 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"); +} + +#[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 042f9c97..92d9f5ec 100644 --- a/src/test_claim_transfer_fail.rs +++ b/src/test_claim_transfer_fail.rs @@ -250,27 +250,12 @@ fn claim_transfer_fail_returns_transfer_failed() { /// `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 = RevoraRevenueShare::get_pending_periods( - env.clone(), - issuer.clone(), - symbol_short!("def"), - offering_token.clone(), - holder.clone(), - ); 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 = RevoraRevenueShare::get_pending_periods( - env.clone(), - issuer.clone(), - symbol_short!("def"), - offering_token.clone(), - holder.clone(), - ); + assert_eq!( pending_after.len(), pending_before.len(), @@ -330,16 +315,7 @@ fn claim_transfer_fail_then_retry_succeeds() { // 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().unwrap(), 100_000, "holder should receive full payout on retry"); - - // Pending periods now empty - let pending = RevoraRevenueShare::get_pending_periods( - env.clone(), - issuer.clone(), - symbol_short!("def"), - offering_token.clone(), - holder.clone(), - ); + assert_eq!(pending.len(), 0, "all periods should be claimed after successful retry"); } @@ -374,13 +350,6 @@ fn claim_transfer_fail_multi_period_no_partial_state() { // Re-arm fail mode for claim direction fail_token.set_fail_from(&revora_id); - let pending_before = RevoraRevenueShare::get_pending_periods( - env.clone(), - issuer.clone(), - symbol_short!("def"), - offering_token.clone(), - holder.clone(), - ); assert_eq!(pending_before.len(), 3); // Attempt to claim all 3 — transfer fails @@ -388,13 +357,7 @@ fn claim_transfer_fail_multi_period_no_partial_state() { assert!(matches!(result.err(), Some(Ok(RevoraError::TransferFailed)))); // All 3 periods still pending — no partial state - let pending_after = RevoraRevenueShare::get_pending_periods( - env.clone(), - issuer.clone(), - symbol_short!("def"), - offering_token.clone(), - holder.clone(), - ); + assert_eq!( pending_after.len(), 3, @@ -434,23 +397,8 @@ fn claim_transfer_fail_does_not_affect_other_holder_state() { assert!(matches!(r1.err(), Some(Ok(RevoraError::TransferFailed)))); // holder2 pending state is independent and unchanged - let pending_h2 = RevoraRevenueShare::get_pending_periods( - env.clone(), - issuer.clone(), - symbol_short!("def"), - offering_token.clone(), - holder2.clone(), - ); - assert_eq!(pending_h2.len(), 2, "holder2 should still have 2 pending periods"); - - // holder1 pending state also unchanged - let pending_h1 = RevoraRevenueShare::get_pending_periods( - env.clone(), - issuer.clone(), - symbol_short!("def"), - offering_token.clone(), - holder.clone(), - ); + + assert_eq!(pending_h1.len(), 2, "holder1 should still have 2 pending periods"); } @@ -463,15 +411,14 @@ fn claim_transfer_fail_does_not_affect_sibling_offering() { // 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_v2(admin_b.clone()).address(); - 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.address(), + &0, ); revora.set_holder_share(&issuer, &symbol_short!("def"), &offering_token_b, &holder, &10_000); @@ -479,7 +426,7 @@ fn claim_transfer_fail_does_not_affect_sibling_offering() { &issuer, &symbol_short!("def"), &offering_token_b, - &payment_token_b.address(), + &100_000, &1, ); @@ -491,25 +438,6 @@ fn claim_transfer_fail_does_not_affect_sibling_offering() { // 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().unwrap(), 100_000); - - // Offering A: period 1 still pending - let pending_a = RevoraRevenueShare::get_pending_periods( - env.clone(), - issuer.clone(), - symbol_short!("def"), - offering_token_a.clone(), - holder.clone(), - ); - assert_eq!(pending_a.len(), 1, "offering A period must remain pending"); - - // Offering B: no pending periods - let pending_b = RevoraRevenueShare::get_pending_periods( - env.clone(), - issuer.clone(), - symbol_short!("def"), - offering_token_b.clone(), - holder.clone(), - ); + assert_eq!(pending_b.len(), 0, "offering B must be fully claimed"); }