diff --git a/frontend/src/lib/services/canisters.services.ts b/frontend/src/lib/services/canisters.services.ts index 6031208c9ff..6e7dc8899f0 100644 --- a/frontend/src/lib/services/canisters.services.ts +++ b/frontend/src/lib/services/canisters.services.ts @@ -3,17 +3,21 @@ import { createCanister as createCanisterApi, detachCanister as detachCanisterApi, getIcpToCyclesExchangeRate as getIcpToCyclesExchangeRateApi, + notifyTopUpCanister, queryCanisterDetails as queryCanisterDetailsApi, queryCanisters, renameCanister as renameCanisterApi, topUpCanister as topUpCanisterApi, updateSettings as updateSettingsApi, } from "$lib/api/canisters.api"; +import { getTransactions } from "$lib/api/icp-index.api"; import type { CanisterDetails, CanisterSettings, } from "$lib/canisters/ic-management/ic-management.canister.types"; import type { CanisterDetails as CanisterInfo } from "$lib/canisters/nns-dapp/nns-dapp.types"; +import { TOP_UP_CANISTER_MEMO } from "$lib/constants/api.constants"; +import { CYCLES_MINTING_CANISTER_ID } from "$lib/constants/canister-ids.constants"; import { FORCE_CALL_STRATEGY } from "$lib/constants/mockable.constants"; import { mainTransactionFeeE8sStore } from "$lib/derived/main-transaction-fee.derived"; import { canistersStore } from "$lib/stores/canisters.store"; @@ -27,8 +31,19 @@ import { mapCanisterErrorToToastMessage, toToastError, } from "$lib/utils/error.utils"; +import { AnonymousIdentity } from "@dfinity/agent"; +import { + AccountIdentifier, + SubAccount, + type TransactionWithId, +} from "@dfinity/ledger-icp"; import type { Principal } from "@dfinity/principal"; -import { ICPToken, TokenAmountV2 } from "@dfinity/utils"; +import { + ICPToken, + TokenAmountV2, + isNullish, + principalToSubAccount, +} from "@dfinity/utils"; import { get } from "svelte/store"; import { getAuthenticatedIdentity } from "./auth.services"; import { getAccountIdentity, loadBalance } from "./icp-accounts.services"; @@ -166,6 +181,73 @@ export const topUpCanister = async ({ } }; +// Returns the blockheight of the transaction, if it was a canister top-up, or +// undefined otherwise. +const getBlockHeightFromCanisterTopUp = ({ + id: blockHeight, + transaction: { memo, operation }, +}: TransactionWithId): bigint | undefined => { + if (memo !== TOP_UP_CANISTER_MEMO || !("Transfer" in operation)) { + return undefined; + } + return blockHeight; +}; + +// Returns true if notify_top_up was called (whether successful or not). +export const notifyTopUpIfNeeded = async ({ + canisterId, +}: { + canisterId: Principal; +}): Promise => { + const subAccount = principalToSubAccount(canisterId); + const cmcAccountIdentifier = AccountIdentifier.fromPrincipal({ + principal: CYCLES_MINTING_CANISTER_ID, + subAccount: SubAccount.fromBytes(subAccount) as SubAccount, + }); + const cmcAccountIdentifierHex = cmcAccountIdentifier.toHex(); + + const { + balance, + transactions: [transaction], + } = await getTransactions({ + identity: new AnonymousIdentity(), + maxResults: 1n, + accountIdentifier: cmcAccountIdentifierHex, + }); + + if (balance === 0n || isNullish(transaction)) { + return false; + } + + const blockHeight = getBlockHeightFromCanisterTopUp(transaction); + + if (isNullish(blockHeight)) { + // This should be very rare but it might be useful to know if it happens. + console.warn( + "CMC subaccount has non-zero balance but the most recent transaction is not a top-up", + { + canisterId: canisterId.toText(), + cmcAccountIdentifierHex, + balance, + transaction, + } + ); + return false; + } + + try { + await notifyTopUpCanister({ + identity: new AnonymousIdentity(), + blockHeight, + canisterId, + }); + } catch (error: unknown) { + console.error(error); + // Ignore. This is just a background fallback. + } + return true; +}; + export const addController = async ({ controller, canisterDetails, diff --git a/frontend/src/tests/lib/services/canisters.services.spec.ts b/frontend/src/tests/lib/services/canisters.services.spec.ts index ade2b499ace..f019191fa81 100644 --- a/frontend/src/tests/lib/services/canisters.services.spec.ts +++ b/frontend/src/tests/lib/services/canisters.services.spec.ts @@ -1,4 +1,5 @@ import * as api from "$lib/api/canisters.api"; +import * as icpIndexApi from "$lib/api/icp-index.api"; import * as ledgerApi from "$lib/api/icp-ledger.api"; import { UserNotTheControllerError } from "$lib/canisters/ic-management/ic-management.errors"; import * as authServices from "$lib/services/auth.services"; @@ -10,6 +11,7 @@ import { getCanisterDetails, getIcpToCyclesExchangeRate, listCanisters, + notifyTopUpIfNeeded, removeController, renameCanister, topUpCanister, @@ -31,8 +33,11 @@ import { } from "$tests/mocks/canisters.mock"; import en from "$tests/mocks/i18n.mock"; import { mockMainAccount } from "$tests/mocks/icp-accounts.store.mock"; +import { createTransactionWithId } from "$tests/mocks/icp-transactions.mock"; import { blockAllCallsTo } from "$tests/utils/module.test-utils"; +import { AnonymousIdentity } from "@dfinity/agent"; import { toastsStore } from "@dfinity/gix-components"; +import { Principal } from "@dfinity/principal"; import { waitFor } from "@testing-library/svelte"; import { get } from "svelte/store"; import type { MockInstance } from "vitest"; @@ -54,6 +59,7 @@ describe("canisters-services", () => { let spyUpdateSettings: MockInstance; let spyCreateCanister: MockInstance; let spyTopUpCanister: MockInstance; + let spyNotifyTopUpCanister: MockInstance; let spyQueryCanisterDetails: MockInstance; let spyGetExchangeRate: MockInstance; @@ -92,6 +98,10 @@ describe("canisters-services", () => { .spyOn(api, "topUpCanister") .mockImplementation(() => Promise.resolve(undefined)); + spyNotifyTopUpCanister = vi + .spyOn(api, "notifyTopUpCanister") + .mockImplementation(() => Promise.resolve(undefined)); + spyQueryCanisterDetails = vi .spyOn(api, "queryCanisterDetails") .mockImplementation(() => Promise.resolve(mockCanisterDetails)); @@ -600,4 +610,133 @@ describe("canisters-services", () => { resetIdentity(); }); }); + + describe("notifyTopUpIfNeeded", () => { + const canisterId = Principal.fromText("mkam6-f4aaa-aaaaa-qablq-cai"); + // Can be reconstructed with: + // CMC_ID=rkp4c-7iaaa-aaaaa-aaaca-cai + // CANISTER_ID=mkam6-f4aaa-aaaaa-qablq-cai + // scripts/convert-id --input text --subaccount_format text --output account_identifier $CMC_ID $CANISTER_ID + const cmcAccountIdentifierHex = + "addb464aaaa06f2e7dabf929fb5f729519848fdce636894806797859d23724eb"; + + it("should notify if there is a non-zero balance from an unburned top-up", async () => { + const blockHeight = 34n; + const memo = 0x50555054n; // TPUP + const topUpTransaction = createTransactionWithId({ + id: blockHeight, + memo, + }); + + const spyGetTransactions = vi.spyOn(icpIndexApi, "getTransactions"); + spyGetTransactions.mockResolvedValue({ + balance: 100_000_000n, + transactions: [topUpTransaction], + }); + + const result = await notifyTopUpIfNeeded({ + canisterId, + }); + + expect(result).toBe(true); + + expect(spyNotifyTopUpCanister).toBeCalledTimes(1); + expect(spyNotifyTopUpCanister).toBeCalledWith({ + canisterId, + blockHeight, + identity: new AnonymousIdentity(), + }); + + expect(spyGetTransactions).toBeCalledTimes(1); + expect(spyGetTransactions).toBeCalledWith({ + accountIdentifier: cmcAccountIdentifierHex, + identity: new AnonymousIdentity(), + maxResults: 1n, + }); + }); + + it("should not notify if there is a zero balance in the cmc account", async () => { + const balance = 0n; + + const spyGetTransactions = vi.spyOn(icpIndexApi, "getTransactions"); + spyGetTransactions.mockResolvedValue({ + balance, + transactions: [], + }); + + const result = await notifyTopUpIfNeeded({ + canisterId, + }); + + expect(result).toBe(false); + + expect(spyNotifyTopUpCanister).toBeCalledTimes(0); + }); + + it("should not notify if there is a non-zero balance from a non-top-up transaction", async () => { + const blockHeight = 34n; + const memo = 0n; + const nonTopUpTransaction = createTransactionWithId({ + id: blockHeight, + memo, + }); + const balance = 100_000_000n; + + const spyConsoleWarn = vi.spyOn(console, "warn").mockReturnValue(); + + const spyGetTransactions = vi.spyOn(icpIndexApi, "getTransactions"); + spyGetTransactions.mockResolvedValue({ + balance, + transactions: [nonTopUpTransaction], + }); + + const result = await notifyTopUpIfNeeded({ + canisterId, + }); + + expect(result).toBe(false); + + expect(spyNotifyTopUpCanister).toBeCalledTimes(0); + + expect(spyConsoleWarn).toBeCalledTimes(1); + expect(spyConsoleWarn).toBeCalledWith( + "CMC subaccount has non-zero balance but the most recent transaction is not a top-up", + { + balance, + canisterId: canisterId.toText(), + cmcAccountIdentifierHex, + transaction: nonTopUpTransaction, + } + ); + }); + + it("should ignore errors on notifying", async () => { + const blockHeight = 34n; + const memo = 0x50555054n; // TPUP + const topUpTransaction = createTransactionWithId({ + id: blockHeight, + memo, + }); + + const spyConsoleError = vi.spyOn(console, "error").mockReturnValue(); + + const spyGetTransactions = vi.spyOn(icpIndexApi, "getTransactions"); + spyGetTransactions.mockResolvedValue({ + balance: 100_000_000n, + transactions: [topUpTransaction], + }); + + const error = new Error("Notify in progress"); + spyNotifyTopUpCanister.mockRejectedValue(error); + + const result = await notifyTopUpIfNeeded({ + canisterId, + }); + + expect(result).toBe(true); + + expect(spyConsoleError).toBeCalledTimes(1); + expect(spyConsoleError).toBeCalledWith(error); + }); + }); });