diff --git a/source/renderer/app/api/api.ts b/source/renderer/app/api/api.ts index d533febfe9..145750657d 100644 --- a/source/renderer/app/api/api.ts +++ b/source/renderer/app/api/api.ts @@ -1275,11 +1275,13 @@ export default class AdaApi { try { const { + transaction, coin_selection, fee: { quantity }, } = await constructTransaction(this.config, params); const result = { + transaction, coinSelection: parseCoinSelectionResponse({ coinSelectionResponse: coin_selection, }), diff --git a/source/renderer/app/api/transactions/types.ts b/source/renderer/app/api/transactions/types.ts index 5b28bda693..5ec16decc8 100644 --- a/source/renderer/app/api/transactions/types.ts +++ b/source/renderer/app/api/transactions/types.ts @@ -196,11 +196,13 @@ export type CoinSelectionOutput = { export type CertificateType = | 'register_reward_account' | 'quit_pool' - | 'join_pool'; + | 'join_pool' + | 'cast_vote'; export type CoinSelectionCertificate = { pool: string; certificateType: CertificateType; rewardAccountPath: Array; + vote?: string; }; export type CoinSelectionCertificates = Array; export type CoinSelectionWithdrawal = { diff --git a/source/renderer/app/components/voting/voting-governance/VotingPowerDelegation.tsx b/source/renderer/app/components/voting/voting-governance/VotingPowerDelegation.tsx index d32e4ba3e7..f11a1ee656 100644 --- a/source/renderer/app/components/voting/voting-governance/VotingPowerDelegation.tsx +++ b/source/renderer/app/components/voting/voting-governance/VotingPowerDelegation.tsx @@ -92,6 +92,16 @@ const mapOfTxErrorCodeToIntl: Record< not_enough_money: messages.initializeNotEnoughMoney, }; +const initialState: State = { + status: 'form', + selectedWallet: null, + selectedVoteType: 'drep', + drepInputState: { + dirty: false, + value: '', + }, +}; + function VotingPowerDelegation({ getStakePoolById, initiateTransaction, @@ -101,15 +111,7 @@ function VotingPowerDelegation({ wallets, stakePools, }: Props) { - const [state, setState] = useState({ - status: 'form', - selectedWallet: null, - selectedVoteType: 'drep', - drepInputState: { - dirty: false, - value: '', - }, - }); + const [state, setState] = useState(initialState); const drepInputIsValid = isDrepIdValid(state.drepInputState.value); @@ -209,9 +211,8 @@ function VotingPowerDelegation({ onChange={(walletId: string) => { const selectedWallet = wallets.find((w) => w.id === walletId); setState({ - ...state, + ...initialState, selectedWallet, - status: 'form', }); }} placeholder={intl.formatMessage(messages.selectWalletPlaceholder)} diff --git a/source/renderer/app/components/voting/voting-governance/VotingPowerDelegationConfirmationDialog.tsx b/source/renderer/app/components/voting/voting-governance/VotingPowerDelegationConfirmationDialog.tsx index f46a943d14..99816318cf 100644 --- a/source/renderer/app/components/voting/voting-governance/VotingPowerDelegationConfirmationDialog.tsx +++ b/source/renderer/app/components/voting/voting-governance/VotingPowerDelegationConfirmationDialog.tsx @@ -4,15 +4,18 @@ import { injectIntl } from 'react-intl'; import { Input } from 'react-polymorph/lib/components/Input'; import { InputSkin } from 'react-polymorph/lib/skins/simple/InputSkin'; import Dialog from '../../widgets/Dialog'; -import DialogCloseButton from '../../widgets/DialogCloseButton'; import { formattedWalletAmount } from '../../../utils/formatters'; -import Wallet, { HwDeviceStatus } from '../../../domains/Wallet'; +import Wallet, { + HwDeviceStatus, + HwDeviceStatuses, +} from '../../../domains/Wallet'; import HardwareWalletStatus from '../../hardware-wallet/HardwareWalletStatus'; import styles from './VotingPowerDelegationConfirmationDialog.scss'; import { DelegateVotesError } from '../../../stores/VotingStore'; import type { Intl, ReactIntlMessage } from '../../../types/i18nTypes'; import { messages } from './VotingPowerDelegationConfirmationDialog.messages'; import globalMessages from '../../../i18n/global-messages'; +import LoadingSpinner from '../../widgets/LoadingSpinner'; import { VoteType } from './types'; import { sharedGovernanceMessages } from './shared-messages'; @@ -108,6 +111,13 @@ function VotingPowerDelegationConfirmationDialog({ })(); }, [intl, onSubmit, redirectToWallet, state]); + const confirmButtonLabel = + state.status === 'awaiting' ? ( + intl.formatMessage(messages.buttonConfirm) + ) : ( + + ); + return ( { - if (state.status !== 'awaiting' || !state.passphrase) return; setState({ - passphrase: state.passphrase, + passphrase: '', status: 'confirmed', }); }, primary: true, - disabled: state.status === 'submitting' || !state.passphrase, + disabled: + state.status !== 'awaiting' || + (selectedWallet.isHardwareWallet + ? hwDeviceStatus !== + HwDeviceStatuses.VERIFYING_TRANSACTION_SUCCEEDED + : !state.passphrase), }, ]} - onClose={onClose} - closeButton={} >

diff --git a/source/renderer/app/stores/HardwareWalletsStore.ts b/source/renderer/app/stores/HardwareWalletsStore.ts index 24b5c567fb..afa424fd58 100644 --- a/source/renderer/app/stores/HardwareWalletsStore.ts +++ b/source/renderer/app/stores/HardwareWalletsStore.ts @@ -2238,6 +2238,7 @@ export default class HardwareWalletsStore extends Store { pool: certificate.pool, // @ts-ignore ts-migrate(2322) FIXME: Type 'number' is not assignable to type 'string'. type: CERTIFICATE_TYPE[certificate.certificateType], + vote: certificate.vote, }); txCertificates.push(txCertificate); return toTrezorCertificate(certificate); @@ -2556,6 +2557,7 @@ export default class HardwareWalletsStore extends Store { pool: certificate.pool, // @ts-ignore ts-migrate(2322) FIXME: Type 'number' is not assignable to type 'string'. type: CERTIFICATE_TYPE[certificate.certificateType], + vote: certificate.vote, }); txCertificates.push(txCertificate); return toLedgerCertificate(certificate); diff --git a/source/renderer/app/stores/VotingStore.ts b/source/renderer/app/stores/VotingStore.ts index 4c5c0fd89c..c1a9c8fc2e 100644 --- a/source/renderer/app/stores/VotingStore.ts +++ b/source/renderer/app/stores/VotingStore.ts @@ -29,8 +29,9 @@ import type { CatalystFund } from '../api/voting/types'; import { EventCategories } from '../analytics'; import type { DelegationCalculateFeeResponse } from '../api/staking/types'; import Wallet from '../domains/Wallet'; -import { logger } from '../utils/logging'; import ApiError from '../domains/ApiError'; +import type { DelegationAction } from '../types/stakingTypes'; +import { GenericApiError } from '../api/common/errors'; export type VotingRegistrationKeyType = { bytes: (...args: Array) => any; @@ -84,6 +85,13 @@ const parseApiCode = ( return error.code; } + if ( + error instanceof GenericApiError && + isExpectedError(expectedCodes, error.values.code) + ) { + return error.values.code; + } + return 'generic'; }; @@ -271,26 +279,89 @@ export default class VotingStore extends Store { chosenOption: string; wallet: Wallet; }) => { - this.constructTxRequest.reset(); - try { - const { - coinSelection, - fee: fees, - } = await this.constructTxRequest.execute({ - walletId: wallet.id, - data: { vote: chosenOption }, - }).promise; + if (wallet.isHardwareWallet) { + const [{ id: stakePoolId }] = this.stores.staking.stakePools; + let dlegationData: { + delegationAction: DelegationAction; + poolId: string; + } = { + delegationAction: 'join', + poolId: stakePoolId, + }; + + if (wallet.isDelegating) { + const { lastDelegatedStakePoolId, delegatedStakePoolId } = wallet; + const poolId = lastDelegatedStakePoolId || delegatedStakePoolId || ''; + dlegationData = { + delegationAction: 'quit', + poolId, + }; + } + + try { + const initialCoinSelection = await this.stores.hardwareWallets.selectDelegationCoins( + { + walletId: wallet.id, + ...dlegationData, + } + ); + + let certificates: object[] = [ + { + certificateType: 'cast_vote', + rewardAccountPath: ['1852H', '1815H', '0H', '2', '0'], + vote: chosenOption, + }, + ]; + + const walletNeedsRegisteringRewardAccount = initialCoinSelection.certificates.some( + (c) => c.certificateType === 'register_reward_account' + ); + if (walletNeedsRegisteringRewardAccount) { + certificates = [ + { + certificateType: 'register_reward_account', + rewardAccountPath: ['1852H', '1815H', '0H', '2', '0'], + }, + ...certificates, + ]; + } + + const coinSelection = { + ...initialCoinSelection, + certificates, + }; - if (wallet.isHardwareWallet) { this.stores.hardwareWallets.updateTxSignRequest(coinSelection); this.stores.hardwareWallets.initiateTransaction({ walletId: wallet.id, }); + + return { + success: true, + fees: coinSelection.fee, + }; + } catch (error) { + return { + success: false, + errorCode: parseApiCode( + expectedInitializeVPDelegationTxErrors, + error + ), + }; } + } + + this.constructTxRequest.reset(); + try { + const constructedTx = await this.constructTxRequest.execute({ + walletId: wallet.id, + data: { vote: chosenOption }, + }).promise; return { success: true, - fees, + fees: constructedTx.fee, }; } catch (error) { return { @@ -310,7 +381,49 @@ export default class VotingStore extends Store { wallet: Wallet; }) => { // TODO: handle HW case - if (wallet.isHardwareWallet) return; + if (wallet.isHardwareWallet) { + try { + await this.stores.hardwareWallets._sendMoney({ + selectedWalletId: wallet.id, + }); + + await new Promise((resolve) => { + const wait = () => { + setTimeout(() => { + const { + sendMoneyRequest, + isTransactionPending, + } = this.stores.hardwareWallets; + if (sendMoneyRequest.isExecuting || isTransactionPending) { + wait(); + return; + } + + resolve(); + }, 2000); + }; + + wait(); + }); + + this.analytics.sendEvent( + EventCategories.VOTING, + 'Casted governance vote', + chosenOption, // 'abstain' | 'no_confidence' | 'drep' + wallet.amount.toNumber() // ADA amount as float with 6 decimal precision + ); + + return { + success: true, + }; + } catch (error) { + const errorCode: GenericErrorCode = 'generic'; + return { + success: false, + errorCode, + }; + } + } this.delegateVotesRequest.reset(); try { diff --git a/source/renderer/app/utils/dataSerialization.ts b/source/renderer/app/utils/dataSerialization.ts index ac110b5981..7479b2f196 100644 --- a/source/renderer/app/utils/dataSerialization.ts +++ b/source/renderer/app/utils/dataSerialization.ts @@ -177,20 +177,38 @@ export const toTxOutputAssets = (assets: CoinSelectionAssetsType) => { return policyIdMap; }; +const parseVoteDelegation = (vote: string): [number] | [number, Buffer] => { + if (!vote) throw new Error('Invalid voting power option'); + if (vote === 'abstain') return [2]; + if (vote === 'no_confidence') return [3]; + + const voteHash = Buffer.from( + utils.buf_to_hex(utils.bech32_decodeAddress(vote)), + 'hex' + ); + return [vote.includes('_script') ? 1 : 0, voteHash]; +}; + export function toTxCertificate(cert: { type: string; accountAddress: string; pool: string | null | undefined; + vote?: string; }) { - const { type, accountAddress, pool } = cert; + const { type, accountAddress, pool, vote } = cert; let hash; let poolHash; + let drep; if (pool) { poolHash = utils.buf_to_hex(utils.bech32_decodeAddress(pool)); hash = Buffer.from(poolHash, 'hex'); } + if (vote) { + drep = parseVoteDelegation(vote); + } + function encodeCBOR(encoder: any) { const accountAddressHash = utils .bech32_decodeAddress(accountAddress) @@ -200,6 +218,7 @@ export function toTxCertificate(cert: { [0]: [type, account], [1]: [type, account], [2]: [type, account, hash], + [9]: [type, account, drep], }; return encoder.pushAny(encodedCertsTypes[type]); } diff --git a/source/renderer/app/utils/hardwareWalletUtils.ts b/source/renderer/app/utils/hardwareWalletUtils.ts index c741fcdf53..1c3de3d387 100644 --- a/source/renderer/app/utils/hardwareWalletUtils.ts +++ b/source/renderer/app/utils/hardwareWalletUtils.ts @@ -21,6 +21,7 @@ export const CERTIFICATE_TYPE = { quit_pool: 1, // quit_pool join_pool: 2, // join_pool + cast_vote: 9, // join_pool }; export const PATH_ROLE_IDENTITY = { role0: 'utxo_external', diff --git a/source/renderer/app/utils/shelleyLedger.ts b/source/renderer/app/utils/shelleyLedger.ts index 4602e00ade..0b80074c5f 100644 --- a/source/renderer/app/utils/shelleyLedger.ts +++ b/source/renderer/app/utils/shelleyLedger.ts @@ -1,30 +1,32 @@ import _ from 'lodash'; import { - utils, - TxOutputDestinationType, AddressType, - TxAuxiliaryDataType, // CHECK THIS - CredentialParamsType, CIP36VoteRegistrationFormat, + CredentialParamsType, + DRepParams, + DRepParamsType, + TxAuxiliaryDataType, + TxOutputDestinationType, + utils, } from '@cardano-foundation/ledgerjs-hw-app-cardano'; import { - str_to_path, base58_decode, + str_to_path, } from '@cardano-foundation/ledgerjs-hw-app-cardano/dist/utils/address'; import { - derivationPathToLedgerPath, + CATALYST_VOTING_REGISTRATION_TYPE, CERTIFICATE_TYPE, + derivationPathToLedgerPath, groupTokensByPolicyId, - CATALYST_VOTING_REGISTRATION_TYPE, } from './hardwareWalletUtils'; import { AddressStyles } from '../domains/WalletAddress'; import type { AddressStyle } from '../api/addresses/types'; import type { + CoinSelectionAssetsType, + CoinSelectionCertificate, CoinSelectionInput, CoinSelectionOutput, - CoinSelectionCertificate, CoinSelectionWithdrawal, - CoinSelectionAssetsType, } from '../api/transactions/types'; import { TxAuxiliaryData } from './dataSerialization'; @@ -47,6 +49,39 @@ export const toTokenBundle = (assets: CoinSelectionAssetsType) => { return tokenBundle; }; +const parseVoteDelegation = ( + cert: CoinSelectionCertificate +): DRepParams | undefined => { + if (cert.certificateType !== 'cast_vote' || !('vote' in cert)) + return undefined; + + if (cert.vote === 'abstain') { + return { + type: DRepParamsType.ABSTAIN, + }; + } + + if (cert.vote === 'no_confidence') { + return { + type: DRepParamsType.NO_CONFIDENCE, + }; + } + + const votHash = utils.buf_to_hex(utils.bech32_decodeAddress(cert.vote)); + + if (cert.vote.includes('_script')) { + return { + type: DRepParamsType.SCRIPT_HASH, + scriptHashHex: votHash, + }; + } + + return { + type: DRepParamsType.KEY_HASH, + keyHashHex: votHash, + }; +}; + export const toLedgerCertificate = (cert: CoinSelectionCertificate) => { return { type: CERTIFICATE_TYPE[cert.certificateType], @@ -58,6 +93,7 @@ export const toLedgerCertificate = (cert: CoinSelectionCertificate) => { poolKeyHashHex: cert.pool ? utils.buf_to_hex(utils.bech32_decodeAddress(cert.pool)) : null, + dRep: parseVoteDelegation(cert), }, }; }; diff --git a/source/renderer/app/utils/shelleyTrezor.ts b/source/renderer/app/utils/shelleyTrezor.ts index 6f3a53be67..2d33611c19 100644 --- a/source/renderer/app/utils/shelleyTrezor.ts +++ b/source/renderer/app/utils/shelleyTrezor.ts @@ -1,5 +1,6 @@ import { utils } from '@cardano-foundation/ledgerjs-hw-app-cardano'; import { Messages } from '@trezor/transport'; +import { CardanoDRep, PROTO } from '@trezor/connect'; import { map } from 'lodash'; import { derivationPathToString, @@ -52,6 +53,35 @@ export const toTrezorOutput = (output: CoinSelectionOutput) => { tokenBundle, }; }; + +const parseVoteDelegation = (cert: CoinSelectionCertificate): CardanoDRep => { + if (cert.vote === 'abstain') { + return { + type: PROTO.CardanoDRepType.ABSTAIN, + }; + } + + if (cert.vote === 'no_confidence') { + return { + type: PROTO.CardanoDRepType.NO_CONFIDENCE, + }; + } + + const voteHash = utils.bech32_decodeAddress(cert.vote).toString('hex'); + + if (cert.vote.includes('_script')) { + return { + type: PROTO.CardanoDRepType.SCRIPT_HASH, + scriptHash: voteHash, + }; + } + + return { + type: PROTO.CardanoDRepType.KEY_HASH, + keyHash: voteHash, + }; +}; + export const toTrezorCertificate = (cert: CoinSelectionCertificate) => { if (cert.pool) { return { @@ -61,6 +91,14 @@ export const toTrezorCertificate = (cert: CoinSelectionCertificate) => { }; } + if (cert.certificateType === 'cast_vote' && 'vote' in cert) { + return { + type: PROTO.CardanoCertificateType.VOTE_DELEGATION, + path: derivationPathToString(cert.rewardAccountPath), + dRep: parseVoteDelegation(cert), + }; + } + return { type: CERTIFICATE_TYPE[cert.certificateType], path: derivationPathToString(cert.rewardAccountPath),