Skip to content

Commit

Permalink
feat: add HW support for VP delegation (#3243)
Browse files Browse the repository at this point in the history
* feat: add ledger support for VP delegation

* feat: add trezor support for VP delegation
  • Loading branch information
szymonmaslowski authored Nov 27, 2024
1 parent 10c01b1 commit b4cdb15
Show file tree
Hide file tree
Showing 10 changed files with 270 additions and 44 deletions.
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: {
delegationAction: DelegationAction;
poolId: string;
} = {
delegationAction: 'join',
poolId: stakePoolId,
};

if (wallet.isDelegating) {
const { lastDelegatedStakePoolId, delegatedStakePoolId } = wallet;
const poolId = lastDelegatedStakePoolId || delegatedStakePoolId || '';
dlegationData = {
delegationAction: 'quit',
poolId,
};
}

try {
const initialCoinSelection = await this.stores.hardwareWallets.selectDelegationCoins(
{
walletId: wallet.id,
...dlegationData,
}
);

let certificates: object[] = [
{
certificateType: 'cast_vote',
rewardAccountPath: ['1852H', '1815H', '0H', '2', '0'],
vote: chosenOption,
},
];

const walletNeedsRegisteringRewardAccount = initialCoinSelection.certificates.some(
(c) => c.certificateType === 'register_reward_account'
);
if (walletNeedsRegisteringRewardAccount) {
certificates = [
{
certificateType: 'register_reward_account',
rewardAccountPath: ['1852H', '1815H', '0H', '2', '0'],
},
...certificates,
];
}

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

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

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

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

return {
success: true,
fees,
fees: constructedTx.fee,
};
} catch (error) {
return {
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

0 comments on commit b4cdb15

Please sign in to comment.