From 1eb0de793855c7a1e00638d6b0969cb96ab4628e Mon Sep 17 00:00:00 2001 From: The MMGen Project Date: Sat, 15 Mar 2025 18:24:54 +0000 Subject: [PATCH] ETH: transaction sending via Etherscan - the HTML transaction broadcast form is used, so no API key is required - the request can be proxied through Tor - availability of the service can be checked with the --test option Example: # check availability: $ mmgen-txsend --autosign --coin=eth --tx-proxy=etherscan --proxy=localhost:9050 --test # send: $ mmgen-txsend --autosign --coin=eth --tx-proxy=etherscan --proxy=localhost:9050 Testing: $ test/cmdtest.py --coin=eth -e -X txsend_etherscan ethdev --- doc/wiki/commands/command-help-txsend.md | 42 ++-- mmgen/cfg.py | 5 +- mmgen/data/version | 2 +- mmgen/help/help_notes.py | 4 + mmgen/main_txsend.py | 18 ++ mmgen/obj.py | 3 + mmgen/tx/tx_proxy.py | 223 +++++++++++++++++++++ nix/packages.nix | 1 + setup.cfg | 1 + test/cmdtest_d/ct_ethdev.py | 19 +- test/cmdtest_d/ct_shared.py | 3 + test/cmdtest_d/etherscan.py | 32 +++ test/overlay/fakemods/mmgen/tx/tx_proxy.py | 10 + test/ref/ethereum/etherscan-form.html | 34 ++++ test/ref/ethereum/etherscan-result.html | 33 +++ 15 files changed, 411 insertions(+), 19 deletions(-) create mode 100755 mmgen/tx/tx_proxy.py create mode 100755 test/cmdtest_d/etherscan.py create mode 100755 test/overlay/fakemods/mmgen/tx/tx_proxy.py create mode 100644 test/ref/ethereum/etherscan-form.html create mode 100644 test/ref/ethereum/etherscan-result.html diff --git a/doc/wiki/commands/command-help-txsend.md b/doc/wiki/commands/command-help-txsend.md index 4c728936..87336ec9 100644 --- a/doc/wiki/commands/command-help-txsend.md +++ b/doc/wiki/commands/command-help-txsend.md @@ -2,21 +2,31 @@ MMGEN-TXSEND: Send a signed MMGen cryptocoin transaction USAGE: mmgen-txsend [opts] [signed transaction file] OPTIONS: - -h, --help Print this help message - --longhelp Print help message for long (global) options - -a, --autosign Send an autosigned transaction created by ‘mmgen-txcreate - --autosign’. The removable device is mounted and unmounted - automatically. The transaction file argument must be omitted - when using this option - -A, --abort Abort an unsent transaction created by ‘mmgen-txcreate - --autosign’ and delete it from the removable device. The - transaction may be signed or unsigned. - -d, --outdir d Specify an alternate directory 'd' for output - -q, --quiet Suppress warnings; overwrite files without prompting - -s, --status Get status of a sent transaction (or the current transaction, - whether sent or unsent, when used with --autosign) - -v, --verbose Be more verbose - -y, --yes Answer 'yes' to prompts, suppress non-essential output + -h, --help Print this help message + --longhelp Print help message for long (global) options + -a, --autosign Send an autosigned transaction created by ‘mmgen-txcreate + --autosign’. The removable device is mounted and unmounted + automatically. The transaction file argument must be omitted + when using this option + -A, --abort Abort an unsent transaction created by ‘mmgen-txcreate + --autosign’ and delete it from the removable device. The + transaction may be signed or unsigned. + -d, --outdir d Specify an alternate directory 'd' for output + -H, --dump-hex F Instead of sending to the network, dump the transaction hex + to file ‘F’. Use filename ‘-’ to dump to standard output. + -m, --mark-sent Mark the transaction as sent by adding it to the removable + device. Used in combination with --autosign when a trans- + action has been successfully sent out-of-band. + -n, --tx-proxy P Send transaction via public TX proxy ‘P’ (supported proxies: + ‘etherscan’). This is done via a publicly accessible web + page, so no API key or registration is required + -q, --quiet Suppress warnings; overwrite files without prompting + -s, --status Get status of a sent transaction (or current transaction, + whether sent or unsent, when used with --autosign) + -t, --test Test whether the transaction can be sent without sending it + -v, --verbose Be more verbose + -x, --proxy P Connect to TX proxy via SOCKS5 proxy ‘P’ (host:port) + -y, --yes Answer 'yes' to prompts, suppress non-essential output - MMGEN v15.1.dev18 March 2025 MMGEN-TXSEND(1) + MMGEN v15.1.dev20 March 2025 MMGEN-TXSEND(1) ``` diff --git a/mmgen/cfg.py b/mmgen/cfg.py index fa88721b..ef4a5d60 100755 --- a/mmgen/cfg.py +++ b/mmgen/cfg.py @@ -381,7 +381,9 @@ class Config(Lockable): 'fee_estimate_mode': _ov('nocase_pfx', ['conservative', 'economical']), 'rpc_backend': _ov('nocase_pfx', ['auto', 'httplib', 'curl', 'aiohttp', 'requests']), 'swap_proto': _ov('nocase_pfx', ['thorchain']), + 'tx_proxy': _ov('nocase_pfx', ['etherscan']) # , 'blockchair' } + _dfl_none_autoset_opts = ('tx_proxy',) _auto_typeset_opts = { 'seed_len': int, @@ -719,7 +721,8 @@ class Config(Lockable): val = None if val is None: - setattr(self, key, self._autoset_opts[key].choices[0]) + if key not in self._dfl_none_autoset_opts: + setattr(self, key, self._autoset_opts[key].choices[0]) else: setattr(self, key, get_autoset_opt(key, val, src=src)) diff --git a/mmgen/data/version b/mmgen/data/version index cb4d67a4..7965f352 100644 --- a/mmgen/data/version +++ b/mmgen/data/version @@ -1 +1 @@ -15.1.dev19 +15.1.dev20 diff --git a/mmgen/help/help_notes.py b/mmgen/help/help_notes.py index 338a6586..66efc4e1 100755 --- a/mmgen/help/help_notes.py +++ b/mmgen/help/help_notes.py @@ -106,6 +106,10 @@ FMT CODES: from ..util import fmt_list return fmt_list(CoinDaemon.get_network_ids(self.cfg), fmt='bare') + def tx_proxies(self): + from ..util import fmt_list + return fmt_list(self.cfg._autoset_opts['tx_proxy'].choices, fmt='fancy') + def rel_fee_desc(self): from ..tx import BaseTX return BaseTX(cfg=self.cfg, proto=self.proto).rel_fee_desc diff --git a/mmgen/main_txsend.py b/mmgen/main_txsend.py index b7cb3711..1e3a9b13 100755 --- a/mmgen/main_txsend.py +++ b/mmgen/main_txsend.py @@ -49,13 +49,21 @@ opts_data = { -m, --mark-sent Mark the transaction as sent by adding it to the removable device. Used in combination with --autosign when a trans- action has been successfully sent out-of-band. +-n, --tx-proxy=P Send transaction via public TX proxy ‘P’ (supported proxies: + {tx_proxies}). This is done via a publicly accessible web + page, so no API key or registration is required -q, --quiet Suppress warnings; overwrite files without prompting -s, --status Get status of a sent transaction (or current transaction, whether sent or unsent, when used with --autosign) -t, --test Test whether the transaction can be sent without sending it -v, --verbose Be more verbose +-x, --proxy=P Connect to TX proxy via SOCKS5 proxy ‘P’ (host:port) -y, --yes Answer 'yes' to prompts, suppress non-essential output """ + }, + 'code': { + 'options': lambda cfg, proto, help_notes, s: s.format( + tx_proxies = help_notes('tx_proxies')) } } @@ -70,6 +78,10 @@ if cfg.mark_sent and not cfg.autosign: if cfg.test and cfg.dump_hex: die(1, '--test cannot be used in combination with --dump-hex') +if cfg.tx_proxy: + from .tx.tx_proxy import check_client + check_client(cfg) + if cfg.dump_hex and cfg.dump_hex != '-': from .fileutil import check_outfile_dir check_outfile_dir(cfg.dump_hex) @@ -162,6 +174,12 @@ async def main(): await post_send(tx) else: await post_send(tx) + elif cfg.tx_proxy: + from .tx.tx_proxy import send_tx + if send_tx(cfg, tx): + if (not cfg.autosign or + keypress_confirm(cfg, 'Mark transaction as sent on removable device?')): + await post_send(tx) elif cfg.test: await tx.test_sendable() elif await tx.send(): diff --git a/mmgen/obj.py b/mmgen/obj.py index fa51aa2e..4941b05d 100755 --- a/mmgen/obj.py +++ b/mmgen/obj.py @@ -332,6 +332,9 @@ class HexStr(HiliteStr, InitErrors): class CoinTxID(HexStr): color, width, hexcase = ('purple', 64, 'lower') +def is_coin_txid(s): + return get_obj(CoinTxID, s=s, silent=True, return_bool=True) + class WalletPassword(HexStr): color, width, hexcase = ('blue', 32, 'lower') diff --git a/mmgen/tx/tx_proxy.py b/mmgen/tx/tx_proxy.py new file mode 100755 index 00000000..48510685 --- /dev/null +++ b/mmgen/tx/tx_proxy.py @@ -0,0 +1,223 @@ +#!/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 + +""" +tx.tx_proxy: tx proxy classes +""" + +from ..color import green, pink, orange +from ..util import msg, msg_r, die + +class TxProxyClient: + + proto = 'https' + verify = True + timeout = 60 + http_hdrs = { + 'User-Agent': 'curl/8.7.1', + 'Proxy-Connection': 'Keep-Alive'} + + def __init__(self, cfg): + self.cfg = cfg + import requests + self.session = requests.Session() + self.session.trust_env = False # ignore *_PROXY environment vars + self.session.headers = self.http_hdrs + if self.cfg.proxy: + self.session.proxies.update({ + 'http': f'socks5h://{self.cfg.proxy}', + 'https': f'socks5h://{self.cfg.proxy}' + }) + + def call(self, name, path, err_fs, timeout, *, data=None): + url = self.proto + '://' + self.host + path + kwargs = { + 'url': url, + 'timeout': timeout or self.timeout, + 'verify': self.verify} + if data: + kwargs['data'] = data + res = getattr(self.session, name)(**kwargs) + if res.status_code != 200: + die(2, '\n' + err_fs.format(s=res.status_code, u=url, d=data)) + return res.content.decode() + + def get(self, *, path, timeout=None): + err_fs = 'HTTP Get failed with status code {s}\n URL: {u}' + return self.call('get', path, err_fs, timeout) + + def post(self, *, path, data, timeout=None): + err_fs = 'HTTP Post failed with status code {s}\n URL: {u}\n DATA: {d}' + return self.call('post', path, err_fs, timeout, data=data) + + def get_form(self, timeout=None): + return self.get(path=self.form_path, timeout=timeout) + + def post_form(self, *, data, timeout=None): + return self.post(path=self.form_path, data=data, timeout=timeout) + + def get_form_element(self, text): + from lxml import html + root = html.document_fromstring(text) + res = [e for e in root.forms if e.attrib.get('action', '').endswith(self.form_path)] + assert res, 'no matching forms!' + assert len(res) == 1, 'more than one matching form!' + return res[0] + + def cache_fn(self, desc): + return f'{self.name}-{desc}.html' + + def save_response(self, data, desc): + from ..fileutil import write_data_to_file + write_data_to_file( + self.cfg, + self.cache_fn(desc), + data, + desc = f'{desc} page from {orange(self.host)}') + +class BlockchairTxProxyClient(TxProxyClient): + + name = 'blockchair' + host = 'blockchair.com' + form_path = '/broadcast' + assets = { + 'avax': 'avalanche', + 'btc': 'bitcoin', + 'bch': 'bitcoin-cash', + 'bnb': 'bnb', + 'dash': 'dash', + 'doge': 'dogecoin', + 'eth': 'ethereum', + 'etc': 'ethereum-classic', + 'ltc': 'litecoin', + 'zec': 'zcash', + } + active_assets = () # tried with ETH, doesn’t work + + def create_post_data(self, *, form_text, coin, tx_hex): + + coin = coin.lower() + assert coin in self.assets, f'coin {coin} not supported by {self.name}' + asset = self.assets[coin] + + form = self.get_form_element(form_text) + data = {} + + e = form.find('.//input') + assert e.attrib['name'] == '_token', 'input name incorrect!' + data['_token'] = e.attrib['value'] + + e = form.find('.//textarea') + assert e.attrib['name'] == 'data', 'textarea name incorrect!' + data['data'] = '0x' + tx_hex + + e = form.find('.//button') + assert e is not None, 'missing button!' + + e = form.find('.//select') + assert e.attrib['name'] == 'blockchain', 'select element name incorrect!' + + assets = [f.get('value') for f in e.iter() if f.get('value')] + assert asset in assets, f'coin {coin} ({asset}) not currently supported by {self.name}' + + data['blockchain'] = asset + + return data + + def get_txid(self, *, result_text): + msg(f'Response parsing TBD. Check the cached response at {self.cache_fn("result")}') + +class EtherscanTxProxyClient(TxProxyClient): + name = 'etherscan' + host = 'etherscan.io' + form_path = '/pushTx' + assets = {'eth': 'ethereum'} + active_assets = ('eth',) + + def create_post_data(self, *, form_text, coin, tx_hex): + + form = self.get_form_element(form_text) + data = {} + + for e in form.findall('.//input'): + data[e.attrib['name']] = e.attrib['value'] + + if len(data) != 4: + msg('') + self.save_response(form_text, 'form') + die(3, f'{len(data)}: unexpected number of keys in data (expected 4)') + + e = form.find('.//textarea') + data[e.attrib['name']] = '0x' + tx_hex + + return data + + def get_txid(self, *, result_text): + import json + from ..obj import CoinTxID, is_coin_txid + form = self.get_form_element(result_text) + json_text = form.find('div/div/div')[1].tail + txid = json.loads(json_text)['result'].removeprefix('0x') + if is_coin_txid(txid): + return CoinTxID(txid) + else: + return False + +def send_tx(cfg, tx): + + c = get_client(cfg) + msg(f'Using {pink(cfg.tx_proxy.upper())} tx proxy') + + if not cfg.test: + tx.confirm_send() + + msg_r(f'Retrieving form from {orange(c.host)}...') + form_text = c.get_form(timeout=180) + msg('done') + + msg_r('Parsing form...') + post_data = c.create_post_data( + form_text = form_text, + coin = cfg.coin, + tx_hex = tx.serialized) + msg('done') + + if cfg.test: + msg(f'Form retrieved from {orange(c.host)} and parsed') + msg(green('Transaction can be sent')) + return False + + msg_r('Sending data...') + result_text = c.post_form(data=post_data, timeout=180) + msg('done') + + msg_r('Parsing response...') + txid = c.get_txid(result_text=result_text) + msg('done') + + msg('Transaction ' + (f'sent: {txid.hl()}' if txid else 'send failed')) + c.save_response(result_text, 'result') + + return bool(txid) + +tx_proxies = { + 'blockchair': BlockchairTxProxyClient, + 'etherscan': EtherscanTxProxyClient +} + +def get_client(cfg, *, check_only=False): + proxy = tx_proxies[cfg.tx_proxy] + if cfg.coin.lower() in proxy.active_assets: + return True if check_only else proxy(cfg) + else: + die(1, f'Coin {cfg.coin} not supported by TX proxy {pink(proxy.name.upper())}') + +def check_client(cfg): + return get_client(cfg, check_only=True) diff --git a/nix/packages.nix b/nix/packages.nix index bd73bc90..b4f33b30 100644 --- a/nix/packages.nix +++ b/nix/packages.nix @@ -46,5 +46,6 @@ semantic-version = semantic-version; pexpect = pexpect; # test suite pycoin = pycoin; # test suite + lxml = lxml; }; } diff --git a/setup.cfg b/setup.cfg index 74f66d70..cf4f7625 100644 --- a/setup.cfg +++ b/setup.cfg @@ -58,6 +58,7 @@ install_requires = aiohttp requests pexpect + lxml scrypt; platform_system != "Windows" # must be installed by hand on MSYS2 semantic-version; platform_system != "Windows" # scripts/create-token.py diff --git a/test/cmdtest_d/ct_ethdev.py b/test/cmdtest_d/ct_ethdev.py index 0615ae37..431ba5f4 100755 --- a/test/cmdtest_d/ct_ethdev.py +++ b/test/cmdtest_d/ct_ethdev.py @@ -58,6 +58,7 @@ from .common import ( ) from .ct_base import CmdTestBase from .ct_shared import CmdTestShared +from .etherscan import run_etherscan_server del_addrs = ('4', '1') dfl_sid = '98831F3A' @@ -178,6 +179,12 @@ token_bals_getbalance = lambda k: { coin = cfg.coin +def etherscan_server_start(): + import threading + t = threading.Thread(target=run_etherscan_server, name='Etherscan server thread') + t.daemon = True + t.start() + class CmdTestEthdev(CmdTestBase, CmdTestShared): 'Ethereum transacting, token deployment and tracking wallet operations' networks = ('eth', 'etc') @@ -237,6 +244,8 @@ class CmdTestEthdev(CmdTestBase, CmdTestShared): ('txview1_sig', 'viewing the signed transaction'), ('tx_status0_bad', 'getting the transaction status'), ('txsign1_ni', 'signing the transaction (non-interactive)'), + ('txsend_etherscan_test','sending the transaction via Etherscan (simulation, with --test)'), + ('txsend_etherscan', 'sending the transaction via Etherscan (simulation)'), ('txsend1', 'sending the transaction'), ('bal1', f'the {coin} balance'), @@ -450,6 +459,9 @@ class CmdTestEthdev(CmdTestBase, CmdTestShared): self.message = 'attack at dawn' self.spawn_env['MMGEN_BOGUS_SEND'] = '' + if type(self) is CmdTestEthdev: + etherscan_server_start() # TODO: stop server when test group finishes executing + @property async def rpc(self): from mmgen.rpc import rpc_init @@ -715,7 +727,7 @@ class CmdTestEthdev(CmdTestBase, CmdTestShared): + [txfile, dfl_words_file]) return self.txsign_ui_common(t, ni=ni, has_label=True) - def txsend(self, ext='{}.regtest.sigtx', add_args=[]): + def txsend(self, ext='{}.regtest.sigtx', add_args=[], test=False): ext = ext.format('-α' if cfg.debug_utf8 else '') txfile = self.get_file_with_ext(ext, no_dot=True) t = self.spawn('mmgen-txsend', self.eth_args + add_args + [txfile]) @@ -723,6 +735,7 @@ class CmdTestEthdev(CmdTestBase, CmdTestShared): t, quiet = not cfg.debug, bogus_send = False, + test = test, has_label = True) return t @@ -765,6 +778,10 @@ class CmdTestEthdev(CmdTestBase, CmdTestShared): return self.tx_status(ext='{}.regtest.sigtx', expect_str='neither in mempool nor blockchain', exit_val=1) def txsign1_ni(self): return self.txsign(ni=True, dev_send=True) + def txsend_etherscan_test(self): + return self.txsend(add_args=['--tx-proxy=ether', '--test'], test='tx_proxy') + def txsend_etherscan(self): + return self.txsend(add_args=['--tx-proxy=ethersc']) def txsend1(self): return self.txsend() def txview1_sig(self): # do after send so that TxID is displayed diff --git a/test/cmdtest_d/ct_shared.py b/test/cmdtest_d/ct_shared.py index 976227e6..fb652be6 100755 --- a/test/cmdtest_d/ct_shared.py +++ b/test/cmdtest_d/ct_shared.py @@ -179,6 +179,9 @@ class CmdTestShared: if bogus_send: txid = '' t.expect('BOGUS transaction NOT sent') + elif test == 'tx_proxy': + t.expect('can be sent') + return True else: m = 'TxID: ' if test else 'Transaction sent: ' txid = strip_ansi_escapes(t.expect_getend(m)) diff --git a/test/cmdtest_d/etherscan.py b/test/cmdtest_d/etherscan.py new file mode 100755 index 00000000..56f9e993 --- /dev/null +++ b/test/cmdtest_d/etherscan.py @@ -0,0 +1,32 @@ +#!/usr/bin/env python3 + +from http.server import HTTPServer, CGIHTTPRequestHandler + +from mmgen.util import msg +from mmgen.util2 import port_in_use + +class handler(CGIHTTPRequestHandler): + header = b'HTTP/1.1 200 OK\nContent-type: text/html\n\n' + + def do_response(self, target): + with open(f'test/ref/ethereum/etherscan-{target}.html') as fh: + text = fh.read() + self.wfile.write(self.header + text.encode()) + + def do_GET(self): + return self.do_response('form') + + def do_POST(self): + return self.do_response('result') + +def run_etherscan_server(server_class=HTTPServer, handler_class=handler): + + if port_in_use(28800): + msg('Port 28800 in use. Assuming etherscan server is running') + return True + + msg('Etherscan server listening on port 28800') + server_address = ('localhost', 28800) + httpd = server_class(server_address, handler_class) + httpd.serve_forever() + msg('Etherscan server exiting') diff --git a/test/overlay/fakemods/mmgen/tx/tx_proxy.py b/test/overlay/fakemods/mmgen/tx/tx_proxy.py new file mode 100755 index 00000000..446c1f5d --- /dev/null +++ b/test/overlay/fakemods/mmgen/tx/tx_proxy.py @@ -0,0 +1,10 @@ +from .tx_proxy_orig import * + +class overlay_fake_EtherscanTxProxyClient: + proto = 'http' + host = 'localhost:28800' + verify = False + +EtherscanTxProxyClient.proto = overlay_fake_EtherscanTxProxyClient.proto +EtherscanTxProxyClient.host = overlay_fake_EtherscanTxProxyClient.host +EtherscanTxProxyClient.verify = overlay_fake_EtherscanTxProxyClient.verify diff --git a/test/ref/ethereum/etherscan-form.html b/test/ref/ethereum/etherscan-form.html new file mode 100644 index 00000000..cff7c73d --- /dev/null +++ b/test/ref/ethereum/etherscan-form.html @@ -0,0 +1,34 @@ + + + + Broadcast Raw Transaction | Etherscan + + + + + +
+ +
+ +
+ + + +
+
+ +
+ +
+

Tip: You can also broadcast programatically via our [eth_sendRawTransaction]. Accepts the paramater "hex" for prefilling the input box above (i.e Click here)

+
+ + +
+
+ + diff --git a/test/ref/ethereum/etherscan-result.html b/test/ref/ethereum/etherscan-result.html new file mode 100644 index 00000000..5ede565c --- /dev/null +++ b/test/ref/ethereum/etherscan-result.html @@ -0,0 +1,33 @@ + + + + Broadcast Raw Transaction | Etherscan + + + +
+ +
+ +
+ + + +
+
+
Success! {"jsonrpc":"2.0","id":1,"result":"0xbeadcafebeadcafebeadcafebeadcafebeadcafebeadcafebeadcafebeadcafe"} + Transaction Hash: 0xbeadcafebeadcafebeadcafebeadcafebeadcafebeadcafebeadcafebeadcafe
+ +
+ +
+

Tip: You can also broadcast programatically via our [eth_sendRawTransaction]. Accepts the paramater "hex" for prefilling the input box above (i.e Click here)

+
+
+ +
+
+
+ +