PYUSDX Token & Yield
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
- The earner manager calls
setAccountInfo(account, earnerRate, feeRate, claimRecipient)to enable earning. - The account's
earningPrincipalis derived from its current balance. - Each account maintains a personal
lastIndexthat compounds continuously:
whereearnerRateis the annual rate converted from basis points (viaconvertFromBasisPoints),elapsedTimeis the seconds since the last update, andSECONDS_PER_YEARis31,536,000. Dividing bySECONDS_PER_YEARexpresses the elapsed time as a fraction of a year. - The
earningPrincipalstays fixed while the present value grows as the index grows: - 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 theexp()approximation.IndexingMathhandles principal ↔︎ present-amount conversions.UIntMath.bound128()caps index growth touint128.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'sclaimRecipient.
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-timesetAccountInfo): yield materializes to the earner's own balance; routing and fee transfers are skipped.- Non-earners:
claimForis 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 iswhenNotPaused).setAccountInfo()remains callable as an emergency lever and materializes yield to the earner's own balance. - At freeze:
_beforeFreeze()usesskipTransfer=true, so yield materializes before freezing. Once materialized, the yield is part of the account's regularbalanceand can subsequently be seized by a forced transfer.
Overflow safety
_addEarningAmountreverts whenearningPrincipalwould exceeduint112._getPrincipalAmountRoundedDownand_getPrincipalAmountRoundedUpuseUIntMath.safe240()for the principal cast; inputs abovetype(uint240).maxrevert withInvalidUInt240instead of truncating.
Transfer mechanics
_transfer(sender, recipient, amount) behaves as follows:
- Reverts if paused, or if the sender, recipient, or
msg.senderis frozen. - If the sender is an earner: subtracts from both
balanceandearningPrincipal(principal rounded up). - If the sender is a non-earner: subtracts from
balanceonly. - If the recipient is an earner: adds to both
balanceandearningPrincipal(principal rounded down). - If the recipient is a non-earner: adds to
balanceonly. - Earners have their index snapshotted (
_updateIndexOf) on every balance change.
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): onlyISSUER_ROLE. Rate-limited. Adds tobalance(andearningPrincipalif the account is an earner). IncreasestotalSupply. - Burn (
burn): onlyISSUER_ROLE. The account must not be frozen. Subtracts frombalance(andearningPrincipalif an earner). DecreasestotalSupply. Burned tokens are sent toaddress(0). - Distribute reward (
distributeReward): only the earner manager. Mints new tokens to an account (also rate-limited). EmitsRewardDistributed.
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
totalSupplyequals the sum of all account balances. Unclaimed yield is not included intotalSupply; yield enters supply only when it is materialized byclaimYield,setAccountInfo, or_beforeFreeze(each mintsyieldWithFeeinto the account's balance and incrementstotalSupplyby the same amount).- An earner's
earningPrincipalis always ≤ theirbalance. - An earner's
lastIndexis monotonically increasing between the_setAccountInfocall that enables earning and the_stopEarningForcall that disables it._stopEarningForclearslastIndexto 0; a subsequent_setAccountInfothat re-enables earning re-initializeslastIndextoEXP_SCALED_ONE(1e12).currentIndexOfreturnsEXP_SCALED_ONE(1e12) for non-earners. - Freezing an account stops its earning and claims all pending yield first.
totalSupplycan increase during pause only throughsetAccountInfo/_beforeFreezeyield materialization.- No regular transfers can occur while paused.
The exact rounding matrices behind these invariants are on the PYUSDX specification page.