WIP first pass
This commit is contained in:
commit
4119729536
104
.gitignore
vendored
Normal file
104
.gitignore
vendored
Normal 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
21
LICENSE
Normal 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
45
README.md
Normal 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
124
example-public.txt
Normal 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
323
psbt.py
Normal 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
215
recovery.py
Executable 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
4
requirements.txt
Normal file
@ -0,0 +1,4 @@
|
||||
|
||||
click>=6.7
|
||||
|
||||
pycoin==0.80
|
||||
22
setup.py
Normal file
22
setup.py
Normal 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
|
||||
''',
|
||||
)
|
||||
|
||||
Loading…
Reference in New Issue
Block a user