Skip to content

Commit

Permalink
multi: Add ledger backend functions.
Browse files Browse the repository at this point in the history
  • Loading branch information
JoeGruffins committed Jul 3, 2023
1 parent 8778cb4 commit 632d455
Show file tree
Hide file tree
Showing 7 changed files with 657 additions and 11 deletions.
196 changes: 196 additions & 0 deletions app/actions/LedgerActions.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,196 @@
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 getAddress("44'/42'/0'");
const hdpk = ledgerHelpers.pubkeyToHDPubkey(
payload.publicKey,
payload.chainCode,
isTestnet
);

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 createTx(arg) {
return doWithTransport((transport) => {
return createTransaction(transport, arg);
});
}
183 changes: 183 additions & 0 deletions app/helpers/ledger.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,183 @@
import { wallet } from "wallet-preload-shim";
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";
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;
}

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

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;
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"]
};
}
Loading

0 comments on commit 632d455

Please sign in to comment.