PYUSDX

PYUSDX Token & Yield

The PYUSDX token: parameters, the earner manager, the per-account claimable yield model, transfers, mint/burn, and rate limiting.

Token parameters

PYUSDX is an upgradeable, non-rebasing ERC-20 token. Its name and symbol are configurable and set at initialization. It uses 6 decimals (PYUSD standard). The yield index uses a precision of 1e12 (EXP_SCALED_ONE), and fee and earner rates are expressed in basis points with a maximum of 10,000 bps (100%).

Earner manager

The earner manager is a single address (not a role) that controls the yield system. It can:

  • setAccountInfo(): enable, disable, or modify an account's earner configuration.
  • distributeReward(): mint additional tokens to any account.

The earner manager also receives fees from yield claims. It is set at initialization and can be changed by the DEFAULT_ADMIN_ROLE.

Yield model

PYUSDX implements non-rebasing, per-account claimable yield via continuous compounding. Unlike a rebasing token, an account's stored balance does not grow on its own. Yield accrues in the background and is only added to the balance when it is claimed (or otherwise materialized).

How yield accrues

  1. The earner manager calls setAccountInfo(account, earnerRate, feeRate, claimRecipient) to enable earning.
  2. The account's earningPrincipal is derived from its current balance.
  3. Each account maintains a personal lastIndex that compounds continuously:currentIndex=lastIndex×exp(earnerRate×elapsedTimeSECONDS_PER_YEAR)currentIndex = lastIndex \times \exp\left(\frac{earnerRate \times elapsedTime}{SECONDS\_PER\_YEAR}\right)
    where earnerRate is the annual rate converted from basis points (via convertFromBasisPoints), elapsedTime is the seconds since the last update, and SECONDS_PER_YEAR is 31,536,000. Dividing by SECONDS_PER_YEAR expresses the elapsed time as a fraction of a year.
  4. The earningPrincipal stays fixed while the present value grows as the index grows:presentValue=principal×currentIndexEXP_SCALED_ONEpresentValue = \frac{principal \times currentIndex}{EXP\_SCALED\_ONE}
  5. Yield is the difference between the compounded value and the stored balance: presentValue - balance.

Each account carries its own index, so earners accrue independently.

Index math

  • ContinuousIndexingMath (from m-extensions) provides the exp() approximation.
  • IndexingMath handles principal ↔︎ present-amount conversions.
  • UIntMath.bound128() caps index growth to uint128.max.
  • Earner rates are in basis points (0–10,000), converted via convertFromBasisPoints.

Claiming yield

Anyone can call claimFor(account). It mints the account's accrued yield (yieldWithFee = presentValue - balance) as new tokens (increasing totalSupply), then splits it two ways:

  • Fee (yieldWithFee × feeRate / 10000) → the earner manager.
  • Net yield (yieldNetOfFee = yieldWithFee - fee) → the account's claimRecipient.

If the claimRecipient is the account itself, the yield simply stays in its balance, and no transfer occurs.

Special cases:

  • skipTransfer=true (used by freeze and pause-time setAccountInfo): yield materializes to the earner's own balance; routing and fee transfers are skipped.
  • Non-earners: claimFor is a no-op.

Whenever yield is materialized, claimFor refreshes the account's lastIndex and lastUpdateTimestamp to the current index on every path (including a zero-fee self-claim where no recipient transfer occurs), so subsequent yield always accrues from the moment of the claim.

Pause/freeze-time yield behavior

Yield can also materialize via the skipTransfer=true path (no fee or claim-recipient routing) in two scenarios outside a normal claimFor:

  • While paused: claimFor() reverts (it is whenNotPaused). setAccountInfo() remains callable as an emergency lever and materializes yield to the earner's own balance.
  • At freeze: _beforeFreeze() uses skipTransfer=true, so yield materializes before freezing. Once materialized, the yield is part of the account's regular balance and can subsequently be seized by a forced transfer.

