From f2d6cad9429a67483d1a1638445028fc16c0da6f Mon Sep 17 00:00:00 2001 From: Shelby Doolittle Date: Wed, 22 Jun 2022 10:29:06 -0400 Subject: [PATCH 1/7] Working first part of staking UI --- .../components/Protocol/DataScreen/index.tsx | 39 ++- src/popup/components/Protocol/StakeTokens.tsx | 232 ++++++++++++++++++ src/services/ProtocolService/index.ts | 22 ++ 3 files changed, 284 insertions(+), 9 deletions(-) create mode 100644 src/popup/components/Protocol/StakeTokens.tsx diff --git a/src/popup/components/Protocol/DataScreen/index.tsx b/src/popup/components/Protocol/DataScreen/index.tsx index 2a267258..afc92aaa 100644 --- a/src/popup/components/Protocol/DataScreen/index.tsx +++ b/src/popup/components/Protocol/DataScreen/index.tsx @@ -12,6 +12,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 @@ -89,6 +90,15 @@ 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(); const { updater: activityStack } = useContext(ActivityStackContext); @@ -108,15 +118,26 @@ function DataScreen() {
- + + + + ); } diff --git a/src/popup/components/Protocol/StakeTokens.tsx b/src/popup/components/Protocol/StakeTokens.tsx new file mode 100644 index 00000000..cf6eae85 --- /dev/null +++ b/src/popup/components/Protocol/StakeTokens.tsx @@ -0,0 +1,232 @@ +import styled from "styled-components"; +import { useState } from "react"; + +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(0); + + const [loading, setLoading] = useState(false); + + const specify = ( + setPage("confirm")} + /> + ); + + const confirm = ( + { + setLoading(true); + const hash = await getProtocolService().stakeTokens( + lockPeriod, + amount, + ); + setPage(); + 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; + } + } +`; + +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 ( + + ); + }); + + const validAmount = props.amount > BigInt(0); + const validLock = lockPeriodOptions.has(props.lockPeriod); + const isValid = validAmount && validLock; + + return ( + + + + Stake Tokens + + + {lockPeriodSelectionElements} + + + + { + props.onChangeAmount(BigInt(e.target.value) * FCL_UNIT); + }} + /> + + + {!isValid ? null : ( +

+ Will lock{" "} + {(props.amount / FCL_UNIT).toString()} FCL for{" "} + {props.lockPeriod} blocks, receiving{" "} + {lockPeriodOptions.get(props.lockPeriod)} shares. +

+ )} + + + Stake + +
+
+ ); +} + +function Confirm(props: { + lockPeriod: number; + amount: bigint; + onConfirm: () => void; + onCancel: () => void; + loading: boolean; +}) { + return ( + + + + Confirm Send + +

+ Send {(props.amount / FCL_UNIT).toString()} FCL to +

+ {props.lockPeriod} + + + + + Send + + +
+
+ ); +} + +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 SendComplete(props: { hash: string; onFinish: () => void }) { + return ( + + + + Send Complete + +

Transaction ID

+

+ {props.hash} +

+ + Return +
+
+ ); +} diff --git a/src/services/ProtocolService/index.ts b/src/services/ProtocolService/index.ts index 8e753f8e..f5a820a2 100644 --- a/src/services/ProtocolService/index.ts +++ b/src/services/ProtocolService/index.ts @@ -360,6 +360,28 @@ export class ProtocolService { ); return new Date(timestamp.toNumber()); } + + async stakeTokens( + lockPeriod: number, + amount: number|bigint, + ): Promise { + throw new Error('Unimplemented!'); + } + + async lockPeriodOptions(): Promise> { + 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].replace(',', '')); + const shares = Number(sharesValue.toHuman()); + map.set(lockPeriod, shares); + } + + return map; + } } class BlockNumberOutsideRange extends Error { From f1ec2075698edfc6e065bbde11f3c80143e9e414 Mon Sep 17 00:00:00 2001 From: Shelby Doolittle Date: Wed, 22 Jun 2022 10:40:19 -0400 Subject: [PATCH 2/7] Implement stake RPC --- src/popup/components/Protocol/StakeTokens.tsx | 16 +++++++++------- src/services/ProtocolService/index.ts | 6 +++++- 2 files changed, 14 insertions(+), 8 deletions(-) diff --git a/src/popup/components/Protocol/StakeTokens.tsx b/src/popup/components/Protocol/StakeTokens.tsx index cf6eae85..96c48bb4 100644 --- a/src/popup/components/Protocol/StakeTokens.tsx +++ b/src/popup/components/Protocol/StakeTokens.tsx @@ -48,7 +48,7 @@ export function StakeTokens(props: { onFinish: () => void }) { lockPeriod, amount, ); - setPage(); + setPage(); setLoading(false); }} onCancel={() => setPage("specify")} @@ -136,6 +136,7 @@ function Specify(props: { - Confirm Send + Confirm Stake

- Send {(props.amount / FCL_UNIT).toString()} FCL to + Stake{" "} + {(props.amount / FCL_UNIT).toString()} FCL for{" "} + {props.lockPeriod} blocks?

- {props.lockPeriod} - Send + Stake
@@ -213,12 +215,12 @@ const BreakStrong = styled.strong` text-align: center; `; -function SendComplete(props: { hash: string; onFinish: () => void }) { +function Complete(props: { hash: string; onFinish: () => void }) { return ( - Send Complete + Stake Complete

Transaction ID

diff --git a/src/services/ProtocolService/index.ts b/src/services/ProtocolService/index.ts index f5a820a2..0a6a89f2 100644 --- a/src/services/ProtocolService/index.ts +++ b/src/services/ProtocolService/index.ts @@ -365,7 +365,11 @@ export class ProtocolService { lockPeriod: number, amount: number|bigint, ): Promise { - throw new Error('Unimplemented!'); + 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> { From 8e6fb5f2992c0ca8b746b1a547bf405e4c38c32d Mon Sep 17 00:00:00 2001 From: Shelby Doolittle Date: Wed, 22 Jun 2022 11:29:51 -0400 Subject: [PATCH 3/7] Run formatter --- src/popup/components/Protocol/StakeTokens.tsx | 42 ++++++++++--------- src/services/ProtocolService/index.ts | 25 +++++++---- 2 files changed, 38 insertions(+), 29 deletions(-) diff --git a/src/popup/components/Protocol/StakeTokens.tsx b/src/popup/components/Protocol/StakeTokens.tsx index 96c48bb4..d51ee6b2 100644 --- a/src/popup/components/Protocol/StakeTokens.tsx +++ b/src/popup/components/Protocol/StakeTokens.tsx @@ -44,10 +44,7 @@ export function StakeTokens(props: { onFinish: () => void }) { amount={amount} onConfirm={async () => { setLoading(true); - const hash = await getProtocolService().stakeTokens( - lockPeriod, - amount, - ); + const hash = await getProtocolService().stakeTokens(lockPeriod, amount); setPage(); setLoading(false); }} @@ -90,7 +87,9 @@ function Specify(props: { onChangeAmount: (a: bigint) => void; onContinue: () => void; }) { - const lockPeriodOptionsLoader = useLoadedState(() => getProtocolService().lockPeriodOptions()); + const lockPeriodOptionsLoader = useLoadedState(() => + getProtocolService().lockPeriodOptions(), + ); if (!lockPeriodOptionsLoader.isLoaded) return null; const lockPeriodOptions = lockPeriodOptionsLoader.value; @@ -98,25 +97,29 @@ function Specify(props: { 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())); + 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 ( -

{thisPeriod} for {shares} shares

- - ); - }); + .sort((eA, eB) => eA[0] - eB[0]) + .map(([thisPeriod, shares]) => { + return ( + + ); + }); const validAmount = props.amount > BigInt(0); const validLock = lockPeriodOptions.has(props.lockPeriod); @@ -179,8 +182,7 @@ function Confirm(props: { Confirm Stake

- Stake{" "} - {(props.amount / FCL_UNIT).toString()} FCL for{" "} + Stake {(props.amount / FCL_UNIT).toString()} FCL for{" "} {props.lockPeriod} blocks?

diff --git a/src/services/ProtocolService/index.ts b/src/services/ProtocolService/index.ts index 0a6a89f2..d7d24e2d 100644 --- a/src/services/ProtocolService/index.ts +++ b/src/services/ProtocolService/index.ts @@ -362,24 +362,31 @@ export class ProtocolService { } async stakeTokens( - lockPeriod: number, - amount: number|bigint, - ): Promise { - const txn = await this.withApi(api => - api.tx.fractalStaking.stake(lockPeriod, amount)); + lockPeriod: number, + amount: number | bigint, + ): Promise { + const txn = await this.withApi((api) => + api.tx.fractalStaking.stake(lockPeriod, amount), + ); - const {hash} = await TxnWatcher.signAndSend(txn as any, this.requireSigner()).inBlock(); + const { hash } = await TxnWatcher.signAndSend( + txn as any, + this.requireSigner(), + ).inBlock(); return hash; } async lockPeriodOptions(): Promise> { const map = new Map(); - const query = await this.withApi( - (api) => api.query.fractalStaking.lockPeriodShares.entries()); + 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].replace(',', '')); + const lockPeriod = Number( + (lockPeriodKey.toHuman() as any)[0].replace(",", ""), + ); const shares = Number(sharesValue.toHuman()); map.set(lockPeriod, shares); } From c23ced6509d4c8514ba7c0ac5f317ea39c1cb256 Mon Sep 17 00:00:00 2001 From: Shelby Doolittle Date: Tue, 28 Jun 2022 17:12:38 -0400 Subject: [PATCH 4/7] Show period in human time --- package.json | 2 ++ src/popup/components/Protocol/StakeTokens.tsx | 31 +++++++++++++++++-- src/services/ProtocolService/index.ts | 2 +- yarn.lock | 12 ++++++- 4 files changed, 42 insertions(+), 5 deletions(-) diff --git a/package.json b/package.json index 80fcaac7..5ac734cd 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/popup/components/Protocol/StakeTokens.tsx b/src/popup/components/Protocol/StakeTokens.tsx index d51ee6b2..8558ad67 100644 --- a/src/popup/components/Protocol/StakeTokens.tsx +++ b/src/popup/components/Protocol/StakeTokens.tsx @@ -1,5 +1,7 @@ import styled from "styled-components"; import { useState } from "react"; +import humanizeDuration from "humanize-duration"; +import ReactTooltip from "react-tooltip"; import { useLoadedState } from "@utils/ReactHooks"; @@ -78,6 +80,12 @@ const LockPeriodSelectionContainer = styled.div` margin: 0; } } + + [data-tip] { + text-decoration: underline; + text-decoration-style: dotted; + cursor: help; + } `; function Specify(props: { @@ -115,7 +123,10 @@ function Specify(props: { onChange={() => props.onChangeLockPeriod(thisPeriod)} />

- {thisPeriod} for {shares} shares + {humanizeBlocks(thisPeriod)} for{" "} + + {shares} distribution shares +

); @@ -132,6 +143,10 @@ function Specify(props: { Stake Tokens + + TODO(nikoladmitroff): Provide text. + + {lockPeriodSelectionElements} @@ -155,8 +170,9 @@ function Specify(props: {

Will lock{" "} {(props.amount / FCL_UNIT).toString()} FCL for{" "} - {props.lockPeriod} blocks, receiving{" "} - {lockPeriodOptions.get(props.lockPeriod)} shares. + {humanizeBlocks(props.lockPeriod)}, receiving{" "} + {lockPeriodOptions.get(props.lockPeriod)}{" "} + distribution shares.

)} @@ -168,6 +184,15 @@ function Specify(props: { ); } +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; diff --git a/src/services/ProtocolService/index.ts b/src/services/ProtocolService/index.ts index d7d24e2d..b7d8083b 100644 --- a/src/services/ProtocolService/index.ts +++ b/src/services/ProtocolService/index.ts @@ -385,7 +385,7 @@ export class ProtocolService { for (const [lockPeriodKey, sharesValue] of query) { const lockPeriod = Number( - (lockPeriodKey.toHuman() as any)[0].replace(",", ""), + (lockPeriodKey.toHuman() as any)[0].replaceAll(",", ""), ); const shares = Number(sharesValue.toHuman()); map.set(lockPeriod, shares); diff --git a/yarn.lock b/yarn.lock index ea3bffe7..7a1dee08 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1153,7 +1153,7 @@ dependencies: regenerator-runtime "^0.13.4" -"@babel/runtime@^7.13.10", "@babel/runtime@^7.15.3", "@babel/runtime@^7.15.4": +"@babel/runtime@^7.13.10": version "7.15.4" resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.15.4.tgz#fd17d16bfdf878e6dd02d19753a39fa8a8d9c84a" integrity sha512-99catp6bHCaxr4sJ/DbTGgHS4+Rs2RVd2g7iOap6SLGPDknRK9ztKNsE/Fg6QhSeh1FGE5f6gHGQmvvn3I3xhw== @@ -2666,6 +2666,11 @@ resolved "https://registry.npmjs.org/@types/html-minifier-terser/-/html-minifier-terser-5.1.2.tgz" integrity sha512-h4lTMgMJctJybDp8CQrxTUiiYmedihHWkjnF/8Pxseu2S6Nlfcy8kwboQ8yejh456rP2yWoEVm1sS/FVsfM48w== +"@types/humanize-duration@^3.27.1": + version "3.27.1" + resolved "https://registry.yarnpkg.com/@types/humanize-duration/-/humanize-duration-3.27.1.tgz#f14740d1f585a0a8e3f46359b62fda8b0eaa31e7" + integrity sha512-K3e+NZlpCKd6Bd/EIdqjFJRFHbrq5TzPPLwREk5Iv/YoIjQrs6ljdAUCo+Lb2xFlGNOjGSE0dqsVD19cZL137w== + "@types/istanbul-lib-coverage@*", "@types/istanbul-lib-coverage@^2.0.0", "@types/istanbul-lib-coverage@^2.0.1": version "2.0.3" resolved "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.3.tgz" @@ -6630,6 +6635,11 @@ human-signals@^1.1.1: resolved "https://registry.npmjs.org/human-signals/-/human-signals-1.1.1.tgz" integrity sha512-SEQu7vl8KjNL2eoGBLF3+wAjpsNfA9XMlXAYj/3EdaNfAlxKthD1xjEQfGOUhllCGGJVNY34bRr6lPINhNjyZw== +humanize-duration@^3.27.2: + version "3.27.2" + resolved "https://registry.yarnpkg.com/humanize-duration/-/humanize-duration-3.27.2.tgz#4b4e565bec098d22c9a54344e16156d1c649f160" + integrity sha512-A15OmA3FLFRnehvF4ZMocsxTZYvHq4ze7L+AgR1DeHw0xC9vMd4euInY83uqGU9/XXKNnVIEeKc1R8G8nKqtzg== + iconv-lite@0.4.24: version "0.4.24" resolved "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz" From 35eb2d307fe83080ac9c42473abd7d0ab910ab43 Mon Sep 17 00:00:00 2001 From: Shelby Doolittle Date: Tue, 28 Jun 2022 17:32:02 -0400 Subject: [PATCH 5/7] Don't show staking button when staking isn't available --- .../components/Protocol/DataScreen/index.tsx | 29 ++++++++++++------- src/services/ProtocolService/index.ts | 4 +++ 2 files changed, 22 insertions(+), 11 deletions(-) diff --git a/src/popup/components/Protocol/DataScreen/index.tsx b/src/popup/components/Protocol/DataScreen/index.tsx index afc92aaa..f5d171be 100644 --- a/src/popup/components/Protocol/DataScreen/index.tsx +++ b/src/popup/components/Protocol/DataScreen/index.tsx @@ -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, @@ -103,6 +105,8 @@ function DataScreen() { const [wallet, setWallet] = useState(); const { updater: activityStack } = useContext(ActivityStackContext); + const canStake = useLoadedState(() => getProtocolService().canStake()); + useEffect(() => { (async () => { const mnemonic = await getProtocolOptIn().getMnemonic(); @@ -128,15 +132,18 @@ function DataScreen() { > Send FCL - + + {!canStake.unwrapOrDefault(false) ? null : ( + + )}
); diff --git a/src/services/ProtocolService/index.ts b/src/services/ProtocolService/index.ts index b7d8083b..23c98a02 100644 --- a/src/services/ProtocolService/index.ts +++ b/src/services/ProtocolService/index.ts @@ -393,6 +393,10 @@ export class ProtocolService { return map; } + + async canStake(): Promise { + return await this.withApi((api) => api.tx.fractalStaking != null); + } } class BlockNumberOutsideRange extends Error { From da501e715a949e2ae57815bc9a67345271eaf8ad Mon Sep 17 00:00:00 2001 From: Nikola Dimitroff Date: Wed, 6 Jul 2022 08:26:33 +0300 Subject: [PATCH 6/7] Added distribution shares text --- src/popup/components/Protocol/StakeTokens.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/popup/components/Protocol/StakeTokens.tsx b/src/popup/components/Protocol/StakeTokens.tsx index 8558ad67..2fc76323 100644 --- a/src/popup/components/Protocol/StakeTokens.tsx +++ b/src/popup/components/Protocol/StakeTokens.tsx @@ -144,7 +144,7 @@ function Specify(props: { - TODO(nikoladmitroff): Provide text. + 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. {lockPeriodSelectionElements} From 6ce8d2abea9586debc93cbe280b53e8565d59954 Mon Sep 17 00:00:00 2001 From: Shelby Doolittle Date: Mon, 11 Jul 2022 10:43:25 -0400 Subject: [PATCH 7/7] Run formatter --- src/popup/components/Protocol/StakeTokens.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/popup/components/Protocol/StakeTokens.tsx b/src/popup/components/Protocol/StakeTokens.tsx index 2fc76323..42249780 100644 --- a/src/popup/components/Protocol/StakeTokens.tsx +++ b/src/popup/components/Protocol/StakeTokens.tsx @@ -144,7 +144,9 @@ function Specify(props: { - 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. + 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. {lockPeriodSelectionElements}