Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: allow safe json upload into osnap tx builder #140

Closed
Show file tree
Hide file tree
Changes from all commits
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
22 changes: 16 additions & 6 deletions src/plugins/oSnap/components/Input/MethodParameter.vue
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { hexZeroPad, isBytesLike } from '@ethersproject/bytes';
const props = defineProps<{
parameter: ParamType;
value: string;
validateOnMount?: boolean;
}>();

const emit = defineEmits<{
Expand Down Expand Up @@ -37,25 +38,28 @@ 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);
if (isNumberInput.value) return validateNumberInput(newValue.value);
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 = '';
isDirty.value = false;
});

watch(newValue, () => {
validationState.value = validate();
emit('updateParameterValue', newValue.value);
});

Expand All @@ -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 {
Expand Down Expand Up @@ -103,6 +107,12 @@ function formatBytes32() {
newValue.value = hexZeroPad(newValue.value, 32);
}
}
onMounted(() => {
if (props.validateOnMount) {
isDirty.value = true;
}
validationState.value = validate();
});
</script>

<template>
Expand Down
5 changes: 5 additions & 0 deletions src/plugins/oSnap/components/Input/TransactionType.vue
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,11 @@ const transactionTypesWithDetails: {
type: 'raw',
title: 'Raw transaction',
description: 'Send a raw transaction'
},
{
type: 'safeImport',
title: 'Import Safe File',
description: 'Import JSON file exported from Gnosis Safe transaction builder'
}
];
</script>
Expand Down
270 changes: 270 additions & 0 deletions src/plugins/oSnap/components/TransactionBuilder/SafeImport.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,270 @@
<script setup lang="ts">
import { SafeImportTransaction, GnosisSafe, Network } from '../../types';
import {
createSafeImportTransaction,
CreateSafeTransactionParams,
extractSafeMethodAndParams,
parseValueInput
} from '../../utils';
import {
isJsonFile,
getFileFromEvent,
parseGnosisSafeFile
} from '../../utils/safeImport';
import MethodParameterInput from '../../components/Input/MethodParameter.vue';
import AddressInput from '../../components/Input/Address.vue';
import { isAddress } from '@ethersproject/address';

const props = defineProps<{
transaction: SafeImportTransaction | undefined;
network: Network;
setTransactionAsInvalid(): void;
}>();

const emit = defineEmits<{
updateTransaction: [transaction: SafeImportTransaction];
}>();

type FileInputState = 'IDLE' | 'INVALID_TYPE' | 'PARSING_ERROR' | 'VALID';
const fileInputState = ref<FileInputState>('IDLE');
const dropping = ref(false);
function updateFileInputState(state: FileInputState) {
fileInputState.value = state;
}
const toggleDropping = () => {
dropping.value = !dropping.value;
};
const isDropping = computed(() => dropping.value === true);

const file = ref<File>();
const safeFile = ref<GnosisSafe.BatchFile>(); // raw, type-safe file
const selectedTransactionIndex = ref<number>();
const processedTransactions = ref<CreateSafeTransactionParams[]>();
const isValueValid = ref(true);
const finalTransaction = ref<CreateSafeTransactionParams>(); // decoded method, extracted args

function resetState() {
fileInputState.value = 'IDLE';
file.value = undefined;
safeFile.value = undefined;
selectedTransactionIndex.value = undefined;
processedTransactions.value = undefined;
finalTransaction.value = undefined;
}

const isToValid = computed(() => {
if (!finalTransaction?.value?.to) {
return true;
}
return isAddress(finalTransaction.value.to);
});

function updateFinalTransaction(tx: Partial<CreateSafeTransactionParams>) {
finalTransaction.value = {
...finalTransaction.value,
...tx
} as CreateSafeTransactionParams;
}

function updateParams(
paramsToUpdate: CreateSafeTransactionParams['parameters']
) {
finalTransaction.value = {
...finalTransaction.value,
parameters: {
...finalTransaction.value?.parameters,
...paramsToUpdate
}
} as CreateSafeTransactionParams;
}

function updateValue(newValue: string) {
try {
if (!finalTransaction.value) {
return;
}
const parsed = parseValueInput(newValue);
updateFinalTransaction({
value: parsed
});
isValueValid.value = true;
} catch (error) {
isValueValid.value = false;
} finally {
updateTransaction();
}
}

watch(file, async () => {
if (!file.value) return;
parseGnosisSafeFile(file.value)
.then(result => {
safeFile.value = result;
updateFileInputState('VALID');
})
.catch(e => {
updateFileInputState('PARSING_ERROR');

console.error(e);
});
});

function handleDrop(event: DragEvent) {
resetState();
dropping.value = false;
const _file = getFileFromEvent(event);
if (!_file) return;
if (!isJsonFile(_file)) {
updateFileInputState('INVALID_TYPE');
return;
}
file.value = _file;
}

function handleFileChange(event: Event) {
resetState();
const _file = getFileFromEvent(event);
if (!_file) return;
if (!isJsonFile(_file)) {
updateFileInputState('INVALID_TYPE');
return;
}
file.value = _file;
}

