Skip to content

Commit fdc2c1f

Browse files
committed
examples: Add salvage_webwallet.py.
This script is meant to enable someone who has an xpriv from an old copay wallet to move their funds to a new wallet.
1 parent 280dc57 commit fdc2c1f

File tree

1 file changed

+300
-0
lines changed

1 file changed

+300
-0
lines changed
Lines changed: 300 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,300 @@
1+
#!/usr/bin/env python3
2+
"""
3+
Copyright (c) 2020, The Decred developers
4+
5+
This example script will dump all funds located in an account derived from the
6+
supplied xpriv seed to a specified address. It is meant to be used for the
7+
purpose of salvaging funds from an old copay wallet.
8+
9+
dcrd must be running on the correct network with txindex and addrindex enabled
10+
in dcrd.conf. rpcuser and rpcpass must also be present in the config file.
11+
"""
12+
import os
13+
from urllib.parse import urlunsplit
14+
15+
from base58 import b58decode
16+
from decred import DecredError
17+
from decred.crypto import crypto
18+
from decred.crypto.secp256k1.curve import curve as Curve
19+
from decred.dcr import account, addrlib, nets, rpc, txscript
20+
from decred.dcr.wire import msgtx, wire
21+
from decred.util import helpers
22+
from decred.util.encode import ByteArray
23+
24+
25+
SERIALIZED_KEY_LENGTH = 4 + 1 + 4 + 4 + 32 + 33 # 78 bytes
26+
INTERNAL = 0
27+
EXTERNAL = 1
28+
FEE_RATE = 10000
29+
GAP_LIMIT = 20
30+
ACCT_NUM = 0
31+
COIN_TYPE_OLD = 20
32+
COIN_TYPE_TESTNET = 1
33+
PURPOSE = 44
34+
35+
36+
def cfg(isTestnet):
37+
dcrdCfgDir = helpers.appDataDir("dcrd")
38+
cfgPath = os.path.join(dcrdCfgDir, "dcrd.conf")
39+
if not os.path.isfile(cfgPath):
40+
return None
41+
cfg = helpers.readINI(cfgPath, ["rpcuser", "rpcpass", "rpccert", "addrindex", "txindex"])
42+
assert "rpcuser" in cfg
43+
assert "rpcpass" in cfg
44+
if "addrindex" not in cfg or cfg["addrindex"] not in ("1", "true"):
45+
raise DecredError("addrindex must be enabled")
46+
if "txindex" not in cfg or cfg["txindex"] not in ("1", "true"):
47+
raise DecredError("txindex must be enabled")
48+
if "rpccert" not in cfg:
49+
cfg["rpccert"] = os.path.join(dcrdCfgDir, "rpc.cert")
50+
if "rpclisten" not in cfg:
51+
cfg["rpclisten"] = "localhost:9109"
52+
if isTestnet:
53+
cfg["rpclisten"] = "localhost:19109"
54+
return cfg
55+
56+
57+
def decodeExtendedKey(netParams, key):
58+
"""
59+
Decode an base58 ExtendedKey using the passphrase and network parameters.
60+
61+
Args:
62+
netParams (module): The network parameters.
63+
key (str): Base-58 encoded extended key.
64+
65+
Returns:
66+
ExtendedKey: The decoded key.
67+
"""
68+
decoded = ByteArray(b58decode(key))
69+
decoded_len = len(decoded)
70+
if decoded_len != SERIALIZED_KEY_LENGTH + 4:
71+
raise DecredError(f"decoded private key is wrong length: {decoded_len}")
72+
73+
# The serialized format is:
74+
# version (4) || depth (1) || parent fingerprint (4)) ||
75+
# child num (4) || chain code (32) || key data (33) || checksum (4)
76+
77+
# Split the payload and checksum up and ensure the checksum matches.
78+
payload = decoded[: decoded_len - 4]
79+
included_cksum = decoded[decoded_len - 4 :]
80+
computed_cksum = crypto.checksum(payload.b)[:4]
81+
if included_cksum != computed_cksum:
82+
raise DecredError("wrong checksum")
83+
84+
# Ensure the version encoded in the payload matches the provided network.
85+
privVersion = netParams.HDPrivateKeyID
86+
pubVersion = netParams.HDPublicKeyID
87+
version = payload[:4]
88+
if version not in (privVersion, pubVersion):
89+
raise DecredError(f"Unknown versions {privVersion} {pubVersion} {version}")
90+
91+
# Deserialize the remaining payload fields.
92+
depth = payload[4:5].int()
93+
parentFP = payload[5:9]
94+
childNum = payload[9:13].int()
95+
chainCode = payload[13:45]
96+
keyData = payload[45:78]
97+
98+
# The key data is a private key if it starts with 0x00. Serialized
99+
# compressed pubkeys either start with 0x02 or 0x03.
100+
isPrivate = keyData[0] == 0x00
101+
if isPrivate:
102+
# Ensure the private key is valid. It must be within the range
103+
# of the order of the secp256k1 curve and not be 0.
104+
keyData = keyData[1:]
105+
# if keyNum.Cmp(secp256k1.S256().N) >= 0 || keyNum.Sign() == 0 {
106+
if (keyData >= Curve.N) or keyData.iszero():
107+
raise DecredError("unusable key")
108+
# Ensure the public key parses correctly and is actually on the
109+
# secp256k1 curve.
110+
Curve.publicKey(keyData.int())
111+
112+
return crypto.ExtendedKey(
113+
privVer=privVersion,
114+
pubVer=pubVersion,
115+
key=keyData,
116+
pubKey="",
117+
chainCode=chainCode,
118+
parentFP=parentFP,
119+
depth=depth,
120+
childNum=childNum,
121+
isPrivate=isPrivate,
122+
)
123+
124+
125+
def getUTXOs(node, key, net):
126+
"""Get a list of all unspent utxo paying to the branch within the gap limit."""
127+
idx, txGap = 0, 0
128+
utxos = []
129+
while txGap < GAP_LIMIT:
130+
try:
131+
addr = addrlib.deriveChildAddress(key, idx, net)
132+
except Exception:
133+
# Very small chance of a bad address.
134+
idx += 1
135+
continue
136+
try:
137+
res = node.searchRawTransactions(addr, verbose=True)
138+
for rawTx in res:
139+
for vout in rawTx.vout:
140+
try:
141+
if addr in vout.scriptPubKey.addresses:
142+
privKey = key.child(idx)
143+
# This should throw if the output is spent.
144+
out = node.getTxOut(rawTx.txHash, vout.n)
145+
utxo = {
146+
"privKey": crypto.privKeyFromBytes(privKey.key),
147+
"hash": rawTx.txHash,
148+
"n": vout.n,
149+
"value": out.value,
150+
"script": out.scriptPubKey.script,
151+
}
152+
utxos.append(utxo)
153+
except Exception:
154+
pass
155+
# txs found, reset no txs gap
156+
txGap = 0
157+
except Exception:
158+
# No txs found.
159+
txGap += 1
160+
idx += 1
161+
return utxos
162+
163+
164+
def signUTXOs(node, utxos, sendToAddr, totalValue, net):
165+
"""
166+
Create one trasaction spending all the outputs to the passed address and
167+
sign the inputs.
168+
"""
169+
payToScript = txscript.payToAddrScript(sendToAddr)
170+
output = msgtx.TxOut(value=0, version=0, pkScript=payToScript)
171+
inputs = []
172+
for utxo in utxos:
173+
opCodeClass = txscript.getP2PKHOpCode(utxo["script"])
174+
tree = (
175+
wire.TxTreeRegular
176+
if opCodeClass == txscript.opNonstake
177+
else wire.TxTreeStake
178+
)
179+
op = msgtx.OutPoint(txHash=utxo["hash"], idx=utxo["n"], tree=tree)
180+
txIn = msgtx.TxIn(previousOutPoint=op, valueIn=int(utxo["value"] * 1e8))
181+
inputs.append(txIn)
182+
183+
newTx = msgtx.MsgTx(
184+
serType=wire.TxSerializeFull,
185+
version=txscript.generatedTxVersion,
186+
txIn=inputs,
187+
txOut=[output],
188+
lockTime=0,
189+
expiry=0,
190+
cachedHash=None,
191+
)
192+
193+
size = txscript.estimateSerializeSize(
194+
[txscript.RedeemP2PKHSigScriptSize for _ in inputs], [output], 0
195+
)
196+
fee = txscript.calcMinRequiredTxRelayFee(FEE_RATE, size)
197+
198+
if fee > totalValue:
199+
raise DecredError("Not enough funds to cover the transaction fee.")
200+
201+
output.value = totalValue - int(fee)
202+
203+
if txscript.isDustOutput(output, FEE_RATE):
204+
raise DecredError("Transaction is considered dust. Not sending.")
205+
206+
for idx, utxo in enumerate(utxos):
207+
signatureScript, _, _, _ = txscript.sign(
208+
net,
209+
newTx,
210+
idx,
211+
utxo["script"],
212+
txscript.SigHashAll,
213+
account.KeySource(priv=lambda _: utxo["privKey"], internal=None),
214+
crypto.STEcdsaSecp256k1,
215+
)
216+
newTx.txIn[idx].signatureScript = signatureScript
217+
218+
return newTx
219+
220+
221+
def main():
222+
net = None
223+
isTestnet = False
224+
tString = ""
225+
netStr = input("Is this mainnet or testnet? (m/t)\n")
226+
if netStr in ("testnet", "test", "t"):
227+
net = nets.testnet
228+
isTestnet = True
229+
tString = "t"
230+
elif netStr in ("mainnet", "main", "m"):
231+
net = nets.mainnet
232+
else:
233+
raise DecredError("Unknown network entered.")
234+
235+
xprivStr = input("Enter xpriv: ")
236+
xpriv = decodeExtendedKey(net, xprivStr)
237+
# Double check that we can reproduce the xpriv.
238+
if xpriv.string() != xprivStr:
239+
raise DecredError("unknown xpriv parsing error")
240+
241+
# Printing a newline.
242+
print()
243+
coinType = COIN_TYPE_OLD
244+
if isTestnet:
245+
coinType = COIN_TYPE_TESTNET
246+
purpose = xpriv.child(crypto.HARDENED_KEY_START + PURPOSE)
247+
cointype = purpose.child(crypto.HARDENED_KEY_START + coinType)
248+
acct = cointype.child(crypto.HARDENED_KEY_START + ACCT_NUM)
249+
internal = acct.child(INTERNAL)
250+
external = acct.child(EXTERNAL)
251+
252+
dcrdConfig = cfg(isTestnet)
253+
node = rpc.Client(
254+
urlunsplit(("https", dcrdConfig["rpclisten"], "/", "", "")),
255+
dcrdConfig["rpcuser"],
256+
dcrdConfig["rpcpass"],
257+
dcrdConfig["rpccert"],
258+
)
259+
260+
utxos = getUTXOs(node, internal, net) + getUTXOs(node, external, net)
261+
totalValue = sum((utxo["value"] for utxo in utxos))
262+
263+
if totalValue == 0:
264+
print("No funds found to send.")
265+
return
266+
267+
print(f"Found {len(utxos)} outputs totalling {totalValue} {tString}dcr.\n")
268+
269+
while True:
270+
sendToAddrStr = input("Input address to send funds: ")
271+
print()
272+
try:
273+
# Will throw if bad addr.
274+
sendToAddr = addrlib.decodeAddress(sendToAddrStr, net)
275+
break
276+
except Exception as e:
277+
print(e)
278+
tryAgain = input("\nBad address. Try again? (y/n)\n")
279+
if tryAgain not in ("y", "yes"):
280+
print("Aborted")
281+
return
282+
283+
signedTx = signUTXOs(node, utxos, sendToAddr, int(totalValue * 1e8), net)
284+
285+
# Double check output script address.
286+
_, gotAddrs, _ = txscript.extractPkScriptAddrs(0, signedTx.txOut[0].pkScript, net)
287+
if gotAddrs[0].address() != sendToAddrStr:
288+
raise DecredError("unknown output address parsing error")
289+
290+
print(f"Got the raw hex: {signedTx.serialize().hex()}")
291+
print(f"{repr(signedTx)}\n")
292+
doIt = input(f"Really send funds to {sendToAddrStr}? (y/n)\n")
293+
if doIt in ("yes", "y"):
294+
txid = node.sendRawTransaction(signedTx)
295+
print(f"\nSent transaction: {reversed(txid).hex()}")
296+
else:
297+
print("Aborted.")
298+
299+
300+
main()

0 commit comments

Comments
 (0)