diff --git a/.env.sample b/.env.sample index 718e4725..178e86bc 100644 --- a/.env.sample +++ b/.env.sample @@ -1 +1,2 @@ -NEXT_PUBLIC_SOLANA_URL= \ No newline at end of file +NEXT_PUBLIC_SOLANA_URL= +NEXT_PUBLIC_HELIUM_TRANSACTION_API=https://blockchain-api.web.helium.io \ No newline at end of file diff --git a/next.config.mjs b/next.config.mjs index f9cce5b1..db97e957 100644 --- a/next.config.mjs +++ b/next.config.mjs @@ -27,6 +27,12 @@ const nextConfig = { port: "", pathname: "/**/**", }, + { + protocol: "https", + hostname: "entities.nft.helium.io", + port: "", + pathname: "/**/**", + }, ...(process.env.NODE_ENV === "development" ? [ { protocol: "http", diff --git a/package.json b/package.json index 9ab4b359..8f15a37a 100644 --- a/package.json +++ b/package.json @@ -12,6 +12,7 @@ "@coral-xyz/anchor": "^0.31.0", "@helium/account-fetch-cache": "^0.11.5", "@helium/account-fetch-cache-hooks": "^0.11.5", + "@helium/blockchain-api": "^0.11.17", "@helium/helium-react-hooks": "^0.11.5", "@helium/hpl-crons-sdk": "^0.11.5", "@helium/modular-governance-hooks": "^0.1.5", @@ -25,6 +26,8 @@ "@helium/voter-stake-registry-sdk": "^0.11.5", "@jup-ag/jup-mobile-adapter": "^0.0.2", "@metaplex-foundation/mpl-token-metadata": "2.10.0", + "@orpc/client": "^1.13.4", + "@orpc/contract": "^1.13.4", "@project-serum/anchor": "^0.26.0", "@radix-ui/react-avatar": "^1.0.4", "@radix-ui/react-checkbox": "^1.0.4", diff --git a/src/app/[network]/positions/delegate-all/page.tsx b/src/app/[network]/positions/delegate-all/page.tsx index e6673dfd..15283a1e 100644 --- a/src/app/[network]/positions/delegate-all/page.tsx +++ b/src/app/[network]/positions/delegate-all/page.tsx @@ -4,15 +4,14 @@ import { ContentSection } from "@/components/ContentSection"; import { Header } from "@/components/Header"; import { DelegateAllPositionsPrompt } from "@/components/PositionManager/DelegateAllPositionsPrompt"; import { IOT_SUB_DAO_KEY, MOBILE_SUB_DAO_KEY } from "@/lib/constants"; -import { onInstructions } from "@/lib/utils"; +import { IOT_MINT, MOBILE_MINT } from "@helium/spl-utils"; import { useGovernance } from "@/providers/GovernanceProvider"; +import { useDelegatePositionMutation } from "@/hooks/useGovernanceMutations"; import { - useAnchorProvider, useSolanaUnixNow, } from "@helium/helium-react-hooks"; import { SubDaoWithMeta, - useDelegatePositions, } from "@helium/voter-stake-registry-hooks"; import { WalletSignTransactionError } from "@solana/wallet-adapter-base"; import BN from "bn.js"; @@ -26,7 +25,8 @@ export default function DelegateAllPositionsPage() { const { positions, subDaos } = useGovernance(); const [subDao, setSubDao] = useState(null); const [automationEnabled, setAutomationEnabled] = useState(true); - const provider = useAnchorProvider(); + + const delegateMutation = useDelegatePositionMutation(); const now = useSolanaUnixNow(); const delegatedPositions = useMemo( @@ -43,19 +43,6 @@ export default function DelegateAllPositionsPage() { [positions, now] ); - const { - delegatePositions, - rentFee: solFees = 0, - prepaidTxFees = 0, - insufficientBalance = false, - error, - loading, - } = useDelegatePositions({ - automationEnabled, - positions: unexpiredPositions, - subDao: subDao || undefined, - }); - useEffect(() => { if (!subDaos || !delegatedPositions || subDao) return; const mobileSubDao = subDaos.find((sd) => @@ -102,9 +89,17 @@ export default function DelegateAllPositionsPage() { const handleConfirm = async () => { try { - await delegatePositions({ - onInstructions: onInstructions(provider), - }); + await delegateMutation.submit( + { + positionMints: unexpiredPositions.map((p) => p.mint.toBase58()), + subDaoMint: subDao?.pubkey.equals(IOT_SUB_DAO_KEY) ? IOT_MINT.toBase58() : MOBILE_MINT.toBase58(), + automationEnabled, + }, + { + header: "Delegate All Positions", + message: "Delegating all positions to subnetwork", + } + ); toast("Delegations updated"); router.replace(`/${network}/positions`); } catch (e: any) { @@ -121,18 +116,18 @@ export default function DelegateAllPositionsPage() { router.back()} onConfirm={handleConfirm} automationEnabled={automationEnabled} setAutomationEnabled={setAutomationEnabled} subDao={subDao} setSubDao={setSubDao} - solFees={solFees} - prepaidTxFees={prepaidTxFees} - error={error ? String(error) : undefined} - loading={loading} - insufficientBalance={!!insufficientBalance} + solFees={delegateMutation.estimatedSolFee?.uiAmount ?? 0} + prepaidTxFees={0} + error={delegateMutation.error ? String(delegateMutation.error) : undefined} + loading={delegateMutation.isPending} + insufficientBalance={false} /> diff --git a/src/components/CreatePositionModal.tsx b/src/components/CreatePositionModal.tsx index 0359782b..1b9eed4d 100644 --- a/src/components/CreatePositionModal.tsx +++ b/src/components/CreatePositionModal.tsx @@ -1,9 +1,8 @@ "use client"; -import { daysToSecs, onInstructions } from "@/lib/utils"; +import { daysToSecs } from "@/lib/utils"; import { useGovernance } from "@/providers/GovernanceProvider"; import { - useAnchorProvider, useMint, useOwnedAmount, } from "@helium/helium-react-hooks"; @@ -11,8 +10,8 @@ import { HNT_MINT, toBN, toNumber } from "@helium/spl-utils"; import { PositionWithMeta, calcLockupMultiplier, - useCreatePosition, } from "@helium/voter-stake-registry-hooks"; +import { useCreatePositionMutation } from "@/hooks/useGovernanceMutations"; import { WalletSignTransactionError } from "@solana/wallet-adapter-base"; import { useWallet } from "@/hooks/useWallet"; import { PublicKey } from "@solana/web3.js"; @@ -30,26 +29,23 @@ import { SubDaoSelection } from "./SubDaoSelection"; import { Button } from "./ui/button"; import { Dialog, DialogContent, DialogTrigger } from "./ui/dialog"; import { PositionPreview } from "./PositionPreview"; -import { MOBILE_SUB_DAO_KEY } from "@/lib/constants"; +import { IOT_SUB_DAO_KEY, MOBILE_SUB_DAO_KEY } from "@/lib/constants"; +import { IOT_MINT, MOBILE_MINT } from "@helium/spl-utils"; import { DataSplitBars } from "./DataSplitBars"; import { AutomationSettings } from "./AutomationSettings"; export const CreatePositionModal: FC> = ({ children, }) => { - const provider = useAnchorProvider(); const [step, setStep] = useState(1); const [open, setOpen] = useState(false); - const [isSubmitting, setIsSubmitting] = useState(false); const [formValues, setFormValues] = useState(); const [selectedSubDaoPk, setSelectedSubDaoPk] = useState(); const [automationEnabled, setAutomationEnabled] = useState(true); const { publicKey: wallet } = useWallet(); const { mint, subDaos, registrar, refetch: refetchState } = useGovernance(); const { amount: ownedAmount, decimals } = useOwnedAmount(wallet, mint); - const { error: createPositionError, createPosition, rentFee, prepaidTxFees, insufficientBalance } = useCreatePosition({ - automationEnabled, - }); + const createPositionMutation = useCreatePositionMutation(); const steps = useMemo(() => (mint.equals(HNT_MINT) ? 3 : 2), [mint]); useEffect(() => { @@ -107,7 +103,7 @@ export const CreatePositionModal: FC> = ({ ); const handleOpenChange = () => { - setIsSubmitting(false); + createPositionMutation.reset(); setFormValues(undefined); setOpen(!open); setStep(1); @@ -121,43 +117,43 @@ export const CreatePositionModal: FC> = ({ const handleLockTokens = async () => { try { const { amount, lockupPeriodInDays, lockupKind } = formValues!; - setIsSubmitting(true); if (decimals) { const amountToLock = toBN(amount, decimals); - await createPosition({ - amount: amountToLock, - lockupPeriodsInDays: lockupPeriodInDays, - lockupKind: lockupKind, - mint, - ...(subDaos && selectedSubDaoPk - ? { - subDao: subDaos.find((subDao) => - subDao.pubkey.equals(selectedSubDaoPk!) - )!, - } - : {}), - onInstructions: onInstructions(provider), - }); + await createPositionMutation.submit( + { + amount: amountToLock.toString(), + mint: mint.toBase58(), + lockupKind: lockupKind as "cliff" | "constant", + lockupPeriodsInDays: lockupPeriodInDays, + ...(subDaos && selectedSubDaoPk + ? { + subDaoMint: selectedSubDaoPk.equals(IOT_SUB_DAO_KEY) + ? IOT_MINT.toBase58() + : MOBILE_MINT.toBase58(), + automationEnabled, + } + : {}), + }, + { + header: "Create Position", + message: "Locking tokens and creating position", + } + ); toast.success("Position created successfully"); - setIsSubmitting(false); - - if (!createPositionError) { - setOpen(false); - refetchState(); - } else { - toast(createPositionError.message); - } + setOpen(false); + refetchState(); } } catch (e: any) { - setIsSubmitting(false); if (!(e instanceof WalletSignTransactionError)) { toast(e.message || "Position creation failed, please try again"); } } }; + const isSubmitting = createPositionMutation.isPending; + const verb = (step === 1 && "Create") || (steps > 2 && step === 2 && "Delegate") || @@ -215,8 +211,8 @@ export const CreatePositionModal: FC> = ({
@@ -250,9 +246,8 @@ export const CreatePositionModal: FC> = ({
diff --git a/src/components/PositionManager/PositionActionBoundary.tsx b/src/components/PositionManager/PositionActionBoundary.tsx index 36b57efd..729fad1d 100644 --- a/src/components/PositionManager/PositionActionBoundary.tsx +++ b/src/components/PositionManager/PositionActionBoundary.tsx @@ -28,7 +28,7 @@ export const PositionActionBoundary: FC< const { hasRewards, isDelegated, numActiveVotes } = position; const hasVotes = numActiveVotes > 0; const hasBlockers = hasRewards || isDelegated || hasVotes; - const canDoWhileBlocked = action === "delegate" || action == "proxy"; + const canDoWhileBlocked = action === "delegate" || action === "proxy" || action === "transferOwnership"; if (!action) { return children; diff --git a/src/components/PositionManager/PositionManager.tsx b/src/components/PositionManager/PositionManager.tsx index 02d182dc..d5590efb 100644 --- a/src/components/PositionManager/PositionManager.tsx +++ b/src/components/PositionManager/PositionManager.tsx @@ -1,35 +1,36 @@ "use client"; -import { EPOCH_LENGTH, onInstructions, secsToDays } from "@/lib/utils"; +import { EPOCH_LENGTH, secsToDays } from "@/lib/utils"; import { useGovernance } from "@/providers/GovernanceProvider"; import { TASK_QUEUE, useDelegationClaimBot } from "@helium/automation-hooks"; -import { - useAnchorProvider, - useSolanaUnixNow, -} from "@helium/helium-react-hooks"; +import { useSolanaUnixNow } from "@helium/helium-react-hooks"; import { delegatedPositionKey } from "@helium/helium-sub-daos-sdk"; import { RiUserSharedFill } from "react-icons/ri"; import { toNumber } from "@helium/spl-utils"; import { PositionWithMeta, SubDaoWithMeta, - useAssignProxies, - useClaimPositionRewards, - useClosePosition, - useDelegatePosition, - useExtendPosition, - useFlipPositionLockupKind, - useRelinquishPositionVotes, - useSplitPosition, useSubDaos, - useTransferPosition, - useUnassignProxies, - useUndelegatePosition, } from "@helium/voter-stake-registry-hooks"; +import { + useAssignProxiesMutation, + useUnassignProxiesMutation, + useFlipLockupKindMutation, + useClaimRewardsMutation, + useClosePositionMutation, + useDelegatePositionMutation, + useUndelegatePositionMutation, + useExtendPositionMutation, + useSplitPositionMutation, + useTransferPositionMutation, + useTransferPositionOwnershipMutation, + useRelinquishPositionVotesMutation, +} from "@/hooks/useGovernanceMutations"; +import { useWallet } from "@/hooks/useWallet"; import { WalletSignTransactionError } from "@solana/wallet-adapter-base"; import BN from "bn.js"; import classNames from "classnames"; -import { ArrowUpFromDot, CheckCheck, Merge, Split } from "lucide-react"; +import { ArrowUpFromDot, CheckCheck, Merge, Send, Split } from "lucide-react"; import { useRouter } from "next/navigation"; import React, { FC, @@ -51,10 +52,11 @@ import { PositionActionBoundary } from "./PositionActionBoundary"; import { PositionCallout } from "./PositionCallout"; import { ReclaimPositionPrompt } from "./ReclaimPositionPrompt"; import { SplitPositionPrompt } from "./SplitPositionPrompt"; +import { TransferOwnershipPrompt } from "./TransferOwnershipPrompt"; import { UpdatePositionDelegationPrompt } from "./UpdatePositionDelegationPrompt"; import { ProxyPositionPrompt } from "./ProxyPositionPrompt"; -import { PublicKey } from "@solana/web3.js"; -import { MOBILE_SUB_DAO_KEY } from "@/lib/constants"; +import { IOT_SUB_DAO_KEY, MOBILE_SUB_DAO_KEY } from "@/lib/constants"; +import { IOT_MINT, MOBILE_MINT } from "@helium/spl-utils"; import { delegationClaimBotKey } from "@helium/hpl-crons-sdk"; export type PositionAction = @@ -65,7 +67,8 @@ export type PositionAction = | "split" | "merge" | "reclaim" - | "proxy"; + | "proxy" + | "transferOwnership"; export interface PositionManagerProps { initAction?: PositionAction; @@ -107,7 +110,6 @@ export const PositionManager: FC = ({ }) => { const unixNow = useSolanaUnixNow() || Date.now() / 1000; const [action, setAction] = useState(initAction); - const provider = useAnchorProvider(); const { mintAcc, network, @@ -116,6 +118,7 @@ export const PositionManager: FC = ({ refetch: refetchState, } = useGovernance(); const isHNT = network === "hnt"; + const { publicKey: walletPublicKey } = useWallet(); const router = useRouter(); const { lockup, isDelegated } = position; const isConstant = Object.keys(lockup.kind)[0] === "constant"; @@ -169,15 +172,12 @@ export const PositionManager: FC = ({ setAction(undefined); }, [refetchState, setAction]); - const { isPending: isAssigningProxy, mutateAsync: assignProxies } = - useAssignProxies(); - const { isPending: isRevokingProxy, mutateAsync: unassignProxies } = - useUnassignProxies(); - const isUpdatingProxy = isAssigningProxy || isRevokingProxy; - const { loading: isFlipping, flipPositionLockupKind } = - useFlipPositionLockupKind(); - const { loading: isClaiming, claimPositionRewards } = - useClaimPositionRewards(); + const assignProxiesMutation = useAssignProxiesMutation(); + const unassignProxiesMutation = useUnassignProxiesMutation(); + const isUpdatingProxy = + assignProxiesMutation.isPending || unassignProxiesMutation.isPending; + const flipLockupKindMutation = useFlipLockupKindMutation(); + const claimRewardsMutation = useClaimRewardsMutation(); const { result: subDaos } = useSubDaos(); const delegationClaimBotK = useMemo( () => @@ -213,29 +213,14 @@ export const PositionManager: FC = ({ } }, [subDaos, subDao, position?.delegatedSubDao]); - const { - loading: isDelegating, - delegatePosition, - rentFee: solFees, - prepaidTxFees, - } = useDelegatePosition({ - automationEnabled, - position, - subDao: subDao || undefined, - }); - - const { loading: isUndelegating, undelegatePosition } = useUndelegatePosition( - { - position, - } - ); - - const { loading: isTransfering, transferPosition } = useTransferPosition(); - const { loading: isSplitting, splitPosition } = useSplitPosition(); - const { loading: isExtending, extendPosition } = useExtendPosition(); - const { loading: isReclaiming, closePosition } = useClosePosition(); - const { loading: isRelinquishing, relinquishPositionVotes } = - useRelinquishPositionVotes(); + const delegateMutation = useDelegatePositionMutation(); + const undelegateMutation = useUndelegatePositionMutation(); + const transferMutation = useTransferPositionMutation(); + const splitMutation = useSplitPositionMutation(); + const extendMutation = useExtendPositionMutation(); + const closeMutation = useClosePositionMutation(); + const transferOwnershipMutation = useTransferPositionOwnershipMutation(); + const relinquishVotesMutation = useRelinquishPositionVotesMutation(); const handleUpdateProxy = async ({ proxy, @@ -248,25 +233,28 @@ export const PositionManager: FC = ({ }) => { try { if (isRevoke) { - await unassignProxies({ - positions: [position], - onInstructions: onInstructions(provider, { - useFirstEstimateForAll: true, - }), - }); + await unassignProxiesMutation.submit( + { + proxyKey: position.proxy?.nextVoter?.toBase58() || "", + positionMints: [position.mint.toBase58()], + }, + { + header: "Revoke Proxy", + message: "Revoking proxy assignment", + } + ); } else { - await assignProxies({ - positions: [position], - recipient: new PublicKey(proxy || ""), - expirationTime: new BN(expirationTime || 0), - onInstructions: async (instructionArrays) => { - for (const instructions of instructionArrays) { - await onInstructions(provider, { - useFirstEstimateForAll: true, - })(instructions); - } + await assignProxiesMutation.submit( + { + proxyKey: proxy || "", + positionMints: [position.mint.toBase58()], + expirationTime: expirationTime || 0, }, - }); + { + header: "Assign Proxy", + message: "Assigning proxy voter", + } + ); } toast(`Proxy ${isRevoke ? "revoked" : "assigned"}`); } catch (e: any) { @@ -281,13 +269,16 @@ export const PositionManager: FC = ({ const handleRelinquishPositionVotes = async () => { try { - await relinquishPositionVotes({ - position, - organization, - onInstructions: onInstructions(provider, { - useFirstEstimateForAll: true, - }), - }); + await relinquishVotesMutation.submit( + { + positionMint: position.mint.toBase58(), + organization: organization.toBase58(), + }, + { + header: "Relinquish Votes", + message: "Relinquishing all votes from position", + } + ); toast("Votes Relinquished"); } catch (e: any) { @@ -299,10 +290,15 @@ export const PositionManager: FC = ({ const handleClosePosition = async () => { try { - await closePosition({ - position, - onInstructions: onInstructions(provider), - }); + await closeMutation.submit( + { + positionMint: position.mint.toBase58(), + }, + { + header: "Reclaim Position", + message: "Closing and reclaiming position", + } + ); router.replace(`/${network}/positions`); toast("Position Reclaimed"); @@ -315,10 +311,15 @@ export const PositionManager: FC = ({ const handleFlipPositionLockupKind = async () => { try { - await flipPositionLockupKind({ - position, - onInstructions: onInstructions(provider), - }); + await flipLockupKindMutation.submit( + { + positionMint: position.mint.toBase58(), + }, + { + header: "Flip Lockup Kind", + message: `Switching position to ${isConstant ? "decaying" : "constant"}`, + } + ); toast("Position flipped"); setAction(undefined); @@ -334,13 +335,15 @@ export const PositionManager: FC = ({ const handleClaimPositionRewards = async () => { try { - await claimPositionRewards({ - position, - onInstructions: onInstructions(provider, { - useFirstEstimateForAll: true, - maxInstructionsPerTx: 8, - }), - }); + await claimRewardsMutation.submit( + { + positionMints: [position.mint.toBase58()], + }, + { + header: "Claim Rewards", + message: "Claiming delegation rewards", + } + ); toast("Rewards claimed"); } catch (e: any) { @@ -352,14 +355,22 @@ export const PositionManager: FC = ({ const handleDelegatePosition = async () => { try { - await delegatePosition({ - onInstructions: onInstructions(provider), - }); + await delegateMutation.submit( + { + positionMints: [position.mint.toBase58()], + subDaoMint: subDao?.pubkey.equals(IOT_SUB_DAO_KEY) ? IOT_MINT.toBase58() : MOBILE_MINT.toBase58(), + automationEnabled, + }, + { + header: "Delegate Position", + message: "Delegating position to subnetwork", + } + ); toast("Delegation updated"); reset(); } catch (e: any) { - console.error(e) + console.error(e); if (!(e instanceof WalletSignTransactionError)) { toast(e.message || "Delegation failed, please try again"); } @@ -368,9 +379,15 @@ export const PositionManager: FC = ({ const handleUndelegatePosition = async () => { try { - await undelegatePosition({ - onInstructions: onInstructions(provider), - }); + await undelegateMutation.submit( + { + positionMint: position.mint.toBase58(), + }, + { + header: "Undelegate Position", + message: "Undelegating position", + } + ); toast("Position undelegated"); reset(); @@ -383,11 +400,16 @@ export const PositionManager: FC = ({ const handleExtendPosition = async (values: LockTokensFormValues) => { try { - await extendPosition({ - position, - lockupPeriodsInDays: values.lockupPeriodInDays, - onInstructions: onInstructions(provider), - }); + await extendMutation.submit( + { + positionMint: position.mint.toBase58(), + lockupPeriodsInDays: values.lockupPeriodInDays, + }, + { + header: "Extend Position", + message: "Extending position lockup period", + } + ); toast("Position extended"); } catch (e: any) { @@ -399,13 +421,18 @@ export const PositionManager: FC = ({ const handleSplitPosition = async (values: LockTokensFormValues) => { try { - await splitPosition({ - sourcePosition: position, - amount: values.amount, - lockupKind: values.lockupKind, - lockupPeriodsInDays: values.lockupPeriodInDays, - onInstructions: onInstructions(provider), - }); + await splitMutation.submit( + { + sourcePositionMint: position.mint.toBase58(), + amount: values.amount.toString(), + lockupKind: values.lockupKind as "cliff" | "constant", + lockupPeriodsInDays: values.lockupPeriodInDays, + }, + { + header: "Split Position", + message: "Splitting position", + } + ); toast("Position split"); reset(); @@ -421,12 +448,17 @@ export const PositionManager: FC = ({ amount: number ) => { try { - await transferPosition({ - sourcePosition: position, - amount, - targetPosition, - onInstructions: onInstructions(provider), - }); + await transferMutation.submit( + { + sourcePositionMint: position.mint.toBase58(), + targetPositionMint: targetPosition.mint.toBase58(), + amount: amount.toString(), + }, + { + header: "Merge Position", + message: "Merging positions", + } + ); if (amount === maxActionableAmount) { router.replace(`/${network}/positions`); @@ -441,6 +473,31 @@ export const PositionManager: FC = ({ } }; + const handleTransferOwnership = async (destinationWallet: string) => { + try { + await transferOwnershipMutation.submit( + { + from: walletPublicKey!.toBase58(), + to: destinationWallet, + positionMint: position.mint.toBase58(), + }, + { + header: "Transfer Ownership", + message: "Transferring position ownership", + } + ); + + toast("Position ownership transferred"); + await new Promise((resolve) => setTimeout(resolve, 3000)); + refetchState(); + router.replace(`/${network}/positions`); + } catch (e: any) { + if (!(e instanceof WalletSignTransactionError)) { + toast(e.message || "Transfer failed, please try again"); + } + } + }; + useAsync(async () => { if (action) { const actionFunctions = { @@ -464,8 +521,8 @@ export const PositionManager: FC = ({
@@ -522,6 +579,14 @@ export const PositionManager: FC = ({ > Merge Position + setAction("transferOwnership")} + disabled={!isHNT} + > + Transfer Ownership +
@@ -537,8 +602,8 @@ export const PositionManager: FC = ({ = ({ {action === "flip" && ( setAction(undefined)} onConfirm={handleFlipPositionLockupKind} /> @@ -574,7 +639,7 @@ export const PositionManager: FC = ({ {action === "reclaim" && ( setAction(undefined)} onConfirm={handleClosePosition} /> @@ -582,7 +647,7 @@ export const PositionManager: FC = ({ {action === "delegate" && ( setAction(undefined)} onConfirm={handleDelegatePosition} onUndelegate={handleUndelegatePosition} @@ -590,8 +655,8 @@ export const PositionManager: FC = ({ setAutomationEnabled={setAutomationEnabled} subDao={subDao} setSubDao={setSubDao} - solFees={solFees} - prepaidTxFees={prepaidTxFees} + solFees={delegateMutation.estimatedSolFee?.uiAmount ?? 0} + prepaidTxFees={0} /> )} {action === "undelegate" &&
Test
} @@ -599,7 +664,7 @@ export const PositionManager: FC = ({ setAction(undefined)} onConfirm={handleExtendPosition} /> @@ -608,7 +673,7 @@ export const PositionManager: FC = ({ setAction(undefined)} onConfirm={handleSplitPosition} /> @@ -618,11 +683,20 @@ export const PositionManager: FC = ({ position={position} positions={mergablePositions} maxActionableAmount={maxActionableAmount} - isSubmitting={isTransfering} + isSubmitting={transferMutation.isPending} onCancel={() => setAction(undefined)} onConfirm={handleMergePosition} /> )} + {action === "transferOwnership" && ( + setAction(undefined)} + onConfirm={handleTransferOwnership} + /> + )}
diff --git a/src/components/PositionManager/TransferOwnershipPrompt.tsx b/src/components/PositionManager/TransferOwnershipPrompt.tsx new file mode 100644 index 00000000..0fedc9f7 --- /dev/null +++ b/src/components/PositionManager/TransferOwnershipPrompt.tsx @@ -0,0 +1,147 @@ +"use client"; + +import { PositionWithMeta } from "@helium/voter-stake-registry-hooks"; +import { PublicKey } from "@solana/web3.js"; +import { Loader2, X } from "lucide-react"; +import React, { FC, useMemo, useState } from "react"; +import { PositionCard } from "../PositionCard"; +import { StepIndicator } from "../StepIndicator"; +import { Button } from "../ui/button"; +import { Input } from "../ui/input"; + +const isValidSolanaAddress = (addr: string): boolean => { + try { + new PublicKey(addr); + return true; + } catch { + return false; + } +}; + +export const TransferOwnershipPrompt: FC<{ + position: PositionWithMeta; + walletAddress: string; + isSubmitting?: boolean; + onCancel: () => void; + onConfirm: (destinationWallet: string) => Promise; +}> = ({ position, walletAddress, isSubmitting, onCancel, onConfirm }) => { + const [step, setStep] = useState(1); + const [destination, setDestination] = useState(""); + + const validationError = useMemo(() => { + if (!destination) return null; + if (!isValidSolanaAddress(destination)) return "Invalid Solana address"; + if (destination === walletAddress) + return "Cannot transfer to your own wallet"; + return null; + }, [destination, walletAddress]); + + const canReview = destination.length > 0 && !validationError; + + const truncatedAddress = destination + ? `${destination.slice(0, 4)}...${destination.slice(-4)}` + : ""; + + const handleSubmit = async () => { + await onConfirm(destination); + }; + + return ( +
+
+ +
+
+
+

Transfer Ownership

+ +
+

+ Transfer this position to another wallet.{" "} + This action is irreversible. The + position and all associated voting power, delegation, and rewards will + move to the destination wallet. +

+
+ {step === 1 && ( + <> +
+ +
+ Destination Wallet Address + setDestination(e.target.value.trim())} + /> + {validationError && ( + + {validationError} + + )} +
+
+
+
+ + +
+
+ + )} + {step === 2 && ( + <> +
+ +
+ + Transferring to + + {destination} +
+

+ Please confirm you want to transfer this position to{" "} + {truncatedAddress}. This cannot + be undone. +

+
+
+
+ + +
+

+ A network fee will be required +

+
+ + )} +
+ ); +}; diff --git a/src/components/Positions.tsx b/src/components/Positions.tsx index a455e5ce..79e1063b 100644 --- a/src/components/Positions.tsx +++ b/src/components/Positions.tsx @@ -7,26 +7,22 @@ import { FaStar, FaCircleArrowRight } from "react-icons/fa6"; import { Loader2 } from "lucide-react"; import { Card, CardContent, CardHeader, CardTitle } from "./ui/card"; import BN from "bn.js"; -import { useClaimAllPositionsRewards } from "@helium/voter-stake-registry-hooks"; import { PositionCard, PositionCardSkeleton } from "./PositionCard"; import { useWallet } from "@/hooks/useWallet"; import { toast } from "sonner"; import { Skeleton } from "./ui/skeleton"; import { CreatePositionButton } from "./CreatePositionButton"; -import { onInstructions } from "@/lib/utils"; -import { - useAnchorProvider, - useSolanaUnixNow, -} from "@helium/helium-react-hooks"; import { ContentSection } from "./ContentSection"; import { WalletSignTransactionError } from "@solana/wallet-adapter-base"; import { useRouter } from "next/navigation"; import { AssignProxyModal } from "./AssignProxyModal"; import { ProxyButton } from "./ProxyButton"; -import { useAssignProxies } from "@helium/voter-stake-registry-hooks"; +import { + useClaimRewardsMutation, + useAssignProxiesMutation, +} from "@/hooks/useGovernanceMutations"; export const Positions: FC = () => { - const provider = useAnchorProvider(); const { connecting } = useWallet(); const { loading: loadingGov, @@ -95,21 +91,21 @@ export const Positions: FC = () => { [unProxiedPositions] ); - const { loading: claimingAllRewards, claimAllPositionsRewards } = - useClaimAllPositionsRewards(); - - const { mutateAsync: assignProxies } = useAssignProxies(); + const claimRewardsMutation = useClaimRewardsMutation(); + const assignProxiesMutation = useAssignProxiesMutation(); const handleClaimRewards = async () => { if (positionsWithRewards) { try { - await claimAllPositionsRewards({ - positions: positionsWithRewards, - onInstructions: onInstructions(provider, { - useFirstEstimateForAll: true, - maxInstructionsPerTx: 8, - }), - }); + await claimRewardsMutation.submit( + { + positionMints: positionsWithRewards.map((p) => p.mint.toBase58()), + }, + { + header: "Claim All Rewards", + message: "Claiming rewards for all positions", + } + ); toast("Rewards claimed!"); refetchState(); @@ -175,12 +171,19 @@ export const Positions: FC = () => { { - await assignProxies({ - ...args, - onInstructions: onInstructions(provider, { - useFirstEstimateForAll: true, - }), - }); + await assignProxiesMutation.submit( + { + proxyKey: args.recipient.toBase58(), + positionMints: args.positions.map((p) => + p.mint.toBase58() + ), + expirationTime: args.expirationTime.toNumber(), + }, + { + header: "Assign Proxy", + message: "Assigning proxy voter to all positions", + } + ); refetchState(); }} > @@ -201,15 +204,15 @@ export const Positions: FC = () => { )} diff --git a/src/components/ProxyProfile.tsx b/src/components/ProxyProfile.tsx index 0ae9313a..f2631b6a 100644 --- a/src/components/ProxyProfile.tsx +++ b/src/components/ProxyProfile.tsx @@ -1,15 +1,17 @@ "use client"; import { networksToMint } from "@/lib/constants"; -import { ellipsisMiddle, humanReadable, onInstructions } from "@/lib/utils"; +import { ellipsisMiddle, humanReadable } from "@/lib/utils"; import { useGovernance } from "@/providers/GovernanceProvider"; -import { useAnchorProvider, useMint } from "@helium/helium-react-hooks"; +import { useMint } from "@helium/helium-react-hooks"; import { proxyQuery, - useAssignProxies, useProxiedTo, - useUnassignProxies, } from "@helium/voter-stake-registry-hooks"; +import { + useAssignProxiesMutation, + useUnassignProxiesMutation, +} from "@/hooks/useGovernanceMutations"; import { VoteService, getRegistrarKey } from "@helium/voter-stake-registry-sdk"; import { PublicKey } from "@solana/web3.js"; import { useQuery } from "@tanstack/react-query"; @@ -44,8 +46,8 @@ export function ProxyProfile({ wallet: walletRaw }: { wallet: string }) { const detail = proxy.detail; const { info: mintAcc } = useMint(mint); const decimals = mintAcc?.decimals; - const { mutateAsync: assignProxies } = useAssignProxies(); - const { mutateAsync: unassignProxies } = useUnassignProxies(); + const assignProxiesMutation = useAssignProxiesMutation(); + const unassignProxiesMutation = useUnassignProxiesMutation(); const { votingPower, positions } = useProxiedTo(wallet); const { result: networks } = useAsync( async (vs: VoteService | undefined) => { @@ -165,8 +167,6 @@ export function ProxyProfile({ wallet: walletRaw }: { wallet: string }) { ); - const provider = useAnchorProvider(); - return ( @@ -211,17 +211,20 @@ export function ProxyProfile({ wallet: walletRaw }: { wallet: string }) { { - return assignProxies({ - ...args, - onInstructions: async (instructionArrays) => { - for (const instructions of instructionArrays) { - await onInstructions(provider, { - useFirstEstimateForAll: true, - })(instructions); - } + onSubmit={async (args) => { + await assignProxiesMutation.submit( + { + proxyKey: args.recipient.toBase58(), + positionMints: args.positions.map((p) => + p.mint.toBase58() + ), + expirationTime: args.expirationTime.toNumber(), }, - }); + { + header: "Assign Proxy", + message: "Assigning proxy voter", + } + ); }} wallet={wallet} > @@ -232,14 +235,20 @@ export function ProxyProfile({ wallet: walletRaw }: { wallet: string }) { /> - unassignProxies({ - ...args, - onInstructions: onInstructions(provider, { - useFirstEstimateForAll: true, - }), - }) - } + onSubmit={async (args) => { + await unassignProxiesMutation.submit( + { + proxyKey: walletRaw, + positionMints: args.positions.map((p) => + p.mint.toBase58() + ), + }, + { + header: "Revoke Proxy", + message: "Revoking proxy assignment", + } + ); + }} wallet={wallet} >
{ - return assignProxies({ - ...args, - onInstructions: async (instructionArrays) => { - for (const instructions of instructionArrays) { - await onInstructions(provider, { - useFirstEstimateForAll: true, - })(instructions); - } + onSubmit={async (args) => { + await assignProxiesMutation.submit( + { + proxyKey: args.recipient.toBase58(), + positionMints: args.positions.map((p) => + p.mint.toBase58() + ), + expirationTime: args.expirationTime.toNumber(), }, - }); + { + header: "Assign Proxy", + message: "Assigning proxy voter", + } + ); }} wallet={wallet} > - unassignProxies({ - ...args, - onInstructions: onInstructions(provider, { - useFirstEstimateForAll: true, - }), - }) - } + onSubmit={async (args) => { + await unassignProxiesMutation.submit( + { + proxyKey: walletRaw, + positionMints: args.positions.map((p) => + p.mint.toBase58() + ), + }, + { + header: "Revoke Proxy", + message: "Revoking proxy assignment", + } + ); + }} wallet={wallet} > diff --git a/src/components/VoteOptions.tsx b/src/components/VoteOptions.tsx index d7f70987..95057768 100644 --- a/src/components/VoteOptions.tsx +++ b/src/components/VoteOptions.tsx @@ -1,14 +1,16 @@ "use client"; import { VoteChoiceWithMeta } from "@/lib/types"; -import { onInstructions } from "@/lib/utils"; import { useGovernance } from "@/providers/GovernanceProvider"; -import { useAnchorProvider } from "@helium/helium-react-hooks"; import { - useAssignProxies, useRelinquishVote, useVote, } from "@helium/voter-stake-registry-hooks"; +import { + useVoteMutation, + useRelinquishVoteMutation, + useAssignProxiesMutation, +} from "@/hooks/useGovernanceMutations"; import { WalletSignTransactionError } from "@solana/wallet-adapter-base"; import { PublicKey } from "@solana/web3.js"; import { FC, useMemo, useState } from "react"; @@ -26,7 +28,6 @@ export const VoteOptions: FC<{ const { didVote, canVote, - vote, loading: voting, voters, } = useVote(proposalKey); @@ -42,21 +43,35 @@ export const VoteOptions: FC<{ ); const canProxy = !!unproxiedPositions?.length; + const positionMints = useMemo( + () => positions?.map((p) => p.mint.toBase58()) || [], + [positions] + ); + const { canRelinquishVote, - relinquishVote, loading: relinquishing, } = useRelinquishVote(proposalKey); - const provider = useAnchorProvider(); + + const voteMutation = useVoteMutation(); + const relinquishVoteMutation = useRelinquishVoteMutation(); + const assignProxiesMutation = useAssignProxiesMutation(); const handleVote = (choice: VoteChoiceWithMeta) => async () => { - if (canVote(choice.index) && provider) { + if (canVote(choice.index)) { try { setCurrVote(choice.index); - await vote({ - choice: choice.index, - onInstructions: onInstructions(provider), - }); + await voteMutation.submit( + { + proposalKey: proposalKey.toBase58(), + positionMints, + choice: choice.index, + }, + { + header: "Cast Vote", + message: `Voting for ${choice.name}`, + } + ); toast("Vote submitted"); } catch (e: any) { console.error(e); @@ -72,12 +87,17 @@ export const VoteOptions: FC<{ if (canRelinquishVote(choice.index)) { try { setCurrVote(choice.index); - await relinquishVote({ - choice: choice.index, - onInstructions: onInstructions(provider, { - useFirstEstimateForAll: true, - }), - }); + await relinquishVoteMutation.submit( + { + proposalKey: proposalKey.toBase58(), + positionMints, + choice: choice.index, + }, + { + header: "Relinquish Vote", + message: `Relinquishing vote for ${choice.name}`, + } + ); toast("Vote relinquished"); } catch (e: any) { console.error(e); @@ -89,8 +109,6 @@ export const VoteOptions: FC<{ } }; - const { mutateAsync: assignProxies } = useAssignProxies(); - return (
@@ -118,17 +136,20 @@ export const VoteOptions: FC<{ precedence over a proxy.

{ - return assignProxies({ - ...args, - onInstructions: async (instructionArrays) => { - for (const instructions of instructionArrays) { - await onInstructions(provider, { - useFirstEstimateForAll: true, - })(instructions); - } + onSubmit={async (args) => { + await assignProxiesMutation.submit( + { + proxyKey: args.recipient.toBase58(), + positionMints: args.positions.map((p) => + p.mint.toBase58() + ), + expirationTime: args.expirationTime.toNumber(), }, - }); + { + header: "Assign Proxy", + message: "Assigning proxy voter", + } + ); }} > @@ -139,7 +160,10 @@ export const VoteOptions: FC<{ {choices.map((r, index) => ( ; +type GovernanceClient = BlockchainApiClient["governance"]; +type GovernanceMethod = keyof GovernanceClient; +type ApiInput = Parameters[0]; +type ApiParams = Omit, "walletAddress">; + +function requireWallet( + wallet: ReturnType["publicKey"] +): string { + if (!wallet) throw new Error("Wallet not connected"); + return wallet.toBase58(); +} + +function useMutationState() { + const [isPending, setIsPending] = useState(false); + const [error, setError] = useState(null); + + const wrapMutate = useCallback( + async (fn: () => Promise): Promise => { + setIsPending(true); + setError(null); + try { + return await fn(); + } catch (e) { + const err = e instanceof Error ? e : new Error(String(e)); + setError(err); + throw err; + } finally { + setIsPending(false); + } + }, + [] + ); + + const reset = useCallback(() => { + setError(null); + setIsPending(false); + }, []); + + return { isPending, error, reset, wrapMutate }; +} + +function responseHasMore(r: unknown): r is { hasMore: boolean } { + return typeof r === "object" && r !== null && "hasMore" in r; +} + +// --- Factory --- + +function useGovernanceMutation< + M extends GovernanceMethod, + TParams = ApiParams, +>(config: { + method: M; + buildTag: (params: TParams) => string; + mapParams?: (params: TParams) => ApiParams; +}) { + const client = useBlockchainApi(); + const { submit: submitTxn } = useGovernanceSubmit(); + const { publicKey: wallet } = useWallet(); + const { isPending, error, reset, wrapMutate } = useMutationState(); + const [estimatedSolFee, setEstimatedSolFee] = + useState(null); + + const resolveApiParams = useCallback( + (params: TParams): ApiParams => + config.mapParams ? config.mapParams(params) : (params as ApiParams), + [config] + ); + + // TS can't narrow a generic-indexed union of callables — safe because each + // call site pins M to a literal, keeping the public API fully typed. + const apiFn = client.governance[config.method] as (...args: any[]) => any; + + const callApi = useCallback( + async (params: TParams) => { + const walletAddress = requireWallet(wallet); + const apiParams = resolveApiParams(params); + const response = await apiFn({ walletAddress, ...apiParams }); + setEstimatedSolFee(response.estimatedSolFee ?? null); + return response; + }, + [wallet, resolveApiParams, apiFn] + ); + + const prepare = useCallback( + (params: TParams) => callApi(params), + [callApi] + ); + + const submit = useCallback( + (params: TParams, options: GovernanceSubmitOptions) => + wrapMutate(async () => { + const response = await callApi(params); + const tag = config.buildTag(params); + const walletAddress = requireWallet(wallet); + const fetchMore = + responseHasMore(response) && response.hasMore + ? () => { + const apiParams = resolveApiParams(params); + return apiFn({ walletAddress, ...apiParams }); + } + : undefined; + return submitTxn(response, { ...options, tag }, fetchMore); + }), + [wrapMutate, callApi, wallet, submitTxn, config, resolveApiParams, apiFn] + ); + + const resetAll = useCallback(() => { + reset(); + setEstimatedSolFee(null); + }, [reset]); + + return { + prepare, + submit, + estimatedSolFee, + isPending, + error, + reset: resetAll, + }; +} + +// --- Position Mutations --- + +type CreatePositionParams = { + amount: string; + mint: string; + lockupKind: "cliff" | "constant"; + lockupPeriodsInDays: number; + subDaoMint?: string; + automationEnabled?: boolean; +}; + +export function useCreatePositionMutation() { + return useGovernanceMutation<"createPosition", CreatePositionParams>({ + method: "createPosition", + buildTag: (p) => + `gov-createPosition-${hashTagParams({ + mint: p.mint, + lockupKind: p.lockupKind, + lockupPeriodsInDays: p.lockupPeriodsInDays, + })}`, + mapParams: ({ amount, mint, ...rest }) => ({ + tokenAmount: { amount, mint }, + ...rest, + }), + }); +} + +export function useClosePositionMutation() { + return useGovernanceMutation({ + method: "closePosition", + buildTag: (p: any) => + `gov-close-${hashTagParams({ position: p.positionMint })}`, + }); +} + +export function useExtendPositionMutation() { + return useGovernanceMutation({ + method: "extendPosition", + buildTag: (p: any) => + `gov-extend-${hashTagParams({ + position: p.positionMint, + lockupPeriodInDays: p.lockupPeriodsInDays, + })}`, + }); +} + +export function useFlipLockupKindMutation() { + return useGovernanceMutation({ + method: "flipLockupKind", + buildTag: (p: any) => + `gov-flipLockupKind-${hashTagParams({ position: p.positionMint })}`, + }); +} + +type SplitPositionParams = { + sourcePositionMint: string; + amount: string; + lockupKind: "cliff" | "constant"; + lockupPeriodsInDays: number; +}; + +export function useSplitPositionMutation() { + return useGovernanceMutation<"splitPosition", SplitPositionParams>({ + method: "splitPosition", + buildTag: (p) => + `gov-split-${hashTagParams({ + position: p.sourcePositionMint, + amount: p.amount, + lockupKind: p.lockupKind, + lockupPeriodInDays: p.lockupPeriodsInDays, + })}`, + mapParams: ({ sourcePositionMint, ...rest }) => ({ + positionMint: sourcePositionMint, + ...rest, + }), + }); +} + +type TransferPositionParams = { + sourcePositionMint: string; + targetPositionMint: string; + amount: string; +}; + +export function useTransferPositionMutation() { + return useGovernanceMutation<"transferPosition", TransferPositionParams>({ + method: "transferPosition", + buildTag: (p) => + `gov-transfer-${hashTagParams({ + sourcePosition: p.sourcePositionMint, + targetPosition: p.targetPositionMint, + amount: p.amount, + })}`, + mapParams: ({ sourcePositionMint, ...rest }) => ({ + positionMint: sourcePositionMint, + ...rest, + }), + }); +} + +export function useTransferPositionOwnershipMutation() { + return useGovernanceMutation({ + method: "transferPositionOwnership", + buildTag: (p: any) => + `gov-transferOwnership-${hashTagParams({ + position: p.positionMint, + to: p.to, + })}`, + }); +} + +// --- Delegation Mutations --- + +export function useDelegatePositionMutation() { + return useGovernanceMutation({ + method: "delegatePositions", + buildTag: (p: any) => + `gov-delegate-${hashTagParams({ + subDao: p.subDaoMint, + automationEnabled: p.automationEnabled ? 1 : 0, + })}`, + }); +} + +export function useExtendDelegationMutation() { + return useGovernanceMutation({ + method: "extendDelegation", + buildTag: (p: any) => + `gov-extendDelegation-${hashTagParams({ position: p.positionMint })}`, + }); +} + +export function useUndelegatePositionMutation() { + return useGovernanceMutation({ + method: "undelegatePosition", + buildTag: (p: any) => + `gov-undelegate-${hashTagParams({ position: p.positionMint })}`, + }); +} + +export function useClaimRewardsMutation() { + return useGovernanceMutation({ + method: "claimDelegationRewards", + buildTag: (p: any) => + `gov-claimRewards-${hashTagParams({ + positions: p.positionMints.sort().join(","), + })}`, + }); +} + +// --- Voting Mutations --- + +export function useVoteMutation() { + return useGovernanceMutation({ + method: "vote", + buildTag: (p: any) => + `proposal-vote-${hashTagParams({ + proposal: p.proposalKey, + choice: p.choice, + })}`, + }); +} + +export function useRelinquishVoteMutation() { + return useGovernanceMutation({ + method: "relinquishVote", + buildTag: (p: any) => + `proposal-relinquish-${hashTagParams({ + proposal: p.proposalKey, + choice: p.choice, + })}`, + }); +} + +export function useRelinquishPositionVotesMutation() { + return useGovernanceMutation({ + method: "relinquishPositionVotes", + buildTag: (p: any) => + `gov-relinquish-${hashTagParams({ + position: p.positionMint, + organization: p.organization, + })}`, + }); +} + +// --- Proxy Mutations --- + +export function useAssignProxiesMutation() { + return useGovernanceMutation({ + method: "assignProxies", + buildTag: (p: any) => + `assign-proxy-${hashTagParams({ + proxyWallet: p.proxyKey, + expirationTime: p.expirationTime, + positions: p.positionMints.sort().join(","), + })}`, + }); +} + +export function useUnassignProxiesMutation() { + return useGovernanceMutation({ + method: "unassignProxies", + buildTag: (p: any) => + `revoke-proxy-${hashTagParams({ + proxyKey: p.proxyKey, + positions: p.positionMints.sort().join(","), + })}`, + }); +} diff --git a/src/hooks/useGovernanceSubmit.ts b/src/hooks/useGovernanceSubmit.ts new file mode 100644 index 00000000..1d7484d0 --- /dev/null +++ b/src/hooks/useGovernanceSubmit.ts @@ -0,0 +1,131 @@ +import type { TransactionData, TokenAmountOutput } from "@helium/blockchain-api"; +import { useQueryClient } from "@tanstack/react-query"; +import { useCallback } from "react"; +import { useWallet } from "./useWallet"; +import { useBlockchainApi } from "@/providers/BlockchainApiProvider"; +import { signTransactionData } from "@/utils/transactionUtils"; + +type BatchStatus = "pending" | "confirmed" | "failed" | "expired" | "partial"; + +const TERMINAL_STATUSES: BatchStatus[] = [ + "confirmed", + "failed", + "expired", + "partial", +]; + +type SubmittableResponse = { + transactionData: TransactionData; + estimatedSolFee?: TokenAmountOutput; + hasMore?: boolean; +}; + +export interface GovernanceSubmitOptions { + header: string; + message: string; + tag?: string; +} + +async function pollForCompletion( + client: ReturnType, + batchId: string, + pollIntervalMs = 2000, + maxPollTime = 60000 +): Promise<{ status: BatchStatus; signatures: string[] }> { + const startTime = Date.now(); + + while (Date.now() - startTime < maxPollTime) { + const result = await client.transactions.get({ + id: batchId, + commitment: "confirmed", + }); + + const status = result.status as BatchStatus; + + if (TERMINAL_STATUSES.includes(status)) { + return { + status, + signatures: result.transactions?.map((t) => t.signature) ?? [], + }; + } + + await new Promise((resolve) => setTimeout(resolve, pollIntervalMs)); + } + + throw new Error("Transaction polling timeout"); +} + +export function useGovernanceSubmit(): { + submit: ( + response: SubmittableResponse, + options: GovernanceSubmitOptions, + fetchMore?: () => Promise + ) => Promise<{ signatures: string[] }>; +} { + const wallet = useWallet(); + const client = useBlockchainApi(); + const queryClient = useQueryClient(); + + const submit = useCallback( + async ( + response: SubmittableResponse, + options: GovernanceSubmitOptions, + fetchMore?: () => Promise + ): Promise<{ signatures: string[] }> => { + if (!wallet.signAllTransactions) { + throw new Error("Wallet does not support signing transactions"); + } + + const allSignatures: string[] = []; + let current = response; + + // eslint-disable-next-line no-constant-condition + while (true) { + const { transactionData } = current; + + if (transactionData.transactions.length === 0) { + break; + } + + const signed = await signTransactionData( + { signAllTransactions: wallet.signAllTransactions }, + transactionData + ); + + const tag = options.tag || "governance"; + const taggedData = { ...signed, tag }; + + const { batchId } = await client.transactions.submit(taggedData); + queryClient.invalidateQueries({ + queryKey: ["pendingTransactions"], + }); + + const { status, signatures } = await pollForCompletion( + client, + batchId + ); + + if (status === "failed" || status === "partial") { + throw new Error("Transaction failed"); + } + + if (status === "expired") { + throw new Error("Transaction expired"); + } + + allSignatures.push(...signatures); + + if (!current.hasMore || !fetchMore) { + break; + } + + current = await fetchMore(); + } + + return { signatures: allSignatures }; + }, + [wallet, client, queryClient] + ); + + return { submit }; +} diff --git a/src/lib/utils.ts b/src/lib/utils.ts index 0e7bf823..873de1c9 100644 --- a/src/lib/utils.ts +++ b/src/lib/utils.ts @@ -1,28 +1,17 @@ import { AnchorProvider } from "@coral-xyz/anchor"; import { init as initProp } from "@helium/proposal-sdk"; -import { - HELIUM_COMMON_LUT, - HELIUM_COMMON_LUT_DEVNET, - batchInstructionsToTxsWithPriorityFee, - bulkSendTransactions, - sendAndConfirmWithRetry, - toVersionedTx, -} from "@helium/spl-utils"; import { PositionWithMeta } from "@helium/voter-stake-registry-hooks"; import { Mint } from "@solana/spl-token"; import { WalletAdapterNetwork } from "@solana/wallet-adapter-base"; import { Connection, - Keypair, PublicKey, - TransactionInstruction, clusterApiUrl, } from "@solana/web3.js"; import BN from "bn.js"; import { clsx, type ClassValue } from "clsx"; import { Metadata } from "next"; import { twMerge } from "tailwind-merge"; -import { MAX_TRANSACTIONS_PER_SIGNATURE_BATCH } from "./constants"; import { ILegacyProposal, ProposalState, ProposalV0 } from "./types"; export const DAYS_PER_YEAR = 365; @@ -293,104 +282,6 @@ export const precision = (a: number) => { return p; }; -export const onInstructions = - ( - provider?: AnchorProvider, - { - maxInstructionsPerTx, - useFirstEstimateForAll = false, - }: { maxInstructionsPerTx?: number; useFirstEstimateForAll?: boolean } = {} - ) => - async ( - instructions: TransactionInstruction[] | TransactionInstruction[][], - sigs?: Keypair[] - ) => { - if (useFirstEstimateForAll) { - // Sort instructions array so longest groups are first - instructions.sort((a, b) => { - const lengthA = Array.isArray(a) ? a.length : 1; - const lengthB = Array.isArray(b) ? b.length : 1; - return lengthB - lengthA; - }); - } - - // Sort instructions array so longest groups are first - instructions.sort((a, b) => { - const lengthA = Array.isArray(a) ? a.length : 1; - const lengthB = Array.isArray(b) ? b.length : 1; - return lengthB - lengthA; - }); - - if (provider) { - const computeScaleUp = 1.4; - - if (sigs) { - const transactions = await batchInstructionsToTxsWithPriorityFee( - provider, - instructions, - { - basePriorityFee: 2, - extraSigners: sigs, - addressLookupTableAddresses: [ - provider.connection.rpcEndpoint.includes("test") - ? HELIUM_COMMON_LUT_DEVNET - : HELIUM_COMMON_LUT, - ], - useFirstEstimateForAll, - computeScaleUp, - maxInstructionsPerTx, - } - ); - const asVersionedTx = transactions.map(toVersionedTx); - let i = 0; - for (const tx of await provider.wallet.signAllTransactions( - asVersionedTx - )) { - const draft = transactions[i]; - sigs.forEach((sig) => { - if (draft.signers?.some((s) => s.publicKey.equals(sig.publicKey))) { - tx.sign([sig]); - } - }); - - await sendAndConfirmWithRetry( - provider.connection, - Buffer.from(tx.serialize()), - { - skipPreflight: true, - }, - "confirmed" - ); - i++; - } - } else { - const transactions = await batchInstructionsToTxsWithPriorityFee( - provider, - instructions, - { - basePriorityFee: 2, - addressLookupTableAddresses: [ - provider.connection.rpcEndpoint.includes("test") - ? HELIUM_COMMON_LUT_DEVNET - : HELIUM_COMMON_LUT, - ], - useFirstEstimateForAll, - computeScaleUp, - maxInstructionsPerTx, - } - ); - - await bulkSendTransactions( - provider, - transactions, - undefined, - 5, - sigs, - MAX_TRANSACTIONS_PER_SIGNATURE_BATCH - ); - } - } - }; export const abbreviateNumber = (number: number) => { let newNumber = number; diff --git a/src/providers/BlockchainApiProvider.tsx b/src/providers/BlockchainApiProvider.tsx new file mode 100644 index 00000000..c3cff05c --- /dev/null +++ b/src/providers/BlockchainApiProvider.tsx @@ -0,0 +1,67 @@ +"use client"; + +import { apiContract } from "@helium/blockchain-api"; +import { createORPCClient, onError } from "@orpc/client"; +import { RPCLink } from "@orpc/client/fetch"; +import { ContractRouterClient } from "@orpc/contract"; +import React, { createContext, useContext, useMemo } from "react"; + +const BlockchainApiContext = createContext | null>(null); + +export const BlockchainApiProvider: React.FC = ({ + children, +}) => { + const client = useMemo(() => { + const url = process.env.NEXT_PUBLIC_HELIUM_TRANSACTION_API; + + if (!url || url.trim() === "") { + console.error( + "Missing blockchain API URL configuration. Please set NEXT_PUBLIC_HELIUM_TRANSACTION_API in your .env file." + ); + return null; + } + + try { + const link = new RPCLink({ + url: `${url.trim()}/rpc`, + headers: () => ({ + "Content-Type": "application/json", + }), + interceptors: [ + onError((error) => { + console.error(error); + }), + ], + }); + const orpcClient: ContractRouterClient = + createORPCClient(link); + + return orpcClient; + } catch (error) { + console.error("Failed to create blockchain API client:", error); + return null; + } + }, []); + + return ( + + {children} + + ); +}; + +export const useBlockchainApi = (): ContractRouterClient< + typeof apiContract +> => { + const context = useContext(BlockchainApiContext); + + if (!context) { + throw new Error( + "useBlockchainApi must be used within a BlockchainApiProvider. Make sure NEXT_PUBLIC_HELIUM_TRANSACTION_API is set in your .env file." + ); + } + + return context; +}; diff --git a/src/providers/providers.tsx b/src/providers/providers.tsx index 92fc150d..69ada154 100644 --- a/src/providers/providers.tsx +++ b/src/providers/providers.tsx @@ -3,6 +3,7 @@ import React, { FC } from "react"; import { WalletProvider, WalletAdapterProvider } from "./WalletProvider"; import { AccountProvider } from "./AccountProvider"; +import { BlockchainApiProvider } from "./BlockchainApiProvider"; import { GovernanceProvider } from "./GovernanceProvider"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; @@ -23,7 +24,9 @@ export const Providers: FC = ({ children }) => { - {children} + + {children} + diff --git a/src/types/orpc.d.ts b/src/types/orpc.d.ts new file mode 100644 index 00000000..843a245f --- /dev/null +++ b/src/types/orpc.d.ts @@ -0,0 +1,18 @@ +declare module "@orpc/client" { + export function createORPCClient(link: any): any; + export function onError(handler: (error: any) => void): any; +} + +declare module "@orpc/client/fetch" { + export class RPCLink { + constructor(options: { + url: string; + headers: () => Record; + interceptors?: any[]; + }); + } +} + +declare module "@orpc/contract" { + export type ContractRouterClient = any; +} diff --git a/src/utils/transactionUtils.ts b/src/utils/transactionUtils.ts new file mode 100644 index 00000000..25fa3b4d --- /dev/null +++ b/src/utils/transactionUtils.ts @@ -0,0 +1,47 @@ +import { VersionedTransaction, Transaction } from "@solana/web3.js"; +import type { TransactionData } from "@helium/blockchain-api"; +import { createHash } from "crypto"; + +interface WalletSigner { + signAllTransactions: ( + txs: T[] + ) => Promise; +} + +// Sign all transactions in a TransactionData object +export async function signTransactionData( + wallet: WalletSigner, + transactionData: TransactionData +): Promise { + const signedTransactions = await wallet.signAllTransactions( + transactionData.transactions.map(({ serializedTransaction }) => + VersionedTransaction.deserialize( + Buffer.from(serializedTransaction, "base64") + ) + ) + ); + + return { + ...transactionData, + transactions: signedTransactions.map((tx, i) => ({ + serializedTransaction: Buffer.from(tx.serialize()).toString("base64"), + metadata: transactionData.transactions[i].metadata, + })), + }; +} + +/** + * Creates a short hash from action parameters for use in transaction tags + * @param params Object with string/number values to hash + * @returns Short hex hash (first 12 characters of sha256) + */ +export function hashTagParams( + params: Record +): string { + const paramsString = Object.entries(params) + .filter(([_, value]) => value !== undefined) + .sort(([a], [b]) => a.localeCompare(b)) + .map(([key, value]) => `${key}:${value}`) + .join(","); + return createHash("sha256").update(paramsString).digest("hex").slice(0, 12); +} diff --git a/yarn.lock b/yarn.lock index ca1cf672..0f185110 100644 --- a/yarn.lock +++ b/yarn.lock @@ -318,6 +318,14 @@ bs58 "^4.0.1" react-async-hook "^4.0.0" +"@helium/blockchain-api@^0.11.17": + version "0.11.17" + resolved "https://registry.yarnpkg.com/@helium/blockchain-api/-/blockchain-api-0.11.17.tgz#586e17be6d4e079af049f98911a8599705ee0b43" + integrity sha512-ajaxZrdCJQ6N4U1034BusYOp5dSFooVeoMii38lTv23qyH2rfhUk+ASsGhxSf2NyDB3myAQYBdBvR1MglysxDQ== + dependencies: + "@orpc/contract" "^1.13.2" + zod "^4.3.5" + "@helium/circuit-breaker-sdk@^0.11.5": version "0.11.5" resolved "https://registry.yarnpkg.com/@helium/circuit-breaker-sdk/-/circuit-breaker-sdk-0.11.5.tgz#10ef7e71fec85603facb4d494b41a4b3ab6946bb" @@ -1056,6 +1064,57 @@ resolved "https://registry.yarnpkg.com/@nolyfill/is-core-module/-/is-core-module-1.0.39.tgz#3dc35ba0f1e66b403c00b39344f870298ebb1c8e" integrity sha512-nn5ozdjYQpUCZlWGuxcJY/KpxkWQs4DcbMCmKojjyrYDEAGy4Ce19NN4v5MduafTwJlbKc99UA8YhSVqq9yPZA== +"@orpc/client@1.13.13", "@orpc/client@^1.13.4": + version "1.13.13" + resolved "https://registry.yarnpkg.com/@orpc/client/-/client-1.13.13.tgz#2abcc4709226fa81996c02ab937655b1afc5ab82" + integrity sha512-jagx/Sa+9K4HEC5lBrUlMSrmR/06hvZctWh93/sKZc8GBk4zM0+71oT1kXQVw1oRYFV2XAq3xy3m6NdM6gfKYA== + dependencies: + "@orpc/shared" "1.13.13" + "@orpc/standard-server" "1.13.13" + "@orpc/standard-server-fetch" "1.13.13" + "@orpc/standard-server-peer" "1.13.13" + +"@orpc/contract@^1.13.2", "@orpc/contract@^1.13.4": + version "1.13.13" + resolved "https://registry.yarnpkg.com/@orpc/contract/-/contract-1.13.13.tgz#f95ebf1f435998d065f11fce49dce861113aa538" + integrity sha512-md6iyrYkePBSJNs1VnVEEnAUORMDPHIf3JGRSHxyssIcNakev/iOjP0HvpH0Sx0MlTBhihAJo6uFL8Vpth58Nw== + dependencies: + "@orpc/client" "1.13.13" + "@orpc/shared" "1.13.13" + "@standard-schema/spec" "^1.1.0" + openapi-types "^12.1.3" + +"@orpc/shared@1.13.13": + version "1.13.13" + resolved "https://registry.yarnpkg.com/@orpc/shared/-/shared-1.13.13.tgz#44b0db1ad5a0f08ba8ebb520b003d09568d0323e" + integrity sha512-kNpYOBjHvmgKHla6munWOaEeA0utEfAvoiZpXjiRjjt1RxTibdwQvVHgxRIBNMXfQsb+ON3Q/wDkoaUhvvSnIw== + dependencies: + radash "^12.1.1" + type-fest "^5.4.4" + +"@orpc/standard-server-fetch@1.13.13": + version "1.13.13" + resolved "https://registry.yarnpkg.com/@orpc/standard-server-fetch/-/standard-server-fetch-1.13.13.tgz#da1d8dbd706aa67a13c860ee1da2d4b462c1e870" + integrity sha512-Lffy26+WtCQkwOUacsrdyeJF1GNzrhm75O3LXKVFXqmSdyVVdyI6zuqLn/YKGODU2L9IqGxZ2CwsV2tE298SSA== + dependencies: + "@orpc/shared" "1.13.13" + "@orpc/standard-server" "1.13.13" + +"@orpc/standard-server-peer@1.13.13": + version "1.13.13" + resolved "https://registry.yarnpkg.com/@orpc/standard-server-peer/-/standard-server-peer-1.13.13.tgz#0fdba351a92886f49a0aeb0ee54650de8621cd22" + integrity sha512-FeWAbXfnZDPYQRajM0hD6GJvHeC3DZILngAjdcLHy5zt3riu6nL2lLPSWDv5yNWWscmYU+CfKmXWd0Z01BOeWA== + dependencies: + "@orpc/shared" "1.13.13" + "@orpc/standard-server" "1.13.13" + +"@orpc/standard-server@1.13.13": + version "1.13.13" + resolved "https://registry.yarnpkg.com/@orpc/standard-server/-/standard-server-1.13.13.tgz#c6db062804d08aff9c736646146e07025418addc" + integrity sha512-9pgS8XvauuRQElkyuD8F3om+nN0KBEnTkhblDHCBzkZERjWkmfirJmshQrWHoFaDTk+nnXHIaY6d7TBTxXdPRw== + dependencies: + "@orpc/shared" "1.13.13" + "@parcel/watcher-android-arm64@2.5.0": version "2.5.0" resolved "https://registry.yarnpkg.com/@parcel/watcher-android-arm64/-/watcher-android-arm64-2.5.0.tgz#e32d3dda6647791ee930556aee206fcd5ea0fb7a" @@ -3703,6 +3762,11 @@ "@solana/web3.js" "^1.73.2" bn.js "^5.2.1" +"@standard-schema/spec@^1.1.0": + version "1.1.0" + resolved "https://registry.yarnpkg.com/@standard-schema/spec/-/spec-1.1.0.tgz#a79b55dbaf8604812f52d140b2c9ab41bc150bb8" + integrity sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w== + "@stellar/js-xdr@^3.1.2": version "3.1.2" resolved "https://registry.yarnpkg.com/@stellar/js-xdr/-/js-xdr-3.1.2.tgz#db7611135cf21e989602fd72f513c3bed621bc74" @@ -8865,6 +8929,11 @@ onetime@^6.0.0: dependencies: mimic-fn "^4.0.0" +openapi-types@^12.1.3: + version "12.1.3" + resolved "https://registry.yarnpkg.com/openapi-types/-/openapi-types-12.1.3.tgz#471995eb26c4b97b7bd356aacf7b91b73e777dd3" + integrity sha512-N4YtSYJqghVu4iek2ZUvcN/0aqH1kRDuNqzcycDxhOUpg7GdvLa2F3DgS6yBNhInhv2r/6I0Flkn7CqL8+nIcw== + optionator@^0.9.3: version "0.9.4" resolved "https://registry.yarnpkg.com/optionator/-/optionator-0.9.4.tgz#7ea1c1a5d91d764fb282139c88fe11e182a3a734" @@ -9452,6 +9521,11 @@ quick-format-unescaped@^4.0.3: resolved "https://registry.yarnpkg.com/quick-format-unescaped/-/quick-format-unescaped-4.0.4.tgz#93ef6dd8d3453cbc7970dd614fad4c5954d6b5a7" integrity sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg== +radash@^12.1.1: + version "12.1.1" + resolved "https://registry.yarnpkg.com/radash/-/radash-12.1.1.tgz#4858a08a04318a07d6d92d17d11b7528938edd93" + integrity sha512-h36JMxKRqrAxVD8201FrCpyeNuUY9Y5zZwujr20fFO77tpUtGa6EZzfKw/3WaiBX95fq7+MpsuMLNdSnORAwSA== + radix3@^1.1.2: version "1.1.2" resolved "https://registry.yarnpkg.com/radix3/-/radix3-1.1.2.tgz#fd27d2af3896c6bf4bcdfab6427c69c2afc69ec0" @@ -10307,6 +10381,11 @@ system-architecture@^0.1.0: resolved "https://registry.yarnpkg.com/system-architecture/-/system-architecture-0.1.0.tgz#71012b3ac141427d97c67c56bc7921af6bff122d" integrity sha512-ulAk51I9UVUyJgxlv9M6lFot2WP3e7t8Kz9+IS6D4rVba1tR9kON+Ey69f+1R4Q8cd45Lod6a4IcJIxnzGc/zA== +tagged-tag@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/tagged-tag/-/tagged-tag-1.0.0.tgz#a0b5917c2864cba54841495abfa3f6b13edcf4d6" + integrity sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng== + tailwind-merge@^2.2.1: version "2.5.5" resolved "https://registry.yarnpkg.com/tailwind-merge/-/tailwind-merge-2.5.5.tgz#98167859b856a2a6b8d2baf038ee171b9d814e39" @@ -10516,6 +10595,13 @@ type-fest@^0.20.2: resolved "https://registry.yarnpkg.com/type-fest/-/type-fest-0.20.2.tgz#1bf207f4b28f91583666cb5fbd327887301cd5f4" integrity sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ== +type-fest@^5.4.4: + version "5.5.0" + resolved "https://registry.yarnpkg.com/type-fest/-/type-fest-5.5.0.tgz#78fca72f3a1f9ec964e6ae260db492b070c56f3b" + integrity sha512-PlBfpQwiUvGViBNX84Yxwjsdhd1TUlXr6zjX7eoirtCPIr08NAmxwa+fcYBTeRQxHo9YC9wwF3m9i700sHma8g== + dependencies: + tagged-tag "^1.0.0" + typed-array-buffer@^1.0.2: version "1.0.2" resolved "https://registry.yarnpkg.com/typed-array-buffer/-/typed-array-buffer-1.0.2.tgz#1867c5d83b20fcb5ccf32649e5e2fc7424474ff3" @@ -11225,6 +11311,11 @@ zod@^3.24.4: resolved "https://registry.yarnpkg.com/zod/-/zod-3.25.76.tgz#26841c3f6fd22a6a2760e7ccb719179768471e34" integrity sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ== +zod@^4.3.5: + version "4.3.6" + resolved "https://registry.yarnpkg.com/zod/-/zod-4.3.6.tgz#89c56e0aa7d2b05107d894412227087885ab112a" + integrity sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg== + zustand@5.0.3: version "5.0.3" resolved "https://registry.yarnpkg.com/zustand/-/zustand-5.0.3.tgz#b323435b73d06b2512e93c77239634374b0e407f"