Skip to content

Commit f6985a7

Browse files
authored
fix(alfred): accept hex subaccounts in Encode ICRC-1 Account UI (#7775)
# Motivation The subaccount field in the Encode ICRC-1 Account utility only accepted a number via `SubAccount.fromID`, which is limited to `Number.MAX_SAFE_INTEGER` and only fills the last 8 bytes. Full 32-byte subaccounts (like neuron account blobs) were silently truncated, making the tool unusable for those cases. # Changes - Changed the subaccount input from `number` to `text` and added a `parseSubAccount` helper that accepts both plain numeric IDs and 32-byte hex strings (with optional `0x` prefix). - Moved hardcoded placeholder and error strings to the i18n file for consistency with the rest of the Alfred section. - Added tests for the `BuildIcrcAccountUtil` component covering numeric IDs, hex subaccounts, error states, and input clearing. # Tests - Added tests. # Todos - [x] Accessibility (a11y) – any impact? - [x] Changelog – is it needed?
1 parent 7ad830f commit f6985a7

File tree

5 files changed

+329
-8
lines changed

5 files changed

+329
-8
lines changed

CHANGELOG-Nns-Dapp-unreleased.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@ proposal is successful, the changes it released will be moved from this file to
1414

1515
#### Added
1616

17+
- Support full 32-byte hex sub-accounts in the Encode ICRC-1 Account command palette utility, in addition to numeric IDs.
18+
1719
#### Changed
1820

1921
#### Deprecated

frontend/src/lib/components/alfred/BuildIcrcAccountUtil.svelte

Lines changed: 49 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2,28 +2,70 @@
22
import Copy from "$lib/components/ui/Copy.svelte";
33
import { i18n } from "$lib/stores/i18n";
44
import { IconKey, Input } from "@dfinity/gix-components";
5-
import { isNullish, nonNullish } from "@dfinity/utils";
5+
import { hexStringToUint8Array, isNullish, nonNullish } from "@dfinity/utils";
66
import { SubAccount } from "@icp-sdk/canisters/ledger/icp";
77
import { encodeIcrcAccount } from "@icp-sdk/canisters/ledger/icrc";
88
import { Principal } from "@icp-sdk/core/principal";
99
import { onMount } from "svelte";
1010
1111
let principalInputRef = $state<HTMLInputElement | undefined>();
1212
let principalInput = $state("");
13-
let subAccountInput = $state<number | undefined>();
13+
let subAccountInput = $state<string | undefined>();
1414
let icrcAccountText = $state<string | null>(null);
1515
let errorMessage = $state<string | null>(null);
1616
17+
const parseHexSubAccount = (hex: string): SubAccount => {
18+
const isValidHex = /^[0-9a-fA-F]+$/.test(hex);
19+
if (hex.length > 64 || !isValidHex) {
20+
throw new Error($i18n.alfred.build_icrc_account_subaccount_error);
21+
}
22+
const sub = SubAccount.fromBytes(
23+
hexStringToUint8Array(hex.padStart(64, "0"))
24+
);
25+
if (sub instanceof Error) {
26+
throw new Error($i18n.alfred.build_icrc_account_subaccount_error);
27+
}
28+
return sub;
29+
};
30+
31+
const parseSubAccount = (input: string): SubAccount => {
32+
const trimmed = input.trim();
33+
34+
if (trimmed.startsWith("0x") || trimmed.startsWith("0X")) {
35+
return parseHexSubAccount(trimmed.slice(2));
36+
}
37+
38+
const containsHexLetters = /[a-fA-F]/.test(trimmed);
39+
if (containsHexLetters || trimmed.length === 64) {
40+
return parseHexSubAccount(trimmed);
41+
}
42+
43+
const isDecimalNumber = /^\d+$/.test(trimmed);
44+
if (isDecimalNumber) {
45+
const num = Number(trimmed);
46+
if (num > Number.MAX_SAFE_INTEGER) {
47+
return parseHexSubAccount(trimmed);
48+
}
49+
return SubAccount.fromID(num);
50+
}
51+
52+
throw new Error($i18n.alfred.build_icrc_account_subaccount_error);
53+
};
54+
1755
$effect(() => {
18-
if (principalInput === "" || isNullish(subAccountInput)) {
56+
if (
57+
principalInput === "" ||
58+
isNullish(subAccountInput) ||
59+
subAccountInput.trim() === ""
60+
) {
1961
icrcAccountText = null;
2062
errorMessage = null;
2163
return;
2264
}
2365
2466
try {
2567
const owner = Principal.fromText(principalInput.trim());
26-
const subaccount = SubAccount.fromID(subAccountInput);
68+
const subaccount = parseSubAccount(subAccountInput);
2769
2870
const icrc = encodeIcrcAccount({
2971
owner,
@@ -58,7 +100,7 @@
58100
<Input
59101
inputType="text"
60102
name="alfred-util-principal"
61-
placeholder="Enter principal"
103+
placeholder={$i18n.alfred.build_icrc_account_principal_placeholder}
62104
autocomplete="off"
63105
spellcheck={false}
64106
testId="alfred-util-principal"
@@ -71,9 +113,9 @@
71113
>{$i18n.alfred.build_icrc_account_subaccount_label}</label
72114
>
73115
<Input
74-
inputType="number"
116+
inputType="text"
75117
name="alfred-util-subaccount"
76-
placeholder="Enter subaccount"
118+
placeholder={$i18n.alfred.build_icrc_account_subaccount_placeholder}
77119
autocomplete="off"
78120
spellcheck={false}
79121
testId="alfred-util-subaccount"

frontend/src/lib/i18n/en.json

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -235,7 +235,10 @@
235235
"build_icrc_account_title": "Encode ICRC-1 Account",
236236
"build_icrc_account_description": "Encode an ICRC-1 account defined by a principal and a subaccount into its textual format.",
237237
"build_icrc_account_principal_label": "Principal",
238-
"build_icrc_account_subaccount_label": "Subaccount"
238+
"build_icrc_account_principal_placeholder": "Enter principal",
239+
"build_icrc_account_subaccount_label": "Subaccount",
240+
"build_icrc_account_subaccount_placeholder": "Number, hex (0x...), or 64-char hex",
241+
"build_icrc_account_subaccount_error": "Invalid subaccount. Use a number, a 0x-prefixed hex, or a 64-char hex string."
239242
},
240243
"header": {
241244
"menu": "Open menu to access navigation options",

frontend/src/lib/types/i18n.d.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -243,7 +243,10 @@ interface I18nAlfred {
243243
build_icrc_account_title: string;
244244
build_icrc_account_description: string;
245245
build_icrc_account_principal_label: string;
246+
build_icrc_account_principal_placeholder: string;
246247
build_icrc_account_subaccount_label: string;
248+
build_icrc_account_subaccount_placeholder: string;
249+
build_icrc_account_subaccount_error: string;
247250
}
248251

249252
interface I18nHeader {
Lines changed: 271 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,271 @@
1+
import BuildIcrcAccountUtil from "$lib/components/alfred/BuildIcrcAccountUtil.svelte";
2+
import { hexStringToUint8Array } from "@dfinity/utils";
3+
import { SubAccount } from "@icp-sdk/canisters/ledger/icp";
4+
import { encodeIcrcAccount } from "@icp-sdk/canisters/ledger/icrc";
5+
import { Principal } from "@icp-sdk/core/principal";
6+
import { fireEvent, render } from "@testing-library/svelte";
7+
import { tick } from "svelte";
8+
9+
describe("BuildIcrcAccountUtil", () => {
10+
const testPrincipal = "rrkah-fqaaa-aaaaa-aaaaq-cai";
11+
const testHexSubaccount =
12+
"ff0c0b36afefffd0c7a4d85c0bcea366acd6d74f45f7703d0783cc6448899c68";
13+
14+
const getPrincipalInput = (container: HTMLElement): HTMLInputElement =>
15+
container.querySelector(
16+
'[data-tid="alfred-util-principal"]'
17+
) as HTMLInputElement;
18+
19+
const getSubaccountInput = (container: HTMLElement): HTMLInputElement =>
20+
container.querySelector(
21+
'[data-tid="alfred-util-subaccount"]'
22+
) as HTMLInputElement;
23+
24+
const getOutput = (container: HTMLElement): HTMLElement | null =>
25+
container.querySelector('[data-tid="alfred-util-hex-output"]');
26+
27+
const getError = (container: HTMLElement): HTMLElement | null =>
28+
container.querySelector(".error-message");
29+
30+
const setInputValues = async (
31+
container: HTMLElement,
32+
{ principal, subaccount }: { principal: string; subaccount: string }
33+
) => {
34+
const principalInput = getPrincipalInput(container);
35+
const subaccountInput = getSubaccountInput(container);
36+
37+
await fireEvent.input(principalInput, { target: { value: principal } });
38+
await fireEvent.input(subaccountInput, { target: { value: subaccount } });
39+
await tick();
40+
};
41+
42+
const expectedIcrcAccount = ({
43+
principal,
44+
subaccountBytes,
45+
}: {
46+
principal: string;
47+
subaccountBytes: Uint8Array;
48+
}): string =>
49+
encodeIcrcAccount({
50+
owner: Principal.fromText(principal),
51+
subaccount: subaccountBytes,
52+
});
53+
54+
it("should render principal and subaccount inputs", () => {
55+
const { container } = render(BuildIcrcAccountUtil);
56+
57+
expect(getPrincipalInput(container)).not.toBeNull();
58+
expect(getSubaccountInput(container)).not.toBeNull();
59+
});
60+
61+
it("should not show output when inputs are empty", () => {
62+
const { container } = render(BuildIcrcAccountUtil);
63+
64+
expect(getOutput(container)).toBeNull();
65+
expect(getError(container)).toBeNull();
66+
});
67+
68+
it("should encode with a numeric subaccount ID", async () => {
69+
const { container } = render(BuildIcrcAccountUtil);
70+
71+
await setInputValues(container, {
72+
principal: testPrincipal,
73+
subaccount: "0",
74+
});
75+
76+
const output = getOutput(container);
77+
expect(output).not.toBeNull();
78+
expect(output?.textContent).toBe(
79+
expectedIcrcAccount({
80+
principal: testPrincipal,
81+
subaccountBytes: SubAccount.fromID(0).toUint8Array(),
82+
})
83+
);
84+
});
85+
86+
it("should encode with a larger numeric subaccount ID", async () => {
87+
const { container } = render(BuildIcrcAccountUtil);
88+
89+
await setInputValues(container, {
90+
principal: testPrincipal,
91+
subaccount: "12345",
92+
});
93+
94+
const output = getOutput(container);
95+
expect(output).not.toBeNull();
96+
expect(output?.textContent).toBe(
97+
expectedIcrcAccount({
98+
principal: testPrincipal,
99+
subaccountBytes: SubAccount.fromID(12345).toUint8Array(),
100+
})
101+
);
102+
});
103+
104+
it("should treat all-digit string exceeding MAX_SAFE_INTEGER as hex", async () => {
105+
const { container } = render(BuildIcrcAccountUtil);
106+
107+
const bigDigitString = "99999999999999999";
108+
await setInputValues(container, {
109+
principal: testPrincipal,
110+
subaccount: bigDigitString,
111+
});
112+
113+
const output = getOutput(container);
114+
expect(output).not.toBeNull();
115+
expect(output?.textContent).toBe(
116+
expectedIcrcAccount({
117+
principal: testPrincipal,
118+
subaccountBytes: hexStringToUint8Array(
119+
bigDigitString.padStart(64, "0")
120+
),
121+
})
122+
);
123+
});
124+
125+
it("should encode with a 32-byte hex subaccount", async () => {
126+
const { container } = render(BuildIcrcAccountUtil);
127+
128+
await setInputValues(container, {
129+
principal: testPrincipal,
130+
subaccount: testHexSubaccount,
131+
});
132+
133+
const hexBytes = new Uint8Array(32);
134+
for (let i = 0; i < 32; i++) {
135+
hexBytes[i] = parseInt(testHexSubaccount.slice(i * 2, i * 2 + 2), 16);
136+
}
137+
138+
const output = getOutput(container);
139+
expect(output).not.toBeNull();
140+
expect(output?.textContent).toBe(
141+
expectedIcrcAccount({
142+
principal: testPrincipal,
143+
subaccountBytes: hexBytes,
144+
})
145+
);
146+
});
147+
148+
it("should encode with a 0x-prefixed hex subaccount", async () => {
149+
const { container } = render(BuildIcrcAccountUtil);
150+
151+
await setInputValues(container, {
152+
principal: testPrincipal,
153+
subaccount: `0x${testHexSubaccount}`,
154+
});
155+
156+
const hexBytes = new Uint8Array(32);
157+
for (let i = 0; i < 32; i++) {
158+
hexBytes[i] = parseInt(testHexSubaccount.slice(i * 2, i * 2 + 2), 16);
159+
}
160+
161+
const output = getOutput(container);
162+
expect(output).not.toBeNull();
163+
expect(output?.textContent).toBe(
164+
expectedIcrcAccount({
165+
principal: testPrincipal,
166+
subaccountBytes: hexBytes,
167+
})
168+
);
169+
});
170+
171+
it("should show error for invalid principal", async () => {
172+
const { container } = render(BuildIcrcAccountUtil);
173+
174+
await setInputValues(container, {
175+
principal: "not-a-principal",
176+
subaccount: "0",
177+
});
178+
179+
expect(getOutput(container)).toBeNull();
180+
expect(getError(container)).not.toBeNull();
181+
});
182+
183+
it("should encode short hex subaccount with zero-padding", async () => {
184+
const { container } = render(BuildIcrcAccountUtil);
185+
186+
await setInputValues(container, {
187+
principal: testPrincipal,
188+
subaccount: "ff0c0b36",
189+
});
190+
191+
const output = getOutput(container);
192+
expect(output).not.toBeNull();
193+
expect(output?.textContent).toBe(
194+
expectedIcrcAccount({
195+
principal: testPrincipal,
196+
subaccountBytes: hexStringToUint8Array("ff0c0b36".padStart(64, "0")),
197+
})
198+
);
199+
});
200+
201+
it("should show error for invalid hex characters", async () => {
202+
const { container } = render(BuildIcrcAccountUtil);
203+
204+
const invalidHex = "zz" + testHexSubaccount.slice(2);
205+
await setInputValues(container, {
206+
principal: testPrincipal,
207+
subaccount: invalidHex,
208+
});
209+
210+
expect(getOutput(container)).toBeNull();
211+
expect(getError(container)).not.toBeNull();
212+
});
213+
214+
it("should clear output when principal is cleared", async () => {
215+
const { container } = render(BuildIcrcAccountUtil);
216+
217+
await setInputValues(container, {
218+
principal: testPrincipal,
219+
subaccount: "0",
220+
});
221+
expect(getOutput(container)).not.toBeNull();
222+
223+
const principalInput = getPrincipalInput(container);
224+
await fireEvent.input(principalInput, { target: { value: "" } });
225+
await tick();
226+
227+
expect(getOutput(container)).toBeNull();
228+
expect(getError(container)).toBeNull();
229+
});
230+
231+
it("should clear output when subaccount is cleared", async () => {
232+
const { container } = render(BuildIcrcAccountUtil);
233+
234+
await setInputValues(container, {
235+
principal: testPrincipal,
236+
subaccount: "0",
237+
});
238+
expect(getOutput(container)).not.toBeNull();
239+
240+
const subaccountInput = getSubaccountInput(container);
241+
await fireEvent.input(subaccountInput, { target: { value: "" } });
242+
await tick();
243+
244+
expect(getOutput(container)).toBeNull();
245+
expect(getError(container)).toBeNull();
246+
});
247+
248+
it("should handle uppercase hex input", async () => {
249+
const { container } = render(BuildIcrcAccountUtil);
250+
251+
const upperHex = testHexSubaccount.toUpperCase();
252+
await setInputValues(container, {
253+
principal: testPrincipal,
254+
subaccount: upperHex,
255+
});
256+
257+
const hexBytes = new Uint8Array(32);
258+
for (let i = 0; i < 32; i++) {
259+
hexBytes[i] = parseInt(testHexSubaccount.slice(i * 2, i * 2 + 2), 16);
260+
}
261+
262+
const output = getOutput(container);
263+
expect(output).not.toBeNull();
264+
expect(output?.textContent).toBe(
265+
expectedIcrcAccount({
266+
principal: testPrincipal,
267+
subaccountBytes: hexBytes,
268+
})
269+
);
270+
});
271+
});

0 commit comments

Comments
 (0)