Browse Source
This commit uses the now created PSBTWalletMixin and additionally creates a SNICKERWalletMixin, and adds a SNICKERReceiver object to jmclient. A test of the end to end workflow of create and then co-sign a SNICKER coinjoin as per the draft BIP is in test_snicker. Additional changes: updated python-bitcointx dependency to >=1.0.5 Minor refactoring of callbacks in tests and additional redeem script checks to PSBTWalletMixin.sign_psbt. Note that this work replaces #403 .master
14 changed files with 897 additions and 12 deletions
@ -0,0 +1,91 @@ |
|||||||
|
#!/usr/bin/python |
||||||
|
from __future__ import (absolute_import, division, |
||||||
|
print_function, unicode_literals) |
||||||
|
from builtins import * # noqa: F401 |
||||||
|
from future.utils import native |
||||||
|
import coincurve as secp256k1 |
||||||
|
import base64 |
||||||
|
import hmac |
||||||
|
import hashlib |
||||||
|
import pyaes |
||||||
|
import os |
||||||
|
import jmbitcoin as btc |
||||||
|
|
||||||
|
ECIES_MAGIC_BYTES = b'BIE1' |
||||||
|
|
||||||
|
class ECIESDecryptionError(Exception): |
||||||
|
pass |
||||||
|
|
||||||
|
# AES primitives. See BIP-SNICKER for specification. |
||||||
|
def aes_encrypt(key, data, iv): |
||||||
|
encrypter = pyaes.Encrypter( |
||||||
|
pyaes.AESModeOfOperationCBC(key, iv=native(iv))) |
||||||
|
enc_data = encrypter.feed(data) |
||||||
|
enc_data += encrypter.feed() |
||||||
|
|
||||||
|
return enc_data |
||||||
|
|
||||||
|
def aes_decrypt(key, data, iv): |
||||||
|
decrypter = pyaes.Decrypter( |
||||||
|
pyaes.AESModeOfOperationCBC(key, iv=native(iv))) |
||||||
|
try: |
||||||
|
dec_data = decrypter.feed(data) |
||||||
|
dec_data += decrypter.feed() |
||||||
|
except ValueError: |
||||||
|
# note decryption errors can come from PKCS7 padding errors |
||||||
|
raise ECIESDecryptionError() |
||||||
|
return dec_data |
||||||
|
|
||||||
|
def ecies_encrypt(message, pubkey): |
||||||
|
""" Take a privkey in raw byte serialization, |
||||||
|
and a pubkey serialized in compressed, binary format (33 bytes), |
||||||
|
and output the shared secret as a 32 byte hash digest output. |
||||||
|
The exact calculation is: |
||||||
|
shared_secret = SHA256(privkey * pubkey) |
||||||
|
.. where * is elliptic curve scalar multiplication. |
||||||
|
See https://github.com/bitcoin/bitcoin/blob/master/src/secp256k1/src/modules/ecdh/main_impl.h |
||||||
|
for implementation details. |
||||||
|
""" |
||||||
|
# create an ephemeral pubkey for this encryption: |
||||||
|
while True: |
||||||
|
r = os.urandom(32) |
||||||
|
# use compressed serialization of the pubkey R: |
||||||
|
try: |
||||||
|
R = btc.privkey_to_pubkey(r + b"\x01") |
||||||
|
break |
||||||
|
except: |
||||||
|
# accounts for improbable overflow: |
||||||
|
continue |
||||||
|
# note that this is *not* ECDH as in the secp256k1_ecdh module, |
||||||
|
# since it uses sha512: |
||||||
|
ecdh_key = btc.multiply(r, pubkey) |
||||||
|
key = hashlib.sha512(ecdh_key).digest() |
||||||
|
iv, key_e, key_m = key[0:16], key[16:32], key[32:] |
||||||
|
ciphertext = aes_encrypt(key_e, message, iv=iv) |
||||||
|
encrypted = ECIES_MAGIC_BYTES + R + ciphertext |
||||||
|
mac = hmac.new(key_m, encrypted, hashlib.sha256).digest() |
||||||
|
return base64.b64encode(encrypted + mac) |
||||||
|
|
||||||
|
def ecies_decrypt(privkey, encrypted): |
||||||
|
if len(privkey) == 33 and privkey[-1] == 1: |
||||||
|
privkey = privkey[:32] |
||||||
|
encrypted = base64.b64decode(encrypted) |
||||||
|
if len(encrypted) < 85: |
||||||
|
raise Exception('invalid ciphertext: length') |
||||||
|
magic = encrypted[:4] |
||||||
|
if magic != ECIES_MAGIC_BYTES: |
||||||
|
raise ECIESDecryptionError() |
||||||
|
ephemeral_pubkey = encrypted[4:37] |
||||||
|
try: |
||||||
|
testR = secp256k1.PublicKey(ephemeral_pubkey) |
||||||
|
except: |
||||||
|
raise ECIESDecryptionError() |
||||||
|
ciphertext = encrypted[37:-32] |
||||||
|
mac = encrypted[-32:] |
||||||
|
ecdh_key = btc.multiply(privkey, ephemeral_pubkey) |
||||||
|
key = hashlib.sha512(ecdh_key).digest() |
||||||
|
iv, key_e, key_m = key[0:16], key[16:32], key[32:] |
||||||
|
if mac != hmac.new(key_m, encrypted[:-32], hashlib.sha256).digest(): |
||||||
|
raise ECIESDecryptionError() |
||||||
|
return aes_decrypt(key_e, ciphertext, iv=iv) |
||||||
|
|
||||||
@ -0,0 +1,58 @@ |
|||||||
|
from __future__ import (absolute_import, division, |
||||||
|
print_function, unicode_literals) |
||||||
|
from builtins import * # noqa: F401 |
||||||
|
|
||||||
|
# Implementation of proposal as per |
||||||
|
# https://gist.github.com/AdamISZ/2c13fb5819bd469ca318156e2cf25d79 |
||||||
|
# (BIP SNICKER) |
||||||
|
# TODO: BIP69 is removed in this implementation, will update BIP draft. |
||||||
|
|
||||||
|
from jmbitcoin.secp256k1_ecies import * |
||||||
|
from jmbitcoin.secp256k1_main import * |
||||||
|
from jmbitcoin.secp256k1_transaction import * |
||||||
|
|
||||||
|
SNICKER_MAGIC_BYTES = b'SNICKER' |
||||||
|
|
||||||
|
# Flags may be added in future versions |
||||||
|
SNICKER_FLAG_NONE = b"\x00" |
||||||
|
|
||||||
|
def snicker_pubkey_tweak(pub, tweak): |
||||||
|
""" use secp256k1 library to perform tweak. |
||||||
|
Both `pub` and `tweak` are expected as byte strings |
||||||
|
(33 and 32 bytes respectively). |
||||||
|
Return value is also a 33 byte string serialization |
||||||
|
of the resulting pubkey (compressed). |
||||||
|
""" |
||||||
|
base_pub = secp256k1.PublicKey(pub) |
||||||
|
return base_pub.add(tweak).format() |
||||||
|
|
||||||
|
def snicker_privkey_tweak(priv, tweak): |
||||||
|
""" use secp256k1 library to perform tweak. |
||||||
|
Both `priv` and `tweak` are expected as byte strings |
||||||
|
(32 or 33 and 32 bytes respectively). |
||||||
|
Return value isa 33 byte string serialization |
||||||
|
of the resulting private key/secret (with compression flag). |
||||||
|
""" |
||||||
|
if len(priv) == 33 and priv[-1] == 1: |
||||||
|
priv = priv[:-1] |
||||||
|
base_priv = secp256k1.PrivateKey(priv) |
||||||
|
return base_priv.add(tweak).secret + b'\x01' |
||||||
|
|
||||||
|
def verify_snicker_output(tx, pub, tweak, spk_type='p2sh-p2wpkh'): |
||||||
|
""" A convenience function to check that one output address in a transaction |
||||||
|
is a SNICKER-type tweak of an existing key. Returns the index of the output |
||||||
|
for which this is True (and there must be only 1), and the derived spk, |
||||||
|
or -1 and None if it is not found exactly once. |
||||||
|
TODO Add support for other scriptPubKey types. |
||||||
|
""" |
||||||
|
assert isinstance(tx, btc.CBitcoinTransaction) |
||||||
|
expected_destination_pub = snicker_pubkey_tweak(pub, tweak) |
||||||
|
expected_destination_spk = pubkey_to_p2sh_p2wpkh_script(expected_destination_pub) |
||||||
|
found = 0 |
||||||
|
for i, o in enumerate(tx.vout): |
||||||
|
if o.scriptPubKey == expected_destination_spk: |
||||||
|
found += 1 |
||||||
|
found_index = i |
||||||
|
if found != 1: |
||||||
|
return -1, None |
||||||
|
return found_index, expected_destination_spk |
||||||
@ -0,0 +1,60 @@ |
|||||||
|
#! /usr/bin/env python |
||||||
|
from __future__ import (absolute_import, division, |
||||||
|
print_function, unicode_literals) |
||||||
|
from builtins import * # noqa: F401 |
||||||
|
'''Tests coincurve binding to libsecp256k1 ecdh module code''' |
||||||
|
|
||||||
|
import hashlib |
||||||
|
import jmbitcoin as btc |
||||||
|
from jmbase import hextobin |
||||||
|
import pytest |
||||||
|
import os |
||||||
|
import json |
||||||
|
testdir = os.path.dirname(os.path.realpath(__file__)) |
||||||
|
|
||||||
|
def test_ecdh(): |
||||||
|
"""Using private key test vectors from Bitcoin Core. |
||||||
|
1. Import a set of private keys from the json file. |
||||||
|
2. Calculate the corresponding public keys. |
||||||
|
3. Do ECDH on the cartesian product (x, Y), with x private |
||||||
|
and Y public keys, for all combinations. |
||||||
|
4. Compare the result from CoinCurve with the manual |
||||||
|
multiplication xY following by hash (sha256). Note that |
||||||
|
sha256(xY) is the default hashing function used for ECDH |
||||||
|
in libsecp256k1. |
||||||
|
|
||||||
|
Since there are about 20 private keys in the json file, this |
||||||
|
creates around 400 test cases (note xX is still valid). |
||||||
|
""" |
||||||
|
with open(os.path.join(testdir,"base58_keys_valid.json"), "r") as f: |
||||||
|
json_data = f.read() |
||||||
|
valid_keys_list = json.loads(json_data) |
||||||
|
extracted_privkeys = [] |
||||||
|
for a in valid_keys_list: |
||||||
|
key, hex_key, prop_dict = a |
||||||
|
if prop_dict["isPrivkey"]: |
||||||
|
c, k = btc.read_privkey(hextobin(hex_key)) |
||||||
|
extracted_privkeys.append(k) |
||||||
|
extracted_pubkeys = [btc.privkey_to_pubkey(x) for x in extracted_privkeys] |
||||||
|
for p in extracted_privkeys: |
||||||
|
for P in extracted_pubkeys: |
||||||
|
c, k = btc.read_privkey(p) |
||||||
|
shared_secret = btc.ecdh(k, P) |
||||||
|
assert len(shared_secret) == 32 |
||||||
|
# try recreating the shared secret manually: |
||||||
|
pre_secret = btc.multiply(p, P) |
||||||
|
derived_secret = hashlib.sha256(pre_secret).digest() |
||||||
|
assert derived_secret == shared_secret |
||||||
|
|
||||||
|
# test some important failure cases; null key, overflow case |
||||||
|
privkeys_invalid = [b'\x00'*32, hextobin( |
||||||
|
'fffffffffffffffffffffffffffffffebaaedce6af48a03bbfd25e8cd0364141')] |
||||||
|
for p in privkeys_invalid: |
||||||
|
with pytest.raises(Exception) as e_info: |
||||||
|
shared_secret = btc.ecdh(p, extracted_pubkeys[0]) |
||||||
|
pubkeys_invalid = [b'0xff' + extracted_pubkeys[0][1:], b'0x00'*12] |
||||||
|
for p in extracted_privkeys: |
||||||
|
with pytest.raises(Exception) as e_info: |
||||||
|
shared_secret = btc.ecdh(p, pubkeys_invalid[0]) |
||||||
|
with pytest.raises(Exception) as e_info: |
||||||
|
shared_secret = btc.ecdh(p, pubkeys_invalid[1]) |
||||||
@ -0,0 +1,43 @@ |
|||||||
|
#! /usr/bin/env python |
||||||
|
from __future__ import (absolute_import, division, |
||||||
|
print_function, unicode_literals) |
||||||
|
from builtins import * # noqa: F401 |
||||||
|
'''Tests ECIES implementation as defined in BIP-SNICKER |
||||||
|
(and will be updated if that is).''' |
||||||
|
|
||||||
|
from jmbase import hextobin |
||||||
|
import jmbitcoin as btc |
||||||
|
import base64 |
||||||
|
import os |
||||||
|
import json |
||||||
|
testdir = os.path.dirname(os.path.realpath(__file__)) |
||||||
|
|
||||||
|
def test_ecies(): |
||||||
|
"""Using private key test vectors from Bitcoin Core. |
||||||
|
1. Import a set of private keys from the json file. |
||||||
|
2. Calculate the corresponding public keys. |
||||||
|
3. Do ECDH on the cartesian product (x, Y), with x private |
||||||
|
and Y public keys, for all combinations. |
||||||
|
4. Compare the result from CoinCurve with the manual |
||||||
|
multiplication xY following by hash (sha256). Note that |
||||||
|
sha256(xY) is the default hashing function used for ECDH |
||||||
|
in libsecp256k1. |
||||||
|
|
||||||
|
Since there are about 20 private keys in the json file, this |
||||||
|
creates around 400 test cases (note xX is still valid). |
||||||
|
""" |
||||||
|
with open(os.path.join(testdir,"base58_keys_valid.json"), "r") as f: |
||||||
|
json_data = f.read() |
||||||
|
valid_keys_list = json.loads(json_data) |
||||||
|
print("got valid keys list") |
||||||
|
extracted_privkeys = [] |
||||||
|
for a in valid_keys_list: |
||||||
|
key, hex_key, prop_dict = a |
||||||
|
if prop_dict["isPrivkey"]: |
||||||
|
c, k = btc.read_privkey(hextobin(hex_key)) |
||||||
|
|
||||||
|
extracted_privkeys.append(k) |
||||||
|
extracted_pubkeys = [btc.privkey_to_pubkey(x) for x in extracted_privkeys] |
||||||
|
for (priv, pub) in zip(extracted_privkeys, extracted_pubkeys): |
||||||
|
test_message = base64.b64encode(os.urandom(15)*20) |
||||||
|
assert btc.ecies_decrypt(priv, btc.ecies_encrypt(test_message, pub)) == test_message |
||||||
@ -0,0 +1,224 @@ |
|||||||
|
#! /usr/bin/env python |
||||||
|
from __future__ import (absolute_import, division, |
||||||
|
print_function, unicode_literals) |
||||||
|
from builtins import * # noqa: F401 |
||||||
|
|
||||||
|
import sys |
||||||
|
import binascii |
||||||
|
|
||||||
|
import jmbitcoin as btc |
||||||
|
from jmclient.configure import get_p2pk_vbyte, 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), |
||||||
|
key_type=self.wallet_service.TYPE) |
||||||
|
|
||||||
|
|
||||||
|
# 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 |
||||||
|
|
||||||
@ -0,0 +1,118 @@ |
|||||||
|
#! /usr/bin/env python |
||||||
|
from __future__ import (absolute_import, division, |
||||||
|
print_function, unicode_literals) |
||||||
|
from builtins import * # noqa: F401 |
||||||
|
'''Test of unusual transaction types creation and push to |
||||||
|
network to check validity.''' |
||||||
|
|
||||||
|
import binascii |
||||||
|
from commontest import make_wallets, dummy_accept_callback, dummy_info_callback |
||||||
|
|
||||||
|
import jmbitcoin as btc |
||||||
|
import pytest |
||||||
|
from jmbase import get_log, bintohex, hextobin |
||||||
|
from jmclient import (load_test_config, jm_single, |
||||||
|
estimate_tx_fee, SNICKERReceiver, direct_send) |
||||||
|
|
||||||
|
log = get_log() |
||||||
|
|
||||||
|
@pytest.mark.parametrize( |
||||||
|
"nw, wallet_structures, mean_amt, sdev_amt, amt, net_transfer", [ |
||||||
|
(2, [[1, 0, 0, 0, 0]] * 2, 4, 0, 20000000, 1000), |
||||||
|
]) |
||||||
|
def test_snicker_e2e(setup_snicker, nw, wallet_structures, |
||||||
|
mean_amt, sdev_amt, amt, net_transfer): |
||||||
|
""" Test strategy: |
||||||
|
1. create two wallets. |
||||||
|
2. with wallet 1 (Receiver), create a single transaction |
||||||
|
tx1, from mixdepth 0 to 1. |
||||||
|
3. with wallet 2 (Proposer), take pubkey of all inputs from tx1, and use |
||||||
|
them to create snicker proposals to the non-change out of tx1, |
||||||
|
in base64 and place in proposals.txt. |
||||||
|
4. Receiver polls for proposals in the file manually (instead of twisted |
||||||
|
LoopingCall) and processes them. |
||||||
|
5. Check for valid final transaction with broadcast. |
||||||
|
""" |
||||||
|
wallets = make_wallets(nw, wallet_structures, mean_amt, sdev_amt) |
||||||
|
for w in wallets.values(): |
||||||
|
w['wallet'].sync_wallet(fast=True) |
||||||
|
print(wallets) |
||||||
|
wallet_r = wallets[0]['wallet'] |
||||||
|
wallet_p = wallets[1]['wallet'] |
||||||
|
# next, create a tx from the receiver wallet |
||||||
|
our_destn_script = wallet_r.get_new_script(1, 1) |
||||||
|
tx = direct_send(wallet_r, btc.coins_to_satoshi(0.3), 0, |
||||||
|
wallet_r.script_to_addr(our_destn_script), |
||||||
|
accept_callback=dummy_accept_callback, |
||||||
|
info_callback=dummy_info_callback, |
||||||
|
return_transaction=True) |
||||||
|
|
||||||
|
assert tx, "Failed to spend from receiver wallet" |
||||||
|
print("Parent transaction OK. It was: ") |
||||||
|
print(tx) |
||||||
|
wallet_r.process_new_tx(tx) |
||||||
|
# we must identify the receiver's output we're going to use; |
||||||
|
# it can be destination or change, that's up to the proposer |
||||||
|
# to guess successfully; here we'll just choose index 0. |
||||||
|
txid1 = tx.GetTxid()[::-1] |
||||||
|
txid1_index = 0 |
||||||
|
|
||||||
|
receiver_start_bal = sum([x['value'] for x in wallet_r.get_all_utxos( |
||||||
|
).values()]) |
||||||
|
|
||||||
|
# Now create a proposal for every input index in tx1 |
||||||
|
# (version 1 proposals mean we source keys from the/an |
||||||
|
# ancestor transaction) |
||||||
|
propose_keys = [] |
||||||
|
for i in range(len(tx.vin)): |
||||||
|
# todo check access to pubkey |
||||||
|
sig, pub = [a for a in iter(tx.wit.vtxinwit[i].scriptWitness)] |
||||||
|
propose_keys.append(pub) |
||||||
|
# the proposer wallet needs to choose a single |
||||||
|
# utxo that is bigger than the output amount of tx1 |
||||||
|
prop_m_utxos = wallet_p.get_utxos_by_mixdepth()[0] |
||||||
|
prop_utxo = prop_m_utxos[list(prop_m_utxos)[0]] |
||||||
|
# get the private key for that utxo |
||||||
|
priv = wallet_p.get_key_from_addr( |
||||||
|
wallet_p.script_to_addr(prop_utxo['script'])) |
||||||
|
prop_input_amt = prop_utxo['value'] |
||||||
|
# construct the arguments for the snicker proposal: |
||||||
|
our_input = list(prop_m_utxos)[0] # should be (txid, index) |
||||||
|
their_input = (txid1, txid1_index) |
||||||
|
our_input_utxo = btc.CMutableTxOut(prop_utxo['value'], |
||||||
|
prop_utxo['script']) |
||||||
|
fee_est = estimate_tx_fee(len(tx.vin), 2) |
||||||
|
change_spk = wallet_p.get_new_script(0, 1) |
||||||
|
|
||||||
|
encrypted_proposals = [] |
||||||
|
|
||||||
|
for p in propose_keys: |
||||||
|
# TODO: this can be a loop over all outputs, |
||||||
|
# not just one guessed output, if desired. |
||||||
|
encrypted_proposals.append( |
||||||
|
wallet_p.create_snicker_proposal( |
||||||
|
our_input, their_input, |
||||||
|
our_input_utxo, |
||||||
|
tx.vout[txid1_index], |
||||||
|
net_transfer, |
||||||
|
fee_est, |
||||||
|
priv, |
||||||
|
p, |
||||||
|
prop_utxo['script'], |
||||||
|
change_spk, |
||||||
|
version_byte=1) + b"," + bintohex(p).encode('utf-8')) |
||||||
|
with open("test_proposals.txt", "wb") as f: |
||||||
|
f.write(b"\n".join(encrypted_proposals)) |
||||||
|
sR = SNICKERReceiver(wallet_r) |
||||||
|
sR.proposals_source = "test_proposals.txt" # avoid clashing with mainnet |
||||||
|
sR.poll_for_proposals() |
||||||
|
assert len(sR.successful_txs) == 1 |
||||||
|
wallet_r.process_new_tx(sR.successful_txs[0]) |
||||||
|
end_utxos = wallet_r.get_all_utxos() |
||||||
|
print("At end the receiver has these utxos: ", end_utxos) |
||||||
|
receiver_end_bal = sum([x['value'] for x in end_utxos.values()]) |
||||||
|
assert receiver_end_bal == receiver_start_bal + net_transfer |
||||||
|
|
||||||
|
@pytest.fixture(scope="module") |
||||||
|
def setup_snicker(): |
||||||
|
load_test_config() |
||||||
Loading…
Reference in new issue