diff --git a/mmgen/cfg.py b/mmgen/cfg.py index 5434900f..3d8669ca 100755 --- a/mmgen/cfg.py +++ b/mmgen/cfg.py @@ -51,8 +51,9 @@ class GlobalConstants(Lockable): min_time_precision = 18 # must match CoinProtocol.coins - core_coins = ('btc', 'bch', 'ltc', 'eth', 'etc', 'zec', 'xmr') + core_coins = ('btc', 'bch', 'ltc', 'eth', 'etc', 'zec', 'xmr', 'rune') rpc_coins = ('btc', 'bch', 'ltc', 'eth', 'etc', 'xmr') + remote_rpc_coins = ('rune',) btc_fork_rpc_coins = ('btc', 'bch', 'ltc') eth_fork_coins = ('eth', 'etc') diff --git a/mmgen/data/version b/mmgen/data/version index 16e48615..1e1569b0 100644 --- a/mmgen/data/version +++ b/mmgen/data/version @@ -1 +1 @@ -15.1.dev43 +15.1.dev44 diff --git a/mmgen/main_txcreate.py b/mmgen/main_txcreate.py index a9a656ae..5123a665 100755 --- a/mmgen/main_txcreate.py +++ b/mmgen/main_txcreate.py @@ -44,7 +44,7 @@ opts_data = { -- -a, --autosign Create a transaction for offline autosigning (see + ‘mmgen-autosign’). The removable device is mounted and + unmounted automatically - -- -A, --fee-adjust= f Adjust transaction fee by factor 'f' (see below) + L- -A, --fee-adjust= f Adjust transaction fee by factor ‘f’ (see below) -- -B, --no-blank Don't blank screen before displaying {a_info} -- -c, --comment-file=f Source the transaction's comment from file 'f' b- -C, --fee-estimate-confs=c Desired number of confirmations for fee estimation @@ -53,7 +53,7 @@ opts_data = { e- -D, --contract-data=D Path to file containing hex-encoded contract data b- -E, --fee-estimate-mode=M Specify the network fee estimate mode. Choices: + {fe_all}. Default: {fe_dfl!r} - -- -f, --fee= f Transaction fee, as a decimal {cu} amount or as + L- -f, --fee= f Transaction fee, as a decimal {cu} amount or as + {fu} (an integer followed by {fl}). + See FEE SPECIFICATION below. If omitted, fee will be + calculated using network fee estimation. @@ -81,9 +81,12 @@ opts_data = { -s -S, --list-assets List available swap assets -- -v, --verbose Produce more verbose output b- -V, --vsize-adj= f Adjust transaction's estimated vsize by factor 'f' - -s -x, --proxy=P Fetch the swap quote via SOCKS5h proxy ‘P’ (host:port). + Ls -x, --proxy=P Fetch the swap quote via SOCKS5h proxy ‘P’ (host:port). + Use special value ‘env’ to honor *_PROXY environment + vars instead. + X- -x, --proxy=P Connect to remote server(s) via SOCKS5h proxy ‘P’ + + (host:port). Use special value ‘env’ to honor *_PROXY + + environment vars instead. -- -y, --yes Answer 'yes' to prompts, suppress non-essential output e- -X, --cached-balances Use cached balances """, diff --git a/mmgen/main_txdo.py b/mmgen/main_txdo.py index fda4ac51..4fc22a8a 100755 --- a/mmgen/main_txdo.py +++ b/mmgen/main_txdo.py @@ -41,7 +41,7 @@ opts_data = { 'options': """ -- -h, --help Print this help message -- --, --longhelp Print help message for long (global) options - -- -A, --fee-adjust= f Adjust transaction fee by factor 'f' (see below) + L- -A, --fee-adjust= f Adjust transaction fee by factor ‘f’ (see below) -- -b, --brain-params=l,p Use seed length 'l' and hash preset 'p' for + brainwallet input -- -B, --no-blank Don't blank screen before displaying {a_info} @@ -53,7 +53,7 @@ opts_data = { -- -e, --echo-passphrase Print passphrase to screen when typing it b- -E, --fee-estimate-mode=M Specify the network fee estimate mode. Choices: + {fe_all}. Default: {fe_dfl!r} - -- -f, --fee= f Transaction fee, as a decimal {cu} amount or as + L- -f, --fee= f Transaction fee, as a decimal {cu} amount or as + {fu} (an integer followed by {fl}). + See FEE SPECIFICATION below. If omitted, fee will be + calculated using network fee estimation. @@ -107,9 +107,12 @@ opts_data = { -- -v, --verbose Produce more verbose output b- -V, --vsize-adj= f Adjust transaction's estimated vsize by factor 'f' e- -w, --wait Wait for transaction confirmation - -s -x, --proxy=P Fetch the swap quote via SOCKS5h proxy ‘P’ (host:port). + Ls -x, --proxy=P Fetch the swap quote via SOCKS5h proxy ‘P’ (host:port). + Use special value ‘env’ to honor *_PROXY environment + vars instead. + X- -x, --proxy=P Connect to remote server(s) via SOCKS5h proxy ‘P’ + + (host:port). Use special value ‘env’ to honor *_PROXY + + environment vars instead. e- -X, --cached-balances Use cached balances -- -y, --yes Answer 'yes' to prompts, suppress non-essential output -- -z, --show-hash-presets Show information on available hash presets diff --git a/mmgen/opts.py b/mmgen/opts.py index 2485a10c..0fa1e5f7 100755 --- a/mmgen/opts.py +++ b/mmgen/opts.py @@ -358,6 +358,8 @@ class UserOpts(Opts): 'e' - Ethereum or Ethereum code fork 'r' - coin supporting RPC 'h' - Bitcoin Cash + 'L' - local RPC coin + 'X' - remote RPC coin '-' - other coin Cmd codes: 'p' - proto required @@ -372,10 +374,11 @@ class UserOpts(Opts): return ret( coin = caps.coin_codes or ( None if coin is None else - ['-', 'r', 'R', 'b', 'h'] if coin == 'bch' else - ['-', 'r', 'R', 'b'] if coin in gc.btc_fork_rpc_coins else - ['-', 'r', 'R', 'e'] if coin in gc.eth_fork_coins else - ['-', 'r'] if coin in gc.rpc_coins else + ['-', 'r', 'R', 'b', 'h', 'L'] if coin == 'bch' else + ['-', 'r', 'R', 'b', 'L'] if coin in gc.btc_fork_rpc_coins else + ['-', 'r', 'R', 'e', 'L'] if coin in gc.eth_fork_coins else + ['-', 'r', 'L'] if coin in gc.rpc_coins else + ['-', 'X'] if coin in gc.remote_rpc_coins else ['-']), cmd = ( ['-'] diff --git a/mmgen/proto/rune/params.py b/mmgen/proto/rune/params.py index d4b2e7df..88447ed4 100755 --- a/mmgen/proto/rune/params.py +++ b/mmgen/proto/rune/params.py @@ -27,7 +27,7 @@ class mainnet(CoinProtocol.Secp256k1): preferred_mmtypes = ('X',) dfl_mmtype = 'X' coin_amt = 'UniAmt' - max_tx_fee = 1 # TODO + max_tx_fee = 0.1 caps = () mmcaps = ('tw', 'rpc', 'rpc_init') base_proto = 'THORChain' diff --git a/mmgen/proto/rune/tw/view.py b/mmgen/proto/rune/tw/view.py index 081b104c..a8e54c8a 100755 --- a/mmgen/proto/rune/tw/view.py +++ b/mmgen/proto/rune/tw/view.py @@ -13,9 +13,4 @@ proto.rune.tw.view: THORChain protocol base class for tracking wallet view class """ class THORChainTwView: - - def gen_subheader(self, cw, color): - yield from super().gen_subheader(cw, color) - if self.proto.network == 'mainnet': - from ....color import red - yield red('For demonstration purposes only! DO NOT SPEND to these addresses!') # TODO + pass diff --git a/mmgen/proto/rune/tx/base.py b/mmgen/proto/rune/tx/base.py new file mode 100755 index 00000000..2783a127 --- /dev/null +++ b/mmgen/proto/rune/tx/base.py @@ -0,0 +1,60 @@ +#!/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.base: THORChain base transaction class +""" + +from ....tx.base import Base as TxBase + +class Base(TxBase): + + dfl_fee = 2000000 + dfl_gas = 800000 + usr_contract_data = None + disable_fee_check = False + + @property + def nondata_outputs(self): + return self.outputs + + @property + def usr_fee(self): + return self.proto.coin_amt(self.dfl_fee, from_unit='satoshi') + + def add_blockcount(self): + pass + + def is_replaceable(self): + return False + + def check_serialized_integrity(self): + if self.signed: + from .protobuf import RuneTx + tx = RuneTx.loads(bytes.fromhex(self.serialized)) + + o = self.txobj + b = tx.body.messages[0].body + + if s := self.proto.encode_addr_bech32x(b.fromAddress) != o['from']: + raise ValueError(f'{s}: invalid ‘from’ address in serialized data') + + if s := self.proto.encode_addr_bech32x(b.toAddress) != o['to']: + raise ValueError(f'{s}: invalid ‘to’ address in serialized data') + + if d := self.proto.coin_amt(int(b.amount[0].amount), from_unit='satoshi') != o['amt']: + raise ValueError(f'{d}: invalid send amount in serialized data') + + if n := tx.authInfo.signerInfos[0].sequence != o['sequence']: + raise ValueError(f'{n}: invalid sequence number in serialized data') + + if self.is_swap: + if b.memo != self.swap_memo.encode(): + raise ValueError(f'{b.memo}: invalid swap memo in serialized data') diff --git a/mmgen/proto/rune/tx/completed.py b/mmgen/proto/rune/tx/completed.py new file mode 100755 index 00000000..18016754 --- /dev/null +++ b/mmgen/proto/rune/tx/completed.py @@ -0,0 +1,29 @@ +#!/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.completed: THORChain completed transaction class +""" + +from ....tx import completed as TxBase + +from ...vm.tx.completed import Completed as VmCompleted + +from .base import Base + +class Completed(VmCompleted, Base, TxBase.Completed): + + @property + def total_gas(self): + return self.txobj['gas'] + + @property + def fee(self): + return self.proto.coin_amt(self.dfl_fee, from_unit='satoshi') diff --git a/mmgen/proto/rune/tx/info.py b/mmgen/proto/rune/tx/info.py new file mode 100755 index 00000000..3b1b4da3 --- /dev/null +++ b/mmgen/proto/rune/tx/info.py @@ -0,0 +1,49 @@ +#!/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.info: THORChain transaction info class +""" + +from ....tx.info import TxInfo, mmid_disp +from ....color import blue, pink +from ....obj import NonNegativeInt + +class TxInfo(TxInfo): + + def format_body(self, blockcount, nonmm_str, max_mmwid, enl, *, terse, sort): + tx = self.tx + t = tx.txobj + fs = """ + From: {f}{f_mmid} + To: {t}{t_mmid} + Amount: {a} {c} + Gas limit: {G} + Sequence: {N} + """.strip().replace('\t', '') + ('\nMemo: {m}' if tx.is_swap else '') + return fs.format( + f = t['from'].hl(0), + t = t['to'].hl(0) if tx.outputs else blue('None'), + a = t['amt'].hl(), + N = NonNegativeInt(t['sequence']).hl(), + m = pink(tx.swap_memo) if tx.is_swap else None, + c = tx.proto.dcoin if tx.outputs else '', + G = NonNegativeInt(tx.total_gas).hl(), + f_mmid = mmid_disp(tx.inputs[0], nonmm_str), + t_mmid = mmid_disp(tx.outputs[0], nonmm_str) if tx.outputs else '') + '\n\n' + + def format_abs_fee(self, iwidth, /, *, color=None): + return self.tx.fee.fmt(iwidth, color=color) + + def format_rel_fee(self): + return '' + + def format_verbose_footer(self): + return '' diff --git a/mmgen/proto/rune/tx/new.py b/mmgen/proto/rune/tx/new.py new file mode 100755 index 00000000..78228dc0 --- /dev/null +++ b/mmgen/proto/rune/tx/new.py @@ -0,0 +1,38 @@ +#!/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.new: THORChain new transaction class +""" + +from ....tx import new as TxBase + +from ...vm.tx.new import New as VmNew + +from .base import Base + +class New(VmNew, Base, TxBase.New): + + async def get_fee(self, fee, outputs_sum, start_fee_desc): + return await self.twctl.get_balance(self.inputs[0].addr) + + async def set_gas(self, *, to_addr=None, force=False): + self.gas = self.dfl_gas + + async def make_txobj(self): # called by create_serialized() + acct_info = self.rpc.get_account_info(self.inputs[0].addr) + self.txobj = { + 'from': self.inputs[0].addr, + 'to': self.outputs[0].addr if self.outputs else None, + 'amt': self.outputs[0].amt if self.outputs else self.swap_amt, + 'gas': self.gas, + 'account_number': int(acct_info['account_number']), + 'sequence': int(acct_info['sequence']), + 'chain_id': self.proto.chain_id} diff --git a/mmgen/proto/rune/tx/online.py b/mmgen/proto/rune/tx/online.py new file mode 100755 index 00000000..1843f26b --- /dev/null +++ b/mmgen/proto/rune/tx/online.py @@ -0,0 +1,51 @@ +#!/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.online: THORChain online signed transaction class +""" + +from ....util import pp_msg, die +from ....tx import online as TxBase + +from .signed import Signed + +class OnlineSigned(Signed, TxBase.OnlineSigned): + + async def test_sendable(self, txhex): + res = self.rpc.tx_op(bytes.fromhex(txhex), op='check_tx') + if res['code'] == 0: + return True + else: + pp_msg(res) + return False + + async def send_checks(self): + pass + + async def send_with_node(self, txhex): + res = self.rpc.tx_op(bytes.fromhex(txhex), op='broadcast_tx_sync') # broadcast_tx_async + if res['code'] == 0: + return res['hash'].lower() + else: + pp_msg(res) + die(2, 'Transaction send failed') + + async def post_network_send(self, coin_txid): + return True + +class Sent(TxBase.Sent, OnlineSigned): + pass + +class AutomountOnlineSigned(TxBase.AutomountOnlineSigned, OnlineSigned): + pass + +class AutomountSent(TxBase.AutomountSent, AutomountOnlineSigned): + pass diff --git a/mmgen/proto/rune/tx/signed.py b/mmgen/proto/rune/tx/signed.py new file mode 100755 index 00000000..8d234f0e --- /dev/null +++ b/mmgen/proto/rune/tx/signed.py @@ -0,0 +1,41 @@ +#!/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.signed: THORChain signed transaction class +""" + +from ....tx import signed as TxBase +from ....obj import CoinTxID, NonNegativeInt +from .completed import Completed + +class Signed(Completed, TxBase.Signed): + + desc = 'signed transaction' + + def parse_txfile_serialized_data(self): + from .protobuf import RuneTx + tx = RuneTx.loads(bytes.fromhex(self.serialized)) + + b = tx.body.messages[0].body + i = tx.authInfo + + self.txobj = { + 'from': self.proto.encode_addr_bech32x(b.fromAddress), + 'to': self.proto.encode_addr_bech32x(b.toAddress), + 'amt': self.proto.coin_amt(int(b.amount[0].amount), from_unit='satoshi'), + 'gas': NonNegativeInt(i.fee.gasLimit), + 'sequence': NonNegativeInt(i.signerInfos[0].sequence)} + + txid = CoinTxID(tx.txid) + assert txid == self.coin_txid, 'serialized txid doesn’t match txid in MMGen transaction file' + +class AutomountSigned(TxBase.AutomountSigned, Signed): + pass diff --git a/mmgen/proto/rune/tx/unsigned.py b/mmgen/proto/rune/tx/unsigned.py new file mode 100755 index 00000000..96115ba9 --- /dev/null +++ b/mmgen/proto/rune/tx/unsigned.py @@ -0,0 +1,54 @@ +#!/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.unsigned: THORChain unsigned transaction class +""" + +from ....tx import unsigned as TxBase +from ....obj import CoinTxID, NonNegativeInt +from ....addr import CoinAddr + +from ...vm.tx.unsigned import Unsigned as VmUnsigned + +from .completed import Completed + +class Unsigned(VmUnsigned, Completed, TxBase.Unsigned): + + def parse_txfile_serialized_data(self): + d = self.serialized + self.txobj = { + 'from': CoinAddr(self.proto, d['from']), + 'to': CoinAddr(self.proto, d['to']) if d['to'] else None, + 'amt': self.proto.coin_amt(d['amt']), + 'gas': NonNegativeInt(d['gas']), + 'account_number': NonNegativeInt(d['account_number']), + 'sequence': NonNegativeInt(d['sequence']), + 'chain_id': d['chain_id']} + + async def do_sign(self, o, wif): + from .protobuf import build_tx, send_tx_parms + tx = build_tx( + self.cfg, + self.proto, + send_tx_parms( + o['from'], + o['to'], + o['amt'], + o['gas'], + o['account_number'], + o['sequence'], + wifkey = wif)) + self.serialized = bytes(tx).hex() + self.coin_txid = CoinTxID(tx.txid) + tx.verify_sig(self.proto, o['account_number']) + +class AutomountUnsigned(TxBase.AutomountUnsigned, Unsigned): + pass diff --git a/mmgen/protocol.py b/mmgen/protocol.py index 923303c2..23756bde 100755 --- a/mmgen/protocol.py +++ b/mmgen/protocol.py @@ -47,7 +47,7 @@ class CoinProtocol(MMGenObject): 'etc': proto_info('EthereumClassic', 4), 'zec': proto_info('Zcash', 2), 'xmr': proto_info('Monero', 5), - 'rune': proto_info('THORChain', 2) + 'rune': proto_info('THORChain', 4) } class Base(Lockable): diff --git a/test/cmdtest_d/httpd/thornode/rpc.py b/test/cmdtest_d/httpd/thornode/rpc.py index 2619a9c8..7e1515bc 100755 --- a/test/cmdtest_d/httpd/thornode/rpc.py +++ b/test/cmdtest_d/httpd/thornode/rpc.py @@ -69,6 +69,19 @@ class ThornodeRPCServer(ThornodeServer): 'codespace': '' } } + elif m := re.search(r'/broadcast_tx_sync$', req_str): + assert method == 'POST' + txhex = environ['wsgi.input'].read(24).decode().removeprefix('tx=0x').upper() + if txhex.startswith('0A540A52'): + data = { + 'result': { + 'code': 0, + 'codespace': '', + 'data': '', + 'hash': '14463C716CF08A814868DB779156BCD85A1DF8EE49E924900A74482E9DEE132D', + 'log': '' + } + } else: raise ValueError(f'‘{req_str}’: malformed query path') diff --git a/test/cmdtest_d/rune.py b/test/cmdtest_d/rune.py index 76ab7550..b24eb30a 100755 --- a/test/cmdtest_d/rune.py +++ b/test/cmdtest_d/rune.py @@ -12,7 +12,7 @@ test.cmdtest_d.rune: THORChain RUNE tests for the cmdtest.py test suite """ -from .include.common import dfl_sid +from .include.common import dfl_sid, dfl_words_file from .httpd.thornode.rpc import ThornodeRPCServer from .ethdev import CmdTestEthdevMethods from .base import CmdTestBase @@ -42,6 +42,10 @@ class CmdTestRune(CmdTestEthdevMethods, CmdTestBase, CmdTestShared): 'tracking wallet and transaction operations', ('twview', 'viewing unspent outputs in tracking wallet'), ('bal_refresh', 'refreshing address balance in tracking wallet'), + ('txcreate1', 'creating a transaction'), + ('txsign1', 'signing the transaction'), + ('txsend1_test', 'testing whether the transaction can be sent'), + ('txsend1', 'sending the transaction'), ), } @@ -81,6 +85,47 @@ class CmdTestRune(CmdTestEthdevMethods, CmdTestBase, CmdTestShared): t.expect(self.menu_prompt, 'q') return t + def txcreate1(self): + t = self.spawn('mmgen-txcreate', self.rune_opts + ['98831F3A:X:2,54.321']) + t.expect(self.menu_prompt, 'q') + t.expect('spend from: ', '3\n') + t.expect('(y/N): ', 'y') # add comment? + t.expect('Comment: ', 'RUNE Boy\n') + t.expect('view: ', 'y') + t.expect('to continue: ', 'z') + t.expect('(y/N): ', 'y') # save? + t.written_to_file('Unsigned transaction') + return t + + def txsign1(self): + return self.txsign_ui_common( + self.spawn( + 'mmgen-txsign', + self.rune_opts + [self.get_file_with_ext('rawtx'), dfl_words_file], + no_passthru_opts = ['coin']), + has_label = True) + + def txsend1_test(self): + return self._txsend(add_args=['--test']) + + def txsend1(self): + return self._txsend() + + def _txsend(self, add_args=[]): + t = self.spawn( + 'mmgen-txsend', + self.rune_opts + add_args + [self.get_file_with_ext('sigtx')], + no_passthru_opts = ['coin']) + t.expect('view: ', 'y') + t.expect('to continue: ', 'z') + t.expect('(y/N): ', 'n') # edit comment? + if add_args == ['--test']: + t.expect('can be sent') + else: + t.expect('to confirm: ', 'YES\n') + t.written_to_file('Sent transaction') + return t + def thornode_server_stop(self): return CmdTestSwapMethods._thornode_server_stop( self, attrname='thornode_server', name='thornode server')