diff --git a/allways/cli/swap_commands/swap.py b/allways/cli/swap_commands/swap.py index 2defb36c..43cf79d8 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,32 @@ 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.' + ] + # 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]`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..faba831e 100644 --- a/allways/validator/reserve_engine.py +++ b/allways/validator/reserve_engine.py @@ -234,6 +234,41 @@ 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) -> 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.""" + # 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: + 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 +320,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) + 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..e5c87b96 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,74 @@ 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_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)) + 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..104ab8df 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,40 @@ 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_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 + 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(): + 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