USPD
Core Contracts

Covered the ten core contracts behind USPD's dual-token architecture, including Stabilizer and Position NFTs, liquidation logic, and cross-chain bridge escrow.
report id
0556
category
Token & RWA
language
Solidity
final report
August 20, 2025
lines of code
2043
Content

Download audit report

1

Executive Summary

This document presents the results of the security review conducted by Nethermind Security for the Permissionless Technologies' USPD Stablecoin protocol contracts.

USPD is an ERC20-compliant USD-pegged stablecoin that maintains its peg through an NFT-based overcollateralization mechanism. The protocol implements a dual-token architecture: cUSPDToken (core non-rebasing shares) handles minting/burning logic and collateral management, while USPDToken provides a rebasing view layer for user-friendly 1:1 USD representation.

The system uses Stabilizer NFTs for priority-based collateral allocation via a linked list structure and Position NFTs for tracking individual backing positions. A liquidation mechanism with variable thresholds based on stabilizer NFT IDs enables automatic stabilizer takeover. Supporting infrastructure includes PriceOracle for ETH/USD pricing, OvercollateralizationReporter for monitoring, escrow contracts for fund management, PoolSharesConversionRate for yield factor conversion, and BridgeEscrow for cross-chain functionality.

This audit focused on the ten core smart contracts: UspdToken, cUSPDToken, StabilizerNFT, PositionEscrow, StabilizerEscrow, PriceOracle, OvercollateralizationReporter, BridgeEscrow, InsuranceEscrow, and PoolSharesConversionRate, along with their interfaces and the complete NFT-based collateralization system.

The audit comprises 2043 lines of Solidity code. The audit was performed using (a) manual analysis of the codebase, and (b) automated analysis tools.

Audit assessment
Audit TypeSecurity Review
Final ReportAugust 20, 2025
Initial Commit Hashf3e3bccd05d3c3a3aa73285ef31f00e27a36866fl
Final Commit Hasha148cd614e
Documentation AssessmentHigh
Test Suite AssessmentHigh
Total findings
12
Fixed
11
Acknowledged
1
Mitigated
0
Unresolved
0

Findings by severity

  • Critical3
  • High2
  • Medium2
  • Low2
  • Informational1
  • Best Practices2

2

Audited Files

#ContractLoCCommentsRatioBlankTotal
1BridgeEscrow.sol1235443.9%29206
2PositionEscrow.sol16811870.2%59345
3PriceOracle.sol2716323.2%55389
4PoolSharesConversionRate.sol5472133.3%22148
5UspdToken.sol17211969.2%39330
6cUSPDToken.sol14510975.2%38292
7InsuranceEscrow.sol343294.1%975
8StabilizerNFT.sol64730747.4%1871141
9OvercollateralizationReporter.sol13510376.3%40278
10StabilizerEscrow.sol706694.3%26162
11interfaces/IStabilizerNFT.sol181372.2%637
12interfaces/IPositionEscrow.sol331339.4%652
13interfaces/IBridgeEscrow.sol322268.8%559
14interfaces/ICreateX.sol2545180.0%878
15interfaces/IPriceOracle.sol1616.2%320
16interfaces/IPoolSharesConversionRate.sol935388.9%650
17interfaces/IStabilizerEscrow.sol151386.7%634
18interfaces/IInsuranceEscrow.sol1425178.6%746
19interfaces/IcUSPDToken.sol3536102.9%980
20interfaces/ILido.sol411275.0%116
21interfaces/IOvercollateralizationReporter.sol2339169.6%1072
Total2043129663.4%5713910

4

System Overview

USPD Protocol is a yield-bearing, overcollateralized stablecoin system built on Ethereum that enables users to mint USD-pegged tokens backed by stETH (Lido Staked Ethereum) collateral. The protocol features a dual-token architecture comprising USPD (rebasing token for users) and cUSPD (non-rebasing shares for DeFi integrations), with collateralization managed through NFT-based positions. The system's design facilitates cross-chain deployments, automated yield distribution, and price oracle integration. It is worth mentioning that the protocol design keeps track of NFT positions as a linked list and iterates over it on every allocation or unallocation, in which it could lead to Gas Exhaustion during the execution of transactions. The client acknowledged and is aware of this.

USPD Protocol Overview
Fig. 2: USPD Protocol Overview

4.1 Actors

  • Users: Primary participants who interact with the protocol by minting and burning cUSPD tokens. Users receive rebasing USPD tokens that automatically reflect yield accrual from the underlying stETH collateral.
  • Stabilizers: Entities that provide stETH collateral to the system to ensure overcollateralization. These providers enable the minting of cUSPD tokens and earn fees for maintaining healthy collateralization ratios. Each stabilizer position is represented by a StabilizerNFT.
  • Liquidators: Actors who monitor for undercollateralized stabilizer positions and initiate liquidations to maintain system solvency, earning a reward in the process.
  • Admins: Protocol governance entities responsible for system configuration, upgrades, and emergency controls. Admins manage the multi-source price oracle system, configure cross-chain bridge parameters, execute contract upgrades via UUPS proxies.
  • Bridge Operators: Entities that facilitate cross-chain operations by managing the locking and unlocking of cUSPD shares in the BridgeEscrow contract. These operators enable USPD to be deployed on Layer 2 networks while maintaining backing collateral on Ethereum mainnet.
  • Oracle Signers: Off-chain actors running the USPD price oracle, which provides a real-time ETH/USD price. Their signed price data is validated against on-chain sources including Chainlink feeds and WETH/USDC Uniswap pools, with automatic invalidation if deviations exceed 5

