From 6a70f02b6591bf4e2ed6790333bd17f250499577 Mon Sep 17 00:00:00 2001 From: Yusef Habib Fernandez Date: Tue, 7 Oct 2025 16:49:21 +0200 Subject: [PATCH 1/4] utils --- frontend/src/lib/types/transaction.ts | 1 + .../src/lib/utils/icp-transactions.utils.ts | 56 +++++++++++++++++++ .../lib/utils/icp-transactions.utils.spec.ts | 4 ++ 3 files changed, 61 insertions(+) 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..ee7d467d072 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,16 @@ 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) + : nonNullish(memo) + ? memo.toString() + : undefined; + return { domKey: `${transaction.id}-${toSelfTransaction ? "0" : "1"}`, isIncoming: isReceive, @@ -265,6 +280,7 @@ export const mapIcpTransactionToUi = ({ timestamp, isFailed: false, isReimbursement: false, + memoText, }; } catch (err) { toastsError({ @@ -274,3 +290,43 @@ export const mapIcpTransactionToUi = ({ }); } }; + +// it should only contain numbers and limit to 64bits +export const isValidIcpMemo = (memo: string): boolean => { + if (!/^\d+$/.test(memo)) return false; + + 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..cf57656541b 100644 --- a/frontend/src/tests/lib/utils/icp-transactions.utils.spec.ts +++ b/frontend/src/tests/lib/utils/icp-transactions.utils.spec.ts @@ -49,6 +49,7 @@ describe("icp-transactions.utils", () => { isFailed: false, headline: "Sent", timestamp: defaultTimestamp, + memoText: "0", }; const toSelfOperation: Operation = { Transfer: { @@ -308,6 +309,7 @@ describe("icp-transactions.utils", () => { const expectedUiTransaction: UiTransaction = { ...defaultUiTransaction, headline: "Staked", + memoText: transaction.transaction.memo.toString(), }; expect( @@ -352,6 +354,7 @@ describe("icp-transactions.utils", () => { const expectedUiTransaction: UiTransaction = { ...defaultUiTransaction, headline: "Create Canister", + memoText: transaction.transaction.memo.toString(), }; expect( @@ -374,6 +377,7 @@ describe("icp-transactions.utils", () => { const expectedUiTransaction: UiTransaction = { ...defaultUiTransaction, headline: "Top-up Canister", + memoText: transaction.transaction.memo.toString(), }; expect( From a143c2a6d0fb839d7c58c0773c4b652de225bf05 Mon Sep 17 00:00:00 2001 From: Yusef Habib Fernandez Date: Tue, 7 Oct 2025 17:22:37 +0200 Subject: [PATCH 2/4] tests --- .../src/lib/utils/icp-transactions.utils.ts | 10 +- .../lib/utils/icp-transactions.utils.spec.ts | 104 ++++++++++++++++++ 2 files changed, 109 insertions(+), 5 deletions(-) diff --git a/frontend/src/lib/utils/icp-transactions.utils.ts b/frontend/src/lib/utils/icp-transactions.utils.ts index ee7d467d072..29e5c241963 100644 --- a/frontend/src/lib/utils/icp-transactions.utils.ts +++ b/frontend/src/lib/utils/icp-transactions.utils.ts @@ -263,9 +263,7 @@ export const mapIcpTransactionToUi = ({ const memoText = nonNullish(icrc1Memo) ? uint8ArrayToHexString(icrc1Memo) - : nonNullish(memo) - ? memo.toString() - : undefined; + : memo.toString(); return { domKey: `${transaction.id}-${toSelfTransaction ? "0" : "1"}`, @@ -291,9 +289,9 @@ export const mapIcpTransactionToUi = ({ } }; -// it should only contain numbers and limit to 64bits +// it should only contain positive numbers and limit to 64bits export const isValidIcpMemo = (memo: string): boolean => { - if (!/^\d+$/.test(memo)) return false; + if (memo.length === 0) return false; try { const UINT64_MAX = 2n ** 64n - 1n; @@ -306,6 +304,8 @@ export const isValidIcpMemo = (memo: string): boolean => { // it should be less than 32 bytes when encoded as UTF-8 export const isValidIcrc1Memo = (memo: string): boolean => { + if (memo.length === 0) return false; + try { return new TextEncoder().encode(memo).length <= 32; } catch { 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 cf57656541b..6350b29ef28 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"; @@ -801,4 +804,105 @@ describe("icp-transactions.utils", () => { ]); }); }); + + describe("isValidIcpMemo", () => { + it("returns false for empty memo", () => { + expect(isValidIcpMemo("")).toBe(false); + }); + + it("returns true for valid numeric memo", () => { + 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); + expect(isValidIcpMemo("")).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 false for empty memo", () => { + expect(isValidIcrc1Memo("")).toBe(false); + }); + + it("returns true for memo within 32 bytes", () => { + 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"); + }); + + it("returns errors for empty memo", () => { + expect( + validateTransactionMemo({ memo: "", destinationAddress: icpAddress }) + ).toBe("ICP_MEMO_ERROR"); + expect( + validateTransactionMemo({ memo: "", destinationAddress: icrcAddress }) + ).toBe("ICRC_MEMO_ERROR"); + }); + }); }); From 6667e5e997c7678e23d09be9e3c6dee346d6b114 Mon Sep 17 00:00:00 2001 From: Yusef Habib Fernandez Date: Wed, 8 Oct 2025 11:47:55 +0200 Subject: [PATCH 3/4] car --- .../src/lib/utils/icp-transactions.utils.ts | 8 ++------ .../lib/utils/icp-transactions.utils.spec.ts | 19 ++----------------- 2 files changed, 4 insertions(+), 23 deletions(-) diff --git a/frontend/src/lib/utils/icp-transactions.utils.ts b/frontend/src/lib/utils/icp-transactions.utils.ts index 29e5c241963..1a9470486ad 100644 --- a/frontend/src/lib/utils/icp-transactions.utils.ts +++ b/frontend/src/lib/utils/icp-transactions.utils.ts @@ -289,10 +289,8 @@ export const mapIcpTransactionToUi = ({ } }; -// it should only contain positive numbers and limit to 64bits +// it should only contain positive numbers and limit to 64 bits export const isValidIcpMemo = (memo: string): boolean => { - if (memo.length === 0) return false; - try { const UINT64_MAX = 2n ** 64n - 1n; const memoBigInt = BigInt(memo); @@ -304,8 +302,6 @@ export const isValidIcpMemo = (memo: string): boolean => { // it should be less than 32 bytes when encoded as UTF-8 export const isValidIcrc1Memo = (memo: string): boolean => { - if (memo.length === 0) return false; - try { return new TextEncoder().encode(memo).length <= 32; } catch { @@ -317,7 +313,7 @@ export const validateTransactionMemo = ({ memo, destinationAddress, }: { - memo: string; + memo?: string; destinationAddress: string; }): "ICP_MEMO_ERROR" | "ICRC_MEMO_ERROR" | undefined => { const isValidIcpAddress = !invalidIcpAddress(destinationAddress); 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 6350b29ef28..f3b0843e4b4 100644 --- a/frontend/src/tests/lib/utils/icp-transactions.utils.spec.ts +++ b/frontend/src/tests/lib/utils/icp-transactions.utils.spec.ts @@ -806,11 +806,8 @@ describe("icp-transactions.utils", () => { }); describe("isValidIcpMemo", () => { - it("returns false for empty memo", () => { - expect(isValidIcpMemo("")).toBe(false); - }); - it("returns true for valid numeric memo", () => { + expect(isValidIcpMemo("")).toBe(true); expect(isValidIcpMemo("123")).toBe(true); expect(isValidIcpMemo("0")).toBe(true); }); @@ -835,11 +832,8 @@ describe("icp-transactions.utils", () => { }); describe("isValidIcrc1Memo", () => { - it("returns false for empty memo", () => { - expect(isValidIcrc1Memo("")).toBe(false); - }); - it("returns true for memo within 32 bytes", () => { + expect(isValidIcrc1Memo("")).toBe(true); expect(isValidIcrc1Memo("short memo")).toBe(true); }); @@ -895,14 +889,5 @@ describe("icp-transactions.utils", () => { }) ).toBe("ICRC_MEMO_ERROR"); }); - - it("returns errors for empty memo", () => { - expect( - validateTransactionMemo({ memo: "", destinationAddress: icpAddress }) - ).toBe("ICP_MEMO_ERROR"); - expect( - validateTransactionMemo({ memo: "", destinationAddress: icrcAddress }) - ).toBe("ICRC_MEMO_ERROR"); - }); }); }); From db893799781eb6c5d4400869ced0d4d6dba419e0 Mon Sep 17 00:00:00 2001 From: Yusef Habib Fernandez Date: Wed, 8 Oct 2025 11:58:19 +0200 Subject: [PATCH 4/4] test --- frontend/src/tests/lib/utils/icp-transactions.utils.spec.ts | 1 - 1 file changed, 1 deletion(-) 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 f3b0843e4b4..3a1fd3e8966 100644 --- a/frontend/src/tests/lib/utils/icp-transactions.utils.spec.ts +++ b/frontend/src/tests/lib/utils/icp-transactions.utils.spec.ts @@ -819,7 +819,6 @@ describe("icp-transactions.utils", () => { it("returns false for non-numeric memo", () => { expect(isValidIcpMemo("abc")).toBe(false); expect(isValidIcpMemo("123abc")).toBe(false); - expect(isValidIcpMemo("")).toBe(false); }); it("returns false for values exceeding uint64 max", () => {