thornode.py 5.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172
  1. #!/usr/bin/env python3
  2. #
  3. # MMGen Wallet, a terminal-based cryptocurrency wallet
  4. # Copyright (C)2013-2025 The MMGen Project <mmgen@tuta.io>
  5. # Licensed under the GNU General Public License, Version 3:
  6. # https://www.gnu.org/licenses
  7. # Public project repositories:
  8. # https://github.com/mmgen/mmgen-wallet
  9. # https://gitlab.com/mmgen/mmgen-wallet
  10. """
  11. swap.proto.thorchain.thornode: THORChain swap protocol network query ops
  12. """
  13. import time, json
  14. from collections import namedtuple
  15. from ....amt import UniAmt
  16. from ....http import HTTPClient
  17. _gd = namedtuple('gas_unit_data', ['code', 'disp'])
  18. gas_unit_data = {
  19. 'satsperbyte': _gd('s', 'sat/byte'),
  20. 'gwei': _gd('G', 'Gwei'),
  21. }
  22. class ThornodeRPCClient(HTTPClient):
  23. http_hdrs = {'Content-Type': 'application/json'}
  24. host = 'thornode.ninerealms.com'
  25. timeout = 5
  26. def __init__(self, tx, *, network_proto=None, host=None):
  27. super().__init__(tx.cfg, network_proto=network_proto, host=host)
  28. class Thornode:
  29. def __init__(self, tx, amt):
  30. self.tx = tx
  31. self.in_amt = UniAmt(f'{amt:.8f}')
  32. self.rpc = ThornodeRPCClient(tx)
  33. def get_quote(self, swap_cfg):
  34. def get_data(send, recv, amt):
  35. get_str = (
  36. '/thorchain/quote/swap?'
  37. f'from_asset={send}&'
  38. f'to_asset={recv}&'
  39. f'amount={amt}&'
  40. f'streaming_interval={swap_cfg.stream_interval}')
  41. data = json.loads(self.rpc.get(path=get_str))
  42. if not 'expiry' in data:
  43. from ....util import pp_fmt, die
  44. die(2, pp_fmt(data))
  45. return data
  46. if self.tx.proto.tokensym or self.tx.recv_asset.asset: # token swap
  47. in_data = get_data(
  48. self.tx.send_asset.full_name,
  49. 'THOR.RUNE',
  50. self.in_amt.to_unit('satoshi'))
  51. if self.tx.proto.network != 'regtest':
  52. time.sleep(1.1) # ninerealms max request rate 1/sec
  53. out_data = get_data(
  54. 'THOR.RUNE',
  55. self.tx.recv_asset.full_name,
  56. in_data['expected_amount_out'])
  57. self.data = in_data | {
  58. 'expected_amount_out': out_data['expected_amount_out'],
  59. 'fees': out_data['fees'],
  60. 'expiry': min(in_data['expiry'], out_data['expiry'])
  61. }
  62. else:
  63. self.data = get_data(
  64. self.tx.send_asset.full_name,
  65. self.tx.recv_asset.full_name,
  66. self.in_amt.to_unit('satoshi'))
  67. async def format_quote(self, trade_limit, *, deduct_est_fee=False):
  68. from ....util import make_timestr, ymsg
  69. from ....util2 import format_elapsed_hr
  70. from ....color import blue, green, cyan, pink, orange, redbg, yelbg, grnbg
  71. from . import name
  72. d = self.data
  73. tx = self.tx
  74. in_coin = tx.send_asset.short_name
  75. out_coin = tx.recv_asset.short_name
  76. in_amt = self.in_amt
  77. out_amt = UniAmt(int(d['expected_amount_out']), from_unit='satoshi')
  78. gas_unit = d['gas_rate_units']
  79. if trade_limit:
  80. from . import ExpInt4
  81. tl_int = ExpInt4(trade_limit.to_unit('satoshi'))
  82. tl_uniamt = UniAmt(tl_int.trunc, from_unit='satoshi')
  83. ratio = float(tl_uniamt / out_amt)
  84. direction = 'ABOVE' if ratio > 1 else 'below'
  85. mcolor, lblcolor = (
  86. (redbg, redbg) if (ratio < 0.93 or ratio > 0.999) else
  87. (yelbg, yelbg) if ratio < 0.97 else
  88. (green, grnbg))
  89. trade_limit_disp = f"""
  90. {lblcolor('Trade limit:')} {tl_uniamt.hl()} {out_coin} """ + mcolor(
  91. f'({abs(1 - ratio) * 100:0.2f}% {direction} expected amount)')
  92. tx_size_adj = len(tl_int.enc) - 1
  93. if tx.proto.is_evm:
  94. tx.adj_gas_with_extra_data_len(len(tl_int.enc) - 1) # one-shot method, no-op if repeated
  95. else:
  96. trade_limit_disp = ''
  97. tx_size_adj = 0
  98. def get_estimated_fee():
  99. return tx.feespec2abs(
  100. fee_arg = d['recommended_gas_rate'] + gas_unit_data[gas_unit].code,
  101. tx_size = None if tx.proto.is_vm else tx.estimate_size() + tx_size_adj)
  102. _amount_in_label = 'Amount in:'
  103. if deduct_est_fee:
  104. if gas_unit in gas_unit_data:
  105. in_amt -= UniAmt(f'{get_estimated_fee():.8f}')
  106. out_amt *= (in_amt / self.in_amt)
  107. _amount_in_label = 'Amount in (estimated):'
  108. else:
  109. ymsg(f'Warning: unknown gas unit ‘{gas_unit}’, cannot estimate fee')
  110. min_in_amt = UniAmt(int(d['recommended_min_amount_in']), from_unit='satoshi')
  111. gas_unit_disp = _.disp if (_ := gas_unit_data.get(gas_unit)) else gas_unit
  112. elapsed_disp = format_elapsed_hr(d['expiry'], future_msg='from now')
  113. fees = d['fees']
  114. fees_t = UniAmt(int(fees['total']), from_unit='satoshi')
  115. fees_pct_disp = str(fees['total_bps'] / 100) + '%'
  116. slip_pct_disp = str(fees['slippage_bps'] / 100) + '%'
  117. hdr = f'SWAP QUOTE (source: {self.rpc.host})'
  118. return f"""
  119. {cyan(hdr)}
  120. Protocol: {blue(name)}
  121. Direction: {orange(f'{tx.send_asset.name} => {tx.recv_asset.name}')}
  122. Vault address: {cyan(self.inbound_address)}
  123. Quote expires: {pink(elapsed_disp)} [{make_timestr(d['expiry'])}]
  124. {_amount_in_label:<22} {in_amt.hl()} {in_coin}
  125. Expected amount out: {out_amt.hl()} {out_coin}{trade_limit_disp}
  126. Rate: {(out_amt / in_amt).hl()} {out_coin}/{in_coin}
  127. Reverse rate: {(in_amt / out_amt).hl()} {in_coin}/{out_coin}
  128. Recommended minimum in amount: {min_in_amt.hl()} {in_coin}
  129. Recommended fee: {pink(d['recommended_gas_rate'])} {pink(gas_unit_disp)}
  130. Network-estimated fee: {await self.tx.network_fee_disp()} (from node)
  131. Fees:
  132. Total: {fees_t.hl()} {out_coin} ({pink(fees_pct_disp)})
  133. Slippage: {pink(slip_pct_disp)}
  134. """
  135. @property
  136. def inbound_address(self):
  137. addr = self.data['inbound_address']
  138. return addr.removeprefix('0x') if self.tx.proto.is_evm else addr
  139. @property
  140. def router(self):
  141. return self.data['router'].lower().removeprefix('0x')
  142. @property
  143. def rel_fee_hint(self):
  144. gas_unit = self.data['gas_rate_units']
  145. if gas_unit in gas_unit_data:
  146. return self.data['recommended_gas_rate'] + gas_unit_data[gas_unit].code
  147. def __str__(self):
  148. from pprint import pformat
  149. return pformat(self.data)