PYUSDX Extensions
Extensions are branded ERC-20 tokens backed 1:1 by PYUSDX. They inherit freeze/pause controls and route the underlying PYUSDX yield to designated recipients. Two extension types exist: YieldToOne and MultiMint.
SwapFacility
The SwapFacility enables 1:1 swaps between PYUSDX and approved extension tokens. It is the exclusive gateway for wrapping and unwrapping.
Swap paths
| From | To | Mechanism |
|---|---|---|
| PYUSDX | Extension | swapIn: transfer PYUSDX in, Extension.wrap() mints extension tokens |
| Extension | PYUSDX | swapOut: Extension.unwrap() burns extension tokens, transfer PYUSDX out |
| Extension A | Extension B | swapExtensions: unwrap A → wrap B |
| Asset (ERC-20) | MultiMint | swapInMultiMint: deposit asset, MultiMint.wrap(asset) mints extension tokens |
| Asset + PYUSDX | MultiMint | replaceAsset: deposit PYUSDX/extension, extract asset from MultiMint reserves |
Key constraints
- Self-swaps are forbidden (
tokenIn == tokenOutreverts). - Only approved extensions (registered in
ExtensionFactory) can be swapped. - Fee-on-transfer tokens are not supported: the actual amount received must equal the specified amount.
swapInMultiMintof an asset with more than 6 decimals:MultiMintpulls only the largest non-dust multiple, andSwapFacilityrefunds any leftover dust to the caller; the emittedSwappedInMultiMintamount reflects the net deposited.- Permit-based approvals are supported for gasless swaps.
SwapFacility uses the same ReentrancyLock as Portal, which enables msgSender() resolution through the lock. This is critical because extensions call back to SwapFacility.msgSender() to identify the original caller.
Extension system
The extension system deploys extensions as beacon proxies. It consists of:
- ExtensionBeacon: a single-type, ERC-1967-compliant versioned implementation registry (one beacon instance per extension type).
- ExtensionFactory: deploys and registers extensions via CREATE3.
- ExtensionBeaconProxy: a per-extension proxy supporting beacon mode (follow-latest) and pinned mode.
- Extension (abstract): the base contract with wrap/unwrap/pause/freeze.
The ExtensionType enum distinguishes the types: NONE (0, not registered), YIELD_TO_ONE (1), and MULTI_MINT (2).
Deployment flow
A deployer deploys extensions via the ExtensionFactory by calling either deployYieldToOne(extensionName, params) or deployMultiMint(extensionName, params). The factory then:
- Resolves the latest implementation from the extension type's
ExtensionBeacon. - Encodes the extension's initializer calldata.
- Deploys the
ExtensionBeaconProxyvia CREATE3 (deterministic address). - Registers the proxy in the
ExtensionFactory's extension type mapping. - Emits
ExtensionDeployed().
ExtensionFactory.registerExtension(extension, type) (gated by FACTORY_MANAGER_ROLE) registers or unregisters an extension manually. A registered extension must first be unregistered (type set to NONE) before being re-registered under a different type. Re-registering a still-registered extension reverts with ExtensionAlreadyRegistered.
The CREATE3 salt is the abi.encodePacked of bytes20(extensionFactoryAddress) + bytes1(0) + bytes11(keccak256(deployerAddress, extensionName)). A deployer can only deploy one extension per network using the same extensionName, which lets the deployer deterministically deploy their extension at the same address across several EVM networks.
extensionName parameter is used only to compute the salt and deploy the ExtensionBeaconProxy at a deterministic address; it is not the ERC-20 name of the extension. The name parameter sets the extension's token name.Beacon upgrade and pinning
Each extension type has its own ExtensionBeacon instance (a YieldToOne beacon and a MultiMint beacon), each a single-type ERC-1967 implementation registry.
- BeaconManager calls
registerImplementation(implementation)on a beacon. The version auto-increments (starting at 1) and the beacon emits the ERC-1967Upgraded(implementation)event. - All proxies served by that beacon upgrade atomically when its
latestVersionchanges, unless an individual proxy is pinned.
An ExtensionBeaconProxy operates in one of two ERC-1967 modes:
- Beacon mode (default): the
eip1967.proxy.beaconslot holds the beacon address; the proxy resolves the beacon's latest implementation on every call and auto-upgrades. - Pinned mode: the
eip1967.proxy.implementationslot holds a frozen implementation address; the proxy ignores the beacon and never auto-upgrades.
Switching modes mutates the eip1967 slots so external ERC-1967 readers (block explorers, tooling) correctly detect a beacon proxy versus a direct proxy. The origin beacon address is additionally stored in a dedicated slot, written once at construction and never modified, so a pinned proxy can always be un-pinned back to its beacon.
The extension's VERSION_MANAGER_ROLE controls pinning:
pinVersion(version): pins to a specific beacon version.versionmust be> 0(reverts withZeroVersion). Resolves the implementation from the origin beacon, switches the proxy to pinned mode, and emitsUpgraded.unpinVersion(): restores beacon mode (follow-latest) and emitsBeaconUpgraded. Reverts withNotPinnedif the proxy is not currently pinned.
Views: isPinned(), pinnedImplementation(), originBeacon().
Extension base contract
The abstract Extension contract provides:
wrap(recipient, amount): called bySwapFacilityonly. Pulls PYUSDX, mints extension tokens.unwrap(amount): called bySwapFacilityonly. Burns extension tokens, returns PYUSDX.- Freeze/pause hooks:
_beforeTransfer,_beforeWrap,_beforeUnwrap,_beforeApprove. - Immutable references:
pyusdx,swapFacility.
YieldToOne
YieldToOne is a 1:1 PYUSDX wrapper where all yield accrues to a single yieldRecipient. Holders get a stable, non-rebasing token while the yield recipient captures the PYUSDX yield earned on the extension's aggregate balance.
Yield flow
- PYUSDX tokens are wrapped into
YieldToOneviaSwapFacility. - The
YieldToOnecontract itself is registered as an earner on PYUSDX (by the earner manager). - PYUSDX yield accrues on the contract's PYUSDX balance.
claimYield()(called byYIELD_RECIPIENT_MANAGER_ROLE):- Realizes pending PYUSDX yield via
IPYUSDX.claimFor(address(this)). - Computes
excess = PYUSDX_balance - totalSupply(which also accounts for direct PYUSDX donations). - Mints
excessextension tokens toyieldRecipient.
- Realizes pending PYUSDX yield via
Incident response
claimYieldsucceeds even while paused (tokens are minted but cannot transfer until unpause).setYieldRecipientskips the claim if the outgoing recipient is frozen, avoiding a revert that would block rotation.- Yield on PYUSDX can be redirected via
setAccountInfoat the PYUSDX level.
Roles
| 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 extends YieldToOne with multi-collateral backing. Users can mint extension tokens by depositing PYUSDX or approved alternative stablecoins. Unwrapping always returns PYUSDX; alternative assets can only be extracted via replaceAsset.
Asset management
setAssetCap(asset, cap): sets the maximum deposit for an asset.cap > 0enables the asset for bothwrapandreplaceAsset. Settingcap == 0is the canonical disable lever and blocks bothwrapandreplaceAssetfor the asset; existing balances remain unwrappable to PYUSDX (held reserves are not stranded).wrap(asset, recipient, amount): depositsassettokens and mints extension tokens, respecting the cap.replaceAsset(asset, recipient, amount): deposits PYUSDX and extractsassetfrom reserves. Reverts withAssetNotAllowed(asset)if the asset hascap == 0, and withCallerNotAllowed(caller)if the replaceAsset whitelist is enforced and the caller is not on it.- Unwrap always returns PYUSDX (inherited from
YieldToOne).
Decimal conversion
Extension tokens use 6 decimals (matching PYUSDX). When wrapping assets with different decimals, _fromAssetToExtensionAmount and _fromExtensionToAssetAmount convert between the two.
For assets with more than 6 decimals, the deposit on wrap is rounded down to the largest multiple of the decimal factor that maps cleanly to extension units. The truncating-division dust is left with the depositor (and refunded by SwapFacility on swapInMultiMint), keeping assetBalance == totalAssets × factor exact across repeated wraps.
Backing model
PYUSDX_backing = totalSupply - totalAssetsis the PYUSDX actually held.excess = PYUSDX_balance - PYUSDX_backingis available for yield claiming.
replaceAsset caller whitelist
replaceAsset (and its SwapFacility entry points) is gated by an optional caller whitelist:
- The whitelist is a set of addresses managed by
ASSET_CAP_MANAGER_ROLEviasetReplaceAssetWhitelistCaller(caller, allowed)and its batch variant (which reverts withArrayLengthMismatchon uneven arrays). - Disabled when empty: if the whitelist holds no addresses,
replaceAssetis open to any caller. Adding the first address switches the whitelist to enforced;isReplaceAssetWhitelistEnabled()reflects this state. - Enforced when non-empty:
replaceAssetreverts withCallerNotAllowed(caller)for any caller not on the list. The caller is the originalmsgSender()resolved throughSwapFacility's reentrancy lock, not theSwapFacilityitself. getReplaceAssetWhitelist()returns the current set;isAllowedToReplaceAsset(caller, asset, amount)folds the whitelist check into its result.
Deployed contract addresses are listed on the PYUSDX platform deployments page.
PYUSDX Compliance & Security
PYUSDX compliance primitives and security model: roles, account freezing, forced transfers, pausing, upgradeability, and reentrancy protection.
PYUSDX Issuance (IssuerGateway)
The IssuerGateway is the only contract authorized to issue PYUSDX, enforcing a time-delay and rate limit on mints.