Overflow safety

  • _addEarningAmount reverts when earningPrincipal would exceed uint112.
  • _getPrincipalAmountRoundedDown and _getPrincipalAmountRoundedUp use UIntMath.safe240() for the principal cast; inputs above type(uint240).max revert with InvalidUInt240 instead of truncating.

Transfer mechanics

_transfer(sender, recipient, amount) behaves as follows:

  1. Reverts if paused, or if the sender, recipient, or msg.sender is frozen.
  2. If the sender is an earner: subtracts from both balance and earningPrincipal (principal rounded up).
  3. If the sender is a non-earner: subtracts from balance only.
  4. If the recipient is an earner: adds to both balance and earningPrincipal (principal rounded down).
  5. If the recipient is a non-earner: adds to balance only.
  6. Earners have their index snapshotted (_updateIndexOf) on every balance change.
Because incoming principal is rounded down and outgoing principal is rounded up, moving value through an earning balance is slightly lossy in yield terms, never in balance or backing. A vault-style integrator that mints shares against balanceOf + accruedYieldOf should call claimFor(self) first, since a small incoming transfer can leave accruedYieldOf decreased by up to one unit. The exact per-operation rounding matrices are on the PYUSDX specification page.

Mint and burn

  • Mint (_mint): only ISSUER_ROLE. Rate-limited. Adds to balance (and earningPrincipal if the account is an earner). Increases totalSupply.
  • Burn (burn): only ISSUER_ROLE. The account must not be frozen. Subtracts from balance (and earningPrincipal if an earner). Decreases totalSupply. Burned tokens are sent to address(0).
  • Distribute reward (distributeReward): only the earner manager. Mints new tokens to an account (also rate-limited). Emits RewardDistributed.
The two ISSUER_ROLE holders only ever burn their own tokens: Portal burns address(this), and IssuerGateway burns the operator that called it (burn(msg.sender, amount)). No path lets an issuer burn a third party's balance. burn handles both account types: for a non-earner it reduces balance only, and for an earner it also reduces earningPrincipal, with the consumed principal rounded up (in the protocol's favor). As a result, any unclaimed yield at the moment of burn absorbs a sub-unit rounding loss in present value.

Rate limiting

Mints and reward distributions are governed by a per-issuer token-bucket rate limit:

  • capacity: the maximum burst size.
  • refillPerSecond: the refill rate.

Rate-limit configuration is mandatory: _enforceRateLimit reverts with RateLimitNotConfigured(issuer) when called for an issuer that has never been configured, and getRateLimitConfig / getRemainingAmount return (0, 0) and 0 respectively for unconfigured issuers. Enabling a rate limit with capacity == 0 reverts with InvalidRateLimitConfig to prevent a "configured but always reverts" bucket state. The limit is enforced on every mint and distributeReward call.

Key invariants

  1. totalSupply equals the sum of all account balances. Unclaimed yield is not included in totalSupply; yield enters supply only when it is materialized by claimYield, setAccountInfo, or _beforeFreeze (each mints yieldWithFee into the account's balance and increments totalSupply by the same amount).
  2. An earner's earningPrincipal is always ≤ their balance.
  3. An earner's lastIndex is monotonically increasing between the _setAccountInfo call that enables earning and the _stopEarningFor call that disables it. _stopEarningFor clears lastIndex to 0; a subsequent _setAccountInfo that re-enables earning re-initializes lastIndex to EXP_SCALED_ONE (1e12). currentIndexOf returns EXP_SCALED_ONE (1e12) for non-earners.
  4. Freezing an account stops its earning and claims all pending yield first.
  5. totalSupply can increase during pause only through setAccountInfo / _beforeFreeze yield materialization.
  6. No regular transfers can occur while paused.

The exact rounding matrices behind these invariants are on the PYUSDX specification page.

Copyright © M0 Foundation 2026