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 16, 2023
1 parent 587d099 commit b1c1525
Show file tree
Hide file tree
Showing 5 changed files with 700 additions and 13 deletions.
235 changes: 235 additions & 0 deletions app/actions/LedgerActions.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,235 @@
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";
export const LDG_WALLET_CLOSED = "LDG_WALLET_CLOSED";

const coin = "decred";
// 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";

async function connect(dispatch, getState) {
dispatch({ type: LDG_CONNECT_ATTEMPT });
try {
// If unable to connect, a timeout error will occur.
await deviceRun(dispatch, getState, async (transport) => {})
} catch (error) {
dispatch({ type: LDG_CONNECT_FAILED });
throw error
}
dispatch({ type: LDG_CONNECT_SUCCESS });
return device;
}

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

// 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 timoutErr = new Error("timeout waiting for transport")
const handleError = (error) => {
if (error === timeoutErr) {
alertNoConnectedDevice()(dispatch);
}
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 pWithTransport = new Promise(async (resolve, reject) => {
resolve(await TransportNodeHid.create())
})

let timeout;
const pTimeout = new Promise((resolve, reject) => {
timeout = setTimeout(() => {reject(timeoutErr)}, 2000)
})

// Creating the transport will wait indefinitely. Timeout if it does not
// connect in a couple seconds.
let res;
await Promise.race([pWithTransport, pTimeout]).then( async (t) => {
clearTimeout(timeout)
res = await fn(t)
}).catch((err) => {
throw err
})

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

0 comments on commit b1c1525

Please sign in to comment.