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.
 
 
 
 

219 lines
9.6 KiB

#! /usr/bin/env python
import sys
import jmbitcoin as btc
from jmclient.configure import jm_single
from jmbase import (get_log, EXIT_FAILURE, utxo_to_utxostr,
bintohex, hextobin)
jlog = get_log()
class SNICKERError(Exception):
pass
class SNICKERReceiver(object):
supported_flags = []
import_branch = 0
# TODO implement http api or similar
# for polling, here just a file:
proposals_source = "proposals.txt"
def __init__(self, wallet_service, income_threshold=0,
acceptance_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 (to account for fees).
# TODO this will be a config variable.
self.income_threshold = income_threshold
# 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
# 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 = []
def poll_for_proposals(self):
""" Intended to 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.
"""
new_proposals = []
with open(self.proposals_source, "rb") as f:
current_entries = f.readlines()
for entry in current_entries:
if entry in self.processed_proposals:
continue
new_proposals.append(entry)
if not self.process_proposals(new_proposals):
jlog.error("Critical logic error, shutting down.")
sys.exit(EXIT_FAILURE)
self.processed_proposals.extend(new_proposals)
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()
print("gau returned these utxos: ", 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)
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 process_proposals(self, proposals):
""" 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:
try:
p, k = kp.split(b',')
except:
jlog.error("Invalid proposal string, ignoring: " + kp)
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.decode('utf-8'))
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
# TODO: interface/API of SNICKERWalletMixin would better take
# address as argument here, not privkey:
priv = self.wallet_service.get_key_from_addr(addr)
result = self.wallet_service.parse_proposal_to_signed_tx(
priv, 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).
# TODO: the more sophisticated actions.
tweaked_key = btc.snicker_pubkey_tweak(k, tweak)
tweaked_spk = btc.pubkey_to_p2sh_p2wpkh_script(tweaked_key)
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:
tweaked_privkey = btc.snicker_privkey_tweak(priv, tweak)
if not btc.privkey_to_pubkey(tweaked_privkey) == tweaked_key:
jlog.error("Was not able to recover tweaked pubkey "
"from tweaked privkey - code error.")
jlog.error("Expected: " + bintohex(tweaked_key))
jlog.error("Got: " + bintohex(btc.privkey_to_pubkey(
tweaked_privkey)))
return False
# the recreated private key matches, so we import to the wallet,
# note that type = None here is because we use the same
# scriptPubKey type as the wallet, this has been implicitly
# checked above by deriving the scriptPubKey.
self.wallet_service.import_private_key(self.import_branch,
self.wallet_service._ENGINE.privkey_to_wif(tweaked_privkey))
# TODO condition on automatic brdcst or not
if not jm_single().bc_interface.pushtx(tx.serialize()):
jlog.error("Failed to broadcast SNICKER CJ.")
return False
self.successful_txs.append(tx)
return True
else:
jlog.debug('Failed to parse proposal: ' + result[1])
continue
else:
# Some extra work to implement checking all possible
# keys.
raise NotImplementedError()
# Completed processing all proposals without any logic
# errors (whether the proposals were valid or accepted
# or not).
return True