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:
parent
7300c1ec84
commit
2f6e52be73
15 changed files with 159 additions and 31 deletions
|
|
@ -1 +1 @@
|
|||
February 2025
|
||||
March 2025
|
||||
|
|
|
|||
|
|
@ -1 +1 @@
|
|||
15.1.dev17
|
||||
15.1.dev18
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
"""
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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!'
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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],
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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='')
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue