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
This commit is contained in:
The MMGen Project 2025-03-15 18:24:54 +00:00
commit 1eb0de7938
Signed by: mmgen
GPG key ID: 3F8B1861E32B7DA2
15 changed files with 411 additions and 19 deletions

View file

@ -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)
```

View file

@ -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))

View file

@ -1 +1 @@
15.1.dev19
15.1.dev20

View file

@ -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

View file

@ -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():

View file

@ -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')

223
mmgen/tx/tx_proxy.py Executable file
View file

@ -0,0 +1,223 @@
#!/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
"""
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)

View file

@ -46,5 +46,6 @@
semantic-version = semantic-version;
pexpect = pexpect; # test suite
pycoin = pycoin; # test suite
lxml = lxml;
};
}

View file

@ -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

View file

@ -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

View file

@ -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))

32
test/cmdtest_d/etherscan.py Executable file
View file

@ -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')

View file

@ -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

View file

@ -0,0 +1,34 @@
<!doctype html>
<html id="html" lang="en">
<head><title>
Broadcast Raw Transaction | Etherscan
</title>
<meta charset="utf-8" />
</head>
<body>
<form action="/search" method="GET">
<input type="hidden" value="" id="hdnSearchLabel" />
</form>
<form method="post" action="./pushTx" id="ctl00" class="js-validate">
<input type="hidden" name="__VIEWSTATE" id="__VIEWSTATE" value="foo" />
<input type="hidden" name="__VIEWSTATEGENERATOR" id="__VIEWSTATEGENERATOR" value="bar" />
<input type="hidden" name="__EVENTVALIDATION" id="__EVENTVALIDATION" value="baz" />
<div>
<div>
<label for="signedTransactionHex">Enter signed transaction hex</label>
<div class="js-form-message">
<textarea name="ctl00$ContentPlaceHolder1$txtRawTx" rows="8" cols="20" maxlength="50000" id="ContentPlaceHolder1_txtRawTx" required="" placeholder="e.g. 0x.." data-bg-msg="Please enter signed transaction hex">
</textarea>
</div>
<p>Tip: You can also broadcast programatically via our <a href="https://docs.etherscan.io/api-endpoints/geth-parity-proxy" target="_blank">[eth_sendRawTransaction]</a>. Accepts the paramater "hex" for prefilling the input box above (i.e <a href="/pushTx?hex=0x000000">Click here</a>)</p>
</div>
<div class="card-footer bg-light">
<input type="submit" name="ctl00$ContentPlaceHolder1$btnSubmit" value="Send Transaction" id="ContentPlaceHolder1_btnSubmit" class="btn btn-primary" />
</div>
</div>
</form>
</body>
</html>

View file

@ -0,0 +1,33 @@
<!doctype html>
<html id="html" lang="en">
<head><title>
Broadcast Raw Transaction | Etherscan
</title><meta charset="utf-8"/>
</head>
<body>
<form action="/search" method="GET">
<input id="hdnIsTestNet" value="False" type="hidden" />
</form>
<form method="post" action="./pushTx" id="ctl00" class="js-validate">
<input type="hidden" name="__VIEWSTATE" id="__VIEWSTATE" value="foo" />
<input type="hidden" name="__VIEWSTATEGENERATOR" id="__VIEWSTATEGENERATOR" value="bar" />
<input type="hidden" name="__EVENTVALIDATION" id="__EVENTVALIDATION" value="baz" />
<div>
<div>
<div role='alert'><button type='button'></button><strong>Success! </strong>{"jsonrpc":"2.0","id":1,"result":"0xbeadcafebeadcafebeadcafebeadcafebeadcafebeadcafebeadcafebeadcafe"}
<span class='d-block'><i></i> Transaction Hash: <a href='/tx/0xbeadcafebeadcafebeadcafebeadcafebeadcafebeadcafebeadcafebeadcafe'><span class='text-primary'><a href='/tx/0xbeadcafebeadcafebeadcafebeadcafebeadcafebeadcafebeadcafebeadcafe'>0xbeadcafebeadcafebeadcafebeadcafebeadcafebeadcafebeadcafebeadcafe</a></span></b></a></span></div>
<label for="signedTransactionHex">Enter signed transaction hex</label>
<div>
<textarea name="ctl00$ContentPlaceHolder1$txtRawTx" rows="8" cols="20" maxlength="50000" id="ContentPlaceHolder1_txtRawTx" required="" placeholder="e.g. 0x.." data-bg-msg="Please enter signed transaction hex">
0xdeadbeef</textarea>
</div>
<p>Tip: You can also broadcast programatically via our <a href="https://docs.etherscan.io/api-endpoints/geth-parity-proxy" target="_blank">[eth_sendRawTransaction]</a>. Accepts the paramater "hex" for prefilling the input box above (i.e <a href="/pushTx?hex=0x000000">Click here</a>)</p>
</div>
<div>
<input type="submit" name="ctl00$ContentPlaceHolder1$btnSubmit" value="Send Transaction" id="ContentPlaceHolder1_btnSubmit" />
</div>
</div>
</form>
</body>
</html>