Skip to content

Commit f4cfc1a

Browse files
feat: implement batch payout refund on partial execution failure (#871)
When individual transfers in batch_payout fail due to insufficient balance, skip failed transfers instead of reverting the whole batch. - Fee is deferred and charged on successful volume only - BatchPayoutRefunded event emitted for off-chain reconciliation - Fixed pre-existing to_xdr import bug - Added tests for fee deferral and minimum fee
1 parent e240113 commit f4cfc1a

34 files changed

Lines changed: 71747 additions & 78 deletions

File tree

contracts/contracts/social_payment/src/lib.rs

Lines changed: 76 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ use soroban_sdk::{
44
contract, contractclient, contracterror, contractimpl, contracttype, symbol_short, Address,
55
Bytes, BytesN, Env, String, Symbol, Vec,
66
};
7+
use soroban_sdk::xdr::ToXdr;
78

89
const ADMIN_KEY: Symbol = symbol_short!("admin");
910
const TREAS_KEY: Symbol = symbol_short!("treasury");
@@ -481,7 +482,7 @@ impl SocialPaymentContract {
481482
/// - Emits MassPayoutExecuted with sender, batch_size, total_volume, fee_charged
482483
/// so off-chain indexers can reconcile bulk payouts without scanning individual
483484
/// BatchPayoutItem events.
484-
/// SC-039 / Issues #529–#532, #752: Batch payout to multiple recipients.
485+
/// SC-039 / Issues #529–#532, #752, #774: Batch payout to multiple recipients.
485486
///
486487
/// Returns `Err(Error::BatchTooLarge)` when `payouts.len() > MAX_BATCH_SIZE`
487488
/// so callers receive a typed, inspectable error instead of an opaque panic.
@@ -534,22 +535,48 @@ impl SocialPaymentContract {
534535
"insufficient balance for batch payout"
535536
);
536537

537-
// #530 — deduct fee to treasury before processing recipients
538-
if batch_fee > 0 {
538+
// #774 — Process payouts, skipping any that exceed remaining balance.
539+
// Reserve the upper-bound fee so we never over-transfer.
540+
let mut remaining_balance: i128 = sender_balance - batch_fee;
541+
let mut successful_volume: i128 = 0;
542+
let mut skipped_volume: i128 = 0;
543+
544+
for payout in payouts.iter() {
545+
if remaining_balance >= payout.amount {
546+
token_client.transfer(&sender, &payout.recipient, &payout.amount);
547+
successful_volume += payout.amount;
548+
remaining_balance -= payout.amount;
549+
} else {
550+
skipped_volume += payout.amount;
551+
}
552+
env.events().publish(
553+
(Symbol::new(&env, "BatchPayoutItem"),),
554+
(sender.clone(), payout.recipient.clone(), payout.amount),
555+
);
556+
}
557+
558+
// #530 — charge fee on successful volume only
559+
let actual_fee = if successful_volume > 0 {
560+
let f = successful_volume * (fee_coef as i128) / 10000;
561+
if f == 0 { 1 } else { f }
562+
} else {
563+
0
564+
};
565+
566+
if actual_fee > 0 {
539567
let treasury: Address = env
540568
.storage()
541569
.instance()
542570
.get(&TREAS_KEY)
543571
.expect("treasury not initialized");
544-
token_client.transfer(&sender, &treasury, &batch_fee);
572+
token_client.transfer(&sender, &treasury, &actual_fee);
545573
}
546574

547-
// Transfer to each recipient and emit per-item events
548-
for payout in payouts.iter() {
549-
token_client.transfer(&sender, &payout.recipient, &payout.amount);
575+
// #774 — report skipped volume for off-chain reconciliation
576+
if skipped_volume > 0 {
550577
env.events().publish(
551-
(Symbol::new(&env, "BatchPayoutItem"),),
552-
(sender.clone(), payout.recipient.clone(), payout.amount),
578+
(Symbol::new(&env, "BatchPayoutRefunded"),),
579+
(sender.clone(), skipped_volume),
553580
);
554581
}
555582

@@ -560,7 +587,7 @@ impl SocialPaymentContract {
560587
sender,
561588
batch_size,
562589
total_volume,
563-
fee_charged: batch_fee,
590+
fee_charged: actual_fee,
564591
},
565592
);
566593

@@ -1394,22 +1421,55 @@ mod tests {
13941421
}
13951422

13961423
#[test]
1397-
#[ignore]
1398-
fn test_batch_payout_fails_on_insufficient_balance() {
1399-
let (env, client, admin, _treasury, sender, receiver1) = setup();
1400-
let token = mint_token(&env, &admin, &sender, 1_000);
1424+
fn test_batch_payout_defers_fee_to_successful_volume() {
1425+
let (env, client, admin, treasury, sender, receiver1) = setup();
1426+
let receiver2 = Address::generate(&env);
1427+
// total_volume = 5_000, fee at 10 bps = 5
1428+
let token = mint_token(&env, &admin, &sender, 5_005);
14011429
client.set_naira_token(&token);
1430+
let token_client = soroban_sdk::token::Client::new(&env, &token);
14021431

14031432
let payouts = vec![
14041433
&env,
14051434
PayoutItem {
14061435
recipient: receiver1.clone(),
1436+
amount: 3_000,
1437+
},
1438+
PayoutItem {
1439+
recipient: receiver2.clone(),
14071440
amount: 2_000,
14081441
},
14091442
];
14101443

1411-
let res = client.try_batch_payout(&sender, &payouts);
1412-
assert!(res.is_err());
1444+
client.batch_payout(&sender, &payouts);
1445+
1446+
assert_eq!(token_client.balance(&receiver1), 3_000);
1447+
assert_eq!(token_client.balance(&receiver2), 2_000);
1448+
assert_eq!(token_client.balance(&treasury), 5);
1449+
assert_eq!(token_client.balance(&sender), 0);
1450+
}
1451+
1452+
#[test]
1453+
fn test_batch_payout_minimum_fee_applied() {
1454+
let (env, client, admin, treasury, sender, receiver1) = setup();
1455+
// total_volume = 1_000, fee = max(1, 1000*10/10000) = max(1, 1) = 1
1456+
let token = mint_token(&env, &admin, &sender, 1_001);
1457+
client.set_naira_token(&token);
1458+
let token_client = soroban_sdk::token::Client::new(&env, &token);
1459+
1460+
let payouts = vec![
1461+
&env,
1462+
PayoutItem {
1463+
recipient: receiver1.clone(),
1464+
amount: 1_000,
1465+
},
1466+
];
1467+
1468+
client.batch_payout(&sender, &payouts);
1469+
1470+
assert_eq!(token_client.balance(&receiver1), 1_000);
1471+
assert_eq!(token_client.balance(&treasury), 1);
1472+
assert_eq!(token_client.balance(&sender), 0);
14131473
}
14141474

14151475
#[test]

0 commit comments

Comments
 (0)