Browse Source

RUNE low-level transaction support

Testing:

    $ test/modtest.py rune
The MMGen Project 5 months ago
parent
commit
55e59cee12

+ 1 - 0
alt-requirements.txt

@@ -1 +1,2 @@
 pycryptodome
+pure-protobuf

+ 1 - 1
mmgen/data/release_date

@@ -1 +1 @@
-May 2025
+June 2025

+ 1 - 1
mmgen/data/version

@@ -1 +1 @@
-15.1.dev42
+15.1.dev43

+ 173 - 0
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 <mmgen@tuta.io>
+# 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)

+ 1 - 0
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'

+ 32 - 0
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')

+ 276 - 0
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 <mmgen@tuta.io>
+# 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])

+ 3 - 0
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

+ 39 - 0
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')
 

+ 228 - 0
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

+ 14 - 0
test/ref/thorchain/mainnet-tx-msgdeposit1.binpb

@@ -0,0 +1,14 @@
+
+Þ
+–
+/types.MsgDeposit€
+#
+
+THORRUNERUNE29923861844C=:LTC~LTC:thor1lukwlve7hayy66qrdkp4k7sh0emjqwergy7tl3:605926421/0/1ÿ,ï³>¿HMhmƒ[z~w ;#C=:LTC~LTC:thor1lukwlve7hayy66qrdkp4k7sh0emjqwergy7tl3:605926421/0/1g
+R
+F
+/cosmos.crypto.secp256k1.PubKey#
+!Ú‰¿ç‚.û‘õ–gªlÆÃvŠ~(®¹®$<–ž³ç
+ä¡
+	
+rune0€Œ�ž@†“™¼Â̹Ɇ½òCšÑ2"¸ eGΰànûÃÿ>̤gû[`Íz‹”>§õæè`ÙxÄšjcÙ~Mí

BIN
test/ref/thorchain/mainnet-tx-msgdeposit2.binpb


+ 15 - 0
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#
+!Ú‰¿ç‚.û‘õ–gªlÆÃvŠ~(®¹®$<–ž³ç
+´ù
+	
+rune0€Œ�ž@¾ƒ$ö¡S[—cqU2âä.ãÃ\¸Ù¶¼ÍšehŽÊL 
+f›ÅŒlxâ"™¾Ì‚Õóîy®‘ƒwk
ÿ2i

+ 11 - 0
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#
+!ù˸@”CÌðCòm�‘ÂU
%xìÄ›³­‰Ôâx‚¿#
+Ñ·€¤è@ÔK.uFÅúâJ,‚—WôœÎ²•S÷á¢ø|ÂñÄn"P�v_ÆØ^‰gc˜db.¼|9¡©?Âðþ]p<J£cm