@@ -57,6 +63,11 @@ const safeLink = computed(() =>
Number of transactions{{ transactions.length }}
+
+import {
+ GnosisSafe,
+ Network,
+ SafeImportTransaction,
+ isErrorWithMessage
+} from '../../types';
+import { initializeSafeImportTransaction } from '../../utils';
+import { parseGnosisSafeFile } from '../../utils/safeImport';
+import FileInput from '../Input/FileInput/FileInput.vue';
+
+const props = defineProps<{
+ network: Network;
+ safe: GnosisSafe | null;
+}>();
+
+// Emits definition
+const emit = defineEmits<{
+ (
+ event: 'update:importedTransactions',
+ importedTransactions: SafeImportTransaction[]
+ ): void;
+}>();
+
+const file = ref(); // raw file, if valid type
+const safeFile = ref(); // parsed, type-safe file
+
+const error = ref();
+
+function resetState() {
+ file.value = undefined;
+ safeFile.value = undefined;
+ error.value = undefined;
+}
+
+watch(file, async () => {
+ if (!file.value) return;
+ parseGnosisSafeFile(file.value, props.safe)
+ .then(result => {
+ safeFile.value = result;
+ })
+ .catch(e => {
+ safeFile.value = undefined;
+ if (isErrorWithMessage(e)) {
+ error.value = e.message;
+ return;
+ }
+ error.value = 'Safe file corrupted. Please select another.';
+ });
+});
+
+function handleFileChange(_file: File | null) {
+ if (_file) {
+ resetState();
+ file.value = _file;
+ }
+}
+
+watch(safeFile, safeFile => {
+ if (safeFile) {
+ const convertedTxs = safeFile.transactions.map(
+ initializeSafeImportTransaction
+ );
+ emit('update:importedTransactions', convertedTxs);
+ }
+});
+
+
+
+
+
diff --git a/src/plugins/oSnap/components/TransactionBuilder/TransferFunds.vue b/src/plugins/oSnap/components/TransactionBuilder/TransferFunds.vue
index df6a023f..c5bf2286 100644
--- a/src/plugins/oSnap/components/TransactionBuilder/TransferFunds.vue
+++ b/src/plugins/oSnap/components/TransactionBuilder/TransferFunds.vue
@@ -5,7 +5,6 @@ import { Network, Token, TransferFundsTransaction } from '../../types';
import {
createTransferFundsTransaction,
getERC20TokenTransferTransactionData,
- getNativeAsset,
isTransferFundsValid
} from '../../utils';
import AddressInput from '../Input/Address.vue';
diff --git a/src/plugins/oSnap/constants.ts b/src/plugins/oSnap/constants.ts
index 795cbe97..44b6a08e 100644
--- a/src/plugins/oSnap/constants.ts
+++ b/src/plugins/oSnap/constants.ts
@@ -1542,7 +1542,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..cf253359 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,22 @@ 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
+ method?: FunctionFragment;
+ parameters?: { [key: string]: string };
+};
/**
* Represents a 'raw' transaction that does not have any additional fields.
@@ -114,6 +132,7 @@ export type ContractInteractionTransaction = BaseTransaction & {
type: 'contractInteraction';
abi?: string;
methodName?: string;
+ method?: FunctionFragment;
parameters?: string[];
};
@@ -448,3 +467,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..cfb18a35 100644
--- a/src/plugins/oSnap/utils/abi.ts
+++ b/src/plugins/oSnap/utils/abi.ts
@@ -6,6 +6,8 @@ import {
mustBeEthereumAddress,
mustBeEthereumContractAddress
} from './validators';
+import { GnosisSafe, SafeImportTransaction } from '../types';
+import { createSafeImportTransaction } 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 +129,57 @@ 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 initializeSafeImportTransaction(
+ unprocessedTransactions: GnosisSafe.BatchTransaction
+): SafeImportTransaction {
+ return createSafeImportTransaction({
+ type: 'safeImport',
+ to: unprocessedTransactions.to,
+ value: unprocessedTransactions.value,
+ data: unprocessedTransactions?.data ?? '',
+ method: unprocessedTransactions.contractMethod
+ ? transformSafeMethodToFunctionFragment(
+ unprocessedTransactions.contractMethod
+ )
+ : undefined,
+ parameters: unprocessedTransactions.contractInputsValues,
+ formatted: ['', 0, '0', '0x']
+ });
+}
+
+export function encodeSafeMethodAndParams(
+ method: SafeImportTransaction['method'],
+ params: SafeImportTransaction['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..35495cc4
--- /dev/null
+++ b/src/plugins/oSnap/utils/safeImport.ts
@@ -0,0 +1,47 @@
+import { addressEqual } from '@/helpers/utils';
+import { GnosisSafe } from '../types';
+import { isSafeFile } from './validators';
+
+export async function parseGnosisSafeFile(
+ file: File,
+ safe: GnosisSafe | null
+): Promise {
+ return new Promise((res, rej) => {
+ const reader = new FileReader();
+ reader.readAsText(file);
+ reader.onload = async () => {
+ try {
+ if (typeof reader.result !== 'string') {
+ return rej(new Error('Buffer can not be parsed'));
+ }
+ const json = JSON.parse(reader.result);
+ if (!isSafeFile(json)) {
+ return rej(new Error('Not a valid Safe transaction file.'));
+ }
+ if (!isCreatedFromSafe(json, safe)) {
+ return rej(
+ new Error(
+ "Safe file does not match the selected treasury's address or chain ID"
+ )
+ );
+ }
+ return res(json);
+ } catch (err) {
+ return rej(new Error('Safe file corrupted. Please select another.'));
+ }
+ };
+ });
+}
+
+function isCreatedFromSafe(
+ batchFile: GnosisSafe.BatchFile,
+ safe: GnosisSafe | null
+): boolean {
+ if (safe && batchFile.meta.createdFromSafeAddress) {
+ return (
+ safe.network === batchFile.chainId &&
+ addressEqual(safe.safeAddress, batchFile.meta.createdFromSafeAddress)
+ );
+ }
+ return false;
+}
diff --git a/src/plugins/oSnap/utils/transactions.ts b/src/plugins/oSnap/utils/transactions.ts
index e8fbf5eb..4724b204 100644
--- a/src/plugins/oSnap/utils/transactions.ts
+++ b/src/plugins/oSnap/utils/transactions.ts
@@ -7,9 +7,12 @@ import {
RawTransaction,
Token,
TransferFundsTransaction,
- TransferNftTransaction
+ TransferNftTransaction,
+ SafeImportTransaction
} from '../types';
-import { encodeMethodAndParams } from './abi';
+import { encodeMethodAndParams, encodeSafeMethodAndParams } from './abi';
+import { isAddress } from '@ethersproject/address';
+import { validateTransaction } from './validators';
/**
* Creates a formatted transaction for the Optimistic Governor to execute
@@ -166,3 +169,76 @@ export function parseValueInput(input: string) {
}
return parseAmount(input);
}
+
+export function createSafeImportTransaction(
+ params: SafeImportTransaction
+): SafeImportTransaction {
+ try {
+ // check "value" & "to"
+ if (!validateTransaction(params)) {
+ throw new Error('Invalid transaction');
+ }
+ const abi = params.method
+ ? JSON.stringify(Array(params.method))
+ : undefined;
+ // is native transfer funds
+ if (!params.method) {
+ const data = '0x';
+ const formatted = createFormattedOptimisticGovernorTransaction({
+ to: params.to,
+ value: params.value,
+ data
+ });
+ return {
+ ...params,
+ isValid: true,
+ abi,
+ formatted,
+ data
+ };
+ }
+ // 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 {
+ ...params,
+ isValid: true,
+
+ formatted,
+ data
+ };
+ }
+
+ // is contract interaction WITH args
+ // will throw if args are invalid
+ const encodedData =
+ params?.data ||
+ encodeSafeMethodAndParams(params.method, params.parameters) ||
+ '0x';
+
+ const formatted = createFormattedOptimisticGovernorTransaction({
+ to: params.to,
+ value: params.value,
+ data: encodedData
+ });
+
+ return {
+ ...params,
+ isValid: true,
+ abi,
+ formatted,
+ data: encodedData
+ };
+ } catch (error) {
+ console.error(error);
+ return {
+ ...params,
+ isValid: false
+ };
+ }
+}
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);
+};