TRENDING
A vintage glass syringe with a metal plunger and orange measurement marks lying on a plain white background
November 9, 2026
How to Stop Command Injection in Python subprocess Calls, Including Git Option Injection and the Windows .cmd Trap
A green valet key and a black ignition key with a remote fob on a white background, the brand emblems blurred
November 9, 2026
Cloudflare’s Trusted Agent Delegation Essay Names 14 Site Problems, but the Identity Standard It Prefers Defines No Delegation, and Its First Shipped Fix Sees Agents Only at Login and Sign-Up
Sepia 1900 postcard photograph of two riders on horses galloping neck and neck at the front of a field on a Hannover racecourse, with the rest of the pack behind them
November 9, 2026
CloudLinux Says an Exploit Hit a CloudLinux 8 Server on November 4 Through Linux Kernel Bug CVE-2026-98276, Fixed Upstream Since September, and Red Hat Still Lists Every Supported RHEL Kernel as Affected
Two yellow commuter trains stand side by side on separate tracks next to a platform in a railway yard, with more tracks running in parallel on both sides and trees in the background
November 9, 2026
How to Run Parallel AI Coding Agents With git worktree and Check Their Branches for Conflicts Before You Merge
Car speedometer dial with a red needle pointing at about 60 miles per hour, or about 100 kilometers per hour on the inner scale
November 9, 2026
METR’s Anthropic Report Turns AI-Led Acceleration Into a 1.9x Slope Ratio That Reproduces From Its Charts, but the Ratio Runs From 1.1x to 2.9x With the Fit Function, and a Second METR Report Says Anthropic’s Public Risk Report Understated the Pace
09 Nov 2026
SXZ.io SXZ.io
  • Home
Search the Site
Popular Searches:
Technology Amazon AI
Recent Posts
Tall orange and white container cranes rising against a clear blue sky at a container terminal
How to Fix Python ModuleNotFoundError and Relative Import Errors, Break Circular Imports, and Stop Local Files From Shadowing the Standard Library
November 8, 2026
The Alcatraz lighthouse at night with beams of light sweeping across the sky above the ruined warden’s house
WIRED’s “Free Chinese AI Models Are Getting Scary Good at Hacking” Rests on Two Cyber Ranges: DeepSeek Is Level With the Leader on One, the GLM It Names Finished Last of Seven, and Three Claude Runs Stopped by Refusals Appear Only in the Chart Data
November 8, 2026
Stepped concrete headquarters building of Crédit Mutuel Arkéa at Le Relecq-Kerhuon in Brittany, seen across a lawn with bare trees and flowering shrubs
Crédit Mutuel Arkéa Says Attackers Reached Its Own Account Through a Service Provider’s Platform; the On-Chain Trail Attributed to the Theft Shows $126 Million in USDC Reaching 17 Wallets in 28 Hours, Most of It Moving On Within Minutes
November 8, 2026
SXZ.io SXZ.io
  • Home

Categories

Articles 290 Posts
News 294 Posts
Learning Hub 263 Posts
Home/Articles/Trail of Bits’ Binance Checklist Turns a Signing-Service Breach Into Four Tests, and the Chain Supplies the Stopwatches: 204 Seconds to the First Sweep, One of 50 Signatures Applied, 3 Hours 49 Minutes to Clear
Articles

Trail of Bits’ Binance Checklist Turns a Signing-Service Breach Into Four Tests, and the Chain Supplies the Stopwatches: 204 Seconds to the First Sweep, One of 50 Signatures Applied, 3 Hours 49 Minutes to Clear

