Skip to content

Commit

Permalink
GIX-1890: Check transaction fee disburse maturity (#3331)
Browse files Browse the repository at this point in the history
# Motivation

Users can't disburse maturity if less than transaction fee.

It's just used as a minimum, it's not that a fee is applied to the
amount.

# Changes

* A new prop `disabledText` in NeuronSelectPercentage.
* New prop in DisburseMaturityModal. Check to enable or disable first
screen.
* New neuron util `minimumAmountToDisburseMaturity`.
* Rename `SPAWN_VARIANCE_PERCENTAGE` to
`MATURITY_MODULATION_VARIANCE_PERCENTAGE`.

# Tests

* Add test case in SnsDisburseMaturityModal.spec.
* Add test for new neuron util.

# Todos

- [ ] Add entry to changelog (if necessary).
Already covered by disburse maturity entry.
  • Loading branch information
lmuntaner authored Sep 19, 2023
1 parent e99420e commit 277d7ab
Show file tree
Hide file tree
Showing 12 changed files with 133 additions and 33 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,14 @@
import TestIdWrapper from "$lib/components/common/TestIdWrapper.svelte";
import { formatMaturity } from "$lib/utils/neuron.utils";
import { replacePlaceholders } from "$lib/utils/i18n.utils";
import { nonNullish } from "@dfinity/utils";
import Tooltip from "../ui/Tooltip.svelte";
export let availableMaturityE8s: bigint;
export let percentage: number;
export let buttonText: string;
export let disabled = false;
export let disabledText: string | undefined = undefined;
let selectedMaturityE8s: bigint;
$: selectedMaturityE8s = (availableMaturityE8s * BigInt(percentage)) / 100n;
Expand Down Expand Up @@ -59,14 +62,27 @@
<button class="secondary" on:click={() => dispatcher("nnsCancel")}>
{$i18n.core.cancel}
</button>
<button
data-tid="select-maturity-percentage-button"
class="primary"
on:click={selectPercentage}
{disabled}
>
{buttonText}
</button>
{#if nonNullish(disabledText)}
<Tooltip id="disabled-disburse-button-modal" text={disabledText}>
<button
data-tid="select-maturity-percentage-button"
class="primary"
on:click={selectPercentage}
{disabled}
>
{buttonText}
</button>
</Tooltip>
{:else}
<button
data-tid="select-maturity-percentage-button"
class="primary"
on:click={selectPercentage}
{disabled}
>
{buttonText}
</button>
{/if}
</div>
</TestIdWrapper>

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
import { E8S_PER_ICP } from "$lib/constants/icp.constants";
import {
MIN_NEURON_STAKE,
SPAWN_VARIANCE_PERCENTAGE,
MATURITY_MODULATION_VARIANCE_PERCENTAGE,
} from "$lib/constants/neurons.constants";
import { i18n } from "$lib/stores/i18n";
import { formatNumber, formatPercentage } from "$lib/utils/format.utils";
Expand Down Expand Up @@ -49,17 +49,22 @@
$i18n.neuron_detail.spawn_neuron_disabled_tooltip,
{
$amount: formatNumber(
MIN_NEURON_STAKE / E8S_PER_ICP / SPAWN_VARIANCE_PERCENTAGE,
MIN_NEURON_STAKE /
E8S_PER_ICP /
MATURITY_MODULATION_VARIANCE_PERCENTAGE,
{ minFraction: 4, maxFraction: 4 }
),
$min: formatNumber(MIN_NEURON_STAKE / E8S_PER_ICP, {
minFraction: 0,
maxFraction: 0,
}),
$varibility: formatPercentage(SPAWN_VARIANCE_PERCENTAGE, {
minFraction: 0,
maxFraction: 0,
}),
$variability: formatPercentage(
MATURITY_MODULATION_VARIANCE_PERCENTAGE,
{
minFraction: 0,
maxFraction: 0,
}
),
}
)}
>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
<script lang="ts">
import { hasEnoughMaturityToDisburse } from "$lib/utils/sns-neuron.utils";
import {
hasEnoughMaturityToDisburse,
minimumAmountToDisburseMaturity,
} from "$lib/utils/sns-neuron.utils";
import { openSnsNeuronModal } from "$lib/utils/modals.utils";
import type { SnsNeuron } from "@dfinity/sns";
import DisburseMaturityButton from "$lib/components/neuron-detail/actions/DisburseMaturityButton.svelte";
Expand All @@ -17,7 +20,11 @@
$: disabledText = !enoughMaturity
? replacePlaceholders(
$i18n.neuron_detail.disburse_maturity_disabled_tooltip,
{ $fee: formatToken({ value: feeE8s }) }
{
$amount: formatToken({
value: minimumAmountToDisburseMaturity(feeE8s),
}),
}
)
: undefined;
Expand Down
2 changes: 1 addition & 1 deletion frontend/src/lib/constants/neurons.constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { E8S_PER_ICP } from "./icp.constants";
export const MAX_NEURONS_MERGED = 2;
export const MIN_NEURON_STAKE = E8S_PER_ICP;
export const MAX_CONCURRENCY = 10;
export const SPAWN_VARIANCE_PERCENTAGE = 0.95;
export const MATURITY_MODULATION_VARIANCE_PERCENTAGE = 0.95;
// Neuron ids are random u64. Max digits of a u64 is 20.
export const MAX_NEURON_ID_DIGITS = 20;

