FeeRouter Smart Contract

Technical documentation for the FeeRouter smart contract - routes commission payments to referrers based on ReplyCorp attribution data. Covers architecture, security, deployment, and integration.

Table of Contents


Overview

The FeeRouter contract is a best-effort fee distribution system designed to route commission payments to influencers based on attribution data provided by ReplyCorp. The contract implements a three-phase distribution model:

  1. Attribution Update Phase: ReplyCorp (as attribution updater) provides attribution percentages and commission amounts

  2. Distribution Start Phase: Authorized signer pulls tokens and initiates distribution

  3. Batch Processing Phase: Fees are distributed in batches (up to 200 recipients per batch) with best-effort transfers

Core Principles

  • Best-Effort Distribution: Transfers may fail silently and accumulate as dust. The contract does NOT guarantee delivery to all recipients.

  • Dust Ownership: All dust (rounding remainders, failed transfers, skipped batches) belongs permanently to the contract owner.

  • Bounded Lifetime: Designed for per-campaign/per-client/per-epoch use. Deploy new instances to avoid unbounded storage growth.

  • ERC20 Only: No native token support - uses standard ERC20 tokens (primarily USDC/USDT).


Architecture

Complete Flow Diagram

Role-Based Access Control

The contract implements three distinct roles:

  1. Owner (Client)

    • Deploys the contract

    • Sets authorized addresses (attribution updater, distribution signer, ReplyCorp wallet)

    • Withdraws accumulated dust

    • Rescues accidentally sent tokens

    • Transfers ownership

  2. Attribution Updater (ReplyCorp)

    • Updates attribution data for conversions

    • Provides attribution weights (based on totalVolume) and commission amounts

    • Cannot modify data after distribution starts

  3. Distribution Signer (Authorized Wallet)

    • Starts distribution by pulling tokens

    • Processes batches of recipients

    • Finalizes distributions

    • Must provide hash verification to ensure they reviewed attribution data

State Machine

Data Flow


Key Design Decisions

1. Attribution Weights vs. Payout Amounts

Decision: Attribution weights are calculated from totalVolume (who influenced the sale), but payout amounts are calculated from commission (what the customer wants to distribute).

Rationale:

  • Attribution reflects influence on the sale (totalVolume)

  • Payout reflects customer's commission budget (commission)

  • This separation allows flexible commission structures while maintaining accurate attribution

Implementation:

2. Best-Effort Distribution Model

Decision: Failed transfers are skipped and accumulated as dust rather than reverting the entire distribution.

Rationale:

  • Some recipients may have invalid addresses or reject transfers

  • Partial distributions are better than no distribution

  • Failed amounts are tracked and can be handled off-chain

Implementation:

  • Uses low-level call() to catch transfer failures

  • Failed transfers accumulate as failedSoFar and accumulatedDust

  • Successful transfers continue even if some fail

3. Batch Processing

Decision: Process recipients in batches of up to 200 to avoid gas limit issues.

Rationale:

  • Large distributions (1000+ recipients) would exceed gas limits

  • Batches can be processed in any order

  • Batches can be skipped (intentional design for best-effort model)

Implementation:

  • Each batch processes up to 200 recipients

  • Batch completion tracked per conversion and version

  • Batches can be processed out of order or skipped entirely

4. Reserved Balance System

Decision: Track reserved balance per conversion and globally to prevent premature rescue.

Rationale:

  • Prevents owner from rescuing funds mid-distribution

  • Enables efficient rescueTokens() calculation

  • Maintains accounting invariants

Implementation:

  • reservedBalance per conversion tracks undistributed commission

  • totalReservedBalance tracks across all active conversions

  • Reserved balance decreases as batches complete

  • Remaining reserved balance converts to dust on finalization

5. Attribution Hash Verification

Decision: Distribution signer must provide matching hash when starting distribution.

Rationale:

  • Ensures signer reviewed exact attribution data before distributing

  • Prevents distribution with stale or incorrect data

  • Provides cryptographic proof of review

Implementation:

  • Hash computed from: keccak256(conversionId, wallets[], weights[], commission, replyCorpFee)

  • Hash stored in attributionHash field

  • startDistribution() requires matching hash

6. Route Versioning

Decision: Increment route version on each attribution update to invalidate old batch completion flags.

Rationale:

  • Prevents batch completion flags from persisting across attribution updates

  • Ensures batch tracking is always current

  • Prevents replay attacks with old batch indices

