diff --git a/CHANGELOG-Nns-Dapp-unreleased.md b/CHANGELOG-Nns-Dapp-unreleased.md index 2e827fdcbd..647a09f56b 100644 --- a/CHANGELOG-Nns-Dapp-unreleased.md +++ b/CHANGELOG-Nns-Dapp-unreleased.md @@ -14,6 +14,8 @@ proposal is successful, the changes it released will be moved from this file to #### Added +- When sending SNS tokens to a burn address (minting account), the transaction fee is shown as 0 and labeled as a burn address. + #### Changed #### Deprecated diff --git a/frontend/src/lib/api/icrc-ledger.api.ts b/frontend/src/lib/api/icrc-ledger.api.ts index f13a228959..8cc14f7330 100644 --- a/frontend/src/lib/api/icrc-ledger.api.ts +++ b/frontend/src/lib/api/icrc-ledger.api.ts @@ -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, @@ -45,6 +46,24 @@ export const queryIcrcToken = async ({ return token; }; +export const queryIcrcMintingAccount = async ({ + certified, + identity, + canisterId, +}: { + certified: boolean; + identity: Identity; + canisterId: Principal; +}): Promise => { + 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, diff --git a/frontend/src/lib/components/transaction/TransactionForm.svelte b/frontend/src/lib/components/transaction/TransactionForm.svelte index c39deaea7f..21a1ac7954 100644 --- a/frontend/src/lib/components/transaction/TransactionForm.svelte +++ b/frontend/src/lib/components/transaction/TransactionForm.svelte @@ -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) => { @@ -78,10 +79,24 @@ return account.identifier !== selectedAccount?.identifier; }; + let isBurnDestination: boolean; + $: isBurnDestination = + nonNullish(burnAddress) && selectedDestinationAddress === burnAddress; + + let effectiveFeeUlps: bigint; + $: effectiveFeeUlps = isBurnDestination + ? 0n + : toTokenAmountV2(transactionFee).toUlps(); + + let effectiveFee: TokenAmountV2 | TokenAmount; + $: effectiveFee = isBurnDestination + ? TokenAmountV2.fromUlps({ amount: 0n, token }) + : transactionFee; + let max = 0; $: max = getMaxTransactionAmount({ balance: selectedAccount?.balanceUlps, - fee: toTokenAmountV2(transactionFee).toUlps(), + fee: effectiveFeeUlps, maxAmount, token, }); @@ -113,7 +128,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) { @@ -348,7 +363,10 @@ /> {#if showLedgerFee} - + {/if} diff --git a/frontend/src/lib/components/transaction/TransactionFormFee.svelte b/frontend/src/lib/components/transaction/TransactionFormFee.svelte index eb149889e8..8fe8b6bc05 100644 --- a/frontend/src/lib/components/transaction/TransactionFormFee.svelte +++ b/frontend/src/lib/components/transaction/TransactionFormFee.svelte @@ -6,12 +6,18 @@ import { formatUsdValue } from "$lib/utils/format.utils"; import { getUsdValue } from "$lib/utils/token.utils"; import { getLedgerCanisterIdFromUniverse } from "$lib/utils/universe.utils"; - import { isNullish, type TokenAmount, TokenAmountV2 } from "@dfinity/utils"; + import { + isNullish, + nonNullish, + type TokenAmount, + TokenAmountV2, + } from "@dfinity/utils"; type Props = { transactionFee: TokenAmount | TokenAmountV2; + description?: string; }; - const { transactionFee }: Props = $props(); + const { transactionFee, description = undefined }: Props = $props(); const usdValueDisplay = $derived.by(() => { const ledgerCanisterId = getLedgerCanisterIdFromUniverse( @@ -29,6 +35,11 @@

