From 2f6e52be738b83f6d041305c85b7efea1fb3d45b Mon Sep 17 00:00:00 2001 From: The MMGen Project Date: Tue, 4 Mar 2025 09:51:05 +0000 Subject: [PATCH] mmgen-swaptx{create,do}: add price protection via --trade-limit option For more information, see: $ mmgen-swaptxcreate --help Testing: $ test/modtest.py tx.memo misc.int_exp_notation $ test/cmdtest.py swap --- mmgen/data/release_date | 2 +- mmgen/data/version | 2 +- mmgen/help/swaptxcreate.py | 19 ++++++++++++ mmgen/main_txcreate.py | 2 ++ mmgen/main_txdo.py | 2 ++ mmgen/proto/btc/tx/base.py | 7 +++++ mmgen/proto/btc/tx/new_swap.py | 11 +++++++ mmgen/swap/proto/thorchain/__init__.py | 6 ++++ mmgen/swap/proto/thorchain/memo.py | 30 ++++++++++++++----- mmgen/swap/proto/thorchain/midgard.py | 26 +++++++++++++--- mmgen/tx/bump.py | 1 + mmgen/tx/new.py | 1 + mmgen/tx/new_swap.py | 21 ++++++++++++- test/cmdtest_d/ct_swap.py | 41 ++++++++++++++++++-------- test/modtest_d/ut_tx.py | 19 ++++++++---- 15 files changed, 159 insertions(+), 31 deletions(-) diff --git a/mmgen/data/release_date b/mmgen/data/release_date index 44800928..db6f1117 100644 --- a/mmgen/data/release_date +++ b/mmgen/data/release_date @@ -1 +1 @@ -February 2025 +March 2025 diff --git a/mmgen/data/version b/mmgen/data/version index 86ce4c31..eaaddb10 100644 --- a/mmgen/data/version +++ b/mmgen/data/version @@ -1 +1 @@ -15.1.dev17 +15.1.dev18 diff --git a/mmgen/help/swaptxcreate.py b/mmgen/help/swaptxcreate.py index bc8e5947..86efe3c3 100755 --- a/mmgen/help/swaptxcreate.py +++ b/mmgen/help/swaptxcreate.py @@ -79,4 +79,23 @@ transaction fees, ‘mmnode-feeview’ and ‘mmnode-blocks-info’, in addition ‘mmnode-ticker’, which can be used to calculate the current cross-rate between the asset pair of a swap, as well as the total receive value in terms of the send value. + + + TRADE LIMIT + +A target value for the swap may be set, known as the “trade limit”. If +this target cannot be met, the network will refund the user’s coins, minus +transaction fees (note that the refund goes to the address associated with the +transaction’s first input, leading to coin reuse). Since under certain +circumstances large amounts of slippage can occur, resulting in significant +losses, setting a trade limit is highly recommended. + +The target may be given as either an absolute coin amount or percentage value. +In the latter case, it’s interpreted as the percentage below the “expected +amount out” returned by the swap quote server. Zero or negative percentage +values are also accepted, but are likely to result in your coins being +refunded. + +The trade limit is rounded to four digits of precision in order to reduce +transaction size. """ diff --git a/mmgen/main_txcreate.py b/mmgen/main_txcreate.py index fe1f634a..5e3a84d8 100755 --- a/mmgen/main_txcreate.py +++ b/mmgen/main_txcreate.py @@ -63,6 +63,8 @@ opts_data = { + MMGen IDs or coin addresses). Note that ALL unspent + outputs associated with each address will be included. bt -l, --locktime= t Lock time (block height or unix seconds) (default: 0) + -s -l, --trade-limit=L Minimum swap amount, as either percentage or absolute + + coin amount (see TRADE LIMIT below) b- -L, --autochg-ignore-labels Ignore labels when autoselecting change addresses -- -m, --minconf= n Minimum number of confirmations required to spend + outputs (default: 1) diff --git a/mmgen/main_txdo.py b/mmgen/main_txdo.py index 5d1dcefa..08873bb4 100755 --- a/mmgen/main_txdo.py +++ b/mmgen/main_txdo.py @@ -70,6 +70,8 @@ opts_data = { -- -k, --keys-from-file=f Provide additional keys for non-{pnm} addresses -- -K, --keygen-backend=n Use backend 'n' for public key generation. Options + for {coin_id}: {kgs} + -s -l, --trade-limit=L Minimum swap amount, as either percentage or absolute + + coin amount (see TRADE LIMIT below) bt -l, --locktime= t Lock time (block height or unix seconds) (default: 0) b- -L, --autochg-ignore-labels Ignore labels when autoselecting change addresses -- -m, --minconf=n Minimum number of confirmations required to spend diff --git a/mmgen/proto/btc/tx/base.py b/mmgen/proto/btc/tx/base.py index b5ebc634..a15b393e 100755 --- a/mmgen/proto/btc/tx/base.py +++ b/mmgen/proto/btc/tx/base.py @@ -305,6 +305,13 @@ class Base(TxBase): raise ValueError(f'{res}: too many data outputs in transaction (only one allowed)') return res[0] if len(res) == 1 else None + @data_output.setter + def data_output(self, val): + dbool = [bool(o.data) for o in self.outputs] + if dbool.count(True) != 1: + raise ValueError('more or less than one data output in transaction!') + self.outputs[dbool.index(True)] = val + @property def data_outputs(self): return [o for o in self.outputs if o.data] diff --git a/mmgen/proto/btc/tx/new_swap.py b/mmgen/proto/btc/tx/new_swap.py index c7367846..ed32cee6 100755 --- a/mmgen/proto/btc/tx/new_swap.py +++ b/mmgen/proto/btc/tx/new_swap.py @@ -128,6 +128,17 @@ class NewSwap(New, TxNewSwap): [f'vault,{args.send_amt}', chg_output.mmid, f'data:{memo}'] if args.send_amt else ['vault', f'data:{memo}']) + def update_data_output(self, trade_limit): + sp = self.swap_proto_mod + o = self.data_output._asdict() + parsed_memo = sp.data.parse(o['data'].decode()) + memo = sp.data( + self.recv_proto, + self.recv_proto.coin_addr(parsed_memo.address), + trade_limit = trade_limit) + o['data'] = f'data:{memo}' + self.data_output = self.Output(self.proto, **o) + def update_vault_addr(self, addr): vault_idx = self.vault_idx assert vault_idx == 0, f'{vault_idx}: vault index is not zero!' diff --git a/mmgen/swap/proto/thorchain/__init__.py b/mmgen/swap/proto/thorchain/__init__.py index 6b623e89..e2131e84 100755 --- a/mmgen/swap/proto/thorchain/__init__.py +++ b/mmgen/swap/proto/thorchain/__init__.py @@ -17,6 +17,7 @@ __all__ = ['data'] name = 'THORChain' class params: + exp_prec = 4 coins = { 'send': { 'BTC': 'Bitcoin', @@ -30,6 +31,11 @@ class params: } } +from ....util2 import ExpInt +class ExpInt4(ExpInt): + def __new__(cls, spec): + return ExpInt.__new__(cls, spec, prec=params.exp_prec) + def rpc_client(tx, amt): from .midgard import Midgard return Midgard(tx, amt) diff --git a/mmgen/swap/proto/thorchain/memo.py b/mmgen/swap/proto/thorchain/memo.py index ac520463..8fa3b301 100755 --- a/mmgen/swap/proto/thorchain/memo.py +++ b/mmgen/swap/proto/thorchain/memo.py @@ -20,7 +20,7 @@ class Memo: # The trade limit, i.e., set 100000000 to get a minimum of 1 full asset, else a refund # Optional. 1e8 or scientific notation - trade_limit = 0 + trade_limit = None # Swap interval in blocks. Optional. If 0, do not stream stream_interval = 1 @@ -102,7 +102,13 @@ class Memo: except ValueError: die('SwapMemoParseError', f'malformed memo (failed to parse {desc} field) [{lsq}]') - for n in (limit, interval, quantity): + from . import ExpInt4 + try: + limit_int = ExpInt4(limit) + except Exception as e: + die('SwapMemoParseError', str(e)) + + for n in (interval, quantity): if not is_int(n): die('SwapMemoParseError', f'malformed memo (non-integer in {desc} field [{lsq}])') @@ -113,18 +119,28 @@ class Memo: 'parsed_memo', ['proto', 'function', 'chain', 'asset', 'address', 'trade_limit', 'stream_interval', 'stream_quantity']) - return ret(proto_name, function, chain, asset, address, int(limit), int(interval), int(quantity)) + return ret(proto_name, function, chain, asset, address, limit_int, int(interval), int(quantity)) - def __init__(self, proto, addr, chain=None): + def __init__(self, proto, addr, chain=None, trade_limit=None): self.proto = proto self.chain = chain or proto.coin - from ....addr import CoinAddr - assert isinstance(addr, CoinAddr) + if trade_limit is None: + self.trade_limit = self.proto.coin_amt('0') + else: + assert type(trade_limit) is self.proto.coin_amt, f'{type(trade_limit)} != {self.proto.coin_amt}' + self.trade_limit = trade_limit + from ....addr import is_coin_addr + assert is_coin_addr(proto, addr) self.addr = addr.views[addr.view_pref] assert not ':' in self.addr # colon is record separator, so address mustn’t contain one def __str__(self): - suf = '/'.join(str(n) for n in (self.trade_limit, self.stream_interval, self.stream_quantity)) + from . import ExpInt4 + try: + tl_enc = ExpInt4(self.trade_limit.to_unit('satoshi')).enc + except Exception as e: + die('SwapMemoParseError', str(e)) + suf = '/'.join(str(n) for n in (tl_enc, self.stream_interval, self.stream_quantity)) asset = f'{self.chain}.{self.proto.coin}' ret = ':'.join([ self.function_abbrevs[self.function], diff --git a/mmgen/swap/proto/thorchain/midgard.py b/mmgen/swap/proto/thorchain/midgard.py index 12c12eb1..c091fd96 100755 --- a/mmgen/swap/proto/thorchain/midgard.py +++ b/mmgen/swap/proto/thorchain/midgard.py @@ -62,10 +62,10 @@ class Midgard: from ....util import pp_fmt, die die(2, pp_fmt(self.data)) - def format_quote(self, *, deduct_est_fee=False): + def format_quote(self, trade_limit, usr_trade_limit, *, deduct_est_fee=False): from ....util import make_timestr, ymsg from ....util2 import format_elapsed_hr - from ....color import blue, cyan, pink, orange + from ....color import blue, green, cyan, pink, orange, redbg, yelbg, grnbg from . import name d = self.data @@ -75,10 +75,28 @@ class Midgard: in_amt = self.in_amt out_amt = tx.recv_proto.coin_amt(int(d['expected_amount_out']), from_unit='satoshi') + if trade_limit: + from . import ExpInt4 + e = ExpInt4(trade_limit.to_unit('satoshi')) + tl_rounded = tx.recv_proto.coin_amt(e.trunc, from_unit='satoshi') + ratio = usr_trade_limit if type(usr_trade_limit) is float else float(tl_rounded / out_amt) + direction = 'ABOVE' if ratio > 1 else 'below' + mcolor, lblcolor = ( + (redbg, redbg) if (ratio < 0.93 or ratio > 0.999) else + (yelbg, yelbg) if ratio < 0.97 else + (green, grnbg)) + trade_limit_disp = f""" + {lblcolor('Trade limit:')} {tl_rounded.hl()} {out_coin} """ + mcolor( + f'({abs(1 - ratio) * 100:0.2f}% {direction} expected amount)') + tx_size_adj = len(e.enc) - 1 + else: + trade_limit_disp = '' + tx_size_adj = 0 + _amount_in_label = 'Amount in:' if deduct_est_fee: if d['gas_rate_units'] == 'satsperbyte': - in_amt -= tx.feespec2abs(d['recommended_gas_rate'] + 's', tx.estimate_size()) + in_amt -= tx.feespec2abs(d['recommended_gas_rate'] + 's', tx.estimate_size() + tx_size_adj) out_amt *= (in_amt / self.in_amt) _amount_in_label = 'Amount in (estimated):' else: @@ -102,7 +120,7 @@ class Midgard: Vault address: {cyan(d['inbound_address'])} Quote expires: {pink(elapsed_disp)} [{make_timestr(d['expiry'])}] {_amount_in_label:<22} {in_amt.hl()} {in_coin} - Expected amount out: {out_amt.hl()} {out_coin} + Expected amount out: {out_amt.hl()} {out_coin}{trade_limit_disp} Rate: {(out_amt / in_amt).hl()} {out_coin}/{in_coin} Reverse rate: {(in_amt / out_amt).hl()} {in_coin}/{out_coin} Recommended minimum in amount: {min_in_amt.hl()} {in_coin} diff --git a/mmgen/tx/bump.py b/mmgen/tx/bump.py index 5a454cc4..b08d5905 100755 --- a/mmgen/tx/bump.py +++ b/mmgen/tx/bump.py @@ -89,6 +89,7 @@ class Bump(Completed, NewSwap): if self.is_swap: self.send_proto = self.proto self.recv_proto = self.check_swap_memo().proto + self.process_swap_options() fee_hint = self.update_vault_output(self.send_amt) else: fee_hint = None diff --git a/mmgen/tx/new.py b/mmgen/tx/new.py index 72898ed2..53acffed 100755 --- a/mmgen/tx/new.py +++ b/mmgen/tx/new.py @@ -432,6 +432,7 @@ class New(Base): cmd_args, addrfile_args = self.get_addrfiles_from_cmdline(cmd_args) if self.is_swap: cmd_args = await self.process_swap_cmdline_args(cmd_args, addrfile_args) + self.process_swap_options() self.proto = self.send_proto # updating self.proto! from ..rpc import rpc_init self.rpc = await rpc_init(self.cfg, self.proto) diff --git a/mmgen/tx/new_swap.py b/mmgen/tx/new_swap.py index d39f10eb..665029f3 100755 --- a/mmgen/tx/new_swap.py +++ b/mmgen/tx/new_swap.py @@ -24,17 +24,35 @@ class NewSwap(New): self.swap_proto_mod = importlib.import_module(f'mmgen.swap.proto.{self.swap_proto}') New.__init__(self, *args, **kwargs) + def process_swap_options(self): + if s := self.cfg.trade_limit: + self.usr_trade_limit = ( + 1 - float(s[:-1]) / 100 if s.endswith('%') else + self.recv_proto.coin_amt(self.cfg.trade_limit)) + else: + self.usr_trade_limit = None + def update_vault_output(self, amt, *, deduct_est_fee=False): sp = self.swap_proto_mod c = sp.rpc_client(self, amt) from ..util import msg from ..term import get_char + + def get_trade_limit(): + if type(self.usr_trade_limit) is self.recv_proto.coin_amt: + return self.usr_trade_limit + elif type(self.usr_trade_limit) is float: + return ( + self.recv_proto.coin_amt(int(c.data['expected_amount_out']), from_unit='satoshi') + * self.usr_trade_limit) + while True: self.cfg._util.qmsg(f'Retrieving data from {c.rpc.host}...') c.get_quote() + trade_limit = get_trade_limit() self.cfg._util.qmsg('OK') - msg(c.format_quote(deduct_est_fee=deduct_est_fee)) + msg(c.format_quote(trade_limit, self.usr_trade_limit, deduct_est_fee=deduct_est_fee)) ch = get_char('Press ‘r’ to refresh quote, any other key to continue: ') msg('') if ch not in 'Rr': @@ -42,4 +60,5 @@ class NewSwap(New): self.swap_quote_expiry = c.data['expiry'] self.update_vault_addr(c.inbound_address) + self.update_data_output(trade_limit) return c.rel_fee_hint diff --git a/test/cmdtest_d/ct_swap.py b/test/cmdtest_d/ct_swap.py index b6598398..09d6dbbd 100755 --- a/test/cmdtest_d/ct_swap.py +++ b/test/cmdtest_d/ct_swap.py @@ -361,7 +361,8 @@ class CmdTestSwap(CmdTestRegtest, CmdTestAutosignThreaded): interactive_fee = None, file_desc = 'Unsigned transaction', reload_quote = False, - sign_and_send = False): + sign_and_send = False, + expect = None): t.expect('abel:\b', 'q') t.expect('to spend: ', f'{inputs}\n') if reload_quote: @@ -374,6 +375,8 @@ class CmdTestSwap(CmdTestRegtest, CmdTestAutosignThreaded): t.expect('to continue: ', 'r') # reload swap quote t.expect('to continue: ', '\n') # exit swap quote view t.expect('view: ', 'y') # view TX + if expect: + t.expect(expect) t.expect('to continue: ', '\n') if sign_and_send: t.passphrase(dfl_wcls.desc, rt_pw) @@ -393,44 +396,58 @@ class CmdTestSwap(CmdTestRegtest, CmdTestAutosignThreaded): def swaptxcreate1(self, idx=3): return self._swaptxcreate_ui_common( - self._swaptxcreate(['BCH', '1.234', f'{self.sid}:C:{idx}', 'LTC', f'{self.sid}:B:3'])) + self._swaptxcreate( + ['BCH', '1.234', f'{self.sid}:C:{idx}', 'LTC', f'{self.sid}:B:3'], + add_opts = ['--trade-limit=0%']), + expect = ':3541e5/1/0') def swaptxcreate2(self): - t = self._swaptxcreate(['BCH', 'LTC'], add_opts=['--no-quiet']) + t = self._swaptxcreate( + ['BCH', 'LTC'], + add_opts = ['--no-quiet', '--trade-limit=3.337%']) t.expect('Enter a number> ', '1') t.expect('OK? (Y/n): ', 'y') - return self._swaptxcreate_ui_common(t, reload_quote=True) + return self._swaptxcreate_ui_common(t, reload_quote=True, expect=':1386e6/1/0') def swaptxcreate3(self): return self._swaptxcreate_ui_common( - self._swaptxcreate(['BCH', 'LTC', f'{self.sid}:B:3'])) + self._swaptxcreate( + ['BCH', 'LTC', f'{self.sid}:B:3'], + add_opts = ['--trade-limit=10.1%']), + expect = ':1289e6/1/0') def swaptxcreate4(self): - t = self._swaptxcreate(['BCH', '1.234', 'C', 'LTC', 'B']) + t = self._swaptxcreate( + ['BCH', '1.234', 'C', 'LTC', 'B'], + add_opts = ['--trade-limit=-1.123%']) t.expect('OK? (Y/n): ', 'y') t.expect('Enter a number> ', '1') t.expect('OK? (Y/n): ', 'y') - return self._swaptxcreate_ui_common(t) + return self._swaptxcreate_ui_common(t, expect=':358e6/1/0') def swaptxcreate5(self): - t = self._swaptxcreate(['BCH', '1.234', f'{self.sid}:C', 'LTC', f'{self.sid}:B']) + t = self._swaptxcreate( + ['BCH', '1.234', f'{self.sid}:C', 'LTC', f'{self.sid}:B'], + add_opts = ['--trade-limit=3.6']) t.expect('OK? (Y/n): ', 'y') t.expect('OK? (Y/n): ', 'y') - return self._swaptxcreate_ui_common(t) + return self._swaptxcreate_ui_common(t, expect=':36e7/1/0') def swaptxcreate6(self): addr = make_burn_addr(self.protos[1], mmtype='bech32') - t = self._swaptxcreate(['BCH', '1.234', f'{self.sid}:C', 'LTC', addr]) + t = self._swaptxcreate( + ['BCH', '1.234', f'{self.sid}:C', 'LTC', addr], + add_opts = ['--trade-limit=2.7%']) t.expect('OK? (Y/n): ', 'y') t.expect('to confirm: ', 'YES\n') - return self._swaptxcreate_ui_common(t) + return self._swaptxcreate_ui_common(t, expect=':3445e5/1/0') def swaptxcreate7(self): t = self._swaptxcreate(['BCH', '0.56789', 'LTC']) t.expect('OK? (Y/n): ', 'y') t.expect('Enter a number> ', '1') t.expect('OK? (Y/n): ', 'y') - return self._swaptxcreate_ui_common(t) + return self._swaptxcreate_ui_common(t, expect=':0/1/0') def _swaptxcreate_bad(self, args, *, exit_val=1, expect1=None, expect2=None): t = self._swaptxcreate(args, exit_val=exit_val) diff --git a/test/modtest_d/ut_tx.py b/test/modtest_d/ut_tx.py index 9407a852..41ff3e53 100755 --- a/test/modtest_d/ut_tx.py +++ b/test/modtest_d/ut_tx.py @@ -177,16 +177,25 @@ class unit_tests: ('ltc', 'bech32'), ('bch', 'compressed'), ): - proto = init_proto(cfg, coin) + proto = init_proto(cfg, coin, need_amt=True) addr = make_burn_addr(proto, addrtype) - if True: + for limit, limit_chk in ( + ('123.4567', 12340000000), + ('1.234567', 123400000), + ('0.01234567', 1234000), + ('0.00012345', 12345), + (None, 0), + ): vmsg('\nTesting memo initialization:') - m = Memo(proto, addr) + m = Memo(proto, addr, trade_limit=proto.coin_amt(limit) if limit else None) vmsg(f'str(memo): {m}') vmsg(f'repr(memo): {m!r}') + vmsg(f'limit: {limit}') p = Memo.parse(m) + limit_dec = proto.coin_amt(p.trade_limit, from_unit='satoshi') + vmsg(f'limit_dec: {limit_dec.hl()}') vmsg('\nTesting memo parsing:') from pprint import pformat @@ -196,7 +205,7 @@ class unit_tests: assert p.chain == coin.upper() assert p.asset == coin.upper() assert p.address == addr.views[addr.view_pref] - assert p.trade_limit == 0 + assert p.trade_limit == limit_chk assert p.stream_interval == 1 assert p.stream_quantity == 0 # auto @@ -237,7 +246,7 @@ class unit_tests: ('bad3', 'SwapMemoParseError', 'function abbrev', bad('z:l:foobar:0/1/0')), ('bad4', 'SwapMemoParseError', 'asset abbrev', bad('=:x:foobar:0/1/0')), ('bad5', 'SwapMemoParseError', 'failed to parse', bad('=:l:foobar:n')), - ('bad6', 'SwapMemoParseError', 'non-integer', bad('=:l:foobar:x/1/0')), + ('bad6', 'SwapMemoParseError', 'invalid specifier', bad('=:l:foobar:x/1/0')), ('bad7', 'SwapMemoParseError', 'extra', bad('=:l:foobar:0/1/0:x')), ), pfx='')