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
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 |
|
|
|
|