diff --git a/app2/app2.nix b/app2/app2.nix index ae52a9a94e..6b40022990 100644 --- a/app2/app2.nix +++ b/app2/app2.nix @@ -20,7 +20,7 @@ _: { { packages = { app2 = jsPkgs.buildNpmPackage { - npmDepsHash = "sha256-4iHm9HkGsQzVmonjtTLbRIHHQRC9ser23gfMzYL6z2A="; + npmDepsHash = "sha256-qo3INRx9IMbe5lfJrm/7/S++u9TVun7NqtXuvqpzCII="; src = ./.; sourceRoot = "app2"; npmFlags = [ "--legacy-peer-deps" ]; diff --git a/app2/package-lock.json b/app2/package-lock.json index c7fed5eda2..c263c7c33f 100644 --- a/app2/package-lock.json +++ b/app2/package-lock.json @@ -13,6 +13,7 @@ "@eslint/js": "^9.20.0", "@keplr-wallet/types": "^0.12.190", "@leapwallet/types": "^0.0.5", + "@scure/base": "^1.2.4", "@sveltejs/adapter-static": "^3.0.8", "@sveltejs/kit": "^2.17.2", "@sveltejs/vite-plugin-svelte": "^5.0.3", diff --git a/app2/package.json b/app2/package.json index e05b91496c..644d80afb7 100644 --- a/app2/package.json +++ b/app2/package.json @@ -20,6 +20,7 @@ "@eslint/js": "^9.20.0", "@keplr-wallet/types": "^0.12.190", "@leapwallet/types": "^0.0.5", + "@scure/base": "^1.2.4", "@sveltejs/adapter-static": "^3.0.8", "@sveltejs/kit": "^2.17.2", "@sveltejs/vite-plugin-svelte": "^5.0.3", diff --git a/app2/src/lib/examples/transfer-arguments.ts b/app2/src/lib/examples/transfer-arguments.ts new file mode 100644 index 0000000000..5a39229749 --- /dev/null +++ b/app2/src/lib/examples/transfer-arguments.ts @@ -0,0 +1,79 @@ +import type { RpcType } from "$lib/schema/chain" + +type EVMTransferInput = { + sourceRpcType: "evm" + destinationRpcType: typeof RpcType.Type + baseToken: string + baseAmount: string + quoteToken: string + quoteAmount: string + sourceChannelId: number + wethToken: string + receiver: string + ucs03address: string +} + +type CosmosTransferInput = { + sourceRpcType: "cosmos" + destinationRpcType: typeof RpcType.Type + baseToken: string + baseAmount: string + quoteToken: string + quoteAmount: string + sourceChannelId: number + receiver: string + ucs03address: string +} + +type AptosTransferInput = { + sourceRpcType: "aptos" + destinationRpcType: typeof RpcType.Type + baseToken: string + baseAmount: string + quoteToken: string + quoteAmount: string + sourceChannelId: number + receiver: string + ucs03address: string +} + +export const examples: { + evm: EVMTransferInput + cosmos: CosmosTransferInput + aptos: AptosTransferInput +} = { + evm: { + sourceRpcType: "evm", + destinationRpcType: "cosmos", + baseToken: "0x1c7d4b196cb0c7b01d743fbc6116a902379c7238", + baseAmount: "1000", + quoteToken: "0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48", + quoteAmount: "1000", + receiver: "union10z7xxj2m8q3f7j58uxmff38ws9u8m0vmne2key", + sourceChannelId: 1, + ucs03address: "0x742d35cc6634c0532925a3b844bc454e4438f44e", + wethToken: "0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2" + }, + cosmos: { + sourceRpcType: "cosmos", + destinationRpcType: "evm", + baseToken: "0x1c7d4b196cb0c7b01d743fbc6116a902379c7238", + baseAmount: "1000", + quoteToken: "0xabcdef1234567890abcdef1234567890abcdef12", + quoteAmount: "1000", + receiver: "0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48", + sourceChannelId: 2, + ucs03address: "0xabcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890" + }, + aptos: { + sourceRpcType: "aptos", + destinationRpcType: "evm", + baseToken: "0x1c7d4b196cb0c7b01d743fbc6116a902379c7238", + baseAmount: "1000", + quoteToken: "0x2abcdef1234567890abcdef1234567890abcdef12", + quoteAmount: "1000", + receiver: "0x1f9090aae28b8a3dceadf281b0f12828e676c326", + sourceChannelId: 3, + ucs03address: "0xabcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890" + } +} diff --git a/app2/src/lib/schema/chain.ts b/app2/src/lib/schema/chain.ts index cdaeb48a72..0862b365d9 100644 --- a/app2/src/lib/schema/chain.ts +++ b/app2/src/lib/schema/chain.ts @@ -7,7 +7,7 @@ export const RpcType = Schema.Union( Schema.Literal("evm"), Schema.Literal("cosmos"), Schema.Literal("aptos") -) +).annotations({ message: () => "type must be 'evm', 'cosmos', or 'aptos'" }) export class ChainFeatures extends Schema.Class("ChainFeatures")({ channel_list: Schema.Boolean, diff --git a/app2/src/lib/schema/channel.ts b/app2/src/lib/schema/channel.ts index 56c851255e..0d1f7525a2 100644 --- a/app2/src/lib/schema/channel.ts +++ b/app2/src/lib/schema/channel.ts @@ -1,3 +1,6 @@ import { Schema } from "effect" -export const ChannelId = Schema.Int.pipe(Schema.brand("ChannelId")) +export const ChannelId = Schema.Int.pipe( + Schema.nonNegative({ message: () => "ChannelId must be non-negative" }), + Schema.brand("ChannelId") +) diff --git a/app2/src/lib/schema/token.ts b/app2/src/lib/schema/token.ts index 85515f3078..ab07ef3473 100644 --- a/app2/src/lib/schema/token.ts +++ b/app2/src/lib/schema/token.ts @@ -1,5 +1,12 @@ import { Schema } from "effect" import { Hex } from "$lib/schema/hex" +import { AddressEvmCanonical } from "$lib/schema/address" export const TokenRawDenom = Hex.pipe(Schema.brand("TokenRawDenom")) export const TokenRawAmount = Schema.BigInt.pipe(Schema.brand("TokenRawAmount")) +export const EVMWethToken = AddressEvmCanonical.pipe( + Schema.annotations({ + message: () => + "WETH token must be a valid EVM canonical address (e.g., 0x followed by 40 hex chars)" + }) +) diff --git a/app2/src/lib/schema/transfer-arguments.ts b/app2/src/lib/schema/transfer-arguments.ts new file mode 100644 index 0000000000..031e73c522 --- /dev/null +++ b/app2/src/lib/schema/transfer-arguments.ts @@ -0,0 +1,96 @@ +import { Schema } from "effect" +import { RpcType } from "$lib/schema/chain" +import { EVMWethToken, TokenRawAmount, TokenRawDenom } from "$lib/schema/token" +import { ChannelId } from "$lib/schema/channel" +import { isValidCanonicalForChain } from "$lib/utils/convert-display" + +const BaseTransferFields = { + baseToken: TokenRawDenom.annotations({ + message: () => "baseToken must be a non-empty string (e.g., token address or symbol)" + }), + baseAmount: TokenRawAmount.annotations({ + message: () => "baseAmount must be a valid bigint string (e.g., '1000000')" + }), + quoteToken: TokenRawDenom.annotations({ + message: () => "quoteToken must be a non-empty string (e.g., token address or symbol)" + }), + quoteAmount: TokenRawAmount.annotations({ + message: () => "quoteAmount must be a valid bigint string (e.g., '1000000')" + }), + sourceChannelId: ChannelId.annotations({ + message: () => "sourceChannelId must be a non-negative integer" + }), + destinationRpcType: RpcType.annotations({ + message: () => "destinationType must be a valid RPC type ('evm', 'cosmos', or 'aptos')" + }) +} + +const EVMTransferSchema = Schema.Struct({ + ...BaseTransferFields, + sourceRpcType: RpcType.pipe( + Schema.filter(v => v === "evm", { message: () => "type must be 'evm'" }) + ), + wethToken: EVMWethToken, + receiver: Schema.String.pipe( + Schema.nonEmptyString({ message: () => "receiver must be a non-empty string" }) + ) +}).pipe( + Schema.filter(data => + isValidCanonicalForChain(data.receiver, data.destinationRpcType) + ? true + : `receiver must be a valid display address for ${data.destinationRpcType}` + ) +) + +export class EVMTransfer extends Schema.Class("EVMTransfer")(EVMTransferSchema) {} + +const CosmosTransferSchema = Schema.Struct({ + ...BaseTransferFields, + sourceRpcType: RpcType.pipe( + Schema.filter(v => v === "cosmos", { message: () => "type must be 'cosmos'" }) + ), + receiver: Schema.String.pipe( + Schema.nonEmptyString({ message: () => "receiver must be a non-empty string" }) + ) +}).pipe( + Schema.filter(data => + isValidCanonicalForChain(data.receiver, data.destinationRpcType) + ? true + : `receiver must be a valid display address for ${data.destinationRpcType}` + ) +) + +export class CosmosTransfer extends Schema.Class("CosmosTransfer")( + CosmosTransferSchema +) {} + +const AptosTransferSchema = Schema.Struct({ + ...BaseTransferFields, + sourceRpcType: RpcType.pipe( + Schema.filter(v => v === "aptos", { message: () => "type must be 'aptos'" }) + ), + receiver: Schema.String.pipe( + Schema.nonEmptyString({ message: () => "receiver must be a non-empty string" }) + ) +}).pipe( + Schema.filter(data => + isValidCanonicalForChain(data.receiver, data.destinationRpcType) + ? true + : `receiver must be a valid display address for ${data.destinationRpcType}` + ) +) + +export class AptosTransfer extends Schema.Class("AptosTransfer")( + AptosTransferSchema +) {} + +export const TransferSchema = Schema.Union(EVMTransfer, CosmosTransfer, AptosTransfer).annotations({ + identifier: "Transfer", + title: "Transfer", + description: "transfer arguments" +}) + +export type Transfer = Schema.Schema.Type +export type EVMTransferType = Schema.Schema.Type +export type CosmosTransferType = Schema.Schema.Type +export type AptosTransferType = Schema.Schema.Type diff --git a/app2/src/lib/utils/convert-display.ts b/app2/src/lib/utils/convert-display.ts new file mode 100644 index 0000000000..4c7cf62710 --- /dev/null +++ b/app2/src/lib/utils/convert-display.ts @@ -0,0 +1,137 @@ +import { bech32 } from "@scure/base" +import { Schema } from "effect" +import { AddressAptosDisplay, AddressCosmosDisplay, AddressEvmDisplay } from "$lib/schema/address" + +/** + * Convert a bech32 display address to canonical bytes + */ +export function cosmosDisplayToCanonical(displayAddress: string): Uint8Array { + try { + const decoded = bech32.decode(displayAddress as `${string}1${string}`) + const canonicalAddress = bech32.fromWords(decoded.words) + return new Uint8Array(canonicalAddress) + } catch (error: any) { + throw new Error(`Invalid Cosmos bech32 address: ${error.message}`) + } +} + +/** + * Convert an EVM display address (hex) to canonical bytes + */ +export function evmDisplayToCanonical(displayAddress: string): Uint8Array { + // Validate EVM address format (0x + 40 hex characters) + if (!/^0x[0-9a-fA-F]{40}$/.test(displayAddress)) { + throw new Error("EVM address must be 0x followed by 40 hex characters") + } + + // Remove 0x prefix and convert to bytes + const hexWithoutPrefix = displayAddress.slice(2) + const bytes = new Uint8Array(20) + + for (let i = 0; i < 40; i += 2) { + bytes[i / 2] = Number.parseInt(hexWithoutPrefix.substring(i, i + 2), 16) + } + + return bytes +} + +/** + * Convert an Aptos display address (hex) to canonical bytes + */ +export function aptosDisplayToCanonical(displayAddress: string): Uint8Array { + // Validate Aptos address format (0x + 64 hex characters) + if (!/^0x[0-9a-fA-F]{64}$/.test(displayAddress)) { + throw new Error("Aptos address must be 0x followed by 64 hex characters") + } + + // Remove 0x prefix and convert to bytes + const hexWithoutPrefix = displayAddress.slice(2) + const bytes = new Uint8Array(32) + + for (let i = 0; i < 64; i += 2) { + bytes[i / 2] = Number.parseInt(hexWithoutPrefix.substring(i, i + 2), 16) + } + + return bytes +} + +/** + * Converts a Uint8Array to a hex string + */ +export function bytesToHex(bytes: Uint8Array): string { + let hexString = "" + for (const byte of bytes) { + hexString += byte.toString(16).padStart(2, "0") + } + return `0x${hexString}` +} + +export const isValidCanonicalForChain = ( + displayAddress: string, + destinationRpcType: string +): boolean => { + if (!displayAddress || displayAddress.length === 0) { + return false + } + + // Function to validate display format using schema + const isValidDisplay = (schema: Schema.Schema): boolean => { + try { + Schema.decodeSync(schema)(displayAddress, { errors: "all" }) + return true + } catch (e) { + return false + } + } + + // First validate the display format using appropriate schema + let isValidDisplayFormat = false + switch (destinationRpcType) { + case "evm": + isValidDisplayFormat = isValidDisplay(AddressEvmDisplay) + break + case "cosmos": + isValidDisplayFormat = isValidDisplay(AddressCosmosDisplay) + break + case "aptos": + isValidDisplayFormat = isValidDisplay(AddressAptosDisplay) + break + default: + return false + } + + // If display format is invalid, canonical format cannot be valid + if (!isValidDisplayFormat) { + return false + } + + // Then convert from display to canonical and validate + try { + let canonicalBytes: Uint8Array + + switch (destinationRpcType) { + case "evm": { + // Convert EVM display address (checksum hex) to canonical bytes (20 bytes) + canonicalBytes = evmDisplayToCanonical(displayAddress) + return canonicalBytes.length === 20 + } + + case "cosmos": { + // Convert Cosmos display address (bech32) to canonical bytes + canonicalBytes = cosmosDisplayToCanonical(displayAddress) + return canonicalBytes.length === 20 || canonicalBytes.length === 32 + } + + case "aptos": { + // Convert Aptos display address (hex) to canonical bytes + canonicalBytes = aptosDisplayToCanonical(displayAddress) + return canonicalBytes.length === 32 + } + + default: + return false + } + } catch (error) { + return false + } +} diff --git a/app2/src/routes/transfer/validate/+page.svelte b/app2/src/routes/transfer/validate/+page.svelte new file mode 100644 index 0000000000..d045e55a03 --- /dev/null +++ b/app2/src/routes/transfer/validate/+page.svelte @@ -0,0 +1,72 @@ + + +
+

Transfer Schema Validation

+

Check the console for detailed logs of the examples.

+ +
+ {#each results as result} +
+

{result.type.toUpperCase()} Example

+ {#if result.data} +

Success!

+
{JSON.stringify(result.data, (_, v) => typeof v === "bigint" ? v.toString() : v, 2)}
+ {:else if result.error} +

Error: {result.error}

+ {/if} +
+ {/each} +
+ +
+ + + +
+
\ No newline at end of file