Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
57ad48b
fix(primary-names): add patching for primary names
atticusofsparta Oct 16, 2025
f06ae20
fix(hb): call patch primary names
atticusofsparta Oct 17, 2025
2692e25
fix(hb): add tests
atticusofsparta Oct 18, 2025
3619567
fix(hb): refactor hb primary names patch
atticusofsparta Oct 18, 2025
9876f91
fix(hb): update primary names patching
atticusofsparta Oct 28, 2025
c820c3e
fix(hb): reconfigure primary name hb sync
atticusofsparta Oct 28, 2025
78d1da0
fix(comment): spelling and formatting
atticusofsparta Oct 29, 2025
8082bad
fix(comment): spelling and formatting
atticusofsparta Oct 29, 2025
5e79506
Merge branch 'develop' into PE-8643-hb-patch-system
atticusofsparta Oct 29, 2025
b61b317
fix(hb): add patch file
atticusofsparta Oct 29, 2025
d18cf6e
fix(patch file): update patch file with primary names stuff
atticusofsparta Oct 29, 2025
e6099b6
Merge branch 'develop' into PE-8643-hb-patch-system
atticusofsparta Oct 30, 2025
04b611b
fix(hb): remove balances from hb reset util
atticusofsparta Oct 30, 2025
627b426
Merge remote-tracking branch 'refs/remotes/origin/PE-8643-hb-patch-sy…
atticusofsparta Oct 30, 2025
e8c79e8
fix(comments): update comments for patch device
atticusofsparta Oct 30, 2025
663aa58
fix(patch): setup patch for balances in hb patch system
atticusofsparta Oct 30, 2025
81c977a
fix(hb): update patch file
atticusofsparta Oct 31, 2025
2744490
fix(tests): update tests with prune state and patch checks
atticusofsparta Oct 31, 2025
9cd4857
fix(patch file): update patch file for hb changes
atticusofsparta Oct 31, 2025
4ce76ec
fix: do not add protocol balance on each patch
atticusofsparta Nov 3, 2025
0ead736
fix: comment on why we copy balances in the prune handler
atticusofsparta Nov 3, 2025
752245d
fix(tests): add prune testing for primary names'
atticusofsparta Nov 3, 2025
4fe8904
fix(tests): update patch tests
atticusofsparta Nov 3, 2025
9b32c69
fix(hb): send individual patch messages
atticusofsparta Nov 3, 2025
569458b
fix(patch): update patch file
atticusofsparta Nov 3, 2025
52cbab5
fix(types): update hb type
atticusofsparta Nov 3, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3,448 changes: 3,448 additions & 0 deletions patches/2025-11-03-patch-device-primary-names-and-balances.lua

Large diffs are not rendered by default.

20 changes: 20 additions & 0 deletions src/globals.lua
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,26 @@ local constants = require(".src.constants")
local utils = require(".src.utils")
local globals = {}

--[[
HyperbeamSync is a table that is used to track changes to our lua state that need to be synced to the Hyperbeam.
the principle of using it is to set the key:value pairs that need to be synced, then
the patch function will pull that from the global state to build the patch message.
After, the HyperbeamSync table is cleared and the next message run will start fresh.
]]
HyperbeamSync = HyperbeamSync
or {
---@type table<string, number> addresses that have had balance changes
balances = {},
primaryNames = {
---@type table<string, boolean> addresses that have had name changes
names = {},
---@type table<string, boolean> addresses that have had owner changes
owners = {},
---@type table<string, boolean> addresses that have had request changes
requests = {},
},
}

--[[
Constants
]]
Expand Down
127 changes: 106 additions & 21 deletions src/hb.lua
Original file line number Diff line number Diff line change
@@ -1,41 +1,126 @@
-- hb.lua needs to be in its own file and not in balances.lua to avoid circular dependencies
local hb = {}

