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/DataScreen/index.tsx b/src/popup/components/Protocol/DataScreen/index.tsx index 2a267258..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, @@ -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 @@ -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(); const { updater: activityStack } = useContext(ActivityStackContext); + const canStake = useLoadedState(() => getProtocolService().canStake()); + useEffect(() => { (async () => { const mnemonic = await getProtocolOptIn().getMnemonic(); @@ -108,15 +122,29 @@ function DataScreen() {
- + + + + {!canStake.unwrapOrDefault(false) ? null : ( + + )} + ); } diff --git a/src/popup/components/Protocol/StakeTokens.tsx b/src/popup/components/Protocol/StakeTokens.tsx new file mode 100644 index 00000000..42249780 --- /dev/null +++ b/src/popup/components/Protocol/StakeTokens.tsx @@ -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(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; + } + } + + [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 ( + + ); + }); + + const validAmount = props.amount > BigInt(0); + const validLock = lockPeriodOptions.has(props.lockPeriod); + const isValid = validAmount && validLock; + + return ( + + + + Stake Tokens + + + + 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} + + + + { + props.onChangeAmount(BigInt(e.target.value) * FCL_UNIT); + }} + /> + + + {!isValid ? null : ( +

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

+ )} + + + Stake + +
+
+ ); +} + +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 ( + + + + Confirm Stake + +

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

+ + + + + Stake + + +
+
+ ); +} + +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 ( + + + + Stake Complete + +

Transaction ID

+

+ {props.hash} +

+ + Return +
+
+ ); +} diff --git a/src/services/ProtocolService/index.ts b/src/services/ProtocolService/index.ts index 8e753f8e..23c98a02 100644 --- a/src/services/ProtocolService/index.ts +++ b/src/services/ProtocolService/index.ts @@ -360,6 +360,43 @@ export class ProtocolService { ); return new Date(timestamp.toNumber()); } + + async stakeTokens( + 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(); + return hash; + } + + 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].replaceAll(",", ""), + ); + const shares = Number(sharesValue.toHuman()); + map.set(lockPeriod, shares); + } + + return map; + } + + async canStake(): Promise { + return await this.withApi((api) => api.tx.fractalStaking != null); + } } class BlockNumberOutsideRange extends Error { 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"