Skip to content

Commit

Permalink
FOLLOW-244: Claim unclaimed neurons from the frontend (#5734)
Browse files Browse the repository at this point in the history
# Motivation

Staking a neuron requires 2 steps: transferring the stake and claiming
the neuron.
Both steps are done in ic-js.
But it's possible that the process gets interrupted in between.
In this case the nns-dapp canister finishes the process.
We want to move this fallback mechanism to the frontend.

The functionality was added in previous PRs. Here was call the service
function from the wallet component to make it actually functional.

# Changes

1. Call `claimNeuronsIfNeeded` from `NnsWallet`.

# Tests

1. Tested manually after disabling the functionality in the canister, by
reloading the page as soon as the staking process is started.
2. Unit tests added for main account, subaccount and hardware wallet
account.

# Todos

- [x] Add entry to changelog (if necessary).
  • Loading branch information
dskloetd authored Nov 6, 2024
1 parent 86de7da commit fbf8c3d
Show file tree
Hide file tree
Showing 4 changed files with 191 additions and 1 deletion.
1 change: 1 addition & 0 deletions CHANGELOG-Nns-Dapp-unreleased.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ proposal is successful, the changes it released will be moved from this file to
#### Changed

* Updated the dark theme and page icons.
* Claim unclaimed neurons from the frontend.

#### Deprecated

Expand Down
38 changes: 37 additions & 1 deletion frontend/src/lib/pages/NnsWallet.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,10 @@
loadIcpAccountNextTransactions,
loadIcpAccountTransactions,
} from "$lib/services/icp-transactions.services";
import { listNeurons } from "$lib/services/neurons.services";
import {
claimNeuronsIfNeeded,
listNeurons,
} from "$lib/services/neurons.services";
import { authStore } from "$lib/stores/auth.store";
import { i18n } from "$lib/stores/i18n";
import { icpAccountBalancesStore } from "$lib/stores/icp-account-balances.store";
Expand Down Expand Up @@ -121,6 +124,39 @@
return false;
};
const tryClaimNeurons = ({
account,
transactionsStore,
neuronAccounts,
}: {
account: Account | undefined;
transactionsStore: IcpTransactionsStoreData;
neuronAccounts: Set<string>;
}) => {
const controller =
account?.principal ?? $authStore.identity?.getPrincipal();
if (
isNullish(controller) ||
isNullish(neuronAccounts) ||
isNullish(account) ||
isNullish(transactionsStore[account.identifier])
) {
return;
}
const transactions = transactionsStore[account.identifier].transactions;
claimNeuronsIfNeeded({
controller,
transactions,
neuronAccounts,
});
};
$: tryClaimNeurons({
account: $selectedAccountStore.account,
transactionsStore: $icpTransactionsStore,
neuronAccounts: $neuronAccountsStore,
});
let uiTransactions: UiTransaction[] | undefined;
$: uiTransactions = makeUiTransactions({
account: $selectedAccountStore.account,
Expand Down
150 changes: 150 additions & 0 deletions frontend/src/tests/lib/pages/NnsWallet.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import * as ledgerApi from "$lib/api/icp-ledger.api";
import * as nnsDappApi from "$lib/api/nns-dapp.api";
import { SYNC_ACCOUNTS_RETRY_SECONDS } from "$lib/constants/accounts.constants";
import {
GOVERNANCE_CANISTER_ID,
INDEX_CANISTER_ID,
LEDGER_CANISTER_ID,
OWN_CANISTER_ID_TEXT,
Expand Down Expand Up @@ -53,6 +54,7 @@ import {
} from "$tests/utils/timers.test-utils";
import { toastsStore } from "@dfinity/gix-components";
import type { TransactionWithId } from "@dfinity/ledger-icp";
import { memoToNeuronAccountIdentifier } from "@dfinity/nns";
import { Principal } from "@dfinity/principal";
import { get } from "svelte/store";
import type { MockInstance } from "vitest";
Expand Down Expand Up @@ -851,6 +853,64 @@ describe("NnsWallet", () => {
});
expect(get(toastsStore)).toEqual([]);
});

describe("when there are staking transactions", () => {
const neuronController = mockMainAccount.principal;
const memo = 54321n;
const neuronAccountIdentifier = memoToNeuronAccountIdentifier({
controller: neuronController,
memo,
governanceCanisterId: GOVERNANCE_CANISTER_ID,
});
const stakingTransaction = createMockSendTransactionWithId({
to: neuronAccountIdentifier.toHex(),
memo,
});
const claimedNeuron = {
...mockNeuron,
fullNeuron: {
...mockNeuron.fullNeuron,
accountIdentifier: neuronAccountIdentifier.toHex(),
},
};

let spyClaimNeuron;

beforeEach(() => {
vi.spyOn(governanceApi, "queryNeuron").mockResolvedValue(claimedNeuron);
vi.spyOn(indexApi, "getTransactions").mockResolvedValue({
transactions: [stakingTransaction],
oldestTxId,
balance: mainBalanceE8s,
});

spyClaimNeuron = vi
.spyOn(governanceApi, "claimOrRefreshNeuronByMemo")
.mockResolvedValue(claimedNeuron.neuronId);
});

it("should claim unclaimed neurons", async () => {
expect(spyClaimNeuron).toBeCalledTimes(0);

await renderWallet({
accountIdentifier: mockMainAccount.identifier,
});

expect(spyClaimNeuron).toBeCalledTimes(1);
});

it("should not claim neurons which already exist in the store", async () => {
neuronsStore.setNeurons({ neurons: [claimedNeuron], certified: true });

expect(spyClaimNeuron).toBeCalledTimes(0);

await renderWallet({
accountIdentifier: mockMainAccount.identifier,
});

expect(spyClaimNeuron).toBeCalledTimes(0);
});
});
});

