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.
866 lines
37 KiB
866 lines
37 KiB
#! /usr/bin/env python |
|
from twisted.internet import protocol, reactor, task |
|
from twisted.internet.error import (ConnectionLost, ConnectionAborted, |
|
ConnectionClosed, ConnectionDone) |
|
from twisted.protocols import amp |
|
try: |
|
from twisted.internet.ssl import ClientContextFactory |
|
except ImportError: |
|
pass |
|
from jmbase import commands |
|
import binascii |
|
import json |
|
import hashlib |
|
import os |
|
import sys |
|
from jmbase import (get_log, EXIT_FAILURE, hextobin, bintohex, |
|
utxo_to_utxostr, bdict_sdict_convert) |
|
from jmclient import (jm_single, get_irc_mchannels, |
|
RegtestBitcoinCoreInterface, |
|
SNICKERReceiver, process_shutdown) |
|
import jmbitcoin as btc |
|
|
|
|
|
jlog = get_log() |
|
|
|
class BaseClientProtocol(amp.AMP): |
|
def checkClientResponse(self, response): |
|
"""A generic check of client acceptance; any failure |
|
is considered criticial. |
|
""" |
|
if 'accepted' not in response or not response['accepted']: |
|
#Unintended client shutdown cannot be tested easily in twisted |
|
reactor.stop() #pragma: no cover |
|
|
|
def defaultErrback(self, failure): |
|
#see testing note above |
|
failure.trap(ConnectionAborted, ConnectionClosed, ConnectionDone, |
|
ConnectionLost) #pragma: no cover |
|
|
|
def defaultCallbacks(self, d): |
|
d.addCallback(self.checkClientResponse) |
|
d.addErrback(self.defaultErrback) |
|
|
|
class JMProtocolError(Exception): |
|
pass |
|
|
|
class BIP78ClientProtocol(BaseClientProtocol): |
|
|
|
def __init__(self, manager, params, |
|
success_callback, failure_callback, |
|
tls_whitelist=[], mode="sender"): |
|
self.manager = manager |
|
# can be "sender" or "receiver" |
|
self.mode = mode |
|
self.success_callback = success_callback |
|
self.failure_callback = failure_callback |
|
if self.mode == "sender": |
|
self.params = params |
|
else: |
|
# receiver only learns params from request |
|
self.params = None |
|
if len(tls_whitelist) == 0: |
|
if isinstance(jm_single().bc_interface, |
|
RegtestBitcoinCoreInterface): |
|
tls_whitelist = ["127.0.0.1"] |
|
self.tls_whitelist = tls_whitelist |
|
|
|
def connectionMade(self): |
|
jcg = jm_single().config.get |
|
if self.mode == "sender": |
|
netconfig = {"socks5_host": jcg("PAYJOIN", "onion_socks5_host"), |
|
"socks5_port": jcg("PAYJOIN", "onion_socks5_port"), |
|
"tls_whitelist": ",".join(self.tls_whitelist), |
|
"servers": [self.manager.server]} |
|
d = self.callRemote(commands.BIP78SenderInit, |
|
netconfig=json.dumps(netconfig)) |
|
else: |
|
netconfig = {"port": 80, |
|
"tor_control_host": jcg("PAYJOIN", "tor_control_host"), |
|
"tor_control_port": jcg("PAYJOIN", "tor_control_port")} |
|
d = self.callRemote(commands.BIP78ReceiverInit, |
|
netconfig=json.dumps(netconfig)) |
|
self.defaultCallbacks(d) |
|
|
|
@commands.BIP78ReceiverUp.responder |
|
def on_BIP78_RECEIVER_UP(self, hostname): |
|
self.manager.bip21_uri_from_onion_hostname(hostname) |
|
return {"accepted": True} |
|
|
|
@commands.BIP78ReceiverOriginalPSBT.responder |
|
def on_BIP78_RECEIVER_ORIGINAL_PSBT(self, body, params): |
|
params = json.loads(params) |
|
# TODO: we don't need binary key/vals client side, but will have to edit |
|
# PayjoinConverter for that: |
|
retval = self.success_callback(body.encode("utf-8"), bdict_sdict_convert( |
|
params, output_binary=True)) |
|
if not retval[0]: |
|
d = self.callRemote(commands.BIP78ReceiverSendError, errormsg=retval[1], |
|
errorcode=retval[2]) |
|
else: |
|
d = self.callRemote(commands.BIP78ReceiverSendProposal, psbt=retval[1]) |
|
self.defaultCallbacks(d) |
|
return {"accepted": True} |
|
|
|
@commands.BIP78ReceiverHiddenServiceShutdown.responder |
|
def on_BIP78_RECEIVER_HIDDEN_SERVICE_SHUTDOWN(self): |
|
""" This is called when the daemon has shut down the HS |
|
because of an invalid message/error. An earlier message |
|
will have conveyed the reason for the error. |
|
""" |
|
self.manager.shutdown() |
|
return {"accepted": True} |
|
|
|
@commands.BIP78ReceiverOnionSetupFailed.responder |
|
def on_BIP78_RECEIVER_ONION_SETUP_FAILED(self, reason): |
|
self.manager.info_callback(reason) |
|
self.manager.shutdown() |
|
return {"accepted": True} |
|
|
|
@commands.BIP78SenderUp.responder |
|
def on_BIP78_SENDER_UP(self): |
|
d = self.callRemote(commands.BIP78SenderOriginalPSBT, |
|
body=self.manager.initial_psbt.to_base64(), |
|
params=json.dumps(self.params)) |
|
self.defaultCallbacks(d) |
|
return {"accepted": True} |
|
|
|
@commands.BIP78SenderReceiveProposal.responder |
|
def on_BIP78_SENDER_RECEIVE_PROPOSAL(self, psbt): |
|
self.success_callback(psbt, self.manager) |
|
return {"accepted": True} |
|
|
|
@commands.BIP78SenderReceiveError.responder |
|
def on_BIP78_SENDER_RECEIVER_ERROR(self, errormsg, errorcode): |
|
self.failure_callback(errormsg, errorcode, self.manager) |
|
return {"accepted": True} |
|
|
|
@commands.BIP78InfoMsg.responder |
|
def on_BIP78_INFO_MSG(self, infomsg): |
|
self.manager.info_callback(infomsg) |
|
return {"accepted": True} |
|
|
|
class SNICKERClientProtocol(BaseClientProtocol): |
|
|
|
def __init__(self, client, servers, tls_whitelist=[], oneshot=False): |
|
# if client is type JMSNICKERReceiver, this will flag |
|
# the use of the receiver workflow (polling loop). |
|
# Otherwise it is assumed to be a proposer workloop, |
|
# which does not have active polling, but only the |
|
# ability to upload when clients call for it. |
|
self.client = client |
|
self.servers = servers |
|
if len(tls_whitelist) == 0: |
|
if isinstance(jm_single().bc_interface, |
|
RegtestBitcoinCoreInterface): |
|
tls_whitelist = ["127.0.0.1"] |
|
self.tls_whitelist = tls_whitelist |
|
self.processed_proposals = [] |
|
self.oneshot = oneshot |
|
|
|
def connectionMade(self): |
|
netconfig = {"socks5_host": jm_single().config.get("PAYJOIN", "onion_socks5_host"), |
|
"socks5_port": jm_single().config.get("PAYJOIN", "onion_socks5_port"), |
|
"servers": self.servers, |
|
"tls_whitelist": ",".join(self.tls_whitelist), |
|
"filterconfig": "", |
|
"credentials": ""} |
|
|
|
if isinstance(self.client, SNICKERReceiver): |
|
d = self.callRemote(commands.SNICKERReceiverInit, |
|
netconfig=json.dumps(netconfig)) |
|
else: |
|
d = self.callRemote(commands.SNICKERProposerInit, |
|
netconfig=json.dumps(netconfig)) |
|
self.defaultCallbacks(d) |
|
|
|
def shutdown(self): |
|
""" Encapsulates shut down actions. |
|
""" |
|
if self.proposal_poll_loop: |
|
self.proposals_poll_loop.stop() |
|
|
|
def poll_for_proposals(self): |
|
""" May be invoked in a LoopingCall or other |
|
event loop. |
|
Retrieves any entries in the proposals_source, then |
|
compares with existing, |
|
and invokes parse_proposal on all new entries. |
|
# TODO considerable thought should go into how to store |
|
proposals cross-runs, and also handling of keys, which |
|
must be optional. |
|
""" |
|
# always check whether the service is still intended to |
|
# be active, before starting the polling actions: |
|
if jm_single().config.get("SNICKER", "enabled") != "true": |
|
self.shutdown() |
|
return |
|
d = self.callRemote(commands.SNICKERReceiverGetProposals) |
|
self.defaultCallbacks(d) |
|
|
|
@commands.SNICKERProposerUp.responder |
|
def on_SNICKER_PROPOSER_UP(self): |
|
jlog.info("SNICKER proposer daemon ready.") |
|
# TODO handle multiple servers correctly |
|
for s in self.servers: |
|
if s == "": |
|
continue |
|
d = self.callRemote(commands.SNICKERRequestPowTarget, |
|
server=s) |
|
self.defaultCallbacks(d) |
|
return {"accepted": True} |
|
|
|
@commands.SNICKERReceivePowTarget.responder |
|
def on_SNICKER_RECEIVE_POW_TARGET(self, server, targetbits): |
|
proposals = self.client.get_proposals(targetbits) |
|
d = self.callRemote(commands.SNICKERProposerPostProposals, |
|
proposals="\n".join([x.decode("utf-8") for x in proposals]), |
|
server = server) |
|
self.defaultCallbacks(d) |
|
return {"accepted": True} |
|
|
|
@commands.SNICKERServerError.responder |
|
def on_SNICKER_SERVER_ERROR(self, server, errorcode): |
|
self.client.info_callback("Server: " + str( |
|
server) + " returned error code: " + str(errorcode)) |
|
return {"accepted": True} |
|
|
|
@commands.SNICKERReceiverUp.responder |
|
def on_SNICKER_RECEIVER_UP(self): |
|
if self.oneshot: |
|
jlog.info("Starting single query to SNICKER server(s).") |
|
reactor.callLater(0.0, self.poll_for_proposals) |
|
else: |
|
jlog.info("Starting SNICKER polling loop") |
|
self.proposal_poll_loop = task.LoopingCall( |
|
self.poll_for_proposals) |
|
poll_interval = int(60.0 * float( |
|
jm_single().config.get("SNICKER", "polling_interval_minutes"))) |
|
self.proposal_poll_loop.start(poll_interval, now=False) |
|
return {"accepted": True} |
|
|
|
@commands.SNICKERReceiverProposals.responder |
|
def on_SNICKER_RECEIVER_PROPOSALS(self, proposals, server): |
|
""" Just passes through the proposals retrieved from |
|
any server, to the SNICKERReceiver client object, asynchronously. |
|
The proposals data must be newline separated. |
|
""" |
|
try: |
|
proposals = proposals.split("\n") |
|
except: |
|
jlog.warn("Error in parsing proposals from server: " + str(server)) |
|
return {"accepted": True} |
|
reactor.callLater(0.0, self.process_proposals, proposals) |
|
return {"accepted": True} |
|
|
|
def process_proposals(self, proposals): |
|
self.client.process_proposals(proposals) |
|
if self.oneshot: |
|
process_shutdown() |
|
|
|
@commands.SNICKERProposalsServerResponse.responder |
|
def on_SNICKER_PROPOSALS_SERVER_RESPONSE(self, response, server): |
|
self.client.info_callback("Response from server: " + str(server) +\ |
|
" was: " + str(response)) |
|
self.client.end_requests_callback(None) |
|
return {"accepted": True} |
|
|
|
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 |
|
|
|
self.shutdown_requested = False |
|
|
|
def connectionMade(self): |
|
jlog.debug('connection was made, starting client.') |
|
self.factory.setClient(self) |
|
self.clientStart() |
|
|
|
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(binascii.hexlify( |
|
self.nick_pubkey)).digest()[:self.nick_hashlen] |
|
self.nick_pkh = btc.base58.encode(self.nick_pkh_raw) |
|
#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 |
|
jm_single().nickname = self.nick |
|
informuser = getattr(self.client, "inform_user_details", None) |
|
if callable(informuser): |
|
informuser() |
|
|
|
@commands.JMInitProto.responder |
|
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 = self.callRemote(commands.JMStartMC, |
|
nick=self.nick) |
|
self.defaultCallbacks(d) |
|
return {'accepted': True} |
|
|
|
@commands.JMRequestMsgSig.responder |
|
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) + " " + sig |
|
d = self.callRemote(commands.JMMsgSignature, |
|
nick=nick, |
|
cmd=cmd, |
|
msg_to_return=msg_to_return, |
|
hostid=hostid) |
|
self.defaultCallbacks(d) |
|
return {'accepted': True} |
|
|
|
@commands.JMRequestMsgSigVerify.responder |
|
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): |
|
jlog.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 == btc.base58.encode(nick_pkh_raw): |
|
jlog.debug("Nick hash check failed, expected: " + str(nick_unpadded) |
|
+ ", got: " + str(btc.base58.encode(nick_pkh_raw))) |
|
verif_result = False |
|
d = self.callRemote(commands.JMMsgSignatureVerify, |
|
verif_result=verif_result, |
|
nick=nick, |
|
fullmsg=fullmsg, |
|
hostid=hostid) |
|
self.defaultCallbacks(d) |
|
return {'accepted': True} |
|
|
|
def make_tx(self, nick_list, txhex): |
|
d = self.callRemote(commands.JMMakeTx, |
|
nick_list= json.dumps(nick_list), |
|
txhex=txhex) |
|
self.defaultCallbacks(d) |
|
|
|
class JMMakerClientProtocol(JMClientProtocol): |
|
def __init__(self, factory, maker, nick_priv=None): |
|
self.factory = factory |
|
#used for keeping track of transactions for the unconf/conf callbacks |
|
self.finalized_offers = {} |
|
JMClientProtocol.__init__(self, factory, maker, nick_priv) |
|
|
|
@commands.JMUp.responder |
|
def on_JM_UP(self): |
|
#wait until ready locally to submit offers (can be delayed |
|
#if wallet sync is slow). |
|
self.offers_ready_loop_counter = 0 |
|
self.offers_ready_loop = task.LoopingCall(self.submitOffers) |
|
self.offers_ready_loop.start(2.0) |
|
return {'accepted': True} |
|
|
|
def submitOffers(self): |
|
self.offers_ready_loop_counter += 1 |
|
if self.offers_ready_loop_counter == 300: |
|
jlog.info("Failed to start after 10 minutes, giving up.") |
|
self.offers_ready_loop.stop() |
|
reactor.stop() |
|
if not self.client.offerlist: |
|
return |
|
self.offers_ready_loop.stop() |
|
d = self.callRemote(commands.JMSetup, |
|
role="MAKER", |
|
offers=json.dumps(self.client.offerlist), |
|
use_fidelity_bond=(self.client.fidelity_bond is not None)) |
|
self.defaultCallbacks(d) |
|
|
|
@commands.JMSetupDone.responder |
|
def on_JM_SETUP_DONE(self): |
|
jlog.info("JM daemon setup complete") |
|
return {'accepted': True} |
|
|
|
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 |
|
#needed only for naming convention in IRC currently |
|
blockchain_source = jm_single().config.get("BLOCKCHAIN", |
|
"blockchain_source") |
|
#needed only for channel naming convention |
|
network = jm_single().config.get("BLOCKCHAIN", "network") |
|
irc_configs = get_irc_mchannels() |
|
#only here because Init message uses this field; not used by makers TODO |
|
minmakers = jm_single().config.getint("POLICY", "minimum_makers") |
|
maker_timeout_sec = jm_single().maker_timeout_sec |
|
|
|
d = self.callRemote(commands.JMInit, |
|
bcsource=blockchain_source, |
|
network=network, |
|
irc_configs=json.dumps(irc_configs), |
|
minmakers=minmakers, |
|
maker_timeout_sec=maker_timeout_sec) |
|
self.defaultCallbacks(d) |
|
|
|
@commands.JMFidelityBondProofRequest.responder |
|
def on_JM_FIDELITY_BOND_PROOF_REQUEST(self, takernick, makernick): |
|
proof_msg = (self.client.fidelity_bond |
|
.create_proof(makernick, takernick) |
|
.create_proof_msg(self.client.fidelity_bond.cert_privkey)) |
|
d = self.callRemote(commands.JMFidelityBondProof, |
|
nick=takernick, |
|
proof=proof_msg) |
|
self.defaultCallbacks(d) |
|
return {"accepted": True} |
|
|
|
@commands.JMAuthReceived.responder |
|
def on_JM_AUTH_RECEIVED(self, nick, offer, commitment, revelation, amount, |
|
kphex): |
|
offer = json.loads(offer) |
|
revelation = json.loads(revelation) |
|
retval = self.client.on_auth_received(nick, offer, |
|
commitment, revelation, amount, kphex) |
|
if not retval[0]: |
|
jlog.info("Maker refuses to continue on receiving auth.") |
|
else: |
|
utxos, auth_pub, cj_addr, change_addr, btc_sig = retval[1:] |
|
# json does not allow non-string keys: |
|
utxos_strkeyed = {} |
|
for k in utxos: |
|
success, u = utxo_to_utxostr(k) |
|
assert success |
|
utxos_strkeyed[u] = {"value": utxos[k]["value"], |
|
"address": utxos[k]["address"]} |
|
auth_pub_hex = bintohex(auth_pub) |
|
d = self.callRemote(commands.JMIOAuth, |
|
nick=nick, |
|
utxolist=json.dumps(utxos_strkeyed), |
|
pubkey=auth_pub_hex, |
|
cjaddr=cj_addr, |
|
changeaddr=change_addr, |
|
pubkeysig=btc_sig) |
|
self.defaultCallbacks(d) |
|
return {"accepted": True} |
|
|
|
@commands.JMTXReceived.responder |
|
def on_JM_TX_RECEIVED(self, nick, txhex, offer): |
|
offer = json.loads(offer) |
|
retval = self.client.on_tx_received(nick, txhex, offer) |
|
if not retval[0]: |
|
jlog.info("Maker refuses to continue on receipt of tx") |
|
else: |
|
sigs = retval[1] |
|
self.finalized_offers[nick] = offer |
|
tx = btc.CMutableTransaction.deserialize(hextobin(txhex)) |
|
self.finalized_offers[nick]["txd"] = tx |
|
txid = tx.GetTxid()[::-1] |
|
# we index the callback by the out-set of the transaction, |
|
# because the txid is not known until all scriptSigs collected |
|
# (hence this is required for Makers, but not Takers). |
|
# For more info see WalletService.transaction_monitor(): |
|
txinfo = tuple((x.scriptPubKey, x.nValue) for x in tx.vout) |
|
self.client.wallet_service.register_callbacks([self.unconfirm_callback], |
|
txinfo, "unconfirmed") |
|
self.client.wallet_service.register_callbacks([self.confirm_callback], |
|
txinfo, "confirmed") |
|
|
|
task.deferLater(reactor, float(jm_single().config.getint("TIMEOUT", |
|
"unconfirm_timeout_sec")), |
|
self.client.wallet_service.check_callback_called, |
|
txinfo, self.unconfirm_callback, "unconfirmed", |
|
"transaction with outputs: " + str(txinfo) + " not broadcast.") |
|
|
|
d = self.callRemote(commands.JMTXSigs, |
|
nick=nick, |
|
sigs=json.dumps(sigs)) |
|
self.defaultCallbacks(d) |
|
return {"accepted": True} |
|
|
|
@commands.JMTXBroadcast.responder |
|
def on_JM_TX_BROADCAST(self, txhex): |
|
""" Makers have no issue broadcasting anything, |
|
so only need to prevent crashes. |
|
Note in particular we don't check the return value, |
|
since the transaction being accepted or not is not |
|
our (maker)'s concern. |
|
""" |
|
try: |
|
txbin = hextobin(txhex) |
|
jm_single().bc_interface.pushtx(txbin) |
|
except: |
|
jlog.info("We received an invalid transaction broadcast " |
|
"request: " + txhex) |
|
return {"accepted": True} |
|
|
|
def tx_match(self, txd): |
|
for k,v in self.finalized_offers.items(): |
|
# Tx considered defined by its output set |
|
if v["txd"].vout == txd.vout: |
|
offerinfo = v |
|
break |
|
else: |
|
return False |
|
return offerinfo |
|
|
|
def unconfirm_callback(self, txd, txid): |
|
#find the offer for this tx |
|
offerinfo = self.tx_match(txd) |
|
if not offerinfo: |
|
return False |
|
to_cancel, to_announce = self.client.on_tx_unconfirmed(offerinfo, |
|
txid) |
|
self.client.modify_orders(to_cancel, to_announce) |
|
|
|
txinfo = tuple((x.scriptPubKey, x.nValue) for x in txd.vout) |
|
confirm_timeout_sec = float(jm_single().config.get( |
|
"TIMEOUT", "confirm_timeout_hours")) * 3600 |
|
task.deferLater(reactor, confirm_timeout_sec, |
|
self.client.wallet_service.check_callback_called, |
|
txinfo, self.confirm_callback, "confirmed", |
|
"transaction with outputs " + str(txinfo) + " not confirmed.") |
|
|
|
d = self.callRemote(commands.JMAnnounceOffers, |
|
to_announce=json.dumps(to_announce), |
|
to_cancel=json.dumps(to_cancel), |
|
offerlist=json.dumps(self.client.offerlist)) |
|
self.defaultCallbacks(d) |
|
return True |
|
|
|
def confirm_callback(self, txd, txid, confirms): |
|
#find the offer for this tx |
|
offerinfo = self.tx_match(txd) |
|
if not offerinfo: |
|
return False |
|
jlog.info('tx in a block: ' + txid + ' with ' + str( |
|
confirms) + ' confirmations.') |
|
to_cancel, to_announce = self.client.on_tx_confirmed(offerinfo, |
|
txid, confirms) |
|
self.client.modify_orders(to_cancel, to_announce) |
|
d = self.callRemote(commands.JMAnnounceOffers, |
|
to_announce=json.dumps(to_announce), |
|
to_cancel=json.dumps(to_cancel), |
|
offerlist=json.dumps(self.client.offerlist)) |
|
self.defaultCallbacks(d) |
|
return True |
|
|
|
class JMTakerClientProtocol(JMClientProtocol): |
|
|
|
def __init__(self, factory, client, nick_priv=None): |
|
self.orderbook = None |
|
JMClientProtocol.__init__(self, factory, client, nick_priv) |
|
|
|
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 |
|
#needed only for naming convention in IRC currently |
|
blockchain_source = jm_single().config.get("BLOCKCHAIN", |
|
"blockchain_source") |
|
#needed only for channel naming convention |
|
network = jm_single().config.get("BLOCKCHAIN", "network") |
|
irc_configs = get_irc_mchannels() |
|
minmakers = jm_single().config.getint("POLICY", "minimum_makers") |
|
maker_timeout_sec = jm_single().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 |
|
reactor.callLater(20*maker_timeout_sec, self.stallMonitor, |
|
self.client.schedule_index+1) |
|
|
|
d = self.callRemote(commands.JMInit, |
|
bcsource=blockchain_source, |
|
network=network, |
|
irc_configs=json.dumps(irc_configs), |
|
minmakers=minmakers, |
|
maker_timeout_sec=maker_timeout_sec) |
|
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. |
|
""" |
|
jlog.info("STALL MONITOR:") |
|
if self.client.aborted: |
|
jlog.info("Transaction was aborted.") |
|
return |
|
if not self.client.schedule_index == schedule_index: |
|
#TODO pre-initialize() ? |
|
jlog.info("No stall detected, continuing") |
|
return |
|
if self.client.waiting_for_conf: |
|
#Don't restart if the tx is already on the network! |
|
jlog.info("No stall detected, continuing") |
|
return |
|
if not self.client.txid: |
|
#txid is set on pushing; if it's not there, we have failed. |
|
jlog.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. |
|
jlog.info("Tx was already pushed; ignoring") |
|
|
|
@commands.JMUp.responder |
|
def on_JM_UP(self): |
|
d = self.callRemote(commands.JMSetup, |
|
role="TAKER", |
|
offers="{}", |
|
use_fidelity_bond=False) |
|
self.defaultCallbacks(d) |
|
return {'accepted': True} |
|
|
|
@commands.JMSetupDone.responder |
|
def on_JM_SETUP_DONE(self): |
|
jlog.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. |
|
reactor.callLater(jm_single().maker_timeout_sec, self.get_offers) |
|
return {'accepted': True} |
|
|
|
@commands.JMFillResponse.responder |
|
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. |
|
""" |
|
ioauth_data = json.loads(ioauth_data) |
|
if not success: |
|
jlog.info("Makers who didnt respond: " + str(ioauth_data)) |
|
self.client.add_ignored_makers(ioauth_data) |
|
return {'accepted': True} |
|
else: |
|
jlog.info("Makers responded with: " + json.dumps(ioauth_data)) |
|
retval = self.client.receive_utxos(ioauth_data) |
|
if not retval[0]: |
|
jlog.info("Taker is not continuing, phase 2 abandoned.") |
|
jlog.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, txhex = retval[1:] |
|
reactor.callLater(0, self.make_tx, nick_list, txhex) |
|
return {'accepted': True} |
|
|
|
@commands.JMOffers.responder |
|
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: |
|
#jlog.debug("Got the orderbook: " + str(self.orderbook)) |
|
retval = self.client.initialize(self.orderbook, fidelity_bonds_list) |
|
#format of retval is: |
|
#True, self.cjamount, commitment, revelation, self.filtered_orderbook) |
|
if not retval[0]: |
|
jlog.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 desired in 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 = self.callRemote(commands.JMFill, |
|
amount=amt, |
|
commitment=str(cmt), |
|
revelation=str(rev), |
|
filled_offers=json.dumps(foffers)) |
|
self.defaultCallbacks(d) |
|
return {'accepted': True} |
|
|
|
@commands.JMSigReceived.responder |
|
def on_JM_SIG_RECEIVED(self, nick, sig): |
|
retval = self.client.on_sig(nick, sig) |
|
if retval: |
|
nick_to_use, txhex = retval |
|
self.push_tx(nick_to_use, txhex) |
|
return {'accepted': True} |
|
|
|
def get_offers(self): |
|
d = self.callRemote(commands.JMRequestOffers) |
|
self.defaultCallbacks(d) |
|
|
|
def push_tx(self, nick_to_push, txhex_to_push): |
|
d = self.callRemote(commands.JMPushTx, nick=str(nick_to_push), |
|
txhex=str(txhex_to_push)) |
|
self.defaultCallbacks(d) |
|
|
|
class SNICKERClientProtocolFactory(protocol.ClientFactory): |
|
protocol = SNICKERClientProtocol |
|
def buildProtocol(self, addr): |
|
return self.protocol(self.client, self.servers, oneshot=self.oneshot) |
|
def __init__(self, client, servers, oneshot=False): |
|
self.client = client |
|
self.servers = servers |
|
self.oneshot = oneshot |
|
|
|
class BIP78ClientProtocolFactory(protocol.ClientFactory): |
|
protocol = BIP78ClientProtocol |
|
def buildProtocol(self, addr): |
|
return self.protocol(self.manager, self.params, |
|
self.success_callback, |
|
self.failure_callback, |
|
tls_whitelist=self.tls_whitelist, |
|
mode=self.mode) |
|
def __init__(self, manager, params, success_callback, |
|
failure_callback, tls_whitelist=[], |
|
mode="sender"): |
|
self.manager = manager |
|
self.params = params |
|
self.success_callback = success_callback |
|
self.failure_callback = failure_callback |
|
self.tls_whitelist = tls_whitelist |
|
self.mode = mode |
|
|
|
class JMClientProtocolFactory(protocol.ClientFactory): |
|
protocol = JMTakerClientProtocol |
|
|
|
def __init__(self, client, proto_type="TAKER"): |
|
self.client = client |
|
self.proto_client = None |
|
self.proto_type = proto_type |
|
if self.proto_type == "MAKER": |
|
self.protocol = JMMakerClientProtocol |
|
|
|
def setClient(self, client): |
|
self.proto_client = client |
|
def getClient(self): |
|
return self.proto_client |
|
|
|
def buildProtocol(self, addr): |
|
return self.protocol(self, self.client) |
|
|
|
def start_reactor(host, port, factory=None, snickerfactory=None, |
|
bip78=False, jm_coinjoin=True, ish=True, |
|
daemon=False, rs=True, gui=False): #pragma: no cover |
|
#(Cannot start the reactor in tests) |
|
#Not used in prod (twisted logging): |
|
#startLogging(stdout) |
|
usessl = True if jm_single().config.get("DAEMON", |
|
"use_ssl") != 'false' else False |
|
|
|
jmcport, snickerport, bip78port = [port]*3 |
|
if daemon: |
|
try: |
|
from jmdaemon import JMDaemonServerProtocolFactory, start_daemon, \ |
|
SNICKERDaemonServerProtocolFactory, BIP78ServerProtocolFactory |
|
except ImportError: |
|
jlog.error("Cannot start daemon without jmdaemon package; " |
|
"either install it, and restart, or, if you want " |
|
"to run the daemon separately, edit the DAEMON " |
|
"section of the config. Quitting.") |
|
return |
|
if jm_coinjoin: |
|
dfactory = JMDaemonServerProtocolFactory() |
|
if snickerfactory: |
|
sdfactory = SNICKERDaemonServerProtocolFactory() |
|
if bip78: |
|
bip78factory = BIP78ServerProtocolFactory() |
|
# ints are immutable in python, to pass by ref we use |
|
# an array object: |
|
port_a = [port] |
|
def start_daemon_on_port(p, f, name, port_offset): |
|
orgp = p[0] |
|
while True: |
|
try: |
|
start_daemon(host, p[0] - port_offset, f, usessl, |
|
'./ssl/key.pem', './ssl/cert.pem') |
|
jlog.info("{} daemon listening on port {}".format( |
|
name, str(p[0] - port_offset))) |
|
break |
|
except Exception: |
|
jlog.warn("Cannot listen on port " + str( |
|
p[0] - port_offset) + ", trying next port") |
|
if p[0] >= (orgp + 100): |
|
jlog.error("Tried 100 ports but cannot " |
|
"listen on any of them. Quitting.") |
|
sys.exit(EXIT_FAILURE) |
|
p[0] += 1 |
|
return p[0] |
|
|
|
if jm_coinjoin: |
|
# TODO either re-apply this port incrementing logic |
|
# to other protocols, or re-work how the ports work entirely. |
|
jmcport = start_daemon_on_port(port_a, dfactory, "Joinmarket", 0) |
|
# (See above) For now these other two are just on ports that are 1K offsets. |
|
if snickerfactory: |
|
snickerport = start_daemon_on_port(port_a, sdfactory, "SNICKER", 1000) - 1000 |
|
if bip78: |
|
start_daemon_on_port(port_a, bip78factory, "BIP78", 2000) |
|
|
|
# if the port had to be incremented due to conflict above, we should update |
|
# it in the config var so e.g. bip78 connections choose the port we actually |
|
# used. |
|
# This is specific to the daemon-in-same-process case; for the external daemon |
|
# the user must just set the right value. |
|
jm_single().config.set("DAEMON", "daemon_port", str(port_a[0])) |
|
|
|
# Note the reactor.connect*** entries do not include BIP78 which |
|
# starts in jmclient.payjoin: |
|
if usessl: |
|
if factory: |
|
reactor.connectSSL(host, jmcport, factory, ClientContextFactory()) |
|
if snickerfactory: |
|
reactor.connectSSL(host, snickerport, snickerfactory, |
|
ClientContextFactory()) |
|
else: |
|
if factory: |
|
reactor.connectTCP(host, jmcport, factory) |
|
if snickerfactory: |
|
reactor.connectTCP(host, snickerport, snickerfactory) |
|
if rs: |
|
if not gui: |
|
reactor.run(installSignalHandlers=ish) |
|
if isinstance(jm_single().bc_interface, RegtestBitcoinCoreInterface): |
|
jm_single().bc_interface.shutdown_signal = True
|
|
|