-
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.
multi: Add ledger backend functions. (#3869)
- Loading branch information
1 parent
41b2e23
commit 09a6cbd
Showing
7 changed files
with
644 additions
and
11 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,205 @@ | ||
import TransportWebUSB from "@ledgerhq/hw-transport-webusb"; | ||
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 { | ||
SIGNTX_ATTEMPT, | ||
SIGNTX_FAILED, | ||
SIGNTX_SUCCESS | ||
} from "./ControlActions"; | ||
|
||
const coin = "decred"; | ||
|
||
import * as selectors from "selectors"; | ||
import * as cfgConstants from "constants/config"; | ||
|
||
export const LDG_LEDGER_ENABLED = "LDG_LEDGER_ENABLED"; | ||
export const LDG_WALLET_CLOSED = "LDG_WALLET_CLOSED"; | ||
|
||
// This is an error's message when an app is open but we are trying to get | ||
// device info. | ||
// const DEVICE_ON_DASHBOARD_EXPECTED = "DeviceOnDashboardExpected"; | ||
|
||
// enableLedger only sets a value in the config. Ledger connections are made | ||
// per action then dropped. | ||
export const enableLedger = () => (dispatch, getState) => { | ||
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 }); | ||
|
||
connect()(dispatch, getState); | ||
}; | ||
|
||
export const LDG_CONNECT_ATTEMPT = "LDG_CONNECT_ATTEMPT"; | ||
export const LDG_CONNECT_FAILED = "LDG_CONNECT_FAILED"; | ||
export const LDG_CONNECT_SUCCESS = "LDG_CONNECT_SUCCESS"; | ||
|
||
// connect only checks that a connection does not error, so a device exists and | ||
// is plugged in. | ||
export const connect = () => async (dispatch /*, getState*/) => { | ||
dispatch({ type: LDG_CONNECT_ATTEMPT }); | ||
try { | ||
await doWithTransport(async () => {}); | ||
} catch (error) { | ||
dispatch({ type: LDG_CONNECT_FAILED }); | ||
throw error; | ||
} | ||
dispatch({ type: LDG_CONNECT_SUCCESS }); | ||
}; | ||
|
||
export const LDG_LEDGER_DISABLED = "LDG_LEDGER_DISABLED"; | ||
|
||
// disableLedger disables ledger integration for the current wallet. Note | ||
// that it does **not** disable in the config, so the wallet will restart as a | ||
// ledger wallet next time it's opened. | ||
export const disableLedger = () => (dispatch) => { | ||
dispatch({ type: LDG_LEDGER_DISABLED }); | ||
}; | ||
|
||
export const LDG_NOCONNECTEDDEVICE = "LDG_NOCONNECTEDDEVICE"; | ||
|
||
export const alertNoConnectedDevice = () => (dispatch) => { | ||
dispatch({ type: LDG_NOCONNECTEDDEVICE }); | ||
}; | ||
|
||
// 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 getAddress(path); | ||
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 arg = await ledgerHelpers.signArg( | ||
rawUnsigTx, | ||
chainParams, | ||
walletService, | ||
dispatch | ||
); | ||
|
||
await dispatch(checkLedgerIsDcrwallet()); | ||
const signedRaw = await createTx(arg); | ||
if (signedRaw.message) { | ||
throw signedRaw.message; | ||
} | ||
|
||
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 }); | ||
// TODO: Enable on mainnet. | ||
const isTestnet = true; | ||
try { | ||
const payload = await getPubKey(isTestnet); | ||
const hdpk = ledgerHelpers.fixPubKeyChecksum(payload); | ||
dispatch({ type: LDG_GETWALLETCREATIONMASTERPUBKEY_SUCCESS }); | ||
return hdpk; | ||
} catch (error) { | ||
dispatch({ error, type: LDG_GETWALLETCREATIONMASTERPUBKEY_FAILED }); | ||
throw error; | ||
} | ||
}; | ||
|
||
function doWithTransport(fn) { | ||
return TransportWebUSB.create() | ||
.then((transport) => { | ||
return fn(transport).then((r) => | ||
transport | ||
.close() | ||
.catch((/*e*/) => {}) // throw? | ||
.then(() => r) | ||
); | ||
}) | ||
.catch((e) => { | ||
throw e; | ||
}); | ||
} | ||
|
||
function getAddress(path) { | ||
const fn = async (transport) => { | ||
const btc = new Btc({ transport, currency: coin }); | ||
return await btc.getWalletPublicKey(path, { | ||
verify: false | ||
}); | ||
}; | ||
return doWithTransport(fn); | ||
} | ||
|
||
function getPubKey(isTestnet) { | ||
const fn = async (transport) => { | ||
const btc = new Btc({ transport, currency: coin }); | ||
let hdPublicKeyID = 0x02fda926; // dpub | ||
if (isTestnet) { | ||
hdPublicKeyID = 0x043587d1; // tpub | ||
} | ||
return await btc.getWalletXpub({ | ||
path: "44'/42'/0'", | ||
xpubVersion: hdPublicKeyID | ||
}); | ||
}; | ||
return doWithTransport(fn); | ||
} | ||
|
||
function createTx(arg) { | ||
return doWithTransport((transport) => { | ||
return createTransaction(transport, arg); | ||
}); | ||
} |
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,164 @@ | ||
import { wallet } from "wallet-preload-shim"; | ||
import { strHashToRaw } from "helpers"; | ||
import { default as blake } from "blake-hash"; | ||
import * as bs58 from "bs58"; | ||
import toBuffer from "typedarray-to-buffer"; | ||
import { getTxFromInputs } from "../actions/TransactionActions"; | ||
|
||
export function addressPath(branch, index) { | ||
const prefix = "44'/42'/0'/"; | ||
const i = (index || 0).toString(); | ||
const b = (branch || 0).toString(); | ||
return prefix + b + "/" + i; | ||
} | ||
|
||
// fixPubKeyChecksum replaces the sha256 checksum, or last four bytes, of a | ||
// pubkey with a blake256 checksum. | ||
export function fixPubKeyChecksum(pubKey) { | ||
const buff = bs58.decode(pubKey).slice(0, -4); | ||
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; | ||
} | ||
|
||
function writeUint64LE(n) { | ||
const buff = new Buffer(8); | ||
const lower = 0xffffffff & n; | ||
// bitshift right (>>>) does not seem to throw away the lower half, so | ||
// dividing and throwing away the remainder. | ||
const upper = Math.floor(n / 0xffffffff); | ||
buff.writeUInt32LE(lower, 0); | ||
buff.writeUInt32LE(upper, 4); | ||
return buff; | ||
} | ||
|
||
function inputToTx(tx) { | ||
const inputs = []; | ||
for (const inp of tx.inputs) { | ||
const sequence = writeUint32LE(inp.sequence); | ||
const tree = new Uint8Array(1); | ||
tree[0] = inp.outputTree; | ||
const prevout = new Uint8Array(36); | ||
prevout.set(strHashToRaw(inp.prevTxId), 0); | ||
prevout.set(writeUint32LE(inp.outputIndex), 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.outputs) { | ||
const output = { | ||
amount: writeUint64LE(out.value), | ||
script: out.script | ||
}; | ||
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.outputs.length; | ||
// TODO: Allow more outputs if possible. | ||
if (numOuts > 2) { | ||
throw "more than two outputs is not expected"; | ||
} | ||
let buffLen = 1; | ||
for (const out of tx.outputs) { | ||
buffLen += 11 + out.script.length; | ||
} | ||
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.outputs) { | ||
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; // varInt for 23/25 bytes | ||
i += 1; | ||
buff.set(out.script, i); | ||
i += out.script.length; | ||
} | ||
return toBuffer(buff); | ||
} | ||
|
||
export async function signArg(txHex, chainParams, walletService, dispatch) { | ||
const tx = await wallet.decodeTransactionLocal(txHex, chainParams); | ||
const inputTxs = await dispatch(getTxFromInputs(tx)); | ||
const inputs = []; | ||
const paths = []; | ||
for (const inp of tx.inputs) { | ||
let verboseInp; | ||
for (const it of inputTxs) { | ||
if (it.hash === inp.prevTxId) { | ||
verboseInp = it; | ||
break; | ||
} | ||
} | ||
if (!verboseInp) { | ||
throw "cound not find input"; | ||
} | ||
const prevOut = inputToTx(verboseInp); | ||
const idx = inp.outputIndex; | ||
inputs.push([prevOut, idx]); | ||
const addr = verboseInp.outputs[idx].decodedScript.address; | ||
const val = await wallet.validateAddress(walletService, addr); | ||
const acct = val.accountNumber.toString(); | ||
const branch = val.isInternal ? "1" : "0"; | ||
const index = val.index.toString(); | ||
paths.push("44'/42'/" + acct + "'/" + branch + "/" + index); | ||
} | ||
let changePath = null; | ||
for (const out of tx.outputs) { | ||
const addr = out.decodedScript.address; | ||
const val = await wallet.validateAddress(walletService, addr); | ||
if (!val.isInternal) { | ||
continue; | ||
} // assume the internal address is change | ||
const acct = val.accountNumber.toString(); | ||
const index = val.index.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"] | ||
}; | ||
} |
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
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
Oops, something went wrong.