From 3b6dbee9cdd8c47295b8be1a0962b505a527c70b Mon Sep 17 00:00:00 2001 From: JoeGruff Date: Fri, 26 Mar 2021 12:29:04 +0900 Subject: [PATCH] multi: Allow trezor ticket purchasing on testnet. Add purchaseTicketsV3 which purchases tickets using a watching only trezor wallet from a vsp with api v3. Errors if not on testnet. Add separate payVSPFee function that will pay a tickets fee if not already paid, and throws if already paid. Correct purchase ticket button for watching only wallets. It no longer asks for a password. Connect purchasing through trezor when isTrezor is true. Add vsp v3 endpoints "feeaddress" and "payfee" to allowed external requests. Change wallet/control.js to not require an unlocked wallet when signTx is false. Add headers to OPTIONS externalRequest. These are required by CORS when making POST requests. --- app/actions/TrezorActions.js | 402 +++++++++++++++++- .../buttons/SendTransactionButton/hooks.js | 6 +- .../PurchaseTab/PurchaseTabPage.jsx | 5 +- .../PurchaseTicketsForm.jsx | 18 +- .../views/TicketsPage/PurchaseTab/hooks.js | 33 +- app/helpers/msgTx.js | 1 + app/helpers/trezor.js | 11 +- app/main_dev/externalRequests.js | 14 + app/middleware/vspapi.js | 22 + app/reducers/trezor.js | 17 +- app/selectors.js | 1 + app/wallet/control.js | 2 + app/wallet/vsp.js | 5 + test/data/decodedTransactions.js | 3 + .../buttons/SendTransactionButton.spec.js | 9 +- 15 files changed, 528 insertions(+), 21 deletions(-) diff --git a/app/actions/TrezorActions.js b/app/actions/TrezorActions.js index b43a067f00..11918aa9a1 100644 --- a/app/actions/TrezorActions.js +++ b/app/actions/TrezorActions.js @@ -8,6 +8,7 @@ import { accountPath, addressPath } from "helpers/trezor"; +import { putUint16, rawHashToHex } from "helpers/byteActions"; import { publishTransactionAttempt } from "./ControlActions"; import * as cfgConstants from "constants/config"; import { @@ -24,6 +25,7 @@ import { SIGNMESSAGE_SUCCESS } from "./ControlActions"; import { getAmountFromTxInputs, getTxFromInputs } from "./TransactionActions"; +import { blake256 } from "walletCrypto"; const session = require("trezor-connect").default; const { @@ -40,6 +42,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; @@ -441,8 +452,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 { @@ -451,11 +492,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 @@ -472,13 +511,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()); @@ -494,6 +537,7 @@ export const signTransactionAttemptTrezor = dispatch({ type: SIGNTX_SUCCESS }); dispatch(publishTransactionAttempt(hexToBytes(signedRaw))); + return signedRaw; } catch (error) { dispatch({ error, type: SIGNTX_FAILED }); } @@ -546,6 +590,7 @@ export const signMessageAttemptTrezor = getSignMessageSignature: payload.signature, type: SIGNMESSAGE_SUCCESS }); + return payload.signature; } catch (error) { dispatch({ error, type: SIGNMESSAGE_FAILED }); } @@ -960,3 +1005,350 @@ 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. + 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 vst 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/buttons/SendTransactionButton/hooks.js b/app/components/buttons/SendTransactionButton/hooks.js index 79c3847526..0f15ec5b20 100644 --- a/app/components/buttons/SendTransactionButton/hooks.js +++ b/app/components/buttons/SendTransactionButton/hooks.js @@ -14,7 +14,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 + ]) + ); return { unsignedTransaction, 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..248e2a01ac 100644 --- a/app/components/views/TicketsPage/PurchaseTab/PurchaseTicketsForm/PurchaseTicketsForm.jsx +++ b/app/components/views/TicketsPage/PurchaseTab/PurchaseTicketsForm/PurchaseTicketsForm.jsx @@ -36,7 +36,18 @@ const PurchaseTicketsForm = ({ rememberedVspHost, toggleRememberVspHostCheckBox, notMixedAccounts, - getRunningIndicator + getRunningIndicator, + isLegacy, + dismissBackupRedeemScript, + onDismissBackupRedeemScript, + isShowingAdvanced, + onToggleShowAdvanced, + getQuickBarComponent, + getAdvancedComponent, + willEnter, + willLeave, + toggleShowVsp, + isPurchasingTicketsTrezor }) => { const intl = useIntl(); return ( @@ -149,7 +160,10 @@ const PurchaseTicketsForm = ({
{isWatchingOnly ? ( - + {purchaseLabel()} ) : isLoading ? ( diff --git a/app/components/views/TicketsPage/PurchaseTab/hooks.js b/app/components/views/TicketsPage/PurchaseTab/hooks.js index 0bb42fcefa..1168fd5cab 100644 --- a/app/components/views/TicketsPage/PurchaseTab/hooks.js +++ b/app/components/views/TicketsPage/PurchaseTab/hooks.js @@ -3,6 +3,7 @@ import { useCallback, useMemo } from "react"; import { useSettings } from "hooks"; import { EXTERNALREQUEST_STAKEPOOL_LISTING } from "constants"; +import { purchaseTicketsAttempt as trezorPurchseTicketsAttempt } from "actions/TrezorActions.js"; import * as vspa from "actions/VSPActions"; import * as ca from "actions/ControlActions.js"; import * as sel from "selectors"; @@ -21,6 +22,8 @@ export const usePurchaseTab = () => { const ticketAutoBuyerRunning = useSelector(sel.getTicketAutoBuyerRunning); const isLoading = useSelector(sel.purchaseTicketsRequestAttempt); const notMixedAccounts = useSelector(sel.getNotMixedAccounts); + const isTrezor = useSelector(sel.isTrezor); + const isPurchasingTicketsTrezor = useSelector(sel.isPurchasingTicketsTrezor); const rememberedVspHost = useSelector(sel.getRememberedVspHost); const visibleAccounts = useSelector(sel.visibleAccounts); @@ -54,8 +57,31 @@ export const usePurchaseTab = () => { [dispatch] ); const purchaseTicketsAttempt = useCallback( - (passphrase, account, numTickets, vsp) => - dispatch(ca.purchaseTicketsAttempt(passphrase, account, numTickets, vsp)), + (passphrase, account, numTickets, vsp) => { + if (isTrezor) { + dispatch(trezorPurchseTicketsAttempt(account, numTickets, vsp)); + } else { + dispatch( + ca.purchaseTicketsAttempt(passphrase, account, numTickets, vsp) + ); + } + }, + [dispatch, isTrezor] + ); + const onEnableTicketAutoBuyer = useCallback( + (passphrase, account, balanceToMaintain, vsp) => + dispatch( + ca.startTicketBuyerV3Attempt( + passphrase, + account, + balanceToMaintain, + vsp + ) + ), + [dispatch] + ); + const onDisableTicketAutoBuyer = useCallback( + () => dispatch(ca.ticketBuyerCancel()), [dispatch] ); @@ -140,6 +166,7 @@ export const usePurchaseTab = () => { vsp, setVSP, numTicketsToBuy, - setNumTicketsToBuy + setNumTicketsToBuy, + isPurchasingTicketsTrezor }; }; diff --git a/app/helpers/msgTx.js b/app/helpers/msgTx.js index 3bd5c83c60..0808c490fc 100644 --- a/app/helpers/msgTx.js +++ b/app/helpers/msgTx.js @@ -268,6 +268,7 @@ export function decodeRawTransaction(rawTx) { position += 4; tx.expiry = rawTx.readUInt32LE(position); position += 4; + tx.prefixOffset = position; } if (tx.serType !== SERTYPE_NOWITNESS) { diff --git a/app/helpers/trezor.js b/app/helpers/trezor.js index a6ed076cfa..468cb93561 100644 --- a/app/helpers/trezor.js +++ b/app/helpers/trezor.js @@ -28,14 +28,14 @@ export const addressPath = (index, branch, account, coinType) => { }; // walletTxToBtcjsTx is a aux function to convert a tx decoded by the decred wallet (ie, -// returned from wallet.decoreRawTransaction call) into a bitcoinjs-compatible +// returned from wallet.decodeRawTransaction call) into a bitcoinjs-compatible // transaction (to be used in trezor). export const walletTxToBtcjsTx = async ( walletService, chainParams, tx, inputTxs, - changeIndex + changeIndexes ) => { const inputs = tx.inputs.map(async (inp) => { const addr = inp.outpointAddress; @@ -81,7 +81,7 @@ export const walletTxToBtcjsTx = async ( const addrValidResp = await wallet.validateAddress(walletService, addr); if (!addrValidResp.isValid) throw "Not a valid address: " + addr; let address_n = null; - if (i === changeIndex && addrValidResp.isMine) { + if (changeIndexes.includes(i) && addrValidResp.isMine) { const addrIndex = addrValidResp.index; const addrBranch = addrValidResp.isInternal ? 1 : 0; address_n = addressPath( @@ -124,7 +124,10 @@ export const walletTxToRefTx = async (walletService, tx) => { const outputs = tx.outputs.map(async (outp) => { const addr = outp.decodedScript.address; const addrValidResp = await wallet.validateAddress(walletService, addr); - if (!addrValidResp.isValid) throw new Error("Not a valid address: " + addr); + // Scripts with zero value can be ignored as they are not a concern when + // spending from an outpoint. + if (outp.value != 0 && !addrValidResp.isValid) + throw new Error("Not a valid address: " + addr); return { amount: outp.value, script_pubkey: rawToHex(outp.script), diff --git a/app/main_dev/externalRequests.js b/app/main_dev/externalRequests.js index 0eef75a5b9..af0b056402 100644 --- a/app/main_dev/externalRequests.js +++ b/app/main_dev/externalRequests.js @@ -121,6 +121,10 @@ export const installSessionHandlers = (mainLogger) => { `connect-src ${connectSrc}; `; } + const requestURL = new URL(details.url); + const maybeVSPReqType = `stakepool_${requestURL.protocol}//${requestURL.host}`; + const isVSPRequest = allowedExternalRequests[maybeVSPReqType]; + if (isDev && /^http[s]?:\/\//.test(details.url)) { // In development (when accessing via the HMR server) we need to overwrite // the origin, otherwise electron fails to contact external servers due @@ -144,6 +148,12 @@ export const installSessionHandlers = (mainLogger) => { newHeaders["Access-Control-Allow-Headers"] = "Content-Type"; } + if (isVSPRequest && details.method === "OPTIONS") { + statusLine = "OK"; + newHeaders["Access-Control-Allow-Headers"] = + "Content-Type,vsp-client-signature"; + } + const globalCfg = getGlobalCfg(); const cfgAllowedVSPs = globalCfg.get(cfgConstants.ALLOWED_VSP_HOSTS, []); if (cfgAllowedVSPs.some((url) => details.url.includes(url))) { @@ -204,6 +214,10 @@ export const allowVSPRequests = (stakePoolHost) => { addAllowedURL(stakePoolHost + "/api/v3/vspinfo"); addAllowedURL(stakePoolHost + "/api/v3/ticketstatus"); + addAllowedURL(stakePoolHost + "/api/v3/feeaddress"); + addAllowedURL(stakePoolHost + "/api/v3/payfee"); + addAllowedURL(stakePoolHost + "/api/ticketstatus"); + allowedExternalRequests[reqType] = true; }; export const reloadAllowedExternalRequests = () => { diff --git a/app/middleware/vspapi.js b/app/middleware/vspapi.js index 82977fe459..0ad4020afd 100644 --- a/app/middleware/vspapi.js +++ b/app/middleware/vspapi.js @@ -66,3 +66,25 @@ export function getVSPTicketStatus({ host, sig, json }, cb) { .then((resp) => cb(resp, null, host)) .catch((error) => cb(null, error, host)); } + +// getFeeAddress gets a ticket`s fee address. +export function getFeeAddress({ host, sig, req }, cb) { + console.log(req); + POST(host + "/api/v3/feeaddress", sig, req) + .then((resp) => cb(resp, null, host)) + .catch((error) => cb(null, error, host)); +} + +// payFee infomrs of a ticket`s fee payment. +export function payFee({ host, sig, req }, cb) { + console.log(req); + POST(host + "/api/v3/payfee", sig, req) + .then((resp) => cb(resp, null, host)) + .catch((error) => cb(null, error, host)); +} + +export function getTicketStatus({ host, vspClientSig, request }, cb) { + POST(host + "/api/ticketstatus", vspClientSig, request) + .then((resp) => cb(resp, null, host)) + .catch((error) => cb(null, error, host)); +} diff --git a/app/reducers/trezor.js b/app/reducers/trezor.js index 596bd18c51..1c1aff38c2 100644 --- a/app/reducers/trezor.js +++ b/app/reducers/trezor.js @@ -19,6 +19,9 @@ import { TRZ_PASSPHRASE_REQUESTED, TRZ_PASSPHRASE_ENTERED, TRZ_PASSPHRASE_CANCELED, + TRZ_PURCHASETICKET_ATTEMPT, + TRZ_PURCHASETICKET_FAILED, + TRZ_PURCHASETICKET_SUCCESS, TRZ_WORD_REQUESTED, TRZ_WORD_ENTERED, TRZ_WORD_CANCELED, @@ -279,6 +282,12 @@ export default function trezor(state = {}, action) { performingOperation: false, performingTogglePassphraseOnDeviceProtection: false }; + case TRZ_PURCHASETICKET_ATTEMPT: + return { + ...state, + performingOperation: true, + purchasingTickets: true + }; case SIGNTX_FAILED: case SIGNTX_SUCCESS: case TRZ_CHANGEHOMESCREEN_FAILED: @@ -305,7 +314,6 @@ export default function trezor(state = {}, action) { performingOperation: false, performingUpdate: false }; - case TRZ_TOGGLEPINPROTECTION_FAILED: case TRZ_TOGGLEPINPROTECTION_SUCCESS: return { @@ -325,6 +333,13 @@ export default function trezor(state = {}, action) { performingOperation: false, performingTogglePassphraseOnDeviceProtection: false }; + case TRZ_PURCHASETICKET_FAILED: + case TRZ_PURCHASETICKET_SUCCESS: + return { + ...state, + performingOperation: false, + purchasingTickets: false + }; case CLOSEWALLET_SUCCESS: return { ...state, enabled: false }; default: diff --git a/app/selectors.js b/app/selectors.js index 8a287b5591..69ebb0409e 100644 --- a/app/selectors.js +++ b/app/selectors.js @@ -1103,6 +1103,7 @@ export const confirmationDialogModalVisible = bool( export const isTrezor = get(["trezor", "enabled"]); export const isPerformingTrezorUpdate = get(["trezor", "performingUpdate"]); +export const isPurchasingTicketsTrezor = get(["trezor", "purchasingTickets"]); export const isSignMessageDisabled = and(isWatchingOnly, not(isTrezor)); export const isChangePassPhraseDisabled = isWatchingOnly; diff --git a/app/wallet/control.js b/app/wallet/control.js index 0384c38105..cc9bb8b966 100644 --- a/app/wallet/control.js +++ b/app/wallet/control.js @@ -222,6 +222,8 @@ export const purchaseTickets = ( resObj.ticketHashes = response .getTicketHashesList() .map((v) => rawHashToHex(v)); + resObj.splitTx = Buffer.from(response.getSplitTx()); + resObj.ticketsList = response.getTicketsList().map((v) => Buffer.from(v)); resolve(resObj); }); }); diff --git a/app/wallet/vsp.js b/app/wallet/vsp.js index 41dbca78d6..500f77d30a 100644 --- a/app/wallet/vsp.js +++ b/app/wallet/vsp.js @@ -16,6 +16,11 @@ const promisifyReqLogNoData = (fnName, Req) => ); export const getVSPInfo = promisifyReqLogNoData("getVSPInfo", api.getVSPInfo); +export const getVSPFeeAddress = promisifyReqLogNoData( + "getFeeAddress", + api.getFeeAddress +); +export const payVSPFee = promisifyReqLogNoData("getFeeAddress", api.payFee); export const getVSPTicketStatus = promisifyReqLogNoData( "getVSPTicketStatus", api.getVSPTicketStatus diff --git a/test/data/decodedTransactions.js b/test/data/decodedTransactions.js index fc7342676d..900df815fe 100644 --- a/test/data/decodedTransactions.js +++ b/test/data/decodedTransactions.js @@ -1,5 +1,6 @@ export const decodedPurchasedTicketTx = { "version": 1, + "prefixOffset": 172, "serType": 0, "numInputs": 1, "inputs": [ @@ -56,6 +57,7 @@ export const decodedPurchasedTicketTx = { // multiTxPrefix is a tx prefix in the format of how our decodeTxs are. We get // this format from wallet.decodeRawTransaction(). export const multiTxPrefix = { + prefixOffset: 211, serType: 1, // TxSerializeNoWitness, version: 1, numInputs: 1, @@ -113,6 +115,7 @@ export const multiTxPrefix = { export const decodedVoteTx = { "version": 1, + "prefixOffset": 201, "serType": 0, "numInputs": 2, "inputs": [ diff --git a/test/unit/components/buttons/SendTransactionButton.spec.js b/test/unit/components/buttons/SendTransactionButton.spec.js index badecc48c7..befaa5896d 100644 --- a/test/unit/components/buttons/SendTransactionButton.spec.js +++ b/test/unit/components/buttons/SendTransactionButton.spec.js @@ -80,10 +80,11 @@ test("render SendTransactionButton when trezor is enabled", async () => { const button = screen.getByRole("button"); user.click(button); expect(mockSignTransactionAttempt).not.toHaveBeenCalled(); - expect(mockSignTransactionAttemptTrezor).toHaveBeenCalledWith( - testUnsignedTransaction, - testConstructTxResponse - ); + expect( + mockSignTransactionAttemptTrezor + ).toHaveBeenCalledWith(testUnsignedTransaction, [ + testConstructTxResponse.changeIndex + ]); await wait(() => expect(mockOnSubmit).toHaveBeenCalled()); });