2525import argparse
2626import sys
2727import os
28+ import re
2829from Crypto .Hash import keccak
2930import time
3031
3132
3233parser = 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
3637netparser = parser .add_mutually_exclusive_group (required = True )
6566parser .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+ )
6887parser .add_argument (
6988 "-n" ,
7089 "--dry-run" ,
7594args = parser .parse_args ()
7695
7796private_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
87113if args .wallet and args .wallet != account .address :
88114 print (
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
119145netname = (
120146 "mainnet"
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+
140190expect_chain = 0xA4B1 if args .mainnet else 0x66EEE
141191actual_chain = w3 .eth .chain_id
142192if 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 )
180236else :
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
187241SESH = 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-
240289error_defs = {}
241290for n in compiled_sol ["ServiceNodeRewards.sol:ServiceNodeRewards" ]["ast" ]["nodes" ]:
242291 if (
@@ -254,10 +303,14 @@ def lookup_error(selector):
254303
255304
256305last_height = 0
257- liquidated = set ()
306+ ignore = set ()
258307liquidation_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"
259312while 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"\n Liquidating 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 } :\n Contract error { err } with data:\n { e .data [10 :]} \x1b [0m"
439+ f"\n \x1b [31;1mFailed to { s_liquidate } SN { pk } :\n Contract 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 } :\n Unknown contract error:\n { e .data } \x1b [0m"
443+ f"\n \x1b [31;1mFailed to { s_liquidate } SN { pk } :\n Unknown 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