thornode.py 6.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183
  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. _gd = namedtuple('gas_unit_data', ['code', 'disp'])
  17. gas_unit_data = {
  18. 'satsperbyte': _gd('s', 'sat/byte'),
  19. 'gwei': _gd('G', 'Gwei'),
  20. }
  21. class ThornodeRPCClient:
  22. http_hdrs = {'Content-Type': 'application/json'}
  23. proto = 'https'
  24. host = 'thornode.ninerealms.com'
  25. verify = True
  26. timeout = 5
  27. def __init__(self, tx, *, proto=None, host=None):
  28. self.cfg = tx.cfg
  29. if proto:
  30. self.proto = proto
  31. if host:
  32. self.host = host
  33. import requests
  34. self.session = requests.Session()
  35. self.session.trust_env = False # ignore *_PROXY environment vars
  36. self.session.headers = self.http_hdrs
  37. if self.cfg.proxy:
  38. self.session.proxies.update({
  39. 'http': f'socks5h://{self.cfg.proxy}',
  40. 'https': f'socks5h://{self.cfg.proxy}'
  41. })
  42. def get(self, path, *, timeout=None):
  43. return self.session.get(
  44. url = self.proto + '://' + self.host + path,
  45. timeout = timeout or self.timeout,
  46. verify = self.verify)
  47. class Thornode:
  48. def __init__(self, tx, amt):
  49. self.tx = tx
  50. self.in_amt = UniAmt(f'{amt:.8f}')
  51. self.rpc = ThornodeRPCClient(tx)
  52. def get_quote(self):
  53. def get_data(send, recv, amt):
  54. get_str = f'/thorchain/quote/swap?from_asset={send}&to_asset={recv}&amount={amt}'
  55. data = json.loads(self.rpc.get(get_str).content)
  56. if not 'expiry' in data:
  57. from ....util import pp_fmt, die
  58. die(2, pp_fmt(data))
  59. return data
  60. if self.tx.proto.tokensym or self.tx.recv_asset.asset: # token swap
  61. in_data = get_data(
  62. self.tx.send_asset.full_name,
  63. 'THOR.RUNE',
  64. self.in_amt.to_unit('satoshi'))
  65. if self.tx.proto.network != 'regtest':
  66. time.sleep(1.1) # ninerealms max request rate 1/sec
  67. out_data = get_data(
  68. 'THOR.RUNE',
  69. self.tx.recv_asset.full_name,
  70. in_data['expected_amount_out'])
  71. self.data = in_data | {
  72. 'expected_amount_out': out_data['expected_amount_out'],
  73. 'fees': out_data['fees'],
  74. 'expiry': min(in_data['expiry'], out_data['expiry'])
  75. }
  76. else:
  77. self.data = get_data(
  78. self.tx.send_asset.full_name,
  79. self.tx.recv_asset.full_name,
  80. self.in_amt.to_unit('satoshi'))
  81. async def format_quote(self, trade_limit, usr_trade_limit, *, deduct_est_fee=False):
  82. from ....util import make_timestr, ymsg
  83. from ....util2 import format_elapsed_hr
  84. from ....color import blue, green, cyan, pink, orange, redbg, yelbg, grnbg
  85. from . import name
  86. d = self.data
  87. tx = self.tx
  88. in_coin = tx.send_asset.short_name
  89. out_coin = tx.recv_asset.short_name
  90. in_amt = self.in_amt
  91. out_amt = UniAmt(int(d['expected_amount_out']), from_unit='satoshi')
  92. gas_unit = d['gas_rate_units']
  93. if trade_limit:
  94. from . import ExpInt4
  95. e = ExpInt4(trade_limit.to_unit('satoshi'))
  96. tl_rounded = UniAmt(e.trunc, from_unit='satoshi')
  97. ratio = usr_trade_limit if type(usr_trade_limit) is float else float(tl_rounded / out_amt)
  98. direction = 'ABOVE' if ratio > 1 else 'below'
  99. mcolor, lblcolor = (
  100. (redbg, redbg) if (ratio < 0.93 or ratio > 0.999) else
  101. (yelbg, yelbg) if ratio < 0.97 else
  102. (green, grnbg))
  103. trade_limit_disp = f"""
  104. {lblcolor('Trade limit:')} {tl_rounded.hl()} {out_coin} """ + mcolor(
  105. f'({abs(1 - ratio) * 100:0.2f}% {direction} expected amount)')
  106. tx_size_adj = len(e.enc) - 1
  107. if tx.proto.is_evm:
  108. tx.adj_gas_with_extra_data_len(len(e.enc) - 1) # one-shot method, no-op if repeated
  109. else:
  110. trade_limit_disp = ''
  111. tx_size_adj = 0
  112. def get_estimated_fee():
  113. return tx.feespec2abs(
  114. fee_arg = d['recommended_gas_rate'] + gas_unit_data[gas_unit].code,
  115. tx_size = None if tx.proto.is_evm else tx.estimate_size() + tx_size_adj)
  116. _amount_in_label = 'Amount in:'
  117. if deduct_est_fee:
  118. if gas_unit in gas_unit_data:
  119. in_amt -= UniAmt(f'{get_estimated_fee():.8f}')
  120. out_amt *= (in_amt / self.in_amt)
  121. _amount_in_label = 'Amount in (estimated):'
  122. else:
  123. ymsg(f'Warning: unknown gas unit ‘{gas_unit}’, cannot estimate fee')
  124. min_in_amt = UniAmt(int(d['recommended_min_amount_in']), from_unit='satoshi')
  125. gas_unit_disp = _.disp if (_ := gas_unit_data.get(gas_unit)) else gas_unit
  126. elapsed_disp = format_elapsed_hr(d['expiry'], future_msg='from now')
  127. fees = d['fees']
  128. fees_t = UniAmt(int(fees['total']), from_unit='satoshi')
  129. fees_pct_disp = str(fees['total_bps'] / 100) + '%'
  130. slip_pct_disp = str(fees['slippage_bps'] / 100) + '%'
  131. hdr = f'SWAP QUOTE (source: {self.rpc.host})'
  132. return f"""
  133. {cyan(hdr)}
  134. Protocol: {blue(name)}
  135. Direction: {orange(f'{tx.send_asset.name} => {tx.recv_asset.name}')}
  136. Vault address: {cyan(self.inbound_address)}
  137. Quote expires: {pink(elapsed_disp)} [{make_timestr(d['expiry'])}]
  138. {_amount_in_label:<22} {in_amt.hl()} {in_coin}
  139. Expected amount out: {out_amt.hl()} {out_coin}{trade_limit_disp}
  140. Rate: {(out_amt / in_amt).hl()} {out_coin}/{in_coin}
  141. Reverse rate: {(in_amt / out_amt).hl()} {in_coin}/{out_coin}
  142. Recommended minimum in amount: {min_in_amt.hl()} {in_coin}
  143. Recommended fee: {pink(d['recommended_gas_rate'])} {pink(gas_unit_disp)}
  144. Network-estimated fee: {await self.tx.network_fee_disp()} (from node)
  145. Fees:
  146. Total: {fees_t.hl()} {out_coin} ({pink(fees_pct_disp)})
  147. Slippage: {pink(slip_pct_disp)}
  148. """
  149. @property
  150. def inbound_address(self):
  151. addr = self.data['inbound_address']
  152. return addr.removeprefix('0x') if self.tx.proto.is_evm else addr
  153. @property
  154. def rel_fee_hint(self):
  155. gas_unit = self.data['gas_rate_units']
  156. if gas_unit in gas_unit_data:
  157. return self.data['recommended_gas_rate'] + gas_unit_data[gas_unit].code
  158. def __str__(self):
  159. from pprint import pformat
  160. return pformat(self.data)