diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index d209822..f5e0689 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -15,7 +15,7 @@ jobs: uses: actions/checkout@v2 - name: Install ao - run: curl -L https://install_ao.g8way.io | bash + run: curl -L https://install_ao.arweave.net | bash - name: Build module run: | diff --git a/__tests__/supply.test.ts b/__tests__/supply.test.ts index cbcd608..46cece0 100644 --- a/__tests__/supply.test.ts +++ b/__tests__/supply.test.ts @@ -876,3 +876,293 @@ describe("Price and underlying asset value, supplies after initial provide", () it.todo("Price input quantity is 1 by default when there is no quantity provided"); }); + +describe("AO delegation", () => { + let handle: HandleFunction; + let tags: Record; + + beforeEach(async () => { + const envWithWAO = { + Process: { + ...env.Process, + Tags: [ + ...env.Process.Tags, + { name: "AO-Token", value: generateArweaveAddress() }, + { name: "Wrapped-AO-Token", value: generateArweaveAddress() }, + ] + } + }; + + handle = await setupProcess(envWithWAO); + tags = normalizeTags(envWithWAO.Process.Tags); + }); + + it("Does not run delegate if there is no wAO token process defined", async () => { + const handle = await setupProcess(env); + const res = await handle(createMessage({ Action: "Delegate" })); + + expect(res.Messages).toHaveLength(0); + }); + + it("Does not delegate any tokens if the claim failed", async () => { + const delegateRes = await handle(createMessage({ Action: "Delegate" })); + + expect(delegateRes.Messages).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + Target: tags["Wrapped-AO-Token"], + Tags: expect.arrayContaining([ + expect.objectContaining({ + name: "Action", + value: "Claim" + }) + ]) + }) + ]) + ); + + const claimRes = await handle(createMessage({ + Owner: tags["Wrapped-AO-Token"], + From: tags["Wrapped-AO-Token"], + Action: "Claim-Error", + Error: "No balance", + "X-Reference": normalizeTags( + getMessageByAction("Claim", delegateRes.Messages)?.Tags || [] + )["Reference"] + })); + + expect(claimRes.Messages).toHaveLength(0); + }); + + it("Does not distribute an invalid quantity", async () => { + const id = generateArweaveAddress(); + const delegateRes = await handle(createMessage({ + Id: id, + Action: "Delegate" + })); + + expect(delegateRes.Messages).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + Target: tags["Wrapped-AO-Token"], + Tags: expect.arrayContaining([ + expect.objectContaining({ + name: "Action", + value: "Claim" + }) + ]) + }) + ]) + ); + + const claimRes = await handle(createMessage({ + Owner: tags["AO-Token"], + From: tags["AO-Token"], + Action: "Credit-Notice", + Quantity: "invalid", + Sender: tags["Wrapped-AO-Token"], + ["Pushed-For"]: id + })); + + expect(claimRes.Messages).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + Tags: expect.arrayContaining([ + expect.objectContaining({ + name: "Action", + value: "Credit-Notice-Error" + }), + expect.objectContaining({ + name: "Error", + value: expect.stringContaining( + "Invalid claimed quantity" + ) + }) + ]) + }) + ]) + ); + }); + + it("Distributes the claimed quantity evenly", async () => { + // prepare by adding holders + const balances = [ + { addr: generateArweaveAddress(), qty: "2" }, + { addr: generateArweaveAddress(), qty: "1" } + ]; + await handle(createMessage({ + Action: "Update", + Data: `Balances = { ["${balances[0].addr}"] = "${balances[0].qty}", ["${balances[1].addr}"] = "${balances[1].qty}" } + TotalSupply = "3"` + })); + + // distribute + const id = generateArweaveAddress(); + const delegateRes = await handle(createMessage({ + Id: id, + Action: "Delegate" + })); + + expect(delegateRes.Messages).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + Target: tags["Wrapped-AO-Token"], + Tags: expect.arrayContaining([ + expect.objectContaining({ + name: "Action", + value: "Claim" + }) + ]) + }) + ]) + ); + + const claimRes = await handle(createMessage({ + Owner: tags["AO-Token"], + From: tags["AO-Token"], + Action: "Credit-Notice", + Quantity: "6", + Sender: tags["Wrapped-AO-Token"], + ["Pushed-For"]: id + })); + + expect(claimRes.Messages).toEqual( + expect.arrayContaining(balances.map((balance) => ( + expect.objectContaining({ + Target: tags["AO-Token"], + Tags: expect.arrayContaining([ + expect.objectContaining({ + name: "Action", + value: "Transfer" + }), + expect.objectContaining({ + name: "Recipient", + value: balance.addr + }), + expect.objectContaining({ + name: "Quantity", + value: (parseInt(balance.qty) * 2).toString() + }), + ]) + }) + ))) + ); + }); + + it("Distributes the claimed quantity with a remainder", async () => { + // prepare by adding holders + const balances = [ + { addr: generateArweaveAddress(), qty: "2" }, + { addr: generateArweaveAddress(), qty: "1" } + ]; + await handle(createMessage({ + Action: "Update", + Data: `Balances = { ["${balances[0].addr}"] = "${balances[0].qty}", ["${balances[1].addr}"] = "${balances[1].qty}" } + TotalSupply = "3"` + })); + + // distribute + const id = generateArweaveAddress(); + const delegateRes = await handle(createMessage({ + Id: id, + Action: "Delegate" + })); + + expect(delegateRes.Messages).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + Target: tags["Wrapped-AO-Token"], + Tags: expect.arrayContaining([ + expect.objectContaining({ + name: "Action", + value: "Claim" + }) + ]) + }) + ]) + ); + + const claimRes = await handle(createMessage({ + Owner: tags["AO-Token"], + From: tags["AO-Token"], + Action: "Credit-Notice", + Quantity: "7", + Sender: tags["Wrapped-AO-Token"], + ["Pushed-For"]: id + })); + + expect(claimRes.Messages).toEqual( + expect.arrayContaining(balances.map((balance) => ( + expect.objectContaining({ + Target: tags["AO-Token"], + Tags: expect.arrayContaining([ + expect.objectContaining({ + name: "Action", + value: "Transfer" + }), + expect.objectContaining({ + name: "Recipient", + value: balance.addr + }), + expect.objectContaining({ + name: "Quantity", + value: (parseInt(balance.qty) * 2).toString() + }), + ]) + }) + ))) + ); + + // now check redistribution of the remainder + const id2 = generateArweaveAddress(); + const delegateRes2 = await handle(createMessage({ + Id: id2, + Action: "Delegate" + })); + + expect(delegateRes2.Messages).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + Target: tags["Wrapped-AO-Token"], + Tags: expect.arrayContaining([ + expect.objectContaining({ + name: "Action", + value: "Claim" + }) + ]) + }) + ]) + ); + + const claimRes2 = await handle(createMessage({ + Owner: tags["AO-Token"], + From: tags["AO-Token"], + Action: "Credit-Notice", + Quantity: "2", + Sender: tags["Wrapped-AO-Token"], + ["Pushed-For"]: id2 + })); + + expect(claimRes2.Messages).toEqual( + expect.arrayContaining(balances.map((balance) => ( + expect.objectContaining({ + Target: tags["AO-Token"], + Tags: expect.arrayContaining([ + expect.objectContaining({ + name: "Action", + value: "Transfer" + }), + expect.objectContaining({ + name: "Recipient", + value: balance.addr + }), + expect.objectContaining({ + name: "Quantity", + value: balance.qty + }), + ]) + }) + ))) + ); + }); +}); diff --git a/src/borrow/pool.lua b/src/borrow/pool.lua index 54505a6..1595ec2 100644 --- a/src/borrow/pool.lua +++ b/src/borrow/pool.lua @@ -11,6 +11,9 @@ function mod.setup(msg) "Invalid collateral id" ) + -- AO token process + AOToken = AOToken or ao.env.Process.Tags["AO-Token"] or "0syT13r0s0tgPmIed95bJnuSqaD29HQNN8D3ElLSrsc" + -- token that can be lent/borrowed CollateralID = CollateralID or ao.env.Process.Tags["Collateral-Id"] diff --git a/src/controller/config.lua b/src/controller/config.lua index a3b0f1a..b02406f 100644 --- a/src/controller/config.lua +++ b/src/controller/config.lua @@ -20,6 +20,8 @@ function mod.update(msg) local newJumpRate = tonumber(msg.Tags["Jump-Rate"]) local newInitRate = tonumber(msg.Tags["Init-Rate"]) local newCooldownPeriod = tonumber(msg.Tags["Cooldown-Period"]) + local newWrappedAOToken = msg.Tags["Wrapped-AO-Token"] + local newAOToken = msg.Tags["AO-Token"] -- validate new config values, update assert( @@ -62,6 +64,14 @@ function mod.update(msg) not newCooldownPeriod or assertions.isValidInteger(newCooldownPeriod), "Invalid cooldown period" ) + assert( + not newWrappedAOToken or assertions.isAddress(newWrappedAOToken), + "Invalid Wrapped AO token process ID" + ) + assert( + not newAOToken or assertions.isAddress(newAOToken), + "Invalid AO token process ID" + ) if newValueLimit then assert( @@ -96,6 +106,8 @@ function mod.update(msg) if newJumpRate then JumpRate = newJumpRate end if newInitRate then InitRate = newInitRate end if newCooldownPeriod then CooldownPeriod = newCooldownPeriod end + if newWrappedAOToken then WrappedAOToken = newWrappedAOToken end + if newAOToken then AOToken = newAOToken end msg.reply({ Oracle = Oracle, @@ -107,7 +119,9 @@ function mod.update(msg) ["Kink-Param"] = tostring(KinkParam), ["Base-Rate"] = tostring(BaseRate), ["Jump-Rate"] = tostring(JumpRate), - ["Init-Rate"] = tostring(InitRate) + ["Init-Rate"] = tostring(InitRate), + ["AO-Token"] = tostring(AOToken), + ["Wrapped-AO-Token"] = tostring(WrappedAOToken) }) end diff --git a/src/process.lua b/src/process.lua index 04e77ef..33dea04 100644 --- a/src/process.lua +++ b/src/process.lua @@ -31,6 +31,7 @@ local liquidate = require ".liquidations.liquidate" local mint = require ".supply.mint" local rate = require ".supply.rate" local redeem = require ".supply.redeem" +local delegation = require ".supply.delegation" local utils = require ".utils.utils" local precision = require ".utils.precision" @@ -51,6 +52,7 @@ local function setup_handlers() pool.setup(msg, env) oracle.setup(msg, env) cooldown.setup(msg, env) + delegation.setup(msg, env) end ) @@ -85,8 +87,11 @@ local function setup_handlers() if msg.Tags.Action ~= "Credit-Notice" then return false -- not a token transfer end + if WrappedAO ~= nil and msg.From == AOToken and msg.Tags.Sender == WrappedAO then + return false -- this oToken process accrues AO and the message is a wAO claim response + end if msg.From ~= CollateralID then - return true -- unknown token + return true -- unknown token end if utils.includes(msg.Tags["X-Action"], { "Repay", @@ -119,6 +124,25 @@ local function setup_handlers() errorHandler = cooldown.refund }) + -- accrued AO distribution for actions that update oToken balances + Handlers.add( + "supply-delegate-ao", + function (msg) + local action = msg.Tags["X-Action"] or msg.Tags.Action + + if action == "Delegate" then return true end + if + action == "Mint" or + action == "Redeem" or + action == "Liquidate-Position" or + action == "Transfer" + then return "continue" end + + return false + end, + delegation.delegate + ) + -- communication with the controller Handlers.add( "controller-updater", diff --git a/src/supply/delegation.lua b/src/supply/delegation.lua new file mode 100644 index 0000000..41c11b3 --- /dev/null +++ b/src/supply/delegation.lua @@ -0,0 +1,120 @@ +local assertions = require ".utils.assertions" +local bint = require ".utils.bint"(1024) +local utils = require ".utils.utils" + +local mod = {} + +---@type HandlerFunction +function mod.setup() + -- validate wAO address + local wAOProcess = ao.env.Process.Tags["Wrapped-AO-Token"] + + if not wAOProcess then return end + assert(assertions.isAddress(wAOProcess), "Invalid wAO process id") + + -- wrapped arweave process id + WrappedAOToken = WrappedAOToken or wAOProcess + + -- remaining quantity to distribute + RemainingDelegateQuantity = RemainingDelegateQuantity or "0" +end + +-- Claims and distributes accrued AO yield for owAR +---@type HandlerFunction +function mod.delegate(msg) + -- only run if defined + if not WrappedAOToken then return end + + -- the original message this message was pushed for + local pushedFor = msg.Tags["Pushed-For"] or msg.Id + + -- record oToken balances before the current interaction, so the + -- correct quantities are used after the handler below is triggered + local balancesRecord = {} + + for addr, balance in pairs(Balances) do + balancesRecord[addr] = balance + end + + -- claim accrued AO, but do not stop execution with .receive() + -- + -- this is necessary, because this handler runs before interactions + -- (mint/redeem/liquidate position/transfer) that should not be delayed + local claimMsg = ao.send({ + Target = WrappedAOToken, + Action = "Claim" + }) + local claimRef = (utils.find( + function (tag) return tag.name == "Reference" end, + claimMsg.Tags + ) or {}).value + + -- add handler that handles a potential claim error/credit-notice + Handlers.once( + function (msg) + local action = msg.Tags.Action + + -- claim error + if action == "Claim-Error" and msg.From == WrappedAOToken and msg.Tags["X-Reference"] == claimRef then + return true + end + + -- credit notice for the claimed AO + if action == "Credit-Notice" and msg.From == AOToken and msg.Tags["Pushed-For"] == pushedFor then + return true + end + + return false + end, + function (msg) + -- do not distribute if there was an error + if msg.Tags.Action == "Claim-Error" then return end + + -- validate quantity + assert( + assertions.isTokenQuantity(msg.Tags.Quantity), + "Invalid claimed quantity" + ) + + -- quantity to distribute (the incoming + the remainder) + local quantity = bint(msg.Tags.Quantity) + bint(RemainingDelegateQuantity or "0") + + -- distribute claimed AO + local remaining = bint(quantity) + local totalSupply = bint(TotalSupply) + local zero = bint.zero() + + for addr, rawBalance in pairs(balancesRecord) do + -- parsed wallet balance + local balance = bint(rawBalance) + + -- amount to distribute to this wallet + local distributeQty = quantity.udiv( + balance * quantity, + totalSupply + ) + + -- distribute if more than 0 + if bint.ult(zero, distributeQty) then + ao.send({ + Target = AOToken, + Action = "Transfer", + Quantity = tostring(distributeQty), + Recipient = addr + }) + remaining = remaining - distributeQty + end + end + + -- make sure that the remainder is at least zero, otherwise + -- something went wrong with the calculations (this should not + -- happen) + assert(bint.ule(zero, remaining), "The distribution remainder cannot be less than zero") + + -- update the remaining amount + RemainingDelegateQuantity = tostring(remaining) + end + ) +end + +return mod