Feat/Use callStatic to improve error messaging (#831)

* feat: make use max only use account balance, add custom max messages

* fix: withdraw threshold limit display

* feat: add callstatic to ethereum transaction hook

* feat: improve types for useTransaction hook

* chore: fix types and remove ts-ignore

* chore: convert all smart contract wrapper methods to match metaclass methods

* fix: this context for calling tx

* chore: fix comment and any type

* chore: typo

Co-authored-by: Edd <edd@vega.xyz>

Co-authored-by: Edd <edd@vega.xyz>
This commit is contained in:
Matthew Russell 2022-07-25 09:48:19 +01:00 committed by GitHub
parent 1afdf4899d
commit e47298761a
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
34 changed files with 319 additions and 287 deletions

View File

@ -40,8 +40,8 @@ export const AppLoader = ({ children }: { children: React.ReactElement }) => {
decimals,
] = await Promise.all([
token.totalSupply(),
staking.totalStaked(),
vesting.totalStaked(),
staking.total_staked(),
vesting.total_staked(),
token.decimals(),
]);

View File

@ -41,9 +41,9 @@ export const BalanceManager = ({ children }: BalanceManagerProps) => {
if (!account || !config) return;
try {
const [b, w, stats, a] = await Promise.all([
contracts.vesting.userTotalAllTranches(account),
contracts.vesting.user_total_all_tranches(account),
contracts.token.balanceOf(account),
contracts.vesting.userStats(account),
contracts.vesting.user_stats(account),
contracts.token.allowance(
account,
config.staking_bridge_contract.address

View File

@ -54,7 +54,7 @@ export const ContractsProvider = ({ children }: { children: JSX.Element }) => {
config.staking_bridge_contract.address,
signer || provider
);
const vegaAddress = await staking.stakingToken();
const vegaAddress = await staking.staking_token();
setContracts({
token: new Token(vegaAddress, signer || provider),

View File

@ -37,8 +37,8 @@ export const useGetUserTrancheBalances = (
const trancheIds = [0, ...userTranches.map((t) => t.tranche_id)];
const promises = trancheIds.map(async (tId) => {
const [t, v] = await Promise.all([
vesting.getTrancheBalance(address, tId),
vesting.getVestedForTranche(address, tId),
vesting.get_tranche_balance(address, tId),
vesting.get_vested_for_tranche(address, tId),
]);
const total = toBigNum(t, decimals);

View File

@ -18,8 +18,8 @@ export function useRefreshAssociatedBalances() {
async (ethAddress: string, vegaKey: string) => {
const [walletAssociatedBalance, vestingAssociatedBalance] =
await Promise.all([
staking.stakeBalance(ethAddress, vegaKey),
vesting.stakeBalance(ethAddress, vegaKey),
staking.stake_balance(ethAddress, vegaKey),
vesting.stake_balance(ethAddress, vegaKey),
]);
appDispatch({

View File

@ -24,13 +24,13 @@ export const useRefreshBalances = (address: string) => {
try {
const [b, w, stats, a, walletStakeBalance, vestingStakeBalance] =
await Promise.all([
vesting.userTotalAllTranches(address),
vesting.user_total_all_tranches(address),
token.balanceOf(address),
vesting.userStats(address),
vesting.user_stats(address),
token.allowance(address, config.staking_bridge_contract.address),
// Refresh connected vega key balances as well if we are connected to a vega key
keypair?.pub ? staking.stakeBalance(address, keypair.pub) : null,
keypair?.pub ? vesting.stakeBalance(address, keypair.pub) : null,
keypair?.pub ? staking.stake_balance(address, keypair.pub) : null,
keypair?.pub ? vesting.stake_balance(address, keypair.pub) : null,
]);
const balance = toBigNum(b, decimals);

View File

@ -41,7 +41,7 @@ export const RedeemFromTranche = () => {
state: txState,
perform,
dispatch: txDispatch,
} = useTransaction(() => vesting.withdrawFromTranche(numberId));
} = useTransaction(() => vesting.withdraw_from_tranche(numberId));
const { token } = useContracts();
const redeemedAmount = React.useMemo(() => {

View File

@ -29,7 +29,7 @@ export const useAddStake = (
appState: { decimals },
} = useAppState();
const contractAdd = useTransaction(
() => vesting.stakeTokens(removeDecimal(amount, decimals), vegaKey),
() => vesting.stake_tokens(removeDecimal(amount, decimals), vegaKey),
confirmations
);
const walletAdd = useTransaction(

View File

@ -21,10 +21,10 @@ export const useRemoveStake = (
// which if staked > wallet balance means you cannot unstaked
// even worse if you stake everything then you can't unstake anything!
const contractRemove = useTransaction(() =>
vesting.removeStake(removeDecimal(amount, appState.decimals), vegaKey)
vesting.remove_stake(removeDecimal(amount, appState.decimals), vegaKey)
);
const walletRemove = useTransaction(() =>
staking.removeStake(removeDecimal(amount, appState.decimals), vegaKey)
staking.remove_stake(removeDecimal(amount, appState.decimals), vegaKey)
);
const refreshBalances = useRefreshBalances(address);
const getAssociationBreakdown = useGetAssociationBreakdown(

View File

@ -38,13 +38,13 @@ export interface DepositFormProps {
selectedAsset?: Asset;
onSelectAsset: (assetId: string) => void;
balance: BigNumber | undefined;
submitApprove: () => Promise<void>;
submitApprove: () => void;
submitDeposit: (args: {
assetSource: string;
amount: string;
vegaPublicKey: string;
}) => Promise<void>;
requestFaucet: () => Promise<void>;
}) => void;
requestFaucet: () => void;
limits: {
max: BigNumber;
deposited: BigNumber;

View File

@ -23,7 +23,10 @@ export const DepositLimits = ({ limits, balance }: DepositLimitsProps) => {
if (limits.deposited.isEqualTo(0)) {
remaining = maxLimit;
} else {
remaining = limits.max.minus(limits.deposited).toString();
const amountRemaining = limits.max.minus(limits.deposited);
remaining = amountRemaining.isGreaterThan(1_000_000)
? t('1m+')
: amountRemaining.toString();
}
return (

View File

@ -13,6 +13,7 @@ import {
useEthereumConfig,
} from '@vegaprotocol/web3';
import { useTokenContract } from '@vegaprotocol/web3';
import { removeDecimal } from '@vegaprotocol/react-helpers';
interface ERC20AssetSource {
__typename: 'ERC20';
@ -77,7 +78,7 @@ export const DepositManager = ({
);
// Set up approve transaction
const approve = useSubmitApproval(tokenContract, asset?.decimals);
const approve = useSubmitApproval(tokenContract);
// Set up deposit transaction
const { confirmationEvent, ...deposit } = useSubmitDeposit();
@ -109,9 +110,15 @@ export const DepositManager = ({
selectedAsset={asset}
onSelectAsset={(id) => setAssetId(id)}
assets={sortBy(assets, 'name')}
submitApprove={approve.perform}
submitDeposit={deposit.perform}
requestFaucet={faucet.perform}
submitApprove={() => {
if (!asset || !config) return;
const amount = removeDecimal('1000000', asset.decimals);
approve.perform(config.collateral_bridge_contract.address, amount);
}}
submitDeposit={(args) => {
deposit.perform(args.assetSource, args.amount, args.vegaPublicKey);
}}
requestFaucet={() => faucet.perform()}
limits={limits}
allowance={allowance}
isFaucetable={isFaucetable}

View File

@ -20,7 +20,7 @@ export const useGetDepositLimits = (asset?: Asset) => {
return;
}
return contract.getDepositMaximum(asset.source.contractAddress);
return contract.get_deposit_maximum(asset.source.contractAddress);
}, [asset, contract]);
useEffect(() => {

View File

@ -1,22 +1,10 @@
import { removeDecimal } from '@vegaprotocol/react-helpers';
import type { Token } from '@vegaprotocol/smart-contracts';
import { useEthereumConfig, useEthereumTransaction } from '@vegaprotocol/web3';
export const useSubmitApproval = (
contract: Token | null,
decimals: number | undefined
) => {
const { config } = useEthereumConfig();
const transaction = useEthereumTransaction(() => {
if (!contract || !config || decimals === undefined) {
return null;
}
const amount = removeDecimal('1000000', decimals);
return contract.approve(config.collateral_bridge_contract.address, amount);
});
import { useEthereumTransaction } from '@vegaprotocol/web3';
export const useSubmitApproval = (contract: Token | null) => {
const transaction = useEthereumTransaction<Token, 'approve'>(
contract,
'approve'
);
return transaction;
};

View File

@ -12,6 +12,11 @@ import {
useEthereumConfig,
useEthereumTransaction,
} from '@vegaprotocol/web3';
import type {
CollateralBridge,
CollateralBridgeNew,
} from '@vegaprotocol/smart-contracts';
import { prepend0x } from '@vegaprotocol/smart-contracts';
const DEPOSIT_EVENT_SUB = gql`
subscription DepositEvent($partyId: ID!) {
@ -35,26 +40,10 @@ export const useSubmitDeposit = () => {
// Store public key from contract arguments for use in the subscription,
// NOTE: it may be different from the users connected key
const [partyId, setPartyId] = useState<string | null>(null);
const { transaction, perform } = useEthereumTransaction<{
assetSource: string;
amount: string;
vegaPublicKey: string;
}>((args) => {
if (!contract) {
return null;
}
// New deposit started clear old confirmation event and start
// tracking deposits for the new public key
setConfirmationEvent(null);
setPartyId(args.vegaPublicKey);
return contract.depositAsset(
args.assetSource,
args.amount,
args.vegaPublicKey
);
}, config?.confirmations);
const { transaction, perform } = useEthereumTransaction<
CollateralBridgeNew | CollateralBridge,
'deposit_asset'
>(contract, 'deposit_asset', config?.confirmations);
useSubscription<DepositEvent, DepositEventVariables>(DEPOSIT_EVENT_SUB, {
variables: { partyId: partyId ? remove0x(partyId) : '' },
@ -90,7 +79,12 @@ export const useSubmitDeposit = () => {
return {
...transaction,
perform,
perform: (...args: Parameters<typeof perform>) => {
setConfirmationEvent(null);
setPartyId(args[2]);
const publicKey = prepend0x(args[2]);
perform(args[0], args[1], publicKey);
},
confirmationEvent,
};
};

View File

@ -1,14 +1,10 @@
import { Token } from '@vegaprotocol/smart-contracts';
import type { TokenFaucetable } from '@vegaprotocol/smart-contracts';
import type { Token, TokenFaucetable } from '@vegaprotocol/smart-contracts';
import { useEthereumTransaction } from '@vegaprotocol/web3';
export const useSubmitFaucet = (contract: Token | TokenFaucetable | null) => {
const transaction = useEthereumTransaction(() => {
if (!contract || contract instanceof Token) {
return null;
}
return contract.faucet();
});
const transaction = useEthereumTransaction<TokenFaucetable, 'faucet'>(
contract,
'faucet'
);
return transaction;
};

View File

@ -50,11 +50,14 @@ export const useOrderEdit = () => {
orderAmendment: {
orderId: order.id,
marketId: order.market.id,
// @ts-ignore fix me please!
price: {
value: removeDecimal(order.price, order.market?.decimalPlaces),
},
timeInForce: VegaWalletOrderTimeInForce[order.timeInForce],
// @ts-ignore fix me please!
sizeDelta: 0,
// @ts-ignore fix me please!
expiresAt: order.expiresAt
? {
value:

View File

@ -1,7 +1,6 @@
import type { BigNumber } from 'ethers';
import { ethers } from 'ethers';
import abi from '../abis/erc20_bridge_new_abi.json';
import { prepend0x } from '../utils';
export class CollateralBridgeNew {
public contract: ethers.Contract;
@ -14,32 +13,28 @@ export class CollateralBridgeNew {
this.contract = new ethers.Contract(address, abi, signerOrProvider);
}
depositAsset(assetSource: string, amount: string, vegaPublicKey: string) {
return this.contract.deposit_asset(
assetSource,
amount,
prepend0x(vegaPublicKey)
);
deposit_asset(assetSource: string, amount: string, vegaPublicKey: string) {
return this.contract.deposit_asset(assetSource, amount, vegaPublicKey);
}
getAssetSource(vegaAssetId: string) {
get_asset_source(vegaAssetId: string) {
return this.contract.get_asset_source(vegaAssetId);
}
getDepositMaximum(assetSource: string): Promise<BigNumber> {
get_deposit_maximum(assetSource: string): Promise<BigNumber> {
return this.contract.get_asset_deposit_lifetime_limit(assetSource);
}
getMultisigControlAddres() {
get_multisig_control_address() {
return this.contract.get_multisig_control_address();
}
getVegaAssetId(address: string) {
get_vega_asset_id(address: string) {
return this.contract.get_vega_asset_id(address);
}
isAssetListed(address: string) {
is_asset_listed(address: string) {
return this.contract.is_asset_listed(address);
}
getWithdrawThreshold(assetSource: string) {
get_withdraw_threshold(assetSource: string) {
return this.contract.get_withdraw_threshold(assetSource);
}
withdrawAsset(
withdraw_asset(
assetSource: string,
amount: string,
target: string,

View File

@ -1,7 +1,6 @@
import type { BigNumber } from 'ethers';
import { ethers } from 'ethers';
import abi from '../abis/erc20_bridge_abi.json';
import { prepend0x } from '../utils';
export class CollateralBridge {
public contract: ethers.Contract;
@ -16,35 +15,31 @@ export class CollateralBridge {
this.address = address;
}
depositAsset(assetSource: string, amount: string, vegaPublicKey: string) {
return this.contract.deposit_asset(
assetSource,
amount,
prepend0x(vegaPublicKey)
);
deposit_asset(assetSource: string, amount: string, vegaPublicKey: string) {
return this.contract.deposit_asset(assetSource, amount, vegaPublicKey);
}
getAssetSource(vegaAssetId: string) {
get_asset_source(vegaAssetId: string) {
return this.contract.get_asset_source(vegaAssetId);
}
getDepositMaximum(assetSource: string): Promise<BigNumber> {
get_deposit_maximum(assetSource: string): Promise<BigNumber> {
return this.contract.get_deposit_maximum(assetSource);
}
getDepositMinimum(assetSource: string): Promise<BigNumber> {
get_deposit_minimum(assetSource: string): Promise<BigNumber> {
return this.contract.get_deposit_minimum(assetSource);
}
getMultisigControlAddres() {
get_multisig_control_address() {
return this.contract.get_multisig_control_address();
}
getVegaAssetId(address: string) {
get_vega_asset_id(address: string) {
return this.contract.get_vega_asset_id(address);
}
isAssetListed(address: string) {
is_asset_listed(address: string) {
return this.contract.is_asset_listed(address);
}
getWithdrawThreshold(assetSource: string) {
get_withdraw_threshold(assetSource: string) {
return this.contract.get_withdraw_threshold(assetSource);
}
withdrawAsset(
withdraw_asset(
assetSource: string,
amount: string,
target: string,

View File

@ -17,23 +17,23 @@ export class StakingBridge {
stake(amount: string, vegaPublicKey: string) {
return this.contract.stake(amount, prepend0x(vegaPublicKey));
}
removeStake(amount: string, vegaPublicKey: string) {
remove_stake(amount: string, vegaPublicKey: string) {
return this.contract.remove_stake(amount, prepend0x(vegaPublicKey));
}
transferStake(amount: string, newAddress: string, vegaPublicKey: string) {
transfer_stake(amount: string, newAddress: string, vegaPublicKey: string) {
return this.contract.transfer_stake(
amount,
newAddress,
prepend0x(vegaPublicKey)
);
}
stakingToken() {
staking_token() {
return this.contract.staking_token();
}
stakeBalance(target: string, vegaPublicKey: string) {
stake_balance(target: string, vegaPublicKey: string) {
return this.contract.stake_balance(target, prepend0x(vegaPublicKey));
}
totalStaked() {
total_staked() {
return this.contract.total_staked();
}
}

View File

@ -14,31 +14,31 @@ export class TokenVesting {
this.address = address;
}
stakeTokens(amount: string, vegaPublicKey: string) {
stake_tokens(amount: string, vegaPublicKey: string) {
return this.contract.stake_tokens(amount, prepend0x(vegaPublicKey));
}
removeStake(amount: string, vegaPublicKey: string) {
remove_stake(amount: string, vegaPublicKey: string) {
return this.contract.remove_stake(amount, prepend0x(vegaPublicKey));
}
stakeBalance(address: string, vegaPublicKey: string) {
stake_balance(address: string, vegaPublicKey: string) {
return this.contract.stake_balance(address, prepend0x(vegaPublicKey));
}
totalStaked() {
total_staked() {
return this.contract.total_staked();
}
userStats(address: string) {
user_stats(address: string) {
return this.contract.user_stats(address);
}
getTrancheBalance(address: string, trancheId: number) {
get_tranche_balance(address: string, trancheId: number) {
return this.contract.get_tranche_balance(address, trancheId);
}
getVestedForTranche(address: string, trancheId: number) {
get_vested_for_tranche(address: string, trancheId: number) {
return this.contract.get_vested_for_tranche(address, trancheId);
}
userTotalAllTranches(address: string) {
user_total_all_tranches(address: string) {
return this.contract.user_total_all_tranches(address);
}
withdrawFromTranche(trancheId: number) {
withdraw_from_tranche(trancheId: number) {
return this.contract.withdraw_from_tranche(trancheId);
}
}

View File

@ -29,4 +29,7 @@ export class Token {
decimals(): Promise<number> {
return this.contract.decimals();
}
faucet() {
/* No op */
}
}

View File

@ -1,10 +1,12 @@
export class EthereumError extends Error {
code: number;
reason: string;
constructor(message: string, code: number) {
constructor(message: string, code: number, reason: string) {
super(message);
this.code = code;
this.name = 'EthereumError';
this.code = code;
this.reason = reason;
}
}

View File

@ -40,7 +40,7 @@ it('Opens when tx starts and closes if the user rejects the tx', () => {
rerender(
generateJsx({
status: EthTxStatus.Error,
error: new EthereumError('User rejected', 4001),
error: new EthereumError('User rejected', 4001, 'reason'),
})
);
@ -82,11 +82,15 @@ it('Dialog states', () => {
expect(screen.getByText('Ethereum transaction complete')).toBeInTheDocument();
const errorMsg = 'Something went wrong';
const reason = 'Transaction failed';
rerender(
generateJsx({ status: EthTxStatus.Error, error: new Error(errorMsg) })
generateJsx({
status: EthTxStatus.Error,
error: new EthereumError(errorMsg, 1, reason),
})
);
expect(screen.getByText(`${props.name} failed`)).toBeInTheDocument();
expect(screen.getByText(errorMsg)).toBeInTheDocument();
expect(screen.getByText(`Error: ${reason}`)).toBeInTheDocument();
});
it('Success state waits for confirmation event if provided', () => {

View File

@ -1,7 +1,8 @@
import { useEffect, useRef, useState } from 'react';
import { t } from '@vegaprotocol/react-helpers';
import { Dialog, Icon, Intent, Loader } from '@vegaprotocol/ui-toolkit';
import { isExpectedEthereumError } from '../ethereum-error';
import { isEthereumError, isExpectedEthereumError } from '../ethereum-error';
import type { TxError } from '../use-ethereum-transaction';
import { EthTxStatus } from '../use-ethereum-transaction';
import { ConfirmRow, TxRow, ConfirmationEventRow } from './dialog-rows';
import { DialogWrapper } from './dialog-wrapper';
@ -9,7 +10,7 @@ import { DialogWrapper } from './dialog-wrapper';
export interface TransactionDialogProps {
name: string;
status: EthTxStatus;
error: Error | null;
error: TxError | null;
confirmations: number;
txHash: string | null;
requiredConfirmations?: number;
@ -32,9 +33,26 @@ export const TransactionDialog = ({
const renderContent = () => {
if (status === EthTxStatus.Error) {
const classNames = 'break-all text-black dark:text-white';
if (isEthereumError(error)) {
return (
<p className={classNames}>
{t('Error')}: {error.reason}
</p>
);
}
if (error instanceof Error) {
return (
<p className={classNames}>
{t('Error')}: {error.message}
</p>
);
}
return (
<p className="break-all text-black dark:text-white">
{error && error.message}
<p className={classNames}>
{t('Error')}: {t('Unknown error')}
</p>
);
}

View File

@ -1,11 +1,7 @@
import { MockedProvider } from '@apollo/client/testing';
import { waitFor } from '@testing-library/react';
import { renderHook, act } from '@testing-library/react-hooks/dom';
import { EthTxStatus } from './use-ethereum-transaction';
import type { ReactNode } from 'react';
import { useEthereumTransaction } from './use-ethereum-transaction';
import type { ethers } from 'ethers';
import { EthereumError } from './ethereum-error';
beforeAll(() => {
jest.useFakeTimers();
@ -17,53 +13,52 @@ afterAll(() => {
class MockContract {
static txHash = 'tx-hash';
confirmations = 0;
depositAsset(args: {
assetSource: string;
amount: string;
vegaPublicKey: string;
}): Promise<ethers.ContractTransaction> {
contract = {
callStatic: {
deposit_asset() {
return new Promise((resolve) => {
setTimeout(() => {
resolve(true);
}, 10);
});
},
},
} as unknown as ethers.Contract;
deposit_asset(assetSource: string, amount: string, vegaPublicKey: string) {
return Promise.resolve({
hash: MockContract.txHash,
wait: () => {
this.confirmations++;
confirmations++;
return new Promise((resolve) => {
setTimeout(
() =>
resolve({
from: 'foo',
confirmations: this.confirmations,
} as ethers.ContractReceipt),
100
);
setTimeout(() => {
resolve({
from: 'foo',
confirmations,
} as ethers.ContractReceipt);
}, 100);
});
},
} as ethers.ContractTransaction);
}
}
let confirmations = 0;
const mockContract = new MockContract();
const requiredConfirmations = 3;
function setup(perform: () => void) {
const wrapper = ({ children }: { children: ReactNode }) => (
<MockedProvider>{children}</MockedProvider>
);
return renderHook(
// @ts-ignore force MockContract
() => useEthereumTransaction(perform, requiredConfirmations),
{ wrapper }
function setup(methodName: 'deposit_asset' = 'deposit_asset') {
return renderHook(() =>
useEthereumTransaction<MockContract, 'deposit_asset'>(
mockContract,
methodName,
requiredConfirmations
)
);
}
it('Ethereum transaction flow', async () => {
const { result } = setup(() => {
return mockContract.depositAsset({
assetSource: 'asset-source',
amount: '100',
vegaPublicKey: 'vega-key',
});
});
const { result } = setup();
expect(result.current).toEqual({
transaction: {
@ -77,27 +72,30 @@ it('Ethereum transaction flow', async () => {
reset: expect.any(Function),
});
act(() => {
result.current.perform();
});
result.current.perform('asset-source', '100', 'vega-key');
expect(result.current.transaction.status).toEqual(EthTxStatus.Requested);
expect(result.current.transaction.status).toEqual(EthTxStatus.Default); // still default as we await result of static call
expect(result.current.transaction.confirmations).toBe(0);
await waitFor(() => {
expect(result.current.transaction.status).toEqual(EthTxStatus.Pending);
expect(result.current.transaction.txHash).toEqual(MockContract.txHash);
await act(async () => {
jest.advanceTimersByTime(10);
});
expect(result.current.transaction.status).toEqual(EthTxStatus.Pending);
expect(result.current.transaction.txHash).toEqual(MockContract.txHash);
expect(result.current.transaction.confirmations).toBe(0);
await act(async () => {
jest.advanceTimersByTime(100);
});
expect(result.current.transaction.confirmations).toBe(1);
expect(result.current.transaction.status).toEqual(EthTxStatus.Pending);
await act(async () => {
jest.advanceTimersByTime(100);
});
expect(result.current.transaction.confirmations).toBe(2);
expect(result.current.transaction.status).toEqual(EthTxStatus.Pending);
@ -114,18 +112,14 @@ it('Ethereum transaction flow', async () => {
});
});
it('Error handling', async () => {
const { result } = setup(() => {
throw new EthereumError(errorMsg, 500);
describe('error handling', () => {
it('ensures correct method is used', async () => {
const { result } = setup('non-existing-method' as 'deposit_asset');
act(() => {
result.current.perform('asset-rouce', '100', 'vega-key');
});
expect(result.current.transaction.status).toEqual(EthTxStatus.Error);
});
const errorMsg = 'test-error';
act(() => {
result.current.perform();
});
expect(result.current.transaction.status).toEqual(EthTxStatus.Error);
expect(result.current.transaction.error instanceof EthereumError).toBe(true);
expect(result.current.transaction.error?.message).toBe(errorMsg);
});

View File

@ -1,6 +1,7 @@
import type { ethers } from 'ethers';
import { useCallback, useState } from 'react';
import { EthereumError, isEthereumError } from './ethereum-error';
import type { EthereumError } from './ethereum-error';
import { isEthereumError } from './ethereum-error';
export enum EthTxStatus {
Default = 'Default',
@ -28,10 +29,16 @@ export const initialState = {
confirmations: 0,
};
export const useEthereumTransaction = <TArgs = void>(
performTransaction: (
args: TArgs
) => Promise<ethers.ContractTransaction> | null,
type DefaultContract = {
contract: ethers.Contract;
};
export const useEthereumTransaction = <
TContract extends DefaultContract,
TMethod extends string
>(
contract: TContract | null,
methodName: keyof TContract,
requiredConfirmations = 1
) => {
const [transaction, _setTransaction] = useState<EthTxState>(initialState);
@ -44,7 +51,27 @@ export const useEthereumTransaction = <TArgs = void>(
}, []);
const perform = useCallback(
async (args: TArgs) => {
// @ts-ignore TS errors here as TMethod doesn't satisfy the constraints on TContract
// its a tricky one to fix but does enforce the correct types when calling perform
async (...args: Parameters<TContract[TMethod]>) => {
try {
if (
!contract ||
typeof contract[methodName] !== 'function' ||
typeof contract.contract.callStatic[methodName as string] !==
'function'
) {
throw new Error('method not found on contract');
}
await contract.contract.callStatic[methodName as string](...args);
} catch (err) {
setTransaction({
status: EthTxStatus.Error,
error: err as EthereumError,
});
return;
}
setTransaction({
status: EthTxStatus.Requested,
error: null,
@ -52,14 +79,13 @@ export const useEthereumTransaction = <TArgs = void>(
});
try {
const res = performTransaction(args);
const method = contract[methodName];
if (res === null) {
setTransaction({ status: EthTxStatus.Default });
return;
if (!method || typeof method !== 'function') {
throw new Error('method not found on contract');
}
const tx = await res;
const tx = await method.call(contract, ...args);
let receipt: ethers.ContractReceipt | null = null;
@ -67,22 +93,21 @@ export const useEthereumTransaction = <TArgs = void>(
for (let i = 1; i <= requiredConfirmations; i++) {
receipt = await tx.wait(i);
setTransaction({ confirmations: receipt.confirmations });
setTransaction({
confirmations: receipt
? receipt.confirmations
: requiredConfirmations,
});
}
if (!receipt) {
throw new Error('No receipt after confirmations are met');
throw new Error('no receipt after confirmations are met');
}
setTransaction({ status: EthTxStatus.Complete, receipt });
} catch (err) {
if (err instanceof Error) {
if (err instanceof Error || isEthereumError(err)) {
setTransaction({ status: EthTxStatus.Error, error: err });
} else if (isEthereumError(err)) {
setTransaction({
status: EthTxStatus.Error,
error: new EthereumError(err.message, err.code),
});
} else {
setTransaction({
status: EthTxStatus.Error,
@ -91,7 +116,7 @@ export const useEthereumTransaction = <TArgs = void>(
}
}
},
[performTransaction, requiredConfirmations, setTransaction]
[contract, methodName, requiredConfirmations, setTransaction]
);
const reset = useCallback(() => {

View File

@ -11,7 +11,10 @@ import * as sentry from '@sentry/react';
import type { Erc20ApprovalNew_erc20WithdrawalApproval } from './__generated__/Erc20ApprovalNew';
jest.mock('@vegaprotocol/web3', () => ({
useBridgeContract: jest.fn(),
useBridgeContract: jest.fn().mockReturnValue({
withdraw_asset: jest.fn(),
isNewContract: true,
}),
useEthereumTransaction: jest.fn(),
}));
@ -56,7 +59,14 @@ it('Should perform the Ethereum transaction with the fetched approval', async ()
result.current.submit(withdrawalId);
});
await waitFor(() => {
expect(mockPerform).toHaveBeenCalledWith(erc20WithdrawalApproval);
expect(mockPerform).toHaveBeenCalledWith(
erc20WithdrawalApproval.assetSource,
erc20WithdrawalApproval.amount,
erc20WithdrawalApproval.targetAddress,
erc20WithdrawalApproval.creation,
erc20WithdrawalApproval.nonce,
erc20WithdrawalApproval.signatures
);
expect(result.current.withdrawalId).toBe(withdrawalId);
});
});

View File

@ -21,58 +21,22 @@ export const PENDING_WITHDRAWAL_FRAGMMENT = gql`
}
`;
export interface NewWithdrawTransactionArgs {
assetSource: string;
amount: string;
nonce: string;
signatures: string;
targetAddress: string;
creation: string;
}
export interface WithdrawTransactionArgs {
assetSource: string;
amount: string;
nonce: string;
signatures: string;
targetAddress: string;
}
export const useCompleteWithdraw = (isNewContract: boolean) => {
const { query, cache } = useApolloClient();
const contract = useBridgeContract(isNewContract);
const [id, setId] = useState('');
const { transaction, perform } = useEthereumTransaction<
WithdrawTransactionArgs | NewWithdrawTransactionArgs
>((args) => {
if (!contract) {
return null;
}
if (contract.isNewContract) {
const withdrawalData = args as NewWithdrawTransactionArgs;
return (contract as CollateralBridgeNew).withdrawAsset(
withdrawalData.assetSource,
withdrawalData.amount,
withdrawalData.targetAddress,
withdrawalData.creation,
withdrawalData.nonce,
withdrawalData.signatures
);
} else {
return (contract as CollateralBridge).withdrawAsset(
args.assetSource,
args.amount,
args.targetAddress,
args.nonce,
args.signatures
);
}
});
CollateralBridgeNew | CollateralBridge,
'withdraw_asset'
>(contract, 'withdraw_asset');
const submit = useCallback(
async (withdrawalId: string) => {
setId(withdrawalId);
try {
if (!contract) {
return;
}
const res = await query<
Erc20Approval | Erc20ApprovalNew,
Erc20ApprovalVariables
@ -83,16 +47,34 @@ export const useCompleteWithdraw = (isNewContract: boolean) => {
variables: { withdrawalId },
});
if (!res.data.erc20WithdrawalApproval) {
const approval = res.data.erc20WithdrawalApproval;
if (!approval) {
throw new Error('Could not retrieve withdrawal approval');
}
perform(res.data.erc20WithdrawalApproval);
if (contract.isNewContract && 'creation' in approval) {
perform(
approval.assetSource,
approval.amount,
approval.targetAddress,
approval.creation,
approval.nonce,
approval.signatures
);
} else {
perform(
approval.assetSource,
approval.amount,
approval.targetAddress,
approval.nonce,
approval.signatures
);
}
} catch (err) {
captureException(err);
}
},
[query, isNewContract, perform]
[contract, query, isNewContract, perform]
);
useEffect(() => {

View File

@ -11,7 +11,7 @@ export const useGetWithdrawLimits = (asset?: Asset) => {
return;
}
return contract.getWithdrawThreshold(asset.source.contractAddress);
return contract.get_withdraw_threshold(asset.source.contractAddress);
}, [asset, contract]);
const {

View File

@ -10,7 +10,10 @@ import { useWithdraw } from './use-withdraw';
import type { Erc20ApprovalNew } from './__generated__/Erc20ApprovalNew';
jest.mock('@vegaprotocol/web3', () => ({
useBridgeContract: jest.fn(),
useBridgeContract: jest.fn().mockReturnValue({
withdraw_asset: jest.fn(),
isNewContract: true,
}),
useEthereumTransaction: jest.fn(),
}));
@ -141,9 +144,15 @@ it('Creates withdrawal and immediately submits Ethereum transaction', async () =
// @ts-ignore MockedRespones types inteferring
mockERC20Approval.result.data.erc20WithdrawalApproval
);
// @ts-ignore MockedRespones types inteferring
const withdrawal = mockERC20Approval.result.data.erc20WithdrawalApproval;
expect(mockPerform).toHaveBeenCalledWith(
// @ts-ignore MockedRespones types inteferring
mockERC20Approval.result.data.erc20WithdrawalApproval
withdrawal.assetSource,
withdrawal.amount,
withdrawal.targetAddress,
withdrawal.creation,
withdrawal.nonce,
withdrawal.signatures
);
});

View File

@ -4,20 +4,19 @@ import { useBridgeContract, useEthereumTransaction } from '@vegaprotocol/web3';
import { useVegaTransaction, useVegaWallet } from '@vegaprotocol/wallet';
import { useCallback, useEffect, useState } from 'react';
import { ERC20_APPROVAL_QUERY, ERC20_APPROVAL_QUERY_NEW } from './queries';
import type {
NewWithdrawTransactionArgs,
WithdrawTransactionArgs,
} from './use-complete-withdraw';
import type {
Erc20Approval,
Erc20ApprovalVariables,
Erc20Approval_erc20WithdrawalApproval,
} from './__generated__/Erc20Approval';
import type {
Erc20ApprovalNew,
Erc20ApprovalNew_erc20WithdrawalApproval,
} from './__generated__/Erc20ApprovalNew';
import type {
CollateralBridge,
CollateralBridgeNew,
} from '@vegaprotocol/smart-contracts';
import type { Erc20ApprovalNew } from './__generated__/Erc20ApprovalNew';
export interface WithdrawalFields {
amount: string;
@ -27,8 +26,11 @@ export interface WithdrawalFields {
export const useWithdraw = (cancelled: boolean, isNewContract: boolean) => {
const [withdrawalId, setWithdrawalId] = useState<string | null>(null);
const [approval, setApproval] =
useState<Erc20Approval_erc20WithdrawalApproval | null>(null);
const [approval, setApproval] = useState<
| Erc20Approval_erc20WithdrawalApproval
| Erc20ApprovalNew_erc20WithdrawalApproval
| null
>(null);
const contract = useBridgeContract(isNewContract);
const { keypair } = useVegaWallet();
@ -42,30 +44,10 @@ export const useWithdraw = (cancelled: boolean, isNewContract: boolean) => {
transaction: ethTx,
perform,
reset: resetEthTx,
} = useEthereumTransaction<WithdrawTransactionArgs>((args) => {
if (!contract) {
return null;
}
if (contract.isNewContract) {
const withdrawalArguments = args as NewWithdrawTransactionArgs;
return (contract as CollateralBridgeNew).withdrawAsset(
withdrawalArguments.assetSource,
withdrawalArguments.amount,
withdrawalArguments.targetAddress,
withdrawalArguments.creation,
withdrawalArguments.nonce,
withdrawalArguments.signatures
);
} else {
return (contract as CollateralBridge).withdrawAsset(
args.assetSource,
args.amount,
args.targetAddress,
args.nonce,
args.signatures
);
}
});
} = useEthereumTransaction<
CollateralBridgeNew | CollateralBridge,
'withdraw_asset'
>(contract, 'withdraw_asset');
const { data, stopPolling } = useQuery<
Erc20Approval | Erc20ApprovalNew,
@ -114,11 +96,28 @@ export const useWithdraw = (cancelled: boolean, isNewContract: boolean) => {
}, [data, stopPolling]);
useEffect(() => {
if (approval && !cancelled) {
perform(approval);
if (approval && contract && !cancelled) {
if (contract.isNewContract && 'creation' in approval) {
perform(
approval.assetSource,
approval.amount,
approval.targetAddress,
approval.creation,
approval.nonce,
approval.signatures
);
} else {
perform(
approval.assetSource,
approval.amount,
approval.targetAddress,
approval.nonce,
approval.signatures
);
}
}
// eslint-disable-next-line
}, [approval]);
}, [approval, contract]);
const reset = useCallback(() => {
resetVegaTx();

View File

@ -4,6 +4,7 @@ import type { VegaTxState } from '@vegaprotocol/wallet';
import { VegaTxStatus } from '@vegaprotocol/wallet';
import type { ReactNode } from 'react';
import type { EthTxState } from '@vegaprotocol/web3';
import { isEthereumError } from '@vegaprotocol/web3';
import { EthTxStatus } from '@vegaprotocol/web3';
import { t } from '@vegaprotocol/react-helpers';
import type { Erc20Approval_erc20WithdrawalApproval } from './__generated__/Erc20Approval';
@ -132,7 +133,11 @@ const getProps = (
intent: Intent.Danger,
children: (
<Step>
{ethTx.error ? ethTx.error.message : t('Something went wrong')}
{isEthereumError(ethTx.error)
? `Error: ${ethTx.error.reason}`
: ethTx.error instanceof Error
? t(`Error: ${ethTx.error.message}`)
: t('Something went wrong')}
</Step>
),
},

View File

@ -76,7 +76,7 @@ it('Expected Ethereum error closes the dialog', async () => {
ethTx: {
...useWithdrawValue.ethTx,
status: EthTxStatus.Error,
error: new EthereumError('User rejected transaction', 4001),
error: new EthereumError('User rejected transaction', 4001, 'reason'),
},
});
rerender(generateJsx(props));