diff --git a/src/components/dashboard-page.tsx b/src/components/dashboard-page.tsx index b646030d..1682d91e 100644 --- a/src/components/dashboard-page.tsx +++ b/src/components/dashboard-page.tsx @@ -127,34 +127,40 @@ export function DashboardPage() { const invalidatablePermitCount = invalidatablePermits.length; - const claimableTotalValue = useMemo(() => { - const assumedDecimals = 18; - let totalSumInWei = 0n; + const claimableTotalsByToken = useMemo(() => { + const totals = new Map(); for (const permit of claimablePermits) { - if (permit.amount) { - try { - totalSumInWei += permit.amount; - } catch (e) { - console.error(`Error parsing amount for claimableTotalValue calc: ${permit.amount}`, e); - } - } - } - try { - return parseFloat(formatUnits(totalSumInWei, assumedDecimals)); - } catch (e) { - console.error("Error formatting total sum:", e); - return 0; + if (!permit.amount || !permit.tokenAddress) continue; + const tokenAddress = permit.tokenAddress as Address; + const key = `${permit.networkId}:${tokenAddress.toLowerCase()}`; + const current = totals.get(key)?.total ?? 0n; + totals.set(key, { networkId: permit.networkId, tokenAddress, total: current + permit.amount }); } + return totals; }, [claimablePermits]); const estimatedTotalValueDisplay = useMemo(() => { if (!preferredRewardTokenAddress) { - return `$${claimableTotalValue.toFixed(2)}`; + if (claimableTotalsByToken.size === 1) { + const [{ networkId, tokenAddress, total }] = Array.from(claimableTotalsByToken.values()); + const tokenInfo = getTokenInfo(networkId, tokenAddress); + if (tokenInfo) { + try { + const formatted = parseFloat(formatUnits(total, tokenInfo.decimals)); + return `${formatted.toFixed(2)} ${tokenInfo.symbol}`; + } catch { + // fallthrough + } + } + } + + // Fallback when multiple tokens are claimable or token metadata is unknown. + return `${claimablePermitCount} Reward${claimablePermitCount !== 1 ? "s" : ""}`; } const preferredTokenInfo = getTokenInfo(chain?.id, preferredRewardTokenAddress); if (!preferredTokenInfo) { - return `$${claimableTotalValue.toFixed(2)} (Unknown Pref Token)`; + return `${claimablePermitCount} Reward${claimablePermitCount !== 1 ? "s" : ""} (Unknown Pref Token)`; } let totalEstimatedValueInWei = 0n; @@ -185,7 +191,7 @@ export function DashboardPage() { console.error("Error formatting estimated total value:", e); return `Error (${preferredTokenInfo.symbol})`; } - }, [claimableTotalValue, preferredRewardTokenAddress, chain?.id, permits, claimablePermits]); + }, [preferredRewardTokenAddress, chain?.id, permits, claimablePermits, claimableTotalsByToken, claimablePermitCount]); const { handleClaimPermit, handleClaimBatch, handleClaimSequential, isClaiming, sequentialClaimError, swapSubmissionStatus, walletConnectionError } = usePermitClaiming({ @@ -198,6 +204,7 @@ export function DashboardPage() { address, chain: chain ?? null, setBalancesAndAllowances, + preferredRewardTokenAddress, }); const { handleInvalidatePermit, handleInvalidatePermitsBatch, isInvalidating } = usePermitInvalidation({ diff --git a/src/constants/supported-reward-tokens.ts b/src/constants/supported-reward-tokens.ts index 697368ed..89f32ecc 100644 --- a/src/constants/supported-reward-tokens.ts +++ b/src/constants/supported-reward-tokens.ts @@ -67,3 +67,10 @@ export function getTokenInfo(chainId: number | undefined, tokenAddress: Address const tokens = getSupportedRewardTokensForChain(chainId); return tokens.find((token) => token.address.toLowerCase() === tokenAddress.toLowerCase()); } + +export function getTokenBySymbol(chainId: number | undefined, symbol: string): RewardTokenInfo | undefined { + if (!chainId) return undefined; + const normalized = symbol.trim().toLowerCase(); + const tokens = getSupportedRewardTokensForChain(chainId); + return tokens.find((token) => token.symbol.trim().toLowerCase() === normalized); +} diff --git a/src/hooks/use-permit-claiming.ts b/src/hooks/use-permit-claiming.ts index 94ef5242..5aef2502 100644 --- a/src/hooks/use-permit-claiming.ts +++ b/src/hooks/use-permit-claiming.ts @@ -1,10 +1,12 @@ // use-permit-claiming.ts: Handles single and batch permit claiming -import { Dispatch, SetStateAction, useState } from "react"; -import { Address, Chain, PublicClient, WalletClient } from "viem"; +import { Dispatch, SetStateAction, useCallback, useState } from "react"; +import { Address, Chain, PublicClient, WalletClient, erc20Abi, isAddress } from "viem"; import { NEW_PERMIT2_ADDRESS } from "../constants/config.ts"; import permit2Abi from "../fixtures/permit2-abi.ts"; import { AllowanceAndBalance, PermitData } from "../types.ts"; +import { postCowSwapOrder, getCowSwapVaultRelayerAddress } from "../utils/cowswap-utils.ts"; +import { getTokenInfo, getTokenBySymbol } from "../constants/supported-reward-tokens.ts"; if (!permit2Abi) { throw new Error("Permit2 ABI could not be loaded"); @@ -20,9 +22,14 @@ interface UsePermitClaimingProps { address: Address | undefined; chain: Chain | null; setBalancesAndAllowances: Dispatch>>; + preferredRewardTokenAddress: Address | null; } -async function simulatePermitTranferFrom(publicClient: PublicClient, address: Address, permit: PermitData) { +/** + * Simulate a single Permit2 `permitTransferFrom` call. + * Used to validate tx shape before broadcasting. + */ +async function simulatePermitTransferFrom(publicClient: PublicClient, address: Address, permit: PermitData) { return await publicClient.simulateContract({ address: permit.permit2Address, abi: permit2Abi, @@ -47,6 +54,10 @@ async function simulatePermitTranferFrom(publicClient: PublicClient, address: Ad }); } +/** + * Simulate a batch Permit2 `batchPermitTransferFrom` call. + * Used to validate tx shape before broadcasting. + */ async function simulateBatchPermitTransferFrom(publicClient: PublicClient, address: Address, permitsToClaim: PermitData[]) { return await publicClient.simulateContract({ address: NEW_PERMIT2_ADDRESS, @@ -72,8 +83,14 @@ async function simulateBatchPermitTransferFrom(publicClient: PublicClient, addre }); } +/** + * Promise-based sleep helper for retry/backoff. + */ const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); +/** + * Best-effort detection for wallet "user rejected" errors across clients/providers. + */ function isUserRejectedRequest(error: unknown): boolean { if (!error) return false; @@ -97,6 +114,9 @@ function isUserRejectedRequest(error: unknown): boolean { return /user rejected|user denied|rejected the request|denied transaction signature|request rejected|action_rejected/i.test(message); } +/** + * Persist a claim tx hash to the backend once. Returns retryability hints. + */ async function recordClaimOnce({ txHash, networkId, @@ -136,6 +156,9 @@ async function recordClaimOnce({ } } +/** + * Persist a claim tx hash with exponential backoff retries for eventual-consistency windows. + */ async function recordClaimWithRetries({ txHash, networkId, @@ -158,6 +181,10 @@ async function recordClaimWithRetries({ return { ok: false, attempts: maxAttempts, lastError: "record-claim retries exhausted" }; } +/** + * Hook that claims Permit2 permits (single, batch, sequential fallback). + * Also optionally posts CoW Swap orders after successful claim based on preferred token settings. + */ export function usePermitClaiming({ setPermits, setError, @@ -167,10 +194,166 @@ export function usePermitClaiming({ address, chain, setBalancesAndAllowances, + preferredRewardTokenAddress, }: UsePermitClaimingProps) { const [isClaiming, setIsClaiming] = useState(false); const [sequentialClaimError, setSequentialClaimError] = useState(null); - const [swapSubmissionStatus] = useState>({}); + const [swapSubmissionStatus, setSwapSubmissionStatus] = useState>({}); + + const maybeSubmitCowSwap = useCallback( + async (permitsClaimed: PermitData[]) => { + if (!preferredRewardTokenAddress) return; + if (!address || !chain || !walletClient || !publicClient) return; + + const chainId = chain.id; + const tokenOut = preferredRewardTokenAddress; + + const uusd = getTokenBySymbol(chainId, "UUSD")?.address; + if (!uusd) return; + + // Group by receiver so we never accidentally send the output to the wrong address. + const group = new Map(); + for (const permit of permitsClaimed) { + if (permit.networkId !== chainId) continue; + if (!permit.tokenAddress) continue; + const tokenIn = permit.tokenAddress as Address; + if (tokenIn.toLowerCase() !== uusd.toLowerCase()) continue; // only support UUSD settlements + if (tokenIn.toLowerCase() === tokenOut.toLowerCase()) continue; + const rawBeneficiary = (permit.beneficiary ?? "").trim(); + const receiver = rawBeneficiary && isAddress(rawBeneficiary) ? (rawBeneficiary as Address) : address; + const key = `${chainId}:${tokenIn.toLowerCase()}->${tokenOut.toLowerCase()}:${receiver.toLowerCase()}`; + const current = group.get(key)?.amountIn ?? 0n; + group.set(key, { tokenIn, receiver, amountIn: current + (permit.amount ?? 0n) }); + } + + const groups = [...group.entries()] + .map(([key, value]) => ({ key, ...value })) + .filter((g) => g.amountIn > 0n); + + // Initialize UI state. + for (const g of groups) { + setSwapSubmissionStatus((prev) => ({ ...prev, [g.key]: { status: "submitting", message: "Preparing swap order..." } })); + } + + // Best-effort allowance: approve CoW vault relayer once per (tokenIn, spender) for the total amount needed + // so multi-group submissions don't overwrite allowances and starve earlier orders. + let spender: Address; + try { + spender = getCowSwapVaultRelayerAddress(chainId); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + for (const g of groups) { + setSwapSubmissionStatus((prev) => ({ ...prev, [g.key]: { status: "error", message: `Swap failed: unsupported chain (${message})` } })); + } + return; + } + const groupsByTokenIn = new Map(); + const approvedTokenIns = new Set
(); + for (const g of groups) { + const arr = groupsByTokenIn.get(g.tokenIn) ?? []; + arr.push(g); + groupsByTokenIn.set(g.tokenIn, arr); + } + + for (const [tokenIn, tokenGroups] of groupsByTokenIn.entries()) { + const totalAmountIn = tokenGroups.reduce((sum, g) => sum + g.amountIn, 0n); + if (totalAmountIn <= 0n) continue; + + let allowance: bigint; + try { + allowance = (await publicClient.readContract({ + address: tokenIn, + abi: erc20Abi, + functionName: "allowance", + args: [address, spender], + })) as bigint; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + for (const g of tokenGroups) { + setSwapSubmissionStatus((prev) => ({ + ...prev, + [g.key]: { status: "error", message: `Swap failed: allowance check failed (${message})` }, + })); + } + continue; + } + + if (allowance >= totalAmountIn) { + approvedTokenIns.add(tokenIn); + continue; + } + + const tokenInfo = getTokenInfo(chainId, tokenIn); + for (const g of tokenGroups) { + setSwapSubmissionStatus((prev) => ({ + ...prev, + [g.key]: { status: "submitting", message: `Approving ${tokenInfo?.symbol ?? "token"} for CoW Swap...` }, + })); + } + + let approveTx: `0x${string}` | null = null; + try { + approveTx = await walletClient.writeContract({ + address: tokenIn, + abi: erc20Abi, + functionName: "approve", + // Prefer least-privilege approvals since this swap is best-effort. + args: [spender, totalAmountIn], + account: address, + chain, + }); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + for (const g of tokenGroups) { + setSwapSubmissionStatus((prev) => ({ ...prev, [g.key]: { status: "error", message: `Swap failed: Approve failed (${message})` } })); + } + continue; + } + try { + await publicClient.waitForTransactionReceipt({ hash: approveTx, timeout: 60_000 }); + approvedTokenIns.add(tokenIn); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + for (const g of tokenGroups) { + setSwapSubmissionStatus((prev) => ({ ...prev, [g.key]: { status: "error", message: `Swap failed: Approve timed out (${message})` } })); + } + } + } + + for (const { key, tokenIn, receiver, amountIn } of groups) { + if (!approvedTokenIns.has(tokenIn)) { + setSwapSubmissionStatus((prev) => { + if (prev[key]?.status === "error") return prev; // keep earlier, more descriptive message + return { ...prev, [key]: { status: "error", message: "Swap skipped: approval missing" } }; + }); + continue; + } + setSwapSubmissionStatus((prev) => ({ ...prev, [key]: { status: "submitting", message: "Signing and posting swap order..." } })); + + try { + const { orderId } = await postCowSwapOrder({ + tokenIn, + tokenOut, + amountIn, + owner: address, + receiver, + chainId, + walletClient, + }); + + setSwapSubmissionStatus((prev) => ({ ...prev, [key]: { status: "submitted", message: `Swap order posted: ${orderId}` } })); + } catch (error) { + if (isUserRejectedRequest(error)) { + setSwapSubmissionStatus((prev) => ({ ...prev, [key]: { status: "rejected", message: "Swap signing rejected by user" } })); + continue; + } + const message = error instanceof Error ? error.message : String(error); + setSwapSubmissionStatus((prev) => ({ ...prev, [key]: { status: "error", message: `Swap failed: ${message}` } })); + } + } + }, + [preferredRewardTokenAddress, address, chain, walletClient, publicClient] + ); const reduceAllowance = (permits: PermitData[]) => { setBalancesAndAllowances((prev) => { @@ -199,6 +382,9 @@ export function usePermitClaiming({ return { success: false, txHash: "" }; } + // Clear prior swap banners/status so the user sees only the current claim's swap attempts. + setSwapSubmissionStatus({}); + setPermits((prev) => prev.map((p) => (p.signature === permit.signature ? { ...p, claimStatus: "Pending" } : p))); let txHash: `0x${string}` | undefined; @@ -208,7 +394,7 @@ export function usePermitClaiming({ throw new Error("Permit2 ABI not found - cannot simulate transaction"); } - const { request } = await simulatePermitTranferFrom(publicClient, address, permit); + const { request } = await simulatePermitTransferFrom(publicClient, address, permit); console.log("Transaction simulation successful", { request }); @@ -258,6 +444,9 @@ export function usePermitClaiming({ if (!result.ok) console.warn("Failed to record claim after retries", { txHash, networkId: permit.networkId, ...result }); }); + // Best-effort: if user selected a preferred payout token, post a CoW swap order after claiming. + void maybeSubmitCowSwap([permit]).catch((err) => console.warn("CoW swap submission failed", err)); + return { success: true, txHash }; } catch (error) { if (isUserRejectedRequest(error)) { @@ -309,6 +498,7 @@ export function usePermitClaiming({ setIsClaiming(true); setSequentialClaimError(null); setError(null); + setSwapSubmissionStatus({}); const toClaim = permitsToClaim; @@ -331,85 +521,86 @@ export function usePermitClaiming({ setPermits((prev) => prev.map((p) => (toClaim.some((c) => c.signature === p.signature) ? { ...p, claimStatus: "Pending" } : p))); const successfullyClaimedPermits: PermitData[] = []; - await Promise.allSettled( - toClaim.map(async (permit) => { - let txHash: `0x${string}` | undefined; - try { - const { request } = await simulatePermitTranferFrom(publicClient, address, permit); - - console.log("Transaction simulation successful", { request }); - - // 2. Send the actual transaction - txHash = await walletClient.writeContract(request); - setPermits((prev) => prev.map((p) => (p.signature === permit.signature ? { ...p, claimStatus: "Pending", transactionHash: txHash } : p))); - updatePermitStatusCache(permit.signature, { claimStatus: "Pending", transactionHash: txHash }); - - // 3. Wait for transaction receipt - let receipt: Awaited> | null = null; - try { - receipt = await publicClient.waitForTransactionReceipt({ hash: txHash }); - console.log("Transaction completed", { receipt }); - } catch (error) { - console.warn("Receipt lookup failed after tx submission; will attempt to record via API", { error, txHash, networkId: permit.networkId }); - } - - if (!receipt) { - void recordClaimWithRetries({ txHash, networkId: permit.networkId }).then((result) => { - if (!result.ok) { - console.warn("Failed to record claim after retries", { txHash, networkId: permit.networkId, ...result }); - return; - } - - setPermits((prev) => - prev.map((p) => (p.signature === permit.signature ? { ...p, claimStatus: "Success", status: "Claimed", transactionHash: txHash } : p)) - ); - updatePermitStatusCache(permit.signature, { status: "Claimed", transactionHash: txHash }); - reduceAllowance([permit]); - }); - return; - } + for (const permit of toClaim) { + let txHash: `0x${string}` | undefined; + try { + const { request } = await simulatePermitTransferFrom(publicClient, address, permit); - if (receipt.status !== "success") throw new Error(`Transaction failed with status: ${receipt.status}`); + console.log("Transaction simulation successful", { request }); - // Update status to success - successfullyClaimedPermits.push(permit); - setPermits((prev) => - prev.map((p) => (p.signature === permit.signature ? { ...p, claimStatus: "Success", status: "Claimed", transactionHash: txHash } : p)) - ); - updatePermitStatusCache(permit.signature, { status: "Claimed", transactionHash: txHash }); + // 2. Send the actual transaction + txHash = await walletClient.writeContract(request); + setPermits((prev) => prev.map((p) => (p.signature === permit.signature ? { ...p, claimStatus: "Pending", transactionHash: txHash } : p))); + updatePermitStatusCache(permit.signature, { claimStatus: "Pending", transactionHash: txHash }); - // Record transaction in database - void recordClaimWithRetries({ txHash, networkId: permit.networkId }).then((result) => { - if (!result.ok) console.warn("Failed to record claim after retries", { txHash, networkId: permit.networkId, ...result }); - }); + // 3. Wait for transaction receipt + let receipt: Awaited> | null = null; + try { + receipt = await publicClient.waitForTransactionReceipt({ hash: txHash }); + console.log("Transaction completed", { receipt }); } catch (error) { - if (isUserRejectedRequest(error) && !txHash) { - setPermits((prev) => prev.map((p) => (p.signature === permit.signature ? { ...p, claimStatus: "Idle" } : p))); - return; - } + console.warn("Receipt lookup failed after tx submission; will attempt to record via API", { error, txHash, networkId: permit.networkId }); + } - const errorMessage = error instanceof Error ? error.message : String(error); - console.error("Sequential claim processing error", { error }); - if (txHash) { - if (errorMessage.includes("Transaction failed with status")) { - setPermits((prev) => prev.map((p) => (p.signature === permit.signature ? { ...p, claimStatus: "Error", transactionHash: txHash } : p))); + if (!receipt) { + void recordClaimWithRetries({ txHash, networkId: permit.networkId }).then((result) => { + if (!result.ok) { + console.warn("Failed to record claim after retries", { txHash, networkId: permit.networkId, ...result }); return; } - setPermits((prev) => prev.map((p) => (p.signature === permit.signature ? { ...p, claimStatus: "Pending", transactionHash: txHash } : p))); - void recordClaimWithRetries({ txHash, networkId: permit.networkId }).then((result) => { - if (!result.ok) console.warn("Failed to record claim after retries", { txHash, networkId: permit.networkId, ...result }); - }); - return; + setPermits((prev) => + prev.map((p) => (p.signature === permit.signature ? { ...p, claimStatus: "Success", status: "Claimed", transactionHash: txHash } : p)) + ); + updatePermitStatusCache(permit.signature, { status: "Claimed", transactionHash: txHash }); + reduceAllowance([permit]); + }); + continue; + } + + if (receipt.status !== "success") throw new Error(`Transaction failed with status: ${receipt.status}`); + + // Update status to success + successfullyClaimedPermits.push(permit); + setPermits((prev) => + prev.map((p) => (p.signature === permit.signature ? { ...p, claimStatus: "Success", status: "Claimed", transactionHash: txHash } : p)) + ); + updatePermitStatusCache(permit.signature, { status: "Claimed", transactionHash: txHash }); + + // Record transaction in database + void recordClaimWithRetries({ txHash, networkId: permit.networkId }).then((result) => { + if (!result.ok) console.warn("Failed to record claim after retries", { txHash, networkId: permit.networkId, ...result }); + }); + } catch (error) { + if (isUserRejectedRequest(error) && !txHash) { + setPermits((prev) => prev.map((p) => (p.signature === permit.signature ? { ...p, claimStatus: "Idle" } : p))); + continue; + } + + const errorMessage = error instanceof Error ? error.message : String(error); + console.error("Sequential claim processing error", { error }); + if (txHash) { + if (errorMessage.includes("Transaction failed with status")) { + setPermits((prev) => prev.map((p) => (p.signature === permit.signature ? { ...p, claimStatus: "Error", transactionHash: txHash } : p))); + continue; } - setPermits((prev) => prev.map((p) => (p.signature === permit.signature ? { ...p, claimStatus: "Error" } : p))); + setPermits((prev) => prev.map((p) => (p.signature === permit.signature ? { ...p, claimStatus: "Pending", transactionHash: txHash } : p))); + void recordClaimWithRetries({ txHash, networkId: permit.networkId }).then((result) => { + if (!result.ok) console.warn("Failed to record claim after retries", { txHash, networkId: permit.networkId, ...result }); + }); + continue; } - }) - ); + + setPermits((prev) => prev.map((p) => (p.signature === permit.signature ? { ...p, claimStatus: "Error" } : p))); + } + } reduceAllowance(successfullyClaimedPermits); setIsClaiming(false); console.log("Sequential claim completed successfully"); + + // Best-effort: post swap orders for aggregated claimed amounts. + void maybeSubmitCowSwap(successfullyClaimedPermits).catch((err) => console.warn("CoW swap submission failed", err)); }; const handleClaimBatch = async (permitsToClaim: PermitData[]) => { @@ -424,6 +615,7 @@ export function usePermitClaiming({ setIsClaiming(true); setSequentialClaimError(null); setError(null); + setSwapSubmissionStatus({}); if (!permitsToClaim.length) { console.warn("Batch RPC: No claimable permits found"); @@ -506,6 +698,9 @@ export function usePermitClaiming({ updatePermitStatusCache(permit.signature, { status: "Claimed", transactionHash: String(txHash) }); }); + // Best-effort: post swap orders for aggregated claimed amounts. + void maybeSubmitCowSwap(permitsToClaim).catch((err) => console.warn("CoW swap submission failed", err)); + if (!batchNetworkId) { console.error("Batch claim expects all permits to share the same networkId"); } else { diff --git a/src/hooks/use-permit-data.ts b/src/hooks/use-permit-data.ts index 3543e765..8963eae1 100644 --- a/src/hooks/use-permit-data.ts +++ b/src/hooks/use-permit-data.ts @@ -5,6 +5,7 @@ import { getCowSwapQuote } from "../utils/cowswap-utils.ts"; import { applyPermitStatusOverrides, loadPermitStatusCache, upsertPermitStatusOverride } from "../utils/permit-status-cache.ts"; import type { WorkerResponse } from "../workers/permit-checker.worker.ts"; import { getPermitCheckerWorker, type PermitCheckerWorker } from "../workers/permit-worker-client.ts"; +import { getTokenInfo } from "../constants/supported-reward-tokens.ts"; interface UsePermitDataProps { address: Address | undefined; @@ -13,6 +14,10 @@ interface UsePermitDataProps { chainId: number | undefined; } +/** + * Hook that loads and filters permits, tracks balances/allowances, and optionally fetches CoW quotes + * (UUSD-only) for a preferred reward token on the connected chain. + */ export function usePermitData({ address, isConnected, preferredRewardTokenAddress, chainId }: UsePermitDataProps) { const [permits, setPermits] = useState([]); const [balancesAndAllowances, setBalancesAndAllowances] = useState>(new Map()); @@ -64,7 +69,11 @@ export function usePermitData({ address, isConnected, preferredRewardTokenAddres const byToken = new Map(); updated.forEach((permit) => { if ( + // Quotes are chain-specific. Only quote permits on the currently connected chain. + permit.networkId === chainId && permit.tokenAddress && + // Only quote UUSD permits (settlement token). Other tokens should display as-is. + getTokenInfo(chainId, permit.tokenAddress as Address)?.symbol.toUpperCase() === "UUSD" && permit.type === "erc20-permit" && permit.status !== "Claimed" && permit.claimStatus !== "Success" && @@ -73,6 +82,10 @@ export function usePermitData({ address, isConnected, preferredRewardTokenAddres const group = byToken.get(permit.tokenAddress as Address) || []; group.push(permit); byToken.set(permit.tokenAddress as Address, group); + } else { + // Clear stale quote data if permit isn't quoteable in the current context. + delete permit.estimatedAmountOut; + delete permit.quoteError; } }); for (const [tokenIn, group] of byToken.entries()) { diff --git a/src/utils/cowswap-utils.ts b/src/utils/cowswap-utils.ts index 87ee3f32..3929da68 100644 --- a/src/utils/cowswap-utils.ts +++ b/src/utils/cowswap-utils.ts @@ -1,5 +1,17 @@ import type { Address } from "viem"; +import type { WalletClient } from "viem"; +import { + COW_PROTOCOL_VAULT_RELAYER_ADDRESS, + OrderBookApi, + OrderQuoteSideKindSell, + OrderSigningUtils, + SigningScheme, + SupportedChainId, + buildAppData, + getQuoteAmountsAndCosts, +} from "@cowprotocol/cow-sdk"; import type { QuoteAmountsAndCosts } from "@cowprotocol/cow-sdk"; +import { COWSWAP_PARTNER_FEE_BPS, COWSWAP_PARTNER_FEE_RECIPIENT } from "../constants/config.ts"; import { getTokenInfo } from "../constants/supported-reward-tokens.ts"; interface CowSwapQuoteParams { @@ -16,6 +28,98 @@ interface CowSwapQuoteResult { amountsAndCosts: QuoteAmountsAndCosts; } +interface CowSwapOrderParams { + tokenIn: Address; + tokenOut: Address; + amountIn: bigint; + owner: Address; + receiver: Address; + chainId: number; + walletClient: WalletClient; +} + +const DEFAULT_SLIPPAGE_BPS = 50; // 0.5% + +function buildCowQuoteRequest({ + tokenIn, + tokenOut, + from, + receiver, + amountIn, + appDataInfo, +}: { + tokenIn: Address; + tokenOut: Address; + from: Address; + receiver: Address; + amountIn: bigint; + appDataInfo: { fullAppData: string; appDataKeccak256: string }; +}) { + return { + sellToken: tokenIn, + buyToken: tokenOut, + from, + receiver, + sellAmountBeforeFee: amountIn.toString(), + kind: OrderQuoteSideKindSell.SELL, + appData: appDataInfo.fullAppData, + appDataHash: appDataInfo.appDataKeccak256, + }; +} + +function assertQuoteShape(quote: Record) { + // Runtime guard before using the quote in an EIP-712 signature. + // Keep in sync with `OrderSigningUtils.getEIP712Types().Order`. + const eip712Types = OrderSigningUtils.getEIP712Types() as unknown as { Order: Array<{ name: string; type: string }> }; + const required = eip712Types.Order.map((t) => t.name); + for (const key of required) { + if (!(key in quote)) throw new Error(`CoW quote missing required field: ${key}`); + } +} + +/** + * CoW SDK expects a specific chain id enum type. Validate at runtime to avoid silently targeting the wrong endpoint. + */ +function asSupportedChainId(chainId: number): SupportedChainId { + const supported = Object.values(SupportedChainId).filter((v): v is number => typeof v === "number"); + if (!supported.includes(chainId)) { + throw new Error(`Unsupported CoW Protocol chainId: ${chainId}`); + } + return chainId as SupportedChainId; +} + +/** + * Returns CoW Protocol vault relayer address for a given chain. + * This is the spender that must be approved for ERC20 sell tokens. + */ +export function getCowSwapVaultRelayerAddress(chainId: number): Address { + const addr = (COW_PROTOCOL_VAULT_RELAYER_ADDRESS as Record)[chainId]; + if (!addr) throw new Error(`Unsupported chainId for CoW vault relayer: ${chainId}`); + return addr; +} + +/** + * Returns partner fee bps for a given chain and output token (if applicable). + * Partner fee is disabled for UUSD output to avoid reducing the settlement token. + */ +function getPartnerFeeBps(chainId: number, tokenOut: Address): number | undefined { + const info = getTokenInfo(chainId, tokenOut); + if (!info) return undefined; + // Apply partner fee to all swaps where the output token is NOT UUSD. + return info.symbol.toUpperCase() === "UUSD" ? undefined : COWSWAP_PARTNER_FEE_BPS; +} + +async function buildCowAppDataInfo(partnerFeeBps: number | undefined) { + return await buildAppData({ + slippageBps: DEFAULT_SLIPPAGE_BPS, + appCode: "pay.ubq.fi", + orderClass: "market", + ...(partnerFeeBps !== undefined + ? { partnerFee: { bps: partnerFeeBps, recipient: COWSWAP_PARTNER_FEE_RECIPIENT } } + : {}), + }); +} + /** * Fetches a quote from the CowSwap API for a potential swap. * Does not require signing or submit an order. @@ -32,16 +136,110 @@ export async function getCowSwapQuote(params: CowSwapQuoteParams): Promise `tokenOut`. + * + * Notes: + * - This only posts the order; settlement depends on liquidity and the owner's token allowance to the CoW vault relayer. + * - Caller should ensure allowance is sufficient before calling, otherwise the quote/order may fail or remain unfillable. + */ +export async function postCowSwapOrder(params: CowSwapOrderParams): Promise<{ orderId: string }> { + const tokenInInfo = getTokenInfo(params.chainId, params.tokenIn); + const tokenOutInfo = getTokenInfo(params.chainId, params.tokenOut); + + if (!tokenInInfo || !tokenOutInfo) { + throw new Error(`Cannot find token info for ${params.tokenIn} or ${params.tokenOut} on chain ${params.chainId}`); + } + + const partnerFeeBps = getPartnerFeeBps(params.chainId, params.tokenOut); + const appDataInfo = await buildCowAppDataInfo(partnerFeeBps); + + const chainId = asSupportedChainId(params.chainId); + const orderBookApi = new OrderBookApi({ chainId }); + const quoteResponse = await orderBookApi.getQuote( + buildCowQuoteRequest({ + tokenIn: params.tokenIn, + tokenOut: params.tokenOut, + from: params.owner, + receiver: params.receiver, + amountIn: params.amountIn, + appDataInfo, + }) + ); + + const rawDomain = await OrderSigningUtils.getDomain(chainId); + const domain = { + name: rawDomain.name, + version: rawDomain.version, + chainId, + verifyingContract: rawDomain.verifyingContract as Address, + }; + const types = OrderSigningUtils.getEIP712Types() as unknown as Record>; + + const quote = quoteResponse.quote as unknown as Record; + assertQuoteShape(quote); + + // Build the message explicitly from the EIP-712 Order fields to avoid leaking extra fields into the signature. + const orderFields = (types.Order ?? []).map((t) => t.name); + const message = Object.fromEntries(orderFields.map((name) => [name, quote[name]])) as Record; + const signature = await params.walletClient.signTypedData({ + account: params.owner, + domain, + primaryType: "Order", + types, + message, + }); + + // Send order with explicit fields to avoid passing unexpected keys to the API. + type SendOrderParams = Parameters[0]; + + // If we provide appDataHash alongside appData, CoW requires appData to be the JSON string. + // Ensure the hash we built from appDataInfo matches the hash that we signed over. + const signedAppData = (message as Record)["appData"]; + if (typeof signedAppData === "string" && signedAppData.toLowerCase() !== appDataInfo.appDataKeccak256.toLowerCase()) { + throw new Error("CoW appData hash mismatch between quote/signature and appDataInfo"); + } + + const orderId = await orderBookApi.sendOrder({ + ...message, + from: params.owner, + quoteId: quoteResponse.id ?? null, + signature, + signingScheme: SigningScheme.EIP712, + appData: appDataInfo.fullAppData, + appDataHash: appDataInfo.appDataKeccak256, + } as unknown as SendOrderParams); + + return { orderId }; +}