commit 7f5852ec1770332521b38afecde2f78d055b39ee Author: Peter D. Gray Date: Mon Feb 10 11:46:59 2020 -0500 Framework diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7ec98c1 --- /dev/null +++ b/.gitignore @@ -0,0 +1,110 @@ +public.txt +output.psbt +foo*.psbt +foo*.txt + + +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +.hypothesis/ +.pytest_cache/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# pyenv +.python-version + +# celery beat schedule file +celerybeat-schedule + +# SageMath parsed files +*.sage.py + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..a0b04e0 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2020 Coinkite Inc. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..a8dad23 --- /dev/null +++ b/README.md @@ -0,0 +1,23 @@ +# PSBT Faker + +A simple program to create test PSBT files, that are plausible and self-consistent so +the PSBT-signing tools will sign them. Does not involve any blockchains... completely +made up inputs. + + +## Usage + +``` +# python3 -m pip install --editable . +# rehash +# pbst_faker test.psbt 1destaddr +``` + +## Requirements + +- `python3.6+` +- `pycoin` version 0.80 +- `click` + +(See `requirements.txt`) + diff --git a/main.py b/main.py new file mode 100755 index 0000000..e8cf893 --- /dev/null +++ b/main.py @@ -0,0 +1,177 @@ +#!/usr/bin/env python3 +# +# To use this, install with: +# +# pip install --editable . +# +# That will create the command "psbt_faker" in your path... or just use "./main.py ..." here +# +# +import click, sys, os, pdb, struct, io, json, re, time +from psbt import BasicPSBT, BasicPSBTInput, BasicPSBTOutput +from pprint import pformat, pprint +from binascii import b2a_hex as _b2a_hex +from binascii import a2b_hex +from io import BytesIO +from collections import namedtuple +from base64 import b64encode, b64decode +from pycoin.tx.Tx import Tx +from pycoin.tx.TxOut import TxOut +from pycoin.tx.TxIn import TxIn +from pycoin.ui import standard_tx_out_script +from pycoin.encoding import b2a_hashed_base58, hash160 +from pycoin.serialize import b2h_rev, b2h, h2b, h2b_rev +from pycoin.contrib.segwit_addr import encode as bech32_encode +from pycoin.key.BIP32Node import BIP32Node +from pycoin.convention import tx_fee +import urllib.request + +b2a_hex = lambda a: str(_b2a_hex(a), 'ascii') +#xfp2hex = lambda a: b2a_hex(a[::-1]).upper() + +TESTNET = False + +def str2ipath(s): + # convert text to numeric path for BIP174 + for i in s.split('/'): + if i == 'm': continue + if not i: continue # trailing or duplicated slashes + + if i[-1] in "'ph": + assert len(i) >= 2, i + here = int(i[:-1]) | 0x80000000 + else: + here = int(i) + assert 0 <= here < 0x80000000, here + + yield here + +def xfp2str(xfp): + # Standardized way to show an xpub's fingerprint... it's a 4-byte string + # and not really an integer. Used to show as '0x%08x' but that's wrong endian. + return b2a_hex(struct.pack('>I', xfp)).upper() + +def str2path(xfp, s): + # output binary needed for BIP-174 + p = list(str2ipath(s)) + return struct.pack('<%dI' % (1 + len(p)), xfp, *p) + +def calc_pubkey(xpubs, path): + # given a map of paths to xpubs, and a single path, calculate the pubkey + assert path[0:2] == 'm/' + + hard_prefix = '/'.join(s for s in path.split('/') if s[-1] == "'") + hard_depth = hard_prefix.count('/') + 1 + + want = ('m/'+hard_prefix) if hard_prefix else 'm' + assert want in xpubs, f"Need: {want} to build pubkey of {path}" + + node = BIP32Node.from_hwif(xpubs[want]) + parts = [s for s in path.split('/') if s != 'm'][hard_depth:] + + # node = node.subkey_for_path(path[2:]) + if not parts: + assert want == path + else: + for sk in parts: + node = node.subkey_for_path(sk) + + return node.sec() + + +@click.command() +@click.argument('out_psbt', type=click.File('wb')) +@click.argument('payout_addresses', type=str, nargs='*') +@click.option('--testnet', '-t', help="Assume testnet3 addresses", is_flag=True, default=False) +@click.option('--xpub', help="Provide XPUB value", default=None) +@click.option('--num-change', '-c', help="Number of change outputs", default=1) +@click.option('--xfp', '--fingerprint', help="Provide XFP value, otherwise discovered from xpub", default=None) +def faker(num_change, payout_addresses, out_psbt, testnet, xfp=None, xpub=None): + + global TESTNET + TESTNET = testnet + + ''' Match lines like: + m/0'/0'/0' => n3ieqYKgVR8oB2zsHVX1Pr7Zc31pP3C7ZJ + m/0/2 => mh7finD8ctq159hbRzAeevSuFBJ1NQjoH2 + and also + m => tpubD6NzVbkrYhZ4XzL5Dhayo67Gorv1YMS7j8pRUvVMd5odC2LBPLAygka9p7748JtSq82FNGPppFEz5xxZUdasBRCqJqXvUHq6xpnsMcYJzeh + ''' + + psbt = BasicPSBT() + + for path, addr in addrs: + print(f"addr: {addr} ... ", end='') + + rr = explora('address', addr, 'utxo') + + if not rr: + print('nada') + continue + + here = 0 + for u in rr: + here += u['value'] + + tt = TxIn(h2b_rev(u['txid']), u['vout']) + spending.append(tt) + #print(rr) + + pin = BasicPSBTInput(idx=len(psbt.inputs)) + psbt.inputs.append(pin) + + pubkey = calc_pubkey(xpubs, path) + + pin.bip32_paths[pubkey] = str2path(xfp, path) + + # fetch the UTXO for witness signging + td = explora('tx', u['txid'], 'hex', is_json=False) + outpt = Tx.from_hex(td.decode('ascii')).txs_out[u['vout']] + + with BytesIO() as b: + outpt.stream(b) + pin.witness_utxo = b.getvalue() + + + print('%.8f BTC' % (here / 1E8)) + total += here + + if len(spending) > 15: + print("Reached practical limit on # of inputs. " + "You'll need to repeat this process again later.") + break + + assert total + + print("Found total: %.8f BTC" % (total / 1E8)) + + print("Planning to send to: %s" % payout_address) + + dest_scr = standard_tx_out_script(payout_address) + + txn = Tx(2,spending,[TxOut(total, dest_scr)]) + + fee = tx_fee.recommended_fee_for_tx(txn) + + # placeholder, single output that isn't change + pout = BasicPSBTOutput(idx=0) + psbt.outputs.append(pout) + + print("Guestimate fee: %.8f BTC" % (fee / 1E8)) + + txn.txs_out[0].coin_value -= fee + + # write txn into PSBT + with BytesIO() as b: + txn.stream(b) + psbt.txn = b.getvalue() + + out_psbt.write(psbt.as_bytes()) + + print("PSBT to be signed:\n\n\t" + out_psbt.name, end='\n\n') + + +if __name__ == '__main__': + recovery() + +# EOF diff --git a/psbt.py b/psbt.py new file mode 100644 index 0000000..62afb9c --- /dev/null +++ b/psbt.py @@ -0,0 +1,323 @@ +# (c) Copyright 2018 by Coinkite Inc. This file is part of Coldcard +# and is covered by GPLv3 license found in COPYING. +# +# psbt.py - yet another PSBT parser/serializer but used only for test cases. +# +import io, struct +from binascii import b2a_hex as _b2a_hex +from binascii import a2b_hex as _a2b_hex +from collections import namedtuple +from base64 import b64encode +from pycoin.tx.Tx import Tx +#from pycoin.tx.TxOut import TxOut +#from pycoin.encoding import b2a_hashed_base58, a2b_hashed_base58 +from pycoin.tx.script.check_signature import parse_signature_blob +from binascii import b2a_hex, a2b_hex +from base64 import b64decode + +b2a_hex = lambda a: str(_b2a_hex(a), 'ascii') + +# BIP-174 aka PSBT defined values +# +PSBT_GLOBAL_UNSIGNED_TX = (0) +PSBT_GLOBAL_XPUB = (1) + +PSBT_IN_NON_WITNESS_UTXO = (0) +PSBT_IN_WITNESS_UTXO = (1) +PSBT_IN_PARTIAL_SIG = (2) +PSBT_IN_SIGHASH_TYPE = (3) +PSBT_IN_REDEEM_SCRIPT = (4) +PSBT_IN_WITNESS_SCRIPT = (5) +PSBT_IN_BIP32_DERIVATION = (6) +PSBT_IN_FINAL_SCRIPTSIG = (7) +PSBT_IN_FINAL_SCRIPTWITNESS = (8) + +PSBT_OUT_REDEEM_SCRIPT = (0) +PSBT_OUT_WITNESS_SCRIPT = (1) +PSBT_OUT_BIP32_DERIVATION = (2) + + +# Serialization/deserialization tools +def ser_compact_size(l): + r = b"" + if l < 253: + r = struct.pack("B", l) + elif l < 0x10000: + r = struct.pack("=6.7 + +pycoin==0.80 diff --git a/setup.py b/setup.py new file mode 100644 index 0000000..273a07c --- /dev/null +++ b/setup.py @@ -0,0 +1,22 @@ +# based on +# +# To use this, install with: +# +# pip install --editable . + +from setuptools import setup + +setup( + name='psbt_faker', + version='1.0', + py_modules=[], + python_requires='>3.6.0', + install_requires=[ + 'Click', + ], + entry_points=''' + [console_scripts] + psbt_faker=main:faker + ''', +) +