Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 13 additions & 1 deletion src/app/proposals/[id]/components/VotingDetails.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,8 @@ import { useQueueProposal } from '@/shared/hooks/useQueueProposal'
import { useExecuteProposal } from '@/shared/hooks/useExecuteProposal'
import { waitForTransactionReceipt } from '@wagmi/core'
import { config } from '@/config'
import { executeTxFlow } from '@/shared/notification'
import { executeTxFlow, showToast } from '@/shared/notification'
import { useCheckTreasuryFunds } from '@/app/proposals/hooks/useCheckTreasuryFunds'
import { Vote } from '@/shared/types'
import { ProposalState } from '@/shared/types'
import Big from '@/lib/big'
Expand Down Expand Up @@ -84,6 +85,7 @@ export const VotingDetails = ({
const { onExecuteProposal, canProposalBeExecuted, proposalEta, proposalQueuedTime } =
useExecuteProposal(proposalId)
const [isExecuting, setIsExecuting] = useState(false)
const { hasEnoughFunds, missingAsset, isLoading: isLoadingFundsCheck } = useCheckTreasuryFunds(proposalId)

const [popoverOpen, setPopoverOpen] = useState(false)
const voteButtonRef = useRef<HTMLButtonElement>(null)
Expand Down Expand Up @@ -147,6 +149,16 @@ export const VotingDetails = ({
}

const handleExecuteProposal = async () => {
// Check if treasury has enough funds before executing
if (!isLoadingFundsCheck && !hasEnoughFunds && missingAsset) {
showToast({
severity: 'error',
title: 'Cannot Execute',
content: `The transaction will fail because the treasury does not have enough ${missingAsset}.`,
})
return
}

const txHash = await executeTxFlow({
onRequestTx: () => {
setIsExecuting(true)
Expand Down
182 changes: 182 additions & 0 deletions src/app/proposals/hooks/useCheckTreasuryFunds.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,182 @@
import { useReadContract, useReadContracts, useBalance } from 'wagmi'
import { useMemo } from 'react'
import { Address } from 'viem'
import { GovernorAbi } from '@/lib/abis/Governor'
import { GovernorAddress, tokenContracts } from '@/lib/contracts'
import { RIFTokenAbi } from '@/lib/abis/RIFTokenAbi'
import { isTreasuryContract, decodeTreasuryTransfer } from '../utils/treasuryFundsCheck'
import type { TreasuryTransferInfo } from '@/app/proposals/shared/treasuryUtils'
import { RIF, USDRIF } from '@/lib/constants'

export interface TreasuryFundsCheckResult {
hasEnoughFunds: boolean
isLoading: boolean
missingAsset?: string
}

/**
* Hook to check if the treasury has enough funds to execute a proposal
* Returns whether funds are sufficient and which asset is missing if not
*/
export const useCheckTreasuryFunds = (proposalId: string): TreasuryFundsCheckResult => {
// Read proposal details from Governor contract
const { data: proposalDetails, isLoading: isLoadingProposal } = useReadContract({
abi: GovernorAbi,
address: GovernorAddress,
functionName: 'proposalDetails',
args: [BigInt(proposalId)],
query: {
enabled: !!proposalId,
},
})

const [targets, , calldatas] = proposalDetails || [[], [], []]

// Find treasury interactions
const treasuryInteractions = useMemo(() => {
if (!targets || !calldatas) return []

const interactions: Array<{ target: Address; transferInfo: TreasuryTransferInfo }> = []

for (let i = 0; i < targets.length; i++) {
const target = targets[i] as Address
const calldata = calldatas[i] as string

if (isTreasuryContract(target) && calldata) {
const transferInfo = decodeTreasuryTransfer(calldata)
if (transferInfo) {
interactions.push({ target, transferInfo })
}
}
}

return interactions
}, [targets, calldatas])

// Get balances for all treasury contracts that are targets
const treasuryTargets = useMemo(() => {
if (!targets) return []
return Array.from(new Set(targets.filter(isTreasuryContract) as Address[]))
}, [targets])

// Get the first treasury target for rBTC balance (most proposals have one treasury target)
const firstTreasuryTarget = treasuryTargets[0]

// Get rBTC balance for the first treasury target
const { data: rbtcBalance, isLoading: isLoadingRBTC } = useBalance({
address: firstTreasuryTarget,
query: {
enabled: !!firstTreasuryTarget,
},
})

// Get ERC20 token balances for all treasury targets
const erc20BalanceContracts = treasuryTargets.flatMap(target => [
{
abi: RIFTokenAbi,
address: tokenContracts[RIF],
functionName: 'balanceOf' as const,
args: [target] as [Address],
},
{
abi: RIFTokenAbi,
address: tokenContracts[USDRIF],
functionName: 'balanceOf' as const,
args: [target] as [Address],
},
])

const { data: erc20Balances, isLoading: isLoadingERC20 } = useReadContracts({
contracts: erc20BalanceContracts,
query: {
enabled: erc20BalanceContracts.length > 0,
},
})

// Check if we have enough funds
const result = useMemo((): TreasuryFundsCheckResult => {
if (isLoadingProposal || isLoadingERC20 || isLoadingRBTC) {
return { hasEnoughFunds: true, isLoading: true }
}

if (treasuryInteractions.length === 0) {
// No treasury interactions, funds check passes
return { hasEnoughFunds: true, isLoading: false }
}

// Build a map of treasury target -> token -> balance
const balancesMap = new Map<Address, Map<string | null, bigint>>()

// Populate rBTC balance for the first treasury target
if (firstTreasuryTarget && rbtcBalance) {
if (!balancesMap.has(firstTreasuryTarget)) {
balancesMap.set(firstTreasuryTarget, new Map())
}
const tokenMap = balancesMap.get(firstTreasuryTarget)!
tokenMap.set(null, rbtcBalance.value || 0n) // null represents rBTC
}

// Populate ERC20 balances
if (erc20Balances) {
treasuryTargets.forEach((target, targetIndex) => {
if (!balancesMap.has(target)) {
balancesMap.set(target, new Map())
}
const tokenMap = balancesMap.get(target)!

// RIF balance is at index targetIndex * 2
const rifBalanceIndex = targetIndex * 2
const usdrifBalanceIndex = targetIndex * 2 + 1

if (erc20Balances[rifBalanceIndex]?.result) {
tokenMap.set(tokenContracts[RIF].toLowerCase(), erc20Balances[rifBalanceIndex].result as bigint)
}
if (erc20Balances[usdrifBalanceIndex]?.result) {
tokenMap.set(
tokenContracts[USDRIF].toLowerCase(),
erc20Balances[usdrifBalanceIndex].result as bigint,
)
}
})
}

// Check each treasury interaction
for (const { target, transferInfo } of treasuryInteractions) {
const tokenMap = balancesMap.get(target)
if (!tokenMap) {
// Can't find balance for this target, assume insufficient
return {
hasEnoughFunds: false,
missingAsset: transferInfo.tokenSymbol,
isLoading: false,
}
}

// Get the balance key
const balanceKey = transferInfo.tokenAddress ? transferInfo.tokenAddress.toLowerCase() : null // null for rBTC

const balance = tokenMap.get(balanceKey) || 0n

if (balance < transferInfo.amount) {
return {
hasEnoughFunds: false,
missingAsset: transferInfo.tokenSymbol,
isLoading: false,
}
}
}

return { hasEnoughFunds: true, isLoading: false }
}, [
isLoadingProposal,
isLoadingERC20,
isLoadingRBTC,
rbtcBalance,
treasuryInteractions,
treasuryTargets,
erc20Balances,
firstTreasuryTarget,
])

return result
}
80 changes: 80 additions & 0 deletions src/app/proposals/shared/treasuryUtils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
import { Address } from 'viem'
import { DecodedData } from './utils'
import { tokenContracts } from '@/lib/contracts'
import { RIF, RBTC, USDRIF, RIF_ADDRESS, USDRIF_ADDRESS, ENV, TRIF } from '@/lib/constants'

/**
* Maps token addresses to their symbols
* Used across the proposal flow for consistent token symbol resolution
*/
export const getTokenSymbolFromAddress = (tokenAddress: Address | string | null): string => {
if (!tokenAddress) return RBTC // null means rBTC

const normalizedAddress = tokenAddress.toLowerCase()

if (
normalizedAddress === tokenContracts[RIF].toLowerCase() ||
normalizedAddress === RIF_ADDRESS.toLowerCase()
) {
return ENV === 'testnet' ? TRIF : RIF
}

if (
normalizedAddress === tokenContracts[USDRIF].toLowerCase() ||
normalizedAddress === USDRIF_ADDRESS.toLowerCase()
) {
return USDRIF
}

return 'UNKNOWN'
}

export interface TreasuryTransferInfo {
tokenAddress: Address | null // null means rBTC
amount: bigint
tokenSymbol: string
}

/**
* Extracts treasury transfer information from decoded proposal calldata
* Returns null if the calldata is not a treasury withdrawal
*
* This function is used to extract transfer details for treasury fund checks
* and proposal action parsing.
*/
export const extractTreasuryTransferInfo = (decodedData: DecodedData): TreasuryTransferInfo | null => {
if (decodedData.type !== 'decoded') {
return null
}

const { functionName, args } = decodedData

if (functionName === 'withdraw') {
// withdraw(address payable to, uint256 amount)
// This is for rBTC
const amount = typeof args[1] === 'bigint' ? args[1] : BigInt(args[1]?.toString() || '0')
return {
tokenAddress: null, // null means rBTC
amount,
tokenSymbol: RBTC,
}
}

if (functionName === 'withdrawERC20') {
// withdrawERC20(address token, address to, uint256 amount)
const tokenAddress = typeof args[0] === 'string' ? (args[0] as Address) : null
const amount = typeof args[2] === 'bigint' ? args[2] : BigInt(args[2]?.toString() || '0')

if (!tokenAddress) {
return null
}

return {
tokenAddress,
amount,
tokenSymbol: getTokenSymbolFromAddress(tokenAddress),
}
}

return null
}
46 changes: 46 additions & 0 deletions src/app/proposals/utils/treasuryFundsCheck.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import { Address, decodeFunctionData } from 'viem'
import { DAOTreasuryAbi } from '@/lib/abis/DAOTreasuryAbi'
import { treasuryContracts } from '@/lib/contracts'
import { extractTreasuryTransferInfo, TreasuryTransferInfo } from '@/app/proposals/shared/treasuryUtils'
import { DecodedData } from '@/app/proposals/shared/utils'

/**
* Checks if an address is a treasury contract
* Uses the treasuryContracts mapping from contracts.ts
*/
export const isTreasuryContract = (address: Address): boolean => {
return Object.values(treasuryContracts).some(
contract => contract.address.toLowerCase() === address.toLowerCase(),
)
}

/**
* Decodes calldata and extracts treasury transfer information
* Uses decodeFunctionData with DAOTreasuryAbi to decode, then extracts transfer info
* using the shared extractTreasuryTransferInfo function
*/
export const decodeTreasuryTransfer = (calldata: string): TreasuryTransferInfo | null => {
try {
const decoded = decodeFunctionData({
data: calldata as `0x${string}`,
abi: DAOTreasuryAbi,
})

// Convert to DecodedData format for extractTreasuryTransferInfo
const decodedData: DecodedData = {
type: 'decoded',
functionName: decoded.functionName as any,
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Using any here

args: decoded.args as any,
inputs: [],
}

// Use the shared extractTreasuryTransferInfo function
return extractTreasuryTransferInfo(decodedData)
} catch (error) {
// If decoding fails, it's not a treasury function we recognize
return null
}
}

// Re-export for convenience
export type { TreasuryTransferInfo } from '@/app/proposals/shared/treasuryUtils'
Loading