diff --git a/src/api/contracts/subsquid.generated.ts b/src/api/contracts/subsquid.generated.ts index e3a81cb..9ff2a35 100644 --- a/src/api/contracts/subsquid.generated.ts +++ b/src/api/contracts/subsquid.generated.ts @@ -402,6 +402,19 @@ export const softCapAbi = [ ////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// export const stakingAbi = [ + { + type: 'function', + inputs: [ + { name: 'staker', internalType: 'address', type: 'address' }, + { name: 'worker', internalType: 'uint256', type: 'uint256' }, + ], + name: 'getDeposit', + outputs: [ + { name: 'depositAmount', internalType: 'uint256', type: 'uint256' }, + { name: 'withdrawAllowed', internalType: 'uint256', type: 'uint256' }, + ], + stateMutability: 'view', + }, { type: 'function', inputs: [ @@ -1144,6 +1157,14 @@ export const useReadStaking = /*#__PURE__*/ createUseReadContract({ abi: stakingAbi, }) +/** + * Wraps __{@link useReadContract}__ with `abi` set to __{@link stakingAbi}__ and `functionName` set to `"getDeposit"` + */ +export const useReadStakingGetDeposit = /*#__PURE__*/ createUseReadContract({ + abi: stakingAbi, + functionName: 'getDeposit', +}) + /** * Wraps __{@link useReadContract}__ with `abi` set to __{@link stakingAbi}__ and `functionName` set to `"claimable"` */ diff --git a/src/api/subsquid-network-squid/useFixWorkers.ts b/src/api/subsquid-network-squid/fixes.ts similarity index 59% rename from src/api/subsquid-network-squid/useFixWorkers.ts rename to src/api/subsquid-network-squid/fixes.ts index 78b21a7..3bfd987 100644 --- a/src/api/subsquid-network-squid/useFixWorkers.ts +++ b/src/api/subsquid-network-squid/fixes.ts @@ -1,12 +1,15 @@ import { useMemo } from 'react'; import { getBlockTime } from '@lib/network'; +import { Simplify } from 'type-fest'; import { useBlock, useReadContracts } from 'wagmi'; import { Worker, WorkerStatus } from '@api/subsquid-network-squid'; import { useContracts } from '@network/useContracts'; import { + stakingAbi, + useReadRouterStaking, useReadRouterWorkerRegistration, useReadWorkerRegistryLockPeriod, workerRegistryAbi, @@ -83,7 +86,7 @@ export function useFixWorkers>({ locked: true, unlockedAt: new Date( timestamp + getBlockTime(unlockBlock - lastL1Block.number), - ).toString(), + ).toISOString(), } : { locked: false }; @@ -106,3 +109,68 @@ export function useFixWorkers>({ data, }; } + +export function useFixDelegations< + T extends { id: string; delegations: { owner: { id: string } }[] }, +>({ workers }: { workers?: T[] }) { + const { ROUTER, CHAIN_ID_L1 } = useContracts(); + + const { data: stakingAddress, isLoading: isStakingAddressLoading } = useReadRouterStaking({ + address: ROUTER, + query: { enabled: !!ROUTER }, + }); + + const { data: lastL1Block, isLoading: isLastL1BlockLoading } = useBlock({ + chainId: CHAIN_ID_L1, + includeTransactions: false, + }); + + const { data: delegationsInfo, isLoading: isDelegationsInfoLoading } = useReadContracts({ + contracts: workers?.flatMap(worker => + worker.delegations.map( + delegation => + ({ + abi: stakingAbi, + address: stakingAddress || '0x', + functionName: 'getDeposit', + args: [delegation.owner.id as `0x${string}`, worker.id], + }) as const, + ), + ), + allowFailure: false, + query: { enabled: !!workers && !!stakingAddress }, + }); + + type R = Simplify< + Omit & { + delegations: Simplify[]; + } + >; + + const data = useMemo(() => { + if (!delegationsInfo || !lastL1Block || !workers) return workers; + + let index = 0; + return workers.map( + worker => + ({ + ...worker, + delegations: worker.delegations.map(delegation => { + const [, unlockBlock] = delegationsInfo[index++]; + const timestamp = Number(lastL1Block.timestamp) * 1000; + const locked = lastL1Block.number < unlockBlock; + const unlockedAt = locked + ? new Date(timestamp + getBlockTime(unlockBlock - lastL1Block.number)).toISOString() + : undefined; + + return { ...delegation, locked, unlockedAt }; + }), + }) as R, + ); + }, [delegationsInfo, lastL1Block, workers]); + + return { + data, + isLoading: isDelegationsInfoLoading || isLastL1BlockLoading || isStakingAddressLoading, + }; +} diff --git a/src/api/subsquid-network-squid/workers-graphql.ts b/src/api/subsquid-network-squid/workers-graphql.ts index f70781e..683f395 100644 --- a/src/api/subsquid-network-squid/workers-graphql.ts +++ b/src/api/subsquid-network-squid/workers-graphql.ts @@ -8,6 +8,7 @@ import { PartialDeep, SimplifyDeep } from 'type-fest'; import { useAccount } from '@network/useAccount.ts'; import { useSquid } from './datasource'; +import { useFixDelegations, useFixWorkers } from './fixes'; import { AccountType, Delegation, @@ -21,7 +22,6 @@ import { Worker, } from './graphql'; import { useNetworkSettings } from './settings-graphql'; -import { useFixWorkers } from './useFixWorkers'; // inherit API interface for internal class // export interface BlockchainApiWorker extends Omit { @@ -381,10 +381,14 @@ export function useMyDelegations({ sortBy, sortDir }: { sortBy: WorkerSortBy; so { address: address || '0x' }, ); - const { data: fixedDelegations, isLoading: isFixedDelegationsLoading } = useFixWorkers({ + const { data: fixedWorkers, isLoading: isFixedWorkersLoading } = useFixWorkers({ workers: delegationsQuery?.workers, }); + const { data: fixedDelegations, isLoading: isFixedDelegationsLoading } = useFixDelegations({ + workers: fixedWorkers, + }); + const data = useMemo(() => { type W = SimplifyDeep< Pick< @@ -402,6 +406,7 @@ export function useMyDelegations({ sortBy, sortDir }: { sortBy: WorkerSortBy; so Pick & { delegations: (Pick & { owner: { id: string; type: AccountType }; + unlockedAt?: string; })[]; } >; @@ -434,12 +439,7 @@ export function useMyDelegations({ sortBy, sortDir }: { sortBy: WorkerSortBy; so .toFixed(); worker.delegations.push({ - deposit: d.deposit, - locked: d.locked, - owner: { - id: d.owner.id, - type: d.owner.type, - }, + ...d, }); }); @@ -450,7 +450,11 @@ export function useMyDelegations({ sortBy, sortDir }: { sortBy: WorkerSortBy; so }, [fixedDelegations, sortBy, sortDir]); return { - isLoading: isSettingsLoading || isDelegationsQueryLoading || isFixedDelegationsLoading, + isLoading: + isSettingsLoading || + isDelegationsQueryLoading || + isFixedWorkersLoading || + isFixedDelegationsLoading, data, }; } @@ -536,7 +540,7 @@ export function useMyWorkerDelegations({ }) { const { address } = useAccount(); const datasource = useSquid(); - const { data, isLoading } = useMyDelegationsQuery( + const { data: delegations, isLoading: isDelegationsLoading } = useMyDelegationsQuery( datasource, { workerId: peerId || '', @@ -544,14 +548,18 @@ export function useMyWorkerDelegations({ }, { select: res => { - return res.workers[0]?.delegations || []; + return res.workers; }, enabled, }, ); + const { data: fixedDelegations, isLoading: isFixedDelegationsLoading } = useFixDelegations({ + workers: delegations, + }); + return { - isLoading: isLoading, - data, + isLoading: isDelegationsLoading || isFixedDelegationsLoading, + data: fixedDelegations?.[0]?.delegations, }; } diff --git a/src/pages/DelegationsPage/DelegationsPage.tsx b/src/pages/DelegationsPage/DelegationsPage.tsx index 66ab0f8..3eb83cb 100644 --- a/src/pages/DelegationsPage/DelegationsPage.tsx +++ b/src/pages/DelegationsPage/DelegationsPage.tsx @@ -103,6 +103,7 @@ export function MyDelegations() { type: d.owner.type, balance: d.deposit, locked: d.locked || false, + unlockedAt: d.unlockedAt || '', }))} disabled={!worker.delegations.some(d => !d.locked)} /> diff --git a/src/pages/WorkersPage/Worker.tsx b/src/pages/WorkersPage/Worker.tsx index 3a12471..2c86131 100644 --- a/src/pages/WorkersPage/Worker.tsx +++ b/src/pages/WorkersPage/Worker.tsx @@ -117,6 +117,8 @@ export const Worker = ({ backPath }: { backPath: string }) => { type: d.owner.type, balance: d.deposit, locked: d.locked || false, + // FIXME: some issue with types + unlockedAt: (d as any).unlockedAt, }))} disabled={isLoading || !delegations?.some(d => !d.locked)} /> @@ -272,7 +274,12 @@ export const Worker = ({ backPath }: { backPath: string }) => { worker.status === WorkerStatus.Deregistering ? ( ) : ( diff --git a/src/pages/WorkersPage/WorkerUndelegate.tsx b/src/pages/WorkersPage/WorkerUndelegate.tsx index cc7aba0..cdb0900 100644 --- a/src/pages/WorkersPage/WorkerUndelegate.tsx +++ b/src/pages/WorkersPage/WorkerUndelegate.tsx @@ -14,13 +14,7 @@ import { useDebounce } from 'use-debounce'; import { stakingAbi, useReadRouterStaking } from '@api/contracts'; import { useWriteSQDTransaction } from '@api/contracts/useWriteTransaction'; import { errorMessage } from '@api/contracts/utils'; -import { - Account, - AccountType, - Delegation, - SourceWalletWithBalance, - Worker, -} from '@api/subsquid-network-squid'; +import { AccountType, SourceWalletWithBalance, Worker } from '@api/subsquid-network-squid'; import { ContractCallDialog } from '@components/ContractCallDialog'; import { Form, FormDivider, FormikSelect, FormikTextInput, FormRow } from '@components/Form'; import { HelpTooltip } from '@components/HelpTooltip'; @@ -32,6 +26,7 @@ import { EXPECTED_APR_TIP, useExpectedAprAfterDelegation } from './WorkerDelegat export type SourceWalletWithDelegation = SourceWalletWithBalance & { locked: boolean; + unlockedAt?: string; }; export const undelegateSchema = yup.object({ @@ -52,21 +47,89 @@ export function WorkerUndelegate({ sources, }: { sources?: SourceWalletWithDelegation[]; - worker?: Pick & { - delegations: (Pick & { - owner: Pick; - unlockedAt?: string; - })[]; - }; + worker?: Pick; disabled?: boolean; }) { const [open, setOpen] = useState(false); - const handleOpen = (e: React.UIEvent) => { - e.stopPropagation(); - setOpen(true); - }; - const handleClose = () => setOpen(false); + const isSourceDisabled = (source: SourceWalletWithDelegation) => + source.balance === '0' || source.locked; + + const isLocked = useMemo(() => !!sources?.length && !sources?.some(d => !d.locked), [sources]); + + const [curTimestamp] = useDebounce(Date.now(), 1000); + const { unlockedAt, timeLeft } = useMemo(() => { + const min = sources?.reduce( + (r, d) => { + if (!d.unlockedAt) return r; + + const ms = new Date(d.unlockedAt).getTime(); + return !r || ms < r ? ms : r; + }, + undefined as number | undefined, + ); + + return { + unlockedAt: min ? new Date(min).toISOString() : undefined, + timeLeft: min ? relativeDateFormat(curTimestamp, min) : undefined, + }; + }, [curTimestamp, sources]); + + return ( + <> + + {isLocked && ( + + + + )} + setOpen(true)} + variant="outlined" + color="error" + disabled={disabled || isLocked} + > + UNDELEGATE + + + setOpen(false)} + worker={worker} + sources={sources} + isSourceDisabled={isSourceDisabled} + /> + + ); +} + +function WorkerUndelegateDialog({ + open, + onClose, + worker, + sources, + isSourceDisabled, +}: { + open: boolean; + onClose: () => void; + worker?: Pick; + sources?: SourceWalletWithDelegation[]; + isSourceDisabled: (source: SourceWalletWithDelegation) => boolean; +}) { const { setWaitHeight } = useSquidHeight(); const contracts = useContracts(); @@ -76,9 +139,6 @@ export function WorkerUndelegate({ address: contracts.ROUTER, }); - const isSourceDisabled = (source: SourceWalletWithDelegation) => - source.balance === '0' || source.locked; - const initialValues = useMemo(() => { const source = sources?.find(c => !isSourceDisabled(c)) || sources?.[0]; @@ -87,7 +147,7 @@ export function WorkerUndelegate({ amount: '0', max: fromSqd(source?.balance).toString(), }; - }, [sources]); + }, [sources, isSourceDisabled]); const formik = useFormik({ initialValues, @@ -118,7 +178,7 @@ export function WorkerUndelegate({ }); setWaitHeight(receipt.blockNumber, []); - handleClose(); + onClose(); } catch (e) { toast.error(errorMessage(e)); } @@ -132,130 +192,74 @@ export function WorkerUndelegate({ enabled: open && !!worker, }); - // const source = useMemo(() => { - // if (!worker) return; - - // return ( - // (formik.values.source - // ? worker?.delegations.find(c => c.owner.id === formik.values.source) - // : worker?.delegations.find(c => fromSqd(c.deposit).gte(0))) || worker?.delegations?.[0] - // ); - // }, [formik.values.source, worker]); - - // const canUndelegate = useMemo(() => { - // return !!worker?.delegations.some(d => !d.locked && BigNumber(d.deposit).gt(0)); - // }, [worker?.delegations]); - - const isLocked = useMemo( - () => !!worker?.delegations.length && !worker.delegations.some(d => !d.locked), - [worker], - ); - const unlockedAt = useMemo(() => { - return ''; - }, []); - return ( - <> - - {isLocked && ( - - - - )} - setOpen(true)} - variant="outlined" - color="error" - disabled={disabled || isLocked} - > - UNDELEGATE - - - { - if (!confirmed) return handleClose(); + { + if (!confirmed) return onClose(); - formik.handleSubmit(); - }} - confirmColor="error" - > -
- - { - return { - label: , - value: s.id, - disabled: isSourceDisabled(s), - }; - }) || [] - } - id="source" - formik={formik} - onChange={e => { - const wallet = worker?.delegations.find(s => s.owner.id === e.target.value); - if (!wallet) return; + formik.handleSubmit(); + }} + confirmColor="error" + > + + + { + return { + label: , + value: s.id, + disabled: isSourceDisabled(s), + }; + }) || [] + } + id="source" + formik={formik} + onChange={e => { + const wallet = sources?.find(s => s.id === e.target.value); + if (!wallet) return; - formik.setFieldValue('source', wallet.owner.id); - formik.setFieldValue('max', fromSqd(wallet.deposit).toFixed()); - }} - /> - - - { - formik.setValues({ - ...formik.values, - amount: formik.values.max, - }); - }} - label="Max" - /> - ), - }} - /> - - - - Expected APR - - {isExpectedAprPending ? '-' : percentFormatter(stakerApr)} - - - -
- + formik.setFieldValue('source', wallet.id); + formik.setFieldValue('max', fromSqd(wallet.balance).toFixed()); + }} + /> + + + { + formik.setValues({ + ...formik.values, + amount: formik.values.max, + }); + }} + label="Max" + /> + ), + }} + /> + + + + Expected APR + + {isExpectedAprPending ? '-' : percentFormatter(stakerApr)} + + + +
); } diff --git a/src/pages/WorkersPage/WorkerWithdraw.tsx b/src/pages/WorkersPage/WorkerWithdraw.tsx index db1590a..279035e 100644 --- a/src/pages/WorkersPage/WorkerWithdraw.tsx +++ b/src/pages/WorkersPage/WorkerWithdraw.tsx @@ -24,10 +24,11 @@ export function WorkerWithdrawButton({ sx, }: { sx?: SxProps; - worker: Pick & { + worker: Pick; + source: SourceWallet & { + locked: boolean; unlockedAt?: string; }; - source: SourceWallet; disabled?: boolean; }) { const [open, setOpen] = useState(false); @@ -35,18 +36,19 @@ export function WorkerWithdrawButton({ return ( <> - {worker.locked && ( + {source.locked && ( setOpen(true)} variant="outlined" color="error" - disabled={disabled || worker.locked} + disabled={disabled || source.locked} > WITHDRAW diff --git a/src/pages/WorkersPage/WorkersPage.tsx b/src/pages/WorkersPage/WorkersPage.tsx index f0418e1..217e68b 100644 --- a/src/pages/WorkersPage/WorkersPage.tsx +++ b/src/pages/WorkersPage/WorkersPage.tsx @@ -120,7 +120,12 @@ export function MyWorkers() { worker.status === WorkerStatus.Deregistering ? ( ) : ( diff --git a/src/theme/theme.tsx b/src/theme/theme.tsx index 0441fd2..f702fdb 100644 --- a/src/theme/theme.tsx +++ b/src/theme/theme.tsx @@ -445,6 +445,7 @@ export const useCreateTheme = (mode: PaletteType) => { // fontFamily, }, + borderRadius: 4, backgroundColor: colors.background.paper, }, inputHiddenLabel: { diff --git a/wagmi.config.ts b/wagmi.config.ts index 0120876..cd985f2 100644 --- a/wagmi.config.ts +++ b/wagmi.config.ts @@ -190,6 +190,19 @@ export default defineConfig({ { name: 'Staking', abi: [ + { + type: 'function', + name: 'getDeposit', + inputs: [ + { name: 'staker', type: 'address', internalType: 'address' }, + { name: 'worker', type: 'uint256', internalType: 'uint256' }, + ], + outputs: [ + { name: 'depositAmount', type: 'uint256', internalType: 'uint256' }, + { name: 'withdrawAllowed', type: 'uint256', internalType: 'uint256' }, + ], + stateMutability: 'view', + }, { type: 'function', name: 'deposit',