diff --git a/alt-requirements.txt b/alt-requirements.txt index acdfd202..c580436c 100644 --- a/alt-requirements.txt +++ b/alt-requirements.txt @@ -1 +1,2 @@ pycryptodome +pure-protobuf diff --git a/mmgen/data/release_date b/mmgen/data/release_date index 612cdf3e..04eebfa2 100644 --- a/mmgen/data/release_date +++ b/mmgen/data/release_date @@ -1 +1 @@ -May 2025 +June 2025 diff --git a/mmgen/data/version b/mmgen/data/version index 90f19ad1..16e48615 100644 --- a/mmgen/data/version +++ b/mmgen/data/version @@ -1 +1 @@ -15.1.dev42 +15.1.dev43 diff --git a/mmgen/proto/cosmos/tx/protobuf.py b/mmgen/proto/cosmos/tx/protobuf.py new file mode 100755 index 00000000..e043f747 --- /dev/null +++ b/mmgen/proto/cosmos/tx/protobuf.py @@ -0,0 +1,173 @@ +#!/usr/bin/env python3 +# +# MMGen Wallet, a terminal-based cryptocurrency wallet +# Copyright (C)2013-2025 The MMGen Project +# Licensed under the GNU General Public License, Version 3: +# https://www.gnu.org/licenses +# Public project repositories: +# https://github.com/mmgen/mmgen-wallet +# https://gitlab.com/mmgen/mmgen-wallet + +""" +proto.cosmos.tx.protobuf: transaction serialization for Cosmos protocol +""" + +# cosmjs-types/src/cosmos/bank/v1beta1/tx.ts +# cosmjs-types/src/cosmos/tx/v1beta1/tx.ts +# cosmjs-types/src/cosmos/tx/signing/v1beta1/signing.ts + +from hashlib import sha256 + +from dataclasses import dataclass +from pure_protobuf.annotations import Field +from pure_protobuf.message import BaseMessage +from typing import Annotated, List, Optional +from enum import IntEnum + +class SignMode(IntEnum): + SIGN_MODE_UNSPECIFIED = 0 + SIGN_MODE_DIRECT = 1 + SIGN_MODE_TEXTUAL = 2 + SIGN_MODE_DIRECT_AUX = 3 + SIGN_MODE_LEGACY_AMINO_JSON = 127 + SIGN_MODE_EIP_191 = 191 + UNRECOGNIZED = -1 + +@dataclass +class PublicKeyData(BaseMessage): + data: Annotated[bytes, Field(1)] + +@dataclass +class PublicKey(BaseMessage): + id: Annotated[str, Field(1)] # '/cosmos.crypto.secp256k1.PubKey' + key: Annotated[PublicKeyData, Field(2)] + +@dataclass +class ModeInfo(BaseMessage): + + @dataclass + class Single(BaseMessage): + mode: Annotated[SignMode, Field(1)] + + @dataclass + class Multi(BaseMessage): + bitarray: Annotated[bytes, Field(1)] + modeInfos: Annotated[List[bytes], Field(2)] + + single: Annotated[Optional[Single], Field(1)] = None + multi: Annotated[Optional[Multi], Field(2)] = None + +@dataclass +class SignerInfo(BaseMessage): + publicKey: Annotated[PublicKey, Field(1)] + # mode_info describes the signing mode of the signer and is a nested + # structure to support nested multisig pubkeys + modeInfo: Annotated[ModeInfo, Field(2)] + # sequence is the sequence of the account, which describes the number of committed + # transactions signed by a given address. It is used to prevent replay attacks. + sequence: Annotated[int, Field(3)] + +@dataclass +class Coin(BaseMessage): + denom: Annotated[str, Field(1)] + amount: Annotated[str, Field(2)] + +@dataclass +class Fee(BaseMessage): + amount: Annotated[Optional[List[Coin]], Field(1)] = None # = field(default_factory=list) + gasLimit: Annotated[Optional[int], Field(2)] = None + payer: Annotated[Optional[str], Field(3)] = None + granter: Annotated[Optional[str], Field(4)] = None + +@dataclass +class AuthInfo(BaseMessage): + signerInfos: Annotated[List[SignerInfo], Field(1)] + fee: Annotated[Optional[Fee], Field(2)] = None + tip: Annotated[Optional[int], Field(3)] = None + +# TxRaw is a variant of Tx that pins the signer's exact binary representation of body +# and auth_info. This is used for signing, broadcasting and verification. The binary +# `serialize(tx: TxRaw)` is stored in Tendermint and the hash `sha256(serialize(tx: +# TxRaw))` becomes the "txhash", commonly used as the transaction ID. +@dataclass +class RawTx(BaseMessage): # TxRaw (cosmjs) + bodyBytes: Annotated[bytes, Field(1)] + authInfoBytes: Annotated[bytes, Field(2)] + signatures: Annotated[List[bytes], Field(3)] + +# SignDoc is the type used for generating sign bytes for SIGN_MODE_DIRECT +@dataclass +class SignDoc(BaseMessage): + bodyBytes: Annotated[bytes, Field(1)] + authInfoBytes: Annotated[bytes, Field(2)] + chainId: Annotated[str, Field(3)] + accountNumber: Annotated[int, Field(4)] + +@dataclass +class TxMsg(BaseMessage): + msgs_cls = None + id: Annotated[str, Field(1)] + bodyBytes: Annotated[bytes, Field(2)] + + def __new__(cls, *, id, bodyBytes): + msg_cls = getattr(cls.msgs_cls, id.removeprefix('/types.')) + me = BaseMessage.__new__(msg_cls) + me.id = id + me.body = getattr(msg_cls, 'Body').loads(bodyBytes) + return me + +@dataclass +class Tx(BaseMessage): + body: Annotated[bytes, Field(1)] + authInfo: Annotated[bytes, Field(2)] + signatures: Annotated[List[bytes], Field(3)] + + @property + def raw(self): + return RawTx( + bodyBytes = bytes(self.body), + authInfoBytes = bytes(self.authInfo), + signatures = self.signatures) + + @property + def txid(self): + return sha256(bytes(self.raw)).hexdigest() + + # raises exception on failure: + def verify_sig(self, proto, account_number, backend='ecdsa'): + sign_doc = SignDoc( + bodyBytes = bytes(self.body), + authInfoBytes = bytes(self.authInfo), + chainId = proto.chain_id, + accountNumber = account_number) + sig = self.signatures[0] + pubkey = self.authInfo.signerInfos[0].publicKey.key.data + msghash = sha256(bytes(sign_doc)).digest() + + if backend == 'ecdsa': + # ecdsa.keys.VerifyingKey.verify_digest(): + # raises BadSignatureError if the signature is invalid or malformed + import ecdsa + ec_pubkey = ecdsa.VerifyingKey.from_string(pubkey, curve=ecdsa.curves.SECP256k1) + ec_pubkey.verify_digest(sig, msghash) + elif backend == 'py_ecc': + from py_ecc.secp256k1.secp256k1 import ecdsa_raw_recover + y_is_odd = pubkey[0] - 2 + x, y = ecdsa_raw_recover( + msghash, + (28 - y_is_odd, int.from_bytes(sig[:32]), int.from_bytes(sig[32:]))) + assert y & 1 == y_is_odd, f'verify_sig(): parity mismatch for TX {self.txid}' + assert int.from_bytes(pubkey[1:]) == x, f'verify_sig(): invalid signature for TX {self.txid}' + else: + raise ValueError(f'verify_sig(): {backend}: unrecognized backend') + +# cosmjs/packages/crypto/src/secp256k1signature.ts +# cosmjs/packages/amino/src/signature.ts +# Signature must be 64 bytes long. Cosmos SDK uses a 2x32 byte fixed length +# encoding for the secp256k1 signature integers r and s. +def make_sig(*, sign_doc, sec_bytes): + from py_ecc.secp256k1.secp256k1 import ecdsa_raw_sign + msghash = sha256(bytes(sign_doc)).digest() + _, r, s = ecdsa_raw_sign(msghash, sec_bytes) + # pmsg('v:', _) # DEBUG + return r.to_bytes(length=32) + s.to_bytes(length=32) diff --git a/mmgen/proto/rune/params.py b/mmgen/proto/rune/params.py index 6abcb9c9..d4b2e7df 100755 --- a/mmgen/proto/rune/params.py +++ b/mmgen/proto/rune/params.py @@ -22,6 +22,7 @@ from ..btc.params import mainnet as btc_mainnet class mainnet(CoinProtocol.Secp256k1): mod_clsname = 'THORChain' network_names = _nw('mainnet', 'stagenet', 'testnet') + chain_id = 'thorchain-1' mmtypes = ('X',) preferred_mmtypes = ('X',) dfl_mmtype = 'X' diff --git a/mmgen/proto/rune/rpc/remote.py b/mmgen/proto/rune/rpc/remote.py index 44cfbe83..2eefd009 100755 --- a/mmgen/proto/rune/rpc/remote.py +++ b/mmgen/proto/rune/rpc/remote.py @@ -25,6 +25,16 @@ def process_response(json_response, errmsg): die('RPCFailure', errmsg) return data['result'] +# HTTP POST, JSON-RPC response: +class ThornodeRemoteRPCClient(HTTPClient): + + timeout = 30 + + def __init__(self, cfg, proto, *, network_proto=None, host=None): + for k, v in proto.rpc_remote_rpc_params.items(): + setattr(self, k, v) + super().__init__(cfg, network_proto=network_proto, host=host) + # HTTP GET, params in query string, JSON-RPC response: class ThornodeRemoteRESTClient(HTTPClient): @@ -45,6 +55,7 @@ class THORChainRemoteRPCClient(RemoteRPCClient): super().__init__(cfg, proto) self.caps = ('lbl_id',) self.rest_api = ThornodeRemoteRESTClient(cfg, proto) + self.rpc_api = ThornodeRemoteRPCClient(cfg, proto) def get_balance(self, addr, *, block=None): res = process_response( @@ -53,3 +64,24 @@ class THORChainRemoteRPCClient(RemoteRPCClient): rune_res = [d for d in res if d['denom'] == 'rune'] assert len(rune_res) == 1, f'{rune_res}: result length is not one!' return self.proto.coin_amt(int(rune_res[0]['amount']), from_unit='satoshi') + + def get_account_info(self, addr, *, block=None): + return process_response( + self.rest_api.get(path=f'/auth/accounts/{addr}'), + errmsg = f'address ‘{addr}’ not found in blockchain')['value'] + + def get_tx_info(self, txhash): + return process_response( + self.rpc_api.post( + path = '/tx', + data = {'hash': '0x' + txhash}), + errmsg = f'get info for transaction {txhash} failed') + + def tx_op(self, txbytes, op=None): + assert isinstance(txbytes, bytes) + assert op in ('check_tx', 'broadcast_tx_sync', 'broadcast_tx_async') + return process_response( + self.rpc_api.post( + path = '/' + op, + data = {'tx': '0x' + txbytes.hex()}), + errmsg = f'transaction operation ‘{op}’ failed') diff --git a/mmgen/proto/rune/tx/protobuf.py b/mmgen/proto/rune/tx/protobuf.py new file mode 100755 index 00000000..c8e405d5 --- /dev/null +++ b/mmgen/proto/rune/tx/protobuf.py @@ -0,0 +1,276 @@ +#!/usr/bin/env python3 +# +# MMGen Wallet, a terminal-based cryptocurrency wallet +# Copyright (C)2013-2025 The MMGen Project +# Licensed under the GNU General Public License, Version 3: +# https://www.gnu.org/licenses +# Public project repositories: +# https://github.com/mmgen/mmgen-wallet +# https://gitlab.com/mmgen/mmgen-wallet + +""" +proto.rune.tx.protobuf: transaction serialization for THORChain protocol +""" + +# https://dev.thorchain.org/technical-faq.html + +from decimal import Decimal +from collections import namedtuple +from dataclasses import dataclass #, field +from typing import Annotated, List, Optional +from pure_protobuf.annotations import Field +from pure_protobuf.message import BaseMessage + +from ...cosmos.tx.protobuf import ( + ModeInfo, + SignDoc, + SignerInfo, + SignMode, + PublicKey, + PublicKeyData, + Coin, + Fee, + AuthInfo, + Tx, + TxMsg, + make_sig) + +send_tx_parms = namedtuple( + 'rune_send_tx_parms', [ + 'from_addr', + 'to_addr', + 'amt', + 'gas_limit', + 'account_number', + 'sequence', + 'pubkey', + 'wifkey', + 'signature'], + defaults = (None, None, None)) + +deposit_tx_parms = namedtuple( + 'rune_deposit_tx_parms', [ + 'chain', + 'symbol', + 'ticker', + 'from_addr', + 'amt', + 'gas_limit', + 'account_number', + 'sequence', + 'decimals', + 'memo', + 'synth', # begin default fields + 'trade', + 'secured', + 'pubkey', + 'wifkey', + 'signature'], + defaults = (None, None, None, None, None, None)) + +# subset of deposit_tx_parms: +swap_tx_parms = namedtuple( + 'rune_swap_tx_parms', [ + 'from_addr', + 'amt', + 'gas_limit', + 'account_number', + 'sequence', + 'memo', + 'pubkey', # begin default fields + 'wifkey', + 'signature'], + defaults = (None, None, None)) + +@dataclass +class Asset(BaseMessage): + chain: Annotated[str, Field(1)] + symbol: Annotated[str, Field(2)] + ticker: Annotated[str, Field(3)] + synth: Annotated[Optional[bool], Field(4)] = None + trade: Annotated[Optional[bool], Field(5)] = None + secured: Annotated[Optional[bool], Field(6)] = None + +@dataclass +class CoinWithAsset(BaseMessage): + asset: Annotated[Asset, Field(1)] + amount: Annotated[str, Field(2)] + decimals: Annotated[Optional[int], Field(3)] = None + +class Messages: + + @dataclass + class MsgSend(BaseMessage): + + @dataclass + class Body(BaseMessage): + fromAddress: Annotated[bytes, Field(1)] + toAddress: Annotated[bytes, Field(2)] + amount: Annotated[List[Coin], Field(3)] + + id: Annotated[str, Field(1)] # '/types.MsgSend' + body: Annotated[Body, Field(2)] + + @dataclass + class MsgDeposit(BaseMessage): + # To initiate a $RUNE -> $ASSET swap a MsgDeposit must be broadcasted to the THORChain blockchain. + # The MsgDeposit does not have a destination address, and has the following properties. + # MsgDeposit{ + # Coins: coins, + # Memo: memo, + # Signer: signer, + # } + @dataclass + class Body(BaseMessage): + coins: Annotated[List[CoinWithAsset], Field(1)] + memo: Annotated[str, Field(2)] + signer: Annotated[bytes, Field(3)] + + id: Annotated[str, Field(1)] # '/types.MsgDeposit' + body: Annotated[Body, Field(2)] + +@dataclass +class RuneTxMsg(TxMsg): + msgs_cls = Messages + id: Annotated[str, Field(1)] + bodyBytes: Annotated[bytes, Field(2)] + +@dataclass +class RuneTxBody(BaseMessage): + messages: Annotated[List[RuneTxMsg], Field(1)] + memo: Annotated[Optional[str], Field(2)] = None + timeoutHeight: Annotated[Optional[int], Field(3)] = None + extensionOptions: Annotated[Optional[bytes], Field(4)] = None + nonCriticalExtensionOptions: Annotated[Optional[bytes], Field(5)] = None + +@dataclass +class RuneTx(Tx): + body: Annotated[RuneTxBody, Field(1)] + authInfo: Annotated[AuthInfo, Field(2)] + signatures: Annotated[List[bytes], Field(3)] + +def amt_to_base_unit(amt, *, decimals): + return int(Decimal(amt) * (Decimal('10') ** decimals)) + +def base_unit_to_amt(n, *, decimals): + return n * Decimal('10') ** -decimals + +def tx_info(tx, proto): + b = tx.body.messages[0].body + s = tx.authInfo.signerInfos[0] + msg_type = tx.body.messages[0].id.removeprefix('/types.') + if msg_type == 'MsgSend': + from_addr = proto.encode_addr_bech32x(b.fromAddress) + to_addr = proto.encode_addr_bech32x(b.toAddress) + asset = b.amount[0].denom.upper() + memo = tx.body.memo + amt = base_unit_to_amt(int(b.amount[0].amount), decimals=8) + elif msg_type == 'MsgDeposit': + from_addr = proto.encode_addr_bech32x(b.signer) + to_addr = 'None' + asset = b.coins[0].asset.symbol + memo = b.memo + amt = base_unit_to_amt(int(b.coins[0].amount), decimals=b.coins[0].decimals or 8) + yield f'TxID: {tx.txid}' + yield f'Type: {msg_type}' + yield f'From: {from_addr}' + yield f'To: {to_addr}' + yield f'Asset: {asset}' + yield f'Amount: {amt}' + yield f'Sequence: {int(s.sequence)}' + yield f'Gas limit: {tx.authInfo.fee.gasLimit}' + yield f'Memo: {memo}' + yield f'Pubkey: {s.publicKey.key.data.hex()}' + +def build_swap_tx(cfg, proto, parms, *, skip_body_memo=False): + + p = parms + assert type(p) == swap_tx_parms, f'{p}: invalid ‘parms’ (not swap_tx_parms instance)' + + return build_tx( + cfg, + proto, + deposit_tx_parms( + chain = 'THOR', + symbol = 'RUNE', + ticker = 'RUNE', + from_addr = p.from_addr, + amt = p.amt, + gas_limit = p.gas_limit, + account_number = p.account_number, + sequence = p.sequence, + decimals = 8, + memo = p.memo, + pubkey = p.pubkey, + wifkey = p.wifkey, + signature = p.signature), + skip_body_memo = skip_body_memo) + +def build_tx(cfg, proto, parms, *, null_fee=False, skip_body_memo=False): + + p = parms + assert type(p) in (send_tx_parms, deposit_tx_parms), f'{p}: invalid ‘parms’ (not *_tx_parms instance)' + + msg_type = 'MsgSend' if type(p) == send_tx_parms else 'MsgDeposit' + + if p.wifkey: + assert p.pubkey is None and p.signature is None + from ....key import PrivKey + from ....keygen import KeyGenerator + privkey = PrivKey(proto=proto, wif=p.wifkey) + pubkey = KeyGenerator(cfg, proto, 'std').to_pubkey(privkey) + else: + assert p.pubkey and p.signature + pubkey = p.pubkey + + cls = getattr(Messages, msg_type) + + if msg_type == 'MsgSend': + message = cls( + id = '/types.MsgSend', + body = cls.Body( + fromAddress = proto.decode_addr(p.from_addr).bytes, + toAddress = proto.decode_addr(p.to_addr).bytes, + amount = [Coin(denom='rune', amount=str(amt_to_base_unit(p.amt, decimals=8)))])) + fee_amt = None + elif msg_type == 'MsgDeposit': + coin_data = CoinWithAsset( + asset = Asset( + chain = p.chain, + symbol = p.symbol, + ticker = p.ticker, + synth = p.synth, + trade = p.trade, + secured = p.secured), + amount = str(amt_to_base_unit(p.amt, decimals=p.decimals or 8)), + decimals = p.decimals) + message = cls( + id = '/types.MsgDeposit', + body = cls.Body( + coins = [coin_data], + memo = p.memo, + signer = proto.decode_addr(p.from_addr).bytes)) + fee_amt = None if null_fee else [Coin(denom='rune', amount='0')] + + signer_info = SignerInfo( + publicKey = PublicKey( + id = '/cosmos.crypto.secp256k1.PubKey', + key = PublicKeyData(data=pubkey)), + modeInfo = ModeInfo( + single = ModeInfo.Single(mode=SignMode.SIGN_MODE_DIRECT), + multi = None), + sequence = p.sequence) + + body = RuneTxBody(messages=[message], memo=None if skip_body_memo else getattr(p, 'memo', None)) + + auth_info = AuthInfo(signerInfos=[signer_info], fee=Fee(gasLimit=p.gas_limit, amount=fee_amt)) + + signature = make_sig( + sign_doc = SignDoc( + bodyBytes = bytes(body), + authInfoBytes = bytes(auth_info), + chainId = proto.chain_id, + accountNumber = p.account_number), + sec_bytes = privkey) if p.wifkey else p.signature + + return RuneTx(body=body, authInfo=auth_info, signatures=[signature]) diff --git a/setup.cfg b/setup.cfg index 1781db64..1a0a439c 100644 --- a/setup.cfg +++ b/setup.cfg @@ -84,6 +84,8 @@ packages = mmgen.proto.btc.rpc mmgen.proto.btc.tx mmgen.proto.btc.tw + mmgen.proto.cosmos + mmgen.proto.cosmos.tx mmgen.proto.etc mmgen.proto.eth mmgen.proto.eth.pyethereum @@ -96,6 +98,7 @@ packages = mmgen.proto.rune mmgen.proto.rune.rpc mmgen.proto.rune.tw + mmgen.proto.rune.tx mmgen.proto.secp256k1 mmgen.proto.xchain mmgen.proto.xmr diff --git a/test/cmdtest_d/httpd/thornode/rpc.py b/test/cmdtest_d/httpd/thornode/rpc.py index 0b86e7e9..2619a9c8 100755 --- a/test/cmdtest_d/httpd/thornode/rpc.py +++ b/test/cmdtest_d/httpd/thornode/rpc.py @@ -30,6 +30,45 @@ class ThornodeRPCServer(ThornodeServer): {'denom': 'rune', 'amount': 987654321321}, {'denom': 'barcoin', 'amount': 123123123123}, ]} + elif m := re.search(r'/auth/accounts/(\S+)', req_str): + data = { + 'result': { + 'value': { + 'address': m[1], + 'pub_key': 'PubKeySecp256k1{0000}', + 'account_number': '1234', + 'sequence': '333444' + }}} + elif m := re.search(r'/tx$', req_str): + assert method == 'POST' + txid = environ['wsgi.input'].read(71).decode().removeprefix('hash=0x').upper() + data = { + 'result': { + 'hash': txid, + 'height': '21298600', + 'index': 2, + 'tx_result': { + 'gas_used': '173222', + 'events': [], + 'codespace': '' + }, + 'tx': 'MHgwMGZvb2Jhcg==' + } + } + elif m := re.search(r'/check_tx$', req_str): + assert method == 'POST' + data = { + 'result': { + 'code': 0, + 'data': '', + 'log': '', + 'info': '', + 'gas_wanted': '-1', + 'gas_used': '53774', + 'events': [], + 'codespace': '' + } + } else: raise ValueError(f'‘{req_str}’: malformed query path') diff --git a/test/modtest_d/rune.py b/test/modtest_d/rune.py new file mode 100755 index 00000000..62b41b15 --- /dev/null +++ b/test/modtest_d/rune.py @@ -0,0 +1,228 @@ +#!/usr/bin/env python3 + +import os +from collections import namedtuple +from decimal import Decimal + +from mmgen.cfg import Config +from mmgen.util import pp_fmt, ymsg +from mmgen.proto.btc.common import hash160 +from mmgen.proto.cosmos.tx.protobuf import RawTx, SignDoc +from mmgen.proto.rune.tx.protobuf import ( + RuneTx, + build_tx, + build_swap_tx, + tx_info, + send_tx_parms, + deposit_tx_parms, + swap_tx_parms) + +from ..include.common import vmsg, silence, end_silence + +test_cfg = Config({'coin': 'rune', 'test_suite': True}) + +altcoin_dep = True +_pv = namedtuple('parse_vector', ['fn', 'txid', 'parms', 'null_fee'], defaults=[None]) + +parse_vectors = [ + _pv( + 'mainnet-tx-msgsend1.binpb', + '36f91982c1911fe1aa66b44eed60e29175e5b8ae3301feef9158b7617779b00e', + send_tx_parms( + 'thor1t60f02r8jvzjrhtnjgfj4ne6rs5wjnejwmj7fh', + 'thor166n4w5039meulfa3p6ydg60ve6ueac7tlt0jws', + '12613.15290000', + 8000000, + 45060, + 302033, + pubkey = '02f9cbb8409443ccf043f26d8f91c2550d2578ecc49bb3ad89d4e21a7882bf1e23', + signature = 'd44b2e0c7546c5fae24a2c829757f49cce1bb29553f7e1a2f87c1ac2f1c46e22' # r + '509d765fc605d85e8967639864622ebc7c39a1a93fc20cf0fe5d703c4aa3636d')), # s + _pv( + 'mainnet-tx-msgdeposit1.binpb', + '1089bbd54746bbc6a40e264d3ce8085561978739094c9c5aac59c569b4c28ba9', + deposit_tx_parms( + 'THOR', 'RUNE', 'RUNE', + 'thor1lukwlve7hayy66qrdkp4k7sh0emjqwergy7tl3', + '299.23861844', + 600000000, + 125632, + 348388, + decimals = 8, + memo = '=:LTC~LTC:thor1lukwlve7hayy66qrdkp4k7sh0emjqwergy7tl3:605926421/0/1', + pubkey = '03da157f891abfe7822efb91f59667aa6cc6c3768a7e280caeb9ae243c969eb3e7', + signature = '869399bcc2ccb9c9c286bdf214439ad132221cb8206547ceb012e06efbc3ff3e' # r + '0ecc1ba4106702fb5b60cd7a8b94193ea71af5e6e860d978c49a6a63d97e4ded')), # s + _pv( + 'mainnet-tx-msgdeposit2.binpb', + '44f45b91e97558e63a11758ac3c186196b9b46f6331f32eff1256888ea879b62', + deposit_tx_parms( + 'ETH', 'USDT-0XDAC17F958D2EE523A2206206994597C13D831EC7', 'USDT', + 'thor1xxncvuptvmgcl5ep7rry3xehtw97jsg9uyv6rn', + '500.00000000', + 50000000, + 88176, + 104625, + decimals = None, + synth = False, + trade = True, + memo = '=:AVAX~AVAX:thor1xxncvuptvmgcl5ep7rry3xehtw97jsg9uyv6rn:2113883178', + pubkey = '02a6e97e3f20809511500d8895117d4344badda9e6af4216d41b10a105d1070254', + signature = 'b0673ab89781199d35b94051f26db30996f055abd71804d67fe4bdf33934bdb9' # r + '30d830675d398c6d042328ef681bf1a23e856ed0ab33d948ea149489400db953'), # s + null_fee = True), + _pv( + 'mainnet-tx-msgdeposit3.binpb', + '02d2fb2f2e5ac00ad4a31c37ffc43b72963f93598f8b3c8f4d3932c2e950b459', + deposit_tx_parms( + 'ETH', 'USDC-0XA0B86991C6218B36C1D19D4A2E9EB0CE3606EB48', 'USDC', + 'thor1lukwlve7hayy66qrdkp4k7sh0emjqwergy7tl3', + '2425.75973697', + 600000000, + 125632, + 375988, + decimals = 8, + synth = None, + trade = True, + memo = '=:THOR.RUNE:thor1lukwlve7hayy66qrdkp4k7sh0emjqwergy7tl3:127025748855/0/1', + pubkey = '03da157f891abfe7822efb91f59667aa6cc6c3768a7e280caeb9ae243c969eb3e7', + signature = 'be8324f6a1535b971d63715532e2e42ee3c35c05b81ed9b6bccd9a1765688eca' + '4ca00a66119b07c5168c6c78e22299becc82d5f311ee79ae9183776b0dff3269')) +] + +_bv = namedtuple('build_vector', ['txid', 'parms', 'null_fee'], defaults=[None]) + +build_vectors = [ + _bv( + '3939612d0ddc55fd4d1c6ef118d1b2085a6655cb57d55ac9efd658467e039e0c', + send_tx_parms( + 'thor1tx3nm6xfynq3re5ehtm6530z0pah9qjeu0r9nd', + 'thor1j5u6vlr8kzt76fe7896hsmurkhgn68j0z4qa6w', + '123.456789', + 8000000, + 12345, + 37, + wifkey = 'L5nWojqqMLq7wh3CfhxUNYQ38acABD6sUao9dfb8i5B5wSefCJXe')), + _bv( + '0d41e0ee40cd18a991cd8f0ef0e60e4c5bea898c53d54e00b6dddc0c9ce7edb7', + deposit_tx_parms( + 'THOR', 'RUNE', 'RUNE', + 'thor18ug6p4zs5dsy0m3u69gf5md5ssdg8hqkk8aya4', + '123.456789', + 8000000, + 12345, + 37, + decimals = 8, + memo = '=:MEMO', + wifkey = 'Ky9bSjPUD35uUaY3JReXiESivnfxV6rLMsW1wTFyvVZwYXpX95vF')) +] + +swap_build_vectors = [ + _bv( + '0d41e0ee40cd18a991cd8f0ef0e60e4c5bea898c53d54e00b6dddc0c9ce7edb7', + swap_tx_parms( + 'thor18ug6p4zs5dsy0m3u69gf5md5ssdg8hqkk8aya4', + '123.456789', + 8000000, + 12345, + 37, + memo = '=:MEMO', + wifkey = 'Ky9bSjPUD35uUaY3JReXiESivnfxV6rLMsW1wTFyvVZwYXpX95vF')) +] + +def test_tx(src, cfg, vec): + + proto = cfg._proto + parms = vec.parms._replace(amt=Decimal(vec.parms.amt)) + if parms.pubkey: + parms = parms._replace( + pubkey = bytes.fromhex(parms.pubkey), + signature = bytes.fromhex(parms.signature)) + + assert src in ('parse', 'build', 'swapbuild') + + if src == 'parse': + tx_in = open(os.path.join('test/ref/thorchain', vec.fn), 'br').read() + tx = RuneTx.loads(tx_in) + if not parms.from_addr: + ymsg(f'Warning: missing test vector data for {vec.fn}') + assert bytes(tx) == tx_in + elif src == 'build': + tx = build_tx(cfg, proto, parms, null_fee=vec.null_fee) + elif src == 'swapbuild': + tx = build_swap_tx(cfg, proto, parms) + + vmsg(pp_fmt(tx)) + + msg_type = 'MsgSend' if tx.body.messages[0].id == '/types.MsgSend' else 'MsgDeposit' + + vmsg('\n TX info:\n ' + '\n '.join(tx_info(tx, proto)) + '\n') + + tx.verify_sig(proto, parms.account_number) + + pubkey = tx.authInfo.signerInfos[0].publicKey.key.data + assert hash160(pubkey) == getattr( + tx.body.messages[0].body, + 'fromAddress' if msg_type == 'MsgSend' else 'signer') + assert tx.txid == vec.txid, tx.txid + + if src == 'parse' and parms.from_addr: + built_tx = build_tx(cfg, proto, parms, null_fee=vec.null_fee) + addr_from_pubkey = proto.encode_addr_bech32x(hash160(pubkey)) + assert addr_from_pubkey == parms.from_addr + assert bytes(built_tx) == tx_in + raw_tx = RawTx(bytes(tx.body), bytes(tx.authInfo), tx.signatures) + assert bytes(raw_tx) == bytes(RawTx.loads(tx_in)) + assert bytes(raw_tx) == bytes(tx.raw) + assert tx_in == bytes(tx) + +class unit_tests: + + def txparse(self, name, ut, desc='transaction parsing and signature verification'): + for vec in parse_vectors: + test_tx('parse', test_cfg, vec) + return True + + def txbuild(self, name, ut, desc='transaction building and signing (MsgSend, MsgDeposit)'): + for vec in build_vectors: + test_tx('build', test_cfg, vec) + return True + + def swaptxbuild(self, name, ut, desc='transaction building and signing (Swap TX)'): + for vec in swap_build_vectors: + test_tx('swapbuild', test_cfg, vec) + return True + + def rpc(self, name, ut, desc='remote RPC operations'): + import sys, asyncio + from mmgen.rpc import rpc_init + from ..cmdtest_d.httpd.thornode.rpc import ThornodeRPCServer + + silence() + regtest_cfg = Config({'coin': 'rune', 'regtest': True, 'test_suite': True}) + end_silence() + + thornode_server = ThornodeRPCServer() + thornode_server.start() + + addr = 'thor1lukwlve7hayy66qrdkp4k7sh0emjqwergy7tl3' + txhash = 'abcdef01' * 8 + txbytes = open('test/ref/thorchain/mainnet-tx-msgsend1.binpb', 'rb').read() + + async def main(): + + rpc = await rpc_init(regtest_cfg) + + res = rpc.get_account_info(addr) + assert res['address'] == addr + assert res['account_number'] + assert res['sequence'] + + res = rpc.get_tx_info(txhash) + assert res['hash'] == txhash.upper() + + res = rpc.tx_op(txbytes, op='check_tx') + assert res['code'] == 0 + + asyncio.run(main()) + return True diff --git a/test/ref/thorchain/mainnet-tx-msgdeposit1.binpb b/test/ref/thorchain/mainnet-tx-msgdeposit1.binpb new file mode 100644 index 00000000..5416f546 --- /dev/null +++ b/test/ref/thorchain/mainnet-tx-msgdeposit1.binpb @@ -0,0 +1,14 @@ + + + +/types.MsgDeposit +# + +THORRUNERUNE 29923861844C=:LTC~LTC:thor1lukwlve7hayy66qrdkp4k7sh0emjqwergy7tl3:605926421/0/1,>HMhm[z~w ;#C=:LTC~LTC:thor1lukwlve7hayy66qrdkp4k7sh0emjqwergy7tl3:605926421/0/1g +R +F +/cosmos.crypto.secp256k1.PubKey# +!.glv~( $< + + +rune0@̹†C2" eGΰn>g[`z>`xĚjc~M \ No newline at end of file diff --git a/test/ref/thorchain/mainnet-tx-msgdeposit2.binpb b/test/ref/thorchain/mainnet-tx-msgdeposit2.binpb new file mode 100644 index 00000000..0239abf3 Binary files /dev/null and b/test/ref/thorchain/mainnet-tx-msgdeposit2.binpb differ diff --git a/test/ref/thorchain/mainnet-tx-msgdeposit3.binpb b/test/ref/thorchain/mainnet-tx-msgdeposit3.binpb new file mode 100644 index 00000000..ff91e0d8 --- /dev/null +++ b/test/ref/thorchain/mainnet-tx-msgdeposit3.binpb @@ -0,0 +1,15 @@ + + + +/types.MsgDeposit +P +> +ETH/USDC-0XA0B86991C6218B36C1D19D4A2E9EB0CE3606EB48USDC( 242575973697H=:THOR.RUNE:thor1lukwlve7hayy66qrdkp4k7sh0emjqwergy7tl3:127025748855/0/1,>HMhm[z~w ;#H=:THOR.RUNE:thor1lukwlve7hayy66qrdkp4k7sh0emjqwergy7tl3:127025748855/0/1g +R +F +/cosmos.crypto.secp256k1.PubKey# +!.glv~( $< + + +rune0@$S[cqU2.\ٶ͚ehL +flx"̂ywk 2i \ No newline at end of file diff --git a/test/ref/thorchain/mainnet-tx-msgsend1.binpb b/test/ref/thorchain/mainnet-tx-msgsend1.binpb new file mode 100644 index 00000000..5028c123 --- /dev/null +++ b/test/ref/thorchain/mainnet-tx-msgsend1.binpb @@ -0,0 +1,11 @@ + +W +U +/types.MsgSendC +^g!s*:(O2֧WQ.ϧiι +rune 1261315290000[ +R +F +/cosmos.crypto.secp256k1.PubKey# +!˸@CCmU %xěx# +ѷ@K. uFJ,WS|n"Pv_^gcdb.|9? ]p