5

Risk Rating Methodology

The risk rating methodology used by Nethermind Security follows the principles established by the OWASP Foundation. The severity of each finding is determined by two factors: Likelihood and Impact.

Likelihood measures how likely the finding is to be uncovered and exploited by an attacker. This factor will be one of the following values:

  1. High: the issue is trivial to exploit and has no specific conditions that need to be met;
  2. Medium: the issue is moderately complex and may have some conditions that need to be met;
  3. Low: the issue is very complex and requires very specific conditions to be met.

When defining the likelihood of a finding, other factors are also considered. These can include but are not limited to motive, opportunity, exploit accessibility, ease of discovery, and ease of exploit.

Impact is a measure of the damage that may be caused if an attacker exploits the finding. This factor will be one of the following values:

  1. High: the issue can cause significant damage, such as loss of funds or the protocol entering an unrecoverable state;
  2. Medium: the issue can cause moderate damage, such as impacts that only affect a small group of users or only a particular part of the protocol;
  3. Low: the issue can cause little to no damage, such as bugs that are easily recoverable or cause unexpected interactions that cause minor inconveniences.

When defining the impact of a finding, other factors are also considered. These can include but are not limited to data and state integrity, loss of availability, financial loss, and reputation damage. After defining the likelihood and impact of an issue, the severity is determined according to the table below.

Impact / LikelihoodLowMediumHigh
HighMediumHighCritical
MediumLowMediumHigh
LowInfo / Best PracticesLowMedium
UndeterminedUndeterminedUndeterminedUndetermined

To address issues that do not fit a High, Medium or Low severity, Nethermind Security also uses three more finding severities: Informational, Best Practices, and Undetermined.

  1. Informational findings do not pose any risk to the application, but they carry some information that the audit team intends to pass to the client formally;
  2. Best Practice findings are used when some piece of code does not conform with smart contract development best practices;
  3. Undetermined findings are used when we cannot predict the impact or likelihood of the issue.

6

Issues

6.1 Stabilizer owners can avoid being collateralized by manipulating the global collateralization ratio

FileFix commitTypeStatus
/src/PositionEscrow.sola0319f4
9afc63f
64b074d
CriticalFixed

For each Stabilizer NFT, a dedicated PositionEscrow contract is deployed to manage the associated collateral. The protocol continuously tracks the global collateralization ratio as a core safety metric, updating it during collateral allocations and removals (e.g., within allocateStabilizerFunds, unallocateStabilizerFunds, and liquidatePosition). For example, in allocateStabilizerFunds:

function allocateStabilizerFunds(
uint256 ethUsdPrice,
uint256 priceDecimals
) external payable override returns (AllocationResult memory result) {
// --SNIP
// --- Update Snapshot via Reporter ---
if (result.totalEthEquivalentAdded > 0) {
reporter.updateSnapshot(int256(result.totalEthEquivalentAdded));
}
// --SNIP
}

The PositionEscrow contract tracks locked collateral using the ERC20 balanceOf method. Collateral additions are intended to be made through the functions PositionEscrow::addCollateralEth and PositionEscrow::addCollateralStETH, both of which correctly notify the system of new collateral:

function addCollateralStETH(uint256 stETHAmount) external override /* Removed role check */ {
if (stETHAmount == 0) revert ZeroAmount();

// Pull stETH from the caller
bool success = IERC20(stETH).transferFrom(msg.sender, address(this), stETHAmount);
if (!success) revert TransferFailed(); // Check allowance and balance

// Emit event acknowledging the stETH added to the pool
emit CollateralAdded(stETHAmount);

// ethValue = stEth * yield
// Report addition to StabilizerNFT
IStabilizerNFT(stabilizerNFTContract).reportCollateralAddition(stETHAmount);
}

Stabilizer owners can bypass these reporting functions by transferring stETH directly to their PositionEscrow via a standard ERC20 transfer. Since PositionEscrow tracks its collateral balance using the ERC20 balanceOf method, this directly increases the escrow's perceived collateral without triggering any collateral addition report. As a result, the global collateralization ratio becomes underreported.

This underreporting can be exploited in several ways:

1) Consider the following example:

1.a) There are two positions with the following allocations:

  • 1st Position: backed cUSPD = $100 | collateral value = $90 | collateralRatio = 90%
  • 2nd Position: backed cUSPD = $100 | collateral value = $90 | collateralRatio = 90%

Global system collateralization ratio is 90%

1.b) Both positions can be liquidated, the 1st stabilizer directly transfers stETH to his Position Escrow to increase his collateral value to $110. This position's allocation is updated as follows:

  • 1st Position: backed cUSPD = $100 | collateral value = $110 | collateralRatio = 110%
  • 2nd Position: backed cUSPD = $100 | collateral value = $90 | collateralRatio = 90%

Crucially, the global system collateralization ratio is still 90% since the last collateral increase was not reported.

1.c) Both positions can still be liquidated. A liquidator submits a TX to liquidate the 1st position. However, given that the system is under-reporting its collateral ratio (90%), the liquidate transaction will revert in the following line, assuming that the position's collateral is healthier than the system, while it is not:

function liquidatePosition(
uint256 liquidatorTokenId,
uint256 positionTokenId,
uint256 cuspdSharesToLiquidate,
IPriceOracle.PriceAttestationQuery calldata priceQuery
) external {
// --SNIP
if (address(reporter) != address(0)) {
uint256 systemCollateralRatio = reporter.getSystemCollateralizationRatio(priceResponse);

if (systemCollateralRatio != type(uint256).max
&& positionCollateralRatio > systemCollateralRatio) {
revert LiquidationNotBelowSystemRatio();
}
}
}

