diff --git a/apps/agentic-chat/src/components/tools/SendUI.tsx b/apps/agentic-chat/src/components/tools/SendUI.tsx index 78c98e45..0b0c52da 100644 --- a/apps/agentic-chat/src/components/tools/SendUI.tsx +++ b/apps/agentic-chat/src/components/tools/SendUI.tsx @@ -9,6 +9,7 @@ import { toolStateToStepStatus } from '@/lib/executionState' import { analytics } from '@/lib/mixpanel' import { switchNetworkStep } from '@/lib/steps/switchNetworkStep' import { firstFourLastFour } from '@/lib/utils' +import { withWalletLock } from '@/lib/walletMutex' import type { SolanaWalletSigner } from '@/utils/chains/types' import { executeSend } from '@/utils/sendExecutor' @@ -30,55 +31,57 @@ export function SendUI({ toolPart }: ToolUIComponentProps<'sendTool'>) { const ctx = useToolExecution(toolCallId, 'sendTool', {}) useExecuteOnce(ctx, sendData, async (data: SendOutput, ctx) => { - try { - const { tx } = data - - if (!tx?.from) throw new Error('Invalid send output: missing tx.from') - if (!tx?.chainId) throw new Error('Invalid send output: missing tx.chainId') - if (!data.sendData?.chainId) throw new Error('Invalid send output: missing sendData.chainId') - - const assetChainId = data.sendData.chainId - const { chainNamespace } = fromChainId(assetChainId) - - const currentAddress = - chainNamespace === CHAIN_NAMESPACE.Evm ? ctx.refs.evmAddress.current : ctx.refs.solanaAddress.current - if (!currentAddress) throw new Error('Wallet disconnected. Please reconnect and try again.') - if (currentAddress.toLowerCase() !== tx.from.toLowerCase()) { - throw new Error('Wallet address changed. Please re-initiate the transaction.') + await withWalletLock(async () => { + try { + const { tx } = data + + if (!tx?.from) throw new Error('Invalid send output: missing tx.from') + if (!tx?.chainId) throw new Error('Invalid send output: missing tx.chainId') + if (!data.sendData?.chainId) throw new Error('Invalid send output: missing sendData.chainId') + + const assetChainId = data.sendData.chainId + const { chainNamespace } = fromChainId(assetChainId) + + const currentAddress = + chainNamespace === CHAIN_NAMESPACE.Evm ? ctx.refs.evmAddress.current : ctx.refs.solanaAddress.current + if (!currentAddress) throw new Error('Wallet disconnected. Please reconnect and try again.') + if (currentAddress.toLowerCase() !== tx.from.toLowerCase()) { + throw new Error('Wallet address changed. Please re-initiate the transaction.') + } + + let solanaSigner: SolanaWalletSigner | undefined + if (chainNamespace === CHAIN_NAMESPACE.Solana && ctx.refs.solanaWallet.current) { + solanaSigner = await ctx.refs.solanaWallet.current.getSigner() + } + + ctx.setState(draft => { + draft.toolOutput = data + draft.meta.networkName = data.sendData.asset.network + }) + ctx.advanceStep() + + await switchNetworkStep(ctx, assetChainId) + + ctx.setSubstatus('Requesting signature...') + const sendTxHash = await executeSend(tx, { solanaSigner }) + ctx.setMeta({ txHash: sendTxHash }) + ctx.advanceStep() + ctx.markTerminal() + ctx.persist() + + analytics.trackSend({ + asset: data.sendData.asset.symbol, + amount: data.sendData.amount, + network: data.sendData.asset.network, + }) + + toast.success(`Send of ${data.sendData.amount} ${data.sendData.asset.symbol.toUpperCase()} is complete`) + } catch (error) { + ctx.failAndPersist(error) + + toast.error(`Send of ${data.sendData.amount} ${data.sendData.asset.symbol.toUpperCase()} failed`) } - - let solanaSigner: SolanaWalletSigner | undefined - if (chainNamespace === CHAIN_NAMESPACE.Solana && ctx.refs.solanaWallet.current) { - solanaSigner = await ctx.refs.solanaWallet.current.getSigner() - } - - ctx.setState(draft => { - draft.toolOutput = data - draft.meta.networkName = data.sendData.asset.network - }) - ctx.advanceStep() - - await switchNetworkStep(ctx, assetChainId) - - ctx.setSubstatus('Requesting signature...') - const sendTxHash = await executeSend(tx, { solanaSigner }) - ctx.setMeta({ txHash: sendTxHash }) - ctx.advanceStep() - ctx.markTerminal() - ctx.persist() - - analytics.trackSend({ - asset: data.sendData.asset.symbol, - amount: data.sendData.amount, - network: data.sendData.asset.network, - }) - - toast.success(`Send of ${data.sendData.amount} ${data.sendData.asset.symbol.toUpperCase()} is complete`) - } catch (error) { - ctx.failAndPersist(error) - - toast.error(`Send of ${data.sendData.amount} ${data.sendData.asset.symbol.toUpperCase()} failed`) - } + }) }) const prepareStepStatus = toolStateToStepStatus(toolState) diff --git a/apps/agentic-chat/src/components/tools/SwitchNetworkUI.tsx b/apps/agentic-chat/src/components/tools/SwitchNetworkUI.tsx index 77b0790b..8c3bb214 100644 --- a/apps/agentic-chat/src/components/tools/SwitchNetworkUI.tsx +++ b/apps/agentic-chat/src/components/tools/SwitchNetworkUI.tsx @@ -6,6 +6,7 @@ import { ArrowRightLeft } from 'lucide-react' import { useExecuteOnce } from '@/hooks/useExecuteOnce' import { useToolExecution } from '@/hooks/useToolExecution' import { networkNameToChainId } from '@/lib/chains' +import { withWalletLock } from '@/lib/walletMutex' import { useChatStore } from '@/stores/chatStore' import { CollapsableDetails } from '../ui/CollapsableDetails' @@ -34,67 +35,69 @@ export function SwitchNetworkUI({ toolPart }: ToolUIComponentProps<'switchNetwor }) useExecuteOnce(ctx, networkData, async (data: SwitchNetworkOutput, ctx) => { - const { refs } = ctx - - const targetChainId = networkNameToChainId[data.network] + await withWalletLock(async () => { + const { refs } = ctx + + const targetChainId = networkNameToChainId[data.network] + + if (!targetChainId) { + ctx.setState(draft => { + draft.error = `Network "${data.network}" not found` + draft.meta.phase = 'error' + draft.meta.network = data.network + }) + ctx.markTerminal() + ctx.persist() + return + } - if (!targetChainId) { - ctx.setState(draft => { - draft.error = `Network "${data.network}" not found` - draft.meta.phase = 'error' - draft.meta.network = data.network - }) - ctx.markTerminal() - ctx.persist() - return - } - - if (data.network === 'solana') { - if (refs.solanaWallet.current && refs.primaryWallet.current && !isSolanaWallet(refs.primaryWallet.current)) { - await refs.changePrimaryWallet.current(refs.solanaWallet.current.id) + if (data.network === 'solana') { + if (refs.solanaWallet.current && refs.primaryWallet.current && !isSolanaWallet(refs.primaryWallet.current)) { + await refs.changePrimaryWallet.current(refs.solanaWallet.current.id) + } + + ctx.setState(draft => { + draft.meta.phase = 'success' + draft.meta.network = data.network + }) + ctx.advanceStep() + ctx.markTerminal() + ctx.persist() + return } ctx.setState(draft => { - draft.meta.phase = 'success' + draft.meta.phase = 'switching' draft.meta.network = data.network + draft.error = undefined }) - ctx.advanceStep() - ctx.markTerminal() - ctx.persist() - return - } - - ctx.setState(draft => { - draft.meta.phase = 'switching' - draft.meta.network = data.network - draft.error = undefined - }) - - try { - if (refs.evmWallet.current && refs.primaryWallet.current && !isEthereumWallet(refs.primaryWallet.current)) { - await refs.changePrimaryWallet.current(refs.evmWallet.current.id) - } - if (!refs.evmWallet.current) { - throw new Error('EVM wallet not connected') + try { + if (refs.evmWallet.current && refs.primaryWallet.current && !isEthereumWallet(refs.primaryWallet.current)) { + await refs.changePrimaryWallet.current(refs.evmWallet.current.id) + } + + if (!refs.evmWallet.current) { + throw new Error('EVM wallet not connected') + } + + await refs.evmWallet.current.connector.switchNetwork({ networkChainId: targetChainId }) + ctx.setState(draft => { + draft.meta.phase = 'success' + }) + ctx.advanceStep() + ctx.markTerminal() + ctx.persist() + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error) + ctx.setState(draft => { + draft.meta.phase = 'error' + draft.error = errorMessage + }) + ctx.markTerminal() + ctx.persist() } - - await refs.evmWallet.current.connector.switchNetwork({ networkChainId: targetChainId }) - ctx.setState(draft => { - draft.meta.phase = 'success' - }) - ctx.advanceStep() - ctx.markTerminal() - ctx.persist() - } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error) - ctx.setState(draft => { - draft.meta.phase = 'error' - draft.error = errorMessage - }) - ctx.markTerminal() - ctx.persist() - } + }) }) const phase = ctx.state.meta.phase diff --git a/apps/agentic-chat/src/components/tools/VaultDepositUI.tsx b/apps/agentic-chat/src/components/tools/VaultDepositUI.tsx index 1c8775c5..10e95025 100644 --- a/apps/agentic-chat/src/components/tools/VaultDepositUI.tsx +++ b/apps/agentic-chat/src/components/tools/VaultDepositUI.tsx @@ -8,6 +8,7 @@ import { useToolExecution } from '@/hooks/useToolExecution' import { toolStateToStepStatus } from '@/lib/executionState' import { switchNetworkStepByChainIdNumber } from '@/lib/steps/switchNetworkStep' import { firstFourLastFour } from '@/lib/utils' +import { withWalletLock } from '@/lib/walletMutex' import { sendTransaction } from '@/utils/sendTransaction' import { Amount } from '../ui/Amount' @@ -27,43 +28,45 @@ export function VaultDepositUI({ toolPart }: ToolUIComponentProps<'vaultDepositT const ctx = useToolExecution(toolCallId, 'vaultDepositTool', {}) useExecuteOnce(ctx, depositData, async (data: VaultDepositOutput, ctx) => { - try { - const { depositTx } = data - - if (!ctx.refs.evmAddress.current) { - throw new Error('Wallet disconnected. Please reconnect and try again.') + await withWalletLock(async () => { + try { + const { depositTx } = data + + if (!ctx.refs.evmAddress.current) { + throw new Error('Wallet disconnected. Please reconnect and try again.') + } + + ctx.setState(draft => { + draft.toolOutput = data + draft.meta.networkName = data.summary.network + }) + ctx.advanceStep() + + const { chainReference } = fromChainId(depositTx.chainId) + await switchNetworkStepByChainIdNumber(ctx, Number(chainReference)) + + ctx.setSubstatus('Requesting signature...') + const depositTxHash = await sendTransaction({ + chainId: depositTx.chainId, + data: depositTx.data, + from: depositTx.from, + to: depositTx.to, + value: depositTx.value, + }) + ctx.setMeta({ txHash: depositTxHash }) + ctx.advanceStep() + ctx.markTerminal() + ctx.persist() + + toast.success( + `Vault deposit of ${data.summary.asset.amount} ${data.summary.asset.symbol.toUpperCase()} is complete` + ) + } catch (error) { + ctx.failAndPersist(error) + + toast.error(`Vault deposit failed`) } - - ctx.setState(draft => { - draft.toolOutput = data - draft.meta.networkName = data.summary.network - }) - ctx.advanceStep() - - const { chainReference } = fromChainId(depositTx.chainId) - await switchNetworkStepByChainIdNumber(ctx, Number(chainReference)) - - ctx.setSubstatus('Requesting signature...') - const depositTxHash = await sendTransaction({ - chainId: depositTx.chainId, - data: depositTx.data, - from: depositTx.from, - to: depositTx.to, - value: depositTx.value, - }) - ctx.setMeta({ txHash: depositTxHash }) - ctx.advanceStep() - ctx.markTerminal() - ctx.persist() - - toast.success( - `Vault deposit of ${data.summary.asset.amount} ${data.summary.asset.symbol.toUpperCase()} is complete` - ) - } catch (error) { - ctx.failAndPersist(error) - - toast.error(`Vault deposit failed`) - } + }) }) const prepareStepStatus = toolStateToStepStatus(toolState) diff --git a/apps/agentic-chat/src/components/tools/VaultWithdrawUI.tsx b/apps/agentic-chat/src/components/tools/VaultWithdrawUI.tsx index c725752f..83785d17 100644 --- a/apps/agentic-chat/src/components/tools/VaultWithdrawUI.tsx +++ b/apps/agentic-chat/src/components/tools/VaultWithdrawUI.tsx @@ -8,6 +8,7 @@ import { toolStateToStepStatus } from '@/lib/executionState' import { submitSafeTxStep } from '@/lib/steps/submitSafeTxStep' import { switchNetworkStepByChainIdNumber } from '@/lib/steps/switchNetworkStep' import { firstFourLastFour } from '@/lib/utils' +import { withWalletLock } from '@/lib/walletMutex' import { Amount } from '../ui/Amount' import { Skeleton } from '../ui/Skeleton' @@ -26,41 +27,43 @@ export function VaultWithdrawUI({ toolPart }: ToolUIComponentProps<'vaultWithdra const ctx = useToolExecution(toolCallId, 'vaultWithdrawTool', {}) useExecuteOnce(ctx, withdrawData, async (data: VaultWithdrawOutput, ctx) => { - try { - const { safeTransaction, summary } = data - - if (!ctx.refs.evmAddress.current) { - throw new Error('Wallet disconnected. Please reconnect and try again.') + await withWalletLock(async () => { + try { + const { safeTransaction, summary } = data + + if (!ctx.refs.evmAddress.current) { + throw new Error('Wallet disconnected. Please reconnect and try again.') + } + + ctx.setState(draft => { + draft.toolOutput = data + draft.meta.networkName = data.summary.network + }) + ctx.advanceStep() + + await switchNetworkStepByChainIdNumber(ctx, safeTransaction.chainId) + + ctx.setSubstatus('Proposing Safe transaction...') + const withdrawTxHash = await submitSafeTxStep(ctx, { + safeAddress: summary.safeAddress, + to: safeTransaction.to, + data: safeTransaction.data, + value: safeTransaction.value, + chainId: safeTransaction.chainId, + }) + ctx.setMeta({ txHash: withdrawTxHash }) + ctx.markTerminal() + ctx.persist() + + toast.success( + `Vault withdrawal of ${data.summary.asset.amount} ${data.summary.asset.symbol.toUpperCase()} is complete` + ) + } catch (error) { + ctx.failAndPersist(error) + + toast.error(`Vault withdrawal failed`) } - - ctx.setState(draft => { - draft.toolOutput = data - draft.meta.networkName = data.summary.network - }) - ctx.advanceStep() - - await switchNetworkStepByChainIdNumber(ctx, safeTransaction.chainId) - - ctx.setSubstatus('Proposing Safe transaction...') - const withdrawTxHash = await submitSafeTxStep(ctx, { - safeAddress: summary.safeAddress, - to: safeTransaction.to, - data: safeTransaction.data, - value: safeTransaction.value, - chainId: safeTransaction.chainId, - }) - ctx.setMeta({ txHash: withdrawTxHash }) - ctx.markTerminal() - ctx.persist() - - toast.success( - `Vault withdrawal of ${data.summary.asset.amount} ${data.summary.asset.symbol.toUpperCase()} is complete` - ) - } catch (error) { - ctx.failAndPersist(error) - - toast.error(`Vault withdrawal failed`) - } + }) }) const prepareStepStatus = toolStateToStepStatus(toolState) diff --git a/apps/agentic-chat/src/components/tools/useCancelConditionalOrderExecution.tsx b/apps/agentic-chat/src/components/tools/useCancelConditionalOrderExecution.tsx index a8f6ea83..bd6ddc91 100644 --- a/apps/agentic-chat/src/components/tools/useCancelConditionalOrderExecution.tsx +++ b/apps/agentic-chat/src/components/tools/useCancelConditionalOrderExecution.tsx @@ -10,6 +10,7 @@ import { analytics } from '@/lib/mixpanel' import { submitSafeTxStep } from '@/lib/steps/submitSafeTxStep' import { switchNetworkStepByChainIdNumber } from '@/lib/steps/switchNetworkStep' import type { StepStatus } from '@/lib/stepUtils' +import { withWalletLock } from '@/lib/walletMutex' import { useOrderStore } from '@/stores/orderStore' export const CANCEL_CONDITIONAL_STEPS = { PREPARE: 0, NETWORK: 1, SUBMIT_CANCEL: 2, CONFIRM_TX: 3 } as const @@ -51,55 +52,57 @@ export function useCancelConditionalOrderExecution( const ctx = useToolExecution(toolCallId, config.toolName, {}) useExecuteOnce(ctx, cancelData, async (data, ctx) => { - try { - const { safeTransaction, safeAddress } = data - - if (!ctx.refs.evmAddress.current) throw new Error('Wallet disconnected. Please reconnect and try again.') - - // Step 0: Prepare - ctx.setState(draft => { - draft.toolOutput = data as unknown - }) - ctx.advanceStep() - - // Step 1: Network switch - await switchNetworkStepByChainIdNumber(ctx, safeTransaction.chainId) - - // Step 2+3: Submit cancel via Safe (executeSafeTransaction already waits for on-chain confirmation) - ctx.setSubstatus('Submitting cancellation...') - const cancelTxHash = await submitSafeTxStep(ctx, { - safeAddress, - to: safeTransaction.to, - data: safeTransaction.data, - value: safeTransaction.value, - chainId: safeTransaction.chainId, - }) - ctx.setMeta({ txHash: cancelTxHash } as Partial) - ctx.advanceStep() - ctx.markTerminal() - ctx.persist() - - useOrderStore.getState().updateStatus(data.orderHash, safeAddress, 'cancelled') - - const network = CHAIN_ID_TO_NETWORK[safeTransaction.chainId] ?? 'unknown' - if (config.toolName === 'cancelStopLossTool') { - analytics.trackCancelStopLoss({ orderId: data.orderHash, network }) - } else { - analytics.trackCancelTwap({ orderId: data.orderHash, network }) + await withWalletLock(async () => { + try { + const { safeTransaction, safeAddress } = data + + if (!ctx.refs.evmAddress.current) throw new Error('Wallet disconnected. Please reconnect and try again.') + + // Step 0: Prepare + ctx.setState(draft => { + draft.toolOutput = data as unknown + }) + ctx.advanceStep() + + // Step 1: Network switch + await switchNetworkStepByChainIdNumber(ctx, safeTransaction.chainId) + + // Step 2+3: Submit cancel via Safe (executeSafeTransaction already waits for on-chain confirmation) + ctx.setSubstatus('Submitting cancellation...') + const cancelTxHash = await submitSafeTxStep(ctx, { + safeAddress, + to: safeTransaction.to, + data: safeTransaction.data, + value: safeTransaction.value, + chainId: safeTransaction.chainId, + }) + ctx.setMeta({ txHash: cancelTxHash } as Partial) + ctx.advanceStep() + ctx.markTerminal() + ctx.persist() + + useOrderStore.getState().updateStatus(data.orderHash, safeAddress, 'cancelled') + + const network = CHAIN_ID_TO_NETWORK[safeTransaction.chainId] ?? 'unknown' + if (config.toolName === 'cancelStopLossTool') { + analytics.trackCancelStopLoss({ orderId: data.orderHash, network }) + } else { + analytics.trackCancelTwap({ orderId: data.orderHash, network }) + } + + toast.success(config.renderSuccessToast(data)) + config.onSuccess?.(data) + } catch (error) { + const errorMessage = ctx.failAndPersist(error) + + toast.error( + + Failed to cancel {config.orderLabel}:{' '} + {errorMessage.length > 100 ? `${errorMessage.slice(0, 100)}...` : errorMessage} + + ) } - - toast.success(config.renderSuccessToast(data)) - config.onSuccess?.(data) - } catch (error) { - const errorMessage = ctx.failAndPersist(error) - - toast.error( - - Failed to cancel {config.orderLabel}:{' '} - {errorMessage.length > 100 ? `${errorMessage.slice(0, 100)}...` : errorMessage} - - ) - } + }) }) const prepareStepStatus = toolStateToStepStatus(toolState) diff --git a/apps/agentic-chat/src/components/tools/useCancelLimitOrderExecution.tsx b/apps/agentic-chat/src/components/tools/useCancelLimitOrderExecution.tsx index d4ed71d7..2c3f5639 100644 --- a/apps/agentic-chat/src/components/tools/useCancelLimitOrderExecution.tsx +++ b/apps/agentic-chat/src/components/tools/useCancelLimitOrderExecution.tsx @@ -11,6 +11,7 @@ import { analytics } from '@/lib/mixpanel' import { signEip712Step } from '@/lib/steps/signEip712Step' import { switchNetworkStepByChainIdNumber } from '@/lib/steps/switchNetworkStep' import type { StepStatus } from '@/lib/stepUtils' +import { withWalletLock } from '@/lib/walletMutex' export const CANCEL_LIMIT_ORDER_STEPS = { PREPARE: 0, NETWORK: 1, SIGN: 2, SUBMIT: 3 } as const @@ -59,50 +60,52 @@ export const useCancelLimitOrderExecution = ( const ctx = useToolExecution(toolCallId, 'cancelLimitOrderTool', {}) useExecuteOnce(ctx, cancelData, async (data, ctx) => { - try { - const { signingData, chainId } = data - - if (!signingData) throw new Error('Invalid cancel order output: missing signingData') - if (!chainId) throw new Error('Invalid cancel order output: missing chainId') - - if (!ctx.refs.evmWallet.current) throw new Error('EVM wallet not connected') - - // Step 0: Prepare - ctx.setState(draft => { - draft.toolOutput = data - draft.meta.networkName = data.network - draft.meta.orderId = data.orderId - }) - ctx.advanceStep() - - // Step 1: Network switch - await switchNetworkStepByChainIdNumber(ctx, chainId) - - // Step 2: Sign EIP-712 cancellation message - const signature = await signEip712Step(ctx, signingData) - - // Step 3: Submit cancellation to CoW - ctx.setSubstatus('Submitting cancellation...') - await submitCancellation(chainId, signingData.message.orderUids, signature) - ctx.advanceStep() - ctx.markTerminal() - ctx.persist() - - toast.success(Your limit order has been cancelled) - - analytics.trackCancelLimitOrder({ - orderId: data.orderId, - network: data.network, - }) - } catch (error) { - const errorMessage = ctx.failAndPersist(error) - - toast.error( - - Failed to cancel order: {errorMessage.length > 100 ? `${errorMessage.slice(0, 100)}...` : errorMessage} - - ) - } + await withWalletLock(async () => { + try { + const { signingData, chainId } = data + + if (!signingData) throw new Error('Invalid cancel order output: missing signingData') + if (!chainId) throw new Error('Invalid cancel order output: missing chainId') + + if (!ctx.refs.evmWallet.current) throw new Error('EVM wallet not connected') + + // Step 0: Prepare + ctx.setState(draft => { + draft.toolOutput = data + draft.meta.networkName = data.network + draft.meta.orderId = data.orderId + }) + ctx.advanceStep() + + // Step 1: Network switch + await switchNetworkStepByChainIdNumber(ctx, chainId) + + // Step 2: Sign EIP-712 cancellation message + const signature = await signEip712Step(ctx, signingData) + + // Step 3: Submit cancellation to CoW + ctx.setSubstatus('Submitting cancellation...') + await submitCancellation(chainId, signingData.message.orderUids, signature) + ctx.advanceStep() + ctx.markTerminal() + ctx.persist() + + toast.success(Your limit order has been cancelled) + + analytics.trackCancelLimitOrder({ + orderId: data.orderId, + network: data.network, + }) + } catch (error) { + const errorMessage = ctx.failAndPersist(error) + + toast.error( + + Failed to cancel order: {errorMessage.length > 100 ? `${errorMessage.slice(0, 100)}...` : errorMessage} + + ) + } + }) }) const prepareStepStatus = toolStateToStepStatus(toolState) diff --git a/apps/agentic-chat/src/components/tools/useConditionalOrderExecution.tsx b/apps/agentic-chat/src/components/tools/useConditionalOrderExecution.tsx index da2d4297..9624edf5 100644 --- a/apps/agentic-chat/src/components/tools/useConditionalOrderExecution.tsx +++ b/apps/agentic-chat/src/components/tools/useConditionalOrderExecution.tsx @@ -10,6 +10,7 @@ import { getStepStatus, toolStateToStepStatus } from '@/lib/executionState' import { ensureSafeReady, executeSafeTransaction } from '@/lib/safe' import { switchNetworkStepByChainIdNumber } from '@/lib/steps/switchNetworkStep' import type { StepStatus } from '@/lib/stepUtils' +import { withWalletLock } from '@/lib/walletMutex' import type { OrderRecord } from '@/stores/orderStore' import { useOrderStore } from '@/stores/orderStore' import { sendTransaction } from '@/utils/sendTransaction' @@ -135,67 +136,69 @@ export function useConditionalOrderExecution const ctx = useToolExecution(toolCallId, config.toolName, {}) useExecuteOnce(ctx, orderData, async (data, ctx) => { - try { - const { safeTransaction } = data - const targetChainId = safeTransaction.chainId - - if (!ctx.refs.evmAddress.current) { - throw new Error('Wallet disconnected. Please reconnect and try again.') + await withWalletLock(async () => { + try { + const { safeTransaction } = data + const targetChainId = safeTransaction.chainId + + if (!ctx.refs.evmAddress.current) { + throw new Error('Wallet disconnected. Please reconnect and try again.') + } + + // Step 0: Prepare + ctx.setState(draft => { + draft.toolOutput = data as unknown + draft.meta.networkName = data.summary.network + }) + ctx.advanceStep() + + // Step 1: Network switch + await switchNetworkStepByChainIdNumber(ctx, targetChainId) + + // Step 2: Safe check — deploy Safe + enable ComposableCoW modules + const safeAddress = await ensureSafeReadyStep(ctx, ctx.refs.evmAddress.current, targetChainId) + + // Step 3: Deposit — transfer sell tokens from EOA to Safe (if needed) + await depositStep(ctx, data, targetChainId) + + // Step 4: Approve via Safe (if needed) + await approveViaSafeStep(ctx, data, safeAddress, targetChainId) + + // Step 5: Submit to ComposableCoW via Safe + if (!ctx.refs.evmWallet.current) throw new Error('EVM wallet not connected') + if (!ctx.refs.evmAddress.current) throw new Error('Wallet disconnected') + + const walletClient = await ctx.refs.evmWallet.current.getWalletClient() + ctx.setSubstatus('Proposing Safe transaction...') + const submitTxHash = await executeSafeTransaction( + safeAddress, + { to: safeTransaction.to, data: safeTransaction.data, value: safeTransaction.value }, + ctx.refs.evmAddress.current, + targetChainId, + walletClient + ) + ctx.setMeta({ txHash: submitTxHash } as Partial) + ctx.advanceStep() + ctx.markTerminal() + ctx.persist() + + useOrderStore + .getState() + .saveOrder(config.toOrderRecord({ data, safeAddress, submitTxHash, chainId: targetChainId })) + + toast.success(config.renderSuccessToast(data)) + config.onSuccess?.(data) + } catch (error) { + const errorMessage = ctx.failAndPersist(error) + + toast.error( + + Failed to set {config.errorLabel}:{' '} + {errorMessage.length > 100 ? `${errorMessage.slice(0, 100)}...` : errorMessage} + + ) } - - // Step 0: Prepare - ctx.setState(draft => { - draft.toolOutput = data as unknown - draft.meta.networkName = data.summary.network - }) - ctx.advanceStep() - - // Step 1: Network switch - await switchNetworkStepByChainIdNumber(ctx, targetChainId) - - // Step 2: Safe check — deploy Safe + enable ComposableCoW modules - const safeAddress = await ensureSafeReadyStep(ctx, ctx.refs.evmAddress.current, targetChainId) - - // Step 3: Deposit — transfer sell tokens from EOA to Safe (if needed) - await depositStep(ctx, data, targetChainId) - - // Step 4: Approve via Safe (if needed) - await approveViaSafeStep(ctx, data, safeAddress, targetChainId) - - // Step 5: Submit to ComposableCoW via Safe - if (!ctx.refs.evmWallet.current) throw new Error('EVM wallet not connected') - if (!ctx.refs.evmAddress.current) throw new Error('Wallet disconnected') - - const walletClient = await ctx.refs.evmWallet.current.getWalletClient() - ctx.setSubstatus('Proposing Safe transaction...') - const submitTxHash = await executeSafeTransaction( - safeAddress, - { to: safeTransaction.to, data: safeTransaction.data, value: safeTransaction.value }, - ctx.refs.evmAddress.current, - targetChainId, - walletClient - ) - ctx.setMeta({ txHash: submitTxHash } as Partial) - ctx.advanceStep() - ctx.markTerminal() - ctx.persist() - - useOrderStore - .getState() - .saveOrder(config.toOrderRecord({ data, safeAddress, submitTxHash, chainId: targetChainId })) - - toast.success(config.renderSuccessToast(data)) - config.onSuccess?.(data) - } catch (error) { - const errorMessage = ctx.failAndPersist(error) - - toast.error( - - Failed to set {config.errorLabel}:{' '} - {errorMessage.length > 100 ? `${errorMessage.slice(0, 100)}...` : errorMessage} - - ) - } + }) }) const prepareStepStatus = toolStateToStepStatus(toolState) diff --git a/apps/agentic-chat/src/components/tools/useLimitOrderExecution.tsx b/apps/agentic-chat/src/components/tools/useLimitOrderExecution.tsx index 5840d2e4..450e3a68 100644 --- a/apps/agentic-chat/src/components/tools/useLimitOrderExecution.tsx +++ b/apps/agentic-chat/src/components/tools/useLimitOrderExecution.tsx @@ -12,6 +12,7 @@ import { analytics } from '@/lib/mixpanel' import { signEip712Step } from '@/lib/steps/signEip712Step' import { switchNetworkStepByChainIdNumber } from '@/lib/steps/switchNetworkStep' import type { StepStatus } from '@/lib/stepUtils' +import { withWalletLock } from '@/lib/walletMutex' import { withRetry } from '@/utils/retry' import { executeApproval } from '@/utils/swapExecutor' import { waitForConfirmedReceipt } from '@/utils/waitForConfirmedReceipt' @@ -91,81 +92,83 @@ export const useLimitOrderExecution = ( const ctx = useToolExecution(toolCallId, 'createLimitOrderTool', {}) useExecuteOnce(ctx, orderData, async (data, ctx) => { - try { - const { signingData, orderParams, needsApproval, approvalTx } = data - - if (!orderParams?.chainId) throw new Error('Invalid limit order output: missing orderParams.chainId') - if (!orderParams?.receiver) throw new Error('Invalid limit order output: missing orderParams.receiver') - if (!signingData) throw new Error('Invalid limit order output: missing signingData') - - const currentAddress = ctx.refs.evmAddress.current - if (!currentAddress) throw new Error('Wallet disconnected. Please reconnect and try again.') - if (currentAddress.toLowerCase() !== orderParams.receiver.toLowerCase()) { - throw new Error('Wallet address changed. Please re-initiate the limit order.') - } + await withWalletLock(async () => { + try { + const { signingData, orderParams, needsApproval, approvalTx } = data + + if (!orderParams?.chainId) throw new Error('Invalid limit order output: missing orderParams.chainId') + if (!orderParams?.receiver) throw new Error('Invalid limit order output: missing orderParams.receiver') + if (!signingData) throw new Error('Invalid limit order output: missing signingData') + + const currentAddress = ctx.refs.evmAddress.current + if (!currentAddress) throw new Error('Wallet disconnected. Please reconnect and try again.') + if (currentAddress.toLowerCase() !== orderParams.receiver.toLowerCase()) { + throw new Error('Wallet address changed. Please re-initiate the limit order.') + } + + // Step 0: Prepare + ctx.setState(draft => { + draft.toolOutput = data + draft.meta.networkName = data.summary.network + }) + ctx.advanceStep() - // Step 0: Prepare - ctx.setState(draft => { - draft.toolOutput = data - draft.meta.networkName = data.summary.network - }) - ctx.advanceStep() - - // Step 1: Network switch - await switchNetworkStepByChainIdNumber(ctx, orderParams.chainId) - - // Step 2: Approve (skip if not needed) - if (needsApproval && approvalTx) { - ctx.setSubstatus('Requesting approval signature...') - const approvalTxHash = await executeApproval(approvalTx) - ctx.setMeta({ approvalTxHash } as Partial) - ctx.setSubstatus('Waiting for confirmation...') - await waitForConfirmedReceipt(orderParams.chainId, approvalTxHash as `0x${string}`) + // Step 1: Network switch + await switchNetworkStepByChainIdNumber(ctx, orderParams.chainId) + + // Step 2: Approve (skip if not needed) + if (needsApproval && approvalTx) { + ctx.setSubstatus('Requesting approval signature...') + const approvalTxHash = await executeApproval(approvalTx) + ctx.setMeta({ approvalTxHash } as Partial) + ctx.setSubstatus('Waiting for confirmation...') + await waitForConfirmedReceipt(orderParams.chainId, approvalTxHash as `0x${string}`) + ctx.advanceStep() + } else { + ctx.skipStep() + } + + // Step 3: Sign EIP-712 message + const signature = await signEip712Step(ctx, signingData) + + // Step 4: Submit to CoW + ctx.setSubstatus('Submitting to CoW Protocol...') + const orderId = await submitSignedOrder(orderParams.chainId, orderParams, signingData, signature) + ctx.setMeta({ orderId } as Partial) ctx.advanceStep() - } else { - ctx.skipStep() + ctx.markTerminal() + ctx.persist() + + toast.success( + + Your limit order to sell{' '} + {' '} + has been placed + + ) + + analytics.trackLimitOrder({ + sellAsset: data.summary.sellAsset.symbol, + buyAsset: data.summary.buyAsset.symbol, + sellAmount: data.summary.sellAsset.amount, + buyAmount: data.summary.buyAsset.estimatedAmount, + network: data.summary.network, + limitPrice: data.summary.limitPrice, + }) + } catch (error) { + const errorMessage = ctx.failAndPersist(error) + + toast.error( + + Failed to place limit order: {errorMessage.length > 100 ? `${errorMessage.slice(0, 100)}...` : errorMessage} + + ) } - - // Step 3: Sign EIP-712 message - const signature = await signEip712Step(ctx, signingData) - - // Step 4: Submit to CoW - ctx.setSubstatus('Submitting to CoW Protocol...') - const orderId = await submitSignedOrder(orderParams.chainId, orderParams, signingData, signature) - ctx.setMeta({ orderId } as Partial) - ctx.advanceStep() - ctx.markTerminal() - ctx.persist() - - toast.success( - - Your limit order to sell{' '} - {' '} - has been placed - - ) - - analytics.trackLimitOrder({ - sellAsset: data.summary.sellAsset.symbol, - buyAsset: data.summary.buyAsset.symbol, - sellAmount: data.summary.sellAsset.amount, - buyAmount: data.summary.buyAsset.estimatedAmount, - network: data.summary.network, - limitPrice: data.summary.limitPrice, - }) - } catch (error) { - const errorMessage = ctx.failAndPersist(error) - - toast.error( - - Failed to place limit order: {errorMessage.length > 100 ? `${errorMessage.slice(0, 100)}...` : errorMessage} - - ) - } + }) }) const prepareStepStatus = toolStateToStepStatus(toolState) diff --git a/apps/agentic-chat/src/components/tools/useSwapExecution.tsx b/apps/agentic-chat/src/components/tools/useSwapExecution.tsx index a6369b1d..5c60632a 100644 --- a/apps/agentic-chat/src/components/tools/useSwapExecution.tsx +++ b/apps/agentic-chat/src/components/tools/useSwapExecution.tsx @@ -11,6 +11,7 @@ import { getStepStatus, toolStateToStepStatus } from '@/lib/executionState' import { analytics } from '@/lib/mixpanel' import { switchNetworkStep } from '@/lib/steps/switchNetworkStep' import type { StepStatus } from '@/lib/stepUtils' +import { withWalletLock } from '@/lib/walletMutex' import type { SolanaWalletSigner } from '@/utils/chains/types' import { executeApproval, executeSwap } from '@/utils/swapExecutor' import { waitForConfirmedReceipt } from '@/utils/waitForConfirmedReceipt' @@ -41,111 +42,114 @@ export const useSwapExecution = ( const ctx = useToolExecution(toolCallId, 'initiateSwapTool', {}) useExecuteOnce(ctx, swapData, async (data, ctx) => { - try { - const { needsApproval, approvalTx, swapTx } = data - - if (!swapTx?.from) throw new Error('Invalid swap output: missing swapTx.from') - if (!swapTx?.chainId) throw new Error('Invalid swap output: missing swapTx.chainId') - if (!data.swapData?.sellAsset?.chainId) throw new Error('Invalid swap output: missing swapData.sellAsset.chainId') - - const sellAssetChainId = data.swapData.sellAsset.chainId - const { chainNamespace, chainReference } = fromChainId(sellAssetChainId) + await withWalletLock(async () => { + try { + const { needsApproval, approvalTx, swapTx } = data + + if (!swapTx?.from) throw new Error('Invalid swap output: missing swapTx.from') + if (!swapTx?.chainId) throw new Error('Invalid swap output: missing swapTx.chainId') + if (!data.swapData?.sellAsset?.chainId) + throw new Error('Invalid swap output: missing swapData.sellAsset.chainId') + + const sellAssetChainId = data.swapData.sellAsset.chainId + const { chainNamespace, chainReference } = fromChainId(sellAssetChainId) + + const currentAddress = + chainNamespace === CHAIN_NAMESPACE.Evm ? ctx.refs.evmAddress.current : ctx.refs.solanaAddress.current + if (!currentAddress) throw new Error('Wallet disconnected. Please reconnect and try again.') + if (currentAddress.toLowerCase() !== swapTx.from.toLowerCase()) { + throw new Error('Wallet address changed. Please re-initiate the swap.') + } - const currentAddress = - chainNamespace === CHAIN_NAMESPACE.Evm ? ctx.refs.evmAddress.current : ctx.refs.solanaAddress.current - if (!currentAddress) throw new Error('Wallet disconnected. Please reconnect and try again.') - if (currentAddress.toLowerCase() !== swapTx.from.toLowerCase()) { - throw new Error('Wallet address changed. Please re-initiate the swap.') - } + let solanaSigner: SolanaWalletSigner | undefined + if (chainNamespace === CHAIN_NAMESPACE.Solana && ctx.refs.solanaWallet.current) { + solanaSigner = await ctx.refs.solanaWallet.current.getSigner() + } - let solanaSigner: SolanaWalletSigner | undefined - if (chainNamespace === CHAIN_NAMESPACE.Solana && ctx.refs.solanaWallet.current) { - solanaSigner = await ctx.refs.solanaWallet.current.getSigner() - } + // Step 0: Quote complete + ctx.setState(draft => { + draft.toolOutput = data + draft.meta.networkName = data.swapData.sellAsset.network + }) + ctx.advanceStep() - // Step 0: Quote complete - ctx.setState(draft => { - draft.toolOutput = data - draft.meta.networkName = data.swapData.sellAsset.network - }) - ctx.advanceStep() - - // Step 1: Network switch - await switchNetworkStep(ctx, sellAssetChainId) - - // Step 2: Approve (skip if not needed) - if (needsApproval && approvalTx) { - ctx.setSubstatus('Requesting approval signature...') - const approvalTxHash = await executeApproval(approvalTx, { solanaSigner }) - ctx.setMeta({ approvalTxHash }) - - if (chainNamespace === CHAIN_NAMESPACE.Evm) { - ctx.setSubstatus('Waiting for confirmation...') - await waitForConfirmedReceipt(Number(chainReference), approvalTxHash as `0x${string}`) + // Step 1: Network switch + await switchNetworkStep(ctx, sellAssetChainId) + + // Step 2: Approve (skip if not needed) + if (needsApproval && approvalTx) { + ctx.setSubstatus('Requesting approval signature...') + const approvalTxHash = await executeApproval(approvalTx, { solanaSigner }) + ctx.setMeta({ approvalTxHash }) + + if (chainNamespace === CHAIN_NAMESPACE.Evm) { + ctx.setSubstatus('Waiting for confirmation...') + await waitForConfirmedReceipt(Number(chainReference), approvalTxHash as `0x${string}`) + } + ctx.advanceStep() + } else { + ctx.skipStep() } + + // Step 3: Swap + ctx.setSubstatus('Requesting signature...') + const swapTxHash = await executeSwap(swapTx, { solanaSigner }) + ctx.setMeta({ txHash: swapTxHash }) ctx.advanceStep() - } else { - ctx.skipStep() + ctx.markTerminal() + ctx.persist() + + analytics.trackSwap({ + sellAsset: data.swapData.sellAsset.symbol, + buyAsset: data.swapData.buyAsset.symbol, + sellAmount: data.swapData.sellAmountCryptoPrecision, + buyAmount: data.swapData.buyAmountCryptoPrecision, + network: data.swapData.sellAsset.network, + }) + + toast.success( + + Your swap of{' '} + {' '} + to{' '} + {' '} + is complete + + ) + } catch (error) { + ctx.failAndPersist(error) + + toast.error( + + Your swap of{' '} + {' '} + to{' '} + {' '} + failed + + ) } - - // Step 3: Swap - ctx.setSubstatus('Requesting signature...') - const swapTxHash = await executeSwap(swapTx, { solanaSigner }) - ctx.setMeta({ txHash: swapTxHash }) - ctx.advanceStep() - ctx.markTerminal() - ctx.persist() - - analytics.trackSwap({ - sellAsset: data.swapData.sellAsset.symbol, - buyAsset: data.swapData.buyAsset.symbol, - sellAmount: data.swapData.sellAmountCryptoPrecision, - buyAmount: data.swapData.buyAmountCryptoPrecision, - network: data.swapData.sellAsset.network, - }) - - toast.success( - - Your swap of{' '} - {' '} - to{' '} - {' '} - is complete - - ) - } catch (error) { - ctx.failAndPersist(error) - - toast.error( - - Your swap of{' '} - {' '} - to{' '} - {' '} - failed - - ) - } + }) }) const quoteStepStatus = toolStateToStepStatus(toolState) diff --git a/apps/agentic-chat/src/components/tools/useVaultWithdrawAllExecution.ts b/apps/agentic-chat/src/components/tools/useVaultWithdrawAllExecution.ts index 169d4bb8..02384a30 100644 --- a/apps/agentic-chat/src/components/tools/useVaultWithdrawAllExecution.ts +++ b/apps/agentic-chat/src/components/tools/useVaultWithdrawAllExecution.ts @@ -9,6 +9,7 @@ import type { ChainResult, ToolExecutionState, VaultWithdrawAllMeta } from '@/li import { getStepStatus, toolStateToStepStatus } from '@/lib/executionState' import { executeSafeBatchTransaction } from '@/lib/safe' import type { StepStatus } from '@/lib/stepUtils' +import { withWalletLock } from '@/lib/walletMutex' export const VAULT_WITHDRAW_ALL_STEPS = { PREPARE: 0, WITHDRAW_CHAINS: 1 } as const @@ -34,81 +35,83 @@ export const useVaultWithdrawAllExecution = ( const ctx = useToolExecution(toolCallId, 'vaultWithdrawAllTool', { chainResults: [] }) useExecuteOnce(ctx, withdrawData, async (data, ctx) => { - try { - if (!ctx.refs.evmAddress.current) { - throw new Error('Wallet disconnected. Please reconnect and try again.') - } - if (!ctx.refs.evmWallet.current) { - throw new Error('EVM wallet not connected') - } + await withWalletLock(async () => { + try { + if (!ctx.refs.evmAddress.current) { + throw new Error('Wallet disconnected. Please reconnect and try again.') + } + if (!ctx.refs.evmWallet.current) { + throw new Error('EVM wallet not connected') + } - // Step 0: Prepare complete - ctx.setState(draft => { - draft.toolOutput = data - }) - ctx.advanceStep() + // Step 0: Prepare complete + ctx.setState(draft => { + draft.toolOutput = data + }) + ctx.advanceStep() - if (ctx.refs.primaryWallet.current && !isEthereumWallet(ctx.refs.primaryWallet.current)) { - await ctx.refs.changePrimaryWallet.current(ctx.refs.evmWallet.current.id) - } + if (ctx.refs.primaryWallet.current && !isEthereumWallet(ctx.refs.primaryWallet.current)) { + await ctx.refs.changePrimaryWallet.current(ctx.refs.evmWallet.current.id) + } - // Step 1: Execute each chain's batch sequentially (network switch -> sign batch) - const chainResults: ChainResult[] = [] - - for (const [i, withdrawal] of data.withdrawals.entries()) { - ctx.setMeta({ currentChainIndex: i }) - - try { - ctx.setSubstatus(`Switching to ${withdrawal.network}...`) - await ctx.refs.evmWallet.current.connector.switchNetwork({ - networkChainId: withdrawal.chainId, - }) - - ctx.setSubstatus(`Proposing Safe transaction on ${withdrawal.network}...`) - const walletClient = await ctx.refs.evmWallet.current.getWalletClient() - const txHash = await executeSafeBatchTransaction( - withdrawal.safeAddress, - withdrawal.safeBatchTransaction, - ctx.refs.evmAddress.current, - withdrawal.chainId, - walletClient - ) - - chainResults.push({ - network: withdrawal.network, - chainId: withdrawal.chainId, - txHash, - }) - - ctx.setMeta({ chainResults: [...chainResults] }) - } catch (chainError) { - const errorMessage = chainError instanceof Error ? chainError.message : String(chainError) - chainResults.push({ - network: withdrawal.network, - chainId: withdrawal.chainId, - error: errorMessage, - }) - - ctx.setMeta({ chainResults: [...chainResults] }) + // Step 1: Execute each chain's batch sequentially (network switch -> sign batch) + const chainResults: ChainResult[] = [] + + for (const [i, withdrawal] of data.withdrawals.entries()) { + ctx.setMeta({ currentChainIndex: i }) + + try { + ctx.setSubstatus(`Switching to ${withdrawal.network}...`) + await ctx.refs.evmWallet.current.connector.switchNetwork({ + networkChainId: withdrawal.chainId, + }) + + ctx.setSubstatus(`Proposing Safe transaction on ${withdrawal.network}...`) + const walletClient = await ctx.refs.evmWallet.current.getWalletClient() + const txHash = await executeSafeBatchTransaction( + withdrawal.safeAddress, + withdrawal.safeBatchTransaction, + ctx.refs.evmAddress.current, + withdrawal.chainId, + walletClient + ) + + chainResults.push({ + network: withdrawal.network, + chainId: withdrawal.chainId, + txHash, + }) + + ctx.setMeta({ chainResults: [...chainResults] }) + } catch (chainError) { + const errorMessage = chainError instanceof Error ? chainError.message : String(chainError) + chainResults.push({ + network: withdrawal.network, + chainId: withdrawal.chainId, + error: errorMessage, + }) + + ctx.setMeta({ chainResults: [...chainResults] }) + } } - } - const hasAnySuccess = chainResults.some(r => r.txHash) - if (!hasAnySuccess) { - throw new Error('All chain withdrawals failed. Please try again.') - } + const hasAnySuccess = chainResults.some(r => r.txHash) + if (!hasAnySuccess) { + throw new Error('All chain withdrawals failed. Please try again.') + } - ctx.setMeta({ chainResults, currentChainIndex: data.withdrawals.length }) - ctx.advanceStep() - ctx.markTerminal() - ctx.persist() + ctx.setMeta({ chainResults, currentChainIndex: data.withdrawals.length }) + ctx.advanceStep() + ctx.markTerminal() + ctx.persist() - toast.success('Vault withdraw all is complete') - } catch (error) { - ctx.failAndPersist(error) + toast.success('Vault withdraw all is complete') + } catch (error) { + ctx.failAndPersist(error) - toast.error('Vault withdraw all failed') - } + toast.error('Vault withdraw all failed') + } + }) }) const prepareStepStatus = toolStateToStepStatus(toolState) diff --git a/apps/agentic-chat/src/lib/walletMutex.test.ts b/apps/agentic-chat/src/lib/walletMutex.test.ts new file mode 100644 index 00000000..bf025392 --- /dev/null +++ b/apps/agentic-chat/src/lib/walletMutex.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, test } from 'bun:test' + +import { withWalletLock } from './walletMutex' + +describe('withWalletLock', () => { + test('serializes concurrent calls', async () => { + const order: number[] = [] + + const first = withWalletLock(async () => { + order.push(1) + await new Promise(r => setTimeout(r, 50)) + order.push(2) + return 'a' + }) + + const second = withWalletLock(async () => { + order.push(3) + await new Promise(r => setTimeout(r, 10)) + order.push(4) + return 'b' + }) + + const [resultA, resultB] = await Promise.all([first, second]) + + expect(order).toEqual([1, 2, 3, 4]) + expect(resultA).toBe('a') + expect(resultB).toBe('b') + }) + + test('releases lock on rejection so next call proceeds', async () => { + const failing = withWalletLock(async () => { + throw new Error('boom') + }) + + // eslint-disable-next-line @typescript-eslint/await-thenable + await expect(failing).rejects.toThrow('boom') + + const result = await withWalletLock(async () => 'recovered') + expect(result).toBe('recovered') + }) + + test('returns the callback return value', async () => { + const result = await withWalletLock(async () => 42) + expect(result).toBe(42) + }) +}) diff --git a/apps/agentic-chat/src/lib/walletMutex.ts b/apps/agentic-chat/src/lib/walletMutex.ts new file mode 100644 index 00000000..91fbb448 --- /dev/null +++ b/apps/agentic-chat/src/lib/walletMutex.ts @@ -0,0 +1,10 @@ +let pending = Promise.resolve() + +export function withWalletLock(fn: () => Promise): Promise { + const next = pending.then(fn, fn) + pending = next.then( + () => {}, + () => {} + ) + return next +}