Skip to content

Commit 2176e6a

Browse files
authored
Merge pull request #781 from walexjnr/walexjnr/issues-735-733-731-730
test: recurring payer transfer, permit nonce replay, storage TTL & supply invariants
2 parents f4f39a7 + 3d67550 commit 2176e6a

23 files changed

Lines changed: 9757 additions & 0 deletions

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,10 @@ for contract upgrades.
2121
- Inline doc comments explaining the purpose of each test scenario across all test files.
2222
- Architecture document (`docs/architecture.md`) covering module responsibilities,
2323
data flow, storage layout, auth model, events, and integration points.
24+
- `transfer_recurring_payer` — transfer a recurring payment to a new payer with
25+
both parties authenticated; the payer index is updated.
26+
- Test coverage for recurring payer transfer, permit nonce replay protection,
27+
storage TTL lifetimes, and dividend/airdrop supply invariants.
2428
- Recurring execution window (`set_recurring_execution_window`) — recurring
2529
executions past `last_charged + interval + window` panic with
2630
`ExecutionWindowExpired` so keepers cannot run stale payments.

src/contract.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -237,6 +237,8 @@ pub trait VeriTixPayTrait {
237237
fn expire_dispute(e: Env, caller: Address, escrow_id: u32);
238238
fn pause_recurring(e: Env, caller: Address, recurring_id: u32);
239239
fn resume_recurring(e: Env, caller: Address, recurring_id: u32);
240+
// #735: transfer a recurring payment to a new payer
241+
fn transfer_recurring_payer(e: Env, caller: Address, recurring_id: u32, new_payer: Address);
240242
// #749: recurring execution window
241243
fn set_recurring_execution_window(e: Env, admin: Address, window_ledgers: u32);
242244
// #743: timed freeze
@@ -1217,6 +1219,8 @@ impl VeriTixPayTrait for VeriTixPay {
12171219
escrow::trigger_auto_release(e, escrow_id)
12181220
}
12191221

1222+
fn transfer_recurring_payer(e: Env, caller: Address, recurring_id: u32, new_payer: Address) {
1223+
recurring::transfer_recurring_payer(&e, &caller, recurring_id, new_payer)
12201224
fn set_recurring_execution_window(e: Env, admin: Address, window_ledgers: u32) {
12211225
recurring::set_recurring_execution_window(&e, &admin, window_ledgers)
12221226
}

src/recurring.rs

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -297,6 +297,62 @@ pub fn get_recurring_by_payer(e: &Env, payer: &Address) -> Vec<u32> {
297297
.unwrap_or(Vec::new(e))
298298
}
299299

300+
/// #735: transfer a recurring payment to a new payer. Both the current payer
301+
/// and the new payer must authenticate, and the payer index is updated so the
302+
/// recurring id shows up under the new payer.
303+
pub fn transfer_recurring_payer(e: &Env, caller: &Address, recurring_id: u32, new_payer: Address) {
304+
caller.require_auth();
305+
306+
let mut record: RecurringRecord = e
307+
.storage()
308+
.persistent()
309+
.get(&DataKey::Recurring(recurring_id))
310+
.expect("recurring not found");
311+
assert!(record.payer == *caller, "not the payer");
312+
assert!(record.active, "recurring is not active");
313+
// Reject a no-op transfer before authenticating the new payer (the host
314+
// rejects double-auth of the same address, so this must be checked first).
315+
assert!(
316+
record.payer != new_payer,
317+
"new payer must differ from current payer"
318+
);
319+
new_payer.require_auth();
320+
321+
// Remove the id from the old payer's index.
322+
let old_index = DataKey::PayerRecurrings(record.payer.clone());
323+
if let Some(ids) = e.storage().persistent().get::<_, Vec<u32>>(&old_index) {
324+
let mut updated: Vec<u32> = Vec::new(e);
325+
for i in 0..ids.len() {
326+
let v = ids.get(i).unwrap();
327+
if v != recurring_id {
328+
updated.push_back(v);
329+
}
330+
}
331+
e.storage().persistent().set(&old_index, &updated);
332+
}
333+
334+
record.payer = new_payer.clone();
335+
e.storage()
336+
.persistent()
337+
.set(&DataKey::Recurring(recurring_id), &record);
338+
339+
// Add the id to the new payer's index.
340+
let mut payer_ids: Vec<u32> = e
341+
.storage()
342+
.persistent()
343+
.get(&DataKey::PayerRecurrings(new_payer.clone()))
344+
.unwrap_or(Vec::new(e));
345+
payer_ids.push_back(recurring_id);
346+
e.storage()
347+
.persistent()
348+
.set(&DataKey::PayerRecurrings(new_payer), &payer_ids);
349+
350+
e.events().publish(
351+
(soroban_sdk::symbol_short!("rcr_pyr"), caller.clone(), record.payer.clone()),
352+
recurring_id,
353+
);
354+
}
355+
300356
pub fn record_execution(e: &Env, recurring_id: u32, amount: i128) {
301357
let ledger = e.ledger().sequence();
302358
let execution = RecurringExecution {

src/recurring_test.rs

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -489,6 +489,18 @@ mod recurring_active_tests {
489489
// Setup mock record and test active vs paused states...
490490
}
491491
}
492+
// ── #735: transfer_recurring_payer ───────────────────────────────────────────
493+
494+
fn transfer_setup(
495+
) -> (
496+
Env,
497+
crate::contract::VeriTixPayClient<'static>,
498+
Address,
499+
Address,
500+
Address,
501+
u32,
502+
Address,
503+
) {
492504
// ── #749: recurring execution window ─────────────────────────────────────────
493505

494506
#[test]
@@ -509,6 +521,79 @@ fn test_execute_recurring_within_execution_window_succeeds() {
509521
soroban_sdk::token::StellarAssetClient::new(&e, &token).mint(&payer, &1000);
510522

511523
let id = client.setup_recurring(&payer, &payee, &token, &100, &100, &5);
524+
(e, client, payer, payee, token, id, contract_id)
525+
}
526+
527+
#[test]
528+
fn test_transfer_recurring_payer_succeeds_with_both_auth() {
529+
use soroban_sdk::testutils::Address as _;
530+
let (e, client, payer, _payee, _token, id, contract_id) = transfer_setup();
531+
let new_payer = Address::generate(&e);
532+
533+
// Both the current payer (caller) and the new payer authenticate.
534+
client.transfer_recurring_payer(&payer, &id, &new_payer);
535+
536+
let record = read_recurring_record(&e, &contract_id, id);
537+
assert_eq!(record.payer, new_payer);
538+
}
539+
540+
#[test]
541+
#[should_panic(expected = "not the payer")]
542+
fn test_transfer_recurring_payer_old_payer_only_panics() {
543+
use soroban_sdk::testutils::Address as _;
544+
let (e, client, _payer, _payee, _token, id, _contract_id) = transfer_setup();
545+
let new_payer = Address::generate(&e);
546+
547+
// A caller that is not the current payer is refused, so a transfer that
548+
// only involves the old payer's authorization cannot succeed.
549+
let intruder = Address::generate(&e);
550+
client.transfer_recurring_payer(&intruder, &id, &new_payer);
551+
}
552+
553+
#[test]
554+
#[should_panic(expected = "not the payer")]
555+
fn test_transfer_recurring_payer_new_payer_only_panics() {
556+
use soroban_sdk::testutils::Address as _;
557+
let (e, client, _payer, payee, _token, id, _contract_id) = transfer_setup();
558+
let new_payer = Address::generate(&e);
559+
560+
// The payee is not the payer, so authorizing only the new payer still fails.
561+
client.transfer_recurring_payer(&payee, &id, &new_payer);
562+
}
563+
564+
#[test]
565+
#[should_panic(expected = "new payer must differ from current payer")]
566+
fn test_transfer_recurring_payer_same_address_panics() {
567+
let (_e, client, payer, _payee, _token, id, _contract_id) = transfer_setup();
568+
569+
client.transfer_recurring_payer(&payer, &id, &payer);
570+
}
571+
572+
#[test]
573+
fn test_transfer_recurring_payer_updates_payer_index() {
574+
use soroban_sdk::testutils::Address as _;
575+
let (e, client, payer, _payee, _token, id, _contract_id) = transfer_setup();
576+
let new_payer = Address::generate(&e);
577+
578+
client.transfer_recurring_payer(&payer, &id, &new_payer);
579+
580+
// The recurring id moves from the old payer's index to the new payer's.
581+
let old_ids = client.get_recurring_by_payer(&payer);
582+
assert_eq!(old_ids.len(), 0);
583+
let new_ids = client.get_recurring_by_payer(&new_payer);
584+
assert_eq!(new_ids.len(), 1);
585+
assert_eq!(new_ids.get(0).unwrap(), id);
586+
}
587+
588+
#[test]
589+
#[should_panic(expected = "recurring is not active")]
590+
fn test_transfer_recurring_payer_inactive_recurring_panics() {
591+
use soroban_sdk::testutils::Address as _;
592+
let (e, client, payer, _payee, _token, id, _contract_id) = transfer_setup();
593+
let new_payer = Address::generate(&e);
594+
595+
client.cancel_recurring(&payer, &id);
596+
client.transfer_recurring_payer(&payer, &id, &new_payer);
512597

513598
// Configure a 1000-ledger execution window.
514599
client.set_recurring_execution_window(&admin, &1000);

0 commit comments

Comments
 (0)