Skip to main content

Why Credits Exist

Without credits, points distribution would be trivially gameable. Consider the naive alternative: At epoch end, distribute points proportional to EPT balance. The attack: Deposit 10,000 USDC one second before finalization. Claim the same points share as someone who deposited 10,000 USDC at epoch start. Zero effective cost (deposit, claim, redeem ST), infinite points per dollar. Credits prevent this by tracking balance x time x activity. Your share of points is proportional to your cumulative contribution over the epoch, not your balance at a single snapshot.

The Credit Formula

Continuous Form

For a single user holding a constant balance over a time interval with a constant creditRate: credits=balance×creditRate×Δt\text{credits} = \text{balance} \times \text{creditRate} \times \Delta t In the general case, where both balance and creditRate change over time, credits are the integral: creditsi=0Tbalancei(t)×creditRate(t)dt\text{credits}_i = \int_0^T \text{balance}_i(t) \times \text{creditRate}(t) \, dt Your credits are the area under the curve of (your EPT balance) x (the credit rate) over time. Holding more EPT, for longer, at higher rates = more credits. The bars represent Alice’s balance × creditRate over time. She held 100 EPT at creditRate=1 for weeks 1—4, then transferred 50 EPT, holding 50 for weeks 5—8. Her total credits are the sum of these values over time.

Points Distribution

At finalization, the Final Points Oracle reports totalPoints earned during the epoch. Your share: pointsPerCredit=totalPointstotalCredits\text{pointsPerCredit} = \frac{\text{totalPoints}}{\text{totalCredits}} yourPoints=yourCredits×pointsPerCreditalreadyClaimed\text{yourPoints} = \text{yourCredits} \times \text{pointsPerCredit} - \text{alreadyClaimed} Where totalCredits is the sum of all individual credits across all holders, and alreadyClaimed tracks incremental claims. This is a pro-rata system: you receive points in proportion to your credit share of the total.

The Constant Rate Proof

An important mathematical property: if creditRate is constant throughout the entire epoch, its exact value doesn’t matter. Proof: Let creditRate = k (constant) for all time. For user i holding b_i EPT for t_i seconds: creditsi=k×bi×ti\text{credits}_i = k \times b_i \times t_i totalCredits=k×j(bj×tj)\text{totalCredits} = k \times \sum_j (b_j \times t_j) pointsi=totalPoints×creditsitotalCredits=totalPoints×k×bi×tik×j(bj×tj)=totalPoints×bi×tij(bj×tj)\text{points}_i = \text{totalPoints} \times \frac{\text{credits}_i}{\text{totalCredits}} = \text{totalPoints} \times \frac{k \times b_i \times t_i}{k \times \sum_j(b_j \times t_j)} = \text{totalPoints} \times \frac{b_i \times t_i}{\sum_j(b_j \times t_j)} The constant k cancels in the ratio. Any constant rate, whether 1, 100, or 1,000,000, produces identical point distribution.
What this means practically:
  • If strategy activity is roughly uniform across the epoch, the creditRate value is irrelevant
  • Constant rate = 1 is a valid degenerate case: it reduces to pure time-weighted balance
  • Variable rate only changes outcomes when activity varies AND different users hold EPT during different activity periods
When does this matter? In the oracle-down fallback. If the Credits Oracle stops publishing, credits accrue at the last known rate. If that rate stays constant for the rest of the epoch, the exact constant chosen doesn’t affect distribution. The system degrades gracefully.

Variable Rate: Why It Exists

The constant rate proof shows that constant rates are mathematically sufficient. So why use a variable rate?

The Fairness Argument

A funding arb strategy opens $500K of OI at epoch start, then the market shifts and the strategy scales down to $50K OI mid-epoch. Exchange points are earned proportional to OI. Most of the epoch’s points came from the first half. Constant rate (rate = 1):
  • Alice (100 EPT, full epoch): 100 x 1 x 604,800 = 60.48M credits
  • Bob (100 EPT, second half only): 100 x 1 x 259,200 = 25.92M credits
Variable rate (rate proportional to OI):
  • Alice Phase 1 (first half, rate=500): 100 x 500 x 345,600 = 17.28B
  • Alice Phase 2 (second half, rate=50): 100 x 50 x 259,200 = 1.296B
  • Alice total: 18.576B
  • Bob Phase 2 only (second half, rate=50): 100 x 50 x 259,200 = 1.296B
