@@ -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
89const ADMIN_KEY : Symbol = symbol_short ! ( "admin" ) ;
910const 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