diff --git a/app/actions/TrezorActions.js b/app/actions/TrezorActions.js index 4a7625d39d..c72bbd2eaf 100644 --- a/app/actions/TrezorActions.js +++ b/app/actions/TrezorActions.js @@ -9,6 +9,7 @@ import { accountPath, addressPath } from "helpers/trezor"; +import { putUint16, rawHashToHex } from "helpers/byteActions"; import { publishTransactionAttempt } from "./ControlActions"; import { MODEL1_DECRED_HOMESCREEN, @@ -25,6 +26,7 @@ import { } from "./ControlActions"; import { getAmountFromTxInputs, getTxFromInputs } from "./TransactionActions"; import { push as pushHistory } from "connected-react-router"; +import { blake256 } from "walletCrypto"; const session = require("trezor-connect").default; const { @@ -41,6 +43,15 @@ const NOBACKUP = "no-backup"; const TRANSPORT_ERROR = "transport-error"; const TRANSPORT_START = "transport-start"; const BOOTLOADER_MODE = "bootloader"; +const testVotingKey = "PtWTXsGfk2YeqcmrRty77EsynNBtxWLLbsVEeTS8bKAGFoYF3qTNq"; +const testVotingAddr = "TsmfmUitQApgnNxQypdGd2x36djCCpDpERU"; +const SERTYPE_NOWITNESS = 1; +const OP_SSGEN_STR = "bb"; +const OP_SSRTX_STR = "bc"; +const OP_TGEN_STR = "c3"; +const STAKE_REVOCATION = "SSRTX"; +const STAKE_GENERATION = "SSGen"; +const TREASURY_GENERATION = "TGen"; let setListeners = false; @@ -450,8 +461,38 @@ const checkTrezorIsDcrwallet = () => async (dispatch, getState) => { if (addrValidResp.index !== 0) throw "Wallet replied with wrong index."; }; +// setStakeInputTypes adds a field to input that denotes stake spends. SSRTX +function setStakeInputTypes(inputs, refTxs) { + const refs = {}; + refTxs.forEach((ref) => (refs[ref.hash] = ref.bin_outputs)); + // Search reference txs for the script that will be signed and determine if + // spending a stake output by comparing the first opcode to SSRTX or SSGEN + // opcodes. + for (let i = 0; i < inputs.length; i++) { + const input = inputs[i]; + const bin_outputs = refs[input.prev_hash]; + if (!bin_outputs) continue; + let s = bin_outputs[input.prev_index].script_pubkey; + if (s.length < 2) { + continue; + } + s = s.slice(0, 2); + switch (s) { + case OP_SSGEN_STR: + input.decred_staking_spend = STAKE_GENERATION; + break; + case OP_SSRTX_STR: + input.decred_staking_spend = STAKE_REVOCATION; + break; + case OP_TGEN_STR: + input.decred_staking_spend = TREASURY_GENERATION; + break; + } + } +} + export const signTransactionAttemptTrezor = - (rawUnsigTx, constructTxResponse) => async (dispatch, getState) => { + (rawUnsigTx, changeIndexes) => async (dispatch, getState) => { dispatch({ type: SIGNTX_ATTEMPT }); const { @@ -460,11 +501,9 @@ export const signTransactionAttemptTrezor = } = getState(); const chainParams = selectors.chainParams(getState()); - debug && console.log("construct tx response", constructTxResponse); + debug && console.log("construct tx response", rawUnsigTx); try { - const changeIndex = constructTxResponse.changeIndex; - const decodedUnsigTxResp = wallet.decodeRawTransaction( Buffer.from(rawUnsigTx, "hex"), chainParams @@ -481,13 +520,17 @@ export const signTransactionAttemptTrezor = chainParams, txCompletedInputs, inputTxs, - changeIndex + changeIndexes ); const refTxs = await Promise.all( inputTxs.map((inpTx) => walletTxToRefTx(walletService, inpTx)) ); + // Determine if this is paying from a stakegen or revocation, which are + // special cases. + setStakeInputTypes(inputs, refTxs); + const payload = await deviceRun(dispatch, getState, async () => { await dispatch(checkTrezorIsDcrwallet()); @@ -503,6 +546,7 @@ export const signTransactionAttemptTrezor = dispatch({ type: SIGNTX_SUCCESS }); dispatch(publishTransactionAttempt(hexToBytes(signedRaw))); + return signedRaw; } catch (error) { dispatch({ error, type: SIGNTX_FAILED }); } @@ -555,6 +599,7 @@ export const signMessageAttemptTrezor = getSignMessageSignature: payload.signature, type: SIGNMESSAGE_SUCCESS }); + return payload.signature; } catch (error) { dispatch({ error, type: SIGNMESSAGE_FAILED }); } @@ -969,3 +1014,351 @@ export const getWalletCreationMasterPubKey = throw error; } }; + +export const TRZ_PURCHASETICKET_ATTEMPT = "TRZ_PURCHASETICKET_ATTEMPT"; +export const TRZ_PURCHASETICKET_FAILED = "TRZ_PURCHASETICKET_FAILED"; +export const TRZ_PURCHASETICKET_SUCCESS = "TRZ_PURCHASETICKET_SUCCESS"; + +// ticketInOuts creates inputs and outputs for use with a trezor signature +// request of a ticket. +async function ticketInsOuts( + getState, + decodedTicket, + decodedInp, + refTxs, + votingAddr +) { + const { + grpc: { walletService } + } = getState(); + const chainParams = selectors.chainParams(getState()); + const ticketOutN = decodedTicket.inputs[0].outputIndex; + const inAddr = decodedInp.outputs[ticketOutN].decodedScript.address; + let addrValidResp = await wallet.validateAddress(walletService, inAddr); + const inAddr_n = addressPath( + addrValidResp.index, + 1, + WALLET_ACCOUNT, + chainParams.HDCoinType + ); + const commitAddr = decodedTicket.outputs[1].decodedScript.address; + addrValidResp = await wallet.validateAddress(walletService, commitAddr); + const commitAddr_n = addressPath( + addrValidResp.index, + 1, + WALLET_ACCOUNT, + chainParams.HDCoinType + ); + const inputAmt = decodedTicket.inputs[0].valueIn.toString(); + const ticketInput = { + address_n: inAddr_n, + prev_hash: decodedTicket.inputs[0].prevTxId, + prev_index: ticketOutN, + amount: inputAmt + }; + const sstxsubmission = { + script_type: "PAYTOADDRESS", + address: votingAddr, + amount: decodedTicket.outputs[0].value.toString() + }; + const ticketsstxcommitment = { + script_type: "PAYTOADDRESS", + address_n: commitAddr_n, + amount: inputAmt + }; + const ticketsstxchange = { + script_type: "PAYTOADDRESS", + address: decodedTicket.outputs[2].decodedScript.address, + amount: "0" + }; + const inputs = [ticketInput]; + const outputs = [sstxsubmission, ticketsstxcommitment, ticketsstxchange]; + return { inputs, outputs }; +} + +export const purchaseTicketsAttempt = + (accountNum, numTickets, vsp) => async (dispatch, getState) => { + dispatch({ type: TRZ_PURCHASETICKET_ATTEMPT }); + + if (noDevice(getState)) { + dispatch({ + error: "Device not connected", + type: TRZ_PURCHASETICKET_FAILED + }); + return; + } + + const { + grpc: { walletService } + } = getState(); + const chainParams = selectors.chainParams(getState()); + + try { + // TODO: Enable on mainnet. The following todo on crypto magic must be + // implemented first. Revocation logic and a re-fee payment method must be + // added. + // TODO: Check the Trezor is a model T with version >= 2.8.3 + if (chainParams.trezorCoinName != "Decred Testnet") + throw "can only be used on testnet"; + // TODO: Fill this with deterministic crypto magic. + const votingKey = testVotingKey; + const votingAddr = testVotingAddr; + const res = await wallet.purchaseTickets( + walletService, + accountNum, + numTickets, + false, + vsp, + {} + ); + const splitTx = res.splitTx; + const decodedInp = await wallet.decodeTransactionLocal( + splitTx, + chainParams + ); + const changeIndexes = []; + for (let i = 0; i < decodedInp.outputs.length; i++) { + changeIndexes.push(i); + } + const signedSplitTx = await signTransactionAttemptTrezor( + splitTx, + changeIndexes + )(dispatch, getState); + if (!signedSplitTx) throw "failed to sign splittx"; + const refTxs = await walletTxToRefTx(walletService, decodedInp); + + for (const ticket of res.ticketsList) { + const decodedTicket = await wallet.decodeTransactionLocal( + ticket, + chainParams + ); + refTxs.hash = decodedTicket.inputs[0].prevTxId; + const { inputs, outputs } = await ticketInsOuts( + getState, + decodedTicket, + decodedInp, + refTxs, + votingAddr + ); + const payload = await deviceRun(dispatch, getState, async () => { + const res = await session.signTransaction({ + coin: chainParams.trezorCoinName, + inputs: inputs, + outputs: outputs, + refTxs: [refTxs], + decredStakingTicket: true + }); + return res.payload; + }); + + const signedRaw = payload.serializedTx; + dispatch(publishTransactionAttempt(hexToBytes(signedRaw))); + // Pay fee. + console.log( + "waiting 5 seconds for the ticket to propogate throughout the network" + ); + await new Promise((r) => setTimeout(r, 5000)); + const host = "https://" + vsp.host; + await payVSPFee( + host, + signedRaw, + signedSplitTx, + votingKey, + accountNum.value, + true, + dispatch, + getState + ); + } + dispatch({ type: TRZ_PURCHASETICKET_SUCCESS }); + } catch (error) { + dispatch({ error, type: TRZ_PURCHASETICKET_FAILED }); + } + }; + +// payVSPFee attempts to contact a vsp about a ticket and pay the fee if +// necessary. It will search transacitons for a suitable fee transaction before +// attempting to pay if newTicket is false. +async function payVSPFee( + host, + txHex, + parentTxHex, + votingKey, + accountNum, + newTicket, + dispatch, + getState +) { + const { + grpc: { walletService } + } = getState(); + // Gather information about the ticket. + const chainParams = selectors.chainParams(getState()); + const txBytes = hexToBytes(txHex); + const decodedTicket = await wallet.decodeTransactionLocal( + txBytes, + chainParams + ); + const commitmentAddr = decodedTicket.outputs[1].decodedScript.address; + + const prefix = txBytes.slice(0, decodedTicket.prefixOffset); + prefix.set(putUint16(SERTYPE_NOWITNESS), 2); + const txid = rawHashToHex(blake256(prefix)); + + // Request fee info from the vspd. + let req = { + timestamp: +new Date(), + tickethash: txid, + tickethex: txHex, + parenthex: parentTxHex + }; + let jsonStr = JSON.stringify(req); + let sig = await signMessageAttemptTrezor(commitmentAddr, jsonStr)( + dispatch, + getState + ); + if (!sig) throw "unable to sign fee address message"; + wallet.allowVSPHost(host); + // This will throw becuase of http.status 400 if already paid. + // TODO: Investigate whether other fee payment errors will cause this to + // throw. Other fee payment errors should continue, and we should only stop + // here if already paid or the ticket is not found by the vsp. + let res = null; + try { + res = await wallet.getVSPFeeAddress({ host, sig, req }); + } catch (error) { + if (error.response && error.response.data && error.response.data.message) { + // NOTE: already paid is error.response.data.code == 3 + throw error.response.data.message; + } + throw error; + } + const payAddr = res.data.feeaddress; + const fee = res.data.feeamount; + + // Find the fee transaction or make a new one. + let feeTx = null; + // Do not search for the fee tx of a new ticket. + if (!newTicket) { + feeTx = await findFeeTx(payAddr, fee, dispatch, getState); + } + if (!feeTx) { + const outputs = [{ destination: payAddr, amount: fee }]; + const txResp = await wallet.constructTransaction( + walletService, + accountNum, + 0, + outputs + ); + const unsignedTx = txResp.unsignedTransaction; + const decodedInp = await wallet.decodeTransactionLocal( + unsignedTx, + chainParams + ); + let changeIndex = 0; + for (const out of decodedInp.outputs) { + const addr = out.decodedScript.address; + const addrValidResp = await wallet.validateAddress(walletService, addr); + if (addrValidResp.isInternal) { + break; + } + changeIndex++; + } + const success = await signTransactionAttemptTrezor(unsignedTx, [ + changeIndex + ])(dispatch, getState); + if (!success) throw "unable to sign fee tx"; + for (let i = 0; i < 5; i++) { + console.log( + "waiting 5 seconds for the fee tx to propogate throughout the network" + ); + await new Promise((r) => setTimeout(r, 5000)); + feeTx = await findFeeTx(payAddr, fee, dispatch, getState); + if (feeTx) break; + } + if (!feeTx) throw "unable to find fee tx " + rawToHex(unsignedTx); + } + + // Send ticket fee data and voting chioces back to the vsp. + const { + grpc: { votingService } + } = getState(); + const voteChoicesRes = await wallet.getVoteChoices(votingService); + const voteChoices = {}; + for (const choice of voteChoicesRes.choicesList) { + voteChoices[choice.agendaId] = choice.choiceId; + } + req = { + timestamp: +new Date(), + tickethash: txid, + feetx: feeTx, + votingkey: votingKey, + votechoices: voteChoices + }; + jsonStr = JSON.stringify(req); + sig = await signMessageAttemptTrezor(commitmentAddr, jsonStr)( + dispatch, + getState + ); + if (!sig) throw "unable to sign fee tx message"; + wallet.allowVSPHost(host); + await wallet.payVSPFee({ host, sig, req }); +} + +// findFeeTx searches unmined and recent transactions for a tx that pays to +// FeeAddr of the amount feeAmt. It stops searching below a resonable depth for +// a ticket. +async function findFeeTx(feeAddr, feeAmt, dispatch, getState) { + const { + grpc: { walletService } + } = getState(); + const chainParams = selectors.chainParams(getState()); + // findFee looks for a transaction the paid out exactl feeAmt and has an + // output address that matches feeAddr. + const findFee = async (res) => { + for (const credit of res) { + if (credit.txType != "sent" && credit.txType != "regular") continue; + const sentAmt = Math.abs(credit.amount + credit.fee); + if (sentAmt == feeAmt) { + const tx = await wallet.decodeTransactionLocal( + hexToBytes(credit.rawTx), + chainParams + ); + if ( + tx.outputs.find( + (e) => e.decodedScript && e.decodedScript.address == feeAddr + ) + ) + return credit.rawTx; + } + } + return null; + }; + // First search mempool. + const { unmined } = await wallet.getTransactions(walletService, -1, -1, 0); + const feeTx = await findFee(unmined); + if (feeTx) return feeTx; + // TODO: Take these constants from the chainparams. + const ticketMaturity = 256; + const ticketExpiry = 40960; + const { currentBlockHeight } = getState().grpc; + let start = currentBlockHeight - 100; + let end = currentBlockHeight; + const maxAge = currentBlockHeight - (ticketMaturity + ticketExpiry); + const blockIncrement = 100; + // Search mined txs in reverse order up until a ticket must have expired on + // mainnet. + while (start > maxAge) { + const { mined } = await wallet.getTransactions( + walletService, + start, + end, + 0 + ); + start -= blockIncrement; + end -= blockIncrement; + const feeTx = await findFee(mined); + if (feeTx) return feeTx; + } + return null; +} diff --git a/app/components/SideBar/MenuLinks/hooks.js b/app/components/SideBar/MenuLinks/hooks.js index bd2e53768c..db0ddee45c 100644 --- a/app/components/SideBar/MenuLinks/hooks.js +++ b/app/components/SideBar/MenuLinks/hooks.js @@ -19,6 +19,7 @@ export function useMenuLinks() { const isTrezor = useSelector(sel.isTrezor); const isLedger = useSelector(sel.isLedger); const lnEnabled = useSelector(sel.lnEnabled); + const isTestNet = useSelector(sel.isTestNet); const newActiveVoteProposalsCount = useSelector( sel.newActiveVoteProposalsCount @@ -54,14 +55,12 @@ export function useMenuLinks() { if (!lnEnabled) { links = links.filter((l) => l.key !== LN_KEY); } - // TODO: Enable ticket purchacing for Trezor. + // TODO: Enable ticket purchacing for Trezor on mainnet. if (isTrezor || isLedger) { - links = links.filter( - (l) => l.key !== DEX_KEY && l.key !== TICKETS_KEY && l.key !== GOV_KEY - ); + links = links.filter((l) => l.key !== DEX_KEY && l.key !== GOV_KEY); } - if (isLedger) { - links = links.filter((l) => l.key !== PRIV_KEY); + if (isLedger || (isTrezor && !isTestNet)) { + links = links.filter((l) => l.key !== PRIV_KEY && l.key !== TICKETS_KEY); } return links.map((link) => ({ ...link, @@ -70,7 +69,7 @@ export function useMenuLinks() { 0 ) })); - }, [notifProps, isTrezor, lnEnabled, isLedger]); + }, [notifProps, isTrezor, lnEnabled, isLedger, isTestNet]); const [activeTabIndex, setActiveTabIndex] = useState(-1); const history = useHistory(); diff --git a/app/components/buttons/SendTransactionButton/hooks.js b/app/components/buttons/SendTransactionButton/hooks.js index 4a12c4955c..85682b5065 100644 --- a/app/components/buttons/SendTransactionButton/hooks.js +++ b/app/components/buttons/SendTransactionButton/hooks.js @@ -16,7 +16,11 @@ export function useSendTransactionButton() { dispatch(ca.signTransactionAttempt(passphrase, rawTx, acct)); }; const onAttemptSignTransactionTrezor = (rawUnsigTx, constructTxResponse) => - dispatch(tza.signTransactionAttemptTrezor(rawUnsigTx, constructTxResponse)); + dispatch( + tza.signTransactionAttemptTrezor(rawUnsigTx, [ + constructTxResponse.changeIndex + ]) + ); const onAttemptSignTransactionLedger = (rawUnsigTx) => dispatch(ldgr.signTransactionAttemptLedger(rawUnsigTx)); diff --git a/app/components/views/HomePage/HomePage.jsx b/app/components/views/HomePage/HomePage.jsx index 2bfb2ad465..254846c277 100644 --- a/app/components/views/HomePage/HomePage.jsx +++ b/app/components/views/HomePage/HomePage.jsx @@ -54,11 +54,12 @@ export default () => { tsDate } = useHomePage(); - // TODO: Enable ticket purchacing for Trezor. + // TODO: Enable ticket purchacing for Trezor on mainnet. const isTrezor = useSelector(sel.isTrezor); const isLedger = useSelector(sel.isLedger); + const isTestNet = useSelector(sel.isTestNet); let recentTickets, tabs; - if (isTrezor || isLedger) { + if ((isTrezor && !isTestNet) || isLedger) { tabs = [balanceTab, transactionsTab]; } else { recentTickets = ( diff --git a/app/components/views/TicketsPage/PurchaseTab/PurchaseTabPage.jsx b/app/components/views/TicketsPage/PurchaseTab/PurchaseTabPage.jsx index 80a1fca681..0b51d5c9f6 100644 --- a/app/components/views/TicketsPage/PurchaseTab/PurchaseTabPage.jsx +++ b/app/components/views/TicketsPage/PurchaseTab/PurchaseTabPage.jsx @@ -72,6 +72,7 @@ export function PurchaseTabPage({ isVSPListingEnabled, onEnableVSPListing, getRunningIndicator, + isPurchasingTicketsTrezor, ...props }) { return ( @@ -115,7 +116,9 @@ export function PurchaseTabPage({ isLoading, rememberedVspHost, toggleRememberVspHostCheckBox, - getRunningIndicator + getRunningIndicator, + isPurchasingTicketsTrezor, + isWatchingOnly }} /> )} diff --git a/app/components/views/TicketsPage/PurchaseTab/PurchaseTicketsForm/PurchaseTicketsForm.jsx b/app/components/views/TicketsPage/PurchaseTab/PurchaseTicketsForm/PurchaseTicketsForm.jsx index 97086981af..c520b45fc8 100644 --- a/app/components/views/TicketsPage/PurchaseTab/PurchaseTicketsForm/PurchaseTicketsForm.jsx +++ b/app/components/views/TicketsPage/PurchaseTab/PurchaseTicketsForm/PurchaseTicketsForm.jsx @@ -36,7 +36,8 @@ const PurchaseTicketsForm = ({ rememberedVspHost, toggleRememberVspHostCheckBox, notMixedAccounts, - getRunningIndicator + getRunningIndicator, + isPurchasingTicketsTrezor }) => { const intl = useIntl(); return ( @@ -149,7 +150,10 @@ const PurchaseTicketsForm = ({