{$i18n.accounts.transaction_fee} + {#if nonNullish(description)} + ({description}) + {/if}

@@ -46,6 +57,10 @@ padding: var(--padding-0_5x) 0; color: var(--text-description); @include fonts.small(); + + .description { + font-style: italic; + } } .value { diff --git a/frontend/src/lib/components/transaction/TransactionReview.svelte b/frontend/src/lib/components/transaction/TransactionReview.svelte index b2d24d29f8..cec02de4e0 100644 --- a/frontend/src/lib/components/transaction/TransactionReview.svelte +++ b/frontend/src/lib/components/transaction/TransactionReview.svelte @@ -17,6 +17,7 @@ description: Snippet; destinationInfo: Snippet; disableSubmit: boolean; + feeDescription?: string; handleGoBack: () => void; receivedAmount: Snippet; selectedNetwork?: TransactionNetwork; @@ -32,6 +33,7 @@ description, destinationInfo, disableSubmit, + feeDescription = undefined, handleGoBack, receivedAmount, selectedNetwork = undefined, @@ -61,6 +63,7 @@ {transactionFee} {showLedgerFee} {receivedAmount} + {feeDescription} /> - {#snippet key()}{ledgerFeeLabel}{/snippet} + {#snippet key()}{ledgerFeeLabel}{#if nonNullish(feeDescription)} ({feeDescription}){/if}{/snippet} {#snippet value()}

+ 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"; @@ -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; @@ -25,6 +28,25 @@ }; let currentStep: WizardStep | undefined; + let burnAddress: string | undefined = undefined; + let mintingAccountLoaded = false; + + onMount(async () => { + try { + const mintingAccount = await queryIcrcMintingAccount({ + identity: getCurrentIdentity(), + canisterId: ledgerCanisterId, + certified: true, + }); + if (nonNullish(mintingAccount)) { + burnAddress = encodeIcrcAccount(mintingAccount); + } + } catch { + // Fall back to treating all addresses as regular (non-burn) transfers. + } finally { + mintingAccountLoaded = true; + } + }); $: title = currentStep?.name === "Form" @@ -43,12 +65,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"); @@ -70,6 +95,8 @@ {token} {transactionFee} {transactionInit} + {burnAddress} + disableContinue={!mintingAccountLoaded} > {title ?? $i18n.accounts.send}

diff --git a/frontend/src/lib/modals/transaction/TransactionModal.svelte b/frontend/src/lib/modals/transaction/TransactionModal.svelte index 84f1b8a392..0a10334264 100644 --- a/frontend/src/lib/modals/transaction/TransactionModal.svelte +++ b/frontend/src/lib/modals/transaction/TransactionModal.svelte @@ -18,6 +18,7 @@ WizardStep, WizardSteps, } from "@dfinity/gix-components"; + import { i18n } from "$lib/stores/i18n"; import type { Principal } from "@icp-sdk/core/principal"; import { ICPToken, @@ -60,6 +61,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); @@ -69,6 +71,15 @@ let selectedDestinationAddress: string | undefined = destinationAddress; + let isBurnDestination: boolean; + $: isBurnDestination = + nonNullish(burnAddress) && selectedDestinationAddress === burnAddress; + + let effectiveTransactionFee: TokenAmountV2 | TokenAmount; + $: effectiveTransactionFee = isBurnDestination + ? TokenAmountV2.fromUlps({ amount: 0n, token }) + : transactionFee; + let showManualAddress = selectDestinationMethods !== "dropdown"; // Wizard modal steps and navigation @@ -165,6 +176,7 @@ {showLedgerFee} on:nnsOpenQRCodeReader={goQRCode} {withMemo} + {burnAddress} > @@ -178,11 +190,14 @@ memo, }} handleGoBack={goBack} - {transactionFee} + transactionFee={effectiveTransactionFee} {disableSubmit} {token} {selectedNetwork} {showLedgerFee} + feeDescription={isBurnDestination + ? $i18n.accounts.burn_address + : undefined} on:nnsSubmit on:nnsClose {withMemo} diff --git a/frontend/src/lib/types/i18n.d.ts b/frontend/src/lib/types/i18n.d.ts index 1fe18beffd..882fdc6c6c 100644 --- a/frontend/src/lib/types/i18n.d.ts +++ b/frontend/src/lib/types/i18n.d.ts @@ -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; diff --git a/frontend/src/tests/lib/modals/accounts/IcrcTokenTransactionModal.spec.ts b/frontend/src/tests/lib/modals/accounts/IcrcTokenTransactionModal.spec.ts index b117f23077..fbb8f1292a 100644 --- a/frontend/src/tests/lib/modals/accounts/IcrcTokenTransactionModal.spec.ts +++ b/frontend/src/tests/lib/modals/accounts/IcrcTokenTransactionModal.spec.ts @@ -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"; @@ -26,6 +27,8 @@ describe("IcrcTokenTransactionModal", () => { amount: token.fee, token, }); + const mintingAccount = { owner: principal(99) }; + const mintingAccountAddress = encodeIcrcAccount(mintingAccount); beforeEach(() => { resetIdentity(); @@ -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, @@ -47,11 +61,80 @@ describe("IcrcTokenTransactionModal", () => { }, }); + await runResolvedPromises(); + return IcrcTokenTransactionModalPo.under( new JestPageObjectElement(container) ); }; + it("should disable continue until minting account is loaded", async () => { + setupAccount(); + let resolveMintingAccount: (value: undefined) => void; + vi.spyOn(ledgerApi, "queryIcrcMintingAccount").mockReturnValue( + new Promise((resolve) => { + resolveMintingAccount = () => resolve(undefined); + }) + ); + + const { container } = await renderModal({ + component: IcrcTokenTransactionModal, + props: { + ledgerCanisterId, + universeId: ledgerCanisterId, + token, + transactionFee, + }, + }); + + const po = IcrcTokenTransactionModalPo.under( + new JestPageObjectElement(container) + ); + + expect(await po.getTransactionFormPo().isContinueButtonEnabled()).toBe( + false + ); + + resolveMintingAccount(undefined); + await runResolvedPromises(); + + expect(await po.getTransactionFormPo().isContinueButtonEnabled()).toBe( + false + ); // still disabled without amount/destination + }); + + it("should enable continue if minting account query fails", async () => { + setupAccount(); + vi.spyOn(ledgerApi, "queryIcrcMintingAccount").mockRejectedValue( + new Error("network error") + ); + + const { container } = await renderModal({ + component: IcrcTokenTransactionModal, + props: { + ledgerCanisterId, + universeId: ledgerCanisterId, + token, + transactionFee, + }, + }); + + const po = IcrcTokenTransactionModalPo.under( + new JestPageObjectElement(container) + ); + + expect(await po.getTransactionFormPo().isContinueButtonEnabled()).toBe( + false + ); + + await runResolvedPromises(); + + expect(await po.getTransactionFormPo().isContinueButtonEnabled()).toBe( + false + ); // still disabled without amount/destination, but no longer blocked by loading + expect(await po.getTransactionFormPo().hasBurnAddressLabel()).toBe(false); + }); + it("should render token in the modal title", async () => { const po = await renderModalComponent(); @@ -59,25 +142,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({ @@ -94,4 +163,80 @@ 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 keep the fee visible and show burn description when destination is the minting account", async () => { + setupAccount(); + const po = await renderModalComponent(); + const formPo = po.getTransactionFormPo(); + + expect(await formPo.getTransactionFormFeePo().isPresent()).toBe(true); + expect(await formPo.hasBurnAddressLabel()).toBe(false); + + await formPo.enterAddress(mintingAccountAddress); + + expect(await formPo.getTransactionFormFeePo().isPresent()).toBe(true); + expect(await formPo.hasBurnAddressLabel()).toBe(true); + }); + + 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 }) + ); + }); + }); }); diff --git a/frontend/src/tests/lib/pages/SnsWallet.spec.ts b/frontend/src/tests/lib/pages/SnsWallet.spec.ts index 8610aef5d8..ab2a0cae56 100644 --- a/frontend/src/tests/lib/pages/SnsWallet.spec.ts +++ b/frontend/src/tests/lib/pages/SnsWallet.spec.ts @@ -78,6 +78,9 @@ describe("SnsWallet", () => { balance: 0n, }); vi.spyOn(icrcLedgerApi, "icrcTransfer").mockResolvedValue(10n); + vi.spyOn(icrcLedgerApi, "queryIcrcMintingAccount").mockResolvedValue( + undefined + ); vi.spyOn( workerTransactionsServices, "initTransactionsWorker" diff --git a/frontend/src/tests/page-objects/TransactionForm.page-object.ts b/frontend/src/tests/page-objects/TransactionForm.page-object.ts index 11a93d28bb..cac31b8c22 100644 --- a/frontend/src/tests/page-objects/TransactionForm.page-object.ts +++ b/frontend/src/tests/page-objects/TransactionForm.page-object.ts @@ -151,4 +151,10 @@ export class TransactionFormPo extends BasePageObject { async hasManualAddressLink(): Promise { return this.getManualAddressLink().isPresent(); } + + hasBurnAddressLabel(): Promise { + return this.root + .byTestId("transaction-form-fee-description") + .isPresent(); + } }