M0 engaged Guardian to review the PYUSDX codebase. From April 20, 2026 to May 22, 2026, a team of 2 auditors reviewed the source code and recorded the findings in this report.
- Published
- Language
- Solidity
- Chains
- Ethereum, Arbitrum, Optimism, Linea, Unichain, Solana
- Sector
- Stablecoins
- 0 Critical
- 0 High
- 0 Medium
- 8 Low
- 13 Informational
Scope
Overview
M0 engaged Guardian to review the PYUSDX codebase. From April 20, 2026 to May 22, 2026, a team of 2 auditors reviewed the source code and recorded the findings in this report.
Findings 21
-
L-01 Low IssuerGateway Does Not Snapshot Proposal Timing Validation Resolved
Description
proposeMintcomputesactiveAtandexpiresAtat proposal time and emits those values in theMintProposedevent, but the protocol stores onlycreatedAtinMintProposal. The proposal itself does not retain the timing parameters that were in effect when it was created. Instead, bothcancelMintandmintrecompute the proposal’s validity window at execution time using the current global values ofmintDelayandmintTTL:// cancelMint — reads current $.mintDelay activeAt = proposal.createdAt + $.mintDelay; // mint — reads current $.mintDelay and $.mintTTL activeAt = proposal.createdAt + $.mintDelay; expiresAt = activeAt + $.mintTTL;Because
setMintDelayandsetMintTTLtake effect immediately and are not snapshotted per proposal, any admin update retroactively changes the execution window for all pending proposals. This creates a mismatch between what was announced inMintProposedand what is ultimately enforced on-chain. A proposal that appeared scheduled to become valid at one time, or expire at one time, can later become executable earlier, later, or expire sooner or later than originally signaled. In other words, proposal timing is not immutable once proposed; it remains subject to governance or admin intervention until execution or cancellation.Recommendation
Snapshot
activeAtandexpiresAtinto theMintProposalstruct at creation time and use the stored values incancelMintandmint:Resolution
M0 Team: The issue was resolved in PR#52.
-
L-02 Low Capital-efficient Bridge DoS Gaming Acknowledged
Description
The rate limit bucket is keyed by issuer address (
mapping(address issuer => Bucket)), not by sender. Portal is the soleISSUER_ROLEholder for bridge-triggered mints, meaning all cross-chain recipients on a given chain share a single bucket. An attacker can drain the entire bucket in a single transaction:- Bridge capacity PYUSDX from chain A → B — Portal-B’s bucket is fully consumed
- All subsequent
receiveMessagecalls on chain B revert withRateLimitExceededuntil the bucket refills - Bridge capacity PYUSDX back from B → A — Portal-A’s bucket is consumed
- Repeat indefinitely
Cost to attacker: Only LayerZero (LZ) gas fees (~$5–50 per round trip, depending on the chain). The PYUSDX capacity is recovered on the destination chain with each hop—resulting in zero net capital loss. Impact: Legitimate cross-chain transfers are blocked for the duration of the bucket refill window (
capacity /refillPerSecondseconds). Sustaining the attack requires only enough ETH to cover LZ fees.Recommendation
Consider a tiered bridge fee model on
sendToken:Below threshold X: zero protocol fee. Gas and LZ relayer costs already represent a meaningful fraction of small transfer values, making low-value bucket drain attempts self-deterring.At or above X: charge Y basis points on the full bridged amount. At this scale, fixed gas/LZ costs are negligible relative to capital deployed, and the fee is the only economic deterrent.Resolution
M0 Team: Acknowledged.
-
L-03 Low Rate Limiters: Dead Code Best Practices Resolved
Description
_calculateRemainingAmountusesMath.tryMulandMath.tryAddto guard against overflow, returningcapacityas a fallback if either operation overflows:(bool mulOk, uint256 refillAmount) = Math.tryMul(elapsed, refillPerSecond); (bool addOk, uint256 total) = Math.tryAdd(remaining, refillAmount); return (!mulOk || !addOk) ? capacity : uint128(Math.min(total, capacity));However, both overflow checks are statically unreachable given the input types:
elapsedis computed asuint40 - uint40, then zero-extended touint256, so its value is bounded by (2^{40}).refillPerSecondisuint128, bounded by (2^{128}).- Product: (2^{40} \times 2^{128} = 2^{168} \ll 2^{256}) →
tryMulwill never returnfalse. remainingisuint128, bounded by (2^{128}).- Sum: (2^{128} + 2^{168} < 2^{256}) →
tryAddwill never returnfalse.
As a result, the branch
(!mulOk || !addOk) ? capacityis dead code. The function will always execute:uint128(Math.min(total, capacity))Impact: There is no security risk. However, there are two minor consequences:
- Gas:
Math.tryMulandMath.tryAddeach perform checked arithmetic internally, which is more expensive than direct
operations. Since
_enforceRateLimitis called on every mint, this overhead accumulates across all issuers over time.- Readability: A reader encountering
tryMul/tryAddmay reasonably assume that overflow is a real concern. This could lead to
unnecessary audit effort or, worse, reliance on the fallback path in future changes where type bounds may differ.
Recommendation
Consider removing the dead code.
Resolution
M0 Team: The issue was resolved in PR#56.
-
L-04 Low Fee Lost On Freeze Despite Safe Transfer Path Rewards Acknowledged
Description
_beforeFreezecalls_claimFor(account, true), which adds yield to the account's balance but skips both net yield toclaim recipientand fee toearner managertransfers. TheskipTransferflag exists to prevent the freeze from reverting ifclaimRecipientis frozen. However, the fee transfer goes toearnerManager, a protocol address that is effectively never frozen, and the contract is not necessarily paused during a freeze. The fee could safely be collected, but theskipTransfer=trueskips it. The fee is only recoverable via manualforceTransferfrom the frozen account before unfreezing, requiring off-chain record-keeping of the fee amount and current earner manager address. If the admin unfreezes without performing this step, the fee is permanently lost.Recommendation
When not paused, transfer the fee to the earner manager before returning:
if (skipTransfer) { + if (!paused() && fee > 0) { + address feeRecipient = \$.earnerManager; ... return (yieldWithFee, fee, yieldNetOfFee); }Resolution
M0 Team: Acknowledged.
-
L-05 Low Wormhole Routes Have No On-Chain Fee Quoting Compatibility Acknowledged
Description
Portal::quote()is used for estimating cross-chain transfer fees, but it reverts for Wormhole routes becauseWormholeBridgeAdapter::quote()unconditionally reverts withOnChainQuoteNotSupported(). Users and integrating contracts have no on-chain mechanism to determine the requiredmsg.valuebefore callingsendToken.However, an on-chain quoting path does exist. Wormhole's ExecutorQuoter::requestQuote can estimate the executor fee, and
ICoreBridge.messageFee()provides the core bridge fee, but neither is integrated into the adapter'squotefunction. If a user underpays, the Executor silently accepts the request (due to thepayable(payeeAddress).transfer(msg.value);line in Executor.sol), but the relay provider never delivers the message, and tokens are alreadyburned/lockedon the source chain.Recommendation
Integrate Wormhole's on-chain quote resolution (https://wormhole.com/docs/protocol/infrastructure/relayers/executor-framework/#on-chain-quote-resolution) via
ExecutorQuoterRouter. This was designed for integrators that cannot pass off-chain signatures, and must rely onPortal.quote(). Store theExecutorQuoterRouteraddress and callquoteExecution()to derive fees on-chain, combining the result withICoreBridge.messageFee()to return the fee estimate.Resolution
M0 Team: Acknowledged.
-
L-06 Low Portal Incompatible With Wormhole Adapter Compatibility Acknowledged
Description
The documentation states
"Other adapters (Hyperlane, Wormhole, etc.) can be added withoutmodifying Portal", however that currently is not the case for a standard Wormhole adapter. The PYUSDX Portal's_sendMessagepasses four parameters toIBridgeAdapter::sendMessage(destination chain, gas limit, refund address, and payload). There is no mechanism to pass additional arguments. The M0 Portal supports a fifthextraArgumentsparameter used by the Wormhole adapter to receive the off-chainsignedQuoterequired by the Executor framework. Without this parameter, the PYUSDX Portal cannot integrate with a Wormhole adapter without either modifying the Portal interface (upgrading) or designing a non-standard adapter that obtains the signed quote through an alternative mechanism.Recommendation
Add an optional
bytes calldata extraArgumentsparameter to the Portal's_sendMessageand theIBridgeAdapter.sendMessageinterface, matching the M0 Portal design.Resolution
M0 Team: Acknowledged.
-
L-07 Low Delegate Clearance On Role Revocation Unexpected Behavior Acknowledged
Description
Revoking the
OPERATOR_ROLEinLayerZeroBridgeAdapterautomatically clears the LayerZero endpoint delegate by setting it toaddress(0). This creates a availability risk: it removes the ability to call privileged recovery functions (skip,clear,nilify) during a nonce-clog scenario, potentially leading to persistent denial of service.- When an operator is revoked, the contract automatically clears the delegate.
- LayerZero endpoint functions (
skip,clear,nilify) can only be called by: - The OApp itself, or
- Its registered delegate
- The adapter does not expose wrapper functions for these operations.
Example Scenario: Nonce-Clog DoS
Recommendation
Make sure that the delegate is reset to appropriate address via setDelegate after its clearance.
Resolution
M0 Team: Acknowledged.
-
L-08 Low replaceAsset() And Low-decimal Assets Rounding Acknowledged
Description
MultiMint.totalAssets()is tracked in 6-decimal extension/PYUSDX units, butreplaceAsset()removes reserve assets in the asset’s native decimals. For assets with fewer than 6 decimals,replaceAsset()converts the requested PYUSDX amount intoassetAmountusing truncation, then:- decreases
assetBalanceby truncated assetAmount - decreases
totalAssetsby the full untruncated amount
This can make totalAssets smaller than the normalized value of actual remaining reserves. Affected logic:
- wrap path adds normalized amount: src/platform/projects/MultiMint.sol:260
- replace path subtracts raw amount: src/platform/projects/MultiMint.sol:279
Example With a 2-decimal asset:
- wrap 10171 asset units
- normalized backing added = 10171 * 10000 = 101,710,000
Then call
replaceAsset(..., amount = 10,171):- 10,171 PYUSDX units converts to 1 asset unit after truncation
- actual normalized reserve removed = 10,000
- but contract subtracts 10,171 from totalAssets
Post-state:
- expected normalized reserves = 101,700,000
- stored totalAssets = 101,699,829
This creates accounting drift and can silently overcharge replacers. Impact
totalAssetscan become inconsistent with actual normalized reserve balances- users can overpay when replacing into low-decimal assets
Recommendation
Consider only accepting the the normalized amount for PYUSDX, and refund the rest via Swap Facility. In above example, 10000 would be transferred to extension while 171 is refunded back.
Resolution
M0 Team: Acknowledged.
- decreases
-
I-01 Informational MultiMint Assumes 1:1 Stablecoin Parity Trust Assumptions Resolved
Description
MultiMint converts between alt-asset amounts and extension-token amounts using only decimal scaling via
_convertAmounts, with no price oracle. As a result, a deposit of 1 USDC always mints exactly 1 PYUSDX-worth of extension tokens, andreplaceAsset(1 PYUSDX)always returns exactly 1 USDC, regardless of market price.Behavior when the asset depegs below $1 In a depeg scenario,
wrap(USDC, ...)becomes immediately exploitable. A user can deposit an asset worth only $0.87 and still receive 1e6 extension tokens, which remain redeemable throughunwrapfor $1 worth of PYUSDX. This creates instant arbitrage at the expense of the extension’s existing PYUSDX backing pool. Each such wrap reducespyusdxBackingper extension token and dilutes existing holders.At the same time,
replaceAssetbecomes economically irrational: no rational actor will spend $1 of PYUSDX to receive only $0.87 of USDC. The result is that reserves accumulate the depegged asset, while no one is incentivized to rotate it out. Net effect: the reserve composition deteriorates as it fills with below-peg assets; extension token holders become collectively undercollateralized; and redemptions become a race. Early unwrappers extract full PYUSDX value, while late holders are left with tokens backed only by impaired assets afterpyusdxBackinghas been exhausted.Behavior when the asset trades above $1 If the asset appreciates above peg, the incentives reverse. replaceAsset
becomes attractive because a user can pay $1 of PYUSDX and receive $1.05 of USDC. This drains high-quality reserves until either caps block further inflows or the appreciated asset is fully extracted.
Meanwhile,
wrap(USDC, ...)becomes unattractive: the user contributes $1.05 of value but still receives only 1e6 extension tokens, redeemable for just $1. Rational users stop wrapping, which reduces the inflow of good collateral. Net effect: above-peg assets are arbitraged out of reserves, leaving behind only at-peg or below-peg assets.Recommendation
Beware of this trust assumption.
Resolution
M0 Team: The issue was resolved in PR#62.
-
I-02 Informational Redemption Is Fully Custodial Documentation Acknowledged
Description
IssuerGateway.burnis restricted byonlyRole(OPERATOR_ROLE)and burns tokens exclusively frommsg.sender’s balance. As implemented, there is no function that allows a PYUSDX holder to directly burn their own tokens on-chain.Redemption Flow The only available redemption path is effectively mediated: 1. A holder enters into a bilateral agreement with an authorized issuer entity (holder of
ISSUER_ROLE). 2. The holder transfers PYUSDX to the issuer (off-chain or via an agreed transfer mechanism). 3. An operator callsIssuerGateway.burn(amount)to burn the received tokens from their own balance. 4. The issuer settles the corresponding PYUSD/USD value with the holder through off-chain means.Recommendation
Consider documenting the custodial redemption dependency explicitly in user-facing materials and the protocol spec
Resolution
M0 Team: Acknowledged.
-
I-03 Informational Misleading YieldClaimed Event On skipTransfer Events Acknowledged
Description
When
_claimForis called withskipTransfer=true(during_beforeFreezeandpaused_setAccountInfo), theYieldClaimed(account, yieldNetOfFee)event is emitted before the early return. Although the account actually receivesyieldWithFee, this event is misleading since no fee is collected by the earner manager, and no yield is actually routed toclaimRecipient. Off-chain systems indexingYieldClaimedcould incorrectly assume the fee was paid and yield was distributed.Recommendation
Consider emitting a separate event for yield handling when
skipTransfer=trueor move theYieldClaimedemission to after the routing logic.Resolution
M0 Team: Acknowledged.
-
I-04 Informational No Cleanup For Expired Mint Proposals Best Practices Acknowledged
Description
Expired mint proposals cannot be cancelled (
ActiveMintProposalrevert) or executed (ExpiredMintProposalrevert), leaving the storage permanently occupied. Over time, frequently expiring proposals accumulate dead storage with no cleanup mechanism.Recommendation
Consider adding a permissionless function for clearing expired proposals:
function clearExpiredProposal(uint48 mintId) external { IssuerGatewayStorage storage \$ = _getIssuerGatewayStorage(); MintProposal storage proposal = \$.mintProposals[mintId]; ... delete \$.mintProposals[mintId]; }Resolution
M0 Team: Acknowledged.
-
I-05 Informational Portal And EarnerManager: RateLimiter Informational Resolved
Description
PYUSDXhas two mint paths that bypassIssuerGateway’s timelock. | Caller | Function | Gating | | --- | --- | --- | |ISSUER_ROLEholder (Portal) |PYUSDX.mint → _enforceRateLimit(msg.sender)| Rate limit bucket only | |earnerManager|PYUSDX.distributeReward → _enforceRateLimit(msg.sender)| Rate limit bucket only | Both paths converge on_enforceRateLimit, which includes an explicit bypass for unconfigured callers:if (bucket.lastRefillTime == 0) return; // unconfigured → unlimitedRecommendation
Consider configuring rate limits for both portal and earner manager
Resolution
M0 Team: The issue was resolved in PR#51.
-
I-06 Informational Per-account Earner Rates And Total Principal Informational Acknowledged
Description
M0 model (single global index):
principal_removed = amount / idx principal_added = amount / idx net Δ principal = 0 (conserved)PYUSDX model (per-account index):
principal_removed = ceil(amount / idx_A) principal_added = floor(amount / idx_B) net Δ principal = floor(amount/idx_B) - ceil(amount/idx_A) (≠ 0)Transfers between earners with different rates produce asymmetric principal changes due to:
- Different indexes (
idx_A ≠ idx_B) - Opposite rounding directions (ceil vs floor)
Example
- Sender A:
idx_A = 2e12→ removes 50 principal - Recipient B:
idx_B = 4e12→ adds 25 principal
→ Net: −25 principal destroyed
Implications
- Path-dependent yield: Moving funds to higher-rate accounts increases total yield over time (extractable via routing strategies)
- No global invariant:
totalEarningPrincipalis not meaningful without normalization
Recommendation
Beware of this behavior and document it
Resolution
M0 Team: Acknowledged.
- Different indexes (
-
I-07 Informational Rate Limiter Provides Limited Protection Informational Acknowledged
Description
The protocol uses a token-bucket rate limiter on minting (
_mint → _enforceRateLimit), which limits damage from a compromisedISSUER_ROLE. However, this protection applies only to one path. Several other roles have equal or greater impact with no similar safeguards:RATE_LIMIT_MANAGER_ROLE– Can remove all limits → enables unlimited mintingFREEZE_MANAGER_ROLE+FORCED_TRANSFER_MANAGER_ROLE– Can freeze and fully drain any account instantlyPAUSER_ROLE– Can halt all protocol activityearnerManager– Can redirect all future yield to attackerDEFAULT_ADMIN_ROLE– Full takeover: remove limits → grantISSUER_ROLE→ mint unlimited (3 tx)
As a result, the rate limiter only helps in a narrow scenario where only the
ISSUER_ROLEis compromised.Recommendation
Do not treat the rate limiter as broad defense-in-depth—it protects only one attack path. Consider placing high-privilege roles behind stricter controls (e.g., timelock or higher-threshold multisig) to make attacks observable and interruptible.
Resolution
M0 Team: Acknowledged.
-
I-08 Informational replaceAsset Lacks Asset Registration Check Validation Resolved
Description
MultiMint::_replaceAssetdoes not verify that the asset was previously registered viasetAssetCap. For an unregistered asset, assetDecimals returns 0, causing_fromExtensionToAssetAmountto produce an incorrect value via_convertAmounts(6, 0, amount). The function then reverts at_revertIfInsufficientAssetBacking, since the balance is zero, but the revert reason is misleading because the function lacks an explicit check. Note_revertIfInvalidAssetonly checks ifassetis a non-zero address and notpyusdx, it does not check if the asset has already been registered.Recommendation
Add a registration check early in
_replaceAsset:if (assetDecimals(asset) == 0) revert InvalidAsset(asset);Resolution
M0 Team: The issue was resolved in PR#58.
-
I-09 Informational Burn Lacks Pre-Claim For Earning Accounts Suggestion Resolved
Description
_beforeFreezeand_setAccountInfoboth call_claimForbefore mutating an earner's balance, butburndoes not. Currently, this has no impact sinceISSUER_ROLEis held byIssuerGateway(which burns from the operator's own non-earning balance) and Portal (which burns from its own non-earning balance). However, the code handles the earning path without claiming first, creating an inconsistency. IfISSUER_ROLEis ever granted to a contract that burns directly from earning accounts, unclaimed yield would be subject to rounding loss.Recommendation
Consider adding
_claimFor(account)before_subtractEarningAmountinburnif the account is an earner, document that burn targets are expected to be non-earning accounts.Resolution
M0 Team: The issue was resolved in PR#61.
-
I-10 Informational ETH Sent To Bridge Adapters Is Not Recoverable Validation Acknowledged
Description
HyperlaneBridgeAdapter::handleandWormholeBridgeAdapter::executeVAAv1are both payable (required by their respective interfaces) but neither forwardsmsg.valueto Portal nor has a withdrawal mechanism. Any ETH sent with these calls is permanently stuck in the adapter contract. While relayers typically send 0 ETH for message-only deliveries, there is no protection against any ETH inclusion (i.e., accidental), and no admin sweep function exists to recover stuck funds. For example, we can see that Hyperlane'sprocessfunction sendsmsg.valuewhen executinghandle:https://github.com/hyperlane-xyz/hyperlane-monorepo/blob/main/solidity/contracts/Mailbox.sol#L241
// Deliver the message to the recipient. IMessageRecipient(recipient).handle{value: msg.value}( _message.origin(), ... ); }Recommendation
Consider adding an admin-gated sweep function to recover stuck ETH.
Resolution
M0 Team: Acknowledged.
-
I-11 Informational Wormhole Consistency Level Undefined Warning Acknowledged
Description
WormholeBridgeAdapter::consistencyLevelis an immutable set at construction, and currently its value is undefined throughout the repo. Tests use15, which is a custom finality level and is not the recommended finalized level. Wormhole's EVM convention defines1as finalized,200as instant, and201as safe. Deploying with a value other than 1 (finalized) introduces reorg risk, and tokens could be minted on the destination chain while the source transaction is rolled back during a chain reorg.Recommendation
Document the intended production
consistencyLeveland verify at deployment that it is set to 1 (finalized) for mainnet, or explicitly acknowledge the reorg risk if a lower finality level is chosen (i.e., for speed).Resolution
M0 Team: Acknowledged.
-
I-12 Informational Missing AccessControl Initializer Call Best Practices Resolved
Description
Portal::initializeandBridgeAdapter::initializeskip calling__AccessControl_init()before granting roles, whileExtensionFactory,ExtensionBeacon, andIssuerGatewayall call it. In current OpenZeppelin versions this is a no-op, so there is no impact. However, the inconsistency means a future OpenZeppelin upgrade that adds logic to__AccessControl_init()would only take effect in some contracts and silently be missed in others.Recommendation
Add
__AccessControl_init()toPortal::initializeandBridgeAdapter::_initialize.Resolution
M0 Team: The issue was resolved in PR#63.
-
I-13 Informational Repeated Wraps Of High-decimal Assets And Dust Rounding Resolved
Description
The invariant totalAssets == sum(assetBalance * factor) may not hold true for high decimal assets (>6) as well.
Examples
8-decimal asset:
- wrap 1 leaves remainder 77
- wrap 2 leaves remainder 91
- combined dust 168 crosses the /100 boundary once
- result: aggregate-normalized reserves exceed stored accounting by 1
18-decimal asset:
- wrap 1 leaves remainder 493,295,770,473
- wrap 2 leaves remainder 793,244,671,737
- combined dust exceeds 1e12
- result: aggregate-normalized reserves exceed stored accounting by 1
Recommendation
Beware of this consideration.
Resolution
M0 Team: The issue was resolved in PR#60.
No findings match.
More from M0
All 10 reports-
Liquidity Delivery Updates
4 findings 4 findings: 1 low, 3 informational -
Liquidity Delivery
59 findings3 critical · 5 high 59 findings: 3 critical, 5 high, 10 medium, 14 low, 27 informational -
M Extensions Updates
16 findings 16 findings: 1 medium, 5 low, 10 informational -
USD8
10 findings1 high 10 findings: 1 high, 1 low, 8 informational
Put your code through the same review.
This review started with a conversation about scope. Tell us what you are building and we will plan yours with you.
