WIP first pass

This commit is contained in:
Peter D. Gray 2019-06-03 11:11:27 -04:00
commit 4119729536
No known key found for this signature in database
GPG Key ID: F0E6CC6AFC16CF7B
8 changed files with 858 additions and 0 deletions

104
.gitignore vendored Normal file
View File

@ -0,0 +1,104 @@
# 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/

21
LICENSE Normal file
View File

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2019 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.

45
README.md Normal file
View File

@ -0,0 +1,45 @@
# PSBT Recovery
A simple program to create a PSBT from various keypaths related to
a Coldcard. Searches for UTXO using <https://Blockstream.info/> API calls.
Typical use will use the "public.txt" file exported from a Coldcard.
For now, it only handles addresses explicitly listed in that file (first five)
but could be improved easily to search more broadly to deposits to a Coldcard.
## Usage
```
# python3 -m pip install --editable .
# rehash
# pbst_recovery data/example.psbt
```
## Requirements
- `python3`
- `pycoin` version 0.80
- `click`
(See `requirements.txt`)
## Input Data
On your Coldcard, go to Advanced > Micro SD > Dump Summary.
Take the "public.txt" file it makes and feed it to this program.
Lines of this form:
m/0'/0'/2' => mtCWD93LGCbKydRh4CVoZkk5yryaprtCFN
Are searched on the Blockchain... Any UTXO found will be added to the PSBT.
# Example Usage
```
% psbt_recory example-public.txt output.psbt mtHSVByP9EYZmB26jASDdPVm19gvpecb5R
... (lots of good output) ...
```

124
example-public.txt Normal file
View File

