Skip to content

Commit 14bd0a6

Browse files
authored
feat(disburse-maturity): Disburse maturity modal (#6911)
# Motivation To simplify the process of claiming matured rewards and avoid the extra steps of creating and disbursing a new neuron, the disburse maturity feature is introduced for NNS neurons (this approach is already used for SNS neurons). This PR adds the "Disburse" modal to the neuron page. **Out of scope** - disbursing to sub-account - disbursing to external addresses. [Jira](https://dfinity.atlassian.net/browse/NNS1-3740) # Changes - New `NnsDisburseMaturityModal` component that heavily reuses DisburseMaturityModal component. - Display disburse maturity modal on page. # Tests - Added. - Verified manually that a user can disburse maturity to the main account. https://github.com/user-attachments/assets/2550e073-0330-45db-92b3-70056e5b4131 # Todos - [ ] Add entry to changelog (if necessary). Not yet
1 parent 0773363 commit 14bd0a6

File tree

3 files changed

+235
-1
lines changed

3 files changed

+235
-1
lines changed
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
<script lang="ts">
2+
import { OWN_CANISTER_ID } from "$lib/constants/canister-ids.constants";
3+
import { MIN_DISBURSEMENT_WITH_VARIANCE } from "$lib/constants/neurons.constants";
4+
import DisburseMaturityModal from "$lib/modals/neurons/DisburseMaturityModal.svelte";
5+
import { disburseMaturity as disburseMaturityService } from "$lib/services/neurons.services";
6+
import { startBusy, stopBusy } from "$lib/stores/busy.store";
7+
import { toastsSuccess } from "$lib/stores/toasts.store";
8+
import type { NeuronInfo } from "@dfinity/nns";
9+
import { ICPToken } from "@dfinity/utils";
10+
11+
type Props = {
12+
neuron: NeuronInfo;
13+
close: () => void;
14+
};
15+
16+
const { neuron, close }: Props = $props();
17+
const disburseMaturity = async ({
18+
detail: { percentageToDisburse },
19+
}: CustomEvent<{
20+
percentageToDisburse: number;
21+
destinationAddress: string;
22+
}>) => {
23+
startBusy({ initiator: "disburse-maturity" });
24+
25+
// TODO(disburse-maturity): switch to account identifier when API supports it
26+
const { success } = await disburseMaturityService({
27+
neuronId: neuron.neuronId,
28+
percentageToDisburse,
29+
});
30+
31+
stopBusy("disburse-maturity");
32+
33+
if (success) {
34+
toastsSuccess({
35+
labelKey: "neuron_detail.disburse_maturity_success",
36+
});
37+
close();
38+
}
39+
};
40+
41+
const availableMaturityE8s = $derived(
42+
neuron.fullNeuron?.maturityE8sEquivalent ?? 0n
43+
);
44+
</script>
45+
46+
<DisburseMaturityModal
47+
{availableMaturityE8s}
48+
minimumAmountE8s={MIN_DISBURSEMENT_WITH_VARIANCE}
49+
on:nnsDisburseMaturity={disburseMaturity}
50+
rootCanisterId={OWN_CANISTER_ID}
51+
token={ICPToken}
52+
on:nnsClose={close}
53+
/>

frontend/src/lib/modals/neurons/NnsNeuronModals.svelte

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
import JoinCommunityFundModal from "$lib/modals/neurons/JoinCommunityFundModal.svelte";
1212
import LosingRewardNeuronsModal from "$lib/modals/neurons/LosingRewardNeuronsModal.svelte";
1313
import NnsAutoStakeMaturityModal from "$lib/modals/neurons/NnsAutoStakeMaturityModal.svelte";
14+
import NnsDisburseMaturityModal from "$lib/modals/neurons/NnsDisburseMaturityModal.svelte";
1415
import NnsStakeMaturityModal from "$lib/modals/neurons/NnsStakeMaturityModal.svelte";
1516
import SpawnNeuronModal from "$lib/modals/neurons/SpawnNeuronModal.svelte";
1617
import SplitNeuronModal from "$lib/modals/neurons/SplitNnsNeuronModal.svelte";
@@ -81,7 +82,7 @@
8182
{/if}
8283

8384
{#if type === "disburse-maturity"}
84-
NnsDisburseMaturityModal
85+
<NnsDisburseMaturityModal {close} {neuron} />
8586
{/if}
8687

8788
{#if type === "auto-stake-maturity"}
Lines changed: 180 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,180 @@
1+
import * as api from "$lib/api/governance.api";
2+
import { MIN_DISBURSEMENT_WITH_VARIANCE } from "$lib/constants/neurons.constants";
3+
import NnsDisburseMaturityModal from "$lib/modals/neurons/NnsDisburseMaturityModal.svelte";
4+
import { neuronsStore } from "$lib/stores/neurons.store";
5+
import { mockIdentity, resetIdentity } from "$tests/mocks/auth.store.mock";
6+
import { mockMainAccount } from "$tests/mocks/icp-accounts.store.mock";
7+
import { renderModal } from "$tests/mocks/modal.mock";
8+
import { mockNeuron } from "$tests/mocks/neurons.mock";
9+
import { DisburseMaturityModalPo } from "$tests/page-objects/DisburseMaturityModal.page-object";
10+
import { JestPageObjectElement } from "$tests/page-objects/jest.page-object";
11+
import { setAccountsForTesting } from "$tests/utils/accounts.test-utils";
12+
import { runResolvedPromises } from "$tests/utils/timers.test-utils";
13+
import { busyStore, toastsStore } from "@dfinity/gix-components";
14+
import type { NeuronInfo } from "@dfinity/nns";
15+
import { get } from "svelte/store";
16+
17+
vi.mock("$lib/api/governance.api");
18+
19+
describe("NnsDisburseMaturityModal", () => {
20+
const minMaturityForDisbursement = MIN_DISBURSEMENT_WITH_VARIANCE;
21+
const enoughMaturityToDisburse1Percent = minMaturityForDisbursement * 100n;
22+
const testNeuron = (
23+
maturityE8sEquivalent: bigint = enoughMaturityToDisburse1Percent
24+
): NeuronInfo => ({
25+
...mockNeuron,
26+
fullNeuron: {
27+
...mockNeuron.fullNeuron,
28+
maturityE8sEquivalent,
29+
controller: mockIdentity.getPrincipal().toText(),
30+
},
31+
});
32+
33+
beforeEach(() => {
34+
resetIdentity();
35+
setAccountsForTesting({
36+
main: mockMainAccount,
37+
hardwareWallets: [],
38+
});
39+
});
40+
41+
const renderNnsDisburseMaturityModal = async ({
42+
neuron,
43+
close,
44+
}: {
45+
neuron: NeuronInfo;
46+
close?: () => void;
47+
}): Promise<DisburseMaturityModalPo> => {
48+
const { container } = await renderModal({
49+
component: NnsDisburseMaturityModal,
50+
props: {
51+
neuron,
52+
neuronId: neuron.neuronId,
53+
close,
54+
},
55+
});
56+
return DisburseMaturityModalPo.under(new JestPageObjectElement(container));
57+
};
58+
59+
it("should display total maturity", async () => {
60+
const po = await renderNnsDisburseMaturityModal({
61+
neuron: testNeuron(minMaturityForDisbursement),
62+
});
63+
// MINIMUM_DISBURSEMENT / MATURITY_MODULATION_VARIANCE_PERCENTAGE
64+
expect(await po.getTotalMaturity()).toBe("1.05");
65+
});
66+
67+
it("should disable next button when 0 selected", async () => {
68+
const po = await renderNnsDisburseMaturityModal({ neuron: testNeuron() });
69+
await po.setPercentage(100);
70+
expect(await po.isNextButtonDisabled()).toBe(false);
71+
await po.setPercentage(0);
72+
expect(await po.isNextButtonDisabled()).toBe(true);
73+
});
74+
75+
it("should enable next button when enough maturity is selected", async () => {
76+
const po = await renderNnsDisburseMaturityModal({ neuron: testNeuron() });
77+
await po.setPercentage(1);
78+
expect(await po.isNextButtonDisabled()).toBe(false);
79+
});
80+
81+
it("should the main address be selected by default", async () => {
82+
const po = await renderNnsDisburseMaturityModal({ neuron: testNeuron() });
83+
await po.setPercentage(10);
84+
await po.clickNextButton();
85+
expect(await po.getConfirmPercentage()).toEqual("10%");
86+
expect(await po.getConfirmDestination()).toEqual("Main");
87+
});
88+
89+
it("should disable next button if amount of maturity is less than enough", async () => {
90+
const po = await renderNnsDisburseMaturityModal({
91+
neuron: testNeuron(minMaturityForDisbursement),
92+
});
93+
await po.setPercentage(99);
94+
expect(await po.isNextButtonDisabled()).toBe(true);
95+
await po.setPercentage(100);
96+
expect(await po.isNextButtonDisabled()).toBe(false);
97+
});
98+
99+
it("should display summary information in the last step", async () => {
100+
const po = await renderNnsDisburseMaturityModal({ neuron: testNeuron() });
101+
await po.setPercentage(50);
102+
await po.clickNextButton();
103+
expect(await po.getConfirmPercentage()).toEqual("50%");
104+
expect(await po.getConfirmTokens()).toBe("50.00-55.26 ICP");
105+
expect(await po.getConfirmDestination()).toEqual("Main");
106+
});
107+
108+
it("should successfully disburse maturity", async () => {
109+
const neuron = testNeuron();
110+
const close = vi.fn();
111+
let resolveDisburseMaturity;
112+
const spyDisburseMaturity = vi
113+
.spyOn(api, "disburseMaturity")
114+
.mockImplementation(
115+
() => new Promise((resolve) => (resolveDisburseMaturity = resolve))
116+
);
117+
const spyQueryNeurons = vi
118+
.spyOn(api, "queryNeurons")
119+
.mockResolvedValue([neuron]);
120+
// Add the neuron to the store to avoid extra query from getIdentityOfControllerByNeuronId
121+
neuronsStore.setNeurons({
122+
neurons: [neuron],
123+
certified: true,
124+
});
125+
const po = await renderNnsDisburseMaturityModal({ neuron, close });
126+
127+
await po.setPercentage(100);
128+
await po.clickNextButton();
129+
130+
expect(
131+
await po.getNeuronConfirmActionScreenPo().getConfirmButton().isDisabled()
132+
).toEqual(false);
133+
expect(spyDisburseMaturity).toHaveBeenCalledTimes(0);
134+
expect(spyQueryNeurons).toHaveBeenCalledTimes(0);
135+
expect(get(busyStore)).toEqual([]);
136+
expect(get(toastsStore)).toEqual([]);
137+
138+
await po.clickConfirmButton();
139+
await runResolvedPromises();
140+
141+
expect(spyQueryNeurons).toHaveBeenCalledTimes(0);
142+
expect(spyDisburseMaturity).toHaveBeenCalledTimes(1);
143+
expect(spyDisburseMaturity).toHaveBeenCalledWith({
144+
neuronId: neuron.neuronId,
145+
percentageToDisburse: 100,
146+
identity: mockIdentity,
147+
});
148+
expect(close).toHaveBeenCalledTimes(0);
149+
expect(get(busyStore)).toEqual([
150+
{
151+
initiator: "disburse-maturity",
152+
text: undefined,
153+
},
154+
]);
155+
expect(get(toastsStore)).toEqual([]);
156+
157+
resolveDisburseMaturity();
158+
await runResolvedPromises();
159+
160+
expect(spyQueryNeurons).toHaveBeenCalledTimes(2);
161+
expect(spyQueryNeurons).toHaveBeenCalledWith(
162+
expect.objectContaining({
163+
certified: false,
164+
})
165+
);
166+
expect(spyQueryNeurons).toHaveBeenCalledWith(
167+
expect.objectContaining({
168+
certified: true,
169+
})
170+
);
171+
expect(get(busyStore)).toEqual([]);
172+
expect(get(toastsStore)).toMatchObject([
173+
{
174+
level: "success",
175+
text: "Maturity successfully disbursed.",
176+
},
177+
]);
178+
expect(close).toHaveBeenCalledTimes(1);
179+
});
180+
});

0 commit comments

Comments
 (0)