diff --git a/data/default_departed_hotkeys.json b/data/default_departed_hotkeys.json new file mode 100644 index 000000000..8ff471d92 --- /dev/null +++ b/data/default_departed_hotkeys.json @@ -0,0 +1 @@ +{"departed_hotkeys": {"5C511WZdVSCAcTVLTcheFAwvDvmDH7une5h7cXsUEJjnYWPs": {"detected_ms": 1746396435687}, "5C5GANtAKokcPvJBGyLcFgY5fYuQaXC3MpVt75codZbLLZrZ": {"detected_ms": 1738723096909}, "5C5W8HYYUMgQKZhpPdZgjfJXt1GK2aBm7K3WAbX25P2JgMYJ": {"detected_ms": 1749774971513}, "5C5dGkAZ8P58Rcm7abWwsKRv91h8aqTsvVak2ogJ6wpxSZPw": {"detected_ms": 1744009413162}, "5C5mfN5XCQg8RgAqu3xh89qyJjeYrFjtf4fzotEuGFFHqmji": {"detected_ms": 1742428175137}, "5C7WucRBNr4ye8Pqvi9MwRHVZu2mLTGKEk71iuye85k2z3Dp": {"detected_ms": 1739860033307}, "5C837cu1KFxw1EGLWAGbExvPZHMq6yZHTz2iU3LWLFhnaHc8": {"detected_ms": 1738723095658}, "5C8GQcaz17FMPA61PfriZZ5iEi72CT3S3xJdzDUcokRhCpCQ": {"detected_ms": 1755529291360}, "5C8Wegdus2cAcwSNU47MdiLXwZdewFkSv93xUWQP3wn32QJV": {"detected_ms": 1744220292427}, "5C8d1ziKpxGzpkrZbqMmKW1DbJwiPXk2KJ8qHmEmL1UJFaWY": {"detected_ms": 1746536425884}, "5CALivVcJBTjYJFMsAkqhppQgq5U2PYW4HejCajHMvTMUgkC": {"detected_ms": 1739645913652}, "5CB6dfQFcmjCuwkKKFguNjnQqPCS9GKWUBokzm1UMLZW5bgw": {"detected_ms": 1742163531496}, "5CD1oNfmr2dP8xVcuBbmyRLCtyKoVNhY8EFqA4L59tUDXB1j": {"detected_ms": 1742261480067}, "5CG2tEx8rQviDc9LYq4RU1pZ4iwcMhEMy54nvKD1pkxs8vZb": {"detected_ms": 1749775147137}, "5CGBuhhwyQqjsGrYkQXx16dJApBtJ16boK2mSc1AKGmC3f7s": {"detected_ms": 1761064598415}, "5CJVmgfReufJPEnDyFZeup1Y7ojxxjSMrtWgCLmQfMbiYfkR": {"detected_ms": 1738723095658}, "5CLhVtnrE1caHMWFqeXGv2cxRq7RBs3cZ6SZXBKhwRJEKAsv": {"detected_ms": 1742266931036}, "5CMevqgp7yDeYo7fKPJHcvtLFwUpEXkPwkfahCMWJLTwqMMh": {"detected_ms": 1755176057022}, "5CQ4aearB7dub8XRxSi7BUTAwWTR9WhHtXkbQhePcpYXhXQp": {"detected_ms": 1752679971149}, "5CSPhJzJ1sZ9NNnuiWDxyaDvzp7Zr87HDdoNkJTYKptZEpCK": {"detected_ms": 1740590372448}, "5CScA9HnGQypkTDT2UdH6FjbSaYK1xiRHbNUsdtwJU4xrbJg": {"detected_ms": 1727414044772}, "5CUUWxGzf4qU5DCgLcL65qAKsQF1ezUTvBzfD548zPEDzxmR": {"detected_ms": 1750541919135}, "5CUUfv9eJ5PE5NbnnyH8VS6J62vpycezUFgd3Z1GuB3jQQDS": {"detected_ms": 1726738801823}, "5CXsrszdjWooHK3tfQH4Zk6spkkSsduFrEHzMemxU7P2wh7H": {"detected_ms": 1744219967682}, "5CXwhj3FpwJTpNdHtGWW7mhQXEKi9yVGJzgpVsren7iJpQbX": {"detected_ms": 1742296267335}, "5CY3NdQ7nQj7MsUEMi68u8poDxkNAJhB9FU48YxzAYC5MhCJ": {"detected_ms": 1738723094527}, "5CZsUnxsyGM2vCPEsdDaBNxARkoQfVMRE4X9264maTDxSkiW": {"detected_ms": 1739358052561}, "5CaP9oSepnjRB9KVYkeA91fRnFkY4DCp6gmJXkfPynRxVeiW": {"detected_ms": 1749151552853}, "5CanwYivSt4GNmX2Dh18ndCybzBm44x5TZNdT2X6egQhuYb9": {"detected_ms": 1747267219675}, "5CapzhULSS2iJuG7tMdPeTUQkQGLPRCQVeVBnw7P9Pt8EsBj": {"detected_ms": 1749738336813}, "5CcHpQs7exiWyoiogYrZZPUSXKDpfYQWsPoyxsydcSdN96Uh": {"detected_ms": 1731405002071}, "5CcNVDt7YLa8YbyUJxS7TZ9y5gsR3qNq3rbKGU6B4A4H541W": {"detected_ms": 1748563617932}, "5CcsBjaLAVfrjsAh6FyaTK4rBikkfQVanEmespwVpDGcE7jP": {"detected_ms": 1727609824456}, "5Cd9bVVja2KdgsTiR7rTAh7a4UKVfnAuYAW1bs8BiedUE9JN": {"detected_ms": 1738052169640}, "5CdA2DnRUYNi5c8UJkCDPPUwKghqgT7Lkdv7XVoaQsYVivir": {"detected_ms": 1748316267510}, "5CepGh5ByHHeYfgGmV5DLDStC5LyQPfWov1pGTZfT18jKuEe": {"detected_ms": 1728583582934}, "5CfAJhpVuwaDYTaKNeg2DLZiieCYsdjntNYpwfBV62pPdWVA": {"detected_ms": 1742840282659}, "5CfuEzqq4Bb3e6Fg3ZUMzTYbHv1CpX3H4Mb4zu3ygS1eVJtV": {"detected_ms": 1734683708056}, "5ChX6tTbNGwCaLeCuQFFNRp57BckJzfT6xL1UmaPQyai5W5j": {"detected_ms": 1721572761541}, "5CiNaSa8nmWmP6KJ8B6RNbF1aJYbboAq8qYDU3u6W6RaUwWG": {"detected_ms": 1747823400008}, "5Cii2pYMVsHuc1hz3ot9oFTF7mqmiCD1fknf4G15onFEWtfX": {"detected_ms": 1738150582062}, "5Ck3US6cjMBoF49aLexLNKSS2WgZFMzxHhSzvw8wA18V33QA": {"detected_ms": 1737752055350}, "5CkGzJ3RfPP9GdxCeH8tGTJjYY94K5BGeboQe7xcSdgLCC57": {"detected_ms": 1754664426435}, "5CkXwy3uECEpXd1KfzruQSHAMDCz7a9hatbHvgH5uJts5Lcv": {"detected_ms": 1748630661760}, "5CkdoLGdokcKrHnpDLV7vfXz5BLH5gT3DYFbb4D6jJiNCMei": {"detected_ms": 1742294817543}, "5Cm46monFjrwTdm1Kx5gZuoyaYdE1Gtgdsj554r5ZfJqbyg8": {"detected_ms": 1740412914959}, "5Cnn8YuBHgYQ5aPXxEbR6J2MLt69ozMS7pHcPqwfifLHK2LJ": {"detected_ms": 1758184402929}, "5Co2PYgxA4TywjTE1oH4c1HEjkRzQmYUVwKHv85Bsabe5V3y": {"detected_ms": 1743172102822}, "5CoU9wjAD292DrdEM77f7PGNHuhWo8oMFL2xYoXyNMvy2z4y": {"detected_ms": 1738723095658}, "5CoZ4BNQ7rmkdg6rVw7fs5CjcXZFoeXapeykWBrjphiDXe54": {"detected_ms": 1752183335768}, "5CqHy2ZKMMicK2SVgftgdpgJsMwBsBFxJGstmxnGwsEpW42j": {"detected_ms": 1740426985457}, "5CqQv8jg6jn7zctN8RRMq8LAFAzmU7bLwpBTv15rLoqRM37C": {"detected_ms": 1750623983204}, "5CqpQ29APwYkXu3z8mkQE7yp4hus3pubbgbuFrK95e3VCSUH": {"detected_ms": 1739856152079}, "5CswNjNeAmwLs1onBreLd8cjc1KQscP3b2anAaqdqPvqSZVW": {"detected_ms": 1731991468639}, "5Ct5amT9YmnfaksGbcZepFnL95N8D59gWStybSvcXGR3RLmv": {"detected_ms": 1740045659082}, "5Ct8Vy5SjjRwYoGTXRZ9DmAr6XHkift8excjcQvmM3FDpYAG": {"detected_ms": 1740564228432}, "5CthGb2xcWvBBFYxEPudSDLm4kGeF9ztkVDb2FJmSftfuJM2": {"detected_ms": 1742136612745}, "5CvdNJn4DbYdrJJAuKPUHn6LR2LzvzGz9Sogs5H6bHZFFnhL": {"detected_ms": 1742280471817}, "5CvzcDNtHvzBKRmZmvnv6c9RZyC4bzoqhAntGrxtVnXwanjV": {"detected_ms": 1727967524271}, "5CwZsjY6zCYcvWz7YAhwGpWC7FbgRSYpUeNQszH4HLFR7orG": {"detected_ms": 1742455527180}, "5CwnLFrary94P6nZMqhzhUwpkuHM1AmFs8JzTgQUu7qLonjX": {"detected_ms": 1737983572102}, "5CyJPzXdK8D3iMdZavxqkPSw3gNWdNrA9wguP1Mp5D8Miv8V": {"detected_ms": 1743428286487}, "5CygY9EN872r2x62wHiuoPvTUYe61gifoDX1kaTw4qKdNG7T": {"detected_ms": 1753109945750}, "5CyoWmrRu8KV3NxBfDyj1LsRitHXnhc5SYeEuzjb11fpLT4V": {"detected_ms": 1743428286495}, "5CyuC5byBz35uiDexRnocba58bMPXYyFzCzvhNBuMsevBwKD": {"detected_ms": 1738723400170}, "5Cz9SaLrZV6SDnhLiRb1vp3zd21h4T374XXs8L4YkZcFHSbU": {"detected_ms": 1738220854245}, "5D1oMWv2nzdd6LBpn4przJoQyQfrdo7pQn1PnzGhWNWisv7M": {"detected_ms": 1746780166637}, "5D4zieKMoRVm477oUyMTZAWZ9orzpiJM8K6ufQQjryiXwpGU": {"detected_ms": 1749127317873}, "5D582P2vwYs3717DYZyBcbCJQecngjE6thfp3nDo8yhge9zr": {"detected_ms": 1743981519383}, "5D5GDbEa5Aedt9fXhWTzngDTANvnf8Q5poSFKYkU9hEZ7P8C": {"detected_ms": 1720093181762}, "5D5R79UNKxN9DorxNW1gzLibfSq49wW51F6xsd1VQMMCvkqF": {"detected_ms": 1750878267150}, "5D9seGkxjyVjkREcgxp3EgUV8RQNnfAbmpJuvNB8b5jJkz6B": {"detected_ms": 1759363749472}, "5DA17uPcLX4vyX2go2QJk3VWs93sfFWrL3333QS23oRqjPQF": {"detected_ms": 1758747685997}, "5DCaKgSaNxvTdy3kRBRA1BSpdRLs4YDstUsZmmS6HbDGt3eK": {"detected_ms": 1749729520234}, "5DCm4HsUpL27Lbr3M45L7DzMXXD4NTVrKH7cEEKre9gw42nE": {"detected_ms": 1722586084020}, "5DCuhMEFomeuriLiMw1ieygqiNASbHxGPfqBicq79qatZp5Y": {"detected_ms": 1738723094096}, "5DCzvCF22vTVhXLtGrd7dBy19iFKKJNxmdSp5uo4C4v6Xx6h": {"detected_ms": 1732385331825}, "5DDBXwjobdGeiM3svUPUogVB6T3YVNC7nmin9p7EQGBrK2hA": {"detected_ms": 1715318900670}, "5DDLobU52sZVp5bem2KKvLMQsiULLnwgYVpg4ZPDHgaEmydU": {"detected_ms": 1749151878222}, "5DFKUoa5BkgQDZnKcoWbufKWMWN5rmcZnN7iairyb1VCdMeW": {"detected_ms": 1738657053530}, "5DFeG996pWPxb6ejwYi9u13hMfjozbEboAmTZxGBvFmf649P": {"detected_ms": 1751407304504}, "5DFzuVVMor2zeHazD9Zg6SZpF2expRKcXF9DihbiYnNJu5Gu": {"detected_ms": 1726817234950}, "5DHbZMcfoJETVRzM3M4HoXhzFGetrRitwKBVBRPYh2uJvgDR": {"detected_ms": 1732038296090}, "5DLy9pz2bkBHP8Lrr918tjCpi8PtiiaLyKpZbaqg3MsK4Jeq": {"detected_ms": 1738924391412}, "5DM6u5RedjBmc6QZvcYMqzJbVJrAoe21QWwiMkbJd1ysy95d": {"detected_ms": 1741617334673}, "5DP7SyfjzHneZ9QDiwqsQxsH68xUSLFb1ZUuw1hs3JextYQQ": {"detected_ms": 1738557475832}, "5DPEuwWVNK6iszpZGQ9vaS36nF2YqUeyapDWugoTn51RQ4cB": {"detected_ms": 1750599654475}, "5DPGWY6zQ3CtweQjvNsiFoegMQrcT2dX3AvPj3MEpfZgWTEF": {"detected_ms": 1738723096529}, "5DQ1XPp8KuDEwGP1eC9eRacpLoA1RBLGX22kk5vAMBtp3kGj": {"detected_ms": 1744219967674}, "5DQ3JM2ucrbGPrvszwbgokdAeMnZSC82JZsSzJiQ5n5RCphq": {"detected_ms": 1738723095373}, "5DU7AjLT6fAY9MnA76GvUFd2rsRp1ZgUTGLBUxKrDE9cPitq": {"detected_ms": 1741033919453}, "5DUW3wmGUVjKuFCs8jeJFkgG5rgTY6fUNRZcFfEWPKA6XS24": {"detected_ms": 1746777935069}, "5DVJgENz9rZEBpAJUyWb1AvrqHZTKgQNeszG4Tyo1tYsbCYS": {"detected_ms": 1736309392594}, "5DWm4yEZm5PtrLbKyxQLnSVsv1b6wz5oTbuRH5C5P4PFA63a": {"detected_ms": 1746146521668}, "5DWmX9m33Tu66Qh12pr41Wk87LWcVkdyM9ZSNJFsks3QritF": {"detected_ms": 1739854587610}, "5DX8tSyGrx1QuoR1wL99TWDusvmmWgQW5su3ik2Sc8y8Mqu3": {"detected_ms": 1731377959039}, "5DXrQ2AhJZRqVxQJWRvgVcsmGvmdqjgzZTfsWE6MEzpviUj1": {"detected_ms": 1741101481492}, "5DZY1LPCTzXmtRBJZ49ua84Pk8Zsm9A95Msp6Cujvfed2MCW": {"detected_ms": 1733327746789}, "5DZzbwGtm1hjrr7LgndjUKCmo9ZDTaE6vgYuvoR1mmuwCVu5": {"detected_ms": 1750541495508}, "5Da5hqCMSVgeGWmzeEnNrime3JKfgTpQmh7dXsdMP58dgeBd": {"detected_ms": 1748844277339}, "5DaW56UxJ9Dk14mvraGSEZhy1c91WyLuT2JnNrnKrwnzmZxk": {"detected_ms": 1745417740946}, "5DcA2dvoUovhZaErpnkLE5kBD8FTKfDS3CSciFVu8Dkxb2mE": {"detected_ms": 1742694239491}, "5DcgKr6s8z75sE4c69iMSM8adfRVex7A8BZe2mouVwMVRis4": {"detected_ms": 1748848175043}, "5DcybJ9frCAxTXhmD9RzPA1cUN3BADQvVfwUDwH63DiZqV9M": {"detected_ms": 1759363249778}, "5DehWPcg8kzCctyiAgmV5pnaSg5uX9bdBomnyiApxUoBpTux": {"detected_ms": 1726272608365}, "5Dexrrts9jr3A4eAKJW3KNKBMpqUc8Gqxt39jHPETuT5tcD2": {"detected_ms": 1741089990249}, "5Df6H6FTq3nYruAnvUNC2beV3ZexYbnW7qqCWgWS3UAqwMiy": {"detected_ms": 1749725742610}, "5Df8YED2EoxY65B2voeCHzY9rn1R76DXB8Cq9f62CsGVRoU5": {"detected_ms": 1738723096385}, "5DfTfq3oGN7SJGV32x2XKtJ2E6PrRnbArHeoAtKBNQmmZ5Ff": {"detected_ms": 1736396132096}, "5DfhKZckZwjCqEcBUsW7jwzA5APCdj5SgZbfK6zzS9bMPuHn": {"detected_ms": 1750626491329}, "5DiKA1gvPbGniM2fNKyNe5g4ETEP4nNanTwjnkiLx6ACjaTq": {"detected_ms": 1749881388213}, "5DjqgrgQcKdrwGDg7RhSkxjnAVWwVgYTBodAdss233s3zJ6T": {"detected_ms": 1744009803674}, "5Dk4UYGv9azRVzBHsG2PdJM1p24krbUTSNfa55CTjYpuMJUS": {"detected_ms": 1743234751577}, "5Dkk7L9ekNHX9QX3UkZGamWfH97YpY5HiGRBRxQifTNoUmLw": {"detected_ms": 1736520921714}, "5DnViSacXqrP8FnQMtpAFGyahUPvU2A6pbrX7wcexb3bmVjb": {"detected_ms": 1749724733755}, "5DnYZQGQAaRMUJeY32dQtbkqsYtBVcaEjxGSUAKF4UCK3Ukj": {"detected_ms": 1755880512681}, "5DoCFr2EoW1CGuYCEXhsuQdWRsgiUMuxGwNt4Xqb5TCptcBW": {"detected_ms": 1739558518754}, "5Dq2HJh54EDPfMPZsboNSJwKNJ8xxtEfPvGeWte4ryhtb19L": {"detected_ms": 1743765980773}, "5DqmvEK7Viv2NpEEJGJVuYaQEGpeSW6HAVxrNvV18CLxKve5": {"detected_ms": 1738723094035}, "5DsqYKhVGjMtFcSk25dwWJgkS7QV3933TVp19zCib34uWYqu": {"detected_ms": 1739819287627}, "5DthKaDbqEauMm25rKmKQCjJYvbshR84NzhAVT4zLq4Dz4qK": {"detected_ms": 1729591812787}, "5DvKRw4q7yGKxFwfm6ZVpQnpCwDt6Dt5hU6UqL8EcNgYZAMC": {"detected_ms": 1748330819818}, "5Dvep8Psc5ASQf6jGJHz5qsi8x1HS2sefRbkKxNNjPcQYPfH": {"detected_ms": 1744219631608}, "5Dxqzduahnqw8q3XSUfTcEZGU7xmAsfJubhHZwvXVLN9fSjR": {"detected_ms": 1739249474010}, "5DyKMgRKjC9vEWqcsc7i9TADWed612wMRpZE21gBB9mBus9p": {"detected_ms": 1733752471911}, "5E2JHti7y1zqnhcXJ6rZg79ZY9SwRAxTehzQJM1JnYeawCim": {"detected_ms": 1743737029099}, "5E49ConX9VKuiZEYA2AE13J2CvL4d7S9GqCDJthMEokVFx8N": {"detected_ms": 1734473889389}, "5E4HHh8yXiENdVXW8px34hhJaisastccra1U5dyE6LSkoTU4": {"detected_ms": 1738723096577}, "5E4JXXNyaMXkg5aW7XR9pvYcmbpPaLiF7qCzzHyG4zeW5wud": {"detected_ms": 1750460467349}, "5E4ZZwVMVwmWvF86B1k9zKyee7pGt5379bYtZTfrBFiBYfVA": {"detected_ms": 1746083209347}, "5E4qTpNJHuW1BdqLE9QMbJKLDcsZ2kQaejvmUkCBNJKSyvGK": {"detected_ms": 1753279108382}, "5E7DEGmFUewdJTnSh829jGc3SpSd295hhvUgNcNiQST6bw4A": {"detected_ms": 1745439805093}, "5E9Ppyn5DzHGaPQmsHVnkNJDjGd7DstqjHWZpQhWPMbqzNex": {"detected_ms": 1740156877288}, "5E9YM1LetgQmUDRrXxcaazj1KJiGdAxYcnnRBzQhuh8RqfBo": {"detected_ms": 1733501027897}, "5EALv1dv2yMFYQrxiUQNd41iCTYqXpXAK6YoEh3KL3oxhzF7": {"detected_ms": 1739814844328}, "5EAStPH9fAZ23vFHZaQ2bX5TxD4pwDkjx7YATxJFj3oNAwz8": {"detected_ms": 1751442712767}, "5EBz3poHAksnoPDz2a7xrjTpirE8LtUQUPGhkqGApiL2zU2t": {"detected_ms": 1738723097660}, "5ECBCZFmj1319j2hXgujMM62DFi6rxSYm2egVfGuJoHXZZES": {"detected_ms": 1752679971331}, "5ECRYT6G4ZsMhe62JmVc6hhSywfS9JpHkKnrrmWwph9mJiXt": {"detected_ms": 1738519468512}, "5EDFuRAkSPsbVGY6Ysjh7ndvz6k3VifWXBQmKFRh8s1qfCMs": {"detected_ms": 1750025407517}, "5EExieRRyVJfXeWCPFJzsHXGQ2omLNmsBKT5jsUyegsyPKV8": {"detected_ms": 1738723095658}, "5EF393sRCV3Q6SFNTpKQed8m3QDGRgfDvke8sUoH3kbLqGZS": {"detected_ms": 1738723096797}, "5EFbAfq4dsGL6Fu6Z4jMkQUF3WiGG7XczadUvT48b9U7gRYW": {"detected_ms": 1748825565649}, "5EHExrrRYUSuVyo6tNDwLbu1xGVckYjJaT6YVQjxS88NpDeF": {"detected_ms": 1752465291061}, "5EHpm2UK3CyhH1zZiJmM6erGrzkmVAF9EnT1QLSPhMzQaQHG": {"detected_ms": 1744574039497}, "5EJCfznQViGZGq37xLULnL7yWbdtR5YbumWkkftL1r5XYbSE": {"detected_ms": 1745493971023}, "5EKxHujBxShExKQ3XsScM8QzXof5FVwSYdrfBwkAGPFbToNB": {"detected_ms": 1743171969315}, "5ELEVV8DywNPQnYC3EhtQr4JZcbX64XYB2GchYh76tsvEsHH": {"detected_ms": 1732009713714}, "5ELQLFFrkeJVxhQ9tBbzcXaujr6wCNvH8G5x9ub92FBWdc5M": {"detected_ms": 1738723095658}, "5ELawfYeKK6CyUmwUYuxsePkbKxwFrvmpKa1M8aZYZkkR1xX": {"detected_ms": 1759363742050}, "5ENbSSqrHkwzBTzdfJDJNjrTseaBGPnLDyc4Le6asVUWsigH": {"detected_ms": 1748905739351}, "5EPevQuGCJpDGUkRqsrPy4VMQjJvxH36LEJyuTVtPwDq3bC6": {"detected_ms": 1742813952916}, "5ER5fb1GYtAyeqn4csmcPEvb1A9aWYYxm6xeEGgAtZ7Zn21q": {"detected_ms": 1740502823961}, "5ERorZ39jVQJ7cMx8j8osuEV8dAHHCbpx8kGZP4Ygt5dxf93": {"detected_ms": 1743748807204}, "5EUDRwhb37HAjq2524xe3LPiRHYWfGghSq1cvjgfMwvcR9dD": {"detected_ms": 1759363251565}, "5EUTaAo7vCGxvLDWRXRrEuqctPjt9fKZmgkaeFZocWECUe9X": {"detected_ms": 1738745352841}, "5EUXGiE1vL3LpkJnrBX2gowcUdc6YeYZkmuHD9DuTaPT9Xx5": {"detected_ms": 1738723097660}, "5EUhaksx4JyWG7vBuC7zgc7ahq8kqkNU2wBwdfFC9D84gxmq": {"detected_ms": 1738674469723}, "5EWKUhycaBQHiHnfE3i2suZ1BvxAAE3HcsFsp8TaR6mu3JrJ": {"detected_ms": 1752183335800}, "5EWSKDmic7fnR89AzVmqLL14YZbJK53pxSc6t3Y7qbYm5SaV": {"detected_ms": 1750549976532}, "5EX3SP1HM7rHabmH6MSa8sJ2PW5m511K4TzJJ3dHTrAmHhmb": {"detected_ms": 1738723097660}, "5EXWvBCADJo1JVv6jHZPTRuV19YuuJBnjG3stBm3bF5cR9oy": {"detected_ms": 1744008395817}, "5EZSgUEHCZX4jYxFXmDMJ2vraS12t5YQCcFYS87h2PgbQnyD": {"detected_ms": 1727110850756}, "5EZuGb2C1yXRpz663jguMBPuTTRcxtPb6DRwacaq1yx1s3E1": {"detected_ms": 1742472945117}, "5EZypfpf252kQbTHwxDqXG6Ej51LRjhXJhAPaQfidWBPJ34h": {"detected_ms": 1733100362207}, "5EchLoxPqCFF3HKrUEufwDfK4mJkzEKQhoutGXFFJHfLQG4M": {"detected_ms": 1740412911283}, "5EeQyxxE8HCtnMVdiQh3HeR4HVcyn8dY7QJwsU6nF5t7KsF1": {"detected_ms": 1746192860885}, "5Eh9p81ioCeoTArv7kSa1PWcaXw33UdRjVQfLQsFPpn474GC": {"detected_ms": 1728640466275}, "5Ejcqv191phNqciuCCZNByd3b6R3pW52B4HMXiFhNfxoysZJ": {"detected_ms": 1740990983615}, "5EjhEJ1Ui5iprHqYkT55jx5re7jhEJbCD58HTAnyx7YgSsWV": {"detected_ms": 1740382532118}, "5EkHD5xtLzwMh98UvxGWpabyQJA52ybzmUsavhrLyuZzc5UH": {"detected_ms": 1748007619849}, "5EkNqccHVTpXp9VoyXS5uKEhUHWLy3uNfWGBy65P4qY2wAAf": {"detected_ms": 1740019873106}, "5Ekh3RXeBnSqM1m6vjd1CiQ3fc5wdWvUQ5ph6pnmwZbJQ4fF": {"detected_ms": 1734709641849}, "5En4sZkPJj1YkGncN9UW144naHMons8WRc48PQD2UcftdPyx": {"detected_ms": 1759363250479}, "5EnpzdVtroCq5dnMgFAuFNBHZtwfdCfMfHotM2zgHLKCHC9q": {"detected_ms": 1748480764525}, "5EnvsXnC4DHxqDYppowtB3grDifMA4eW5ZrR4cjCL7APWTC3": {"detected_ms": 1749774357348}, "5EsTrvdoEGS6Bh3gLXxhNVRfpHrQgYk8AoXE8yWhphXKieTB": {"detected_ms": 1723706922806}, "5EsqVEf6z7Z1zs3A27HUjmVWh4Yd2KrFQKFndUFEGAN9u48t": {"detected_ms": 1753771733082}, "5Et6DsfKyfe2PBziKo48XNsTCWst92q8xWLdcFy6hig427qH": {"detected_ms": 1738723094009}, "5EtR9REUYwaAYzKbMbGjsT9i1ipchaKYoJ5g97ofcCVF2vZ1": {"detected_ms": 1739703231427}, "5EtaAo22nCU7mb1gPKd9vj5cXiWWMtMkrJVrKDA18a2TWqgW": {"detected_ms": 1759363734194}, "5Ev6K8hcsWzLiWTZqByshJ3Rx1LcNbzBrygRuTD9B8kRMKWJ": {"detected_ms": 1732081986981}, "5EvCwqQfr1YpyaT4mkWM2cxq5oHzgiDf7Ro1mKHCBdmpDMXP": {"detected_ms": 1742466960423}, "5EvaRWNZ1mn1vosdneWAV6zsTf2orcBJjp8NruaYPb9PYpK4": {"detected_ms": 1727768921413}, "5Exax1W9RiNbARDejrthf4SK1FQ2u9DPUhCq9jm58gUysTy4": {"detected_ms": 1730287222060}, "5ExnPeAAAPXsgZt9W4tUV2n1FBLA3jVcarEqWdKsjSB8gdDH": {"detected_ms": 1743764386203}, "5Exyt4ZRyXS7izHAzgvfx2vJPE6FykcZXXGVvvnJooaeKJcT": {"detected_ms": 1743646736104}, "5EySUmguNd7eoWoTABxdLDnoCsGHekAPVfr7s43ooPMQr8nJ": {"detected_ms": 1727803281087}, "5F1Dk6TyMrsx3dhiG1hLWhPnACNFKz4mfLkgG3s7n1Q9qpJ4": {"detected_ms": 1738723095658}, "5F1JjosqJzxL4wKRMG4g8RYq4n3z4XW7tt4N3BP8JcSJxrNN": {"detected_ms": 1729030835447}, "5F1QK41jcZRUbzMk8jUBVpdvgw4WNKaUDHpLFQJZg3TgJynV": {"detected_ms": 1747839671735}, "5F1YPyJtfjAGNe8YeuZWKY1V56NTqmtj3inLumMTBSohtjhW": {"detected_ms": 1729622131335}, "5F1eiT1fSMhLJTETjMpt7S7GGbcDsFcPJevWTj1nE56BjqWJ": {"detected_ms": 1754968297585}, "5F1mQGgCXd9LHmJpWvLgsScm6VF1zauayWYmusi8kZf75XZm": {"detected_ms": 1756149554705}, "5F25maVPbzV4fojdABw5Jmawr43UAc5uNRJ3VjgKCUZrYFQh": {"detected_ms": 1743748927235}, "5F27mtJajiotkPfKCU42Qb81JFLepBzBrVL71qZruiLZ44qn": {"detected_ms": 1759363247729}, "5F2NxuGzJ8ZhhDfSnxnaDrjr8rx2ZTXZ5doWjjjoZzeBfUaG": {"detected_ms": 1758835913415}, "5F42rNzErYsnVqZVgB1HYTAuHWyAimoiBtvFhqGQaP7KuMGb": {"detected_ms": 1738723094470}, "5F6WHdqfyg3qUtvZJhGMttNsrkgwzXGT7Y1ZQLfk9YoCxbjX": {"detected_ms": 1741143681806}, "5F7JyNZasVK5gbpL2R5XrjqDpVcN7z36ivuyBUSnuNSLHNDv": {"detected_ms": 1739857588512}, "5F7RwQwCK2NCZaiUWoSnvH4G5QF99FSuhrXwZbsFB8aW3Ft4": {"detected_ms": 1743601007917}, "5F7ThFe3ZRxTVFjz4M8ciTHbcVAdVDHLZ7SDyMgdFJpdLdEm": {"detected_ms": 1732041943936}, "5F7Xh9ey5KxtPCj9K8iP6ufzWknMVqkLktzjAKqtPsmH2qNc": {"detected_ms": 1747230352880}, "5FA3r1bcPJwZVJBXJzXkfFAXMMDo48Fhqy7XVaZWGSRkEXms": {"detected_ms": 1727416324903}, "5FBk7ZC5KPX64ZYzabCn2i1R9dyFT9bMg8SQxBrjRz1ujLR3": {"detected_ms": 1746487141943}, "5FCisP5b74sXkQdZeKoxCttaYv7TXvMjvu9saw6dxU4wh9vp": {"detected_ms": 1736657761291}, "5FEPBMuohFf4wqu7JAc5xV4k8Xam1EoUsbNMp2aB5Z4FvJ7T": {"detected_ms": 1738709564119}, "5FEei8Z7FG6RTuHFec3evsiXyzKVv1fof8bhk9RSFshf7qwF": {"detected_ms": 1749732011758}, "5FHZ3r6ZT1QkeoBrrsty6iLxCw16macZwUGR9SRu2dUAEs5z": {"detected_ms": 1747682239099}, "5FHj4ky6YvF8eF9D6pFS2Do9j7qVG9LS7mMZ9bha6M47gGgZ": {"detected_ms": 1741123815154}, "5FHn2R21vPqGAcW6JFUnwPEMXH4goBK99ipkjL7RmTPPEFGu": {"detected_ms": 1747278590755}, "5FJ36ZiRcrNkNLBfx7kPaHqBe2chTcRtYMHeAcQWykD1CA5u": {"detected_ms": 1726694421643}, "5FLLP6EBR1ybv16Go7PnJnTAp8F7dF1ThcbubVchhzs2v4JX": {"detected_ms": 1749807938404}, "5FLY15XBgugbKYtwpGxnqsUphNV9WGuygN6J3Z3ALn6JV2Au": {"detected_ms": 1741187436131}, "5FLioWpytDkBzV5vq1DjSUfXPwL4GxNabgmPPc1Ytw1WpdeT": {"detected_ms": 1759363698791}, "5FNb58q7MFdajy4xxt8r7NdY95TQYb6AxzeBz3x87MQ8h6xm": {"detected_ms": 1738723096330}, "5FNsVBuDPkNhP5R46p5NHHr3xgW8Zqn5A1zoqbgPvxaYmDVV": {"detected_ms": 1750541923949}, "5FR6v4D6fn1oJLCDpUthWnMtGcVk3gcQ1qHyUzgvxyEBdpSH": {"detected_ms": 1747840595532}, "5FREPpDNYdqJBvXgXSgiXo78f5eMq2dZEeW5cyc3wU4TPdS1": {"detected_ms": 1727791725706}, "5FRPLyZ25mr2MTjQguXJDDDzByHbJMGRTmY9rtMBkNVVsKWG": {"detected_ms": 1740469480713}, "5FRU69hkGokDVfk3erB8qcCo5o5k7HxdmmudhkyvyD6h9dnq": {"detected_ms": 1748958904753}, "5FRpp1he5F1Z5wc7CVNfepoajXinwNAZAxh1RC4pEbQXj8n9": {"detected_ms": 1752193135271}, "5FTR8y26ap56vvahaxbB4PYxSkTQFpkQDqZN32uTVcW9cKjy": {"detected_ms": 1748857073338}, "5FU9YidcK1ySmt8QzqEu2kjDGrwuEVZDR2XRstswnfJiFyc3": {"detected_ms": 1733779444284}, "5FUbUjTuSrcUbDKTWWbjiyY8QTJS5c9nvWseLSopCFJDju43": {"detected_ms": 1748308356022}, "5FW3VTgZ1t5PxVwBTGPVgaTK3qwvDTY4wU6h3DmajxxEZB3H": {"detected_ms": 1738946933933}, "5FWqvH7xedb7UbE9sUWnriNMwfrein6YYTPzY9Cm1XTrzdJs": {"detected_ms": 1750749510109}, "5FWxhjhkLN5HFWzR1MX4T5mC67mdEjPY3vdQwpeMUc5FnEXD": {"detected_ms": 1739325699509}, "5FYftdyb99YibPZ9hi12Mj67HzTzcQ5Avny2pHz63rxj9meg": {"detected_ms": 1742580622222}, "5FZ5YfRwXG3jN2FPgetfoDQr6jkAS2JQZQzu9GX7jTfnZLPd": {"detected_ms": 1726486214305}, "5FbN7EEe5sdxXuTgCQAGCWWwydJ82SryHSYnJCkLmzNngfjX": {"detected_ms": 1744613806634}, "5FbSPKfb3BAy9Nz3b1LEPVU2JRYgN4jE1aYmmEMRecr2rZKp": {"detected_ms": 1723584939042}, "5FbaR3qjbbnYpkDCkuh4TUqqen1UMSscqjmhoDWQgGRh189o": {"detected_ms": 1744219631600}, "5Fc39mqXCJrkwVLTZCduUgkmkUv7Rsz2kgtkHQVMQo8ZTn5U": {"detected_ms": 1727790914347}, "5FeViVcQiczbDac6daHnYR5ascXKzRhvpRoficHFepaAQcBa": {"detected_ms": 1738235430205}, "5FfB44ckxo1nGadEksJUdswvUjPCjsWjLq4eShkm5gqHvPur": {"detected_ms": 1759363691352}, "5FgVe9Qsbp9V2Lu3NrYbGZJq9gBNLq5B1YEd27y1BGCJ7DH6": {"detected_ms": 1732370585523}, "5Fgdtd86JprwGDii2jYkJUgUKo2NWojENtzKmr2piPHyNQGu": {"detected_ms": 1738723096112}, "5Fh8cRxBUC1o81DzsMsRgvTuo7PmUfgFp6pvM7xWX66NN5iS": {"detected_ms": 1747038825088}, "5Fhao2KzVa7irBt6UBENed3aTc6ABA7Rvz7Ggku7m9ymZYou": {"detected_ms": 1734558909043}, "5FjQVqqZBAHkPbymJfnyG94cnS2CLwQvSJSpCuEeYr4sLuAq": {"detected_ms": 1728231176112}, "5FmqXG5YBU1Hke9jHD5FT41CUM9gVod7nFgYvbd7PmpqcUJm": {"detected_ms": 1738723095749}, "5FneLVpcy3vWx5gYcizq3Rd1ZJqBijKZscLG3aCuGHVSajGo": {"detected_ms": 1749470489268}, "5Fnjj1aNPw6wXUy5YiRGBjPxtYuEkDg2H3wFL7cQ9znJtNNv": {"detected_ms": 1759363720167}, "5FpeLTCmGJa7VcdU6vTpdLWWgKh7WhRcFJ1uNX2NX6d54FEM": {"detected_ms": 1733288036143}, "5FpypsPpSFUBpByFXMkJ34sV88PRjAKSSBkHkmGXMqFHR19Q": {"detected_ms": 1744162749278}, "5Fq8UjEhA2Q9ZreExUqRiUPkQDedDG8nomKWHaaxmVMKXEsd": {"detected_ms": 1748901621416}, "5FqSBwa7KXvv8piHdMyVbcXQwNWvT9WjHZGHAQwtoGVQD3vo": {"detected_ms": 1748848855930}, "5FqUwxdjuwgdgq7f1w8c8724tCaBuzYwDd5WsZdrxcRyzmfu": {"detected_ms": 1744882935223}, "5FsS3UvMN8cZQZ51cyz2Fcnnjqx4rVAHPcVAc4LarCZZjshf": {"detected_ms": 1738723095644}, "5Fuyyu4XtCBw73U635SwybJXu1EUzcyj15LkQHPwQMCbAiNW": {"detected_ms": 1752461009296}, "5Fv593mBnKBpB7gamWzQtX4QGQcp546ECkTSFwxRPjacitzy": {"detected_ms": 1759363706216}, "5Fv8drPhHEYbNyv3uecV1sHeod1FAeEJMyZ7JXFxmVBBDrGU": {"detected_ms": 1742661164894}, "5FvqsbF1z9jwJ9YdzPYA1k7h7kM2MbdTvPywCuDHLAcHuRLC": {"detected_ms": 1746417517847}, "5FvsLcoZAXwKB3QRFmKdpL6Cp3tqkfNpSdWA7ubMZFCaPtbZ": {"detected_ms": 1721898329912}, "5FyCL4nvyYLTwUDfJZofMX8Vp1WeKgGNj2q7AQ9QTufAgnmL": {"detected_ms": 1729149617997}, "5G13m3qmkr5cdqAHkR864jVxdRcJAFRE8ZvPuikeoYNsXuU3": {"detected_ms": 1740513176249}, "5G1FFNUrq9UBoZjaA1Bw7JZ79EkQg9QqZpGrdNki1zvPa1e8": {"detected_ms": 1743781632826}, "5G1R1WuVySDd2qFciQ7xBLLoeyqUGZZeUTHLtrqoyLxoqPYq": {"detected_ms": 1738723094987}, "5G1WL4u9R6Q855LDM5KpEZKsqVGJWhfo8ZdDmf8XohPfeSdV": {"detected_ms": 1744159867412}, "5G1sqJDHgmjKRJVbfrLpomGoXgbj2Bjf88QMmNAeG3ZDsZEF": {"detected_ms": 1755622903925}, "5G6bL5tRwg472PsJcLmfBQcR1xTosbei6vtbZBSH5FzCSf4j": {"detected_ms": 1749657364142}, "5G9tRL7TK6tRPP6TMvgRuJwB8FvwFkodrU7tNtUxhzyPQxV1": {"detected_ms": 1739773329356}, "5G9xt7soCYYNDadFcWfeABzbN7xAV6UderG7wKoVBP4hcF5Q": {"detected_ms": 1730880504422}, "5G9yGe6TEDxx7wD2mpM2XSu8P7gVt6wwUvuXRrHGAYJDA955": {"detected_ms": 1738723097478}, "5GA214EQwrWKtDxf2BZptLmeaxF1Nmm6WxVD93YUZRn8Bk5s": {"detected_ms": 1758836004817}, "5GBhWFMwxyvSo47LnctazwmfUHrnAieK8vuJgsEPeugKrJQ4": {"detected_ms": 1748001620346}, "5GBogr6pPUFdht3VzRWj5jp3sWYeazrhafG6eqK69Q3BDTWD": {"detected_ms": 1750722461894}, "5GCDZ6Vum2vj1YgKtw7Kv2fVXTPmV1pxoHh1YrsxqBvf9SRa": {"detected_ms": 1729005606268}, "5GCRF1NLkU41tipELQbVsFuH8ZAYxtwWpn4FYiXiu6QHUaHA": {"detected_ms": 1729593476825}, "5GEhcYPVoSgacLojXoGj2xjYV2LGzarKwdn9xCvmf8y3Hqhx": {"detected_ms": 1753442918183}, "5GGxkqvYhRC4RB2LeWr2d13r4kiiqnDagDJMssNvRcfM98an": {"detected_ms": 1747934924000}, "5GHd149LeQK4YwS67pSffodPtRR2Yg6Di7sLQsKaShB4tZhQ": {"detected_ms": 1742467318914}, "5GKMqtvfWMF7phS6DnTDtym1DHJGPqbfq1jTM23aKnUG5Njx": {"detected_ms": 1736783409726}, "5GKYNr6fDLdBrvYCat8TbU57G8RKKnoHTiGUWXzCP8HJN7kd": {"detected_ms": 1741674669309}, "5GKfxp9zhRkKEYfrRrVhbm4UT8E9ksppRGWpH7P42ZM8pvzU": {"detected_ms": 1744639839488}, "5GLFBkdfy2Fk5opjJiCESRbtMiZwS8jvoEYHYtPcZLVs3PML": {"detected_ms": 1741883750241}, "5GNAi7DBGfLftEsnYpY7MzSSLsMU79nD9ASC8VgLSsnHWko5": {"detected_ms": 1728125751950}, "5GNNaFZ7LTj78ysSNoZD3oW3QDPVnZxftd43MHnDLguvyPvQ": {"detected_ms": 1743770401954}, "5GP8B298AZiDNxWB4wQRUxdL5ffnJNCZ1NFpPyfRLK7amfyd": {"detected_ms": 1751478578524}, "5GQyxWwzDVW78C7akiNcfvHmUDnn8a2AdAxY5gLT482yRjeP": {"detected_ms": 1761082035359}, "5GQzKQ7xUueagkVmpywMn3UGeHAY445JUrKoUQvEWHpxEkEN": {"detected_ms": 1740709769995}, "5GTL7WXa4JM2yEUjFoCy2PZVLioNs1HzAGLKhuCDzzoeQCTR": {"detected_ms": 1741044847059}, "5GTrxGH6z7poSbEwdmGdj1ayLyUK4NisJGRSzGRrvoCrVJ38": {"detected_ms": 1746354166501}, "5GTx3NsD1n4ALGrEbptQkbYDjtz8ZvkYPu3ByrLHPv9QVazC": {"detected_ms": 1759363252107}, "5GVyf2Qc78JMe7GdhCCkoaUHvgP5gfgueh4cNZf517MpibYJ": {"detected_ms": 1742311663562}, "5GWL86ciS1ibbi7BdsAh1H8jtNqFc7TFvYvDMFdHj1NCb9Zz": {"detected_ms": 1749515629864}, "5GWwGQK52p4fjNAxAoNDVf5X1oeRAvAzHXFjbCVx9uNgKnev": {"detected_ms": 1749643887848}, "5GYrq6YTawHqRAqaVNMzBGzo2EeSj7qZNB7q3xLha8RJmifW": {"detected_ms": 1749156849409}, "5GYzMmsZgyCxcfymbZiycxz5nedBLfYPkiF7DwQHpaLerKVr": {"detected_ms": 1743675536309}, "5GZ7m12TUXrff8LPbbhSR8Sahh8FdtJbRfUxN666yVUqJPJY": {"detected_ms": 1732267619592}, "5GZWcXvWgFK3Se9g34kVFwbR9rycN1sipWDNW6hKbuXPbjvK": {"detected_ms": 1759363489760}, "5GbRFisQqn5xkcLJX8wEAciL62ZnfJdrdhytTqRZesWj5Ftr": {"detected_ms": 1738570455576}, "5GbrTogmStgmh85QNaHeronJ4GVMdURdfoLaNb7sz286U6C5": {"detected_ms": 1759363497136}, "5GbzScBLzU6cKUkLWQ6acpbrGsTn4pzPYAfEqKrRGW1pUAPP": {"detected_ms": 1729898453520}, "5GdcPqLFTnL6vamqLy1b8yzPdJsu38nTZyf3SVEM9hTN5DiA": {"detected_ms": 1731393090089}, "5GefkNwD3PB85kyd3vzn2ewHG1w2DJo1mg945VKs73pVmBUT": {"detected_ms": 1750541495056}, "5GejszMStE7UMDHyFRQv1gHYqrcqCkSuVCL4ULuP8uzuKe7a": {"detected_ms": 1738723095329}, "5GgGvtzTFkaaRtwpiyWaF9CWfrWZ63CeECYR2rAkL3VvFRgH": {"detected_ms": 1751880809904}, "5GgY7e9t9JEn15okkvWiPDVgiiyr6BxvdRPZfyWNBkJdoWdH": {"detected_ms": 1738723096203}, "5GgbDcvvmt2wp5LczcEaz9ChLUTt18469CuLvny2mrxNgAfe": {"detected_ms": 1748224606263}, "5GhBV59jmKHd7iZ1F18wgPY2wKFx8hD7Z6hGuL1RNkLBKHbu": {"detected_ms": 1742776159466}, "5GhCxfBcA7Ur5iiAS343xwvrYHTUfBjBi4JimiL5LhujRT9t": {"detected_ms": 1729878271008}, "5GhRAbtoaXXvkZKz8v7RiqRYy8omY5jpEtd1vTcHW1owH4gt": {"detected_ms": 1726229147717}, "5GhRddUNcwWSaaa8o5ipcYr4HLCYMg1WwH3rUWdF6RHgE581": {"detected_ms": 1721836511207}, "5GhUdZ4Cv3tmZ36DnuYPFMgEdPBAxtLGSuN3S8ozHYKREBTH": {"detected_ms": 1747271804095}, "5GpBQ1zHDJ14KPtKRXMZi5Nsh7iumgFRHj9wom6NGzTSNnHC": {"detected_ms": 1758772225308}, "5GqMmDM4BaH9Ndg8ASWhwwSgSiC76T2m2no1qDbh2ZZ3iJrs": {"detected_ms": 1738723095658}, "5Grgb5e4aHrGzhAd1ZSFQwUHQSM5yaJw5Dp7T7ss7yLY17jB": {"detected_ms": 1744218264133}, "5GsNcT3ENpxQdNnM2LTSC5beBneEddZjpUhNVCcrdUbicp1w": {"detected_ms": 1748849936496}, "5GucPphXea9yp8mu81r9z2rYSQ5R2PKqsZsfBAadvGWmh3k3": {"detected_ms": 1740707275362}, "5GuiLqgzZ7fbeb74NsUQoxVukc9tjTkh6TZuJX24g6gWMq91": {"detected_ms": 1739170955508}, "5GumPRB9vJzZt9ZMwhYEcVeT2BEC6ZjM6hVWXQXbZmZHB7ye": {"detected_ms": 1728507711299}, "5GuyfzBLtWhBF6Hba6DtxWMqgi4CzvQgYMFHjFpfh2F6ZfGM": {"detected_ms": 1750703364940}, "5GwxgYwLEmh7c7cxsguPWfUTNQA5SqS1uuNKb8PibEYXbeBG": {"detected_ms": 1750091135764}, "5GyBmAHFSFRca5BYY5yHC3S8VEcvZwgamsxyZTXep5prVz9f": {"detected_ms": 1744219631617}, "5H1UDsPRysiauQW7taQn6ZBpnp2WXaasmsaiFGwnddwMh5DC": {"detected_ms": 1749154838245}, "5H1b6cUFuACLBCba46uCGbgwVCTrYUmEv2m2X6Yqx8KzinjY": {"detected_ms": 1748289204431}, "5H1iQAvzxXXPChmMutvtWgoboVJvJZGhaXsB4T8eGCUyo6Ag": {"detected_ms": 1740474738578}, "5H1nJ3NotgNVhYyTG23T5jjGzdjuFC4zqZ8VtKcUZGDNfPcw": {"detected_ms": 1748351513933}, "5H3Zo3mMusk55tfMhirYTyCsYmQVfMTDhNGHyUdRNJsE8ajj": {"detected_ms": 1741368372911}, "5H4Ns4etDVraivTbUQw1S5BX9JjNmecHnUgtDhkSt9UF76UF": {"detected_ms": 1732274750966}, "5H5qxw414C81WdCnRWfEopqFiEz9QHAHxVEWcxffAzUjyJTb": {"detected_ms": 1740018111112}, "5H67WyekqLpuXAd4PC6AsnzybFkqK8Apj2dptZ7GQqLcDimc": {"detected_ms": 1722208901955}, "5H8X178MPpfeUMsWVAQsBkwFQ6gJMze5DvSpuFXED9Yh4ptG": {"detected_ms": 1737785812490}, "5H8h6TzHKuxJj68gUb6nnRGcGLF6Bn9QC9yRhUSqmTt6oi2x": {"detected_ms": 1738723095658}, "5H91V7L65yXxagcF58S8uX94cmQ7F6xyYsK5fzEKFctX3yhN": {"detected_ms": 1759363249249}, "5HB7yym8jsf1GQ8s3sXbabXdBFpuwgX2EGCZpVpvnHUYecHA": {"detected_ms": 1739092370138}, "5HCJ6okRkmCsu7iLEWotBxgcZy11RhbxSzs8MXT4Dei9osUx": {"detected_ms": 1748875178257}, "5HDjwdba5EvQy27CD6HksabaHaPP4NSHLLaH2o9CiD3aA5hv": {"detected_ms": 1743831646284}, "5HDmzyhrEco9w6Jv8eE3hDMcXSE4AGg1MuezPR4u2covxKwZ": {"detected_ms": 1722876899544}, "5HE4v4d2ShWpsWeasynVaawvBjRcZsLX2sd5MGPiwoJfywir": {"detected_ms": 1748827812889}, "5HEF4s6BQWmJQxuPcNCyMfSVu2CH8KaPfB7hxbSc5V76RoNg": {"detected_ms": 1730118809087}, "5HGjWU5aSc9pKxvQyKt71b69aEuiyMRjm7tsYS1tN5FTVH8Z": {"detected_ms": 1738723094500}, "5HH6ANqoDg2AQwEFQpDnK4UcMadRWN978Uu16gh1rbj6jyBw": {"detected_ms": 1746739573481}, "5HHQaK8PC6LjSzqYbPqAkMPsxPeTVEpahccpbkr8nftSHPyV": {"detected_ms": 1748701103027}, "5HL4DHBfBFs8TqwNrgHmrMy5sNEafobpBikTdFA7ZZQ7owCu": {"detected_ms": 1738596674709}, "5HMgng5e1Y8GEEsoQ3Tuq7gLAxX5NXDAtsaYtiyxD4J3eRbr": {"detected_ms": 1759363755919}, "5HN1ogjYW4rNYzCkyNLKWJQ8qmiojfRe3XcTmZxTmRruJTp2": {"detected_ms": 1746358340588}, "5HNAEqKbN9tYxbcVXKWg6yqNv9zKgR3z2WE7vyQVGys59m4G": {"detected_ms": 1738723094597}, "5HQPaJuMkEfEdrZz6wc44VAgvizjv3GTCDDiqRUzyuiZzU4n": {"detected_ms": 1744196912671}, "5HQnY1B1zkedLp7VtJEJcacLwtW1TNqHDKbiTRgw5hN8Xd3Z": {"detected_ms": 1746736898092}, "5HQvLHxJdKjjZPgxh16sGEApfyqqUQnn7XJPUXD881U8jCss": {"detected_ms": 1748850828902}, "5HRPWHpNxV97gTN7E2jrNheSCs63pEU1X8uSK5uThq4hrDYN": {"detected_ms": 1738727544664}, "5HSvPgQHKu5bxyAT6ejgQwMVdhs8wJ3yY12xyLkFLGjQFVkg": {"detected_ms": 1742614948341}, "5HT59gGJNipXr7TwxTqW3eUAqChE7Vn1vgnUet2yMZe1tG5W": {"detected_ms": 1748111635052}, "5HTVmEBicnSE1iFrSRtYnmyv5bhj6SRmiZHT3Twfbh9jon14": {"detected_ms": 1736491663110}, "5HVrZaySfiXdjdP2WjznPffsyntBPV4hKiM8qMHKjvAUv7Zi": {"detected_ms": 1730081111042}, "5HWAxTrb6CgBhUZDnE15yvLiwmjmYYMDAKkPtCXyCkdmMNGP": {"detected_ms": 1729691374193}, "5HYNQS3rK4e9uAGZS5dtGPbpout7hHKbNFvYJNzipgnWe4kR": {"detected_ms": 1736063183841}, "5HbJwoZ5ixitbHg4oBARKzJ6y828DCzzzvDYMw4xsqu7tqLL": {"detected_ms": 1732155956575}, "5Hbd1S9JiUTFD9rUkbVtywjtfd82nb6HuannsjonMS36N1tb": {"detected_ms": 1739688425239}, "5HeNFBvP8MP1RrZMQwg5Pk3fxpecahRHxzMovRHbz7EufHdF": {"detected_ms": 1759363446953}, "5Hg1XxDnYdxHSZyS3doLoaS7AUVXDKmdrRCxNdAGYtCQvuZU": {"detected_ms": 1742581032348}, "5HgKApmbGpjEeAFVAYGtrTmYTdugbdWcnG2jq1BLLvxkGjX4": {"detected_ms": 1748850949457}, "5HgajQXpuLkJ67J7f1wF2i7JujmyVYw5Hn8DikpTBPx6mcyp": {"detected_ms": 1735727621494}, "5HghCsXhk3fA9HBcwsq49Z691BuhhULkLpwqx93b32gjj751": {"detected_ms": 1749828330030}, "5Hh3a7f15fyEJoKoqvBeu8RisgXQQKDU5CHJSpxMmTbf1mrd": {"detected_ms": 1733779397803}, "5Hij9aRdbW9AdgjCbxE8NY6E3jPt3xof7Cc8DEDKAutPxPE5": {"detected_ms": 1743633456887}, "5HmWABWpe8cfQQx9jRsnLCgvrjXK58jYGQStbLNabqQ4CMrt": {"detected_ms": 1731393090094}, "5HmyHw1wSGTqLMAX6odNaB2wRiDv1KFZhBq9EqXx1tAfn8aA": {"detected_ms": 1743817798336}, "5HnCPqPM3kaY6wjt9mjHjKd6e85pw5obhiQ7J2vWBjvc7XDD": {"detected_ms": 1739859118987}, "5HorXPBzd5NqAAChPpVAyMPiNHjERJ16XMVK3m73JLTX1Pd9": {"detected_ms": 1732086050270}}} \ No newline at end of file diff --git a/meta/meta.json b/meta/meta.json index bce2eb298..39b5047cd 100644 --- a/meta/meta.json +++ b/meta/meta.json @@ -1,3 +1,3 @@ { - "subnet_version": "7.0.1" + "subnet_version": "7.0.2" } diff --git a/neurons/validator.py b/neurons/validator.py index 9709e6117..9186e34d9 100644 --- a/neurons/validator.py +++ b/neurons/validator.py @@ -952,6 +952,21 @@ def should_fail_early(self, synapse: template.protocol.SendSignal | template.pro synapse.error_message = msg return True + # don't process re-registered miners + if self.elimination_manager.is_hotkey_re_registered(synapse.dendrite.hotkey): + # Get deregistration timestamp and convert to human-readable date + departed_info = self.elimination_manager.departed_hotkeys.get(synapse.dendrite.hotkey, {}) + detected_ms = departed_info.get("detected_ms", 0) + dereg_date = TimeUtil.millis_to_formatted_date_str(detected_ms) if detected_ms else "unknown" + + msg = (f"This miner hotkey {synapse.dendrite.hotkey} was previously de-registered and is not allowed to re-register. " + f"De-registered on: {dereg_date} UTC. " + f"Re-registration is not permitted on this subnet.") + bt.logging.warning(msg) + synapse.successfully_processed = False + synapse.error_message = msg + return True + order_uuid = synapse.miner_order_uuid tp = self.parse_trade_pair_from_signal(signal) if order_uuid and self.uuid_tracker.exists(order_uuid): diff --git a/shared_objects/metagraph_updater.py b/shared_objects/metagraph_updater.py index 2f6a4d7ea..742e49fa8 100644 --- a/shared_objects/metagraph_updater.py +++ b/shared_objects/metagraph_updater.py @@ -10,6 +10,7 @@ from vali_objects.vali_config import ValiConfig, TradePair from shared_objects.cache_controller import CacheController from shared_objects.error_utils import ErrorUtils +from shared_objects.metagraph_utils import is_anomalous_hotkey_loss from shared_objects.subtensor_lock import get_subtensor_lock from time_util.time_util import TimeUtil @@ -772,9 +773,10 @@ def update_metagraph(self): if not lost_hotkeys and not gained_hotkeys: bt.logging.info(f"metagraph hotkeys remain the same. n = {len(hotkeys_after)}") - percent_lost = 100 * len(lost_hotkeys) / len(hotkeys_before) if lost_hotkeys else 0 + # Use shared anomaly detection logic + is_anomalous, percent_lost = is_anomalous_hotkey_loss(lost_hotkeys, len(hotkeys_before)) # failsafe condition to reject new metagraph - if len(lost_hotkeys) > 10 and percent_lost >= 25: + if is_anomalous: error_msg = (f"Too many hotkeys lost in metagraph update: {len(lost_hotkeys)} hotkeys lost, " f"{percent_lost:.2f}% of total hotkeys. Rejecting new metagraph. ALERT A TEAM MEMBER ASAP...") bt.logging.error(error_msg) diff --git a/shared_objects/metagraph_utils.py b/shared_objects/metagraph_utils.py new file mode 100644 index 000000000..04257373f --- /dev/null +++ b/shared_objects/metagraph_utils.py @@ -0,0 +1,61 @@ +# developer: jbonilla +# Copyright © 2024 Taoshi Inc + +""" +Shared utilities for metagraph analysis and anomaly detection. +""" + +# Constants for anomaly detection +ANOMALY_DETECTION_MIN_LOST = 10 # Minimum number of lost hotkeys to trigger anomaly detection +ANOMALY_DETECTION_PERCENT_THRESHOLD = 25 # Percentage threshold for anomaly detection + + +def is_anomalous_hotkey_loss(lost_hotkeys: set, total_hotkeys_before: int) -> tuple[bool, float]: + """ + Detect anomalous drops in hotkey counts to avoid false positives from network issues. + + This function identifies when too many hotkeys disappear at once, which likely indicates + a network connectivity issue rather than legitimate de-registrations. Both the absolute + count and percentage must exceed thresholds to trigger anomaly detection. + + Args: + lost_hotkeys: Set of hotkeys that were lost in the metagraph update + total_hotkeys_before: Total number of hotkeys before the change + + Returns: + tuple[bool, float]: (is_anomalous, percent_lost) + - is_anomalous: True if the change is anomalous (likely a network issue), False otherwise + - percent_lost: Percentage of hotkeys lost (0-100) + + Examples: + >>> # Normal case: 5 hotkeys lost out of 100 (5%) + >>> is_anomalous_hotkey_loss({1, 2, 3, 4, 5}, 100) + (False, 5.0) + + >>> # Anomalous case: 30 hotkeys lost out of 100 (30%) + >>> is_anomalous_hotkey_loss(set(range(30)), 100) + (True, 30.0) + + >>> # Edge case: 15 hotkeys lost out of 40 (37.5% - high percentage but meets both thresholds) + >>> is_anomalous_hotkey_loss(set(range(15)), 40) + (True, 37.5) + + >>> # Edge case: 11 hotkeys lost out of 100 (11% - above min count but below percent threshold) + >>> is_anomalous_hotkey_loss(set(range(11)), 100) + (False, 11.0) + """ + # Handle edge cases + if not lost_hotkeys or total_hotkeys_before == 0: + return False, 0.0 + + num_lost = len(lost_hotkeys) + percent_lost = 100.0 * num_lost / total_hotkeys_before + + # Anomaly if we lost more than MIN_LOST hotkeys AND >= PERCENT_THRESHOLD of total + # Both conditions must be true to avoid false positives + is_anomalous = ( + num_lost > ANOMALY_DETECTION_MIN_LOST and + percent_lost >= ANOMALY_DETECTION_PERCENT_THRESHOLD + ) + + return is_anomalous, percent_lost diff --git a/tests/vali_tests/mock_utils.py b/tests/vali_tests/mock_utils.py index b39113c54..57eb9b4ca 100644 --- a/tests/vali_tests/mock_utils.py +++ b/tests/vali_tests/mock_utils.py @@ -120,6 +120,30 @@ def remove_hotkey(self, hotkey: str): self.uid_to_hotkey = {i: hk for i, hk in enumerate(self.hotkeys)} self.hotkey_to_uid = {hk: i for i, hk in enumerate(self.hotkeys)} + def add_hotkey(self, hotkey: str): + """Add a hotkey to the metagraph (simulate re-registration)""" + if hotkey not in self.hotkeys: + self.hotkeys.append(hotkey) + self.n = len(self.hotkeys) + + # Add to all lists with default values + new_uid = len(self.uids) + self.uids.append(new_uid) + self.stakes.append(100.0) + self.trust.append(1.0) + self.consensus.append(1.0) + self.incentive.append(1.0) + self.dividends.append(0.0) + self.active.append(1) + self.last_update.append(self.block) + self.validator_permit.append(True) + self.block_at_registration.append(self.block) + + # Update mappings + self.uid_to_hotkey[new_uid] = hotkey + self.hotkey_to_uid[hotkey] = new_uid + self.uid_to_block[new_uid] = self.block + class EnhancedMockChallengePeriodManager(BaseMockChallengePeriodManager): """Enhanced mock challenge period manager with full bucket support""" diff --git a/tests/vali_tests/test_reregistration.py b/tests/vali_tests/test_reregistration.py new file mode 100644 index 000000000..e00a6568d --- /dev/null +++ b/tests/vali_tests/test_reregistration.py @@ -0,0 +1,581 @@ +# developer: jbonilla +# Copyright © 2024 Taoshi Inc +import os +from unittest.mock import MagicMock, Mock, patch +from tests.vali_tests.mock_utils import ( + EnhancedMockMetagraph, + EnhancedMockChallengePeriodManager, + EnhancedMockPositionManager, + EnhancedMockPerfLedgerManager, + MockLedgerFactory, +) +from tests.vali_tests.base_objects.test_base import TestBase +from time_util.time_util import TimeUtil, MS_IN_8_HOURS, MS_IN_24_HOURS +from vali_objects.enums.order_type_enum import OrderType +from vali_objects.position import Position +from vali_objects.utils.elimination_manager import ( + EliminationManager, + DEPARTED_HOTKEYS_KEY +) +from shared_objects.metagraph_utils import ( + ANOMALY_DETECTION_MIN_LOST, + ANOMALY_DETECTION_PERCENT_THRESHOLD +) +from vali_objects.utils.miner_bucket_enum import MinerBucket +from vali_objects.utils.plagiarism_manager import PlagiarismManager +from vali_objects.utils.position_lock import PositionLocks +from vali_objects.utils.live_price_fetcher import LivePriceFetcher +from vali_objects.utils.vali_bkp_utils import ValiBkpUtils +from vali_objects.utils.validator_contract_manager import ValidatorContractManager +from vali_objects.utils.vali_utils import ValiUtils +from vali_objects.vali_config import TradePair +from vali_objects.vali_dataclasses.order import Order +import template + +class TestReregistration(TestBase): + """Integration tests for re-registration tracking and rejection""" + + def setUp(self): + super().setUp() + + # Create test miners + self.NORMAL_MINER = "normal_miner" + self.DEREGISTERED_MINER = "deregistered_miner" + self.REREGISTERED_MINER = "reregistered_miner" + self.FUTURE_REREG_MINER = "future_rereg_miner" + + self.all_miners = [ + self.NORMAL_MINER, + self.DEREGISTERED_MINER, + self.REREGISTERED_MINER, + self.FUTURE_REREG_MINER + ] + + # Initialize components + self.mock_metagraph = EnhancedMockMetagraph(self.all_miners) + + # Set up live price fetcher + secrets = ValiUtils.get_secrets(running_unit_tests=True) + self.live_price_fetcher = LivePriceFetcher(secrets=secrets, disable_ws=True) + + self.position_locks = PositionLocks() + + # Create IPC manager for multiprocessing simulation + # Use side_effect to return a NEW list/dict each time, not the same object + self.mock_ipc_manager = MagicMock() + self.mock_ipc_manager.list.side_effect = lambda: [] + self.mock_ipc_manager.dict.side_effect = lambda: {} + + # Create managers + self.perf_ledger_manager = EnhancedMockPerfLedgerManager( + self.mock_metagraph, + ipc_manager=self.mock_ipc_manager, + running_unit_tests=True, + perf_ledger_hks_to_invalidate={} + ) + + self.contract_manager = ValidatorContractManager(running_unit_tests=True) + self.plagiarism_manager = PlagiarismManager(slack_notifier=None, running_unit_tests=True) + + self.elimination_manager = EliminationManager( + self.mock_metagraph, + None, # position_manager set later + None, # challengeperiod_manager set later + running_unit_tests=True, + ipc_manager=self.mock_ipc_manager, + contract_manager=self.contract_manager + ) + + self.position_manager = EnhancedMockPositionManager( + self.mock_metagraph, + perf_ledger_manager=self.perf_ledger_manager, + elimination_manager=self.elimination_manager, + live_price_fetcher=self.live_price_fetcher + ) + + self.challengeperiod_manager = EnhancedMockChallengePeriodManager( + self.mock_metagraph, + position_manager=self.position_manager, + perf_ledger_manager=self.perf_ledger_manager, + contract_manager=self.contract_manager, + plagiarism_manager=self.plagiarism_manager, + running_unit_tests=True + ) + + # Set circular references + self.elimination_manager.position_manager = self.position_manager + self.elimination_manager.challengeperiod_manager = self.challengeperiod_manager + + # Clear all data + self.clear_all_data() + + # Set up initial state + self._setup_test_environment() + + def tearDown(self): + super().tearDown() + self.clear_all_data() + + def clear_all_data(self): + """Clear all test data""" + self.position_manager.clear_all_miner_positions() + self.perf_ledger_manager.clear_perf_ledgers_from_disk() + self.challengeperiod_manager._clear_challengeperiod_in_memory_and_disk() + self.elimination_manager.clear_eliminations() + + # Clear departed hotkeys file + departed_file = ValiBkpUtils.get_departed_hotkeys_dir(running_unit_tests=True) + if os.path.exists(departed_file): + os.remove(departed_file) + + def _setup_test_environment(self): + """Set up basic test environment""" + # Create positions for all miners + base_time = TimeUtil.now_in_millis() - MS_IN_24_HOURS * 5 + + for miner in self.all_miners: + position = Position( + miner_hotkey=miner, + position_uuid=f"{miner}_BTCUSD", + open_ms=base_time, + trade_pair=TradePair.BTCUSD, + is_closed_position=False, + orders=[Order( + price=60000, + processed_ms=base_time, + order_uuid=f"order_{miner}_BTCUSD", + trade_pair=TradePair.BTCUSD, + order_type=OrderType.LONG, + leverage=0.5 + )] + ) + self.position_manager.save_miner_position(position) + + # Set all miners to main competition + for miner in self.all_miners: + self.challengeperiod_manager.set_miner_bucket(miner, MinerBucket.MAINCOMP, 0) + + # Create basic performance ledgers + ledgers = {} + for miner in self.all_miners: + ledgers[miner] = MockLedgerFactory.create_winning_ledger(final_return=1.05) + self.perf_ledger_manager.save_perf_ledgers(ledgers) + + def _setup_polygon_mocks(self, mock_candle_fetcher, mock_get_candles, mock_market_close): + """Helper to set up Polygon API mocks""" + mock_candle_fetcher.return_value = [] + mock_get_candles.return_value = [] + from vali_objects.utils.live_price_fetcher import PriceSource + mock_market_close.return_value = PriceSource(open=50000, high=50000, low=50000, close=50000, volume=0, vwap=50000, timestamp=0) + + @patch('data_generator.polygon_data_service.PolygonDataService.get_event_before_market_close') + @patch('data_generator.polygon_data_service.PolygonDataService.get_candles_for_trade_pair') + @patch('data_generator.polygon_data_service.PolygonDataService.unified_candle_fetcher') + def test_departed_hotkey_tracking_on_deregistration(self, mock_candle_fetcher, mock_get_candles, mock_market_close): + """Test that departed hotkeys are tracked when miners leave the metagraph""" + self._setup_polygon_mocks(mock_candle_fetcher, mock_get_candles, mock_market_close) + + # Initial state - no departed hotkeys + self.assertEqual(len(self.elimination_manager.departed_hotkeys), 0) + + # Remove a miner from metagraph (simulate de-registration) + self.mock_metagraph.remove_hotkey(self.DEREGISTERED_MINER) + + # Process eliminations to trigger departed hotkey tracking + self.elimination_manager.process_eliminations(self.position_locks) + + # Verify the departed hotkey was tracked + self.assertIn(self.DEREGISTERED_MINER, self.elimination_manager.departed_hotkeys) + self.assertEqual(len(self.elimination_manager.departed_hotkeys), 1) + + # Verify it was persisted to disk + departed_file = ValiBkpUtils.get_departed_hotkeys_dir(running_unit_tests=True) + self.assertTrue(os.path.exists(departed_file)) + + # Load from disk and verify + departed_data = ValiUtils.get_vali_json_file(departed_file, DEPARTED_HOTKEYS_KEY) + self.assertIn(self.DEREGISTERED_MINER, departed_data) + + @patch('data_generator.polygon_data_service.PolygonDataService.get_event_before_market_close') + @patch('data_generator.polygon_data_service.PolygonDataService.get_candles_for_trade_pair') + @patch('data_generator.polygon_data_service.PolygonDataService.unified_candle_fetcher') + def test_multiple_departures_tracked(self, mock_candle_fetcher, mock_get_candles, mock_market_close): + """Test tracking multiple miners leaving the metagraph""" + self._setup_polygon_mocks(mock_candle_fetcher, mock_get_candles, mock_market_close) + + # Remove multiple miners + self.mock_metagraph.remove_hotkey(self.DEREGISTERED_MINER) + self.mock_metagraph.remove_hotkey(self.FUTURE_REREG_MINER) + + # Process eliminations + self.elimination_manager.process_eliminations(self.position_locks) + + # Verify both were tracked + self.assertIn(self.DEREGISTERED_MINER, self.elimination_manager.departed_hotkeys) + self.assertIn(self.FUTURE_REREG_MINER, self.elimination_manager.departed_hotkeys) + self.assertEqual(len(self.elimination_manager.departed_hotkeys), 2) + + @patch('data_generator.polygon_data_service.PolygonDataService.get_event_before_market_close') + @patch('data_generator.polygon_data_service.PolygonDataService.get_candles_for_trade_pair') + @patch('data_generator.polygon_data_service.PolygonDataService.unified_candle_fetcher') + def test_anomalous_departure_ignored(self, mock_candle_fetcher, mock_get_candles, mock_market_close): + self._setup_polygon_mocks(mock_candle_fetcher, mock_get_candles, mock_market_close) + """Test that anomalous mass departures are ignored to avoid false positives""" + # Create a large number of miners + large_miner_set = [f"miner_{i}" for i in range(50)] + self.mock_metagraph = EnhancedMockMetagraph(large_miner_set) + + # Reinitialize elimination manager with new metagraph + self.elimination_manager = EliminationManager( + self.mock_metagraph, + self.position_manager, + self.challengeperiod_manager, + running_unit_tests=True, + ipc_manager=self.mock_ipc_manager, + contract_manager=self.contract_manager + ) + + # Process once to set previous_metagraph_hotkeys + self.elimination_manager.process_eliminations(self.position_locks) + + # Remove 30% of miners (should trigger anomaly detection: >10 hotkeys AND >=25%) + miners_to_remove = large_miner_set[:15] # 15 out of 50 = 30% + for miner in miners_to_remove: + self.mock_metagraph.remove_hotkey(miner) + + # Process eliminations + self.elimination_manager.process_eliminations(self.position_locks) + + # Verify departed hotkeys were NOT tracked (anomaly detected) + self.assertEqual(len(self.elimination_manager.departed_hotkeys), 0) + + @patch('data_generator.polygon_data_service.PolygonDataService.get_event_before_market_close') + @patch('data_generator.polygon_data_service.PolygonDataService.get_candles_for_trade_pair') + @patch('data_generator.polygon_data_service.PolygonDataService.unified_candle_fetcher') + def test_normal_departure_below_anomaly_threshold(self, mock_candle_fetcher, mock_get_candles, mock_market_close): + self._setup_polygon_mocks(mock_candle_fetcher, mock_get_candles, mock_market_close) + """Test that normal departures below threshold are tracked""" + # Create miners + miner_set = [f"miner_{i}" for i in range(50)] + self.mock_metagraph = EnhancedMockMetagraph(miner_set) + + # Reinitialize elimination manager + self.elimination_manager = EliminationManager( + self.mock_metagraph, + self.position_manager, + self.challengeperiod_manager, + running_unit_tests=True, + ipc_manager=self.mock_ipc_manager, + contract_manager=self.contract_manager + ) + + # Process once to set baseline + self.elimination_manager.process_eliminations(self.position_locks) + + # Remove only 5 miners (5 out of 50 = 10%, below 25% threshold) + miners_to_remove = miner_set[:5] + for miner in miners_to_remove: + self.mock_metagraph.remove_hotkey(miner) + + # Process eliminations + self.elimination_manager.process_eliminations(self.position_locks) + + # Verify departed hotkeys WERE tracked (not anomalous) + self.assertEqual(len(self.elimination_manager.departed_hotkeys), 5) + for miner in miners_to_remove: + self.assertIn(miner, self.elimination_manager.departed_hotkeys) + + @patch('data_generator.polygon_data_service.PolygonDataService.get_event_before_market_close') + @patch('data_generator.polygon_data_service.PolygonDataService.get_candles_for_trade_pair') + @patch('data_generator.polygon_data_service.PolygonDataService.unified_candle_fetcher') + def test_reregistration_detection(self, mock_candle_fetcher, mock_get_candles, mock_market_close): + self._setup_polygon_mocks(mock_candle_fetcher, mock_get_candles, mock_market_close) + """Test detection when a departed miner re-registers""" + # Remove miner from metagraph + self.mock_metagraph.remove_hotkey(self.REREGISTERED_MINER) + + # Process to track departure + self.elimination_manager.process_eliminations(self.position_locks) + self.assertIn(self.REREGISTERED_MINER, self.elimination_manager.departed_hotkeys) + + # Re-add miner to metagraph (simulate re-registration) + self.mock_metagraph.add_hotkey(self.REREGISTERED_MINER) + + # Process eliminations again + self.elimination_manager.process_eliminations(self.position_locks) + + # Verify re-registration was detected (check via is_hotkey_re_registered) + self.assertTrue(self.elimination_manager.is_hotkey_re_registered(self.REREGISTERED_MINER)) + + # Verify the hotkey is still in departed list (permanent record) + self.assertIn(self.REREGISTERED_MINER, self.elimination_manager.departed_hotkeys) + + @patch('data_generator.polygon_data_service.PolygonDataService.get_event_before_market_close') + @patch('data_generator.polygon_data_service.PolygonDataService.get_candles_for_trade_pair') + @patch('data_generator.polygon_data_service.PolygonDataService.unified_candle_fetcher') + def test_is_hotkey_re_registered_method(self, mock_candle_fetcher, mock_get_candles, mock_market_close): + self._setup_polygon_mocks(mock_candle_fetcher, mock_get_candles, mock_market_close) + """Test the is_hotkey_re_registered() lookup method""" + # Normal miner - should return False + self.assertFalse(self.elimination_manager.is_hotkey_re_registered(self.NORMAL_MINER)) + + # Miner that has never been in metagraph - should return False + self.assertFalse(self.elimination_manager.is_hotkey_re_registered("unknown_miner")) + + # Set up re-registered miner + self.mock_metagraph.remove_hotkey(self.REREGISTERED_MINER) + self.elimination_manager.process_eliminations(self.position_locks) + + # While departed - should return False (not currently in metagraph) + self.assertFalse(self.elimination_manager.is_hotkey_re_registered(self.REREGISTERED_MINER)) + + # Re-add to metagraph + self.mock_metagraph.add_hotkey(self.REREGISTERED_MINER) + + # Now should return True (in metagraph AND in departed list) + self.assertTrue(self.elimination_manager.is_hotkey_re_registered(self.REREGISTERED_MINER)) + + @patch('data_generator.polygon_data_service.PolygonDataService.get_event_before_market_close') + @patch('data_generator.polygon_data_service.PolygonDataService.get_candles_for_trade_pair') + @patch('data_generator.polygon_data_service.PolygonDataService.unified_candle_fetcher') + def test_departed_hotkeys_persistence_across_restart(self, mock_candle_fetcher, mock_get_candles, mock_market_close): + self._setup_polygon_mocks(mock_candle_fetcher, mock_get_candles, mock_market_close) + """Test that departed hotkeys persist across elimination manager restart""" + # Track some departed miners + self.mock_metagraph.remove_hotkey(self.DEREGISTERED_MINER) + self.mock_metagraph.remove_hotkey(self.FUTURE_REREG_MINER) + self.elimination_manager.process_eliminations(self.position_locks) + + # Verify they were tracked + self.assertEqual(len(self.elimination_manager.departed_hotkeys), 2) + + # Create new elimination manager (simulate restart) + new_elimination_manager = EliminationManager( + self.mock_metagraph, + self.position_manager, + self.challengeperiod_manager, + running_unit_tests=True, + contract_manager=self.contract_manager + ) + + # Verify departed hotkeys were loaded from disk + self.assertEqual(len(new_elimination_manager.departed_hotkeys), 2) + self.assertIn(self.DEREGISTERED_MINER, new_elimination_manager.departed_hotkeys) + self.assertIn(self.FUTURE_REREG_MINER, new_elimination_manager.departed_hotkeys) + + @patch('data_generator.polygon_data_service.PolygonDataService.get_event_before_market_close') + @patch('data_generator.polygon_data_service.PolygonDataService.get_candles_for_trade_pair') + @patch('data_generator.polygon_data_service.PolygonDataService.unified_candle_fetcher') + def test_validator_rejects_reregistered_orders(self, mock_candle_fetcher, mock_get_candles, mock_market_close): + self._setup_polygon_mocks(mock_candle_fetcher, mock_get_candles, mock_market_close) + """Test that validator's should_fail_early rejects re-registered miners""" + # Import validator components + from neurons.validator import Validator + + # Create mock synapse for signal + mock_synapse = Mock(spec=template.protocol.SendSignal) + mock_synapse.dendrite = Mock() + mock_synapse.dendrite.hotkey = self.REREGISTERED_MINER + mock_synapse.miner_order_uuid = "test_uuid" + mock_synapse.successfully_processed = True + mock_synapse.error_message = "" + + # Create mock signal + mock_signal = { + "trade_pair": { + "trade_pair_id": "BTCUSD" + }, + "order_type": "LONG", + "leverage": 0.5 + } + + # Set up re-registered miner + self.mock_metagraph.remove_hotkey(self.REREGISTERED_MINER) + self.elimination_manager.process_eliminations(self.position_locks) + self.mock_metagraph.add_hotkey(self.REREGISTERED_MINER) + + # Verify re-registration detected + self.assertTrue(self.elimination_manager.is_hotkey_re_registered(self.REREGISTERED_MINER)) + + # Test rejection logic directly (simulating should_fail_early check) + if self.elimination_manager.is_hotkey_re_registered(mock_synapse.dendrite.hotkey): + mock_synapse.successfully_processed = False + mock_synapse.error_message = ( + f"This miner hotkey {mock_synapse.dendrite.hotkey} was previously de-registered " + f"and is not allowed to re-register. Re-registration is not permitted on this subnet." + ) + + # Verify the order was rejected + self.assertFalse(mock_synapse.successfully_processed) + self.assertIn("previously de-registered", mock_synapse.error_message) + self.assertIn("not allowed to re-register", mock_synapse.error_message) + + def test_normal_miner_not_rejected(self): + """Test that normal miners (never departed) are not rejected""" + # Create mock synapse + mock_synapse = Mock(spec=template.protocol.SendSignal) + mock_synapse.dendrite = Mock() + mock_synapse.dendrite.hotkey = self.NORMAL_MINER + mock_synapse.successfully_processed = True + mock_synapse.error_message = "" + + # Normal miner should not be flagged as re-registered + self.assertFalse(self.elimination_manager.is_hotkey_re_registered(self.NORMAL_MINER)) + + # Simulate the check (should pass) + if self.elimination_manager.is_hotkey_re_registered(mock_synapse.dendrite.hotkey): + mock_synapse.successfully_processed = False + mock_synapse.error_message = "Should not reach here" + + # Verify order was NOT rejected + self.assertTrue(mock_synapse.successfully_processed) + self.assertEqual(mock_synapse.error_message, "") + + @patch('data_generator.polygon_data_service.PolygonDataService.get_event_before_market_close') + @patch('data_generator.polygon_data_service.PolygonDataService.get_candles_for_trade_pair') + @patch('data_generator.polygon_data_service.PolygonDataService.unified_candle_fetcher') + def test_departed_miner_not_yet_reregistered(self, mock_candle_fetcher, mock_get_candles, mock_market_close): + self._setup_polygon_mocks(mock_candle_fetcher, mock_get_candles, mock_market_close) + """Test that departed miners (not yet re-registered) are handled correctly""" + # Create mock synapse + mock_synapse = Mock(spec=template.protocol.SendSignal) + mock_synapse.dendrite = Mock() + mock_synapse.dendrite.hotkey = self.DEREGISTERED_MINER + + # De-register the miner + self.mock_metagraph.remove_hotkey(self.DEREGISTERED_MINER) + self.elimination_manager.process_eliminations(self.position_locks) + + # Departed but not re-registered should return False (not in metagraph) + self.assertFalse(self.elimination_manager.is_hotkey_re_registered(self.DEREGISTERED_MINER)) + + @patch('data_generator.polygon_data_service.PolygonDataService.get_event_before_market_close') + @patch('data_generator.polygon_data_service.PolygonDataService.get_candles_for_trade_pair') + @patch('data_generator.polygon_data_service.PolygonDataService.unified_candle_fetcher') + def test_multiple_reregistrations_tracked(self, mock_candle_fetcher, mock_get_candles, mock_market_close): + self._setup_polygon_mocks(mock_candle_fetcher, mock_get_candles, mock_market_close) + """Test tracking multiple re-registrations""" + # Set up multiple re-registered miners + miners_to_rereg = [self.REREGISTERED_MINER, self.FUTURE_REREG_MINER] + + for miner in miners_to_rereg: + # De-register + self.mock_metagraph.remove_hotkey(miner) + + self.elimination_manager.process_eliminations(self.position_locks) + + # Verify both tracked as departed + self.assertEqual(len(self.elimination_manager.departed_hotkeys), 2) + + # Re-register both + for miner in miners_to_rereg: + self.mock_metagraph.add_hotkey(miner) + + # Both should be detected as re-registered + for miner in miners_to_rereg: + self.assertTrue(self.elimination_manager.is_hotkey_re_registered(miner)) + + @patch('data_generator.polygon_data_service.PolygonDataService.get_event_before_market_close') + @patch('data_generator.polygon_data_service.PolygonDataService.get_candles_for_trade_pair') + @patch('data_generator.polygon_data_service.PolygonDataService.unified_candle_fetcher') + def test_departed_file_format(self, mock_candle_fetcher, mock_get_candles, mock_market_close): + self._setup_polygon_mocks(mock_candle_fetcher, mock_get_candles, mock_market_close) + """Test that the departed hotkeys file has correct format""" + # Track some departures + self.mock_metagraph.remove_hotkey(self.DEREGISTERED_MINER) + self.elimination_manager.process_eliminations(self.position_locks) + + # Read file directly + departed_file = ValiBkpUtils.get_departed_hotkeys_dir(running_unit_tests=True) + with open(departed_file, 'r') as f: + import json + data = json.load(f) + + # Verify structure - should be a dict with metadata + self.assertIn(DEPARTED_HOTKEYS_KEY, data) + self.assertIsInstance(data[DEPARTED_HOTKEYS_KEY], dict) + self.assertIn(self.DEREGISTERED_MINER, data[DEPARTED_HOTKEYS_KEY]) + # Verify metadata is present + metadata = data[DEPARTED_HOTKEYS_KEY][self.DEREGISTERED_MINER] + self.assertIn("detected_ms", metadata) + + @patch('data_generator.polygon_data_service.PolygonDataService.get_event_before_market_close') + @patch('data_generator.polygon_data_service.PolygonDataService.get_candles_for_trade_pair') + @patch('data_generator.polygon_data_service.PolygonDataService.unified_candle_fetcher') + def test_no_duplicate_departed_tracking(self, mock_candle_fetcher, mock_get_candles, mock_market_close): + self._setup_polygon_mocks(mock_candle_fetcher, mock_get_candles, mock_market_close) + """Test that the same miner isn't added to departed list multiple times""" + # Remove miner + self.mock_metagraph.remove_hotkey(self.DEREGISTERED_MINER) + self.elimination_manager.process_eliminations(self.position_locks) + + # Process multiple times + self.elimination_manager.process_eliminations(self.position_locks) + self.elimination_manager.process_eliminations(self.position_locks) + + # Should only appear once (dict keys are unique by definition) + self.assertIn(self.DEREGISTERED_MINER, self.elimination_manager.departed_hotkeys) + self.assertEqual(len(self.elimination_manager.departed_hotkeys), 1) + + @patch('data_generator.polygon_data_service.PolygonDataService.get_event_before_market_close') + @patch('data_generator.polygon_data_service.PolygonDataService.get_candles_for_trade_pair') + @patch('data_generator.polygon_data_service.PolygonDataService.unified_candle_fetcher') + def test_anomaly_threshold_boundary(self, mock_candle_fetcher, mock_get_candles, mock_market_close): + self._setup_polygon_mocks(mock_candle_fetcher, mock_get_candles, mock_market_close) + """Test anomaly detection at exact boundary conditions""" + # Create exactly 40 miners (to test 10 miner / 25% boundary) + miner_set = [f"miner_{i}" for i in range(40)] + self.mock_metagraph = EnhancedMockMetagraph(miner_set) + + self.elimination_manager = EliminationManager( + self.mock_metagraph, + self.position_manager, + self.challengeperiod_manager, + running_unit_tests=True, + ipc_manager=self.mock_ipc_manager, + contract_manager=self.contract_manager + ) + + self.elimination_manager.process_eliminations(self.position_locks) + + # Remove exactly 10 miners = 25% (boundary case: should NOT trigger anomaly, needs >10) + miners_to_remove = miner_set[:10] + for miner in miners_to_remove: + self.mock_metagraph.remove_hotkey(miner) + + self.elimination_manager.process_eliminations(self.position_locks) + + # At boundary (exactly 10 miners AND 25%), should NOT trigger anomaly (needs > 10) + # So departed hotkeys should be tracked + self.assertEqual(len(self.elimination_manager.departed_hotkeys), 10) + + @patch('data_generator.polygon_data_service.PolygonDataService.get_event_before_market_close') + @patch('data_generator.polygon_data_service.PolygonDataService.get_candles_for_trade_pair') + @patch('data_generator.polygon_data_service.PolygonDataService.unified_candle_fetcher') + def test_below_anomaly_threshold_boundary(self, mock_candle_fetcher, mock_get_candles, mock_market_close): + self._setup_polygon_mocks(mock_candle_fetcher, mock_get_candles, mock_market_close) + """Test tracking just below anomaly threshold""" + # Create 41 miners + miner_set = [f"miner_{i}" for i in range(41)] + self.mock_metagraph = EnhancedMockMetagraph(miner_set) + + self.elimination_manager = EliminationManager( + self.mock_metagraph, + self.position_manager, + self.challengeperiod_manager, + running_unit_tests=True, + ipc_manager=self.mock_ipc_manager, + contract_manager=self.contract_manager + ) + + self.elimination_manager.process_eliminations(self.position_locks) + + # Remove 10 miners = 24.4% (just below 25% threshold, should NOT trigger anomaly) + miners_to_remove = miner_set[:10] + for miner in miners_to_remove: + self.mock_metagraph.remove_hotkey(miner) + + self.elimination_manager.process_eliminations(self.position_locks) + + # Just below threshold, should track + self.assertEqual(len(self.elimination_manager.departed_hotkeys), 10) diff --git a/vali_objects/utils/elimination_manager.py b/vali_objects/utils/elimination_manager.py index ad4a13e30..e46388b20 100644 --- a/vali_objects/utils/elimination_manager.py +++ b/vali_objects/utils/elimination_manager.py @@ -11,6 +11,7 @@ from vali_objects.utils.vali_utils import ValiUtils from vali_objects.vali_config import ValiConfig, TradePair from shared_objects.cache_controller import CacheController +from shared_objects.metagraph_utils import is_anomalous_hotkey_loss from vali_objects.utils.vali_bkp_utils import ValiBkpUtils import bittensor as bt @@ -25,6 +26,9 @@ class EliminationReason(Enum): FAILED_CHALLENGE_PERIOD_DRAWDOWN = "FAILED_CHALLENGE_PERIOD_DRAWDOWN" LIQUIDATED = "LIQUIDATED" +# Constants for departed hotkeys tracking +DEPARTED_HOTKEYS_KEY = "departed_hotkeys" + class EliminationManager(CacheController): """" We basically want to zero out the weights of the eliminated miners @@ -49,8 +53,10 @@ def __init__(self, metagraph, position_manager, challengeperiod_manager, if ipc_manager: self.eliminations = ipc_manager.list() + self.departed_hotkeys = ipc_manager.dict() else: self.eliminations = [] + self.departed_hotkeys = {} self.eliminations.extend(self.get_eliminations_from_disk()) if len(self.eliminations) == 0: ValiBkpUtils.write_file( @@ -58,6 +64,14 @@ def __init__(self, metagraph, position_manager, challengeperiod_manager, {CacheController.ELIMINATIONS: []} ) + # Initialize departed hotkeys tracking + self.departed_hotkeys.update(self._get_departed_hotkeys_from_disk()) + if len(self.departed_hotkeys) == 0: + self._save_departed_hotkeys() + + # Track previous metagraph hotkeys to detect changes + self.previous_metagraph_hotkeys = set(self.metagraph.hotkeys) if self.metagraph.hotkeys else set() + def handle_perf_ledger_eliminations(self, position_locks): perf_ledger_eliminations = self.position_manager.perf_ledger_manager.get_perf_ledger_eliminations() n_eliminations = 0 @@ -169,6 +183,8 @@ def process_eliminations(self, position_locks): bt.logging.info(f"running elimination manager. invalidation data {dict(self.position_manager.perf_ledger_manager.perf_ledger_hks_to_invalidate)}") + # Update departed hotkeys tracking first to detect re-registrations + self._update_departed_hotkeys() self.handle_first_refresh(position_locks) self.handle_perf_ledger_eliminations(position_locks) self.handle_challenge_period_eliminations(position_locks) @@ -272,9 +288,15 @@ def get_eliminations_from_memory(self): def get_eliminations_from_disk(self) -> list: location = ValiBkpUtils.get_eliminations_dir(running_unit_tests=self.running_unit_tests) - cached_eliminations = ValiUtils.get_vali_json_file(location, CacheController.ELIMINATIONS) - bt.logging.trace(f"Loaded [{len(cached_eliminations)}] eliminations from disk. Dir: {location}") - return cached_eliminations + try: + cached_eliminations = ValiUtils.get_vali_json_file(location, CacheController.ELIMINATIONS) + if cached_eliminations is None: + cached_eliminations = [] + bt.logging.trace(f"Loaded [{len(cached_eliminations)}] eliminations from disk. Dir: {location}") + return cached_eliminations + except Exception as e: + bt.logging.warning(f"Could not load eliminations from disk: {e}. Starting with empty list.") + return [] def append_elimination_row(self, hotkey, current_dd, reason, t_ms=None, price_info=None, return_info=None): elimination_row = self.generate_elimination_row(hotkey, current_dd, reason, t_ms=t_ms, @@ -337,3 +359,137 @@ def handle_zombies(self, position_locks): elif self.is_zombie_hotkey(hotkey, all_hotkeys_set): self.append_elimination_row(hotkey=hotkey, current_dd=None, reason=EliminationReason.ZOMBIE.value) self.handle_eliminated_miner(hotkey, {}, position_locks) + + def _update_departed_hotkeys(self): + """ + Track hotkeys that have departed from the metagraph (de-registered). + Ignores anomalous changes that might indicate network issues. + Should be called during process_eliminations to keep departed hotkeys up to date. + """ + if self.is_backtesting: + return + + current_hotkeys = set(self.metagraph.hotkeys) if self.metagraph.hotkeys else set() + lost_hotkeys = self.previous_metagraph_hotkeys - current_hotkeys + gained_hotkeys = current_hotkeys - self.previous_metagraph_hotkeys + + # Log changes + if lost_hotkeys: + bt.logging.debug(f"Metagraph lost hotkeys: {lost_hotkeys}") + if gained_hotkeys: + bt.logging.debug(f"Metagraph gained hotkeys: {gained_hotkeys}") + + # Check for re-registered hotkeys + departed_hotkeys_set = set(self.departed_hotkeys.keys()) + re_registered_hotkeys = gained_hotkeys & departed_hotkeys_set + if re_registered_hotkeys: + bt.logging.warning( + f"Detected {len(re_registered_hotkeys)} re-registered miners: {re_registered_hotkeys}. " + f"These hotkeys were previously de-registered and have re-registered. " + f"Their orders will be rejected." + ) + + # Only track legitimate departures (not anomalous drops) + is_anomalous, _ = is_anomalous_hotkey_loss(lost_hotkeys, len(self.previous_metagraph_hotkeys)) + if lost_hotkeys and not is_anomalous: + # Add lost hotkeys to departed tracking + new_departures = lost_hotkeys - departed_hotkeys_set + if new_departures: + current_time_ms = TimeUtil.now_in_millis() + for hotkey in new_departures: + self.departed_hotkeys[hotkey] = { + "detected_ms": current_time_ms + } + self._save_departed_hotkeys() + bt.logging.info( + f"Tracked {len(new_departures)} newly departed hotkeys: {new_departures}. " + f"Total departed hotkeys: {len(self.departed_hotkeys)}" + ) + elif lost_hotkeys: + bt.logging.warning( + f"Detected anomalous metagraph change: {len(lost_hotkeys)} hotkeys lost " + f"({100 * len(lost_hotkeys) / len(self.previous_metagraph_hotkeys):.1f}% of total). " + f"Not tracking as departed to avoid false positives." + ) + + # Update previous hotkeys for next iteration + self.previous_metagraph_hotkeys = current_hotkeys + + def is_hotkey_re_registered(self, hotkey: str) -> bool: + """ + Check if a hotkey is re-registered (was previously de-registered and has re-registered). + + Args: + hotkey: The hotkey to check + + Returns: + True if the hotkey is in the metagraph AND in the departed_hotkeys dict, False otherwise + """ + if not hotkey: + return False + + current_hotkeys = set(self.metagraph.hotkeys) if self.metagraph.hotkeys else set() + + # Re-registered if currently in metagraph AND previously departed (O(1) dict lookup) + return hotkey in current_hotkeys and hotkey in self.departed_hotkeys + + def _get_departed_hotkeys_from_disk(self) -> dict: + """Load departed hotkeys from disk. + + Tries to load from validation/departed_hotkeys.json (runtime file). + If not found, falls back to data/default_departed_hotkeys.json (committed default). + + Returns: + Dict mapping hotkey -> metadata dict with key: detected_ms + """ + location = ValiBkpUtils.get_departed_hotkeys_dir(running_unit_tests=self.running_unit_tests) + try: + departed_data = ValiUtils.get_vali_json_file(location, DEPARTED_HOTKEYS_KEY) + if departed_data is None: + departed_data = {} + # Handle legacy list format for backwards compatibility + if isinstance(departed_data, list): + bt.logging.info(f"Converting legacy departed hotkeys list to dict format") + departed_data = {hotkey: {"detected_ms": 0} for hotkey in departed_data} + bt.logging.trace(f"Loaded {len(departed_data)} departed hotkeys from disk. Dir: {location}") + return departed_data + except Exception as e: + bt.logging.warning(f"Could not load departed hotkeys from disk: {e}. Trying default file...") + # Fall back to default file committed to repo + return self._get_departed_hotkeys_from_default_file() + + def _get_departed_hotkeys_from_default_file(self) -> dict: + """Load departed hotkeys from the default file committed to the repository. + + This file (data/default_departed_hotkeys.json) contains all historically departed + hotkeys and serves as a fallback when the runtime file doesn't exist. + + Returns: + Dict mapping hotkey -> metadata dict with key: detected_ms + """ + import os + base_dir = ValiBkpUtils.get_vali_dir(running_unit_tests=self.running_unit_tests).replace('/validation/', '') + default_location = os.path.join(base_dir, 'data', 'default_departed_hotkeys.json') + + try: + departed_data = ValiUtils.get_vali_json_file(default_location, DEPARTED_HOTKEYS_KEY) + if departed_data is None: + departed_data = {} + # Handle legacy list format for backwards compatibility + if isinstance(departed_data, list): + bt.logging.info(f"Converting legacy default departed hotkeys list to dict format") + departed_data = {hotkey: {"detected_ms": 0} for hotkey in departed_data} + bt.logging.info(f"Loaded {len(departed_data)} departed hotkeys from default file: {default_location}") + return departed_data + except Exception as e: + bt.logging.warning(f"Could not load departed hotkeys from default file: {e}. Starting with empty dict.") + return {} + + def _save_departed_hotkeys(self): + """Save departed hotkeys to disk.""" + if not self.is_backtesting: + departed_dict = dict(self.departed_hotkeys) # Convert proxy dict to regular dict + departed_data = {DEPARTED_HOTKEYS_KEY: departed_dict} + bt.logging.trace(f"Writing {len(departed_dict)} departed hotkeys to disk") + output_location = ValiBkpUtils.get_departed_hotkeys_dir(running_unit_tests=self.running_unit_tests) + ValiBkpUtils.write_file(output_location, departed_data) diff --git a/vali_objects/utils/vali_bkp_utils.py b/vali_objects/utils/vali_bkp_utils.py index 6b58a6f67..2f98d13f8 100644 --- a/vali_objects/utils/vali_bkp_utils.py +++ b/vali_objects/utils/vali_bkp_utils.py @@ -84,6 +84,11 @@ def get_eliminations_dir(running_unit_tests=False) -> str: suffix = "/tests" if running_unit_tests else "" return ValiConfig.BASE_DIR + f"{suffix}/validation/eliminations.json" + @staticmethod + def get_departed_hotkeys_dir(running_unit_tests=False) -> str: + suffix = "/tests" if running_unit_tests else "" + return ValiConfig.BASE_DIR + f"{suffix}/validation/departed_hotkeys.json" + @staticmethod def get_perf_ledger_eliminations_dir(running_unit_tests=False) -> str: suffix = "/tests" if running_unit_tests else ""