PYUSDX

PYUSDX Extensions

The SwapFacility, extension factory and beacons, YieldToOne, and MultiMint: branded ERC-20 wrappers backed 1:1 by PYUSDX.

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

FromToMechanism
PYUSDXExtensionswapIn: transfer PYUSDX in, Extension.wrap() mints extension tokens
ExtensionPYUSDXswapOut: Extension.unwrap() burns extension tokens, transfer PYUSDX out
Extension AExtension BswapExtensions: unwrap A → wrap B
Asset (ERC-20)MultiMintswapInMultiMint: deposit asset, MultiMint.wrap(asset) mints extension tokens
Asset + PYUSDXMultiMintreplaceAsset: deposit PYUSDX/extension, extract asset from MultiMint reserves

Key constraints

  • Self-swaps are forbidden (tokenIn == tokenOut reverts).
  • 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.
  • swapInMultiMint of an asset with more than 6 decimals: MultiMint pulls only the largest non-dust multiple, and SwapFacility refunds any leftover dust to the caller; the emitted SwappedInMultiMint amount 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:

  1. ExtensionBeacon: a single-type, ERC-1967-compliant versioned implementation registry (one beacon instance per extension type).
  2. ExtensionFactory: deploys and registers extensions via CREATE3.
  3. ExtensionBeaconProxy: a per-extension proxy supporting beacon mode (follow-latest) and pinned mode.
  4. 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:

  1. Resolves the latest implementation from the extension type's ExtensionBeacon.
  2. Encodes the extension's initializer calldata.
  3. Deploys the ExtensionBeaconProxy via CREATE3 (deterministic address).
  4. Registers the proxy in the ExtensionFactory's extension type mapping.
  5. 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.

The 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.

  1. BeaconManager calls registerImplementation(implementation) on a beacon. The version auto-increments (starting at 1) and the beacon emits the ERC-1967 Upgraded(implementation) event.
  2. All proxies served by that beacon upgrade atomically when its latestVersion changes, unless an individual proxy is pinned.

An ExtensionBeaconProxy operates in one of two ERC-1967 modes:

  • Beacon mode (default): the eip1967.proxy.beacon slot holds the beacon address; the proxy resolves the beacon's latest implementation on every call and auto-upgrades.
  • Pinned mode: the eip1967.proxy.implementation slot 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. version must be > 0 (reverts with ZeroVersion). Resolves the implementation from the origin beacon, switches the proxy to pinned mode, and emits Upgraded.
  • unpinVersion(): restores beacon mode (follow-latest) and emits BeaconUpgraded. Reverts with NotPinned if the proxy is not currently pinned.

Views: isPinned(), pinnedImplementation(), originBeacon().

Extension base contract

The abstract Extension contract provides:

  • wrap(recipient, amount): called by SwapFacility only. Pulls PYUSDX, mints extension tokens.
  • unwrap(amount): called by SwapFacility only. 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

  1. PYUSDX tokens are wrapped into YieldToOne via SwapFacility.
  2. The YieldToOne contract itself is registered as an earner on PYUSDX (by the earner manager).
  3. PYUSDX yield accrues on the contract's PYUSDX balance.
  4. claimYield() (called by YIELD_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 excess extension tokens to yieldRecipient.

Incident response

  • claimYield succeeds even while paused (tokens are minted but cannot transfer until unpause).
  • setYieldRecipient skips the claim if the outgoing recipient is frozen, avoiding a revert that would block rotation.
  • Yield on PYUSDX can be redirected via setAccountInfo at the PYUSDX level.

Roles

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 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 > 0 enables the asset for both wrap and replaceAsset. Setting cap == 0 is the canonical disable lever and blocks both wrap and replaceAsset for the asset; existing balances remain unwrappable to PYUSDX (held reserves are not stranded).
  • wrap(asset, recipient, amount): deposits asset tokens and mints extension tokens, respecting the cap.
  • replaceAsset(asset, recipient, amount): deposits PYUSDX and extracts asset from reserves. Reverts with AssetNotAllowed(asset) if the asset has cap == 0, and with CallerNotAllowed(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

totalSupply=PYUSDX_backing+totalAssetstotalSupply = PYUSDX\_backing + totalAssets
  • PYUSDX_backing = totalSupply - totalAssets is the PYUSDX actually held.
  • excess = PYUSDX_balance - PYUSDX_backing is 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_ROLE via setReplaceAssetWhitelistCaller(caller, allowed) and its batch variant (which reverts with ArrayLengthMismatch on uneven arrays).
  • Disabled when empty: if the whitelist holds no addresses, replaceAsset is open to any caller. Adding the first address switches the whitelist to enforced; isReplaceAssetWhitelistEnabled() reflects this state.
  • Enforced when non-empty: replaceAsset reverts with CallerNotAllowed(caller) for any caller not on the list. The caller is the original msgSender() resolved through SwapFacility's reentrancy lock, not the SwapFacility itself.
  • 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.

Copyright © M0 Foundation 2026