Skip to content

Commit a4babdd

Browse files
committed
liquidator: add --once, --exit, --pubkeys; plus fixes
`--once` lets you run one iteration then exit the script `--exit` submits exits rather than liquidations `--pubkeys` lets you specify specific pubkeys to exit/liquidate (in which case everything not matching is ignored). Other changes: - when `--dry-run` specified then allow running without a private key (just generate a random one). - Require `--mainnet` to run on mainnet, so that you don't get surprising results if you accidentally forget the network. - Add detection and workaround for (now fixed) Oxen bug where a node could be both active and recently removed.
1 parent 7d55c31 commit a4babdd

File tree

1 file changed

+148
-65
lines changed

1 file changed

+148
-65
lines changed

scripts/liquidator.py

Lines changed: 148 additions & 65 deletions
Original file line numberDiff line numberDiff line change
@@ -25,12 +25,13 @@
2525
import argparse
2626
import sys
2727
import os
28+
import re
2829
from Crypto.Hash import keccak
2930
import time
3031

3132

3233
parser = argparse.ArgumentParser(
33-
prog="liquidator", description="Auto-liquidator of deregged/expires Session nodes"
34+
prog="liquidator", description="Auto-liquidator of deregged/expired Session nodes"
3435
)
3536

3637
netparser = parser.add_mutually_exclusive_group(required=True)
@@ -65,6 +66,24 @@
6566
parser.add_argument(
6667
"-m", "--max-liquidations", type=int, help="Stop after liquidating this many SNs"
6768
)
69+
parser.add_argument(
70+
"-1",
71+
"--once",
72+
action="store_true",
73+
help="Run one iteration and then exit, rather than sleeping and repeating indefinitely",
74+
)
75+
parser.add_argument(
76+
"-E",
77+
"--exit",
78+
action="store_true",
79+
help="Submit exits (earning no reward) instead of liquidations (with reward)",
80+
)
81+
parser.add_argument(
82+
"-P",
83+
"--pubkeys",
84+
type=str,
85+
help="Only liquidate/exit nodes that have an Oxen or BLS pubkey in the given list (whitespace or comma delimited)",
86+
)
6887
parser.add_argument(
6988
"-n",
7089
"--dry-run",
@@ -75,14 +94,21 @@
7594
args = parser.parse_args()
7695

7796
private_key = os.environ.get("ETH_PRIVATE_KEY")
78-
if not private_key:
79-
print("ETH_PRIVATE_KEY is not set!", file=sys.stderr)
80-
sys.exit(1)
81-
if not private_key.startswith("0x") or len(private_key) != 66:
82-
print("ETH_PRIVATE_KEY is set but looks invalid", file=sys.stderr)
83-
sys.exit(1)
84-
85-
account = Account.from_key(private_key)
97+
if args.dry_run and not private_key:
98+
account = Account.create()
99+
print(
100+
"ETH_PRIVATE_KEY is not set, but --dry-run is used so generating a random one:",
101+
file=sys.stderr,
102+
)
103+
print(f" privkey={Web3.to_hex(account.key)}", file=sys.stderr)
104+
else:
105+
if not private_key:
106+
print("ETH_PRIVATE_KEY is not set!", file=sys.stderr)
107+
sys.exit(1)
108+
if not private_key.startswith("0x") or len(private_key) != 66:
109+
print("ETH_PRIVATE_KEY is set but looks invalid", file=sys.stderr)
110+
sys.exit(1)
111+
account = Account.from_key(private_key)
86112

87113
if args.wallet and args.wallet != account.address:
88114
print(
@@ -91,30 +117,30 @@
91117
)
92118
sys.exit(1)
93119

94-
print(f"Using wallet {account.address}")
95120

96-
print(f"Loading contracts...")
97-
basedir = os.path.dirname(__file__) + "/.."
98-
install_solc("0.8.26")
99-
compiled_sol = compile_source(
100-
"""
101-
import "SESH.sol";
102-
import "ServiceNodeRewards.sol";
103-
""",
104-
base_path=basedir,
105-
include_path=f"{basedir}/contracts",
106-
solc_version="0.8.26",
107-
revert_strings="debug",
108-
import_remappings={
109-
"@openzeppelin/contracts": "node_modules/@openzeppelin/contracts",
110-
"@openzeppelin/contracts-upgradeable": "node_modules/@openzeppelin/contracts-upgradeable",
111-
},
112-
)
121+
def verbose(*a, **kw):
122+
if args.verbose:
123+
print(*a, **kw)
113124

114-
w3 = Web3(Web3.HTTPProvider(args.l2))
115-
if not w3.is_connected():
116-
print("L2 connection failed; check your --l2 value", file=sys.stderr)
117-
sys.exit(1)
125+
126+
filter_pks = set()
127+
if args.pubkeys:
128+
for pk in re.split(r"[\s,]+", args.pubkeys):
129+
if not pk:
130+
continue
131+
if len(pk) not in (64, 128) or not all(
132+
c in "0123456789ABCDEFabcdef" for c in pk
133+
):
134+
print(f"Invalid pubkey '{pk}' given to --pubkeys", file=sys.stderr)
135+
sys.exit(1)
136+
filter_pks.add(pk)
137+
if not filter_pks:
138+
print(f"Error: No pubkeys provided to --pubkeys/-P option")
139+
sys.exit(1)
140+
verbose(f"Filtering on {len(filter_pks)} pubkeys")
141+
142+
143+
print(f"Using wallet {account.address}")
118144

119145
netname = (
120146
"mainnet"
@@ -137,6 +163,30 @@
137163
sys.exit(1)
138164

139165

166+
print(f"Loading contracts...")
167+
basedir = os.path.dirname(__file__) + "/.."
168+
install_solc("0.8.30")
169+
compiled_sol = compile_source(
170+
"""
171+
import "SESH.sol";
172+
import "ServiceNodeRewards.sol";
173+
""",
174+
base_path=basedir,
175+
include_path=f"{basedir}/contracts",
176+
solc_version="0.8.30",
177+
revert_strings="debug",
178+
import_remappings={
179+
"@openzeppelin/contracts": "node_modules/@openzeppelin/contracts",
180+
"@openzeppelin/contracts-upgradeable": "node_modules/@openzeppelin/contracts-upgradeable",
181+
},
182+
)
183+
184+
w3 = Web3(Web3.HTTPProvider(args.l2))
185+
if not w3.is_connected():
186+
print("L2 connection failed; check your --l2 value", file=sys.stderr)
187+
sys.exit(1)
188+
189+
140190
expect_chain = 0xA4B1 if args.mainnet else 0x66EEE
141191
actual_chain = w3.eth.chain_id
142192
if actual_chain != expect_chain:
@@ -159,7 +209,13 @@ def get_contract(name, addr):
159209
return w3.eth.contract(address=addr, abi=compiled_sol[name]["abi"])
160210

161211

162-
if args.devnet:
212+
if args.mainnet:
213+
print("Configured for SESH mainnet")
214+
sesh_addr, snrewards_addr = (
215+
"0x10Ea9E5303670331Bdddfa66A4cEA47dae4fcF3b",
216+
"0xC2B9fC251aC068763EbDfdecc792E3352E351c00",
217+
)
218+
elif args.devnet:
163219
print("Configured for Oxen devnet(v3)")
164220
sesh_addr, snrewards_addr = (
165221
"0x8CB4DC28d63868eCF7Da6a31768a88dCF4465def",
@@ -178,10 +234,8 @@ def get_contract(name, addr):
178234
"0x0B5C58A27A41D5fE3FF83d74060d761D7dDDc1D2",
179235
)
180236
else:
181-
sesh_addr, snrewards_addr = (
182-
"0x10Ea9E5303670331Bdddfa66A4cEA47dae4fcF3b",
183-
"0xC2B9fC251aC068763EbDfdecc792E3352E351c00",
184-
)
237+
print(f"This script does not support Session {netname} yet!", file=sys.stderr)
238+
sys.exit(1)
185239

186240

187241
SESH = get_contract("SESH.sol:SESH", sesh_addr).functions
@@ -232,11 +286,6 @@ def encode_bls_signature(bls_sig):
232286
return tuple(int(bls_sig[off + i : off + i + 64], 16) for i in (0, 64, 128, 192))
233287

234288

235-
def verbose(*a, **kw):
236-
if args.verbose:
237-
print(*a, **kw)
238-
239-
240289
error_defs = {}
241290
for n in compiled_sol["ServiceNodeRewards.sol:ServiceNodeRewards"]["ast"]["nodes"]:
242291
if (
@@ -254,10 +303,14 @@ def lookup_error(selector):
254303

255304

256305
last_height = 0
257-
liquidated = set()
306+
ignore = set()
258307
liquidation_attempts = 0
308+
s_liquidatable = "exitable" if args.exit else "liquidatable"
309+
s_Liquidating = "Exiting" if args.exit else "Liquidating"
310+
s_liquidation = "exit" if args.exit else "liquidation"
311+
s_liquidate = "exit" if args.exit else "liquidate"
259312
while True:
260-
verbose("Checking for liquidatable nodes...")
313+
verbose(f"Checking for {s_liquidatable} nodes...")
261314

262315
contract_nodes = set(
263316
f"{x[0]:064x}{x[1]:064x}"
@@ -279,44 +332,67 @@ def lookup_error(selector):
279332
)
280333
r.raise_for_status()
281334
r = r.json()["result"]
282-
verbose(f"{len(r)} potentially liquidatable nodes")
335+
verbose(f"{len(r)} potentially {s_liquidatable} nodes")
336+
337+
# FIXME - hack around bug of being in both active and recently removed:
338+
rsns = requests.post(
339+
oxen_rpc,
340+
json={"jsonrpc": "2.0", "id": 0, "method": "get_service_nodes",
341+
"params": {"fields": ["service_node_pubkey"]}})
342+
rsns.raise_for_status()
343+
active_sns = set(x["service_node_pubkey"] for x in rsns.json()["result"]["service_node_states"])
283344

284345
for sn in r:
285346
pk = sn["service_node_pubkey"]
286347
bls = sn["info"]["pubkey_bls"]
287-
if pk in liquidated:
288-
verbose(f"Already liquidated {pk}")
289-
elif bls not in contract_nodes:
348+
if pk in active_sns:
349+
print(f"Error: not exiting {pk} because it's both active and recently removed")
350+
ignore.add(pk)
351+
if pk in ignore:
352+
continue
353+
if filter_pks and not (pk in filter_pks or bls in filter_pks):
354+
verbose(f"Given pubkey filter does not include {pk}")
355+
ignore.add(pk)
356+
continue
357+
if bls not in contract_nodes:
290358
verbose(
291-
f"{pk} (BLS: {bls}) is not in the contract (perhaps liquidation/removal already in progress?)"
359+
f"{pk} (BLS: {bls}) is not in the contract (perhaps liquidation/exit already in progress?)"
292360
)
293-
elif sn["liquidation_height"] <= height:
294-
verbose(f"{pk} is liquidatable")
361+
ignore.add(pk)
362+
continue
363+
if args.exit or sn["liquidation_height"] <= height:
364+
verbose(f"{pk} is {s_liquidatable}!")
295365
liquidate.append(sn)
296366
else:
367+
n_blocks = sn["liquidation_height"] - height
368+
duration = (
369+
"{}d{:.0f}h".format(n_blocks // 720, (n_blocks % 720) / 30)
370+
if n_blocks >= 720
371+
else "{}h{}m".format(n_blocks // 30, (n_blocks % 30) * 2)
372+
)
297373
verbose(
298-
f"{pk} not liquidatable (liquidation height: {sn['liquidation_height']})"
374+
f"{pk} not liquidatable until: {sn['liquidation_height']}, in {n_blocks} blocks (~{duration})"
299375
)
300376

301377
except Exception as e:
302378
print(f"oxend liquidation list request failed: {e}", file=sys.stderr)
303379
continue
304380

305-
if len(liquidate) > 0:
306-
print(f"Proceeding to liquidate {len(liquidate)} eligible nodes")
381+
if liquidate:
382+
print(f"{s_Liquidating} {len(liquidate)} eligible service nodes")
307383
for sn in liquidate:
308384
try:
309385
pk = sn["service_node_pubkey"]
310386
info = sn["info"]
311-
print(f"\nLiquidating SN {pk}\n BLS: {info['pubkey_bls']}")
387+
print(f"\n{s_Liquidating} SN {pk}\n BLS: {info['pubkey_bls']}")
312388

313389
r = requests.post(
314390
oxen_rpc,
315391
json={
316392
"jsonrpc": "2.0",
317393
"id": 0,
318394
"method": "bls_exit_liquidation_request",
319-
"params": {"pubkey": pk, "liquidate": True},
395+
"params": {"pubkey": pk, "liquidate": not args.exit},
320396
},
321397
timeout=20,
322398
)
@@ -325,53 +401,60 @@ def lookup_error(selector):
325401

326402
if "error" in r:
327403
print(
328-
f"Failed to obtain liquidation signature for {pk}: {r['error']['message']}"
404+
f"Failed to obtain {s_liquidation} signature for {pk}: {r['error']['message']}"
329405
)
330406
continue
331407

332-
print(" Obtained service node network liquidation signature")
408+
print(f" Obtained service node network {s_liquidation} signature")
333409

334410
r = r["result"]
335411
bls_pk = r["bls_pubkey"]
336412
bls_pk = (int(bls_pk[0:64], 16), int(bls_pk[64:128], 16))
337413
bls_sig = r["signature"]
338414
bls_sig = tuple(int(bls_sig[i : i + 64], 16) for i in (0, 64, 128, 192))
339415

340-
tx = ServiceNodeRewards.liquidateBLSPublicKeyWithSignature(
341-
bls_pk, r["timestamp"], bls_sig, r["non_signer_indices"]
416+
meth = (
417+
ServiceNodeRewards.exitBLSPublicKeyWithSignature
418+
if args.exit
419+
else ServiceNodeRewards.liquidateBLSPublicKeyWithSignature
342420
)
421+
tx = meth(bls_pk, r["timestamp"], bls_sig, r["non_signer_indices"])
343422
fn_details = f"ServiceNodeRewards (={ServiceNodeRewards.address}) function {tx.fn_name} (={tx.selector}) with args:\n{tx.arguments}"
344423
if args.dry_run:
345424
print(f" \x1b[32;1mDRY-RUN: would have invoked {fn_details}\x1b[0m")
346425
else:
347426
verbose(f" About to invoke: {fn_details}")
348-
print(" Submitting liquidating tx...", end="", flush=True)
427+
print(f" Submitting {s_liquidation} tx...", end="", flush=True)
349428
txid = tx.transact()
350429
print(
351430
f"\x1b[32;1m done! txid: \x1b]8;;{tx_url(txid.hex())}\x1b\\{txid.hex()}\x1b]8;;\x1b\\\x1b[0m"
352431
)
353432

354-
liquidated.add(pk)
433+
ignore.add(pk)
355434

356435
except w3ex.ContractCustomError as e:
357436
err = lookup_error(e.data[2:10])
358437
if err:
359438
print(
360-
f"\n\x1b[31;1mFailed to liquidate SN {pk}:\nContract error {err} with data:\n {e.data[10:]}\x1b[0m"
439+
f"\n\x1b[31;1mFailed to {s_liquidate} SN {pk}:\nContract error {err} with data:\n {e.data[10:]}\x1b[0m"
361440
)
362441
else:
363442
print(
364-
f"\n\x1b[31;1mFailed to liquidate SN {pk}:\nUnknown contract error:\n {e.data}\x1b[0m"
443+
f"\n\x1b[31;1mFailed to {s_liquidate} SN {pk}:\nUnknown contract error:\n {e.data}\x1b[0m"
365444
)
366445
except Exception as e:
367-
print(f"\n\x1b[31;1mFailed to liquidate SN {pk}: {e}\x1b[0m")
446+
print(f"\n\x1b[31;1mFailed to {s_liquidate} SN {pk}: {e}\x1b[0m")
368447

369448
liquidation_attempts += 1
370449
if args.max_liquidations and liquidation_attempts >= args.max_liquidations:
371450
print(
372-
f"Reached --max-liquidations ({args.max_liquidations}) liquidation attempts, exiting"
451+
f"Reached --max-liquidations ({args.max_liquidations}) {s_liquidation} attempts; exiting"
373452
)
374453
sys.exit(0)
375454

455+
if args.once:
456+
verbose(f"Done!")
457+
break
458+
376459
verbose(f"Done loop; sleeping for {args.sleep}")
377460
time.sleep(args.sleep)

0 commit comments

Comments
 (0)