Skip to content

Commit b6ea810

Browse files
AnnieIjFracverse Dev
andauthored
contract: enforce distributor auth and batch size checks for mass payouts (#977) (#1094)
Add a distributor/relayer whitelist so only authorized addresses (plus admin) may submit batch_payout calls: * DataKey::Distributor(Address) — per-address persistent storage entry * Error::Unauthorized (= 4) — returned when an unlisted caller attempts a mass payout * add_distributor(admin, distributor) — admin-only whitelist function * remove_distributor(admin, distributor) — admin-only removal function * is_authorized_distributor(distributor) -> bool — public read helper * batch_payout now checks caller is admin OR whitelisted distributor before proceeding; non-matching callers get Error::Unauthorized All 9 existing batch_payout tests updated to whitelist the test sender so they continue to exercise the fee / event / volume logic as before. Six new tests added: - test_batch_payout_unauthorized_caller_rejected - test_batch_payout_authorized_distributor_accepted - test_batch_payout_admin_can_submit_directly - test_admin_can_add_distributor - test_admin_can_remove_distributor - test_batch_payout_distributor_still_enforces_batch_limit Closes #977 Co-authored-by: Fracverse Dev <dev@fracverse.io>
1 parent 5590c43 commit b6ea810

1 file changed

Lines changed: 249 additions & 3 deletions

File tree

  • contracts/contracts/social_payment/src

‎contracts/contracts/social_payment/src/lib.rs‎

Lines changed: 249 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -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 {
5962
pub 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

Comments
 (0)