From 735982cc562da0934a051eec4b814261569afd58 Mon Sep 17 00:00:00 2001 From: daveedAJ Date: Tue, 26 May 2026 14:39:32 -0700 Subject: [PATCH 1/2] feat: support atomic replacement of pending issuer transfer --- ISSUER_TRANSFER.md | 28 ++++++++++++++++++++++++++++ src/lib.rs | 45 +++++++++++++++++++++++++++++++++++++++++++++ src/test.rs | 46 ++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 119 insertions(+) diff --git a/ISSUER_TRANSFER.md b/ISSUER_TRANSFER.md index 685f2e3a7..23def06a1 100644 --- a/ISSUER_TRANSFER.md +++ b/ISSUER_TRANSFER.md @@ -87,6 +87,34 @@ client.cancel_issuer_transfer(&token); - `OfferingNotFound` - Token doesn't have a registered offering - `ContractFrozen` - Contract is frozen by admin +### Optional: Replace Pending Transfer (Current Issuer) + +The current issuer can replace an active pending transfer with a new proposed issuer in one atomic operation. + +```rust +// Current issuer updates the pending transfer target and refreshes expiry +client.replace_issuer_transfer(&token, &new_issuer); +``` + +**What happens:** +- Contract verifies caller is the current issuer (via `require_auth`) +- Validates an existing pending transfer exists +- Overwrites `PendingIssuerTransfer(token)` with a fresh `new_issuer` and new timestamp +- Emits `iss_canc` for the old pending proposal +- Emits `iss_prop` for the new pending proposal +- This preserves a single canonical pending transfer and restarts the expiry window + +**Why this is useful:** +- Avoids a two-step cancel + propose workflow +- Keeps off-chain indexers in sync by emitting both cancel and propose events +- Ensures the old issuer remains the only authority to modify the pending transfer + +**Possible errors:** +- `NoTransferPending` - No transfer is pending +- `OfferingNotFound` - Token doesn't have a registered offering +- `NotAuthorized` - Caller is not the current issuer +- `ContractFrozen` - Contract is frozen by admin + ## Query Functions ### Check Pending Transfer diff --git a/src/lib.rs b/src/lib.rs index 3c7d88ac8..e690f2ef0 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1613,6 +1613,51 @@ impl RevoraRevenueShare { Ok(()) } + pub fn replace_issuer_transfer( + env: Env, + issuer: Address, + namespace: Symbol, + token: Address, + new_issuer: Address, + ) -> Result<(), RevoraError> { + Self::require_not_frozen(&env)?; + Self::require_not_paused(&env)?; + issuer.require_auth(); + + let offering_id = OfferingId { + issuer: issuer.clone(), + namespace: namespace.clone(), + token: token.clone(), + }; + let current_issuer = + Self::get_current_issuer(&env, issuer.clone(), namespace.clone(), token.clone()) + .ok_or(RevoraError::OfferingNotFound)?; + if current_issuer != issuer { + return Err(RevoraError::NotAuthorized); + } + + let key = DataKey::PendingIssuerTransfer(offering_id.clone()); + if !env.storage().persistent().has(&key) { + return Err(RevoraError::NoTransferPending); + } + + 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 }); + + env.events().publish( + (EVENT_ISSUER_TRANSFER_CANCELLED, issuer.clone(), namespace.clone(), token.clone()), + (issuer.clone(), pending.new_issuer.clone()), + ); + env.events().publish( + (EVENT_ISSUER_TRANSFER_PROPOSED, issuer.clone(), namespace.clone(), token.clone()), + (new_issuer.clone(), timestamp), + ); + Ok(()) + } + pub fn accept_issuer_transfer( env: Env, new_issuer: Address, diff --git a/src/test.rs b/src/test.rs index d3851ba00..fb639b3b3 100644 --- a/src/test.rs +++ b/src/test.rs @@ -5186,6 +5186,52 @@ fn issuer_transfer_cancel_then_can_propose_again() { ); } +#[test] +fn issuer_transfer_replace_active_transfer() { + 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); + + client.propose_issuer_transfer(&issuer, &symbol_short!("def"), &token, &new_issuer_1); + let before = legacy_events(&env).len(); + + client.replace_issuer_transfer(&issuer, &symbol_short!("def"), &token, &new_issuer_2); + + assert_eq!(client.get_pending_issuer_transfer(&issuer, &symbol_short!("def"), &token), Some(new_issuer_2)); + assert_eq!(legacy_events(&env).len(), before + 2); +} + +#[test] +fn issuer_transfer_replace_with_same_target_resets_expiry() { + 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 key = DataKey::PendingIssuerTransfer(OfferingId { + issuer: issuer.clone(), + namespace: symbol_short!("def"), + token: token.clone(), + }); + let pending_before: PendingTransfer = env.storage().persistent().get(&key).unwrap(); + + env.ledger().with_mut(|li| li.timestamp = li.timestamp + 10); + client.replace_issuer_transfer(&issuer, &symbol_short!("def"), &token, new_issuer.clone()); + + let pending_after: PendingTransfer = env.storage().persistent().get(&key).unwrap(); + assert_eq!(pending_after.new_issuer, new_issuer); + assert!(pending_after.timestamp > pending_before.timestamp); +} + +#[test] +fn issuer_transfer_replace_without_pending_transfer_fails() { + let (env, client, issuer, token, _payment_token, _contract_id) = claim_setup(); + let new_issuer = Address::generate(&env); + + let result = client.try_replace_issuer_transfer(&issuer, &symbol_short!("def"), &token, &new_issuer); + assert!(result.is_err()); +} + // ── Security and abuse prevention tests ────────────────────── #[test] From f204d862fd9d549ba09ce15a079de61c4968ef7b Mon Sep 17 00:00:00 2001 From: daveedAJ Date: Wed, 27 May 2026 13:49:56 -0700 Subject: [PATCH 2/2] docs: clarify and test get_payment_token None semantics --- README.md | 2 +- docs/payment-token-locking.md | 2 +- src/lib.rs | 7 ++++++- src/test.rs | 31 +++++++++++++++++++++++++++++++ 4 files changed, 39 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index a6a117f18..f51bb2741 100644 --- a/README.md +++ b/README.md @@ -25,7 +25,7 @@ Soroban contract for revenue-share offerings and blacklist management. |--------|------------|---------|------|-------------| | `register_offering` | `issuer: Address`, `token: Address`, `revenue_share_bps: u32` | `Result<(), RevoraError>` | issuer | Register a revenue-share offering. Fails with `InvalidRevenueShareBps` if `revenue_share_bps > 10000`. | | `get_offering` | `issuer: Address`, `token: Address` | `Option` | — | Fetch one offering by issuer and token. | -| `get_payment_token` | `issuer: Address`, `namespace: Symbol`, `token: Address` | `Option
` | — | Return the payment token locked by the first successful deposit. Returns `None` before the first successful deposit or for an unknown offering. | +| `get_payment_token` | `issuer: Address`, `namespace: Symbol`, `token: Address` | `Option
` | — | Return the payment token locked by the first successful deposit. Returns `None` if the offering is unknown or if the offering exists but has not yet received a successful deposit. | | `list_offerings` | `issuer: Address` | `Vec
` | — | List offering tokens for issuer (first page only, up to 20). | | `report_revenue` | `issuer: Address`, `token: Address`, `amount: i128`, `period_id: u64` | `Result<(), RevoraError>` | issuer | Emit or correct a revenue report. New periods update `AuditSummary`; existing periods may be corrected with `override_existing=true`, which emits explicit override events and applies the net delta to `total_revenue` without incrementing `report_count`. | | `get_offering_count` | `issuer: Address` | `u32` | — | Total offerings registered by issuer. | diff --git a/docs/payment-token-locking.md b/docs/payment-token-locking.md index 4fa192241..fe6213e86 100644 --- a/docs/payment-token-locking.md +++ b/docs/payment-token-locking.md @@ -16,7 +16,7 @@ deposit processing or `get_payment_token` reads. ## Behavior -- `get_payment_token` returns `None` before the first successful deposit. +- `get_payment_token` returns `None` if the offering is unknown or if the offering exists but has not yet recorded a successful deposit. - The first successful deposit writes `PaymentToken = payment_token`. - Subsequent deposits must use that exact token or fail with `RevoraError::PaymentTokenMismatch`. diff --git a/src/lib.rs b/src/lib.rs index e690f2ef0..cd1892637 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -2098,7 +2098,12 @@ impl RevoraRevenueShare { /// Return the locked payment token for an offering. /// - /// Returns `None` until the first successful deposit persists the `PaymentToken` key. + /// Returns `None` when: + /// - the offering is unknown, or + /// - the offering exists but has not yet recorded a successful deposit. + /// + /// Once the first successful deposit persists the `PaymentToken` key, this returns + /// `Some(payment_token)` for that locked token. pub fn get_payment_token( env: Env, issuer: Address, diff --git a/src/test.rs b/src/test.rs index fb639b3b3..c2c8e572e 100644 --- a/src/test.rs +++ b/src/test.rs @@ -1822,6 +1822,37 @@ fn get_payment_token_returns_none_for_unknown_offering() { assert_eq!(client.get_payment_token(&issuer, &symbol_short!("def"), &offering_token), None); } +#[test] +fn failed_invalid_first_deposit_does_not_lock_payment_token() { + let env = Env::default(); + env.mock_all_auths(); + let client = make_client(&env); + let issuer = Address::generate(&env); + let offering_token = Address::generate(&env); + let payment_token = Address::generate(&env); + + client.register_offering( + &issuer, + &symbol_short!("def"), + &offering_token, + &5_000, + &payment_token, + &0, + ); + + let result = client.try_deposit_revenue( + &issuer, + &symbol_short!("def"), + &offering_token, + &payment_token, + &100_000, + &0, + ); + + assert_eq!(result, Err(Ok(RevoraError::InvalidPeriodId))); + assert_eq!(client.get_payment_token(&issuer, &symbol_short!("def"), &offering_token), None); +} + #[test] fn deposit_revenue_multiple_periods() { let (_env, client, issuer, token, payment_token, _contract_id) = claim_setup();