From cf9a84202076bf403632b7bc7bc74c75aeaf1f6d Mon Sep 17 00:00:00 2001 From: Valentin Lab Date: Thu, 26 Mar 2026 14:09:09 +0100 Subject: [PATCH 1/3] fix: lower the default gas limit from 5M to 0.5M gwei --- src/pyc3l/__init__.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/pyc3l/__init__.py b/src/pyc3l/__init__.py index a97624f..7a2021a 100644 --- a/src/pyc3l/__init__.py +++ b/src/pyc3l/__init__.py @@ -279,8 +279,7 @@ def send_transaction( transaction = { "to": fn[0], "value": 0, - # "gas": 2500000, - "gas": 5000000, + "gas": 500_000, "gasPrice": gas_price, "nonce": self.update_nonce(nonce), "data": fn[1] + data, From 74c3570c72596976f04b75ddf4d3c304f125bf2a Mon Sep 17 00:00:00 2001 From: Valentin Lab Date: Thu, 26 Mar 2026 14:12:39 +0100 Subject: [PATCH 2/3] fix: pkg: remove ``.gitignore`` from dist --- pyproject.toml | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/pyproject.toml b/pyproject.toml index 94e5b77..be93908 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -67,5 +67,13 @@ exclude_lines = [ [tool.hatch.build.targets.sdist] exclude = [ "/.github", + ".gitignore", "/doc", ] +include = [ + "/src", + "/_version.py", + "/pyproject.toml", + "/README.md", + "/LICENSE", +] From 06970a631f1927a3ad7a436a603937e72b6da035 Mon Sep 17 00:00:00 2001 From: Valentin Lab Date: Mon, 8 Jun 2026 11:30:23 +0200 Subject: [PATCH 3/3] fix: serialize and confirm nonce usage to avoid gaps and collisions ``send_transaction`` previously layered its own pending-nonce tracker (``update_nonce`` / ``_additional_nonce`` / ``hasChangedBlock``) on top of geth's ``pending`` nonce -- which already tracks in-flight transactions. The two trackers double-counted, producing nonce gaps, and with no cross-process lock, concurrent Odoo workers could grab the same nonce. Both led to transactions being acked by geth then evicted without being mined. Rework ``send_transaction`` into a per-account critical section that: - acquires an ``fcntl.flock`` lock keyed on the account (same-host cross-process mutual exclusion), with polled acquisition so contention is logged and bounded (``NonceLockTimeout``); - trusts geth's ``pending`` nonce verbatim (no local arithmetic); - pins the whole serialized chain of sends to a single node, stored in the lock file, to dodge cross-node propagation lag of the node-local pending nonce; the pin stays warm via the file mtime (``NONCE_NODE_PIN_TTL``) and re-elects once idle; - waits until geth acknowledges the transaction (visible in pool or a block) before releasing, logging the wait duration on both success and timeout (``TransactionUnconfirmed``). Remove the now-obsolete ``update_nonce``, ``_additional_nonce``, ``_current_block``, ``hasChangedBlock`` and ``registerCurrentBlock`` (verified: no external callers). Add ``tests/test_nonce.py`` covering nonce trust, gap avoidance, lock mutual exclusion, node pinning (warm reuse / cold re-election, forced endpoint honored only when cold), confirmation, and the lock-wait visibility and timeout paths. Assisted-by: Claude:claude-opus-4-8 --- src/pyc3l/__init__.py | 385 +++++++++++++++++++++++++++++----- tests/test_nonce.py | 466 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 804 insertions(+), 47 deletions(-) create mode 100644 tests/test_nonce.py diff --git a/src/pyc3l/__init__.py b/src/pyc3l/__init__.py index 7a2021a..a69bb1e 100644 --- a/src/pyc3l/__init__.py +++ b/src/pyc3l/__init__.py @@ -9,6 +9,9 @@ import sys import inspect +import os +import fcntl +import contextlib # Check if we're running on Python 3.11 or later if sys.version_info >= (3, 11): @@ -33,7 +36,12 @@ def getargspec(func): from .wallet import Wallet from .ApiCommunication import ApiCommunication, ComChainABI -from .ApiHandling import ApiHandling, Endpoint, APIErrorNoMessage +from .ApiHandling import ( + ApiHandling, + Endpoint, + APIError, + APIErrorNoMessage, +) from .lib.dt import utc_ts_to_dt, utc_ts_to_local_iso, dt_to_local_iso logger = logging.getLogger(__name__) @@ -41,6 +49,88 @@ def getargspec(func): NONE = {} +## Nonce confirmation: after sending a transaction we hold the +## per-account lock until geth has acknowledged the transaction (it +## becomes visible in the pool or in a block). The timeout is +## deliberately generous for a first deployment; the actual wait +## duration is logged on every send so the value can be tuned from +## real data. See ``doc/admin.org`` task "fix gestion du nonce". +NONCE_CONFIRM_TIMEOUT = 15 * 60 ## seconds +NONCE_POLL_INTERVAL = 2 ## seconds between visibility polls + +## Why pin a node (and why a TTL): +## +## geth's ``pending`` nonce is *node-local*: when we send a tx to node +## A, A's pending nonce advances immediately, but the other nodes only +## learn about that tx once it has propagated across the cluster (gossip +## + mempool sync), which is NOT instantaneous. If a follow-up send for +## the same account hit a *different* node B before propagation, B would +## still report the OLD pending nonce -> we'd reuse a nonce / create a +## gap. To avoid this we pin the whole serialized chain of sends for an +## account to a single node (stored in the lock file), so every send +## reads a pending nonce that already reflects our previous sends. +## +## The TTL bounds how long that pin stays "sticky". The lock file's +## mtime is refreshed on every release; while sends keep happening +## (age < TTL) we keep reusing the pinned node -- the burst stays on one +## node, side-stepping cross-node propagation lag entirely. Once the +## account has been idle for longer than the TTL, any in-flight tx has +## had ample time to propagate to every node, so it is safe to release +## the pin and re-elect a fresh node on the next acquisition (allowing +## normal endpoint rotation, or honoring a newly-forced endpoint). The +## TTL is therefore a conservative lower bound on "time for a tx to be +## seen cluster-wide"; 5 min is generous on purpose. +NONCE_NODE_PIN_TTL = 5 * 60 ## seconds + +## Acquisition of the per-account nonce lock. We poll the lock +## non-blocking so that contention is observable (logged) and bounded. +## The hard timeout is set ABOVE ``NONCE_CONFIRM_TIMEOUT`` on purpose: +## a *legitimate* holder may hold the lock for up to the confirmation +## timeout while waiting for its own transaction to be acknowledged, so +## we must not preempt it; only a wedged holder should ever trip this. +NONCE_LOCK_POLL_INTERVAL = 0.5 ## seconds between acquisition attempts +NONCE_LOCK_WARN_INTERVAL = 30 ## seconds between "still waiting" warnings +NONCE_LOCK_TIMEOUT = NONCE_CONFIRM_TIMEOUT + 60 ## seconds, hard cap + + +class NonceLockTimeout(Exception): + """Could not acquire the per-account nonce lock in time. + + Raised when the lock for an account stays held past + ``NONCE_LOCK_TIMEOUT`` -- which exceeds the confirmation timeout, + so a healthy holder never trips it. Hitting this means the holder + is wedged; we refuse to send rather than risk a nonce collision. + """ + + def __init__(self, address, waited): + self.address = address + self.waited = waited + super().__init__( + f"Could not acquire nonce lock for account 0x{address} " + f"after waiting {waited:.1f}s (holder appears wedged)" + ) + + +class TransactionUnconfirmed(Exception): + """A transaction was sent but geth never acknowledged it in time. + + This is explicitly NOT a failure: the transaction may still be + mined later. It signals that we could not *confirm* the send + within ``NONCE_CONFIRM_TIMEOUT``. Callers MUST NOT blindly + re-send (that would risk a double-spend); the situation requires + deliberate handling. + """ + + def __init__(self, tx_hash, nonce, node, waited): + self.tx_hash = tx_hash + self.nonce = nonce + self.node = node + self.waited = waited + super().__init__( + f"Transaction 0x{tx_hash} (nonce {nonce}) not acknowledged " + f"by {node} after {waited:.1f}s" + ) + def decode_data(abi_types, data): unique = False @@ -102,10 +192,6 @@ class Block(AddressableBridgeObject): pass class Pyc3l: def __init__(self, endpoint=None): - self._additional_nonce = 0 - - self._current_block = 0 - self._endpoint_last_usage = None if endpoint: @@ -215,16 +301,6 @@ def getAccountTransactions(self, address, count=10, offset=0): import json return [json.loads(r) for r in transactions] - def hasChangedBlock(self, do_reset=False): - new_current_block = self.getBlockNumber() - res = new_current_block != self._current_block - if do_reset: - self._current_block = new_current_block - return res - - def registerCurrentBlock(self): - self.hasChangedBlock(do_reset=True) - def read(self, fn, args, abi_return_type="int256"): data = { "ethCall": { @@ -271,42 +347,257 @@ def send_transaction( ciphered_message_from="", ciphered_message_to="", ): - tr_infos = self.getTrInfos(account.address) - gas_price = int(tr_infos["gasprice"], 0) - gas_price_gwei = Web3.fromWei(gas_price, "gwei") - nonce = int(tr_infos["nonce"], 0) - logger.info(f"Gas price: {gas_price!r} wei ({gas_price_gwei} gwei), Nonce: {nonce!r}") - transaction = { - "to": fn[0], - "value": 0, - "gas": 500_000, - "gasPrice": gas_price, - "nonce": self.update_nonce(nonce), - "data": fn[1] + data, - "from": account.address, - } + ## The whole nonce dance is serialized per emitting account and + ## pinned to a single node, so that geth's ``pending`` nonce + ## (which already tracks our in-flight transactions) stays a + ## reliable source of truth. ``_nonce_lock`` yields a Pyc3l + ## instance bound to the pinned node; every API call below goes + ## through it. We trust the pending nonce verbatim -- no local + ## arithmetic -- and only release the lock once geth has + ## acknowledged the transaction. See ``doc/admin.org``. + with self._nonce_lock(account.address) as pyc3l: + tr_infos = pyc3l.getTrInfos(account.address) + gas_price = int(tr_infos["gasprice"], 0) + gas_price_gwei = Web3.fromWei(gas_price, "gwei") + nonce = int(tr_infos["nonce"], 0) + logger.info( + f"Gas price: {gas_price!r} wei ({gas_price_gwei} gwei), " + f"Nonce: {nonce!r} (geth pending, trusted)" + ) + transaction = { + "to": fn[0], + "value": 0, + "gas": 500_000, + "gasPrice": gas_price, + "nonce": nonce, + "data": fn[1] + data, + "from": account.address, + } - signed = Eth.account.signTransaction(transaction, account.privateKey) - str_version = ( - "0x" + str(codecs.getencoder("hex_codec")(signed.rawTransaction)[0])[2:-1] - ) - raw_tx = {"rawtx": str_version} + signed = Eth.account.signTransaction( + transaction, account.privateKey + ) + str_version = ( + "0x" + + str(codecs.getencoder("hex_codec")(signed.rawTransaction)[0])[2:-1] + ) + raw_tx = {"rawtx": str_version} - if ciphered_message_from != "": - raw_tx["memo_from"] = ciphered_message_from + if ciphered_message_from != "": + raw_tx["memo_from"] = ciphered_message_from - if ciphered_message_to != "": - raw_tx["memo_to"] = ciphered_message_to + if ciphered_message_to != "": + raw_tx["memo_to"] = ciphered_message_to - return self.endpoint.api.post(data=raw_tx) + tx_hash = pyc3l.endpoint.api.post(data=raw_tx) - def update_nonce(self, nonce): - if not self.hasChangedBlock(do_reset=True): - self._additional_nonce = self._additional_nonce + 1 - return nonce + self._additional_nonce - else: - self._additional_nonce = 0 - return nonce + pyc3l._await_tx_visible(tx_hash, account.address, nonce) + return tx_hash + + ## Nonce serialization (per-account lock + node pinning + confirmation) + + def _nonce_lock_path(self, address): + """Filesystem path of the per-account nonce lock file. + + Lives under ``$XDG_STATE_HOME`` (default ``~/.local/state``), + next to the endpoints state file, one file per account. + """ + xdg_state_home = os.environ.get("XDG_STATE_HOME") or os.path.join( + os.path.expanduser("~"), ".local", "state" + ) + address = address[2:] if address.startswith("0x") else address + return os.path.join( + xdg_state_home, "pyc3l", "nonce-lock", address.lower() + ) + + @contextlib.contextmanager + def _nonce_lock(self, address): + """Serialize nonce usage for ``address``, yielding a pinned Pyc3l. + + Key (mutual exclusion) is the account; value (file content) is + the elected node URL, so that every serialized sender for this + account talks to the *same* node and sees a consistent + ``pending`` nonce. The context manager yields a fresh + :class:`Pyc3l` bound to that node (``Pyc3l(endpoint=url)`` has + no resolver, hence no rotation): the caller does the whole + nonce dance through that pinned instance. + + Node freshness is driven by the lock file's modification time: + on release we *touch* the file, marking the pin as just-used. + On acquisition, if the file was touched within + ``NONCE_NODE_PIN_TTL`` we reuse the node it holds (the pin is + "warm" -- stickiness across a contention burst); if it is + older (idle), we elect a fresh node and store it. Under + sustained load the file is touched on every release and never + goes stale, so the node stays pinned for the whole burst; only + a genuine idle gap triggers re-election. + + ``fcntl.flock`` gives same-host cross-process exclusion and is + released automatically by the OS if the holder dies. The file + is never unlinked, so there is no orphaned-inode race to guard + against. Acquisition is polled (not a silent blocking wait) so + contention is logged and bounded -- see + :meth:`_acquire_nonce_lock`. + """ + path = self._nonce_lock_path(address) + os.makedirs(os.path.dirname(path), exist_ok=True) + + fd = os.open(path, os.O_RDWR | os.O_CREAT, 0o600) + try: + self._acquire_nonce_lock(fd, address) + node_url = self._pinned_node_url(fd) + try: + yield Pyc3l(endpoint=node_url) + finally: + ## Touch the file: keep the node pin warm for the next + ## (possibly already-waiting) sender. + os.utime(fd, None) + finally: + fcntl.flock(fd, fcntl.LOCK_UN) + os.close(fd) + + @staticmethod + def _acquire_nonce_lock(fd, address): + """Acquire the exclusive lock on ``fd``, with visibility + bound. + + Unlike a bare blocking ``flock(LOCK_EX)`` (which waits silently + and forever), this polls the lock non-blocking so that: + + - contention is *logged* (INFO on first wait, periodic WARNING + while still waiting), + - the time spent waiting is *measured* and logged on success, + - the wait is *bounded* by ``NONCE_LOCK_TIMEOUT`` -- past which + we give up with :class:`NonceLockTimeout` rather than block a + worker on a wedged holder. + + The fast path (uncontended) takes the lock on the first attempt + and logs nothing. + """ + t0 = time.time() + last_warn = t0 + waiting = False + while True: + try: + fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB) + except (BlockingIOError, OSError): + ## Lock is held by another sender for this account. + now = time.time() + waited = now - t0 + if not waiting: + waiting = True + logger.info( + "nonce-lock: account=0x%s held by another sender, " + "waiting...", + address, + ) + if waited >= NONCE_LOCK_TIMEOUT: + logger.warning( + "nonce-lock: account=0x%s giving up after " + "waited=%.1fs (timeout)", + address, waited, + ) + raise NonceLockTimeout(address, waited) + if now - last_warn >= NONCE_LOCK_WARN_INTERVAL: + last_warn = now + logger.warning( + "nonce-lock: account=0x%s still waiting, " + "waited=%.1fs", + address, waited, + ) + time.sleep(NONCE_LOCK_POLL_INTERVAL) + continue + ## Lock acquired. + if waiting: + logger.info( + "nonce-lock: account=0x%s acquired after waited=%.1fs", + address, time.time() - t0, + ) + return + + def _pinned_node_url(self, fd): + """Return the node URL to pin for this critical section. + + Reuse the node URL stored in the lock file when the pin is + still warm (file touched within ``NONCE_NODE_PIN_TTL``); + otherwise elect a fresh node and persist its URL. See the + ``NONCE_NODE_PIN_TTL`` comment for *why* (cross-node + propagation of geth's node-local pending nonce). + + Consequence: while the pin is warm, the stored node wins even + over an explicitly-forced endpoint (``Pyc3l(endpoint=...)`` / + the CLI ``-e`` flag) -- staying on the in-flight node is what + keeps the pending nonce consistent. A forced endpoint only + takes effect once the pin has gone cold (idle > TTL), at which + point any in-flight tx has propagated cluster-wide and it is + safe to switch. + """ + os.lseek(fd, 0, os.SEEK_SET) + stored = os.read(fd, 4096).decode("utf-8").strip() + age = time.time() - os.fstat(fd).st_mtime + if stored and age < NONCE_NODE_PIN_TTL: + logger.info( + f"endpoint: {stored} (nonce-pinned, reused, idle {age:.0f}s)" + ) + return stored + + ## No pin yet, or the pin went stale (idle): elect a fresh node + ## through the normal rotation and persist its URL. + node_url = str(self.endpoint) + os.lseek(fd, 0, os.SEEK_SET) + os.ftruncate(fd, 0) + os.write(fd, node_url.encode("utf-8")) + os.fsync(fd) + reason = "stale" if stored else "new" + logger.info(f"endpoint: {node_url} (nonce-pinned, elected, {reason})") + return node_url + + def _await_tx_visible(self, tx_hash, address, nonce): + """Block until geth acknowledges ``tx_hash`` (pool or block). + + Polls the pinned node until the transaction becomes visible by + hash, or until ``NONCE_CONFIRM_TIMEOUT`` elapses. The wait + duration is logged on *both* outcomes so the timeout can be + tuned from real data. On timeout raises + :class:`TransactionUnconfirmed` (an explicit "unknown", NOT a + failure). + """ + bare_hash = tx_hash[2:] if tx_hash.startswith("0x") else tx_hash + node = str(self.endpoint) + t0 = time.time() + polls = 0 + while True: + polls += 1 + if self._tx_is_visible(bare_hash): + waited = time.time() - t0 + logger.info( + "nonce-confirm: account=0x%s nonce=%d node=%s " + "hash=0x%s waited=%.3fs polls=%d outcome=confirmed", + address, nonce, node, bare_hash, waited, polls, + ) + return + waited = time.time() - t0 + if waited >= NONCE_CONFIRM_TIMEOUT: + logger.warning( + "nonce-confirm: account=0x%s nonce=%d node=%s " + "hash=0x%s waited=%.3fs polls=%d outcome=unconfirmed", + address, nonce, node, bare_hash, waited, polls, + ) + raise TransactionUnconfirmed(bare_hash, nonce, node, waited) + time.sleep(NONCE_POLL_INTERVAL) + + def _tx_is_visible(self, bare_hash): + """True if geth knows ``bare_hash`` (in the pool or in a block). + + An unknown transaction makes the API answer with an empty + error, surfaced as :class:`APIErrorNoMessage`; we treat that + as "not visible yet". Any other API error is a real problem + and propagates. + """ + try: + self.getTransactionInfo(bare_hash) + return True + except APIErrorNoMessage: + return False ## Sub-objects @@ -653,4 +944,4 @@ def BlockByNumber(self, nb): data = {"number": hex(nb), "hash": "0x0"} block._data = data block.address = block._data["hash"] - return block \ No newline at end of file + return block diff --git a/tests/test_nonce.py b/tests/test_nonce.py new file mode 100644 index 0000000..afcd6c7 --- /dev/null +++ b/tests/test_nonce.py @@ -0,0 +1,466 @@ +"""Tests for nonce management in ``send_transaction``. + +These tests exercise the per-account critical section that replaced +the old ``update_nonce`` heuristic: it must trust geth's ``pending`` +nonce verbatim, serialize concurrent senders, pin the node across the +serialized chain, drop the pin when idle, confirm the transaction by +hash, and raise an explicit ``TransactionUnconfirmed`` on timeout. + +The network and signing layers are stubbed so the logic under test is +the lock / nonce / confirmation flow, not web3 or HTTP. A single fake +node is shared by URL, so node-pinning (read the node back from the +lock file and rebuild an ``Endpoint``) routes to the same fake. +""" + +import os +import time +import tempfile +import threading +import unittest + +import pyc3l +from pyc3l import Pyc3l, TransactionUnconfirmed, NonceLockTimeout +from pyc3l.ApiHandling import APIErrorNoMessage + + +FAKE_NODE = "https://node-test.example.org" +FAKE_ACCOUNT = "0x89D73145d78C07da5911fC2055657Da12fa0b9Ad" + + +class FakeSigned: + """Stand-in for ``Eth.account.signTransaction``'s return value.""" + + rawTransaction = b"\xde\xad\xbe\xef" + + +class FakeAccount: + address = FAKE_ACCOUNT + privateKey = b"\x00" * 32 + + +class FakeApi: + """Stable ``.api`` object: records rawtx broadcasts, serves txdata.""" + + def __init__(self, behaviour): + self._b = behaviour + + def post(self, data=None): + if data and "rawtx" in data: + return self._b.broadcast() + ## txdata (nonce / gasprice) request. + return { + "nonce": hex(self._b.pending_nonce()), + "gasprice": "0x3b9aca00", + } + + +class FakeEndpoint: + """Minimal Endpoint replacement. + + Shares a single ``behaviour`` per URL via the class registry, so a + node read back from the lock file (``Endpoint(stored_url)``) routes + to the same behaviour as the originally-elected node. + """ + + registry = {} + + def __init__(self, url): + self._url = url + self.behaviour = FakeEndpoint.registry[url] + self.api = FakeApi(self.behaviour) + + def __str__(self): + return self._url + + +class NodeBehaviour: + """In-memory geth-like node behaviour for one URL.""" + + def __init__(self, start_nonce=0, visible_after=0): + self._nonce = start_nonce + self.visible_after = visible_after + self.sent = [] + self.poll_calls = 0 + self.lock = threading.Lock() + self.in_section = 0 + self.max_in_section = 0 + self.broadcast_delay = 0.0 + + def pending_nonce(self): + return self._nonce + + def broadcast(self): + with self.lock: + self.in_section += 1 + self.max_in_section = max(self.max_in_section, self.in_section) + if self.broadcast_delay: + time.sleep(self.broadcast_delay) + with self.lock: + self.sent.append(self._nonce) + self._nonce += 1 ## geth promoteTx: pending nonce advances + self.in_section -= 1 + return "abc123" ## fake tx hash (bare) + + def tx_info(self, tx_hash): + self.poll_calls += 1 + if self.poll_calls <= self.visible_after: + raise APIErrorNoMessage("not visible yet") + return {"transaction": {"blockNumber": "0x1"}} + + +class NonceTestBase(unittest.TestCase): + + def setUp(self): + ## Isolate lock files into a throwaway state dir. + self._tmp = tempfile.TemporaryDirectory() + self._prev_xdg = os.environ.get("XDG_STATE_HOME") + os.environ["XDG_STATE_HOME"] = self._tmp.name + + ## Stub signing (no eth_account / web3 keys). Record the nonce + ## actually placed into the transaction. + self._prev_sign = pyc3l.Eth.account.signTransaction + self.signed_nonces = [] + + def fake_sign(transaction, private_key): + self.signed_nonces.append(transaction["nonce"]) + return FakeSigned() + + pyc3l.Eth.account.signTransaction = fake_sign + + ## Swap Endpoint for our fake across the module. + self._prev_endpoint_cls = pyc3l.Endpoint + pyc3l.Endpoint = FakeEndpoint + FakeEndpoint.registry = {} + + ## Route every Pyc3l instance's API calls (outer AND the pinned + ## instance built inside _nonce_lock) to the fake node bound to + ## its endpoint URL. Patched at the class level so the + ## freshly-constructed pinned Pyc3l is covered too. + self._prev_get_tx_info = Pyc3l.getTransactionInfo + self._prev_get_tr_infos = Pyc3l.getTrInfos + + def getTransactionInfo(inner, tx_hash): + return FakeEndpoint.registry[str(inner.endpoint)].tx_info(tx_hash) + + def getTrInfos(inner, address): + b = FakeEndpoint.registry[str(inner.endpoint)] + return {"nonce": hex(b.pending_nonce()), "gasprice": "0x3b9aca00"} + + Pyc3l.getTransactionInfo = getTransactionInfo + Pyc3l.getTrInfos = getTrInfos + + ## Speed up polling everywhere. + self._prev_interval = pyc3l.NONCE_POLL_INTERVAL + pyc3l.NONCE_POLL_INTERVAL = 0.01 + + def tearDown(self): + pyc3l.Eth.account.signTransaction = self._prev_sign + pyc3l.Endpoint = self._prev_endpoint_cls + Pyc3l.getTransactionInfo = self._prev_get_tx_info + Pyc3l.getTrInfos = self._prev_get_tr_infos + pyc3l.NONCE_POLL_INTERVAL = self._prev_interval + if self._prev_xdg is None: + os.environ.pop("XDG_STATE_HOME", None) + else: + os.environ["XDG_STATE_HOME"] = self._prev_xdg + self._tmp.cleanup() + + def make_node(self, **kwargs): + b = NodeBehaviour(**kwargs) + FakeEndpoint.registry[FAKE_NODE] = b + return b + + def make_pyc3l(self, behaviour): + ## __init__ runs after setUp swapped Endpoint, so _endpoint is + ## already a FakeEndpoint bound to the registered behaviour. + return Pyc3l(endpoint=FAKE_NODE) + + +class TestTrustPendingNonce(NonceTestBase): + + def test_uses_geth_pending_nonce_verbatim(self): + b = self.make_node(start_nonce=1777) + p = self.make_pyc3l(b) + + tx_hash = p.send_transaction(("0xc", "0xf"), "", FakeAccount()) + + self.assertEqual(tx_hash, "abc123") + ## No offset: the exact geth pending nonce is used. + self.assertEqual(b.sent, [1777]) + self.assertEqual(self.signed_nonces, [1777]) + + def test_no_gap_on_two_sequential_sends(self): + b = self.make_node(start_nonce=100) + p = self.make_pyc3l(b) + + p.send_transaction(("0xc", "0xf"), "", FakeAccount()) + p.send_transaction(("0xc", "0xf"), "", FakeAccount()) + + ## Consecutive nonces, no gap (100, 101) and no reuse. + self.assertEqual(b.sent, [100, 101]) + + +class TestConfirmation(NonceTestBase): + + def test_waits_until_visible(self): + b = self.make_node(start_nonce=5, visible_after=2) + p = self.make_pyc3l(b) + tx_hash = p.send_transaction(("0xc", "0xf"), "", FakeAccount()) + self.assertEqual(tx_hash, "abc123") + self.assertGreaterEqual(b.poll_calls, 3) + + def test_timeout_raises_transaction_unconfirmed(self): + b = self.make_node(start_nonce=5, visible_after=10**9) + p = self.make_pyc3l(b) + prev_to = pyc3l.NONCE_CONFIRM_TIMEOUT + pyc3l.NONCE_CONFIRM_TIMEOUT = 0.05 + try: + with self.assertRaises(TransactionUnconfirmed) as ctx: + p.send_transaction(("0xc", "0xf"), "", FakeAccount()) + finally: + pyc3l.NONCE_CONFIRM_TIMEOUT = prev_to + + e = ctx.exception + self.assertEqual(e.nonce, 5) + self.assertEqual(e.tx_hash, "abc123") + self.assertGreater(e.waited, 0) + ## The transaction WAS broadcast; only confirmation timed out. + self.assertEqual(b.sent, [5]) + + +class TestNodePinningAndLockFile(NonceTestBase): + + def test_lock_file_stores_node_and_persists(self): + b = self.make_node(start_nonce=7) + p = self.make_pyc3l(b) + lock_path = p._nonce_lock_path(FAKE_ACCOUNT) # noqa: SLF001 + + ## Observe the lock-file content while inside the section: the + ## pinned node URL must already be persisted before the tx is + ## broadcast/confirmed. + seen = {} + orig_broadcast = b.broadcast + + def broadcast(): + with open(lock_path) as f: + seen["file_node"] = f.read().strip() + return orig_broadcast() + + b.broadcast = broadcast + + p.send_transaction(("0xc", "0xf"), "", FakeAccount()) + + ## During the section: node persisted in the lock file. + self.assertEqual(seen["file_node"], FAKE_NODE) + ## After the section: the file persists with the node, freshly + ## touched, so the next sender reuses it. + self.assertTrue(os.path.exists(lock_path)) + with open(lock_path) as f: + self.assertEqual(f.read().strip(), FAKE_NODE) + self.assertLess(time.time() - os.path.getmtime(lock_path), 5) + + def test_yields_pyc3l_pinned_to_the_node(self): + b = self.make_node(start_nonce=7) + p = self.make_pyc3l(b) + + with p._nonce_lock(FAKE_ACCOUNT) as pinned: # noqa: SLF001 + ## The yielded value is a distinct, fixed-endpoint Pyc3l. + self.assertIsInstance(pinned, Pyc3l) + self.assertIsNot(pinned, p) + self.assertEqual(str(pinned.endpoint), FAKE_NODE) + ## Fixed endpoint => no resolver => no rotation. + self.assertIsNone(pinned._endpoint_resolver) # noqa: SLF001 + + def test_warm_pin_is_reused_stale_pin_is_reelected(self): + b = self.make_node(start_nonce=7) + p = self.make_pyc3l(b) + lock_path = p._nonce_lock_path(FAKE_ACCOUNT) # noqa: SLF001 + + ## First send creates and pins the node. + p.send_transaction(("0xc", "0xf"), "", FakeAccount()) + self.assertEqual(b.sent, [7]) + + ## Warm pin: a second send within the TTL reuses the same file + ## (node unchanged, file still present). + p.send_transaction(("0xc", "0xf"), "", FakeAccount()) + self.assertTrue(os.path.exists(lock_path)) + with open(lock_path) as f: + self.assertEqual(f.read().strip(), FAKE_NODE) + + ## Force the pin stale by ageing the file past the TTL. + old = time.time() - (pyc3l.NONCE_NODE_PIN_TTL + 10) + os.utime(lock_path, (old, old)) + + ## A stale pin triggers re-election. Make the outer instance + ## elect a different node URL on the next (cold) election. + other_url = "https://node-other.example.org" + FakeEndpoint.registry[other_url] = b ## same behaviour + p._endpoint = FakeEndpoint(other_url) # noqa: SLF001 + + p.send_transaction(("0xc", "0xf"), "", FakeAccount()) + + ## Re-election wrote the new node into the (persisted) file. + with open(lock_path) as f: + self.assertEqual(f.read().strip(), other_url) + + def test_forced_endpoint_ignored_while_warm_honored_when_cold(self): + """A forced endpoint (CLI ``-e`` / ``Pyc3l(endpoint=...)``) must + lose to a *warm* pin (stickiness keeps the burst on the + in-flight node) but win once the pin has gone *cold*. + + This is the exact scenario of forcing ``-e node-001`` while the + lock is still warm-pinned to ``node-002``. + """ + node_002 = FAKE_NODE + node_001 = "https://node-001.example.org" + + b = self.make_node(start_nonce=7) ## registers node-002 + FakeEndpoint.registry[node_001] = b ## node-001 -> same behaviour + + lock_path = self.make_pyc3l(b)._nonce_lock_path( # noqa: SLF001 + FAKE_ACCOUNT + ) + + ## Pre-pin the lock file to node-002, freshly touched (warm). + os.makedirs(os.path.dirname(lock_path), exist_ok=True) + with open(lock_path, "w") as f: + f.write(node_002) + now = time.time() + os.utime(lock_path, (now, now)) + + ## A Pyc3l forced to node-001 (the -e case). + forced = Pyc3l(endpoint=node_001) + forced.getTransactionInfo = b.tx_info + self.assertEqual(str(forced.endpoint), node_001) + + ## WARM: the forced endpoint is IGNORED; the warm pin wins. + forced.send_transaction(("0xc", "0xf"), "", FakeAccount()) + with open(lock_path) as f: + self.assertEqual(f.read().strip(), node_002) + + ## Now age the lock past the TTL -> COLD. + old = time.time() - (pyc3l.NONCE_NODE_PIN_TTL + 10) + os.utime(lock_path, (old, old)) + + ## COLD: the forced endpoint now WINS and is persisted. + forced.send_transaction(("0xc", "0xf"), "", FakeAccount()) + with open(lock_path) as f: + self.assertEqual(f.read().strip(), node_001) + + +class TestMutualExclusion(NonceTestBase): + def test_concurrent_senders_do_not_collide(self): + """Two independent Pyc3l instances (≈ two processes) contending + on the same account must serialize via the shared lock file: + distinct consecutive nonces, never overlapping sections. + """ + b = self.make_node(start_nonce=200) + b.broadcast_delay = 0.02 ## widen the critical-section window + p1 = self.make_pyc3l(b) + p2 = self.make_pyc3l(b) + + threads = [ + threading.Thread( + target=lambda p=p: p.send_transaction( + ("0xc", "0xf"), "", FakeAccount() + ) + ) + for p in (p1, p2) + ] + for t in threads: + t.start() + for t in threads: + t.join() + + ## Never two senders broadcasting at once. + self.assertEqual(b.max_in_section, 1) + ## Two distinct consecutive nonces, no collision. + self.assertEqual(sorted(b.sent), [200, 201]) + + +class TestLockWaitVisibility(NonceTestBase): + def _hold_lock(self, address): + """Open the lock file and hold flock LOCK_EX, returning the fd. + + Does not create or touch a node behaviour, so an + already-registered node stays intact. + """ + import fcntl + + xdg = os.environ["XDG_STATE_HOME"] + bare = address[2:] if address.startswith("0x") else address + path = os.path.join(xdg, "pyc3l", "nonce-lock", bare.lower()) + os.makedirs(os.path.dirname(path), exist_ok=True) + fd = os.open(path, os.O_RDWR | os.O_CREAT, 0o600) + fcntl.flock(fd, fcntl.LOCK_EX) + return fd + + def test_logs_wait_and_acquires_after_release(self): + b = self.make_node(start_nonce=9) + p = self.make_pyc3l(b) + + ## Hold the lock from "another sender", release it shortly after. + held_fd = self._hold_lock(FAKE_ACCOUNT) + + def release_soon(): + time.sleep(0.15) + import fcntl + + fcntl.flock(held_fd, fcntl.LOCK_UN) + os.close(held_fd) + + releaser = threading.Thread(target=release_soon) + + ## Speed up the acquisition poll. + prev = pyc3l.NONCE_LOCK_POLL_INTERVAL + pyc3l.NONCE_LOCK_POLL_INTERVAL = 0.02 + try: + releaser.start() + with self.assertLogs("pyc3l", level="INFO") as cm: + t0 = time.time() + p.send_transaction(("0xc", "0xf"), "", FakeAccount()) + waited = time.time() - t0 + releaser.join() + finally: + pyc3l.NONCE_LOCK_POLL_INTERVAL = prev + + ## We blocked until the holder released (~0.15s). + self.assertGreaterEqual(waited, 0.1) + ## The wait was logged (start + acquired-after). + logs = "\n".join(cm.output) + self.assertIn("held by another sender, waiting", logs) + self.assertIn("acquired after waited=", logs) + ## And the send still went through with the trusted nonce. + self.assertEqual(b.sent, [9]) + + def test_timeout_raises_nonce_lock_timeout(self): + b = self.make_node(start_nonce=9) + p = self.make_pyc3l(b) + + ## Hold the lock for the whole test; never release. + held_fd = self._hold_lock(FAKE_ACCOUNT) + + prev_to = pyc3l.NONCE_LOCK_TIMEOUT + prev_iv = pyc3l.NONCE_LOCK_POLL_INTERVAL + pyc3l.NONCE_LOCK_TIMEOUT = 0.1 + pyc3l.NONCE_LOCK_POLL_INTERVAL = 0.02 + try: + with self.assertRaises(NonceLockTimeout) as ctx: + p.send_transaction(("0xc", "0xf"), "", FakeAccount()) + finally: + pyc3l.NONCE_LOCK_TIMEOUT = prev_to + pyc3l.NONCE_LOCK_POLL_INTERVAL = prev_iv + import fcntl + + fcntl.flock(held_fd, fcntl.LOCK_UN) + os.close(held_fd) + + e = ctx.exception + self.assertEqual(e.address, FAKE_ACCOUNT) + self.assertGreaterEqual(e.waited, 0.1) + ## Nothing was broadcast: we never entered the critical section. + self.assertEqual(b.sent, []) + + +if __name__ == "__main__": + unittest.main()