Here, the positionCollateralRatio is 110% while the systemCollateralRatio is 90%.

2) Moreover, after directly funding their escrow, a stabilizer can call removeExcessCollateral to withdraw the unreported collateral. The system will report the removal, further desynchronizing the global collateral state, since the addition was never reported.

function removeExcessCollateral(
address payable recipient,
uint256 amountToRemove,
IPriceOracle.PriceAttestationQuery calldata priceQuery
) external override onlyRole(EXCESSCOLLATERALMANAGER_ROLE) {
// --SNIP
// Report removal to StabilizerNFT
IStabilizerNFT(stabilizerNFTContract).reportCollateralRemoval(amountToRemove);
}

These issues can lead to a denial of service for other users, prevent legitimate liquidations, and severely undermine the protocol’s risk management and collateral accounting.

Recommendation

Consider tracking the stETH locked in EscrowPositions via internal state, this forces stabilizer owners to pass through addCollateralEth and addCollateralStETH where the collateral addition is reported.

6.2 Uniswap ETH/USDC price is incorrectly fetched, causing price deviation checks to always revert

FileFix commitTypeStatus
/src/PriceOracle.sol3e9fce8CriticalFixed

The PriceOracle::attestationService function is used throughout the system to calculate the price of ETH. This function relies on a price attestation from the Morpho oracle and verifies the price against Chainlink and Uniswap as a deviation check:

function attestationService(
PriceAttestationQuery calldata priceQuery
) public payable whenNotPaused returns (PriceResponse memory) {
// --SNIP
if (block.chainid == 1) {
// Get prices from other sources
uint256 chainlinkPrice = uint256(
getChainlinkDataFeedLatestAnswer()
);
if (chainlinkPrice == 0) {
revert PriceSourceUnavailable("Chainlink");
}

uint256 uniswapV3Price = getUniswapV3WethUsdcPrice();
if (uniswapV3Price == 0) {
revert PriceSourceUnavailable("Uniswap V3");
}

// Check price deviations
if (!_isPriceDeviationAcceptable(priceQuery.price,chainlinkPrice,uniswapV3Price)) {
revert PriceDeviationTooHigh(priceQuery.price,chainlinkPrice,uniswapV3Price);
}
}
// --SNIP
}

The issue arises in the getUniswapV3WethUsdcPrice function:

function getUniswapV3WethUsdcPrice() public view returns (uint) {
IUniswapV3Factory factory = IUniswapV3Factory(uniswapV3Factory);
address uniswapV3PoolWethUSDC = factory.getPool(
uniswapRouter.WETH(),
usdcAddress,
3000
);
if (uniswapV3PoolWethUSDC != address(0)) {
IUniswapV3PoolState uniswapPoolState = IUniswapV3PoolState(uniswapV3PoolWethUSDC);
(uint sqrtPriceX96, , , , , , ) = uniswapPoolState.slot0();
==> return ((sqrtPriceX96 / 2 ** 96) ** 2) * 1e12;
}

return 0;
}

The function incorrectly assumes that sqrtPriceX96 from slot0 gives the price of ETH in terms of USDC, when in reality, for USDC/WETH pool, it gives the price of USDC in terms of ETH (because, in the USDC/WETH pool, USDC is token0). As a result, the returned price is inverted and significantly larger than the actual ETH price in USDC. This leads to the price deviation check always reverting, effectively causing a permanent denial of service to the protocol.

Recommendation

Consider correcting the implementation to invert the price so it accurately reflects the price of ETH in terms of USDC.

6.3 MINIMUM_UNALLOCATE_COLLATERALIZATION_RATIO is incorrectly set, rendering the system collateralization ratio check ineffective

FileFix commitTypeStatus
/src/StabilizerNFT.sol862e3d8CriticalFixed

When burning cUSPD, the unallocateStabilizerFunds function is called to withdraw the corresponding collateral. Before proceeding, the function checks that the global system collateralization ratio is healthy (i.e., above 100%):

function unallocateStabilizerFunds(
uint256 poolSharesToUnallocate, // Changed parameter name
IPriceOracle.PriceResponse memory priceResponse
) external override returns (uint256 unallocatedEth) {
// --SNIP
if (address(reporter) != address(0)) {
uint256 currentSystemRatio = reporter.getSystemCollateralizationRatio(priceResponse);
if (currentSystemRatio < MINIMUM_UNALLOCATE_COLLATERALIZATION_RATIO) {
revert SystemUnstableUnallocationNotAllowed();
}
}
}

However, there is a critical misalignment between the scaling of the value returned by reporter.getSystemCollateralizationRatio and the value of MINIMUM_UNALLOCATE_COLLATERALIZATION_RATIO. The former is scaled by 10000, while the latter is not:

uint256 public constant MINIMUM_UNALLOCATE_COLLATERALIZATION_RATIO = 100; // e.g., 100 for 100%

As a result, the current value of 100 actually represents a 1% collateralization ratio when compared against the scaled currentSystemRatio, effectively making the collateralization check meaningless.

Recommendation

Consider updating the value of MINIMUM_UNALLOCATE_COLLATERALIZATION_RATIO with the scaling used in the return value of getSystemCollateralizationRatio.

6.4 Transferring the Stabilizer NFT does not transfer the EXCESSCOLLATERALMANAGER_ROLE in the PositionEscrow contract

