Skip to content
This repository has been archived by the owner on Jan 11, 2024. It is now read-only.

Commit

Permalink
Basic UI that allows staking but with minimal/poor user experience (#196
Browse files Browse the repository at this point in the history
)

* Working first part of staking UI

* Implement stake RPC

* Run formatter

* Show period in human time

* Don't show staking button when staking isn't available

* Added distribution shares text

* Run formatter

Co-authored-by: Nikola Dimitroff <[email protected]>
  • Loading branch information
shelbyd and nikoladimitroff authored Jul 11, 2022
1 parent e6cfc3b commit c9b9e01
Show file tree
Hide file tree
Showing 5 changed files with 352 additions and 12 deletions.
2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,9 @@
"@polkadot/util-crypto": "^7.1.2",
"@trustfractal/polkadot-utils": "^0.1.7",
"@trustfractal/sdk": "^0.5.2",
"@types/humanize-duration": "^3.27.1",
"ethers": "^5.1.3",
"humanize-duration": "^3.27.2",
"js-base64": "^3.6.0",
"mirror-creator": "^1.1.0",
"moment": "^2.29.1",
Expand Down
50 changes: 39 additions & 11 deletions src/popup/components/Protocol/DataScreen/index.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import styled from "styled-components";

import { useState, useEffect, useContext } from "react";
import { getProtocolOptIn } from "@services/Factory";

import { getProtocolOptIn, getProtocolService } from "@services/Factory";
import { useLoadedState } from "@utils/ReactHooks";

