From c6d2fc9270cdbe88dc0fa3c9d32a8d5801e2c8d5 Mon Sep 17 00:00:00 2001 From: Lukas Date: Wed, 11 Dec 2024 22:23:18 +0100 Subject: [PATCH] refactor(app): changes to approach --- .../TransferFrom/components/DebugBox.svelte | 54 +++--- app/src/lib/components/TransferFrom/config.ts | 11 ++ .../lib/components/TransferFrom/index.svelte | 47 ++--- .../lib/components/TransferFrom/intents.ts | 175 ------------------ .../{transfer.ts => transfer/context.ts} | 26 +-- .../components/TransferFrom/transfer/index.ts | 30 +++ .../TransferFrom/transfer/intents.ts | 101 ++++++++++ .../{validation.ts => transfer/schema.ts} | 45 +++-- .../TransferFrom/transfer/validation.ts | 161 ++++++++++++++++ 9 files changed, 403 insertions(+), 247 deletions(-) delete mode 100644 app/src/lib/components/TransferFrom/intents.ts rename app/src/lib/components/TransferFrom/{transfer.ts => transfer/context.ts} (85%) create mode 100644 app/src/lib/components/TransferFrom/transfer/index.ts create mode 100644 app/src/lib/components/TransferFrom/transfer/intents.ts rename app/src/lib/components/TransferFrom/{validation.ts => transfer/schema.ts} (59%) create mode 100644 app/src/lib/components/TransferFrom/transfer/validation.ts diff --git a/app/src/lib/components/TransferFrom/components/DebugBox.svelte b/app/src/lib/components/TransferFrom/components/DebugBox.svelte index e4745eac8d..76a4ae0f75 100644 --- a/app/src/lib/components/TransferFrom/components/DebugBox.svelte +++ b/app/src/lib/components/TransferFrom/components/DebugBox.svelte @@ -1,29 +1,29 @@ -
@@ -38,15 +38,13 @@ export let assetInfo: DebugProps["assetInfo"]

Raw Intents

