memo.py 5.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164
  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.memo: THORChain swap protocol memo class
  12. """
  13. from ....util import die, is_hex_str
  14. from ....amt import UniAmt
  15. from . import name as proto_name
  16. class THORChainMemo:
  17. # The trade limit, i.e., set 100000000 to get a minimum of 1 full asset, else a refund
  18. # Optional. 1e8 or scientific notation
  19. trade_limit = None
  20. # Swap interval in blocks. Optional. If 0, do not stream
  21. stream_interval = 1
  22. # Swap quantity. The interval value determines the frequency of swaps in blocks
  23. # Optional. If 0, network will determine the number of swaps
  24. stream_quantity = 0
  25. max_len = 250
  26. function = 'SWAP'
  27. asset_abbrevs = {
  28. 'BTC.BTC': 'b',
  29. 'LTC.LTC': 'l',
  30. 'BCH.BCH': 'c',
  31. 'ETH.ETH': 'e',
  32. 'DOGE.DOGE': 'd',
  33. 'THOR.RUNE': 'r',
  34. }
  35. evm_chains = ('ETH', 'AVAX', 'BSC', 'BASE')
  36. function_abbrevs = {
  37. 'SWAP': '=',
  38. }
  39. @classmethod
  40. def is_partial_memo(cls, bytes_data):
  41. import re
  42. ops = {
  43. 'swap': ('SWAP', 's', '='),
  44. 'add': ('ADD', 'a', r'\+'),
  45. 'withdraw': ('WITHDRAW', 'wd', '-'),
  46. 'loan': (r'LOAN(\+|-)', r'\$(\+|-)'), # open/repay
  47. 'pool': (r'POOL(\+|-)',),
  48. 'trade': (r'TRADE(\+|-)',),
  49. 'secure': (r'SECURE(\+|-)',),
  50. 'misc': ('BOND', 'UNBOND', 'LEAVE', 'MIGRATE', 'NOOP', 'DONATE', 'RESERVE'),
  51. }
  52. pat = r'^(' + '|'.join('|'.join(pats) for pats in ops.values()) + r'):\S\S+'
  53. return bool(re.search(pat.encode(), bytes_data))
  54. @classmethod
  55. def parse(cls, s):
  56. """
  57. All fields are validated, excluding address (cannot validate, since network is unknown)
  58. """
  59. from collections import namedtuple
  60. from ....util import is_int
  61. def get_item(desc):
  62. try:
  63. return fields.pop(0)
  64. except IndexError:
  65. die('SwapMemoParseError', f'malformed {proto_name} memo (missing {desc} field)')
  66. def get_id(data, item, desc):
  67. if item in data:
  68. return item
  69. rev_data = {v:k for k,v in data.items()}
  70. if item in rev_data:
  71. return rev_data[item]
  72. die('SwapMemoParseError', f'{item!r}: unrecognized {proto_name} {desc} abbreviation')
  73. fields = str(s).split(':')
  74. if len(fields) < 4:
  75. die('SwapMemoParseError', 'memo must contain at least 4 comma-separated fields')
  76. function = get_id(cls.function_abbrevs, get_item('function'), 'function')
  77. chain, asset = get_id(cls.asset_abbrevs, get_item('asset'), 'asset').split('.')
  78. address = get_item('address')
  79. if chain in cls.evm_chains:
  80. assert address.startswith('0x'), f'{address}: address does not start with ‘0x’'
  81. assert len(address) == 42, f'{address}: address has incorrect length ({len(address)} != 42)'
  82. address = address.removeprefix('0x')
  83. desc = 'trade_limit/stream_interval/stream_quantity'
  84. lsq = get_item(desc)
  85. try:
  86. limit, interval, quantity = lsq.split('/')
  87. except ValueError:
  88. die('SwapMemoParseError', f'malformed memo (failed to parse {desc} field) [{lsq}]')
  89. from . import ExpInt4
  90. try:
  91. limit_int = ExpInt4(limit)
  92. except Exception as e:
  93. die('SwapMemoParseError', str(e))
  94. for n in (interval, quantity):
  95. if not is_int(n):
  96. die('SwapMemoParseError', f'malformed memo (non-integer in {desc} field [{lsq}])')
  97. if fields:
  98. die('SwapMemoParseError', 'malformed memo (unrecognized extra data)')
  99. ret = namedtuple(
  100. 'parsed_memo',
  101. ['proto', 'function', 'chain', 'asset', 'address', 'trade_limit', 'stream_interval', 'stream_quantity'])
  102. return ret(proto_name, function, chain, asset, address, limit_int, int(interval), int(quantity))
  103. def __init__(self, proto, addr, *, chain=None, trade_limit=None):
  104. self.proto = proto
  105. self.chain = chain or proto.coin
  106. if trade_limit is None:
  107. self.trade_limit = UniAmt('0')
  108. else:
  109. assert type(trade_limit) is UniAmt, f'{type(trade_limit)} != {UniAmt}'
  110. self.trade_limit = trade_limit
  111. from ....addr import is_coin_addr
  112. assert is_coin_addr(proto, addr)
  113. self.addr = addr.views[addr.view_pref]
  114. assert not ':' in self.addr # colon is record separator, so address mustn’t contain one
  115. if self.chain in self.evm_chains:
  116. assert len(self.addr) == 40, f'{self.addr}: address has incorrect length ({len(self.addr)} != 40)'
  117. assert is_hex_str(self.addr), f'{self.addr}: address is not a hexadecimal string'
  118. self.addr = '0x' + self.addr
  119. def __str__(self):
  120. from . import ExpInt4
  121. try:
  122. tl_enc = ExpInt4(self.trade_limit.to_unit('satoshi')).enc
  123. except Exception as e:
  124. die('SwapMemoParseError', str(e))
  125. suf = '/'.join(str(n) for n in (tl_enc, self.stream_interval, self.stream_quantity))
  126. asset = f'{self.chain}.{self.proto.coin}'
  127. ret = ':'.join([
  128. self.function_abbrevs[self.function],
  129. self.asset_abbrevs[asset],
  130. self.addr,
  131. suf])
  132. assert len(ret) <= self.max_len, f'{proto_name} memo exceeds maximum length of {self.max_len}'
  133. return ret