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 Sep 15, 2023
1 parent 8778cb4 commit 74284d2
Show file tree
Hide file tree
Showing 7 changed files with 644 additions and 11 deletions.
205 changes: 205 additions & 0 deletions app/actions/LedgerActions.js
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);
});
}
164 changes: 164 additions & 0 deletions app/helpers/ledger.js
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"]
};
}
12 changes: 12 additions & 0 deletions app/main.development.js
Original file line number Diff line number Diff line change
Expand Up @@ -873,6 +873,18 @@ app.on("ready", async () => {
height: 1000,
icon: __dirname + "/dcrdex.png"
});

mainWindow.webContents.session.setDevicePermissionHandler((details) => {
// Allow Ledger devices which share a unique vendor ID.
if (
details.deviceType === "usb" &&
details.device &&
details.device.vendorId === 0x2c97
) {
return true;
}
return false;
});
});

app.on("before-quit", async (event) => {
Expand Down
2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -273,6 +273,8 @@
"@formatjs/intl-utils": "^1.6.0",
"@grpc/grpc-js": "1.7.3",
"@hot-loader/react-dom": "16.14.0",
"@ledgerhq/hw-app-btc": "^10.0.5",
"@ledgerhq/hw-transport-webusb": "^6.27.16",
"@peculiar/webcrypto": "1.4.3",
"@xstate/react": "^0.8.1",
"blake-hash": "^2.0.0",
Expand Down
Loading

0 comments on commit 74284d2

Please sign in to comment.