{#each Object.entries($intents) as [key, value]} - {#if key !== "errors" && key !== "isValid"} -

{key}: "{value}"

- {/if} +

{key}: "{value}"

{/each}
-

Errors:

- {#each Object.entries($intents.errors) as [key, value]} +

Validation Errors:

+ {#each Object.entries($validation) as [key, value]}

{key}: "{value}"

{/each}
diff --git a/app/src/lib/components/TransferFrom/config.ts b/app/src/lib/components/TransferFrom/config.ts index 50c68d52da..a69929c1cf 100644 --- a/app/src/lib/components/TransferFrom/config.ts +++ b/app/src/lib/components/TransferFrom/config.ts @@ -1 +1,12 @@ +import type { RawTransferIntents } from "$lib/components/TransferFrom/transfer/intents.ts"; + export const TRANSFER_DEBUG = true + +export const defaultParams: RawTransferIntents = { + source: "union-testnet-8", + destination: "11155111", + asset: "", + receiver: "", + amount: "", + isValid: false +} diff --git a/app/src/lib/components/TransferFrom/index.svelte b/app/src/lib/components/TransferFrom/index.svelte index 1ab1f0bae9..5fa6e1c501 100644 --- a/app/src/lib/components/TransferFrom/index.svelte +++ b/app/src/lib/components/TransferFrom/index.svelte @@ -1,12 +1,13 @@
intents.updateField('source', event)} /> - {#if $intents.errors.source} - {$intents.errors.source} + {#if $validation.source} + {$validation.source} {/if}
@@ -40,12 +41,12 @@ id="destination" name="destination" placeholder="Enter destination chain" - class="w-[300px] p-1 {$intents.errors.destination ? 'border-red-500' : ''}" + class="w-[300px] p-1 {$validation.destination ? 'border-red-500' : ''}" value={$intents.destination} on:input={event => intents.updateField('destination', event)} /> - {#if $intents.errors.destination} - {$intents.errors.destination} + {#if $validation.destination} + {$validation.destination} {/if} @@ -56,12 +57,12 @@ id="asset" name="asset" placeholder="Enter asset" - class="w-[300px] p-1 {$intents.errors.asset ? 'border-red-500' : ''}" + class="w-[300px] p-1 {$validation.asset ? 'border-red-500' : ''}" value={$intents.asset} on:input={event => intents.updateField('asset', event)} /> - {#if $intents.errors.asset} - {$intents.errors.asset} + {#if $validation.asset} + {$validation.asset} {/if} @@ -83,12 +84,12 @@ data-field="amount" autocapitalize="none" pattern="^[0-9]*[.,]?[0-9]*$" - class="w-[300px] p-1 {$intents.errors.amount ? 'border-red-500' : ''}" + class="w-[300px] p-1 {$validation.amount ? 'border-red-500' : ''}" value={$intents.amount} on:input={event => intents.updateField('amount', event)} /> - {#if $intents.errors.amount} - {$intents.errors.amount} + {#if $validation.amount} + {$validation.amount} {/if} @@ -104,18 +105,22 @@ spellcheck="false" autocomplete="off" data-field="receiver" - class="w-[300px] p-1 disabled:bg-black/30 {$intents.errors.receiver ? 'border-red-500' : ''}" + class="w-[300px] p-1 disabled:bg-black/30 {$validation.receiver ? 'border-red-500' : ''}" placeholder="Enter destination address" value={$intents.receiver} on:input={event => intents.updateField('receiver', event)} /> - {#if $intents.errors.receiver} - {$intents.errors.receiver} + {#if $validation.receiver} + {$validation.receiver} {/if} {#if TRANSFER_DEBUG} - + {/if} \ No newline at end of file diff --git a/app/src/lib/components/TransferFrom/intents.ts b/app/src/lib/components/TransferFrom/intents.ts deleted file mode 100644 index 59f735a926..0000000000 --- a/app/src/lib/components/TransferFrom/intents.ts +++ /dev/null @@ -1,175 +0,0 @@ -import { writable } from "svelte/store" -import { browser } from "$app/environment" -import { page } from "$app/stores" -import { transferSchema } from "./validation.ts" -import { safeParse } from "valibot" -import { debounce } from "$lib/utilities" - -//Need to clean up the types so they make sense -//RawTransferIntents should not contain errors etc just the raw inputs - -export type FormFields = { - source: string - destination: string - asset: string - receiver: string - amount: string -} - -type FieldErrors = Partial> - -interface RawTransferIntents extends FormFields { - errors: FieldErrors - isValid: boolean -} - -export interface IntentStore { - subscribe: (callback: (value: RawTransferIntents) => void) => () => void - set: (value: Partial) => void - updateField: (field: keyof FormFields, valueOrEvent: string | Event) => void - reset: () => void - validate: () => Promise -} - -const defaultParams: RawTransferIntents = { - source: "union-testnet-8", - destination: "union-testnet-8", - asset: "", - receiver: "", - amount: "", - errors: {}, - isValid: false -} - -export function createIntentStore(): IntentStore { - const store = writable(defaultParams) - const { subscribe, set, update } = store - - const debouncedUpdateUrl = debounce( - ({ source, destination, asset, receiver, amount }: FormFields) => { - if (browser) { - const url = new URL(window.location.href) - const params = { source, destination, asset, receiver, amount } - - Object.entries(params).forEach(([key, val]) => { - if (val) { - url.searchParams.set(key, val) - } else { - url.searchParams.delete(key) - } - }) - history.replaceState({}, "", url.toString()) - window.dispatchEvent(new PopStateEvent("popstate")) - } - }, - 1000 - ) - - function validate(params: FormFields): FieldErrors { - const result = safeParse(transferSchema, params) - - if (!result.success) { - return result.issues.reduce((acc, issue) => { - const fieldName = issue.path?.[0]?.key as keyof FormFields - - if (fieldName && !params[fieldName]) { - return acc - } - - if (fieldName) { - acc[fieldName] = issue.message - } - return acc - }, {} as FieldErrors) - } - - return {} - } - - if (browser) { - page.subscribe(pageData => { - if (pageData?.url?.searchParams) { - const newParams: Partial = {} - const queryParams = pageData.url.searchParams - ;(Object.keys(defaultParams) as Array).forEach(key => { - const value = queryParams.get(key) - if (value) { - newParams[key] = value - } - }) - - update(state => { - const validatedErrors = validate({ ...state, ...newParams }) - return { - ...state, - ...newParams, - errors: validatedErrors, - isValid: Object.keys(validatedErrors).length === 0 - } - }) - } - }) - } - - return { - subscribe, - - set: (value: Partial) => { - update(state => { - const newParams = { ...state, ...value } - const errors = validate(newParams) - debouncedUpdateUrl(newParams) - return { - ...newParams, - errors, - isValid: Object.keys(errors).length === 0 - } - }) - }, - - updateField: (field: keyof FormFields, valueOrEvent: string | Event) => { - const value = - valueOrEvent instanceof Event - ? (valueOrEvent.target as HTMLInputElement).value - : valueOrEvent - - update(state => { - const newParams = { ...state, [field]: value } - const errors = validate(newParams) - debouncedUpdateUrl(newParams) - return { - ...newParams, - errors, - isValid: Object.keys(errors).length === 0 - } - }) - }, - - reset: () => { - update(_state => { - const errors = validate({ ...defaultParams }) - const isValid = Object.keys(errors).length === 0 - if (browser) { - history.replaceState({}, "", window.location.pathname) - } - return { - ...defaultParams, - errors, - isValid - } - }) - }, - - validate: () => { - return new Promise(resolve => { - update(state => { - const { source, destination, asset, receiver, amount } = state - const errors = validate({ source, destination, asset, receiver, amount }) - const isValid = Object.keys(errors).length === 0 - resolve(isValid) - return { ...state, errors, isValid } - }) - }) - } - } -} diff --git a/app/src/lib/components/TransferFrom/transfer.ts b/app/src/lib/components/TransferFrom/transfer/context.ts similarity index 85% rename from app/src/lib/components/TransferFrom/transfer.ts rename to app/src/lib/components/TransferFrom/transfer/context.ts index ef0f41a931..ed0de5d489 100644 --- a/app/src/lib/components/TransferFrom/transfer.ts +++ b/app/src/lib/components/TransferFrom/transfer/context.ts @@ -1,21 +1,21 @@ import { derived, get, type Readable } from "svelte/store" -import { createIntentStore, type IntentStore } from "./intents.ts" +import type { IntentStore } from "./intents" import { userAddrCosmos } from "$lib/wallet/cosmos" import { userAddrEvm } from "$lib/wallet/evm" import { userAddressAptos } from "$lib/wallet/aptos" import { userBalancesQuery } from "$lib/queries/balance" import type { Chain, UserAddresses } from "$lib/types" import { useQueryClient } from "@tanstack/svelte-query" -import type { Address } from "$lib/wallet/types.ts" +import type { Address } from "$lib/wallet/types" -type AddressBalance = { +export type AddressBalance = { balance: bigint gasToken: boolean address: Address symbol: string } -type NamedBalance = { +export type NamedBalance = { balance: bigint address: string name: string | null @@ -23,12 +23,11 @@ type NamedBalance = { gasToken: boolean } -type EmptyBalance = {} +export type EmptyBalance = {} -type Balance = AddressBalance | NamedBalance | EmptyBalance +export type Balance = AddressBalance | NamedBalance | EmptyBalance -interface TransferStore { - intents: IntentStore +export interface ContextStore { chains: Array userAddress: Readable sourceChain: Readable @@ -36,8 +35,8 @@ interface TransferStore { balances: Readable> assetInfo: Readable } -export function createTransferStore(): TransferStore { - const intents = createIntentStore() + +export function createContextStore(intents: IntentStore): ContextStore { const queryClient = useQueryClient() function queryData>( @@ -48,13 +47,16 @@ export function createTransferStore(): TransferStore { return (filter ? data.filter(filter) : data) as T } + // Chain data const chains = queryData>(["chains"], chain => chain.enabled_staging) + // User address data const userAddress = derived( [userAddrCosmos, userAddrEvm, userAddressAptos], ([cosmos, evm, aptos]) => ({ evm, aptos, cosmos }) ) as Readable + // Chain selections const sourceChain = derived(intents, intentsValue => chains.find(chain => chain.chain_id === intentsValue.source) ) @@ -63,6 +65,7 @@ export function createTransferStore(): TransferStore { chains.find(chain => chain.chain_id === intentsValue.destination) ) + // Balance data const balances = derived( [ intents, @@ -93,7 +96,6 @@ export function createTransferStore(): TransferStore { ) return { - intents, chains, userAddress, sourceChain, @@ -101,4 +103,4 @@ export function createTransferStore(): TransferStore { balances, assetInfo } -} +} \ No newline at end of file diff --git a/app/src/lib/components/TransferFrom/transfer/index.ts b/app/src/lib/components/TransferFrom/transfer/index.ts new file mode 100644 index 0000000000..f5745b1148 --- /dev/null +++ b/app/src/lib/components/TransferFrom/transfer/index.ts @@ -0,0 +1,30 @@ +import {createIntentStore, type IntentStore} from "./intents" +import type {Readable} from "svelte/store"; +import type {Chain, UserAddresses} from "$lib/types.ts"; +import {type Balance, createContextStore} from "$lib/components/TransferFrom/transfer/context.ts"; +import {createValidationStore, type ValidationStore} from "$lib/components/TransferFrom/transfer/validation.ts"; + +export interface TransferStore { + intents: IntentStore + context: { + chains: Array + userAddress: Readable + sourceChain: Readable + destinationChain: Readable + balances: Readable> + assetInfo: Readable + } + validation: ValidationStore +} + +export function createTransferStore(): TransferStore { + const intents = createIntentStore() + const context = createContextStore(intents) + const validation = createValidationStore(intents, context) + + return { + intents, + context, + validation + } +} \ No newline at end of file diff --git a/app/src/lib/components/TransferFrom/transfer/intents.ts b/app/src/lib/components/TransferFrom/transfer/intents.ts new file mode 100644 index 0000000000..e5817ad693 --- /dev/null +++ b/app/src/lib/components/TransferFrom/transfer/intents.ts @@ -0,0 +1,101 @@ +import { writable } from "svelte/store" +import { browser } from "$app/environment" +import { page } from "$app/stores" +import { debounce } from "$lib/utilities" +import {defaultParams} from "$lib/components/TransferFrom/config.ts"; + +export type FormFields = { + source: string + destination: string + asset: string + receiver: string + amount: string +} + +export interface RawTransferIntents extends FormFields { + isValid: boolean +} + +export interface IntentStore { + subscribe: (callback: (value: RawTransferIntents) => void) => () => void + set: (value: Partial) => void + updateField: (field: keyof FormFields, valueOrEvent: string | Event) => void + reset: () => void +} + +export function createIntentStore(): IntentStore { + const store = writable(defaultParams) + const { subscribe, set, update } = store + + const debouncedUpdateUrl = debounce( + ({ source, destination, asset, receiver, amount }: FormFields) => { + if (browser) { + const url = new URL(window.location.href) + const params = { source, destination, asset, receiver, amount } + + Object.entries(params).forEach(([key, val]) => { + if (val) { + url.searchParams.set(key, val) + } else { + url.searchParams.delete(key) + } + }) + history.replaceState({}, "", url.toString()) + window.dispatchEvent(new PopStateEvent("popstate")) + } + }, + 1000 + ) + + if (browser) { + page.subscribe(pageData => { + if (pageData?.url?.searchParams) { + const newParams: Partial = {} + const queryParams = pageData.url.searchParams + ;(Object.keys(defaultParams) as Array).forEach(key => { + const value = queryParams.get(key) + if (value) { + newParams[key] = value + } + }) + + update(state => ({ + ...state, + ...newParams + })) + } + }) + } + + return { + subscribe, + + set: (value: Partial) => { + update(state => { + const newParams = { ...state, ...value } + debouncedUpdateUrl(newParams) + return newParams + }) + }, + + updateField: (field: keyof FormFields, valueOrEvent: string | Event) => { + const value = + valueOrEvent instanceof Event + ? (valueOrEvent.target as HTMLInputElement).value + : valueOrEvent + + update(state => { + const newParams = { ...state, [field]: value } + debouncedUpdateUrl(newParams) + return newParams + }) + }, + + reset: () => { + if (browser) { + history.replaceState({}, "", window.location.pathname) + } + set(defaultParams) + } + } +} \ No newline at end of file diff --git a/app/src/lib/components/TransferFrom/validation.ts b/app/src/lib/components/TransferFrom/transfer/schema.ts similarity index 59% rename from app/src/lib/components/TransferFrom/validation.ts rename to app/src/lib/components/TransferFrom/transfer/schema.ts index f30a3dc24f..854054b93d 100644 --- a/app/src/lib/components/TransferFrom/validation.ts +++ b/app/src/lib/components/TransferFrom/transfer/schema.ts @@ -40,27 +40,50 @@ export const transferSchema = v.pipe( v.string(), v.trim(), v.title("Amount"), - v.description("Amount must be a valid number greater than 0"), - v.check(value => { - const parsedValue = Number.parseFloat(value) - return !Number.isNaN(parsedValue) && parsedValue > 0 - }, "Amount must be greater than 0") + v.description("Amount must be a valid number greater than 0 and not exceed balance"), + ), + balance: v.pipe( + v.string(), + v.trim(), + v.title("Balance"), + v.description("Current balance for the asset") ) }), + // Check amount against balance + v.forward( + v.partialCheck( + [["amount"], ["balance"]], + input => { + const amount = Number.parseFloat(input.amount) + const balance = Number.parseFloat(input.balance) + + // Check if values are valid numbers and amount is positive + if (Number.isNaN(amount) || Number.isNaN(balance) || amount <= 0) { + return false + } + + // Check if balance covers amount + return amount <= balance + }, + "Amount must be a valid number greater than 0 and not exceed available balance" + ), + ["amount"] + ), + // Validate receiver address v.forward( v.partialCheck( - [["destination"], ["receiver"]], // Validate receiver against destination chain + [["destination"], ["receiver"]], input => { if (aptosChainId.includes(input.destination)) { - return isHex(input.receiver) // Aptos: Hexadecimal address + return isHex(input.receiver) } if (evmChainId.includes(input.destination)) { - return isValidEvmAddress(input.receiver) // EVM: Valid Ethereum address + return isValidEvmAddress(input.receiver) } if (cosmosChainId.includes(input.destination)) { - return isValidBech32Address(input.receiver) // Cosmos: Bech32 address + return isValidBech32Address(input.receiver) } - return false // If destination doesn't match any chain, fail validation + return false }, "`receiver` must be a valid address for the selected destination chain" ), @@ -68,4 +91,4 @@ export const transferSchema = v.pipe( ) ) -export type TransferSchema = v.InferOutput +export type TransferSchema = v.InferOutput \ No newline at end of file diff --git a/app/src/lib/components/TransferFrom/transfer/validation.ts b/app/src/lib/components/TransferFrom/transfer/validation.ts new file mode 100644 index 0000000000..446f6a47c2 --- /dev/null +++ b/app/src/lib/components/TransferFrom/transfer/validation.ts @@ -0,0 +1,161 @@ +import type { Readable } from "svelte/store" +import { derived } from "svelte/store" +import type {IntentStore, FormFields, RawTransferIntents} from "./intents" +import type { Chain, UserAddresses } from "$lib/types" +import type { Balance, ContextStore } from "$lib/components/TransferFrom/transfer/context" +import { transferSchema } from "./schema.ts" +import { safeParse } from "valibot" + +export type FieldErrors = Partial> + +export interface ValidationStore extends Readable { + validate: () => Promise +} + +interface ValidationContext { + balances: Balance[] + sourceChain: Chain | undefined + destinationChain: Chain | undefined + assetInfo: Balance | undefined + chains: Chain[] +} + +export function createValidationStore( + intents: IntentStore, + context: ContextStore +): ValidationStore { + const errors = derived< + [ + Readable, + Readable, + Readable, + Readable, + Readable + ], + FieldErrors + >( + [ + intents, + context.balances, + context.sourceChain, + context.destinationChain, + context.assetInfo + ], + ([$intents, $balances, $sourceChain, $destinationChain, $assetInfo]) => { + return validateAll({ + formFields: { + source: $intents.source, + destination: $intents.destination, + asset: $intents.asset, + receiver: $intents.receiver, + amount: $intents.amount + }, + balances: $balances, + sourceChain: $sourceChain, + destinationChain: $destinationChain, + assetInfo: $assetInfo, + chains: context.chains + }) + } + ) + + function validateAll({ + formFields, + balances, + sourceChain, + destinationChain, + assetInfo, + chains + }: { + formFields: FormFields + balances: Balance[] + sourceChain: Chain | undefined + destinationChain: Chain | undefined + assetInfo: Balance | undefined + chains: Chain[] + }): FieldErrors { + const schemaErrors = validateSchema(formFields) + const businessErrors = validateBusinessRules(formFields, { + balances, + sourceChain, + destinationChain, + assetInfo, + chains + }) + + return { + ...schemaErrors, + ...businessErrors + } + } + + function validateSchema(params: FormFields): FieldErrors { + const result = safeParse(transferSchema, params) + + if (!result.success) { + return result.issues.reduce((acc, issue) => { + const fieldName = issue.path?.[0]?.key as keyof FormFields + + if (fieldName && !params[fieldName]) { + return acc + } + + if (fieldName) { + acc[fieldName] = issue.message + } + return acc + }, {} as FieldErrors) + } + + return {} + } + + function validateBusinessRules( + formFields: FormFields, + context: ValidationContext + ): FieldErrors { + const errors: FieldErrors = {} + + // Validate chains + if (formFields.source === formFields.destination) { + errors.destination = "Source and destination chains must be different" + } + + // Validate chain existence + if (!context.sourceChain) { + errors.source = "Invalid source chain" + } + if (!context.destinationChain) { + errors.destination = "Invalid destination chain" + } + + // Validate amount against balance + if (formFields.amount && context.assetInfo && 'balance' in context.assetInfo) { + const amount = parseFloat(formFields.amount) + const balance = Number(context.assetInfo.balance) + if (amount > balance) { + errors.amount = "Insufficient balance" + } + } + + // Add any other cross-field or context-dependent validations + + return errors + } + + return { + subscribe: errors.subscribe, + validate: () => { + return new Promise(resolve => { + let currentErrors: FieldErrors = {} + const unsubscribe = errors.subscribe(value => { + currentErrors = value + }) + + const isValid = Object.keys(currentErrors).length === 0 + unsubscribe() + resolve(isValid) + }) + } + } +} \ No newline at end of file