FileFix commitTypeStatus
/src/StabilizerNFT.sol7335fab
f20f5bf
HighFixed

When a user mints a stabilizer NFT, two contracts are initialized: PositionEscrow and StabilizerEscrow. During initialization, the EXCESSCOLLATERALMANAGER_ROLE—which permits the holder to withdraw excess collateral via removeExcessCollateral—is granted to the stabilizer NFT owner:

function initialize( // <-- Renamed from constructor
address _stabilizerNFT,
address _stabilizerOwner,
address _stETHAddress,
address _lidoAddress,
address _rateContractAddress,
address _oracleAddress
) external initializer { // <-- Added initializer modifier
__AccessControl_init(); // <-- Initialize AccessControl

if (_stabilizerNFT == address(0) || _stabilizerOwner == address(0) || _stETHAddress == address(0) || _lidoAddress == address(0) || _rateContractAddress == address(0) || _oracleAddress == address(0)) {
revert ZeroAddress();
}

stabilizerNFTContract = _stabilizerNFT;
stETH = _stETHAddress;
lido = _lidoAddress;
rateContract = _rateContractAddress;
oracle = _oracleAddress;

_grantRole(DEFAULT_ADMIN_ROLE, _stabilizerNFT);
_grantRole(STABILIZER_ROLE, _stabilizerNFT);
_grantRole(EXCESSCOLLATERALMANAGER_ROLE, _stabilizerOwner);
}

However, when the stabilizer NFT is transferred to a new account, the EXCESSCOLLATERALMANAGER_ROLE is not revoked from the previous owner nor granted to the new owner. The _update function, which is invoked on transfers, does not handle role updates:

// StabilizerNFT.sol

function _update(address to, uint256 tokenId, address auth)
internal
override(ERC721Upgradeable, ERC721EnumerableUpgradeable)
returns (address)
{
return super._update(to, tokenId, auth);
}

This allows the previous owner to continue withdrawing excess collateral, while the new owner does not gain the necessary permissions.

Recommendation

Consider revoking the EXCESSCOLLATERALMANAGER_ROLE role from the old owner and grant it to the new one upon NFT transfer.

6.5 Unallocated shares from unallocateStabilizerFunds are not refunded to the user and are permanently lost

FileFix commitTypeStatus
/src/StabilizerNFT.sola811587HighFixed

When users burn their cUSPD tokens by calling cUSPDToken::burnShares, the function immediately burns the specified number of shares and then calls StabilizerNFT::unallocateStabilizerFunds to unallocate the corresponding collateral and refund it to the user:

function burnShares(
uint256 sharesAmount,
address payable to,
IPriceOracle.PriceAttestationQuery calldata priceQuery
) external returns (uint256 unallocatedStEthReturned) {
// --SNIP
==> _burn(msg.sender, sharesAmount);

// 3. Unallocate funds via StabilizerNFT
uint256 unallocatedStEth = stabilizer.unallocateStabilizerFunds(
sharesAmount,
oracleResponse
);
// --SNIP

}

Within unallocateStabilizerFunds, the function iterates backward over the allocated positions to withdraw the equivalent ETH of the burned shares. Notably, the implementation enforces a minimum remaining gas requirement before continuing the loop, which is intended to prevent DoS condition in cases with a large number of allocated positions:

function unallocateStabilizerFunds(
uint256 poolSharesToUnallocate, // Changed parameter name
IPriceOracle.PriceResponse memory priceResponse
) external override returns (uint256 unallocatedEth) {
// --SNIP
uint256 currentId = highestAllocatedId;
uint256 remainingPoolShares = poolSharesToUnallocate;
uint256 totalUserStEthReturned; //default = 0;
uint256 totalEthEquivalentRemovedAggregate; //default = 0;

while (currentId != 0 && remainingPoolShares > 0) {
if (gasleft() < MIN_GAS) break;
// --SNIP
}
require(totalUserStEthReturned > 0, "No funds unallocated");

if (totalEthEquivalentRemovedAggregate > 0) {
reporter.updateSnapshot(-int256(totalEthEquivalentRemovedAggregate));
}

return totalUserStEthReturned;
}

If the loop exits early due to gas exhaustion, the function may not fully unallocate all of the shares passed in poolSharesToUnallocate. However, since the entire amount is already burned in cUSPDToken::burnShares before calling unallocateStabilizerFunds, any unprocessed shares are neither refunded to the user nor retained, resulting in a permanent loss of those shares.

Recommendation

Consider modifying the logic to burn only the shares that have been successfully unallocated in the StabilizerNFT

6.6 Malicious Stabilizers can permanently DoS the protocol

FileFix commitTypeStatus
/src/StabilizerNFT.sol—MediumFixed

When minting cUSPD, the token contract calls StabilizerNFT::allocateStabilizerFunds to over-collateralize the minted tokens. The function iterates over unallocated positions to back the new tokens:

function allocateStabilizerFunds(
// poolSharesToMint removed
uint256 ethUsdPrice,
uint256 priceDecimals
) external payable override returns (AllocationResult memory result) {
require(msg.sender == address(cuspdToken), "Only cUSPD contract"); // Check against cUSPD
require(lowestUnallocatedId != 0, "No unallocated funds");
require(msg.value > 0, "No ETH sent"); // User must send ETH

uint256 currentId = lowestUnallocatedId;
uint256 remainingEth = msg.value;
while (currentId != 0 && remainingEth > 0) {
// --SNIP
}
}

