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.
1026 lines
42 KiB
1026 lines
42 KiB
#! /usr/bin/env python |
|
from __future__ import print_function |
|
import base64, abc, threading, time |
|
from daemon import ( |
|
encrypt_encode, decode_decrypt, COMMAND_PREFIX, ORDER_KEYS, |
|
NICK_HASH_LENGTH, NICK_MAX_ENCODED, JM_VERSION, JOINMARKET_NICK_HEADER, |
|
nickname, plaintext_commands, encrypted_commands, commitment_broadcast_list, |
|
offername_list, public_commands, private_commands) |
|
from base.support import get_log |
|
from functools import wraps |
|
|
|
log = get_log() |
|
|
|
|
|
class CJPeerError(StandardError): |
|
pass |
|
|
|
|
|
class MChannelThread(threading.Thread): |
|
|
|
def __init__(self, mc): |
|
threading.Thread.__init__(self, name='MCThread') |
|
self.daemon = True |
|
self.mc = mc |
|
|
|
def run(self): |
|
self.mc.run() |
|
|
|
|
|
class MessageChannelCollection(object): |
|
"""Class which encapsulates a set of |
|
message channels. Maintains state about active |
|
connections to counterparties, and state of |
|
encapsulated message channel instances. |
|
Public messages are broadcast over all available |
|
channels, while privmsgs with one counterparty are |
|
"locked" to the channel on which they are initiated, |
|
although bear in mind they need not be the same for |
|
both sides of the conversation. |
|
In the current joinmarket protocol, this "lock" |
|
is set at the time of the !reloffer (etc) privmsg or |
|
pubmsg from the maker. |
|
Note that MessageChannel implementations must support |
|
asynchronous messaging (adding to Queue.Queue objects, |
|
which are thread safe, e.g.) |
|
Callback chain is in some cases extended with an extra |
|
layer, e.g. to manage a "connected" state across all |
|
encapsulated message channels. |
|
""" |
|
|
|
def check_privmsg(func): |
|
"""decorator to check if private messages |
|
are correctly activated |
|
""" |
|
|
|
@wraps(func) |
|
def func_wrapper(inst, *args, **kwargs): |
|
cp = args[0] |
|
if cp in inst.active_channels: |
|
return func(inst, *args, **kwargs) |
|
else: |
|
for mc in inst.available_channels(): |
|
#nicks_seen[mc] guaranteed to exist |
|
#from constructor |
|
if cp in inst.nicks_seen[mc]: |
|
log.debug("Dynamic switch nick: " + cp) |
|
inst.active_channels[cp] = mc |
|
#early return on first success; |
|
#means that we assume that if we have |
|
#ever seen a message from this counterparty |
|
#on one messagechannel which is currently active, |
|
#we assume it's still |
|
#available. Of course, this is optimistic, |
|
#but still much better to do this than to |
|
#immediately give up when any one connection |
|
#is broken. |
|
return func(inst, *args, **kwargs) |
|
#Failure to send is a critical error for a transaction, |
|
#but should not kill the bot. So, we don't raise an |
|
#exception, but rather allow sending to continue, which |
|
#should usually result in tx completion just timing out. |
|
log.warn("Couldn't find a route to send privmsg") |
|
log.warn("For counterparty: " + str(cp)) |
|
|
|
return func_wrapper |
|
|
|
def __init__(self, mchannels): |
|
self.mchannels = mchannels |
|
#To keep track of chosen channels |
|
#for private messaging counterparties. |
|
self.active_channels = {} |
|
#To keep track of message channel status; |
|
#0: not started 1: started 2: failed/broken/inactive |
|
self.mc_status = dict([(x, 0) for x in self.mchannels]) |
|
#To keep track of counterparties having at least once |
|
#made their presence known on a channel |
|
self.nicks_seen = {} |
|
for mc in self.mchannels: |
|
self.nicks_seen[mc] = set() |
|
#callback to mark nicks as seen when they privmsg |
|
mc.on_privmsg_trigger = self.on_privmsg |
|
#keep track of whether we want to deliberately |
|
#shut down the connections |
|
self.give_up = False |
|
#only allow on_welcome() to fire once. |
|
self.welcomed = False |
|
#control access |
|
self.mc_lock = threading.Lock() |
|
|
|
def set_nick(self, nick): |
|
self.nick = nick |
|
#protocol level var: |
|
nickname = self.nick |
|
for mc in self.mchannels: |
|
mc.set_nick(self.nick) |
|
|
|
def available_channels(self): |
|
return [x for x in self.mchannels if self.mc_status[x] == 1] |
|
|
|
def unavailable_channels(self): |
|
return [x for x in self.mchannels if self.mc_status[x] != 1] |
|
|
|
def flush_nicks(self): |
|
"""Any message channel which is not |
|
active must wipe any state information on peers |
|
connected for that message channel. If a peer is |
|
available on another chan, switch the active_channel |
|
for that nick to (an)(the) other, to make failure |
|
to communicate as unlikely as possible. |
|
""" |
|
for mc in self.unavailable_channels(): |
|
self.nicks_seen[mc] = set() |
|
ac = self.active_channels |
|
for peer in [x for x in ac if ac[x] == mc]: |
|
for mc2 in self.available_channels(): |
|
if peer in self.nicks_seen[mc2]: |
|
log.debug("Dynamically switching: " + peer + " to: " + \ |
|
str(mc2.serverport)) |
|
self.active_channels[peer] = mc2 |
|
break |
|
#Remove all entries for the newly unavailable channel |
|
self.active_channels = dict([(a, ac[a]) for a in ac if ac[a] != mc]) |
|
|
|
def set_daemon(self, daemon): |
|
self.daemon = daemon |
|
for mc in self.mchannels: |
|
mc.daemon = daemon |
|
|
|
def add_channel(self, mchannel): |
|
"""TODO Not currently in use, |
|
may be some issues with intialization. |
|
""" |
|
if mchannel not in self.mchannels: |
|
self.mc_status[mc] = 0 |
|
self.nicks_seen[mc] = set() |
|
self.mchannels += mchannel |
|
self.mchannels = list(set(self.mchannels)) |
|
|
|
def see_nick(self, nick, mc): |
|
with self.mc_lock: |
|
self.nicks_seen[mc].add(nick) |
|
|
|
def unsee_nick(self, nick, mc): |
|
with self.mc_lock: |
|
self.nicks_seen[mc] = self.nicks_seen[mc].difference(set([nick])) |
|
|
|
def run(self, failures=None): |
|
"""At the moment this is effectively a |
|
do-nothing main loop. May be suboptimal. |
|
For now it allows us to receive the |
|
shutdown() signal for all message channels |
|
and propagate it. |
|
Additionally, for testing, a parameter 'failures' |
|
may be passed, a tuple (type, message channel index, count) |
|
which will perform a connection shutdown of type type |
|
after iteration count count on message channel |
|
self.mchannels[channel index]. |
|
""" |
|
for mc in self.mchannels: |
|
MChannelThread(mc).start() |
|
i = 0 |
|
while True: |
|
time.sleep(1) |
|
i += 1 |
|
if self.give_up: |
|
log.info("Shutting down all connections") |
|
break |
|
#feature only used for testing: |
|
#deliberately shutdown a connection at a certain time. |
|
#TODO may not be sufficiently deterministic. |
|
if failures and i == failures[2]: |
|
if failures[0] == 'break': |
|
self.mchannels[failures[1]].close() |
|
elif failures[0] == 'shutdown': |
|
self.mchannels[failures[1]].shutdown() |
|
else: |
|
raise NotImplementedError("Failure injection type unknown") |
|
|
|
#UNCONDITIONAL PUBLIC/BROADCAST: use all message |
|
#channels for these functions. |
|
|
|
def shutdown(self): |
|
"""Stop the main loop of the message channel, |
|
shutting down subsidiary resources gracefully. |
|
Note that unexpected disconnections MUST be |
|
handled by the implementation itself (restarting |
|
as appropriate). |
|
""" |
|
for mc in self.available_channels(): |
|
mc.shutdown() |
|
self.give_up = True |
|
|
|
def pubmsg(self, msg): |
|
"""Send a message onto the shared, public |
|
channels (the joinmarket pit). |
|
""" |
|
log.debug("Pubmsging: " + str(msg)) |
|
for mc in self.available_channels(): |
|
mc.pubmsg(msg) |
|
|
|
def cancel_orders(self, oid_list): |
|
for mc in self.available_channels(): |
|
mc.cancel_orders(oid_list) |
|
|
|
# OrderbookWatch callback |
|
def request_orderbook(self): |
|
for mc in self.available_channels(): |
|
mc.request_orderbook() |
|
|
|
#END PUBLIC/BROADCAST SECTION |
|
|
|
def get_encryption_box(self, cmd, nick): |
|
"""Establish whether the message is to be |
|
encrypted/decrypted based on the command string. |
|
If so, retrieve the appropriate crypto_box object |
|
and return. """ |
|
if cmd in plaintext_commands: |
|
return None, False |
|
else: |
|
return self.daemon.get_crypto_box_from_nick(nick), True |
|
|
|
def prepare_privmsg(self, nick, cmd, message, mc=None): |
|
# should we encrypt? |
|
box, encrypt = self.get_encryption_box(cmd, nick) |
|
if encrypt: |
|
if not box: |
|
log.debug('error, dont have encryption box object for ' + nick + |
|
', dropping message') |
|
return |
|
message = encrypt_encode(message, box) |
|
|
|
#Anti-replay measure: append the message channel identifier |
|
#to the signature; this prevents cross-channel replay but NOT |
|
#same-channel replay (in case of snooper after dropped connection |
|
#on this channel). |
|
if nick in self.active_channels: |
|
hostid = self.active_channels[nick].hostid |
|
else: |
|
log.info("Failed to send message to: " + str(nick) + \ |
|
"; cannot find on any message channel.") |
|
return |
|
msg_to_be_signed = message + str(hostid) |
|
|
|
self.daemon.request_signed_message(nick, cmd, message, msg_to_be_signed, |
|
hostid) |
|
|
|
def privmsg(self, nick, cmd, message, mc=None): |
|
"""Send a message to a specific counterparty, |
|
either specifying a single message channel, or |
|
allowing it to be deduced from self.active_channels dict |
|
""" |
|
if mc is not None: |
|
if mc not in self.available_channels(): |
|
#second chance: is mc a hostid corresponding to an active channel? |
|
matching_channels = [x |
|
for x in self.available_channels() |
|
if mc == x.hostid] |
|
if len(matching_channels) != 1: |
|
#raise because implies logic error |
|
raise Exception( |
|
"Tried to privmsg on an unavailable message channel.") |
|
mc = matching_channels[0] |
|
mc.privmsg(nick, cmd, message) |
|
return |
|
else: |
|
mc.privmsg(nick, cmd, message) |
|
return |
|
if nick in self.active_channels: |
|
self.active_channels[nick].privmsg(nick, cmd, message) |
|
return |
|
else: |
|
log.info("Failed to send message to: " + str(nick) + \ |
|
"; cannot find on any message channel.") |
|
return |
|
|
|
def announce_orders(self, orderlist, nick=None, new_mc=None): |
|
"""Send orders defined in list orderlist either |
|
to the shared public channel (pit), on all |
|
message channels, if nick=None, |
|
or to an individual counterparty nick, as |
|
privmsg, on a specific mc. |
|
""" |
|
order_keys = ['oid', 'minsize', 'maxsize', 'txfee', 'cjfee'] |
|
orderlines = [] |
|
for order in orderlist: |
|
orderlines.append(COMMAND_PREFIX + order['ordertype'] + \ |
|
' ' + ' '.join([str(order[k]) for k in order_keys])) |
|
if new_mc is not None and new_mc not in self.available_channels(): |
|
log.info( |
|
"Tried to announce orders on an unavailable message channel.") |
|
return |
|
if nick is None: |
|
for mc in self.available_channels(): |
|
mc.announce_orders(orderlines) |
|
else: |
|
#we are sending to one cp, so privmsg |
|
#in order to use privmsg, we must set "cmd" to be the first command |
|
#in the first orderline, and the rest are treated like a message. |
|
cmd = orderlist[0]['ordertype'] |
|
msg = ' '.join(orderlines[0].split(' ')[1:]) |
|
msg += ''.join(orderlines[1:]) |
|
if new_mc: |
|
self.privmsg(nick, cmd, msg, new_mc) |
|
else: |
|
for mc in self.available_channels(): |
|
if nick in self.nicks_seen[mc]: |
|
self.privmsg(nick, cmd, msg, mc) |
|
|
|
@check_privmsg |
|
def send_pubkey(self, nick, pubkey): |
|
self.active_channels[nick].privmsg(nick, 'pubkey', pubkey) |
|
|
|
@check_privmsg |
|
def send_ioauth(self, nick, utxo_list, auth_pub, cj_addr, change_addr, sig): |
|
self.active_channels[nick].send_ioauth(nick, utxo_list, auth_pub, |
|
cj_addr, change_addr, sig) |
|
|
|
@check_privmsg |
|
def send_sigs(self, nick, sig_list): |
|
self.active_channels[nick].send_sigs(nick, sig_list) |
|
|
|
# Taker callbacks |
|
def fill_orders(self, nick_order_dict, cj_amount, taker_pubkey, commitment): |
|
""" |
|
The orders dict does not contain information |
|
about which message channel the counterparty bots are active |
|
on; this can be hacked-around by including that information |
|
in the order data, but this is highly undesirable, partly |
|
architecturally (the joinmarket business logic has no business |
|
knowing about the message channel), and partly because it |
|
would break backwards compatibility. |
|
So, we use a trigger in on_order_seen and assume that it |
|
makes sense to set the active_channel for that nick to the one |
|
it was last seen active on. |
|
""" |
|
for mc in self.available_channels(): |
|
filtered_nick_order_dict = {k: v |
|
for k, v in nick_order_dict.iteritems() |
|
if mc == self.active_channels[k]} |
|
mc.fill_orders(filtered_nick_order_dict, cj_amount, taker_pubkey, |
|
commitment) |
|
|
|
@check_privmsg |
|
def send_error(self, nick, errormsg): |
|
#TODO this might need to support non-active nicks TODO |
|
self.active_channels[nick].send_error(nick, errormsg) |
|
|
|
@check_privmsg |
|
def push_tx(self, nick, txhex): |
|
#TODO supporting sending to arbitrary nicks |
|
#adds quite a bit of complexity, not supported |
|
#initially; will fail if nick is not part of TX |
|
self.active_channels[nick].push_tx(nick, txhex) |
|
|
|
def send_tx(self, nick_list, txhex): |
|
"""Push out the transaction to nicks |
|
in groups by their message channel. |
|
""" |
|
tx_nick_sets = {} |
|
for nick in nick_list: |
|
if nick not in self.active_channels: |
|
#This could be a fatal error for a transaction, |
|
#but might not be for the bot (tx recreation etc.) |
|
#TODO look for another channel via nicks_seen. |
|
#Rare case so not a high priority. |
|
log.info("Cannot send transaction to nick, not active: " + nick) |
|
return |
|
if self.active_channels[nick] not in tx_nick_sets: |
|
tx_nick_sets[self.active_channels[nick]] = [nick] |
|
else: |
|
tx_nick_sets[self.active_channels[nick]].append(nick) |
|
for mc, nl in tx_nick_sets.iteritems(): |
|
self.prepare_send_tx(mc, nl, txhex) |
|
|
|
def prepare_send_tx(self, mc, nick_list, txhex): |
|
txb64 = base64.b64encode(txhex.decode('hex')) |
|
for nick in nick_list: |
|
self.prepare_privmsg(nick, "tx", txb64, mc=mc) |
|
|
|
#CALLBACKS REGISTRATION SECTION |
|
|
|
# taker commands |
|
def register_taker_callbacks(self, |
|
on_error=None, |
|
on_pubkey=None, |
|
on_ioauth=None, |
|
on_sig=None): |
|
for mc in self.mchannels: |
|
mc.register_taker_callbacks(on_error, on_pubkey, on_ioauth, on_sig) |
|
|
|
def on_connect_trigger(self, mc): |
|
"""Mark the specified message channel |
|
as (re) connected. |
|
""" |
|
self.mc_status[mc] = 1 |
|
|
|
def on_disconnect_trigger(self, mc): |
|
"""Mark the specified message channel as |
|
disconnected. Track loss of private connections |
|
to individual nicks. If no message channels are |
|
now connected, fire on_disconnect to calling code. |
|
""" |
|
self.mc_status[mc] = 2 |
|
self.flush_nicks() |
|
log.debug("On disconnect fired, nicks_seen is now: " + str( |
|
self.nicks_seen)) |
|
if not any([x == 1 for x in self.mc_status.values()]): |
|
if self.on_disconnect: |
|
self.on_disconnect() |
|
|
|
def on_welcome_trigger(self, mc): |
|
"""Update status of specified message channel |
|
as connected. If all required message channels |
|
are initialized (not state 0), fire the |
|
on_welcome() event to calling code to signal |
|
that processing can start. |
|
This is wrapped with a lock as can be fired by |
|
message channel child threads. |
|
""" |
|
with self.mc_lock: |
|
if self.welcomed: |
|
return |
|
#This trigger indicates successful login |
|
#so we update status. |
|
self.mc_status[mc] = 1 |
|
#This way broadcasts orders or requests ONCE to ALL mchans |
|
#which are actually available. |
|
if not any([x == 0 for x in self.mc_status.values()]): |
|
if self.on_welcome: |
|
self.on_welcome() |
|
self.welcomed = True |
|
|
|
def on_nick_leave_trigger(self, nick, mc): |
|
"""If a nick leaves one message channel, |
|
and we are currently talking to it on that |
|
channel, attempt to dynamically switch to |
|
another channel on which it has been seen. |
|
If we are currently talking to it on a different |
|
channel, we ignore the signal, since it shouldn't |
|
interrupt processing. |
|
If we are not currently talking to it at all, |
|
just call on_nick_leave (which currently does nothing). |
|
""" |
|
|
|
#mark the nick as 'unseen' on that channel |
|
self.unsee_nick(nick, mc) |
|
if nick not in self.active_channels: |
|
if self.on_nick_leave: |
|
self.on_nick_leave(nick) |
|
elif self.active_channels[nick] == mc: |
|
del self.active_channels[nick] |
|
#Attempt to dynamically switch channels |
|
#Is the nick available on another channel? |
|
other_channels = [x for x in self.available_channels() if x != mc] |
|
if len(other_channels) == 0: |
|
log.warn( |
|
"Cannot reconnect to dropped nick, no connections available.") |
|
if self.on_nick_leave: |
|
self.on_nick_leave(nick) |
|
return |
|
for oc in other_channels: |
|
if nick in self.nicks_seen[oc]: |
|
log.debug("Found a new channel, setting to: " + nick + \ |
|
"," + str(oc.serverport)) |
|
self.active_channels[nick] = oc |
|
#Note we don't call on_nick_leave in this case |
|
return |
|
#If loop completed without success, we failed to find |
|
#this counterparty anywhere else |
|
log.debug("Nick: " + nick + " has left.") |
|
if self.on_nick_leave: |
|
self.on_nick_leave(nick) |
|
#The remaining case is if the channel that the |
|
#nick has left is not the one we're currently using. |
|
return |
|
|
|
def register_channel_callbacks(self, |
|
on_welcome=None, |
|
on_set_topic=None, |
|
on_connect=None, |
|
on_disconnect=None, |
|
on_nick_leave=None, |
|
on_nick_change=None): |
|
"""Special cases: |
|
on_welcome: we maintain it |
|
in this class, since we only want to trigger arrival |
|
when all channels are joined, not multiple times, then |
|
broadcast whatever it is we want to broadcast on arrival. |
|
|
|
on_nick_leave: this needs to be maintained in this class, |
|
since a nick only leaves the pit when it has departed *all* our |
|
message channels. |
|
|
|
on_nick_change: a bot which changes its nick on one channel |
|
must also successfully change its nick on all channels, or quit. |
|
|
|
on_disconnect: must be maintained here; if a bot disconnects |
|
only one it must remain viable, otherwise this has no point! |
|
|
|
on_connect: must reset the message channel status to connected. |
|
""" |
|
self.on_welcome = on_welcome |
|
self.on_disconnect = on_disconnect |
|
self.on_nick_leave = on_nick_leave |
|
self.on_connect = on_connect |
|
self.on_nick_change = on_nick_change |
|
for mc in self.mchannels: |
|
mc.register_channel_callbacks( |
|
self.on_welcome_trigger, on_set_topic, self.on_connect_trigger, |
|
self.on_disconnect_trigger, self.on_nick_leave_trigger, |
|
self.on_nick_change_trigger, self.see_nick) |
|
|
|
def on_nick_change_trigger(self, new_nick): |
|
"""If any underlying messagechannel object fails to register |
|
a nick/username, trigger all of them to change to the newly |
|
chosen nick/user. |
|
""" |
|
for mc in self.available_channels(): |
|
mc.change_nick(new_nick) |
|
if self.on_nick_change: |
|
self.on_nick_change(new_nick) |
|
|
|
def on_order_seen_trigger(self, mc, counterparty, oid, ordertype, minsize, |
|
maxsize, txfee, cjfee): |
|
"""This is the entry point into private messaging. |
|
Hence, it fixes for the rest of the conversation, which |
|
message channel the bots are going to communicate over |
|
(privately). |
|
Use the orderbook update as a signal that this counterparty (nick) |
|
is present on this message channel, before passing to calling code. |
|
Note that this will get called at least once per message channel, |
|
so it will simply end up setting the active channel to the last one |
|
that arrives. |
|
""" |
|
#Note that the counterparty will be added to the set for *each* |
|
#message channel where it has published an order (priv or pub), |
|
#so that we can hope to contact it at any one of those mcs. |
|
self.nicks_seen[mc].add(counterparty) |
|
|
|
self.active_channels[counterparty] = mc |
|
if self.on_order_seen: |
|
self.on_order_seen(counterparty, oid, ordertype, minsize, maxsize, |
|
txfee, cjfee) |
|
|
|
# orderbook watcher commands |
|
def register_orderbookwatch_callbacks(self, |
|
on_order_seen=None, |
|
on_order_cancel=None): |
|
"""Special cases: |
|
on_order_seen: use it as a trigger for presence of nick. |
|
on_order_cancel: what happens if cancel/modify in one place |
|
but not another? TODO |
|
""" |
|
self.on_order_seen = on_order_seen |
|
for mc in self.mchannels: |
|
mc.register_orderbookwatch_callbacks(self.on_order_seen_trigger, |
|
on_order_cancel) |
|
|
|
def on_orderbook_requested_trigger(self, nick, mc): |
|
"""Update nicks_seen state to reflect presence of |
|
taker on this message channel before pass-through. |
|
""" |
|
self.see_nick(nick, mc) |
|
if self.on_orderbook_requested: |
|
self.on_orderbook_requested(nick, mc) |
|
|
|
# maker commands |
|
def register_maker_callbacks(self, |
|
on_orderbook_requested=None, |
|
on_order_fill=None, |
|
on_seen_auth=None, |
|
on_seen_tx=None, |
|
on_push_tx=None, |
|
on_commitment_seen=None, |
|
on_commitment_transferred=None): |
|
"""Special cases: |
|
on_orderbook_requested must trigger addition to the nicks_seen |
|
database, so that makers can know that a taker is in principle |
|
available on this message channel. |
|
""" |
|
self.on_orderbook_requested = on_orderbook_requested |
|
for mc in self.mchannels: |
|
mc.register_maker_callbacks(self.on_orderbook_requested_trigger, |
|
on_order_fill, on_seen_auth, on_seen_tx, |
|
on_push_tx, on_commitment_seen, |
|
on_commitment_transferred) |
|
|
|
def on_verified_privmsg(self, nick, message, hostid): |
|
"""Called from daemon when message was successfully verified, |
|
to pass back into individual messagechannel |
|
""" |
|
matched_channels = [x for x in self.mchannels if hostid == x.hostid] |
|
if len(matched_channels) != 1: |
|
log.warn("Channel on which privmsg was received is now inactive; " |
|
"continuing to process this message") |
|
mc = matched_channels[0] |
|
mc.on_verified_privmsg(nick, message) |
|
|
|
def on_privmsg(self, nick, mchan): |
|
"""Registered as a callback for all mchannels: |
|
set the nick as seen on privmsg, as it may not |
|
be triggered if it doesn't issue a pubmsg. |
|
""" |
|
if mchan in self.available_channels(): |
|
self.see_nick(nick, mchan) |
|
#Should not be reached; but in weird case that the channel |
|
#is not available, there is nothing to do. |
|
|
|
|
|
class MessageChannel(object): |
|
__metaclass__ = abc.ABCMeta |
|
"""Abstract class which implements a way for bots to communicate. |
|
The Joinmarket messaging protocol is implemented here, while |
|
subclasses implement the OTW messaging protocol layer, as described |
|
in the abstract methods section below. |
|
""" |
|
|
|
def __init__(self, daemon=None): |
|
self.daemon = daemon |
|
# all |
|
self.on_welcome = None |
|
self.on_set_topic = None |
|
self.on_connect = None |
|
self.on_disconnect = None |
|
self.on_nick_leave = None |
|
self.on_nick_change = None |
|
self.on_pubmsg_trigger = None |
|
self.on_privmsg_trigger = None |
|
# orderbook watch functions |
|
self.on_order_seen = None |
|
self.on_order_cancel = None |
|
# taker functions |
|
self.on_error = None |
|
self.on_pubkey = None |
|
self.on_ioauth = None |
|
self.on_sig = None |
|
# maker functions |
|
self.on_orderbook_requested = None |
|
self.on_order_fill = None |
|
self.on_seen_auth = None |
|
self.on_seen_tx = None |
|
self.on_push_tx = None |
|
|
|
self.daemon = None |
|
|
|
"""THIS SECTION MUST BE IMPLEMENTED BY SUBCLASSES""" |
|
|
|
#In addition to the below functions, the implementation |
|
#must also call the callback function self.on_set_topic |
|
#to relay the public channel topic at startup. |
|
|
|
#Also, the implementation constructor (__init__) must |
|
#provide login credentials specific to itself as arguments. |
|
|
|
@abc.abstractmethod |
|
def run(self): |
|
"""Main running loop of the message channel""" |
|
|
|
@abc.abstractmethod |
|
def shutdown(self): |
|
"""Stop the main loop of the message channel, |
|
shutting down subsidiary resources gracefully. |
|
Note that unexpected disconnections MUST be |
|
handled by the implementation itself (restarting |
|
as appropriate).""" |
|
|
|
@abc.abstractmethod |
|
def _pubmsg(self, msg): |
|
"""Send a message onto the shared, public |
|
channel (the joinmarket pit).""" |
|
|
|
@abc.abstractmethod |
|
def _privmsg(self, nick, cmd, message): |
|
"""Send a message to a specific counterparty""" |
|
|
|
@abc.abstractmethod |
|
def _announce_orders(self, orderlist, nick): |
|
"""Send orders defined in list orderlist either |
|
to the shared public channel (pit), if nick=None, |
|
or to an individual counterparty nick. Note that |
|
calling code will access this via self.announce_orders.""" |
|
|
|
@abc.abstractmethod |
|
def change_nick(self, new_nick): |
|
"""Change the nick/username for this message channel |
|
instance to new_nick |
|
""" |
|
|
|
"""END OF SUBCLASS IMPLEMENTATION SECTION""" |
|
|
|
def set_nick(self, nick): |
|
self.given_nick = nick |
|
self.nick = self.given_nick |
|
|
|
def register_channel_callbacks(self, |
|
on_welcome=None, |
|
on_set_topic=None, |
|
on_connect=None, |
|
on_disconnect=None, |
|
on_nick_leave=None, |
|
on_nick_change=None, |
|
on_pubmsg_trigger=None): |
|
self.on_welcome = on_welcome |
|
self.on_set_topic = on_set_topic |
|
self.on_connect = on_connect |
|
self.on_disconnect = on_disconnect |
|
self.on_nick_leave = on_nick_leave |
|
self.on_nick_change = on_nick_change |
|
#Fire to MCcollection to mark nicks as "seen" |
|
self.on_pubmsg_trigger = on_pubmsg_trigger |
|
|
|
# orderbook watcher commands |
|
def register_orderbookwatch_callbacks(self, |
|
on_order_seen=None, |
|
on_order_cancel=None): |
|
self.on_order_seen = on_order_seen |
|
self.on_order_cancel = on_order_cancel |
|
|
|
# taker commands |
|
def register_taker_callbacks(self, |
|
on_error=None, |
|
on_pubkey=None, |
|
on_ioauth=None, |
|
on_sig=None): |
|
self.on_error = on_error |
|
self.on_pubkey = on_pubkey |
|
self.on_ioauth = on_ioauth |
|
self.on_sig = on_sig |
|
|
|
# maker commands |
|
def register_maker_callbacks(self, |
|
on_orderbook_requested=None, |
|
on_order_fill=None, |
|
on_seen_auth=None, |
|
on_seen_tx=None, |
|
on_push_tx=None, |
|
on_commitment_seen=None, |
|
on_commitment_transferred=None): |
|
self.on_orderbook_requested = on_orderbook_requested |
|
self.on_order_fill = on_order_fill |
|
self.on_seen_auth = on_seen_auth |
|
self.on_seen_tx = on_seen_tx |
|
self.on_push_tx = on_push_tx |
|
self.on_commitment_seen = on_commitment_seen |
|
self.on_commitment_transferred = on_commitment_transferred |
|
|
|
def announce_orders(self, orderlines): |
|
self._announce_orders(orderlines) |
|
|
|
def check_for_orders(self, nick, _chunks): |
|
if _chunks[0] in offername_list: |
|
try: |
|
counterparty = nick |
|
oid = _chunks[1] |
|
ordertype = _chunks[0] |
|
minsize = _chunks[2] |
|
maxsize = _chunks[3] |
|
txfee = _chunks[4] |
|
cjfee = _chunks[5] |
|
if self.on_order_seen: |
|
self.on_order_seen(self, counterparty, oid, ordertype, |
|
minsize, maxsize, txfee, cjfee) |
|
except IndexError as e: |
|
log.debug(e) |
|
log.debug('index error parsing chunks, possibly malformed' |
|
'offer by other party. No user action required.') |
|
# TODO what now? just ignore iirc |
|
finally: |
|
return True |
|
return False |
|
|
|
def check_for_commitments(self, nick, _chunks, private=False): |
|
"""If a commitment message is found in a pubmsg, trigger |
|
callback on_commitment_seen, if as a privmsg, trigger |
|
callback on_commitment_transferred. These callbacks are (for now) |
|
only used by Makers. |
|
""" |
|
if _chunks[0] in commitment_broadcast_list: |
|
try: |
|
counterparty = nick |
|
commitment = _chunks[1] |
|
if private: |
|
if self.on_commitment_transferred: |
|
self.on_commitment_transferred(counterparty, commitment) |
|
else: |
|
if self.on_commitment_seen: |
|
self.on_commitment_seen(counterparty, commitment) |
|
except IndexError as e: |
|
log.debug(e) |
|
log.debug('index error parsing chunks, possibly malformed' |
|
'offer by other party. No user action required.') |
|
finally: |
|
return True |
|
return False |
|
|
|
def cancel_orders(self, oid_list): |
|
clines = [COMMAND_PREFIX + 'cancel ' + str(oid) for oid in oid_list] |
|
self.pubmsg(''.join(clines)) |
|
|
|
def send_pubkey(self, nick, pubkey): |
|
self.privmsg(nick, 'pubkey', pubkey) |
|
|
|
def send_ioauth(self, nick, utxo_list, auth_pub, cj_addr, change_addr, sig): |
|
authmsg = str(','.join(utxo_list)) + ' ' + ' '.join([auth_pub, cj_addr, |
|
change_addr, sig]) |
|
self.privmsg(nick, 'ioauth', authmsg) |
|
|
|
def send_sigs(self, nick, sig_list): |
|
# TODO make it send the sigs on one line if there's space |
|
for s in sig_list: |
|
self.privmsg(nick, 'sig', s) |
|
|
|
# OrderbookWatch callback |
|
def request_orderbook(self): |
|
self.pubmsg(COMMAND_PREFIX + 'orderbook') |
|
|
|
# Taker callbacks |
|
def fill_orders(self, nick_order_dict, cj_amount, taker_pubkey, commitment): |
|
for c, order in nick_order_dict.iteritems(): |
|
msg = str(order['oid']) + ' ' + str(cj_amount) + ' ' + taker_pubkey |
|
msg += ' ' + commitment |
|
self.privmsg(c, 'fill', msg) |
|
|
|
def push_tx(self, nick, txhex): |
|
txb64 = base64.b64encode(txhex.decode('hex')) |
|
self.privmsg(nick, 'push', txb64) |
|
|
|
def send_error(self, nick, errormsg): |
|
log.info('error<%s> : %s' % (nick, errormsg)) |
|
self.privmsg(nick, 'error', errormsg) |
|
raise CJPeerError() |
|
|
|
def pubmsg(self, message): |
|
log.debug('>>pubmsg ' + message) |
|
#Currently there is no joinmarket protocol logic here; |
|
#just pass-through. |
|
self._pubmsg(message) |
|
|
|
def privmsg(self, nick, cmd, message): |
|
log.debug('>>privmsg on %s: ' % (self.hostid) + 'nick=' + nick + ' cmd=' |
|
+ cmd + ' msg=' + message) |
|
#forward to the implementation class (use single _ for polymrphsm to work) |
|
self._privmsg(nick, cmd, message) |
|
|
|
def on_pubmsg(self, nick, message): |
|
#Even illegal messages mark a nick as "seen" |
|
if self.on_pubmsg_trigger: |
|
self.on_pubmsg_trigger(nick, self) |
|
if message[0] != COMMAND_PREFIX: |
|
return |
|
commands = message[1:].split(COMMAND_PREFIX) |
|
#DOS vector: repeated !orderbook requests, see #298. |
|
if commands.count('orderbook') > 1: |
|
return |
|
for command in commands: |
|
_chunks = command.split(" ") |
|
if self.check_for_orders(nick, _chunks): |
|
pass |
|
if self.check_for_commitments(nick, _chunks): |
|
pass |
|
elif _chunks[0] == 'cancel': |
|
# !cancel [oid] |
|
try: |
|
oid = int(_chunks[1]) |
|
if self.on_order_cancel: |
|
self.on_order_cancel(nick, oid) |
|
except (ValueError, IndexError) as e: |
|
log.debug("!cancel " + repr(e)) |
|
return |
|
elif _chunks[0] == 'orderbook': |
|
if self.on_orderbook_requested: |
|
self.on_orderbook_requested(nick, self) |
|
else: |
|
# TODO this is for testing/debugging, should be removed, see taker.py |
|
if hasattr(self, 'debug_on_pubmsg_cmd'): |
|
self.debug_on_pubmsg_cmd(nick, _chunks) |
|
|
|
def on_privmsg(self, nick, message): |
|
"""handles the case when a private message is received""" |
|
#Aberrant short messages should be handled by subclasses |
|
#in _privmsg, but this constitutes a sanity check. Note that |
|
#messages which use an encrypted_command but present no |
|
#ciphertext will be rejected with the ValueError on decryption. |
|
#Other ill formatted messages will be caught in the try block. |
|
if len(message) < 2: |
|
return |
|
|
|
if message[0] != COMMAND_PREFIX: |
|
log.debug('message not a cmd') |
|
return |
|
cmd_string = message[1:].split(' ')[0] |
|
if cmd_string not in plaintext_commands + encrypted_commands: |
|
log.debug('cmd not in cmd_list, line="' + message + '"') |
|
return |
|
#Verify nick ownership |
|
sig = message[1:].split(' ')[-2:] |
|
#reconstruct original message without cmd |
|
rawmessage = ' '.join(message[1:].split(' ')[1:-2]) |
|
#sanity check that the sig was appended properly |
|
if len(sig) != 2 or len(rawmessage) == 0: |
|
log.debug("Sig not properly appended to privmsg, ignoring") |
|
return |
|
self.daemon.request_signature_verify( |
|
rawmessage + str(self.hostid), message, sig[1], sig[0], nick, |
|
NICK_HASH_LENGTH, NICK_MAX_ENCODED, str(self.hostid)) |
|
|
|
def on_verified_privmsg(self, nick, message): |
|
#Marks the nick as active on this channel; note *only* if verified. |
|
#Otherwise squatter/attacker can persuade us to send privmsgs to him. |
|
if self.on_privmsg_trigger: |
|
self.on_privmsg_trigger(nick, self) |
|
#strip sig from message for processing, having verified |
|
message = " ".join(message[1:].split(" ")[:-2]) |
|
for command in message.split(COMMAND_PREFIX): |
|
_chunks = command.split(" ") |
|
|
|
#Decrypt if necessary |
|
if _chunks[0] in encrypted_commands: |
|
box, encrypt = self.daemon.mcc.get_encryption_box(_chunks[0], |
|
nick) |
|
if encrypt: |
|
if not box: |
|
log.debug('error, dont have encryption box object for ' |
|
+ nick + ', dropping message') |
|
return |
|
# need to decrypt everything after the command string |
|
to_decrypt = ''.join(_chunks[1:]) |
|
try: |
|
decrypted = decode_decrypt(to_decrypt, box) |
|
except ValueError as e: |
|
log.debug('valueerror when decrypting, skipping: ' + |
|
repr(e)) |
|
return |
|
#rebuild the chunks array as if it had been plaintext |
|
_chunks = [_chunks[0]] + decrypted.split(" ") |
|
|
|
# looks like a very similar pattern for all of these |
|
# check for a command name, parse arguments, call a function |
|
# maybe we need some eval() trickery to do it better |
|
|
|
try: |
|
# orderbook watch commands |
|
if self.check_for_orders(nick, _chunks): |
|
pass |
|
# taker commands |
|
elif _chunks[0] == 'pubkey': |
|
maker_pk = _chunks[1] |
|
if self.on_pubkey: |
|
self.on_pubkey(nick, maker_pk) |
|
elif _chunks[0] == 'ioauth': |
|
utxo_list = _chunks[1].split(',') |
|
auth_pub = _chunks[2] |
|
cj_addr = _chunks[3] |
|
change_addr = _chunks[4] |
|
btc_sig = _chunks[5] |
|
if self.on_ioauth: |
|
self.on_ioauth(nick, utxo_list, auth_pub, cj_addr, |
|
change_addr, btc_sig) |
|
elif _chunks[0] == 'sig': |
|
sig = _chunks[1] |
|
if self.on_sig: |
|
self.on_sig(nick, sig) |
|
|
|
# maker commands |
|
if self.check_for_commitments(nick, _chunks, private=True): |
|
pass |
|
if _chunks[0] == 'fill': |
|
try: |
|
oid = int(_chunks[1]) |
|
amount = int(_chunks[2]) |
|
taker_pk = _chunks[3] |
|
if len(_chunks) > 4: |
|
commit = _chunks[4] |
|
else: |
|
commit = None |
|
except (ValueError, IndexError) as e: |
|
self.send_error(nick, str(e)) |
|
if self.on_order_fill: |
|
self.on_order_fill(nick, oid, amount, taker_pk, commit) |
|
elif _chunks[0] == 'auth': |
|
try: |
|
cr = _chunks[1] |
|
except (ValueError, IndexError) as e: |
|
self.send_error(nick, str(e)) |
|
if self.on_seen_auth: |
|
self.on_seen_auth(nick, cr) |
|
elif _chunks[0] == 'tx': |
|
b64tx = _chunks[1] |
|
try: |
|
txhex = base64.b64decode(b64tx).encode('hex') |
|
except TypeError as e: |
|
self.send_error(nick, 'bad base64 tx. ' + repr(e)) |
|
if self.on_seen_tx: |
|
self.on_seen_tx(nick, txhex) |
|
elif _chunks[0] == 'push': |
|
b64tx = _chunks[1] |
|
try: |
|
txhex = base64.b64decode(b64tx).encode('hex') |
|
except TypeError as e: |
|
self.send_error(nick, 'bad base64 tx. ' + repr(e)) |
|
if self.on_push_tx: |
|
self.on_push_tx(nick, txhex) |
|
except CJPeerError: |
|
# TODO proper error handling |
|
log.debug('cj peer error TODO handle') |
|
continue
|
|
|