Browse Source

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
The MMGen Project 1 week ago
parent
commit
2f6e52be73

+ 1 - 1
mmgen/data/release_date

@@ -1 +1 @@
-February 2025
+March 2025

+ 1 - 1
mmgen/data/version

@@ -1 +1 @@
-15.1.dev17
+15.1.dev18

+ 19 - 0
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.
 """

+ 2 - 0
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)

+ 2 - 0
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

+ 7 - 0
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]

+ 11 - 0
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!'

+ 6 - 0
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)

+ 23 - 7
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],

+ 22 - 4
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}

+ 1 - 0
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

+ 1 - 0
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)

+ 20 - 1
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

+ 29 - 12
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)

+ 14 - 5
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='')