Constant RateVariable Rate
Alice’s share70.0%93.5%
Bob’s share30.0%6.5%
With constant rate, Bob gets 30% of points even though he held EPT only during the low-activity period (when the strategy was generating very few points). With variable rate, Alice correctly gets ~93.5% because she held EPT during the high-OI period when nearly all the points were being generated.

The Pricing Argument

Variable creditRate makes EPT valuation more informationally efficient. When creditRate is high (strategy is active), EPT accrues credits faster, making it more valuable to hold right now. This information flows into flash loop economics: during high-activity periods, the implied cost of EPT through the flash loop better reflects its true value. With constant rate, EPT’s only value driver is time remaining. The market can’t distinguish “strategy has high OI” from “strategy is idle.”
Time-weighted average balance is equivalent to the constant-rate credit system (creditRate = 1). It works, but it can’t distinguish high-activity from low-activity periods. Variable creditRate adds this dimension, making the distribution fairer when strategy activity fluctuates.
In theory, yes. If you knew creditRate was about to increase, depositing beforehand would earn you more credits during the high-rate period. In practice, creditRate reflects real-time strategy activity (OI, volume), which is partially observable on exchange dashboards. This is public information, and flash loop demand should already reflect expected future rates. You’re competing against the market’s collective information.

On-Chain Implementation

State Variables

The contract avoids computing the integral explicitly. Instead, it uses a globalCreditIndex pattern, a single accumulator that tracks cumulative credit-seconds per EPT unit:
Contract state (global):
  creditRate         : u256    // Credits per EPT per second (set by oracle)
  globalCreditIndex  : u256    // Cumulative credit-seconds per EPT unit
  lastCheckpointTime : u64     // Timestamp of last global checkpoint
  totalCredits       : u256    // Running sum of all settled credits

Per-user state:
  userCreditIndex[addr] : u256 // User's last-seen globalCreditIndex
  userCredits[addr]     : u256 // User's accumulated settled credits
globalCreditIndex increases monotonically. The difference between two snapshots of globalCreditIndex tells you how many credit-seconds per EPT accrued in that interval. Multiply by the user’s balance, and that’s how many credits they earned.

Algorithm: Global Checkpoint

Called before any state-changing operation. Advances the global index by the time elapsed x current rate:
fn globalCheckpoint():
    dt = block_timestamp - lastCheckpointTime
    if dt > 0:
        globalCreditIndex += creditRate × dt
        lastCheckpointTime = block_timestamp
What this does: If 300 seconds have passed since the last checkpoint and creditRate = 10, the global index increases by 3,000. This means each EPT unit earned 3,000 credit-seconds during that interval.

Algorithm: User Checkpoint

Called lazily on every transfer, mint, or claim. Settles a user’s accrued credits:
fn userCheckpoint(addr):
    globalCheckpoint()                              // ensure index is current
    delta = globalCreditIndex - userCreditIndex[addr]  // credits per EPT since last seen
    newCredits = balance[addr] × delta              // total new credits for this user
    userCredits[addr] += newCredits                 // accumulate
    totalCredits += newCredits                      // track global total
    userCreditIndex[addr] = globalCreditIndex       // mark as seen
Why this is gas-efficient: No iteration over all holders. Each user’s credits are settled only when they interact with the contract. Between interactions, credits are implicitly accruing via the global index.
Gas efficiency. Starknet charges for computation. Settling credits on every block for every holder would be prohibitively expensive. Lazy settlement achieves mathematical equivalence: the result is identical to continuous tracking, but computation only happens when needed (on transfer, claim, or rate change).

Algorithm: Credit Rate Update

When the Credits Oracle publishes a new rate:
fn updateCreditRate(newRate):
    globalCheckpoint()      // settle all accrued credits at the OLD rate
    creditRate = newRate    // future credits accrue at the NEW rate
Critical detail: The global checkpoint must run before the rate changes. This ensures credits for the old period are locked in at the old rate. Without this, switching from rate 10 to rate 20 would retroactively apply rate 20 to the old period for any user who hasn’t checkpointed yet.

Algorithm: Transfer