---@param oldBalances table<string, number> A table of addresses and their balances
---@return table<string, boolean> affectedBalancesAddresses table of addresses that have had balance changes
function hb.patchBalances(oldBalances)
assert(type(oldBalances) == "table", "Old balances must be a table")
---@return table<string, string>|nil affectedBalancesAddresses table of addresses and their balance values as strings
function hb.createBalancesPatch()
local affectedBalancesAddresses = {}
for address, _ in pairs(oldBalances) do
if Balances[address] ~= oldBalances[address] then
for address, _ in pairs(Balances) do
if HyperbeamSync.balances[address] ~= Balances[address] then
affectedBalancesAddresses[address] = true
end
end
for address, _ in pairs(Balances) do
if oldBalances[address] ~= Balances[address] then

for address, _ in pairs(HyperbeamSync.balances) do
if Balances[address] ~= HyperbeamSync.balances[address] then
affectedBalancesAddresses[address] = true
end
end

--- For simplicity we always include the protocol balance in the patch message
--- this also prevents us from sending an empty patch message and deleting the entire hyperbeam balances table\
-- Convert all affected addresses from boolean flags to actual balance values
local balancesPatch = {}
for address, _ in pairs(affectedBalancesAddresses) do
balancesPatch[address] = tostring(Balances[address] or 0)
end

if next(balancesPatch) == nil then
return nil
end
Comment on lines +7 to +27
Copy link

@coderabbitai coderabbitai bot Nov 3, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Delta detection regressed to full snapshots.
After hb.resetHyperbeamSync() only touched addresses live in the sync table, yet iterating every entry in Balances compares them against nil, so this block flags every address as “changed”. Each patch becomes a full balance snapshot instead of a tight delta, bloating payloads and defeating the optimization.

Apply this diff so the payload is derived strictly from the addresses we tracked:

-	for address, _ in pairs(Balances) do
-		if HyperbeamSync.balances[address] ~= Balances[address] then
-			affectedBalancesAddresses[address] = true
-		end
-	end
-
-	for address, _ in pairs(HyperbeamSync.balances) do
-		if Balances[address] ~= HyperbeamSync.balances[address] then
-			affectedBalancesAddresses[address] = true
-		end
-	end
+	for address, _ in pairs(HyperbeamSync.balances) do
+		affectedBalancesAddresses[address] = true
+	end

Removals still come through because missing entries fall back to "0" in the conversion step.

🤖 Prompt for AI Agents
In src/hb.lua around lines 7 to 27, the delta detection loops are iterating all
entries in Balances which, after hb.resetHyperbeamSync(), compares actual
balances to nil and marks every address as changed; remove the first loop that
iterates Balances and only iterate the addresses tracked in
HyperbeamSync.balances (the sync table) when comparing to Balances so the patch
is built strictly from tracked addresses; keep the conversion step that turns
missing entries into "0" so removals still emit correctly and return nil if no
changes.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is intentional - we need every address to be updated if its been altered since we have to update the remote store, even if locally that address was removed.

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@atticusofsparta, thank you for the clarification! I understand now—the dual iteration is by design to ensure complete synchronization with the remote Hyperbeam store.

Since HyperbeamSync.balances captures a snapshot before handlers modify state, comparing in both directions correctly detects:

  • First loop (iterating Balances): new or modified addresses
  • Second loop (iterating HyperbeamSync.balances): removed or modified addresses from the snapshot

This ensures all changes—additions, modifications, and removals—are captured and synced to the remote store. I've noted this for future reference.


✏️ Learnings added
Learnt from: atticusofsparta
Repo: ar-io/ar-io-network-process PR: 442
File: src/hb.lua:7-27
Timestamp: 2025-11-03T22:41:58.648Z
Learning: In `src/hb.lua`, the `hb.createBalancesPatch()` function intentionally iterates both `Balances` and `HyperbeamSync.balances` in two separate loops to detect all changes (additions, modifications, and removals) for complete remote store synchronization. The `HyperbeamSync.balances` table holds a snapshot of balances before handlers run, so comparing in both directions ensures every altered address is included in the patch sent to Hyperbeam.

Note: Learnings are effective only in the context of similar code segments. To apply general coding guidelines, please configure review instructions. You can manage existing learnings in the app.