@ -0,0 +1,124 @@
# Coldcard Wallet Summary File
## For wallet with master key fingerprint: 4369050F
### Wallet operates on blockchain: Bitcoin Testnet
For BIP44, this is coin_type '1', and internally we use symbol XTN for this blockchain.
## Top-level, 'master' extended public key ('m/'):
tpubD6NzVbkrYhZ4XzL5Dhayo67Gorv1YMS7j8pRUvVMd5odC2LBPLAygka9p7748JtSq82FNGPppFEz5xxZUdasBRCqJqXvUHq6xpnsMcYJzeh
Derived public keys, as may be needed for different systems:
## For Bitcoin Core: m/{account}'/{change}'/{idx}'
m => tpubD6NzVbkrYhZ4XzL5Dhayo67Gorv1YMS7j8pRUvVMd5odC2LBPLAygka9p7748JtSq82FNGPppFEz5xxZUdasBRCqJqXvUHq6xpnsMcYJzeh
... first 5 receive addresses (account=0, change=0):
m/0'/0'/0' => n3ieqYKgVR8oB2zsHVX1Pr7Zc31pP3C7ZJ
m/0'/0'/1' => mxdBZ5va2Z2kZUjPUPXizXNgPRYnoe2arJ
m/0'/0'/2' => mtCWD93LGCbKydRh4CVoZkk5yryaprtCFN
m/0'/0'/3' => mo92QdcrfszH2kg22veey17Sm49d597hAh
m/0'/0'/4' => mvUVep7Put4LpEZjHWEYsvXKUx6kKJg76K
## For Bitcoin Core (Segregated Witness, P2PKH): m/{account}'/{change}'/{idx}'
m => tpubD6NzVbkrYhZ4XzL5Dhayo67Gorv1YMS7j8pRUvVMd5odC2LBPLAygka9p7748JtSq82FNGPppFEz5xxZUdasBRCqJqXvUHq6xpnsMcYJzeh
# SLIP-132 style
m => vpub5SLqN2bLY4WeZbkz6o2eAzau8oXTfix4MHBgZaeSXAHyLY31onzmJBb3Xf76bzdNDwFjFjgqMphqdDEpeNubhWPLWZPmcwXtqR5ApPBKwYE
... first 5 receive addresses (account=0, change=0):
m/0'/0'/0' => tb1q7wyjlvslzczhvn5splzvxvqtqpq4ut38e3x9q3
m/0'/0'/1' => tb1qhwnm8j2fmvtpatny5zw6qm6ve2ma39u50qffpy
m/0'/0'/2' => tb1q3vw9lxvhm8e5fcznkdqw45jmeg5vhtkd6j6uf5
m/0'/0'/3' => tb1q2wd5nmlhv2pg5a0x4edcxwt4qnsq9pxr2alpnp
m/0'/0'/4' => tb1q5sfdptg93zp9exhywzy9382x65dyns34fgnlqz
## For Electrum (not BIP44): m/{change}/{idx}
m => tpubD6NzVbkrYhZ4XzL5Dhayo67Gorv1YMS7j8pRUvVMd5odC2LBPLAygka9p7748JtSq82FNGPppFEz5xxZUdasBRCqJqXvUHq6xpnsMcYJzeh
... first 5 receive addresses (account=0, change=0):
m/0/0 => mgXtK5HnvpgAM2tUvbwZ8z1YbouTCe1hW5
m/0/1 => msRDCUoNLzVW9xdY7WQ36v8PfgBzdCRfY4
m/0/2 => mh7finD8ctq159hbRzAeevSuFBJ1NQjoH2
m/0/3 => mjcHcR28EC9EUjwvJuHP4soipwsHTRupHR
m/0/4 => myuziudzi7Vbh2XZLV29jwq6bfhwYqdbLJ
## For BIP44 / Electrum: m/44'/1'/{account}'/{change}/{idx}
m/44'/1' => tpubDBhBG3fyUsfqeSraAkP2s7zCioQZpgCVKvnWJJKqAcUowSL4uVkoHD2mcskLvkk4Eiim72ETM8btRE44aCw5iM8ChHz4jCucF5zJoSX1LXw
... first 5 receive addresses (account=0, change=0):
m/44'/1'/0'/0/0 => mtHSVByP9EYZmB26jASDdPVm19gvpecb5R
m/44'/1'/0'/0/1 => mi4nT9xGY7MBA9pUbZF5WFAt5ww4HFgsc8
m/44'/1'/0'/0/2 => n46twT2B5bJYBVqoPAEz6mVS3TbCFX8gSc
m/44'/1'/0'/0/3 => mspfdREbMeeHapVr9xF7vC67h9L4e6jUMD
m/44'/1'/0'/0/4 => mvPGoSq5NJ4qDS5mEFKR4ay35du4e33qLe
## For BIP49 (P2WPKH-nested-in-P2SH): m/49'/1'/{account}'/{change}/{idx}
m/49'/1' => tpubDB2bRjQ6fX2ermfaUw6BTNKTqVAuKHw5ejUqNibuqVsACw85qVcXn4eT9BsLSeLqeD3ZYdJwKodeTwcj5Tivtsovb1GxFB88ghAAaQFyWUm
# SLIP-132 style
m/49'/1' => upub5CAAzVZfWCSm35uNXfkDdBhazTduW3TXMmKsfys7MZydJM1h1JGkmS1CqXunvRRqdPAEgd1PQijx7uHRXWdecjJpvPSNov1SHZNpegZz8jw
... first 5 receive addresses (account=0, change=0):
m/49'/1'/0'/0/0 => 2NCAJ5wD4GvmW32GFLVybKPNphNU8UYoEJv
m/49'/1'/0'/0/1 => 2MtaCqkBbyweyjESfmfXTRSGjpyiBhZyqCx
m/49'/1'/0'/0/2 => 2MxTuZhWZoyfeHKYbvZ8zFcoiyARD6htq8P
m/49'/1'/0'/0/3 => 2N7icQTg3FttDTyczYhLe49ijiiTL4YGwyv
m/49'/1'/0'/0/4 => 2Mti6y2Vd4XHNbBJ4FUaWNg3XtjbQiJGK88
## For BIP84 (Native Segwit P2PKH): m/84'/1'/{account}'/{change}/{idx}
m/84'/1' => tpubDBRJZLrRukCbMNnJKKvcdK4MzqQVJfqwJFzLTvmBsQ9VutQoyY6Lz37jrfRCVehAJhdAtMzDQFZkrHmD9ftzDwYLeMXL7F3rjY8AijtG6jf
# SLIP-132 style
m/84'/1' => vpub5XP9Rmguu7ABNzDDCRNH1DXzKn1wS3MsvQMbYavGmUdr4Q7ePzv8bU8daDREyLS5hWremqHDwq2cPY3UKRDik2iqr5PBFtkec8QUBY9PRme
... first 5 receive addresses (account=0, change=0):
m/84'/1'/0'/0/0 => tb1qupyd58ndsh7lut0et0vtrq432jvu9jtdyws9n9
m/84'/1'/0'/0/1 => tb1qceytj4vfrg22cy7mp5mnfps4ffgseas29mddjp
m/84'/1'/0'/0/2 => tb1qxvzlcjr2djne6n60q0ytzet49mcqumpdqmluzm
m/84'/1'/0'/0/3 => tb1qlr7en2u9xcnd5n2var03u7j3yk4ze07kn72mzs
m/84'/1'/0'/0/4 => tb1qk2gjyuhpvsjqdyrwctkq5ze6dknkapee7qgj6t
# Your Multisig Wallets
Name: Sample-3-15
Policy: 3 of 15
Derivation: 45'
79E279C2: tpubD9h2yEghZWRp4Mvi4MPhyP7ZN8GDqYVRMk6rNf5omds7WTjmRZiok8xgwEP3uXLVbpxVrqnjm4bNXL6tLwHtYF9J7uVSG9u95Yid38fX9dT
B4F08263: tpubD8zYsexbkYEiCbTso12bUsE8Y1CUn3WHjLER3fWqc8mcP7FhDK1Rc6Tixr6v3SQ4XBi5d4bbTskUCxe4eZujkL2cQ3enCDENtBYJYzYuUaR
C7466461: tpubD8yeTfF4L8aCEaQbuPjjzNeyPs2WGJPNWcBMDuDP7NP2VjLBCB5afvfhAg3oTytxvnLXZbMBWyEhs2nt3wmduwSCMotB8RHcxxkvMRtZHrq
3C3C00C9: tpubD9jpJX26AjUzTjCuZb9PfWmKjrSjFzXfNjBFwMY6ckt9qw3m9rpYw3NGD2yZut6UbFuQZm2xttchgchzGjJn26Fu1uZp1tveV1WcmUaXpay
D1790E2F: tpubD8cBsaZfRPmyPGVeThECbc9QSVeMwbPFiSjP9sL18wWvgmr4d5zRKt6Ui4ULRh1upZ2PyEkkYYRpkLm54A9kNTtS6bdyfr1spz2VnKheikt
781FA47B: tpubD9AqW9bXwRSdDeCroC88GD4DGyst1Q3gN4FfdZQRow2zR3ctEMLoVSqGghVKYFk1PhEaGuRxkYSEEL8gxK36JuTCV8Lx8cn7SNQsChcgEEd
C406B677: tpubD99AL1Y6SZd21cfQUYQMn9CZ2qUaLV1TtNeHdiF6zLdP8EiEpKAbfnyjFupZhfacc6SR3GCv3HcTNDaYBPnXkpmefrvwKUmAhfkfiegQ5jP
FBA1CA0D: tpubD8N1Mwa4k3qCBuTBUMRRjhYoinWKV4RL3F7ejGmyqdm6iJJrdkEgwUw8Bme9DKdda4VEPT6BxnaPXkXXtF4Z6Z9HS4zdrwiMHnvkaMRgGVo
AE261795: tpubD9MgmFNaPdLHfFX7bZvyiDir5vM7DL2P8cXAMKNdhrnnUwcj4ts1PpjkXKNZcSBozs9dfnw3TrEYaSPmGbhpkVN6hunUheyBvzmwiD27k9E
FC267DEF: tpubDA2DxRfGUHmsbj3TthZSUyaBYhxgKc2wDMTAmTyN1ZhkTjB6LwFFTL9QCaRKUQKm4sBAxyr5mEdaj5BDK85ERSyb3qAVyrGkC5fJcVhMczu
751D66E4: tpubD9wVkBGpo7g3jpWe7HZ72aHcXawpvb8k7RpfhXP3P8pcKTzXxgbu8zQas4kmRno3LW9n5KL6PoDfiQxEmXu3Dio6dndP8WvZWQoptPvJPNW
6D5C7071: tpubD96VF6RREC5d9oVPgjC2iTDve65yNw3yCGm27bFgstzwS5bT77HJPL6UGtn73qthqvHWq9LmKM6GExum5WL1hVMuY62FuEyU695Pv9rGpny
7605A55D: tpubD8x3oveobb62Fnjt5gqr22PgcBuSHLdYxoQVvZSpmhWeFpSuTyZQ9pWAKVTNxZ4xUsJ3TvdyCZbyE767xkxeXU3i4YPzXeWggMUHvGHhFX9
EF483023: tpubD8X8Rg6iL6cFEFJW6mUyF4mfEVKRJDn4EUWkFg6mrk6XheRuy5zCBk5KPeZVgH1GgWo52nJLJHj4vm5PpJZbSJf5DWFQDwbtv1N1DSH67Fn
4369050F: tpubD8NXmKsmWp3a3DXhbihAYbYLGaRNVdTnr6JoSxxfXYQcmwVtW2hv8QoDwng6JtEonmJoL3cNEwfd2cLXMpGezwZ2vL2dQ7259bueNKj9C8n
---

