You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 

358 lines
15 KiB

# -*- coding: utf-8 -*-
import json
import hashlib
import os
from electrum.bitcoin import base_encode
from .. import jmbitcoin as btc
from ..jmbase import commands
from ..jmbase import hextobin, bintohex
from ..jmdaemon import JMDaemonServerProtocol
class BaseClientProtocol(commands.CallRemoteMock):
def checkClientResponse(self, response):
"""A generic check of client acceptance; any failure
is considered criticial.
"""
if 'accepted' not in response or not response['accepted']:
self.logger.error(f'checkClientResponse not accepted: {response}')
raise Exception(response)
def defaultErrback(self, failure):
raise failure
def defaultCallbacks(self, d):
d.addCallback(self.checkClientResponse)
d.addErrback(self.defaultErrback)
class JMClientProtocol(BaseClientProtocol):
def __init__(self, factory, client, nick_priv=None):
self.client = client
self.factory = factory
if not nick_priv:
self.nick_priv = hashlib.sha256(
os.urandom(16)).digest() + b"\x01"
else:
self.nick_priv = nick_priv
def connectionMade(self):
self.logger.debug('connection was made, starting client.')
def set_nick(self):
""" Algorithm: take pubkey and hex-serialized it;
then SHA2(hexpub) but truncate output to nick_hashlen.
Then encode to a base58 string (no check).
Then prepend J and version char (e.g. '5').
Finally append padding to nick_maxencoded (+2).
"""
self.nick_pubkey = btc.privkey_to_pubkey(self.nick_priv)
# note we use binascii hexlify directly here because input
# to hashing must be encoded.
self.nick_pkh_raw = hashlib.sha256(
self.nick_pubkey.get_public_key_hex().encode()
).digest()[:self.nick_hashlen]
self.nick_pkh = base_encode(self.nick_pkh_raw, base=58)
# right pad to maximum possible; b58 is not fixed length.
# Use 'O' as one of the 4 not included chars in base58.
self.nick_pkh += 'O' * (self.nick_maxencoded - len(self.nick_pkh))
# The constructed length will be 1 + 1 + NICK_MAX_ENCODED
self.nick = self.nick_header + str(self.jm_version) + self.nick_pkh
informuser = getattr(self.client, "inform_user_details", None)
if callable(informuser):
informuser()
@commands.JMInitProto.responder
async def on_JM_INIT_PROTO(self, nick_hash_length, nick_max_encoded,
joinmarket_nick_header, joinmarket_version):
"""Daemon indicates init-ed status and passes back protocol constants.
Use protocol settings to set actual nick from nick private key,
then call setup to instantiate message channel connections
in the daemon.
"""
self.nick_hashlen = nick_hash_length
self.nick_maxencoded = nick_max_encoded
self.nick_header = joinmarket_nick_header
self.jm_version = joinmarket_version
self.set_nick()
d = await self.callRemote(commands.JMStartMC, self.proto_daemon,
nick=self.nick)
self.defaultCallbacks(d)
return {'accepted': True}
@commands.JMRequestMsgSig.responder
async def on_JM_REQUEST_MSGSIG(self, nick, cmd, msg, msg_to_be_signed,
hostid):
sig = btc.ecdsa_sign(str(msg_to_be_signed), self.nick_priv)
msg_to_return = (str(msg) + " " +
bintohex(self.nick_pubkey.get_public_key_bytes()) +
" " + sig)
d = await self.callRemote(commands.JMMsgSignature, self.proto_daemon,
nick=nick,
cmd=cmd,
msg_to_return=msg_to_return,
hostid=hostid)
self.defaultCallbacks(d)
return {'accepted': True}
@commands.JMRequestMsgSigVerify.responder
async def on_JM_REQUEST_MSGSIG_VERIFY(self, msg, fullmsg, sig, pubkey,
nick, hashlen, max_encoded, hostid):
pubkey_bin = hextobin(pubkey)
verif_result = True
if not btc.ecdsa_verify(str(msg), sig, pubkey_bin):
# workaround for hostid, which sometimes is lowercase-only
# for some IRC connections
if (not btc.ecdsa_verify(str(msg[:-len(hostid)] + hostid.lower()),
sig, pubkey_bin)):
self.logger.debug("nick signature verification failed,"
" ignoring: " + str(nick))
verif_result = False
# check that nick matches hash of pubkey
nick_pkh_raw = hashlib.sha256(
pubkey.encode("ascii")
).digest()[:hashlen]
nick_stripped = nick[2:2 + max_encoded]
# strip right padding
nick_unpadded = ''.join([x for x in nick_stripped if x != 'O'])
if not nick_unpadded == base_encode(nick_pkh_raw, base=58):
self.logger.debug("Nick hash check failed, expected: " +
str(nick_unpadded) + ", got: " +
str(btc.base58.encode(nick_pkh_raw)))
verif_result = False
d = await self.callRemote(commands.JMMsgSignatureVerify,
self.proto_daemon,
verif_result=verif_result,
nick=nick,
fullmsg=fullmsg,
hostid=hostid)
self.defaultCallbacks(d)
return {'accepted': True}
async def make_tx(self, nick_list, tx):
d = await self.callRemote(commands.JMMakeTx, self.proto_daemon,
nick_list=nick_list,
tx=tx)
self.defaultCallbacks(d)
async def request_mc_shutdown(self):
""" To ensure that lingering message channel
connections are shut down when the client itself
is shutting down.
"""
d = await self.callRemote(commands.JMShutdown, self.proto_daemon)
self.defaultCallbacks(d)
return {'accepted': True}
class JMTakerClientProtocol(JMClientProtocol):
def __init__(self, factory, client, nick_priv=None):
self.orderbook = None
self.jmman = client.jmman
self.logger = self.jmman.logger
self.proto_daemon = factory.proto_daemon
JMClientProtocol.__init__(self, factory, client, nick_priv)
async def clientStart(self):
"""Upon confirmation of network connection
to daemon, request message channel initialization
with relevant config data for our message channels
"""
if self.client.aborted:
return
jmman = self.jmman
# needed only for naming convention in IRC currently
blockchain_source = jmman.jmconf.BLOCKCHAIN_SOURCE
# needed only for channel naming convention
network = jmman.jmconf.blockchain_network
chan_configs = self.factory.get_mchannels(mode="TAKER")
minmakers = jmman.jmconf.minimum_makers
maker_timeout_sec = jmman.jmconf.maker_timeout_sec
# To avoid creating yet another config variable, we set the timeout
# to 20 * maker_timeout_sec.
if not hasattr(self.client, 'testflag'): # pragma: no cover
commands.callLater(20*maker_timeout_sec, self.stallMonitor,
self.client.schedule_index+1)
d = await self.callRemote(commands.JMInit, self.proto_daemon,
bcsource=blockchain_source,
network=network,
chan_configs=chan_configs,
minmakers=minmakers,
maker_timeout_sec=maker_timeout_sec,
dust_threshold=jmman.jmconf.DUST_THRESHOLD,
blacklist_location=None)
self.defaultCallbacks(d)
def stallMonitor(self, schedule_index):
"""Diagnoses whether long wait is due to any kind of failure;
if so, calls the taker on_finished_callback with a failure
flag so that the transaction can be re-tried or abandoned, as desired.
Note that this *MUST* not trigger any action once the
coinjoin transaction is seen on the network (hence waiting_for_conf).
The schedule index parameter tells us whether the processing has moved
on to the next item before we were woken up.
"""
self.logger.info("STALL MONITOR:")
if self.client.aborted:
self.logger.info("Transaction was aborted.")
return
if not self.client.schedule_index == schedule_index:
# TODO pre-initialize() ?
self.logger.info("No stall detected, continuing")
return
if self.client.waiting_for_conf:
# Don't restart if the tx is already on the network!
self.logger.info("No stall detected, continuing")
return
if not self.client.txid:
# txid is set on pushing; if it's not there, we have failed.
self.logger.info("Stall detected. Retrying transaction"
" if possible ...")
self.client.on_finished_callback(False, True, 0.0)
else:
# This shouldn't really happen; if the tx confirmed,
# the finished callback should already be called.
self.logger.info("Tx was already pushed; ignoring")
@commands.JMUp.responder
async def on_JM_UP(self):
d = await self.callRemote(commands.JMSetup, self.proto_daemon,
role="TAKER",
initdata=None,
use_fidelity_bond=False)
self.defaultCallbacks(d)
return {'accepted': True}
@commands.JMSetupDone.responder
async def on_JM_SETUP_DONE(self):
self.logger.info("JM daemon setup complete")
# The daemon is ready and has requested the orderbook
# from the pit; we can request the entire orderbook
# and filter it as we choose.
commands.callLater(self.jmman.jmconf.maker_timeout_sec,
self.get_offers)
return {'accepted': True}
@commands.JMFillResponse.responder
async def on_JM_FILL_RESPONSE(self, success, ioauth_data):
"""Receives the entire set of phase 1 data (principally utxos)
from the counterparties and passes through to the Taker for
tx construction. If there were sufficient makers, data is passed
over for exactly those makers that responded. If not, the list
of non-responsive makers is added to the permanent "ignored_makers"
list, but the Taker processing is bypassed and the transaction
is abandoned here (so will be picked up as stalled in multi-join
schedules).
In the first of the above two cases, after the Taker processes
the ioauth data and returns the proposed
transaction, passes the phase 2 initiating data to the daemon.
"""
if not success:
self.logger.info("Makers who didnt respond: " + str(ioauth_data))
self.client.add_ignored_makers(ioauth_data)
return {'accepted': True}
else:
self.logger.info("Makers responded with: " + str(ioauth_data))
retval = await self.client.receive_utxos(ioauth_data)
if not retval[0]:
self.logger.info("Taker is not continuing, phase 2 abandoned.")
self.logger.info("Reason: " + str(retval[1]))
if len(self.client.schedule) == 1:
# see comment for the same invocation in on_JM_OFFERS;
# the logic here is the same.
self.client.on_finished_callback(False, False, 0.0)
return {'accepted': False}
else:
nick_list, tx = retval[1:]
commands.callLater(0, self.make_tx, nick_list, tx)
return {'accepted': True}
@commands.JMOffers.responder
async def on_JM_OFFERS(self, orderbook, fidelitybonds):
self.orderbook = json.loads(orderbook)
fidelity_bonds_list = json.loads(fidelitybonds)
# Removed for now, as judged too large, even for DEBUG:
# self.logger.debug("Got the orderbook: " + str(self.orderbook))
retval = await self.client.initialize(self.orderbook,
fidelity_bonds_list)
# format of retval is:
# True, self.cjamount, commitment, revelation, self.filtered_orderbook)
if not retval[0]:
self.logger.info("Taker not continuing after receipt of orderbook")
if len(self.client.schedule) == 1:
# In single sendpayments, allow immediate quit.
# This could be an optional feature also for multi-entry
# schedules, but is not the functionality
# desiredin general (tumbler).
self.client.on_finished_callback(False, False, 0.0)
return {'accepted': True}
elif retval[0] == "commitment-failure":
# This case occurs if we cannot find any utxos for reasons
# other than age, which is a permanent failure
self.client.on_finished_callback(False, False, 0.0)
return {'accepted': True}
amt, cmt, rev, foffers = retval[1:]
d = await self.callRemote(commands.JMFill, self.proto_daemon,
amount=amt,
commitment=str(cmt),
revelation=str(rev),
filled_offers=foffers)
self.defaultCallbacks(d)
return {'accepted': True}
@commands.JMSigReceived.responder
async def on_JM_SIG_RECEIVED(self, nick, sig):
retval = await self.client.on_sig(nick, sig)
if retval:
nick_to_use, tx = retval
await self.push_tx(nick_to_use, tx)
return {'accepted': True}
async def get_offers(self):
d = await self.callRemote(commands.JMRequestOffers, self.proto_daemon)
self.defaultCallbacks(d)
async def push_tx(self, nick_to_push, tx):
d = await self.callRemote(commands.JMPushTx, self.proto_daemon,
nick=str(nick_to_push), tx=tx)
self.defaultCallbacks(d)
class JMClientProtocolFactory:
protocol = JMTakerClientProtocol
def __init__(self, client, proto_type="TAKER"):
self.client = client
self.jmman = client.jmman
self.proto_client = None
self.proto_daemon = JMDaemonServerProtocol(self, client.jmman)
self.proto_type = proto_type
self.setClient(self.buildProtocol())
def setClient(self, client):
self.proto_client = client
def getClient(self):
return self.proto_client
def buildProtocol(self):
proto_client = self.protocol(self, self.client)
proto_client.connectionMade()
return proto_client
def get_mchannels(self, mode):
""" A transparent wrapper that allows override,
so that a script can return a customised set of
message channel configs; currently used for testing
multiple bots on regtest.
"""
return list(self.jmman.jmconf.get_msg_channels().values())