Your transaction is processing.
diff --git a/src/packages/v4v5/components/modals/V4V5TokenHoldersModal/TokenDistributionChart/TokenPieChart.tsx b/src/packages/v4v5/components/modals/V4V5TokenHoldersModal/TokenDistributionChart/TokenPieChart.tsx
index ab0a3b5d75..a4714663b1 100644
--- a/src/packages/v4v5/components/modals/V4V5TokenHoldersModal/TokenDistributionChart/TokenPieChart.tsx
+++ b/src/packages/v4v5/components/modals/V4V5TokenHoldersModal/TokenDistributionChart/TokenPieChart.tsx
@@ -106,6 +106,7 @@ export default function TokenPieChart({
innerRadius={size / 2 - 60}
stroke={stroke}
minAngle={1.5}
+ isAnimationActive={false}
>
{pieChartData.map((entry, index) => {
let fill: string
diff --git a/src/packages/v4v5/contexts/V4V5ProjectMetadataProvider.tsx b/src/packages/v4v5/contexts/V4V5ProjectMetadataProvider.tsx
index 67f1f41e7d..5ec7e5f491 100644
--- a/src/packages/v4v5/contexts/V4V5ProjectMetadataProvider.tsx
+++ b/src/packages/v4v5/contexts/V4V5ProjectMetadataProvider.tsx
@@ -15,6 +15,7 @@ export default function V4V5ProjectMetadataProvider({
const { metadata } = useJBProjectMetadataContext()
const projectMetadata = metadata?.data ?? undefined
+ const isLoading = metadata?.isLoading ?? true
const isArchived = false
@@ -25,6 +26,7 @@ export default function V4V5ProjectMetadataProvider({
isArchived,
projectId: _projectId,
pv: PV_V4,
+ isLoading,
refetchProjectMetadata: () => null, // TODO
}}
>
diff --git a/src/packages/v4v5/graphql/queries/activityEvents.graphql b/src/packages/v4v5/graphql/queries/activityEvents.graphql
index 451c364d96..cb67a3a31d 100644
--- a/src/packages/v4v5/graphql/queries/activityEvents.graphql
+++ b/src/packages/v4v5/graphql/queries/activityEvents.graphql
@@ -27,6 +27,7 @@ query ActivityEvents(
token
currency
decimals
+ version
}
payEvent {
id
diff --git a/src/packages/v4v5/hooks/useDeployOmnichainErc20.ts b/src/packages/v4v5/hooks/useDeployOmnichainErc20.ts
index 1b81c61657..eef2fc08a1 100644
--- a/src/packages/v4v5/hooks/useDeployOmnichainErc20.ts
+++ b/src/packages/v4v5/hooks/useDeployOmnichainErc20.ts
@@ -5,6 +5,7 @@ import { ContractFunctionArgs, encodeFunctionData } from 'viem'
import { useWallet } from 'hooks/Wallet'
import { Address } from 'viem'
import { useV4V5Version } from '../contexts/V4V5VersionProvider'
+import { estimateContractGasWithFallback, OMNICHAIN_GAS_FALLBACKS } from '../utils/estimateOmnichainGas'
export function useDeployOmnichainErc20() {
const { userAddress } = useWallet()
@@ -23,41 +24,46 @@ export function useDeployOmnichainErc20() {
},
chainIds: JBChainId[],
) {
- if (!userAddress) return
+ if (!userAddress || !projectControllerAddress) return
- const relayrTransactions = chainIds.map(chainId => {
- const args = deployData[chainId]
- let encoded
- if (!args) throw new Error('No deploy data for chain ' + chainId)
-
- if (version === 4 && projectControllerAddress === jbContractAddress['4'][JBCoreContracts.JBController4_1][chainId]) {
- // Use v4.1 controller ABI
- encoded = encodeFunctionData({
- abi: jbController4_1Abi,
+ const relayrTransactions = await Promise.all(
+ chainIds.map(async chainId => {
+ const args = deployData[chainId]
+ if (!args) throw new Error('No deploy data for chain ' + chainId)
+
+ const useV41Abi = version === 4 && projectControllerAddress === jbContractAddress['4'][JBCoreContracts.JBController4_1][chainId]
+ const abi = useV41Abi ? jbController4_1Abi : jbControllerAbi
+
+ const to = projectControllerAddress as Address
+
+ const gas = await estimateContractGasWithFallback({
+ chainId,
+ contractAddress: to,
+ abi,
functionName: 'deployERC20For',
args,
+ userAddress,
+ fallbackGas: OMNICHAIN_GAS_FALLBACKS.DEPLOY_ERC20,
})
- } else {
- // Use v4 controller ABI
- encoded = encodeFunctionData({
- abi: jbControllerAbi,
+
+ const encoded = encodeFunctionData({
+ abi,
functionName: 'deployERC20For',
args,
})
- }
- const to = projectControllerAddress as Address
- return {
- data: {
- from: userAddress,
- to,
- value: 0n,
- gas: 300_000n * BigInt(chainIds.length),
- data: encoded,
- },
- chainId,
- }
- })
+ return {
+ data: {
+ from: userAddress,
+ to,
+ value: 0n,
+ gas,
+ data: encoded,
+ },
+ chainId,
+ }
+ })
+ )
return getRelayrTxQuote(relayrTransactions)
}
diff --git a/src/packages/v4v5/hooks/useETHReceivedFromTokens.ts b/src/packages/v4v5/hooks/useETHReceivedFromTokens.ts
index e62415e21e..4e9f9bf3fc 100644
--- a/src/packages/v4v5/hooks/useETHReceivedFromTokens.ts
+++ b/src/packages/v4v5/hooks/useETHReceivedFromTokens.ts
@@ -16,11 +16,12 @@ export function useETHReceivedFromTokens(
const { projectId, contracts, contractAddress } = useJBContractContext()
const { data: totalSupply } = useReadContract({
abi: jbTokensAbi,
- address: contractAddress(JBCoreContracts.JBTokens),
+ address: contractAddress(JBCoreContracts.JBTokens, chainId),
functionName: 'totalSupplyOf',
args: [projectId],
+ chainId,
})
- const { data: nativeTokenSurplus } = useNativeTokenSurplus()
+ const { data: nativeTokenSurplus } = useNativeTokenSurplus({ chainId })
const { rulesetMetadata } = useJBRulesetByChain(chainId)
const { data: tokensReserved } = useReadContract({
abi: jbControllerAbi,
diff --git a/src/packages/v4v5/hooks/useJBAllRulesetsCrossChain.ts b/src/packages/v4v5/hooks/useJBAllRulesetsCrossChain.ts
index f4765818f8..5c6cc65ed5 100644
--- a/src/packages/v4v5/hooks/useJBAllRulesetsCrossChain.ts
+++ b/src/packages/v4v5/hooks/useJBAllRulesetsCrossChain.ts
@@ -1,16 +1,24 @@
-import { CashOutTaxRate, ReservedPercent, RulesetWeight, WeightCutPercent, jbControllerAbi, jbContractAddress, JBCoreContracts } from "juice-sdk-core"
+import { CashOutTaxRate, ReservedPercent, RulesetWeight, WeightCutPercent, jbControllerAbi, jbContractAddress, JBCoreContracts, JBRulesetData, JBRulesetMetadata } from "juice-sdk-core"
import { useReadContract } from "wagmi"
import { JBChainId } from "juice-sdk-react"
import { useV4V5Version } from '../contexts/V4V5VersionProvider'
+export type RulesetWithMetadata = {
+ ruleset: JBRulesetData
+ metadata: JBRulesetMetadata
+}
+
export function useJBAllRulesetsCrossChain({
projectId,
- rulesetNumber,
- chainId
+ startingId,
+ chainId,
+ size = 10n,
}: {
projectId: bigint
- rulesetNumber: bigint
+ /** The ruleset ID to start fetching from (going backwards). Use ruleset.id, not cycleNumber. */
+ startingId: bigint
chainId: JBChainId
+ size?: bigint
}) {
const { version } = useV4V5Version()
// For v4, use JBController4_1. For v5, use standard JBController
@@ -18,26 +26,26 @@ export function useJBAllRulesetsCrossChain({
? jbContractAddress['4'][JBCoreContracts.JBController4_1][chainId]
: jbContractAddress['5'][JBCoreContracts.JBController][chainId]
- const { data, isLoading } = useReadContract({
+ const { data, isLoading, refetch } = useReadContract({
abi: jbControllerAbi,
address: controllerAddress,
functionName: 'allRulesetsOf',
args: [
projectId,
- rulesetNumber,
- 10n, // size (The maximum number of rulesets to return). Arbritrarily set
+ startingId,
+ size,
],
chainId
})
- if (!data) return { data: undefined, isLoading }
+ if (!data) return { data: undefined, isLoading, refetch }
return {
- data: data?.map((obj) => ({
+ data: data?.map((obj): RulesetWithMetadata => ({
ruleset: {
- ...obj.ruleset,
- weight: new RulesetWeight(obj.ruleset.weight),
- weightCutPercent: new WeightCutPercent(obj.ruleset.weightCutPercent),
+ ...obj.ruleset,
+ weight: new RulesetWeight(obj.ruleset.weight),
+ weightCutPercent: new WeightCutPercent(obj.ruleset.weightCutPercent),
},
metadata: {
...obj.metadata,
@@ -45,6 +53,7 @@ export function useJBAllRulesetsCrossChain({
reservedPercent: new ReservedPercent(obj.metadata.reservedPercent)
}
})),
- isLoading
+ isLoading,
+ refetch,
}
}
diff --git a/src/packages/v4v5/hooks/useJBRulesetHistory.ts b/src/packages/v4v5/hooks/useJBRulesetHistory.ts
new file mode 100644
index 0000000000..92ebd406f8
--- /dev/null
+++ b/src/packages/v4v5/hooks/useJBRulesetHistory.ts
@@ -0,0 +1,305 @@
+import { JBRulesetData, JBRulesetMetadata } from 'juice-sdk-core'
+import { useJBProjectId, useJBRuleset } from 'juice-sdk-react'
+import { useCallback, useEffect, useMemo, useState } from 'react'
+import { useJBAllRulesetsCrossChain, RulesetWithMetadata } from './useJBAllRulesetsCrossChain'
+import { useJBUpcomingRuleset } from './useJBUpcomingRuleset'
+import { useCyclesPanelSelectedChain } from '../views/V4V5ProjectDashboard/V4V5ProjectTabs/V4V5CyclesPayoutsPanel/contexts/CyclesPanelSelectedChainContext'
+
+export type CycleStatus = 'current' | 'upcoming' | 'past'
+
+export type CycleOption = {
+ cycleNumber: number
+ status: CycleStatus
+ start: number
+ duration: number
+}
+
+export type RulesetDiff = {
+ field: string
+ current: unknown
+ previous: unknown
+}
+
+const INITIAL_LOAD_SIZE = 20n
+const LOAD_MORE_SIZE = 10n
+
+export function useJBRulesetHistory() {
+ const { selectedChainId } = useCyclesPanelSelectedChain()
+ const { projectId } = useJBProjectId(selectedChainId)
+
+ const [selectedCycleNumber, setSelectedCycleNumber] = useState
(null)
+ const [loadedRulesets, setLoadedRulesets] = useState([])
+ const [hasMoreRulesets, setHasMoreRulesets] = useState(true)
+ const [isLoadingMore, setIsLoadingMore] = useState(false)
+ const [historicalDataLoaded, setHistoricalDataLoaded] = useState(false)
+
+ // Get current ruleset
+ const {
+ ruleset: currentRuleset,
+ rulesetMetadata: currentRulesetMetadata,
+ isLoading: currentRulesetLoading,
+ } = useJBRuleset({
+ chainId: selectedChainId,
+ projectId,
+ })
+
+ // Get upcoming ruleset
+ const {
+ ruleset: upcomingRuleset,
+ rulesetMetadata: upcomingRulesetMetadata,
+ isLoading: upcomingRulesetLoading,
+ } = useJBUpcomingRuleset(selectedChainId)
+
+ // Use the ruleset ID (not cycleNumber) for fetching historical rulesets
+ // The allRulesetsOf contract method takes startingId which is the ruleset.id
+ const currentRulesetId = currentRuleset?.id ?? 0
+
+ // Fetch historical rulesets starting from current ruleset ID (going backwards)
+ const { data: historicalRulesets, isLoading: historicalRulesetsLoading } =
+ useJBAllRulesetsCrossChain({
+ projectId: BigInt(projectId ?? 0),
+ startingId: BigInt(currentRulesetId || 1),
+ chainId: selectedChainId!,
+ size: INITIAL_LOAD_SIZE,
+ })
+
+ // Initialize loaded rulesets when historical data is fetched (only once)
+ useEffect(() => {
+ if (historicalRulesets && historicalRulesets.length > 0 && !historicalDataLoaded) {
+ setLoadedRulesets(historicalRulesets)
+ setHistoricalDataLoaded(true)
+ // If we got fewer rulesets than requested, there are no more to load
+ setHasMoreRulesets(historicalRulesets.length >= Number(INITIAL_LOAD_SIZE))
+ }
+ }, [historicalRulesets, historicalDataLoaded])
+
+ // Set default selected cycle to current when data loads
+ useEffect(() => {
+ if (currentRuleset && selectedCycleNumber === null) {
+ setSelectedCycleNumber(currentRuleset.cycleNumber)
+ }
+ }, [currentRuleset, selectedCycleNumber])
+
+ // Determine if upcoming ruleset is different from current
+ const hasUpcoming = useMemo(() => {
+ if (!upcomingRuleset || !currentRuleset) return false
+ return upcomingRuleset.cycleNumber > currentRuleset.cycleNumber
+ }, [upcomingRuleset, currentRuleset])
+
+ // Build the list of available cycles for the dropdown
+ const cycleOptions = useMemo((): CycleOption[] => {
+ const options: CycleOption[] = []
+
+ // Add upcoming if available
+ if (hasUpcoming && upcomingRuleset) {
+ options.push({
+ cycleNumber: upcomingRuleset.cycleNumber,
+ status: 'upcoming',
+ start: upcomingRuleset.start,
+ duration: upcomingRuleset.duration,
+ })
+ }
+
+ // Add current
+ if (currentRuleset) {
+ options.push({
+ cycleNumber: currentRuleset.cycleNumber,
+ status: 'current',
+ start: currentRuleset.start,
+ duration: currentRuleset.duration,
+ })
+ }
+
+ // Add historical from loaded rulesets (skip current as it's already added)
+ loadedRulesets.forEach(({ ruleset }) => {
+ if (ruleset.cycleNumber < (currentRuleset?.cycleNumber ?? 0)) {
+ options.push({
+ cycleNumber: ruleset.cycleNumber,
+ status: 'past',
+ start: ruleset.start,
+ duration: ruleset.duration,
+ })
+ }
+ })
+
+ // Sort by cycle number descending (newest first)
+ return options.sort((a, b) => b.cycleNumber - a.cycleNumber)
+ }, [hasUpcoming, upcomingRuleset, currentRuleset, loadedRulesets])
+
+ // Get the currently selected ruleset data
+ const selectedRuleset = useMemo((): {
+ ruleset: JBRulesetData | undefined
+ metadata: JBRulesetMetadata | undefined
+ status: CycleStatus
+ } => {
+ if (!selectedCycleNumber) {
+ return { ruleset: undefined, metadata: undefined, status: 'current' }
+ }
+
+ // Check if it's upcoming
+ if (hasUpcoming && upcomingRuleset?.cycleNumber === selectedCycleNumber) {
+ return {
+ ruleset: upcomingRuleset,
+ metadata: upcomingRulesetMetadata,
+ status: 'upcoming',
+ }
+ }
+
+ // Check if it's current
+ if (currentRuleset?.cycleNumber === selectedCycleNumber) {
+ return {
+ ruleset: currentRuleset,
+ metadata: currentRulesetMetadata,
+ status: 'current',
+ }
+ }
+
+ // Find in historical
+ const historical = loadedRulesets.find(
+ r => r.ruleset.cycleNumber === selectedCycleNumber,
+ )
+ if (historical) {
+ return {
+ ruleset: historical.ruleset,
+ metadata: historical.metadata,
+ status: 'past',
+ }
+ }
+
+ return { ruleset: undefined, metadata: undefined, status: 'past' }
+ }, [
+ selectedCycleNumber,
+ hasUpcoming,
+ upcomingRuleset,
+ upcomingRulesetMetadata,
+ currentRuleset,
+ currentRulesetMetadata,
+ loadedRulesets,
+ ])
+
+ // Get the previous ruleset for diff comparison
+ const previousRuleset = useMemo((): RulesetWithMetadata | undefined => {
+ if (!selectedCycleNumber) return undefined
+
+ // Find the ruleset with cycle number - 1
+ const prevCycleNumber = selectedCycleNumber - 1
+ if (prevCycleNumber < 1) return undefined
+
+ // Check if it's in loaded rulesets
+ return loadedRulesets.find(r => r.ruleset.cycleNumber === prevCycleNumber)
+ }, [selectedCycleNumber, loadedRulesets])
+
+ // Load more historical rulesets
+ const loadMoreRulesets = useCallback(async () => {
+ if (!hasMoreRulesets || isLoadingMore || loadedRulesets.length === 0) return
+
+ setIsLoadingMore(true)
+
+ // Find the oldest loaded ruleset
+ const oldestCycle = Math.min(...loadedRulesets.map(r => r.ruleset.cycleNumber))
+
+ if (oldestCycle <= 1) {
+ setHasMoreRulesets(false)
+ setIsLoadingMore(false)
+ return
+ }
+
+ // Note: In a real implementation, you'd need to use a separate query here
+ // For now, we indicate that more can be loaded but the actual loading
+ // would require adjusting the hook to support dynamic fetching
+ setIsLoadingMore(false)
+ }, [hasMoreRulesets, isLoadingMore, loadedRulesets])
+
+ // Navigation functions
+ const goToNextCycle = useCallback(() => {
+ if (!selectedCycleNumber) return
+
+ const currentIndex = cycleOptions.findIndex(
+ opt => opt.cycleNumber === selectedCycleNumber,
+ )
+ if (currentIndex > 0) {
+ setSelectedCycleNumber(cycleOptions[currentIndex - 1].cycleNumber)
+ }
+ }, [selectedCycleNumber, cycleOptions])
+
+ const goToPreviousCycle = useCallback(() => {
+ if (!selectedCycleNumber) return
+
+ const currentIndex = cycleOptions.findIndex(
+ opt => opt.cycleNumber === selectedCycleNumber,
+ )
+ if (currentIndex < cycleOptions.length - 1) {
+ setSelectedCycleNumber(cycleOptions[currentIndex + 1].cycleNumber)
+ }
+ }, [selectedCycleNumber, cycleOptions])
+
+ const jumpToCycle = useCallback((cycleNumber: number) => {
+ setSelectedCycleNumber(cycleNumber)
+ }, [])
+
+ const goToCurrent = useCallback(() => {
+ if (currentRuleset) {
+ setSelectedCycleNumber(currentRuleset.cycleNumber)
+ }
+ }, [currentRuleset])
+
+ // Determine if navigation is possible
+ const canGoNext = useMemo(() => {
+ if (!selectedCycleNumber) return false
+ const currentIndex = cycleOptions.findIndex(
+ opt => opt.cycleNumber === selectedCycleNumber,
+ )
+ return currentIndex > 0
+ }, [selectedCycleNumber, cycleOptions])
+
+ const canGoPrevious = useMemo(() => {
+ if (!selectedCycleNumber) return false
+ const currentIndex = cycleOptions.findIndex(
+ opt => opt.cycleNumber === selectedCycleNumber,
+ )
+ return currentIndex < cycleOptions.length - 1
+ }, [selectedCycleNumber, cycleOptions])
+
+ const isLoading =
+ currentRulesetLoading || upcomingRulesetLoading || historicalRulesetsLoading
+
+ const totalCycles = currentRuleset?.cycleNumber ?? 0
+
+ return {
+ // Selected ruleset data
+ selectedRuleset: selectedRuleset.ruleset,
+ selectedRulesetMetadata: selectedRuleset.metadata,
+ selectedCycleNumber,
+ selectedCycleStatus: selectedRuleset.status,
+
+ // Previous ruleset for diff
+ previousRuleset: previousRuleset?.ruleset,
+ previousRulesetMetadata: previousRuleset?.metadata,
+
+ // Current ruleset reference
+ currentRuleset,
+ currentRulesetMetadata,
+ currentCycleNumber: currentRuleset?.cycleNumber,
+
+ // Upcoming ruleset
+ upcomingRuleset: hasUpcoming ? upcomingRuleset : undefined,
+ upcomingRulesetMetadata: hasUpcoming ? upcomingRulesetMetadata : undefined,
+ hasUpcoming,
+
+ // Navigation
+ cycleOptions,
+ canGoNext,
+ canGoPrevious,
+ goToNextCycle,
+ goToPreviousCycle,
+ jumpToCycle,
+ goToCurrent,
+ totalCycles,
+
+ // Loading states
+ isLoading,
+ isLoadingMore,
+ hasMoreRulesets,
+ loadMoreRulesets,
+ }
+}
diff --git a/src/packages/v4v5/hooks/useOmnichainEditProjectDetailsTx.ts b/src/packages/v4v5/hooks/useOmnichainEditProjectDetailsTx.ts
index ba249a7b86..e56e26fbf1 100644
--- a/src/packages/v4v5/hooks/useOmnichainEditProjectDetailsTx.ts
+++ b/src/packages/v4v5/hooks/useOmnichainEditProjectDetailsTx.ts
@@ -1,9 +1,10 @@
-import { JBChainId, createSalt, jbControllerAbi } from 'juice-sdk-core'
+import { JBChainId, jbControllerAbi } from 'juice-sdk-core'
import { useGetRelayrTxBundle, useGetRelayrTxQuote, useJBContractContext, useSendRelayrTx } from 'juice-sdk-react'
import { useWallet } from 'hooks/Wallet'
import { useSuckers } from 'juice-sdk-react'
import { encodeFunctionData } from 'viem'
+import { estimateContractGasWithFallback, OMNICHAIN_GAS_FALLBACKS } from '../utils/estimateOmnichainGas'
export function useOmnichainEditProjectDetailsTx() {
const { userAddress } = useWallet()
@@ -18,21 +19,35 @@ export function useOmnichainEditProjectDetailsTx() {
) {
if (!userAddress || !controllerAddress) return
if (!suckers || suckers.length === 0) throw new Error('No project chains available')
- const salt = createSalt()
- const txs = suckers.map(sucker => {
- const chainId = sucker.peerChainId as JBChainId
- const projectId = BigInt(sucker.projectId)
- const encoded = encodeFunctionData({
- abi: jbControllerAbi,
- functionName: 'setUriOf',
- args: [projectId, cid],
+
+ const txs = await Promise.all(
+ suckers.map(async sucker => {
+ const chainId = sucker.peerChainId as JBChainId
+ const projectId = BigInt(sucker.projectId)
+ const args = [projectId, cid] as const
+
+ const gas = await estimateContractGasWithFallback({
+ chainId,
+ contractAddress: controllerAddress as `0x${string}`,
+ abi: jbControllerAbi,
+ functionName: 'setUriOf',
+ args,
+ userAddress,
+ fallbackGas: OMNICHAIN_GAS_FALLBACKS.SET_URI,
+ })
+
+ const encoded = encodeFunctionData({
+ abi: jbControllerAbi,
+ functionName: 'setUriOf',
+ args,
+ })
+ const to = controllerAddress as `0x${string}`
+ return {
+ data: { from: userAddress, to, value: 0n, gas, data: encoded },
+ chainId,
+ }
})
- const to = controllerAddress as `0x${string}`
- return {
- data: { from: userAddress, to, value: 0n, gas: 200_000n * BigInt(suckers.length), data: encoded },
- chainId,
- }
- })
+ )
return getRelayrTxQuote(txs)
}
diff --git a/src/packages/v4v5/hooks/useTransferOmnichainProjectOwnership.ts b/src/packages/v4v5/hooks/useTransferOmnichainProjectOwnership.ts
index 5801f1e022..88b7a20467 100644
--- a/src/packages/v4v5/hooks/useTransferOmnichainProjectOwnership.ts
+++ b/src/packages/v4v5/hooks/useTransferOmnichainProjectOwnership.ts
@@ -4,6 +4,7 @@ import { Address, encodeFunctionData } from 'viem'
import { useWallet } from 'hooks/Wallet'
import { useV4V5Version } from '../contexts/V4V5VersionProvider'
+import { estimateContractGasWithFallback, OMNICHAIN_GAS_FALLBACKS } from '../utils/estimateOmnichainGas'
export function useTransferOmnichainProjectOwnership() {
const { userAddress } = useWallet()
@@ -23,33 +24,47 @@ export function useTransferOmnichainProjectOwnership() {
}
},
chainIds: JBChainId[],
- ) {
+ ) {
if (!userAddress) {
return
}
- const relayrTransactions = chainIds.map(chainId => {
- const args = transferData[chainId]
- if (!args) throw new Error('No transfer data for chain ' + chainId)
-
- const encoded = encodeFunctionData({
- abi: jbProjectsAbi,
- functionName: 'safeTransferFrom',
- args: [args.from, args.to, args.tokenId],
+ const relayrTransactions = await Promise.all(
+ chainIds.map(async chainId => {
+ const transferArgs = transferData[chainId]
+ if (!transferArgs) throw new Error('No transfer data for chain ' + chainId)
+
+ const args = [transferArgs.from, transferArgs.to, transferArgs.tokenId] as const
+ const to = jbContractAddress[versionString][JBCoreContracts.JBProjects][chainId] as Address
+
+ const gas = await estimateContractGasWithFallback({
+ chainId,
+ contractAddress: to,
+ abi: jbProjectsAbi,
+ functionName: 'safeTransferFrom',
+ args,
+ userAddress,
+ fallbackGas: OMNICHAIN_GAS_FALLBACKS.TRANSFER_OWNERSHIP,
+ })
+
+ const encoded = encodeFunctionData({
+ abi: jbProjectsAbi,
+ functionName: 'safeTransferFrom',
+ args,
+ })
+
+ return {
+ data: {
+ from: userAddress,
+ to,
+ value: 0n,
+ gas,
+ data: encoded,
+ },
+ chainId,
+ }
})
-
- const to = jbContractAddress[versionString][JBCoreContracts.JBProjects][chainId] as Address
- return {
- data: {
- from: userAddress,
- to,
- value: 0n,
- gas: 300_000n * BigInt(chainIds.length),
- data: encoded,
- },
- chainId,
- }
- })
+ )
const result = await getRelayrTxQuote(relayrTransactions)
return result
diff --git a/src/packages/v4v5/hooks/useV4V5WalletHasPermission.ts b/src/packages/v4v5/hooks/useV4V5WalletHasPermission.ts
index 4a8b469628..07c64823ee 100644
--- a/src/packages/v4v5/hooks/useV4V5WalletHasPermission.ts
+++ b/src/packages/v4v5/hooks/useV4V5WalletHasPermission.ts
@@ -57,10 +57,5 @@ export function useV4V5WalletHasPermission(
// - wallet is direct owner
// - wallet has explicit operator permission
// - wallet is a signer on the Safe that owns the project (covers multisig ownership case)
- // - in development environment
- return (
- isOwner ||
- hasOperatorPermission.data ||
- process.env.NODE_ENV === 'development'
- )
+ return isOwner || !!hasOperatorPermission.data
}
diff --git a/src/packages/v4v5/utils/estimateOmnichainGas.ts b/src/packages/v4v5/utils/estimateOmnichainGas.ts
new file mode 100644
index 0000000000..540a582749
--- /dev/null
+++ b/src/packages/v4v5/utils/estimateOmnichainGas.ts
@@ -0,0 +1,85 @@
+import { Abi, Address, Client, encodeFunctionData, PublicClient, Transport } from 'viem'
+import { estimateGas } from 'viem/actions'
+import { JBChainId } from 'juice-sdk-core'
+import { wagmiConfig } from 'contexts/Para/Providers'
+import { Chain } from 'viem/chains'
+
+const GAS_BUFFER_PERCENT = 120n // 20% buffer
+
+/**
+ * Fallback gas values per operation type.
+ * These are based on the original hardcoded values in the codebase,
+ * used when dynamic estimation fails.
+ */
+export const OMNICHAIN_GAS_FALLBACKS = {
+ SET_URI: 200_000n,
+ QUEUE_RULESETS: 200_000n,
+ DEPLOY_ERC20: 300_000n,
+ TRANSFER_OWNERSHIP: 300_000n,
+ LAUNCH_PROJECT: 1_000_000n,
+ LAUNCH_NFT_PROJECT: 3_000_000n,
+} as const
+
+export type EstimateGasParams = {
+ chainId: JBChainId
+ contractAddress: Address
+ abi: Abi
+ functionName: string
+ args: readonly unknown[]
+ userAddress: Address
+ fallbackGas: bigint
+}
+
+/**
+ * Estimates gas for a contract call on a specific chain.
+ * Applies a 20% buffer to the estimate.
+ * Falls back to the provided fallbackGas value if estimation fails.
+ */
+export async function estimateContractGasWithFallback({
+ chainId,
+ contractAddress,
+ abi,
+ functionName,
+ args,
+ userAddress,
+ fallbackGas,
+}: EstimateGasParams): Promise {
+ try {
+ const client = wagmiConfig.getClient({ chainId }) as Client
+
+ const data = encodeFunctionData({
+ abi,
+ functionName,
+ args,
+ })
+
+ const gasEstimate = await estimateGas(client as PublicClient, {
+ to: contractAddress,
+ data,
+ account: userAddress as Address,
+ })
+
+ // Apply 20% buffer
+ return (gasEstimate * GAS_BUFFER_PERCENT) / 100n
+ } catch {
+ // Silent fallback to default gas value
+ return fallbackGas
+ }
+}
+
+/**
+ * Estimates gas for multiple chains in parallel.
+ * Each chain estimation is independent and uses its own fallback.
+ */
+export async function estimateGasForChains(
+ chainEstimates: EstimateGasParams[],
+): Promise
+
+