import {
Subsubtitle,
Text,
Expand All @@ -12,6 +14,7 @@ import {
import Wallet from "@models/Wallet";
import Button from "@popup/components/common/Button";
import { SendTokens } from "@popup/components/Protocol/SendTokens";
import { StakeTokens } from "@popup/components/Protocol/StakeTokens";
import { ActivityStackContext } from "@popup/containers/ActivityStack";

// @ts-ignore
Expand Down Expand Up @@ -89,10 +92,21 @@ function AddLiveness() {
);
}

const ActionsContainer = styled.div`
width: 100%;
display: flex;
flex-direction: row;
justify-content: space-evenly;
align-items: center;
`;

function DataScreen() {
const [wallet, setWallet] = useState<Wallet>();
const { updater: activityStack } = useContext(ActivityStackContext);

const canStake = useLoadedState(() => getProtocolService().canStake());

useEffect(() => {
(async () => {
const mnemonic = await getProtocolOptIn().getMnemonic();
Expand All @@ -108,15 +122,29 @@ function DataScreen() {
<Minting />
<WebpageViews />
<Address wallet={wallet} />
<Button
onClick={() =>
activityStack.push(
<SendTokens onFinish={() => activityStack.pop()} />,
)
}
>
Send FCL
</Button>
<ActionsContainer>
<Button
onClick={() =>
activityStack.push(
<SendTokens onFinish={() => activityStack.pop()} />,
)
}
>
Send FCL
</Button>

{!canStake.unwrapOrDefault(false) ? null : (
<Button
onClick={() =>
activityStack.push(
<StakeTokens onFinish={() => activityStack.pop()} />,
)
}
>
Stake FCL
</Button>
)}
</ActionsContainer>
</VerticalSequence>
);
}
Expand Down
263 changes: 263 additions & 0 deletions src/popup/components/Protocol/StakeTokens.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,263 @@
import styled from "styled-components";
import { useState } from "react";
import humanizeDuration from "humanize-duration";
import ReactTooltip from "react-tooltip";

import { useLoadedState } from "@utils/ReactHooks";

import Button from "@popup/components/common/Button";
import Input from "@popup/components/common/Input";
import RadioInput from "@popup/components/common/RadioInput";
import {
Cta,
Title,
Icon,
IconNames,
VerticalSequence,
} from "@popup/components/Protocol/common";

import { getProtocolService } from "@services/Factory";

const FCL_UNIT = BigInt(10 ** 12);

export function StakeTokens(props: { onFinish: () => void }) {
const [page, setPage] = useState<"specify" | "confirm" | JSX.Element>(
"specify",
);

const [amount, setAmount] = useState(BigInt(0));
const [lockPeriod, setLockPeriod] = useState<number>(0);

const [loading, setLoading] = useState(false);

const specify = (
<Specify
lockPeriod={lockPeriod}
onChangeLockPeriod={setLockPeriod}
amount={amount}
onChangeAmount={setAmount}
onContinue={() => setPage("confirm")}
/>
);

const confirm = (
<Confirm
lockPeriod={lockPeriod}
amount={amount}
onConfirm={async () => {
setLoading(true);
const hash = await getProtocolService().stakeTokens(lockPeriod, amount);
setPage(<Complete onFinish={props.onFinish} hash={hash} />);
setLoading(false);
}}
onCancel={() => setPage("specify")}
loading={loading}
/>
);

if (page === "specify") {
return specify;
} else if (page === "confirm") {
return confirm;
} else {
return page;
}
}

const LockPeriodSelectionContainer = styled.div`
display: flex;
flex-direction: column;
align-items: flex-start;
row-gap: 8px;
.lock-period-option {
display: flex;
flex-direction: row;
align-items: center;
column-gap: 8px;
p {
margin: 0;
}
}
[data-tip] {
text-decoration: underline;
text-decoration-style: dotted;
cursor: help;
}
`;

function Specify(props: {
lockPeriod: number;
onChangeLockPeriod: (a: number) => void;
amount: bigint;
onChangeAmount: (a: bigint) => void;
onContinue: () => void;
}) {
const lockPeriodOptionsLoader = useLoadedState(() =>
getProtocolService().lockPeriodOptions(),
);

if (!lockPeriodOptionsLoader.isLoaded) return null;
const lockPeriodOptions = lockPeriodOptionsLoader.value;

if (!lockPeriodOptions.has(props.lockPeriod) && lockPeriodOptions.size > 0) {
// Timout to handle React error of updating a component from another.
setTimeout(() => {
const smallestLockPeriod = Math.min(
...Array.from(lockPeriodOptions.keys()),
);
props.onChangeLockPeriod(smallestLockPeriod);
});
return null;
}

const lockPeriodSelectionElements = Array.from(lockPeriodOptions.entries())
.sort((eA, eB) => eA[0] - eB[0])
.map(([thisPeriod, shares]) => {
return (
<label className="lock-period-option" key={thisPeriod}>
<RadioInput
checked={thisPeriod === props.lockPeriod}
onChange={() => props.onChangeLockPeriod(thisPeriod)}
/>
<p>
{humanizeBlocks(thisPeriod)} for{" "}
<span data-tip data-for="distributionSharesTip">
{shares} distribution shares
</span>
</p>
</label>
);
});

const validAmount = props.amount > BigInt(0);
const validLock = lockPeriodOptions.has(props.lockPeriod);
const isValid = validAmount && validLock;

return (
<ScreenContainer>
<VerticalSequence>
<Icon name={IconNames.PROTOCOL} />
<Title>Stake Tokens</Title>

<LockPeriodSelectionContainer>
<ReactTooltip id="distributionSharesTip" place="top" effect="solid">
By staking for longer periods of time and bigger amounts, you gain
more distribution shares. Your actual rewarded coins will be
proportional to the amount of distribution shares.
</ReactTooltip>

{lockPeriodSelectionElements}
</LockPeriodSelectionContainer>

<HorizontalContainer>
<Input
label="Amount"
type="number"
autoFocus
value={
props.amount === BigInt(0)
? ""
: (props.amount / FCL_UNIT).toString()
}
onChange={(e) => {
props.onChangeAmount(BigInt(e.target.value) * FCL_UNIT);
}}
/>
</HorizontalContainer>

{!isValid ? null : (
<p>
Will lock{" "}
<strong>{(props.amount / FCL_UNIT).toString()} FCL</strong> for{" "}
<strong>{humanizeBlocks(props.lockPeriod)}</strong>, receiving{" "}
<strong>{lockPeriodOptions.get(props.lockPeriod)}</strong>{" "}
distribution shares.
</p>
)}

<Cta disabled={!isValid} onClick={props.onContinue}>
Stake
</Cta>
</VerticalSequence>
</ScreenContainer>
);
}

function humanizeBlocks(blocks: number): string {
const secondsPerBlock = 6;
const durationMs = blocks * secondsPerBlock * 1000;
return humanizeDuration(durationMs, {
units: ["w", "d", "h", "m"],
round: true,
});
}

function Confirm(props: {
lockPeriod: number;
amount: bigint;
onConfirm: () => void;
onCancel: () => void;
loading: boolean;
}) {
return (
<ScreenContainer>
<VerticalSequence>
<Icon name={IconNames.PROTOCOL} />
<Title>Confirm Stake</Title>

<p>
Stake <strong>{(props.amount / FCL_UNIT).toString()} FCL</strong> for{" "}
<strong>{props.lockPeriod}</strong> blocks?
</p>

<HorizontalContainer>
<Button alternative loading={props.loading} onClick={props.onCancel}>
Cancel
</Button>
<Cta loading={props.loading} onClick={props.onConfirm}>
Stake
</Cta>
</HorizontalContainer>
</VerticalSequence>
</ScreenContainer>
);
}

const ScreenContainer = styled.div`
padding: var(--s-12);
`;

const HorizontalContainer = styled.div`
display: flex;
flex-direction: row;
justify-content: center;
align-items: flex-start;
column-gap: var(--s-12);
`;

const BreakStrong = styled.strong`
word-break: break-all;
text-align: center;
`;

function Complete(props: { hash: string; onFinish: () => void }) {
return (
<ScreenContainer>
<VerticalSequence>
<Icon name={IconNames.PROTOCOL} />
<Title>Stake Complete</Title>

<p>Transaction ID</p>
<p>
<BreakStrong>{props.hash}</BreakStrong>
</p>

<Cta onClick={props.onFinish}>Return</Cta>
</VerticalSequence>
</ScreenContainer>
);
}
37 changes: 37 additions & 0 deletions src/services/ProtocolService/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -360,6 +360,43 @@ export class ProtocolService {
);
return new Date(timestamp.toNumber());
}

async stakeTokens(
lockPeriod: number,
amount: number | bigint,
): Promise<string> {
const txn = await this.withApi((api) =>
api.tx.fractalStaking.stake(lockPeriod, amount),
);

const { hash } = await TxnWatcher.signAndSend(
txn as any,
this.requireSigner(),
).inBlock();
return hash;
}

async lockPeriodOptions(): Promise<Map<number, number>> {
const map = new Map();

const query = await this.withApi((api) =>
api.query.fractalStaking.lockPeriodShares.entries(),
);

for (const [lockPeriodKey, sharesValue] of query) {
const lockPeriod = Number(
(lockPeriodKey.toHuman() as any)[0].replaceAll(",", ""),
);
const shares = Number(sharesValue.toHuman());
map.set(lockPeriod, shares);
}

return map;
}

async canStake(): Promise<boolean> {
return await this.withApi((api) => api.tx.fractalStaking != null);
}
}

class BlockNumberOutsideRange extends Error {
Expand Down
Loading

0 comments on commit c9b9e01

Please sign in to comment.