From a2de1d2590f12fbb0c2bfd3f03998f90d79fdad9 Mon Sep 17 00:00:00 2001 From: woahwhattheheck Date: Sun, 4 Oct 2026 21:00:52 -0400 Subject: [PATCH] feat(contracts): add bounded sender payment history --- .github/workflows/sender-history.yml | 40 ++++++++++++ contracts/SENDER_HISTORY.md | 38 +++++++++++ contracts/bulk_payment/src/history_test.rs | 63 +++++++++++++++++++ contracts/bulk_payment/src/lib.rs | 12 +++- contracts/common/src/lib.rs | 2 + contracts/common/src/sender_history.rs | 55 ++++++++++++++++ .../cross_asset_payment/src/history_test.rs | 42 +++++++++++++ contracts/cross_asset_payment/src/lib.rs | 16 ++++- 8 files changed, 266 insertions(+), 2 deletions(-) create mode 100644 .github/workflows/sender-history.yml create mode 100644 contracts/SENDER_HISTORY.md create mode 100644 contracts/bulk_payment/src/history_test.rs create mode 100644 contracts/common/src/sender_history.rs create mode 100644 contracts/cross_asset_payment/src/history_test.rs diff --git a/.github/workflows/sender-history.yml b/.github/workflows/sender-history.yml new file mode 100644 index 00000000..2d1a727f --- /dev/null +++ b/.github/workflows/sender-history.yml @@ -0,0 +1,40 @@ +name: Sender history contracts + +on: + push: + paths: + - 'contracts/common/**' + - 'contracts/bulk_payment/**' + - 'contracts/cross_asset_payment/**' + - '.github/workflows/sender-history.yml' + pull_request: + paths: + - 'contracts/common/**' + - 'contracts/bulk_payment/**' + - 'contracts/cross_asset_payment/**' + - '.github/workflows/sender-history.yml' + +permissions: + contents: read + +concurrency: + group: sender-history-${{ github.ref }} + cancel-in-progress: true + +jobs: + contracts: + runs-on: ubuntu-latest + timeout-minutes: 20 + env: + CARGO_TERM_COLOR: never + CARGO_BUILD_JOBS: 2 + CARGO_PROFILE_DEV_DEBUG: 0 + CARGO_PROFILE_TEST_DEBUG: 0 + steps: + - uses: actions/checkout@v4 + with: + persist-credentials: false + - name: Toolchain + run: rustc --version && cargo --version + - name: Test affected contract packages + run: cargo test --locked -p bulk_payment -p cross_asset_payment diff --git a/contracts/SENDER_HISTORY.md b/contracts/SENDER_HISTORY.md new file mode 100644 index 00000000..1dcdf65c --- /dev/null +++ b/contracts/SENDER_HISTORY.md @@ -0,0 +1,38 @@ +# Sender payment history + +Both payment contracts expose `get_batches_by_sender(sender, page, limit)`. +In `bulk_payment` the result contains batch IDs; in `cross_asset_payment` it +contains payment IDs. The latter also exposes `get_payments_by_sender` with +identical behavior. Read each ID using the existing `get_batch` or `get_payment`. +No authorization is required, matching the existing public record getters. + +Pages are **zero-based** and return newest creations first. For a sender whose +IDs were created as `[1, 3, 4]`, page 0 / limit 2 returns `[4, 3]` and page 1 +returns `[1]`. Limits above 100 are clamped to 100 before calculating the page +offset. A zero limit, unknown sender, or out-of-range page returns an empty list. +Use the same limit between pages. This is offset pagination, not a snapshot: +concurrent new payments shift page boundaries, so clients should refresh from +page 0 or deduplicate IDs when paging through an actively changing history. + +Both bulk execution modes append exactly once when they create a batch record. +A partial batch with no successful transfers is still a record and is included. +Reverted calls add no history. Cross-asset status changes and cancellations do +not add entries, remove entries, or reorder existing IDs. + +The shared index stores a count per sender and one persistent entry per ID. +An append writes two bounded entries; a page reads one count and at most 100 IDs. +It does not scan all senders/payments or load an unbounded history vector. +New entries receive a 518,400-ledger TTL. Archived persistent entries must be +restored through normal Soroban transaction simulation/restoration; the history +reader does not refresh rent or silently treat missing indexed IDs as success. +The existing bulk-record storage layout and payment/fee behavior are unchanged. + +The index covers records created by this version. Deployments upgrading from a +version without the index need a separately coordinated historical backfill; +this change does not silently scan or migrate old records during payments. + +Run the two affected contract suites (three additional history tests): + +```sh +cargo test --locked -p bulk_payment -p cross_asset_payment +``` diff --git a/contracts/bulk_payment/src/history_test.rs b/contracts/bulk_payment/src/history_test.rs new file mode 100644 index 00000000..bec1067a --- /dev/null +++ b/contracts/bulk_payment/src/history_test.rs @@ -0,0 +1,63 @@ +use super::*; +use soroban_sdk::{testutils::Address as _, token::StellarAssetClient, vec}; + +#[test] +fn sender_history_tracks_creation_modes_and_isolates_senders() { + let env = Env::default(); + env.mock_all_auths(); + let admin = Address::generate(&env); + let sender = Address::generate(&env); + let other = Address::generate(&env); + let token = env.register_stellar_asset_contract_v2(admin.clone()).address(); + let asset = StellarAssetClient::new(&env, &token); + asset.mint(&sender, &100); + asset.mint(&other, &100); + let address = env.register(BulkPaymentContract, ()); + let client = BulkPaymentContractClient::new(&env, &address); + client.initialize(&admin); + let payments = vec![&env, PaymentOp { recipient: Address::generate(&env), amount: 1 }]; + + assert!(client.get_batches_by_sender(&sender, &0, &10).is_empty()); + let first = client.execute_batch(&sender, &token, &payments, &client.get_sequence()); + let other_id = client.execute_batch(&other, &token, &payments, &client.get_sequence()); + let partial = client.execute_batch_partial(&sender, &token, &payments, &client.get_sequence()); + let skipped_ops = vec![&env, PaymentOp { recipient: Address::generate(&env), amount: 0 }]; + let skipped = client.execute_batch_partial(&sender, &token, &skipped_ops, &client.get_sequence()); + assert_eq!(client.get_batches_by_sender(&sender, &0, &2), vec![&env, skipped, partial]); + assert_eq!(client.get_batches_by_sender(&sender, &1, &2), vec![&env, first]); + assert!(client.get_batches_by_sender(&sender, &2, &2).is_empty()); + assert_eq!(client.get_batches_by_sender(&other, &0, &10), vec![&env, other_id]); + assert!(client.get_batches_by_sender(&Address::generate(&env), &0, &10).is_empty()); + assert!(client.get_batches_by_sender(&sender, &0, &0).is_empty()); + assert!(client.get_batches_by_sender(&sender, &u32::MAX, &u32::MAX).is_empty()); + + let sequence = client.get_sequence(); + assert!(client.try_execute_batch(&sender, &token, &Vec::new(&env), &sequence).is_err()); + assert_eq!(client.get_sequence(), sequence); + assert_eq!(client.get_batches_by_sender(&sender, &0, &10), vec![&env, skipped, partial, first]); +} + +#[test] +fn sender_history_caps_pages_without_rewriting_prior_items() { + let env = Env::default(); + let sender = Address::generate(&env); + let address = env.register(BulkPaymentContract, ()); + let client = BulkPaymentContractClient::new(&env, &address); + // Populate the real shared index directly to isolate the page-size boundary + // from token transfers. These are index fixtures, not fake payment records. + env.as_contract(&address, || { + for id in 1..=101u64 { + common::sender_history::append(&env, &sender, id); + } + }); + let first = client.get_batches_by_sender(&sender, &0, &u32::MAX); + assert_eq!(first.len(), 100); + assert_eq!(first.get(0), Some(101)); + assert_eq!(first.get(99), Some(2)); + assert_eq!(client.get_batches_by_sender(&sender, &1, &u32::MAX), vec![&env, 1u64]); + assert!(client.get_batches_by_sender(&sender, &2, &100).is_empty()); + + let separate_address = env.register(BulkPaymentContract, ()); + let separate = BulkPaymentContractClient::new(&env, &separate_address); + assert!(separate.get_batches_by_sender(&sender, &0, &100).is_empty()); +} diff --git a/contracts/bulk_payment/src/lib.rs b/contracts/bulk_payment/src/lib.rs index 84eb1a15..dc665a0c 100644 --- a/contracts/bulk_payment/src/lib.rs +++ b/contracts/bulk_payment/src/lib.rs @@ -149,6 +149,7 @@ impl BulkPaymentContract { } let batch_id = Self::next_batch_id(&env); + common::sender_history::append(&env, &sender, batch_id); env.storage().instance().set(&DataKey::Batch(batch_id), &BatchRecord { sender, token, @@ -241,6 +242,7 @@ impl BulkPaymentContract { }; let batch_id = Self::next_batch_id(&env); + common::sender_history::append(&env, &sender, batch_id); env.storage().instance().set(&DataKey::Batch(batch_id), &BatchRecord { sender, token, @@ -269,6 +271,12 @@ impl BulkPaymentContract { env.storage().instance().get(&DataKey::BatchCount).unwrap_or(0) } + /// Zero-based sender history, newest first; limit is capped at 100. + /// Includes partial/all-skipped batch records, but never reverted calls. + pub fn get_batches_by_sender(env: Env, sender: Address, page: u32, limit: u32) -> Vec { + common::sender_history::page(&env, &sender, page, limit) + } + // ── Private helpers ─────────────────────────────────────────────────────── fn check_and_advance_sequence(env: &Env, expected: u64) -> Result<(), ContractError> { @@ -293,4 +301,6 @@ impl BulkPaymentContract { } #[cfg(test)] -mod test; \ No newline at end of file +mod test; +#[cfg(test)] +mod history_test; diff --git a/contracts/common/src/lib.rs b/contracts/common/src/lib.rs index b048e006..7ffcb7cf 100644 --- a/contracts/common/src/lib.rs +++ b/contracts/common/src/lib.rs @@ -2,6 +2,8 @@ use soroban_sdk::{contracterror, Address, Env, IntoVal, Val}; +pub mod sender_history; + #[contracterror] #[derive(Copy, Clone, Debug, PartialEq)] #[repr(u32)] diff --git a/contracts/common/src/sender_history.rs b/contracts/common/src/sender_history.rs new file mode 100644 index 00000000..aabdfdd7 --- /dev/null +++ b/contracts/common/src/sender_history.rs @@ -0,0 +1,55 @@ +//! Append-only sender history shared by payment contracts. Storage is scoped to +//! the invoking contract; entries are independent of payment status. + +use soroban_sdk::{contracttype, Address, Env, Vec}; + +#[contracttype] +enum HistoryKey { + SenderHistoryCount(Address), + SenderHistoryItem(Address, u64), +} + +pub const MAX_PAGE_SIZE: u32 = 100; +const HISTORY_TTL_LEDGERS: u32 = 518_400; + +/// Record one successful creation. Both writes roll back with the surrounding +/// contract invocation. Each append writes two bounded persistent entries rather +/// than loading and rewriting an ever-growing vector or instance storage map. +pub fn append(env: &Env, sender: &Address, record_id: u64) { + let count_key = HistoryKey::SenderHistoryCount(sender.clone()); + let count: u64 = env.storage().persistent().get(&count_key).unwrap_or(0); + let next = count.checked_add(1).expect("sender history index exhausted"); + let item_key = HistoryKey::SenderHistoryItem(sender.clone(), count); + env.storage().persistent().set(&item_key, &record_id); + env.storage().persistent().extend_ttl( + &item_key, HISTORY_TTL_LEDGERS / 2, HISTORY_TTL_LEDGERS, + ); + env.storage().persistent().set(&count_key, &next); + env.storage().persistent().extend_ttl( + &count_key, HISTORY_TTL_LEDGERS / 2, HISTORY_TTL_LEDGERS, + ); +} + +/// Zero-based pages, newest creation first. The effective limit is capped at +/// MAX_PAGE_SIZE and determines the page offset. Zero limits, unknown senders +/// and pages beyond the end return an empty vector. Reads do not change TTLs. +pub fn page(env: &Env, sender: &Address, page: u32, limit: u32) -> Vec { + let mut ids = Vec::new(env); + let limit = core::cmp::min(limit, MAX_PAGE_SIZE); + if limit == 0 { + return ids; + } + let count_key = HistoryKey::SenderHistoryCount(sender.clone()); + let count: u64 = env.storage().persistent().get(&count_key).unwrap_or(0); + // Widen before multiplying. Even the largest u32 page cannot overflow u64. + let offset = u64::from(page) * u64::from(limit); + let end = count.saturating_sub(offset); + let start = end.saturating_sub(u64::from(limit)); + for position in (start..end).rev() { + let key = HistoryKey::SenderHistoryItem(sender.clone(), position); + let id: u64 = env.storage().persistent().get(&key) + .expect("sender history entry missing"); + ids.push_back(id); + } + ids +} diff --git a/contracts/cross_asset_payment/src/history_test.rs b/contracts/cross_asset_payment/src/history_test.rs new file mode 100644 index 00000000..be11a7e9 --- /dev/null +++ b/contracts/cross_asset_payment/src/history_test.rs @@ -0,0 +1,42 @@ +use super::*; +use soroban_sdk::{testutils::Address as _, token::StellarAssetClient, vec}; + +#[test] +fn sender_history_preserves_creation_order_across_status_changes() { + let env = Env::default(); + env.mock_all_auths(); + let admin = Address::generate(&env); + let sender = Address::generate(&env); + let other = Address::generate(&env); + let token = env.register_stellar_asset_contract_v2(admin.clone()).address(); + let asset = StellarAssetClient::new(&env, &token); + asset.mint(&sender, &100); + asset.mint(&other, &100); + let address = env.register(CrossAssetPaymentContract, ()); + let client = CrossAssetPaymentContractClient::new(&env, &address); + client.init(&admin, &0); + let receiver = String::from_str(&env, "worker-1"); + let target = String::from_str(&env, "EUR"); + let anchor = String::from_str(&env, "anchor-eu"); + let create = |from: &Address| client.initiate_payment(from, &10, &token, &receiver, &target, &anchor); + + assert!(client.get_payments_by_sender(&sender, &0, &2).is_empty()); + let first = create(&sender); + let other_id = create(&other); + let second = create(&sender); + let third = create(&sender); + client.update_status(&first, &symbol_short!("complete")); + client.cancel_payment(&sender, &second); + assert_eq!(client.get_payments_by_sender(&sender, &0, &2), vec![&env, third, second]); + assert_eq!(client.get_payments_by_sender(&sender, &1, &2), vec![&env, first]); + assert!(client.get_payments_by_sender(&sender, &2, &2).is_empty()); + assert_eq!(client.get_payments_by_sender(&other, &0, &10), vec![&env, other_id]); + assert_eq!(client.get_batches_by_sender(&sender, &0, &10), vec![&env, third, second, first]); + + assert!(client.try_initiate_payment(&sender, &0, &token, &receiver, &target, &anchor).is_err()); + assert_eq!(client.get_payment_count(), 4); + assert_eq!(client.get_payments_by_sender(&sender, &0, &10), vec![&env, third, second, first]); + assert!(client.get_payments_by_sender(&Address::generate(&env), &0, &10).is_empty()); + assert!(client.get_payments_by_sender(&sender, &0, &0).is_empty()); + assert!(client.get_payments_by_sender(&sender, &u32::MAX, &u32::MAX).is_empty()); +} diff --git a/contracts/cross_asset_payment/src/lib.rs b/contracts/cross_asset_payment/src/lib.rs index c5715147..4a4c0075 100644 --- a/contracts/cross_asset_payment/src/lib.rs +++ b/contracts/cross_asset_payment/src/lib.rs @@ -2,7 +2,7 @@ use soroban_sdk::{ contract, contractimpl, contracttype, contracterror, contractevent, - symbol_short, Address, Env, String, Symbol, token, + symbol_short, Address, Env, String, Symbol, Vec, token, }; use common::CommonError; @@ -175,6 +175,7 @@ impl CrossAssetPaymentContract { PAYMENT_TTL_LEDGERS, PAYMENT_TTL_LEDGERS, ); + common::sender_history::append(&env, &from, count); PaymentInitiatedEvent { payment_id: count, @@ -260,6 +261,19 @@ impl CrossAssetPaymentContract { env.storage().instance().get(&DataKey::PaymentCount).unwrap_or(0) } + /// Zero-based payment history, newest first; limit is capped at 100. + /// Status changes and cancellations never reorder or remove history entries. + pub fn get_payments_by_sender(env: Env, sender: Address, page: u32, limit: u32) -> Vec { + common::sender_history::page(&env, &sender, page, limit) + } + + /// Uniform history API shared with bulk_payment; returns payment IDs here. + pub fn get_batches_by_sender(env: Env, sender: Address, page: u32, limit: u32) -> Vec { + common::sender_history::page(&env, &sender, page, limit) + } + } mod test; +#[cfg(test)] +mod history_test;