Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/dirty-sails-hunt.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"@aave/react": minor
"@aave/client": patch
---

**feature:** add `useWaitForSwapOutcomes` hook to track multiple swap outcomes with optional callback support
4 changes: 2 additions & 2 deletions packages/client/src/actions/swap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -116,7 +116,7 @@ export function swappableTokens(
* }).andThen(plan => {
* switch (plan.__typename) {
* case 'SwapByIntent':
* return signSwapByIntentWith(plan.data)
* return signSwapTypedDataWith(plan.data)
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

the signature of this helper is not aligned with the actual helpers, let's use the viem's version for these examples, so:

Suggested change
* return signSwapTypedDataWith(plan.data)
* return signSwapTypedDataWith(wallet, plan.data)

* .andThen((signature) => swap({ intent: { quoteId: quote.quoteId, signature } }))
* .andThen((plan) => {
* // …
Expand All @@ -125,7 +125,7 @@ export function swappableTokens(
*
* case 'SwapByIntentWithApprovalRequired':
* return sendTransaction(plan.transaction)
* .andThen(signSwapByIntentWith(plan.data))
* .andThen(signSwapTypedDataWith(plan.data))
* .andThen((signature) => swap({ intent: { quoteId: quote.quoteId, signature } }))
* .andThen((plan) => {
* // …
Expand Down
176 changes: 175 additions & 1 deletion packages/react/src/swap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,12 @@ import {
prepareSwapCancel,
prepareTokenSwap,
repayWithSupplyQuote,
type SwapOutcome,
supplySwapQuote,
swap,
swapQuote,
swapStatus,
waitForSwapOutcome,
withdrawSwapQuote,
} from '@aave/client/actions';
import {
Expand Down Expand Up @@ -52,6 +54,7 @@ import {
type SwapApprovalRequired,
type SwapByIntent,
type SwapByIntentInput,
type SwapId,
SwappableTokensQuery,
type SwappableTokensRequest,
SwapQuoteQuery,
Expand All @@ -68,7 +71,7 @@ import type {
Signature,
} from '@aave/types';
import { invariant, isSignature, okAsync, ResultAwareError } from '@aave/types';
import { useCallback } from 'react';
import { useCallback, useEffect, useRef, useState } from 'react';
import { useAaveClient } from './context';
import {
type CancelOperation,
Expand Down Expand Up @@ -1515,3 +1518,174 @@ export function useCancelSwap(
[client, handler],
);
}

type SwapOrderWithOutcome = {
receipt: SwapReceipt;
outcome?: SwapOutcome;
};

export type UseWaitForSwapOutcomesOptions = {
/**
* Callback function that is triggered when a swap reaches a final outcome.
* This is useful for showing notifications/toasts when swaps complete.
*
* @param receipt - The swap receipt that reached a final outcome
* @param outcome - The final outcome (SwapFulfilled, SwapCancelled, or SwapExpired)
*/
onOutcome?: (receipt: SwapReceipt, outcome: SwapOutcome) => void;
};

/**
* Wait for multiple swaps to reach their final outcomes (cancelled, expired, or fulfilled).
*
* When a swap reaches a final outcome, the optional `onOutcome` callback is triggered,
* making it perfect for showing notifications/toasts.
*
* ```tsx
* const [waitForOutcome, { loading, error }] = useWaitForSwapOutcomes({
* onOutcome: (receipt, outcome) => {
* switch (outcome.__typename) {
* case 'SwapFulfilled':
* toast.success(`Swap completed! ${receipt.explorerLink}`);
* break;
* case 'SwapCancelled':
* toast.info('Swap was cancelled');
* break;
* case 'SwapExpired':
* toast.error('Swap expired');
* break;
* }
* },
* });
*
* // Start tracking swaps in the background
* useEffect(() => {
* if (swapReceipt) {
* waitForOutcome(swapReceipt);
* }
* }, [swapReceipt, waitForOutcome]);
* ```
*/
export function useWaitForSwapOutcomes(
options?: UseWaitForSwapOutcomesOptions,
): [
(
receipt: SwapReceipt,
) => ResultAsync<SwapOutcome, TimeoutError | UnexpectedError>,
{
loading: boolean;
error: TimeoutError | UnexpectedError | undefined;
},
] {
const client = useAaveClient();
const { onOutcome } = options ?? {};

const [data, setData] = useState<Map<SwapId, SwapOrderWithOutcome>>(
new Map(),
);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<
TimeoutError | UnexpectedError | undefined
>(undefined);

const pendingCountRef = useRef(0);
const activePollsRef = useRef<Map<SwapId, AbortController>>(new Map());
// Keep a ref of the latest callback to avoid stale closures
const onOutcomeRef = useRef(onOutcome);
useEffect(() => {
onOutcomeRef.current = onOutcome;
}, [onOutcome]);
// Keep a ref of the latest data to avoid stale closures
const dataRef = useRef(data);
useEffect(() => {
dataRef.current = data;
}, [data]);

const execute = useCallback(
(receipt: SwapReceipt) => {
const id = receipt.id;

// Cancel existing poll if any
const existingPoll = activePollsRef.current.get(id);
if (existingPoll) {
existingPoll.abort();
}

// Skip if already has final outcome
const existing = dataRef.current.get(id);
if (existing?.outcome) {
// Already have final outcome - notify if callback provided
if (onOutcomeRef.current) {
onOutcomeRef.current(existing.receipt, existing.outcome);
}
// Remove from tracking since we have the final outcome
setData((prev) => {
const updated = new Map(prev);
updated.delete(id);
return updated;
});
// Return the existing result
return okAsync(existing.outcome);
}

pendingCountRef.current += 1;
setLoading(true);
setError(undefined);

// Initialize the entry
setData((prev) => {
const updated = new Map(prev);
updated.set(id, {
receipt,
outcome: undefined,
});
return updated;
});

const abortController = new AbortController();
activePollsRef.current.set(id, abortController);

const result = waitForSwapOutcome(client)(receipt);

result.match(
(outcome) => {
if (abortController.signal.aborted) {
return;
}

pendingCountRef.current -= 1;

// Trigger notification callback if provided
if (onOutcomeRef.current) {
onOutcomeRef.current(receipt, outcome);
}

// Remove from tracking since we have the final outcome
setData((prev) => {
const updated = new Map(prev);
updated.delete(id);
return updated;
});

setLoading(pendingCountRef.current > 0);
activePollsRef.current.delete(id);
},
(err) => {
if (abortController.signal.aborted) {
return;
}

pendingCountRef.current -= 1;
setError(err);
setLoading(pendingCountRef.current > 0);
activePollsRef.current.delete(id);
},
);

return result;
},
[client],
);

return [execute, { loading, error }];
}
Loading