Skip to content

Commit

Permalink
feat(frontend): BtcConvertTokenWizard component
Browse files Browse the repository at this point in the history
  • Loading branch information
DenysKarmazynDFINITY committed Nov 19, 2024
1 parent ca6eac7 commit e410133
Show file tree
Hide file tree
Showing 5 changed files with 348 additions and 3 deletions.
150 changes: 150 additions & 0 deletions src/frontend/src/btc/components/convert/BtcConvertTokenWizard.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
<script lang="ts">
import type { WizardStep } from '@dfinity/gix-components';
import { isNullish, nonNullish } from '@dfinity/utils';
import { createEventDispatcher, getContext, setContext } from 'svelte';
import BtcConvertForm from '$btc/components/convert/BtcConvertForm.svelte';
import BtcConvertProgress from '$btc/components/convert/BtcConvertProgress.svelte';
import BtcConvertReview from '$btc/components/convert/BtcConvertReview.svelte';
import UtxosFeeContext from '$btc/components/fee/UtxosFeeContext.svelte';
import { sendBtc } from '$btc/services/btc-send.services';
import {
UTXOS_FEE_CONTEXT_KEY,
type UtxosFeeContext as UtxosFeeContextType,
initUtxosFeeStore
} from '$btc/stores/utxos-fee.store';
import { btcAddressStore } from '$icp/stores/btc.store';
import ButtonBack from '$lib/components/ui/ButtonBack.svelte';
import ButtonCancel from '$lib/components/ui/ButtonCancel.svelte';
import {
btcAddressMainnet,
btcAddressRegtest,
btcAddressTestnet
} from '$lib/derived/address.derived';
import { authIdentity } from '$lib/derived/auth.derived';
import { ProgressStepsConvert } from '$lib/enums/progress-steps';
import { WizardStepsConvert } from '$lib/enums/wizard-steps';
import { nullishSignOut } from '$lib/services/auth.services';
import { CONVERT_CONTEXT_KEY, type ConvertContext } from '$lib/stores/convert.store';
import { i18n } from '$lib/stores/i18n.store';
import { toastsError } from '$lib/stores/toasts.store';
import type { NetworkId } from '$lib/types/network';
import type { OptionAmount } from '$lib/types/send';
import { invalidAmount, isNullishOrEmpty } from '$lib/utils/input.utils';
import {
isNetworkIdBTCRegtest,
isNetworkIdBTCTestnet,
mapNetworkIdToBitcoinNetwork
} from '$lib/utils/network.utils';
export let currentStep: WizardStep | undefined;
export let sendAmount: OptionAmount;
export let receiveAmount: number | undefined;
export let convertProgressStep: string;
export let formCancelAction: 'back' | 'close' = 'close';
const utxosFeeStore = initUtxosFeeStore();
setContext<UtxosFeeContextType>(UTXOS_FEE_CONTEXT_KEY, {
store: utxosFeeStore
});
const { sourceToken, destinationToken } = getContext<ConvertContext>(CONVERT_CONTEXT_KEY);
const progress = (step: ProgressStepsConvert) => (convertProgressStep = step);
let networkId: NetworkId | undefined = undefined;
$: networkId = $sourceToken.network.id;
let sourceAddress: string;
$: sourceAddress =
(isNetworkIdBTCTestnet(networkId)
? $btcAddressTestnet
: isNetworkIdBTCRegtest(networkId)
? $btcAddressRegtest
: $btcAddressMainnet) ?? '';
let destinationAddress: string | undefined = undefined;
$: destinationAddress = $btcAddressStore?.[$destinationToken.id]?.data;
const dispatch = createEventDispatcher();
const convert = async () => {
const network = nonNullish(networkId) ? mapNetworkIdToBitcoinNetwork(networkId) : undefined;
if (isNullish($authIdentity)) {
await nullishSignOut();
return;
}
// The data has been already validated in the previous conversion flow steps.
if (
isNullish(network) ||
isNullishOrEmpty(destinationAddress) ||
invalidAmount(sendAmount) ||
isNullish(sendAmount) ||
isNullish($utxosFeeStore?.utxosFee)
) {
toastsError({
msg: { text: $i18n.convert.error.unexpected_missing_data }
});
return;
}
dispatch('icNext');
try {
// TODO: add tracking
await sendBtc({
destination: destinationAddress,
amount: sendAmount,
utxosFee: $utxosFeeStore.utxosFee,
network,
source: sourceAddress,
identity: $authIdentity,
onProgress: () => {
if (convertProgressStep === ProgressStepsConvert.INITIALIZATION) {
progress(ProgressStepsConvert.CONVERT);
} else if (convertProgressStep === ProgressStepsConvert.CONVERT) {
progress(ProgressStepsConvert.UPDATE_UI);
}
}
});
progress(ProgressStepsConvert.DONE);
setTimeout(() => close(), 750);
} catch (err: unknown) {
toastsError({
msg: { text: $i18n.convert.error.unexpected },
err
});
dispatch('icBack');
}
};
const close = () => dispatch('icClose');
const back = () => dispatch('icBack');
</script>

