From 40e0f0894a1147596f168235695eeaf216de22d2 Mon Sep 17 00:00:00 2001 From: anderdc Date: Fri, 24 Jul 2026 13:53:11 -0500 Subject: [PATCH 1/2] fix(swap): keep a verified deposit's claim window from lapsing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A taker on the manual path sends source funds, then relays them with `alw swap post-tx`. submit_swap_claim requires reserved_until >= now, and if the reservation lapses in between there is no claim, no Swap, no timeout and no refund — the deposit is simply gone, to the miner, in silence. Three TAO sends were lost this way today. Two independent gaps, one on each side of the send. Validator: confirm_deposit now slides reserved_until forward when a verified deposit arrives with little runway left, before submitting the claim. Evidence-gated — the deposit has already matched the pinned reservation, so we only extend for a taker who demonstrably sent funds, never on request. This is the same justification the crank extends under, one step earlier in the lifecycle: the crank can only act once a Swap exists, and here there is no Swap precisely because the claim has not landed. Best-effort; a failed extension still attempts the claim, since the reservation may have just enough runway and a claim that lands beats a clean error path. CLI: the manual path now prints the deadline as a wall-clock instant plus the raw reserved_until, and says plainly that missing it is unrecoverable. _SEND_MARGIN_SECS is checked once, at reservation time; after that the send and the relay happen outside this process and nothing re-checks it. A bare countdown is stale the moment it prints and useless in an agent's log, hence the absolute time. When --send was not used it also points at `alw swap now --send`, which keeps send + relay + watch in-process where the guard still applies. Limits, stated plainly: neither change recovers an already-expired reservation (the contract forbids extending one), and neither helps a taker who sends with no reservation at all. This closes the honest race — deposit verified, runway nearly gone — not every way to lose funds here. The structural gap remains that source funds go to the miner before a Swap exists, with no escrow. --- allways/cli/swap_commands/swap.py | 27 +++++++++++++- allways/validator/reserve_engine.py | 37 +++++++++++++++++++ tests/test_reserve_engine.py | 56 +++++++++++++++++++++++++++++ tests/test_swap_now_reservation.py | 31 ++++++++++++++++ 4 files changed, 150 insertions(+), 1 deletion(-) diff --git a/allways/cli/swap_commands/swap.py b/allways/cli/swap_commands/swap.py index 2defb36c..a79b0c1d 100644 --- a/allways/cli/swap_commands/swap.py +++ b/allways/cli/swap_commands/swap.py @@ -11,7 +11,7 @@ import json import sys import time -from typing import NamedTuple, Optional +from typing import List, NamedTuple, Optional import click @@ -568,6 +568,31 @@ def swap_now_command( f'[green] Reserved.[/green] Send [cyan]{amount_opt} {from_chain.upper()}[/cyan] to ' f'[cyan]{resv.miner_from_addr}[/cyan], then run [bold]alw swap post-tx[/bold] with the tx hash.' ) + for line in _deadline_lines(int(resv.reserved_until), want_send): + console.print(line) + + +def _deadline_lines(reserved_until: int, want_send: bool, now: Optional[int] = None) -> List[str]: + """The send-deadline notice for the manual path, where the taker sends outside this process. + + `_SEND_MARGIN_SECS` is checked once, at reservation time. After that nothing re-checks it: the + send and the relay happen elsewhere, and a deposit that lands after `reserved_until` yields no + claim, no Swap, no timeout and no refund. So state the deadline as a wall-clock instant — a bare + countdown is stale the moment it prints, and an agent that logs it can't tell when it was true. + `reserved_until` is echoed raw so a script can parse it instead of the rendered time.""" + now = int(time.time()) if now is None else now + at = time.strftime('%H:%M:%S UTC', time.gmtime(reserved_until)) + lines = [ + f' [yellow]Deadline:[/yellow] post-tx must complete by [cyan]{at}[/cyan] ' + f'([cyan]{max(0, reserved_until - now)}s[/cyan] from now, reserved_until={reserved_until}). ' + 'Miss it and the deposit is unrecoverable — there is no refund.' + ] + if not want_send: + lines.append( + ' [dim]Sending outside `alw` forfeits the pre-send safety check. ' + '`alw swap now --send` does the send, the relay and the watch in one step.[/dim]' + ) + return lines def _source_provider(from_chain: str, client, config): diff --git a/allways/validator/reserve_engine.py b/allways/validator/reserve_engine.py index 696b815e..c3c8269d 100644 --- a/allways/validator/reserve_engine.py +++ b/allways/validator/reserve_engine.py @@ -234,6 +234,38 @@ class ConfirmResult: sig: str = '' +# Runway submit_swap_claim needs to land after a deposit verifies. Only the claim tx remains at this +# point — the send and the relay are already done — so this is deliberately much shorter than the +# CLI's pre-send margin. Once the Swap exists the crank owns every later extension. +CLAIM_RELAY_MARGIN_SECS = 90 + + +def _extend_for_claim(client, miner_pk, reservation, now: int) -> None: + """Slide `reserved_until` forward so the pending claim can land, when a verified deposit arrives + with little runway left. + + Evidence-gated on purpose: the caller has already matched this deposit against the pinned + reservation, so we only ever extend for a taker who demonstrably sent funds — never on request. + That is the same justification the crank extends under, one step earlier in the lifecycle: the + crank can only help once a Swap exists, and here there is no Swap yet precisely because the claim + has not landed. + + Best-effort. A failed extension must not sink the claim — the reservation may still have just + enough runway, and a claim that lands is worth more than a clean error path.""" + reserved_until = int(getattr(reservation, 'reserved_until', 0) or 0) + ceiling = int(getattr(reservation, 'max_extend_at', 0) or 0) + if reserved_until - now >= CLAIM_RELAY_MARGIN_SECS: + return # plenty of runway + target = min(now + CLAIM_RELAY_MARGIN_SECS, ceiling) + if target <= reserved_until: + return # already at the contract ceiling — nothing left to buy + try: + client.extend_reservation(miner_pk, target) + bt.logging.info(f'claim runway: extended reserved_until {reserved_until} -> {target} (+{target - now}s)') + except Exception as e: # noqa: BLE001 - never block the claim on a failed extension + bt.logging.warning(f'claim runway: extend_reservation failed ({e}); attempting the claim anyway') + + def confirm_deposit(validator, miner_hotkey: str, from_tx_hash: str, from_tx_block: int = 0) -> ConfirmResult: """Relay a user's source deposit into a claim: verify the tx against the pinned reservation, then submit_swap_claim (creating the Swap in PendingAttestation). Accepts a content-valid deposit even before @@ -285,6 +317,11 @@ def confirm_deposit(validator, miner_hotkey: str, from_tx_hash: str, from_tx_blo if not is_tx_fresh(tx_info, int(reservation.created_at), grace): return ConfirmResult(False, 'Source tx fails freshness — stale/replayed deposit') + # The taker's funds are already on the source chain and this deposit just verified against the + # pinned reservation — but submit_swap_claim needs reserved_until >= now, and once it lapses there + # is no claim, no Swap, no timeout and no refund: the deposit is simply lost. Buy runway first. + _extend_for_claim(client, miner_pk, reservation, now) + swap_key = swap_key_from_tx_hash(from_tx_hash) sig = client.submit_swap_claim(miner_pk, swap_key, from_tx_hash, tx_info.block_number or 0) return ConfirmResult(True, '', swap_key.hex(), sig) diff --git a/tests/test_reserve_engine.py b/tests/test_reserve_engine.py index b2cad02d..037e2c83 100644 --- a/tests/test_reserve_engine.py +++ b/tests/test_reserve_engine.py @@ -6,6 +6,7 @@ import tempfile import threading +import time from pathlib import Path from types import SimpleNamespace @@ -366,6 +367,7 @@ def test_malformed_swap_key_raises_value_error(tmp_path): # ── confirm_deposit: deferred-confirmation intake. Accepts a content-valid deposit even before it fully # confirms (the crank defers voting until confirmations accrue); fast-fails without a claim on absent/mismatch # (None) or a stale MINED deposit, so the short reservation TTL frees the miner. +import allways.validator.reserve_engine as rc # noqa: E402 from allways.chain_providers.base import ProviderUnreachableError, TransactionInfo # noqa: E402 from allways.validator.reserve_engine import confirm_deposit # noqa: E402 @@ -377,6 +379,8 @@ def __init__(self, reservation, **kw): super().__init__(**kw) self._reservation = reservation self.claims = [] + self.extensions = [] + self.extend_raises = False def get_reservation(self, miner): return self._reservation @@ -385,6 +389,12 @@ def submit_swap_claim(self, miner, swap_key, from_tx_hash, from_tx_block): self.claims.append((swap_key, from_tx_hash, from_tx_block)) return 'claimsig' + def extend_reservation(self, miner, target_at): + if self.extend_raises: + raise RuntimeError('rpc down') + self.extensions.append(target_at) + return 'extendsig' + class _FakeProvider: def __init__(self, tx_info, *, unreachable=False, grace=0): @@ -410,6 +420,7 @@ def _confirm_reservation(**over): from_amount=100_000, from_addr='userBTC', created_at=CONFIRM_CREATED_AT, + max_extend_at=FUTURE, ) d.update(over) return SimpleNamespace(**d) @@ -486,3 +497,48 @@ def test_confirm_rejects_when_reservation_already_claimed(): def test_confirm_provider_unreachable_resends_without_claim(): r, client = _confirm(_confirm_reservation(), None, unreachable=True) assert not r.ok and not client.claims and 'unreachable' in r.reason.lower() + + +# ── claim runway: a verified deposit must not lose its window mid-relay ────── +# submit_swap_claim needs reserved_until >= now. If it lapses between the taker sending and the +# relay landing there is no claim, no Swap, no timeout and no refund — the deposit is just gone. +# So a deposit that has already verified against the pinned reservation buys runway first. +def _near_expiry(secs_left, **over): + return _confirm_reservation(reserved_until=int(time.time()) + secs_left, **over) + + +def test_confirm_extends_reservation_when_runway_is_short(): + r, client = _confirm(_near_expiry(20), _tx(confirmed=False, block_time=None)) + assert r.ok and client.claims, 'the claim must still be submitted' + assert len(client.extensions) == 1 + # Extended to a real margin ahead of now, not merely one second past the old deadline. + assert client.extensions[0] >= int(time.time()) + rc.CLAIM_RELAY_MARGIN_SECS - 5 + + +def test_confirm_does_not_extend_when_runway_is_ample(): + # Don't burn an extension (or the ceiling budget) on a reservation that has plenty left. + r, client = _confirm(_near_expiry(rc.CLAIM_RELAY_MARGIN_SECS + 60), _tx(confirmed=False, block_time=None)) + assert r.ok and client.claims + assert client.extensions == [] + + +def test_confirm_does_not_extend_past_the_contract_ceiling(): + # max_extend_at is frozen at creation; the contract rejects a target above it, so don't try. + now = int(time.time()) + resv = _confirm_reservation(reserved_until=now + 20, max_extend_at=now + 20) + r, client = _confirm(resv, _tx(confirmed=False, block_time=None)) + assert r.ok and client.claims, 'no headroom left, but the claim is still worth attempting' + assert client.extensions == [] + + +def test_confirm_claims_even_if_the_extension_fails(): + # Best-effort: the reservation may still have just enough runway, and a claim that lands beats a + # clean error path. A failed extension must never sink the deposit. + client = _ConfirmClient(_near_expiry(20)) + client.extend_raises = True + provider = _FakeProvider(_tx(confirmed=False, block_time=None)) + validator = SimpleNamespace( + solana_client=client, axon_chain_providers={'btc': provider}, axon_lock=threading.RLock() + ) + r = confirm_deposit(validator, HOTKEY, 'srctxhash') + assert r.ok and client.claims diff --git a/tests/test_swap_now_reservation.py b/tests/test_swap_now_reservation.py index a1283617..9d918726 100644 --- a/tests/test_swap_now_reservation.py +++ b/tests/test_swap_now_reservation.py @@ -14,6 +14,7 @@ from allways.cli.swap_commands.helpers import live_unclaimed from allways.cli.swap_commands.swap import ( _SEND_MARGIN_SECS, + _deadline_lines, _poll_drawn, _poll_reservation, _self_crank_resolve, @@ -400,3 +401,33 @@ def test_watch_swap_reports_completed_after_close(monkeypatch): swap_mod._watch_swap(client, 'ab' * 32, 'tao') joined = ' '.join(out) assert 'COMPLETED' in joined and 'never existed' not in joined + + +# ── manual-path deadline notice ───────────────────────────────────────────── +# _SEND_MARGIN_SECS is checked once, at reservation time. On the manual path the send and the relay +# happen outside this process, so nothing re-checks it — and a deposit landing after reserved_until +# yields no claim, no Swap, no timeout and no refund. The notice is the only thing standing between +# an out-of-band sender and a silent, permanent loss. +def test_deadline_notice_states_the_wall_clock_instant(): + now = 1_700_000_000 + lines = _deadline_lines(now + 300, want_send=False, now=now) + body = ' '.join(lines) + assert '300s' in body # runway from the caller's `now`, not from import time + assert 'reserved_until=1700000300' in body # raw, so an agent can parse it + assert time.strftime('%H:%M:%S UTC', time.gmtime(now + 300)) in body + assert 'no refund' in body + + +def test_deadline_notice_warns_only_when_not_auto_sending(): + now = 1_700_000_000 + manual = ' '.join(_deadline_lines(now + 300, want_send=False, now=now)) + assert 'forfeits the pre-send safety check' in manual + # --send keeps send+relay in-process, so the guard still applies — don't cry wolf. + auto = ' '.join(_deadline_lines(now + 300, want_send=True, now=now)) + assert 'forfeits' not in auto + + +def test_deadline_notice_never_shows_negative_runway(): + now = 1_700_000_000 + body = ' '.join(_deadline_lines(now - 50, want_send=False, now=now)) + assert '0s' in body and '-50' not in body From e720255b73588abce1f59b3fceab191ff15b0fa1 Mon Sep 17 00:00:00 2001 From: anderdc Date: Fri, 24 Jul 2026 16:14:55 -0500 Subject: [PATCH 2/2] fix(swap): recapture the clock for the claim extension; always warn on manual send MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review follow-ups. 1. `_extend_for_claim` took `now` from `confirm_deposit`, stamped *before* `verify_transaction` — a source-chain RPC that can burn seconds on BTC. The stale clock cut both ways: the runway check could read "ample" while the real window was already under the margin, and an extension computed off it bought less than 90s of real time. It now re-reads the clock itself; the param is gone. 2. On the manual path the "forfeits the pre-send safety check" warning was gated on `want_send`, so a `--send` run that fell back to manual (missing creds, wrong wallet) never saw it — the audience that needs it most. The warning is now unconditional; only the `alw swap now --send` hint stays gated, since it's redundant for someone who just asked for it. Tests: the new runway test hangs the source RPC on a fake clock and asserts the extension fires — it fails against the pre-fix signature. Deadline-notice tests split to cover warning-always / hint-gated. 804 -> 806 passed. ruff check + format clean. --- allways/cli/swap_commands/swap.py | 9 +++++---- allways/validator/reserve_engine.py | 7 +++++-- tests/test_reserve_engine.py | 26 ++++++++++++++++++++++++++ tests/test_swap_now_reservation.py | 19 +++++++++++++------ 4 files changed, 49 insertions(+), 12 deletions(-) diff --git a/allways/cli/swap_commands/swap.py b/allways/cli/swap_commands/swap.py index a79b0c1d..43cf79d8 100644 --- a/allways/cli/swap_commands/swap.py +++ b/allways/cli/swap_commands/swap.py @@ -587,11 +587,12 @@ def _deadline_lines(reserved_until: int, want_send: bool, now: Optional[int] = N f'([cyan]{max(0, reserved_until - now)}s[/cyan] from now, reserved_until={reserved_until}). ' 'Miss it and the deposit is unrecoverable — there is no refund.' ] + # The warning is unconditional: reaching this notice means the send happens outside `alw`, and + # --send that fell back to manual (no creds, wrong wallet) is exactly the case that needs telling. + # Only the hint is gated — pointless for someone who just asked for --send. + lines.append(' [dim]Sending outside `alw` forfeits the pre-send safety check.[/dim]') if not want_send: - lines.append( - ' [dim]Sending outside `alw` forfeits the pre-send safety check. ' - '`alw swap now --send` does the send, the relay and the watch in one step.[/dim]' - ) + lines.append(' [dim]`alw swap now --send` does the send, the relay and the watch in one step.[/dim]') return lines diff --git a/allways/validator/reserve_engine.py b/allways/validator/reserve_engine.py index c3c8269d..faba831e 100644 --- a/allways/validator/reserve_engine.py +++ b/allways/validator/reserve_engine.py @@ -240,7 +240,7 @@ class ConfirmResult: CLAIM_RELAY_MARGIN_SECS = 90 -def _extend_for_claim(client, miner_pk, reservation, now: int) -> None: +def _extend_for_claim(client, miner_pk, reservation) -> None: """Slide `reserved_until` forward so the pending claim can land, when a verified deposit arrives with little runway left. @@ -252,6 +252,9 @@ def _extend_for_claim(client, miner_pk, reservation, now: int) -> None: Best-effort. A failed extension must not sink the claim — the reservation may still have just enough runway, and a claim that lands is worth more than a clean error path.""" + # Re-read the clock rather than take the caller's: verify_transaction is a source-chain RPC that + # can burn seconds, and a stale `now` both overstates the runway and undershoots the target. + now = int(time.time()) reserved_until = int(getattr(reservation, 'reserved_until', 0) or 0) ceiling = int(getattr(reservation, 'max_extend_at', 0) or 0) if reserved_until - now >= CLAIM_RELAY_MARGIN_SECS: @@ -320,7 +323,7 @@ def confirm_deposit(validator, miner_hotkey: str, from_tx_hash: str, from_tx_blo # The taker's funds are already on the source chain and this deposit just verified against the # pinned reservation — but submit_swap_claim needs reserved_until >= now, and once it lapses there # is no claim, no Swap, no timeout and no refund: the deposit is simply lost. Buy runway first. - _extend_for_claim(client, miner_pk, reservation, now) + _extend_for_claim(client, miner_pk, reservation) swap_key = swap_key_from_tx_hash(from_tx_hash) sig = client.submit_swap_claim(miner_pk, swap_key, from_tx_hash, tx_info.block_number or 0) diff --git a/tests/test_reserve_engine.py b/tests/test_reserve_engine.py index 037e2c83..e5c87b96 100644 --- a/tests/test_reserve_engine.py +++ b/tests/test_reserve_engine.py @@ -515,6 +515,32 @@ def test_confirm_extends_reservation_when_runway_is_short(): assert client.extensions[0] >= int(time.time()) + rc.CLAIM_RELAY_MARGIN_SECS - 5 +def test_confirm_measures_runway_after_the_source_rpc(monkeypatch): + # verify_transaction is a source-chain RPC that can burn seconds on BTC. Runway read before it + # runs can say "ample" while the real window is already short, and an extension computed off that + # stale clock buys less than the margin — so the helper re-reads the clock. + start = int(time.time()) + clock = {'t': start} + resv = _confirm_reservation(reserved_until=start + rc.CLAIM_RELAY_MARGIN_SECS + 30, max_extend_at=start + 10_000) + client = _ConfirmClient(resv) + + class _SlowProvider(_FakeProvider): + def verify_transaction(self, **kw): + clock['t'] += 60 # the RPC hung; the window shrank while we waited + return super().verify_transaction(**kw) + + validator = SimpleNamespace( + solana_client=client, + axon_chain_providers={'btc': _SlowProvider(_tx(confirmed=False, block_time=None))}, + axon_lock=threading.RLock(), + ) + monkeypatch.setattr(rc.time, 'time', lambda: clock['t']) + r = confirm_deposit(validator, HOTKEY, 'srctxhash') + assert r.ok and client.claims + # Off the pre-RPC clock this reservation looks ample and never extends. + assert client.extensions == [clock['t'] + rc.CLAIM_RELAY_MARGIN_SECS] + + def test_confirm_does_not_extend_when_runway_is_ample(): # Don't burn an extension (or the ceiling budget) on a reservation that has plenty left. r, client = _confirm(_near_expiry(rc.CLAIM_RELAY_MARGIN_SECS + 60), _tx(confirmed=False, block_time=None)) diff --git a/tests/test_swap_now_reservation.py b/tests/test_swap_now_reservation.py index 9d918726..104ab8df 100644 --- a/tests/test_swap_now_reservation.py +++ b/tests/test_swap_now_reservation.py @@ -418,13 +418,20 @@ def test_deadline_notice_states_the_wall_clock_instant(): assert 'no refund' in body -def test_deadline_notice_warns_only_when_not_auto_sending(): +def test_deadline_notice_always_warns_that_the_guard_is_forfeit(): + # Reaching this notice means the send happens outside `alw` either way. --send that degraded to + # manual (missing creds, wrong wallet) is the case that most needs telling, so never gate this. now = 1_700_000_000 - manual = ' '.join(_deadline_lines(now + 300, want_send=False, now=now)) - assert 'forfeits the pre-send safety check' in manual - # --send keeps send+relay in-process, so the guard still applies — don't cry wolf. - auto = ' '.join(_deadline_lines(now + 300, want_send=True, now=now)) - assert 'forfeits' not in auto + for want_send in (False, True): + body = ' '.join(_deadline_lines(now + 300, want_send=want_send, now=now)) + assert 'forfeits the pre-send safety check' in body + + +def test_deadline_notice_hints_at_send_only_when_it_was_not_asked_for(): + now = 1_700_000_000 + assert '--send' in ' '.join(_deadline_lines(now + 300, want_send=False, now=now)) + # Redundant for a taker who already asked for --send and fell through to manual. + assert '--send' not in ' '.join(_deadline_lines(now + 300, want_send=True, now=now)) def test_deadline_notice_never_shows_negative_runway():