Skip to content
Draft
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
19 changes: 19 additions & 0 deletions frontend/src/lib/api/icrc-ledger.api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { logWithTimestamp } from "$lib/utils/dev.utils";
import { mapOptionalToken } from "$lib/utils/icrc-tokens.utils";
import {
arrayOfNumberToUint8Array,
fromNullable,
isNullish,
nonNullish,
toNullable,
Expand Down Expand Up @@ -45,6 +46,24 @@ export const queryIcrcToken = async ({
return token;
};

export const queryIcrcMintingAccount = async ({
certified,
identity,
canisterId,
}: {
certified: boolean;
identity: Identity;
canisterId: Principal;
}): Promise<IcrcAccount | undefined> => {
const { canister } = await icrcLedgerCanister({ identity, canisterId });
const result = fromNullable(await canister.getMintingAccount({ certified }));
if (isNullish(result)) return undefined;
return {
owner: result.owner,
subaccount: fromNullable(result.subaccount),
};
};

export const queryIcrcBalance = async ({
identity,
certified,
Expand Down
28 changes: 25 additions & 3 deletions frontend/src/lib/components/transaction/TransactionForm.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@
export let validateAmount: ValidateAmountFn = () => undefined;
export let withMemo: boolean = false;
export let memo: string | undefined = undefined;
export let burnAddress: string | undefined = undefined;

let filterSourceAccounts: (account: Account) => boolean;
$: filterSourceAccounts = (account: Account) => {
Expand All @@ -78,10 +79,19 @@
return account.identifier !== selectedAccount?.identifier;
};

let isBurnDestination: boolean;
$: isBurnDestination =
nonNullish(burnAddress) && selectedDestinationAddress === burnAddress;

let effectiveFeeUlps: bigint;
$: effectiveFeeUlps = isBurnDestination
? 0n
: toTokenAmountV2(transactionFee).toUlps();

let max = 0;
$: max = getMaxTransactionAmount({
balance: selectedAccount?.balanceUlps,
fee: toTokenAmountV2(transactionFee).toUlps(),
fee: effectiveFeeUlps,
maxAmount,
token,
});
Expand Down Expand Up @@ -113,7 +123,7 @@
const tokens = TokenAmountV2.fromNumber({ amount, token });
assertEnoughAccountFunds({
account: selectedAccount,
amountUlps: tokens.toUlps() + toTokenAmountV2(transactionFee).toUlps(),
amountUlps: tokens.toUlps() + effectiveFeeUlps,
});
errorMessage = validateAmount({ amount, selectedAccount });
} catch (error: unknown) {
Expand Down Expand Up @@ -328,6 +338,12 @@
{/if}
{/if}

{#if isBurnDestination}
<p class="burn-address-label" data-tid="burn-address-label">
{$i18n.accounts.burn_address}
</p>
{/if}

{#if mustSelectNetwork}
<TransactionFormItemNetwork
bind:selectedNetwork
Expand All @@ -347,7 +363,7 @@
{balance}
/>

{#if showLedgerFee}
{#if showLedgerFee && !isBurnDestination}
<TransactionFormFee {transactionFee} />
{/if}

Expand Down Expand Up @@ -418,6 +434,12 @@
}
}

.burn-address-label {
font-size: var(--font-size-small);
color: var(--text-description);
margin: 0;
}

.manual-address-info {
display: flex;
flex-direction: column;
Expand Down
1 change: 1 addition & 0 deletions frontend/src/lib/i18n/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -352,6 +352,7 @@
"hardware_wallet_text": "Ledger device",
"token_transaction_fee": "$tokenSymbol Ledger Fee",
"transaction_fee": "Transaction Fee",
"burn_address": "Burn address",
"review_transaction": "Review Transaction",
"current_balance": "Current balance",
"confirm_and_send": "Confirm and Send",
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
<script lang="ts">
import { queryIcrcMintingAccount } from "$lib/api/icrc-ledger.api";
import TransactionModal from "$lib/modals/transaction/TransactionModal.svelte";
import { getCurrentIdentity } from "$lib/services/auth.services";
import { transferTokens } from "$lib/services/icrc-accounts.services";
import { startBusy, stopBusy } from "$lib/stores/busy.store";
import { i18n } from "$lib/stores/i18n";
Expand All @@ -11,7 +13,8 @@
import type { WizardStep } from "@dfinity/gix-components";
import type { Principal } from "@icp-sdk/core/principal";
import { TokenAmountV2, nonNullish, type Token } from "@dfinity/utils";
import { createEventDispatcher } from "svelte";
import { encodeIcrcAccount } from "@icp-sdk/canisters/ledger/icrc";
import { createEventDispatcher, onMount } from "svelte";

export let selectedAccount: Account | undefined = undefined;
export let ledgerCanisterId: Principal;
Expand All @@ -25,6 +28,18 @@
};

let currentStep: WizardStep | undefined;
let burnAddress: string | undefined = undefined;

onMount(async () => {
const mintingAccount = await queryIcrcMintingAccount({
identity: getCurrentIdentity(),
canisterId: ledgerCanisterId,
certified: false,
});
if (nonNullish(mintingAccount)) {
burnAddress = encodeIcrcAccount(mintingAccount);
}
});

$: title =
currentStep?.name === "Form"
Expand All @@ -43,12 +58,15 @@
initiator: "accounts",
});

const isBurn =
nonNullish(burnAddress) && destinationAddress === burnAddress;

const { blockIndex } = await transferTokens({
source: sourceAccount,
destinationAddress,
amountUlps: numberToUlps({ amount, token }),
ledgerCanisterId,
fee: transactionFee.toUlps(),
fee: isBurn ? 0n : transactionFee.toUlps(),
});

stopBusy("accounts");
Expand All @@ -70,6 +88,7 @@
{token}
{transactionFee}
{transactionInit}
{burnAddress}
>
<svelte:fragment slot="title">{title ?? $i18n.accounts.send}</svelte:fragment>
<p slot="description" class="value no-margin">
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@

// Optional transaction memo to include in the submission payload
export let withMemo: boolean = false;
export let burnAddress: string | undefined = undefined;

// Init configuration only once when component is mounting. The configuration should not vary when user interact with the form.
let canSelectDestination = isNullish(transactionInit.destinationAddress);
Expand All @@ -69,6 +70,10 @@

let selectedDestinationAddress: string | undefined = destinationAddress;

let isBurnDestination: boolean;
$: isBurnDestination =
nonNullish(burnAddress) && selectedDestinationAddress === burnAddress;

let showManualAddress = selectDestinationMethods !== "dropdown";

// Wizard modal steps and navigation
Expand Down Expand Up @@ -165,6 +170,7 @@
{showLedgerFee}
on:nnsOpenQRCodeReader={goQRCode}
{withMemo}
{burnAddress}
>
<slot name="additional-info-form" slot="additional-info" />
</TransactionForm>
Expand All @@ -182,7 +188,7 @@
{disableSubmit}
{token}
{selectedNetwork}
{showLedgerFee}
showLedgerFee={showLedgerFee && !isBurnDestination}
on:nnsSubmit
on:nnsClose
{withMemo}
Expand Down
1 change: 1 addition & 0 deletions frontend/src/lib/types/i18n.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -364,6 +364,7 @@ interface I18nAccounts {
hardware_wallet_text: string;
token_transaction_fee: string;
transaction_fee: string;
burn_address: string;
review_transaction: string;
current_balance: string;
confirm_and_send: string;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import { renderModal } from "$tests/mocks/modal.mock";
import { principal } from "$tests/mocks/sns-projects.mock";
import { IcrcTokenTransactionModalPo } from "$tests/page-objects/IcrcTokenTransactionModal.page-object";
import { JestPageObjectElement } from "$tests/page-objects/jest.page-object";
import { runResolvedPromises } from "$tests/utils/timers.test-utils";
import { TokenAmountV2 } from "@dfinity/utils";
import { encodeIcrcAccount } from "@icp-sdk/canisters/ledger/icrc";

Expand All @@ -26,6 +27,8 @@ describe("IcrcTokenTransactionModal", () => {
amount: token.fee,
token,
});
const mintingAccount = { owner: principal(99) };
const mintingAccountAddress = encodeIcrcAccount(mintingAccount);

beforeEach(() => {
resetIdentity();
Expand All @@ -34,8 +37,19 @@ describe("IcrcTokenTransactionModal", () => {
routeId: AppPath.Accounts,
});
vi.spyOn(ledgerApi, "icrcTransfer").mockResolvedValue(1234n);
vi.spyOn(ledgerApi, "queryIcrcMintingAccount").mockResolvedValue(undefined);
});

const setupAccount = () => {
icrcAccountsStore.set({
ledgerCanisterId,
accounts: {
accounts: [{ ...mockIcrcMainAccount, balanceUlps: 1000n * 10n ** 18n }],
certified: true,
},
});
};

const renderModalComponent = async () => {
const { container } = await renderModal({
component: IcrcTokenTransactionModal,
Expand All @@ -47,6 +61,8 @@ describe("IcrcTokenTransactionModal", () => {
},
});

await runResolvedPromises();

return IcrcTokenTransactionModalPo.under(
new JestPageObjectElement(container)
);
Expand All @@ -59,25 +75,11 @@ describe("IcrcTokenTransactionModal", () => {
});

it("should transfer tokens", async () => {
// Used to choose the source account
icrcAccountsStore.set({
ledgerCanisterId,
accounts: {
accounts: [
{
...mockIcrcMainAccount,
balanceUlps: 1000n * 10n ** 18n,
},
],
certified: true,
},
});
setupAccount();

const po = await renderModalComponent();

const toAccount = {
owner: principal(2),
};
const toAccount = { owner: principal(2) };
const amount = 10;

await po.transferToAddress({
Expand All @@ -94,4 +96,78 @@ describe("IcrcTokenTransactionModal", () => {
fee: token.fee,
});
});

describe("burn address", () => {
beforeEach(() => {
vi.spyOn(ledgerApi, "queryIcrcMintingAccount").mockResolvedValue(
mintingAccount
);
});

it("should show burn address label when destination is the minting account", async () => {
setupAccount();
const po = await renderModalComponent();
const formPo = po.getTransactionFormPo();

expect(await formPo.hasBurnAddressLabel()).toBe(false);

await formPo.enterAddress(mintingAccountAddress);

expect(await formPo.hasBurnAddressLabel()).toBe(true);
});

it("should not show burn address label for a regular address", async () => {
setupAccount();
const po = await renderModalComponent();
const formPo = po.getTransactionFormPo();

await formPo.enterAddress(encodeIcrcAccount({ owner: principal(2) }));

expect(await formPo.hasBurnAddressLabel()).toBe(false);
});

it("should hide the fee when destination is the minting account", async () => {
setupAccount();
const po = await renderModalComponent();
const formPo = po.getTransactionFormPo();

expect(await formPo.hasFee()).toBe(true);

await formPo.enterAddress(mintingAccountAddress);

expect(await formPo.hasFee()).toBe(false);
});

it("should transfer with fee 0 when destination is the minting account", async () => {
setupAccount();
const po = await renderModalComponent();

await po.transferToAddress({
destinationAddress: mintingAccountAddress,
amount: 1,
});

expect(ledgerApi.icrcTransfer).toHaveBeenCalledTimes(1);
expect(ledgerApi.icrcTransfer).toHaveBeenCalledWith(
expect.objectContaining({
fee: 0n,
to: mintingAccount,
})
);
});

it("should transfer with normal fee for a regular address", async () => {
setupAccount();
const po = await renderModalComponent();

await po.transferToAddress({
destinationAddress: encodeIcrcAccount({ owner: principal(2) }),
amount: 1,
});

expect(ledgerApi.icrcTransfer).toHaveBeenCalledWith(
expect.objectContaining({ fee: token.fee })
);
});
});
});
3 changes: 3 additions & 0 deletions frontend/src/tests/lib/pages/SnsWallet.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,9 @@ describe("SnsWallet", () => {
balance: 0n,
});
vi.spyOn(icrcLedgerApi, "icrcTransfer").mockResolvedValue(10n);
vi.spyOn(icrcLedgerApi, "queryIcrcMintingAccount").mockResolvedValue(
undefined
);
vi.spyOn(
workerTransactionsServices,
"initTransactionsWorker"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -151,4 +151,12 @@ export class TransactionFormPo extends BasePageObject {
async hasManualAddressLink(): Promise<boolean> {
return this.getManualAddressLink().isPresent();
}

hasBurnAddressLabel(): Promise<boolean> {
return this.root.byTestId("burn-address-label").isPresent();
}

async hasFee(): Promise<boolean> {
return this.getTransactionFormFeePo().isPresent();
}
}
Loading