TECHNICAL SPECIFICATIONS

PYUSDX Specification

Low-level reference for PYUSDX: token parameters, roles, initialization parameters, rounding matrices, invariants, the cross-chain message format, errors, and events.

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

ParameterValue
NameConfigurable (set at init)
SymbolConfigurable (set at init)
Decimals6 (PYUSD standard)
Index precision1e12 (EXP_SCALED_ONE)
Fee rate max10,000 bps (100%)

Roles

Consolidated across all contracts.

PYUSDX

RoleHolderPowers
DEFAULT_ADMIN_ROLEAdminGrant/revoke all roles, set earner manager
ISSUER_ROLEIssuerGatewaymint, burn
PAUSER_ROLEPauserPause/unpause all transfers
FREEZE_MANAGER_ROLEFreeze managerFreeze/unfreeze accounts
FORCED_TRANSFER_MANAGER_ROLEForced transfer managerSeize funds from frozen accounts
RATE_LIMIT_MANAGER_ROLERate limit managerSet per-issuer rate limits

The earner manager is a single address, not a role.

IssuerGateway

RolePowers
DEFAULT_ADMIN_ROLESet mint delay, set mint TTL
OPERATOR_ROLEPropose mints, burn tokens
EXECUTOR_ROLEExecute mints after delay

Portal

RoleCapabilities
DEFAULT_ADMIN_ROLEGrant/revoke all roles, setFallbackRecipient
OPERATOR_ROLEsetDefaultBridgeAdapter, setSupportedBridgeAdapter, setPayloadGasLimit
PAUSER_ROLEpauseSend, unpauseSend, pauseReceive, unpauseReceive, pauseAll, unpauseAll

BridgeAdapter

RoleCapabilities
DEFAULT_ADMIN_ROLEGrant/revoke all roles
OPERATOR_ROLEsetPeer, setBridgeChainId, setDelegate

YieldToOne

RolePowers
DEFAULT_ADMIN_ROLEGrant/revoke roles
YIELD_RECIPIENT_MANAGER_ROLEClaim yield, set yield recipient
VERSION_MANAGER_ROLEPin beacon version
FREEZE_MANAGER_ROLEFreeze/unfreeze accounts
PAUSER_ROLEPause/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

MethodEarnerNon-earner
mint / distributeRewardround down principalexact amount
burnround up principalexact amount
transfersee transfers belowsee transfers below
claimForyield = floor(principal × index / 1e12) − balance, clamped at 0no-op

PYUSDX transfers

Sender → RecipientSender side → Recipient side
earner → earnerround up → round down
earner → non-earnerround up → amount
non-earner → earneramount → round down
non-earner → non-earneramount → 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.

RecipientSender (frozen) → Recipient
earneramount → round down
non-earneramount → 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.

OperationNet PYUSDX-side rounding
wrapUser → Extension. User-side: round up (if earner) or exact (non-earner); Extension-side: round down.
unwrapExtension → 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.

OperationSource-chain roundingDestination-chain rounding
sendTokenSender → 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

  1. totalSupply equals the sum of all account balances. Unclaimed yield is not included in totalSupply; yield enters supply only when materialized by claimYield, setAccountInfo, or _beforeFreeze.
  2. An earner's earningPrincipal is always ≤ their balance.
  3. An earner's lastIndex is monotonically increasing between the _setAccountInfo that enables earning and the _stopEarningFor that disables it. _stopEarningFor clears lastIndex to 0; a subsequent re-enable re-initializes it 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.

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

ErrorCondition
ZeroAdminAdmin address is zero at init
ZeroIssuerIssuer address is zero at init
ZeroEarnerManagerEarner manager is zero
ZeroAccountAccount address is zero
ZeroAmountAmount is zero
NotEarnerManagerCaller is not the earner manager
InvalidAccountInfoDisabling earning but non-zero fee/recipient
FeeRateTooHigh(feeRate)Fee rate > 10,000 bps
EarnerRateTooHigh(earnerRate)Earner rate > 10,000 bps
ArrayLengthZeroEmpty array input
ArrayLengthMismatchArray lengths don't match
InsufficientBalance(account, balance, amount)Burn exceeds balance

IssuerGateway

ErrorCondition
ZeroPYUSDXPYUSDX address is zero
ZeroMintAmountMint amount is zero
ZeroMintRecipientMint recipient is zero
ZeroBurnAmountBurn amount is zero
ZeroMintTTLMint TTL is zero
InvalidMintProposalProposal does not exist
PendingMintProposal(activeAt)Mint not yet active
ExpiredMintProposal(expiresAt)Mint has expired
NotMintProposalCreatorCaller didn't create the proposal
ActiveMintProposal(activeAt)Cannot cancel an active proposal

RateLimiter

ErrorCondition
ZeroRateLimitManagerRate limit manager is zero
RateLimitExceeded(requested, available)Mint exceeds rate limit
InvalidRateLimitRemovalDisabling with non-zero params
RateLimitNotConfigured(issuer)_enforceRateLimit invoked for an issuer that has never been configured
InvalidRateLimitConfigEnabling a rate limit with capacity == 0

Portal

ErrorCondition
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

ErrorCondition
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
ArrayLengthMismatchBatch setReplaceAssetWhitelistCaller arrays differ in length

Events

ContractEvents
PYUSDXStartedEarning, StoppedEarning, AccountInfoUpdated, EarnerManagerSet, IndexUpdated, YieldClaimed, FeeClaimed, RewardDistributed
IssuerGatewayMintProposed, MintExecuted, MintCanceled, BurnExecuted, MintDelaySet, MintTTLSet
PortalTokenSent, TokenReceived, RedirectedToFallbackRecipient, WrapFailed, send/receive pause events
SwapFacilitySwapped, SwappedIn, SwappedOut, SwappedInMultiMint, MultiMintAssetReplaced
YieldToOneYieldClaimed, YieldRecipientSet. Pin/unpin emit ERC-1967 Upgraded / BeaconUpgraded
MultiMintAssetWrapped, AssetReplaced, AssetCapSet, ReplaceAssetWhitelistCallerSet

External dependencies

DependencyUsage
m-extensionsCore shared library
ERC20ExtendedUpgradeableBase ERC-20 with extensions
FreezableAccount freezing
ForcedTransferableFund seizure
PausableGlobal pause
ContinuousIndexingMathExponential index math
IndexingMathPrincipal/present conversion
UIntMathSafe uint conversions
OpenZeppelin UpgradeableAccessControl, Initializable, Proxy

Copyright © M0 Foundation 2026