diff --git a/package.json b/package.json index 27d2c13f96..69334f5285 100644 --- a/package.json +++ b/package.json @@ -196,7 +196,7 @@ "yarn-lockfile": "1.1.1" }, "dependencies": { - "@cardano-foundation/ledgerjs-hw-app-cardano": "7.1.3", + "@cardano-foundation/ledgerjs-hw-app-cardano": "^7.1.4", "@iohk-jormungandr/wallet-js": "0.5.0-pre7", "@ledgerhq/hw-transport-node-hid": "6.27.15", "@trezor/connect": "9.3.0", 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..2c9fd47263 100644 --- a/source/renderer/app/utils/dataSerialization.ts +++ b/source/renderer/app/utils/dataSerialization.ts @@ -177,20 +177,35 @@ export const toTxOutputAssets = (assets: CoinSelectionAssetsType) => { return policyIdMap; }; +const parseVoteDelegation = (vote: string): [number] | [number, string] => { + if (!vote) throw new Error('Invalid voting power option'); + if (vote === 'abstain') return [2]; + if (vote === 'no_confidence') return [3]; + + const voteHash = utils.buf_to_hex(utils.bech32_decodeAddress(vote)); + 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 +215,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..af87c3eea8 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,37 @@ 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, + }; + } + + if (cert.vote.includes('_script')) { + return { + type: DRepParamsType.SCRIPT_HASH, + scriptHashHex: cert.vote, + }; + } + + return { + type: DRepParamsType.KEY_HASH, + keyHashHex: cert.vote, + }; +}; + export const toLedgerCertificate = (cert: CoinSelectionCertificate) => { return { type: CERTIFICATE_TYPE[cert.certificateType], @@ -58,6 +91,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/yarn.lock b/yarn.lock index 75777d73ac..a365c85a83 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1208,12 +1208,12 @@ version "0.2.3" resolved "https://registry.yarnpkg.com/@bcoe/v8-coverage/-/v8-coverage-0.2.3.tgz#75a2e8b51cb758a7553d6804a5932d7aace75c39" -"@cardano-foundation/ledgerjs-hw-app-cardano@7.1.3": - version "7.1.3" - resolved "https://registry.yarnpkg.com/@cardano-foundation/ledgerjs-hw-app-cardano/-/ledgerjs-hw-app-cardano-7.1.3.tgz#1d8bb05993c3e061029defff36e8d707221c05d5" - integrity sha512-1cW5WgF2pkla2pvNp/lGsPfdDh8FjnRRLUaHsyxgZd2NNtr939F9k5R/ExCuJS0Ish4wnArucbPAZ15Cq7PIUA== +"@cardano-foundation/ledgerjs-hw-app-cardano@^7.1.4": + version "7.1.4" + resolved "https://registry.yarnpkg.com/@cardano-foundation/ledgerjs-hw-app-cardano/-/ledgerjs-hw-app-cardano-7.1.4.tgz#e3e484edf950a871d3d3c87750077565162eee9f" + integrity sha512-bkZ78H0m6E22Fe4nN+K0HY0O2lrPk9Pjs/gv0U5xvJyrMqwmR4wm9h8QXd/AwJ084KIhfpCSGDCQ0CN/K++vNw== dependencies: - "@ledgerhq/hw-transport" "^6.27.10" + "@ledgerhq/hw-transport" "^6.31.2" base-x "^3.0.5" bech32 "^1.1.4" int64-buffer "^1.0.1" @@ -1808,15 +1808,6 @@ "@jridgewell/resolve-uri" "^3.0.3" "@jridgewell/sourcemap-codec" "^1.4.10" -"@ledgerhq/devices@^8.0.0": - version "8.0.0" - resolved "https://registry.yarnpkg.com/@ledgerhq/devices/-/devices-8.0.0.tgz#8fe9f9e442e28b7a20bcdf4c2eed06ce7b8f76ae" - dependencies: - "@ledgerhq/errors" "^6.12.3" - "@ledgerhq/logs" "^6.10.1" - rxjs "6" - semver "^7.3.5" - "@ledgerhq/devices@^8.0.3": version "8.0.3" resolved "https://registry.yarnpkg.com/@ledgerhq/devices/-/devices-8.0.3.tgz#bca76789b9dec2353ea8b089f7bd183ed3047afd" @@ -1827,15 +1818,26 @@ rxjs "6" semver "^7.3.5" -"@ledgerhq/errors@^6.12.3": - version "6.12.3" - resolved "https://registry.yarnpkg.com/@ledgerhq/errors/-/errors-6.12.3.tgz#a610caae1eeeb7cb038525e5212fe03217dda683" +"@ledgerhq/devices@^8.4.4": + version "8.4.4" + resolved "https://registry.yarnpkg.com/@ledgerhq/devices/-/devices-8.4.4.tgz#0d195c1650fe57da2fad7f0d9074a0190947cd6f" + integrity sha512-sz/ryhe/R687RHtevIE9RlKaV8kkKykUV4k29e7GAVwzHX1gqG+O75cu1NCJUHLbp3eABV5FdvZejqRUlLis9A== + dependencies: + "@ledgerhq/errors" "^6.19.1" + "@ledgerhq/logs" "^6.12.0" + rxjs "^7.8.1" + semver "^7.3.5" "@ledgerhq/errors@^6.12.6": version "6.12.6" resolved "https://registry.yarnpkg.com/@ledgerhq/errors/-/errors-6.12.6.tgz#f89c82c91c2930f34bc3e0d86a27ec7b6e6e4f5f" integrity sha512-D+r2B09vaRO06wfGoss+rNgwqWSoK0bCtsaJWzlD2hv1zxTtucqVtSztbRFypIqxWTCb3ix5Nh2dWHEJVTp2Xw== +"@ledgerhq/errors@^6.19.1": + version "6.19.1" + resolved "https://registry.yarnpkg.com/@ledgerhq/errors/-/errors-6.19.1.tgz#d9ac45ad4ff839e468b8f63766e665537aaede58" + integrity sha512-75yK7Nnit/Gp7gdrJAz0ipp31CCgncRp+evWt6QawQEtQKYEDfGo10QywgrrBBixeRxwnMy1DP6g2oCWRf1bjw== + "@ledgerhq/hw-transport-node-hid-noevents@^6.27.15": version "6.27.15" resolved "https://registry.yarnpkg.com/@ledgerhq/hw-transport-node-hid-noevents/-/hw-transport-node-hid-noevents-6.27.15.tgz#fbf968ac6fa9345f2642da609f3bdf3236a3ae37" @@ -1861,14 +1863,6 @@ node-hid "^2.1.2" usb "^1.7.0" -"@ledgerhq/hw-transport@^6.27.10": - version "6.28.1" - resolved "https://registry.yarnpkg.com/@ledgerhq/hw-transport/-/hw-transport-6.28.1.tgz#cb22fe9bc23af4682c30f2aac7fe6f7ab13ed65a" - dependencies: - "@ledgerhq/devices" "^8.0.0" - "@ledgerhq/errors" "^6.12.3" - events "^3.3.0" - "@ledgerhq/hw-transport@^6.28.4": version "6.28.4" resolved "https://registry.yarnpkg.com/@ledgerhq/hw-transport/-/hw-transport-6.28.4.tgz#c2fc5bff4fca71ac44f069b775d33d0b1b5d9000" @@ -1878,10 +1872,25 @@ "@ledgerhq/errors" "^6.12.6" events "^3.3.0" +"@ledgerhq/hw-transport@^6.31.2": + version "6.31.4" + resolved "https://registry.yarnpkg.com/@ledgerhq/hw-transport/-/hw-transport-6.31.4.tgz#9b23a6de4a4caaa5c24b149c2dea8adde46f0eb1" + integrity sha512-6c1ir/cXWJm5dCWdq55NPgCJ3UuKuuxRvf//Xs36Bq9BwkV2YaRQhZITAkads83l07NAdR16hkTWqqpwFMaI6A== + dependencies: + "@ledgerhq/devices" "^8.4.4" + "@ledgerhq/errors" "^6.19.1" + "@ledgerhq/logs" "^6.12.0" + events "^3.3.0" + "@ledgerhq/logs@^6.10.1": version "6.10.1" resolved "https://registry.yarnpkg.com/@ledgerhq/logs/-/logs-6.10.1.tgz#5bd16082261d7364eabb511c788f00937dac588d" +"@ledgerhq/logs@^6.12.0": + version "6.12.0" + resolved "https://registry.yarnpkg.com/@ledgerhq/logs/-/logs-6.12.0.tgz#ad903528bf3687a44da435d7b2479d724d374f5d" + integrity sha512-ExDoj1QV5eC6TEbMdLUMMk9cfvNKhhv5gXol4SmULRVCx/3iyCPhJ74nsb3S0Vb+/f+XujBEj3vQn5+cwS0fNA== + "@leichtgewicht/ip-codec@^2.0.1": version "2.0.3" resolved "https://registry.yarnpkg.com/@leichtgewicht/ip-codec/-/ip-codec-2.0.3.tgz#0300943770e04231041a51bd39f0439b5c7ab4f0" @@ -15809,6 +15818,13 @@ rxjs@^7.5.4: dependencies: tslib "^2.1.0" +rxjs@^7.8.1: + version "7.8.1" + resolved "https://registry.yarnpkg.com/rxjs/-/rxjs-7.8.1.tgz#6f6f3d99ea8044291efd92e7c7fcf562c4057543" + integrity sha512-AA3TVj+0A2iuIoQkWEK/tqFjBq2j+6PO6Y0zJcvzLAFhEFIO3HL0vls9hWLncZbAAbK0mar7oZ4V079I/qPMxg== + dependencies: + tslib "^2.1.0" + safe-buffer@5.1.1: version "5.1.1" resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.1.1.tgz#893312af69b2123def71f57889001671eeb2c853"