Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 40 additions & 0 deletions .github/workflows/sender-history.yml
Original file line number Diff line number Diff line change
@@ -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
38 changes: 38 additions & 0 deletions contracts/SENDER_HISTORY.md
Original file line number Diff line number Diff line change
@@ -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
```
63 changes: 63 additions & 0 deletions contracts/bulk_payment/src/history_test.rs
Original file line number Diff line number Diff line change
@@ -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());
}
12 changes: 11 additions & 1 deletion contracts/bulk_payment/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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<u64> {
common::sender_history::page(&env, &sender, page, limit)
}

// ── Private helpers ───────────────────────────────────────────────────────

fn check_and_advance_sequence(env: &Env, expected: u64) -> Result<(), ContractError> {
Expand All @@ -293,4 +301,6 @@ impl BulkPaymentContract {
}

#[cfg(test)]
mod test;
mod test;
#[cfg(test)]
mod history_test;
2 changes: 2 additions & 0 deletions contracts/common/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@

use soroban_sdk::{contracterror, Address, Env, IntoVal, Val};

pub mod sender_history;

#[contracterror]
#[derive(Copy, Clone, Debug, PartialEq)]
#[repr(u32)]
Expand Down
55 changes: 55 additions & 0 deletions contracts/common/src/sender_history.rs
Original file line number Diff line number Diff line change
@@ -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<u64> {
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
}
42 changes: 42 additions & 0 deletions contracts/cross_asset_payment/src/history_test.rs
Original file line number Diff line number Diff line change
@@ -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());
}
16 changes: 15 additions & 1 deletion contracts/cross_asset_payment/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -175,6 +175,7 @@ impl CrossAssetPaymentContract {
PAYMENT_TTL_LEDGERS,
PAYMENT_TTL_LEDGERS,
);
common::sender_history::append(&env, &from, count);

PaymentInitiatedEvent {
payment_id: count,
Expand Down Expand Up @@ -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<u64> {
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<u64> {
common::sender_history::page(&env, &sender, page, limit)
}

}

mod test;
#[cfg(test)]
mod history_test;