From 2b4ae667640db041b8278fe4eecea10f6d415d14 Mon Sep 17 00:00:00 2001 From: Aren Date: Fri, 27 Feb 2026 21:45:47 +0400 Subject: [PATCH 1/3] Add @train-protocol/sdk-evm package and integrate HTLC client for EVM support; refactor ManualClaim and UserActions components to utilize new HTLC write client. Update pnpm-lock.yaml and package.json for dependencies. --- .../Swap/AtomicChat/Actions/ManualClaim.tsx | 11 +- .../Swap/AtomicChat/Actions/UserActions.tsx | 77 ++-- apps/app/context/secretDerivationContext.tsx | 8 +- apps/app/hooks/htlc/useHTLCWriteClient.ts | 44 +++ apps/app/lib/htlc/createHTLCClient.ts | 12 +- .../htlc/secretDerivation/walletSign/evm.ts | 16 +- apps/app/package.json | 1 + packages/sdk-evm/package.json | 41 +++ packages/sdk-evm/src/abi.ts | 27 ++ .../evm => sdk-evm/src}/abis/EVM_HTLC.json | 0 packages/sdk-evm/src/client.ts | 324 +++++++++++++++++ packages/sdk-evm/src/index.ts | 26 ++ packages/sdk-evm/src/login/index.ts | 1 + .../src/login/wallet-sign.ts} | 2 +- packages/sdk-evm/src/rpc.ts | 67 ++++ packages/sdk-evm/src/types.ts | 62 ++++ packages/sdk-evm/src/utils.ts | 47 +++ packages/sdk-evm/tsconfig.json | 17 + packages/sdk/package.json | 4 +- .../sdk/src/htlc-clients/evm/abis/ERC20.json | 258 -------------- packages/sdk/src/htlc-clients/evm/client.ts | 336 ------------------ packages/sdk/src/htlc-clients/evm/index.ts | 1 - packages/sdk/src/htlc-clients/index.ts | 1 - packages/sdk/src/index.ts | 2 +- packages/sdk/src/login/index.ts | 1 - packages/sdk/src/registry.ts | 49 +++ packages/sdk/src/types/htlc-client.ts | 4 +- packages/sdk/src/types/params.ts | 2 + pnpm-lock.yaml | 46 ++- 29 files changed, 803 insertions(+), 684 deletions(-) create mode 100644 apps/app/hooks/htlc/useHTLCWriteClient.ts create mode 100644 packages/sdk-evm/package.json create mode 100644 packages/sdk-evm/src/abi.ts rename packages/{sdk/src/htlc-clients/evm => sdk-evm/src}/abis/EVM_HTLC.json (100%) create mode 100644 packages/sdk-evm/src/client.ts create mode 100644 packages/sdk-evm/src/index.ts create mode 100644 packages/sdk-evm/src/login/index.ts rename packages/{sdk/src/login/wallet-sign/evm.ts => sdk-evm/src/login/wallet-sign.ts} (96%) create mode 100644 packages/sdk-evm/src/rpc.ts create mode 100644 packages/sdk-evm/src/types.ts create mode 100644 packages/sdk-evm/src/utils.ts create mode 100644 packages/sdk-evm/tsconfig.json delete mode 100644 packages/sdk/src/htlc-clients/evm/abis/ERC20.json delete mode 100644 packages/sdk/src/htlc-clients/evm/client.ts delete mode 100644 packages/sdk/src/htlc-clients/evm/index.ts delete mode 100644 packages/sdk/src/htlc-clients/index.ts create mode 100644 packages/sdk/src/registry.ts diff --git a/apps/app/components/Swap/AtomicChat/Actions/ManualClaim.tsx b/apps/app/components/Swap/AtomicChat/Actions/ManualClaim.tsx index c2522a9d..257313b1 100644 --- a/apps/app/components/Swap/AtomicChat/Actions/ManualClaim.tsx +++ b/apps/app/components/Swap/AtomicChat/Actions/ManualClaim.tsx @@ -4,9 +4,7 @@ import useWallet from "@/apps/app/hooks/useWallet"; import { WalletActionButton } from "../../buttons"; import posthog from "posthog-js"; import { SwapViewType } from "."; -import { useWalletClient } from "wagmi"; -import { createHTLCClient } from "@/apps/app/lib/htlc/createHTLCClient"; -import { useRpcConfigStore } from "@/apps/app/stores/rpcConfigStore"; +import { useHTLCWriteClient } from "@/apps/app/hooks/htlc/useHTLCWriteClient"; export const ManualClaimAction: FC<{ type: SwapViewType }> = ({ type }) => { const { @@ -24,8 +22,7 @@ export const ManualClaimAction: FC<{ type: SwapViewType }> = ({ type }) => { const { provider } = useWallet(destination_network, 'withdrawal'); const wallet = provider?.activeWallet; - const { data: walletClient } = useWalletClient(); - const getEffectiveRpcUrls = useRpcConfigStore(s => s.getEffectiveRpcUrls); + const createWriteClient = useHTLCWriteClient(); const handleManualClaim = async () => { try { @@ -35,12 +32,10 @@ export const ManualClaimAction: FC<{ type: SwapViewType }> = ({ type }) => { if (!destination_asset) throw new Error("No destination asset"); if (!destAtomicContract) throw new Error("No destination contract"); if (!address) throw new Error("No destination address"); - if (!walletClient) throw new Error("No wallet client"); - if (provider?.activeWallet && (provider.activeWallet.chainId != destination_network.chainId) && provider.switchChain) await provider.switchChain(provider.activeWallet, destination_network.chainId); - const writeClient = createHTLCClient(destination_network, getEffectiveRpcUrls, walletClient); + const writeClient = await createWriteClient(destination_network, wallet); const txHash = await writeClient.claim({ type: destination_asset.contractAddress ? 'erc20' : 'native', diff --git a/apps/app/components/Swap/AtomicChat/Actions/UserActions.tsx b/apps/app/components/Swap/AtomicChat/Actions/UserActions.tsx index 4927791b..3c8a721b 100644 --- a/apps/app/components/Swap/AtomicChat/Actions/UserActions.tsx +++ b/apps/app/components/Swap/AtomicChat/Actions/UserActions.tsx @@ -7,11 +7,10 @@ import { LockStatus } from "@/apps/app/Models/phtlc/PHTLC"; import { SwapQuote } from "@/apps/app/lib/trainApiClient"; import { useSwapStore } from "@/apps/app/stores/swapStore"; import { SwapViewType } from "."; -import { useConfig, useWalletClient } from "wagmi"; +import { useConfig } from "wagmi"; import { useSecretDerivation } from "@/apps/app/context/secretDerivationContext"; import { secretToHashlock } from "@train-protocol/sdk"; -import { createHTLCClient } from "@/apps/app/lib/htlc/createHTLCClient"; -import { useRpcConfigStore } from "@/apps/app/stores/rpcConfigStore"; +import { useHTLCWriteClient } from "@/apps/app/hooks/htlc/useHTLCWriteClient"; import { useSelectedAccount } from "@/context/swapAccounts"; import { Address } from "@/lib/address"; @@ -24,10 +23,9 @@ export const UserCommitAction: FC = ({ quote, type }) => const { source_network, destination_network, amount, address, source_asset, destination_asset, onUserLock, hashlock, setError, srcAtomicContract } = useAtomicState(); const { provider } = useWallet(source_network, 'withdrawal') const wallet = provider?.activeWallet - const { data: walletClient } = useWalletClient() const { deriveSecret } = useSecretDerivation() const config = useConfig() - const getEffectiveRpcUrls = useRpcConfigStore(s => s.getEffectiveRpcUrls) + const createWriteClient = useHTLCWriteClient() const sourceAccount = useSelectedAccount('from', source_network?.caip2Id) const sourceWallet = (sourceAccount?.address && source_network) ? provider?.connectedWallets?.find(w => Address.equals(w.address, sourceAccount?.address, source_network)) : undefined @@ -37,45 +35,20 @@ export const UserCommitAction: FC = ({ quote, type }) => const handleUserLock = async () => { try { - if (!amount) { - throw new Error("No amount specified") - } - if (!address) { - throw new Error("Please enter a valid address") - } - if (!destination_network) { - throw new Error("No destination chain") - } - if (!source_network) { - throw new Error("No source chain") - } - if (!source_asset) { - throw new Error("No source asset") - } - if (!destination_asset) { - throw new Error("No destination asset") - } - if (!atomicContract) { - throw new Error("No atomic contract") - } - if (!destLpAddress || !srcLpAddress) { - throw new Error("No lp address") - } - if (!walletClient) { - throw new Error("No wallet client") - } + if(!quote || !source_network || !sourceWallet || !provider?.activeWallet || !amount || !address || !destination_network || !destination_asset || !source_asset || !atomicContract || !destLpAddress || !srcLpAddress) throw new Error("Missing params") if (provider && sourceWallet && (sourceWallet.chainId != source_network.chainId) && provider.switchChain) await provider.switchChain(sourceWallet, source_network.chainId) const { secret, nonce } = await deriveSecret({ - wallet: provider?.activeWallet, + wallet: provider.activeWallet, config }) - const htlcHashlock = secretToHashlock(secret) + const hashlock = secretToHashlock(secret) - const writeClient = createHTLCClient(source_network, getEffectiveRpcUrls, walletClient) + const writeClient = await createWriteClient(source_network, sourceWallet) const result = await writeClient.createHTLC({ + ...resolveQuote(quote), address, amount: amount.toString(), destinationChain: destination_network.caip2Id, @@ -88,16 +61,8 @@ export const UserCommitAction: FC = ({ quote, type }) => decimals: source_asset.decimals, atomicContract, chainId: source_network.chainId, - solverData: quote?.signature, - quoteExpiry: quote?.quoteExpirationTimestampInSeconds, - rewardToken: quote?.reward ? quote?.reward.rewardToken : undefined, - rewardRecipient: quote?.reward ? quote?.reward.rewardRecipientAddress : undefined, - rewardAmount: quote?.reward ? quote?.reward.amount : undefined, - rewardTimelockDelta: quote?.reward ? quote?.reward.rewardTimelockTimeSpanInSeconds : undefined, - destinationAmount: quote?.receiveAmount, - timelockDelta: quote?.timelock.timelockTimeSpanInSeconds, - hashlock: htlcHashlock, - nonce, + hashlock, + nonce }) if (result?.hashlock && result?.hash) { onUserLock( @@ -140,12 +105,26 @@ export const UserCommitAction: FC = ({ quote, type }) => } +const resolveQuote = (quote: SwapQuote) => { + return { + solverData: quote?.signature, + quoteExpiry: quote?.quoteExpirationTimestampInSeconds, + rewardToken: quote?.reward ? quote?.reward.rewardToken : undefined, + rewardRecipient: quote?.reward ? quote?.reward.rewardRecipientAddress : undefined, + rewardAmount: quote?.reward ? quote?.reward.amount : undefined, + rewardTimelockDelta: quote?.reward ? quote?.reward.rewardTimelockTimeSpanInSeconds : undefined, + destinationAmount: quote?.receiveAmount, + timelockDelta: quote?.timelock.timelockTimeSpanInSeconds, + } +} + export const UserRefundAction: FC<{ type: SwapViewType }> = ({ type }) => { const { source_network, hashlock, sourceDetails, source_asset, setError, refundTxId, srcAtomicContract } = useAtomicState() const { provider: source_provider } = useWallet(source_network, 'withdrawal') - const { data: walletClient } = useWalletClient() + const sourceAccount = useSelectedAccount('from', source_network?.caip2Id) + const sourceWallet = (sourceAccount?.address && source_network) ? source_provider?.connectedWallets?.find(w => Address.equals(w.address, sourceAccount?.address, source_network)) : undefined const updateSwap = useSwapStore(s => s.updateSwap) - const getEffectiveRpcUrls = useRpcConfigStore(s => s.getEffectiveRpcUrls) + const createWriteClient = useHTLCWriteClient() const [requestedRefund, setRequestedRefund] = useState(false) @@ -158,12 +137,12 @@ export const UserRefundAction: FC<{ type: SwapViewType }> = ({ type }) => { if (!sourceDetails) throw new Error("No commitment") if (!source_asset) throw new Error("No source asset") if (!srcAtomicContract) throw new Error("No atomic contract") - if (!walletClient) throw new Error("No wallet client") + if (!sourceWallet) throw new Error("No wallet client") if (source_provider?.activeWallet && (source_provider.activeWallet.chainId != source_network.chainId) && source_provider.switchChain) await source_provider.switchChain(source_provider.activeWallet, source_network.chainId) - const writeClient = createHTLCClient(source_network, getEffectiveRpcUrls, walletClient) + const writeClient = await createWriteClient(source_network, sourceWallet) const res = await writeClient.refund({ type: (source_asset?.contractAddress && source_asset.contractAddress !== '0x0000000000000000000000000000000000000000') ? 'erc20' : 'native', diff --git a/apps/app/context/secretDerivationContext.tsx b/apps/app/context/secretDerivationContext.tsx index 0cd2a8fc..96220fff 100644 --- a/apps/app/context/secretDerivationContext.tsx +++ b/apps/app/context/secretDerivationContext.tsx @@ -1,16 +1,16 @@ // context/secretDerivationContext.tsx import { createContext, useContext, useEffect, useCallback, ReactNode } from 'react'; -import { Wallet } from '@/apps/app/Models/WalletProvider'; +import { Wallet } from '@/Models/WalletProvider'; import { checkPrfSupport, deriveKeyWithPasskey, registerPasskey, PrfSupportResult -} from '@/apps/app/lib/htlc/secretDerivation'; -import { deriveKeyFromEvmSignature } from '@/apps/app/lib/htlc/secretDerivation/walletSign/evm'; -import { useSecretDerivationStore, DerivationStatus } from '@/apps/app/stores/secretDerivationStore'; +} from '@/lib/htlc/secretDerivation'; +import { useSecretDerivationStore, DerivationStatus } from '@/stores/secretDerivationStore'; import { DerivationMethod, deriveSecretFromTimelock } from '@train-protocol/sdk'; +import { deriveKeyFromEvmSignature } from '@/lib/htlc/secretDerivation/walletSign/evm'; interface SecretDerivationContextValue { method: DerivationMethod | null; diff --git a/apps/app/hooks/htlc/useHTLCWriteClient.ts b/apps/app/hooks/htlc/useHTLCWriteClient.ts new file mode 100644 index 00000000..f84019f7 --- /dev/null +++ b/apps/app/hooks/htlc/useHTLCWriteClient.ts @@ -0,0 +1,44 @@ +import { useCallback } from 'react' +import { useConfig } from 'wagmi' +import { getWalletClient } from 'wagmi/actions' +import { createHTLCClient as createClient, IHTLCClient } from '@train-protocol/sdk' +import '@train-protocol/sdk-evm' +import type { EvmSigner } from '@train-protocol/sdk-evm' +import { Network } from '../../Models/Network' +import { Wallet } from '@/Models/WalletProvider' +import { useRpcConfigStore } from '@/stores/rpcConfigStore' +import resolveChain from '@/lib/resolveChain' + +/** Hook that returns a factory for creating HTLC write clients with a signer */ +export function useHTLCWriteClient() { + const config = useConfig() + const getEffectiveRpcUrls = useRpcConfigStore(s => s.getEffectiveRpcUrls) + + return useCallback(async (network: Network, wallet?: Wallet): Promise => { + const chainType = network.caip2Id.split(':')[0] + const rpcUrl = getEffectiveRpcUrls(network)[0] ?? network.nodes?.[0]?.url ?? '' + const chain = resolveChain(network) + + let signer: EvmSigner | undefined + if (wallet?.address && chain) { + const walletClient = await getWalletClient(config, { + chainId: chain.id, + account: wallet.address as `0x${string}`, + }) + signer = { + address: walletClient.account.address, + sendTransaction: async (tx) => { + return walletClient.sendTransaction({ + to: tx.to as `0x${string}`, + data: tx.data as `0x${string}`, + value: tx.value, + chain, + account: walletClient.account, + }) + }, + } + } + + return createClient(chainType, { rpcUrl, signer }) + }, [config, getEffectiveRpcUrls]) +} diff --git a/apps/app/lib/htlc/createHTLCClient.ts b/apps/app/lib/htlc/createHTLCClient.ts index a23272a1..b6d3d4d8 100644 --- a/apps/app/lib/htlc/createHTLCClient.ts +++ b/apps/app/lib/htlc/createHTLCClient.ts @@ -1,19 +1,13 @@ -import { WalletClient } from 'viem' -import { EvmHTLCClient, IHTLCClient } from '@train-protocol/sdk' +import { createHTLCClient as createClient, IHTLCClient } from '@train-protocol/sdk' +import '@train-protocol/sdk-evm' // side-effect: registers eip155 htlc client import { Network } from '../../Models/Network' export function createHTLCClient( network: Network, getEffectiveRpcUrls: (network: Network) => string[], - walletClient?: WalletClient ): IHTLCClient { const chainType = network.caip2Id.split(':')[0] const rpcUrl = getEffectiveRpcUrls(network)[0] ?? network.nodes?.[0]?.url ?? '' - switch (chainType) { - case 'eip155': - return new EvmHTLCClient({ rpcUrl, walletClient }) - default: - throw new Error(`Unsupported chain type: ${chainType} for network ${network.caip2Id}`) - } + return createClient(chainType, { rpcUrl }) } diff --git a/apps/app/lib/htlc/secretDerivation/walletSign/evm.ts b/apps/app/lib/htlc/secretDerivation/walletSign/evm.ts index 61127891..4aa3f558 100644 --- a/apps/app/lib/htlc/secretDerivation/walletSign/evm.ts +++ b/apps/app/lib/htlc/secretDerivation/walletSign/evm.ts @@ -1,10 +1,9 @@ -// Compatibility shim — wraps @train-protocol/sdk deriveKeyFromEvmSignature with the old wagmi Config API +// Compatibility shim — wraps SDK wallet sign registry with the Wagmi Config API import { getAccount } from '@wagmi/core' import { Config } from 'wagmi' -import { - deriveKeyFromEvmSignature as sdkDeriveKey, - getEvmTypedData as sdkGetEvmTypedData, -} from '@train-protocol/sdk' +import { deriveKeyFromWallet } from '@train-protocol/sdk' +import '@train-protocol/sdk-evm' // side-effect: registers evm wallet sign +import { getEvmTypedData as sdkGetEvmTypedData } from '@train-protocol/sdk-evm' const isSandbox = process.env.NEXT_PUBLIC_API_VERSION === 'sandbox' @@ -21,8 +20,9 @@ export const deriveKeyFromEvmSignature = async ( request: (args: { method: string; params: unknown[] }) => Promise } - return sdkDeriveKey(provider, address, { - sandbox: isSandbox, - currentChainId: account.chainId, + return deriveKeyFromWallet('eip155', { + provider, + address, + options: { sandbox: isSandbox, currentChainId: account.chainId }, }) } diff --git a/apps/app/package.json b/apps/app/package.json index edd94930..1a109d5c 100644 --- a/apps/app/package.json +++ b/apps/app/package.json @@ -58,6 +58,7 @@ "@ton/ton": "^13.11.1", "@tonconnect/ui-react": "^2.0.0-beta.4", "@train-protocol/sdk": "workspace:^", + "@train-protocol/sdk-evm": "workspace:^", "@uidotdev/usehooks": "^2.4.1", "@vercel/analytics": "^1.5.0", "@walletconnect/ethereum-provider": "2.21.8", diff --git a/packages/sdk-evm/package.json b/packages/sdk-evm/package.json new file mode 100644 index 00000000..66fd1a98 --- /dev/null +++ b/packages/sdk-evm/package.json @@ -0,0 +1,41 @@ +{ + "name": "@train-protocol/sdk-evm", + "version": "0.1.0", + "description": "Train Protocol SDK — EVM HTLC client", + "type": "module", + "main": "dist/esm/index.js", + "types": "dist/types/index.d.ts", + "exports": { + ".": { + "types": "./dist/types/index.d.ts", + "default": "./dist/esm/index.js" + } + }, + "sideEffects": false, + "files": [ + "dist", + "dist/**/*" + ], + "scripts": { + "build": "pnpm clean && pnpm build:esm+types", + "build:esm+types": "tsc --project tsconfig.json --rootDir ./src --outDir ./dist/esm --declaration --declarationMap --declarationDir ./dist/types", + "clean": "rimraf dist tsconfig.tsbuildinfo", + "dev": "tsc --project tsconfig.json --rootDir ./src --outDir ./dist/esm --declaration --declarationMap --declarationDir ./dist/types --watch", + "check:types": "tsc --noEmit" + }, + "dependencies": { + "ox": "^0.13.2" + }, + "peerDependencies": { + "@train-protocol/sdk": "workspace:^" + }, + "devDependencies": { + "@train-protocol/sdk": "workspace:^", + "@types/node": "^20", + "rimraf": "^6.0.1", + "typescript": "catalog:" + }, + "engines": { + "node": ">=18" + } +} diff --git a/packages/sdk-evm/src/abi.ts b/packages/sdk-evm/src/abi.ts new file mode 100644 index 00000000..847c45b8 --- /dev/null +++ b/packages/sdk-evm/src/abi.ts @@ -0,0 +1,27 @@ +import { AbiFunction, AbiEvent } from 'ox' +import HTLCAbi from './abis/EVM_HTLC.json' + +// Pre-parse HTLC contract functions from ABI +export const htlcFunctions = { + getUserLock: AbiFunction.fromAbi(HTLCAbi, 'getUserLock'), + getSolverLock: AbiFunction.fromAbi(HTLCAbi, 'getSolverLock'), + getSolverLockCount: AbiFunction.fromAbi(HTLCAbi, 'getSolverLockCount'), + userLock: AbiFunction.fromAbi(HTLCAbi, 'userLock'), + refundUser: AbiFunction.fromAbi(HTLCAbi, 'refundUser'), + redeemSolver: AbiFunction.fromAbi(HTLCAbi, 'redeemSolver'), +} + +// Pre-parse HTLC events from ABI +export const htlcEvents = { + UserLocked: AbiEvent.fromAbi(HTLCAbi, 'UserLocked'), +} + +// ERC20 functions — inline instead of importing the broken StarkNet ABI +export const erc20Functions = { + allowance: AbiFunction.from( + 'function allowance(address owner, address spender) view returns (uint256)' + ), + approve: AbiFunction.from( + 'function approve(address spender, uint256 amount) returns (bool)' + ), +} diff --git a/packages/sdk/src/htlc-clients/evm/abis/EVM_HTLC.json b/packages/sdk-evm/src/abis/EVM_HTLC.json similarity index 100% rename from packages/sdk/src/htlc-clients/evm/abis/EVM_HTLC.json rename to packages/sdk-evm/src/abis/EVM_HTLC.json diff --git a/packages/sdk-evm/src/client.ts b/packages/sdk-evm/src/client.ts new file mode 100644 index 00000000..f5afb372 --- /dev/null +++ b/packages/sdk-evm/src/client.ts @@ -0,0 +1,324 @@ +import { AbiFunction, AbiEvent } from 'ox' +import { + CreateHTLCParams, + LockParams, + RefundParams, + ClaimParams, + LockDetails, + LockStatus, + AtomicResult, + RecoveredSwapData, + IHTLCClient, +} from '@train-protocol/sdk' +import { htlcFunctions, htlcEvents, erc20Functions } from './abi.js' +import { JsonRpcClient } from './rpc.js' +import { ZERO_ADDRESS, parseUnits, formatUnits, toHex32, waitForReceipt } from './utils.js' +import type { EvmHTLCClientConfig, EvmSigner, RpcLog } from './types.js' + +export class EvmHTLCClient implements IHTLCClient { + private rpc: JsonRpcClient + private signer: EvmSigner | undefined + + constructor(config: EvmHTLCClientConfig) { + this.rpc = new JsonRpcClient(config.rpcUrl) + this.signer = config.signer + } + + async createHTLC( + params: CreateHTLCParams + ): Promise { + const { + destinationChain, + sourceChain, + destinationAsset, + sourceAsset, + srcLpAddress: lpAddress, + address, + amount, + decimals, + atomicContract, + quoteExpiry, + rewardToken, + rewardRecipient, + rewardAmount, + rewardTimelockDelta, + solverData, + destinationAmount, + timelockDelta, + hashlock, + nonce: timestamp, + } = params + + if (!this.signer) throw new Error('Signer required for createHTLC') + + const parsedAmount = parseUnits(amount.toString(), decimals) + const tokenAddress = sourceAsset.contractAddress || ZERO_ADDRESS + const isNativeToken = !sourceAsset.contractAddress || sourceAsset.contractAddress === ZERO_ADDRESS + + // Handle ERC20 approval + if (!isNativeToken && sourceAsset.contractAddress) { + const allowanceData = AbiFunction.encodeData(erc20Functions.allowance, [ + address as `0x${string}`, + atomicContract as `0x${string}`, + ]) + const allowanceResult = await this.rpc.ethCall(sourceAsset.contractAddress, allowanceData) + const allowance = AbiFunction.decodeResult(erc20Functions.allowance, allowanceResult as `0x${string}`) + + if (allowance < parsedAmount) { + const approveData = AbiFunction.encodeData(erc20Functions.approve, [ + atomicContract as `0x${string}`, + parsedAmount, + ]) + const approveHash = await this.signer.sendTransaction({ + to: sourceAsset.contractAddress, + data: approveData, + }) + await waitForReceipt(this.rpc, approveHash) + } + } + + const userLockParams = { + hashlock: hashlock as `0x${string}`, + amount: parsedAmount, + rewardAmount: rewardAmount || 0n, + timelockDelta, + rewardTimelockDelta: rewardTimelockDelta ?? 0, + quoteExpiry, + sender: address as `0x${string}`, + recipient: lpAddress as `0x${string}`, + token: tokenAddress as `0x${string}`, + rewardToken: rewardToken ?? '', + rewardRecipient: rewardRecipient ?? '', + srcChain: sourceChain || '', + } + + const destinationInfo = { + dstChain: destinationChain, + dstAddress: address, + dstAmount: destinationAmount, + dstToken: destinationAsset, + } + + const userData = toHex32(BigInt(timestamp)) + const calldata = AbiFunction.encodeData(htlcFunctions.userLock, [ + userLockParams, + destinationInfo, + userData as `0x${string}`, + (solverData || '0x') as `0x${string}`, + ]) + + // Simulate via eth_call before sending + await this.rpc.ethCall( + atomicContract, + calldata, + address, + isNativeToken ? parsedAmount : undefined + ) + + const hash = await this.signer.sendTransaction({ + to: atomicContract, + data: calldata, + value: isNativeToken ? parsedAmount : undefined, + }) + + return { hash, hashlock, nonce: timestamp } + } + + async getUserLockDetails(params: LockParams): Promise { + const { id, contractAddress, txId } = params + + const calldata = AbiFunction.encodeData(htlcFunctions.getUserLock, [id as `0x${string}`]) + const raw = await this.rpc.ethCall(contractAddress, calldata) + const result = AbiFunction.decodeResult(htlcFunctions.getUserLock, raw as `0x${string}`) as any + + const lockExists = result.sender !== ZERO_ADDRESS + + let userData: string | undefined + let blockTimestamp: number | undefined + + if (lockExists && txId) { + try { + const receipt = await this.rpc.getTransactionReceipt(txId) + if (receipt) { + const lockEvent = this.findUserLockedEvent(receipt.logs, id) + if (lockEvent?.userData && lockEvent.userData !== '0x') { + userData = BigInt(lockEvent.userData as string).toString() + } + + const block = await this.rpc.getBlockByNumber(receipt.blockNumber) + if (block) { + blockTimestamp = Number(BigInt(block.timestamp)) * 1000 + } + } + } catch (e) { + console.error('Error fetching userData from tx receipt:', e) + } + } + + return { + hashlock: lockExists ? id : undefined, + amount: Number(formatUnits(BigInt(result.amount), 18)), + secret: result.secret != 0n ? BigInt(result.secret) : undefined, + sender: lockExists ? result.sender : undefined, + recipient: result.recipient !== ZERO_ADDRESS ? result.recipient : undefined, + token: result.token !== ZERO_ADDRESS ? result.token : undefined, + timelock: Number(result.timelock), + status: lockExists ? Number(result.status) as LockStatus : undefined, + claimed: Number(result.status), + userData, + blockTimestamp, + } + } + + async getSolverLockDetails(params: LockParams): Promise { + const { id, contractAddress } = params + + const countData = AbiFunction.encodeData(htlcFunctions.getSolverLockCount, [id as `0x${string}`]) + const countRaw = await this.rpc.ethCall(contractAddress, countData) + const count = AbiFunction.decodeResult(htlcFunctions.getSolverLockCount, countRaw as `0x${string}`) + + if (Number(count) === 0) return null + + const lockData = AbiFunction.encodeData(htlcFunctions.getSolverLock, [id as `0x${string}`, 1n]) + const lockRaw = await this.rpc.ethCall(contractAddress, lockData) + const result = AbiFunction.decodeResult(htlcFunctions.getSolverLock, lockRaw as `0x${string}`) as any + + const lockExists = result.sender !== ZERO_ADDRESS + if (!lockExists) return null + + return { + hashlock: id, + amount: Number(formatUnits(BigInt(result.amount), params.decimals ?? 18)), + secret: result.secret != 0n ? BigInt(result.secret) : undefined, + sender: result.sender, + recipient: result.recipient !== ZERO_ADDRESS ? result.recipient : undefined, + token: result.token !== ZERO_ADDRESS ? result.token : undefined, + timelock: Number(result.timelock), + reward: Number(formatUnits(BigInt(result.reward), params.decimals ?? 18)), + rewardTimelock: Number(result.rewardTimelock), + rewardRecipient: result.rewardRecipient !== ZERO_ADDRESS ? result.rewardRecipient : undefined, + rewardToken: result.rewardToken !== ZERO_ADDRESS ? result.rewardToken : undefined, + status: Number(result.status) as LockStatus, + claimed: Number(result.status), + index: 0, + } + } + + async secureGetDetails( + params: LockParams, + nodeUrls: string[], + ): Promise { + const { id, contractAddress } = params + + const calldata = AbiFunction.encodeData(htlcFunctions.getUserLock, [id as `0x${string}`]) + + const results = await Promise.all( + nodeUrls.map(async (url) => { + const rpc = new JsonRpcClient(url) + const raw = await rpc.ethCall(contractAddress, calldata) + return AbiFunction.decodeResult(htlcFunctions.getUserLock, raw as `0x${string}`) as any + }) + ) + + const validResults = results.filter(r => BigInt(r.amount) > 0n) + if (!validResults.length) return null + + const [firstResult, ...otherResults] = validResults + if (!otherResults.every(r => BigInt(r.amount) === BigInt(firstResult.amount))) { + throw new Error('Lock details do not match across the provided nodes') + } + + return { + hashlock: id, + amount: Number(formatUnits(BigInt(firstResult.amount), params.decimals ?? 18)), + secret: firstResult.secret != 0n ? BigInt(firstResult.secret) : undefined, + sender: firstResult.sender !== ZERO_ADDRESS ? firstResult.sender : undefined, + recipient: firstResult.recipient !== ZERO_ADDRESS ? firstResult.recipient : undefined, + token: firstResult.token !== ZERO_ADDRESS ? firstResult.token : undefined, + timelock: Number(firstResult.timelock), + status: Number(firstResult.status) as LockStatus, + claimed: Number(firstResult.status), + userData: firstResult.userData !== ZERO_ADDRESS ? Number(firstResult.userData).toString() : undefined, + } + } + + async refund(params: RefundParams): Promise { + if (!this.signer) throw new Error('Signer required for refund') + const { id, contractAddress } = params + + const calldata = AbiFunction.encodeData(htlcFunctions.refundUser, [id as `0x${string}`]) + + // Simulate via eth_call + await this.rpc.ethCall(contractAddress, calldata, this.signer.address) + + return await this.signer.sendTransaction({ + to: contractAddress, + data: calldata, + }) + } + + async claim(params: ClaimParams): Promise { + if (!this.signer) throw new Error('Signer required for claim') + const { id, contractAddress, secret, destinationAddress } = params + + const account = destinationAddress ?? this.signer.address + + const calldata = AbiFunction.encodeData(htlcFunctions.redeemSolver, [ + id as `0x${string}`, + 1n, + BigInt(secret), + ]) + + // Simulate via eth_call + await this.rpc.ethCall(contractAddress, calldata, account) + + return await this.signer.sendTransaction({ + to: contractAddress, + data: calldata, + }) + } + + async recoverSwap(txHash: string): Promise { + const [receipt, tx] = await Promise.all([ + this.rpc.getTransactionReceipt(txHash), + this.rpc.getTransaction(txHash), + ]) + + if (!receipt || !tx) throw new Error('Transaction not found') + + const lockEvent = this.findUserLockedEvent(receipt.logs) + if (!lockEvent) throw new Error('This transaction does not contain a swap lock') + + return { + hashlock: lockEvent.hashlock as string, + sender: lockEvent.sender as string, + recipient: lockEvent.recipient as string, + srcChain: lockEvent.srcChain as string, + dstChain: lockEvent.dstChain as string, + token: lockEvent.token as string, + amount: lockEvent.amount as bigint, + dstAddress: lockEvent.dstAddress as string, + dstAmount: lockEvent.dstAmount as bigint, + dstToken: lockEvent.dstToken as string, + srcContract: tx.to as string, + } + } + + /** Parse UserLocked event from raw RPC logs */ + private findUserLockedEvent(logs: RpcLog[], matchHashlock?: string): Record | null { + for (const log of logs) { + try { + const decoded = AbiEvent.decode(htlcEvents.UserLocked, { + data: log.data as `0x${string}`, + topics: log.topics as [`0x${string}`, ...`0x${string}`[]], + }) as unknown as Record + if (!matchHashlock || decoded.hashlock === matchHashlock) { + return decoded + } + } catch { + // Not a UserLocked event, skip + } + } + return null + } +} diff --git a/packages/sdk-evm/src/index.ts b/packages/sdk-evm/src/index.ts new file mode 100644 index 00000000..77034503 --- /dev/null +++ b/packages/sdk-evm/src/index.ts @@ -0,0 +1,26 @@ +import { registerHTLCClient, registerWalletSign } from '@train-protocol/sdk' +import { EvmHTLCClient } from './client.js' +import { deriveKeyFromEvmSignature } from './login/index.js' +import type { Eip1193Provider } from './login/index.js' +import type { EvmSigner } from './types.js' + +// Self-register EVM HTLC client for the eip155 namespace +registerHTLCClient('eip155', (config) => new EvmHTLCClient({ + rpcUrl: config.rpcUrl as string, + signer: config.signer as EvmSigner | undefined, + chainId: config.chainId as number | undefined, +})) + +// Self-register EVM wallet sign for login +registerWalletSign('eip155', async (config) => { + return deriveKeyFromEvmSignature( + config.provider as Eip1193Provider, + config.address as `0x${string}`, + config.options as { sandbox?: boolean; currentChainId?: number } | undefined, + ) +}) + +export { EvmHTLCClient } from './client.js' +export type { EvmHTLCClientConfig, EvmSigner } from './types.js' +export { deriveKeyFromEvmSignature, getEvmTypedData } from './login/index.js' +export type { Eip1193Provider } from './login/index.js' diff --git a/packages/sdk-evm/src/login/index.ts b/packages/sdk-evm/src/login/index.ts new file mode 100644 index 00000000..2f95f0ef --- /dev/null +++ b/packages/sdk-evm/src/login/index.ts @@ -0,0 +1 @@ +export * from './wallet-sign' diff --git a/packages/sdk/src/login/wallet-sign/evm.ts b/packages/sdk-evm/src/login/wallet-sign.ts similarity index 96% rename from packages/sdk/src/login/wallet-sign/evm.ts rename to packages/sdk-evm/src/login/wallet-sign.ts index 48da37f6..e0eea09c 100644 --- a/packages/sdk/src/login/wallet-sign/evm.ts +++ b/packages/sdk-evm/src/login/wallet-sign.ts @@ -1,4 +1,4 @@ -import { deriveKeyMaterial, IDENTITY_SALT } from '../key-derivation'; +import { deriveKeyMaterial, IDENTITY_SALT } from '@train-protocol/sdk'; export interface Eip1193Provider { request(args: { method: string; params: unknown[] }): Promise diff --git a/packages/sdk-evm/src/rpc.ts b/packages/sdk-evm/src/rpc.ts new file mode 100644 index 00000000..6dbd0bfd --- /dev/null +++ b/packages/sdk-evm/src/rpc.ts @@ -0,0 +1,67 @@ +import type { RpcTransactionReceipt, RpcTransaction, RpcBlock } from './types.js' + +export class JsonRpcError extends Error { + constructor( + message: string, + public code: number, + public data?: unknown + ) { + super(message) + this.name = 'JsonRpcError' + } +} + +let requestId = 0 + +export class JsonRpcClient { + constructor(private url: string) {} + + async call(method: string, params: unknown[]): Promise { + const response = await fetch(this.url, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + jsonrpc: '2.0', + id: ++requestId, + method, + params, + }), + }) + + if (!response.ok) { + throw new Error(`RPC HTTP error: ${response.status} ${response.statusText}`) + } + + const json = await response.json() + if (json.error) { + throw new JsonRpcError(json.error.message, json.error.code, json.error.data) + } + return json.result + } + + /** Execute a read-only contract call (eth_call) */ + async ethCall( + to: string, + data: string, + from?: string, + value?: bigint, + blockTag = 'latest' + ): Promise { + const callObj: Record = { to, data } + if (from) callObj.from = from + if (value) callObj.value = '0x' + value.toString(16) + return this.call('eth_call', [callObj, blockTag]) as Promise + } + + async getTransactionReceipt(txHash: string): Promise { + return this.call('eth_getTransactionReceipt', [txHash]) as Promise + } + + async getTransaction(txHash: string): Promise { + return this.call('eth_getTransactionByHash', [txHash]) as Promise + } + + async getBlockByNumber(blockNumber: string, fullTxs = false): Promise { + return this.call('eth_getBlockByNumber', [blockNumber, fullTxs]) as Promise + } +} diff --git a/packages/sdk-evm/src/types.ts b/packages/sdk-evm/src/types.ts new file mode 100644 index 00000000..f0103c33 --- /dev/null +++ b/packages/sdk-evm/src/types.ts @@ -0,0 +1,62 @@ +/** + * Minimal signer interface for EVM write operations. + * Integrators wrap their library's signer (viem WalletClient, ethers Signer, + * raw EIP-1193 provider) into this interface. + */ +export interface EvmSigner { + /** The signer's address (checksummed or lowercase) */ + address: string + + /** + * Sign and broadcast a transaction, returning the tx hash. + * The SDK builds all calldata; the signer only needs to sign and send. + */ + sendTransaction(tx: { + to: string + data: string + value?: bigint + chainId?: number + }): Promise +} + +export interface EvmHTLCClientConfig { + /** RPC URL for read operations */ + rpcUrl: string + /** Optional signer for write operations (createHTLC, refund, claim) */ + signer?: EvmSigner + /** Optional chain ID for validation */ + chainId?: number +} + +/** Raw JSON-RPC transaction receipt */ +export interface RpcTransactionReceipt { + transactionHash: string + blockNumber: string + status: string + logs: RpcLog[] +} + +export interface RpcLog { + address: string + topics: string[] + data: string + logIndex: string + blockNumber: string + transactionHash: string +} + +/** Raw JSON-RPC transaction */ +export interface RpcTransaction { + from: string + to: string | null + hash: string + input: string + value: string + blockNumber: string +} + +/** Raw JSON-RPC block */ +export interface RpcBlock { + number: string + timestamp: string +} diff --git a/packages/sdk-evm/src/utils.ts b/packages/sdk-evm/src/utils.ts new file mode 100644 index 00000000..82a453be --- /dev/null +++ b/packages/sdk-evm/src/utils.ts @@ -0,0 +1,47 @@ +import type { JsonRpcClient } from './rpc.js' +import type { RpcTransactionReceipt } from './types.js' + +export const ZERO_ADDRESS = '0x0000000000000000000000000000000000000000' + +/** Convert a human-readable amount to its smallest unit (e.g. '1.5' with 18 decimals → 1500000000000000000n) */ +export function parseUnits(value: string, decimals: number): bigint { + const [integer = '0', fraction = ''] = value.split('.') + const paddedFraction = fraction.padEnd(decimals, '0').slice(0, decimals) + return BigInt(integer + paddedFraction) +} + +/** Convert from smallest unit to human-readable (e.g. 1500000000000000000n with 18 decimals → '1.5') */ +export function formatUnits(value: bigint, decimals: number): string { + const str = value.toString().padStart(decimals + 1, '0') + const intPart = str.slice(0, str.length - decimals) || '0' + const fracPart = str.slice(str.length - decimals).replace(/0+$/, '') + return fracPart ? `${intPart}.${fracPart}` : intPart +} + +/** Convert a bigint to a 0x-prefixed 32-byte hex string */ +export function toHex32(value: bigint): string { + return ('0x' + value.toString(16).padStart(64, '0')) as string +} + +/** Poll for a transaction receipt until confirmed or timeout */ +export async function waitForReceipt( + rpc: JsonRpcClient, + txHash: string, + options?: { timeout?: number; interval?: number } +): Promise { + const timeout = options?.timeout ?? 120_000 + const interval = options?.interval ?? 2_000 + const start = Date.now() + + while (Date.now() - start < timeout) { + const receipt = await rpc.getTransactionReceipt(txHash) + if (receipt) { + if (receipt.status === '0x0') { + throw new Error(`Transaction reverted: ${txHash}`) + } + return receipt + } + await new Promise(r => setTimeout(r, interval)) + } + throw new Error(`Transaction receipt timeout after ${timeout}ms: ${txHash}`) +} diff --git a/packages/sdk-evm/tsconfig.json b/packages/sdk-evm/tsconfig.json new file mode 100644 index 00000000..6d0a64c4 --- /dev/null +++ b/packages/sdk-evm/tsconfig.json @@ -0,0 +1,17 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "lib": ["dom", "dom.iterable", "esnext"], + "module": "ESNext", + "moduleResolution": "Bundler", + "declaration": true, + "noEmit": false, + "resolveJsonModule": true, + "types": ["node"], + "paths": { + "@/*": ["./src/*"] + } + }, + "include": ["src"], + "exclude": ["node_modules", "dist"] +} diff --git a/packages/sdk/package.json b/packages/sdk/package.json index 008e03e4..dd7a5c12 100644 --- a/packages/sdk/package.json +++ b/packages/sdk/package.json @@ -24,15 +24,13 @@ "check:types": "tsc --noEmit" }, "peerDependencies": { - "viem": "catalog:", "@noble/hashes": "catalog:" }, "devDependencies": { "@noble/hashes": "catalog:", "@types/node": "^20", "rimraf": "^6.0.1", - "typescript": "catalog:", - "viem": "catalog:" + "typescript": "catalog:" }, "engines": { "node": ">=18" diff --git a/packages/sdk/src/htlc-clients/evm/abis/ERC20.json b/packages/sdk/src/htlc-clients/evm/abis/ERC20.json deleted file mode 100644 index 82033929..00000000 --- a/packages/sdk/src/htlc-clients/evm/abis/ERC20.json +++ /dev/null @@ -1,258 +0,0 @@ -[ - { - "members": [ - { - "name": "low", - "offset": 0, - "type": "felt" - }, - { - "name": "high", - "offset": 1, - "type": "felt" - } - ], - "name": "Uint256", - "size": 2, - "type": "struct" - }, - { - "inputs": [ - { - "name": "name", - "type": "felt" - }, - { - "name": "symbol", - "type": "felt" - }, - { - "name": "recipient", - "type": "felt" - } - ], - "name": "constructor", - "outputs": [], - "type": "constructor" - }, - { - "inputs": [], - "name": "name", - "outputs": [ - { - "name": "name", - "type": "felt" - } - ], - "stateMutability": "view", - "type": "function" - }, - { - "inputs": [], - "name": "symbol", - "outputs": [ - { - "name": "symbol", - "type": "felt" - } - ], - "stateMutability": "view", - "type": "function" - }, - { - "inputs": [], - "name": "totalSupply", - "outputs": [ - { - "name": "totalSupply", - "type": "Uint256" - } - ], - "stateMutability": "view", - "type": "function" - }, - { - "inputs": [], - "name": "decimals", - "outputs": [ - { - "name": "decimals", - "type": "felt" - } - ], - "stateMutability": "view", - "type": "function" - }, - { - "inputs": [ - { - "name": "account", - "type": "felt" - } - ], - "name": "balanceOf", - "outputs": [ - { - "name": "balance", - "type": "Uint256" - } - ], - "stateMutability": "view", - "type": "function" - }, - { - "inputs": [ - { - "name": "owner", - "type": "felt" - }, - { - "name": "spender", - "type": "felt" - } - ], - "name": "allowance", - "outputs": [ - { - "name": "remaining", - "type": "Uint256" - } - ], - "stateMutability": "view", - "type": "function" - }, - { - "inputs": [ - { - "name": "recipient", - "type": "felt" - }, - { - "name": "amount", - "type": "Uint256" - } - ], - "name": "transfer", - "outputs": [ - { - "name": "success", - "type": "felt" - } - ], - "type": "function" - }, - { - "inputs": [ - { - "name": "sender", - "type": "felt" - }, - { - "name": "recipient", - "type": "felt" - }, - { - "name": "amount", - "type": "Uint256" - } - ], - "name": "transferFrom", - "outputs": [ - { - "name": "success", - "type": "felt" - } - ], - "type": "function" - }, - { - "inputs": [ - { - "name": "spender", - "type": "felt" - }, - { - "name": "amount", - "type": "Uint256" - } - ], - "name": "approve", - "outputs": [ - { - "name": "success", - "type": "felt" - } - ], - "type": "function" - }, - { - "inputs": [ - { - "name": "spender", - "type": "felt" - }, - { - "name": "added_value", - "type": "Uint256" - } - ], - "name": "increaseAllowance", - "outputs": [ - { - "name": "success", - "type": "felt" - } - ], - "type": "function" - }, - { - "inputs": [ - { - "name": "spender", - "type": "felt" - }, - { - "name": "subtracted_value", - "type": "Uint256" - } - ], - "name": "decreaseAllowance", - "outputs": [ - { - "name": "success", - "type": "felt" - } - ], - "type": "function" - }, - { - "inputs": [ - { - "name": "recipient", - "type": "felt" - }, - { - "name": "amount", - "type": "Uint256" - } - ], - "name": "mint", - "outputs": [], - "type": "function" - }, - { - "inputs": [ - { - "name": "user", - "type": "felt" - }, - { - "name": "amount", - "type": "Uint256" - } - ], - "name": "burn", - "outputs": [], - "type": "function" - } -] diff --git a/packages/sdk/src/htlc-clients/evm/client.ts b/packages/sdk/src/htlc-clients/evm/client.ts deleted file mode 100644 index 834cebe7..00000000 --- a/packages/sdk/src/htlc-clients/evm/client.ts +++ /dev/null @@ -1,336 +0,0 @@ -import { - PublicClient, - WalletClient, - createPublicClient, - http, - zeroAddress, - toHex, - parseEventLogs, - parseUnits, - formatUnits -} from 'viem'; -import { CreateHTLCParams, LockParams, RefundParams, ClaimParams } from '../../types/params'; -import { LockDetails, LockStatus } from '../../types/lock'; -import { AtomicResult, RecoveredSwapData } from '../../types/atomic'; -import HTLCAbi from './abis/EVM_HTLC.json'; -import ERC20Abi from './abis/ERC20.json'; -import { IHTLCClient } from '../../types/htlc-client'; - -export interface EvmHTLCClientConfig { - rpcUrl: string - walletClient?: WalletClient -} - -export class EvmHTLCClient implements IHTLCClient { - private publicClient: PublicClient; - private walletClient: WalletClient | undefined; - - constructor(config: EvmHTLCClientConfig) { - this.publicClient = createPublicClient({ transport: http(config.rpcUrl) }) as PublicClient; - this.walletClient = config.walletClient; - } - - /** - * Create an HTLC lock on source chain. - * Caller must derive hashlock + nonce externally via the crypto module. - */ - async createHTLC( - params: CreateHTLCParams & { hashlock: string; nonce: number } - ): Promise { - const { - destinationChain, - sourceChain, - destinationAsset, - sourceAsset, - srcLpAddress: lpAddress, - address, - amount, - decimals, - atomicContract, - chainId, - quoteExpiry, - rewardToken, - rewardRecipient, - rewardAmount, - rewardTimelockDelta, - solverData, - destinationAmount, - timelockDelta, - hashlock, - nonce: timestamp, - } = params; - - if (!this.walletClient) throw new Error('WalletClient required for createHTLC'); - const parsedAmount = parseUnits(amount.toString(), decimals); - - const tokenAddress = sourceAsset.contractAddress - ? (sourceAsset.contractAddress as `0x${string}`) - : zeroAddress; - - const isNativeToken = !sourceAsset.contractAddress || sourceAsset.contractAddress === zeroAddress; - - // Handle ERC20 approval - if (!isNativeToken && sourceAsset.contractAddress) { - const allowance = await this.publicClient.readContract({ - abi: ERC20Abi, - address: sourceAsset.contractAddress as `0x${string}`, - functionName: 'allowance', - args: [address as `0x${string}`, atomicContract as `0x${string}`], - }) as bigint; - - if (allowance < parsedAmount) { - const approveHash = await this.walletClient.writeContract({ - abi: ERC20Abi, - address: sourceAsset.contractAddress as `0x${string}`, - functionName: 'approve', - args: [atomicContract as `0x${string}`, parsedAmount], - account: address as `0x${string}`, - chain: this.publicClient.chain, - }); - await this.publicClient.waitForTransactionReceipt({ hash: approveHash }); - } - } - - const userLockParams = { - hashlock, - srcChain: sourceChain || '', - amount: parsedAmount, - timelockDelta, - quoteExpiry, - sender: address as `0x${string}`, - recipient: lpAddress as `0x${string}`, - token: tokenAddress, - rewardAmount: rewardAmount || 0n, - rewardToken: rewardToken ? rewardToken as `0x${string}` : zeroAddress, - rewardRecipient: rewardRecipient ? rewardRecipient as `0x${string}` : zeroAddress, - rewardTimelockDelta: rewardTimelockDelta ?? 0, - }; - - const destinationInfo = { - dstChain: destinationChain, - dstAddress: address, - dstAmount: destinationAmount, - dstToken: destinationAsset, - }; - - const userData = toHex(BigInt(timestamp), { size: 32 }); - - const simulationData: any = { - account: address as `0x${string}`, - abi: HTLCAbi, - address: atomicContract as `0x${string}`, - functionName: 'userLock', - args: [userLockParams, destinationInfo, userData, solverData], - chain: this.publicClient.chain, - }; - - if (isNativeToken) { - simulationData.value = parsedAmount; - } - - const { request } = await this.publicClient.simulateContract(simulationData); - const hash = await this.walletClient.writeContract(request as any); - - return { hash, hashlock, nonce: timestamp }; - } - - async getUserLockDetails(params: LockParams): Promise { - const { id, contractAddress, txId } = params; - - const result: any = await this.publicClient.readContract({ - abi: HTLCAbi, - address: contractAddress as `0x${string}`, - functionName: 'getUserLock', - args: [id], - }); - - const lockExists = result.sender !== zeroAddress; - - let userData: string | undefined; - let blockTimestamp: number | undefined; - - if (lockExists && txId) { - try { - const receipt = await this.publicClient.getTransactionReceipt({ - hash: txId as `0x${string}`, - }); - const logs = parseEventLogs({ - abi: HTLCAbi, - logs: receipt.logs, - eventName: 'UserLocked', - }) as any[]; - const lockEvent = logs.find(log => log.args.hashlock === id); - if (lockEvent?.args?.userData && lockEvent.args.userData !== '0x') { - userData = BigInt(lockEvent.args.userData).toString(); - } - const block = await this.publicClient.getBlock({ blockNumber: receipt.blockNumber }); - blockTimestamp = Number(block.timestamp) * 1000; - } catch (e) { - console.error('Error fetching userData from tx receipt:', e); - } - } - - return { - hashlock: lockExists ? id : undefined, - amount: Number(formatUnits(BigInt(result.amount), 18)), - secret: result.secret != 0n ? BigInt(result.secret) : undefined, - sender: lockExists ? result.sender : undefined, - recipient: result.recipient !== zeroAddress ? result.recipient : undefined, - token: result.token !== zeroAddress ? result.token : undefined, - timelock: Number(result.timelock), - status: lockExists ? Number(result.status) as LockStatus : undefined, - claimed: Number(result.status), - userData, - blockTimestamp, - }; - } - - async getSolverLockDetails(params: LockParams): Promise { - const { id, contractAddress } = params; - - const count: any = await this.publicClient.readContract({ - abi: HTLCAbi, - address: contractAddress as `0x${string}`, - functionName: 'getSolverLockCount', - args: [id], - }); - - if (Number(count) === 0) return null; - - const result: any = await this.publicClient.readContract({ - abi: HTLCAbi, - address: contractAddress as `0x${string}`, - functionName: 'getSolverLock', - args: [id, 1], - }); - - const lockExists = result.sender !== zeroAddress; - if (!lockExists) return null; - - return { - hashlock: id, - amount: Number(formatUnits(BigInt(result.amount), params.decimals ?? 18)), - secret: result.secret != 0n ? BigInt(result.secret) : undefined, - sender: result.sender, - recipient: result.recipient !== zeroAddress ? result.recipient : undefined, - token: result.token !== zeroAddress ? result.token : undefined, - timelock: Number(result.timelock), - reward: Number(formatUnits(BigInt(result.reward), params.decimals ?? 18)), - rewardTimelock: Number(result.rewardTimelock), - rewardRecipient: result.rewardRecipient !== zeroAddress ? result.rewardRecipient : undefined, - rewardToken: result.rewardToken !== zeroAddress ? result.rewardToken : undefined, - status: Number(result.status) as LockStatus, - claimed: Number(result.status), - index: 0, - }; - } - - /** - * Multi-node consensus verification — queries multiple RPC nodes and verifies they agree. - */ - async secureGetDetails( - params: LockParams, - nodeUrls: string[], - ): Promise { - const { id, contractAddress } = params; - - const clients = nodeUrls.map(url => - createPublicClient({ transport: http(url) }) - ); - - const results = await Promise.all(clients.map(client => - client.readContract({ - abi: HTLCAbi, - address: contractAddress as `0x${string}`, - functionName: 'getUserLock', - args: [id], - }) - )); - - const validResults = (results as any[]).filter(r => r.amount > 0n); - if (!validResults.length) return null; - - const [firstResult, ...otherResults] = validResults; - if (!otherResults.every(r => r.amount === firstResult.amount)) { - throw new Error('Lock details do not match across the provided nodes'); - } - - return { - hashlock: id, - amount: Number(formatUnits(BigInt(firstResult.amount), params.decimals ?? 18)), - secret: firstResult.secret != 0n ? BigInt(firstResult.secret) : undefined, - sender: firstResult.sender !== zeroAddress ? firstResult.sender : undefined, - recipient: firstResult.recipient !== zeroAddress ? firstResult.recipient : undefined, - token: firstResult.token !== zeroAddress ? firstResult.token : undefined, - timelock: Number(firstResult.timelock), - status: Number(firstResult.status) as LockStatus, - claimed: Number(firstResult.status), - userData: firstResult.userData !== zeroAddress ? Number(firstResult.userData).toString() : undefined, - }; - } - - async refund(params: RefundParams): Promise { - if (!this.walletClient) throw new Error('WalletClient required for refund'); - const { id, contractAddress } = params; - - const { request } = await this.publicClient.simulateContract({ - account: this.walletClient.account?.address as `0x${string}`, - abi: HTLCAbi, - address: contractAddress as `0x${string}`, - functionName: 'refundUser', - args: [id], - chain: this.publicClient.chain, - }); - - return await this.walletClient.writeContract(request as any); - } - - async claim(params: ClaimParams): Promise { - if (!this.walletClient) throw new Error('WalletClient required for claim'); - const { id, contractAddress, secret, destinationAddress } = params; - - const account = (destinationAddress ?? this.walletClient.account?.address) as `0x${string}`; - - const { request } = await this.publicClient.simulateContract({ - account, - abi: HTLCAbi, - address: contractAddress as `0x${string}`, - functionName: 'redeemSolver', - args: [id, 1, BigInt(secret)], - chain: this.publicClient.chain, - }); - - return await this.walletClient.writeContract(request as any); - } - - async recoverSwap(txHash: `0x${string}`): Promise { - const [receipt, tx] = await Promise.all([ - this.publicClient.getTransactionReceipt({ hash: txHash }), - this.publicClient.getTransaction({ hash: txHash }), - ]); - - const logs = parseEventLogs({ - abi: HTLCAbi, - logs: receipt.logs, - eventName: 'UserLocked', - }) as any[]; - - if (!logs.length) throw new Error('This transaction does not contain a swap lock'); - - const args = logs[0].args; - - return { - hashlock: args.hashlock, - sender: args.sender, - recipient: args.recipient, - srcChain: args.srcChain, - dstChain: args.dstChain, - token: args.token, - amount: args.amount, - dstAddress: args.dstAddress, - dstAmount: args.dstAmount, - dstToken: args.dstToken, - srcContract: tx.to as string, - }; - } -} diff --git a/packages/sdk/src/htlc-clients/evm/index.ts b/packages/sdk/src/htlc-clients/evm/index.ts deleted file mode 100644 index 83dae763..00000000 --- a/packages/sdk/src/htlc-clients/evm/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './client' diff --git a/packages/sdk/src/htlc-clients/index.ts b/packages/sdk/src/htlc-clients/index.ts deleted file mode 100644 index 62ca59a0..00000000 --- a/packages/sdk/src/htlc-clients/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './evm' \ No newline at end of file diff --git a/packages/sdk/src/index.ts b/packages/sdk/src/index.ts index 251a043e..1840a333 100644 --- a/packages/sdk/src/index.ts +++ b/packages/sdk/src/index.ts @@ -1,5 +1,5 @@ export * from './types' export * from './login' -export * from './htlc-clients' export * from './api' export * from './verification' +export * from './registry' \ No newline at end of file diff --git a/packages/sdk/src/login/index.ts b/packages/sdk/src/login/index.ts index 3fc93970..73a72e41 100644 --- a/packages/sdk/src/login/index.ts +++ b/packages/sdk/src/login/index.ts @@ -1,4 +1,3 @@ export * from './types' export * from './key-derivation' export * from './passkey-service' -export * from './wallet-sign/evm' diff --git a/packages/sdk/src/registry.ts b/packages/sdk/src/registry.ts new file mode 100644 index 00000000..bb9087be --- /dev/null +++ b/packages/sdk/src/registry.ts @@ -0,0 +1,49 @@ +import type { IHTLCClient } from './types/htlc-client' + +export type HTLCClientFactory = (config: Record) => IHTLCClient + +const registry = new Map() + +export function registerHTLCClient(chainNamespace: string, factory: HTLCClientFactory): void { + registry.set(chainNamespace, factory) +} + +export function createHTLCClient(chainNamespace: string, config: Record): IHTLCClient { + const factory = registry.get(chainNamespace) + if (!factory) { + throw new Error( + `No HTLC client registered for chain namespace: ${chainNamespace}. ` + + `Did you forget to import the corresponding @train-protocol/sdk-* package?` + ) + } + return factory(config) +} + +export function getRegisteredNamespaces(): string[] { + return Array.from(registry.keys()) +} + +// --- Wallet Sign Registry --- + +export type WalletSignFactory = (config: Record) => Promise + +const walletSignRegistry = new Map() + +export function registerWalletSign(providerName: string, factory: WalletSignFactory): void { + walletSignRegistry.set(providerName, factory) +} + +export function deriveKeyFromWallet(providerName: string, config: Record): Promise { + const factory = walletSignRegistry.get(providerName) + if (!factory) { + throw new Error( + `No wallet sign registered for provider: ${providerName}. ` + + `Did you forget to import the corresponding @train-protocol/sdk-* package?` + ) + } + return factory(config) +} + +export function getRegisteredWalletSignProviders(): string[] { + return Array.from(walletSignRegistry.keys()) +} diff --git a/packages/sdk/src/types/htlc-client.ts b/packages/sdk/src/types/htlc-client.ts index 601823c0..f0bc28c4 100644 --- a/packages/sdk/src/types/htlc-client.ts +++ b/packages/sdk/src/types/htlc-client.ts @@ -6,9 +6,9 @@ export interface IHTLCClient { getUserLockDetails(params: LockParams): Promise getSolverLockDetails(params: LockParams): Promise secureGetDetails(params: LockParams, nodeUrls: string[]): Promise - recoverSwap(txHash: `0x${string}`): Promise + recoverSwap(txHash: string): Promise - createHTLC(params: CreateHTLCParams & { hashlock: string; nonce: number }): Promise + createHTLC(params: CreateHTLCParams): Promise refund(params: RefundParams): Promise claim(params: ClaimParams): Promise } \ No newline at end of file diff --git a/packages/sdk/src/types/params.ts b/packages/sdk/src/types/params.ts index 8b96e5a2..f68345c6 100644 --- a/packages/sdk/src/types/params.ts +++ b/packages/sdk/src/types/params.ts @@ -22,6 +22,8 @@ export type CreateHTLCParams = { rewardAmount?: string; rewardTimelockDelta?: number; timelockDelta?: number; + hashlock: string; + nonce: number } export type LockParams = { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2a3c24c6..07a2bec2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -165,6 +165,9 @@ importers: '@train-protocol/sdk': specifier: workspace:^ version: link:../../packages/sdk + '@train-protocol/sdk-evm': + specifier: workspace:^ + version: link:../../packages/sdk-evm '@uidotdev/usehooks': specifier: ^2.4.1 version: 2.4.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1) @@ -340,9 +343,25 @@ importers: typescript: specifier: 'catalog:' version: 5.9.3 - viem: + + packages/sdk-evm: + dependencies: + ox: + specifier: ^0.13.2 + version: 0.13.2(typescript@5.9.3)(zod@3.25.76) + devDependencies: + '@train-protocol/sdk': + specifier: workspace:^ + version: link:../sdk + '@types/node': + specifier: ^20 + version: 20.19.34 + rimraf: + specifier: ^6.0.1 + version: 6.1.3 + typescript: specifier: 'catalog:' - version: 2.44.1(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@3.25.76) + version: 5.9.3 packages: @@ -8455,6 +8474,14 @@ packages: typescript: optional: true + ox@0.13.2: + resolution: {integrity: sha512-baredVMHHOSEQkte+2bopeEdm1OJwrfzYCck8ObiTTynVUMDH9LhZCPiojuaT95i9Ha0i634JZvabWrudzm/pg==} + peerDependencies: + typescript: '>=5.4.0' + peerDependenciesMeta: + typescript: + optional: true + ox@0.6.7: resolution: {integrity: sha512-17Gk/eFsFRAZ80p5eKqv89a57uXjd3NgIf1CaXojATPBuujVc/fQSVhBeAU9JCRB+k7J50WQAyWTxK19T9GgbA==} peerDependencies: @@ -22610,6 +22637,21 @@ snapshots: transitivePeerDependencies: - zod + ox@0.13.2(typescript@5.9.3)(zod@3.25.76): + dependencies: + '@adraffy/ens-normalize': 1.11.1 + '@noble/ciphers': 1.3.0 + '@noble/curves': 1.9.1 + '@noble/hashes': 1.8.0 + '@scure/bip32': 1.7.0 + '@scure/bip39': 1.6.0 + abitype: 1.2.3(typescript@5.9.3)(zod@3.25.76) + eventemitter3: 5.0.1 + optionalDependencies: + typescript: 5.9.3 + transitivePeerDependencies: + - zod + ox@0.6.7(typescript@5.9.3): dependencies: '@adraffy/ens-normalize': 1.11.1 From d1d27277a3d37de8fa98a6718b01c1ebe9e9bd6b Mon Sep 17 00:00:00 2001 From: Aren Date: Fri, 27 Feb 2026 21:45:53 +0400 Subject: [PATCH 2/3] Refactor EvmHTLCClient to improve type safety with Hex type and streamline HTLC operations. Introduce refund and claim methods, enhance createHTLC with ERC20 allowance checks, and update user lock details handling. --- packages/sdk-evm/src/client.ts | 245 ++++++++++++++++----------------- 1 file changed, 121 insertions(+), 124 deletions(-) diff --git a/packages/sdk-evm/src/client.ts b/packages/sdk-evm/src/client.ts index f5afb372..cf428487 100644 --- a/packages/sdk-evm/src/client.ts +++ b/packages/sdk-evm/src/client.ts @@ -15,6 +15,9 @@ import { JsonRpcClient } from './rpc.js' import { ZERO_ADDRESS, parseUnits, formatUnits, toHex32, waitForReceipt } from './utils.js' import type { EvmHTLCClientConfig, EvmSigner, RpcLog } from './types.js' +type Hex = `0x${string}` +const hex = (v: string): Hex => v as Hex + export class EvmHTLCClient implements IHTLCClient { private rpc: JsonRpcClient private signer: EvmSigner | undefined @@ -24,9 +27,10 @@ export class EvmHTLCClient implements IHTLCClient { this.signer = config.signer } - async createHTLC( - params: CreateHTLCParams - ): Promise { + // ── Write Operations ─────────────────────────────────────────────── + + async createHTLC(params: CreateHTLCParams): Promise { + const signer = this.requireSigner() const { destinationChain, sourceChain, @@ -49,73 +53,54 @@ export class EvmHTLCClient implements IHTLCClient { nonce: timestamp, } = params - if (!this.signer) throw new Error('Signer required for createHTLC') - const parsedAmount = parseUnits(amount.toString(), decimals) const tokenAddress = sourceAsset.contractAddress || ZERO_ADDRESS const isNativeToken = !sourceAsset.contractAddress || sourceAsset.contractAddress === ZERO_ADDRESS - // Handle ERC20 approval - if (!isNativeToken && sourceAsset.contractAddress) { - const allowanceData = AbiFunction.encodeData(erc20Functions.allowance, [ - address as `0x${string}`, - atomicContract as `0x${string}`, - ]) - const allowanceResult = await this.rpc.ethCall(sourceAsset.contractAddress, allowanceData) - const allowance = AbiFunction.decodeResult(erc20Functions.allowance, allowanceResult as `0x${string}`) - - if (allowance < parsedAmount) { - const approveData = AbiFunction.encodeData(erc20Functions.approve, [ - atomicContract as `0x${string}`, - parsedAmount, - ]) - const approveHash = await this.signer.sendTransaction({ - to: sourceAsset.contractAddress, - data: approveData, - }) - await waitForReceipt(this.rpc, approveHash) - } - } - - const userLockParams = { - hashlock: hashlock as `0x${string}`, - amount: parsedAmount, - rewardAmount: rewardAmount || 0n, - timelockDelta, - rewardTimelockDelta: rewardTimelockDelta ?? 0, - quoteExpiry, - sender: address as `0x${string}`, - recipient: lpAddress as `0x${string}`, - token: tokenAddress as `0x${string}`, - rewardToken: rewardToken ?? '', - rewardRecipient: rewardRecipient ?? '', - srcChain: sourceChain || '', - } - - const destinationInfo = { - dstChain: destinationChain, - dstAddress: address, - dstAmount: destinationAmount, - dstToken: destinationAsset, + if (!isNativeToken) { + await this.ensureERC20Allowance( + sourceAsset.contractAddress!, + address, + atomicContract, + parsedAmount, + signer, + ) } const userData = toHex32(BigInt(timestamp)) const calldata = AbiFunction.encodeData(htlcFunctions.userLock, [ - userLockParams, - destinationInfo, - userData as `0x${string}`, - (solverData || '0x') as `0x${string}`, + { + hashlock: hex(hashlock), + amount: parsedAmount, + rewardAmount: rewardAmount || 0n, + timelockDelta, + rewardTimelockDelta: rewardTimelockDelta ?? 0, + quoteExpiry, + sender: hex(address), + recipient: hex(lpAddress), + token: hex(tokenAddress), + rewardToken: rewardToken ?? '', + rewardRecipient: rewardRecipient ?? '', + srcChain: sourceChain || '', + }, + { + dstChain: destinationChain, + dstAddress: address, + dstAmount: destinationAmount, + dstToken: destinationAsset, + }, + hex(userData), + hex(solverData || '0x'), ]) - // Simulate via eth_call before sending await this.rpc.ethCall( atomicContract, calldata, address, - isNativeToken ? parsedAmount : undefined + isNativeToken ? parsedAmount : undefined, ) - const hash = await this.signer.sendTransaction({ + const hash = await signer.sendTransaction({ to: atomicContract, data: calldata, value: isNativeToken ? parsedAmount : undefined, @@ -124,15 +109,43 @@ export class EvmHTLCClient implements IHTLCClient { return { hash, hashlock, nonce: timestamp } } + async refund(params: RefundParams): Promise { + const signer = this.requireSigner() + const { id, contractAddress } = params + + const calldata = AbiFunction.encodeData(htlcFunctions.refundUser, [hex(id)]) + + await this.rpc.ethCall(contractAddress, calldata, signer.address) + + return signer.sendTransaction({ to: contractAddress, data: calldata }) + } + + async claim(params: ClaimParams): Promise { + const signer = this.requireSigner() + const { id, contractAddress, secret, destinationAddress } = params + + const caller = destinationAddress ?? signer.address + const calldata = AbiFunction.encodeData(htlcFunctions.redeemSolver, [ + hex(id), + 1n, + BigInt(secret), + ]) + + await this.rpc.ethCall(contractAddress, calldata, caller) + + return signer.sendTransaction({ to: contractAddress, data: calldata }) + } + + // ── Read Operations ──────────────────────────────────────────────── + async getUserLockDetails(params: LockParams): Promise { const { id, contractAddress, txId } = params - const calldata = AbiFunction.encodeData(htlcFunctions.getUserLock, [id as `0x${string}`]) + const calldata = AbiFunction.encodeData(htlcFunctions.getUserLock, [hex(id)]) const raw = await this.rpc.ethCall(contractAddress, calldata) - const result = AbiFunction.decodeResult(htlcFunctions.getUserLock, raw as `0x${string}`) as any + const result = AbiFunction.decodeResult(htlcFunctions.getUserLock, hex(raw)) as any const lockExists = result.sender !== ZERO_ADDRESS - let userData: string | undefined let blockTimestamp: number | undefined @@ -158,7 +171,7 @@ export class EvmHTLCClient implements IHTLCClient { return { hashlock: lockExists ? id : undefined, amount: Number(formatUnits(BigInt(result.amount), 18)), - secret: result.secret != 0n ? BigInt(result.secret) : undefined, + secret: result.secret !== 0n ? BigInt(result.secret) : undefined, sender: lockExists ? result.sender : undefined, recipient: result.recipient !== ZERO_ADDRESS ? result.recipient : undefined, token: result.token !== ZERO_ADDRESS ? result.token : undefined, @@ -173,23 +186,22 @@ export class EvmHTLCClient implements IHTLCClient { async getSolverLockDetails(params: LockParams): Promise { const { id, contractAddress } = params - const countData = AbiFunction.encodeData(htlcFunctions.getSolverLockCount, [id as `0x${string}`]) + const countData = AbiFunction.encodeData(htlcFunctions.getSolverLockCount, [hex(id)]) const countRaw = await this.rpc.ethCall(contractAddress, countData) - const count = AbiFunction.decodeResult(htlcFunctions.getSolverLockCount, countRaw as `0x${string}`) + const count = AbiFunction.decodeResult(htlcFunctions.getSolverLockCount, hex(countRaw)) if (Number(count) === 0) return null - const lockData = AbiFunction.encodeData(htlcFunctions.getSolverLock, [id as `0x${string}`, 1n]) + const lockData = AbiFunction.encodeData(htlcFunctions.getSolverLock, [hex(id), 1n]) const lockRaw = await this.rpc.ethCall(contractAddress, lockData) - const result = AbiFunction.decodeResult(htlcFunctions.getSolverLock, lockRaw as `0x${string}`) as any + const result = AbiFunction.decodeResult(htlcFunctions.getSolverLock, hex(lockRaw)) as any - const lockExists = result.sender !== ZERO_ADDRESS - if (!lockExists) return null + if (result.sender === ZERO_ADDRESS) return null return { hashlock: id, amount: Number(formatUnits(BigInt(result.amount), params.decimals ?? 18)), - secret: result.secret != 0n ? BigInt(result.secret) : undefined, + secret: result.secret !== 0n ? BigInt(result.secret) : undefined, sender: result.sender, recipient: result.recipient !== ZERO_ADDRESS ? result.recipient : undefined, token: result.token !== ZERO_ADDRESS ? result.token : undefined, @@ -204,80 +216,40 @@ export class EvmHTLCClient implements IHTLCClient { } } - async secureGetDetails( - params: LockParams, - nodeUrls: string[], - ): Promise { + async secureGetDetails(params: LockParams, nodeUrls: string[]): Promise { const { id, contractAddress } = params - const calldata = AbiFunction.encodeData(htlcFunctions.getUserLock, [id as `0x${string}`]) - + const calldata = AbiFunction.encodeData(htlcFunctions.getUserLock, [hex(id)]) const results = await Promise.all( nodeUrls.map(async (url) => { const rpc = new JsonRpcClient(url) const raw = await rpc.ethCall(contractAddress, calldata) - return AbiFunction.decodeResult(htlcFunctions.getUserLock, raw as `0x${string}`) as any - }) + return AbiFunction.decodeResult(htlcFunctions.getUserLock, hex(raw)) as any + }), ) const validResults = results.filter(r => BigInt(r.amount) > 0n) if (!validResults.length) return null - const [firstResult, ...otherResults] = validResults - if (!otherResults.every(r => BigInt(r.amount) === BigInt(firstResult.amount))) { + const [first, ...rest] = validResults + if (!rest.every(r => BigInt(r.amount) === BigInt(first.amount))) { throw new Error('Lock details do not match across the provided nodes') } return { hashlock: id, - amount: Number(formatUnits(BigInt(firstResult.amount), params.decimals ?? 18)), - secret: firstResult.secret != 0n ? BigInt(firstResult.secret) : undefined, - sender: firstResult.sender !== ZERO_ADDRESS ? firstResult.sender : undefined, - recipient: firstResult.recipient !== ZERO_ADDRESS ? firstResult.recipient : undefined, - token: firstResult.token !== ZERO_ADDRESS ? firstResult.token : undefined, - timelock: Number(firstResult.timelock), - status: Number(firstResult.status) as LockStatus, - claimed: Number(firstResult.status), - userData: firstResult.userData !== ZERO_ADDRESS ? Number(firstResult.userData).toString() : undefined, + amount: Number(formatUnits(BigInt(first.amount), params.decimals ?? 18)), + secret: first.secret !== 0n ? BigInt(first.secret) : undefined, + sender: first.sender !== ZERO_ADDRESS ? first.sender : undefined, + recipient: first.recipient !== ZERO_ADDRESS ? first.recipient : undefined, + token: first.token !== ZERO_ADDRESS ? first.token : undefined, + timelock: Number(first.timelock), + status: Number(first.status) as LockStatus, + claimed: Number(first.status), + userData: first.userData !== ZERO_ADDRESS ? Number(first.userData).toString() : undefined, } } - async refund(params: RefundParams): Promise { - if (!this.signer) throw new Error('Signer required for refund') - const { id, contractAddress } = params - - const calldata = AbiFunction.encodeData(htlcFunctions.refundUser, [id as `0x${string}`]) - - // Simulate via eth_call - await this.rpc.ethCall(contractAddress, calldata, this.signer.address) - - return await this.signer.sendTransaction({ - to: contractAddress, - data: calldata, - }) - } - - async claim(params: ClaimParams): Promise { - if (!this.signer) throw new Error('Signer required for claim') - const { id, contractAddress, secret, destinationAddress } = params - - const account = destinationAddress ?? this.signer.address - - const calldata = AbiFunction.encodeData(htlcFunctions.redeemSolver, [ - id as `0x${string}`, - 1n, - BigInt(secret), - ]) - - // Simulate via eth_call - await this.rpc.ethCall(contractAddress, calldata, account) - - return await this.signer.sendTransaction({ - to: contractAddress, - data: calldata, - }) - } - async recoverSwap(txHash: string): Promise { const [receipt, tx] = await Promise.all([ this.rpc.getTransactionReceipt(txHash), @@ -304,19 +276,44 @@ export class EvmHTLCClient implements IHTLCClient { } } - /** Parse UserLocked event from raw RPC logs */ + // ── Private Helpers ──────────────────────────────────────────────── + + private requireSigner(): EvmSigner { + if (!this.signer) throw new Error('Signer required') + return this.signer + } + + private async ensureERC20Allowance( + tokenAddress: string, + owner: string, + spender: string, + requiredAmount: bigint, + signer: EvmSigner, + ): Promise { + const allowanceData = AbiFunction.encodeData(erc20Functions.allowance, [hex(owner), hex(spender)]) + const allowanceRaw = await this.rpc.ethCall(tokenAddress, allowanceData) + const allowance = AbiFunction.decodeResult(erc20Functions.allowance, hex(allowanceRaw)) + + if (allowance >= requiredAmount) return + + const approveData = AbiFunction.encodeData(erc20Functions.approve, [hex(spender), requiredAmount]) + const approveHash = await signer.sendTransaction({ to: tokenAddress, data: approveData }) + await waitForReceipt(this.rpc, approveHash) + } + private findUserLockedEvent(logs: RpcLog[], matchHashlock?: string): Record | null { for (const log of logs) { try { const decoded = AbiEvent.decode(htlcEvents.UserLocked, { - data: log.data as `0x${string}`, - topics: log.topics as [`0x${string}`, ...`0x${string}`[]], + data: hex(log.data), + topics: log.topics as [Hex, ...Hex[]], }) as unknown as Record + if (!matchHashlock || decoded.hashlock === matchHashlock) { return decoded } } catch { - // Not a UserLocked event, skip + // Not a UserLocked event — skip } } return null From 11c99cc75be426b24fe22ddf655e114a6b7a3d70 Mon Sep 17 00:00:00 2001 From: Aren Date: Fri, 27 Feb 2026 22:32:03 +0400 Subject: [PATCH 3/3] Update package.json to specify sideEffects for ESM build, ensuring proper tree-shaking and optimization. --- packages/sdk-evm/package.json | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/sdk-evm/package.json b/packages/sdk-evm/package.json index 66fd1a98..4a82cbad 100644 --- a/packages/sdk-evm/package.json +++ b/packages/sdk-evm/package.json @@ -11,7 +11,9 @@ "default": "./dist/esm/index.js" } }, - "sideEffects": false, + "sideEffects": [ + "./dist/esm/index.js" + ], "files": [ "dist", "dist/**/*"