🧠 Learnings used
Learnt from: atticusofsparta
Repo: ar-io/ar-io-network-process PR: 435
File: src/hb.lua:0-0
Timestamp: 2025-10-08T16:44:07.797Z
Learning: In `src/hb.lua`, the `hb.patchBalances()` function intentionally uses the global `Balances` variable directly and only accepts `oldBalances` as a parameter, rather than accepting both old and new balances as parameters.

Learnt from: atticusofsparta
Repo: ar-io/ar-io-network-process PR: 435
File: src/main.lua:0-0
Timestamp: 2025-10-08T16:45:02.923Z
Learning: In `src/main.lua`, the owner-triggered manual `Patch-Hyperbeam-Balances` handler deliberately omits the `info.supply` field that `hb.patchBalances()` includes. The manual patch is for one-time initialization of Hyperbeam state, while automatic patches handle ongoing state synchronization with supply information.


local patchMessage = {
device = "[email protected]",
balances = { [ao.id] = tostring(Balances[ao.id] or 0) },
return balancesPatch
end

---@return PrimaryNames|nil affectedPrimaryNamesAddresses
function hb.createPrimaryNamesPatch()
---@type PrimaryNames
local affectedPrimaryNamesAddresses = {
names = {},
owners = {},
requests = {},
}
for address, _ in pairs(affectedBalancesAddresses) do
patchMessage.balances[address] = tostring(Balances[address] or 0)

-- if no changes, return early. This will allow downstream code to not send the patch state for this key ('primary-names')
if
next(_G.HyperbeamSync.primaryNames.names) == nil
and next(_G.HyperbeamSync.primaryNames.owners) == nil
and next(_G.HyperbeamSync.primaryNames.requests) == nil
then
return nil
end

-- build the affected primary names addresses table for the patch message
for name, _ in pairs(_G.HyperbeamSync.primaryNames.names) do
-- we need to send an empty string to remove the name
affectedPrimaryNamesAddresses.names[name] = PrimaryNames.names[name] or ""
end
for owner, _ in pairs(_G.HyperbeamSync.primaryNames.owners) do
-- we need to send an empty table to remove the owner primary name data
affectedPrimaryNamesAddresses.owners[owner] = PrimaryNames.owners[owner] or {}
end
for address, _ in pairs(_G.HyperbeamSync.primaryNames.requests) do
-- we need to send an empty table to remove the request
affectedPrimaryNamesAddresses.requests[address] = PrimaryNames.requests[address] or {}
end

-- Setting the property to {} will nuke the entire table from patch device state
-- We do this because we want to remove the entire table from patch device state if it's empty
if next(PrimaryNames.names) == nil then
affectedPrimaryNamesAddresses.names = {}
-- setting the property to nil will remove it from the patch message entirely to avoid sending an empty table and nuking patch device state
-- We do this to AVOID sending an empty table and nuking patch device state if our lua state is not empty.
elseif next(affectedPrimaryNamesAddresses.names) == nil then
affectedPrimaryNamesAddresses.names = nil
end

if next(PrimaryNames.owners) == nil then
affectedPrimaryNamesAddresses.owners = {}
elseif next(affectedPrimaryNamesAddresses.owners) == nil then
affectedPrimaryNamesAddresses.owners = nil
end

if next(PrimaryNames.requests) == nil then
affectedPrimaryNamesAddresses.requests = {}
elseif next(affectedPrimaryNamesAddresses.requests) == nil then
affectedPrimaryNamesAddresses.requests = nil
end

-- if we're not sending any data, return nil which will allow downstream code to not send the patch message
-- We do this to AVOID sending an empty table and nuking patch device state if our lua state is not empty.
if next(affectedPrimaryNamesAddresses) == nil then
return nil
end

return affectedPrimaryNamesAddresses
end

function hb.resetHyperbeamSync()
HyperbeamSync = {
balances = {},
primaryNames = {
names = {},
owners = {},
requests = {},
},
}
end

--[[
1. Create the data patches
2. Send the patch message if there are any data patches
3. Reset the hyperbeam sync
]]
function hb.patchHyperbeamState()
-- Only add patches that have data
local primaryNamesPatch = hb.createPrimaryNamesPatch()
if primaryNamesPatch then
ao.send({ device = "[email protected]", ["primary-names"] = primaryNamesPatch })
end

-- only send the patch message if there are affected balances, otherwise we'll end up deleting the entire hyperbeam balances table
if next(patchMessage.balances) == nil then
return {}
else
ao.send(patchMessage)
local balancesPatch = hb.createBalancesPatch()
if balancesPatch then
ao.send({ device = "[email protected]", balances = balancesPatch })
end

return affectedBalancesAddresses
hb.resetHyperbeamSync()
end

return hb
13 changes: 8 additions & 5 deletions src/main.lua
Original file line number Diff line number Diff line change
Expand Up @@ -476,9 +476,10 @@ local function addEventingHandler(handlerName, pattern, handleFn, critical, prin
Handlers.add(handlerName, pattern, function(msg)
-- Store the old balances to compare after the handler has run for patching state
-- Only do this for the last handler to avoid unnecessary copying
local oldBalances = nil

local shouldPatchHbState = false
if pattern(msg) ~= "continue" then
oldBalances = utils.deepCopy(Balances)
shouldPatchHbState = true
end
-- add an ARIOEvent to the message if it doesn't exist
msg.ioEvent = msg.ioEvent or ARIOEvent(msg)
Expand All @@ -503,9 +504,8 @@ local function addEventingHandler(handlerName, pattern, handleFn, critical, prin
error(errorWithEvent, 0) -- 0 ensures not to include this line number in the error message
end

-- Send patch message to HB
if oldBalances then
hb.patchBalances(oldBalances)
if shouldPatchHbState then
hb.patchHyperbeamState()
end

msg.ioEvent:addField("Handler-Memory-KiB-Used", collectgarbage("count"), false)
Expand All @@ -529,6 +529,9 @@ end, CRITICAL, false)
addEventingHandler("prune", function()
return "continue" -- continue is a pattern that matches every message and continues to the next handler that matches the tags
end, function(msg)
-- We copy the balances here and put them in HyperbeamSync so that in subsequent handlers we can compare the previous and new balances and send a patch message if there are any changes
HyperbeamSync.balances = utils.deepCopy(Balances)

Comment on lines +532 to +534
Copy link

@coderabbitai coderabbitai bot Nov 3, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion | 🟠 Major

HyperbeamSync.balances is used as a snapshot, but documented as a change‑set

HyperbeamSync.balances = utils.deepCopy(Balances) stores a full snapshot (address→number). In src/globals.lua it’s documented as “addresses that have had balance changes” (address→boolean). This mismatch can mislead future changes.

Align the contract:

  • Either rename the field to balancesSnapshot here and in hb.* consumers, or
  • Update the doc in src/globals.lua to state it’s a snapshot table used for diffing.

If renaming is too broad for this PR, minimally update the documentation now. Based on learnings.

🤖 Prompt for AI Agents
In src/main.lua around lines 532–534, HyperbeamSync.balances is being assigned a
full snapshot via utils.deepCopy(Balances) but src/globals.lua documents that
field as a change-set (address→boolean); fix the mismatch by either renaming the
field here and in all hb.* consumers to balancesSnapshot (and update
usages/tests accordingly) or, if renaming is too large for this PR, update the
documentation in src/globals.lua to state that HyperbeamSync.balances is a
snapshot table (address→number) used for diffing/patch generation; prefer the
minimal doc update now if you want to avoid broad refactors.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is intentional - we need every address to be updated if its been altered since we have to update the remote store, even if locally that address was removed.

I set the type to indicate what it should be here.

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@atticusofsparta, thank you for the clarification! I understand now – the snapshot approach is intentional to capture the complete before-state so that hb.createBalancesPatch() can detect all changes (additions, modifications, and removals) by comparing in both directions. This ensures every altered address is included in the patch sent to Hyperbeam for remote store synchronization.

I appreciate you adding the type annotations to document this correctly. The design makes sense for your synchronization requirements.


🧠 Learnings used
Learnt from: atticusofsparta
Repo: ar-io/ar-io-network-process PR: 442
File: src/hb.lua:7-27
Timestamp: 2025-11-03T22:41:58.648Z
Learning: In `src/hb.lua`, the `hb.createBalancesPatch()` function intentionally iterates both `Balances` and `HyperbeamSync.balances` in two separate loops to detect all changes (additions, modifications, and removals) for complete remote store synchronization. The `HyperbeamSync.balances` table holds a snapshot of balances before handlers run, so comparing in both directions ensures every altered address is included in the patch sent to Hyperbeam.

Learnt from: atticusofsparta
Repo: ar-io/ar-io-network-process PR: 435
File: src/main.lua:0-0
Timestamp: 2025-10-08T16:45:02.923Z
Learning: In `src/main.lua`, the owner-triggered manual `Patch-Hyperbeam-Balances` handler deliberately omits the `info.supply` field that `hb.patchBalances()` includes. The manual patch is for one-time initialization of Hyperbeam state, while automatic patches handle ongoing state synchronization with supply information.

Learnt from: atticusofsparta
Repo: ar-io/ar-io-network-process PR: 435
File: src/hb.lua:0-0
Timestamp: 2025-10-08T16:44:07.797Z
Learning: In `src/hb.lua`, the `hb.patchBalances()` function intentionally uses the global `Balances` variable directly and only accepts `oldBalances` as a parameter, rather than accepting both old and new balances as parameters.

local epochIndex = epochs.getEpochIndexForTimestamp(msg.Timestamp)
msg.ioEvent:addField("Epoch-Index", epochIndex)

Expand Down
25 changes: 25 additions & 0 deletions src/primary_names.lua
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,8 @@ function primaryNames.createPrimaryNameRequest(name, initiator, timestamp, msgId
else
-- otherwise store the request for asynchronous approval
PrimaryNames.requests[initiator] = request
-- track the changes in the hyperbeam sync
HyperbeamSync.primaryNames.requests[initiator] = true
primaryNames.scheduleNextPrimaryNamesPruning(request.endTimestamp)
end

Expand Down Expand Up @@ -205,6 +207,7 @@ function primaryNames.approvePrimaryNameRequest(recipient, name, from, timestamp

-- set the primary name
local newPrimaryName = primaryNames.setPrimaryNameFromRequest(recipient, request, timestamp)

return {
newPrimaryName = newPrimaryName,
request = request,
Expand All @@ -228,6 +231,12 @@ function primaryNames.setPrimaryNameFromRequest(recipient, request, startTimesta
startTimestamp = startTimestamp,
}
PrimaryNames.requests[recipient] = nil

-- track the changes in the hyperbeam sync
HyperbeamSync.primaryNames.names[request.name] = true
HyperbeamSync.primaryNames.owners[recipient] = true
HyperbeamSync.primaryNames.requests[recipient] = true

return {
name = request.name,
owner = recipient,
Expand All @@ -247,6 +256,10 @@ function primaryNames.removePrimaryNames(names, from)
local removedPrimaryNamesAndOwners = {}
for _, name in pairs(names) do
local removedPrimaryNameAndOwner = primaryNames.removePrimaryName(name, from)
-- track the changes in the hyperbeam sync
HyperbeamSync.primaryNames.names[name] = true
HyperbeamSync.primaryNames.owners[removedPrimaryNameAndOwner.owner] = true

table.insert(removedPrimaryNamesAndOwners, removedPrimaryNameAndOwner)
end
return removedPrimaryNamesAndOwners
Expand All @@ -272,6 +285,12 @@ function primaryNames.removePrimaryName(name, from)
if PrimaryNames.requests[primaryName.owner] and PrimaryNames.requests[primaryName.owner].name == name then
PrimaryNames.requests[primaryName.owner] = nil
end

-- track the changes in the hyperbeam sync
HyperbeamSync.primaryNames.names[name] = true
HyperbeamSync.primaryNames.owners[primaryName.owner] = true
HyperbeamSync.primaryNames.requests[primaryName.owner] = true

return {
name = name,
owner = primaryName.owner,
Expand Down Expand Up @@ -348,6 +367,9 @@ function primaryNames.removePrimaryNamesForBaseName(baseName)
local primaryNamesForBaseName = primaryNames.getPrimaryNamesForBaseName(baseName)
for _, nameData in pairs(primaryNamesForBaseName) do
local removedName = primaryNames.removePrimaryName(nameData.name, nameData.owner)
-- track the changes in the hyperbeam sync
HyperbeamSync.primaryNames.names[nameData.name] = true
HyperbeamSync.primaryNames.owners[nameData.owner] = true
table.insert(removedNames, removedName)
end
return removedNames
Expand Down Expand Up @@ -410,6 +432,9 @@ function primaryNames.prunePrimaryNameRequests(timestamp)
if request.endTimestamp <= timestamp then
PrimaryNames.requests[initiator] = nil
prunedNameRequests[initiator] = request

-- track the changes in the hyperbeam sync
HyperbeamSync.primaryNames.requests[initiator] = true
else
primaryNames.scheduleNextPrimaryNamesPruning(request.endTimestamp)
end
Expand Down
16 changes: 8 additions & 8 deletions tests/gar.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -980,7 +980,7 @@ describe('GatewayRegistry', async () => {
delete decreaseDelegateStakeResult.Messages[0].Data;
assert.deepStrictEqual(decreaseDelegateStakeResult.Messages[0], {
Target: delegatorAddress,
Anchor: '00000000000000000000000000000013',
Anchor: '00000000000000000000000000000012',
Tags: [
{
name: 'Action',
Expand Down Expand Up @@ -1020,7 +1020,7 @@ describe('GatewayRegistry', async () => {
},
{
name: 'Ref_',
value: '13',
value: '12',
},
{
name: 'Type',
Expand Down Expand Up @@ -1754,7 +1754,7 @@ describe('GatewayRegistry', async () => {
memory: gatewayMemory,
});

assert.equal(result.Messages.length, 2);
assert.equal(result.Messages.length, 1);
assert.equal(result.Messages[0].Target, observerAddress);
assert.deepEqual(JSON.parse(result.Messages[0].Data), {
reports: {
Expand All @@ -1776,7 +1776,7 @@ describe('GatewayRegistry', async () => {
shouldAssertNoResultError: false,
});

assert.equal(result.Messages.length, 2);
assert.equal(result.Messages.length, 1);
assert.equal(result.Messages[0].Target, invalidObserver);
assert.ok(
result.Messages[0].Data.includes(
Expand All @@ -1795,7 +1795,7 @@ describe('GatewayRegistry', async () => {
memory: gatewayMemory,
shouldAssertNoResultError: false,
});
assert.equal(result.Messages.length, 2);
assert.equal(result.Messages.length, 1);
assert.ok(
result.Messages[0].Data.includes(
'Invalid report tx id. Must be a valid Arweave address.',
Expand All @@ -1814,7 +1814,7 @@ describe('GatewayRegistry', async () => {
shouldAssertNoResultError: false,
});

assert.equal(result.Messages?.length, 2);
assert.equal(result.Messages?.length, 1);
assert.ok(
result.Messages[0].Data.includes('Invalid failed gateway address:'),
);
Expand All @@ -1831,7 +1831,7 @@ describe('GatewayRegistry', async () => {
shouldAssertNoResultError: false,
});

assert.equal(result.Messages.length, 2);
assert.equal(result.Messages.length, 1);
assert.ok(
result.Messages[0].Data.includes(
`Observations for epoch 0 must be submitted after ${epochSettings.epochZeroStartTimestamp}`,
Expand All @@ -1851,7 +1851,7 @@ describe('GatewayRegistry', async () => {
shouldAssertNoResultError: false,
});

assert.equal(result.Messages.length, 2);
assert.equal(result.Messages.length, 1);
assert.ok(
result.Messages[0].Data.includes(
`Observations for epoch 0 must be submitted before ${epochSettings.epochZeroStartTimestamp + epochSettings.durationMs}`,
Expand Down
Loading
Loading