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
17 changes: 13 additions & 4 deletions src/autoforward.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
3 changes: 3 additions & 0 deletions src/env.py
Original file line number Diff line number Diff line change
Expand Up @@ -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'))
Expand Down
28 changes: 26 additions & 2 deletions tests/test_dry_run.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
import json
import os
import sys
import time
import unittest
from unittest import mock

Expand All @@ -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.
Expand Down Expand Up @@ -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()):
Expand Down Expand Up @@ -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
Expand Down
Loading