From 8ed784373b876205590147873c0808b452fb76e6 Mon Sep 17 00:00:00 2001 From: dtfiedler Date: Tue, 10 Sep 2024 06:40:25 -0600 Subject: [PATCH 1/2] feat(gar): slash operators by 20% if they fail 30 consecutive epochs --- spec/gar_spec.lua | 122 +++++++++++++++++++++++----------------------- src/balances.lua | 3 +- src/gar.lua | 29 +++++++++-- 3 files changed, 88 insertions(+), 66 deletions(-) diff --git a/spec/gar_spec.lua b/spec/gar_spec.lua index 2b11bd0..87b5b66 100644 --- a/spec/gar_spec.lua +++ b/spec/gar_spec.lua @@ -554,72 +554,74 @@ describe("gar", function() end) describe("pruneGateways", function() - it("should remove gateways with endTimestamp < currentTimestamp", function() - local currentTimestamp = 1000000 - local msgId = "msgId" + it( + "should remove gateways with endTimestamp < currentTimestamp, slash gateways with failedConsecutiveEpochs > 30 and mark them for leaving", + function() + local currentTimestamp = 1000000 + local msgId = "msgId" - -- Set up test gateways - _G.GatewayRegistry = { - ["address1"] = { - startTimestamp = currentTimestamp - 1000, - endTimestamp = currentTimestamp - 100, -- Expired - status = "leaving", - operatorStake = gar.getSettings().operators.minStake, - vaults = {}, - delegates = {}, - stats = { - failedConsecutiveEpochs = 30, + -- Set up test gateways + _G.GatewayRegistry = { + ["address1"] = { + startTimestamp = currentTimestamp - 1000, + endTimestamp = currentTimestamp - 100, -- Expired + status = "leaving", + operatorStake = gar.getSettings().operators.minStake, + vaults = {}, + delegates = {}, + stats = { + failedConsecutiveEpochs = 30, + }, + -- Other gateway properties... }, - -- Other gateway properties... - }, - ["address2"] = { - startTimestamp = currentTimestamp - 100, - endTimestamp = currentTimestamp + 100, -- Not expired - status = "joined", - operatorStake = gar.getSettings().operators.minStake, - vaults = {}, - delegates = {}, - stats = { - failedConsecutiveEpochs = 30, + ["address2"] = { + startTimestamp = currentTimestamp - 100, + endTimestamp = currentTimestamp + 100, -- Not expired, failedConsecutiveEpochs is 20 + status = "joined", + operatorStake = gar.getSettings().operators.minStake, + vaults = {}, + delegates = {}, + stats = { + failedConsecutiveEpochs = 20, + }, + -- Other gateway properties... }, - -- Other gateway properties... - }, - ["address3"] = { - startTimestamp = currentTimestamp - 100, - endTimestamp = 0, -- Not expired, but failedConsecutiveEpochs is 30 - status = "joined", - operatorStake = gar.getSettings().operators.minStake + 10000, - vaults = {}, - delegates = {}, - stats = { - failedConsecutiveEpochs = 30, + ["address3"] = { + startTimestamp = currentTimestamp - 100, + endTimestamp = 0, -- Not expired, but failedConsecutiveEpochs is 30 + status = "joined", + operatorStake = gar.getSettings().operators.minStake + 10000, + vaults = {}, + delegates = {}, + stats = { + failedConsecutiveEpochs = 30, + }, + -- Other gateway properties... }, - -- Other gateway properties... - }, - } + } - -- Call pruneGateways - gar.pruneGateways(currentTimestamp, msgId) + -- Call pruneGateways + local protocolBalanceBefore = _G.Balances[ao.id] or 0 + local status, err = pcall(gar.pruneGateways, currentTimestamp, msgId) + assert.is_true(status) + assert.is_nil(err) - -- Check results - assert.is_nil(GatewayRegistry["address1"]) - assert.is_not_nil(GatewayRegistry["address2"]) - assert.is_not_nil(GatewayRegistry["address3"]) - -- check that gateway 3 was marked as leaving and stake is vaulted - assert.are.equal("leaving", GatewayRegistry["address3"].status) - assert.are.equal(0, GatewayRegistry["address3"].operatorStake) - -- Check that gateway 3's operator stake is vaulted - assert.are.same({ - balance = gar.getSettings().operators.minStake, - startTimestamp = currentTimestamp, - endTimestamp = currentTimestamp + gar.getSettings().operators.leaveLengthMs, - }, GatewayRegistry["address3"].vaults["address3"]) - assert.are.same({ - balance = 10000, - startTimestamp = currentTimestamp, - endTimestamp = currentTimestamp + gar.getSettings().operators.withdrawLengthMs, - }, GatewayRegistry["address3"].vaults[msgId]) - end) + local expectedSlashedStake = math.floor((gar.getSettings().operators.minStake + 10000) * 0.2) + local expectedRemainingStake = math.floor((gar.getSettings().operators.minStake + 10000) * 0.8) + assert.is_nil(GatewayRegistry["address1"]) -- removed + assert.is_not_nil(GatewayRegistry["address2"]) -- not removed + assert.is_not_nil(GatewayRegistry["address3"]) -- not removed + -- Check that gateway 3's operator stake is slashed by 20% and the remaining stake is vaulted + assert.are.equal("leaving", GatewayRegistry["address3"].status) + assert.are.equal(0, GatewayRegistry["address3"].operatorStake) + assert.are.same({ + balance = expectedRemainingStake, + startTimestamp = currentTimestamp, + endTimestamp = currentTimestamp + gar.getSettings().operators.leaveLengthMs, + }, GatewayRegistry["address3"].vaults["address3"]) + assert.are.equal(protocolBalanceBefore + expectedSlashedStake, Balances[ao.id]) + end + ) it("should handle empty GatewayRegistry", function() local currentTimestamp = 1000000 diff --git a/src/balances.lua b/src/balances.lua index 4816973..bd42178 100644 --- a/src/balances.lua +++ b/src/balances.lua @@ -42,8 +42,9 @@ function balances.reduceBalance(target, qty) end function balances.increaseBalance(target, qty) + assert(utils.isInteger(qty), debug.traceback("Quantity must be an integer: " .. qty)) local prevBalance = balances.getBalance(target) or 0 - Balances[target] = prevBalance + qty + Balances[target] = math.floor(prevBalance + qty) end function balances.getPaginatedBalances(cursor, limit, sortBy, sortOrder) diff --git a/src/gar.lua b/src/gar.lua index f3baa3b..29be7d4 100644 --- a/src/gar.lua +++ b/src/gar.lua @@ -18,6 +18,7 @@ GatewayRegistrySettings = GatewayRegistrySettings maxDelegates = 10000, leaveLengthMs = 90 * 24 * 60 * 60 * 1000, -- 90 days that balance will be vaulted failedEpochCountMax = 30, -- number of epochs failed before marked as leaving + failedEpochSlashPercentage = 0.2, -- 20% of stake is returned to protocol balance }, delegates = { minStake = 500 * 1000000, -- 500 IO @@ -89,17 +90,17 @@ function gar.leaveNetwork(from, currentTimestamp, msgId) -- Add minimum staked tokens to a vault that unlocks after the gateway completely leaves the network gateway.vaults[from] = { - balance = gar.getSettings().operators.minStake, + balance = math.min(gar.getSettings().operators.minStake, gateway.operatorStake), startTimestamp = currentTimestamp, endTimestamp = gatewayEndTimestamp, } - gateway.operatorStake = gateway.operatorStake - gar.getSettings().operators.minStake + local remainingStake = gateway.operatorStake - gar.getSettings().operators.minStake -- Add remainder to another vault - if gateway.operatorStake > 0 then + if remainingStake > 0 then gateway.vaults[msgId] = { - balance = gateway.operatorStake, + balance = remainingStake, startTimestamp = currentTimestamp, endTimestamp = gatewayStakeWithdrawTimestamp, } @@ -628,7 +629,8 @@ function gar.pruneGateways(currentTimestamp, msgId) and garSettings ~= nil and gateway.stats.failedConsecutiveEpochs >= garSettings.operators.failedEpochCountMax then - -- mark as leaving + -- slash operator stake and return 20% of operator stake to the protocol balance and mark as leaving + gar.slashOperatorStake(address) gar.leaveNetwork(address, currentTimestamp, msgId) else if gateway.status == "leaving" and gateway.endTimestamp <= currentTimestamp then @@ -640,6 +642,23 @@ function gar.pruneGateways(currentTimestamp, msgId) end end +function gar.slashOperatorStake(address) + local gateway = gar.getGateway(address) + if gateway == nil then + error("Gateway does not exist") + end + local garSettings = gar.getSettings() + if garSettings == nil then + error("Gateway Registry settings do not exist") + end + + local slashAmount = math.floor(gateway.operatorStake * garSettings.operators.failedEpochSlashPercentage) + gateway.operatorStake = gateway.operatorStake - slashAmount + balances.increaseBalance(ao.id, slashAmount) + GatewayRegistry[address] = gateway + -- TODO: send slash notice to gateway address +end + function gar.getPaginatedGateways(cursor, limit, sortBy, sortOrder) local gateways = gar.getGateways() local gatewaysArray = {} From bd5f1f6720c2109fa992733161a0d7900aaf6146 Mon Sep 17 00:00:00 2001 From: dtfiedler Date: Tue, 10 Sep 2024 06:47:22 -0600 Subject: [PATCH 2/2] chore(test): add unit test for slash operator stake --- spec/gar_spec.lua | 28 +++++++++++++++++++++++++--- src/balances.lua | 2 +- src/gar.lua | 12 ++++++++---- 3 files changed, 34 insertions(+), 8 deletions(-) diff --git a/spec/gar_spec.lua b/spec/gar_spec.lua index 87b5b66..68b0701 100644 --- a/spec/gar_spec.lua +++ b/spec/gar_spec.lua @@ -501,6 +501,28 @@ describe("gar", function() end) end) + describe("slashOperatorStake", function() + it("should slash operator stake by the provided slash amount and return it to the protocol balance", function() + local slashAmount = 10000 + Balances[ao.id] = 0 + GatewayRegistry["test-this-is-valid-arweave-wallet-address-1"] = { + operatorStake = gar.getSettings().operators.minStake, + totalDelegatedStake = 0, + vaults = {}, + delegates = {}, + } + local status, err = + pcall(gar.slashOperatorStake, "test-this-is-valid-arweave-wallet-address-1", slashAmount) + assert.is_true(status) + assert.is_nil(err) + assert.are.equal( + gar.getSettings().operators.minStake - slashAmount, + GatewayRegistry["test-this-is-valid-arweave-wallet-address-1"].operatorStake + ) + assert.are.equal(slashAmount, Balances[ao.id]) + end) + end) + describe("getGatewayWeightsAtTimestamp", function() it("shoulud properly compute weights based on gateways for a given timestamp", function() GatewayRegistry["test-this-is-valid-arweave-wallet-address-1"] = { @@ -590,7 +612,7 @@ describe("gar", function() startTimestamp = currentTimestamp - 100, endTimestamp = 0, -- Not expired, but failedConsecutiveEpochs is 30 status = "joined", - operatorStake = gar.getSettings().operators.minStake + 10000, + operatorStake = gar.getSettings().operators.minStake + 10000, -- will slash 20% of the min operator stake vaults = {}, delegates = {}, stats = { @@ -606,8 +628,8 @@ describe("gar", function() assert.is_true(status) assert.is_nil(err) - local expectedSlashedStake = math.floor((gar.getSettings().operators.minStake + 10000) * 0.2) - local expectedRemainingStake = math.floor((gar.getSettings().operators.minStake + 10000) * 0.8) + local expectedSlashedStake = math.floor(gar.getSettings().operators.minStake * 0.2) + local expectedRemainingStake = math.floor(gar.getSettings().operators.minStake * 0.8) + 10000 assert.is_nil(GatewayRegistry["address1"]) -- removed assert.is_not_nil(GatewayRegistry["address2"]) -- not removed assert.is_not_nil(GatewayRegistry["address3"]) -- not removed diff --git a/src/balances.lua b/src/balances.lua index bd42178..a3d6be0 100644 --- a/src/balances.lua +++ b/src/balances.lua @@ -44,7 +44,7 @@ end function balances.increaseBalance(target, qty) assert(utils.isInteger(qty), debug.traceback("Quantity must be an integer: " .. qty)) local prevBalance = balances.getBalance(target) or 0 - Balances[target] = math.floor(prevBalance + qty) + Balances[target] = prevBalance + qty end function balances.getPaginatedBalances(cursor, limit, sortBy, sortOrder) diff --git a/src/gar.lua b/src/gar.lua index 29be7d4..2685383 100644 --- a/src/gar.lua +++ b/src/gar.lua @@ -629,8 +629,10 @@ function gar.pruneGateways(currentTimestamp, msgId) and garSettings ~= nil and gateway.stats.failedConsecutiveEpochs >= garSettings.operators.failedEpochCountMax then - -- slash operator stake and return 20% of operator stake to the protocol balance and mark as leaving - gar.slashOperatorStake(address) + -- slash 20% of the minimum operator stake and return the rest to the protocol balance, then mark the gateway as leaving + local slashedOperatorStake = math.min(gateway.operatorStake, garSettings.operators.minStake) + local slashAmount = math.floor(slashedOperatorStake * garSettings.operators.failedEpochSlashPercentage) + gar.slashOperatorStake(address, slashAmount) gar.leaveNetwork(address, currentTimestamp, msgId) else if gateway.status == "leaving" and gateway.endTimestamp <= currentTimestamp then @@ -642,7 +644,10 @@ function gar.pruneGateways(currentTimestamp, msgId) end end -function gar.slashOperatorStake(address) +function gar.slashOperatorStake(address, slashAmount) + assert(utils.isInteger(slashAmount), "Slash amount must be an integer") + assert(slashAmount > 0, "Slash amount must be greater than 0") + local gateway = gar.getGateway(address) if gateway == nil then error("Gateway does not exist") @@ -652,7 +657,6 @@ function gar.slashOperatorStake(address) error("Gateway Registry settings do not exist") end - local slashAmount = math.floor(gateway.operatorStake * garSettings.operators.failedEpochSlashPercentage) gateway.operatorStake = gateway.operatorStake - slashAmount balances.increaseBalance(ao.id, slashAmount) GatewayRegistry[address] = gateway