diff --git a/frontend/src/lib/types/transaction.ts b/frontend/src/lib/types/transaction.ts index 54ee27e8c7a..e7957e9fde3 100644 --- a/frontend/src/lib/types/transaction.ts +++ b/frontend/src/lib/types/transaction.ts @@ -84,6 +84,7 @@ export interface UiTransaction { // Always positive. tokenAmount: TokenAmount | TokenAmountV2; timestamp?: Date; + memoText?: string; } export enum TransactionNetwork { diff --git a/frontend/src/lib/utils/icp-transactions.utils.ts b/frontend/src/lib/utils/icp-transactions.utils.ts index 8ad24a78a7f..1a9470486ad 100644 --- a/frontend/src/lib/utils/icp-transactions.utils.ts +++ b/frontend/src/lib/utils/icp-transactions.utils.ts @@ -8,6 +8,10 @@ import { AccountTransactionType, type UiTransaction, } from "$lib/types/transaction"; +import { + invalidIcpAddress, + invalidIcrcAddress, +} from "$lib/utils/accounts.utils"; import { transactionName } from "$lib/utils/transactions.utils"; import type { Operation, @@ -20,6 +24,7 @@ import { fromNullable, isNullish, nonNullish, + uint8ArrayToHexString, } from "@dfinity/utils"; const isToSelf = (transaction: Transaction): boolean => { @@ -252,6 +257,14 @@ export const mapIcpTransactionToUi = ({ const timestamp = nonNullish(timestampMilliseconds) ? new Date(timestampMilliseconds) : undefined; + + const memo = transaction.transaction.memo; + const icrc1Memo = transaction.transaction.icrc1_memo?.[0]; + + const memoText = nonNullish(icrc1Memo) + ? uint8ArrayToHexString(icrc1Memo) + : memo.toString(); + return { domKey: `${transaction.id}-${toSelfTransaction ? "0" : "1"}`, isIncoming: isReceive, @@ -265,6 +278,7 @@ export const mapIcpTransactionToUi = ({ timestamp, isFailed: false, isReimbursement: false, + memoText, }; } catch (err) { toastsError({ @@ -274,3 +288,41 @@ export const mapIcpTransactionToUi = ({ }); } }; + +// it should only contain positive numbers and limit to 64 bits +export const isValidIcpMemo = (memo: string): boolean => { + try { + const UINT64_MAX = 2n ** 64n - 1n; + const memoBigInt = BigInt(memo); + return memoBigInt >= 0n && memoBigInt <= UINT64_MAX; + } catch { + return false; + } +}; + +// it should be less than 32 bytes when encoded as UTF-8 +export const isValidIcrc1Memo = (memo: string): boolean => { + try { + return new TextEncoder().encode(memo).length <= 32; + } catch { + return false; + } +}; + +export const validateTransactionMemo = ({ + memo, + destinationAddress, +}: { + memo?: string; + destinationAddress: string; +}): "ICP_MEMO_ERROR" | "ICRC_MEMO_ERROR" | undefined => { + const isValidIcpAddress = !invalidIcpAddress(destinationAddress); + if (nonNullish(memo) && isValidIcpAddress && !isValidIcpMemo(memo)) { + return "ICP_MEMO_ERROR"; + } + + const isValidIcrcAddress = !invalidIcrcAddress(destinationAddress); + if (nonNullish(memo) && isValidIcrcAddress && !isValidIcrc1Memo(memo)) { + return "ICRC_MEMO_ERROR"; + } +}; diff --git a/frontend/src/tests/lib/utils/icp-transactions.utils.spec.ts b/frontend/src/tests/lib/utils/icp-transactions.utils.spec.ts index bf6ce7503f8..3a1fd3e8966 100644 --- a/frontend/src/tests/lib/utils/icp-transactions.utils.spec.ts +++ b/frontend/src/tests/lib/utils/icp-transactions.utils.spec.ts @@ -6,10 +6,13 @@ import { NANO_SECONDS_IN_MILLISECOND } from "$lib/constants/constants"; import * as toastsStore from "$lib/stores/toasts.store"; import { type UiTransaction } from "$lib/types/transaction"; import { + isValidIcpMemo, + isValidIcrc1Memo, mapIcpTransactionToReport, mapIcpTransactionToUi, mapToSelfTransactions, sortTransactionsByIdDescendingOrder, + validateTransactionMemo, } from "$lib/utils/icp-transactions.utils"; import en from "$tests/mocks/i18n.mock"; import { createTransactionWithId } from "$tests/mocks/icp-transactions.mock"; @@ -49,6 +52,7 @@ describe("icp-transactions.utils", () => { isFailed: false, headline: "Sent", timestamp: defaultTimestamp, + memoText: "0", }; const toSelfOperation: Operation = { Transfer: { @@ -308,6 +312,7 @@ describe("icp-transactions.utils", () => { const expectedUiTransaction: UiTransaction = { ...defaultUiTransaction, headline: "Staked", + memoText: transaction.transaction.memo.toString(), }; expect( @@ -352,6 +357,7 @@ describe("icp-transactions.utils", () => { const expectedUiTransaction: UiTransaction = { ...defaultUiTransaction, headline: "Create Canister", + memoText: transaction.transaction.memo.toString(), }; expect( @@ -374,6 +380,7 @@ describe("icp-transactions.utils", () => { const expectedUiTransaction: UiTransaction = { ...defaultUiTransaction, headline: "Top-up Canister", + memoText: transaction.transaction.memo.toString(), }; expect( @@ -797,4 +804,89 @@ describe("icp-transactions.utils", () => { ]); }); }); + + describe("isValidIcpMemo", () => { + it("returns true for valid numeric memo", () => { + expect(isValidIcpMemo("")).toBe(true); + expect(isValidIcpMemo("123")).toBe(true); + expect(isValidIcpMemo("0")).toBe(true); + }); + + it("returns true for max uint64 value", () => { + expect(isValidIcpMemo("18446744073709551615")).toBe(true); + }); + + it("returns false for non-numeric memo", () => { + expect(isValidIcpMemo("abc")).toBe(false); + expect(isValidIcpMemo("123abc")).toBe(false); + }); + + it("returns false for values exceeding uint64 max", () => { + expect(isValidIcpMemo("18446744073709551616")).toBe(false); + }); + + it("returns false for negative values", () => { + expect(isValidIcpMemo("-1")).toBe(false); + }); + }); + + describe("isValidIcrc1Memo", () => { + it("returns true for memo within 32 bytes", () => { + expect(isValidIcrc1Memo("")).toBe(true); + expect(isValidIcrc1Memo("short memo")).toBe(true); + }); + + it("returns true for memo exactly 32 bytes", () => { + expect(isValidIcrc1Memo("a".repeat(32))).toBe(true); + }); + + it("returns false for memo exceeding 32 bytes", () => { + expect(isValidIcrc1Memo("a".repeat(33))).toBe(false); + }); + + it("handles unicode characters correctly", () => { + expect(isValidIcrc1Memo("💎")).toBe(true); // 4 bytes + expect(isValidIcrc1Memo("💎".repeat(8))).toBe(true); // 32 bytes + expect(isValidIcrc1Memo("💎".repeat(9))).toBe(false); // 36 bytes + }); + }); + + describe("validateTransactionMemo", () => { + const icpAddress = + "5b315d2f6702cb3a27d826161797d7b2c2e131cd312aece51d4d5574d1247087"; + const icrcAddress = "rrkah-fqaaa-aaaaa-aaaaq-cai"; + + it("returns undefined for valid ICP memo", () => { + expect( + validateTransactionMemo({ memo: "123", destinationAddress: icpAddress }) + ).toBeUndefined(); + }); + + it("returns undefined for valid ICRC memo", () => { + expect( + validateTransactionMemo({ + memo: "valid memo", + destinationAddress: icrcAddress, + }) + ).toBeUndefined(); + }); + + it("returns ICP_MEMO_ERROR for invalid ICP memo", () => { + expect( + validateTransactionMemo({ + memo: "invalid", + destinationAddress: icpAddress, + }) + ).toBe("ICP_MEMO_ERROR"); + }); + + it("returns ICRC_MEMO_ERROR for invalid ICRC memo", () => { + expect( + validateTransactionMemo({ + memo: "a".repeat(33), + destinationAddress: icrcAddress, + }) + ).toBe("ICRC_MEMO_ERROR"); + }); + }); });