Skip to content
Merged
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
1 change: 1 addition & 0 deletions frontend/src/lib/types/transaction.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,7 @@ export interface UiTransaction {
// Always positive.
tokenAmount: TokenAmount | TokenAmountV2;
timestamp?: Date;
memoText?: string;
}

export enum TransactionNetwork {
Expand Down
52 changes: 52 additions & 0 deletions frontend/src/lib/utils/icp-transactions.utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -20,6 +24,7 @@ import {
fromNullable,
isNullish,
nonNullish,
uint8ArrayToHexString,
} from "@dfinity/utils";

const isToSelf = (transaction: Transaction): boolean => {
Expand Down Expand Up @@ -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,
Expand All @@ -265,6 +278,7 @@ export const mapIcpTransactionToUi = ({
timestamp,
isFailed: false,
isReimbursement: false,
memoText,
};
} catch (err) {
toastsError({
Expand All @@ -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";
}
};
92 changes: 92 additions & 0 deletions frontend/src/tests/lib/utils/icp-transactions.utils.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -49,6 +52,7 @@ describe("icp-transactions.utils", () => {
isFailed: false,
headline: "Sent",
timestamp: defaultTimestamp,
memoText: "0",
};
const toSelfOperation: Operation = {
Transfer: {
Expand Down Expand Up @@ -308,6 +312,7 @@ describe("icp-transactions.utils", () => {
const expectedUiTransaction: UiTransaction = {
...defaultUiTransaction,
headline: "Staked",
memoText: transaction.transaction.memo.toString(),
};

expect(
Expand Down Expand Up @@ -352,6 +357,7 @@ describe("icp-transactions.utils", () => {
const expectedUiTransaction: UiTransaction = {
...defaultUiTransaction,
headline: "Create Canister",
memoText: transaction.transaction.memo.toString(),
};

expect(
Expand All @@ -374,6 +380,7 @@ describe("icp-transactions.utils", () => {
const expectedUiTransaction: UiTransaction = {
...defaultUiTransaction,
headline: "Top-up Canister",
memoText: transaction.transaction.memo.toString(),
};

expect(
Expand Down Expand Up @@ -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");
});
});
});