Organization
- @liquidops
Engagement Type
Spearbit Web3
Period
-
Repositories
N/A
Researchers
Findings
High Risk
3 findings
3 fixed
0 acknowledged
Medium Risk
11 findings
9 fixed
2 acknowledged
Low Risk
24 findings
14 fixed
10 acknowledged
Informational
22 findings
14 fixed
8 acknowledged
Gas Optimizations
3 findings
3 fixed
0 acknowledged
High Risk3 findings
Borrowing interest is calculated inaccurately and can be manipulated
Description
The system keeps track of each user's loan and the accrued interest individually. Each user has a corresponding last-update timestamp, which is compared with the current timestamp to calculate the newly accrued interest only when the user's position is updated, e.g., when borrowing funds or repaying debt.
The accrued interest during a period is calculated as:
interestAccrued = debt * (utilizationRate * baseRate + initRate) * delay / oneYearInMswhere
utilizationRate = TotalBorrows / (TotalBorrows + Cash)
, i.e., the market's current utilization rate. Thedelay
variable represents the time difference between the user's current and last actions.However, using the current utilization rate to calculate the interest in the past
delay
period is incorrect. It assumes that the rate has not been changed since the last user's action, which, however, is not always the case because other users' actions can arbitrarily change the market's state.Consider the following example:
- t = 0, User A borrows. The utilization rate is 50%.
- t = 100, User B borrows. The utilization rate becomes 90%.
- t = 101, User A repays. The interests are calculated using a rate of 90%, but it was 50% most of the time. User A ends up paying more interest.
Additionally, an attacker could exploit this design to lower the interest rate to a number close to
initRate
. Before repaying their debt, they could mint a large number of oTokens with another account to decrease the market's utilization rate as much as possible. After repaying the debt, they redeem the underlying tokens without incurring a loss.Recommendation
Based on how the interest is calculated, the system should be designed to update every user's debt whenever the market's utilization rate or the state has changed. Compound V2's design is an example:
- Every user has a corresponding
interestIndex
, and the protocol keeps track of a globalborrowIndex
. - Before performing any action that will change the market's state, the system accrues interest by increasing the
borrowIndex
using the formula above. - The user's debt is calculated as
borrowBalance * borrowIndex / interestIndex
, which updates automatically when theborrowIndex
increases. - When a user borrows, their
borrowBalance
is first updated to include the accrued interest so far and then increased by the borrowed amount. Next, theirinterestIndex
is set to the globalborrowIndex
. The process is similar when a user repays their debt.
LiquidOps
Fixed in PR 27.
Spearbit
Verified.
Incorrect Tag Parsing in Interest Sync
Description
In the
interest.lua
contract, thesyncInterests
function incorrectly checks for a repayment action by evaluatingmsg.Tags.Action == "Repay"
. However, according to the messaging conventions established by the protocol (specifically theborrow-loan-interest-sync-dynamic
pattern), the repayment intent is signaled via theX-Action
tag, not theAction
tag. As a result, the intended conditional branch for interest synchronization on repayments is bypassed, and execution falls through to the defaultelse
block.This default case uses
msg.From
as the interest update target, which in repayment flows refers to the collateral token process—not the borrower. Consequently, the system updates the interest balance for an incorrect user and skips the actual borrower, allowing them to underpay or bypass interest accrual entirely.Additionally, there is no validation to ensure that
msg.Tags.Sender
ormsg.Tags["X-On-Behalf"]
is a properly formed address before updating interest. This opens the door to further inconsistencies or even execution errors if unexpected data is received in those fields.Recommendation
The logic in
syncInterests
should be corrected to checkmsg.Tags["X-Action"] == Repay
, in combination with Action == Credit-Notice andmsg.From == CollateralID
before falling back tomsg.Tags.Action
, ensuring that repayment actions are properly routed. WhenX-Action
is"Repay"
, the function should also validate that eithermsg.Tags["X-On-Behalf"]
ormsg.Tags.Sender
is a valid address using the existingassertions.isAddress
helper before proceeding with the interest update.This ensures the correct borrower’s interest is updated, prevents misuse via malformed tags, and aligns with the repayment flow conventions of the protocol.
LiquidOps
Code is removed in PR 27 due to redesign.
Spearbit
Verified.
Partial liquidations allow position management blocking and liquidation prevention due to queue mechanism implementation
State
- Fixed
PR #26
Severity
- Severity: High
Submitted by
Christos Pap
Description
The
LiquidOps
protocol allows partial liquidations where only a portion of a loan is liquidated. When a liquidation is initiated, the target user is added to aLiquidationQueue
to prevent duplicate liquidations:-- Add the target to the liquidation queue to lock further liquidationstable.insert(LiquidationQueue, target)The user remains in this queue until the liquidation is complete, at which point they are removed:
-- Remove target from liquidation queue to allow further liquidationsLiquidationQueue = utils.filter(function (v) return v ~= target end,LiquidationQueue)While a user is in the
LiquidationQueue
, they are prevented from performing certain operations, as many protocol actions check if the user is queued:-- check-queue handler-- the user is queued if they're either in the collateral-- or the liquidation queuesreturn msg.reply({["In-Queue"] = json.encode(utils.includes(user, CollateralQueue) orutils.includes(user, LiquidationQueue))})Due to lack of minimum liquidation size requirements, this implementation allows for two attack vectors:
- An attacker could liquidate a minimal amount of a user's position to forcefully add them to the queue, temporarily preventing them from performing critical actions such as repaying loans or managing collateral.
- Users at risk of liquidation could create multiple accounts to perform minimal self-liquidations (as direct self-liquidation is not allowed from the protocol), effectively preventing other liquidators from capturing the full liquidation incentives and potentially preventing the liquidation of undercollateralized positions.
Recommendation
Implement a minimum liquidation threshold as a percentage of the total outstanding loan (e.g., 20%) to make these attacks economically unfeasible. While this does not fully mitigate the issue, it makes the attack much more expensive to perform.
LiquidOps
Fixed in PR 26.
Spearbit
Verified, as the requirement of a 20% minimum liquidation threshold makes the attack vectors economically infeasible.
Medium Risk11 findings
No liquidation incentives if the discount interval has passed
State
Severity
- Severity: Medium
≈
Likelihood: Medium×
Impact: Medium Submitted by
Eric Wang
Description
When a position is liquidated, a
discount
factor is calculated based on the time that has passed since the start of the auction. The auction follows a Dutch auction design, where thediscount
factor decreases linearly to zero if a configured period,DiscountInterval
has passed.However, it is better to keep a non-zero discount even if the period has passed, which would ensure that liquidators have an incentive to liquidate underwater positions in the system. Otherwise, without being liquidated, the position's debt could increase over time and potentially become bad debt, which puts the protocol at risk of insolvency.
Recommendation
Consider defining a variable as the minimum discount factor, which can be configurable by the controller or admin, and setting the
discount
variable to this value when a specific interval has passed.LiquidOps
Fixed in commit 85e9fdb.
Spearbit
Verified.
Incorrect implementation of supplyRate()
State
Severity
- Severity: Medium
Submitted by
Eric Wang
Description
The
supplyRate()
function ininterest.lua
calculates the supply rate based on the current borrow and utilization rates. However, the implementation is incorrect and has two issues:- At L54,
borrowRateFloat
is calculated asborrowRate / rateMul
and, therefore, does not need to be divided byrateMul
again at L60. - At L61, the utilization rate is incorrectly calculated as
TotalBorrows / TotalSupply / 10 ^ CollateralDenomination
. It should beTotalBorrows / (TotalBorrows + Cash)
by definition.
Recommendation
Consider modifying the
supplyRate()
function as suggested above.LiquidOps
Fixed in PR 24.
Spearbit
Verified.
Incorrect rounding direction when calculating the seized collateral from the borrower
State
Severity
- Severity: Medium
≈
Likelihood: Low×
Impact: High Submitted by
Eric Wang
Description
The
liquidatePosition()
function inliquidate.lua
transfers out the underlying token of a givenquantity
. It calculates how many oTokens the transferred underlying tokens are worth and subtract that value from the borrower's position. The value,qtyValueInoToken
, is calculated as:-- get supplied quantity value-- (total supply / total pooled) * incominglocal qtyValueInoToken = bint.udiv(totalSupply * quantity,totalPooled)where
totalSupply
is the oToken's total supply, andtotalPooled
is the sum of total borrows and cash in the market.The
bint.udiv()
performs an integer division with the result rounded down, which benefits the borrower as their oToken balance is subtracted with a smaller value. The rounding error may be insignificant iftotalSupply
andtotalPooled
are large enough. However, in an empty-market attack scenario, where the attacker artificially inflates the market's exchange rate, the rounding error could become large enough to cause issues with the protocol.For example, assuming an attacker can inflate the exchange rate such that 1 oToken is worth 100,000 underlying tokens. If the repay value is less than 100,000 underlying tokens,
qtyValueInoToken
will be 0. As a result, the borrower will not incur a loss while being liquidated. However, the oToken still transfers thequantity
amount of underlying tokens to the liquidator.Regarding how an attacker can inflate the exchange rate on an empty market, see the finding Security considerations of exchange-rate inflation in empty markets.
Recommendation
Consider implementing a helper function that rounds up the division result to calculate the
qtyValueInoToken
value. Also, consider implementing security measures and checks to mitigate potential exchange-rate inflation attacks on empty markets.LiquidOps
Fixed in commit 767ffbd.
Spearbit
Verified.
Missing Invariant Check for Liquidation Threshold
State
Severity
- Severity: Medium
Submitted by
Cryptara
Description
The configuration logic found in both
controller.lua
andconfig.lua
fails to enforce a critical invariant for lending safety: theLiquidationThreshold
must be strictly greater than theCollateralFactor
. This condition is necessary to ensure that borrowers are always at risk of liquidation before their collateral value drops below the borrowed amount, giving the protocol a safe buffer to cover debt positions during adverse price movements.Without this invariant enforced, an administrator may configure the pool with a
CollateralFactor
equal to or greater than theLiquidationThreshold
, effectively allowing users to borrow more than the system can safely reclaim in a liquidation event. This misconfiguration directly undermines the protocol’s solvency assumptions and can lead to irrecoverable bad debt, especially during fast market downturns where prices can gap through the liquidation margin.Furthermore, the current inline documentation in
config.lua
is misleading. It comments that the "liquidation threshold (should be lower than the collateral factor)", which is incorrect and contradicts the intended safety model. The correct relationship is that theLiquidationThreshold
should always be strictly higher than theCollateralFactor
.Recommendation
The configuration validation logic should include an explicit check to enforce that
LiquidationThreshold > CollateralFactor
both in the main controller's token listing (controller.lua
) and within the dynamic update routine (config.lua
). If the condition is not met, the system should reject the update and return a descriptive error message to prevent misconfiguration.For example, in both configuration paths:
assert(liquidationThreshold > collateralFactor,"Liquidation threshold must be greater than the collateral factor")This preserves the health of the lending pool and ensures that all borrow positions remain recoverable in a liquidation scenario.
Lastly, update the documentation line in
config.lua
to reflect the correct logic:-- liquidation threshold (should be greater than the collateral factor)LiquidOps
Invariant check was added in PR 24.
Spearbit
Verified.
Security considerations of exchange-rate inflation in empty markets
State
- Acknowledged
Severity
- Severity: Medium
Submitted by
Eric Wang
Description
An empty market refers to a market with little or no liquidity, which can occur when a new market is created and listed without initial liquidity from the creator, etc. One of the most common vulnerabilities of empty markets is exchange-rate inflation, i.e., inflating the
totalPooled
amount while keepingtotalSupply
as a small number, which can be done through direct donations or stealth donations.Specifically, stealth donations occur due to rounding errors when users mint or redeem tokens, which increases the exchange rate. For example, assuming an attacker holds 1 oToken (in the smallest unit), and the initial exchange rate is 2, i.e., 1 oToken is worth 2 underlying tokens. The attacker can:
- Mint 0 oToken with 1 underlying token. The exchange rate becomes 3.
- Mint 0 oToken with 2 underlying tokens. The exchange rate becomes 5.
- Mint 0 oToken with 4 underlying tokens. The exchange rate becomes 9.
and so on. The attacker will be able to inflate the exchange rate exponentially. Although the attacker effectively donates their funds to the market through minting, they do not incur a loss as they hold the only oToken, which can redeem all the underlying tokens afterward.
If the markets have an initial exchange rate of 1, the attacker could first mint some oTokens and borrow the underlying tokens with some collateral in the other markets. After one block has passed, interest will be accrued, causing the exchange rate to exceed 1. The attacker then burns all the tokens except 1, resulting in 2 underlying tokens left in the market and, therefore, achieving the initial condition.
Exchange-rate inflation could lead to security issues when combined with other vulnerabilities, e.g., incorrect rounding directions. Compound V2 implemented an incorrect rounding direction in the redeem function, which has caused several incidents on Compound V2 forks, i.e., the empty market attack. The attackers were able to inflate the exchange rate first to increase the impact caused by the incorrect rounding direction and, therefore, redeem their donated tokens and left the position with a large debt.
For the reviewed protocol, the rounding direction when a user redeems an underlying token is correct. However, we identified an incorrect rounding direction in calculating the seized collateral from a borrower, which can potentially cause a more significant impact when combined with exchange-rate inflation. See the finding "Incorrect rounding direction when calculating the seized collateral from the borrower".
Every market should have sufficient liquidity to mitigate exchange-rate inflation attacks. Note that stealth donations are still possible even though the markets have enough liquidity. However, it would be inefficient or impractical for attackers to execute, and they are also more likely to incur a loss due to donations since they do not hold most of the oTokens.
Recommendation
Consider following this procedure when listing new markets:
- Configure the collateral factor as 0 during the market's setup. Alternatively, the
pool.setup()
function could explicitly setCollateralFactor
to 0. - Mint enough oTokens (e.g., 10^12 or more) and keep them securely or send it to the "zero" address to make them unavailable permanently.
- Set the collateral factor to a positive value to enable the market when necessary.
LiquidOps
Acknowledged, we will enforce this practice when listing new tokens.
Spearbit
Acknowledged.
Incomplete Validation in isAddress()
State
Severity
- Severity: Medium
Submitted by
Eric Wang
Description
The
isAddress()
function inassertions.lua
validates the input and returns whether the input is a valid address string. However, there are two issues in the implementation:- At L10, the comparison
not type(addr) == "string"
should betype(addr) ~= "string"
instead since thenot
operation has higher precedence than==
. - At L12, the regex check should be
string.match(addr, "^[A-z0-9_-]+$")
, i.e., including the^
and$
metacharacters, to ensure that all characters are valid.
Recommendation
Consider implementing the above suggestions.
LiquidOps
Fixed in commit a713065.
Spearbit
Verified.
Incorrect Update of Auction List after Liquidation
State
Severity
- Severity: Medium
Submitted by
Eric Wang
Description
When liquidating a position, the
removeWhenDone
variable indicates whether the position should be removed from the auction list after the current liquidation. According to the comment:the auction needs to be removed if there will be no loans left when the liquidation is complete
Based on this comment, the comparison at L690 should be
== nil
instead. It is because if the position has no action loan after the liquidation, theutil.find()
function would return anil
.However, instead of checking if the position has an active loan, a more accurate way is to check if the position would become healthy after liquidation. The position should be kept on the auction list if it is still unhealthy. Otherwise, it should be removed. The current method would still keep a healthy position with an active loan on the auction list.
Recommendation
Consider fixing the comparison at L690 or implementing the logic based on whether the position would become healthy.
LiquidOps
Fixed in commit 6e6e9db.
Spearbit
Verified.
Position enumeration in sync-auctions handler can lead to DoS when processing large numbers of positions
State
- Fixed
PR #24
Severity
- Severity: Medium
Submitted by
Christos Pap
Description
The
LiquidOps
protocol'ssync-auctions
handler performs a scan of all market positions to identify underwater positions eligible for liquidation. This handler executes a computationally intensive process that:- Fetches all token prices from the oracle
- Queries all positions across all markets
- Processes and aggregates every user position
- Evaluates each position's liquidation eligibility
-- POSITION DATA COLLECTION-- Prepare to query all positions across all markets (oTokens)-- by creating message requests for each market in the system.---@type MessageParam[]local positionMsgs = {}for _, token in ipairs(Tokens) dotable.insert(positionMsgs, { Target = token.oToken, Action = "Positions" })end-- MULTI-MARKET POSITION FETCHING-- Execute all the position queries in parallel and collect responses.-- Each response contains all user positions in a specific market.---@type Message[]local rawPositions = scheduler.schedule(table.unpack(positionMsgs))This position scanning process scales linearly with the number of positions (
O(n)
complexity), resulting in performance degradation as the position count increases. An attacker could create numerous small positions across different markets, forcing the system to process each one during liquidation checks. Since the handler iterates through all positions, this could significantly slow down the protocol's operations when the number of positions grows large.Importantly, this scaling issue can arise not only from malicious activity but also naturally as the protocol gains adoption and more users create positions.
Additionally, the protocol doesn't clear positions when they're fully repaid, instead keeping them with zero values, which may lead to unnecessary processing of empty positions and wasted storage resources.
Recommendation
To mitigate these issues, consider implementing some of the following changes:
- Enforce a minimum size for opening positions, and require that positions either remain above this threshold or be closed entirely.
- When positions are fully repaid, remove them from storage entirely (set to
nil
) rather than keeping them with zero values. - Move the
sync-auction
handler into a separate process to avoid the controller being DoS'ed. - Implement an upper limit on the number of users that can be processed in a single message (for example process only
X
users at a time), allowing for batch processing that prevents excessive computational consumption and timeouts. - Perform stress tests to quantify the maximum number of positions the
sync-auctions
handler can process within reasonable time and resource limits under various load conditions. - Consider introducing a small fee for invoking the
sync-auctions
function to disincentivize spamming or excessive calls while evaluating if such a fee might negatively impact the protocol's health by discouraging legitimate liquidations when they are needed.
LiquidOps
Partially fixed in PR24.
Spearbit
Verified. The fix now removes the value from storage entirely.
Tokens not always refunded
State
Severity
- Severity: Medium
Submitted by
Gerard Persoon
Description
In
process.lua
tokens are refunded whenFrom ~= CollateralID
.However if transfers do have
From == CollateralID
, theX-tag
could be specified incorrectly or could not be forwarded due to a bug in the token contract.In
controller.lua
there is no code to refund tokens if theX-tag
isn'tLiquidate
. This can happen with the supplied libraries, see findingTag "X-Action" = "Liquidate" missing in Javascript library
.If the tokens aren't returned then they stay stuck in
process.lua
/controller.lua
, although they can be recovery by the protocol viabatch-update
and other management actions.Recommendation
Consider refunding tokens in more situations, for both
process.lua
andcontroller.lua
.UpdateInProgress only checked in a limited number of situations
State
- Acknowledged
Severity
- Severity: Medium
Submitted by
Gerard Persoon
Description
UpdateInProgress
is only checked in a limited number of situations: only during "Add-To-Queue", which is send inBorrow()
,Transfer()
andRedeem()
.However the updates are powerful and can perform an arbitrary action on all
oTokens
. Such an update could influence all actions.Recommendation
Consider checking
UpdateInProgress
for all actions.Also consider adding a check for
UpdateInProgress
in the function to handle "Check-Queue-For".LiquidOps
Acknowledged. We will not remediate the entire issue here, since actions that don’t influence user positions would just run normally. Additionally, actions that happen instantly and don’t use coroutines will just run on the new version (fine for us, since they arrived later) and any other action should use the queue.
Spearbit
Acknowledged.
Potential race condition between repayments and liquidations due to missing queue check, may allow incorrect accounting
State
- Fixed
PR #25
Severity
- Severity: Medium
Submitted by
Christos Pap
Description
The LiquidOps protocol has a potential race condition between loan repayments and liquidations that can lead to accounting inconsistencies. The issue exists because the
repay.handler
function does not check if the user is in the controller'sLiquidationQueue
before processing a repayment.While the initial transfer handler is wrapped with
queue.useQueue()
which performs queue checks, the internalrepay.handler
that processes theCredit-Notice
withX-Action = "Repay"
lacks this protection:Handlers.advanced({name = "borrow-repay",pattern = {From = CollateralID,Action = "Credit-Notice",["X-Action"] = "Repay"},handle = repay.handler,errorHandler = repay.error})In the
repay.handler
function, no queue check is performed:function repay.handler(msg)assert(assertions.isTokenQuantity(msg.Tags.Quantity),"Invalid incoming transfer quantity")-- quantity of tokens suppliedlocal quantity = bint(msg.Tags.Quantity)-- allow repaying on behalf of someone elselocal target = msg.Tags["X-On-Behalf"] or msg.Tags.Sender-- check if a loan can be repaid for the targetassert(repay.canRepay(target, msg.Timestamp),"Cannot repay a loan for this user")-- execute repaylocal refundQty, actualRepaidQty = repay.repayToPool(target,quantity,true)-- Rest of the function...}Meanwhile, the controller's liquidation process adds users to the
LiquidationQueue
during liquidation:-- Add the target to the liquidation queue to lock further liquidationstable.insert(LiquidationQueue, target)-- ... liquidation processing ...-- Remove target from liquidation queue after completionLiquidationQueue = utils.filter(function (v) return v ~= target end,LiquidationQueue)Impact
This race condition can lead to a an incorrect accounting scenario where both repayment and liquidation succeed simultaneously and is as follows:
sequenceDiagram participant A as User A participant P as Protocol (oToken) participant C as Controller participant B as User B (Liquidator) A->>P: Transfer w/ X-Action="Repay" (Collateral Token) Note over P: Initial handler checks queue: OK B->>C: Transfer w/ X-Action="Liquidate" (Collateral Token) Note over C: Adds User A to LiquidationQueue Note over C: Sends Liquidate-Borrow to oToken P P->>A: (Implicit) Credit-Notice w/ X-Action="Repay" Note over P: repay.handler executes repay.repayToPool<br>Loan balance decreases/cleared<br>Cash increases<br>TotalBorrows decreases P->>B: (Implicit) Credit-Notice w/ X-Action="Liquidate-Borrow" P->>A: Sends Repay-Confirmation P->>B: Sends Liquidate-Confirmation/Error? + Refund?Recommendation
Apply queue checks to all handlers that modify user positions, including the internal
repay.handler
.LiquidOps
Fixed in PR25
Spearbit
Fix looks good as it:
- Merges
CollateralQueue
andLiquidationQueue
into a singleQueue
which simplifies the logic. - It wraps the
repay.handler
andmint.handler
withqueue.useQueue(oracle.withOracle(borrow))
Low Risk24 findings
Insufficient input validation in isCollateralizedWithout()
State
Severity
- Severity: Low
Submitted by
Eric Wang
Description
The
isCollateralizedWithout()
function returns whether a position is still collateralized if a givencapacity
is removed from the position.The function should ensure
removedCapacity
is less than or equal toposition.capacity
. Otherwise, the subtraction would result in a negative value, causing incorrect results from thebint.ule()
function sincebint.ule()
considers both inputs as unsigned integers.Recommendation
Consider changing the function implementation as follows. The difference is in the first condition:
function mod.isCollateralizedWithout(removedCapacity, position)return bint.ult(removedCapacity, position.capacity) andbint.ule(position.borrowBalance, position.capacity - removedCapacity)endLiquidOps
Fixed in commit 7efb85d.
Spearbit
Verified.
Insufficient validation of returned values from tonumber()
State
Severity
- Severity: Low
Submitted by
Eric Wang
Description
Several uses of
tonumber()
are identified in this codebase to convert a value into a number. Since many of the input values are provided by the users, it would be necessary to validate them and ensure the results are valid numbers instead of special values to avoid unexpected results in subsequent operations.Below are some edge cases of the
tonumber()
function:tonumber("1e1000") -> inf
tonumber("-1e1000") -> -inf
tonumber(nan) -> nan
. Note that the inputnan
is not a string.
These values,
inf
,-inf
, andnan
, pass the~= nil
check, e.g.,inf ~= nil
is true in Lua.Recommendation
Consider implementing a utility function that validates the input and the result of
tonumber()
, e.g.,function stringToNumber(s)if type(s) ~= "string" then return false endvalue = tonumber(s)if type(value) ~= "number" then return false end-- Checks inf, -inf, nanif value == math.huge or value == -math.huge or x ~= x then return false endreturn valueendNote that
assertions.isBintRaw()
, which is called fromisTokenQuantity()
, already ensures the result is not infinity ornan
. However, the checks in, e.g.,controller.lua
, are less thorough, so it could be helpful to implement additional checks.LiquidOps
Fixed in commit dc291a1.
Spearbit
Verified.
Potential error and precision loss in getUSDDenominated() due to use of tostring()
State
Severity
- Severity: Low
Submitted by
Eric Wang
Description
The built-in
tostring()
function converts a value to a string. If the input is a floating point number, it does not necessarily output a string of the number in the decimal notation but possibly in the scientific notation. For example,tostring(0.00001)
returns"1e-05"
instead of"0.00001"
.The
getUSDDenominated()
function incorrectly assumes that the returned string is in the decimal notation, which would cause an error when converting the final string intobint()
. Similarly, thegetFractionsCount()
could yield incorrect results.Additionally the precision of the returned string is limited, note the missing decimals in this POC:
val = 12345678.55554444print(val) -- 12345678.555544Recommendation
Consider implementing a helper function that uses format strings to convert numbers into strings. For example,
string.format("%0.16f", val)
would return a string in the decimal notation with 16 digits after the decimal point. Note that the precision is not arbitrary and has limitations since Lua numbers are stored as double-precision floating-point numbers, following the IEEE 754 standard.Also, consider replacing the
tostring()
function with the helper function to ensure the converted string format is correct in other functions in the codebase. For example, theutils.bintToFloat()
function performs string manipulation on atostring()
result, and therefore, it should be ensured that the initial string value has a correct format.Note: check all instances, including
getFractionsCount()
andgetUSDDenominated()
in bothoracle.lua
andcontroller.lua
.LiquidOps
Fixed in commit a1277c8.
Spearbit
Verified.
Potential Division by Zero in Interest Calculation
Description
In the
updateInterest
function of theinterest.lua
contract, the yearly interest for a user is calculated using the expression:local ownedYearlyInterest = bint.udiv(yieldingQty * mod.context.totalLent * mod.context.baseRate,mod.context.totalPooled * mod.context.rateMulWithPercentage) + bint.udiv(yieldingQty * mod.context.initRate,mod.context.rateMulWithPercentage)This expression can result in a division by zero scenario when
mod.context.totalPooled
is zero. Although this is considered an edge case and unlikely under normal operations—sincetotalPooled
is the sum ofTotalBorrows + Cash
—it's theoretically possible during the initial protocol bootstrap or after a mass withdrawal or liquidation event that zeroes out the pooled funds.In such cases, the division operation will throw or produce undefined behavior due to an invalid denominator, which may lead to process failure or halt interest synchronization for users.
Recommendation
To ensure stability and robustness of the protocol, introduce a conditional check before the interest calculation. If
totalPooled == 0
, the dynamic interest component should be skipped, and only the fixed portion (initRate
) should be applied. This protects the protocol from potential runtime errors during abnormal liquidity states.LiquidOps
Fixed in PR 27.
Spearbit
Verified.
Incomplete Token Quantity Validation
State
Severity
- Severity: Low
Submitted by
Cryptara
Description
The
isTokenQuantity
function withinassertions.lua
is intended to validate token amounts before they are used in financial logic. However, its current implementation is overly permissive and fails to reject several invalid or potentially dangerous string formats.Specifically, the function only checks if the first character is
"-"
when evaluating strings, but this allows malformed or misleading values like"+-123"
or" -42"
to pass the validation. These values ultimately result in reverts or incorrect parsing when passed tobint
, but the purpose ofisTokenQuantity
is to act as an early gatekeeper before such operations.The function also accepts strings like
"asdf"
or"123abc"
as valid inputs, as long as they do not start with"-"
, due to the lack of strict numeric parsing.Example of invalid acceptance:
assert.isTokenQuantity("+-123") --> true (should be false)assert.isTokenQuantity("asdf") --> true (should be false)assert.isTokenQuantity(" -1") --> true (should be false)These cases may cause subtle issues depending on the downstream logic or error handling of
bint
.Recommendation
Strengthen the
isTokenQuantity
function by enforcing strict numeric validation usingtonumber
and explicit rejection of any malformed, negative, or non-numeric inputs.This ensures only strictly positive, integer-formatted quantities are accepted and aligns the validation logic with the expectations of downstream arithmetic and financial processing.
LiquidOps
Fixed in PR 28.
Spearbit
Verified.
Redundant code in borrow()
State
- Fixed
PR #27
Severity
- Severity: Low
Submitted by
Gerard Persoon
Description
Function
borrow()
has code to initializeInterests[]
and calculatie the acrued interest.However this code is already executed via the handler "borrow-loan-interest-sync-dynamic" that calls
interest.syncInterests()
.Note:
updateInterest()
updatesTotalBorrows
, so the cached value oflocal lent = bint(TotalBorrows)
on L38 should not be used on L72. However inupdateInterest()
, when the interest has just been updated, thenTotalBorrows
won't be updated.Recommendation
Doublecheck the conclusion and remove the redundant code from
borrow()
.Queue mechanism lacks timeout and out-of-memory protection, risking permanent user locking
State
- Acknowledged
Severity
- Severity: Low
≈
Likelihood: Low×
Impact: High Submitted by
Christos Pap
Description
The
LiquidOps
protocol implements a queueing mechanism to prevent concurrent operations by the same user on their position. When a user initiates an operation likeborrowing
,redeeming
, or transferring tokens, they are added to a queue until the operation completes.function mod.useQueue(handle, errorHandler)return function (msg, env)-- default sender of the interaction is the message senderlocal sender = msg.Fromlocal isCreditNotice = msg.Tags.Action == "Credit-Notice"-- if the message is a credit notice, update the senderif isCreditNotice thensender = msg.Tags.Senderend-- update and set queuelocal res = mod.setQueued(sender, true).receive()-- if we weren't able to queue the user,-- then they have already been queued-- (an operation is already in progress)if not res thenlocal err = "The sender is already queued for an operation"The implementation uses
pcall
to catch errors during the operation and ensure users are removed from the queue afterward:-- call the handler - here is the borrow, redeems and token transferslocal status, err = pcall(handle, msg, env)-- unqueue and notify if it failedmod.setQueued(sender, false).notifyOnFailedQueue()However, this approach may not catch all cases, as out-of-memory conditions or execution timeouts might not be caught by pcall, leading to execution termination before the unqueue operation runs. In such cases, users could remain permanently locked in the queue, unable to manage their positions.
Recommendation
Consider implementing a hybrid approach to address this issue while respecting message execution timing uncertainties (as delays in execution may occur):
- When adding users to the queue, record the timestamp of when they were queued.
- Users may call a function that identifies users who have been in the queue for an excessive period (like 2 hours).
- Before forcibly removing a user from the queue, verify their current state in the system to ensure they're not in the middle of a valid operation.
Alternatively, an admin function can be created to manually release users from the queue in emergency situations, with appropriate safeguards and logging.
This approach balances the need to prevent users from getting permanently stuck while respecting the asynchronous nature of message execution in the system.
LiquidOps
Acknowledged. Due to the nature of AOS, we can "admin remove" an user from the queue by executing a list filter manually.
Queue = utils.filter(function (addr) return addr ~= "address_to_remove" end,Queue)Spearbit
Acknowledged.
Auction timer reset enables strategic discount advantage
State
- Fixed
PR #28
Severity
- Severity: Low
Submitted by
Christos Pap
Description
The LiquidOps protocol implements a time-based discount system for liquidations. When a position first becomes eligible for liquidation, it is recorded in the Auctions table with the current timestamp, and liquidators receive a maximum discount. Over time, this discount decreases, incentivizing quick liquidations.
if bint.ult(position.liquidationLimit, position.borrowBalance) thenlocal discount = 0-- AUCTION TRACKING-- Track when a liquidatable position is first discovered to enable-- time-based discount calculations. New auctions start with max discount.if Auctions[address] == nil then-- Record when this position became liquidatableAuctions[address] = msg.Timestampelseif msg.Tags.Action == "Get-Liquidations" then-- Calculate the current discount based on time since discoverydiscount = tokens.getDiscount(address)endHowever, the protocol also includes logic to remove positions from the Auctions table once they become healthy:
elseif Auctions[address] ~= nil then-- AUCTION CLEANUP-- If a previously liquidatable position is now healthy, remove it-- from the auctions list to ensure it's not incorrectly targeted.Auctions[address] = nilendThis implementation creates a potential edge case that sophisticated liquidators could exploit:
- A liquidator identifies a position with a reduced discount (auction has been active for some time)
- The liquidator makes a small repayment to temporarily bring the position above the liquidation threshold
- This causes
Auctions[address]
to be set tonil
- When market conditions cause the position to fall below the threshold again, it gets a fresh timestamp and maximum discount
- The liquidator can then liquidate with the renewed maximum discount
While this behavior doesn't compromise protocol security, it potentially allows strategic manipulation of the discount mechanism, especially during periods of price volatility.
Recommendation
Consider implementing one or more of the following measures:
- Explicitly document this potential edge case in the protocol documentation.
- Implement a cooling-off period before resetting the discount timestamp. For example, only reset the auction timer if a position has remained healthy for a minimum duration (e.g., 1 hour). This would prevent rapid cycling of positions in and out of liquidation status to game the discount system.
LiquidOps
Fixed in PR28.
Spearbit
The fix looks good. It uses
Handlers.remove
to safely cancel any pre-existing removal timer for the target, ensuring only the latest cooldown is active, and then employsHandlers.once
to schedule the actual auction removal (Auctions[target] = nil
) to execute only after the 3-hour cooldown has elapsed. CallingHandlers.remove
when no handler exists for the target name results in no action being taken.Missing oracle price data format validation
State
- Acknowledged
Severity
- Severity: Low
≈
Likelihood: Low×
Impact: High Submitted by
Christos Pap
Description
The LiquidOps protocol relies on external price data from an oracle to determine position health, calculate liquidation thresholds, and execute liquidations. The current implementation fetches price data but performs minimal validation on the returned data structure.
While the code checks if data is returned and validates timestamp freshness, it does not perform format validation on the actual price values. This could lead to issues if the oracle returns malformed price data, such as:
- Multiple decimal points in price values
- Non-numeric characters in price strings
- Negative values or other invalid price inputs
Invalid price data could lead to incorrect calculations in functions like position valuation, liquidation threshold determination, and collateral-to-debt ratio calculations, potentially causing unexpected behavior in the protocol.
-- Get price data for an array of token symbolsfunction oracle.sync()---@type RawPriceslocal res = {}-- all collateral tickerslocal symbols = utils.map(---@param f Friendfunction (f) return f.ticker end,Tokens)-- no tokens to syncif #symbols == 0 then return res end---@type string|nillocal rawData = ao.send({Target = Oracle,Action = "v2.Request-Latest-Data",Tickers = json.encode(symbols)}).receive().Data-- no price data returnedif not rawData or rawData == "" then return res end---@type OracleDatalocal data = json.decode(rawData)for ticker, p in pairs(data) do-- only add data if the timestamp is up to dateif p.t + MaxOracleDelay >= Timestamp thenres[ticker] = {price = p.v,timestamp = p.t}endendreturn resendRecommendation
Implement additional validation of oracle price data to ensure all values conform to expected formats before using them in calculations, including:
- Each price has exactly one decimal point
- Validate against non-numeric characters in price values (except the one decimal point)
LiquidOps
Acknowledged. This is something we'll look into in the future.
Spearbit
Acknowledged.
Withdrawing the entire pool
State
- Acknowledged
Severity
- Severity: Low
Submitted by
Gerard Persoon
Description
Function
Redeem()
prevents withdrawing the entire pool by usingult()
butliquidatePosition()
doesn't do that because it usesule()
. Leaving a small amount avoids division by zero errors.Recommendation
Consider using
ult()
inliquidatePosition()
too.In that case also consider adapting the
Liquidate
function incontroller.lua
, L626.LiquidOps
Acknowledged. We'd prefer not to limit liquidations. We (as the protocol) will also have tokens in the pool by default anyway according to finding Security considerations of exchange-rate inflation in empty markets.
Spearbit
Acknowledged.
Handling of "Add-Friend" and "Remove-Friend" errors
State
- Acknowledged
Severity
- Severity: Low
Submitted by
Gerard Persoon
Description
The call to "Add-Friend" could potentially result in an error message. The "List" and "Unlist" functions in "controller.lua" don't catch these replies. This way the failure could escape attention and later on cause issues.
Recommendation
Consider catching the replies and sending an error message to the caller of "List" and "Unlist".
LiquidOps
Acknowledged.
Spearbit
Acknowledged.
List token check doesn't check everything
State
- Acknowledged
Severity
- Severity: Low
Submitted by
Gerard Persoon
Description
When "List"-ing a token it is also important that
X-..
tags are forwarded. This currently isn't verified.Recommendation
If you think this is worth solving: it could be tested with a transfer, but then a balance of the token is required.
LiquidOps
Acknowledged. Unfortunately this cannot be verified, because transferring are the only action that support X- forwarded tags. We will manually check by verifying the token code when listing tokens.
Spearbit
Acknowledged.
Race condition in "List"
State
- Acknowledged
Severity
- Severity: Low
≈
Likelihood: Low×
Impact: Low Submitted by
Gerard Persoon
Description
Suppose a token "List" is send and right after that a second "List" request, with different parameters is send.
There is a duplicate check on L225-L228, however after that there are several external actions. On L325-L330 the token is inserted in the
Token
tabel.Between the check and the insertion the second "List" request could be accepted and it depends on timing what values are used.
This scenario isn't very likely because "List" is an authorized function.
Recommendation
Consider having some kind of lock, however avoid lock situation where the token can't be listed anymore.
LiquidOps
Acknowledged.
Spearbit
Acknowledged.
Duplicate tickers cause issues
State
- Acknowledged
Severity
- Severity: Low
Submitted by
Gerard Persoon
Description
The oracle uses
tickers
to retrieve information so it doesn't support duplicate tickers.However the
List
function does allow duplicate tickers, because it only does duplicate checks on addresses.Note:
List
is authorized so normally duplicate tickers will not be listed.Recommendation
Consider also checking for duplicate tickers in the "List" function of "controller.lua".
Once a new version of an oracle exists that supports token address, consider changing the code to use that.
LiquidOps
Acknowledged. Once a better oracle that supports token addresses is available on AO, we'll switch to that. (We are aware of some projects that plan to do this).
Spearbit
Acknowledged.
ValueLimit not checked everywhere
State
- Acknowledged
Severity
- Severity: Low
Submitted by
Gerard Persoon
Description
Repay()
doesn't checkquantity
<=actualRepaidQty
. For comparison, the functionsmint()
,redeem()
andborrow()
do have such a check.Also refunds don't check this
ValueLimit
. This could be important in combination bugs in the code.Recommendation
Consider adding a check for
actualRepaidQty
inRepay()
.Consider adding a limit for refunds to prevent large refunds when a bug occurs.
LiquidOps
Acknowledged. The value limit was only added for the beta, so this will probably be revamped.
Spearbit
Acknowledged.
Unprotected JSON decoding can lead to unexpected errors in the protocol
State
Severity
- Severity: Low
Submitted by
Christos Pap
Description
The protocol contains instances where
json.decode
is used without error handling protection, which could lead to runtime errors if malformed JSON data is encountered:-- In src/controller.lualocal data = json.decode(rawData)-- In src/borrow/pool.luaFriends = Friends or json.decode(ao.env.Process.Tags.Friends) or {}-- In src/controller.lualocal marketPositions = json.decode(market.Data)When
json.decode
encounters invalid JSON data, it throws an error that terminates the current execution flow. This can cause unexpected behavior, particularly in critical functions like price data processing in the oracle service or when initializing protocol configuration data.While the impact of these issues may typically be limited since most inputs come from trusted sources, any error in those operations may disrupt protocol operations.
Recommendation
Use Lua's protected call (
pcall
) when parsing JSON to prevent potential runtime errors, which is already used inoracle.lua:mod.setup()
.LiquidOps
Fixed in PR28 partially.
Spearbit
Verified
Missing input parameter checks
State
Severity
- Severity: Low
Submitted by
Gerard Persoon
Description
Throught the code, input parameters are validated:
assertions.isAddress()
for addresses;assertions.isTokenQuantity()
for quantities;
However this isn't done in all situations. For fields that are forwarded to other message, it is useful to check early to save processing power/gas.
Recommendation
Consider adding checks on input parameters:
assertions.isAddress()
checks:target
inliquidatePosition()
liquidator
inliquidateBorrow()
target
inliquidateBorrow()
msg.Tags["X-Reward-Market"]
inliquidateBorrow()
target
insyncInterestForUser()
Oracle
inoracle.setup()
liquidator
in "liquidate" ofcontroller.lua
rewardToken
in "liquidate" ofcontroller.lua
msg.Tags.Quantity
in "liquidate" ofcontroller.lua
assertions.isTokenQuantity()
checks:msg.Tags["X-Reward-Quantity"]
inliquidateBorrow()
msg.Tags.Quantity
inliquidate.refund()
Note: doublecheck that any additional error checks are handled well with regards to refunds.
No check inTokenData / outTokenData / availableRewardQty are found
State
Severity
- Severity: Low
Submitted by
Gerard Persoon
Description
In function "liquidate" a scan is made over all positions in order to find values for
inTokenData
/outTokenData
/availableRewardQty
. However there is no check these values have indeed been found. If these values haven't been found the rest of the code executes with empty values.Recommendation
Consider checking values for
inTokenData
/outTokenData
/availableRewardQty
have indeed been found.Missing default values in configuration
State
Severity
- Severity: Low
Submitted by
Gerard Persoon
Description
In several situations, default values are defined in case a configuration parameter is missing. However this isn't done everywhere and this could lead to incorrectly configured code.
Recommendation
Consider using default values for the accompanying code. If no relevant default value can be determined consider stopping with an error.
Function updateInterest() doesn't check Loans[address] == nil
State
- Fixed
PR #27
Severity
- Severity: Low
Submitted by
Gerard Persoon
Description
Function
updateInterest()
doesn't explictly check for the situation whereLoans[address] == nil
. This could cause an error inbint()
. Although this situation normally shouldn't happen it is safe to make sure.Recommendation
Consider handling the situation where
Loans[address] == nil
.Function position() doesn't check TotalSupply == 0
State
Severity
- Severity: Low
Submitted by
Gerard Persoon
Description
The function
position()
doesn't explicitly check forTotalSupply == 0
. Although this is unlikely, it is safe to check this to prevent a division by 0.Recommendation
Consider handling the case where
TotalSupply == 0
.The update() function can execute any code
State
- Acknowledged
Severity
- Severity: Low
Submitted by
Gerard Persoon
Description
The
update()
function can execute any code. If there is a bug in the code it could make theoToken
unusable and lock the tokens inside.Recommendation
Consider adding some checks after
install()
to make sure basic functionality still works, perhaps theupdate()
function itself.LiquidOps
Acknowledged. We are planning to add extra security that prevents one wallet by itself to run an update and have a multisig-like system for it.
Spearbit
Acknowledged.
Testing suite limitations may affect codebase robustness
State
- Acknowledged
Severity
- Severity: Low
Submitted by
Christos Pap
Description
The current testing suite for the
LiquidOps
protocol has several limitations that may hinder comprehensive validation and may conceal potential vulnerabilities.While unit tests exist for some functionalities, there are areas where the testing suite can be improved:
- Untested Core Modules: Some functionalities remain untested. For example, the test file
__tests__/borrow.test.ts
explicitly marks entire sections related to Position, Repaying and Interests logic withit.todo
, indicating a lack of tests for those modules. - Lack of Coverage Metrics: The AO/AOS ecosystem seems to lack tools for measuring test coverage. This absence makes it difficult to quantify and assess how much of the Lua codebase is covered by the existing tests, potentially masking untested code paths and logic within the tested files themselves.
- Absence of Integration, Multi-Actor and Stateful Tests: The suite primarily consists of unit tests focusing on individual actions in isolation. It currently lacks Integration, Multi-Actor, and Stateful Tests, the absence of which may mean other vulnerabilities remain undiscovered. These test types are particularly crucial due to the unique message-passing architecture of AO and AOS, where operations frequently involve interactions across multiple different processes.
Recommendation
Enhance the testing suite significantly to provide greater assurance of the protocol's robustness and security. It's recommended to prioritize writing unit tests for the currently uncovered modules and to develop integration and multi-actor tests.
Furthermore, you can consider launching the protocol on a testnet or conducting a closed beta program as these expose the system to real-world usage patterns and may uncover edge cases that cannot be easily caught solely through testing.
LiquidOps
Acknowledged. We are planning to improve coverage and add a complete test suite once
AO
supports it. With their upcomingHyperBeam
platform, this is already being implemented.Spearbit
Acknowledged
Input for bint() should not be nil
State
Severity
- Severity: Low
Submitted by
Gerard Persoon
Description
When converting to
bint()
, its important that the input value isn'tnil
, otherwise it will error. This is ensured in serveral places by using a default value, however on other places its missing.Recommendation
Consider using default values for the accompanying code. If no relevant default value can be determined consider stopping with an error.
Informational22 findings
Dead Code in Cooldown Enforcement Logic
State
Severity
- Severity: Informational
Submitted by
Cryptara
Description
In
cooldown.lua
, the cooldown enforcement logic in both thegate
andrefund
functions contains conditional blocks that check ifmsg.Tags.Action == "Credit-Notice"
in order to modify the sender address or to route specific refund behavior. However, these checks are effectively dead code due to how the cooldown system is integrated in the protocol.The
controller-cooldown-gate
pattern is explicitly applied only to user-level actions such as"Borrow"
and"Redeem"
, as established in the protocol’s handler routing layer. These actions never carryAction = "Credit-Notice"
, which is reserved for token transfer messages.Since
Credit-Notice
is not routed through the cooldown system, these blocks are unreachable and add unnecessary complexity.Recommendation
Remove the
if msg.Tags.Action == "Credit-Notice"
branches from both thegate
andrefund
functions incooldown.lua
. This will clean up the logic, reduce cognitive overhead for future maintainers, and eliminate confusion around dead paths.LiquidOps
Fixed in PR 29.
Spearbit
Verified.
Incorrect Return Type Annotations in Middleware Functions
State
- Acknowledged
Severity
- Severity: Informational
Submitted by
Cryptara
Description
In both
queue.lua
andoracle.lua
, the higher-order functionsuseQueue
andwithOracle
are incorrectly annotated with@return HandlerFunction
. These functions return other functions (i.e., closures) that conform to theHandlerFunction
type, meaning the correct type annotation should reflect that the return value is a function itself, not a direct handler result.The current form:
---@return HandlerFunctionincorrectly implies that
useQueue
andwithOracle
immediately return a value compatible withHandlerFunction
, rather than a function that when called conforms toHandlerFunction
. This distinction is critical for accurate documentation, type checking, and static analysis.Specifically:
- In
queue.lua
,useQueue
wraps a handler with queuing logic and returns a middleware handler function. - In
oracle.lua
,withOracle
decorates a handler with real-time oracle data injection and returns a callable that fitsHandlerFunction
.
Recommendation
Update the return type annotations for both
useQueue
andwithOracle
to:---@return function:HandlerFunctionThis explicitly communicates that the return value is a function that matches the
HandlerFunction
type signature. It enhances tooling support and readability, ensuring middleware is correctly understood as a functional wrapper, not a direct handler execution.LiquidOps
Acknowledged. Unfortunately this breaks the
sumneko/Lua
extension's types we use.Spearbit
Acknowledged.
Use utils.filter() to Update the Handlers.coroutines Table
State
Severity
- Severity: Informational
Submitted by
Eric Wang
Description
In Lua, when an element is removed from an array table, the next element is shifted into that position. Therefore, if the table is being iterated at the same time, the next element will be skipped. For example:
T = {}table.insert(T, { data = "X" })table.insert(T, { data = "X" })for i, x in ipairs(T) doif x.data == "X" thentable.remove(T, i)endendT -- still has an element { { data = "X" } }In
process.lua
, since theHandlers.coroutines
table is being iterated while the code removes the elements, some elements may fail to be removed. Additionally, usingTable.remove()
while iterating the array has a time complexity ofO(n^2)
, while the customutil.filter()
function isO(n)
.Recommendation
Consider using the
utils.filter()
function to remove the elements.LiquidOps
Fixed in commit f9ed6c6.
Spearbit
Verified.
Unused Function spawnProtocolLogo
State
Severity
- Severity: Informational
Submitted by
Cryptara
Description
Within
controller.lua
, the functionspawnProtocolLogo
is defined to dynamically generate a LiquidOps-themed SVG image incorporating a provided collateral logo. This function constructs a composite SVG and sends it to the AO process to be registered as a spawned asset. However, this function is never invoked anywhere in the codebase.The intended use seems to be within the token listing path—presumably to decorate newly listed tokens with protocol-specific visuals—but in practice, the listing logic directly uses the
msg.Tags.Logo
or defaults toinfo.Tags.Logo
, bypassing any dynamic rendering or invocation ofspawnProtocolLogo
.This makes
spawnProtocolLogo
dead code, which adds unnecessary cognitive overhead and potential confusion for future maintainers. Moreover, maintaining unused logic in critical protocol components (like the controller) introduces an unnecessary surface area for audit and testing.Recommendation
Remove the
spawnProtocolLogo
function fromcontroller.lua
as it is not used anywhere in the codebase and has no active call sites. If dynamic logo generation is needed in the future, it should be reintroduced in a separate utility module or injected explicitly into the token listing flow with a clear toggle or configuration flag.LiquidOps
Fixed in PR 29.
Spearbit
Verified.
Potential division by 0 in function getUnderlyingWorth()
State
Severity
- Severity: Informational
Submitted by
Gerard Persoon
Description
In function
getUnderlyingWorth()
there is a potential division by 0 iftotalSupply==0
. This is unlikely to occur in practice, because in that situation there are no tokens minted yet, however it better to be safe.Recommendation
Consider check for
totalSupply==0
and returning a plausible value.Inconsistent Type Handling in Interest Updates
Description
The
updateInterest
function ininterest.lua
performs arithmetic operations onInterests[address].value
, which is sometimes treated as a string and other times cast directly into abint
. While this works in practice due to Lua’s dynamic typing and coercion behavior, it leads to type inconsistency and unnecessary risk in critical arithmetic paths.For instance, the following code appears to operate correctly:
Interests[address] = {value = tostring(Interests[address].value + interestAccrued),updated = timestamp}However,
Interests[address].value
is originally stored as a string (e.g.,"1234"
), which is implicitly treated as a number during the addition. This works as a proof of concept but introduces undefined behavior if the value format changes (e.g., stringified non-numeric values) or if future refactors tighten type safety.The code also re-calls
bint(Interests[address].value)
after having already created abint
version earlier asinterestQty
, causing redundant conversions.Recommendation
Standardize the data flow by ensuring all arithmetic values are explicitly cast and kept as
bint
objects throughout the calculation process. This improves reliability, avoids silent coercion bugs, and clarifies intent.This change ensures type consistency and eliminates the ambiguity of treating
.value
as a numeric string. It also aligns the interest calculation logic with the strictness required for financial correctness.LiquidOps
Fixed in PR27 with the revamped interest accrual changes.
Spearbit
Verified.
Unnecessary default value after bint()
State
- Fixed
PR #27
Severity
- Severity: Informational
Submitted by
Gerard Persoon
Description
Function
updateInterest()
assignsinterestQty
and uses a default value in casebint(...)
returnsnil
.However
bint()
error if it can't convert the input value.Recommendation
Consider removing the default value to simlify the code:
-local interestQty = bint(Interests[address].value) or mod.context.zero+local interestQty = bint(Interests[address].value)LiquidOps
Fixed with the interest accrual revamp.
Spearbit
Verified
Tag "X-Action" = "Liquidate" missing in Javascript library
State
- Fixed
PR #10
Severity
- Severity: Informational
Submitted by
Gerard Persoon
Description
In the library
LiquidOps-JS
the tag "X-Action" = "Liquidate" is missing. Due to this liquidations won't be triggered via this library and the send tokens will stay stuck incontroller
, see finding TBD.Background information: when a liquidator initiates a liquidation, they transfer tokens to the controller. The transfer message is sent to the token that will be used to pay for the user's outstanding loan. Any messages with the "X-" prefix are forwarded, so the liquidator also needs to add an "X-Action" = "Liquidate" tag to their transfer. This is forwarded in the "Credit-Notice" message, so it must have "X-Action" = "Liquidate".
Recommendation
Add the tag "X-Action" = "Liquidate" to the library
LiquidOps-JS
.LiquidOps
Solved in PR 10 of LiquidOps-JS.
Spearbit
Verified
Controller doesn't handle "Debit-Notice" messages
State
- Acknowledged
Severity
- Severity: Informational
Submitted by
Gerard Persoon
Description
In
process.lua
there is a handler to ignore "Debit-Notice" messages.However
controller.lua
doesn't have this. A "Debit-Notice" message could be received after doing a transfer on L705 or L735.Recommendation
Doublecheck if "Debit-Notice" messages should be handled by
controller.lua
too and if so add code to do that.LiquidOps
It is not necessary to add a handler for
debit-notices
, simply to ignore them, because aos processes by default don’t do anything with a message that doesn’t match a handler. We only need this in theoToken
code (process.lua
), because we modified the handlers to send an error message if the incoming message did not match any handlers (the emptydebit-notice
handler prevents this).Spearbit
Acknowledged
Typos, Incorrect comments and missing function parameter documentation
State
Severity
- Severity: Informational
Submitted by
Christos Pap
Description
In the course of the security review, several instances of typos and incorrect comments were found in the codebase. The following instances were identified:
- In controller.lua:L1133, "accouting" should be "accounting"
- In rate.lua:L6, "wroth" should be "worth"
- In rate.lua:L15, "than" should be "then"
- In utils.lua:L57, "whetehr" should be "whether"
- In utils.lua:L333, the comment is incomplete. It can be changed to:
Turn a floating point number into a bigint, scaled by an optional multiplier (default 100000)
- In controller.lua:L469, "posisition" should be "position"
- In pool.lua:L54, "anc" should be "and".
- In mint.lua:L85 there is no
---@param
for msg - In controller.lua:L958 the comment should be
if the time passed is higher than the discount interval
- In liquidate.lua:L23 "tartget" should be "target"
Recommendation
It's recommended to fix these instances of typos and incorrect comments.
For thorough documentation, ensure that all functions have:
- Accurate descriptions of what they do
- Complete
@param
annotations for all parameters - Proper
@return
type annotations - Correct spelling throughout comments and documentation
LiquidOps
Note: for #8, it has
---@type HandlerFunction
, that should handle the Lua types/params/return for the entire function.Spearbit
Verified
Code in getUSDDenominated() can be simplified
State
Severity
- Severity: Informational
Submitted by
Gerard Persoon
Description
The code to calculate
denominated
ingetUSDDenominated()
can be simplified, which makes it easier to understand.Recommendation
Consider changing the code in both
oracle.lua
andcontroller.lua
to the following.local wholeDigits = string.len(denominated) - fractionsdenominated = denominated .. string.rep("0", denominator)denominated = string.sub(denominated, 1, wholeDigits + denominator)Unused default value of Denomination
State
Severity
- Severity: Informational
Submitted by
Eric Wang
Description
When setting up a new oToken, the
CollateralDenomination
andDenomination
values are set as follows:-- the wrapped token's denominationCollateralDenomination = tonumber(ao.env.Process.Tags["Collateral-Denomination"] or 0) or 0Denomination = CollateralDenomination or 12Note that if
CollateralDenomination
is set to a default value of 0,Denomination
will be set to 0 as well since0 or 12 == 0
.Denomination
will never be set to a default value of 12.Recommendation
Consider either changing L11 to
CollateralDenomination = tonumber(ao.env.Process.Tags["Collateral-Denomination"] or 12) or 12or changing L13 to
if CollateralDenomination == 0 then Denomination = 12 else Denomination = CollateralDenominationLiquidOps
Fixed in commit 876345a.
Spearbit
Verified.
Several TODOs left in the code
State
- Acknowledged
Severity
- Severity: Informational
Submitted by
Gerard Persoon
Description
There are several TODOs left in the code.
Recommendation
Check the TODOs and resolve them.
LiquidOps
Acknowledged.
Spearbit
Acknowledged.
Several authorized functions are not called from controller.lua
State
Severity
- Severity: Informational
Submitted by
Gerard Persoon
Description
Several authorized functions are not called from
controller.lua
. This means they will have to be called directly on theoToken
.According to the project the plan is to introduce some kind of governance model, which would call these functions.
Recommendation
At least document the way these functions should be called.
Misleading function name
State
- Acknowledged
Severity
- Severity: Informational
Submitted by
Gerard Persoon
Description
The function name
canRepayExact()
is misleading. It seem to requirequantity == borrowBalance + interestBalance
however the implementation isquantity < borrowBalance + interestBalance
.Recommendation
Check the intention of
canRepayExact()
. If it should ensure exact payment, update to logic to do so. Otherwise change the function name and the related comments in bothrepay.lua
andliquidate.lua
.LiquidOps
Acknowledged, we will look into this in the future
Spearbit
Acknowledged.
Controller.lua doesn't send Raw-Error
State
- Acknowledged
Severity
- Severity: Informational
Submitted by
Gerard Persoon
Description
In error messages, the
oToken
code includes theRaw-Error
. Howevercontroller.lua
doesn't add/support that. Having theRaw-Error
would make troubleshooting easier.It doesn't forward the
Raw-Error
from the loan liquidation result. It also doesn't add it in an error in "liquidate".Recommendation
Consider also supporting
Raw-Error
messages incontroller.lua
.LiquidOps
Acknowledged.
Spearbit
Acknowledged.
Use of msg.From could be made more readable
State
Severity
- Severity: Informational
Submitted by
Gerard Persoon
Description
Refunds of collateral tokens use
Target = msg.From
, which is somewhat difficult to understand.Recommendation
Consider using
CollateralID
instead ofmsg.From
, where it is suremsg.From == CollateralID
.Javascript library uses extra tags
State
- Acknowledged
Severity
- Severity: Informational
Submitted by
Gerard Persoon
Description
The javascript library adds the tag:
"Protocol-Name" = "LiquidOps"
. However theLUA
code doesn't do this.To be consistent, it might useful to add.
Recommendation
Consider adding the tag:
"Protocol-Name" = "LiquidOps"
to all messages.LiquidOps
Acknowledged.
Spearbit
Acknowledged.
Description of data fields incomplete
State
Severity
- Severity: Informational
Submitted by
Gerard Persoon
Description
The description of several data field is incomplete.
Recommendation
Add documention to
Friends
andTokens
to indicate they have the following fields:- id:string (address)
- ticker:string
- oToken:process
- denomination:number
ao.env.Process.Owner could be made clearer
State
Severity
- Severity: Informational
Submitted by
Gerard Persoon
Description
process.lua
andqueue.lua
useao.env.Process.Owner
, which is not directly clear. Howeverao.env.Process.Owner
is thecontroller
.Recommendation
Consider replacing
ao.env.Process.Owner
with a variable likeController
.Code structure
State
- Acknowledged
Severity
- Severity: Informational
Submitted by
Gerard Persoon
Description
The code contains a lot of duplication which makes maintenance more difficult.
This is mainly due to the fact that
controller.lua
is one large file because of deployment details. According to the project there are ways to split this file.Recommendation
Consider splitting
controller.lua
and combine the code where possible with the code fromprocess.lua
and theoTokens
.LiquidOps
Acknowledged. This is planned in the future.
Spearbit
Acknowledged.
Documentation is limited
State
- Acknowledged
Severity
- Severity: Informational
Submitted by
Gerard Persoon
Description
The documentation is currently limited, which makes it more difficult to use, maintain and review the code.
Especially the division between
controller.lua
, which is run onAOS
and the rest of the code, which run in a seperateWASM
module isn't clear.Recommendation
Consider creating more documentation:
- explain the division between
controller.lua
and the rest of the code; - add diagrams that explain the flows of message;
- add specifications for the messages and error messages that can be received and sent;
- add a table to show which actions map to which functions. See example below;
- an overview of the changes made to
ao.lua
; - a list of the deployed code including addresses.
Name Action X-Action From Sender Function Error handler liquidate-borrow Credit-Notice Liquidate-Borrow CollateralID controller liquidateBorrow refund ... LiquidOps
Acknowledged. This is planned in the future along with a whitepaper/lightpaper.
Spearbit
Acknowledged.
Gas Optimizations3 findings
Redundant check for LiquidationQueue
State
Severity
- Severity: Gas optimization
Submitted by
Gerard Persoon
Description
The "Liquidate" function checks the
LiquidationQueue
twice. Between the first and the second call there are no external messages send/received, so the second check isn't necessary.Recommendation
Consider removing the second check.
Quantity can be verified before position data calculation to allow early failure in borrow and redeem functions
State
- Fixed
PR #29
Severity
- Severity: Gas optimization
Submitted by
Christos Pap
Description
In the
LiquidOps
protocol'sborrow
function, the quantity validation occurs after fetching the user's position data:-- get position datalocal pos = position.globalPosition(account, oracle)-- verify quantityassert(assertions.isTokenQuantity(msg.Tags.Quantity),"Invalid borrow quantity")The quantity validation is independent of the position data and doesn't rely on any calculated values from the position. This means the protocol is performing an expensive operation (position data calculation) before validating the input quantity, which could be invalid.
If the quantity validation fails, the protocol would have wasted resources calculating position data unnecessarily. By moving the validation earlier in the function flow, the protocol could fail fast when invalid input is provided, saving computational resources and improving gas efficiency.
Similar validation can be adjusted in other functions, like
redeem
, where the same pattern seems to exist.Recommendation
Move the quantity validation before the position data calculation to allow early failure for invalid inputs:
-- verify quantityassert(assertions.isTokenQuantity(msg.Tags.Quantity),"Invalid borrow quantity")-- get position datalocal pos = position.globalPosition(account, oracle)LiquidOps
Fixed in PR29.
Spearbit
Verified. The quantity is now checked before position data calculation.
Redundant boolean operation
State
Severity
- Severity: Gas optimization
Submitted by
Eric Wang
Description
In
process.lua
, at L319, the expressionmsg.Action and msg.Action or "Unknown"
can be simplified tomsg.Action or "Unknown"
as they are equivalent.Recommendation
Consider implementing the above suggestion.
LiquidOps
Fixed in commit 7a14723.
Spearbit
Verified.