Every EPT transfer checkpoints both parties:
fn transfer(from, to, amount):
    userCheckpoint(from)    // lock sender's credits so far
    userCheckpoint(to)      // lock receiver's credits so far
    balance[from] -= amount
    balance[to] += amount
After this, the sender’s future credit accrual is based on their reduced balance, and the receiver’s future accrual is based on their increased balance. No credits are lost or created. They’re settled at the exact moment of transfer.

Algorithm: Mint (On Deposit)

fn mint(to, amount):
    userCheckpoint(to)      // settle any existing credits
    balance[to] += amount
    totalSupply += amount
The new EPT starts accruing credits from the mint timestamp. It earns zero credits for time before it existed.

Algorithm: Claim Points (Post-Finalization)

fn claimPoints(addr):
    require(finalized == true)
    userCheckpoint(addr)                                        // final settlement
    require(totalCredits > 0, "no credits accrued")             // guard: zero-activity epoch
    pointsPerCredit = totalPoints / totalCredits
    gross = userCredits[addr] × pointsPerCredit - alreadyClaimed[addr]
    net = gross - redeemFee(gross)
    alreadyClaimed[addr] += gross
    PointsToken.mint(addr, net)
Yes. A read-only function can compute (globalCreditIndex + creditRate x timeSinceCheckpoint - userCreditIndex) x balance + userCredits. This is a view call, no gas cost, and shows your current credit balance at any time.

Worked Examples

All examples use a 604,800-second epoch (7 days) for simplicity. The math works identically for longer epochs (8—12 weeks). Only the numbers scale.

Single Holder, Constant Rate

The simplest possible case. One user, no transfers, constant creditRate.
Setup:
  Epoch duration: 604,800 seconds
  Alice deposits $100 at t=0 → receives 100 EPT
  creditRate = 1 (constant)
  totalPoints at finalization = 1,000

Credits:
  Alice: 100 × 1 × 604,800 = 60,480,000

Points:
  pointsPerCredit = 1,000 / 60,480,000
  Alice: 60,480,000 / 60,480,000 × 1,000 = 1,000 points

Alice is the only holder → she gets all 1,000 points. As expected.

The globalCreditIndex Walkthrough

The globalCreditIndex is the most important state variable. It’s how the contract avoids iterating over all holders. Here’s a step-by-step trace through Example 4:
t=0: Epoch starts
  globalCreditIndex = 0
  lastCheckpointTime = 0
  creditRate = 10

t=0: Alice deposits 100 EPT
  → mint(Alice, 100)
  → userCheckpoint(Alice):
      globalCheckpoint(): dt=0, no change
      delta = 0 - 0 = 0
      newCredits = 100 × 0 = 0
      userCredits[Alice] = 0
      userCreditIndex[Alice] = 0
  → balance[Alice] = 100

t=259,200: Alice transfers 50 EPT to Bob
  → transfer(Alice, Bob, 50)
  → userCheckpoint(Alice):
      globalCheckpoint():
        dt = 259,200 - 0 = 259,200
        globalCreditIndex = 0 + 10 × 259,200 = 2,592,000
        lastCheckpointTime = 259,200
      delta = 2,592,000 - 0 = 2,592,000
      newCredits = 100 × 2,592,000 = 259,200,000
      userCredits[Alice] = 259,200,000
      totalCredits = 259,200,000
      userCreditIndex[Alice] = 2,592,000

  → userCheckpoint(Bob):
      globalCheckpoint(): dt=0 (just ran)
      delta = 2,592,000 - 0 = 2,592,000
      newCredits = 0 × 2,592,000 = 0  (Bob has no balance yet!)
      userCredits[Bob] = 0
      userCreditIndex[Bob] = 2,592,000

  → balance[Alice] = 50, balance[Bob] = 50

t=345,600: Oracle updates creditRate to 20
  → updateCreditRate(20):
      globalCheckpoint():
        dt = 345,600 - 259,200 = 86,400
        globalCreditIndex = 2,592,000 + 10 × 86,400 = 3,456,000
        lastCheckpointTime = 345,600
      creditRate = 20