Implementation:

  • routeVersion[conversionId] increments on each updateAttribution()

  • Batch completion tracked per version: batchCompleted[conversionId][version][batchIndex]


Contract Specification

State Variables

Structs

Distribution

AttributionData

Functions

Owner Functions

setAttributionUpdater(address newUpdater)

  • Access: Owner only

  • Purpose: Set/update ReplyCorp attribution updater address

  • Validation: New updater cannot be zero address

  • Event: AttributionUpdaterUpdated

setDistributionSigner(address newSigner)

  • Access: Owner only

  • Purpose: Set/update authorized distribution signer

  • Validation:

    • Cannot be zero address

    • Cannot be token contract address

    • Cannot be contract address

  • Event: DistributionSignerUpdated

setReplyCorpWallet(address newWallet)

  • Access: Owner only

  • Purpose: Update ReplyCorp wallet address

  • Validation:

    • Cannot be zero address

    • Cannot be token contract address

    • Cannot be contract address

  • Event: ReplyCorpWalletUpdated

transferOwnership(address newOwner)

  • Access: Owner only

  • Purpose: Transfer contract ownership

  • Validation: New owner cannot be zero address

  • Event: OwnershipTransferred

withdrawDust(uint256 amount)

  • Access: Owner only

  • Purpose: Withdraw accumulated dust

  • Validation:

    • Amount must be > 0

    • Amount must be <= accumulatedDust

  • Reentrancy: Protected

  • Event: DustWithdrawn

rescueTokens(uint256 amount)

  • Access: Owner only

  • Purpose: Rescue accidentally sent tokens

  • Validation:

    • Amount must be > 0

    • Amount must be <= rescuable balance

    • Rescuable = contract balance - accumulatedDust - totalReservedBalance

  • Reentrancy: Protected

  • Event: TokensRescued

Attribution Updater Functions

updateAttribution(bytes32 conversionId, Distribution[] calldata distributions, uint256 commission, uint256 replyCorpFee)

  • Access: Attribution updater only

  • Purpose: Update attribution data for a conversion

  • Validation:

    • ConversionId cannot be zero

    • Distribution signer must be set

    • Distributions array cannot be empty

    • Commission must be > 0

    • Commission must not cause overflow

    • ReplyCorp fee must not cause overflow

    • Wallets must be sorted in ascending order (deterministic)

    • Wallets cannot be zero, token address, or contract address

    • Attribution weights must sum to 10000 (100%)

    • Attribution must not already be distributed

    • Distribution must not have started

    • Reserved balance must be zero (no stale state)

  • Side Effects:

    • Increments route version

    • Clears existing distributions

    • Computes attribution hash

    • Stores new attribution data

    • Resets distribution state

  • Event: AttributionUpdated (includes computed amounts and hash)

Distribution Signer Functions

startDistribution(bytes32 conversionId, uint256 totalAmount, bytes32 expectedHash)

  • Access: Distribution signer only

  • Purpose: Start distribution by pulling tokens

  • Validation:

    • ConversionId cannot be zero

    • Attribution data must exist

    • Distribution must not already be started

    • Distribution must not already be finalized

    • Total amount must match stored totalAmount

    • Expected hash must match stored attributionHash

  • Side Effects:

    • Pulls tokens from signer via transferFrom (signer must approve first)

    • Transfers ReplyCorp fee immediately

    • Reserves commission balance

    • Marks distribution as started

  • Reentrancy: Protected

  • Event: DistributionStarted

processBatch(bytes32 conversionId, uint256 batchIndex)

  • Access: Distribution signer only

  • Purpose: Process a batch of recipients (up to 200)

  • Validation:

    • ConversionId cannot be zero

    • Attribution data must exist

    • Distribution must be started

    • Distribution must not be finalized

    • Batch must not already be completed

    • Batch index must be in range

  • Side Effects:

    • Processes up to 200 recipients

    • Uses best-effort transfers (skips failures)

    • Updates paidSoFar and failedSoFar

    • Releases reserved balance for successful transfers

    • Accumulates failed transfers as dust

    • Marks batch as completed

  • Reentrancy: Protected

  • Event: BatchDistributed (includes failure counts)

finalizeDistribution(bytes32 conversionId)

  • Access: Distribution signer only

  • Purpose: Finalize distribution and convert remaining reserved balance to dust

  • Validation:

    • ConversionId cannot be zero

    • Distribution must be started

    • Distribution must not already be finalized

  • Side Effects:

    • Converts remaining reserved balance to dust

    • Releases reserved balance

    • Marks distribution as finalized

    • Validates invariants

  • Reentrancy: Protected

  • Event: FeesDistributed (includes total dust)

