diff --git a/src/plugins/oSnap/components/Input/MethodParameter.vue b/src/plugins/oSnap/components/Input/MethodParameter.vue index a0f9025c..7e906d87 100644 --- a/src/plugins/oSnap/components/Input/MethodParameter.vue +++ b/src/plugins/oSnap/components/Input/MethodParameter.vue @@ -8,6 +8,7 @@ import { hexZeroPad, isBytesLike } from '@ethersproject/bytes'; const props = defineProps<{ parameter: ParamType; value: string; + validateOnMount?: boolean; }>(); const emit = defineEmits<{ @@ -37,8 +38,12 @@ const inputType = computed(() => { const label = `${props.parameter.name} (${props.parameter.type})`; const arrayPlaceholder = `E.g. ["text", 123, 0x123]`; +const newValue = ref(props.value); + +const validationState = ref(true); +const isInputValid = computed(() => validationState.value); -const isInputValid = computed(() => { +function validate() { if (!isDirty.value) return true; if (isAddressInput.value) return isAddress(newValue.value); if (isArrayInput.value) return validateArrayInput(newValue.value); @@ -46,9 +51,7 @@ const isInputValid = computed(() => { if (isBytes32Input.value) return validateBytes32Input(newValue.value); if (isBytesInput.value) return validateBytesInput(newValue.value); return true; -}); - -const newValue = ref(props.value); +} watch(props.parameter, () => { newValue.value = ''; @@ -56,6 +59,7 @@ watch(props.parameter, () => { }); watch(newValue, () => { + validationState.value = validate(); emit('updateParameterValue', newValue.value); }); @@ -69,8 +73,8 @@ function validateBytesInput(value: string) { function validateBytes32Input(value: string) { try { - if (value.slice(2).length > 64) { - throw new Error('String too long'); + if (value.slice(2).length !== 64) { + throw new Error('Not 32 bytes'); } return isBytesLike(value); } catch { @@ -103,6 +107,12 @@ function formatBytes32() { newValue.value = hexZeroPad(newValue.value, 32); } } +onMounted(() => { + if (props.validateOnMount) { + isDirty.value = true; + } + validationState.value = validate(); +}); diff --git a/src/plugins/oSnap/constants.ts b/src/plugins/oSnap/constants.ts index 4c1797fa..ee03a6ee 100644 --- a/src/plugins/oSnap/constants.ts +++ b/src/plugins/oSnap/constants.ts @@ -1519,7 +1519,8 @@ export const transactionTypes = [ 'transferFunds', 'transferNFT', 'contractInteraction', - 'raw' + 'raw', + 'safeImport' ] as const; export const solidityZeroHexString = diff --git a/src/plugins/oSnap/types.ts b/src/plugins/oSnap/types.ts index 998aaeef..c807f2fc 100644 --- a/src/plugins/oSnap/types.ts +++ b/src/plugins/oSnap/types.ts @@ -2,6 +2,7 @@ import { BigNumber } from '@ethersproject/bignumber'; import { Contract, Event } from '@ethersproject/contracts'; import networks from '@snapshot-labs/snapshot.js/src/networks.json'; import { safePrefixes, transactionTypes } from './constants'; +import { FunctionFragment } from '@ethersproject/abi'; /** * Represents details about the chains that snapshot supports as described in the `networks` json file. @@ -77,7 +78,8 @@ export type Transaction = | RawTransaction | ContractInteractionTransaction | TransferNftTransaction - | TransferFundsTransaction; + | TransferFundsTransaction + | SafeImportTransaction; /** * Represents the fields that all transactions share. @@ -93,6 +95,23 @@ export type BaseTransaction = { formatted: OptimisticGovernorTransaction; isValid?: boolean; }; +/** + * Represents a transaction that interacts with an arbitrary contract from safe json file import. + * + * @field `abi` field is the ABI of the contract that the transaction interacts with, represented as a JSON string. + * + * @field `methodName` field is the name of the method on the contract that the transaction calls. + * + * @field `parameters` field is an array of strings that represent the parameters that the method takes. NOTE: some methods take arrays or tuples as arguments, so some of these strings in the array may be JSON formatted arrays or tuples. + */ + +export type SafeImportTransaction = BaseTransaction & { + type: 'safeImport'; + abi?: string; // represents partial ABI only + methodName?: string; + method?: GnosisSafe.BatchTransaction['contractMethod']; + parameters?: GnosisSafe.BatchTransaction['contractInputsValues']; +}; /** * Represents a 'raw' transaction that does not have any additional fields. @@ -114,6 +133,7 @@ export type ContractInteractionTransaction = BaseTransaction & { type: 'contractInteraction'; abi?: string; methodName?: string; + method?: FunctionFragment; parameters?: string[]; }; @@ -448,3 +468,69 @@ export type SpaceConfigResponse = bondToken: boolean; bondAmount: boolean; }; + +export namespace GnosisSafe { + export interface ProposedTransaction { + id: number; + contractInterface: ContractInterface | null; + description: { + to: string; + value: string; + customTransactionData?: string; + contractMethod?: ContractMethod; + contractFieldsValues?: Record; + contractMethodIndex?: string; + nativeCurrencySymbol?: string; + networkPrefix?: string; + }; + raw: { to: string; value: string; data: string }; + } + + export interface ContractInterface { + methods: ContractMethod[]; + } + + export interface Batch { + id: number | string; + name: string; + transactions: ProposedTransaction[]; + } + + export interface BatchFile { + version: string; + chainId: string; + createdAt: number; + meta: BatchFileMeta; + transactions: BatchTransaction[]; + } + + export interface BatchFileMeta { + txBuilderVersion?: string; + checksum?: string; + createdFromSafeAddress?: string; + createdFromOwnerAddress?: string; + name: string; + description?: string; + } + + export interface BatchTransaction { + to: string; + value: string; + data?: string; + contractMethod?: ContractMethod; + contractInputsValues?: { [key: string]: string }; + } + + export interface ContractMethod { + inputs: ContractInput[]; + name: string; + payable: boolean; + } + + export interface ContractInput { + internalType: string; + name: string; + type: string; + components?: ContractInput[]; + } +} diff --git a/src/plugins/oSnap/utils/abi.ts b/src/plugins/oSnap/utils/abi.ts index ca1641a8..7177bdec 100644 --- a/src/plugins/oSnap/utils/abi.ts +++ b/src/plugins/oSnap/utils/abi.ts @@ -1,4 +1,9 @@ -import { FunctionFragment, Interface, ParamType } from '@ethersproject/abi'; +import { + FunctionFragment, + Interface, + JsonFragmentType, + ParamType +} from '@ethersproject/abi'; import { BigNumberish } from '@ethersproject/bignumber'; import { memoize } from 'lodash'; import { ERC20_ABI, ERC721_ABI, EXPLORER_API_URLS } from '../constants'; @@ -6,6 +11,8 @@ import { mustBeEthereumAddress, mustBeEthereumContractAddress } from './validators'; +import { GnosisSafe } from '../types'; +import { CreateSafeTransactionParams } from './transactions'; /** * Checks if the `parameter` of a contract method `method` takes an array or tuple as input, based on the `baseType` of the parameter. @@ -127,6 +134,55 @@ export function encodeMethodAndParams( return contractInterface.encodeFunctionData(method, parameterValues); } +export function transformSafeMethodToFunctionFragment( + method: GnosisSafe.ContractMethod +): FunctionFragment { + const fragment = FunctionFragment.from({ + ...method, + type: 'function', + stateMutability: method.payable ? 'payable' : 'nonpayable' + }); + return fragment; +} + +export function extractSafeMethodAndParams( + unprocessedTransactions: GnosisSafe.BatchTransaction +): CreateSafeTransactionParams { + return { + to: unprocessedTransactions.to, + value: unprocessedTransactions.value, + data: unprocessedTransactions.data ?? null, + functionFragment: unprocessedTransactions.contractMethod + ? transformSafeMethodToFunctionFragment( + unprocessedTransactions.contractMethod + ) + : undefined, + parameters: unprocessedTransactions.contractInputsValues + }; +} + +export function encodeSafeMethodAndParams( + method: CreateSafeTransactionParams['functionFragment'], + params: CreateSafeTransactionParams['parameters'] +) { + if (!params || !method) return; + const missingParams = Object.values(params).length !== method.inputs.length; + if (missingParams) { + throw new Error('Some params are missing'); + } + const abiSlice = Array(method); + const contractInterface = new Interface(abiSlice); + + const parameterValues = method.inputs.map(input => { + const value = params[input.name]; + if (isArrayParameter(input.baseType)) { + return JSON.parse(value); + } + return value; + }); + return contractInterface.encodeFunctionData(method.name, parameterValues); +} + /** * Returns the transaction data for an ERC20 transfer. */ diff --git a/src/plugins/oSnap/utils/safeImport.ts b/src/plugins/oSnap/utils/safeImport.ts new file mode 100644 index 00000000..d6452cb5 --- /dev/null +++ b/src/plugins/oSnap/utils/safeImport.ts @@ -0,0 +1,43 @@ +import { GnosisSafe } from '../types'; +import { isSafeFile } from './validators'; + +export async function parseGnosisSafeFile( + file: File +): Promise { + return new Promise((res, rej) => { + const reader = new FileReader(); + reader.readAsText(file); + reader.onload = async () => { + try { + if (typeof reader.result !== 'string') { + throw new Error('Buffer can not be parsed'); + } + const json = JSON.parse(reader.result); + if (!isSafeFile(json)) { + throw new Error('Not a Gnosis Safe transaction file!'); + } + return res(json); + } catch (err) { + rej(err); + } + }; + }); +} + +export function isJsonFile(file: File) { + return file.type === 'application/json'; +} + +export function getFileFromEvent(event: DragEvent | Event) { + let _file: File | undefined; + + if (event instanceof DragEvent) { + _file = event.dataTransfer?.files?.[0]; + } + + if (event.target && event.target instanceof HTMLInputElement) { + _file = (event?.currentTarget as HTMLInputElement)?.files?.[0]; + } + if (!_file) return; + return _file; +} diff --git a/src/plugins/oSnap/utils/transactions.ts b/src/plugins/oSnap/utils/transactions.ts index e8fbf5eb..e496d3c4 100644 --- a/src/plugins/oSnap/utils/transactions.ts +++ b/src/plugins/oSnap/utils/transactions.ts @@ -7,9 +7,11 @@ import { RawTransaction, Token, TransferFundsTransaction, - TransferNftTransaction + TransferNftTransaction, + SafeImportTransaction, + GnosisSafe } from '../types'; -import { encodeMethodAndParams } from './abi'; +import { encodeMethodAndParams, encodeSafeMethodAndParams } from './abi'; /** * Creates a formatted transaction for the Optimistic Governor to execute @@ -166,3 +168,164 @@ export function parseValueInput(input: string) { } return parseAmount(input); } + +export type CreateSafeTransactionParams = { + to: string; + value: string; + data: string | null; + functionFragment?: FunctionFragment; + parameters?: { [key: string]: string }; +}; + +export function createSafeImportTransaction( + params: CreateSafeTransactionParams +): SafeImportTransaction { + const abi = JSON.stringify(Array(params.functionFragment)); + const methodName = params.functionFragment?.name; + const parameters = params.parameters; + // is native transfer funds + if (!params.functionFragment) { + const data = '0x'; + const formatted = createFormattedOptimisticGovernorTransaction({ + to: params.to, + value: params.value, + data + }); + return { + type: 'safeImport', + isValid: true, + abi, + formatted, + to: params.to, + value: params.value, + data, + methodName, + parameters + }; + } + // is contract interaction with NO args + if (!params.parameters) { + const data = params?.data || '0x'; + const formatted = createFormattedOptimisticGovernorTransaction({ + to: params.to, + value: params.value, + data + }); + return { + type: 'safeImport', + isValid: true, + abi, + formatted, + to: params.to, + value: params.value, + data, + methodName, + parameters + }; + } + + // is contract interaction WITH args + const encodedData = + params?.data || + encodeSafeMethodAndParams(params.functionFragment, params.parameters) || + '0x'; + + const formatted = createFormattedOptimisticGovernorTransaction({ + to: params.to, + value: params.value, + data: encodedData + }); + + return { + type: 'safeImport', + isValid: true, + abi, + formatted, + to: params.to, + value: params.value, + data: encodedData, + methodName, + parameters + }; +} + +// export function createSafeImportTransaction( +// params: GnosisSafe.BatchTransaction +// ): SafeImportTransaction { +// const abi = JSON.stringify([params.contractMethod]); +// const subtype = params.contractMethod +// ? 'contractInteraction' +// : 'nativeTransfer'; +// const methodName = params.contractMethod?.name; +// const parameters = params.contractInputsValues; +// // is native transfer funds +// if (!params.contractMethod) { +// const data = '0x'; +// const formatted = createFormattedOptimisticGovernorTransaction({ +// to: params.to, +// value: params.value, +// data +// }); +// return { +// type: 'safeImport', +// isValid: true, +// abi, +// formatted, +// to: params.to, +// value: params.value, +// data, +// subtype, +// methodName, +// parameters +// }; +// } +// // is contract interaction with NO args +// if (!params.contractInputsValues) { +// const data = params?.data || '0x'; +// const formatted = createFormattedOptimisticGovernorTransaction({ +// to: params.to, +// value: params.value, +// data +// }); +// return { +// type: 'safeImport', +// isValid: true, +// abi, +// formatted, +// to: params.to, +// value: params.value, +// data, +// subtype, +// methodName, +// parameters +// }; +// } + +// // is contract interaction WITH args +// const encodedData = +// params?.data || +// encodeSafeMethodAndParams( +// params.contractMethod, +// params.contractInputsValues +// ) || +// '0x'; + +// const formatted = createFormattedOptimisticGovernorTransaction({ +// to: params.to, +// value: params.value, +// data: encodedData +// }); + +// return { +// type: 'safeImport', +// isValid: true, +// abi, +// formatted, +// to: params.to, +// value: params.value, +// data: encodedData, +// subtype, +// methodName, +// parameters +// }; +// } diff --git a/src/plugins/oSnap/utils/validators.ts b/src/plugins/oSnap/utils/validators.ts index 4a69a383..5834f925 100644 --- a/src/plugins/oSnap/utils/validators.ts +++ b/src/plugins/oSnap/utils/validators.ts @@ -9,7 +9,7 @@ import { isBigNumberish } from '@ethersproject/bignumber/lib/bignumber'; import { isHexString } from '@ethersproject/bytes'; import getProvider from '@snapshot-labs/snapshot.js/src/utils/provider'; import { OPTIMISTIC_GOVERNOR_ABI } from '../constants'; -import { BaseTransaction, NFT, Token, Transaction } from '../types'; +import { BaseTransaction, NFT, Token, Transaction, GnosisSafe } from '../types'; import { parseUnits } from '@ethersproject/units'; import { useMemoize } from '@vueuse/core'; @@ -136,3 +136,77 @@ export const checkIsContract = useMemoize( async (address: string, network: string) => await isContractAddress(address, network) ); + +// check if json is a safe json type +export const isSafeFile = (input: any): input is GnosisSafe.BatchFile => { + const $io0 = (input: any): boolean => + 'string' === typeof input.version && + 'string' === typeof input.chainId && + 'number' === typeof input.createdAt && + 'object' === typeof input.meta && + null !== input.meta && + $io1(input.meta) && + Array.isArray(input.transactions) && + input.transactions.every( + (elem: any) => 'object' === typeof elem && null !== elem && $io2(elem) + ); + const $io1 = (input: any): boolean => + (null === input.txBuilderVersion || + undefined === input.txBuilderVersion || + 'string' === typeof input.txBuilderVersion) && + (null === input.checksum || + undefined === input.checksum || + 'string' === typeof input.checksum) && + (null === input.createdFromSafeAddress || + undefined === input.createdFromSafeAddress || + 'string' === typeof input.createdFromSafeAddress) && + (null === input.createdFromOwnerAddress || + undefined === input.createdFromOwnerAddress || + 'string' === typeof input.createdFromOwnerAddress) && + 'string' === typeof input.name && + (null === input.description || + undefined === input.description || + 'string' === typeof input.description); + const $io2 = (input: any): boolean => + 'string' === typeof input.to && + 'string' === typeof input.value && + (null === input.data || + undefined === input.data || + 'string' === typeof input.data) && + (null === input.contractMethod || + undefined === input.contractMethod || + ('object' === typeof input.contractMethod && + null !== input.contractMethod && + $io3(input.contractMethod))) && + (null === input.contractInputsValues || + undefined === input.contractInputsValues || + ('object' === typeof input.contractInputsValues && + null !== input.contractInputsValues && + false === Array.isArray(input.contractInputsValues) && + $io5(input.contractInputsValues))); + const $io3 = (input: any): boolean => + Array.isArray(input.inputs) && + input.inputs.every( + (elem: any) => 'object' === typeof elem && null !== elem && $io4(elem) + ) && + 'string' === typeof input.name && + 'boolean' === typeof input.payable; + const $io4 = (input: any): boolean => + (undefined === input.internalType || + 'string' === typeof input.internalType) && + 'string' === typeof input.name && + 'string' === typeof input.type && + (null === input.components || + undefined === input.components || + (Array.isArray(input.components) && + input.components.every( + (elem: any) => 'object' === typeof elem && null !== elem && $io4(elem) + ))); + const $io5 = (input: any): boolean => + Object.keys(input).every((key: any) => { + const value = input[key]; + if (undefined === value) return true; + return 'string' === typeof value; + }); + return 'object' === typeof input && null !== input && $io0(input); +};