We tested Trail of Bits’ four-step wallet checklist against Binance’s own report and the Ethereum chain: its premises hold, the chain adds a clock (204 seconds to the first sweep, 3 hours 49 minutes...

November 7, 2026 29 Min Read
27

Trail of Bits published a short checklist on November 6 under the title “After Binance, check what your signing systems can approve” (by Tarun Bansal, Jaime Iglesias and Anish Naik). It rests on one question: “Can your security controls recognize when a valid signature grants an attacker wallet authority?” Its four steps are to reduce access to signing infrastructure, treat delegation as an administrative privilege, limit damage even if signing is compromised, and detect changes in wallet authority. Each step ends with a “Verify the result” test.

Table Of Content

  • What we found
  • Where each premise stands
  • Step 2 on the real transaction: 50 signatures, one applied
  • An authorization carries no account
  • Replaying the install transaction
  • Step 4 on the real block: four monitors, four answers
  • Step 2 as code: what a signing policy has to see
  • Step 3 and the stopwatch
  • 204 seconds, then four more sweeps
  • The key kept signing
  • The last sweep was already pending
  • The leftover signatures were dead within 204 seconds
  • What the checklist leaves out, and what we would add
  • A revocation drill
  • A nonce check after every revocation
  • A burst alert at the signing service
  • A signing log that can answer which nonces were signed
  • Ask what keeps feeding a suspect wallet
  • What we could not check
  • Run it yourself
  • Sources read
  • Appendix: eth7702.py, the library every script imports

We did not take the post’s account of the incident on trust. Binance published its own “Initial Findings and Response Update” on November 3, and the Ethereum side of the attack is public. So we checked the checklist’s factual premises against Binance’s report and against the chain, wrote a small standard-library Python library that replays the EIP-7702 rules, and ran four tools on the real transactions: an inspector, a comparison of four monitors, a signing-policy sketch with tests, and a block-by-block stopwatch. Our earlier report covers the theft and the FBI’s attribution; this piece is about what the checklist asks defenders to test.

What we found

  • The premises hold. We checked eight factual statements in the post against Binance’s report, its two earlier updates and the chain. None was contradicted. The table in the next section lists them.
  • The chain gives every test a stopwatch. The first sweep came 204 seconds after the delegation landed, four more followed, and the delegation was cleared 3 hours 49 minutes 36 seconds after it was installed.
  • One transaction, 50 signatures, one applied. All 50 authorizations recover to Binance’s wallet. Exactly one was applied; 34 were stale and 15 were for nonces the wallet had not reached yet. A monitor that counts tuples, watches the wallet’s address or filters on transaction type gets a different answer from one that recovers the signer or reads the wallet’s code.
  • A policy written as code refuses all 50. The sketch below rejects 49 on its nonce rule and the 50th because custody accounts are never delegated.
  • The checklist’s tests include no revocation drill. Binance says the wallet’s use of multi-party computation (MPC) left “no single private key” that could revoke the delegation at once and that a “specialized procedure” “required additional time.” We would add that drill, a nonce check after every revocation, and a burst alert on authorization requests.

What we did not do: we ran no exploit code, did not decompile the attacker’s contract, and cannot see Binance’s signing service, so nothing here says how its policy was configured. Step 1, an inventory of everything that can ask for a signature, is an internal exercise that nothing public bears on, so we left it out. Binance’s pages answer scripts with a bot challenge, so its report and updates were read from Internet Archive captures and its X posts through a public mirror. All times are UTC.

Where each premise stands

The post’s account of the incident is compact enough to test sentence by sentence. The first column gives the post’s words, the second what we found.

What the post says What the record shows Verdict
“a compromised server with permission to request signatures can misuse a protected key without extracting it.” Binance’s report: “The attacker exploited unauthorized access to an internal wallet signing service to initiate transactions outside the normal withdrawal process.” Its second update said it had “found no evidence that wallet private keys were leaked.” On chain, all 50 authorizations carry signatures that recover to the wallet’s own address. Holds
“EIP-7702 places delegation authorizations outside ordinary calldata.” The install transaction has empty calldata (0x). Its 50 authorizations sit in the authorization list of a type-4 transaction. Holds
Ethereum keeps the instruction “in place until it is changed or cleared using the wallet’s key.” Installed in block 26,300,753 and cleared in block 26,301,896 by a type-4 transaction the wallet sent to itself, with one tuple pointing at the zero address. Binance: it “executed a new authorization from the Binance14 hot wallet to the zero address.” Holds
The delegation “enabled repeated transfers, including funds deposited after the first theft, without further signatures from Binance.” Five sweeps, the first 3 minutes 24 seconds and the last 3 hours 35 minutes 48 seconds after the install; the four after the first took about 5,160 ETH between them. The last, 2,961.9 ETH at 23:56:47, matches Binance’s “final unauthorized transfer of 2,961.9 ETH.” Holds
“Other Binance wallets were drained through ordinary signed transfers.” Binance’s report has XRP, SOL, OP and BTC leaving hot wallets through transactions initiated at the signing service; EIP-7702 appears in it only for the Ethereum step. Our earlier report matched TRM’s 28,006 ETH ordinary transfer to a second Ethereum hot wallet. Holds
“Even after delegation, the original EOA key can still transfer funds or change the delegate.” EIP-7702 modifies EIP-3607 “to allow EOAs whose code is a valid delegation indicator” to send transactions. The wallet sent 93 transactions of its own while delegated (65 calls with the ERC-20 transfer selector, 26 calls to one contract and 2 plain ETH sends), then signed the clearing. Holds
Approve “the authorizing account, delegate address and code, chain, and nonce” separately. The signed message is keccak(MAGIC || rlp([chain_id, address, nonce])). Chain, delegate address and nonce are in it. The account is recovered from the signature. The delegate’s code is not in it at all, so code has to be pinned by policy. Holds, with a note
Binance’s first update pointed at a compromised DevOps laptop and a service container; its second reported additional breaches and no evidence of a private-key leak. October 30 update: the incident “may involve a compromised DevOps engineer’s laptop, which allowed an attacker to gain access to a service container and submit unauthorized withdrawal transactions.” Update #2: “our subsequent investigation has uncovered additional points of breach,” and “we have found no evidence that wallet private keys were leaked.” Holds

One sentence in Binance’s report deserves a closer look than the table allows. It says “The attackers submitted 50 signing transactions to manipulate the account nonce and establish malicious delegation authorizations.” What is on chain is one transaction carrying 50 signed authorizations, which fits that wording if each authorization was one signing request, something the chain cannot show. And as far as we can see the batch did not manipulate the nonce at all. It covered a range of nonces and one of them happened to match, as the next section shows.

Step 2 on the real transaction: 50 signatures, one applied

The post’s second step asks a signing service to “recognize requests that change account authority.” To see what such a request looks like in bytes, we rebuilt the rules from the specification and replayed the real install transaction.

An authorization carries no account

An EIP-7702 authorization is a tuple of chain ID, delegate address, nonce and a signature. There is no field for the account being delegated. The specification calls that account the authority, and it is whoever’s key signed keccak(MAGIC || rlp([chain_id, address, nonce])), where MAGIC is the single byte 0x05. The client recovers the authority from the signature and then applies the rules in order: “Verify the chain ID is 0 or the ID of the current chain,” the authority’s code must be empty or already a delegation, and “Verify the nonce of authority is equal to nonce.” A tuple that fails any check is skipped: “If any step above fails, immediately stop processing the tuple and continue to the next tuple in the list.” The sender pays for all of them: “The transaction sender will pay for all authorization tuples, regardless of validity or duplication.”

Two things follow for anyone building a monitor. A skipped tuple does not make the transaction fail, so a successful transaction does not mean its tuples took effect. And the only way to learn which account a tuple targets is to recover the signer. The gateway we used does that for you in an extra authority field on each tuple, which is not part of the signed data.

The library that does the recovery and replays the rules is at the end of this article as eth7702.py. It is standard-library Python: Keccak-256 (checked against the empty-code hash printed in the EIP), RLP for the three-field message, secp256k1 signer recovery, the rules above as apply_tuples, and a JSON-RPC client that backs off when the public gateway answers 429. Every script imports it.

Replaying the install transaction

The script below fetches the install transaction, recovers all 50 signers, works out the wallet’s nonce at the moment the transaction ran, applies the rules, and compares the prediction with the chain. It then does the same for the transaction that cleared the delegation, and finally looks for the first block in which the wallet’s nonce had reached the lowest value in the attacker’s batch.

# inspect_tx.py : replay the EIP-7702 rules on the real install transaction and compare the result with the chain.
from collections import Counter

from eth7702 import apply_tuples, code_at, nonce_at, rpc, tuple_signer

INSTALL = "0xad99264d5532ae0dd04b2004eebda05f25657e70dacb764f7ac8d4ccd051298b"
WALLET = "0x28c6c06298d514db089934071355e5743bf21d60"   # Elliptic's "Binance 14"

tx = rpc("eth_getTransactionByHash", [INSTALL])
block_no, index = int(tx["blockNumber"], 16), int(tx["transactionIndex"], 16)
tuples = tx["authorizationList"]
print(f"type {int(tx['type'], 16)} | block {block_no} index {index} | calldata {tx['input']!r} | sent to the wallet itself: {tx['to'] == WALLET}")

# 1. who signed? A tuple carries no account field; the signer is recovered from (r, s, yParity).
signers = [tuple_signer(t) for t in tuples]
print(f"tuples: {len(tuples)} | distinct signers recovered: {sorted(set(signers))}")
print("the node's own 'authority' field agrees with our recovery for", sum(s == t.get("authority") for s, t in zip(signers, tuples)), "of", len(tuples))

# 2. what was the wallet's nonce when this transaction ran? End of the previous block, plus the wallet's own
#    transactions that came earlier in the same block.
block = rpc("eth_getBlockByNumber", [hex(block_no), True])
earlier = [t for t in block["transactions"] if t["from"].lower() == WALLET and int(t["transactionIndex"], 16) < index]
nonce_then = nonce_at(WALLET, block_no - 1) + len(earlier)
print(f"wallet nonce when the transaction ran: {nonce_then} (end of previous block {nonce_at(WALLET, block_no - 1)} + {len(earlier)} of its own transactions earlier in the block)")

# 3. apply the rules tuple by tuple
state = {WALLET: {"nonce": nonce_then, "delegate": None}}
verdicts = apply_tuples(tuples, state)
print("verdicts:", dict(Counter(v for _, _, v in verdicts)))
applied = next(i for i, _, v in verdicts if v == "APPLIED")
nonces = [int(t["nonce"], 16) for t in tuples]
print(f"list position {applied} was applied (nonce {nonces[applied]}); nonces run {nonces[0]} down to {nonces[-1]}")
print("first 'future' nonce:", min(n for (i, _, v), n in zip(verdicts, nonces) if v == "skip: future"), "| last 'stale' nonce:", max(n for (i, _, v), n in zip(verdicts, nonces) if v == "skip: stale"))

# 4. does the model agree with the chain?
print("model after the transaction :", state[WALLET])
print("chain after the block       : nonce", nonce_at(WALLET, block_no), "| code", code_at(WALLET, block_no))
print("chain before the block      : nonce", nonce_at(WALLET, block_no - 1), "| code", code_at(WALLET, block_no - 1))

# 5. the clearing transaction: the wallet sends a type-4 transaction to itself with one tuple that points at the zero address
CLEAR = "0xafbf94c92a8d749988411669fa14d4f8866d9b4420b4ebd299f28e958dac7209"
c = rpc("eth_getTransactionByHash", [CLEAR])
cb, ct = int(c["blockNumber"], 16), c["authorizationList"]
print(f"\nclearing: block {cb} | sender == recipient == wallet: {c['from'] == c['to'] == WALLET} | tuples {len(ct)} | delegate {ct[0]['address']}")
print(f"transaction nonce {int(c['nonce'], 16)} | tuple nonce {int(ct[0]['nonce'], 16)} (the sender's nonce is incremented before the list is processed)")
state = {WALLET: {"nonce": int(c["nonce"], 16) + 1, "delegate": "0x63e842b054bd624a465454d533c33f573e9c6fae"}}
print("model:", apply_tuples(ct, state)[0][2], "->", state[WALLET], "| chain after:", nonce_at(WALLET, cb), code_at(WALLET, cb))

# 6. a clue to when the signatures were requested: the first block in which the wallet's nonce had reached the lowest
#    nonce in the batch (nobody asks for a signature on a nonce the account has not reached yet, if they want it to apply soon)
lowest = min(nonces)
lo, hi = block_no - 120, block_no
while lo < hi:
    mid = (lo + hi) // 2
    lo, hi = (lo, mid) if nonce_at(WALLET, mid) >= lowest else (mid + 1, hi)
stamp = lambda b: int(rpc("eth_getBlockByNumber", [hex(b), False])["timestamp"], 16)
print(f"\nthe wallet's nonce first reached {lowest} (the lowest in the batch) in block {lo}, {stamp(block_no) - stamp(lo)} seconds before the install")
type 4 | block 26300753 index 138 | calldata '0x' | sent to the wallet itself: True
tuples: 50 | distinct signers recovered: ['0x28c6c06298d514db089934071355e5743bf21d60']
the node's own 'authority' field agrees with our recovery for 50 of 50
wallet nonce when the transaction ran: 18914809 (end of previous block 18914808 + 1 of its own transactions earlier in the block)
verdicts: {'skip: future': 15, 'APPLIED': 1, 'skip: stale': 34}
list position 15 was applied (nonce 18914809); nonces run 18914824 down to 18914775
first 'future' nonce: 18914810 | last 'stale' nonce: 18914808
model after the transaction : {'nonce': 18914810, 'delegate': '0x63e842b054bd624a465454d533c33f573e9c6fae'}
chain after the block       : nonce 18914810 | code 0xef010063e842b054bd624a465454d533c33f573e9c6fae
chain before the block      : nonce 18914808 | code 0x

clearing: block 26301896 | sender == recipient == wallet: True | tuples 1 | delegate 0x0000000000000000000000000000000000000000
transaction nonce 18914903 | tuple nonce 18914904 (the sender's nonce is incremented before the list is processed)
model: APPLIED -> {'nonce': 18914905, 'delegate': None} | chain after: 18914905 0x

the wallet's nonce first reached 18914775 (the lowest in the batch) in block 26300737, 192 seconds before the install

Reading the output from the top:

  • The calldata is empty and the wallet is the recipient. A monitor that decodes calldata sees nothing here. The attacker did send the transaction to the wallet itself, which turns out to matter in the monitors section.
  • All 50 tuples recover to one address, the wallet, and the node’s own authority field agrees with our recovery for 50 of 50. So the signatures are genuine outputs of whatever system holds that wallet’s signing capability. The chain cannot say which system or which API.
  • The nonce when the transaction ran was 18,914,809. The previous block ended at 18,914,808, and the wallet itself sent a transaction earlier in the same block (index 133, five slots before the install at index 138). That one ordinary transaction decided which tuple counted.
  • Fifteen tuples were for the future, one applied and 34 were stale. The list runs from 18,914,824 down to 18,914,775, and the applied tuple was the 16th. Higher nonces come first, so each of those was checked, found ahead of the wallet’s nonce and skipped, until the match at 18,914,809. Every lower nonce after it was stale.
  • The model agrees with the chain. Both end at nonce 18,914,810 with the code 0xef0100 followed by the delegate’s address.
  • The clearing confirms the self-send rule. The wallet’s transaction had nonce 18,914,903, and its single tuple, which points at the zero address, carried 18,914,904, because the sender’s nonce is incremented before the list is processed. The model applies it, and the chain shows nonce 18,914,905 and empty code.
  • The nonce reached the bottom of the batch 192 seconds before the install. That is a clue to when the signatures were requested, and we come back to it in the recommendations.

The shape of the batch matters. The applied nonce sat 34 above the lowest value and 15 below the highest, and the wallet was sending roughly 11 transactions a minute (we measured that in our earlier report), so a narrow window would probably have missed. TRM’s description is that “by preparing signatures for 50 consecutive counts, the attacker guaranteed that one would still be valid when the transaction executed.” The width did the work. This is also exactly the case the post’s step 4 means by “mixed authorization tuples”: 49 of 50 tuples are skipped by design and the transaction still succeeds.

Step 4 on the real block: four monitors, four answers

Step 4 says monitoring should “reconstruct authority changes and asset movements in order,” and the post’s opening warns that a monitor watching only transfers and ownership changes “may miss the shift in wallet authority until assets move.” We wrote four ways of watching the one wallet and ran them over the 41 blocks around the delegation, with a negative control at the end that sends the same transaction to an unrelated address.

# monitors.py : four ways to watch one custody address, run over the 41 blocks around the delegation.
import copy

from eth7702 import code_at, rpc, tuple_signer

WALLET = "0x28c6c06298d514db089934071355e5743bf21d60"
INSTALL_BLOCK = 26_300_753


def by_address(tx):      # 1. the wallet is the sender, the recipient, or appears in the calldata
    return WALLET in (tx["from"].lower(), (tx.get("to") or "").lower()) or WALLET[2:] in tx["input"].lower()


def by_type(tx):         # 2. any set-code (type 4) transaction
    return tx["type"] == "0x4"


def by_authority(tx):    # 3. a tuple whose recovered signer is the wallet
    return any(tuple_signer(t).lower() == WALLET for t in tx.get("authorizationList") or [])


txs, hits = [], {1: [], 2: [], 3: []}
for b in range(INSTALL_BLOCK - 20, INSTALL_BLOCK + 21):
    for tx in rpc("eth_getBlockByNumber", [hex(b), True])["transactions"]:
        txs.append(tx)
        for n, monitor in ((1, by_address), (2, by_type), (3, by_authority)):
            if tx["type"] != "0x4" and monitor is by_authority:
                continue                      # only set-code transactions carry tuples
            if monitor(tx):
                hits[n].append((int(tx["blockNumber"], 16), int(tx["transactionIndex"], 16)))

type4 = [t for t in txs if t["type"] == "0x4"]
print(f"blocks {INSTALL_BLOCK - 20}..{INSTALL_BLOCK + 20}: {len(txs)} transactions, {len(type4)} set-code transactions, "
      f"{sum(len(t['authorizationList']) for t in type4)} tuples")
print(f"1. wallet as sender/recipient/calldata : {len(hits[1])} hits (it is a busy wallet), install transaction among them: {(INSTALL_BLOCK, 138) in hits[1]}")
print(f"2. any set-code transaction            : {len(hits[2])} hits, only {sum(h[0] == INSTALL_BLOCK and h[1] == 138 for h in hits[2])} aimed at the wallet")
print(f"3. recovered signer is the wallet      : {len(hits[3])} hit(s) -> {hits[3]}")

first = next(b for b in range(INSTALL_BLOCK - 5, INSTALL_BLOCK + 5) if code_at(WALLET, b).startswith("0xef0100"))
print(f"4. eth_getCode poll                    : first delegated block {first} (previous block's code: {code_at(WALLET, first - 1)!r})")

# negative control: the same transaction, but sent to an unrelated address
install = next(t for t in type4 if t["hash"] == "0xad99264d5532ae0dd04b2004eebda05f25657e70dacb764f7ac8d4ccd051298b")
moved = copy.deepcopy(install)
moved["to"] = "0x000000000000000000000000000000000000dead"
print("what if the attacker had not sent it to the wallet?  monitor 1:", by_address(moved), "| monitor 2:", by_type(moved), "| monitor 3:", by_authority(moved))
blocks 26300733..26300773: 10064 transactions, 179 set-code transactions, 448 tuples
1. wallet as sender/recipient/calldata : 207 hits (it is a busy wallet), install transaction among them: True
2. any set-code transaction            : 179 hits, only 1 aimed at the wallet
3. recovered signer is the wallet      : 1 hit(s) -> [(26300753, 138)]
4. eth_getCode poll                    : first delegated block 26300753 (previous block's code: '0x')
what if the attacker had not sent it to the wallet?  monitor 1: False | monitor 2: True | monitor 3: True
Monitor Hits in 41 blocks Catches the install After changing the recipient
1. Wallet as sender, recipient or in calldata 207 Yes, but only because the attacker chose the wallet as recipient Misses
2. Any set-code (type 4) transaction 179 Yes, among 178 others Still flags, among 178 others
3. A tuple’s recovered signer is the wallet 1 Yes Still flags
4. eth_getCode poll First change in block 26,300,753 Yes Unaffected

The 41 blocks hold 10,064 transactions, of which 179 are set-code transactions carrying 448 tuples, about 4.4 transactions per block. Alerting on the transaction type would page someone several times a block. Watching the wallet’s address flags 207 transactions because the wallet is busy, and it catches the install only because the attacker chose to send it to the wallet. In the negative control, where one field changes, the address monitor goes blind, signer recovery still flags the transaction, and the code poll never looks at the transaction at all.

The code poll is the cheapest: one eth_getCode call per watched address per block. It flips in the block that carries the install, which is 17 blocks, or 204 seconds, before the first sweep. A monitor that waits for transfers sees nothing until then. The poll cannot say who delegated or why, and a delegation to a harmless contract looks identical, so it works best as the trigger for the signer-recovery check and a runbook decision, not as a verdict. The 41-block window is a sample around one event, not a census of mainnet.

Step 2 as code: what a signing policy has to see

The post tells teams: “Reject EIP-7702 authorizations for custody externally owned accounts (EOAs) that do not need delegation, and ensure generic digest-signing APIs cannot bypass that restriction.” The second half is the subtle one. An authorization is a signature over 32 bytes. A service that signs any 32-byte digest it is handed cannot tell an authorization from anything else, because a hash hides what it was computed from. So for a custody key the service has to accept only structured requests, encode and hash them itself, and refuse bare digests.

The sketch below does that and adds three rules the checklist does not spell out. Chain ID 0 is refused, because the EIP makes it valid on every chain. The requested nonce must equal the account’s current nonce. And a second authorization for the same account inside a burst window is refused. It also checks the delegate by address and code hash, as the checklist asks. Revocation stays possible through a break-glass path with two approvals, because a policy that cannot sign the clearing transaction would turn the incident into a longer one.

# policy.py : the checks a signing service should make before it signs anything that can become an EIP-7702 authorization.
from dataclasses import dataclass

from eth7702 import ZERO


@dataclass(frozen=True)
class AuthRequest:
    """The only way to ask for an authorization signature: structured fields, never a bare digest."""
    account: str
    chain_id: int
    delegate: str
    nonce: int
    approvals: int = 0
    break_glass: bool = False       # set only by the revocation runbook


@dataclass(frozen=True)
class DigestRequest:
    """A generic 'sign these 32 bytes' call."""
    account: str
    digest: bytes


class Policy:
    def __init__(self, custody, approved, window_s=600):
        self.custody = {a.lower() for a in custody}                     # accounts that must never be delegated
        self.approved = {a.lower(): set(v) for a, v in approved.items()}  # account -> {(delegate, code hash)}
        self.window_s = window_s
        self.last_signed = {}                                           # account -> time of its last authorization signature

    def decide(self, req, *, now, chain_nonce, codehash_of):
        """(allowed, reason). chain_nonce is the account's CURRENT nonce read from a node; for a transaction the
        account sends itself, pass nonce + 1, because the sender's nonce is incremented before the list is processed."""
        acct = req.account.lower()
        if isinstance(req, DigestRequest):
            if acct in self.custody:
                return False, "raw digest for a custody key: an authorization cannot be told from any other 32 bytes"
            return True, "not a custody key"
        if req.chain_id == 0:
            return False, "chain id 0 is valid on every chain"
        if req.nonce != chain_nonce:
            return False, f"nonce {req.nonce} is not the account's current nonce {chain_nonce}"
        if now - self.last_signed.get(acct, -1e18) < self.window_s:
            return False, "a second authorization for this account inside the burst window"
        if req.delegate.lower() == ZERO:                                # revocation has to stay possible
            if not (req.break_glass and req.approvals >= 2):
                return False, "revocation needs the break-glass runbook and two approvals"
        else:
            if acct in self.custody:
                return False, "custody accounts are never delegated"
            if (req.delegate.lower(), codehash_of(req.delegate)) not in self.approved.get(acct, set()):
                return False, "delegate address and code hash not approved for this account"
            if req.approvals < 2:
                return False, "needs two approvals"
        self.last_signed[acct] = now
        return True, "ok"

The tests use the numbers from the real transaction.

# test_policy.py : run with  python -m pytest -q test_policy.py
from collections import Counter

from eth7702 import ZERO, auth_digest
from policy import AuthRequest, DigestRequest, Policy

WALLET = "0x28c6c06298d514db089934071355e5743bf21d60"
DELEGATE = "0x63e842b054bd624a465454d533c33f573e9c6fae"
OTHER = "0x1111111111111111111111111111111111111111"
CODEHASH = lambda address: "0xaaaa"      # stand-in for keccak(eth_getCode(address)) read from a node
NOW_NONCE = 18_914_809                    # the wallet's nonce when the attacker's transaction ran


def policy():
    return Policy(custody=[WALLET], approved={OTHER: {(DELEGATE, "0xaaaa")}})


def ask(p, req, nonce=NOW_NONCE, now=0):
    return p.decide(req, now=now, chain_nonce=nonce, codehash_of=CODEHASH)


def test_the_whole_attacker_batch_is_refused():
    p = policy()
    reasons = Counter()
    for n in range(18_914_775, 18_914_825):          # the 50 nonces on chain, each asked for with two approvals
        ok, why = ask(p, AuthRequest(WALLET, 1, DELEGATE, n, approvals=2))
        assert not ok
        reasons["not the current nonce" if "current nonce" in why else why] += 1
    # 49 die on the nonce rule; the one that matches the current nonce dies because custody accounts are never delegated
    assert reasons == {"not the current nonce": 49, "custody accounts are never delegated": 1}


def test_a_raw_digest_for_a_custody_key_is_refused():
    digest = auth_digest(1, DELEGATE, NOW_NONCE)
    assert not ask(policy(), DigestRequest(WALLET, digest))[0]
    assert ask(policy(), DigestRequest(OTHER, digest))[0]


def test_chain_id_zero_is_refused():
    assert not ask(policy(), AuthRequest(OTHER, 0, DELEGATE, NOW_NONCE, approvals=2))[0]


def test_an_approved_delegate_needs_two_approvals_and_the_right_code_hash():
    p = policy()
    assert not ask(p, AuthRequest(OTHER, 1, DELEGATE, NOW_NONCE, approvals=1))[0]
    assert ask(p, AuthRequest(OTHER, 1, DELEGATE, NOW_NONCE, approvals=2), now=1)[0]
    swapped = Policy(custody=[WALLET], approved={OTHER: {(DELEGATE, "0xbbbb")}})
    assert not ask(swapped, AuthRequest(OTHER, 1, DELEGATE, NOW_NONCE, approvals=2))[0]


def test_a_second_request_inside_the_burst_window_is_refused():
    p = policy()
    assert ask(p, AuthRequest(OTHER, 1, DELEGATE, NOW_NONCE, approvals=2), now=0)[0]
    assert not ask(p, AuthRequest(OTHER, 1, DELEGATE, NOW_NONCE, approvals=2), now=599)[0]
    assert ask(p, AuthRequest(OTHER, 1, DELEGATE, NOW_NONCE, approvals=2), now=601)[0]


def test_revocation_stays_possible_but_only_through_the_runbook():
    p = policy()
    assert not ask(p, AuthRequest(WALLET, 1, ZERO, NOW_NONCE, approvals=2))[0]
    assert not ask(p, AuthRequest(WALLET, 1, ZERO, NOW_NONCE, approvals=1, break_glass=True))[0]
    assert ask(p, AuthRequest(WALLET, 1, ZERO, NOW_NONCE, approvals=2, break_glass=True))[0]


def test_a_self_sent_transaction_signs_nonce_plus_one():
    # the clearing on chain: transaction nonce 18,914,903, tuple nonce 18,914,904
    p = policy()
    ok, _ = ask(p, AuthRequest(WALLET, 1, ZERO, 18_914_904, approvals=2, break_glass=True), nonce=18_914_903 + 1)
    assert ok
    assert not ask(policy(), AuthRequest(WALLET, 1, ZERO, 18_914_903, approvals=2, break_glass=True), nonce=18_914_903 + 1)[0]
....... [100%]
7 passed in 0.02s

The first test replays the attacker’s batch as 50 requests with two approvals each. All 50 are refused: 49 on the nonce rule, and the one that matches the current nonce because custody accounts are never delegated. We also broke the policy six ways, one rule at a time (no chain-ID rule, any nonce accepted, no burst window, raw digests accepted, revocation without the runbook, and an address-only delegate check), with a throwaway script not shown here. At least one of the seven tests failed each time.

The nonce rule is the one we would lean on. The attacker’s batch asks for 50 nonces at once. A service that signs only the account’s current nonce, read from a node it trusts, cannot be asked for a window. The attacker has to ask again for each attempt, and the burst rule turns repeated asking into an alert. That is our reading of how the controls interact, and we could not test it against Binance’s system. The policy is only as good as its inputs: the nonce and the code hash have to come from a source the attacker cannot influence, and MPC platforms differ in what they let a policy see. Binance says it is “reviewing the incident response process, signing policy coverage, hot wallet controls” and more, and we cannot say what its policy covered.

Step 3 and the stopwatch

Step 3’s verification says: “Record alert and containment times, and demonstrate that responders can cancel or contain the withdrawal before execution.” The chain lets us read part of Binance’s clock: when the delegation landed, when each sweep came, and when it was cleared. The script below walks every block from the install to the clearing, reads the wallet’s ETH balance and nonce in each, and prints a sweep whenever the balance falls to exactly 1.0 ETH, which is where it landed every time. It then tallies the wallet’s own transactions in the blocks where its nonce moved.

# window_scan.py : walk every block from the install (26,300,753) to the clearing (26,301,896) and read
# Binance 14's ETH balance and nonce in each one. Standard library only; about 115 JSON-RPC batches, around two minutes.
import json
import os
from collections import Counter
from datetime import UTC, datetime

from eth7702 import rpc, rpc_batch

A = "0x28C6c06298d514Db089934071355E5743bf21d60"
FIRST, LAST = 26_300_753, 26_301_896
OUT = os.path.join(os.path.dirname(os.path.abspath(__file__)), "out")
os.makedirs(OUT, exist_ok=True)


def scan():
    blocks = list(range(FIRST - 1, LAST + 2))
    rows = {}
    for i in range(0, len(blocks), 10):
        chunk = blocks[i:i + 10]
        calls = []
        for b in chunk:
            calls += [("eth_getBalance", [A, hex(b)]), ("eth_getTransactionCount", [A, hex(b)])]
        res = rpc_batch(calls)
        for k, b in enumerate(chunk):
            rows[b] = {"eth": int(res[2 * k], 16) / 1e18, "nonce": int(res[2 * k + 1], 16)}
    return rows


def clock(ts):
    return datetime.fromtimestamp(ts, UTC).strftime("%H:%M:%S")


def mmss(seconds):
    return f"+{seconds // 3600}:{seconds % 3600 // 60:02d}:{seconds % 60:02d}"


if __name__ == "__main__":
    rows = scan()
    json.dump(rows, open(os.path.join(OUT, "window_rows.json"), "w"))
    stamp = lambda b: int(rpc("eth_getBlockByNumber", [hex(b), False])["timestamp"], 16)
    t0 = stamp(FIRST)
    print(f"blocks scanned: {len(rows)} | delegation installed in block {FIRST} at {clock(t0)} UTC")
    # a sweep = the balance falls to exactly 1.0 ETH from something larger
    drops = [b for b in sorted(rows) if b > FIRST and rows[b]["eth"] == 1.0 and rows[b - 1]["eth"] > 1.0]
    print("sweep  block      UTC        after install  blocks later  ETH swept")
    for n, b in enumerate(drops, 1):
        t = stamp(b)
        print(f"{n:>5}  {b}   {clock(t)}   {mmss(t - t0)}      {b - FIRST:>5}      {rows[b - 1]['eth'] - 1.0:>10,.1f}")
    tc = stamp(LAST)
    print(f"clear  {LAST}   {clock(tc)}   {mmss(tc - t0)}      {LAST - FIRST:>5}")
    print(f"last sweep to clearing: {mmss(tc - stamp(drops[-1]))[1:]} | ETH swept after the first sweep: {sum(rows[b - 1]['eth'] - 1.0 for b in drops[1:]):,.1f}")
    # the nonce window the attacker signed: 18,914,775 .. 18,914,824
    cross = next(b for b in sorted(rows) if rows[b]["nonce"] > 18_914_824)
    print(f"the wallet's nonce first passed the highest signed value (18,914,824) in block {cross}, {cross - FIRST} blocks after the install")
    own = rows[LAST - 1]["nonce"] - rows[FIRST]["nonce"]
    print(f"nonce after the install {rows[FIRST]['nonce']}, just before the clearing transaction {rows[LAST - 1]['nonce']}: {own} transactions of the wallet's own in between")
    # what were they? Fetch only the blocks where the wallet's nonce moved and sort its transactions by call shape.
    moved = [b for b in sorted(rows)[1:] if FIRST < b < LAST and rows[b]["nonce"] > rows[b - 1]["nonce"]]
    kinds, targets = Counter(), Counter()
    for b in moved:
        for tx in rpc("eth_getBlockByNumber", [hex(b), True])["transactions"]:
            if tx["from"].lower() == A.lower():
                kind = "plain ETH send" if tx["input"] == "0x" else "ERC-20 transfer selector" if tx["input"].startswith("0xa9059cbb") else "other call"
                kinds[kind] += 1
                if kind == "other call":
                    targets[tx["to"]] += 1
    print("their shape:", dict(kinds), "| the other calls went to", dict(targets))
blocks scanned: 1146 | delegation installed in block 26300753 at 20:20:59 UTC
sweep  block      UTC        after install  blocks later  ETH swept
    1  26300770   20:24:23   +0:03:24         17       167,464.2
    2  26300968   21:04:23   +0:43:24        215         1,699.9
    3  26301167   21:44:11   +1:23:12        414           369.8
    4  26301444   22:39:35   +2:18:36        691           128.5
    5  26301828   23:56:47   +3:35:48       1075         2,961.9
clear  26301896   00:10:35   +3:49:36       1143
last sweep to clearing: 0:13:48 | ETH swept after the first sweep: 5,160.2
the wallet's nonce first passed the highest signed value (18,914,824) in block 26300770, 17 blocks after the install
nonce after the install 18914810, just before the clearing transaction 18914903: 93 transactions of the wallet's own in between
their shape: {'other call': 26, 'ERC-20 transfer selector': 65, 'plain ETH send': 2} | the other calls went to {'0xee7ae85f2fe2239e27d9c1e23fffe168d63b4055': 26}

204 seconds, then four more sweeps

The first sweep came 17 blocks, or 3 minutes 24 seconds, after the delegation. It took 167,464.2 ETH out of the wallet and, according to our earlier report, 63 different tokens including 1.615 billion USDT. Four more sweeps followed, at +43 minutes, +1 hour 23 minutes, +2 hours 18 minutes and +3 hours 35 minutes, for about 5,160 ETH between them. The wallet had refilled each time. Our scan sees ETH only, and our earlier report noted that TRM lists two further sweeps at 20:41 and 20:59 that may have moved only tokens, so we do not vouch for those.

The earliest post we found from Binance’s own account, at 21:29:09, came 1 hour 8 minutes after the delegation and said only that its teams were “aware of reports of a potential security issue.” The post that named a compromised DevOps laptop came at 02:25:05 on October 30, 2 hours 14 minutes after the clearing. For the post’s step 3 drill those are the numbers to beat: 204 seconds before the first sweep, a little over an hour before Binance’s own account said anything, and nearly four hours before containment.

The key kept signing

While the delegation was live the wallet’s own key stayed in use. Between the install and the clearing its nonce rose from 18,914,810 to 18,914,903, which is 93 transactions of its own (the tally above gives their shape; we did not decode them further, and some may have been Binance’s own response). That is the post’s step 3 premise seen from the other side: a delegation does not take the key’s powers away, so the key could always have cleared it. The delay is not about the key being gone. Binance’s own account of the delay is this: “Because the hot wallet used Multi-Party Computation (MPC), there was no single private key that could be used to immediately revoke the malicious EIP-7702 authorizations. The team therefore developed and executed a specialized procedure to revoke these authorizations, which required additional time.” It adds that “suspending withdrawals and shutting down hot wallet services did not automatically invalidate existing on-chain authorizations.” The chain cannot show how the 3 hours 49 minutes divided between recognizing the delegation, building the procedure and signing.

The last sweep was already pending

Binance’s report says the final transfer “had previously been broadcast and remained pending before subsequently finalising,” and the chain puts it 13 minutes 48 seconds before the clearing. A transaction that is already pending cannot be recalled, so a transfer can land after the decision to revoke has been made. The post’s test says: “Confirm that the blocked withdrawal cannot subsequently execute.” For a delegation, a drill needs the same question asked of calls that are already in flight.

The leftover signatures were dead within 204 seconds

The 15 tuples for nonces ahead of the wallet were not wasted. Each could still be applied at the moment the wallet’s nonce reached its value, by anyone, because the signatures sit in a public transaction. Here the wallet’s own traffic carried its nonce past the highest signed value, 18,914,824, in block 26,300,770, the first sweep’s block, 17 blocks after the install. By the time Binance cleared the delegation the nonce was 18,914,903, 79 above the highest signed value, so nothing in that batch could ever apply again. A quiet wallet would not outrun its batch like that. For such a wallet, clearing the delegation would leave the unused signatures valid until the nonce caught up with them.

What the checklist leaves out, and what we would add

These are our recommendations, each tied to something above. They are not claims about what any exchange does.

A revocation drill

As we read them, the post’s tests cover refusing a delegation and containing a withdrawal. None covers revoking a delegation that is already installed, which is where Binance says the time went. Time the full path to a signed zero-address authorization with the normal MPC quorum, then again with approvers missing, and write the number into the runbook. The self-sent form needs the account’s nonce plus one, which the policy test above pins.

A nonce check after every revocation

Compare the account’s current nonce with the highest nonce your service ever signed an authorization for. If the account is behind, advance it past that value with ordinary transactions before declaring the account clean. Binance’s wallet needed none of this because its own traffic did the work, which would not hold for a quiet wallet.

A burst alert at the signing service

Fifty authorization signatures for one account and one delegate, with consecutive nonces, inside a few minutes is a pattern we would not expect an honest workflow to produce. Our reading of the nonces is that the batch was requested around block 26,300,737, 192 seconds before the install, when the wallet’s nonce first reached the lowest signed value. If so, a burst alert at the signing service would have had about three minutes before any delegation existed. That inference is ours and cannot be confirmed from the chain, which shows the transaction and not when the requests were made.

A signing log that can answer which nonces were signed

The nonce check and the burst alert both need a record of every digest the service signed, kept where the service’s own host cannot rewrite it. We built one for AI agent actions in an earlier tutorial, and the same design, a hash chain with signed checkpoints, fits here.

Ask what keeps feeding a suspect wallet

The wallet’s balance had refilled enough that the second sweep, exactly 40 minutes after the first, took 1,699.9 ETH, and it refilled again before the third, fourth and fifth. We did not trace what fed it. If an automated process consolidates funds into a hot wallet, stopping that inflow is part of containing a wallet you believe is delegated. The checklist’s outflow controls do not cover it.

What we could not check

  • Binance’s signing service. We do not know whether the 50 signatures came through a dedicated authorization call or a generic digest API, or what its policy allowed. Binance says “Some technical details will remain confidential to prevent malicious actors from using them to plan attacks,” and that its findings “will be subject to further independent review.”
  • The attacker’s contract. We did not decompile it, so we cannot say what it does beyond the sweeps we saw.
  • The 93 transactions. We sorted them by call shape. We did not decode the contract calls or tell the wallet’s routine traffic from recovery work.
  • The post’s own tests. The post describes its tests in prose and we found no code or link to any, so the tools here are our reading of them.
  • The census. The 41-block count is a sample around one event, not a measure of mainnet.

Run it yourself

Put the six files in one folder and use Python 3.13. Nothing needs installing except pytest for the policy tests. python inspect_tx.py and python monitors.py take well under a minute, python window_scan.py takes about three minutes, and python -m pytest -q test_policy.py runs offline. The scripts talk to Tenderly’s public gateway, which rate-limits; the helper retries after a 429, and if it still fails you can point the RPC constant at another archive-capable endpoint. Everything they read is historical state, so the numbers do not change between runs.

Sources read

  • Trail of Bits, After Binance, check what your signing systems can approve (November 6, 2026).
  • Binance, Initial Findings and Response Update (November 3, 2026; Internet Archive capture of November 5), and its October 30 update and update #2 (read from captures).
  • EIP-7702: Set Code for EOAs.
  • TRM Labs, North Korea Steals USD 6.36 Billion From Binance in the Largest Hack in History.
  • Our earlier report, The FBI Blames North Korea for the $6.3 Billion Binance Theft, for the block-level scan of the sweeps and the second hot wallet.

Appendix: eth7702.py, the library every script imports

Keccak-256 is checked against the empty-code hash in the EIP, and every signer it recovers from the real transaction equals the wallet’s address, which would be vanishingly unlikely if any step were wrong.

# eth7702.py : standard library only. Keccak-256, RLP, secp256k1 signer recovery, the EIP-7702
# authorization rules, and a small JSON-RPC client. Python 3.13.
import json
import time
import urllib.error
import urllib.request

RPC = "https://mainnet.gateway.tenderly.co"
UA = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36"

# ---------------------------------------------------------------- Keccak-256 (the Ethereum variant, padding 0x01)
_RC = [0x0000000000000001, 0x0000000000008082, 0x800000000000808A, 0x8000000080008000,
       0x000000000000808B, 0x0000000080000001, 0x8000000080008081, 0x8000000000008009,
       0x000000000000008A, 0x0000000000000088, 0x0000000080008009, 0x000000008000000A,
       0x000000008000808B, 0x800000000000008B, 0x8000000000008089, 0x8000000000008003,
       0x8000000000008002, 0x8000000000000080, 0x000000000000800A, 0x800000008000000A,
       0x8000000080008081, 0x8000000000008080, 0x0000000080000001, 0x8000000080008008]
_ROT = [[0, 36, 3, 41, 18], [1, 44, 10, 45, 2], [62, 6, 43, 15, 61], [28, 55, 25, 21, 56], [27, 20, 39, 8, 14]]
_M = (1 << 64) - 1


def _rol(v, n):
    n %= 64
    return ((v << n) | (v >> (64 - n))) & _M if n else v


def _keccak_f(a):
    for rnd in range(24):
        c = [a[x] ^ a[x + 5] ^ a[x + 10] ^ a[x + 15] ^ a[x + 20] for x in range(5)]
        d = [c[(x - 1) % 5] ^ _rol(c[(x + 1) % 5], 1) for x in range(5)]
        a = [a[i] ^ d[i % 5] for i in range(25)]
        b = [0] * 25
        for x in range(5):
            for y in range(5):
                b[y + 5 * ((2 * x + 3 * y) % 5)] = _rol(a[x + 5 * y], _ROT[x][y])
        a = [b[i] ^ ((~b[(i % 5 + 1) % 5 + 5 * (i // 5)]) & b[(i % 5 + 2) % 5 + 5 * (i // 5)]) for i in range(25)]
        a[0] ^= _RC[rnd]
    return a


def keccak256(data: bytes) -> bytes:
    rate = 136
    padded = bytearray(data) + b"\x01"
    padded += b"\x00" * (-len(padded) % rate)
    padded[-1] ^= 0x80
    state = [0] * 25
    for off in range(0, len(padded), rate):
        for i in range(rate // 8):
            state[i] ^= int.from_bytes(padded[off + 8 * i: off + 8 * i + 8], "little")
        state = _keccak_f(state)
    return b"".join(state[i].to_bytes(8, "little") for i in range(4))


# ---------------------------------------------------------------- RLP (enough for [chain_id, address, nonce])
def rlp(x) -> bytes:
    if isinstance(x, int):
        x = x.to_bytes((x.bit_length() + 7) // 8, "big") if x else b""
    if isinstance(x, (bytes, bytearray)):
        x = bytes(x)
        if len(x) == 1 and x[0] < 0x80:
            return x
        return _len(len(x), 0x80) + x
    body = b"".join(rlp(i) for i in x)
    return _len(len(body), 0xC0) + body


def _len(n, base):
    if n < 56:
        return bytes([base + n])
    nb = n.to_bytes((n.bit_length() + 7) // 8, "big")
    return bytes([base + 55 + len(nb)]) + nb


# ---------------------------------------------------------------- secp256k1 signer recovery
P = 2**256 - 2**32 - 977
N = 0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEBAAEDCE6AF48A03BBFD25E8CD0364141
G = (0x79BE667EF9DCBBAC55A06295CE870B07029BFCDB2DCE28D959F2815B16F81798,
     0x483ADA7726A3C4655DA4FBFC0E1108A8FD17B448A68554199C47D08FFB10D4B8)


def _add(p1, p2):
    if p1 is None:
        return p2
    if p2 is None:
        return p1
    (x1, y1), (x2, y2) = p1, p2
    if x1 == x2 and (y1 + y2) % P == 0:
        return None
    if p1 == p2:
        m = 3 * x1 * x1 * pow(2 * y1, -1, P) % P
    else:
        m = (y2 - y1) * pow(x2 - x1, -1, P) % P
    x3 = (m * m - x1 - x2) % P
    return x3, (m * (x1 - x3) - y1) % P


def _mul(k, pt):
    out = None
    while k:
        if k & 1:
            out = _add(out, pt)
        pt = _add(pt, pt)
        k >>= 1
    return out


def recover(msg_hash: bytes, r: int, s: int, y_parity: int) -> str:
    """Return the 0x address that signed msg_hash, or raise ValueError."""
    if not (0 < r < N and 0 < s < N):
        raise ValueError("r or s out of range")
    y2 = (pow(r, 3, P) + 7) % P
    y = pow(y2, (P + 1) // 4, P)
    if y * y % P != y2:
        raise ValueError("r is not a curve x coordinate")
    if y & 1 != y_parity:
        y = P - y
    z = int.from_bytes(msg_hash, "big")
    rinv = pow(r, -1, N)
    q = _mul(rinv, _add(_mul(s, (r, y)), _mul((-z) % N, G)))
    if q is None:
        raise ValueError("point at infinity")
    return "0x" + keccak256(q[0].to_bytes(32, "big") + q[1].to_bytes(32, "big"))[12:].hex()


# ---------------------------------------------------------------- EIP-7702 authorization tuples
MAGIC = b"\x05"
EMPTY_CODE_HASH = "c5d2460186f7233c927e7db2dcc703c0e500b653ca82273b7bfad8045d85a470"
assert keccak256(b"").hex() == EMPTY_CODE_HASH, "Keccak self-test failed"   # the constant printed in the EIP
ZERO = "0x" + "00" * 20


def auth_digest(chain_id: int, address: str, nonce: int) -> bytes:
    """keccak(MAGIC || rlp([chain_id, address, nonce])), the message the authority signs."""
    return keccak256(MAGIC + rlp([chain_id, bytes.fromhex(address[2:]), nonce]))


def tuple_signer(t: dict) -> str:
    """Signer (authority) of one RPC-format authorization tuple."""
    h = auth_digest(int(t["chainId"], 16), t["address"], int(t["nonce"], 16))
    return recover(h, int(t["r"], 16), int(t["s"], 16), int(t["yParity"], 16))


def apply_tuples(tuples, state, chain_id=1):
    """Replay the EIP-7702 'Behavior' rules for one transaction.
    state: {authority_lowercase: {"nonce": int, "delegate": str or None}}. Mutates state.
    Returns one (index, authority, verdict) per tuple; a bad tuple is skipped, never a failure."""
    out = []
    for i, t in enumerate(tuples):
        cid, nonce = int(t["chainId"], 16), int(t["nonce"], 16)
        if cid not in (0, chain_id):
            out.append((i, None, "skip: chain id")); continue
        if nonce >= 2**64 - 1:
            out.append((i, None, "skip: nonce too large")); continue
        s = int(t["s"], 16)
        if s > N // 2:
            out.append((i, None, "skip: high s")); continue
        try:
            who = tuple_signer(t).lower()
        except ValueError:
            out.append((i, None, "skip: bad signature")); continue
        acct = state.setdefault(who, {"nonce": 0, "delegate": None})
        if acct["nonce"] != nonce:
            out.append((i, who, "skip: stale" if nonce < acct["nonce"] else "skip: future")); continue
        acct["delegate"] = None if t["address"].lower() == ZERO else t["address"].lower()
        acct["nonce"] += 1
        out.append((i, who, "APPLIED"))
    return out


# ---------------------------------------------------------------- JSON-RPC
def _post(body, url, timeout):
    """POST with a polite retry: public gateways answer 429 when asked too fast."""
    for attempt in range(8):
        req = urllib.request.Request(url, body, {"Content-Type": "application/json", "User-Agent": UA})
        try:
            return json.load(urllib.request.urlopen(req, timeout=timeout))
        except urllib.error.HTTPError as err:
            if err.code != 429 or attempt == 7:
                raise
            time.sleep(1.5 * (attempt + 1))


def rpc(method, params, url=RPC):
    j = _post(json.dumps({"jsonrpc": "2.0", "id": 1, "method": method, "params": params}).encode(), url, 60)
    if "error" in j:
        raise RuntimeError(j["error"])
    return j["result"]


def rpc_batch(calls, url=RPC, size=20):
    """calls: list of (method, params). Sent in batches of `size`. Returns results in order."""
    out = []
    for i in range(0, len(calls), size):
        chunk = calls[i:i + size]
        body = json.dumps([{"jsonrpc": "2.0", "id": k, "method": m, "params": p} for k, (m, p) in enumerate(chunk)]).encode()
        res = sorted(_post(body, url, 120), key=lambda r: r["id"])
        out += [r.get("result") for r in res]
        time.sleep(0.1)
    return out


def nonce_at(addr, block):
    return int(rpc("eth_getTransactionCount", [addr, hex(block) if isinstance(block, int) else block]), 16)


def code_at(addr, block):
    return rpc("eth_getCode", [addr, hex(block) if isinstance(block, int) else block])

Tags:

BinanceEIP-7702EthereumIncident ResponseKey ManagementTrail of BitsWallet Security

Share

A square wooden sandbox filled with sand, a bucket and toys on grey paving stones
Previous Post

WorkOS Says a Researcher’s AI Pentest Agent Escaped Its Sandbox and Pulled Customer Records From an Internal Sales Tool Using a Development Credential Left in Production, but the Update Does Not Say How It Got Out

Two rusty iron turnstile gates on a stone-walled path with a gap and a bollard between them
Next Post

CISA Lists a SonicOS Flaw That Can Let Valid Credentials Skip SAML MFA as Exploited the Day SonicWall Disclosed It, and the Gen7 Build the Advisory Gives as the Minimum Fix Mentions Only One of the Seven Fixes in Its Release Notes

No Comment! Be the first one.

Leave a Reply Cancel reply

Your email address will not be published. Required fields are marked *

Latest
08 Nov
How to Fix Python ModuleNotFoundError and Relative Import Errors, Break Circular Imports, and Stop Local Files From Shadowing the Standard Library
08 Nov
WIRED’s “Free Chinese AI Models Are Getting Scary Good at Hacking” Rests on Two Cyber Ranges: DeepSeek Is Level With the Leader on One, the GLM It Names Finished Last of Seven, and Three Claude Runs Stopped by Refusals Appear Only in the Chart Data
Trending
November 8, 2026
How to Fix Python ModuleNotFoundError and Relative Import Errors, Break Circular Imports, and Stop Local Files From Shadowing the Standard Library
November 8, 2026
WIRED’s “Free Chinese AI Models Are Getting Scary Good at Hacking” Rests on Two Cyber Ranges: DeepSeek Is Level With the Leader on One, the GLM It Names Finished Last of Seven, and Three Claude Runs Stopped by Refusals Appear Only in the Chart Data
November 8, 2026
Crédit Mutuel Arkéa Says Attackers Reached Its Own Account Through a Service Provider’s Platform; the On-Chain Trail Attributed to the Theft Shows $126 Million in USDC Reaching 17 Wallets in 28 Hours, Most of It Moving On Within Minutes
November 8, 2026
How to Stop Command Injection in Python subprocess Calls, Including Git Option Injection and the Windows .cmd Trap
November 8, 2026
Cloudflare’s Trusted Agent Delegation Essay Names 14 Site Problems, but the Identity Standard It Prefers Defines No Delegation, and Its First Shipped Fix Sees Agents Only at Login and Sign-Up
November 8, 2026
CloudLinux Says an Exploit Hit a CloudLinux 8 Server on November 4 Through Linux Kernel Bug CVE-2026-98276, Fixed Upstream Since September, and Red Hat Still Lists Every Supported RHEL Kernel as Affected

Related Posts

Blue-lit server racks in a modern data center, illustrating the compute infrastructure behind the AI boom.
Articles

The AI Boom Is Spending Real Money Before Proving Real Returns

June 7, 2026
Technician working with a laptop beside server racks, representing enterprise AI retrieval infrastructure
Articles

Google’s Agentic RAG Push Makes Enterprise AI Less of a One-Shot Guess

June 7, 2026
A person with a laptop and smartphone, representing digital attention and AI-assisted work
Articles

AI Chatbots Are Making Attention a Design Problem

June 7, 2026
A customer-support representative wearing a headset against a dark studio background.
Articles

The Meta AI Support Hack Was a Plain Old Authorization Failure

June 7, 2026
SXZ.io SXZ.io
  • [email protected]

Categories

Articles
Learning Hub
News

All Rights Reserved by SXZ.io ©2026