View Functions

getDistributionRoute(bytes32 conversionId)

  • Returns: Complete distribution route including distributions array, amounts, hash, and status

  • Purpose: Read attribution data and distribution status

isBatchCompleted(bytes32 conversionId, uint256 batchIndex)

  • Returns: Whether the specified batch has been completed

  • Purpose: Check batch completion status (uses current route version)

getBatchCount(bytes32 conversionId)

  • Returns: Number of batches required (ceiling of distributions.length / 200)

  • Purpose: Calculate total batches needed


Security Model

Access Control

  1. Owner Functions: Only owner can modify authorized addresses and withdraw dust

  2. Attribution Updater: Only ReplyCorp can update attribution data

  3. Distribution Signer: Only authorized signer can start/process/finalize distributions

Input Validation

  • Zero address checks on all address parameters

  • Overflow protection on arithmetic operations

  • Array length validation

  • State validation (cannot update after distribution starts)

  • Hash verification (signer must provide matching hash)

Reentrancy Protection

  • All external functions use nonReentrant modifier

  • Uses OpenZeppelin's ReentrancyGuard

  • SafeERC20 for token transfers

State Invariants

  1. Reserved Balance Invariant: data.reservedBalance == data.commission after startDistribution()

  2. Total Reserved Invariant: totalReservedBalance >= data.reservedBalance (global >= per-conversion)

  3. Contract Balance Invariant: contractBalance >= accumulatedDust (after finalization)

  4. Theoretical Payout Invariant: theoreticalTotal <= commission (floor division ensures this)

Hash Verification

  • Attribution hash computed deterministically from conversion data

  • Signer must provide matching hash when starting distribution

  • Prevents distribution with stale or incorrect data

Batch Completion Tracking

  • Batches tracked per conversion and version

  • Version increments on attribution update (invalidates old batch flags)

  • Prevents replay attacks with old batch indices


Dust Handling Policy

What is Dust?

Dust consists of:

  1. Rounding Remainders: Floor division of commission creates small remainders

  2. Failed Transfers: Transfers that fail (invalid addresses, rejections)

  3. Skipped Batches: Batches that are never processed

  4. Unreachable Recipients: Recipients with zero payout (due to floor division)

Dust Accumulation

  • Dust accumulates in accumulatedDust counter

  • Failed transfers added immediately during batch processing

  • Remaining reserved balance converted to dust on finalization

  • Dust is tracked explicitly (never inferred from balance)

Dust Ownership

  • All dust belongs permanently to the contract owner

  • ReplyCorp and influencers have no claim on undelivered funds

  • Owner can withdraw dust via withdrawDust()

  • Dust withdrawal reduces contract balance but not reserved balance

Example Dust Scenarios

Scenario 1: Rounding Dust

Scenario 2: Failed Transfer

Scenario 3: Skipped Batch


Batch Processing Model

Batch Size

  • 200 recipients per batch (gas limit consideration)

  • Batches numbered starting from 0

  • Last batch may have fewer than 200 recipients

Batch Processing Rules

  1. Order Independence: Batches can be processed in any order

  2. Skippable: Batches can be skipped entirely

  3. Idempotent: Processing the same batch twice is prevented

  4. Version-Aware: Batch completion tracked per route version

Batch Completion Detection

  • Contract tracks completion per batch index

  • isBatchCompleted() checks current route version

  • getBatchCount() calculates total batches needed

Finalization

  • Must be called explicitly (does not auto-finalize)

  • Can be called even if zero batches processed (converts entire commission to dust)

  • Converts remaining reserved balance to dust

  • Marks distribution as finalized

Best-Effort Behavior

  • Failed transfers are skipped (not reverted)

  • Successful transfers continue even if some fail

  • Failed amounts accumulate as dust

  • Partial distributions are allowed


Deployment & Initialization

Deployment Steps

  1. Deploy Contract

    • Client deploys with their wallet (becomes owner)

    • Token address is immutable

    • ReplyCorp wallet can be updated later

  2. Set Attribution Updater

    • Owner sets ReplyCorp address as attribution updater

  3. Set Distribution Signer

    • Owner sets authorized wallet that will call distribution functions

  4. Set ReplyCorp Wallet (if different from constructor)

    • Owner can update ReplyCorp wallet address

Initialization Checklist


Integration Guide

1. Attribution Update (ReplyCorp)