<UtxosFeeContext amount={sendAmount} {networkId}>
{#if currentStep?.name === WizardStepsConvert.CONVERT}
<BtcConvertForm on:icNext on:icClose bind:sendAmount bind:receiveAmount source={sourceAddress}>
<svelte:fragment slot="cancel">
{#if formCancelAction === 'back'}
<ButtonBack on:click={back} />
{:else}
<ButtonCancel on:click={close} />
{/if}
</svelte:fragment>
</BtcConvertForm>
{:else if currentStep?.name === WizardStepsConvert.REVIEW}
<BtcConvertReview on:icConvert={convert} on:icBack {sendAmount} {receiveAmount}
><ButtonBack slot="cancel" on:click={back} /></BtcConvertReview
>
{:else if currentStep?.name === WizardStepsConvert.CONVERTING}
<BtcConvertProgress bind:convertProgressStep />
{:else}
<slot />
{/if}
</UtxosFeeContext>
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@
<ButtonGroup slot="toolbar">
<slot name="cancel" />

<Button on:click={() => dispatch('icConvert')}>
<Button on:click={() => dispatch('icConvert')} testId="convert-review-button-next">
{$i18n.convert.text.convert_button}
</Button>
</ButtonGroup>
Expand Down
4 changes: 3 additions & 1 deletion src/frontend/src/lib/i18n/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -403,7 +403,9 @@
"insufficient_funds_for_fee": "Insufficient balance to cover the fees"
},
"error": {
"loading_cketh_helper": "Error while loading the ckETH helper contract address. No minter canister ID has been initialized."
"loading_cketh_helper": "Error while loading the ckETH helper contract address. No minter canister ID has been initialized.",
"unexpected": "Something went wrong while converting tokens.",
"unexpected_missing_data": "Something went wrong while initiating a convert transaction."
}
},
"buy": {
Expand Down
2 changes: 1 addition & 1 deletion src/frontend/src/lib/types/i18n.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -369,7 +369,7 @@ interface I18nConvert {
refreshing_ui: string;
};
assertion: { insufficient_funds: string; insufficient_funds_for_fee: string };
error: { loading_cketh_helper: string };
error: { loading_cketh_helper: string; unexpected: string; unexpected_missing_data: string };
}

