Skip to content

feat(social_payment): bulk discount for batch payout fees - #1087

Closed
mubby4 wants to merge 2 commits into
Fracverse:masterfrom
mubby4:feat/976-batch-payout-fee-discount
Closed

mubby4 wants to merge 2 commits into
Fracverse:masterfrom
mubby4:feat/976-batch-payout-fee-discount

Conversation

@mubby4

@mubby4 mubby4 commented Sep 26, 2026

Copy link
Copy Markdown

Overview

Implements the discounted batch transaction fee this issue asks for in SocialPaymentContract::batch_payout. The batch fee was calculated at the same flat coefficient as a single pay call (total_volume * fee_coef / 10_000) with the item count playing no part, so bulk senders had no fee incentive over sending the same payments one at a time. The fee is now tiered on the number of payouts a batch settles, deepening as the batch grows up to MAX_BATCH_SIZE.

The volume-based fee, the treasury routing, the 1-stroop minimum and the "charge on settled volume only" behaviour are all preserved — the discount is applied on top of them.

Related Issue

Addresses the fee model described for mass payout. Note this work is deliberately additive: batches of fewer than 5 payouts are charged exactly what they are charged today, so no existing caller's cost changes at small sizes.

Changes

contracts/contracts/social_payment/src/lib.rs

  • [ADD] BATCH_DISCOUNT_MIN_ITEMS (u32 = 5) — the batch size below which the single-payment rate applies unchanged. At that size the per-recipient saving would be dust and integer rounding would swallow it.

  • [ADD] batch_discount_bps(item_count) — the discount tier table, kept as one match so the tiers are auditable in a single place:

    Settled payouts Discount Effective coefficient at the default 10 bps
    1–4 0% 10 bps
    5–9 5% 9.5 bps
    10–24 10% 9 bps
    25–49 20% 8 bps
    50–MAX_BATCH_SIZE 30% 7 bps
  • [ADD] discounted_fee(fee, discount_bps) — applies the discount with a single integer division.

  • [MODIFY] batch_payout — the balance-check quote is now calculate_fee(total_volume, fee_coef), the same expression single payments use (including its 1-stroop floor) instead of an inlined total_volume * fee_coef / 10000. Behaviour is identical for every volume the old expression handled, and the split multiply in calculate_fee removes the overflow exposure on large volumes. The quote stays the undiscounted upper bound, so the balance requirement is unchanged and remains conservative.

  • [MODIFY] batch_payout — a successful_count counter is tracked alongside successful_volume, and the charged fee is discounted_fee(calculate_fee(successful_volume, fee_coef), batch_discount_bps(successful_count)) with the 1-stroop minimum re-applied. The tier is chosen from payouts that settled, not payouts that were requested, so the caller is rewarded for payments that actually went out.

  • [MODIFY] the batch_payout doc comment now states the tier table and that the fee is charged on settled volume.

Tests added to the in-crate tests module

  • test_small_batch_pays_the_single_payment_rate — 4 payouts of 1_000 route 4 stroops to the treasury, i.e. calculate_fee(4_000, 10), identical to a single payment. This pins the "existing behaviour is unchanged below the threshold" property.
  • test_batch_payout_applies_bulk_discount — 10 payouts of 1_000 (volume 10_000, gross fee 10) route 9 stroops, the 10% tier, and the MassPayoutExecuted summary reports fee_charged: 9, batch_size: 10, total_volume: 10_000.
  • test_batch_discount_tier_deepens_with_batch_size — every tier boundary (0, 1, 4, 5, 9, 10, 24, 25, 49, 50, MAX_BATCH_SIZE).
  • test_batch_effective_fee_rate_never_rises_with_batch_size — over real volumes at 100 bps, the effective rate (fee per unit volume) is non-increasing across 1 → 100 payouts, and a 100-payout batch routes strictly less than the undiscounted rate. This is the property a bulk incentive has to hold, checked against rounding rather than just the basis-point table.
  • test_discount_can_round_to_zero_and_the_caller_restores_the_minimum — a 100-payout batch of 1 stroop each has a gross fee at the 1-stroop minimum, which the 30% discount rounds to zero; the test asserts the helper exposes that plainly so the floor is applied deliberately at the call site.

