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

Basic UI that allows staking but with minimal/poor user experience #196

Merged
merged 7 commits into from
Jul 11, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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