Skip to content

Commit

Permalink
actions: Add ledger backend functions.
Browse files Browse the repository at this point in the history
  • Loading branch information
JoeGruffins committed May 4, 2023
1 parent 587d099 commit 7278544
Show file tree
Hide file tree
Showing 3 changed files with 404 additions and 0 deletions.
170 changes: 170 additions & 0 deletions app/actions/LedgerActions.js
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;
}
};
167 changes: 167 additions & 0 deletions app/helpers/ledger.js
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: () => {}
};
}
Loading

0 comments on commit 7278544

Please sign in to comment.