323
psbt.py Normal file
View File

@ -0,0 +1,323 @@
# (c) Copyright 2018 by Coinkite Inc. This file is part of Coldcard <coldcardwallet.com>
# 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("<BH", 253, l)
elif l < 0x100000000:
r = struct.pack("<BI", 254, l)
else:
r = struct.pack("<BQ", 255, l)
return r
def deser_compact_size(f):
try:
nit = f.read(1)[0]
except IndexError:
return None # end of file
if nit == 253:
nit = struct.unpack("<H", f.read(2))[0]
elif nit == 254:
nit = struct.unpack("<I", f.read(4))[0]
elif nit == 255:
nit = struct.unpack("<Q", f.read(8))[0]
return nit
class PSBTSection:
def __init__(self, fd=None, idx=None):
self.defaults()
self.my_index = idx
if not fd: return
while 1:
ks = deser_compact_size(fd)
if ks is None: break
if ks == 0: break
key = fd.read(ks)
vs = deser_compact_size(fd)
val = fd.read(vs)
kt = key[0]
self.parse_kv(kt, key[1:], val)
def serialize(self, fd, my_idx):
def wr(ktype, val, key=b''):
fd.write(ser_compact_size(1 + len(key)))
fd.write(bytes([ktype]) + key)
fd.write(ser_compact_size(len(val)))
fd.write(val)
self.serialize_kvs(wr)
fd.write(b'\0')
class BasicPSBTInput(PSBTSection):
def defaults(self):
self.utxo = None
self.witness_utxo = None
self.part_sigs = {}
self.sighash = None
self.bip32_paths = {}
self.redeem_script = None
self.witness_script = None
self.others = {}
def __eq__(a, b):
if a.sighash != b.sighash:
if a.sighash is not None and b.sighash is not None:
return False
rv = a.utxo == b.utxo and \
a.witness_utxo == b.witness_utxo and \
a.redeem_script == b.redeem_script and \
a.witness_script == b.witness_script and \
a.my_index == b.my_index and \
a.bip32_paths == b.bip32_paths and \
sorted(a.part_sigs.keys()) == sorted(b.part_sigs.keys())
if rv:
# NOTE: equality test on signatures requires parsing DER stupidness
# and some maybe understanding of R/S values on curve that I don't have.
assert all(parse_signature_blob(a.part_sigs[k])
== parse_signature_blob(b.part_sigs[k]) for k in a.part_sigs)
return rv
def parse_kv(self, kt, key, val):
if kt == PSBT_IN_NON_WITNESS_UTXO:
self.utxo = val
assert not key
elif kt == PSBT_IN_WITNESS_UTXO:
self.witness_utxo = val
assert not key
elif kt == PSBT_IN_PARTIAL_SIG:
self.part_sigs[key] = val
elif kt == PSBT_IN_SIGHASH_TYPE:
assert len(val) == 4
self.sighash = struct.unpack("<I", val)[0]
assert not key
elif kt == PSBT_IN_BIP32_DERIVATION:
self.bip32_paths[key] = val
elif kt == PSBT_IN_REDEEM_SCRIPT:
self.redeem_script = val
assert not key
elif kt == PSBT_IN_WITNESS_SCRIPT:
self.witness_script = val
assert not key
elif kt in ( PSBT_IN_REDEEM_SCRIPT,
PSBT_IN_WITNESS_SCRIPT,
PSBT_IN_FINAL_SCRIPTSIG,
PSBT_IN_FINAL_SCRIPTWITNESS):
assert not key
self.others[kt] = val
else:
raise KeyError(kt)
def serialize_kvs(self, wr):
if self.utxo:
wr(PSBT_IN_NON_WITNESS_UTXO, self.utxo)
if self.witness_utxo:
wr(PSBT_IN_WITNESS_UTXO, self.witness_utxo)
if self.redeem_script:
wr(PSBT_IN_REDEEM_SCRIPT, self.redeem_script)
if self.witness_script:
wr(PSBT_IN_WITNESS_SCRIPT, self.witness_script)
for pk, val in sorted(self.part_sigs.items()):
wr(PSBT_IN_PARTIAL_SIG, val, pk)
if self.sighash is not None:
wr(PSBT_IN_SIGHASH_TYPE, struct.pack('<I', self.sighash))
for k in self.bip32_paths:
wr(PSBT_IN_BIP32_DERIVATION, self.bip32_paths[k], k)
for k in self.others:
wr(k, self.others[k])
class BasicPSBTOutput(PSBTSection):
def defaults(self):
self.redeem_script = None
self.witness_script = None
self.bip32_paths = {}
def __eq__(a, b):
return a.redeem_script == b.redeem_script and \
a.witness_script == b.witness_script and \
a.my_index == b.my_index and \
a.bip32_paths == b.bip32_paths
def parse_kv(self, kt, key, val):
if kt == PSBT_OUT_REDEEM_SCRIPT:
self.redeem_script = val
assert not key
elif kt == PSBT_OUT_WITNESS_SCRIPT:
self.witness_script = val
assert not key
elif kt == PSBT_OUT_BIP32_DERIVATION:
self.bip32_paths[key] = val
else:
raise ValueError(kt)
def serialize_kvs(self, wr):
if self.redeem_script:
wr(PSBT_OUT_REDEEM_SCRIPT, self.redeem_script)
if self.witness_script:
wr(PSBT_OUT_WITNESS_SCRIPT, self.witness_script)
for k in self.bip32_paths:
wr(PSBT_OUT_BIP32_DERIVATION, self.bip32_paths[k], k)
class BasicPSBT:
"Just? parse and store"
def __init__(self):
self.txn = None
self.xpubs = {}
self.inputs = []
self.outputs = []
def __eq__(a, b):
return a.txn == b.txn and \
len(a.inputs) == len(b.inputs) and \
len(a.outputs) == len(b.outputs) and \
all(a.inputs[i] == b.inputs[i] for i in range(len(a.inputs))) and \
all(a.outputs[i] == b.outputs[i] for i in range(len(a.outputs))) and \
sorted(a.xpubs.items()) == sorted(b.xpubs.items())
def parse(self, raw):
# auto-detect and decode Base64 and Hex.
if raw[0:10].lower() == b'70736274ff':
raw = a2b_hex(raw.strip())
if raw[0:6] == b'cHNidP':
raw = b64decode(raw)
assert raw[0:5] == b'psbt\xff', "bad magic"
with io.BytesIO(raw[5:]) as fd:
# globals
while 1:
ks = deser_compact_size(fd)
if ks is None: break
if ks == 0: break
key = fd.read(ks)
vs = deser_compact_size(fd)
val = fd.read(vs)
kt = key[0]
if kt == PSBT_GLOBAL_UNSIGNED_TX:
self.txn = val
t = Tx.parse(io.BytesIO(val))
num_ins = len(t.txs_in)
num_outs = len(t.txs_out)
elif kt == PSBT_GLOBAL_XPUB:
self.xpubs[key[1:]] = val
else:
raise ValueError('unknown global key type: 0x%02x' % kt)
assert self.txn, 'missing reqd section'
self.inputs = [BasicPSBTInput(fd, idx) for idx in range(num_ins)]
self.outputs = [BasicPSBTOutput(fd, idx) for idx in range(num_outs)]
sep = fd.read(1)
assert sep == b''
return self
def serialize(self, fd):
def wr(ktype, val, key=b''):
fd.write(ser_compact_size(1 + len(key)))
fd.write(bytes([ktype]) + key)
fd.write(ser_compact_size(len(val)))
fd.write(val)
fd.write(b'psbt\xff')
wr(PSBT_GLOBAL_UNSIGNED_TX, self.txn)
for k in self.xpubs:
wr(PSBT_GLOBAL_XPUB, self.xpubs[k], key=k)
# sep
fd.write(b'\0')
for idx, inp in enumerate(self.inputs):
inp.serialize(fd, idx)
for idx, outp in enumerate(self.outputs):
outp.serialize(fd, idx)
def as_bytes(self):
with io.BytesIO() as fd:
self.serialize(fd)
return fd.getvalue()
def test_my_psbt():
import glob, io
for fn in glob.glob('data/*.psbt'):
if 'missing_txn.psbt' in fn: continue
if 'unknowns-ins.psbt' in fn: continue
raw = open(fn, 'rb').read()
print("\n\nFILE: %s" % fn)
p = BasicPSBT().parse(raw)
fd = io.BytesIO()
p.serialize(fd)
assert p.txn in fd.getvalue()
chk = BasicPSBT().parse(fd.getvalue())
assert chk == p
# EOF

