diff --git a/frontend/components/modals/LlcModal.tsx b/frontend/components/modals/LlcModal.tsx new file mode 100644 index 00000000..525432b2 --- /dev/null +++ b/frontend/components/modals/LlcModal.tsx @@ -0,0 +1,194 @@ +import Spinner from '@components/Spinner' +import CloseIcon from '@components/icons/CloseIcon' +import { Transition, Dialog } from '@headlessui/react' +import { Fragment } from 'react' + +type LlcModalProps = { + isLlcModalOpen: boolean + setIsLlcModalOpen: (open: boolean) => void + onSignLlc: () => void + isSigning?: boolean +} +export function LlcModal({ + isSigning, + isLlcModalOpen, + setIsLlcModalOpen, + onSignLlc, +}: LlcModalProps) { + return ( + <> + {/* TODO: refactor base modal to follow composition approach */} + + setIsLlcModalOpen(false)} + > + +
+ +
+
+ + + +
+ + JOINDER AGREEMENT TO OPERATING AGREEMENT OF PYTH DAO LLC + +
+

+ This Joinder Agreement to the Operating Agreement (this + “Joinder”) is made and entered into as + of the date hereof (the “Effective Date + ”) by and between PYTH DAO LLC, a non-profit limited + liability company incorporated as per the laws of + Republic of the Marshall Islands (the “ + Company”) and you (the “ + Joining Party”). Capitalized terms used + but not defined in this Joinder shall have the + respective meanings ascribed to such terms in the + Operating Agreement (as defined below). +

+

+ The Joining Party acknowledges that: +

+

+ The Members of the Company have entered into that + {/* TODO: put the right date here */} + certain Operating Agreement, dated as of [__] (the “ + Operating Agreement”), a copy of which + is available at the following link:{' '} + + Operating Agreement + + .{' '} +

+

+ The Joining Party desires to become a Member of the + Company, pursuant to the terms and conditions set forth + in the Operating Agreement. +

+

+ Pursuant to the Article III of the Operating Agreement, + the Joining Party intends to acquire a Membership + Interest in the Company by staking PYTH SPL Tokens in + the Pyth Staking Program. +

+

+ Pursuant to Article IX of the Operating Agreement, any + prospective member may become a Member automatically by + acquiring a Membership Interest as described in Article + III of the Operating Agreement and upon signing an + agreement (including electronically via their wallet + address) stating, among other things that they agree to + become a Member of the Company and be bound by the terms + of the Operating Agreement. +

+

+ AGREEMENT +

+

+ NOW, THEREFORE, in consideration of the mutual covenants + and agreements herein contained, and other good and + valuable consideration, the receipt and sufficiency of + which are hereby acknowledged, the Joining Party and the + Company agree as follows: +

+

+ Joinder. The Joining Party hereby agrees to (i) + become a Member, pursuant to Articles III and IX of the + Operating Agreement, and (ii) be bound by and adhere to + the terms and conditions of the Operating Agreement. +

+

+ Disclaimer. The Joining Party has had the + opportunity to consult with its own tax adviser and + counsel to discuss and understand any tax and other + legal consequences of acquisition, ownership and + disposition of the Tokens, and any voting of their + Membership Interests. +

+

+ Representations and Warranties of Joining Party. + The Joining Party hereby represents and warrants to + Company as follows: +

+

+ Authorization. The Joining Party has full power + and authority and, with respect to any individual, the + capacity to enter into this Joinder. This Joinder when + executed and delivered by Joining Party, will constitute + valid and legally binding obligations of the Joining + Party, enforceable in accordance with its terms. +

+

+ Restricted Persons. The Joining Party is not a + resident of Iran, Syria, Cuba, North Korea, or the + Crimea, Donetsk, or Luhansk regions of the Ukraine or + any other regions and jurisdictions, as updated per RMI + government guidelines and set forth in the Pyth + Governance Program. +

+

+ Miscellaneous. +

+

+ Governing Law. This Joinder is governed by and + shall be construed in accordance with the laws of the + Republic of the Marshall Islands without regard to the + conflicts of law principles thereof. +

+

+ Amendment. Any provision of this Joinder may be + amended only by a written agreement executed by you and + the Company. +

+
+ + +
+
+
+
+
+
+
+ + ) +} diff --git a/frontend/components/modals/LockedModal.tsx b/frontend/components/modals/LockedModal.tsx index b4f8545d..f8466fa0 100644 --- a/frontend/components/modals/LockedModal.tsx +++ b/frontend/components/modals/LockedModal.tsx @@ -12,6 +12,9 @@ import { useBalance } from 'hooks/useBalance' import { useNextVestingEvent } from 'hooks/useNextVestingEvent' import { useStakeConnection } from 'hooks/useStakeConnection' import { MainStakeAccount } from 'pages' +import { LlcModal } from './LlcModal' +import { useCallback, useState } from 'react' +import toast from 'react-hot-toast' export type LockedModalProps = { isLockedModalOpen: boolean @@ -188,6 +191,20 @@ function LockedModalButton({ const unvestedPreUnlockAll = useUnvestedPreUnlockAllMutation() const unvestedUnlockAll = useUnvestedUnlockAllMutation() + const [isLlcModalOpen, setIsLlcModalOpen] = useState(false) + + const onStakeAll = useCallback(async () => { + if (mainStakeAccount === 'NA' || mainStakeAccount === undefined) return + const isLlcMember = stakeConnection!.isLlcMember(mainStakeAccount) + + if (isLlcMember === true) + unvestedLockAll.mutate({ + mainStakeAccount: mainStakeAccount, + stakeConnection: stakeConnection!, + }) + else setIsLlcModalOpen(true) + }, [stakeConnection, mainStakeAccount]) + if (mainStakeAccount === 'NA') return <> switch (currentVestingAccountState) { @@ -234,12 +251,7 @@ function LockedModalButton({ + + { + // Once the user clicks sign llc + // Sign and stake will happen in the same transaction + unvestedLockAll.mutate({ + mainStakeAccount: mainStakeAccount as StakeAccount, + stakeConnection: stakeConnection!, + }) + setIsLlcModalOpen(false) + }} + /> ) } diff --git a/frontend/components/panels/StakePanel.tsx b/frontend/components/panels/StakePanel.tsx index f8eb861c..04e06608 100644 --- a/frontend/components/panels/StakePanel.tsx +++ b/frontend/components/panels/StakePanel.tsx @@ -16,6 +16,8 @@ import { } from './components' import { isSufficientBalance as isSufficientBalanceFn } from 'utils/isSufficientBalance' import { WalletModalButton } from '@components/WalletModalButton' +import { LlcModal } from '@components/modals/LlcModal' +import toast from 'react-hot-toast' type StakePanelProps = { mainStakeAccount: MainStakeAccount @@ -40,19 +42,36 @@ export function StakePanel({ mainStakeAccount }: StakePanelProps) { const [amount, setAmount] = useState('') - const deposit = (amount: string) => - // we are disabling actions when mainStakeAccount is undefined - // or stakeConnection is undefined - depositMutation.mutate({ - amount, - // If mainStakeAccount is undefined this action is disabled - // undefined means that the mainStakeAccount is loading. - // If we execute this action, this will work. But it will create a - // new stake account for the user. - mainStakeAccount: mainStakeAccount as StakeAccount | 'NA', - // action is disabled below if these is undefined - stakeConnection: stakeConnection!, - }) + const deposit = useCallback( + (amount: string) => + // we are disabling actions when mainStakeAccount is undefined + // or stakeConnection is undefined + depositMutation.mutate({ + amount, + // If mainStakeAccount is undefined this action is disabled + // undefined means that the mainStakeAccount is loading. + // If we execute this action, this will work. But it will create a + // new stake account for the user. + mainStakeAccount: mainStakeAccount as StakeAccount | 'NA', + // action is disabled below if these is undefined + stakeConnection: stakeConnection!, + }), + [depositMutation.mutate, mainStakeAccount, stakeConnection] + ) + + const [isLlcModalOpen, setIsLlcModalOpen] = useState(false) + + // This only executes if deposit action is enabled + const onAction = useCallback(() => { + if (mainStakeAccount === 'NA') setIsLlcModalOpen(true) + else { + const isLlcMember = stakeConnection!.isLlcMember(mainStakeAccount!) + + if (isLlcMember === true) deposit(amount) + else setIsLlcModalOpen(true) + } + }, [deposit, amount, stakeConnection, mainStakeAccount]) + const isSufficientBalance = isSufficientBalanceFn(amount, pythBalance) // set amount when input changes @@ -88,7 +107,7 @@ export function StakePanel({ mainStakeAccount }: StakePanelProps) { ) : ( deposit(amount)} + onAction={onAction} isActionDisabled={ !isSufficientBalance || // if mainStakeAccount is undefined, the action should be disabled @@ -107,6 +126,17 @@ export function StakePanel({ mainStakeAccount }: StakePanelProps) { /> )} + { + // Once the user clicks sign llc agreement + // Sign and deposit will happen in the same transaction + // We are handling the loading in the panel itself. + deposit(amount) + setIsLlcModalOpen(false) + }} + /> ) diff --git a/frontend/hooks/useJoinDaoLlcMutation.ts b/frontend/hooks/useJoinDaoLlcMutation.ts new file mode 100644 index 00000000..41471071 --- /dev/null +++ b/frontend/hooks/useJoinDaoLlcMutation.ts @@ -0,0 +1,34 @@ +import { StakeAccount, StakeConnection } from '@pythnetwork/staking' +import toast from 'react-hot-toast' +import { useMutation, useQueryClient } from 'react-query' +import { StakeConnectionQueryKey } from './useStakeConnection' + +export function useJoinDaoLlcMutation() { + const queryClient = useQueryClient() + + return useMutation( + ['sign-llc-mutation'], + async ({ + stakeConnection, + mainStakeAccount, + }: { + stakeConnection: StakeConnection + mainStakeAccount: StakeAccount + }) => { + await stakeConnection.joinDaoLlc(mainStakeAccount) + toast.success(`Successfully signed LLC agreement!`) + }, + { + onSuccess() { + // invalidate all except stake connection + queryClient.invalidateQueries({ + predicate: (query) => query.queryKey[0] !== StakeConnectionQueryKey, + }) + }, + + onError(error: Error) { + toast.error(error.message) + }, + } + ) +} diff --git a/frontend/pages/index.tsx b/frontend/pages/index.tsx index 477980ab..d866c671 100644 --- a/frontend/pages/index.tsx +++ b/frontend/pages/index.tsx @@ -22,6 +22,8 @@ import { StakePanel } from '@components/panels/StakePanel' import { UnstakePanel } from '@components/panels/UnstakePanel' import { WithdrawPanel } from '@components/panels/WithdrawPanel' import { useStakeConnection } from 'hooks/useStakeConnection' +import { LlcModal } from '@components/modals/LlcModal' +import { useJoinDaoLlcMutation } from 'hooks/useJoinDaoLlcMutation' enum TabEnum { Stake, @@ -50,7 +52,8 @@ const Staking: NextPage = () => { const wallet = useAnchorWallet() const isWalletConnected = wallet !== undefined - const { isLoading: isStakeConnectionLoading } = useStakeConnection() + const { data: stakeConnection, isLoading: isStakeConnectionLoading } = + useStakeConnection() const { data: stakeAccounts, isLoading: isStakeAccountsLoading } = useStakeAccounts() @@ -93,6 +96,19 @@ const Staking: NextPage = () => { const { data: currentVestingAccountState } = useVestingAccountState(mainStakeAccount) + const joinDaoLlcMutation = useJoinDaoLlcMutation() + const [isLlcModalOpen, setIsLlcModalOpen] = useState(false) + useEffect(() => { + if ( + stakeConnection === undefined || + mainStakeAccount === undefined || + mainStakeAccount === 'NA' + ) + return + + if (!stakeConnection.isLlcMember(mainStakeAccount)) setIsLlcModalOpen(true) + }, [stakeConnection, mainStakeAccount]) + // First stake connection will load, then stake accounts, and // then if a main stake account exists, the balance will load // else the balance won't load, it will be undefined @@ -404,6 +420,25 @@ const Staking: NextPage = () => { + { + // this modal will only be shown if there is a stakeConnection and mainStakeAccount + joinDaoLlcMutation.mutate( + { + stakeConnection: stakeConnection!, + mainStakeAccount: mainStakeAccount as StakeAccount, + }, + { + onSettled() { + setIsLlcModalOpen(false) + }, + } + ) + }} + isSigning={joinDaoLlcMutation.isLoading} + /> ) } diff --git a/frontend/styles/globals.css b/frontend/styles/globals.css index d3a3f9ff..162b6885 100644 --- a/frontend/styles/globals.css +++ b/frontend/styles/globals.css @@ -95,3 +95,14 @@ html::-webkit-scrollbar-thumb { background-color: #e6dafe; border-radius: 3px; } + +.scrollbar { + overflow-y: auto; +} +.scrollbar::-webkit-scrollbar { + width: 3px; +} +.scrollbar::-webkit-scrollbar-thumb { + background-color: #e6dafe; + border-radius: 3px; +} diff --git a/staking/app/StakeConnection.ts b/staking/app/StakeConnection.ts index 6634b7f6..1d3c0bbf 100644 --- a/staking/app/StakeConnection.ts +++ b/staking/app/StakeConnection.ts @@ -463,7 +463,7 @@ export class StakeConnection { return stakeAccountKeypair; } - public async isLlcMember(stakeAccount: StakeAccount) { + public isLlcMember(stakeAccount: StakeAccount) { return ( JSON.stringify(stakeAccount.stakeAccountMetadata.signedAgreementHash) == JSON.stringify(this.config.agreementHash) @@ -580,7 +580,7 @@ export class StakeConnection { ); } - if (!(await this.isLlcMember(stakeAccount))) { + if (!this.isLlcMember(stakeAccount)) { await this.withJoinDaoLlc(transaction.instructions, stakeAccount.address); } @@ -597,6 +597,15 @@ export class StakeConnection { await this.provider.sendAndConfirm(transaction); } + /** + * Join the DAO LLC for the given stake account. + */ + public async joinDaoLlc(stakeAccount: StakeAccount) { + const transaction: Transaction = new Transaction(); + await this.withJoinDaoLlc(transaction.instructions, stakeAccount.address); + await this.provider.sendAndConfirm(transaction); + } + public async setupVestingAccount( amount: PythBalance, owner: PublicKey, @@ -662,7 +671,7 @@ export class StakeConnection { ); } - if (!stakeAccount || !(await this.isLlcMember(stakeAccount))) { + if (!stakeAccount || !this.isLlcMember(stakeAccount)) { await this.withJoinDaoLlc(ixs, stakeAccountAddress); } @@ -767,7 +776,7 @@ export class StakeConnection { ); } - if (!stakeAccount || !(await this.isLlcMember(stakeAccount))) { + if (!stakeAccount || !this.isLlcMember(stakeAccount)) { await this.withJoinDaoLlc(ixs, stakeAccountAddress); }