@@ -32,6 +32,9 @@ pub enum Error {
3232 NonceAlreadyUsed = 2 ,
3333 /// The signature verification failed for the signed payment authorization.
3434 InvalidSignature = 3 ,
35+ /// The caller is not the admin and is not on the authorized distributor whitelist.
36+ /// Only admin and explicitly whitelisted distributors/relayers may submit mass payouts.
37+ Unauthorized = 4 ,
3538}
3639
3740// ── External contract interface ───────────────────────────────────────────────
@@ -59,6 +62,9 @@ pub enum Visibility {
5962pub enum DataKey {
6063 PaymentRequest ( u64 ) ,
6164 Nonce ( Address ) ,
65+ /// #977: per-address distributor/relayer whitelist entry.
66+ /// Stored as `bool` (always `true`); absence means not authorized.
67+ Distributor ( Address ) ,
6268}
6369
6470/// SC-041: Emitted for every executed payment. Carries both the raw
@@ -336,6 +342,50 @@ impl SocialPaymentContract {
336342 . expect ( "user registry not configured" )
337343 }
338344
345+ // ── Distributor / relayer whitelist (#977) ────────────────────────────────
346+
347+ /// Add `distributor` to the authorized distributor whitelist.
348+ ///
349+ /// Only admin may call this. Authorized distributors are the only addresses
350+ /// (besides the admin itself) permitted to submit `batch_payout` calls.
351+ pub fn add_distributor ( env : Env , admin : Address , distributor : Address ) {
352+ let stored_admin: Address = env
353+ . storage ( )
354+ . instance ( )
355+ . get ( & ADMIN_KEY )
356+ . expect ( "not initialized" ) ;
357+ admin. require_auth ( ) ;
358+ assert ! ( admin == stored_admin, "only admin can add distributors" ) ;
359+ env. storage ( )
360+ . persistent ( )
361+ . set ( & DataKey :: Distributor ( distributor) , & true ) ;
362+ }
363+
364+ /// Remove `distributor` from the authorized distributor whitelist.
365+ ///
366+ /// Only admin may call this. Subsequent `batch_payout` calls from the removed
367+ /// address will fail with `Error::Unauthorized`.
368+ pub fn remove_distributor ( env : Env , admin : Address , distributor : Address ) {
369+ let stored_admin: Address = env
370+ . storage ( )
371+ . instance ( )
372+ . get ( & ADMIN_KEY )
373+ . expect ( "not initialized" ) ;
374+ admin. require_auth ( ) ;
375+ assert ! ( admin == stored_admin, "only admin can remove distributors" ) ;
376+ env. storage ( )
377+ . persistent ( )
378+ . remove ( & DataKey :: Distributor ( distributor) ) ;
379+ }
380+
381+ /// Return `true` if `distributor` is on the authorized whitelist.
382+ pub fn is_authorized_distributor ( env : Env , distributor : Address ) -> bool {
383+ env. storage ( )
384+ . persistent ( )
385+ . get :: < _ , bool > ( & DataKey :: Distributor ( distributor) )
386+ . unwrap_or ( false )
387+ }
388+
339389 // ── Payment requests / invoices ───────────────────────────────────────────
340390
341391 /// Create a payment request (invoice) asking `payer_username` to pay
@@ -503,6 +553,24 @@ impl SocialPaymentContract {
503553 // #531 — explicit sender auth
504554 sender. require_auth ( ) ;
505555
556+ // #977 — distributor/relayer authorization:
557+ // Only the admin or an explicitly whitelisted distributor may submit
558+ // mass payouts. Any other address is rejected immediately.
559+ let admin: Address = env
560+ . storage ( )
561+ . instance ( )
562+ . get ( & ADMIN_KEY )
563+ . expect ( "not initialized" ) ;
564+ let is_admin = sender == admin;
565+ let is_distributor = env
566+ . storage ( )
567+ . persistent ( )
568+ . get :: < _ , bool > ( & DataKey :: Distributor ( sender. clone ( ) ) )
569+ . unwrap_or ( false ) ;
570+ if !is_admin && !is_distributor {
571+ return Err ( Error :: Unauthorized ) ;
572+ }
573+
506574 // #752 / #531 — enforce max batch size; return typed error on violation
507575 let batch_size = payouts. len ( ) ;
508576 if batch_size > MAX_BATCH_SIZE {
@@ -1297,6 +1365,8 @@ mod tests {
12971365 let token = mint_token ( & env, & admin, & sender, 10_000 ) ;
12981366 client. set_naira_token ( & token) ;
12991367 let token_client = soroban_sdk:: token:: Client :: new ( & env, & token) ;
1368+ // #977: whitelist sender as an authorized distributor
1369+ client. add_distributor ( & admin, & sender) ;
13001370
13011371 let payouts = vec ! [
13021372 & env,
@@ -1329,6 +1399,8 @@ mod tests {
13291399 // Set fee to 50 bps (0.5%)
13301400 client. set_fee_coefficient ( & 50 ) ;
13311401 let token_client = soroban_sdk:: token:: Client :: new ( & env, & token) ;
1402+ // #977: whitelist sender as an authorized distributor
1403+ client. add_distributor ( & admin, & sender) ;
13321404
13331405 let payouts = vec ! [
13341406 & env,
@@ -1357,6 +1429,8 @@ mod tests {
13571429 let ( env, client, admin, _treasury, sender, _receiver) = setup ( ) ;
13581430 let token = mint_token ( & env, & admin, & sender, 1_000_000 ) ;
13591431 client. set_naira_token ( & token) ;
1432+ // #977: whitelist sender — size check must fire even for authorized distributors
1433+ client. add_distributor ( & admin, & sender) ;
13601434
13611435 // Build a batch with 101 items (exceeds MAX_BATCH_SIZE = 100)
13621436 let mut items = soroban_sdk:: vec![ & env] ;
@@ -1381,6 +1455,8 @@ mod tests {
13811455 // Mint enough to cover 100 × 1 stroop + fee
13821456 let token = mint_token ( & env, & admin, & sender, 10_000 ) ;
13831457 client. set_naira_token ( & token) ;
1458+ // #977: whitelist sender
1459+ client. add_distributor ( & admin, & sender) ;
13841460
13851461 let mut items = soroban_sdk:: vec![ & env] ;
13861462 for _ in 0 ..100u32 {
@@ -1395,14 +1471,17 @@ mod tests {
13951471 client. batch_payout ( & sender, & items) ;
13961472 }
13971473
1398- // ── #531: sender auth required ──────────────────────────────── ────────────
1474+ // ── #531 / #977 : sender auth + distributor whitelist required ────────────
13991475 #[ test]
14001476 fn test_batch_payout_requires_sender_auth ( ) {
14011477 let ( env, client, admin, _treasury, sender, receiver1) = setup ( ) ;
14021478 let token = mint_token ( & env, & admin, & sender, 10_000 ) ;
14031479 client. set_naira_token ( & token) ;
1480+ // #977: whitelist sender so the distributor check passes
1481+ client. add_distributor ( & admin, & sender) ;
14041482
1405- // mock_all_auths is active in setup() so this call succeeds
1483+ // mock_all_auths is active in setup() so the Soroban auth check passes;
1484+ // the distributor whitelist check also passes because we added sender above.
14061485 let payouts = vec ! [
14071486 & env,
14081487 PayoutItem {
@@ -1411,7 +1490,7 @@ mod tests {
14111490 } ,
14121491 ] ;
14131492 client. batch_payout ( & sender, & payouts) ;
1414- // no panic = auth was enforced and satisfied by mock
1493+ // no panic = both auth and whitelist were enforced and satisfied
14151494 }
14161495
14171496 // ── #532 / #978: MassPayoutExecuted event for off-chain accounting ───────
@@ -1432,6 +1511,8 @@ mod tests {
14321511 let token = mint_token ( & env, & admin, & sender, 20_000 ) ;
14331512 client. set_naira_token ( & token) ;
14341513 let token_client = soroban_sdk:: token:: Client :: new ( & env, & token) ;
1514+ // #977: whitelist sender
1515+ client. add_distributor ( & admin, & sender) ;
14351516
14361517 let payouts = vec ! [
14371518 & env,
@@ -1468,6 +1549,8 @@ mod tests {
14681549 client. set_naira_token ( & token) ;
14691550 let token_client = soroban_sdk:: token:: Client :: new ( & env, & token) ;
14701551 let treasury_before = token_client. balance ( & treasury) ;
1552+ // #977: whitelist sender
1553+ client. add_distributor ( & admin, & sender) ;
14711554
14721555 let payouts = vec ! [
14731556 & env,
@@ -1511,6 +1594,8 @@ mod tests {
15111594 client. set_naira_token ( & token) ;
15121595 client. set_fee_coefficient ( & 50 ) ; // 0.5%
15131596 let token_client = soroban_sdk:: token:: Client :: new ( & env, & token) ;
1597+ // #977: whitelist sender
1598+ client. add_distributor ( & admin, & sender) ;
15141599
15151600 let payouts = vec ! [
15161601 & env,
@@ -1541,6 +1626,8 @@ mod tests {
15411626 let token = mint_token ( & env, & admin, & sender, 10_010 ) ;
15421627 client. set_naira_token ( & token) ;
15431628 let token_client = soroban_sdk:: token:: Client :: new ( & env, & token) ;
1629+ // #977: whitelist sender
1630+ client. add_distributor ( & admin, & sender) ;
15441631
15451632 let payouts = vec ! [
15461633 & env,
@@ -1568,6 +1655,8 @@ mod tests {
15681655 let token = mint_token ( & env, & admin, & sender, 5_005 ) ;
15691656 client. set_naira_token ( & token) ;
15701657 let token_client = soroban_sdk:: token:: Client :: new ( & env, & token) ;
1658+ // #977: whitelist sender
1659+ client. add_distributor ( & admin, & sender) ;
15711660
15721661 let payouts = vec ! [
15731662 & env,
@@ -1596,6 +1685,8 @@ mod tests {
15961685 let token = mint_token ( & env, & admin, & sender, 1_001 ) ;
15971686 client. set_naira_token ( & token) ;
15981687 let token_client = soroban_sdk:: token:: Client :: new ( & env, & token) ;
1688+ // #977: whitelist sender
1689+ client. add_distributor ( & admin, & sender) ;
15991690
16001691 let payouts = vec ! [
16011692 & env,
@@ -1931,5 +2022,160 @@ mod tests {
19312022 // After user1 makes a payment, only their nonce should increment
19322023 // user2's nonce should remain at 0
19332024 }
2025+
2026+ // ── #977: Distributor / relayer whitelist ─────────────────────────────────
2027+
2028+ /// Unauthorized address (not admin, not whitelisted) must be rejected with
2029+ /// Error::Unauthorized — regardless of Soroban-level auth being satisfied.
2030+ #[ test]
2031+ fn test_batch_payout_unauthorized_caller_rejected ( ) {
2032+ let ( env, client, admin, _treasury, _sender, _receiver) = setup ( ) ;
2033+ let token = mint_token ( & env, & admin, & admin, 10_000 ) ;
2034+ client. set_naira_token ( & token) ;
2035+
2036+ let random_caller = Address :: generate ( & env) ;
2037+ // Give the random caller some tokens
2038+ let token_admin = soroban_sdk:: token:: StellarAssetClient :: new ( & env, & token) ;
2039+ token_admin. mint ( & random_caller, & 10_000 ) ;
2040+
2041+ let recipient = Address :: generate ( & env) ;
2042+ let payouts = vec ! [
2043+ & env,
2044+ PayoutItem {
2045+ recipient,
2046+ amount: 100 ,
2047+ } ,
2048+ ] ;
2049+
2050+ // mock_all_auths means the Soroban auth check passes — but the
2051+ // distributor whitelist check must still reject a non-admin, non-whitelisted caller.
2052+ assert_eq ! (
2053+ client. try_batch_payout( & random_caller, & payouts) ,
2054+ Err ( Ok ( Error :: Unauthorized ) ) ,
2055+ "non-whitelisted, non-admin caller must be rejected with Unauthorized"
2056+ ) ;
2057+ }
2058+
2059+ /// Once whitelisted, a distributor must be able to submit batch_payout.
2060+ #[ test]
2061+ fn test_batch_payout_authorized_distributor_accepted ( ) {
2062+ let ( env, client, admin, _treasury, _sender, _receiver) = setup ( ) ;
2063+ let distributor = Address :: generate ( & env) ;
2064+ let token = mint_token ( & env, & admin, & distributor, 10_000 ) ;
2065+ client. set_naira_token ( & token) ;
2066+
2067+ // Whitelist the distributor
2068+ client. add_distributor ( & admin, & distributor) ;
2069+ assert ! ( client. is_authorized_distributor( & distributor) ) ;
2070+
2071+ let recipient = Address :: generate ( & env) ;
2072+ let payouts = vec ! [
2073+ & env,
2074+ PayoutItem {
2075+ recipient: recipient. clone( ) ,
2076+ amount: 1_000 ,
2077+ } ,
2078+ ] ;
2079+
2080+ // Should succeed — distributor is whitelisted
2081+ client. batch_payout ( & distributor, & payouts) ;
2082+
2083+ let token_client = soroban_sdk:: token:: Client :: new ( & env, & token) ;
2084+ assert_eq ! ( token_client. balance( & recipient) , 1_000 ) ;
2085+ }
2086+
2087+ /// Admin can always call batch_payout without being explicitly whitelisted.
2088+ #[ test]
2089+ fn test_batch_payout_admin_can_submit_directly ( ) {
2090+ let ( env, client, admin, treasury, _sender, _receiver) = setup ( ) ;
2091+ let token = mint_token ( & env, & admin, & admin, 10_000 ) ;
2092+ client. set_naira_token ( & token) ;
2093+
2094+ let recipient = Address :: generate ( & env) ;
2095+ let payouts = vec ! [
2096+ & env,
2097+ PayoutItem {
2098+ recipient: recipient. clone( ) ,
2099+ amount: 5_000 ,
2100+ } ,
2101+ ] ;
2102+
2103+ // Admin is not in the distributor whitelist, but must pass the check anyway.
2104+ client. batch_payout ( & admin, & payouts) ;
2105+
2106+ let token_client = soroban_sdk:: token:: Client :: new ( & env, & token) ;
2107+ assert_eq ! ( token_client. balance( & recipient) , 5_000 ) ;
2108+ // fee = 5_000 * 10 / 10000 = 5 (minimum 1)
2109+ assert_eq ! ( token_client. balance( & treasury) , 5 ) ;
2110+ }
2111+
2112+ /// Admin can add a distributor; is_authorized_distributor reflects the change.
2113+ #[ test]
2114+ fn test_admin_can_add_distributor ( ) {
2115+ let ( env, client, admin, _treasury, _sender, _receiver) = setup ( ) ;
2116+ let distributor = Address :: generate ( & env) ;
2117+
2118+ assert ! ( !client. is_authorized_distributor( & distributor) , "not yet whitelisted" ) ;
2119+ client. add_distributor ( & admin, & distributor) ;
2120+ assert ! ( client. is_authorized_distributor( & distributor) , "should be whitelisted after add" ) ;
2121+ }
2122+
2123+ /// Admin can remove a distributor; subsequent calls must be rejected.
2124+ #[ test]
2125+ fn test_admin_can_remove_distributor ( ) {
2126+ let ( env, client, admin, _treasury, _sender, _receiver) = setup ( ) ;
2127+ let token = mint_token ( & env, & admin, & admin, 10_000 ) ;
2128+ client. set_naira_token ( & token) ;
2129+
2130+ let distributor = Address :: generate ( & env) ;
2131+ let token_admin = soroban_sdk:: token:: StellarAssetClient :: new ( & env, & token) ;
2132+ token_admin. mint ( & distributor, & 10_000 ) ;
2133+
2134+ // Add then remove
2135+ client. add_distributor ( & admin, & distributor) ;
2136+ assert ! ( client. is_authorized_distributor( & distributor) ) ;
2137+ client. remove_distributor ( & admin, & distributor) ;
2138+ assert ! ( !client. is_authorized_distributor( & distributor) , "should no longer be whitelisted" ) ;
2139+
2140+ let recipient = Address :: generate ( & env) ;
2141+ let payouts = vec ! [
2142+ & env,
2143+ PayoutItem { recipient, amount: 100 } ,
2144+ ] ;
2145+
2146+ // After removal, call must fail
2147+ assert_eq ! (
2148+ client. try_batch_payout( & distributor, & payouts) ,
2149+ Err ( Ok ( Error :: Unauthorized ) ) ,
2150+ "removed distributor must be rejected"
2151+ ) ;
2152+ }
2153+
2154+ /// Batch size limit is still enforced even for an authorized distributor.
2155+ #[ test]
2156+ fn test_batch_payout_distributor_still_enforces_batch_limit ( ) {
2157+ let ( env, client, admin, _treasury, _sender, _receiver) = setup ( ) ;
2158+ let distributor = Address :: generate ( & env) ;
2159+ let token = mint_token ( & env, & admin, & admin, 1_000_000 ) ;
2160+ client. set_naira_token ( & token) ;
2161+ let token_admin = soroban_sdk:: token:: StellarAssetClient :: new ( & env, & token) ;
2162+ token_admin. mint ( & distributor, & 1_000_000 ) ;
2163+
2164+ client. add_distributor ( & admin, & distributor) ;
2165+
2166+ let mut items = soroban_sdk:: vec![ & env] ;
2167+ for _ in 0 ..101u32 {
2168+ items. push_back ( PayoutItem {
2169+ recipient : Address :: generate ( & env) ,
2170+ amount : 1 ,
2171+ } ) ;
2172+ }
2173+
2174+ assert_eq ! (
2175+ client. try_batch_payout( & distributor, & items) ,
2176+ Err ( Ok ( Error :: BatchTooLarge ) ) ,
2177+ "MAX_BATCH_SIZE must still apply for whitelisted distributors"
2178+ ) ;
2179+ }
19342180}
19352181
0 commit comments