For each unallocated position, the function determines the required collateral, withdraws it from the StabilizerEscrow, and attempts to deposit it into the PositionEscrow:

uint256 stabilizerStEthNeeded = (remainingEth * (pos.minCollateralRatio - 10000)) / 10000;
// Determine how much stabilizer stETH can actually be allocated (min of needed and available)
uint256 toAllocate = stabilizerStEthNeeded > escrowBalance
? escrowBalance
: stabilizerStEthNeeded;

// If stabilizer can't provide the ideally needed amount, adjust the user's ETH share accordingly
uint256 userEthShare = remainingEth;
if (toAllocate < stabilizerStEthNeeded) {
userEthShare = (toAllocate * 10000) / (pos.minCollateralRatio - 10000);

if (userEthShare > remainingEth) {
userEthShare = remainingEth;
}
}

// --- Interact with PositionEscrow ---
// PositionNFT interaction removed
address positionEscrowAddress = positionEscrows[currentId];

// 1. Transfer Stabilizer's stETH from StabilizerEscrow to PositionEscrow
// Approve this contract to pull from StabilizerEscrow
IStabilizerEscrow(escrowAddress).approveAllocation(toAllocate, address(this));
// Pull the funds
bool successStabilizer = IERC20(stETH).transferFrom(escrowAddress, positionEscrowAddress, toAllocate);
if (!successStabilizer) revert("Stabilizer stETH transfer to PositionEscrow failed");

// 2. Call PositionEscrow.addCollateralFromStabilizer
// This sends the user's ETH (userEthShare) which gets converted to stETH inside PositionEscrow,
// and acknowledges the stabilizer's stETH (toAllocate) that we just transferred.
IPositionEscrow(positionEscrowAddress).addCollateralFromStabilizer{value: userEthShare}(toAllocate);

The addCollateralFromStabilizer function in PositionEscrow stakes the ETH into Lido and reverts if either staking fails or the returned stETH is zero:

function addCollateralFromStabilizer(uint256 stabilizerStEthAmount)
external
payable
override
onlyRole(STABILIZER_ROLE)
{
uint256 userEthAmount = msg.value;
if (userEthAmount == 0 && stabilizerStEthAmount == 0) revert ZeroAmount(); // Must add something

uint256 userStEthReceived; //default 0
if (userEthAmount > 0) {
// Stake User's ETH via Lido - stETH is minted directly to this contract
try ILido(lido).submit{value: userEthAmount}(address(0)) returns (uint256 receivedStEth) {
userStEthReceived = receivedStEth;
==> if (userStEthReceived == 0) revert TransferFailed(); // Lido submit should return > 0 stETH
} catch {
revert TransferFailed(); // Lido submit failed
}
}

// --SNIP
}

