diff --git a/src/plugins/oSnap/utils/abi.ts b/src/plugins/oSnap/utils/abi.ts index be3e76a1..8e26f485 100644 --- a/src/plugins/oSnap/utils/abi.ts +++ b/src/plugins/oSnap/utils/abi.ts @@ -1,15 +1,18 @@ -import { ABI } from '@/helpers/interfaces'; import { - FormatTypes, FunctionFragment, Interface, ParamType } from '@ethersproject/abi'; import { BigNumberish } from '@ethersproject/bignumber'; -import memoize from 'lodash/memoize'; +import { memoize } from 'lodash'; import { ERC20_ABI, ERC721_ABI, EXPLORER_API_URLS } from '../constants'; import { mustBeEthereumAddress, mustBeEthereumContractAddress } from './validators'; +/** + * Checks if the `parameter` of a contract method `method` takes an array or tuple as input, based on the `baseType` of the parameter. + * + * If this is the case, we must parse the value as JSON and verify that it is valid. + */ export function isArrayParameter(parameter: string): boolean { return ['tuple', 'array'].includes(parameter); } @@ -33,10 +36,9 @@ const fetchContractABI = memoize( (url, contractAddress) => `${url}_${contractAddress}` ); -export function parseMethodToABI(method: FunctionFragment) { - return [method.format(FormatTypes.full)]; -} - +/** + * Returns the ABI of a contract at the given address + */ export async function getContractABI( network: string, contractAddress: string @@ -71,11 +73,19 @@ export async function getContractABI( } } +/** + * Checks if a method is a write function. + * + * Only write functions can be executed by the Optimistic Governor. + */ export function isWriteFunction(method: FunctionFragment) { if (!method.stateMutability) return true; return !['view', 'pure'].includes(method.stateMutability); } +/** + * Returns the write functions of a contract ABI. + */ export function getABIWriteFunctions(abi: string) { const abiInterface = new Interface(abi); return ( @@ -90,6 +100,11 @@ export function getABIWriteFunctions(abi: string) { ); } +/** + * Handles the extraction of the method's arguments from the `values` array. + * + * If the parameter is an array or tuple, we parse the value as JSON. + */ function extractMethodArgs(values: string[]) { return (param: ParamType, index: number) => { const value = values[index]; @@ -100,6 +115,9 @@ function extractMethodArgs(values: string[]) { }; } +/** + * Encodes the method and parameters of a contract interaction. + */ export function encodeMethodAndParams( abi: string, method: FunctionFragment, @@ -110,6 +128,9 @@ export function encodeMethodAndParams( return contractInterface.encodeFunctionData(method, parameterValues); } +/** + * Returns the transaction data for an ERC20 transfer. + */ export function getERC20TokenTransferTransactionData( recipientAddress: string, amount: BigNumberish @@ -121,6 +142,9 @@ export function getERC20TokenTransferTransactionData( ]); } +/** + * Returns the transaction data for an ERC721 transfer. + */ export function getERC721TokenTransferTransactionData( fromAddress: string, recipientAddress: string, diff --git a/src/plugins/oSnap/utils/getters.ts b/src/plugins/oSnap/utils/getters.ts index e029d447..a51f58c9 100644 --- a/src/plugins/oSnap/utils/getters.ts +++ b/src/plugins/oSnap/utils/getters.ts @@ -17,9 +17,14 @@ import { contractData, safePrefixes } from '../constants'; -import { BalanceResponse, NFT, Network, OptimisticGovernorTransaction, SafeNetworkPrefix, Token } from '../types'; +import { BalanceResponse, NFT, Network, OptimisticGovernorTransaction, SafeNetworkPrefix } from '../types'; import { pageEvents } from './events'; +/** + * Calls the Gnosis Safe Transaction API + * + * Ideal usage is to specify the shape of the response with the generic type parameter, assuming that the shape of the response is known. + */ async function callGnosisSafeTransactionApi( network: Network, url: string @@ -29,6 +34,9 @@ async function callGnosisSafeTransactionApi( return response.json() as TResult; } +/** + * Fetches the balances of the tokens owned by a given Safe. + */ export const getGnosisSafeBalances = memoize( (network: Network, safeAddress: string) => { const endpointPath = `/v1/safes/${safeAddress}/balances/`; @@ -37,6 +45,9 @@ export const getGnosisSafeBalances = memoize( (safeAddress, network) => `${safeAddress}_${network}` ); +/** + * Fetches the collectibles owned by a given Safe. + */ export const getGnosisSafeCollectibles = memoize( (network: Network, safeAddress: string) => { const endpointPath = `/v2/safes/${safeAddress}/collectibles/`; @@ -51,14 +62,9 @@ export const getGnosisSafeCollectibles = memoize( (safeAddress, network) => `${safeAddress}_${network}` ); -export const getGnosisSafeToken = memoize( - async (network, tokenAddress): Promise => { - const endpointPath = `/v1/tokens/${tokenAddress}`; - return callGnosisSafeTransactionApi(network, endpointPath); - }, - (tokenAddress, network) => `${tokenAddress}_${network}` -); - +/** + * Fetches the block number of a given contract's deployment. + */ function getDeployBlock(params: { network: Network, name: string, @@ -67,10 +73,14 @@ function getDeployBlock(params: { if (results.length === 1) return results[0].deployBlock ?? 0; return 0; } + +/** + * Fetches the subgraph url for a given contract on a given network. + */ function getContractSubgraph(params: { network: Network; name: string; -}): string { +}) { const results = contractData.filter(contract => contract.network === params.network && contract.name === params.name) if (results.length > 1) throw new Error( @@ -86,13 +96,26 @@ function getContractSubgraph(params: { ); return results[0].subgraph; } + +/** + * A helper that wraps the getContractSubgraph function to return the subgraph url for the OptimisticGovernor contract on a given network. + */ function getOptimisticGovernorSubgraph(network: Network): string { return getContractSubgraph({ network, name: 'OptimisticGovernor' }); } + +/** + * A helper that wraps the getContractSubgraph function to return the subgraph url for the OptimisticOracleV3 contract on a given network. + */ function getOracleV3Subgraph(network: Network): string { return getContractSubgraph({ network, name: 'OptimisticOracleV3' }); } +/** + * Executes a graphql query. + * + * Ideal usage is to specify the shape of the response with the generic type parameter, assuming that the shape of the response is known. + */ export const queryGql = async (url: string, query: string) => { try { const response = await fetch(url, { @@ -122,6 +145,9 @@ export const queryGql = async (url: string, query: string) => { } }; +/** + * Returns the address of the Optimistic Governor contract deployment associated with a given treasury (Safe) from the graph. + */ export const getModuleAddressForTreasury = async (network: Network, treasuryAddress: string) => { const subgraph = getOptimisticGovernorSubgraph(network); const query = ` @@ -142,11 +168,9 @@ export const getModuleAddressForTreasury = async (network: Network, treasuryAddr return result?.safe?.optimisticGovernor?.id ?? ''; } -export const getSpaceHasOsnapEnabledTreasury = async (treasuries: TreasuryWallet[]) => { - const isOsnapEnabledOnTreasuriesResult = await Promise.all(treasuries.map(treasury => getIsOsnapEnabled(treasury.network as Network, treasury.address))) - return isOsnapEnabledOnTreasuriesResult.some(isOsnapEnabled => isOsnapEnabled); -} - +/** + * Checks if a given treasury (Safe) has enabled oSnap. + */ export const getIsOsnapEnabled = async ( network: Network, safeAddress: string @@ -166,6 +190,19 @@ export const getIsOsnapEnabled = async ( return result?.safe?.isOptimisticGovernorEnabled ?? false; }; +/** + * Takes an array of treasuries and checks if any of them have oSnap enabled. + */ +export const getSpaceHasOsnapEnabledTreasury = async (treasuries: TreasuryWallet[]) => { + const isOsnapEnabledOnTreasuriesResult = await Promise.all(treasuries.map(treasury => getIsOsnapEnabled(treasury.network as Network, treasury.address))) + return isOsnapEnabledOnTreasuriesResult.some(isOsnapEnabled => isOsnapEnabled); +} + +/** + * Creates the url for the Safe app to configure oSnap. + * + * The data that the Safe app needs is encoded as URL search params. + */ export function makeConfigureOsnapUrl(params: { safeAddress: string; network: Network; @@ -195,10 +232,11 @@ export function makeConfigureOsnapUrl(params: { return url; } -const findAssertionGql = async ( - network: Network, - params: { assertionId: string } -) => { +/** + * Fetches the details of a given assertion from the Optimistic Oracle V3 subgraph. + */ +async function findAssertionGql(network: Network, + params: { assertionId: string; }) { const oracleUrl = getOracleV3Subgraph(network); const request = ` { @@ -213,18 +251,16 @@ const findAssertionGql = async ( `; const result = await queryGql(oracleUrl, request); return result?.assertion; -}; -// Search optimistic governor for individual proposal -const findProposalGql = async ( - network: Network, - params: { proposalHash: string; explanation: string; ogAddress: string } -) => { +} +/** + * Fetches the details of a given proposal from the Optimistic Governor subgraph. + */ +async function findProposalGql(network: Network, + params: { proposalHash: string; explanation: string; ogAddress: string; }) { const subgraph = getOptimisticGovernorSubgraph(network); const request = ` { - proposals(where:{proposalHash:"${params.proposalHash}",explanation:"${ - params.explanation - }",optimisticGovernor:"${params.ogAddress.toLowerCase()}"}){ + proposals(where:{proposalHash:"${params.proposalHash}",explanation:"${params.explanation}",optimisticGovernor:"${params.ogAddress.toLowerCase()}"}){ id executed assertionId @@ -233,12 +269,13 @@ const findProposalGql = async ( `; const result = await queryGql(subgraph, request); return result?.proposals; -}; +} -const getBondDetails = async ( - provider: StaticJsonRpcProvider, - moduleAddress: string -) => { +/** + * Fetches details about a given Optimistic Governor contract deployment's bond, including the collateral token address, symbol, decimals, and the user's balance and allowance. + */ +async function getBondDetails(provider: StaticJsonRpcProvider, + moduleAddress: string) { const { web3Account } = useWeb3(); const moduleContract = new Contract(moduleAddress, OPTIMISTIC_GOVERNOR_ABI, provider); @@ -276,15 +313,16 @@ const getBondDetails = async ( await updateCurrentUserBondInfo(); return bondInfo; -}; +} -export const getModuleDetailsFromChain = async ( - provider: StaticJsonRpcProvider, +/** + * Legacy / fallback function to fetch the details of a given assertion from the Optimistic Oracle V3 contract. + */ +export async function getModuleDetailsFromChain(provider: StaticJsonRpcProvider, network: Network, moduleAddress: string, explanation: string, - transactions: OptimisticGovernorTransaction[] | undefined -) => { + transactions: OptimisticGovernorTransaction[] | undefined) { const moduleContract = new Contract(moduleAddress, OPTIMISTIC_GOVERNOR_ABI, provider); const moduleDetails: [ [gnosisSafeAddress: string], @@ -306,10 +344,8 @@ export const getModuleDetailsFromChain = async ( const livenessPeriod = moduleDetails[4][0]; const bondDetails = await getBondDetails(provider, moduleAddress); - if ( - Number(minimumBond) > 0 && - Number(minimumBond) > Number(bondDetails.currentUserBondAllowance) - ) { + if (Number(minimumBond) > 0 && + Number(minimumBond) > Number(bondDetails.currentUserBondAllowance)) { needsApproval = true; } @@ -367,8 +403,7 @@ export const getModuleDetailsFromChain = async ( // but the explanation field should be unique. we will filter this out later. const assertionId: string = await moduleContract.assertionIds(proposalHash); - const activeProposal = - assertionId !== + const activeProposal = assertionId !== '0x0000000000000000000000000000000000000000000000000000000000000000'; // Search for requests with matching ancillary data @@ -380,49 +415,48 @@ export const getModuleDetailsFromChain = async ( const latestBlock = await provider.getBlock('latest'); // modify this per chain. this should be updated with constants for all chains. start block is og deploy block. // this needs to be optimized to reduce loading time, currently takes a long time to parse 3k blocks at a time. - const oGstartBlock = getDeployBlock({network, name: 'OptimisticGovernor'}); - const oOStartBlock = getDeployBlock({network, name: 'OptimisticOracleV3'}); + const oGstartBlock = getDeployBlock({ network, name: 'OptimisticGovernor' }); + const oOStartBlock = getDeployBlock({ network, name: 'OptimisticOracleV3' }); const maxRange = 3000; - const [assertionEvents, transactionsProposedEvents, executionEvents] = - await Promise.all([ - pageEvents( - oOStartBlock, - latestBlock.number, - maxRange, - ({ start, end }: { start: number; end: number }) => { - return oracleContract.queryFilter( - oracleContract.filters.AssertionMade(), - start, - end - ); - } - ), - pageEvents( - oGstartBlock, - latestBlock.number, - maxRange, - ({ start, end }: { start: number; end: number }) => { - return moduleContract.queryFilter( - moduleContract.filters.TransactionsProposed(), - start, - end - ); - } - ), - pageEvents( - oGstartBlock, - latestBlock.number, - maxRange, - ({ start, end }: { start: number; end: number }) => { - return moduleContract.queryFilter( - moduleContract.filters.ProposalExecuted(proposalHash), - start, - end - ); - } - ) - ]); + const [assertionEvents, transactionsProposedEvents, executionEvents] = await Promise.all([ + pageEvents( + oOStartBlock, + latestBlock.number, + maxRange, + ({ start, end }: { start: number; end: number; }) => { + return oracleContract.queryFilter( + oracleContract.filters.AssertionMade(), + start, + end + ); + } + ), + pageEvents( + oGstartBlock, + latestBlock.number, + maxRange, + ({ start, end }: { start: number; end: number; }) => { + return moduleContract.queryFilter( + moduleContract.filters.TransactionsProposed(), + start, + end + ); + } + ), + pageEvents( + oGstartBlock, + latestBlock.number, + maxRange, + ({ start, end }: { start: number; end: number; }) => { + return moduleContract.queryFilter( + moduleContract.filters.ProposalExecuted(proposalHash), + start, + end + ); + } + ) + ]); const thisModuleAssertionEvent = assertionEvents.filter(event => { return ( @@ -433,13 +467,12 @@ export const getModuleDetailsFromChain = async ( // Get the full proposal events (with state). const fullAssertionEvent = await Promise.all( - thisModuleAssertionEvent.map(async event => { + thisModuleAssertionEvent.map(async (event) => { const assertion = await oracleContract.getAssertion( event.args?.assertionId ); - const isExpired = - Math.floor(Date.now() / 1000) >= assertion.expirationTime.toNumber(); + const isExpired = Math.floor(Date.now() / 1000) >= assertion.expirationTime.toNumber(); return { assertionId: event?.args?.assertionId, @@ -453,10 +486,9 @@ export const getModuleDetailsFromChain = async ( }) ); - const thisProposalTransactionsProposedEvents = - transactionsProposedEvents.filter( - event => toUtf8String(event.args?.explanation) === explanation - ); + const thisProposalTransactionsProposedEvents = transactionsProposedEvents.filter( + event => toUtf8String(event.args?.explanation) === explanation + ); const assertion = thisProposalTransactionsProposedEvents.map( tx => tx.args?.assertionId @@ -464,8 +496,7 @@ export const getModuleDetailsFromChain = async ( const assertionIds = executionEvents.map(tx => tx.args?.assertionId); - const proposalExecuted = assertion.some(assertionId => - assertionIds.includes(assertionId) + const proposalExecuted = assertion.some(assertionId => assertionIds.includes(assertionId) ); return { @@ -486,18 +517,18 @@ export const getModuleDetailsFromChain = async ( proposalExecuted: proposalExecuted, livenessPeriod: livenessPeriod.toString() }; -}; +} -// This is intended to function identically to getModuleDetailsUma but use subgraphs rather than web3 events. -// This has a lot of duplicate code on purpose. Reducing code duplication will require a risky refactor, -// and we also want a fallback function in case the graph is down, so we will leave the original untouched for now. -export const getModuleDetailsGql = async ( - provider: StaticJsonRpcProvider, +/** + * This is intended to function identically to getModuleDetailsUma but use subgraphs rather than web3 events. + * This has a lot of duplicate code on purpose. Reducing code duplication will require a risky refactor, + * and we also want a fallback function in case the graph is down, so we will leave the original untouched for now. + */ +export async function getModuleDetailsGql(provider: StaticJsonRpcProvider, network: Network, moduleAddress: string, explanation: string, - transactions: OptimisticGovernorTransaction[] | undefined -) => { + transactions: OptimisticGovernorTransaction[] | undefined) { const moduleContract = new Contract(moduleAddress, OPTIMISTIC_GOVERNOR_ABI, provider); const moduleDetails: [ [gnosisSafeAddress: string], @@ -517,10 +548,8 @@ export const getModuleDetailsGql = async ( const livenessPeriod = moduleDetails[4][0]; const bondDetails = await getBondDetails(provider, moduleAddress); - if ( - Number(minimumBond) > 0 && - Number(minimumBond) > Number(bondDetails.currentUserBondAllowance) - ) { + if (Number(minimumBond) > 0 && + Number(minimumBond) > Number(bondDetails.currentUserBondAllowance)) { needsApproval = true; } @@ -561,8 +590,7 @@ export const getModuleDetailsGql = async ( // but the explanation field should be unique. we will filter this out later. const assertionId = await moduleContract.assertionIds(proposalHash); - const activeProposal = - assertionId !== + const activeProposal = assertionId !== '0x0000000000000000000000000000000000000000000000000000000000000000'; const [proposal] = await findProposalGql(network, { @@ -572,20 +600,19 @@ export const getModuleDetailsGql = async ( }); const proposalExecuted = proposal?.executed ? true : false; const assertion = proposal?.assertionId - ? await findAssertionGql(network, { assertionId: proposal.assertionId }) + ? await findAssertionGql(network, { assertionId: proposal.assertionId }) : undefined; const assertionEvent = assertion ? { - assertionId: assertion.assertionId, - expirationTimestamp: BigNumber.from(assertion.expirationTime), - isExpired: - Math.floor(Date.now() / 1000) >= Number(assertion.expirationTime), - isSettled: assertion.settlementHash ? true : false, - proposalHash, - proposalTxHash: assertion.assertionHash, - logIndex: assertion.assertionLogIndex - } + assertionId: assertion.assertionId, + expirationTimestamp: BigNumber.from(assertion.expirationTime), + isExpired: Math.floor(Date.now() / 1000) >= Number(assertion.expirationTime), + isSettled: assertion.settlementHash ? true : false, + proposalHash, + proposalTxHash: assertion.assertionHash, + logIndex: assertion.assertionLogIndex + } : undefined; return { @@ -606,8 +633,11 @@ export const getModuleDetailsGql = async ( proposalExecuted, livenessPeriod: livenessPeriod.toString() }; -}; +} +/** + * Fetches the details of a given Optimistic Governor contract deployment from the graph. + */ export async function getModuleDetails( network: Network, moduleAddress: string, @@ -637,6 +667,9 @@ export async function getModuleDetails( } } +/** + * Merges the result of getModuleDetails with the proposalId and explanation. + */ export async function getExecutionDetails( network: Network, moduleAddress: string, @@ -658,10 +691,18 @@ export async function getExecutionDetails( }; } +/** + * Returns the EIP-3770 prefix for a given network. + * + * @see SafeNetworkPrefix + */ export function getSafeNetworkPrefix(network: Network): SafeNetworkPrefix { return safePrefixes[network]; } +/** + * Returns the url for a given Safe app on a given network. + */ export function getSafeAppLink(network: Network, safeAddress: string, appUrl = "https://gnosis-safe.io/app/") { const prefix = getSafeNetworkPrefix(network); return `${appUrl}${prefix}:${safeAddress}`; diff --git a/src/plugins/oSnap/utils/proposal.ts b/src/plugins/oSnap/utils/proposal.ts index dfaf5d58..7b2eff67 100644 --- a/src/plugins/oSnap/utils/proposal.ts +++ b/src/plugins/oSnap/utils/proposal.ts @@ -4,6 +4,11 @@ import { ERC20_ABI, OPTIMISTIC_GOVERNOR_ABI } from "../constants"; import { OptimisticGovernorTransaction, Network } from "../types"; import { getModuleDetails } from "./getters"; +/** + * The user must approve the spend of the collateral token before they can submit a proposal. + * + * If the proposal is disputed and fails a vote, the user will lose their bond. + */ export async function *approveBond( network: Network, web3: any, @@ -31,6 +36,9 @@ export async function *approveBond( yield; } +/** + * Submits a proposal to the Optimistic Governor. + */ export async function *submitProposal( web3: any, moduleAddress: string, @@ -51,6 +59,11 @@ export async function *submitProposal( console.log('[DAO module] submitted proposal:', receipt); } +/** + * Executes a proposal on the Optimistic Governor. + * + * This can only be done after the dispute window has ended. + */ export async function *executeProposal( web3: any, moduleAddress: string, diff --git a/src/plugins/oSnap/utils/transactions.ts b/src/plugins/oSnap/utils/transactions.ts index a02cb926..0c9a795c 100644 --- a/src/plugins/oSnap/utils/transactions.ts +++ b/src/plugins/oSnap/utils/transactions.ts @@ -3,6 +3,13 @@ import { BigNumber } from '@ethersproject/bignumber'; import { ContractInteractionTransaction, NFT, OptimisticGovernorTransaction, RawTransaction, Token, TransferFundsTransaction, TransferNftTransaction } from '../types'; import { encodeMethodAndParams } from './abi'; +/** + * Creates a formatted transaction for the Optimistic Governor to execute + * + * note: the value for `operation` is always zero because we do not support delegatecall. + * + * @see OptimisticGovernorTransaction + */ export function createFormattedOptimisticGovernorTransaction({ to, value, @@ -20,6 +27,11 @@ export function createFormattedOptimisticGovernorTransaction({ ] } +/** + * Creates a raw transaction for the Optimistic Governor to execute + * + * @see RawTransaction + */ export function createRawTransaction(params: { to: string; value: string; @@ -34,6 +46,11 @@ export function createRawTransaction(params: { } } +/** + * Creates a transaction to transfer an NFT + * + * @see TransferNftTransaction + */ export function createTransferNftTransaction(params: { recipient: string, collectable: NFT, @@ -59,6 +76,11 @@ export function createTransferNftTransaction(params: { } } +/** + * Creates a transaction to transfer funds + * + * @see TransferFundsTransaction + */ export function createTransferFundsTransaction(params: { recipient: string; amount: string; @@ -87,6 +109,13 @@ export function createTransferFundsTransaction(params: { } } +/** + * Creates a transaction to interact with a contract. + * + * the `method` is executed with the given `parameters`. + * + * @see ContractInteractionTransaction + */ export function createContractInteractionTransaction(params: { to: string; value: string; diff --git a/src/plugins/oSnap/utils/validators.ts b/src/plugins/oSnap/utils/validators.ts index eacf7829..22f6f792 100644 --- a/src/plugins/oSnap/utils/validators.ts +++ b/src/plugins/oSnap/utils/validators.ts @@ -8,12 +8,18 @@ import getProvider from '@snapshot-labs/snapshot.js/src/utils/provider'; import { OPTIMISTIC_GOVERNOR_ABI } from '../constants'; import { BaseTransaction } from '../types'; +/** + * Validates that the given `address` is a valid Ethereum address + */ export const mustBeEthereumAddress = memoize((address: string) => { const startsWith0x = address?.startsWith('0x'); const isValidAddress = isAddress(address); return startsWith0x && isValidAddress; }); +/** + * Validates that the given `address` is a valid Ethereum contract address + */ export const mustBeEthereumContractAddress = memoize( async (network: string, address: string) => { const provider = getProvider(network) as JsonRpcProvider; @@ -26,6 +32,9 @@ export const mustBeEthereumContractAddress = memoize( (url, contractAddress) => `${url}_${contractAddress}` ); +/** + * Validates a transaction. + */ export function validateTransaction(transaction: BaseTransaction) { const addressEmptyOrValidate = transaction.to === '' || isAddress(transaction.to); @@ -36,6 +45,9 @@ export function validateTransaction(transaction: BaseTransaction) { ); } +/** + * Validates a module address. + */ export async function validateModuleAddress(network: string, moduleAddress: string): Promise { if (!isAddress(moduleAddress)) return false; const provider: StaticJsonRpcProvider = getProvider(network);