local coroutine = require "coroutine"
local bint = require ".bint"(1024)
local utils = require ".utils"
local json = require "json"
local liquidations = {}
local assertions = {}
local scheduler = {}
local oracle = {}
local tokens = {}
local queue = {}
-- oToken module ID
Module = Module or "C6CQfrL29jZ-LYXV2lKn09d3pBIM6adDFwWqh2ICikM"
-- oracle id and tolerance
Oracle = Oracle or "4fVi8P-xSRWxZ0EE0EpltDe8WJJvcD9QyFXMqfk-1UQ"
MaxOracleDelay = MaxOracleDelay or 1200000
-- admin addresses
Owners = Owners or {}
-- liquidops logo tx id
ProtocolLogo = ProtocolLogo or ""
-- holds all the processes that are part of the protocol
-- a member consists of the following fields:
-- - id: string (this is the address of the collateral supported by LiquidOps)
-- - ticker: string (the ticker of the collateral)
-- - oToken: string (the address of the oToken process for the collateral)
-- - denomination: integer (the denomination of the collateral)
---@type Friend[]
Tokens = Tokens or {}
-- queue for operations that change the user's position
---@type { address: string, origin: string }[]
Queue = Queue or {}
-- current timestamp
Timestamp = Timestamp or 0
-- cached auctions (position wallet address, timestamp when discovered)
---@type table
Auctions = Auctions or {}
-- maximum and minimum discount that can be applied to a loan in percentages
MaxDiscount = MaxDiscount or 5
MinDiscount = MinDiscount or 1
-- the period till the auction reaches the minimum discount (market price)
DiscountInterval = DiscountInterval or 1000 * 60 * 60 -- 1 hour
PrecisionFactor = 1000000
-- minimum liquidation percentage (a liquidator is required to liquidate at least this percentage of the total loan)
MinLiquidationThreshold = MinLiquidationThreshold or 20
---@alias TokenData { ticker: string, denomination: number }
---@alias PriceParam { ticker: string, quantity: Bint?, denomination: number }
---@alias CollateralBorrow { token: string, ticker: string, quantity: string }
---@alias QualifyingPosition { target: string, depts: CollateralBorrow[], collaterals: CollateralBorrow[], discount: string }
Handlers.add(
"setup-patching",
function () return "continue" end,
function (msg)
ao.send({
device = "patch@1.0",
["token-info"] = { name = "LiquidOps Controller" },
oracle = Oracle,
["discount-config"] = {
min = MinDiscount,
max = MaxDiscount,
interval = DiscountInterval
},
tokens = Tokens,
queue = Queue,
auctions = Auctions
})
end
)
Handlers.add(
"sync-timestamp",
function () return "continue" end,
function (msg) Timestamp = msg.Timestamp end
)
Handlers.add(
"info",
{ Action = "Info" },
function (msg)
msg.reply({
Name = "LiquidOps Controller",
Module = Module,
Oracle = Oracle,
["Max-Discount"] = tostring(MaxDiscount),
["Min-Discount"] = tostring(MinDiscount),
["Discount-Interval"] = tostring(DiscountInterval),
Data = json.encode(Tokens)
})
end
)
Handlers.add(
"sync-auctions",
Handlers.utils.hasMatchingTagOf("Action", { "Cron", "Get-Liquidations" }),
function (msg)
-- fetch prices first, so the processing of the positions won't be delayed
local rawPrices = oracle.sync()
-- generate position messages
---@type MessageParam[]
local positionMsgs = {}
for _, token in ipairs(Tokens) do
table.insert(positionMsgs, { Target = token.oToken, Action = "Positions" })
end
-- get all user positions
---@type Message[]
local rawPositions = scheduler.schedule(table.unpack(positionMsgs))
-- protocol positions in USD
---@type table
local allPositions = {}
local zero = bint.zero()
-- add positions
for _, market in ipairs(rawPositions) do
---@type boolean, table
local parsed, marketPositions = pcall(json.decode, market.Data)
assert(parsed, "Could not parse market data for " .. market.From)
local ticker = market.Tags["Collateral-Ticker"]
local denomination = tonumber(market.Tags["Collateral-Denomination"]) or 0
local collateral = utils.find(
function (t) return t.oToken == market.From end,
Tokens
)
-- add each position in the market by their usd value
for address, position in pairs(marketPositions) do
local posLiquidationLimit = bint(position["Liquidation-Limit"] or 0)
local posBorrowBalance = bint(position["Borrow-Balance"] or 0)
local hasCollateral = bint.ult(zero, posLiquidationLimit)
local hasLoan = bint.ult(zero, posBorrowBalance)
if hasCollateral or hasLoan then
allPositions[address] = allPositions[address] or {
liquidationLimit = zero,
borrowBalance = zero,
debts = {},
collaterals = {}
}
-- add liquidation limit
if hasCollateral and collateral ~= nil then
allPositions[address].liquidationLimit = allPositions[address].liquidationLimit + oracle.getValue(
rawPrices,
posLiquidationLimit,
ticker,
denomination
)
table.insert(allPositions[address].collaterals, {
token = collateral.id,
ticker = ticker,
quantity = position.Collateralization
})
end
-- add borrow balance
if hasLoan and collateral ~= nil then
allPositions[address].borrowBalance = allPositions[address].borrowBalance + oracle.getValue(
rawPrices,
posBorrowBalance,
ticker,
denomination
)
table.insert(allPositions[address].debts, {
token = collateral.id,
ticker = ticker,
quantity = position["Borrow-Balance"]
})
end
end
end
end
---@type QualifyingPosition[]
local qualifyingPositions = {}
-- now find the positions that can be auctioned
-- and update existing auctions
for address, position in pairs(allPositions) do
-- check if the position can be liquidated
if bint.ult(position.liquidationLimit, position.borrowBalance) then
-- add auction
liquidations.addAuction(address, msg.Timestamp)
-- calculate discount
local discount = tokens.getDiscount(address)
if msg.Tags.Action == "Get-Liquidations" then
table.insert(qualifyingPositions, {
target = address,
debts = position.debts,
collaterals = position.collaterals,
discount = discount
})
end
else
-- remove auction, it is no longer necessary
liquidations.removeAuction(address)
end
end
if msg.Tags.Action == "Get-Liquidations" then
msg.reply({
Data = json.encode({
liquidations = qualifyingPositions,
tokens = Tokens,
maxDiscount = MaxDiscount,
minDiscount = MinDiscount,
discountInterval = DiscountInterval,
prices = rawPrices,
precisionFactor = PrecisionFactor
})
})
end
end
)
-- Verify if the caller of an admin function is
-- authorized to run this action
---@param action string Accepted action
---@return PatternFunction
function assertions.isAdminAction(action)
return function (msg)
if msg.From ~= ao.env.Process.Id and not utils.includes(msg.From, Owners) then
return false
end
return msg.Tags.Action == action
end
end
Handlers.add(
"list",
assertions.isAdminAction("List"),
function (msg)
-- token to be listed
local token = msg.Tags.Token
assert(
assertions.isAddress(token),
"Invalid token address"
)
assert(
utils.find(function (t) return t.id == token end, Tokens) == nil,
"Token already listed"
)
-- check configuration
local liquidationThreshold = tonumber(msg.Tags["Liquidation-Threshold"])
local collateralFactor = tonumber(msg.Tags["Collateral-Factor"])
local reserveFactor = tonumber(msg.Tags["Reserve-Factor"])
local baseRate = tonumber(msg.Tags["Base-Rate"])
local initRate = tonumber(msg.Tags["Init-Rate"])
local jumpRate = tonumber(msg.Tags["Jump-Rate"])
local cooldownPeriod = tonumber(msg.Tags["Cooldown-Period"])
local kinkParam = tonumber(msg.Tags["Kink-Param"])
assert(
collateralFactor ~= nil and type(collateralFactor) == "number",
"Invalid collateral factor"
)
assert(
collateralFactor // 1 == collateralFactor and collateralFactor >= 0 and collateralFactor = 0 and liquidationThreshold collateralFactor or liquidationThreshold == 0 and collateralFactor == 0,
"Liquidation threshold must be greater than the collateral factor"
)
assert(
reserveFactor ~= nil and type(reserveFactor) == "number",
"Invalid reserve factor"
)
assert(
reserveFactor // 1 == reserveFactor and reserveFactor >= 0 and reserveFactor = 0 and kinkParam