contracts/contracts/social_payment/CHANGELOG.md

  • [MODIFY] added the tier table, the new helpers, and notes on the quote expression and the re-applied minimum.

Verification Results

Executed through the GitHub CI contract job. Intended exact-head command:

  cd contracts && cargo test --all-features

This environment produced the change through the GitHub REST API only (no local
clone), so the suite was not run here. Pass/fail is reported by CI on this head
rather than claimed here. What was checked at this head:

  - Two existing batch tests cover 1- and 2-payout batches and a 100-payout batch
    of 1 stroop each. All stay below BATCH_DISCOUNT_MIN_ITEMS or land on the
    1-stroop floor, so their observable output (treasury balance, balances,
    MassPayoutExecuted.fee_charged) is unchanged and their committed
    test_snapshots/*.json still match:
      test_batch_payout_transfers_to_multiple_recipients (2 payouts, 5_000 volume)
      test_batch_payout_deducts_batch_fee_to_treasury     (2 payouts, 10_000 volume)
      test_batch_payout_defers_fee_to_successful_volume   (2 payouts, 5_000 volume)
      test_batch_payout_minimum_fee_applied               (1 payout)
      test_batch_payout_emits_mass_payout_executed_event  (2 payouts)
      test_batch_payout_accepts_exactly_max_batch_size    (100 payouts of 1 stroop
        → gross fee 1, discounted 0, floored back to 1: unchanged)
  - The equivalence of the new quote to the old inlined expression was checked by
    hand for volumes 100, 4_000, 5_000, 10_000 and 20_000, and empty batches still
    quote 0 because calculate_fee is guarded on total_volume > 0.
  - The five new tests create new test_snapshots entries, which the Soroban test
    framework writes on first run.
Acceptance Criteria Status
Deduct dynamic platform fee from sender for the entire batch ✅ batch_payout charges one fee for the batch, now tiered on the settled item count; the balance check is unchanged and remains conservative
Route fee sum to platform treasury address ✅ The discounted fee is still routed to TREAS_KEY in a single transfer, only when settled volume is non-zero
Discounted batch fee logic to incentivise bulk processing ✅ batch_discount_bps — 5% at 5 payouts rising to 30% at 50+, so one batch call is strictly cheaper than the equivalent individual payments
Fee calculated dynamically on item count and transaction sizes ✅ Volume sets the fee (calculate_fee(settled_volume, fee_coef)), settled item count selects the discount tier
Existing behaviour preserved ✅ Batches below 5 payouts pay the single-payment rate; the 1-stroop minimum, settled-volume charging and treasury routing are unchanged; the six existing batch tests keep identical observable output
Focused positive, negative and boundary coverage ✅ 5 new tests: flat rate below the threshold, discount applied end to end against the summary event, all tier boundaries, the monotonic effective-rate property over real volumes, and the rounding-to-zero floor
No unrelated scope ✅ Only batch_payout, two new private helpers, its doc comment, the crate CHANGELOG and the test module

Residual risk / out of scope

  • The skipped-payout branch is unreachable at this head. batch_payout asserts sender_balance >= total_volume + batch_fee before the loop, then starts remaining_balance at sender_balance - batch_fee, which is therefore always >= total_volume. Every payout fits, so skipped_volume is always 0 and the BatchPayoutRefunded event never fires. Because of this the discount tier currently equals the requested batch size in practice; the tier is nevertheless derived from successful_count so it stays correct if that gate is relaxed. Fixing the gate is a separate decision about whether an underfunded batch should partially pay or fail, and is deliberately not changed here.
  • The fee model is a protocol-economics change, so a maintainer decision on the tier boundaries is expected. The tiers are isolated in batch_discount_bps to make retuning a one-line change.

Closes #976

@ONEONUORA ONEONUORA closed this Sep 26, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

contract: Mass Payout: Implement fee calculation and deductions logic for batch payments

2 participants