diff --git a/CHANGELOG.md b/CHANGELOG.md index 0916f2f7db..4816319034 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,10 +2,18 @@ ## vNext +### Features + +- Added Cardano governance voting tab ([PR 3240](https://github.com/input-output-hk/daedalus/pull/3240)) + ### Chores - Update `cardano-node` to 10.1.2 via `cardano-wallet` v2024-11-18 ([PR 3229](https://github.com/input-output-hk/daedalus/pull/3229)) +### Fixes + +- Handle createTransaction error when Conway era wallet has staking rewards but has not participated in governance yet ([PR 3237](https://github.com/input-output-hk/daedalus/pull/3237) + ## 6.0.2 ### Fixes diff --git a/source/common/utils/assertIsBech32WithPrefix.ts b/source/common/utils/assertIsBech32WithPrefix.ts new file mode 100644 index 0000000000..bafa43fd64 --- /dev/null +++ b/source/common/utils/assertIsBech32WithPrefix.ts @@ -0,0 +1,34 @@ +import { bech32, Decoded } from 'bech32'; + +const MAX_BECH32_LENGTH_LIMIT = 1023; + +const isOneOf = (target: T, options: T | T[]) => + (Array.isArray(options) && options.includes(target)) || target === options; + +export const assertIsBech32WithPrefix = ( + target: string, + prefix: string | string[], + expectedDecodedLength?: number | number[] +): void => { + let decoded: Decoded; + try { + decoded = bech32.decode(target, MAX_BECH32_LENGTH_LIMIT); + } catch (error) { + throw new Error( + `expected bech32-encoded string with '${prefix}' prefix; ${error}` + ); + } + if (!isOneOf(decoded.prefix, prefix)) { + throw new Error( + `expected bech32 prefix '${prefix}', got '${decoded.prefix}''` + ); + } + if ( + expectedDecodedLength && + !isOneOf(decoded.words.length, expectedDecodedLength) + ) { + throw new Error( + `expected decoded length of '${expectedDecodedLength}', got '${decoded.words.length}'` + ); + } +}; diff --git a/source/renderer/app/Routes.tsx b/source/renderer/app/Routes.tsx index 7f6129b635..03de8e302f 100644 --- a/source/renderer/app/Routes.tsx +++ b/source/renderer/app/Routes.tsx @@ -36,6 +36,8 @@ import VotingRegistrationPage from './containers/voting/VotingRegistrationPage'; import { IS_STAKING_INFO_PAGE_AVAILABLE } from './config/stakingConfig'; import AnalyticsConsentPage from './containers/profile/AnalyticsConsentPage'; import TrackedRoute from './analytics/TrackedRoute'; +import Voting from './containers/voting/Voting'; +import VotingGovernancePage from './containers/voting/VotingGovernancePage'; export const Routes = withRouter(() => ( @@ -205,11 +207,20 @@ export const Routes = withRouter(() => ( component={RedeemItnRewardsContainer} /> - + + + + + + diff --git a/source/renderer/app/analytics/MatomoAnalyticsTracker.ts b/source/renderer/app/analytics/MatomoAnalyticsTracker.ts index 62aa224be4..5943fd0c41 100644 --- a/source/renderer/app/analytics/MatomoAnalyticsTracker.ts +++ b/source/renderer/app/analytics/MatomoAnalyticsTracker.ts @@ -34,8 +34,8 @@ export class MatomoAnalyticsTracker implements AnalyticsTracker { return this.#analyticsClient.sendPageNavigationEvent(pageTitle); } - sendEvent(category: string, name: string, action?: string) { - return this.#analyticsClient.sendEvent(category, name, action); + sendEvent(category: string, name: string, action?: string, value?: number) { + return this.#analyticsClient.sendEvent(category, name, action, value); } async #enableTrackingIfAccepted() { diff --git a/source/renderer/app/analytics/MatomoClient.ts b/source/renderer/app/analytics/MatomoClient.ts index dc16024049..19aa2b65c0 100644 --- a/source/renderer/app/analytics/MatomoClient.ts +++ b/source/renderer/app/analytics/MatomoClient.ts @@ -44,7 +44,8 @@ export class MatomoClient implements AnalyticsClient { sendEvent = async ( category: string, action: string, - name?: string + name?: string, + value?: number ): Promise => { this.matomoTracker.track({ _id: this.userId, @@ -52,6 +53,7 @@ export class MatomoClient implements AnalyticsClient { e_c: category, e_a: action, e_n: name, + e_v: value, url: this.getAnalyticsURL(), }); }; diff --git a/source/renderer/app/analytics/types.ts b/source/renderer/app/analytics/types.ts index 56737d3a6b..aa0e40b58e 100644 --- a/source/renderer/app/analytics/types.ts +++ b/source/renderer/app/analytics/types.ts @@ -1,6 +1,11 @@ export interface AnalyticsClient { sendPageNavigationEvent(pageTitle: string): Promise; - sendEvent(category: string, action: string, name?: string): Promise; + sendEvent( + category: string, + action: string, + name?: string, + value?: number + ): Promise; } export enum AnalyticsAcceptanceStatus { @@ -13,7 +18,12 @@ export interface AnalyticsTracker { enableTracking(): Promise; disableTracking(): void; sendPageNavigationEvent(pageTitle: string): void; - sendEvent(category: EventCategories, name: string, action?: string): void; + sendEvent( + category: EventCategories, + name: string, + action?: string, + value?: number + ): void; } export enum EventCategories { diff --git a/source/renderer/app/api/api.ts b/source/renderer/app/api/api.ts index d88c4a64e5..145750657d 100644 --- a/source/renderer/app/api/api.ts +++ b/source/renderer/app/api/api.ts @@ -34,12 +34,16 @@ import { getWithdrawalHistory } from './transactions/requests/getWithdrawalHisto import { createTransaction } from './transactions/requests/createTransaction'; import { createByronWalletTransaction } from './transactions/requests/createByronWalletTransaction'; import { deleteLegacyTransaction } from './transactions/requests/deleteLegacyTransaction'; -import { selectCoins } from './transactions/requests/selectCoins'; +import { + selectCoins, + SelectCoinsResponseType, +} from './transactions/requests/selectCoins'; import { createExternalTransaction } from './transactions/requests/createExternalTransaction'; import { getPublicKey } from './transactions/requests/getPublicKey'; import { getICOPublicKey } from './transactions/requests/getICOPublicKey'; // Voting requests import { createWalletSignature } from './voting/requests/createWalletSignature'; +import { delegateVotes } from './voting/requests/delegateVotes'; import { getCatalystFund } from './voting/requests/getCatalystFund'; // Wallets requests import { updateSpendingPassword } from './wallets/requests/updateSpendingPassword'; @@ -148,6 +152,7 @@ import { GetWithdrawalsResponse, VotingMetadataType, ICOPublicKeyParams, + ConstructTransactionData, } from './transactions/types'; // Wallets Types import { @@ -207,6 +212,7 @@ import type { CreateWalletSignatureRequest, GetCatalystFundResponse, CatalystFund, + DelegateVotesParams, } from './voting/types'; import type { StakePoolProps } from '../domains/StakePool'; import type { FaultInjectionIpcRequest } from '../../../common/types/cardano-node.types'; @@ -229,6 +235,117 @@ import { getAccountPublicKey } from './wallets/requests/getAccountPublicKey'; import { doesWalletRequireAdaToRemainToSupportTokens } from './utils/apiHelpers'; import { AssetLocalData } from '../types/localDataTypes'; import { handleNotEnoughMoneyError } from './errors'; +import { constructTransaction } from './transactions/requests/constructTransaction'; + +const parseCoinSelectionResponse = ({ + delegation, + coinSelectionResponse: response, +}: { + delegation?: CoinSelectionsDelegationRequestType; + coinSelectionResponse: SelectCoinsResponseType; +}) => { + // @TODO - handle CHANGE parameter on smarter way and change corresponding downstream logic + const outputs = concat(response.outputs, response.change); + // Calculate fee from inputs and outputs + const inputsData = []; + const outputsData = []; + const certificatesData = []; + let totalInputs = new BigNumber(0); + let totalOutputs = new BigNumber(0); + map(response.inputs, (input) => { + const inputAmount = new BigNumber(input.amount.quantity.toString()); + // @ts-ignore ts-migrate(2339) FIXME: Property 'assets' does not exist on type 'unknown'... Remove this comment to see the full error message + const inputAssets = map(input.assets, (asset) => ({ + policyId: asset.policy_id, + assetName: asset.asset_name, + quantity: asset.quantity, + })); + totalInputs = totalInputs.plus(inputAmount); + const inputData = { + address: input.address, + amount: input.amount, + id: input.id, + index: input.index, + derivationPath: input.derivation_path, + assets: inputAssets, + }; + // @ts-ignore ts-migrate(2339) FIXME: Property 'push' does not exist on type '{}'. + inputsData.push(inputData); + }); + map(outputs, (output) => { + const outputAmount = new BigNumber(output.amount.quantity.toString()); + // @ts-ignore ts-migrate(2339) FIXME: Property 'assets' does not exist on type 'unknown'... Remove this comment to see the full error message + const outputAssets = map(output.assets, (asset) => ({ + policyId: asset.policy_id, + assetName: asset.asset_name, + quantity: asset.quantity, + })); + totalOutputs = totalOutputs.plus(outputAmount); + const outputData = { + address: output.address, + amount: output.amount, + derivationPath: output.derivation_path || null, + assets: outputAssets, + }; + // @ts-ignore ts-migrate(2339) FIXME: Property 'push' does not exist on type '{}'. + outputsData.push(outputData); + }); + + if (response.certificates) { + map(response.certificates, (certificate) => { + const certificateData = { + certificateType: certificate.certificate_type, + rewardAccountPath: certificate.reward_account_path, + pool: certificate.pool || null, + }; + // @ts-ignore ts-migrate(2339) FIXME: Property 'push' does not exist on type '{}'. + certificatesData.push(certificateData); + }); + } + + const withdrawalsData = map(response.withdrawals, (withdrawal) => ({ + stakeAddress: withdrawal.stake_address, + derivationPath: withdrawal.derivation_path, + amount: withdrawal.amount, + })); + const depositsArray = map(response.deposits_taken, (deposit) => + deposit.quantity.toString() + ); + const deposits = depositsArray.length + ? BigNumber.sum.apply(null, depositsArray) + : new BigNumber(0); + // @TODO - Use API response + // https://bump.sh/doc/cardano-wallet-diff/changes/c11ebb1b-39c1-40b6-96b9-610705c62cb8#operation-selectcoins-200-deposits_returned + const depositsReclaimed = + delegation && delegation.delegationAction === DELEGATION_ACTIONS.QUIT + ? new BigNumber(DELEGATION_DEPOSIT).multipliedBy(LOVELACES_PER_ADA) + : new BigNumber(0); + const withdrawalsArray = map(response.withdrawals, (withdrawal) => + withdrawal.amount.quantity.toString() + ); + const withdrawals = withdrawalsArray.length + ? BigNumber.sum.apply(null, withdrawalsArray) + : new BigNumber(0); + + if (withdrawals) { + totalOutputs = totalOutputs.minus(withdrawals); + } + + const fee = + delegation && delegation.delegationAction === DELEGATION_ACTIONS.QUIT + ? totalInputs.minus(totalOutputs).plus(depositsReclaimed) + : totalInputs.minus(totalOutputs).minus(deposits); + return { + inputs: inputsData, + outputs: outputsData, + certificates: certificatesData, + withdrawals: withdrawals.gt(0) ? withdrawalsData : [], + fee: fee.dividedBy(LOVELACES_PER_ADA), + deposits: deposits.dividedBy(LOVELACES_PER_ADA), + depositsReclaimed: depositsReclaimed.dividedBy(LOVELACES_PER_ADA), + metadata: response.metadata || null, + }; +}; export default class AdaApi { config: RequestConfig; @@ -899,6 +1016,9 @@ export default class AdaApi { .set('wrongEncryptionPassphrase') .where('code', 'bad_request') .inc('message', 'passphrase is too short') + .set('conwayWalletNotDelegatedToDRep') + .where('code', 'created_invalid_transaction') + .inc('message', 'ConwayWdrlNotDelegatedToDRep') .set('transactionIsTooBig', true, { linkLabel: 'tooBigTransactionErrorLinkLabel', linkURL: 'tooBigTransactionErrorLinkURL', @@ -1121,111 +1241,16 @@ export default class AdaApi { throw new Error('Missing parameters!'); } - const response = await selectCoins(this.config, { + const coinSelectionResponse = await selectCoins(this.config, { walletId, data, }); - // @TODO - handle CHANGE parameter on smarter way and change corresponding downstream logic - const outputs = concat(response.outputs, response.change); - // Calculate fee from inputs and outputs - const inputsData = []; - const outputsData = []; - const certificatesData = []; - let totalInputs = new BigNumber(0); - let totalOutputs = new BigNumber(0); - map(response.inputs, (input) => { - const inputAmount = new BigNumber(input.amount.quantity.toString()); - // @ts-ignore ts-migrate(2339) FIXME: Property 'assets' does not exist on type 'unknown'... Remove this comment to see the full error message - const inputAssets = map(input.assets, (asset) => ({ - policyId: asset.policy_id, - assetName: asset.asset_name, - quantity: asset.quantity, - })); - totalInputs = totalInputs.plus(inputAmount); - const inputData = { - address: input.address, - amount: input.amount, - id: input.id, - index: input.index, - derivationPath: input.derivation_path, - assets: inputAssets, - }; - // @ts-ignore ts-migrate(2339) FIXME: Property 'push' does not exist on type '{}'. - inputsData.push(inputData); - }); - map(outputs, (output) => { - const outputAmount = new BigNumber(output.amount.quantity.toString()); - // @ts-ignore ts-migrate(2339) FIXME: Property 'assets' does not exist on type 'unknown'... Remove this comment to see the full error message - const outputAssets = map(output.assets, (asset) => ({ - policyId: asset.policy_id, - assetName: asset.asset_name, - quantity: asset.quantity, - })); - totalOutputs = totalOutputs.plus(outputAmount); - const outputData = { - address: output.address, - amount: output.amount, - derivationPath: output.derivation_path || null, - assets: outputAssets, - }; - // @ts-ignore ts-migrate(2339) FIXME: Property 'push' does not exist on type '{}'. - outputsData.push(outputData); - }); - - if (response.certificates) { - map(response.certificates, (certificate) => { - const certificateData = { - certificateType: certificate.certificate_type, - rewardAccountPath: certificate.reward_account_path, - pool: certificate.pool || null, - }; - // @ts-ignore ts-migrate(2339) FIXME: Property 'push' does not exist on type '{}'. - certificatesData.push(certificateData); - }); - } - const withdrawalsData = map(response.withdrawals, (withdrawal) => ({ - stakeAddress: withdrawal.stake_address, - derivationPath: withdrawal.derivation_path, - amount: withdrawal.amount, - })); - const depositsArray = map(response.deposits_taken, (deposit) => - deposit.quantity.toString() - ); - const deposits = depositsArray.length - ? BigNumber.sum.apply(null, depositsArray) - : new BigNumber(0); - // @TODO - Use API response - // https://bump.sh/doc/cardano-wallet-diff/changes/c11ebb1b-39c1-40b6-96b9-610705c62cb8#operation-selectcoins-200-deposits_returned - const depositsReclaimed = - delegation && delegation.delegationAction === DELEGATION_ACTIONS.QUIT - ? new BigNumber(DELEGATION_DEPOSIT).multipliedBy(LOVELACES_PER_ADA) - : new BigNumber(0); - const withdrawalsArray = map(response.withdrawals, (withdrawal) => - withdrawal.amount.quantity.toString() - ); - const withdrawals = withdrawalsArray.length - ? BigNumber.sum.apply(null, withdrawalsArray) - : new BigNumber(0); - - if (withdrawals) { - totalOutputs = totalOutputs.minus(withdrawals); - } + const extendedResponse = parseCoinSelectionResponse({ + delegation, + coinSelectionResponse, + }); - const fee = - delegation && delegation.delegationAction === DELEGATION_ACTIONS.QUIT - ? totalInputs.minus(totalOutputs).plus(depositsReclaimed) - : totalInputs.minus(totalOutputs).minus(deposits); - const extendedResponse = { - inputs: inputsData, - outputs: outputsData, - certificates: certificatesData, - withdrawals: withdrawals.gt(0) ? withdrawalsData : [], - fee: fee.dividedBy(LOVELACES_PER_ADA), - deposits: deposits.dividedBy(LOVELACES_PER_ADA), - depositsReclaimed: depositsReclaimed.dividedBy(LOVELACES_PER_ADA), - metadata: response.metadata || null, - }; logger.debug('AdaApi::selectCoins success', { extendedResponse, }); @@ -1242,6 +1267,41 @@ export default class AdaApi { }); } }; + + constructTransaction = async (params: ConstructTransactionData) => { + logger.debug('AdaApi::delegateVotes called', { + parameters: filterLogData(params), + }); + + try { + const { + transaction, + coin_selection, + fee: { quantity }, + } = await constructTransaction(this.config, params); + + const result = { + transaction, + coinSelection: parseCoinSelectionResponse({ + coinSelectionResponse: coin_selection, + }), + fee: new BigNumber(quantity.toString()).dividedBy(LOVELACES_PER_ADA), + }; + + logger.debug('AdaApi::constructTransaction success', { + result, + }); + + return result; + } catch (error) { + logger.debug('AdaApi::constructTransaction error', { + error, + }); + + throw new ApiError(error); + } + }; + createExternalTransaction = async ( request: CreateExternalTransactionRequest ): Promise => { @@ -2747,6 +2807,28 @@ export default class AdaApi { throw new ApiError(error); } }; + + delegateVotes = async (params: DelegateVotesParams) => { + logger.debug('AdaApi::delegateVotes called', { + parameters: filterLogData(params), + }); + + try { + const response = await delegateVotes(this.config, params); + logger.debug('AdaApi::delegateVotes success', { + response, + }); + + return response; + } catch (error) { + logger.debug('AdaApi::delegateVotes error', { + error, + }); + + throw new ApiError(error); + } + }; + createVotingRegistrationTransaction = async ( request: CreateVotingRegistrationRequest ): Promise => { @@ -3086,16 +3168,22 @@ const _createTransactionFromServerData = action( address, }) ); + + const isVoteTx = data.certificates.find( + (c) => c.certificate_type === 'cast_vote' + ); + const otherTxType = + direction === 'outgoing' + ? TransactionTypes.EXPEND + : TransactionTypes.INCOME; + return new WalletTransaction({ id, confirmations, slotNumber, epochNumber, title: direction === 'outgoing' ? 'Ada sent' : 'Ada received', - type: - direction === 'outgoing' - ? TransactionTypes.EXPEND - : TransactionTypes.INCOME, + type: isVoteTx ? TransactionTypes.VOTE : otherTxType, amount: new BigNumber( direction === 'outgoing' ? `-${amount.quantity.toString()}` diff --git a/source/renderer/app/api/errors.ts b/source/renderer/app/api/errors.ts index acac84ba15..1146da01bc 100644 --- a/source/renderer/app/api/errors.ts +++ b/source/renderer/app/api/errors.ts @@ -121,6 +121,12 @@ export const messages = defineMessages({ description: '"Balance after transaction would not leave enough ada in the wallet to support tokens remaining in wallet', }, + conwayWalletNotDelegatedToDRep: { + id: 'api.errors.conwayWalletNotDelegatedToDRep', + defaultMessage: '!!!conwayWalletNotDelegatedToDRep', + description: + 'Error message shown when conway era wallet has staking rewards but has not participated in governance yet.', + }, }); type Balances = { diff --git a/source/renderer/app/api/transactions/requests/constructTransaction.ts b/source/renderer/app/api/transactions/requests/constructTransaction.ts new file mode 100644 index 0000000000..b8b78a3225 --- /dev/null +++ b/source/renderer/app/api/transactions/requests/constructTransaction.ts @@ -0,0 +1,20 @@ +import type { RequestConfig } from '../../common/types'; +import { + ConstructTransactionData, + ConstructTransactionResponse, +} from '../types'; +import { request } from '../../utils/request'; + +export const constructTransaction = ( + config: RequestConfig, + { walletId, data }: ConstructTransactionData +): Promise => + request( + { + method: 'POST', + path: `/v2/wallets/${walletId}/transactions-construct`, + ...config, + }, + {}, + data + ); diff --git a/source/renderer/app/api/transactions/types.ts b/source/renderer/app/api/transactions/types.ts index 45328b04b7..5ec16decc8 100644 --- a/source/renderer/app/api/transactions/types.ts +++ b/source/renderer/app/api/transactions/types.ts @@ -5,6 +5,7 @@ import type { DelegationAction } from '../../types/stakingTypes'; import type { ApiTokens } from '../assets/types'; import type { TransactionMetadata } from '../../types/TransactionMetadata'; import type { PathRoleIdentityType } from '../../utils/hardwareWalletUtils'; +import { SelectCoinsResponseType } from './requests/selectCoins'; export type TransactionAmount = { quantity: number; @@ -53,6 +54,11 @@ export type Transaction = { withdrawals: Array; status: TransactionState; metadata?: TransactionMetadata; + certificates?: Array<{ + pool?: string; + certificate_type: DelegationAction; + reward_account_path: Array; + }>; }; export type Transactions = Array; export type TransactionInputs = { @@ -78,7 +84,12 @@ export type TransactionAddresses = { to: Array; withdrawals: Array; }; -export type TransactionType = 'card' | 'expend' | 'income' | 'exchange'; +export type TransactionType = + | 'card' + | 'expend' + | 'income' + | 'exchange' + | 'vote'; // Req / Res Transaction Types export type GetTransactionsRequest = { walletId: string; @@ -185,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 = { @@ -285,3 +298,20 @@ export type VotingDataType = { metadata: VotingMetadataType; nonce: number; }; + +export type ConstructTransactionData = { + walletId: string; + data: { + // 'abstain' | 'no_confidence' | dRepId + vote?: string; + }; +}; + +export type ConstructTransactionResponse = { + fee: { + quantity: number; + unit: WalletUnits.LOVELACE; + }; + coin_selection: SelectCoinsResponseType; + transaction: string; +}; diff --git a/source/renderer/app/api/voting/requests/delegateVotes.ts b/source/renderer/app/api/voting/requests/delegateVotes.ts new file mode 100644 index 0000000000..cc5f6c63a0 --- /dev/null +++ b/source/renderer/app/api/voting/requests/delegateVotes.ts @@ -0,0 +1,18 @@ +import { request } from '../../utils/request'; +import { RequestConfig } from '../../common/types'; +import { Transaction } from '../../transactions/types'; +import { DelegateVotesParams } from '../types'; + +export const delegateVotes = ( + config: RequestConfig, + { dRepId, passphrase, walletId }: DelegateVotesParams +): Promise => + request( + { + ...config, + method: 'PUT', + path: `/v2/dreps/${dRepId}/wallets/${walletId}`, + }, + {}, + { passphrase } + ); diff --git a/source/renderer/app/api/voting/types.ts b/source/renderer/app/api/voting/types.ts index ab15db9d65..7a4dadf570 100644 --- a/source/renderer/app/api/voting/types.ts +++ b/source/renderer/app/api/voting/types.ts @@ -53,3 +53,9 @@ export type CatalystFund = { registrationSnapshotTime: Date; }; }; + +export type DelegateVotesParams = { + dRepId: string; + passphrase: string; + walletId: string; +}; diff --git a/source/renderer/app/assets/images/wallet-nav/voting.inline.svg b/source/renderer/app/assets/images/wallet-nav/voting.inline.svg new file mode 100644 index 0000000000..55cff85184 --- /dev/null +++ b/source/renderer/app/assets/images/wallet-nav/voting.inline.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/source/renderer/app/components/voting/voting-governance/VotingPowerDelegation.messages.ts b/source/renderer/app/components/voting/voting-governance/VotingPowerDelegation.messages.ts new file mode 100644 index 0000000000..63a4ada8c9 --- /dev/null +++ b/source/renderer/app/components/voting/voting-governance/VotingPowerDelegation.messages.ts @@ -0,0 +1,96 @@ +import { defineMessages } from 'react-intl'; + +export const messages = defineMessages({ + drepInputLabel: { + id: 'voting.governance.drepInputLabel', + defaultMessage: + '!!!Please type or paste a valid DRep ID here. Look up {drepDirectoryLink}', + description: 'Label for DRep input on the governance page', + }, + drepInputLabelPreprod: { + id: 'voting.governance.drepInputLabelPreprod', + defaultMessage: '!!!Please type or paste a valid DRep ID here.', + description: 'Label for DRep input on the governance page for preprod', + }, + drepInputLabelLinkText: { + id: 'voting.governance.drepInputLabelLinkText', + defaultMessage: '!!!DRep directory', + description: 'Label link text for DRep input on the governance page', + }, + drepInputLabelLinkUrl: { + id: 'voting.governance.drepInputLabelLinkUrl', + defaultMessage: 'https://gov.tools/drep_directory', + description: 'Label link url for DRep input on the governance page', + }, + drepInputLabelLinkUrlPreview: { + id: 'voting.governance.drepInputLabelLinkUrlPreview', + defaultMessage: 'https://preview.gov.tools/drep_directory', + description: 'Label link url for DRep input on the governance page', + }, + drepInputError: { + id: 'voting.governance.drepInputError', + defaultMessage: '!!!Invalid DRep ID', + description: 'Error for DRep input on the governance page', + }, + drepInputPlaceholder: { + id: 'voting.governance.drepInputPlaceholder', + defaultMessage: '!!!Paste DRep ID here …', + description: 'Placeholder for DRep input on the governance page', + }, + heading: { + id: 'voting.governance.heading', + defaultMessage: '!!!CARDANO VOTING POWER DELEGATION', + description: 'Headline for Governance', + }, + paragraph1: { + id: 'voting.governance.paragraph1', + defaultMessage: '!!!Governance first paragraph', + description: 'First paragraph for governance page', + }, + paragraph1LinkUrl: { + id: 'voting.governance.paragraph1LinkUrl', + defaultMessage: '!!!Governance first paragraph link url', + description: 'First paragraph link for governance page', + }, + paragraph1LinkText: { + id: 'voting.governance.paragraph1LinkText', + defaultMessage: '!!!Governance link label', + description: 'Link labels for governance page', + }, + selectWalletLabel: { + id: 'voting.governance.selectWalletLabel', + defaultMessage: '!!!Select a wallet to delegate from', + description: 'Label for the wallet select on the governance page', + }, + selectWalletPlaceholder: { + id: 'voting.governance.selectWalletPlaceholder', + defaultMessage: '!!!Select a wallet …', + description: 'Placeholder for the wallet select on the governance page', + }, + selectVotingTypeLabel: { + id: 'voting.governance.selectVotingTypeLabel', + defaultMessage: '!!!Select voting registration type', + description: + 'Label for the registration type select on the governance page', + }, + submitLabel: { + id: 'voting.governance.submitLabel', + defaultMessage: '!!!Submit', + description: 'Label for the submit button on the governance page', + }, + initializeTxErrorGeneric: { + id: 'voting.governance.initializeTxError.generic', + defaultMessage: '!!!Could not initialize transaction. Please try again!', + description: 'Generic error for initialize transaction', + }, + initializeTxErrorSameVote: { + id: 'voting.governance.initializeTxError.sameVote', + defaultMessage: '!!!Chosen same value as previously', + description: 'Chosen same value as previously', + }, + initializeNotEnoughMoney: { + id: 'voting.governance.initializeTxError.notEnoughMoney', + defaultMessage: '!!!Not enough funds', + description: 'Governance voting error when wallet has not enough funds', + }, +}); diff --git a/source/renderer/app/components/voting/voting-governance/VotingPowerDelegation.scss b/source/renderer/app/components/voting/voting-governance/VotingPowerDelegation.scss new file mode 100644 index 0000000000..0ca5e2cb17 --- /dev/null +++ b/source/renderer/app/components/voting/voting-governance/VotingPowerDelegation.scss @@ -0,0 +1,72 @@ +@import '../votingConfig'; +@import '../../../themes/mixins/error-message.scss'; + +.component { + flex: 1 0 0; + padding: 20px; +} + +.heading { + @extend %accentText; + font-family: var(--font-semibold); + font-size: 18px; + letter-spacing: 2px; + margin-bottom: 14px; + text-align: center; + text-transform: uppercase; +} + +.info { + @extend %regularText; + p { + display: block; + margin-bottom: 1em; + } +} + +.walletSelect { + margin-top: 20px; + + &.error { + input { + border-color: var(--theme-color-error); + } + + :global { + .SimpleSelect_selectInput { + &:after { + background-color: var(--theme-color-error); + } + } + } + } + + :global { + .SimpleOptions_option { + align-items: center; + display: flex; + height: 50px; + padding-bottom: 0; + padding-top: 0; + } + } +} + +.generalError { + @include error-message; +} + +.voteTypeSelect, +.drepInput, +.generalError { + margin-top: 40px; +} + +.generalError, +.voteSubmit { + margin-top: 28px; +} + +.voteSubmit { + width: 100%; +} diff --git a/source/renderer/app/components/voting/voting-governance/VotingPowerDelegation.tsx b/source/renderer/app/components/voting/voting-governance/VotingPowerDelegation.tsx new file mode 100644 index 0000000000..1fc0a07ea3 --- /dev/null +++ b/source/renderer/app/components/voting/voting-governance/VotingPowerDelegation.tsx @@ -0,0 +1,324 @@ +import React, { useEffect, useState } from 'react'; +import { observer } from 'mobx-react'; +import { injectIntl, FormattedMessage } from 'react-intl'; +import { Input } from 'react-polymorph/lib/components/Input'; +import { Button } from 'react-polymorph/lib/components/Button'; + +import BigNumber from 'bignumber.js'; +import BorderedBox from '../../widgets/BorderedBox'; +import { messages } from './VotingPowerDelegation.messages'; +import styles from './VotingPowerDelegation.scss'; +import type { Intl } from '../../../types/i18nTypes'; +import WalletsDropdown from '../../widgets/forms/WalletsDropdown'; +import Wallet from '../../../domains/Wallet'; +import StakePool from '../../../domains/StakePool'; +import ItemsDropdown from '../../widgets/forms/ItemsDropdown'; +import { assertIsBech32WithPrefix } from '../../../../../common/utils/assertIsBech32WithPrefix'; +import { Separator } from '../../widgets/separator/Separator'; +import { InitializeVPDelegationTxError } from '../../../stores/VotingStore'; +import { VoteType } from './types'; +import { sharedGovernanceMessages } from './shared-messages'; + +type Props = { + getStakePoolById: (...args: Array) => any; + initiateTransaction: (params: { + chosenOption: string; + wallet: Wallet; + }) => Promise< + | { success: true; fees: BigNumber } + | { success: false; errorCode: InitializeVPDelegationTxError } + >; + intl: Intl; + onExternalLinkClick: (...args: Array) => any; + stakePools: Array; + wallets: Array; + renderConfirmationDialog: (params: { + chosenOption: string; + fees: BigNumber; + onClose: () => void; + selectedWallet: Wallet; + }) => React.ReactElement; +}; + +type FormData = { + selectedWallet: Wallet; + selectedVoteType: VoteType; + drepInputState: { + dirty: boolean; + value: string; + }; + fees?: BigNumber; +}; + +type Form = Omit & { + selectedWallet: Wallet | null; + status: 'form'; +}; + +type FormWithError = Omit & { + txInitError: InitializeVPDelegationTxError; + status: 'form-with-error'; +}; + +type StateFormComplete = FormData & { + status: 'form-submitted' | 'form-initiating-tx'; +}; + +type StateConfirmation = Omit & { + fees: BigNumber; + status: 'confirmation'; +}; + +type State = Form | FormWithError | StateFormComplete | StateConfirmation; + +// TODO discuss if we need to restrict the length +const isDrepIdValid = (drepId: string) => { + try { + assertIsBech32WithPrefix(drepId, ['drep', 'drep_script']); + } catch (e) { + return false; + } + + return true; +}; + +const mapOfTxErrorCodeToIntl: Record< + InitializeVPDelegationTxError, + typeof messages[keyof typeof messages] +> = { + generic: messages.initializeTxErrorGeneric, + same_vote: messages.initializeTxErrorSameVote, + no_utxos_available: messages.initializeNotEnoughMoney, + not_enough_money: messages.initializeNotEnoughMoney, +}; + +const initialState: State = { + status: 'form', + selectedWallet: null, + selectedVoteType: 'drep', + drepInputState: { + dirty: false, + value: '', + }, +}; + +function VotingPowerDelegation({ + getStakePoolById, + initiateTransaction, + intl, + onExternalLinkClick, + renderConfirmationDialog, + wallets, + stakePools, +}: Props) { + const [state, setState] = useState(initialState); + + const drepInputIsValid = isDrepIdValid(state.drepInputState.value); + + const formIsValid = + !!state.selectedWallet && + (state.selectedVoteType === 'drep' ? drepInputIsValid : true); + + const submitButtonDisabled = + !formIsValid || + state.status === 'form-submitted' || + state.status === 'form-with-error' || + state.status === 'form-initiating-tx'; + + const voteTypes: { value: VoteType; label: string }[] = [ + { + value: 'abstain', + label: intl.formatMessage(sharedGovernanceMessages.abstain), + }, + { + value: 'no_confidence', + label: intl.formatMessage(sharedGovernanceMessages.noConfidence), + }, + { + value: 'drep', + label: intl.formatMessage(sharedGovernanceMessages.delegateToDRep), + }, + ]; + + const chosenOption = state.drepInputState.value || state.selectedVoteType; + + useEffect(() => { + (async () => { + if (state.status !== 'form-submitted') return; + setState({ + ...state, + status: 'form-initiating-tx', + }); + const result = await initiateTransaction({ + chosenOption, + wallet: state.selectedWallet, + }); + + if (result.success === true) { + setState({ + ...state, + fees: result.fees, + status: 'confirmation', + }); + } else { + setState({ + ...state, + txInitError: result.errorCode, + status: 'form-with-error', + }); + } + })(); + }, [initiateTransaction, intl, state]); + + return ( + <> +
+ +

