PYUSDX Specification
This page is the lookup reference for PYUSDX. For explanations of the mechanics, see the PYUSDX overview and its linked pages.
- Solidity: 0.8.34
- EVM target: Cancun
- License: BUSL-1.1
Token parameters
| Parameter | Value |
|---|---|
| Name | Configurable (set at init) |
| Symbol | Configurable (set at init) |
| Decimals | 6 (PYUSD standard) |
| Index precision | 1e12 (EXP_SCALED_ONE) |
| Fee rate max | 10,000 bps (100%) |
Roles
Consolidated across all contracts.
PYUSDX
| Role | Holder | Powers |
|---|---|---|
DEFAULT_ADMIN_ROLE | Admin | Grant/revoke all roles, set earner manager |
ISSUER_ROLE | IssuerGateway | mint, burn |
PAUSER_ROLE | Pauser | Pause/unpause all transfers |
FREEZE_MANAGER_ROLE | Freeze manager | Freeze/unfreeze accounts |
FORCED_TRANSFER_MANAGER_ROLE | Forced transfer manager | Seize funds from frozen accounts |
RATE_LIMIT_MANAGER_ROLE | Rate limit manager | Set per-issuer rate limits |
The earner manager is a single address, not a role.
IssuerGateway
| Role | Powers |
|---|---|
DEFAULT_ADMIN_ROLE | Set mint delay, set mint TTL |
OPERATOR_ROLE | Propose mints, burn tokens |
EXECUTOR_ROLE | Execute mints after delay |
Portal
| Role | Capabilities |
|---|---|
DEFAULT_ADMIN_ROLE | Grant/revoke all roles, setFallbackRecipient |
OPERATOR_ROLE | setDefaultBridgeAdapter, setSupportedBridgeAdapter, setPayloadGasLimit |
PAUSER_ROLE | pauseSend, unpauseSend, pauseReceive, unpauseReceive, pauseAll, unpauseAll |
BridgeAdapter
| Role | Capabilities |
|---|---|
DEFAULT_ADMIN_ROLE | Grant/revoke all roles |
OPERATOR_ROLE | setPeer, setBridgeChainId, setDelegate |
YieldToOne
| Role | Powers |
|---|---|
DEFAULT_ADMIN_ROLE | Grant/revoke roles |
YIELD_RECIPIENT_MANAGER_ROLE | Claim yield, set yield recipient |
VERSION_MANAGER_ROLE | Pin beacon version |
FREEZE_MANAGER_ROLE | Freeze/unfreeze accounts |
PAUSER_ROLE | Pause/unpause transfers |
MultiMint
MultiMint inherits YieldToOne roles and adds ASSET_CAP_MANAGER_ROLE, which sets asset caps and manages the replaceAsset caller whitelist.
Initialization parameters
PYUSDX
struct InitializeParams {
string name;
string symbol;
address admin;
address pauser;
address freezeManager;
address forcedTransferManager;
address earnerManager;
address rateLimitManager;
address issuer;
}
IssuerGateway
initialize(admin, operator, executor, mintDelay, mintTTL)
Portal
initialize(admin, pauser, operator, fallbackRecipient)
SwapFacility
initialize(admin, pauser)
// Constructor: (pyusdx, extensionFactory)
ExtensionFactory
initialize(admin, factoryManager)
// Constructor: (pyusdx, swapFacility, yieldToOneBeacon, multiMintBeacon)
ExtensionBeacon
initialize(admin, beaconManager, initialImplementation)
// Constructor: (pyusdx, swapFacility)
Rounding
Rounding is conservative for the protocol. When converting between principal and present amount, PYUSDX never credits more principal than the present amount supports, and never debits less than required. For an earner with current index I:
- Inbound (
_addEarningAmount):principal += floor(amount × 1e12 / I), round down. - Outbound (
_subtractEarningAmount):principal -= ceil(amount × 1e12 / I), round up.
Non-earner accounts have no earningPrincipal; their balance updates use the exact amount.
PYUSDX methods
| Method | Earner | Non-earner |
|---|---|---|
mint / distributeReward | round down principal | exact amount |
burn | round up principal | exact amount |
transfer | see transfers below | see transfers below |
claimFor | yield = floor(principal × index / 1e12) − balance, clamped at 0 | no-op |
PYUSDX transfers
| Sender → Recipient | Sender side → Recipient side |
|---|---|
| earner → earner | round up → round down |
| earner → non-earner | round up → amount |
| non-earner → earner | amount → round down |
| non-earner → non-earner | amount → amount |
In all four cases the protocol's net principal accounting is conservative: outgoing principal is over-debited; incoming principal is under-credited. There is no balance or solvency impact; only yield is affected. Moving value through an earning balance can reduce an account's unrealized and future yield by a bounded amount: up to 1 unit (1e-6 PYUSDX) per receive, up to 2 per wrap() + unwrap() round trip.
Forced transfer
A forced transfer runs on a frozen account, which is always non-earning at seizure (freeze materializes pending yield first). The sender side is therefore exact-amount; the recipient side is earner-aware.
| Recipient | Sender (frozen) → Recipient |
|---|---|
| earner | amount → round down |
| non-earner | amount → amount |
Extensions (YieldToOne / MultiMint)
Extension tokens are 1:1 wrappers around PYUSDX. The Extension contract itself is a registered earner on PYUSDX, so wrap/unwrap routes PYUSDX into or out of an earner balance via regular _transfer calls.
| Operation | Net PYUSDX-side rounding |
|---|---|
wrap | User → Extension. User-side: round up (if earner) or exact (non-earner); Extension-side: round down. |
unwrap | Extension → User. Extension-side: round up; user-side: round down (if earner) or exact (non-earner). |
replaceAsset (MultiMint) | PYUSDX deposit per the transfer matrix; alternative-asset withdrawal at exact amount (no PYUSDX rounding on the asset leg). |
Wrap and unwrap always move PYUSDX 1:1 — the amount of extension tokens minted or PYUSDX returned equals the amount supplied. The rounding above applies only to the Extension's earning principal (its future PYUSDX yield), never to the transferred amount or the 1:1 backing.
The extension token's own balanceOf mapping uses exact-amount accounting; extension tokens are not themselves yield-bearing.
Portal (cross-chain)
Portal moves PYUSDX between chains via burn-and-mint.
| Operation | Source-chain rounding | Destination-chain rounding |
|---|---|---|
sendToken | Sender → Portal per the transfer matrix; Portal (non-earner) burns at exact amount | — |
receiveMessage (destToken == PYUSDX) | — | Mint to recipient (earner: round down; non-earner: exact) |
receiveMessage (destToken is an Extension) | — | Mint to Portal at exact amount; SwapFacility wraps into the destination Extension |
For an earner sender → earner recipient, source-chain principal is debited rounded up while destination-chain principal is credited rounded down. The sub-unit difference is conservative (no over-issuance).
Invariants
totalSupplyequals the sum of all account balances. Unclaimed yield is not included intotalSupply; yield enters supply only when materialized byclaimYield,setAccountInfo, or_beforeFreeze.- An earner's
earningPrincipalis always ≤ theirbalance. - An earner's
lastIndexis monotonically increasing between the_setAccountInfothat enables earning and the_stopEarningForthat disables it._stopEarningForclearslastIndexto 0; a subsequent re-enable re-initializes it toEXP_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.
Additional guarantees: totalSupply == sum(balanceOf) at all times (balances are stored directly and always honored), and accruedYieldOf(account) ≥ 0 (yield is floored at zero).
Cross-chain message format
The token-transfer payload is 180 bytes, encoded with abi.encodePacked:
┌──────────────┬──────────────┬────────────┬───────────┬──────────────┬──────────┬─────────────┐
│ Dest ChainID │ Dest Peer │ Message ID │ Amount │ Dest Token │ Sender │ Recipient │
│ (4 bytes) │ (32 bytes) │ (32 bytes) │ (16 bytes)│ (32 bytes) │(32 bytes)│ (32 bytes) │
└──────────────┴──────────────┴────────────┴───────────┴──────────────┴──────────┴─────────────┘
On the destination chain, Dest ChainID and Dest Peer are read as targetChainId and targetBridgeAdapter and validated against currentChainId() and msg.sender respectively.
Errors
PYUSDX
| Error | Condition |
|---|---|
ZeroAdmin | Admin address is zero at init |
ZeroIssuer | Issuer address is zero at init |
ZeroEarnerManager | Earner manager is zero |
ZeroAccount | Account address is zero |
ZeroAmount | Amount is zero |
NotEarnerManager | Caller is not the earner manager |
InvalidAccountInfo | Disabling earning but non-zero fee/recipient |
FeeRateTooHigh(feeRate) | Fee rate > 10,000 bps |
EarnerRateTooHigh(earnerRate) | Earner rate > 10,000 bps |
ArrayLengthZero | Empty array input |
ArrayLengthMismatch | Array lengths don't match |
InsufficientBalance(account, balance, amount) | Burn exceeds balance |
IssuerGateway
| Error | Condition |
|---|---|
ZeroPYUSDX | PYUSDX address is zero |
ZeroMintAmount | Mint amount is zero |
ZeroMintRecipient | Mint recipient is zero |
ZeroBurnAmount | Burn amount is zero |
ZeroMintTTL | Mint TTL is zero |
InvalidMintProposal | Proposal does not exist |
PendingMintProposal(activeAt) | Mint not yet active |
ExpiredMintProposal(expiresAt) | Mint has expired |
NotMintProposalCreator | Caller didn't create the proposal |
ActiveMintProposal(activeAt) | Cannot cancel an active proposal |
RateLimiter
| Error | Condition |
|---|---|
ZeroRateLimitManager | Rate limit manager is zero |
RateLimitExceeded(requested, available) | Mint exceeds rate limit |
InvalidRateLimitRemoval | Disabling with non-zero params |
RateLimitNotConfigured(issuer) | _enforceRateLimit invoked for an issuer that has never been configured |
InvalidRateLimitConfig | Enabling a rate limit with capacity == 0 |
Portal
| Error | Condition |
|---|---|
InvalidTargetChain(targetChainId) | Decoded targetChainId does not match currentChainId() |
InvalidTargetBridgeAdapter(targetBridgeAdapter) | Decoded targetBridgeAdapter does not match msg.sender |
MessageAlreadyProcessed(messageId) | Inbound messageId has already been processed |
MultiMint
| Error | Condition |
|---|---|
AssetNotAllowed(asset) | replaceAsset called for an asset with cap == 0 |
AssetCapReached(asset) | wrap would push asset balance past cap |
InvalidAsset(asset) | Asset address is zero, or address mismatch |
CallerNotAllowed(caller) | replaceAsset caller is not on the enforced whitelist |
ArrayLengthMismatch | Batch setReplaceAssetWhitelistCaller arrays differ in length |
Events
| Contract | Events |
|---|---|
| PYUSDX | StartedEarning, StoppedEarning, AccountInfoUpdated, EarnerManagerSet, IndexUpdated, YieldClaimed, FeeClaimed, RewardDistributed |
| IssuerGateway | MintProposed, MintExecuted, MintCanceled, BurnExecuted, MintDelaySet, MintTTLSet |
| Portal | TokenSent, TokenReceived, RedirectedToFallbackRecipient, WrapFailed, send/receive pause events |
| SwapFacility | Swapped, SwappedIn, SwappedOut, SwappedInMultiMint, MultiMintAssetReplaced |
| YieldToOne | YieldClaimed, YieldRecipientSet. Pin/unpin emit ERC-1967 Upgraded / BeaconUpgraded |
| MultiMint | AssetWrapped, AssetReplaced, AssetCapSet, ReplaceAssetWhitelistCallerSet |
External dependencies
| Dependency | Usage |
|---|---|
| m-extensions | Core shared library |
ERC20ExtendedUpgradeable | Base ERC-20 with extensions |
Freezable | Account freezing |
ForcedTransferable | Fund seizure |
Pausable | Global pause |
ContinuousIndexingMath | Exponential index math |
IndexingMath | Principal/present conversion |
UIntMath | Safe uint conversions |
| OpenZeppelin Upgradeable | AccessControl, Initializable, Proxy |
Related
- PYUSDX overview: what PYUSDX is and its contracts
- Token & yield: the yield model and transfer mechanics
- PYUSDX platform deployments: deployed contract addresses
Portal V2
Technical deep dive into M0's unified cross-chain bridge and messaging system, featuring modular bridge adapters, token bridging, and protocol metadata propagation.
PYUSDX Cross-chain (Portal)
The Portal bridges PYUSDX and its extensions across EVM chains using a burn-and-mint mechanism and pluggable bridge adapters.