t=604,800: Epoch ends, finalization triggered
  → claimPoints(Alice):
      userCheckpoint(Alice):
        globalCheckpoint():
          dt = 604,800 - 345,600 = 259,200
          globalCreditIndex = 3,456,000 + 20 × 259,200 = 8,640,000
          lastCheckpointTime = 604,800
        delta = 8,640,000 - 2,592,000 = 6,048,000
        newCredits = 50 × 6,048,000 = 302,400,000
        userCredits[Alice] = 259,200,000 + 302,400,000 = 561,600,000
        totalCredits += 302,400,000
        userCreditIndex[Alice] = 8,640,000

  → claimPoints(Bob):
      userCheckpoint(Bob):
        globalCheckpoint(): dt=0 (just ran)
        delta = 8,640,000 - 2,592,000 = 6,048,000
        newCredits = 50 × 6,048,000 = 302,400,000
        userCredits[Bob] = 302,400,000
        totalCredits += 302,400,000
        userCreditIndex[Bob] = 8,640,000

Final totalCredits: 259,200,000 + 302,400,000 + 302,400,000 = 864,000,000

Points:
  Alice: 561,600,000 / 864,000,000 × 1,000 = 650.0
  Bob:   302,400,000 / 864,000,000 × 1,000 = 350.0
The index trace matches Example 4 exactly. The key property: Bob’s userCreditIndex was set to 2,592,000 at the transfer (even though he had zero balance), so when he claims at globalCreditIndex = 8,640,000, the delta correctly captures only the period he actually held EPT.

Edge Cases and Invariants

Edge Case: User Checkpoints Without Balance Change

If a user calls claimPoints() or any other checkpoint-triggering function, their credits are settled even if no transfer occurs. This is by design. It ensures userCredits is always current before computing point claims.

Edge Case: Zero-Balance User

If a user transfers all their EPT and later receives some back, their userCreditIndex is updated at each interaction. When they receive EPT again, delta x 0 = 0 credits for the gap period. Correct. They shouldn’t earn credits while holding zero EPT.

Edge Case: Oracle Goes Down

If the Credits Oracle stops publishing, creditRate stays at its last known value. Credits accrue at a constant rate. Per the constant rate proof, if the rate remains constant for the rest of the epoch, the exact constant doesn’t affect the final distribution. The system degrades to time-weighted balance.

Edge Case: First Depositor

When the first user mints EPT, globalCreditIndex = 0 and userCreditIndex = 0. No credits are earned retroactively. The system starts fresh.

Invariant: Credit Conservation

Credits are never created or destroyed by transfers. When Alice sends EPT to Bob:
  1. Alice’s accrued credits are settled (added to totalCredits)
  2. Bob’s accrued credits are settled (added to totalCredits)
  3. Only balances change, not the credit index or settlement logic
This means totalCredits at finalization correctly equals the sum of all individual credits, regardless of how many transfers occurred.

Invariant: No Double-Counting

The alreadyClaimed tracker in claimPoints() prevents double-counting. Even if a user calls claimPoints() multiple times, gross - alreadyClaimed ensures they only receive the incremental amount.
No credits accrue. If creditRate is zero for the entire epoch, totalCredits = 0 and the pointsPerCredit calculation would divide by zero. The oracle only sets creditRate = 0 if the strategy has zero activity, in which case totalPoints should also be zero. The contract guards against this edge case with a zero-totalCredits check.

Comparison: Pendle vs ArcX Credit Systems

Pendle (YT)ArcX (EPT)
What accruesYield (SY tokens)Credits (abstract units converted to points at finalization)
Accrual mechanismYield streams continuously in SY tokens. Real tokens drip to YT holdersCredits are abstract accounting. No tokens move until finalization
Rate sourceSY exchange rate (on-chain, autonomous)creditRate from oracle (off-chain, ArcX-operated)
Variable rate?Implicitly, SY rate changes with underlying protocol APYExplicitly, oracle pushes rate updates
Maturity valueYT → $0 (all yield has streamed)EPT → redeemable for PointsTokens (retains value)
Checkpoint patternInterest index (similar to Aave/Compound)globalCreditIndex (same mathematical pattern)
ArcX’s credit system is architecturally similar to the interest accrual patterns used across DeFi (Aave’s liquidity index, Compound’s exchange rate). Credits don’t represent real yield, though. They represent a claim on points that get distributed later, not a continuous stream of real tokens.