Skip to content
$1,000,000 in security audit grants are live now, Apply here →

Security review · May 2026

PYUSDX

for M0

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

8 resolved · 13 acknowledged

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

  1. L-01 Low IssuerGateway Does Not Snapshot Proposal Timing Validation Resolved
    Location
    IssuerGateway.sol:114-119

    Description

    proposeMint computes activeAt and expiresAt at proposal time and emits those values in the MintProposed event, but the protocol stores only createdAt in MintProposal. The proposal itself does not retain the timing parameters that were in effect when it was created. Instead, both cancelMint and mint recompute the proposal’s validity window at execution time using the current global values of mintDelay and mintTTL:

    // cancelMint — reads current $.mintDelay
    activeAt = proposal.createdAt + $.mintDelay;
    // mint — reads current $.mintDelay and $.mintTTL
    activeAt  = proposal.createdAt + $.mintDelay;
    expiresAt = activeAt           + $.mintTTL;
    

    Because setMintDelay and setMintTTL take 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 in MintProposed and 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 activeAt and expiresAt into the MintProposal struct at creation time and use the stored values in cancelMint and mint:

    Resolution

    M0 Team: The issue was resolved in PR#52.

  2. L-02 Low Capital-efficient Bridge DoS Gaming Acknowledged
    Location
    RateLimiter.sol:21, RateLimiter.sol:128-133, Portal.sol:197

    Description

    The rate limit bucket is keyed by issuer address (mapping(address issuer => Bucket)), not by sender. Portal is the sole ISSUER_ROLE holder 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 receiveMessage calls on chain B revert with RateLimitExceeded until 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 / refillPerSecond seconds). 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.

  3. L-03 Low Rate Limiters: Dead Code Best Practices Resolved
    Location
    RateLimiter.sol:175-178

    Description

    _calculateRemainingAmount uses Math.tryMul and Math.tryAdd to guard against overflow, returning capacity as 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:

    • elapsed is computed as uint40 - uint40, then zero-extended to uint256, so its value is bounded by (2^{40}).
    • refillPerSecond is uint128, bounded by (2^{128}).
    • Product: (2^{40} \times 2^{128} = 2^{168} \ll 2^{256}) → tryMul will never return false.
    • remaining is uint128, bounded by (2^{128}).
    • Sum: (2^{128} + 2^{168} < 2^{256}) → tryAdd will never return false.

    As a result, the branch (!mulOk || !addOk) ? capacity is 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.tryMul and Math.tryAdd each perform checked arithmetic internally, which is more expensive than direct

    operations. Since _enforceRateLimit is called on every mint, this overhead accumulates across all issuers over time.

    • Readability: A reader encountering tryMul/tryAdd may 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.

  4. L-04 Low Fee Lost On Freeze Despite Safe Transfer Path Rewards Acknowledged
    Location
    PYUSDX.sol: 458-478

    Description

    _beforeFreeze calls _claimFor(account, true), which adds yield to the account's balance but skips both net yield to claim recipient and fee to earner manager transfers. The skipTransfer flag exists to prevent the freeze from reverting if claimRecipient is frozen. However, the fee transfer goes to earnerManager, 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 the skipTransfer=true skips it. The fee is only recoverable via manual forceTransfer from 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.

  5. L-05 Low Wormhole Routes Have No On-Chain Fee Quoting Compatibility Acknowledged
    Location
    WormholeBridgeAdapter.sol: 81-86

    Description

    Portal::quote() is used for estimating cross-chain transfer fees, but it reverts for Wormhole routes because WormholeBridgeAdapter::quote() unconditionally reverts with OnChainQuoteNotSupported(). Users and integrating contracts have no on-chain mechanism to determine the required msg.value before calling sendToken.

    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's quote function. If a user underpays, the Executor silently accepts the request (due to the payable(payeeAddress).transfer(msg.value); line in Executor.sol), but the relay provider never delivers the message, and tokens are already burned/locked on 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 on Portal.quote(). Store the ExecutorQuoterRouter address and call quoteExecution() to derive fees on-chain, combining the result with ICoreBridge.messageFee() to return the fee estimate.

    Resolution

    M0 Team: Acknowledged.

  6. L-06 Low Portal Incompatible With Wormhole Adapter Compatibility Acknowledged
    Location
    Portal.sol: 385

    Description

    The documentation states "Other adapters (Hyperlane, Wormhole, etc.) can be added without modifying Portal", however that currently is not the case for a standard Wormhole adapter. The PYUSDX Portal's _sendMessage passes four parameters to IBridgeAdapter::sendMessage (destination chain, gas limit, refund address, and payload). There is no mechanism to pass additional arguments. The M0 Portal supports a fifth extraArguments parameter used by the Wormhole adapter to receive the off-chain signedQuote required 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 extraArguments parameter to the Portal's _sendMessage and the IBridgeAdapter.sendMessage interface, matching the M0 Portal design.

    Resolution

    M0 Team: Acknowledged.

  7. L-07 Low Delegate Clearance On Role Revocation Unexpected Behavior Acknowledged
    Location
    LayerZeroBridgeAdapter.sol: 99-100

    Description

    Revoking the OPERATOR_ROLE in LayerZeroBridgeAdapter automatically clears the LayerZero endpoint delegate by setting it to address(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.

  8. L-08 Low replaceAsset() And Low-decimal Assets Rounding Acknowledged
    Location
    MultiMint.sol: 296

    Description

    MultiMint.totalAssets() is tracked in 6-decimal extension/PYUSDX units, but replaceAsset() removes reserve assets in the asset’s native decimals. For assets with fewer than 6 decimals, replaceAsset() converts the requested PYUSDX amount into assetAmount using truncation, then:

    • decreases assetBalance by truncated assetAmount
    • decreases totalAssets by 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

    • totalAssets can 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.

  9. I-01 Informational MultiMint Assumes 1:1 Stablecoin Parity Trust Assumptions Resolved
    Location
    Multimint.sol

    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, and replaceAsset(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 through unwrap for $1 worth of PYUSDX. This creates instant arbitrage at the expense of the extension’s existing PYUSDX backing pool. Each such wrap reduces pyusdxBacking per extension token and dilutes existing holders.

    At the same time, replaceAsset becomes 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 after pyusdxBacking has 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.

  10. I-02 Informational Redemption Is Fully Custodial Documentation Acknowledged
    Location
    IssuerGateway.sol:183-189

    Description

    IssuerGateway.burn is restricted by onlyRole(OPERATOR_ROLE) and burns tokens exclusively from msg.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 calls IssuerGateway.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.

  11. I-03 Informational Misleading YieldClaimed Event On skipTransfer Events Acknowledged
    Location
    PYUSDX.sol: 446-462

    Description

    When _claimFor is called with skipTransfer=true (during _beforeFreeze and paused _setAccountInfo), the YieldClaimed(account, yieldNetOfFee) event is emitted before the early return. Although the account actually receives yieldWithFee, this event is misleading since no fee is collected by the earner manager, and no yield is actually routed to claimRecipient. Off-chain systems indexing YieldClaimed could incorrectly assume the fee was paid and yield was distributed.

    Recommendation

    Consider emitting a separate event for yield handling when skipTransfer=true or move the YieldClaimed emission to after the routing logic.

    Resolution

    M0 Team: Acknowledged.

  12. I-04 Informational No Cleanup For Expired Mint Proposals Best Practices Acknowledged
    Location
    IssuerGateway.sol: 137

    Description

    Expired mint proposals cannot be cancelled (ActiveMintProposal revert) or executed (ExpiredMintProposal revert), 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.

  13. I-05 Informational Portal And EarnerManager: RateLimiter Informational Resolved
    Location
    RateLimiter.sol:128-133 PYUSDX.sol:116-118 PYUSDX.sol:136-140 Portal.sol:197

    Description

    PYUSDX has two mint paths that bypass IssuerGateway’s timelock. | Caller | Function | Gating | | --- | --- | --- | | ISSUER_ROLE holder (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 → unlimited
    

    Recommendation

    Consider configuring rate limits for both portal and earner manager

    Resolution

    M0 Team: The issue was resolved in PR#51.

  14. I-06 Informational Per-account Earner Rates And Total Principal Informational Acknowledged
    Location
    PYUSDX.sol:36 PYUSDX.sol:515–523 PYUSDX.sol:541–551

    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: totalEarningPrincipal is not meaningful without normalization

    Recommendation

    Beware of this behavior and document it

    Resolution

    M0 Team: Acknowledged.

  15. I-07 Informational Rate Limiter Provides Limited Protection Informational Acknowledged
    Location
    src/PYUSDX.sol, src/abstract/RateLimiter.sol

    Description

    The protocol uses a token-bucket rate limiter on minting (_mint → _enforceRateLimit), which limits damage from a compromised ISSUER_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 minting
    • FREEZE_MANAGER_ROLE + FORCED_TRANSFER_MANAGER_ROLE – Can freeze and fully drain any account instantly
    • PAUSER_ROLE – Can halt all protocol activity
    • earnerManager – Can redirect all future yield to attacker
    • DEFAULT_ADMIN_ROLE – Full takeover: remove limits → grant ISSUER_ROLE → mint unlimited (3 tx)

    As a result, the rate limiter only helps in a narrow scenario where only the ISSUER_ROLE is 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.

  16. I-08 Informational replaceAsset Lacks Asset Registration Check Validation Resolved
    Location
    MultiMint.sol: 279

    Description

    MultiMint::_replaceAsset does not verify that the asset was previously registered via setAssetCap. For an unregistered asset, assetDecimals returns 0, causing _fromExtensionToAssetAmount to 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 _revertIfInvalidAsset only checks if asset is a non-zero address and not pyusdx, 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.

  17. I-09 Informational Burn Lacks Pre-Claim For Earning Accounts Suggestion Resolved
    Location
    PYUSDX.sol: 121

    Description

    _beforeFreeze and _setAccountInfo both call _claimFor before mutating an earner's balance, but burn does not. Currently, this has no impact since ISSUER_ROLE is held by IssuerGateway (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. If ISSUER_ROLE is 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 _subtractEarningAmount in burn if 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.

  18. I-10 Informational ETH Sent To Bridge Adapters Is Not Recoverable Validation Acknowledged
    Location
    HyperlaneBridgeAdapter.sol: 69

    Description

    HyperlaneBridgeAdapter::handle and WormholeBridgeAdapter::executeVAAv1 are both payable (required by their respective interfaces) but neither forwards msg.value to 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's process function sends msg.value when executing handle:

    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.

  19. I-11 Informational Wormhole Consistency Level Undefined Warning Acknowledged
    Location
    WormholeBridgeAdapter.sol: 57-72

    Description

    WormholeBridgeAdapter::consistencyLevel is an immutable set at construction, and currently its value is undefined throughout the repo. Tests use 15, which is a custom finality level and is not the recommended finalized level. Wormhole's EVM convention defines 1 as finalized, 200 as instant, and 201 as 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 consistencyLevel and 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.

  20. I-12 Informational Missing AccessControl Initializer Call Best Practices Resolved
    Location
    BridgeAdapter.sol: 52

    Description

    Portal::initialize and BridgeAdapter::initialize skip calling __AccessControl_init() before granting roles, while ExtensionFactory, ExtensionBeacon, and IssuerGateway all 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() to Portal::initialize and BridgeAdapter::_initialize.

    Resolution

    M0 Team: The issue was resolved in PR#63.

  21. I-13 Informational Repeated Wraps Of High-decimal Assets And Dust Rounding Resolved
    Location
    MultiMint.sol: 243

    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.

More from M0

All 10 reports
  1. Liquidity Delivery Updates

    4 findings 4 findings: 1 low, 3 informational
  2. Liquidity Delivery

    59 findings3 critical · 5 high 59 findings: 3 critical, 5 high, 10 medium, 14 low, 27 informational
  3. M Extensions Updates

    16 findings 16 findings: 1 medium, 5 low, 10 informational
  4. 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.

Get a quote