+ {intl.formatMessage(messages.heading)} +

+
+

+ + onExternalLinkClick( + intl.formatMessage(messages.paragraph1LinkUrl), + event + ) + } + > + {intl.formatMessage(messages.paragraph1LinkText)} + + ), + }} + /> +

+
+ + + + { + const selectedWallet = wallets.find((w) => w.id === walletId); + setState({ + ...initialState, + selectedWallet, + }); + }} + placeholder={intl.formatMessage(messages.selectWalletPlaceholder)} + value={state.selectedWallet?.id || null} + getStakePoolById={getStakePoolById} + /> + + {state.selectedWallet && ( + + setState({ + ...state, + selectedVoteType: option.value, + status: 'form', + }) + } + value={state.selectedVoteType} + /> + )} + + {state.selectedWallet && state.selectedVoteType === 'drep' && ( + { + setState({ + ...state, + drepInputState: { + dirty: true, + value, + }, + status: 'form', + }); + }} + spellCheck={false} + value={state.drepInputState.value} + label={ + + onExternalLinkClick( + intl.formatMessage( + environment.isMainnet + ? messages.drepInputLabelLinkUrl + : messages.drepInputLabelLinkUrlPreview + ), + event + ) + } + > + {intl.formatMessage(messages.drepInputLabelLinkText)} + + ), + }} + /> + } + placeholder={intl.formatMessage(messages.drepInputPlaceholder)} + error={ + state.drepInputState.dirty && !drepInputIsValid + ? intl.formatMessage(messages.drepInputError) + : undefined + } + /> + )} + + {state.status === 'form-with-error' && ( +

+ {intl.formatMessage(mapOfTxErrorCodeToIntl[state.txInitError])} +

+ )} + +
+ {state.status === 'confirmation' && + renderConfirmationDialog({ + chosenOption, + fees: state.fees, + onClose: () => { + setState({ + ...state, + status: 'form', + }); + }, + selectedWallet: state.selectedWallet, + })} + + ); +} + +export default injectIntl(observer(VotingPowerDelegation)); diff --git a/source/renderer/app/components/voting/voting-governance/VotingPowerDelegationConfirmationDialog.messages.ts b/source/renderer/app/components/voting/voting-governance/VotingPowerDelegationConfirmationDialog.messages.ts new file mode 100644 index 0000000000..6d4ac09472 --- /dev/null +++ b/source/renderer/app/components/voting/voting-governance/VotingPowerDelegationConfirmationDialog.messages.ts @@ -0,0 +1,40 @@ +import { defineMessages } from 'react-intl'; + +export const messages = defineMessages({ + title: { + id: 'voting.governance.confirmationDialog.title', + defaultMessage: '!!!Confirm Transaction', + description: 'Title for confirm dialog', + }, + vote: { + id: 'voting.governance.confirmationDialog.vote', + defaultMessage: '!!!Vote', + description: 'Vote title', + }, + fee: { + id: 'voting.governance.confirmationDialog.fee', + defaultMessage: '!!!Transaction fee', + description: 'Fee title', + }, + password: { + id: 'voting.governance.confirmationDialog.password', + defaultMessage: '!!!Spending password', + description: 'Label for password input', + }, + errorGeneric: { + id: 'voting.governance.confirmationDialog.error.generic', + defaultMessage: + '!!!Something went wrong during transaction submission. Please try again in a few minutes.', + description: 'Generic error message', + }, + buttonCancel: { + id: 'voting.governance.confirmationDialog.button.cancel', + defaultMessage: '!!!Cancel', + description: 'Cancel button', + }, + buttonConfirm: { + id: 'voting.governance.confirmationDialog.button.confirm', + defaultMessage: '!!!Confirm', + description: 'Confirm button', + }, +}); diff --git a/source/renderer/app/components/voting/voting-governance/VotingPowerDelegationConfirmationDialog.scss b/source/renderer/app/components/voting/voting-governance/VotingPowerDelegationConfirmationDialog.scss new file mode 100644 index 0000000000..3ae5cc5ead --- /dev/null +++ b/source/renderer/app/components/voting/voting-governance/VotingPowerDelegationConfirmationDialog.scss @@ -0,0 +1,21 @@ +@import '../votingConfig'; +@import '../../../themes/mixins/error-message.scss'; + +.content { + padding-top: 20px; +} + +.paragraphTitle { + @extend %accentText; + margin-bottom: 12px; +} + +.paragraphValue { + @extend %regularText; + margin-bottom: 36px; +} + +.error { + margin-top: 24px; + @include error-message; +} diff --git a/source/renderer/app/components/voting/voting-governance/VotingPowerDelegationConfirmationDialog.tsx b/source/renderer/app/components/voting/voting-governance/VotingPowerDelegationConfirmationDialog.tsx new file mode 100644 index 0000000000..7ff44a0f62 --- /dev/null +++ b/source/renderer/app/components/voting/voting-governance/VotingPowerDelegationConfirmationDialog.tsx @@ -0,0 +1,196 @@ +import React, { useEffect, useState } from 'react'; +import BigNumber from 'bignumber.js'; +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 { formattedWalletAmount } from '../../../utils/formatters'; +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 LoadingSpinner from '../../widgets/LoadingSpinner'; +import { VoteType } from './types'; +import { sharedGovernanceMessages } from './shared-messages'; +import { messages as apiErrorMessage } from '../../../api/errors'; + +const mapOfTxErrorCodeToIntl: Record< + DelegateVotesError, + typeof messages[keyof typeof messages] +> = { + generic: messages.errorGeneric, + wrong_encryption_passphrase: apiErrorMessage.wrongEncryptionPassphrase, +}; + +const mapVoteToIntlMessage = (vote: VoteType | string): ReactIntlMessage => { + switch (vote) { + case 'abstain': + return sharedGovernanceMessages.abstain; + case 'no_confidence': + return sharedGovernanceMessages.noConfidence; + default: + return sharedGovernanceMessages.delegateToDRep; + } +}; + +export type VotingPowerDelegationConfirmationDialogState = + | { + error?: DelegateVotesError; + passphrase: string; + status: 'awaiting'; + } + | { + passphrase: string; + status: 'confirmed'; + } + | { status: 'submitting' }; + +type VotingPowerDelegationConfirmationDialogProps = { + chosenOption: string; + fees: BigNumber; + hwDeviceStatus: HwDeviceStatus; + intl: Intl; + isTrezor: boolean; + onClose: () => void; + onExternalLinkClick: (...args: Array) => any; + onSubmit: ( + passphrase?: string + ) => Promise< + { success: true } | { success: false; errorCode: DelegateVotesError } + >; + redirectToWallet: (walletId: string) => void; + selectedWallet: Wallet; +}; + +function VotingPowerDelegationConfirmationDialog({ + chosenOption, + fees, + hwDeviceStatus, + intl, + isTrezor, + onClose, + onExternalLinkClick, + onSubmit, + redirectToWallet, + selectedWallet, +}: VotingPowerDelegationConfirmationDialogProps) { + const [state, setState] = useState< + VotingPowerDelegationConfirmationDialogState + >({ + passphrase: '', + status: 'awaiting', + }); + + useEffect(() => { + (async () => { + if (state.status !== 'confirmed') return; + + const { passphrase, ...restState } = state; + setState({ + ...restState, + status: 'submitting', + }); + + const result = await onSubmit(passphrase); + + if (result.success === true) { + redirectToWallet(selectedWallet.id); + return; + } + + setState({ + ...state, + error: result.errorCode, + status: 'awaiting', + }); + })(); + }, [intl, onSubmit, redirectToWallet, state]); + + const confirmButtonLabel = + state.status === 'awaiting' ? ( + intl.formatMessage(messages.buttonConfirm) + ) : ( + + ); + + return ( + { + setState({ + passphrase: ('passphrase' in state && state.passphrase) || '', + status: 'confirmed', + }); + }, + primary: true, + disabled: + state.status !== 'awaiting' || + (selectedWallet.isHardwareWallet + ? hwDeviceStatus !== + HwDeviceStatuses.VERIFYING_TRANSACTION_SUCCEEDED + : !state.passphrase), + }, + ]} + > +
+

