From d7f758809b0d669786f8fc94fab7cb687b98d344 Mon Sep 17 00:00:00 2001 From: Seth For Privacy <40500387+sethforprivacy@users.noreply.github.com> Date: Fri, 23 Oct 2026 14:04:58 -0400 Subject: [PATCH] autoforward: never send to a Kraken address that is about to expire Kraken now gives deposit addresses an expiry (`expiretm`; the LTC ones last about 7 days, some never expire). A deposit that lands after expiry "may not be possible" to process, or costs a recovery fee. Kraken hands back its newest unused address until a deposit lands on it. So an address left unused by a failed pass (a fee check, a broadcast error) keeps coming back until it expires, and the address code never looked at the expiry. An address now has to stay valid for KRAKEN_ADDRESS_MIN_VALIDITY seconds (default 86400). The rule applies to both lists Kraken returns, and to the existing address a dry run reuses. When Kraken offers only addresses expiring sooner, the coin's pass fails with an error naming the margin, so Zabbix alerts and nothing is sent. Against Kraken's real lists today (read-only): `expiretm` comes as a string, "0" for never. 4 BTC and 1 LTC addresses expire within 24h, and every coin still has a valid address for a dry run. Tests: the Kraken fake can give addresses an expiry. A live BTC forward handed an address expiring in an hour must raise and broadcast nothing, and one valid for 7 days still forwards. Ignoring the expiry fails the first test. Co-Authored-By: Claude Opus 5.5 --- src/autoforward.py | 17 +++++++++++++---- src/env.py | 3 +++ tests/test_dry_run.py | 28 ++++++++++++++++++++++++++-- 3 files changed, 42 insertions(+), 6 deletions(-) diff --git a/src/autoforward.py b/src/autoforward.py index 634cf65..239a700 100644 --- a/src/autoforward.py +++ b/src/autoforward.py @@ -239,23 +239,32 @@ def get_new_kraken_address(asset: Literal['btc', 'ltc', 'xmr', 'zec']) -> str: 'method': sym_to_name[asset] } + # Kraken returns its newest unused address until a deposit lands on it, + # so one left unused by a failed pass keeps coming back until it expires. + def valid(addr) -> bool: + expires = int(addr.get('expiretm') or 0) # 0: never + return not expires or expires - time() >= env.KRAKEN_ADDRESS_MIN_VALIDITY + result = util.kraken_request('/0/private/DepositAddresses', payload) - first_new_address = next((addr for addr in result if addr.get('new', False)), None) + first_new_address = next((addr for addr in result if addr.get('new', False) and valid(addr)), None) if first_new_address: return first_new_address['address'] elif env.DRY_RUN and result: # Creating addresses is a write on the Kraken account; a dry run # reuses an existing one. - return result[0]['address'] + existing = next((addr for addr in result if valid(addr)), None) + if existing: + return existing['address'] else: payload['new'] = True result = util.kraken_request('/0/private/DepositAddresses', payload) - first_new_address = next((addr for addr in result if addr.get('new', False)), None) + first_new_address = next((addr for addr in result if addr.get('new', False) and valid(addr)), None) if first_new_address: return first_new_address['address'] - raise Exception(f'Kraken did not return a new address: {json.dumps(result, indent=2)}') + raise Exception(f'Kraken returned no {asset} address that is new and expires more than ' + f'{env.KRAKEN_ADDRESS_MIN_VALIDITY}s from now: {json.dumps(result, indent=2)}') def attempt_electrum_autoforward(coin: ElectrumCoin): coin_upper = coin.upper() diff --git a/src/env.py b/src/env.py index d957dfe..918629d 100644 --- a/src/env.py +++ b/src/env.py @@ -93,6 +93,9 @@ def _csv(name: str, default: str) -> list[str]: KRAKEN_API_KEY = os.getenv('KRAKEN_API_KEY', '') KRAKEN_API_SECRET = os.getenv('KRAKEN_API_SECRET', '') +# Seconds a Kraken deposit address must stay valid (`expiretm`) to be sent +# to: a deposit landing after expiry may be lost or cost a recovery fee. +KRAKEN_ADDRESS_MIN_VALIDITY = int(os.getenv('KRAKEN_ADDRESS_MIN_VALIDITY', '86400')) MAX_NETWORK_FEE_PERCENT = float(os.getenv('MAX_NETWORK_FEE_PERCENT', '5')) MAX_SLIPPAGE_PERCENT = float(os.getenv('MAX_SLIPPAGE_PERCENT', '0.5')) diff --git a/tests/test_dry_run.py b/tests/test_dry_run.py index 28c0a62..2e2f1bb 100644 --- a/tests/test_dry_run.py +++ b/tests/test_dry_run.py @@ -18,6 +18,7 @@ import json import os import sys +import time import unittest from unittest import mock @@ -44,8 +45,10 @@ def raise_for_status(self): class Backends: """Canned answers for exactly the calls the forward/sale paths make.""" - def __init__(self, utxos=None, tip=1000, zcash_ready=True, zcash_empty_pools=()): + def __init__(self, utxos=None, tip=1000, zcash_ready=True, zcash_empty_pools=(), kraken_address_expires_in=None): self.sent = [] + # Seconds until Kraken's deposit addresses expire (`expiretm`); None: never. + self.kraken_address_expires_in = kraken_address_expires_in self.zcash_ready = zcash_ready self.zcash_empty_pools = zcash_empty_pools # One mature output by default; tests of selection pass their own. @@ -134,7 +137,9 @@ def monero(self, method, params): def kraken(self, path, data): if path.startswith('/0/private/DepositAddresses'): address = 't1krakenaddress' if data.get('asset') == 'zec' else 'kraken-address' - return [{'address': address, 'new': bool(data.get('new'))}] + expires = self.kraken_address_expires_in + expiretm = '0' if expires is None else str(int(time.time()) + expires) + return [{'address': address, 'expiretm': expiretm, 'new': bool(data.get('new'))}] if path.startswith('/0/public/AssetPairs'): wanted = data.get('pair') if wanted and not any(wanted in (pair_id, p['altname']) for pair_id, p in self.PAIRS.items()): @@ -313,6 +318,25 @@ def test_mweb_is_pegged_out_to_an_ltc_change_address_not_sent_to_kraken(self): [p for b, method, p in backends.sent if method == 'listaddresses']) +class KrakenAddressExpiry(unittest.TestCase): + def test_never_forwards_to_an_address_about_to_expire(self): + # Kraken hands back its newest unused address until a deposit lands + # on it, so an address left unused by a failed pass keeps coming back + # until it expires; a deposit to an expired address may be lost. + backends = Backends(kraken_address_expires_in=3600) + with mock.patch('requests.post', backends.post), mock.patch('requests.get', backends.get): + module = load('autoforward', dry_run=False, kraken_key=True) + with self.assertRaisesRegex(Exception, 'expires'): + module.attempt_electrum_autoforward('btc') + self.assertEqual(backends.fund_moving(), ['kraken new deposit address']) + + def test_forwards_to_an_address_valid_past_the_margin(self): + backends = Backends(kraken_address_expires_in=7 * 86400) + with mock.patch('requests.post', backends.post), mock.patch('requests.get', backends.get): + load('autoforward', dry_run=False, kraken_key=True).attempt_electrum_autoforward('btc') + self.assertIn('electrum broadcast', backends.fund_moving()) + + class BackendFailures(unittest.TestCase): def test_electrum_restart_reloads_the_wallet(self): # A restarted Electrum daemon answers 'wallet not loaded' until