interface I18nBuy {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,193 @@
import BtcConvertTokenWizard from '$btc/components/convert/BtcConvertTokenWizard.svelte';
import * as btcSendServices from '$btc/services/btc-send.services';
import * as utxosFeeStore from '$btc/stores/utxos-fee.store';
import type { UtxosFee } from '$btc/types/btc-send';
import { BTC_MAINNET_TOKEN } from '$env/tokens.btc.env';
import { ETHEREUM_TOKEN, ICP_TOKEN } from '$env/tokens.env';
import { btcAddressStore } from '$icp/stores/btc.store';
import * as addressesStore from '$lib/derived/address.derived';
import * as authStore from '$lib/derived/auth.derived';
import { ProgressStepsConvert } from '$lib/enums/progress-steps';
import { WizardStepsConvert } from '$lib/enums/wizard-steps';
import { CONVERT_CONTEXT_KEY } from '$lib/stores/convert.store';
import type { Token } from '$lib/types/token';
import { mockBtcAddress, mockUtxosFee } from '$tests/mocks/btc.mock';
import { mockIdentity } from '$tests/mocks/identity.mock';
import { mockPage } from '$tests/mocks/page.store.mock';
import type { Identity } from '@dfinity/agent';
import { assertNonNullish } from '@dfinity/utils';
import { fireEvent, render } from '@testing-library/svelte';
import { readable } from 'svelte/store';

describe('BtcConvertTokenWizard', () => {
const sendAmount = 0.001;
const mockContext = (sourceToken: Token | undefined = BTC_MAINNET_TOKEN) =>
new Map([
[
CONVERT_CONTEXT_KEY,
{
sourceToken: readable(sourceToken),
destinationToken: readable(ICP_TOKEN)
}
]
]);
const props = {
currentStep: {
name: WizardStepsConvert.REVIEW,
title: 'title'
},
convertProgressStep: ProgressStepsConvert.INITIALIZATION,
sendAmount: sendAmount,
receiveAmount: sendAmount
};
const mockBtcSendServices = () => vi.spyOn(btcSendServices, 'sendBtc').mockResolvedValue();
const mockAuthStore = (value: Identity | null = mockIdentity) =>
vi.spyOn(authStore, 'authIdentity', 'get').mockImplementation(() => readable(value));
const mockBtcAddressStore = (address: string | undefined = mockBtcAddress) => {
btcAddressStore.set({
tokenId: ICP_TOKEN.id,
data: {
certified: true,
data: address
}
});
};
const mockAddressesStore = () =>
vi
.spyOn(addressesStore, 'btcAddressMainnet', 'get')
.mockImplementation(() => readable(mockBtcAddress));
const mockUtxosFeeStore = (utxosFee?: UtxosFee) => {
const store = utxosFeeStore.initUtxosFeeStore();

vi.spyOn(utxosFeeStore, 'initUtxosFeeStore').mockImplementation(() => {
store.setUtxosFee({ utxosFee });
return store;
});
};
const clickConvertButton = async (container: HTMLElement) => {
const convertButtonSelector = '[data-tid="convert-review-button-next"]';
const button: HTMLButtonElement | null = container.querySelector(convertButtonSelector);
assertNonNullish(button, 'Button not found');
await fireEvent.click(button);
};

beforeEach(() => {
mockPage.reset();
});

it('should call sendBtc if all requirements are met', async () => {
const spy = mockBtcSendServices();
mockAuthStore();
mockBtcAddressStore();
mockAddressesStore();
mockUtxosFeeStore(mockUtxosFee);

const { container } = render(BtcConvertTokenWizard, {
props,
context: mockContext()
});

await clickConvertButton(container);

expect(spy).toHaveBeenCalledWith(
// all params except "onProgress"
expect.objectContaining({
amount: sendAmount,
destination: mockBtcAddress,
identity: mockIdentity,
network: BTC_MAINNET_TOKEN.network.env,
source: mockBtcAddress,
utxosFee: expect.objectContaining(mockUtxosFee)
})
);
expect(spy).toHaveBeenCalledOnce();
});

it('should not call sendBtc if authIdentity is not defined', async () => {
const spy = mockBtcSendServices();
mockAuthStore(null);
mockBtcAddressStore();
mockAddressesStore();
mockUtxosFeeStore(mockUtxosFee);

const { container } = render(BtcConvertTokenWizard, {
props,
context: mockContext()
});

await clickConvertButton(container);

expect(spy).not.toHaveBeenCalled();
});

it('should not call sendBtc if network is not BTC', async () => {
const spy = mockBtcSendServices();
mockAuthStore();
mockAddressesStore();
mockBtcAddressStore();
mockUtxosFeeStore(mockUtxosFee);

const { container } = render(BtcConvertTokenWizard, {
props,
context: mockContext(ETHEREUM_TOKEN)
});

await clickConvertButton(container);

expect(spy).not.toHaveBeenCalled();
});

it('should not call sendBtc if destination address is not defined', async () => {
const spy = mockBtcSendServices();
mockAuthStore();
mockAddressesStore();
mockBtcAddressStore('');
mockUtxosFeeStore(mockUtxosFee);

const { container } = render(BtcConvertTokenWizard, {
props,
context: mockContext()
});

await clickConvertButton(container);

expect(spy).not.toHaveBeenCalled();
});

it('should not call sendBtc if sendAmount is not defined', async () => {
const spy = mockBtcSendServices();
mockAuthStore();
mockAddressesStore();
mockBtcAddressStore();
mockUtxosFeeStore(mockUtxosFee);

const { container } = render(BtcConvertTokenWizard, {
props: {
...props,
sendAmount: undefined
},
context: mockContext()
});

await clickConvertButton(container);

expect(spy).not.toHaveBeenCalled();
});

it('should not call sendBtc if utxos are not defined', async () => {
const spy = mockBtcSendServices();
mockAuthStore();
mockAddressesStore();
mockBtcAddressStore();
mockUtxosFeeStore(undefined);

const { container } = render(BtcConvertTokenWizard, {
props,
context: mockContext()
});

await clickConvertButton(container);

expect(spy).not.toHaveBeenCalled();
});
});

0 comments on commit e410133

Please sign in to comment.