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.
 
 
 
 

266 lines
12 KiB

#! /usr/bin/env python
import os
from twisted.application.service import Service
from twisted.internet import task
import jmbitcoin as btc
from jmclient.configure import jm_single
from jmbase import (get_log, utxo_to_utxostr,
hextobin, bintohex)
jlog = get_log()
class SNICKERError(Exception):
pass
class SNICKERReceiverService(Service):
def __init__(self, receiver):
assert isinstance(receiver, SNICKERReceiver)
self.receiver = receiver
# main monitor loop
self.monitor_loop = task.LoopingCall(self.receiver.poll_for_proposals)
def startService(self):
""" Encapsulates start up actions.
This service depends on the receiver's
wallet service to start, so wait for that.
"""
self.wait_for_wallet = task.LoopingCall(self.wait_for_wallet_sync)
self.wait_for_wallet.start(5.0)
def wait_for_wallet_sync(self):
if self.receiver.wallet_service.isRunning():
jlog.info("SNICKER service starting because wallet service is up.")
self.wait_for_wallet.stop()
self.monitor_loop.start(5.0)
super().startService()
def stopService(self, wallet=False):
""" Encapsulates shut down actions.
Optionally also shut down the underlying
wallet service (default False).
"""
if self.monitor_loop:
self.monitor_loop.stop()
if wallet:
self.receiver.wallet_service.stopService()
super().stopService()
def isRunning(self):
return self.running == 1
class SNICKERReceiver(object):
supported_flags = []
def __init__(self, wallet_service, acceptance_callback=None,
info_callback=None):
"""
Class to manage processing of SNICKER proposals and
co-signs and broadcasts in case the application level
configuration permits.
`acceptance_callback`, if specified, must have arguments
and return type as for the default_acceptance_callback
in this class.
"""
# This is a Joinmarket WalletService object.
self.wallet_service = wallet_service
# The simplest filter on accepting SNICKER joins:
# that they pay a minimum of this value in satoshis,
# which can be negative (e.g. to account for fees).
self.income_threshold = jm_single().config.getint("SNICKER",
"lowest_net_gain")
# The acceptance callback which defines if we accept
# a valid proposal and sign it, or not.
if acceptance_callback is None:
self.acceptance_callback = self.default_acceptance_callback
else:
self.acceptance_callback = acceptance_callback
# callback for information messages to UI
if not info_callback:
self.info_callback = self.default_info_callback
else:
self.info_callback = info_callback
# A list of currently viable key candidates; these must
# all be (pub)keys for which the privkey is accessible,
# i.e. they must be in-wallet keys.
# This list will be continuously updated by polling the
# wallet.
self.candidate_keys = []
# A list of already processed proposals
self.processed_proposals = []
# maintain a list of all successfully broadcast
# SNICKER transactions in the current run.
self.successful_txs = []
# the main monitoring loop that checks for proposals:
self.proposal_poll_loop = None
def default_info_callback(self, msg):
jlog.info(msg)
if not os.path.exists(self.proposals_source):
with open(self.proposals_source, "wb") as f:
jlog.info("created proposals source file.")
def default_acceptance_callback(self, our_ins, their_ins,
our_outs, their_outs):
""" Accepts lists of inputs as CTXIns,
a single output (belonging to us) as a CTxOut,
and a list of other outputs (belonging not to us) in same
format, and must return only True or False representing acceptance.
Note that this code is relying on the calling function to give
accurate information about the outputs.
"""
# we must find the utxo in our wallet to get its amount.
# this serves as a sanity check that the input is indeed
# ours.
# we use get_all* because for these purposes mixdepth
# is irrelevant.
utxos = self.wallet_service.get_all_utxos()
our_in_amts = []
our_out_amts = []
for i in our_ins:
utxo_for_i = (i.prevout.hash[::-1], i.prevout.n)
if utxo_for_i not in utxos.keys():
success, utxostr = utxo_to_utxostr(utxo_for_i)
if not success:
jlog.error("Code error: input utxo in wrong format.")
jlog.debug("The input utxo was not found: " + utxostr)
jlog.debug("NB: This can simply mean the coin is already spent.")
return False
our_in_amts.append(utxos[utxo_for_i]["value"])
for o in our_outs:
our_out_amts.append(o.nValue)
if sum(our_out_amts) - sum(our_in_amts) < self.income_threshold:
return False
return True
def log_successful_tx(self, tx):
""" TODO: add dedicated SNICKER log file.
"""
self.successful_txs.append(tx)
jlog.info(btc.human_readable_transaction(tx))
def process_proposals(self, proposals):
""" This is the "meat" of the SNICKERReceiver service.
It parses proposals and creates and broadcasts transactions
with the wallet, assuming all conditions are met.
Note that this is ONLY called from the proposals poll loop.
Each entry in `proposals` is of form:
encrypted_proposal - base64 string
key - hex encoded compressed pubkey, or ''
if the key is not null, we attempt to decrypt and
process according to that key, else cycles over all keys.
If all SNICKER validations succeed, the decision to spend is
entirely dependent on self.acceptance_callback.
If the callback returns True, we co-sign and broadcast the
transaction and also update the wallet with the new
imported key (TODO: future versions will enable searching
for these keys using history + HD tree; note the jmbitcoin
snicker.py module DOES insist on ECDH being correctly used,
so this will always be possible for transactions created here.
Returned is a list of txids of any transactions which
were broadcast, unless a critical error occurs, in which case
False is returned (to minimize this function's trust in other
parts of the code being executed, if something appears to be
inconsistent, we trigger immediate halt with this return).
"""
for kp in proposals:
# handle empty list entries:
if not kp:
continue
try:
p, k = kp.split(',')
except:
# could argue for info or warning debug level,
# but potential for a lot of unwanted output.
jlog.debug("Invalid proposal string, ignoring: " + kp)
continue
if k is not None:
# note that this operation will succeed as long as
# the key is in the wallet._script_map, which will
# be true if the key is at an HD index lower than
# the current wallet.index_cache
k = hextobin(k)
addr = self.wallet_service.pubkey_to_addr(k)
if not self.wallet_service.is_known_addr(addr):
jlog.debug("Key not recognized as part of our "
"wallet, ignoring.")
continue
result = self.wallet_service.parse_proposal_to_signed_tx(
addr, p, self.acceptance_callback)
if result[0] is not None:
tx, tweak, out_spk = result
# We will: rederive the key as a sanity check,
# and see if it matches the claimed spk.
# Then, we import the key into the wallet
# (even though it's re-derivable from history, this
# is the easiest for a first implementation).
# Finally, we co-sign, then push.
# (Again, simplest function: checks already passed,
# so do it automatically).
tweaked_key = btc.snicker_pubkey_tweak(k, tweak)
tweaked_spk = self.wallet_service.pubkey_to_script(
tweaked_key)
# Derive original path to make sure we change
# mixdepth:
source_path = self.wallet_service.script_to_path(
self.wallet_service.pubkey_to_script(k))
# NB This will give the correct source mixdepth independent
# of whether the key is imported or not:
source_mixdepth = self.wallet_service.get_details(
source_path)[0]
if not tweaked_spk == out_spk:
jlog.error("The spk derived from the pubkey does "
"not match the scriptPubkey returned from "
"the snicker module - code error.")
return False
# before import, we should derive the tweaked *private* key
# from the tweak, also; failure of this critical sanity check
# is a code error. If the recreated private key matches, we
# import to the wallet. Note that this happens *before* pushing
# the coinjoin transaction to the network, which is advisably
# conservative (never possible to have broadcast a tx without
# having already stored the output's key).
success, msg = self.wallet_service.check_tweak_matches_and_import(
addr, tweak, tweaked_key, source_mixdepth)
if not success:
jlog.error(msg)
return False
# TODO condition on automatic brdcst or not
if not jm_single().bc_interface.pushtx(tx.serialize()):
# this represents an error about state (or conceivably,
# an ultra-short window in which the spent utxo was
# consumed in another transaction), but not really
# an internal logic error, so we do NOT return False
jlog.error("Failed to broadcast SNICKER coinjoin: " +\
bintohex(tx.GetTxid()[::-1]))
jlog.info(btc.human_readable_transaction(tx))
jlog.info("Successfully broadcast SNICKER coinjoin: " +\
bintohex(tx.GetTxid()[::-1]))
self.log_successful_tx(tx)
else:
jlog.debug('Failed to parse proposal: ' + result[1])
else:
# Some extra work to implement checking all possible
# keys.
jlog.info("Proposal without pubkey was not processed.")
# Completed processing all proposals without any logic
# errors (whether the proposals were valid or accepted
# or not).
return True