function updateTransaction() {
try {
if (!finalTransaction.value) {
throw new Error('No Transaction selected');
}

if (!isValueValid.value) {
throw new Error('"Value" field is invalid');
}

if (!isToValid.value) {
throw new Error('"To" field is invalid');
}

const tx = createSafeImportTransaction(finalTransaction.value);
console.log(tx);
emit('updateTransaction', tx);
} catch (error) {
console.error(error);
props.setTransactionAsInvalid();
}
}

watch(safeFile, safeFile => {
if (safeFile) {
finalTransaction.value = undefined;
selectedTransactionIndex.value = undefined;
const convertedTxs = safeFile.transactions.map(extractSafeMethodAndParams);
processedTransactions.value = convertedTxs;
}
});

watch(selectedTransactionIndex, index => {
if (
index === undefined ||
!processedTransactions.value ||
processedTransactions.value[index] === undefined
)
return;
finalTransaction.value = processedTransactions.value[index];
});

watch(finalTransaction, updateTransaction);
</script>

<template>
<label
for="file_input"
@dragenter.prevent="toggleDropping"
@dragleave.prevent="toggleDropping"
@dragover.prevent
@drop.prevent="handleDrop($event)"
class="my-2 w-full group hover:bg-transparent hover:border-skin-text border-skin-border hover:cursor-pointer inline-block border border-dashed py-2 px-4 rounded-xl"
:class="{
'border-solid border-skin-text bg-transparent': isDropping,
'bg-red/10 border-red/50 text-red/80':
fileInputState === 'INVALID_TYPE' || fileInputState === 'PARSING_ERROR',
'bg-green/10 border-green/50 text-green/80': fileInputState === 'VALID'
}"
>
<div class="flex flex-col gap-1 items-center justify-center">
<i-ho-upload />
<span v-if="fileInputState === 'IDLE'"
>Click to select file, or drag n drop</span
>
<span v-if="fileInputState === 'INVALID_TYPE'"
>File type must be JSON. Please choose another.</span
>
<span v-if="fileInputState === 'PARSING_ERROR'"
>Safe file corrupted, please choose another.</span
>
<span v-if="fileInputState === 'VALID' && file?.name">{{
file.name
}}</span>
</div>

<input
id="file_input"
class="hidden"
accept="application/json"
type="file"
@change="handleFileChange"
/>
</label>

<UiSelect
v-if="safeFile?.transactions?.length"
v-model="selectedTransactionIndex"
>
<template #label>Select Transaction</template>
<option v-for="(tx, i) in safeFile.transactions" :key="i" :value="i">
{{
tx?.contractMethod?.name
? `Contract interaction (${tx?.contractMethod?.name})`
: 'Native Transfer'
}}
</option>
</UiSelect>

<div v-if="finalTransaction" class="flex flex-col gap-2 mt-2">
<AddressInput
:modelValue="finalTransaction.to"
@change="(e: string) => updateFinalTransaction({ to: e })"
:label="$t('safeSnap.to')"
:error="!isToValid ? 'Invalid address' : undefined"
/>

<UiInput
placeholder="123456"
:error="!isValueValid && 'Invalid value'"
:model-value="finalTransaction.value"
@update:model-value="(e: string) => updateValue(e)"
>
<template #label>Value (wei)</template>
</UiInput>

<!-- ContractInteraction Parameters -->
<div
class="flex flex-col gap-2"
v-if="finalTransaction.functionFragment?.inputs?.length"
>
<div class="text-left mt-3">Function Parameters</div>
<div class="divider h-[1px] bg-skin-border mb-3" />
<MethodParameterInput
v-for="input in finalTransaction.functionFragment.inputs"
:key="input.name"
:validateOnMount="true"
:parameter="input"
:value="finalTransaction?.parameters?.[input.name] ?? ''"
@update-parameter-value="
(e: string) => updateParams({ [input.name]: e })
"
/>
</div>
</div>
</template>
12 changes: 11 additions & 1 deletion src/plugins/oSnap/components/TransactionBuilder/Transaction.vue
Original file line number Diff line number Diff line change
Expand Up @@ -9,13 +9,15 @@ import {
type Transaction as TTransaction,
type TransactionType as TTransactionType,
type Token,
type TransferFundsTransaction
type TransferFundsTransaction,
SafeImportTransaction
} from '../../types';
import TransactionType from '../Input/TransactionType.vue';
import ContractInteraction from './ContractInteraction.vue';
import RawTransaction from './RawTransaction.vue';
import TransferFunds from './TransferFunds.vue';
import TransferNFT from './TransferNFT.vue';
import SafeImport from './SafeImport.vue';

const props = defineProps<{
transaction: TTransaction;
Expand Down Expand Up @@ -109,5 +111,13 @@ function setTransactionAsInvalid() {
:setTransactionAsInvalid="setTransactionAsInvalid"
@update-transaction="updateTransaction"
/>

<SafeImport
v-if="transaction.type === 'safeImport'"
:transaction="newTransaction as SafeImportTransaction"
:network="network"
:setTransactionAsInvalid="setTransactionAsInvalid"
@update-transaction="updateTransaction"
/>
</div>
</template>
3 changes: 2 additions & 1 deletion src/plugins/oSnap/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1519,7 +1519,8 @@ export const transactionTypes = [
'transferFunds',
'transferNFT',
'contractInteraction',
'raw'
'raw',
'safeImport'
] as const;

export const solidityZeroHexString =
Expand Down
Loading
Loading