-
Notifications
You must be signed in to change notification settings - Fork 121
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
actions: Add ledger backend functions.
- Loading branch information
1 parent
587d099
commit 7278544
Showing
3 changed files
with
404 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,170 @@ | ||
import TransportNodeHid from "@ledgerhq/hw-transport-node-hid-singleton"; | ||
import { createTransaction } from "@ledgerhq/hw-app-btc/lib/createTransaction"; | ||
import Btc from "@ledgerhq/hw-app-btc"; | ||
import * as ledgerHelpers from "../helpers/ledger"; | ||
import { wallet } from "wallet-preload-shim"; | ||
import { publishTransactionAttempt } from "./ControlActions"; | ||
import { hexToBytes } from "helpers"; | ||
import { getTxFromInputs } from "../actions/TransactionActions"; | ||
import { | ||
SIGNTX_ATTEMPT, | ||
SIGNTX_FAILED, | ||
SIGNTX_SUCCESS | ||
} from "./ControlActions"; | ||
|
||
import * as selectors from "selectors"; | ||
import * as cfgConstants from "constants/config"; | ||
|
||
export const LDG_LEDGER_ENABLED = "LDG_LEDGER_ENABLED"; | ||
|
||
const coin = "decred"; | ||
|
||
// enableLedger only sets a value in the config. Ledger connections are made | ||
// per action then dropped. | ||
export const enableLedger = () => (dispatch, getState) => { | ||
// TODO: Enable on mainnet. | ||
const isTestnet = selectors.isTestNet(getState()); | ||
if (!isTestnet) throw "ledger is currently under development and should only be used on testnet"; | ||
|
||
const walletName = selectors.getWalletName(getState()); | ||
|
||
if (walletName) { | ||
const config = wallet.getWalletCfg( | ||
selectors.isTestNet(getState()), | ||
walletName | ||
); | ||
config.set(cfgConstants.LEDGER, true); | ||
} | ||
|
||
dispatch({ type: LDG_LEDGER_ENABLED }); | ||
}; | ||
|
||
// deviceRun is the main function for executing ledger operations. This handles | ||
// cleanup for cancellations and device disconnections during mid-operation (eg: | ||
// someone disconnected ledger while it was waiting for a pin input). | ||
// In general, fn itself shouldn't handle errors, letting this function handle | ||
// the common cases, which are then propagated up the call stack into fn's | ||
// parent. | ||
async function deviceRun(dispatch, getState, fn) { | ||
const handleError = (error) => { | ||
// TODO: Special error checking such as device not connected and device on | ||
// wrong screen for action. | ||
return error; | ||
}; | ||
|
||
try { | ||
// TODO: Enable on mainnet. | ||
const isTestnet = selectors.isTestNet(getState()); | ||
if (!isTestnet) throw "ledger is currently under development and should only be used on testnet"; | ||
const transport = await TransportNodeHid.create(); | ||
const res = await fn(transport); | ||
if (res && res.error) throw handleError(res.error); | ||
return res; | ||
} catch (error) { | ||
throw handleError(error); | ||
} | ||
} | ||
|
||
// checkLedgerIsDcrwallet verifies whether the wallet currently running on | ||
// dcrwallet (presumably a watch only wallet created from a ledger provided | ||
// xpub) is the same wallet as the one of the currently connected ledger. This | ||
// function throws an error if they are not the same. | ||
// This is useful for making sure, prior to performing some wallet related | ||
// function such as transaction signing, that ledger will correctly perform the | ||
// operation. | ||
// Note that this might trigger pin/passphrase modals, depending on the current | ||
// ledger configuration. | ||
// The way the check is performed is by generating the first address from the | ||
// ledger wallet and then validating this address agains dcrwallet, ensuring | ||
// this is an owned address at the appropriate branch/index. | ||
// This check is only valid for a single session (ie, a single execution of | ||
// `deviceRun`) as the physical device might change between sessions. | ||
const checkLedgerIsDcrwallet = () => async (dispatch, getState) => { | ||
const { | ||
grpc: { walletService } | ||
} = getState(); | ||
|
||
const path = ledgerHelpers.addressPath(0, 0); | ||
const payload = await deviceRun(dispatch, getState, async (transport) => { | ||
const btc = new Btc({ transport, currency: coin }); | ||
const res = await btc.getWalletPublicKey(path, { | ||
verify: false | ||
}); | ||
return res; | ||
}); | ||
const addr = payload.bitcoinAddress; | ||
|
||
const addrValidResp = await wallet.validateAddress(walletService, addr); | ||
if (!addrValidResp.isValid) | ||
throw "Ledger provided an invalid address " + addr; | ||
|
||
if (!addrValidResp.isMine) | ||
throw "Ledger and dcrwallet not running from the same extended public key"; | ||
|
||
if (addrValidResp.index !== 0) throw "Wallet replied with wrong index."; | ||
}; | ||
|
||
export const signTransactionAttemptLedger = | ||
(rawUnsigTx) => async (dispatch, getState) => { | ||
dispatch({ type: SIGNTX_ATTEMPT }); | ||
|
||
const { | ||
grpc: { walletService } | ||
} = getState(); | ||
const chainParams = selectors.chainParams(getState()); | ||
|
||
try { | ||
const decodedUnsigTxResp = wallet.decodeRawTransaction( | ||
Buffer.from(rawUnsigTx, "hex"), | ||
chainParams | ||
); | ||
const getInputs = (tx) => { | ||
return async () => { | ||
return await dispatch(getTxFromInputs(tx)); | ||
}; | ||
}; | ||
const arg = await ledgerHelpers.signArgs(decodedUnsigTxResp, wallet, walletService, getInputs); | ||
|
||
const signedRaw = await deviceRun(dispatch, getState, async (transport) => { | ||
await dispatch(checkLedgerIsDcrwallet()); | ||
|
||
const res = await createTransaction(transport, arg); | ||
return res; | ||
}); | ||
|
||
dispatch({ type: SIGNTX_SUCCESS }); | ||
dispatch(publishTransactionAttempt(hexToBytes(signedRaw))); | ||
} catch (error) { | ||
dispatch({ error, type: SIGNTX_FAILED }); | ||
} | ||
}; | ||
|
||
export const LDG_GETWALLETCREATIONMASTERPUBKEY_ATTEMPT = | ||
"LDG_GETWALLETCREATIONMASTERPUBKEY_ATTEMPT"; | ||
export const LDG_GETWALLETCREATIONMASTERPUBKEY_FAILED = | ||
"LDG_GETWALLETCREATIONMASTERPUBKEY_FAILED"; | ||
export const LDG_GETWALLETCREATIONMASTERPUBKEY_SUCCESS = | ||
"LDG_GETWALLETCREATIONMASTERPUBKEY_SUCCESS"; | ||
|
||
export const getWalletCreationMasterPubKey = | ||
() => async (dispatch, getState) => { | ||
dispatch({ type: LDG_GETWALLETCREATIONMASTERPUBKEY_ATTEMPT }); | ||
try { | ||
|
||
const payload = await deviceRun(dispatch, getState, async (transport) => { | ||
const btc = new Btc({ transport, currency: "decred" }); | ||
const res = await btc.getWalletPublicKey("44'/42'/0'", { | ||
verify: false | ||
}); | ||
return res; | ||
}); | ||
const hdpk = ledgerHelpers.pubkeyToHDPubkey(payload.publicKey, payload.chainCode); | ||
|
||
dispatch({ type: LDG_GETWALLETCREATIONMASTERPUBKEY_SUCCESS }); | ||
|
||
return hdpk; | ||
} catch (error) { | ||
dispatch({ error, type: LDG_GETWALLETCREATIONMASTERPUBKEY_FAILED }); | ||
throw error; | ||
} | ||
}; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,167 @@ | ||
import { hexToBytes, strHashToRaw } from "helpers"; | ||
import * as secp256k1 from"secp256k1"; | ||
import { default as blake } from "blake-hash"; | ||
import * as bs58 from "bs58"; | ||
import toBuffer from "typedarray-to-buffer"; | ||
|
||
export function addressPath (branch, index) { | ||
const prefix = "44'/42'/0'/"; | ||
const i = (index || 0).toString(); | ||
const b = (branch || 0).toString(); | ||
return prefix + b + "/" + i; | ||
} | ||
|
||
export function pubkeyToHDPubkey(pubkey, chainCode, isTestnet) { | ||
const pk = secp256k1.publicKeyConvert(hexToBytes(pubkey), true); // from uncompressed to compressed | ||
const cc = hexToBytes(chainCode); | ||
let hdPublicKeyID = hexToBytes("02fda926"); // dpub | ||
if (isTestnet) { | ||
hdPublicKeyID = hexToBytes("043587d1"); // tpub | ||
} | ||
const parentFP = hexToBytes("00000000"); // not true but we dont know the fingerprint | ||
const childNum = hexToBytes("80000000"); // always first hardened child | ||
const depth = 2; // account is depth 2 | ||
const buff = new Uint8Array(78); // 4 network identifier + 1 depth + 4 parent fingerprint + 4 child number + 32 chain code + 33 compressed public key | ||
let i = 0; | ||
buff.set(hdPublicKeyID, i); | ||
i += 4; | ||
buff[i] = depth; | ||
i += 1; | ||
buff.set(parentFP, i); | ||
i += 4; | ||
buff.set(childNum, i); | ||
i += 4; | ||
buff.set(cc, i); | ||
i += 32; | ||
buff.set(pk, i); | ||
const firstPass = blake("blake256").update(Buffer.from(buff)).digest(); | ||
const secondPass = blake("blake256").update(firstPass).digest(); | ||
const fullSerialize = Buffer.concat([Buffer.from(buff), secondPass.slice(0, 4)]); | ||
return bs58.encode(fullSerialize); | ||
} | ||
|
||
function writeUint16LE(n) { | ||
const buff = new Buffer(2); | ||
buff.writeUInt16LE(n, 0); | ||
return buff; | ||
} | ||
|
||
function writeUint32LE(n) { | ||
const buff = new Buffer(4); | ||
buff.writeUInt32LE(n, 0); | ||
return buff; | ||
} | ||
|
||
/* global BigInt */ | ||
function writeUint64LE(n) { | ||
const buff = new Buffer(8); | ||
buff.writeBigUInt64LE( BigInt(n), 0); | ||
return buff; | ||
} | ||
|
||
function inputToTx(tx) { | ||
const inputs = []; | ||
for (const inp of tx.inputsList) { | ||
const sequence = writeUint32LE(inp.sequence); | ||
const tree = new Uint8Array(1); | ||
tree[0] = inp.tree; | ||
const prevout = new Uint8Array(36); | ||
prevout.set(strHashToRaw(inp.previousTransactionHash), 0); | ||
prevout.set(writeUint32LE(inp.previousTransactionIndex), 32); | ||
const input = { | ||
prevout: toBuffer(prevout), | ||
script: toBuffer(new Uint8Array(25)), | ||
sequence: sequence, | ||
tree: toBuffer(tree) | ||
}; | ||
inputs.push(input); | ||
} | ||
const outputs = []; | ||
for (const out of tx.outputsList) { | ||
const output = { | ||
amount: writeUint64LE(out.value), | ||
script: Buffer.from(out.script, "hex") | ||
}; | ||
outputs.push(output); | ||
} | ||
return { | ||
version: writeUint32LE(tx.version), // Pretty sure this is a uint16 but ledger does not want that. | ||
inputs: inputs, | ||
outputs: outputs, | ||
locktime: writeUint32LE(tx.lockTime), | ||
nExpiryHeight: writeUint32LE(tx.expiry) | ||
}; | ||
} | ||
|
||
function createPrefix(tx) { | ||
const numOuts = tx.outputsList.length; | ||
if (numOuts > 2) { throw "more than two outputs is not expected"; } | ||
let buffLen = 1; | ||
for ( const out of tx.outputsList) { | ||
buffLen += 11 + out.script.length / 2; // script in hex atm | ||
} | ||
const buff = new Uint8Array(buffLen); // 1 varInt + ( 8 value + 2 tx version + 1 varInt + (23/25?) variable script length) * number of outputs | ||
let i = 0; | ||
buff[i] = numOuts; | ||
i += 1; | ||
for ( const out of tx.outputsList) { | ||
buff.set(writeUint64LE(out.value), i); | ||
i += 8; | ||
buff.set(writeUint16LE(out.version), i); | ||
i += 2; | ||
// TODO: Clean this up for production? Should use smarter logic to get varInts. | ||
buff[i] = out.script.length / 2; // varInt for 23/25 bytes | ||
i += 1; | ||
buff.set(Buffer.from(out.script, "hex"), i); | ||
i += out.script.length / 2; | ||
} | ||
return toBuffer(buff); | ||
} | ||
|
||
export async function signArg(txHex, wallet, walletService, getTxFromInputs) { | ||
const tx = await wallet.decodeTransaction(walletService, txHex); | ||
const inputTxs = await getTxFromInputs(tx); | ||
const inputs = []; | ||
const paths = []; | ||
let i = 0; | ||
for ( const inp of inputTxs ) { | ||
const prevOut = inputToTx(inp); | ||
const idx = tx.inputsList[i].previousTransactionIndex; | ||
inputs.push([prevOut, idx]); | ||
const addrs = inp.outputsList[idx].addressesList; | ||
if (addrs.length != 1) throw "unexpected spending from multisig"; | ||
const val = await wallet.validateAddress(walletService, addrs[0]); | ||
const acct = val.getAccountNumber().toString(); | ||
const branch = val.getIsInternal() ? "1" : "0"; | ||
const index = val.getIndex().toString(); | ||
paths.push("44'/42'/" + acct + "'/" + branch + "/" + index); | ||
i++; | ||
} | ||
let changePath = null; | ||
for ( const out of tx.outputsList ) { | ||
if (out.addressesList.length != 1) { continue; } | ||
const addr = out.addressesList[0]; | ||
const val = await wallet.validateAddress(walletService, addr); | ||
if (!val.getIsInternal()) { continue; } // assume the internal address is change | ||
const acct = val.getAccountNumber().toString(); | ||
const index = val.getIndex().toString(); | ||
changePath = "44'/42'/" + acct + "'/1/" + index; | ||
break; | ||
} | ||
|
||
return { | ||
inputs: inputs, | ||
associatedKeysets: paths, | ||
changePath: changePath, | ||
outputScriptHex: createPrefix(tx), | ||
lockTime: tx.lockTime, | ||
sigHashType: 1, // SIGHASH_ALL | ||
segwit: false, | ||
expiryHeight: writeUint32LE(tx.expiry), | ||
useTrustedInputForSegwit: false, | ||
additionals: ["decred"], | ||
onDeviceStreaming: () => {}, | ||
onDeviceSignatureGranted: () => {}, | ||
onDeviceSignatureRequested: () => {} | ||
}; | ||
} |
Oops, something went wrong.