diff --git a/.agents/skills/nexus-sdk-bridge-flows/SKILL.md b/.agents/skills/nexus-sdk-bridge-flows/SKILL.md index 48549bf8..8755ca87 100644 --- a/.agents/skills/nexus-sdk-bridge-flows/SKILL.md +++ b/.agents/skills/nexus-sdk-bridge-flows/SKILL.md @@ -4,7 +4,7 @@ description: Implement bridge, bridgeAndTransfer, bridgeAndExecute, and execute --- # Bridge and Execute Flows - + ## Call bridge(params, options?) - Use to move tokens cross-chain (intent-based bridge). - Signature: diff --git a/registry/nexus-elements/common/hooks/useTransactionExecution.ts b/registry/nexus-elements/common/hooks/useTransactionExecution.ts index f272b8ec..83695566 100644 --- a/registry/nexus-elements/common/hooks/useTransactionExecution.ts +++ b/registry/nexus-elements/common/hooks/useTransactionExecution.ts @@ -149,6 +149,9 @@ export function useTransactionExecution({ notifyHistoryRefresh?.(); }; + const isRunStale = (currentRunId: number) => + currentRunId !== runIdRef.current; + const handleTransaction = async () => { if (commitLockRef.current) return; commitLockRef.current = true; @@ -221,7 +224,7 @@ export function useTransactionExecution({ } const maxForCurrentSelection = await getMaxForCurrentSelection(); - if (currentRunId !== runIdRef.current) return; + if (isRunStale(currentRunId)) return; if (!maxForCurrentSelection) { const message = `Unable to determine max ${operationName} amount for selected sources. Please try again.`; setTxError(message); @@ -250,7 +253,7 @@ export function useTransactionExecution({ setAppliedSourceSelectionKey(sourceSelectionKey); const onEvent = (event: TransactionFlowEvent) => { - if (currentRunId !== runIdRef.current) return; + if (isRunStale(currentRunId)) return; if (event.name === NEXUS_EVENTS.STEPS_LIST) { const list = Array.isArray(event.args) ? event.args : []; onStepsList(list as BridgeStepType[]); @@ -269,6 +272,29 @@ export function useTransactionExecution({ } }; + if (nexusSDK) { + nexusSDK.setOnIntentHook((data) => { + if (isRunStale(currentRunId)) { + try { data.deny(); } catch {} + return; + } + + if (!inputs.amount) { + try { data.deny(); } catch {} + return; + } + + intent.current = data; + }); + + nexusSDK.setOnAllowanceHook((data) => { + if (isRunStale(currentRunId)) { + return; + } + allowance.current = data; + }); + } + const transactionResult = await executeTransaction({ token: inputs.token, amount: amountBigInt, @@ -362,15 +388,35 @@ export function useTransactionExecution({ await handleTransaction(); }; - const invalidatePendingExecution = useCallback(() => { - runIdRef.current += 1; - if (intent.current) { - intent.current.deny(); - intent.current = null; - } - setRefreshing(false); - setAppliedSourceSelectionKey("ALL"); - }, [intent, setAppliedSourceSelectionKey, setRefreshing]); + const invalidatePendingExecution = useCallback( + (options?: { forceResetUI?: boolean }) => { + runIdRef.current += 1; + commitLockRef.current = false; + if (intent.current) { + intent.current.deny(); + intent.current = null; + } + allowance.current = null; + setAppliedSourceSelectionKey("ALL"); + + // Actively clear UI flags if inputs become cleanly invalid (zero, empty, etc.) + if (options?.forceResetUI) { + setStatus("idle"); + setRefreshing(false); + resetSteps(); + setLastExplorerUrl(""); + } + }, + [ + allowance, + intent, + setAppliedSourceSelectionKey, + setLastExplorerUrl, + setRefreshing, + setStatus, + resetSteps, + ], + ); return { refreshIntent, diff --git a/registry/nexus-elements/common/hooks/useTransactionFlow.ts b/registry/nexus-elements/common/hooks/useTransactionFlow.ts index 2cfc9e78..c8a714d4 100644 --- a/registry/nexus-elements/common/hooks/useTransactionFlow.ts +++ b/registry/nexus-elements/common/hooks/useTransactionFlow.ts @@ -573,5 +573,6 @@ export function useTransactionFlow(props: UseTransactionFlowProps) { requiredSafetyTotal: sourceSelection.requiredSafetyTotal, maxAvailableAmount: selectedSourcesMaxAmount ?? undefined, isInputsValid: areInputsValid, + invalidatePendingExecution, }; } diff --git a/registry/nexus-elements/deposit/hooks/use-deposit-widget.ts b/registry/nexus-elements/deposit/hooks/use-deposit-widget.ts index ba649a3c..c12e30cc 100644 --- a/registry/nexus-elements/deposit/hooks/use-deposit-widget.ts +++ b/registry/nexus-elements/deposit/hooks/use-deposit-widget.ts @@ -115,6 +115,11 @@ export function useDepositWidget( const determiningSwapComplete = useRef(false); const lastSimulationTime = useRef(0); const suppressNextWidgetPreviewCancelError = useRef(false); + const depositRunIdRef = useRef(0); + + const isRunStale = useCallback((runId: number) => { + return runId !== depositRunIdRef.current; + }, []); const denyActiveSwapIntent = useCallback( (options?: { suppressUiError?: boolean }) => { @@ -187,9 +192,13 @@ export function useDepositWidget( // Action callbacks const setInputs = useCallback( (next: Partial) => { + if (next.amount === "") { + dispatch({ type: "setStatus", payload: "idle" }); + denyActiveSwapIntent({ suppressUiError: true }); + } dispatch({ type: "setInputs", payload: next }); }, - [dispatch], + [dispatch, denyActiveSwapIntent], ); const setTxError = useCallback( @@ -203,10 +212,33 @@ export function useDepositWidget( * Start the swap and execute flow with the SDK */ const start = useCallback( - (inputs: SwapAndExecuteParams, targetAmountUsd?: number) => { + (inputs: SwapAndExecuteParams, runId: number, targetAmountUsd?: number) => { if (!nexusSDK || !inputs || isProcessing) return; seed(SWAP_EXPECTED_STEPS); + + if (nexusSDK) { + nexusSDK.setOnSwapIntentHook((data: OnSwapIntentHookData) => { + if (isRunStale(runId)) { + try { data.deny(); } catch {} + return; + } + + if (!state.inputs.amount) { + try { data.deny(); } catch {} + return; + } + + swapIntent.current = data; + }); + + nexusSDK.setOnAllowanceHook((data) => { + if (isRunStale(runId)) { + return; + } + }); + } + const requiredAmountUsd = targetAmountUsd ?? parseUsdAmount(state.inputs.amount); const { sourcePoolIds, selectedSourceIds, fromSources } = @@ -236,6 +268,7 @@ export function useDepositWidget( nexusSDK .swapAndExecute(inputsWithSources, { onEvent: (event) => { + if (isRunStale(runId)) return; if (event.name === NEXUS_EVENTS.SWAP_STEP_COMPLETE) { const step = event.args as SwapStepType & { completed?: boolean; @@ -267,6 +300,7 @@ export function useDepositWidget( }, }) .then((data: SwapAndExecuteResult) => { + if (isRunStale(runId)) return; suppressNextWidgetPreviewCancelError.current = false; // Extract source swaps from the result @@ -359,6 +393,7 @@ export function useDepositWidget( }); }) .catch((error) => { + if (isRunStale(runId)) return; const { code, message } = handleNexusError(error); const isUserRejectedError = code === ERROR_CODES.USER_DENIED_INTENT || @@ -497,7 +532,8 @@ export function useDepositWidget( }); dispatch({ type: "setStatus", payload: "simulation-loading" }); dispatch({ type: "setSimulationLoading", payload: true }); - start(newInputs, totalAmountUsd); + const runId = ++depositRunIdRef.current; + start(newInputs, runId, totalAmountUsd); return true; }, [ diff --git a/registry/nexus-elements/fast-bridge/fast-bridge.tsx b/registry/nexus-elements/fast-bridge/fast-bridge.tsx index ff67dc50..f0be436b 100644 --- a/registry/nexus-elements/fast-bridge/fast-bridge.tsx +++ b/registry/nexus-elements/fast-bridge/fast-bridge.tsx @@ -1,5 +1,5 @@ "use client"; -import { type FC, useEffect, useState } from "react"; +import { type FC, useEffect, useMemo, useRef, useState } from "react"; import { Card, CardContent } from "../ui/card"; import ChainSelect from "./components/chain-select"; import TokenSelect from "./components/token-select"; @@ -96,6 +96,7 @@ const FastBridge: FC = ({ requiredSafetyTotal, maxAvailableAmount, isInputsValid, + invalidatePendingExecution, } = useBridge({ prefill, network: network ?? "mainnet", @@ -118,6 +119,67 @@ const FastBridge: FC = ({ } }, [intent.current?.intent]); + const autoIntentTriggered = useRef(false); + const lastAutoIntentKeyRef = useRef(""); + const autoIntentKey = useMemo( + () => + [ + inputs?.amount ?? "", + inputs?.chain ?? "", + inputs?.token ?? "", + inputs?.recipient ?? "", + ].join("|"), + [inputs?.amount, inputs?.chain, inputs?.token, inputs?.recipient], + ); + + useEffect(() => { + if (lastAutoIntentKeyRef.current === autoIntentKey) { + return; + } + lastAutoIntentKeyRef.current = autoIntentKey; + autoIntentTriggered.current = false; + }, [autoIntentKey]); + + useEffect(() => { + if ( + !(inputs?.amount && inputs?.chain && inputs?.token && inputs?.recipient) + ) { + return; + } + if (!isInputsValid) { + return; + } + if (!bridgableBalance) { + return; + } + if (availableSources.length === 0) { + return; + } + if (intent.current) { + return; + } + if (autoIntentTriggered.current) { + return; + } + + const timer = setTimeout(() => { + autoIntentTriggered.current = true; + handleTransaction(); + }, 500); + + return () => clearTimeout(timer); + }, [ + availableSources.length, + bridgableBalance, + inputs?.amount, + inputs?.chain, + inputs?.recipient, + inputs?.token, + isInputsValid, + intent, + handleTransaction, + ]); + return ( @@ -141,7 +203,12 @@ const FastBridge: FC = ({ /> setInputs({ ...inputs, amount })} + onChange={(amount) => { + setInputs({ ...inputs, amount }); + if (!amount) { + invalidatePendingExecution({ forceResetUI: true }); + } + } } bridgableBalance={filteredBridgableBalance} onCommit={() => void commitAmount()} disabled={refreshing || !!prefill?.amount} @@ -156,7 +223,7 @@ const FastBridge: FC = ({ } disabled={!!prefill?.recipient} /> - {intent?.current?.intent && ( + {Boolean(inputs?.amount) && intent?.current?.intent && ( <> , ) => void, + invalidatePendingExecution: flow.invalidatePendingExecution, }; }; diff --git a/registry/nexus-elements/swaps/hooks/useSwaps.ts b/registry/nexus-elements/swaps/hooks/useSwaps.ts index 24c88126..57a6050c 100644 --- a/registry/nexus-elements/swaps/hooks/useSwaps.ts +++ b/registry/nexus-elements/swaps/hooks/useSwaps.ts @@ -582,6 +582,27 @@ const useSwaps = ({ toTokenAddress: toToken.tokenAddress, }; + if (nexusSDK) { + nexusSDK.setOnSwapIntentHook((data: OnSwapIntentHookData) => { + if (swapRunIdRef.current !== runId) { + try { data.deny(); } catch {} + return; + } + + const amount = state.swapMode === "exactIn" ? state.inputs.fromAmount : state.inputs.toAmount; + if (!amount) { + try { data.deny(); } catch {} + return; + } + + swapIntent.current = data; + }); + + nexusSDK.setOnAllowanceHook((data) => { + if (swapRunIdRef.current !== runId) return; + }); + } + const result = await nexusSDK.swapWithExactIn(swapInput, { onEvent: (event) => { if (swapRunIdRef.current !== runId) return; @@ -624,6 +645,28 @@ const useSwaps = ({ ...(exactOutFromSources ? { fromSources: exactOutFromSources } : {}), }; + if (nexusSDK) { + nexusSDK.setOnSwapIntentHook((data: OnSwapIntentHookData) => { + if (swapRunIdRef.current !== runId) { + try { data.deny(); } catch {} + return; + } + + const amount = state.swapMode === "exactIn" ? state.inputs.fromAmount : state.inputs.toAmount; + if (!amount) { + try { data.deny(); } catch {} + return; + } + + swapIntent.current = data; + }); + + nexusSDK.setOnAllowanceHook((data) => { + if (swapRunIdRef.current !== runId) return; + // Allowance handling if needed, but useSwaps might handle it differently + }); + } + const result = await nexusSDK.swapWithExactOut(swapInput, { onEvent: (event) => { if (swapRunIdRef.current !== runId) return; @@ -684,7 +727,7 @@ const useSwaps = ({ return runId; }; - const debouncedSwapStart = useDebouncedCallback(startSwap, 1200); + const debouncedSwapStart = useDebouncedCallback(startSwap, 500); const reset = () => { // invalidate any in-flight swap run @@ -930,6 +973,18 @@ const useSwaps = ({ dispatch({ type: "setError", payload: null }); dispatch({ type: "setStatus", payload: "idle" }); } + + const nextFromAmount = inputs.fromAmount !== undefined ? inputs.fromAmount : state.inputs.fromAmount; + const nextToAmount = inputs.toAmount !== undefined ? inputs.toAmount : state.inputs.toAmount; + const mode = state.swapMode; + const isAmountCleared = mode === "exactIn" ? !nextFromAmount : !nextToAmount; + + if (isAmountCleared) { + dispatch({ type: "setStatus", payload: "idle" }); + swapIntent.current?.deny(); + swapIntent.current = null; + } + dispatch({ type: "setInputs", payload: inputs }); }, txError: state.error, diff --git a/registry/nexus-elements/transfer/hooks/useTransfer.ts b/registry/nexus-elements/transfer/hooks/useTransfer.ts index babf1aa6..4de6ad4c 100644 --- a/registry/nexus-elements/transfer/hooks/useTransfer.ts +++ b/registry/nexus-elements/transfer/hooks/useTransfer.ts @@ -101,6 +101,7 @@ const useTransfer = ({ setInputs: flow.setInputs as ( next: FastTransferState | Partial, ) => void, + invalidatePendingExecution: flow.invalidatePendingExecution, }; }; diff --git a/registry/nexus-elements/transfer/transfer.tsx b/registry/nexus-elements/transfer/transfer.tsx index e56c0221..a4ded270 100644 --- a/registry/nexus-elements/transfer/transfer.tsx +++ b/registry/nexus-elements/transfer/transfer.tsx @@ -1,5 +1,5 @@ "use client"; -import { type FC, useEffect, useState } from "react"; +import { type FC, useEffect, useMemo, useRef, useState } from "react"; import { Card, CardContent } from "../ui/card"; import ChainSelect from "./components/chain-select"; import TokenSelect from "./components/token-select"; @@ -90,6 +90,7 @@ const FastTransfer: FC = ({ requiredSafetyTotal, maxAvailableAmount, isInputsValid, + invalidatePendingExecution, } = useTransfer({ prefill, network: network ?? "mainnet", @@ -111,6 +112,67 @@ const FastTransfer: FC = ({ } }, [intent.current?.intent]); + const autoIntentTriggered = useRef(false); + const lastAutoIntentKeyRef = useRef(""); + const autoIntentKey = useMemo( + () => + [ + inputs?.amount ?? "", + inputs?.chain ?? "", + inputs?.token ?? "", + inputs?.recipient ?? "", + ].join("|"), + [inputs?.amount, inputs?.chain, inputs?.token, inputs?.recipient], + ); + + useEffect(() => { + if (lastAutoIntentKeyRef.current === autoIntentKey) { + return; + } + lastAutoIntentKeyRef.current = autoIntentKey; + autoIntentTriggered.current = false; + }, [autoIntentKey]); + + useEffect(() => { + if ( + !(inputs?.amount && inputs?.chain && inputs?.token && inputs?.recipient) + ) { + return; + } + if (!isInputsValid) { + return; + } + if (!bridgableBalance) { + return; + } + if (availableSources.length === 0) { + return; + } + if (intent.current) { + return; + } + if (autoIntentTriggered.current) { + return; + } + + const timer = setTimeout(() => { + autoIntentTriggered.current = true; + handleTransaction(); + }, 500); + + return () => clearTimeout(timer); + }, [ + availableSources.length, + bridgableBalance, + inputs?.amount, + inputs?.chain, + inputs?.recipient, + inputs?.token, + isInputsValid, + intent, + handleTransaction, + ]); + return ( @@ -134,7 +196,12 @@ const FastTransfer: FC = ({ /> setInputs({ ...inputs, amount })} + onChange={(amount) => { + setInputs({ ...inputs, amount }); + if (!amount) { + invalidatePendingExecution({ forceResetUI: true }); + } + }} bridgableBalance={filteredBridgableBalance} onCommit={() => void commitAmount()} disabled={refreshing || !!prefill?.amount} @@ -149,7 +216,7 @@ const FastTransfer: FC = ({ } disabled={!!prefill?.recipient} /> - {intent?.current?.intent && ( + {Boolean(inputs?.amount) && intent?.current?.intent && ( <>