describe("accounts loaded (Subaccount)", () => {
Expand All @@ -870,6 +930,52 @@ describe("NnsWallet", () => {

expect(await po.getRenameButtonPo().isPresent()).toBe(true);
});

describe("when there are staking transactions", () => {
// Subaccounts don't have a principal so we use the principal of the user
// as the controller.
const neuronController = mockIdentity.getPrincipal();
const memo = 54321n;
const neuronAccountIdentifier = memoToNeuronAccountIdentifier({
controller: neuronController,
memo,
governanceCanisterId: GOVERNANCE_CANISTER_ID,
});
const stakingTransaction = createMockSendTransactionWithId({
to: neuronAccountIdentifier.toHex(),
memo,
});
const claimedNeuron = {
...mockNeuron,
fullNeuron: {
...mockNeuron.fullNeuron,
accountIdentifier: neuronAccountIdentifier.toHex(),
},
};

let spyClaimNeuron;

beforeEach(() => {
vi.spyOn(governanceApi, "queryNeuron").mockResolvedValue(claimedNeuron);
vi.spyOn(indexApi, "getTransactions").mockResolvedValue({
transactions: [stakingTransaction],
oldestTxId,
balance: mainBalanceE8s,
});

spyClaimNeuron = vi
.spyOn(governanceApi, "claimOrRefreshNeuronByMemo")
.mockResolvedValue(claimedNeuron.neuronId);
});

it("should claim unclaimed neurons", async () => {
expect(spyClaimNeuron).toBeCalledTimes(0);

await renderWallet(props);

expect(spyClaimNeuron).toBeCalledTimes(1);
});
});
});

describe("accounts loaded (Hardware Wallet)", () => {
Expand Down Expand Up @@ -905,6 +1011,50 @@ describe("NnsWallet", () => {
expect(await po.getListNeuronsButtonPo().isPresent()).toBe(true);
expect(await po.getShowHardwareWalletButtonPo().isPresent()).toBe(true);
});

describe("when there are staking transactions", () => {
const neuronController = testHwPrincipal;
const memo = 54321n;
const neuronAccountIdentifier = memoToNeuronAccountIdentifier({
controller: neuronController,
memo,
governanceCanisterId: GOVERNANCE_CANISTER_ID,
});
const stakingTransaction = createMockSendTransactionWithId({
to: neuronAccountIdentifier.toHex(),
memo,
});
const claimedNeuron = {
...mockNeuron,
fullNeuron: {
...mockNeuron.fullNeuron,
accountIdentifier: neuronAccountIdentifier.toHex(),
},
};

let spyClaimNeuron;

beforeEach(() => {
vi.spyOn(governanceApi, "queryNeuron").mockResolvedValue(claimedNeuron);
vi.spyOn(indexApi, "getTransactions").mockResolvedValue({
transactions: [stakingTransaction],
oldestTxId,
balance: mainBalanceE8s,
});

spyClaimNeuron = vi
.spyOn(governanceApi, "claimOrRefreshNeuronByMemo")
.mockResolvedValue(claimedNeuron.neuronId);
});

it("should claim unclaimed neurons", async () => {
expect(spyClaimNeuron).toBeCalledTimes(0);

await renderWallet(props);

expect(spyClaimNeuron).toBeCalledTimes(1);
});
});
});

describe("when no accounts and user navigates away", () => {
Expand Down
3 changes: 3 additions & 0 deletions frontend/src/tests/mocks/transaction.mock.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,10 +49,12 @@ export const createMockSendTransactionWithId = ({
amount = 110_000_023n,
fee = 10_000n,
to = mockSubAccount.identifier,
memo = 0n,
}: {
amount?: bigint;
fee?: bigint;
to?: string;
memo?: bigint;
}): TransactionWithId => {
const transfer = {
...mockTransactionWithId.transaction["Transfer"],
Expand All @@ -66,6 +68,7 @@ export const createMockSendTransactionWithId = ({
const transaction = {
...mockTransactionWithId.transaction,
operation,
memo,
};
return {
...mockTransactionWithId,
Expand Down

0 comments on commit fbf8c3d

Please sign in to comment.