+ {intl.formatMessage(messages.vote)} +

+

+ {intl.formatMessage(mapVoteToIntlMessage(chosenOption))} +

+ +

+ {intl.formatMessage(messages.fee)} +

+

{formattedWalletAmount(fees)}

+ + {selectedWallet.isHardwareWallet ? ( + + ) : ( + { + if (state.status !== 'awaiting') return; + setState({ + ...state, + passphrase, + }); + }} + disabled={state.status !== 'awaiting'} + type={'password'} + label={intl.formatMessage(messages.password)} + skin={InputSkin} + /> + )} + + {'error' in state && ( +

+ {intl.formatMessage(mapOfTxErrorCodeToIntl[state.error])} +

+ )} +
+
+ ); +} + +export default injectIntl(VotingPowerDelegationConfirmationDialog); diff --git a/source/renderer/app/components/voting/voting-governance/shared-messages.ts b/source/renderer/app/components/voting/voting-governance/shared-messages.ts new file mode 100644 index 0000000000..0c030a6af2 --- /dev/null +++ b/source/renderer/app/components/voting/voting-governance/shared-messages.ts @@ -0,0 +1,19 @@ +import { defineMessages } from 'react-intl'; + +export const sharedGovernanceMessages = defineMessages({ + abstain: { + id: 'voting.governance.abstain', + defaultMessage: '!!!Abstain', + description: 'Translation for the "abstain" governance vote type', + }, + delegateToDRep: { + id: 'voting.governance.delegateToDRep', + defaultMessage: '!!!Delegate to DRep', + description: 'Translation for the "delegate to DRep" governance vote type', + }, + noConfidence: { + id: 'voting.governance.noConfidence', + defaultMessage: '!!!No Confidence', + description: 'Translation for the "no confidence" governance vote type', + }, +}); diff --git a/source/renderer/app/components/voting/voting-governance/types.ts b/source/renderer/app/components/voting/voting-governance/types.ts new file mode 100644 index 0000000000..d7cf2c4deb --- /dev/null +++ b/source/renderer/app/components/voting/voting-governance/types.ts @@ -0,0 +1 @@ +export type VoteType = 'abstain' | 'no_confidence' | 'drep'; diff --git a/source/renderer/app/components/wallet/transactions/Transaction.tsx b/source/renderer/app/components/wallet/transactions/Transaction.tsx index c7f1d86fd8..c7d7ef6038 100644 --- a/source/renderer/app/components/wallet/transactions/Transaction.tsx +++ b/source/renderer/app/components/wallet/transactions/Transaction.tsx @@ -19,7 +19,10 @@ import { } from '../../../domains/WalletTransaction'; import WholeSelectionText from '../../widgets/WholeSelectionText'; import globalMessages from '../../../i18n/global-messages'; -import type { TransactionState } from '../../../api/transactions/types'; +import type { + TransactionState, + TransactionType, +} from '../../../api/transactions/types'; import { PENDING_TIME_LIMIT } from '../../../config/txnsConfig'; import CancelTransactionConfirmationDialog from './CancelTransactionConfirmationDialog'; import type { AssetToken } from '../../../api/assets/types'; @@ -93,6 +96,11 @@ const messages = defineMessages({ defaultMessage: '!!!{transactionsType} received', description: 'Label "{transactionsType} received" for the transaction.', }, + voted: { + id: 'wallet.transaction.voted', + defaultMessage: '!!!Voting Power Delegation', + description: 'Title for governance voting transactions.', + }, fromAddress: { id: 'wallet.transaction.address.from', defaultMessage: '!!!From address', @@ -519,6 +527,21 @@ export default class Transaction extends Component { } }; + const getTitle = (txType: TransactionType): string => { + switch (txType) { + case TransactionTypes.EXPEND: + return intl.formatMessage(messages.sent, { + transactionsType, + }); + case TransactionTypes.VOTE: + return intl.formatMessage(messages.voted); + default: + return intl.formatMessage(messages.received, { + transactionsType, + }); + } + }; + const exceedsPendingTimeLimit = this.hasExceededPendingTimeLimit(); const assetsSeparatorStyles = classNames([ styles.assetsSeparator, @@ -544,15 +567,7 @@ export default class Transaction extends Component {
-
- {data.type === TransactionTypes.EXPEND - ? intl.formatMessage(messages.sent, { - transactionsType, - }) - : intl.formatMessage(messages.received, { - transactionsType, - })} -
+
{getTitle(data.type)}
{data.amount && (
{ icon = exchangeIcon; break; + case TransactionTypes.VOTE: + icon = votingIcon; + break; + default: icon = ''; break; diff --git a/source/renderer/app/components/widgets/FormattedHTMLMessageWithLink.tsx b/source/renderer/app/components/widgets/FormattedHTMLMessageWithLink.tsx index 3a55745fba..e5394ef768 100644 --- a/source/renderer/app/components/widgets/FormattedHTMLMessageWithLink.tsx +++ b/source/renderer/app/components/widgets/FormattedHTMLMessageWithLink.tsx @@ -1,12 +1,13 @@ import React, { Component, Fragment } from 'react'; import { intlShape } from 'react-intl'; import type { ReactIntlMessageShape } from '../../i18n/types'; +import { ReactIntlMessage } from '../../types/i18nTypes'; type ReactIntlMessageShapeWithLink = ReactIntlMessageShape & { values: { linkPosition?: string; - linkLabel: string; - linkURL: string; + linkLabel: ReactIntlMessage; + linkURL: ReactIntlMessage; }; }; type Props = { @@ -23,9 +24,7 @@ export class FormattedHTMLMessageWithLink extends Component { const { message, onExternalLinkClick } = this.props; const { linkPosition, linkLabel, linkURL } = message.values; const MainMessage = ( - -  {intl.formatMessage(message)}  - + {intl.formatMessage(message)} ); const url = intl.formatMessage(linkURL); const Link = ( @@ -36,7 +35,11 @@ export class FormattedHTMLMessageWithLink extends Component { ); return linkPosition === 'before' - ? [Link, MainMessage] - : [MainMessage, Link]; + ? [Link,  , MainMessage] + : [ + MainMessage, +  , + Link, + ]; } } diff --git a/source/renderer/app/components/widgets/forms/ItemsDropdown.tsx b/source/renderer/app/components/widgets/forms/ItemsDropdown.tsx index 7599f0d4a0..aacfe297af 100644 --- a/source/renderer/app/components/widgets/forms/ItemsDropdown.tsx +++ b/source/renderer/app/components/widgets/forms/ItemsDropdown.tsx @@ -29,8 +29,12 @@ import globalMessages from '../../../i18n/global-messages'; */ export type ItemDropdownProps = { options: Array; + label?: string; className?: string; disabled?: boolean; + handleChange?: (...args: Array) => any; + value?: string; + placeholder?: string; }; export const onSearchItemsDropdown = ( searchValue: string, diff --git a/source/renderer/app/config/votingNavigationConfig.ts b/source/renderer/app/config/votingNavigationConfig.ts new file mode 100644 index 0000000000..b2e0b26f6e --- /dev/null +++ b/source/renderer/app/config/votingNavigationConfig.ts @@ -0,0 +1,4 @@ +export const VOTING_NAV_IDS = { + REGISTRATION: 'registration', + GOVERNANCE: 'governance', +}; diff --git a/source/renderer/app/containers/voting/Voting.tsx b/source/renderer/app/containers/voting/Voting.tsx new file mode 100644 index 0000000000..c7f893e2fc --- /dev/null +++ b/source/renderer/app/containers/voting/Voting.tsx @@ -0,0 +1,69 @@ +import React, { Component } from 'react'; +import { inject, observer } from 'mobx-react'; +import { defineMessages, intlShape, injectIntl } from 'react-intl'; +import Navigation from '../../components/navigation/Navigation'; +import type { NavButtonProps } from '../../components/navigation/Navigation'; +import type { InjectedContainerProps } from '../../types/injectedPropsType'; +import MainLayout from '../MainLayout'; +import { ROUTES } from '../../routes-config'; + +const messages = defineMessages({ + votingTabCatalyst: { + id: 'voting.tabs.catalyst', + defaultMessage: '!!!Catalyst Voting', + description: 'Label for the catalyst voting tab.', + }, + votingTabGovernance: { + id: 'voting.tabs.governance', + defaultMessage: '!!!Governance', + description: 'Label for the governance voting tab.', + }, +}); + +type Props = InjectedContainerProps & { + intl: intlShape.isRequired; +}; + +@inject('stores', 'actions') +@observer +class Voting extends Component { + static defaultProps = { + actions: null, + stores: null, + }; + + render() { + const { app } = this.props.stores; + const { intl } = this.props; + const navItems: Array = [ + { + id: ROUTES.VOTING.REGISTRATION, + label: intl.formatMessage(messages.votingTabCatalyst), + }, + { + id: ROUTES.VOTING.GOVERNANCE, + label: intl.formatMessage(messages.votingTabGovernance), + }, + ]; + const activeItem = navItems.find((item) => app.currentRoute === item.id); + return ( + +
+ navItemId === activeItem.id} + onNavItemClick={(navItemId: string) => { + this.props.actions.router.goToRoute.trigger({ + route: navItemId, + }); + }} + /> +
+ {this.props.children} +
+ ); + } +} + +export default injectIntl(Voting); diff --git a/source/renderer/app/containers/voting/VotingGovernancePage.tsx b/source/renderer/app/containers/voting/VotingGovernancePage.tsx new file mode 100644 index 0000000000..346cd78afb --- /dev/null +++ b/source/renderer/app/containers/voting/VotingGovernancePage.tsx @@ -0,0 +1,72 @@ +import React, { Component } from 'react'; +import { inject, observer } from 'mobx-react'; +import type { InjectedProps } from '../../types/injectedPropsType'; +import VotingPowerDelegation from '../../components/voting/voting-governance/VotingPowerDelegation'; +import VotingPowerDelegationConfirmationDialog from '../../components/voting/voting-governance/VotingPowerDelegationConfirmationDialog'; +import { ROUTES } from '../../routes-config'; + +type Props = InjectedProps; + +@inject('stores', 'actions') +@observer +class VotingGovernancePage extends Component { + static defaultProps = { + actions: null, + stores: null, + }; + + render() { + const { + wallets, + staking, + app, + voting, + hardwareWallets, + } = this.props.stores; + const { openExternalLink } = app; + return ( + ( + + voting.delegateVotes({ + chosenOption, + passphrase, + wallet: selectedWallet, + }) + } + redirectToWallet={(id) => { + this.props.actions.router.goToRoute.trigger({ + route: ROUTES.WALLETS.SUMMARY, + params: { + id, + }, + }); + }} + selectedWallet={selectedWallet} + /> + )} + /> + ); + } +} + +export default VotingGovernancePage; diff --git a/source/renderer/app/containers/voting/VotingRegistrationPage.tsx b/source/renderer/app/containers/voting/VotingRegistrationPage.tsx index 365dc7b390..61a16cd183 100644 --- a/source/renderer/app/containers/voting/VotingRegistrationPage.tsx +++ b/source/renderer/app/containers/voting/VotingRegistrationPage.tsx @@ -1,6 +1,5 @@ import React, { Component } from 'react'; import { observer, inject } from 'mobx-react'; -import Layout from '../MainLayout'; import { VOTING_REGISTRATION_MIN_WALLET_FUNDS } from '../../config/votingConfig'; import VerticalFlexContainer from '../../components/layout/VerticalFlexContainer'; import VotingInfo from '../../components/voting/voting-info/VotingInfo'; @@ -77,7 +76,7 @@ class VotingRegistrationPage extends Component { ); const innerContent = this.getInnerContent(isVotingRegistrationDialogOpen); return ( - + <> {innerContent} @@ -86,7 +85,7 @@ class VotingRegistrationPage extends Component { {isVotingRegistrationDialogOpen && ( )} - + ); } } diff --git a/source/renderer/app/containers/wallet/dialogs/send-confirmation/SendConfirmation.view.tsx b/source/renderer/app/containers/wallet/dialogs/send-confirmation/SendConfirmation.view.tsx index 37fddf1d35..c8a786b7aa 100644 --- a/source/renderer/app/containers/wallet/dialogs/send-confirmation/SendConfirmation.view.tsx +++ b/source/renderer/app/containers/wallet/dialogs/send-confirmation/SendConfirmation.view.tsx @@ -89,7 +89,8 @@ function View({ !isPasswordValid({ isHardwareWallet, isValid: passphraseField.isValid, - }), + }) || + !!error, }, ]; diff --git a/source/renderer/app/containers/wallet/dialogs/send-confirmation/styles.scss b/source/renderer/app/containers/wallet/dialogs/send-confirmation/styles.scss index 52ad13f4ec..e991293b97 100644 --- a/source/renderer/app/containers/wallet/dialogs/send-confirmation/styles.scss +++ b/source/renderer/app/containers/wallet/dialogs/send-confirmation/styles.scss @@ -1,4 +1,4 @@ -@import '../../../../themes/mixins//error-message.scss'; +@import '../../../../themes/mixins/error-message.scss'; .root { font-family: var(--font-medium); diff --git a/source/renderer/app/domains/WalletTransaction.ts b/source/renderer/app/domains/WalletTransaction.ts index 219bd37d5a..cc83440cb2 100644 --- a/source/renderer/app/domains/WalletTransaction.ts +++ b/source/renderer/app/domains/WalletTransaction.ts @@ -21,6 +21,7 @@ export const TransactionTypes: EnumMap = { EXPEND: 'expend', INCOME: 'income', EXCHANGE: 'exchange', + VOTE: 'vote', }; export const TransactionWithdrawal: TransactionWithdrawalType = 'self'; export class WalletTransaction { diff --git a/source/renderer/app/i18n/locales/defaultMessages.json b/source/renderer/app/i18n/locales/defaultMessages.json index 0307a1fb28..a631f8f3be 100644 --- a/source/renderer/app/i18n/locales/defaultMessages.json +++ b/source/renderer/app/i18n/locales/defaultMessages.json @@ -95,6 +95,11 @@ "defaultMessage": "!!!Insufficient funds to support tokens. You need at least an additional {adaAmount} ADA in your wallet to process this transaction.", "description": "\"Balance after transaction would not leave enough ada in the wallet to support tokens remaining in wallet", "id": "api.errors.NotEnoughFundsForTransactionFeesErrorWithTokens" + }, + { + "defaultMessage": "!!!conwayWalletNotDelegatedToDRep", + "description": "Error message shown when conway era wallet has staking rewards but has not participated in governance yet.", + "id": "api.errors.conwayWalletNotDelegatedToDRep" } ], "path": "source/renderer/app/api/errors.ts" @@ -2244,6 +2249,21 @@ ], "path": "source/renderer/app/containers/staking/StakingRewardsPage.tsx" }, + { + "descriptors": [ + { + "defaultMessage": "!!!Catalyst Voting", + "description": "Label for the catalyst voting tab.", + "id": "voting.tabs.catalyst" + }, + { + "defaultMessage": "!!!Governance", + "description": "Label for the governance voting tab.", + "id": "voting.tabs.governance" + } + ], + "path": "source/renderer/app/containers/voting/Voting.tsx" + }, { "descriptors": [ { @@ -4384,6 +4404,161 @@ ], "path": "source/renderer/app/components/staking/widgets/TooltipPool.tsx" }, + { + "descriptors": [ + { + "defaultMessage": "!!!Please type or paste a valid DRep ID here. Look up {drepDirectoryLink}", + "description": "Label for DRep input on the governance page", + "id": "voting.governance.drepInputLabel" + }, + { + "defaultMessage": "!!!Please type or paste a valid DRep ID here.", + "description": "Label for DRep input on the governance page for preprod", + "id": "voting.governance.drepInputLabelPreprod" + }, + { + "defaultMessage": "!!!DRep directory", + "description": "Label link text for DRep input on the governance page", + "id": "voting.governance.drepInputLabelLinkText" + }, + { + "defaultMessage": "https://gov.tools/drep_directory", + "description": "Label link url for DRep input on the governance page", + "id": "voting.governance.drepInputLabelLinkUrl" + }, + { + "defaultMessage": "https://preview.gov.tools/drep_directory", + "description": "Label link url for DRep input on the governance page", + "id": "voting.governance.drepInputLabelLinkUrlPreview" + }, + { + "defaultMessage": "!!!Invalid DRep ID", + "description": "Error for DRep input on the governance page", + "id": "voting.governance.drepInputError" + }, + { + "defaultMessage": "!!!Paste DRep ID here …", + "description": "Placeholder for DRep input on the governance page", + "id": "voting.governance.drepInputPlaceholder" + }, + { + "defaultMessage": "!!!CARDANO VOTING POWER DELEGATION", + "description": "Headline for Governance", + "id": "voting.governance.heading" + }, + { + "defaultMessage": "!!!Governance first paragraph", + "description": "First paragraph for governance page", + "id": "voting.governance.paragraph1" + }, + { + "defaultMessage": "!!!Governance first paragraph link url", + "description": "First paragraph link for governance page", + "id": "voting.governance.paragraph1LinkUrl" + }, + { + "defaultMessage": "!!!Governance link label", + "description": "Link labels for governance page", + "id": "voting.governance.paragraph1LinkText" + }, + { + "defaultMessage": "!!!Select a wallet to delegate from", + "description": "Label for the wallet select on the governance page", + "id": "voting.governance.selectWalletLabel" + }, + { + "defaultMessage": "!!!Select a wallet …", + "description": "Placeholder for the wallet select on the governance page", + "id": "voting.governance.selectWalletPlaceholder" + }, + { + "defaultMessage": "!!!Select voting registration type", + "description": "Label for the registration type select on the governance page", + "id": "voting.governance.selectVotingTypeLabel" + }, + { + "defaultMessage": "!!!Submit", + "description": "Label for the submit button on the governance page", + "id": "voting.governance.submitLabel" + }, + { + "defaultMessage": "!!!Could not initialize transaction. Please try again!", + "description": "Generic error for initialize transaction", + "id": "voting.governance.initializeTxError.generic" + }, + { + "defaultMessage": "!!!Chosen same value as previously", + "description": "Chosen same value as previously", + "id": "voting.governance.initializeTxError.sameVote" + }, + { + "defaultMessage": "!!!Not enough funds", + "description": "Governance voting error when wallet has not enough funds", + "id": "voting.governance.initializeTxError.notEnoughMoney" + } + ], + "path": "source/renderer/app/components/voting/voting-governance/VotingPowerDelegation.messages.ts" + }, + { + "descriptors": [ + { + "defaultMessage": "!!!Confirm Transaction", + "description": "Title for confirm dialog", + "id": "voting.governance.confirmationDialog.title" + }, + { + "defaultMessage": "!!!Vote", + "description": "Vote title", + "id": "voting.governance.confirmationDialog.vote" + }, + { + "defaultMessage": "!!!Transaction fee", + "description": "Fee title", + "id": "voting.governance.confirmationDialog.fee" + }, + { + "defaultMessage": "!!!Spending password", + "description": "Label for password input", + "id": "voting.governance.confirmationDialog.password" + }, + { + "defaultMessage": "!!!Something went wrong during transaction submission. Please try again in a few minutes.", + "description": "Generic error message", + "id": "voting.governance.confirmationDialog.error.generic" + }, + { + "defaultMessage": "!!!Cancel", + "description": "Cancel button", + "id": "voting.governance.confirmationDialog.button.cancel" + }, + { + "defaultMessage": "!!!Confirm", + "description": "Confirm button", + "id": "voting.governance.confirmationDialog.button.confirm" + } + ], + "path": "source/renderer/app/components/voting/voting-governance/VotingPowerDelegationConfirmationDialog.messages.ts" + }, + { + "descriptors": [ + { + "defaultMessage": "!!!Abstain", + "description": "Translation for the \"abstain\" governance vote type", + "id": "voting.governance.abstain" + }, + { + "defaultMessage": "!!!Delegate to DRep", + "description": "Translation for the \"delegate to DRep\" governance vote type", + "id": "voting.governance.delegateToDRep" + }, + { + "defaultMessage": "!!!No Confidence", + "description": "Translation for the \"no confidence\" governance vote type", + "id": "voting.governance.noConfidence" + } + ], + "path": "source/renderer/app/components/voting/voting-governance/shared-messages.ts" + }, { "descriptors": [ { @@ -6476,6 +6651,11 @@ "description": "Label \"{transactionsType} received\" for the transaction.", "id": "wallet.transaction.received" }, + { + "defaultMessage": "!!!Voting Power Delegation", + "description": "Title for governance voting transactions.", + "id": "wallet.transaction.voted" + }, { "defaultMessage": "!!!From address", "description": "From address", diff --git a/source/renderer/app/i18n/locales/en-US.json b/source/renderer/app/i18n/locales/en-US.json index 0086307637..331d8d88fe 100755 --- a/source/renderer/app/i18n/locales/en-US.json +++ b/source/renderer/app/i18n/locales/en-US.json @@ -34,6 +34,7 @@ "api.errors.WalletAlreadyImportedError": "Wallet you are trying to import already exists.", "api.errors.WalletAlreadyRestoredError": "Wallet you are trying to restore already exists.", "api.errors.WalletFileImportError": "Wallet could not be imported, please make sure you are providing a correct file.", + "api.errors.conwayWalletNotDelegatedToDRep": "Daedalus cannot process this transaction because of blocked staking rewards. To unblock your rewards go to the Cardano voting power delegation tab and delegate your voting power", "api.errors.inputsDepleted": "Your wallet contains only reward funds.
Please send at least 1 ADA to your wallet so that you can spend the funds.", "api.errors.invalidAddress": "Please enter a valid address.", "api.errors.invalidSmashServer": "This URL is not a valid SMASH server", @@ -718,6 +719,34 @@ "voting.catalystFooterLinks.newsletter": "Newsletter", "voting.catalystFooterLinks.projects": "Projects", "voting.fundName": "Fund{votingFundNumber}", + "voting.governance.abstain": "Abstain", + "voting.governance.confirmationDialog.button.cancel": "Cancel", + "voting.governance.confirmationDialog.button.confirm": "Confirm", + "voting.governance.confirmationDialog.error.generic": "Something went wrong during transaction submission. Please try again in a few minutes.", + "voting.governance.confirmationDialog.fee": "Transaction fee", + "voting.governance.confirmationDialog.password": "Spending password", + "voting.governance.confirmationDialog.title": "Confirm Transaction", + "voting.governance.confirmationDialog.vote": "Vote", + "voting.governance.delegateToDRep": "Delegate to DRep (default)", + "voting.governance.drepInputError": "Invalid DRep ID", + "voting.governance.drepInputLabel": "Please type or paste a valid DRep ID here. Look up {drepDirectoryLink}", + "voting.governance.drepInputLabelLinkText": "DRep directory", + "voting.governance.drepInputLabelLinkUrl": "https://gov.tools/drep_directory", + "voting.governance.drepInputLabelLinkUrlPreview": "https://preview.gov.tools/drep_directory", + "voting.governance.drepInputLabelPreprod": "Please type or paste a valid DRep ID here.", + "voting.governance.drepInputPlaceholder": "Paste DRep ID here …", + "voting.governance.heading": "CARDANO VOTING POWER DELEGATION", + "voting.governance.initializeTxError.generic": "Something went wrong during transaction initialization. Please try again in a few minutes. ", + "voting.governance.initializeTxError.notEnoughMoney": "This wallet does not contain the minimum required amount of 1 ADA. The wallet might still be syncing. Please choose another wallet to vote.", + "voting.governance.initializeTxError.sameVote": "This voting power delegation choice has already been successfully recorded in a previous transaction. Please change the registration type or DRep ID in order to proceed.", + "voting.governance.noConfidence": "No Confidence", + "voting.governance.paragraph1": "Register your voting power to withdraw rewards. Learn more about {Link}.", + "voting.governance.paragraph1LinkText": "Cardano Governance", + "voting.governance.paragraph1LinkUrl": "https://docs.intersectmbo.org/cardano/cardano-governance/overview", + "voting.governance.selectVotingTypeLabel": "Select voting registration type", + "voting.governance.selectWalletLabel": "Select a wallet to delegate from", + "voting.governance.selectWalletPlaceholder": "Select a wallet …", + "voting.governance.submitLabel": "Submit", "voting.info.androidAppButtonUrl": "https://play.google.com/store/apps/details?id=io.iohk.vitvoting", "voting.info.appleAppButtonUrl": "https://apps.apple.com/in/app/catalyst-voting/id1517473397", "voting.info.learnMoreLinkLabel": "Learn more", @@ -736,6 +765,8 @@ "voting.resultsPhase.viewResultsLinkURL": "https://projectcatalyst.io/funds", "voting.snapshotPhase.snapshotDateLabel": "Snapshot date:", "voting.snapshotPhase.votingDateLabel": "Next voting period:", + "voting.tabs.catalyst": "Catalyst Voting", + "voting.tabs.governance": "Governance", "voting.tallyingPhase.endDateLabel": "Voting ended:", "voting.tallyingPhase.resultsLabel": "Check back for results on:", "voting.votingOpenPhase.dateLabel": "Voting period open between:", @@ -1308,6 +1339,7 @@ "wallet.transaction.type.exchange": "Exchange", "wallet.transaction.unresolvedInputAddressesAdditionalLabel": "to see these addresses.", "wallet.transaction.unresolvedInputAddressesLinkLabel": "Open this transaction in Cardano explorer", + "wallet.transaction.voted": "Voting Power Delegation", "wallet.transactions.csv.column.addressesFrom": "Addresses from", "wallet.transactions.csv.column.addressesTo": "Addresses to", "wallet.transactions.csv.column.amount.deposit": "Deposit amount (ADA)", diff --git a/source/renderer/app/i18n/locales/ja-JP.json b/source/renderer/app/i18n/locales/ja-JP.json index 49612f7a3f..7fe92b08a8 100755 --- a/source/renderer/app/i18n/locales/ja-JP.json +++ b/source/renderer/app/i18n/locales/ja-JP.json @@ -34,6 +34,7 @@ "api.errors.WalletAlreadyImportedError": "インポートしようとしているウォレットは既に存在します。", "api.errors.WalletAlreadyRestoredError": "復元しようとしているウォレットは既に存在します。", "api.errors.WalletFileImportError": "ウォレットをインポートできませんでした。有効なファイルを指定していることを確認してください。", + "api.errors.conwayWalletNotDelegatedToDRep": "ステーキング報酬がブロックされているため、このトランザクションを処理できません。報酬のブロックを解除するには、Cardano投票権の委任タブに移動し、投票権を委任してください。", "api.errors.inputsDepleted": "このウォレットには報酬として得られた資金しか入っていません。
資金を使用するには1ADA以上をウォレットに入金してください。", "api.errors.invalidAddress": "有効なアドレスを入力してください。", "api.errors.invalidSmashServer": "このURLは有効なSMASHサーバーではありません", @@ -718,6 +719,34 @@ "voting.catalystFooterLinks.newsletter": "ニュースレター", "voting.catalystFooterLinks.projects": "プロジェクト", "voting.fundName": "Fund{votingFundNumber}", + "voting.governance.abstain": "棄権", + "voting.governance.confirmationDialog.button.cancel": "キャンセル", + "voting.governance.confirmationDialog.button.confirm": "確認", + "voting.governance.confirmationDialog.error.generic": "トランザクションの初期化中に問題が発生しました。しばらくしてからもう一度お試しください。", + "voting.governance.confirmationDialog.fee": "平均手数料", + "voting.governance.confirmationDialog.password": "パスワードを入力してください", + "voting.governance.confirmationDialog.title": "トランザクションの確認", + "voting.governance.confirmationDialog.vote": "投票", + "voting.governance.delegateToDRep": "DRepに委任(初期設定)", + "voting.governance.drepInputError": "無効なDRep IDです", + "voting.governance.drepInputLabel": "有効なDRep IDを入力するか貼り付けます。{drepDirectoryLink}で検索する", + "voting.governance.drepInputLabelLinkText": "DRepディレクトリ", + "voting.governance.drepInputLabelLinkUrl": "https://gov.tools/drep_directory", + "voting.governance.drepInputLabelLinkUrlPreview": "https://preview.gov.tools/drep_directory", + "voting.governance.drepInputLabelPreprod": "有効なDRep IDを入力するか貼り付けます。", + "voting.governance.drepInputPlaceholder": "DRep IDを追加してください", + "voting.governance.heading": "Cardano投票権の委任", + "voting.governance.initializeTxError.generic": "トランザクションの初期化中に問題が発生しました。しばらくしてからもう一度お試しください。", + "voting.governance.initializeTxError.notEnoughMoney": "このウォレットには必要最低額1ADAが入っていません。ウォレットはまだ同期中かもしれません。別の投票用ウォレットを選択してください。", + "voting.governance.initializeTxError.sameVote": "この投票権委任の選択は、以前のトランザクションですでに正常に記録されています。続けるには登録の種類またはDRep IDを変更してください。", + "voting.governance.noConfidence": "不信任", + "voting.governance.paragraph1": "投票権を登録すると、報酬を引き出せるようになります。詳細は{Link}をご覧ください。", + "voting.governance.paragraph1LinkText": "Cardano Governance", + "voting.governance.paragraph1LinkUrl": "https://docs.intersectmbo.org/cardano/cardano-governance/overview", + "voting.governance.selectVotingTypeLabel": "投票登録の種類を選択してください", + "voting.governance.selectWalletLabel": "委任するウォレットを選択してください", + "voting.governance.selectWalletPlaceholder": "ウォレットを選択してください", + "voting.governance.submitLabel": "送信する", "voting.info.androidAppButtonUrl": "https://play.google.com/store/apps/details?id=io.iohk.vitvoting", "voting.info.appleAppButtonUrl": "https://apps.apple.com/in/app/catalyst-voting/id1517473397", "voting.info.learnMoreLinkLabel": "もっと知る", @@ -736,6 +765,8 @@ "voting.resultsPhase.viewResultsLinkURL": "https://projectcatalyst.io/funds", "voting.snapshotPhase.snapshotDateLabel": "スナップショット日:", "voting.snapshotPhase.votingDateLabel": "次回投票期間:", + "voting.tabs.catalyst": "Catalyst投票", + "voting.tabs.governance": "ガバナンス", "voting.tallyingPhase.endDateLabel": "投票締め切り:", "voting.tallyingPhase.resultsLabel": "結果発表:", "voting.votingOpenPhase.dateLabel": "投票期間:", @@ -1308,6 +1339,7 @@ "wallet.transaction.type.exchange": "換金", "wallet.transaction.unresolvedInputAddressesAdditionalLabel": "アドレスを表示する。", "wallet.transaction.unresolvedInputAddressesLinkLabel": "Cardanoエクスプローラーでこのトランザクションを開き", + "wallet.transaction.voted": "投票権の委任", "wallet.transactions.csv.column.addressesFrom": "送信元アドレス", "wallet.transactions.csv.column.addressesTo": "送信先アドレス", "wallet.transactions.csv.column.amount.deposit": "デポジット額(ADA)", diff --git a/source/renderer/app/routes-config.ts b/source/renderer/app/routes-config.ts index a25b0aa24f..26c7ea94d0 100644 --- a/source/renderer/app/routes-config.ts +++ b/source/renderer/app/routes-config.ts @@ -32,7 +32,9 @@ export const ROUTES = { UTXO: '/wallets/:id/utxo', }, VOTING: { + ROOT: '/voting', REGISTRATION: '/voting/registration', + GOVERNANCE: '/voting/governance', }, SETTINGS: { ROOT: '/settings', 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 02c6128eac..c1a9c8fc2e 100644 --- a/source/renderer/app/stores/VotingStore.ts +++ b/source/renderer/app/stores/VotingStore.ts @@ -1,5 +1,6 @@ import { action, computed, observable, runInAction } from 'mobx'; import { get } from 'lodash'; +import BigNumber from 'bignumber.js'; import Store from './lib/Store'; import Request from './lib/LocalizedRequest'; import { ROUTES } from '../routes-config'; @@ -26,6 +27,11 @@ import type { } from '../api/transactions/types'; import type { CatalystFund } from '../api/voting/types'; import { EventCategories } from '../analytics'; +import type { DelegationCalculateFeeResponse } from '../api/staking/types'; +import Wallet from '../domains/Wallet'; +import ApiError from '../domains/ApiError'; +import type { DelegationAction } from '../types/stakingTypes'; +import { GenericApiError } from '../api/common/errors'; export type VotingRegistrationKeyType = { bytes: (...args: Array) => any; @@ -47,6 +53,48 @@ export enum FundPhase { TALLYING = 'tallying', RESULTS = 'results', } + +type GenericErrorCode = 'generic'; + +export type InitializeVPDelegationTxError = + | GenericErrorCode + | typeof expectedInitializeVPDelegationTxErrors[number]; +export const expectedInitializeVPDelegationTxErrors = [ + 'same_vote', + 'no_utxos_available', + 'not_enough_money', +] as const; + +export type DelegateVotesError = + | GenericErrorCode + | typeof expectedDelegateVotesErrors[number]; +export const expectedDelegateVotesErrors = [ + 'wrong_encryption_passphrase', +] as const; + +const parseApiCode = ( + expectedCodes: readonly ErrorCode[], + error: any +): ErrorCode | GenericErrorCode => { + const isExpectedError = ( + expectedCodes: readonly ErrorCode[], + errorCode: string + ): errorCode is ErrorCode => expectedCodes.includes(errorCode as ErrorCode); + + if (error instanceof ApiError && isExpectedError(expectedCodes, error.code)) { + return error.code; + } + + if ( + error instanceof GenericApiError && + isExpectedError(expectedCodes, error.values.code) + ) { + return error.values.code; + } + + return 'generic'; +}; + export default class VotingStore extends Store { @observable registrationStep = 1; @@ -117,6 +165,18 @@ export default class VotingStore extends Store { this.api.ada.createWalletSignature ); @observable + delegateVotesRequest: Request = new Request( + this.api.ada.delegateVotes + ); + @observable + constructTxRequest: Request< + ReturnType + > = new Request(this.api.ada.constructTransaction); + @observable + calculateFeeRequest: Request = new Request( + this.api.ada.calculateDelegationFee + ); + @observable getTransactionRequest: Request = new Request( this.api.ada.getTransaction ); @@ -211,6 +271,186 @@ export default class VotingStore extends Store { _setQrCode = (value: string | null | undefined) => { this.qrCode = value; }; + + initializeVPDelegationTx = async ({ + chosenOption, + wallet, + }: { + chosenOption: string; + wallet: Wallet; + }) => { + 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, + }; + + 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: constructedTx.fee, + }; + } catch (error) { + return { + success: false, + errorCode: parseApiCode(expectedInitializeVPDelegationTxErrors, error), + }; + } + }; + + delegateVotes = async ({ + chosenOption, + passphrase, + wallet, + }: { + chosenOption: string; + passphrase: string; + wallet: Wallet; + }) => { + // TODO: handle HW case + 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 { + await this.delegateVotesRequest.execute({ + dRepId: chosenOption, + passphrase, + walletId: wallet.id, + }).promise; + + 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) { + return { + success: false, + errorCode: parseApiCode(expectedDelegateVotesErrors, error), + }; + } + }; + prepareVotingData = async ({ walletId }: { walletId: string }) => { try { const [address] = await this.stores.addresses.getAddressesByWalletId( diff --git a/source/renderer/app/stores/WalletsStore.ts b/source/renderer/app/stores/WalletsStore.ts index 2c29904715..4cb40dfa45 100644 --- a/source/renderer/app/stores/WalletsStore.ts +++ b/source/renderer/app/stores/WalletsStore.ts @@ -837,7 +837,12 @@ export default class WalletsStore extends Store { : null; const wallet = this.active; if (!wallet) throw new Error('Active wallet required before sending.'); - // @ts-ignore ts-migrate(1320) FIXME: Type of 'await' operand must either be a valid pro... Remove this comment to see the full error message + + /** + * Do not try to catch the request error here, its intended to throw + * a localized error created in app/api/api.ts + */ + // @ts-ignore await this.sendMoneyRequest.execute({ address: receiver, amount: parseInt(amount, 10), @@ -847,6 +852,7 @@ export default class WalletsStore extends Store { assets: formattedAssets, hasAssetsRemainingAfterTransaction, }); + // The following code will not be executed if the request above fails this.analytics.sendEvent( EventCategories.WALLETS, 'Transaction made', diff --git a/source/renderer/app/types/stakingTypes.ts b/source/renderer/app/types/stakingTypes.ts index ae9d55b4fd..80237d4c81 100644 --- a/source/renderer/app/types/stakingTypes.ts +++ b/source/renderer/app/types/stakingTypes.ts @@ -1,3 +1,3 @@ export type RedeemItnRewardsStep = 'configuration' | 'confirmation' | 'result'; export type SmashServerType = 'iohk' | 'custom' | 'direct' | 'none'; -export type DelegationAction = 'join' | 'quit'; +export type DelegationAction = 'join' | 'quit' | 'cast_vote'; 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), diff --git a/storybook/stories/common/ItemsDropdown.stories.tsx b/storybook/stories/common/ItemsDropdown.stories.tsx index 00231ff862..fbfac49f32 100644 --- a/storybook/stories/common/ItemsDropdown.stories.tsx +++ b/storybook/stories/common/ItemsDropdown.stories.tsx @@ -136,11 +136,12 @@ storiesOf('Common / ItemsDropdown', module) options={options} // @ts-ignore ts-migrate(2769) FIXME: No overload matches this call. value={store.state.value} - onChange={(value) => + handleChange={(value) => store.set({ value, }) } + // @ts-ignore ts-migrate(2769) FIXME: No overload matches this call. hasSearch error={boolean('Has error', false) ? 'Error message' : ''} /> diff --git a/storybook/stories/wallets/send/WalletSend.stories.tsx b/storybook/stories/wallets/send/WalletSend.stories.tsx index 89716a8f01..653fdcf3f1 100644 --- a/storybook/stories/wallets/send/WalletSend.stories.tsx +++ b/storybook/stories/wallets/send/WalletSend.stories.tsx @@ -15,11 +15,13 @@ import { NUMBER_OPTIONS } from '../../../../source/renderer/app/config/profileCo import Wallet, { HwDeviceStatuses, } from '../../../../source/renderer/app/domains/Wallet'; +import { messages } from '../../../../source/renderer/app/api/errors'; // Screens import WalletSendForm from '../../../../source/renderer/app/components/wallet/WalletSendForm'; import type { WalletTokens } from '../../../../source/renderer/app/api/assets/types'; import { WalletSendConfirmationDialogView } from '../../../../source/renderer/app/containers/wallet/dialogs/send-confirmation/SendConfirmation.view'; import { noopAnalyticsTracker as analyticsTracker } from '../../../../source/renderer/app/analytics'; +import LocalizableError from '../../../../source/renderer/app/i18n/LocalizableError'; const allAssets = [ generateAssetToken( @@ -470,7 +472,11 @@ storiesOf('Wallets / Send', module) isSubmitting={boolean('isSubmitting', false)} isHardwareWallet={boolean('isHardwareWallet', false)} formattedTotalAmount="21.000000" - error={null} + error={ + new LocalizableError({ + ...messages.conwayWalletNotDelegatedToDRep, + }) + } onCancel={action('onCancel')} onSubmitCb={action('onSubmitCb')} onTermsCheckboxClick={action('onTermsCheckboxClick')} diff --git a/translations/messages.json b/translations/messages.json index 148e633f75..f1c7ea1bdf 100644 --- a/translations/messages.json +++ b/translations/messages.json @@ -95,6 +95,11 @@ "defaultMessage": "!!!Insufficient funds to support tokens. You need at least an additional {adaAmount} ADA in your wallet to process this transaction.", "description": "\"Balance after transaction would not leave enough ada in the wallet to support tokens remaining in wallet", "id": "api.errors.NotEnoughFundsForTransactionFeesErrorWithTokens" + }, + { + "defaultMessage": "!!!conwayWalletNotDelegatedToDRep", + "description": "Error message shown when conway era wallet has staking rewards but has not participated in governance yet.", + "id": "api.errors.conwayWalletNotDelegatedToDRep" } ], "path": "source/renderer/app/api/errors.ts" @@ -2244,6 +2249,21 @@ ], "path": "source/renderer/app/containers/staking/StakingRewardsPage.tsx" }, + { + "descriptors": [ + { + "defaultMessage": "!!!Catalyst Voting", + "description": "Label for the catalyst voting tab.", + "id": "voting.tabs.catalyst" + }, + { + "defaultMessage": "!!!Governance", + "description": "Label for the governance voting tab.", + "id": "voting.tabs.governance" + } + ], + "path": "source/renderer/app/containers/voting/Voting.tsx" + }, { "descriptors": [ { @@ -4384,6 +4404,161 @@ ], "path": "source/renderer/app/components/staking/widgets/TooltipPool.tsx" }, + { + "descriptors": [ + { + "defaultMessage": "!!!Please type or paste a valid DRep ID here. Look up {drepDirectoryLink}", + "description": "Label for DRep input on the governance page", + "id": "voting.governance.drepInputLabel" + }, + { + "defaultMessage": "!!!Please type or paste a valid DRep ID here.", + "description": "Label for DRep input on the governance page for preprod", + "id": "voting.governance.drepInputLabelPreprod" + }, + { + "defaultMessage": "!!!DRep directory", + "description": "Label link text for DRep input on the governance page", + "id": "voting.governance.drepInputLabelLinkText" + }, + { + "defaultMessage": "https://gov.tools/drep_directory", + "description": "Label link url for DRep input on the governance page", + "id": "voting.governance.drepInputLabelLinkUrl" + }, + { + "defaultMessage": "https://preview.gov.tools/drep_directory", + "description": "Label link url for DRep input on the governance page", + "id": "voting.governance.drepInputLabelLinkUrlPreview" + }, + { + "defaultMessage": "!!!Invalid DRep ID", + "description": "Error for DRep input on the governance page", + "id": "voting.governance.drepInputError" + }, + { + "defaultMessage": "!!!Paste DRep ID here …", + "description": "Placeholder for DRep input on the governance page", + "id": "voting.governance.drepInputPlaceholder" + }, + { + "defaultMessage": "!!!CARDANO VOTING POWER DELEGATION", + "description": "Headline for Governance", + "id": "voting.governance.heading" + }, + { + "defaultMessage": "!!!Governance first paragraph", + "description": "First paragraph for governance page", + "id": "voting.governance.paragraph1" + }, + { + "defaultMessage": "!!!Governance first paragraph link url", + "description": "First paragraph link for governance page", + "id": "voting.governance.paragraph1LinkUrl" + }, + { + "defaultMessage": "!!!Governance link label", + "description": "Link labels for governance page", + "id": "voting.governance.paragraph1LinkText" + }, + { + "defaultMessage": "!!!Select a wallet to delegate from", + "description": "Label for the wallet select on the governance page", + "id": "voting.governance.selectWalletLabel" + }, + { + "defaultMessage": "!!!Select a wallet …", + "description": "Placeholder for the wallet select on the governance page", + "id": "voting.governance.selectWalletPlaceholder" + }, + { + "defaultMessage": "!!!Select voting registration type", + "description": "Label for the registration type select on the governance page", + "id": "voting.governance.selectVotingTypeLabel" + }, + { + "defaultMessage": "!!!Submit", + "description": "Label for the submit button on the governance page", + "id": "voting.governance.submitLabel" + }, + { + "defaultMessage": "!!!Could not initialize transaction. Please try again!", + "description": "Generic error for initialize transaction", + "id": "voting.governance.initializeTxError.generic" + }, + { + "defaultMessage": "!!!Chosen same value as previously", + "description": "Chosen same value as previously", + "id": "voting.governance.initializeTxError.sameVote" + }, + { + "defaultMessage": "!!!Not enough funds", + "description": "Governance voting error when wallet has not enough funds", + "id": "voting.governance.initializeTxError.notEnoughMoney" + } + ], + "path": "source/renderer/app/components/voting/voting-governance/VotingPowerDelegation.messages.ts" + }, + { + "descriptors": [ + { + "defaultMessage": "!!!Confirm Transaction", + "description": "Title for confirm dialog", + "id": "voting.governance.confirmationDialog.title" + }, + { + "defaultMessage": "!!!Vote", + "description": "Vote title", + "id": "voting.governance.confirmationDialog.vote" + }, + { + "defaultMessage": "!!!Transaction fee", + "description": "Fee title", + "id": "voting.governance.confirmationDialog.fee" + }, + { + "defaultMessage": "!!!Spending password", + "description": "Label for password input", + "id": "voting.governance.confirmationDialog.password" + }, + { + "defaultMessage": "!!!Something went wrong during transaction submission. Please try again in a few minutes.", + "description": "Generic error message", + "id": "voting.governance.confirmationDialog.error.generic" + }, + { + "defaultMessage": "!!!Cancel", + "description": "Cancel button", + "id": "voting.governance.confirmationDialog.button.cancel" + }, + { + "defaultMessage": "!!!Confirm", + "description": "Confirm button", + "id": "voting.governance.confirmationDialog.button.confirm" + } + ], + "path": "source/renderer/app/components/voting/voting-governance/VotingPowerDelegationConfirmationDialog.messages.ts" + }, + { + "descriptors": [ + { + "defaultMessage": "!!!Abstain", + "description": "Translation for the \"abstain\" governance vote type", + "id": "voting.governance.abstain" + }, + { + "defaultMessage": "!!!Delegate to DRep", + "description": "Translation for the \"delegate to DRep\" governance vote type", + "id": "voting.governance.delegateToDRep" + }, + { + "defaultMessage": "!!!No Confidence", + "description": "Translation for the \"no confidence\" governance vote type", + "id": "voting.governance.noConfidence" + } + ], + "path": "source/renderer/app/components/voting/voting-governance/shared-messages.ts" + }, { "descriptors": [ { @@ -6476,6 +6651,11 @@ "description": "Label \"{transactionsType} received\" for the transaction.", "id": "wallet.transaction.received" }, + { + "defaultMessage": "!!!Voting Power Delegation", + "description": "Title for governance voting transactions.", + "id": "wallet.transaction.voted" + }, { "defaultMessage": "!!!From address", "description": "From address",