diff --git a/package.json b/package.json index 38b43d56..2ecf3d37 100644 --- a/package.json +++ b/package.json @@ -9,6 +9,7 @@ "test:unit:debug": "rm -rf coverage && mkdir -p coverage && DEBUG=true busted . && luacov", "test:coverage": "rm -rf luacov-html && yarn test:unit && luacov --reporter html && open luacov-html/index.html", "test:integration": "yarn build && node --test --experimental-wasm-memory64 **/*.test.mjs", + "patch:new": "node patch.mjs $1", "monitor:down": "docker compose -f tests/monitor/docker-compose.test.yml down", "monitor": "yarn monitor:down && node --test tests/monitor/monitor.test.mjs", "monitor:devnet": "yarn monitor:down && ARIO_NETWORK_PROCESS_ID=GaQrvEMKBpkjofgnBi_B3IgIDmY_XYelVLB6GcRGrHc node --test tests/monitor/monitor.test.mjs", diff --git a/patch.mjs b/patch.mjs new file mode 100644 index 00000000..6686b02b --- /dev/null +++ b/patch.mjs @@ -0,0 +1,26 @@ +/** + * + * Script to create a new patch file. + * + * Usage: node patch.mjs + * + * Example: node patch.mjs add-demand-factor-data + * + * + */ +import fs from 'fs'; + +const date = new Date().toISOString().split('T')[0]; + +const patchName = process.argv[2]; + +if (!patchName) { + console.error('Patch name is required'); + process.exit(1); +} + +fs.mkdirSync('patches', { recursive: true }); +fs.writeFileSync( + `patches/${date}-${patchName.replace(/ /g, '-').toLowerCase().trim()}.lua`, + '--[[\n\tPLACEHOLDER FOR PATCH DESCRIPTION\n\n\n\tReviewers: [PLACEHOLDER FOR REVIEWERS]\n]]--', +); diff --git a/patches/2025-03-10-create-primary-name-request-demand-factor.lua b/patches/2025-03-10-create-primary-name-request-demand-factor.lua new file mode 100644 index 00000000..d20f18b2 --- /dev/null +++ b/patches/2025-03-10-create-primary-name-request-demand-factor.lua @@ -0,0 +1,238 @@ +--[[ + Adds demand factor data to the ioEvent for the requestPrimaryName handler. + + NOTE: we have to include all the local functions in this patch as they are not available in global scope. + + Reviewers: Dylan, Ariel, Atticus, Jon, Phil, Derek +]] +-- +local utils = require(".src.utils") +local primaryNames = require(".src.primary_names") +local arns = require(".src.arns") +local gar = require(".src.gar") +local balances = require(".src.balances") +local demand = require(".src.demand") +local constants = require(".src.constants") + +-- Update the primaryNames global function to return the demand factor data +primaryNames.createPrimaryNameRequest = function(name, initiator, timestamp, msgId, fundFrom) + fundFrom = fundFrom or "balance" + + primaryNames.assertValidPrimaryName(name) + + name = string.lower(name) + local baseName = utils.baseNameForName(name) + + --- check the primary name request for the initiator does not already exist for the same name + --- this allows the caller to create a new request and pay the fee again, so long as it is for a different name + local existingRequest = primaryNames.getPrimaryNameRequest(initiator) + assert( + not existingRequest or existingRequest.name ~= name, + "Primary name request by '" .. initiator .. "' for '" .. name .. "' already exists" + ) + + --- check the primary name is not already owned + local primaryNameOwner = primaryNames.getAddressForPrimaryName(name) + assert(not primaryNameOwner, "Primary name is already owned") + + local record = arns.getRecord(baseName) + assert(record, "ArNS record '" .. baseName .. "' does not exist") + assert(arns.recordIsActive(record, timestamp), "ArNS record '" .. baseName .. "' is not active") + + local requestCost = arns.getTokenCost({ + intent = "Primary-Name-Request", + name = name, + currentTimestamp = timestamp, + record = record, + }) + + local fundingPlan = gar.getFundingPlan(initiator, requestCost.tokenCost, fundFrom) + assert(fundingPlan and fundingPlan.shortfall == 0, "Insufficient balances") + local fundingResult = gar.applyFundingPlan(fundingPlan, msgId, timestamp) + assert(fundingResult.totalFunded == requestCost.tokenCost, "Funding plan application failed") + + --- transfer the primary name cost from the initiator to the protocol balance + balances.increaseBalance(ao.id, requestCost.tokenCost) + demand.tallyNamePurchase(requestCost.tokenCost) + + local request = { + name = name, + startTimestamp = timestamp, + endTimestamp = timestamp + constants.PRIMARY_NAME_REQUEST_DURATION_MS, + } + + --- if the initiator is base name owner, then just set the primary name and return + local newPrimaryName + if record.processId == initiator then + newPrimaryName = primaryNames.setPrimaryNameFromRequest(initiator, request, timestamp) + else + -- otherwise store the request for asynchronous approval + PrimaryNames.requests[initiator] = request + primaryNames.scheduleNextPrimaryNamesPruning(request.endTimestamp) + end + + return { + request = request, + newPrimaryName = newPrimaryName, + baseNameOwner = record.processId, + fundingPlan = fundingPlan, + fundingResult = fundingResult, + demandFactor = demand.getDemandFactorInfo(), + } +end + +-- Now update main.lua to use the new function and add the demand factor data +local createPrimaryNameRequestHandlerIndex = utils.findInArray(Handlers.list, function(handler) + return handler.name == "requestPrimaryName" +end) + +if not createPrimaryNameRequestHandlerIndex then + error("Failed to find requestPrimaryName handler") +end + +local createPrimaryNameRequestHandler = Handlers.list[createPrimaryNameRequestHandlerIndex] +if not createPrimaryNameRequestHandler then + error("Failed to find requestPrimaryName handler") +end + +local function Send(msg, response) + if msg.reply then + --- Reference: https://github.com/permaweb/aos/blob/main/blueprints/patch-legacy-reply.lua + msg.reply(response) + else + ao.send(response) + end +end + +local function assertValidFundFrom(fundFrom) + if fundFrom == nil then + return + end + local validFundFrom = utils.createLookupTable({ "any", "balance", "stakes" }) + assert(validFundFrom[fundFrom], "Invalid fund from type. Must be one of: any, balance, stakes") +end + +local function addPrimaryNameCounts(ioEvent) + ioEvent:addField("Total-Primary-Names", utils.lengthOfTable(primaryNames.getUnsafePrimaryNames())) + ioEvent:addField("Total-Primary-Name-Requests", utils.lengthOfTable(primaryNames.getUnsafePrimaryNameRequests())) +end + +local function adjustSuppliesForFundingPlan(fundingPlan, rewardForInitiator) + if not fundingPlan then + return + end + rewardForInitiator = rewardForInitiator or 0 + local totalActiveStakesUsed = utils.reduce(fundingPlan.stakes, function(acc, _, stakeSpendingPlan) + return acc + stakeSpendingPlan.delegatedStake + end, 0) + local totalWithdrawStakesUsed = utils.reduce(fundingPlan.stakes, function(acc, _, stakeSpendingPlan) + return acc + + utils.reduce(stakeSpendingPlan.vaults, function(acc2, _, vaultBalance) + return acc2 + vaultBalance + end, 0) + end, 0) + LastKnownStakedSupply = LastKnownStakedSupply - totalActiveStakesUsed + LastKnownWithdrawSupply = LastKnownWithdrawSupply - totalWithdrawStakesUsed + LastKnownCirculatingSupply = LastKnownCirculatingSupply - fundingPlan.balance + rewardForInitiator +end + +local function addResultFundingPlanFields(ioEvent, result) + ioEvent:addFieldsWithPrefixIfExist(result.fundingPlan, "FP-", { "balance" }) + local fundingPlanVaultsCount = 0 + local fundingPlanStakesAmount = utils.reduce( + result.fundingPlan and result.fundingPlan.stakes or {}, + function(acc, _, delegation) + return acc + + delegation.delegatedStake + + utils.reduce(delegation.vaults, function(acc2, _, vaultAmount) + fundingPlanVaultsCount = fundingPlanVaultsCount + 1 + return acc2 + vaultAmount + end, 0) + end, + 0 + ) + if fundingPlanStakesAmount > 0 then + ioEvent:addField("FP-Stakes-Amount", fundingPlanStakesAmount) + end + if fundingPlanVaultsCount > 0 then + ioEvent:addField("FP-Vaults-Count", fundingPlanVaultsCount) + end + local newWithdrawVaultsTallies = utils.reduce( + result.fundingResult and result.fundingResult.newWithdrawVaults or {}, + function(acc, _, newWithdrawVault) + acc.totalBalance = acc.totalBalance + + utils.reduce(newWithdrawVault, function(acc2, _, vault) + acc.count = acc.count + 1 + return acc2 + vault.balance + end, 0) + return acc + end, + { count = 0, totalBalance = 0 } + ) + if newWithdrawVaultsTallies.count > 0 then + ioEvent:addField("New-Withdraw-Vaults-Count", newWithdrawVaultsTallies.count) + ioEvent:addField("New-Withdraw-Vaults-Total-Balance", newWithdrawVaultsTallies.totalBalance) + end + adjustSuppliesForFundingPlan(result.fundingPlan, result.returnedName and result.returnedName.rewardForInitiator) +end + +--- @param ioEvent ARIOEvent +--- @param primaryNameResult CreatePrimaryNameResult|PrimaryNameRequestApproval +local function addPrimaryNameRequestData(ioEvent, primaryNameResult) + ioEvent:addFieldsIfExist(primaryNameResult, { "baseNameOwner" }) + ioEvent:addFieldsIfExist(primaryNameResult.newPrimaryName, { "owner", "startTimestamp" }) + ioEvent:addFieldsWithPrefixIfExist(primaryNameResult.request, "Request-", { "startTimestamp", "endTimestamp" }) + addResultFundingPlanFields(ioEvent, primaryNameResult) + addPrimaryNameCounts(ioEvent) + + -- add the demand factor data to the ioEvent + if primaryNameResult.demandFactor and type(primaryNameResult.demandFactor) == "table" then + ioEvent:addField("DF-Trailing-Period-Purchases", (primaryNameResult.demandFactor.trailingPeriodPurchases or {})) + ioEvent:addField("DF-Trailing-Period-Revenues", (primaryNameResult.demandFactor.trailingPeriodRevenues or {})) + ioEvent:addFieldsWithPrefixIfExist(primaryNameResult.demandFactor, "DF-", { + "currentPeriod", + "currentDemandFactor", + "consecutivePeriodsWithMinDemandFactor", + "revenueThisPeriod", + "purchasesThisPeriod", + }) + end +end + +-- Update the handler to use the new function and add the demand factor data +createPrimaryNameRequestHandler.handler = function(msg) + local fundFrom = msg.Tags["Fund-From"] + local name = msg.Tags.Name and string.lower(msg.Tags.Name) or nil + local initiator = msg.From + assert(name, "Name is required") + assert(initiator, "Initiator is required") + assertValidFundFrom(fundFrom) + + local primaryNameResult = primaryNames.createPrimaryNameRequest(name, initiator, msg.Timestamp, msg.Id, fundFrom) + + addPrimaryNameRequestData(msg.ioEvent, primaryNameResult) + + --- if the from is the new owner, then send an approved notice to the from + if primaryNameResult.newPrimaryName then + Send(msg, { + Target = msg.From, + Action = ActionMap.ApprovePrimaryNameRequest .. "-Notice", + Data = json.encode(primaryNameResult), + }) + return + end + + if primaryNameResult.request then + --- send a notice to the msg.From, and the base name owner + Send(msg, { + Target = msg.From, + Action = ActionMap.PrimaryNameRequest .. "-Notice", + Data = json.encode(primaryNameResult), + }) + Send(msg, { + Target = primaryNameResult.baseNameOwner, + Action = ActionMap.PrimaryNameRequest .. "-Notice", + Data = json.encode(primaryNameResult), + }) + end +end diff --git a/spec/primary_names_spec.lua b/spec/primary_names_spec.lua index df6ba989..d2b3ccf9 100644 --- a/spec/primary_names_spec.lua +++ b/spec/primary_names_spec.lua @@ -1,5 +1,6 @@ local primaryNames = require("primary_names") local utils = require("utils") +local demand = require("demand") describe("Primary Names", function() before_each(function() @@ -308,6 +309,7 @@ describe("Primary Names", function() 1234567890, "test-msg-id" ) + local demandFactor = demand.getDemandFactorInfo() assert.are.same({ request = { name = "test", @@ -325,6 +327,7 @@ describe("Primary Names", function() newWithdrawVaults = {}, totalFunded = 200000, }, + demandFactor = demandFactor, }, primaryNameRequest) assert.are.equal(9800000, _G.Balances["user-requesting-primary-name"]) assert.are.equal(200000, _G.Balances[ao.id]) @@ -361,6 +364,7 @@ describe("Primary Names", function() 1234567890, "test-msg-id" ) + local demandFactor = demand.getDemandFactorInfo() assert.are.same({ request = { name = primaryName, @@ -378,6 +382,7 @@ describe("Primary Names", function() newWithdrawVaults = {}, totalFunded = 200000, }, + demandFactor = demandFactor, }, primaryNameRequest) assert.are.equal(9800000, _G.Balances["user-requesting-primary-name"]) assert.are.equal(200000, _G.Balances[ao.id]) diff --git a/src/main.lua b/src/main.lua index a6b9bcae..0121194d 100644 --- a/src/main.lua +++ b/src/main.lua @@ -386,6 +386,19 @@ local function addPrimaryNameRequestData(ioEvent, primaryNameResult) ioEvent:addFieldsWithPrefixIfExist(primaryNameResult.request, "Request-", { "startTimestamp", "endTimestamp" }) addResultFundingPlanFields(ioEvent, primaryNameResult) addPrimaryNameCounts(ioEvent) + + -- demand factor data + if primaryNameResult.demandFactor and type(primaryNameResult.demandFactor) == "table" then + ioEvent:addField("DF-Trailing-Period-Purchases", (primaryNameResult.demandFactor.trailingPeriodPurchases or {})) + ioEvent:addField("DF-Trailing-Period-Revenues", (primaryNameResult.demandFactor.trailingPeriodRevenues or {})) + ioEvent:addFieldsWithPrefixIfExist(primaryNameResult.demandFactor, "DF-", { + "currentPeriod", + "currentDemandFactor", + "consecutivePeriodsWithMinDemandFactor", + "revenueThisPeriod", + "purchasesThisPeriod", + }) + end end local function assertValueBytesLowerThan(value, remainingBytes, tablesSeen) diff --git a/src/primary_names.lua b/src/primary_names.lua index 504e5b3f..08b2e7d7 100644 --- a/src/primary_names.lua +++ b/src/primary_names.lua @@ -40,7 +40,8 @@ local primaryNames = {} --- @field baseNameOwner WalletAddress --- @field fundingPlan table --- @field fundingResult table - +--- @field demandFactor table +--- -- NOTE: lua 5.3 has limited regex support, particularly for lookaheads and negative lookaheads or use of {n} ---@param name string ---@description Asserts that the provided name is a valid undername @@ -154,6 +155,7 @@ function primaryNames.createPrimaryNameRequest(name, initiator, timestamp, msgId baseNameOwner = record.processId, fundingPlan = fundingPlan, fundingResult = fundingResult, + demandFactor = demand.getDemandFactorInfo(), } end diff --git a/tests/primary.test.mjs b/tests/primary.test.mjs index 3c3f2ee3..676e82ae 100644 --- a/tests/primary.test.mjs +++ b/tests/primary.test.mjs @@ -1,6 +1,7 @@ import { assertNoResultError, buyRecord, + getDemandFactorInfo, handle, parseEventsFromResult, setUpStake, @@ -213,6 +214,8 @@ describe('primary names', function () { type: 'permabuy', }); + const buyRecordData = JSON.parse(buyRecordResult.Messages[0].Data); + const stakeResult = await setUpStake({ memory: buyRecordResult.Memory, stakerAddress: recipient, @@ -229,6 +232,10 @@ describe('primary names', function () { fundFrom: 'stakes', }); + const requestPrimaryNameData = JSON.parse( + requestPrimaryNameResult.Messages[0].Data, + ); + const parsedEvents = parseEventsFromResult(requestPrimaryNameResult); assert.equal(parsedEvents.length, 1); assert.deepStrictEqual(parsedEvents[0], { @@ -253,6 +260,15 @@ describe('primary names', function () { 'Memory-KiB-Used': parsedEvents[0]['Memory-KiB-Used'], 'Handler-Memory-KiB-Used': parsedEvents[0]['Handler-Memory-KiB-Used'], 'Final-Memory-KiB-Used': parsedEvents[0]['Final-Memory-KiB-Used'], + 'DF-Consecutive-Periods-With-Min-Demand-Factor': 0, + 'DF-Trailing-Period-Purchases': [0, 0, 0, 0, 0, 0, 0], + 'DF-Trailing-Period-Revenues': [0, 0, 0, 0, 0, 0, 0], + 'DF-Current-Demand-Factor': 1, + 'DF-Current-Period': 1, + 'DF-Purchases-This-Period': 2, + 'DF-Revenue-This-Period': + buyRecordData.purchasePrice + + requestPrimaryNameData.fundingResult.totalFunded, }); const { result: getPrimaryNameRequestResult } = @@ -397,6 +413,11 @@ describe('primary names', function () { memory: buyRecordResult.Memory, }); + const demandFactor = await getDemandFactorInfo({ + memory: requestPrimaryNameResult.Memory, + timestamp: approvalTimestamp, + }); + assertNoResultError(requestPrimaryNameResult); const parsedEvents = parseEventsFromResult(requestPrimaryNameResult); assert.equal(parsedEvents.length, 1); @@ -422,6 +443,14 @@ describe('primary names', function () { 'Memory-KiB-Used': parsedEvents[0]['Memory-KiB-Used'], 'Handler-Memory-KiB-Used': parsedEvents[0]['Handler-Memory-KiB-Used'], 'Final-Memory-KiB-Used': parsedEvents[0]['Final-Memory-KiB-Used'], + // validate the demand factor data was updated + 'DF-Consecutive-Periods-With-Min-Demand-Factor': 0, + 'DF-Trailing-Period-Purchases': [0, 0, 0, 0, 0, 0, 0], + 'DF-Trailing-Period-Revenues': [0, 0, 0, 0, 0, 0, 0], + 'DF-Current-Demand-Factor': 1, + 'DF-Current-Period': 1, + 'DF-Purchases-This-Period': 2, + 'DF-Revenue-This-Period': 2001000000, // buy name + request primary name }); // there should be only one message with the Approve-Primary-Name-Request-Notice action @@ -461,6 +490,7 @@ describe('primary names', function () { name: 'test-name', startTimestamp: approvalTimestamp, }, + demandFactor, }); // now fetch the primary name using the owner address