215
recovery.py Executable file
View File

@ -0,0 +1,215 @@
#!/usr/bin/env python3
#
# To use this, install with:
#
# pip install --editable .
#
# That will create the command "psbt_recover" in your path... or just use "./psbt_recover ..." here
#
#
import click, sys, os, pdb, struct, io, json, re, time
from psbt import BasicPSBT, BasicPSBTInput, BasicPSBTOutput
from pprint import pformat
from binascii import b2a_hex as _b2a_hex
from binascii import a2b_hex
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.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
import urllib.request
b2a_hex = lambda a: str(_b2a_hex(a), 'ascii')
#xfp2hex = lambda a: b2a_hex(a[::-1]).upper()
TESTNET = False
def explora(*parts):
base = 'https://blockstream.info/'
if TESTNET:
base += 'testnet/'
url = f'{base}api/' + '/'.join(parts)
time.sleep(0.1)
with urllib.request.urlopen(url) as response:
return json.load(response)
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('/')
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('public_txt', type=click.File('rt'))
@click.argument('payout_address', type=str)
@click.argument('out_psbt', type=click.File('wb'))
@click.option('--testnet', '-t', help="Assume testnet3 addresses", is_flag=True, default=False)
@click.option('--xfp', '--fingerprint', help="Provide XFP value, otherwise discovered from file", default=None)
def recovery(public_txt, payout_address, out_psbt, testnet, xfp=None):
global TESTNET
TESTNET = testnet
''' Match lines like:
m/0'/0'/0' => n3ieqYKgVR8oB2zsHVX1Pr7Zc31pP3C7ZJ
m/0/2 => mh7finD8ctq159hbRzAeevSuFBJ1NQjoH2
and also
m => tpubD6NzVbkrYhZ4XzL5Dhayo67Gorv1YMS7j8pRUvVMd5odC2LBPLAygka9p7748JtSq82FNGPppFEz5xxZUdasBRCqJqXvUHq6xpnsMcYJzeh
'''
pat_dest = re.compile(r"(m[0-9'/]*)\s+=>\s+(\w+)")
# match pubkeys, including SLIP132 confusion
pat_pk = re.compile(r"(\wpub\w{100,140})")
addrs = []
xpubs = {}
for ln in public_txt:
m = pat_dest.search(ln)
if m:
path, addr = m.group(1), m.group(2)
xp = pat_pk.search(addr)
if xp:
xp = xp.group(1)
if path not in xpubs:
xpubs[path] = xp
elif xpubs[path] != xp:
if xp[0] in 'vVuU':
# slip-132 junk
pass
else:
print(f'Conflict for {path} xpub:\n {xp}\n {xpubs[path]}')
else:
#print(f"{path} => {addr}")
assert path[0:2] == 'm/'
addrs.append( (path, addr) )
if addr.startswith('tb1') and not TESTNET:
print("Looks like TESTNET addresses; switching.")
TESTNET = True
if not xfp:
if 'master key fingerprint: 0x' in ln:
# pre 2.1.0 firmware w/ LE32 value
xfp = int(ln.split(': ')[1], 16)
elif 'master key fingerprint: ' in ln:
# after 2.1.0 firmware w/ BE32 value
xfp, = struct.unpack('>I', a2b_hex(ln.split(': ')[1].strip()))
if xfp:
print("Fingerprint is: " + xfp2str(xfp))
if not addrs:
print("No addresses found!")
sys.exit(1)
print("Found %d xpubs: %s" % (len(xpubs), ' '.join(xpubs)))
print("Found %d addresses. Checking for balances.\n" % len(addrs))
# verify we have enough data
trouble = 0
for path, addr in addrs:
try:
calc_pubkey(xpubs, path)
except AssertionError as exc:
print(str(exc))
trouble += 1
if trouble:
sys.exit(1)
spending = []
amt = 0
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)
calc_pubkey(xpubs, path)
pin.bip32_paths[pubkey] = str2path(xfp, path)
print('%.8f BTC' % (here / 1E8))
amt += here
if len(spending) > 15:
print("Reached practical limit on # of inputs. "
"You'll need to repeat this process again later.")
break
txn = Tx(2,spending,[])
if __name__ == '__main__':
recovery()
# EOF

4
requirements.txt Normal file
View File

@ -0,0 +1,4 @@
click>=6.7
pycoin==0.80

22
setup.py Normal file
View File

@ -0,0 +1,22 @@
# based on <http://click.pocoo.org/5/setuptools/#setuptools-integration>
#
# To use this, install with:
#
# pip install --editable .
from setuptools import setup
setup(
name='psbt_recover',
version='1.0',
py_modules=[],
python_requires='>3.6.0',
install_requires=[
'Click',
],
entry_points='''
[console_scripts]
psbt_recovery=recovery:recovery
''',
)