Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add HW support for VP delegation #3243

Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions source/renderer/app/api/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1275,11 +1275,13 @@ export default class AdaApi {

try {
const {
transaction,
coin_selection,
fee: { quantity },
} = await constructTransaction(this.config, params);

const result = {
transaction,
coinSelection: parseCoinSelectionResponse({
coinSelectionResponse: coin_selection,
}),
Expand Down
4 changes: 3 additions & 1 deletion source/renderer/app/api/transactions/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -196,11 +196,13 @@ export type CoinSelectionOutput = {
export type CertificateType =
| 'register_reward_account'
| 'quit_pool'
| 'join_pool';
| 'join_pool'
| 'cast_vote';
export type CoinSelectionCertificate = {
pool: string;
certificateType: CertificateType;
rewardAccountPath: Array<string>;
vote?: string;
};
export type CoinSelectionCertificates = Array<CoinSelectionCertificate>;
export type CoinSelectionWithdrawal = {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,16 @@ const mapOfTxErrorCodeToIntl: Record<
not_enough_money: messages.initializeNotEnoughMoney,
};

const initialState: State = {
status: 'form',
selectedWallet: null,
selectedVoteType: 'drep',
drepInputState: {
dirty: false,
value: '',
},
};

function VotingPowerDelegation({
getStakePoolById,
initiateTransaction,
Expand All @@ -101,15 +111,7 @@ function VotingPowerDelegation({
wallets,
stakePools,
}: Props) {
const [state, setState] = useState<State>({
status: 'form',
selectedWallet: null,
selectedVoteType: 'drep',
drepInputState: {
dirty: false,
value: '',
},
});
const [state, setState] = useState<State>(initialState);

const drepInputIsValid = isDrepIdValid(state.drepInputState.value);

Expand Down Expand Up @@ -209,9 +211,8 @@ function VotingPowerDelegation({
onChange={(walletId: string) => {
const selectedWallet = wallets.find((w) => w.id === walletId);
setState({
...state,
...initialState,
selectedWallet,
status: 'form',
});
}}
placeholder={intl.formatMessage(messages.selectWalletPlaceholder)}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,15 +4,18 @@ import { injectIntl } from 'react-intl';
import { Input } from 'react-polymorph/lib/components/Input';
import { InputSkin } from 'react-polymorph/lib/skins/simple/InputSkin';
import Dialog from '../../widgets/Dialog';
import DialogCloseButton from '../../widgets/DialogCloseButton';
import { formattedWalletAmount } from '../../../utils/formatters';
import Wallet, { HwDeviceStatus } from '../../../domains/Wallet';
import Wallet, {
HwDeviceStatus,
HwDeviceStatuses,
} from '../../../domains/Wallet';
import HardwareWalletStatus from '../../hardware-wallet/HardwareWalletStatus';
import styles from './VotingPowerDelegationConfirmationDialog.scss';
import { DelegateVotesError } from '../../../stores/VotingStore';
import type { Intl, ReactIntlMessage } from '../../../types/i18nTypes';
import { messages } from './VotingPowerDelegationConfirmationDialog.messages';
import globalMessages from '../../../i18n/global-messages';
import LoadingSpinner from '../../widgets/LoadingSpinner';
import { VoteType } from './types';
import { sharedGovernanceMessages } from './shared-messages';

Expand Down Expand Up @@ -108,30 +111,39 @@ function VotingPowerDelegationConfirmationDialog({
})();
}, [intl, onSubmit, redirectToWallet, state]);

const confirmButtonLabel =
state.status === 'awaiting' ? (
intl.formatMessage(messages.buttonConfirm)
) : (
<LoadingSpinner />
);

return (
<Dialog
title={intl.formatMessage(messages.title)}
actions={[
{
label: intl.formatMessage(messages.buttonCancel),
onClick: onClose,
disabled: state.status === 'submitting',
disabled: state.status !== 'awaiting',
},
{
label: intl.formatMessage(messages.buttonConfirm),
label: confirmButtonLabel,
onClick: () => {
if (state.status !== 'awaiting' || !state.passphrase) return;
setState({
passphrase: state.passphrase,
passphrase: '',
status: 'confirmed',
});
},
primary: true,
disabled: state.status === 'submitting' || !state.passphrase,
disabled:
state.status !== 'awaiting' ||
(selectedWallet.isHardwareWallet
? hwDeviceStatus !==
HwDeviceStatuses.VERIFYING_TRANSACTION_SUCCEEDED
: !state.passphrase),
},
]}
onClose={onClose}
closeButton={<DialogCloseButton onClose={onClose} />}
>
<div className={styles.content}>
<p className={styles.paragraphTitle}>
Expand Down
2 changes: 2 additions & 0 deletions source/renderer/app/stores/HardwareWalletsStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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);
Expand Down
139 changes: 126 additions & 13 deletions source/renderer/app/stores/VotingStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,8 +29,9 @@ import type { CatalystFund } from '../api/voting/types';
import { EventCategories } from '../analytics';
import type { DelegationCalculateFeeResponse } from '../api/staking/types';
import Wallet from '../domains/Wallet';
import { logger } from '../utils/logging';
import ApiError from '../domains/ApiError';
import type { DelegationAction } from '../types/stakingTypes';
import { GenericApiError } from '../api/common/errors';

export type VotingRegistrationKeyType = {
bytes: (...args: Array<any>) => any;
Expand Down Expand Up @@ -84,6 +85,13 @@ const parseApiCode = <ErrorCode extends string>(
return error.code;
}

if (
error instanceof GenericApiError &&
isExpectedError(expectedCodes, error.values.code)
) {
return error.values.code;
}

return 'generic';
};

Expand Down Expand Up @@ -271,26 +279,89 @@ export default class VotingStore extends Store {
chosenOption: string;
wallet: Wallet;
}) => {
this.constructTxRequest.reset();
try {
const {
coinSelection,
fee: fees,
} = await this.constructTxRequest.execute({
walletId: wallet.id,
data: { vote: chosenOption },
}).promise;
if (wallet.isHardwareWallet) {
const [{ id: stakePoolId }] = this.stores.staking.stakePools;
let dlegationData: {
Copy link

@mirceahasegan mirceahasegan Nov 26, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

small typo. Just nitpicking, it could be ignored 😄

-      let dlegationData: {
+      let delegationData: {

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'],
Copy link

@mirceahasegan mirceahasegan Nov 26, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I am sorry. I am unfamiliar with Daedalus. Is it a single account wallet? (i.e. account is always '0H')

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, that's correct. Daedalus is a single-account wallet.

},
...certificates,
];
}

const coinSelection = {
...initialCoinSelection,
certificates,
};

if (wallet.isHardwareWallet) {
this.stores.hardwareWallets.updateTxSignRequest(coinSelection);
this.stores.hardwareWallets.initiateTransaction({
walletId: wallet.id,
});

return {
success: true,
fees: coinSelection.fee,
};
} catch (error) {
return {
success: false,
errorCode: parseApiCode(
expectedInitializeVPDelegationTxErrors,
error
),
};
}
}

this.constructTxRequest.reset();
try {
const constructedTx = await this.constructTxRequest.execute({
walletId: wallet.id,
data: { vote: chosenOption },
}).promise;

return {
success: true,
fees,
fees: constructedTx.fee,
};
} catch (error) {
return {
Expand All @@ -310,7 +381,49 @@ export default class VotingStore extends Store {
wallet: Wallet;
}) => {
// TODO: handle HW case
if (wallet.isHardwareWallet) return;
if (wallet.isHardwareWallet) {
try {
await this.stores.hardwareWallets._sendMoney({
selectedWalletId: wallet.id,
});

await new Promise<void>((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 {
Expand Down
21 changes: 20 additions & 1 deletion source/renderer/app/utils/dataSerialization.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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]);
}
Expand Down
1 change: 1 addition & 0 deletions source/renderer/app/utils/hardwareWalletUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
Loading