|
| 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