Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 27 additions & 1 deletion allways/cli/swap_commands/swap.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
import json
import sys
import time
from typing import NamedTuple, Optional
from typing import List, NamedTuple, Optional

import click

Expand Down Expand Up @@ -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:

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Small gap: with --send, if _auto_send_wizard falls back to manual (missing creds, wrong wallet), we still land on this notice — but want_send=True suppresses the warning, even though that user is now sending outside alw too. That's arguably the audience that needs it most: an agent whose auto-send silently degraded.

Suggest splitting the line: always print the "forfeits the pre-send safety check" warning when the manual notice is reached; only gate the alw swap now --send hint on want_send (it's redundant for someone who just tried it). Non-blocking.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed, and taken — also in e720255. Split exactly as you proposed:

  • unconditional: Sending outside alw forfeits the pre-send safety check.
  • gated on not want_send: `alw swap now --send` does the send, the relay and the watch in one step.

You are right that reaching this notice at all means the send is happening outside the process — _auto_send_wizard returning False (no source provider, wallet does not control the pinned from_addr, declined confirm, coldkey unlock failed) is the only way past it with want_send=True, and none of those leave the guard applying. The old gate keyed off intent when the only thing that matters is outcome.

Test split to match: test_deadline_notice_always_warns_that_the_guard_is_forfeit (both values of want_send) and test_deadline_notice_hints_at_send_only_when_it_was_not_asked_for.

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):
Expand Down
40 changes: 40 additions & 0 deletions allways/validator/reserve_engine.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down
82 changes: 82 additions & 0 deletions tests/test_reserve_engine.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@

import tempfile
import threading
import time
from pathlib import Path
from types import SimpleNamespace

Expand Down Expand Up @@ -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

Expand All @@ -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
Expand All @@ -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):
Expand All @@ -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)
Expand Down Expand Up @@ -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
38 changes: 38 additions & 0 deletions tests/test_swap_now_reservation.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
Loading