@swapper-finance/sdk
Version:
JavaScript SDK form Swapper
324 lines (295 loc) • 10.1 kB
text/typescript
/* eslint-disable @typescript-eslint/no-explicit-any */
import { useState, useCallback, useRef } from "react";
import { ethers, BigNumber } from "ethers";
import { useNavigate } from "react-router-dom";
import { Chain, Route, SwapperAppPayload, TokenPrices } from "@src/models";
import { TokenWithChain } from "@src/models";
import {
ProcessingState,
RoutePath,
HistoryEntryAction,
HistoryEntryStatus,
Balances,
BalancesSnapshotMap,
} from "@src/interfaces";
import { CASH_TOKEN_ADDRESS_BY_CHAIN } from "@src/config";
import { calculateGasCashback, weiToHumanReadable } from "@src/utils";
import { SwapMode } from "@src/interfaces";
import swapperConfig from "../../../swapper.config";
import getTokenAmountOut from "@src/utils/getTokenAmountOut";
import { UserOperationGasPrice } from "../PrivySmartWalletProvider";
export const useTransactionState = (
transactionState: ProcessingState,
setTransactionState: React.Dispatch<React.SetStateAction<ProcessingState>>,
smartWalletAddress: string | undefined,
executeTransaction: (
calls: any[],
chainId: string,
gasCostInUSDC?: string,
userOperationGasPrice?: UserOperationGasPrice,
) => Promise<string | undefined>,
addTransactionToLocalHistory: (entry: any, address: string) => void,
fetchHistory: () => void,
fetchAllBalances: () => Promise<void>,
route: Route | undefined,
token: TokenWithChain | undefined,
cashToken: TokenWithChain | undefined,
swapMode: SwapMode,
chain: Chain | undefined,
setNewPurchaseCounter: React.Dispatch<React.SetStateAction<number>>,
setBalancesSnapshots: React.Dispatch<
React.SetStateAction<BalancesSnapshotMap>
>,
tokensBalances: Record<string, Record<string, Balances>>,
tokensPrices: TokenPrices,
isExternalMode: boolean,
integrationConfig: Partial<SwapperAppPayload>,
) => {
const navigate = useNavigate();
const [cashbackAmount, setCashbackAmount] = useState<string | undefined>(
undefined,
);
const [pointsAmount, setPointsAmount] = useState<string | undefined>(
undefined,
);
const [transactionHash, setTransactionHash] = useState("");
const [tokenOutAmount, setTokenOutAmount] = useState("");
const [transactionError, setTransactionError] = useState("");
const executionIdRef = useRef(0);
const executeSwapTransaction = useCallback(async () => {
const currentExecutionId = ++executionIdRef.current;
if (!route || !token || !smartWalletAddress || !chain) {
setTransactionError("Invalid swap transaction, please try again.");
setTransactionState(ProcessingState.TransactionError);
navigate(RoutePath.Processing);
return;
}
setTransactionHash("");
setTransactionState(ProcessingState.Pending);
navigate(RoutePath.Processing);
try {
// Prepare transaction calls
const calls: {
to: `0x${string}`;
value?: bigint;
data?: `0x${string}`;
}[] = [];
if (route.transactions?.approve) {
calls.push({
to: route.transactions?.approve.to,
value: BigInt(route.transactions?.approve.value),
data: route.transactions?.approve.data,
});
}
if (route.transactions?.swap) {
calls.push({
to: route.transactions?.swap.to,
value: BigInt(route.transactions?.swap.value),
data: route.transactions?.swap.data,
});
}
// Execute the transaction
const txHash = await executeTransaction(
calls,
chain.chainId,
route?.gasCostInUSDC,
route?.userOperationGasPrice,
);
if (!txHash) {
throw new Error("Transaction failed");
}
setTransactionHash(txHash);
// Send tx to backend and process it in stats collector
const processTxPayload = {
transactionHash: txHash,
blockchainId: chain.chainId,
};
try {
const response = await fetch(`${swapperConfig.apiUrl}/process-tx`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(processTxPayload),
});
if (!response.ok) {
addTransactionToLocalHistory(
{
txHash,
action:
swapMode === SwapMode.Buy
? HistoryEntryAction.buy
: HistoryEntryAction.sell,
priceUsd: "-",
tokenAddress: token.address,
date: new Date(),
status: HistoryEntryStatus.pending,
},
smartWalletAddress,
);
console.error(`process-tx failed: ${response.statusText}`);
} else {
const { pointsForTransaction } = await response.json();
setPointsAmount(pointsForTransaction.toString());
await fetchHistory();
}
} catch (error) {
console.error("Error processing transaction:", error);
}
// Get the swap target address
const swapTargetAddress = route?.transactions?.swap?.to;
const provider = new ethers.providers.JsonRpcProvider(
chain.publicRpcUrls[0],
);
const receipt = await provider.waitForTransaction(txHash);
const receivedTokenAmount = await getTokenAmountOut(
receipt,
swapMode === SwapMode.Buy
? token.address
: CASH_TOKEN_ADDRESS_BY_CHAIN[chain.chainId],
isExternalMode
? integrationConfig.externalWalletAddress!
: smartWalletAddress,
);
if (currentExecutionId === executionIdRef.current) {
setTokenOutAmount(
weiToHumanReadable({
amount: receivedTokenAmount,
decimals:
swapMode === SwapMode.Buy
? token.decimals
: cashToken?.decimals || 6,
precisionFractionalPlaces: 5,
}),
);
}
// TODO consider moving this to backend since public rpc urls are unreliable
if (
route?.gasCostInUSDC &&
chain?.publicRpcUrls[0] &&
swapTargetAddress &&
currentExecutionId === executionIdRef.current
) {
// Get transaction receipt from hash
const gasCostInUSDC = BigNumber.from(route.gasCostInUSDC);
// Run gas cashback calculation and balance fetching in parallel
const [gasCashbackResult] = await Promise.all([
calculateGasCashback(
receipt,
gasCostInUSDC,
CASH_TOKEN_ADDRESS_BY_CHAIN[chain.chainId],
cashToken?.decimals || 6,
smartWalletAddress,
[swapTargetAddress],
),
fetchAllBalances(),
]);
if (gasCashbackResult.cashbackAmount.gt(0)) {
if (currentExecutionId === executionIdRef.current) {
setCashbackAmount(
weiToHumanReadable({
amount: gasCashbackResult.cashbackAmount.toString(),
decimals: cashToken?.decimals || 6,
precisionFractionalPlaces: 4,
}),
);
}
}
} else {
await fetchAllBalances();
}
if (currentExecutionId === executionIdRef.current) {
setTransactionState(ProcessingState.TransactionSuccess);
}
setBalancesSnapshots((prev) => {
const next = { ...prev };
// Snapshot for total cash
if (!next.totalCash) {
const totalBefore = Object.entries(tokensBalances).reduce(
(sum, [chainId, balancesMap]) => {
const cashAddr =
CASH_TOKEN_ADDRESS_BY_CHAIN[chainId]?.toLowerCase();
if (!cashAddr) return sum;
const record = balancesMap[cashAddr];
return (
sum + (record?.humanReadable ? Number(record.humanReadable) : 0)
);
},
0,
);
next.totalCash = {
initialAmount: totalBefore.toString(),
initialValue: totalBefore.toFixed(2),
};
}
// Snapshot for the specific token
const tokenAddress =
swapMode === SwapMode.Buy
? token.address.toLowerCase()
: CASH_TOKEN_ADDRESS_BY_CHAIN[chain.chainId].toLowerCase();
const key = `${chain.chainId}-${tokenAddress}`;
if (!next[key]) {
const balRecord =
tokensBalances[chain.chainId]?.[token.address.toLowerCase()];
const amountBefore = balRecord?.humanReadable || "0";
const priceBefore =
tokensPrices[chain.chainId]?.[token.address] || "0";
const valueBefore = (
Number(amountBefore) * Number(priceBefore)
).toFixed(2);
next[key] = {
initialAmount: amountBefore,
initialValue: valueBefore,
};
}
return next;
});
setNewPurchaseCounter((prev) => prev + 1);
} catch (err) {
console.error(err);
if (currentExecutionId === executionIdRef.current) {
let errorMessage = err instanceof Error ? err.message : String(err);
if (
(err as Error)?.message?.includes(
"The Provider is disconnected from all chains",
)
) {
errorMessage = "User transaction signature denied.";
}
setTransactionError(errorMessage);
setTransactionState(ProcessingState.TransactionError);
}
}
}, [
route,
token,
executeTransaction,
smartWalletAddress,
swapMode,
chain,
cashToken,
addTransactionToLocalHistory,
fetchHistory,
fetchAllBalances,
navigate,
setTransactionState,
setNewPurchaseCounter,
setBalancesSnapshots,
tokensBalances,
tokensPrices,
integrationConfig.externalWalletAddress,
isExternalMode,
]);
return {
transactionHash,
setTransactionHash,
transactionState,
setTransactionState,
tokenOutAmount,
setTokenOutAmount,
cashbackAmount,
setCashbackAmount,
pointsAmount,
setPointsAmount,
executeSwapTransaction,
transactionError,
};
};