Skip to content

Commit

Permalink
feat(PE-6656) - return initiated dutch auctions (#92)
Browse files Browse the repository at this point in the history
Summary:
Introduces and `Auction` object class that is used to store auctions on
the `NameRegistry`. We should look towards using more of these classes
to leverage some better static analysis and type enforcement on
functions. The class is responsible for computing prices based on the
initialization details (baseFee, demandFactor, etc.). Auctions are
stored as instances of this class, and returned as simple table
(excluding function) so clients can compute prices themselves.

This initial implementation includes handler support for ANTs returning
`permabuy` name registrations. In a separate PR we'll introduce
Lease-Expiration-Intiated-Auctions where a name will go in to auction
after the grace period has ended.

Checklist:
- [x] business logic (and docs) to create and store auctions in state
with pricing information
- [x] handler for `Release-Name` that allows integration with ANTs
- [x] handler for `Auction-Info` to get auction information of a given
name
- [x] handler for `Auction-Bid` to submit bid to acquire name in auction
and update arns registry
- [x] unit tests with >80% coverage
- [x] integration tests for all handlers
- [x] update ANT source code to support `Release-Name` handler that
forwards to this process
    - PR: ar-io/ar-io-ant-process#23
- [x] update [ar-io-sdk](https://github.com/ar-io/ar-io-sdk) to include
auction types and APIs for auctions
    - PR: ar-io/ar-io-sdk#235
- [ ] add event data for auction creation and auction bids
- WILL DO IN A SEPARATE PR ONCE E2E TESTING IN DEVNET HAS BEEN VALIDATED

TODOs:
- [x] storing prices in auction state impacts the handler response
(`Data` ends up being an empty table - not sure if this is a bug in AO
or we are hitting a memory limit). it also costs ~600KiB to store those
values in state, which could bloat our memory when several auctions are
in process. we could consider just storing the settings of the auction
pricing in state, and use that to calculate prices when necessary (still
have to create the table, but response doesn't need to contain it). need
to think more about this and what is necessary for clients to be able to
showing pricing charts.
- we've modified to store the auction settings on the auction object,
and then clients will compute the price with various parameters

Here is the sequence diagram for a permabuy initiated auction
<img width="794" alt="image"
src="https://github.com/user-attachments/assets/8b1699e2-a764-4a7d-ad65-cafddfe66c19">
  • Loading branch information
dtfiedler authored Oct 23, 2024
2 parents b40035e + c48a9d6 commit 6d76e21
Show file tree
Hide file tree
Showing 14 changed files with 1,350 additions and 682 deletions.
33 changes: 33 additions & 0 deletions docs/auctions.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
```mermaid
sequenceDiagram
participant Owner/Initiator
participant Bidder
participant ANTProcess
participant ProtocolBalance
participant ARNSRegistry
Owner/Initiator ->> ANTProcess: Send "Release-Name" message with tags
alt Owner/Initiator is not owner of ANT process
ANTProcess ->> Owner/Initiator: Invalid-Return-Name-Notice
else Owner/Initiator is owner of ANT process
ANTProcess ->> ARNSRegistry: Notify registry with `Release-Name` with [Name, Recipient]
alt Name does not exist or does not map to process id
ARNSRegistry ->> Owner/Initiator: Invalid-Return-Name-Notice (could be ANT)
else Name exists and maps to name
ARNSRegistry ->> ARNSRegistry: Create auction for name
ARNSRegistry ->> ARNSRegistry: Accept bids for auction
alt Valid bid received before expiration
Bidder ->> ARNSRegistry: Send bid message with process-id
ARNSRegistry ->> ARNSRegistry: Validate bid and calculate payouts
ARNSRegistry ->> ARNSRegistry: Name added to registry
ARNSRegistry ->> ProtocolBalance: Transfer 50% of proceeds
ARNSRegistry ->> Owner/Initiator: Transfer 50% of proceeds
ARNSRegistry ->> Bidder: Send Auction-Bid-Success-Notice
else Invalid bid received before expiration (insufficient balance, too low, etc.)
ARNSRegistry ->> Bidder: Auction-Bid-Failure-Notice
else No bid received, auction expires
ARNSRegistry ->> ARNSRegistry: Release name, no payouts
end
end
end
```
2 changes: 1 addition & 1 deletion docs/save_observations.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ graph TD
CheckFailedGateway -- Valid --> ProcessFailedGateway{Check existing gateway failures}
CheckFailedGateway -- Invalid --> Skip
Skip --> UpdateObserverReportTxId[Update Observer Report Tx Id]
ProcessFailedGateway -- not found --> CreateFailedGateway[Create Failed Gateway Object]
ProcessFailedGateway -- Does not Exist --> CreateFailedGateway[Create Failed Gateway Object]
ProcessFailedGateway -- Exists --> UpdateFailedGateway[Update Failed Gateway Object]
UpdateFailedGateway -- Updated --> UpdateObserverReportTxId[Update Observer Report Tx Id]
CreateFailedGateway -- Created --> UpdateObserverReportTxId[Update Observer Report Tx Id]
Expand Down
258 changes: 258 additions & 0 deletions spec/arns_spec.lua
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ local arns = require("arns")
local balances = require("balances")
local demand = require("demand")
local utils = require("utils")
local Auction = require("auctions")

describe("arns", function()
local timestamp = 0
Expand All @@ -16,6 +17,7 @@ describe("arns", function()
_G.NameRegistry = {
records = {},
reserved = {},
auctions = {},
}
_G.Balances = {
[testAddressArweave] = startBalance,
Expand Down Expand Up @@ -592,6 +594,41 @@ describe("arns", function()
end)
end)

describe("pruneAuctions", function()
it("should remove expired auctions", function()
local currentTimestamp = 1000000
local existingAuction = Auction:new(
"active-auction",
currentTimestamp,
1,
500000000,
"test-initiator",
arns.calculateRegistrationFee
)
local expiredAuction = Auction:new(
"expired-auction",
currentTimestamp,
1,
500000000,
"test-initiator",
arns.calculateRegistrationFee
)
-- manually set the end timestamp to the current timestamp
expiredAuction.endTimestamp = currentTimestamp
_G.NameRegistry.auctions = {
["active-auction"] = existingAuction,
["expired-auction"] = expiredAuction,
}
local prunedAuctions = arns.pruneAuctions(currentTimestamp)
assert.are.same({
["expired-auction"] = expiredAuction,
}, prunedAuctions)
assert.are.same({
["active-auction"] = existingAuction,
}, _G.NameRegistry.auctions)
end)
end)

describe("getRegistrationFees", function()
it("should return the correct registration prices", function()
local registrationFees = arns.getRegistrationFees()
Expand All @@ -606,6 +643,227 @@ describe("arns", function()
end)
end)

describe("auctions", function()
before_each(function()
_G.NameRegistry.records["test-name"] = {
endTimestamp = nil,
processId = "test-process-id",
purchasePrice = 600000000,
startTimestamp = 0,
type = "permabuy",
undernameLimit = 10,
}
end)

describe("createAuction", function()
it("should create an auction and remove any existing record", function()
local auction = arns.createAuction("test-name", 1000000, "test-initiator")
local twoWeeksMs = 1000 * 60 * 60 * 24 * 14
assert.are.equal(auction.name, "test-name")
assert.are.equal(auction.startTimestamp, 1000000)
assert.are.equal(auction.endTimestamp, twoWeeksMs + 1000000) -- 14 days late
assert.are.equal(auction.initiator, "test-initiator")
assert.are.equal(auction.baseFee, 500000000)
assert.are.equal(auction.demandFactor, 1)
assert.are.equal(auction.settings.decayRate, 0.02037911 / (1000 * 60 * 60 * 24 * 14))
assert.are.equal(auction.settings.scalingExponent, 190)
assert.are.equal(auction.settings.startPriceMultiplier, 50)
assert.are.equal(auction.settings.durationMs, twoWeeksMs)
assert.are.equal(NameRegistry.records["test-name"], nil)
end)

it("should throw an error if the name is already in the auction map", function()
local existingAuction =
Auction:new("test-name", 1000000, 1, 500000000, "test-initiator", arns.calculateRegistrationFee)
_G.NameRegistry.auctions = {
["test-name"] = existingAuction,
}
local status, error = pcall(arns.createAuction, "test-name", 1000000, "test-initiator")
assert.is_false(status)
assert.match("Auction already exists", error)
end)

it("should throw an error if the name is not registered", function()
_G.NameRegistry.records["test-name"] = nil
local status, error = pcall(arns.createAuction, "test-name", 1000000, "test-initiator")
assert.is_false(status)
assert.match("Name is not registered", error)
end)
end)

describe("getAuction", function()
it("should return the auction", function()
local auction = arns.createAuction("test-name", 1000000, "test-initiator")
local retrievedAuction = arns.getAuction("test-name")
assert.are.same(retrievedAuction, auction)
end)
end)

describe("getPriceForAuctionAtTimestamp", function()
it("should return the correct price for an auction at a given timestamp for a permabuy", function()
local startTimestamp = 1000000
local auction = arns.createAuction("test-name", startTimestamp, "test-initiator")
local currentTimestamp = startTimestamp + 1000 * 60 * 60 * 24 * 7 -- 1 week into the auction
local decayRate = 0.02037911 / (1000 * 60 * 60 * 24 * 14)
local scalingExponent = 190
local expectedStartPrice = auction.registrationFeeCalculator(
"permabuy",
auction.baseFee,
nil,
auction.demandFactor
) * 50
local timeSinceStart = currentTimestamp - auction.startTimestamp
local totalDecaySinceStart = decayRate * timeSinceStart
local expectedPriceAtTimestamp =
math.floor(expectedStartPrice * ((1 - totalDecaySinceStart) ^ scalingExponent))
local priceAtTimestamp = auction:getPriceForAuctionAtTimestamp(currentTimestamp, "permabuy", nil)
assert.are.equal(expectedPriceAtTimestamp, priceAtTimestamp)
end)
end)

describe("computePricesForAuction", function()
it("should return the correct prices for an auction with for a lease", function()
local startTimestamp = 1729524023521
local auction = arns.createAuction("test-name", startTimestamp, "test-initiator")
local intervalMs = 1000 * 60 * 15 -- 15 min (how granular we want to compute the prices)
local prices = auction:computePricesForAuction("lease", 1, intervalMs)
local baseFee = 500000000
local oneYearLeaseFee = baseFee * constants.ANNUAL_PERCENTAGE_FEE * 1
local floorPrice = baseFee + oneYearLeaseFee
local startPriceForLease = floorPrice * 50
-- create the curve of prices using the parameters of the auction
local decayRate = auction.settings.decayRate
local scalingExponent = auction.settings.scalingExponent
-- all the prices before the last one should match
for i = startTimestamp, auction.endTimestamp - intervalMs, intervalMs do
local timeSinceStart = i - auction.startTimestamp
local totalDecaySinceStart = decayRate * timeSinceStart
local expectedPriceAtTimestamp =
math.floor(startPriceForLease * ((1 - totalDecaySinceStart) ^ scalingExponent))
assert.are.equal(
expectedPriceAtTimestamp,
prices[i],
"Price at timestamp" .. i .. " should be " .. expectedPriceAtTimestamp
)
end
-- make sure the last price at the end of the auction is the floor price
local lastProvidedPrice = prices[auction.endTimestamp]
local lastComputedPrice = auction:getPriceForAuctionAtTimestamp(auction.endTimestamp, "lease", 1)
local listPricePercentDifference = (lastComputedPrice - lastProvidedPrice) / lastProvidedPrice
assert.is_true(
listPricePercentDifference <= 0.0001,
"Last price should be within 0.01% of the final price in the interval. Last computed: "
.. lastComputedPrice
.. " Last provided: "
.. lastProvidedPrice
)
end)
end)

describe("submitAuctionBid", function()
it(
"should accept bid on an existing auction and transfer tokens to the auction initiator and protocol balance, and create the record",
function()
local startTimestamp = 1000000
local bidTimestamp = startTimestamp + 1000 * 60 * 2 -- 2 min into the auction
local demandBefore = demand.getCurrentPeriodPurchases()
local revenueBefore = demand.getCurrentPeriodRevenue()
local baseFee = 500000000
local permabuyAnnualFee = baseFee * constants.ANNUAL_PERCENTAGE_FEE * 20
local floorPrice = baseFee + permabuyAnnualFee
local startPrice = floorPrice * 50
local auction = arns.createAuction("test-name", startTimestamp, "test-initiator")
local result = arns.submitAuctionBid(
"test-name",
startPrice,
testAddressArweave,
bidTimestamp,
"test-process-id",
"permabuy",
nil
)
local balances = balances.getBalances()
local expectedPrice = math.floor(
startPrice
* (
(1 - (auction.settings.decayRate * (bidTimestamp - startTimestamp)))
^ auction.settings.scalingExponent
)
)
local expectedRecord = {
endTimestamp = nil,
processId = "test-process-id",
purchasePrice = expectedPrice,
startTimestamp = bidTimestamp,
type = "permabuy",
undernameLimit = 10,
}
local expectedInitiatorReward = math.floor(expectedPrice * 0.5)
local expectedProtocolReward = expectedPrice - expectedInitiatorReward
assert.are.equal(expectedInitiatorReward, balances["test-initiator"])
assert.are.equal(expectedProtocolReward, balances[_G.ao.id])
assert.are.equal(nil, NameRegistry.auctions["test-name"])
assert.are.same(expectedRecord, NameRegistry.records["test-name"])
assert.are.same(expectedRecord, result.record)
assert.are.equal(
demandBefore + 1,
demand.getCurrentPeriodPurchases(),
"Purchases should increase by 1"
)
assert.are.equal(
revenueBefore + expectedPrice,
demand.getCurrentPeriodRevenue(),
"Revenue should increase by the bid amount"
)
end
)

it("should throw an error if the auction is not found", function()
local status, error =
pcall(arns.submitAuctionBid, "test-name-2", 1000000000, "test-bidder", 1000000, "test-process-id")
assert.is_false(status)
assert.match("Auction does not exist", error)
end)

it("should throw an error if the bid is not high enough", function()
local startTimestamp = 1000000
local auction = arns.createAuction("test-name", startTimestamp, "test-initiator")
local startPrice = auction:getPriceForAuctionAtTimestamp(startTimestamp, "permabuy", nil)
local status, error = pcall(
arns.submitAuctionBid,
"test-name",
startPrice - 1,
testAddressArweave,
startTimestamp,
"test-process-id",
"permabuy",
nil
)
assert.is_false(status)
assert.match("Bid amount is less than the required bid of " .. startPrice, error)
end)

it("should throw an error if the bidder does not have enough balance", function()
local startTimestamp = 1000000
local auction = arns.createAuction("test-name", startTimestamp, "test-initiator")
local requiredBid = auction:getPriceForAuctionAtTimestamp(startTimestamp, "permabuy", nil)
_G.Balances[testAddressArweave] = requiredBid - 1
local status, error = pcall(
arns.submitAuctionBid,
"test-name",
requiredBid,
testAddressArweave,
startTimestamp,
"test-process-id",
"permabuy",
nil
)
assert.is_false(status)
assert.match("Insufficient balance", error)
end)
end)
end)

describe("getPaginatedRecords", function()
before_each(function()
_G.NameRegistry = {
Expand Down
Loading

0 comments on commit 6d76e21

Please sign in to comment.