Holder Rewards
A reward epoch takes the Pair Asset acquired for a Pair Market and allocates it across eligible token holders, in exact integer units, with a Merkle root that lets every allocation be proven independently.
The reward epoch
Holder rewards are paid in the Pair Asset. When a settlement acquires the Pair Asset for a Pair Market, part of the market’s unallocated Pair Asset balance is released into a Reward EpochReward EpochA window over which acquired Pair Asset is allocated to eligible holders by their holder weight. Produces exact integer allocations and a Merkle root.. The epoch measures who held the token, for how long and in what size, then turns that measurement into an allocation per wallet.
The epoch covers the window since the market’s previous settlement (or since the market was created, for its first epoch) up to the moment the current settlement completes. With the default hourly cadence, that is one hour of holding. The release size is a configured share of the unallocated balance:
Each epoch is written to the ledger table reward_epochs. These are its real fields:
- id
- Primary key of the epoch.
- pairMarketId
- The Pair Market whose holders the epoch rewards.
- epochNumber
- Sequence number per Pair Market, starting at 1.
- startAt / endAt
- The holding window measured by the epoch.
- methodology
- time_weighted_balance, average_balance or snapshot_min_hold.
- rewardInstrumentId
- The Pair Instrument whose Pair Asset the epoch distributes.
- totalPoolRaw
- The pool in the Pair Asset’s raw base units. Allocations sum to exactly this value.
- totalPoolUsd
- The pool valued at the settlement’s execution price.
- eligibleHolderCount
- Holders with a positive weight after exclusions and thresholds.
- totalWeight
- Σ holder weight. Stored as an integer (numeric 60,0).
- allocationRoot
- Hex Merkle root over every (wallet, amountRaw) allocation.
- status
- open, calculating, allocated, finalized.
- ledgerMode
- live or dev_simulated, inherited from the settlement plan.
Per-wallet results land in reward_allocations (epochId, wallet, weight, amountRaw, usdValue, status). An allocation starts as claimable and becomes claimed when a claim settles it. Claims themselves are rows in reward_claims.
Why time-weighted holdings
A naive snapshot reads every balance at one instant and divides the pool by those balances. It rewards whoever holds at that instant, regardless of what happened before. A wallet can buy a large position seconds before the snapshot, collect most of the pool, and sell immediately after.
Time weighting closes that gap. A holder’s Holder WeightHolder WeightA holder's share basis inside a reward epoch. Under the time-weighted method: the integral of balance over time held inside the epoch (token-seconds). is the area under its balance curve inside the epoch: balance multiplied by time held. A large balance held for two seconds produces a small area. A modest balance held for the whole epoch produces a large one.
reward_i = weight_i / Σ_j weight_j × reward_pool
| Wallet | Weight (token-hours) | Share | Reward |
|---|---|---|---|
| Wallet A | 2,400 | 39.96% | 399.6 units |
| Wallet B | 6 | 0.10% | 1 units |
| Wallet C | 3,600 | 59.94% | 599.4 units |
Figure summary: Wallet A holds 100 tokens for the full 24 hours. Wallet B buys 10,000 tokens at hour 23.9994, about two seconds before the close. Wallet C buys 300 tokens at hour 12 and holds to the close. A naive snapshot gives B 961.54 of 1,000 units, A 9.62 and C 28.85. Time-weighted balance gives A 2,400 token-hours (399.60 units), B 6 token-hours (1.00 unit) and C 3,600 token-hours (599.40 units).
The arithmetic behind the time-weighted view:
B: 10,000 × 0.0006 h = 6 → 6 / 6,006 × 1,000 = 1.00
C: 300 × 12 h = 3,600 → 3,600 / 6,006 × 1,000 = 599.40
The snapshot gives Wallet B 10,000 / 10,400 of the pool, which is 961.54 units. Time weighting gives it 1.00. Wallet A, the holder who was present for the entire epoch, moves from 9.62 units to 399.60.
The three methodologies
Each Pair Market declares one methodology in its configuration (rewardMethodology). All three are implemented as pure functions in src/server/pair/rewards.ts and covered by tests. Each one receives the holder balance timeline as segments (wallet, balanceRaw, from, to), clips every segment to the epoch window, and returns an integer weight per wallet.
| Methodology | Weight | Thresholds | Behavior |
|---|---|---|---|
| time_weighted_balance | Σ balanceRaw × seconds held inside the epoch | Segments below minHoldingRaw are skipped | The default. Rewards size and duration together; a position opened at the close earns almost nothing. |
| average_balance | time-weighted weight ÷ epoch duration in seconds (integer division) | Holders whose average is below minHoldingRaw are dropped | Same proportions as time weighting, expressed as an average balance, with a floor that removes dust holders. |
| snapshot_min_hold | balanceRaw at endAt | Counts only wallets that held a positive balance for at least minHoldShare of the epoch; minHoldingRaw also applies | A snapshot with a holding requirement. The settlement engine sets minHoldShare to 25%, so a two-second position never qualifies. |
Wallet B in the figure above earns nothing under snapshot_min_hold: it held for 2.16 seconds of an 86,400-second epoch, far short of the 21,600 seconds that 25% requires. Wallets A and C qualify and are weighted by their closing balances, 100 and 300.
Exclusions
Before any weight is computed, the engine removes wallets that hold tokens for structural reasons rather than as holders. Each exclusion carries a reason, and the epoch result lists every excluded wallet with that reason.
- ·creator: the creator wallet, when the market’s excludeCreator setting is on (the default).
- ·vault: the Pair Vault address. Always excluded.
- ·liquidity: holder rows tagged as liquidity accounts, such as the bonding curve’s token account, when excludeLiquidityAccounts is on (the default).
- ·configured: any address in the market’s excludedWallets list.
Exact integer allocation
The Pair Asset is a token with fixed decimals, so allocations are integers in raw base units. Proportional shares are almost never whole numbers. Rounding every share down leaves units unallocated; rounding to nearest can allocate more than the pool. The engine uses the largest-remainder method, which allocates the pool exactly.
/**
* Largest-remainder allocation: floor every share, then hand the leftover units to the
* largest fractional remainders (ties broken by wallet for determinism). Σ amount === pool.
*/
export function allocatePool(pool: bigint, weights: Map<string, bigint>): Allocation[];- 01Multiply the pool by each weight and divide by the total weight. Keep the integer quotient (the floor) and the remainder.
- 02Subtract the sum of floors from the pool. The difference is the number of leftover units, always fewer than the number of holders.
- 03Sort holders by remainder, largest first. Equal remainders are ordered by wallet address so the result is deterministic.
- 04Give one leftover unit to each holder in that order until none remain.
| Wallet | Weight | 1,000 × weight | Floor | Remainder | Leftover | Allocation |
|---|---|---|---|---|---|---|
| Wallet A | 2,400 | 2,400,000 | 399 | 3,606 | +1 (2nd) | 400 |
| Wallet B | 6 | 6,000 | 0 | 6,000 | +1 (1st) | 1 |
| Wallet C | 3,600 | 3,600,000 | 599 | 2,406 | 0 | 599 |
| Total | 6,006 | 6,006,000 | 998 | 2 | 1,000 |
Figure summary: Weights 2,400, 6 and 3,600 (total 6,006) share a pool of 1,000 raw units. Floors are 399, 0 and 599, summing to 998. Remainders are 3,606, 6,000 and 2,406 (out of 6,006). The two leftover units go to Wallet B (largest remainder) and Wallet A (second). Final allocations: A 400, B 1, C 599, summing to exactly 1,000.
6,000 = 0 × 6,006 + 6,000
3,600,000 = 599 × 6,006 + 2,406
1,000 − (399 + 0 + 599) = 2 leftover units → B, then A
The tests assert Σ allocations == pool for every case, that an empty pool or zero total weight yields no allocations, and that the output is identical regardless of the order in which holders are supplied. Wallets whose final amount is zero are dropped from the allocation list.
Merkle root
Every finalized epoch carries an allocationRoot: the root of a Merkle tree whose leaves are the epoch’s allocations. The root commits to the full allocation table in 32 bytes. Anyone holding the table can recompute it, and any single allocation can be proven against it with a short proof.
- ·Leaf: SHA-256 of the UTF-8 string wallet:amountRaw, for example
<wallet>:400. - ·Tree: leaves are sorted; each parent is SHA-256 of its two children concatenated smaller-first (a sorted-pair tree, so proofs need no left or right flags). An odd node at the end of a level is carried up unchanged.
- ·Proof: the list of sibling hashes from leaf to root. verifyProof folds them with the same sorted-pair rule and compares the result to the root.
// src/server/pair/rewards.ts
export function leafHash(wallet: string, amountRaw: bigint): Buffer {
return sha256(Buffer.from(`${wallet}:${amountRaw.toString()}`, "utf8"));
}
/** Sorted-pair Merkle tree (order-independent siblings), hex root. */
export function merkleRoot(leaves: Buffer[]): string;
export function merkleProof(leaves: Buffer[], target: Buffer): string[];
export function verifyProof(leaf: Buffer, proof: string[], root: string): boolean;The tests build an epoch, compute its root and verify a proof for every allocation. The root is what an onchain Merkle distributor checks a claim against, which is how live claims pay out of the Pair Vault without trusting PairStreet’s database. See Accounting for how allocations reconcile against the vault.
Claim flow
A claim moves a holder’s allocated Pair Asset from the Pair Vault to the holder’s wallet. The flow checks eligibility before anything is written, and pays each allocation once.
- HolderWALLET
Connects a wallet on pairstreet.xyz.
- View claimableGET /api/portfolio/[wallet]
Sums reward_allocations with status claimable, per Pair Market, in units and USD.
- Eligibility checkELIGIBILITY ENGINE
Evaluated per Pair Instrument before anything is written. A restricted region returns “Pair unavailable in your region.”
- Claim transactionPOST /api/rewards/claim
Carries a wallet-signed intent (single use, five-minute window). Live claims prove (wallet, amountRaw) against allocationRoot.
- Pair Asset transferPAIR VAULT → WALLET
The Pair Asset moves from the market’s Pair Vault to the holder’s wallet.
- Accounting updatedLEDGER
reward_claims row, allocations set to claimed, holderRewardsClaimableUsd moves to holderRewardsClaimedUsd, vault balance reduced, PAIR_REWARD_CLAIMED emitted.
Figure summary: Holder, then view claimable (portfolio endpoint sums claimable allocations), then eligibility check per Pair Instrument, then claim transaction (POST /api/rewards/claim with a wallet-signed intent; live claims verify against the epoch's Merkle root), then Pair Asset transfer from the Pair Vault, then accounting updated (allocations marked claimed, claim recorded, Pair Market and vault read models adjusted, PAIR_REWARD_CLAIMED event).
The allocation update is conditional on status = claimable and runs in the same database transaction as the claim row. If the number of rows updated differs from the number expected, the whole transaction rolls back and the holder retries. A second claim for the same allocations finds nothing claimable. The tests cover all three: a restricted region is rejected, a valid claim pays the exact claimable amount, and a repeat claim pays nothing.
Hover a state for its meaning.
Figure summary: Four claim states. UNCLAIMED (allocation claimable) moves to CLAIMING when a claim is submitted (reward_claims pending). CLAIMING moves to CONFIRMED when the transfer lands (claim confirmed, allocation claimed), or to FAILED when the transaction fails. FAILED returns to UNCLAIMED: the allocation stays claimable and the holder can retry.
Eligibility is enforced at claim time as well as at launch. A holder in a region the Pair Instrument restricts keeps the token and can trade it; rewards from that Pair are claimable from an eligible region. The rules are described in Eligibility.
Status
Holder rewards are built in three layers, and each one has its own activation path.
| Component | Status | Detail |
|---|---|---|
| Reward engine | Implemented, tested | Three methodologies, exclusions, largest-remainder allocation and Merkle roots. Pure functions with tests for exact sums, determinism, exclusions, sniper resistance and proofs. |
| Epoch ledger | Implemented | The settlement engine writes reward_epochs and reward_allocations in the same transaction as the acquisition, and recomputeFromLedger rebuilds the read model from those rows. |
| Live claims | Activates per route | Onchain Merkle distributor paid from the Pair Vault. Activates with each Pair Instrument’s settlement route and vault custody. |
| Live holder indexing | In integration | A Helius webhook or Geyser stream of Pump program events builds the per-wallet balance timeline that feeds live reward weights. |