2. Distribution Start (Authorized Signer)

3. Batch Processing (Authorized Signer)

4. Finalization (Authorized Signer)

5. Dust Withdrawal (Owner)


Version History

  • VERSION 1 (Current): Initial implementation with batch processing and best-effort distribution


Testing

The contract includes comprehensive test coverage:

  • 77 passing tests

  • 91.45% branch coverage (FeeRouter.sol)

  • 89.38% overall branch coverage

  • 100% statement, function, and line coverage

Test file: packages/contracts/test/FeeRouter.test.ts


Gas Analysis

Gas costs were measured using Hardhat's gas reporting. All measurements are with optimizer enabled (200 runs).

Attribution Update Gas Costs

Recipients
Gas Used
Notes

1

~245,000

Base cost for attribution update

10

~680,000

Linear scaling with recipient count

50

~2,614,000

Storage costs increase with array size

200

~9,872,000

Maximum single-batch size

Key Observations:

  • Gas scales approximately linearly with recipient count

  • Each additional recipient adds ~49,000-50,000 gas

  • Storage costs dominate for large recipient lists

Distribution Gas Costs

Operation
Gas Used
Notes

startDistribution()

~165,000

Pulls tokens, transfers ReplyCorp fee

processBatch() (1 recipient)

~120,000

Base batch processing cost

processBatch() (10 recipients)

~401,000

~28,000 gas per recipient

processBatch() (50 recipients)

~1,652,000

~30,000 gas per recipient

processBatch() (200 recipients)

~6,345,000

~31,000 gas per recipient

finalizeDistribution()

~49,000

Converts remaining to dust

Key Observations:

  • startDistribution() has fixed cost regardless of recipient count

  • processBatch() gas scales linearly: ~30,000 gas per recipient

  • finalizeDistribution() has minimal fixed cost

  • Batch size of 200 is optimal for gas efficiency

Complete Flow Gas Costs

Recipients
Total Gas
Breakdown

1

~580,000

updateAttribution: 245k startDistribution: 165k processBatch: 120k finalizeDistribution: 49k

200

~16,433,000

updateAttribution: 9,872k startDistribution: 165k processBatch: 6,345k finalizeDistribution: 49k

Gas Cost Breakdown by Phase

Attribution Phase (ReplyCorp):

  • 1 recipient: ~245,000 gas

  • 200 recipients: ~9,872,000 gas

  • Per recipient: ~49,000 gas

Distribution Phase (Signer):

  • startDistribution: ~165,000 gas (fixed)

  • processBatch: ~30,000 gas per recipient

  • finalizeDistribution: ~49,000 gas (fixed)

  • Minimum (1 recipient): ~334,000 gas

  • 200 recipients: ~6,559,000 gas

Multi-Batch Scenarios

For distributions with >200 recipients:

  • Each additional batch adds ~6,345,000 gas (for full 200-recipient batches)

  • Example: 500 recipients = 3 batches

    • Batch 0: ~6,345,000 gas

    • Batch 1: ~6,345,000 gas

    • Batch 2: ~1,586,000 gas (100 recipients)

    • Total batch processing: ~14,276,000 gas

Gas Optimization Notes

  1. Batch Size: 200 recipients per batch is optimal (avoids gas limit while maximizing efficiency)

  2. Attribution Updates: Can be expensive for large recipient lists - consider batching updates

  3. Distribution: Fixed costs (~214,000 gas) + variable costs (~30,000 per recipient)

  4. Finalization: Always required but minimal cost (~49,000 gas)

Running Gas Tests

This will run dedicated gas tests and output gas usage for each operation.

Test file: packages/contracts/test/FeeRouter.gas.test.ts


Security Audit Considerations

Known Limitations

  1. Best-Effort Distribution: Failed transfers are not reverted - partial distributions possible

  2. Batch Skipping: Batches can be skipped, converting funds to dust

  3. No Native Token Support: ERC20 only

  4. Bounded Lifetime: Not designed for unbounded conversion counts

  5. No Fee-on-Transfer Support: Standard ERC20 only

Recommendations

  1. Monitor Failed Transfers: Track failedSoFar and handle off-chain

  2. Process All Batches: Ensure all batches processed before finalization

  3. Verify Attribution Hash: Always verify hash before starting distribution

  4. Secure Owner Wallet: Owner controls significant functionality

  5. Regular Dust Withdrawal: Withdraw accumulated dust periodically


License

SPDX-License-Identifier: MIT


References

Last updated