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
This commit is contained in:
The MMGen Project 2025-03-04 09:51:05 +00:00
commit 2f6e52be73
Signed by: mmgen
GPG key ID: 3F8B1861E32B7DA2
15 changed files with 159 additions and 31 deletions

View file

@ -1 +1 @@
February 2025
March 2025

View file

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

View file

@ -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 users coins, minus
transaction fees (note that the refund goes to the address associated with the
transactions 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, its 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.
"""

View file

@ -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)

View file

@ -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

View file

@ -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]

View file

@ -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!'

View file

@ -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)

View file

@ -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],

View file

@ -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}

View file

@ -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

View file

@ -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)

View file

@ -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

View file

@ -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)

View file

@ -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='')