Expand Down
4 changes: 2 additions & 2 deletions frontend/src/lib/i18n/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -611,7 +611,7 @@
"spawn_neuron": "Spawn Neuron",
"spawn": "Spawn",
"stake_maturity_disabled_tooltip": "Currently, you do not have any maturity available to stake into this neuron.",
"disburse_maturity_disabled_tooltip": "You do not have enough maturity to disburse. The minimum is: $fee.",
"disburse_maturity_disabled_tooltip": "You do not have enough maturity to disburse. The minimum is: $amount.",
"stake_maturity_tooltip": "Merge Maturity has been replaced by Stake Maturity. <a href=\"https://wiki.internetcomputer.org/wiki/NNS_neuron_operations_related_to_maturity\" rel=\"noopener noreferrer\" aria-label=\"Find more information about the new stake maturity\" target=\"_blank\">Learn more</a>.",
"start_dissolve_description": "This will cause your neuron to lose its age bonus.\nAre you sure you wish to continue?",
"stop_dissolve_description": "Are you sure you want to stop the dissolve process?",
Expand All @@ -628,7 +628,7 @@
"disburse_neuron_title": "Disburse Neuron",
"split_neuron_success": "Your neuron has successfully been split.",
"split_neuron_disabled_tooltip": "Neuron needs a minimum stake of $amount $token to be splittable.",
"spawn_neuron_disabled_tooltip": "You need at least the equivalent of $amount ICP ($min / $varibility) to spawn a new neuron from maturity.",
"spawn_neuron_disabled_tooltip": "You need at least the equivalent of $amount ICP ($min / $variability) to spawn a new neuron from maturity.",
"hotkeys_title": "Hotkeys",
"add_hotkey": "Add Hotkey",
"no_notkeys": "No hotkeys",
Expand Down
21 changes: 20 additions & 1 deletion frontend/src/lib/modals/neurons/DisburseMaturityModal.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@
export let availableMaturityE8s: bigint;
export let tokenSymbol: string;
export let minimumAmountE8s: bigint;
const steps: WizardSteps = [
{
name: "SelectPercentage",
Expand All @@ -33,6 +35,22 @@
let modal: WizardModal;
let percentageToDisburse = 0;
let selectedMaturityE8s: bigint;
$: selectedMaturityE8s =
(availableMaturityE8s * BigInt(percentageToDisburse)) / 100n;
let disableDisburse = false;
$: disableDisburse = selectedMaturityE8s < minimumAmountE8s;
// Show the text only if the selected percentage is greater than 0.
let disabledText: string | undefined = undefined;
$: disabledText =
disableDisburse && percentageToDisburse > 0
? replacePlaceholders(
$i18n.neuron_detail.disburse_maturity_disabled_tooltip,
{ $amount: formatToken({ value: minimumAmountE8s }) }
)
: undefined;
const dispatcher = createEventDispatcher();
const disburseNeuronMaturity = () =>
Expand Down Expand Up @@ -74,7 +92,8 @@
on:nnsSelectPercentage={goToConfirm}
on:nnsCancel={close}
bind:percentage={percentageToDisburse}
disabled={percentageToDisburse === 0}
disabled={disableDisburse}
{disabledText}
>
<div class="percentage-container" slot="description">
<span class="description">
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,15 @@
import type { SnsNeuron, SnsNeuronId } from "@dfinity/sns";
import type { Principal } from "@dfinity/principal";
import { disburseMaturity as disburseMaturityService } from "$lib/services/sns-neurons.services";
import { formattedMaturity } from "$lib/utils/sns-neuron.utils";
import {
formattedMaturity,
minimumAmountToDisburseMaturity,
} from "$lib/utils/sns-neuron.utils";
import DisburseMaturityModal from "$lib/modals/neurons/DisburseMaturityModal.svelte";
import { snsProjectMainAccountStore } from "$lib/derived/sns/sns-project-accounts.derived";
import { shortenWithMiddleEllipsis } from "$lib/utils/format.utils";
import { tokensStore } from "$lib/stores/tokens.store";
import type { Token } from "@dfinity/utils";
import type { IcrcTokenMetadata } from "$lib/types/icrc";
export let neuron: SnsNeuron;
export let neuronId: SnsNeuronId;
Expand All @@ -25,9 +28,14 @@
$snsProjectMainAccountStore?.identifier ?? ""
);
let token: Token | undefined;
let token: IcrcTokenMetadata | undefined;
$: token = $tokensStore[rootCanisterId.toText()]?.token;
let minimumAmountE8s: bigint;
// Token is loaded with all the projects from the aggregator.
// Therefore, if the user made it here, it's present.
$: minimumAmountE8s = minimumAmountToDisburseMaturity(token?.fee ?? 0n);
const dispatcher = createEventDispatcher();
const close = () => dispatcher("nnsClose");
Expand Down Expand Up @@ -57,6 +65,7 @@

<DisburseMaturityModal
availableMaturityE8s={neuron.maturity_e8s_equivalent}
{minimumAmountE8s}
tokenSymbol={token?.symbol ?? ""}
on:nnsDisburseMaturity={disburseMaturity}
on:nnsClose
Expand Down
7 changes: 5 additions & 2 deletions frontend/src/lib/utils/neuron.utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,9 @@ import {
import {
AGE_MULTIPLIER,
DISSOLVE_DELAY_MULTIPLIER,
MATURITY_MODULATION_VARIANCE_PERCENTAGE,
MAX_NEURONS_MERGED,
MIN_NEURON_STAKE,
SPAWN_VARIANCE_PERCENTAGE,
TOPICS_TO_FOLLOW_NNS,
} from "$lib/constants/neurons.constants";
import { DEPRECATED_TOPICS } from "$lib/constants/proposals.constants";
Expand Down Expand Up @@ -499,7 +499,10 @@ export const isEnoughMaturityToSpawn = ({
const maturitySelected = Math.floor(
(Number(fullNeuron.maturityE8sEquivalent) * percentage) / 100
);
return maturitySelected >= MIN_NEURON_STAKE / SPAWN_VARIANCE_PERCENTAGE;
return (
maturitySelected >=
MIN_NEURON_STAKE / MATURITY_MODULATION_VARIANCE_PERCENTAGE
);
};

export const isSpawning = (neuron: NeuronInfo): boolean =>
Expand Down
14 changes: 12 additions & 2 deletions frontend/src/lib/utils/sns-neuron.utils.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { MATURITY_MODULATION_VARIANCE_PERCENTAGE } from "$lib/constants/neurons.constants";
import {
HOTKEY_PERMISSIONS,
MANAGE_HOTKEY_PERMISSIONS,
Expand Down Expand Up @@ -505,7 +506,7 @@ export const hasEnoughMaturityToStake = (
): boolean => (neuron?.maturity_e8s_equivalent ?? BigInt(0)) > BigInt(0);

/**
* Is the maturity of the neuron bigger than the transaction fee?
* Is the maturity of the neuron bigger than the minimum amount to disburse?
* @param {SnsNeuron} neuron
* @param {bigint} feeE8s
*/
Expand All @@ -515,7 +516,8 @@ export const hasEnoughMaturityToDisburse = ({
}: {
feeE8s: bigint;
neuron: SnsNeuron;
}): boolean => maturity_e8s_equivalent >= feeE8s;
}): boolean =>
maturity_e8s_equivalent >= minimumAmountToDisburseMaturity(feeE8s);

/**
* Does the neuron has staked maturity?
Expand Down Expand Up @@ -981,3 +983,11 @@ export const totalDisbursingMaturity = ({
(acc, disbursement) => acc + disbursement.amount_e8s,
BigInt(0)
);

/**
* The governance canister checks that the amount to disburse in the worst case (of the maturity modulation) is bigger than the transaction fee.
*
* Source: https://sourcegraph.com/github.com/dfinity/ic/-/blob/rs/sns/governance/src/governance.rs?L1651
*/
export const minimumAmountToDisburseMaturity = (fee: bigint): bigint =>
BigInt(Math.ceil(Number(fee) / MATURITY_MODULATION_VARIANCE_PERCENTAGE));
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ describe("SnsDisburseMaturityButton", () => {
const po = renderComponent(
{
...mockSnsNeuron,
maturity_e8s_equivalent: fee + 10n,
maturity_e8s_equivalent: fee * 2n,
staked_maturity_e8s_equivalent: [],
},
fee
Expand All @@ -50,7 +50,7 @@ describe("SnsDisburseMaturityButton", () => {

expect(await po.isDisabled()).toBe(true);
expect(await po.getTooltipText()).toBe(
"You do not have enough maturity to disburse. The minimum is: 0.0001."
"You do not have enough maturity to disburse. The minimum is: 0.00010527."
);
});

Expand Down
20 changes: 20 additions & 0 deletions frontend/src/tests/lib/modals/sns/SnsDisburseMaturityModal.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,26 @@ describe("SnsDisburseMaturityModal", () => {
expect(await po.isNextButtonDisabled()).toBe(false);
});

it("should disable next button if amount of maturity is less than transaction fee", async () => {
const fee = 100_000_000n;
const neuron = createMockSnsNeuron({
id: [1],
maturity: fee * 2n,
});
tokensStore.setToken({
canisterId: rootCanisterId,
token: {
fee,
...mockSnsToken,
},
});
// Maturity is 2x the fee, so 10% of maturity is not enough to cover the fee
const percentage = 10;
const po = await renderSnsDisburseMaturityModal(neuron);
await po.setPercentage(percentage);
expect(await po.isNextButtonDisabled()).toBe(false);
});

it("should display selected percentage and total maturity", async () => {
const neuron = createMockSnsNeuron({
id: [1],
Expand Down
19 changes: 15 additions & 4 deletions frontend/src/tests/lib/utils/sns-neuron.utils.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ import {
isUserHotkey,
isVesting,
minNeuronSplittable,
minimumAmountToDisburseMaturity,
needsRefresh,
neuronAge,
nextMemo,
Expand Down Expand Up @@ -1391,10 +1392,10 @@ describe("sns-neuron utils", () => {

describe("hasEnoughMaturityToDisburse", () => {
const feeE8s = 10_000n;
it("should return true if maturity is more than fee", () => {
it("should return true if maturity is more than fee in worst modulation scenario", () => {
const neuron = {
...mockSnsNeuron,
maturity_e8s_equivalent: feeE8s + 1n,
maturity_e8s_equivalent: 10526n + 1n,
};
expect(hasEnoughMaturityToDisburse({ neuron, feeE8s })).toBe(true);
});
Expand All @@ -1407,12 +1408,12 @@ describe("sns-neuron utils", () => {
expect(hasEnoughMaturityToDisburse({ neuron, feeE8s })).toBe(false);
});

it("should return true if maturity is same as fee", () => {
it("should return false if maturity is same as fee", () => {
const neuron = {
...mockSnsNeuron,
maturity_e8s_equivalent: feeE8s,
};
expect(hasEnoughMaturityToDisburse({ neuron, feeE8s })).toBe(true);
expect(hasEnoughMaturityToDisburse({ neuron, feeE8s })).toBe(false);
});
});

Expand Down Expand Up @@ -2492,4 +2493,14 @@ describe("sns-neuron utils", () => {
expect(totalDisbursingMaturity(neuron)).toBe(0n);
});
});

describe("minimumAmountToDisburseMaturity", () => {
it("returns worst case of maturity modulation", () => {
expect(minimumAmountToDisburseMaturity(10_000n)).toBe(10527n);
});

it("returns 0 if fee is 0", () => {
expect(minimumAmountToDisburseMaturity(0n)).toBe(0n);
});
});
});

0 comments on commit 277d7ab

Please sign in to comment.