Since userEthShare is ultimately determined by the available collateral in a stabilizer’s position ( min(unallocated funds, msg.value):

// StabilizerNFT::allocateStabilizerFunds

uint256 toAllocate = stabilizerStEthNeeded > escrowBalance
? escrowBalance
: stabilizerStEthNeeded;

a malicious stabilizer can create a position with an extremely small amount of collateral (e.g., 1 wei). During allocation, this minimal value will be submitted to Lido for staking. Due to the internal Lido logic, staking such a small amount will return zero stETH, causing addCollateralFromStabilizer to revert and thus reverting the entire mint transaction:

// Lido.sol

function getSharesByPooledEth(uint256 _ethAmount) public view returns (uint256) {
return _ethAmount
.mul(_getTotalShares())
.div(_getTotalPooledEther());
}

At the time of writing, the value of _getTotalShares and _getTotalPooledEther are 7536473279201896554811148 and 9107424450461297259544963, respectively. So, staking 1 wei would result in zero stETH due to Lido’s proportional share calculation.

Because the allocation logic always processes positions starting from the lowest unallocated position and there is no privileged mechanism to remove or skip malicious positions, a single malicious position can indefinitely block the minting of new cUSPD tokens, permanently DoSing to the protocol.

Recommendation

Consider enforcing a minimum collateral amount for stabilizer positions

6.7 Stabilizers Can Set a Minimum Collateralization Ratio That Makes Their Positions Immediately Liquidatable

FileFix commitTypeStatus
/src/StabilizerNFT.solf14708aMediumFixed

The StabilizerNFT contract allows a stabilizer to set a minimum collateralization ratio ( minCollateralRatio) for their position using the setMinCollateralizationRatio(...) function. This ratio determines the collateral level when their funds are allocated via allocateStabilizerFunds(...). Another feature, liquidatePosition(...), allows users to liquidate positions that are under a specific collateralization threshold.

The problem is a logical flaw where the minimum allowed minCollateralRatio is lower than the liquidation thresholds available to lower NFT Id holders. A stabilizer can set their minCollateralRatio to 11000 (110%), which is the lowest permitted value.

function setMinCollateralizationRatio(
uint256 tokenId,
uint256 newRatio
) external {
require(ownerOf(tokenId) == msg.sender, "Not token owner");
// @audit-issue A user can set a ratio of 11000 (110%).
require(newRatio >= 11000 && newRatio <= 100000, "Ratio must be at between 110.00% and 1000%");
// ...
positions[tokenId].minCollateralRatio = newRatio;
// ...
}

However, the liquidation system gives an advantage to holders of low-numbered NFTs. The owner of liquidatorTokenId 1 can liquidate any position with a collateral ratio below 12500 (125%), and this threshold decreases for higher token IDs.

function liquidatePosition(...) external {
// ...
uint256 calculatedThreshold;
if (liquidatorTokenId == 0) {
calculatedThreshold = 11000;
} else {
// ...
if (((liquidatorTokenId - 1) * 50) >= (12500 - 11000)) {
calculatedThreshold = 11000;
} else {
// @audit The threshold can be as high as 12500 for liquidatorTokenId 1.
calculatedThreshold = 12500 - ((liquidatorTokenId - 1) * 50);
}
}

// @audit A position with a ratio of 11000 will pass this check if the
// liquidator has a privileged token ID (e.g., ID 1-30).
require(
positionCollateralRatio < calculatedThreshold,
"Position not below liquidation threshold"
);
// ...
}

This creates a scenario where a stabilizer setting their minimum collateralization ratio to 110% will find their position immediately liquidatable by any NFT holder with an ID between 1 and 30, because their set minimum (110%) is below the liquidation thresholds for those NFT IDs (e.g., ID 30's threshold is 110.5%). These positions become available for immediate liquidation, despite the stabilizer's intention to set a reasonable minimum.

Recommendation

Consider increasing the minimum value for newRatio in the setMinCollateralizationRatio(...) function. The minimum should be greater than the highest possible liquidation threshold.

6.8 Misconfigured Signer Can Cause Denial of Service in attestationService(...)

FileFix commitTypeStatus
/src/PriceOracle.sol1218e1cLowFixed

The attestationService(...) function in the PriceOracle contract is called by other protocol contracts to verify signed price data supplied by end-users. The function uses the priceQuery.dataTimestamp field to prevent two types of issues: data being too old (staleness) and old data being replayed after newer data has already been submitted.

The function correctly checks that the provided dataTimestamp is not too far in the past relative to block.timestamp. However, it lacks a corresponding check to ensure the dataTimestamp is not unreasonably far in the future.

function attestationService(
PriceAttestationQuery calldata priceQuery
) public payable whenNotPaused returns (PriceResponse memory) {
// ...

// @audit A misconfigured signer can set `lastAttestationTimestamp` to a large future value,
// causing this check to fail for all subsequent legitimate calls.
if ((priceQuery.dataTimestamp + 1000 * 15) < lastAttestationTimestamp) {
revert StaleAttestation(
lastAttestationTimestamp,
priceQuery.dataTimestamp
);
}

// ...

// @audit This update allows a misconfigured signer to poison the contract's state
// with a large future timestamp, breaking the replay protection.
lastAttestationTimestamp = priceQuery.dataTimestamp;
// ...
}

This could lead to a DoS if a signer is misconfigured. For instance, a bug in the off-chain signing software or an incorrect system clock could cause a signer to submit an attestation with a large future timestamp. If this transaction is processed, it would set lastAttestationTimestamp to this incorrect future value. Consequently, all subsequent attestations from correctly configured signers would fail the replay protection check causing them to revert. This would unintentionally halt all price updates and disrupt protocol operations that rely on the oracle

Recommendation

Consider adding an upper-bound check on priceQuery.dataTimestamp within the attestationService(...) function.

6.9 Stale yield factor from queued messages can devalue L2 USPD

FileFix commitTypeStatus
/src/BridgeEscrow.sol—LowAcknowledged

The protocol allows USPD tokens to be bridged from L1 to L2 through Wormhole's NTTManager (Native Token Transfer Manager) contract. When tokens are bridged, the message payload includes the token amount and the current L1 yieldFactor. On L2, the releaseShares(...) function in the BridgeEscrow contract mints the bridged tokens and updates the L2's yield factor to match the one sent from L1.

function releaseShares(...) external nonReentrant {
// ...
if (block.chainid == MAINNET_CHAIN_ID) {
// ...
} else {
// L2 (Satellite Chain): Bridging from L1 to L2.
IcUSPDToken(address(cUSPDToken)).mint(recipient, cUSPDShareAmount);
totalBridgedInShares += cUSPDShareAmount;

// @audit The yield factor from the message is used to update the rate contract.
rateContract.updateL2YieldFactor(l2YieldFactor);
}
// ...
}

The Wormhole NTTManager contract has a rate-limiting feature. If the rate of incoming transfers to an L2 chain goes over a set limit, the transfer message is not handled right away but is placed in a queue. This queued message contains the yieldFactor from when it was started on L1 and can be run later by anyone.

// NttManager.sol

function _handleTransfer(
uint16 sourceChainId,
bytes32 sourceNttManagerAddress,
TransceiverStructs.NttManagerMessage memory message,
bytes32 digest
) internal {
TransceiverStructs.NativeTokenTransfer memory nativeTokenTransfer =
TransceiverStructs.parseNativeTokenTransfer(message.payload);

// verify that the destination chain is valid
if (nativeTokenTransfer.toChain != chainId) {
revert InvalidTargetChain(nativeTokenTransfer.toChain, chainId);
}
uint8 toDecimals = tokenDecimals();
TrimmedAmount nativeTransferAmount = (nativeTokenTransfer.amount.untrim(toDecimals)).trim(toDecimals, toDecimals);

address transferRecipient = fromWormholeFormat(nativeTokenTransfer.to);

uint256 receivedYield = _handleAdditionalPayload(
sourceChainId, sourceNttManagerAddress, message.id, message.sender, nativeTokenTransfer
);

bool enqueued = _enqueueOrConsumeInboundRateLimit(
digest, sourceChainId, nativeTransferAmount, transferRecipient, receivedYield
);

==> if (enqueued) {
return;
}

_mintOrUnlockToRecipient(
digest, transferRecipient, nativeTransferAmount, false, sourceChainId, receivedYield
);
}

This creates a situation where a delayed bridge message can update the L2 yieldFactor with a stale value. For example, a transfer is started on L1, but due to rate limits, its execution is queued on L2. While it's in the queue, other transfers are handled, and the L2 yieldFactor is correctly updated. If the queued message with the old (and potentially lower) yieldFactor is executed, it'll overwrite the correct/the most up-to-date yieldFactor value.

This would cause the USPDToken.balanceOf(...) function on L2 to return lower values for all holders, as the balance depends on shares multiplied by the yield factor. If USPD token on L2 is used as collateral in DeFi protocols for lending and borrowing, this sudden change in the USPD balance can lead to liquidation of user positions.

Recommendation

Consider implementing staleness checks for yieldFactor on L2. This staleness check would prevent an older message from updating the more recent yieldFactor.

Resolution

We had a staleness check in there that prevented a lower yieldFactor being set than the existing one. Now, we removed that, because in an unknown situation where the stETH yield actually gets negative for some reason, we are still stuck with the lockstep for stETH yield and if the stETH yield is negative so is ours. It is going to be a known issue, no fix for the time being.

6.10 The function setLiquidationParameters should enforce _payoutPercent to be strictly greater than 100

FileFix commitTypeStatus
/src/StabilizerNFT.sol531e688InfoFixed

The setLiquidationParameters function allows the admin to update the value of liquidationLiquidatorPayoutPercent, which determines the reward payout for liquidators when they liquidate under-collateralized positions:

function setLiquidationParameters(uint256 _payoutPercent /* Removed _thresholdPercent */) external onlyRole(DEFAULT_ADMIN_ROLE) {
==> require(_payoutPercent >= 100, "Payout percent must be >= 100");

liquidationLiquidatorPayoutPercent = _payoutPercent;
emit LiquidationParametersUpdated(_payoutPercent);
}

Currently, the function permits setting the payout percent to exactly 100%, which would mean liquidators receive no incentive for performing liquidations. This will discourage participation from liquidators.

Recommendation

Consider updating the validation check to require _payoutPercent to be strictly greater than 100, ensuring that liquidators always receive a positive incentive for their participation.

6.11 Address is used before being validated

FileFix commitTypeStatus
/src/StabilizerNFT.sol531e688Best PracticesFixed

In the StabilizerNFT contract's liquidatePosition(...) function, it fetches the current price of stETH from an oracle contract. The address of this oracle is retrieved by calling cuspdToken.oracle().

The function first attempts to call the attestationService(...) function on the oracle address returned by cuspdToken.oracle(). Only after this call does it check whether the returned oracle address is a non-zero address. The addresses should be validated before they are used in interactions.

function liquidatePosition(
// ...
) external {
// ...
// @audit The address returned by `cuspdToken.oracle()` is used before it is validated.
IPriceOracle.PriceResponse memory priceResponse = IPriceOracle(cuspdToken.oracle()).attestationService(priceQuery);
// @audit The check for a zero address occurs after the address has already been used.
require(address(cuspdToken.oracle()) != address(0), "Oracle not set in cUSPDToken");
require(priceResponse.price > 0, "Invalid oracle price");
// ...
}

Recommendation

Consider validating the oracle address before using it.

6.12 The shortfall condition in liquidatePosition is always true and can safely be removed

FileFix commitTypeStatus
/src/StabilizerNFT.sole20ed01Best PracticesFixed

In the liquidatePosition(...) function, the logic for distributing collateral to a liquidator is split into two main branches. The else block at line is executed only when the actual backing collateral is insufficient to cover the liquidator's target payout ( actualBackingStEth < targetPayoutToLiquidator).

However, inside this else block, another check is performed:

function liquidatePosition(
// ...
) external {
// ...
{
// ...
if (actualBackingStEth >= targetPayoutToLiquidator) {
// ...
} else {
// ...

// @audit This check is always true because the code is in the `else`
// block of the check above.
==> if (targetPayoutToLiquidator > actualBackingStEth) {
uint256 shortfallAmount = targetPayoutToLiquidator - actualBackingStEth;
// ...
}
}
}
// ...
}

This condition is guaranteed to be true because it is the same condition that caused this branch of the code to be executed in the first place.

This redundant check consumes unnecessary gas and adds a small amount of bytecode.

Recommendation

Consider removing the redundant if condition.

7

Documentation Evaluation

Software documentation refers to the written or visual information that describes the functionality, architecture, design, and implementation of software. It provides a comprehensive overview of the software system and helps users, developers, and stakeholders understand how the software works, how to use it, and how to maintain it. Software documentation can take different forms, such as user manuals, system manuals, technical specifications, requirements documents, design documents, and code comments. Software documentation is critical in software development, enabling effective communication between developers, testers, users, and other stakeholders. It helps to ensure that everyone involved in the development process has a shared understanding of the software system and its functionality. Moreover, software documentation can improve software maintenance by providing a clear and complete understanding of the software system, making it easier for developers to maintain, modify, and update the software over time. Smart contracts can use various types of software documentation. Some of the most common types include:

  • Technical whitepaper: A technical whitepaper is a comprehensive document describing the smart contract’s design and technical details. It includes information about the purpose of the contract, its architecture, its components, and how they interact with each other;
  • User manual: A user manual is a document that provides information about how to use the smart contract. It includes step-by-step instructions on how to perform various tasks and explains the different features and functionalities of the contract;
  • Code documentation: Code documentation is a document that provides details about the code of the smart contract. It includes information about the functions, variables, and classes used in the code, as well as explanations of how they work;
  • API documentation: API documentation is a document that provides information about the API (Application Programming Interface) of the smart contract. It includes details about the methods, parameters, and responses that can be used to interact with the contract;
  • Testing documentation: Testing documentation is a document that provides information about how the smart contract was tested. It includes details about the test cases that were used, the results of the tests, and any issues that were identified during testing;
  • Audit documentation: Audit documentation includes reports, notes, and other materials related to the security audit of the smart contract. This type of documentation is critical in ensuring that the smart contract is secure and free from vulnerabilities.

These types of documentation are essential for smart contract development and maintenance. They help ensure that the contract is properly designed, implemented, and tested, and they provide a reference for developers who need to modify or maintain the contract in the future.

8

Remarks & Test Suite

Morpher team provided an overview of the main system components during the kick-off call with a detailed explanation of the intended functionalities along with a written specification for the different contracts and roles. Moreover, the team addressed all questions and concerns raised by the Nethermind Security team, providing valuable insights and a comprehensive understanding of the project's technical aspects.

The Morpher's test suite is comprehensive and clearly outlines the contract's specification. To assess the test coverage for the audited files, the Nethermind Security team applied a technique called mutation testing to uncover the untested paths. This technique introduces slight modifications to the code called "mutations" or "mutants." An example of a mutation is, for example, changing the operator in an expression (a + b) to (a - b), or removing a require(a > b) statement entirely from the code. With these changes, the code no longer follows the expected business logic of the application, and the test suite should reflect that by failing.

Evaluation of the test suite with mutation testing consists of two phases:

  1. Generating the modified version of each contract, called "mutants."
  2. Inserting the mutant into the original codebase and running the test suite.

Only one modification can be tested at a time. If the contract has ten mutations, the test suite must run ten times (once for every mutation). If any of the tests fail, it means that the test suite caught the change in the code. Whenever that happens, the particular mutant is considered "slain" or "killed" and is removed from the mutant's set. If that does not occur, a new test case can be added to cover the code branch to "kill" the mutant.

The following table outlines the results of the analysis performed on Morpher's smart contracts. The first column lists the contracts that were tested using mutation testing. The second column indicates the number of mutants generated and how many were "slain" (i.e., caught by the test suite). The third column provides the percentage of mutants slain, reflecting the effectiveness of the test suite in covering the particular contract. The higher the score, the better the test suite is at finding bugs.

ContractMutants (slain / total generated)Score
src/BridgeEscrow.sol62 / 7286.11%
src/InsuranceEscrow.sol14 / 14100.00%
src/OvercollateralizationReporter.sol169 / 17795.48%
src/PoolSharesConversionRate.sol35 / 3892.10%
src/PositionEscrow.sol182 / 19792.38%
src/PriceOracle.sol162 / 18488.04%
src/StabilizerEscrow.sol26 / 3183.87%
src/StabilizerNFT.sol626 / 80078.25%
src/UspdToken.sol131 / 14192.90%
src/cUSPDToken.sol151 / 16790.41%
Total1558 / 182185.55%
Test suite
316Tests passed0Failed

The complete test output is in the full report.

As auditors, we recommend that any changes or updates made to the audited codebase undergo a re-audit or security review to address potential vulnerabilities or risks introduced by the modifications. By conducting a re-audit or security review of the modified codebase, you can significantly enhance the overall security of your system and reduce the likelihood of exploitation. However, we do not possess the authority or right to impose obligations or restrictions on our clients regarding codebase updates, modifications, or subsequent audits. Accordingly, the decision to seek a re-audit or security review lies solely with you.

This report is based on the scope of materials and documentation provided by you to Nethermind in order that Nethermind could conduct the security review outlined in 1. Executive Summary and 2. Audited Files. The results set out in this report may not be complete nor inclusive of all vulnerabilities. Nethermind has provided the review and this report on an as-is, where-is, and as-available basis. You agree that your access and/or use, including but not limited to any associated services, products, protocols, platforms, content, and materials, will be at your sole risk. Blockchain technology remains under development and is subject to unknown risks and flaws. The review does not extend to the compiler layer, or any other areas beyond the programming language, or other programming aspects that could present security risks. This report does not indicate the endorsement of any particular project or team, nor guarantee its security. No third party should rely on this report in any way, including for the purpose of making any decisions to buy or sell a product, service or any other asset. To the fullest extent permitted by law, Nethermind disclaims any liability in connection with this report, its content, and any related services and products and your use thereof, including, without limitation, the implied warranties of merchantability, fitness for a particular purpose, and non-infringement. Nethermind does not warrant, endorse, guarantee, or assume responsibility for any product or service advertised or offered by a third party through the product, any open source or third-party software, code, libraries, materials, or information linked to, called by, referenced by or accessible through the report, its content, and the related services and products, any hyperlinked websites, any websites or mobile applications appearing on any advertising, and Nethermind will not be a party to or in any way be responsible for monitoring any transaction between you and any third-party providers of products or services. As with the purchase or use of a product or service through any medium or in any environment, you should use your best judgment and exercise caution where appropriate. FOR AVOIDANCE OF DOUBT, THE REPORT, ITS CONTENT, ACCESS, AND/OR USAGE THEREOF, INCLUDING ANY ASSOCIATED SERVICES OR MATERIALS, SHALL NOT BE CONSIDERED OR RELIED UPON AS ANY FORM OF FINANCIAL, INVESTMENT, TAX, LEGAL, REGULATORY, OR OTHER ADVICE.