diff --git a/AGENTS.md b/AGENTS.md index 2a68149e..4e762b8c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -142,6 +142,11 @@ When touching transaction and position flows, validation MUST include all releva 7. **UI clarity and duplication checks**: remove duplicate/redundant/low-signal data and keep only decision-critical information. 8. **Null/data-corruption resilience**: guard null/undefined/stale API/contract fields so malformed data fails gracefully. 9. **Runtime guards on optional config/routes**: avoid unsafe non-null assertions in tx-critical paths; unsupported routes/config must degrade gracefully. +10. **Bundler authorization chokepoint**: every Morpho bundler transaction path (supply, borrow, repay, rebalance, leverage/deleverage) must route through `useBundlerAuthorizationStep` rather than implementing ad hoc authorization logic per hook. +11. **Locale-safe decimal inputs**: transaction-critical amount/slippage inputs must accept both `,` and `.`, preserve transient edit states (e.g. `''`, `.`) during typing, and only normalize/clamp on commit (`blur`/submit) so delete-and-retype flows never lock users into stale values. +12. **Aggregator API contract integrity**: quote-only request params must never be forwarded to transaction-build endpoints (e.g. Velora `version` on `/prices` but not `/transactions/:network`); enforce endpoint-specific payload/query builders, normalize fetch/network failures into typed API errors, and verify returned route token addresses match requested canonical token addresses before using previews/tx payloads. +13. **User-rejection error normalization**: transaction hooks must map wallet rejection payloads (EIP-1193 `4001`, `ACTION_REJECTED`, viem request-argument dumps) to a short canonical UI message (`User rejected transaction.`) and never render raw payload text in inline UI/error boxes. +14. **Input/state integrity in tx-critical UIs**: never strip unsupported numeric syntax into a different value (e.g. `1e-6` must be rejected, not rewritten), and after any balance refetch re-derive selected token objects from refreshed data before allowing `Max`/submit. ### REQUIRED: Regression Rule Capture diff --git a/docs/BUNDLER_STRATEGY.md b/docs/BUNDLER_STRATEGY.md new file mode 100644 index 00000000..e3caf96d --- /dev/null +++ b/docs/BUNDLER_STRATEGY.md @@ -0,0 +1,59 @@ +# Bundler Strategy (V2 + V3) + +Last updated: February 27, 2026 + +## Goal + +Keep stable V2 transaction paths for existing product behavior while introducing a separate V3 path only where swaps are required. + +## Current Production Split + +### Bundler V2 (active) + +Use Bundler V2 for: + +- Multi-supply and direct supply +- Borrow +- Repay +- Rebalance +- Existing deterministic leverage/deleverage flow (ERC4626-only route) + +Implementation rule: + +- Any V2 Morpho transaction path must use `src/hooks/useBundlerAuthorizationStep.ts`. + +## Planned Bundler V3 Scope + +Use Bundler V3 only for swap-dependent features: + +- `rebalanceWithSwap` +- Generalized `useLeverage` route (any pair, not only ERC4626 deterministic route) + +Do not migrate all legacy V2 hooks at once. Keep both tracks parallel so current users are not forced into new contract-approval risk. + +## Bundler V3 Architecture Notes + +Bundler V3 is adapter-driven and supports composing actions (including swaps) through dedicated adapter contracts and callbacks. This is structurally different from the narrower direct-action shape used in current V2 hooks. + +For Monarch, this implies: + +- Keep swap quote/execution logic isolated from V2 hooks. +- Introduce V3-specific builders/hooks instead of overloading existing V2 hooks. +- Add route guards for unsupported adapter paths and degrade gracefully. +- Reuse shared Velora API chokepoints from `src/features/swap/api/velora.ts` (quote + tx payload preparation) in future V3 bundler flows. + +## Historical Approval Incident (April 2025) + +Morpho published a security notice on April 10, 2025 regarding approvals to Bundler3 contracts. The guidance was to revoke approvals to affected Bundler3 addresses on specific networks. + +Engineering implications: + +- Never assume perpetual approvals are harmless for adapter-capable contracts. +- Default to explicit, minimal-privilege approvals and clear spender visibility in UI. +- Keep V3 rollout isolated and auditable before broadening to all transaction paths. + +## Migration Sequence + +1. Swap-first (now): use Velora quote + transaction payload execution in standalone swap flow. +2. Add V3 for swap-dependent product features only (`rebalanceWithSwap`, generalized leverage). +3. Keep V2 as default for non-swap flows until V3 parity and risk posture are validated. diff --git a/docs/TECHNICAL_OVERVIEW.md b/docs/TECHNICAL_OVERVIEW.md index e3d035bb..e120664c 100644 --- a/docs/TECHNICAL_OVERVIEW.md +++ b/docs/TECHNICAL_OVERVIEW.md @@ -22,7 +22,7 @@ Monarch is a client-side DeFi dashboard for the Morpho Blue lending protocol. It | Viem | 2.40.2 | Ethereum utilities | | @reown/appkit | 1.8.14 | Wallet connection (WalletConnect v3) | | @morpho-org/blue-sdk | 5.3.0 | Morpho Blue protocol SDK | -| @cowprotocol/cow-sdk | 7.2.9 | Intent-based swaps | +| Velora (ParaSwap) API | HTTP | Same-chain quote + transaction payloads for swaps | **Wagmi v3 integration notes:** - Prefer `useConnection()` when you need wallet state (`address`, `chainId`, `isConnected`) in one place. @@ -126,6 +126,26 @@ MorphoChainlinkOracleData { --- +## Transaction Architecture + +### Bundler Responsibility Matrix + +| Path | Bundler | Notes | +|------|---------|-------| +| Multi-supply / direct supply / borrow / repay / rebalance | Bundler V2 | Current production path | +| Leverage/deleverage (current deterministic ERC4626 route) | Bundler V2 | Existing route remains unchanged | +| Generic swap-only modal | No bundler (Velora direct tx) | Quote + tx payload from Velora API | +| Planned: `rebalanceWithSwap`, generalized `useLeverage` (any pair) | Bundler V3 | Planned migration path for swap-dependent actions | + +### Authorization Model + +- Any flow that sends Morpho actions through Bundler V2 must pass through `useBundlerAuthorizationStep`. +- Signature mode is used for Permit2 and native-token flows. +- Transaction mode is used for standard ERC20 approval flow when signature mode is not selected. +- See [`BUNDLER_STRATEGY.md`](/Users/antonasso/programming/morpho/monarch/docs/BUNDLER_STRATEGY.md) for migration rules and security guardrails. + +--- + ## Data Sources ### Dual-Source Strategy @@ -304,6 +324,7 @@ Fallback Strategy: | Morpho API | `https://blue-api.morpho.org/graphql` | Markets, vaults, positions | | The Graph | Per-chain subgraph URLs | Fallback data, suppliers, borrowers | | Merkl API | `https://api.merkl.xyz` | Reward campaigns | +| Velora API | `https://api.paraswap.io` | Swap quotes and executable tx payloads | | Alchemy | Per-chain RPC | Default RPC provider | ### Smart Contracts @@ -336,3 +357,4 @@ Fallback Strategy: | All Stores | `/src/stores/` | | All Query Hooks | `/src/hooks/queries/` | | Vault Storage | `/src/utils/vault-storage.ts` | +| Bundler Migration Notes | `/docs/BUNDLER_STRATEGY.md` | diff --git a/package.json b/package.json index 31e81abd..384d885a 100644 --- a/package.json +++ b/package.json @@ -17,9 +17,6 @@ "typecheck": "tsc --noEmit" }, "dependencies": { - "@cowprotocol/cow-sdk": "^7.2.9", - "@cowprotocol/sdk-bridging": "^1.2.0", - "@cowprotocol/sdk-viem-adapter": "^0.3.0", "@heroicons/react": "^2.2.0", "@internationalized/date": "^3.8.2", "@merkl/api": "^1.7.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2144c5ed..5d7a73e6 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8,15 +8,6 @@ importers: .: dependencies: - '@cowprotocol/cow-sdk': - specifier: ^7.2.9 - version: 7.2.9(ajv@8.17.1)(cross-fetch@4.1.0)(ipfs-only-hash@4.0.0)(multiformats@9.9.0) - '@cowprotocol/sdk-bridging': - specifier: ^1.2.0 - version: 1.2.0(ajv@8.17.1)(cross-fetch@4.1.0)(ipfs-only-hash@4.0.0)(multiformats@9.9.0) - '@cowprotocol/sdk-viem-adapter': - specifier: ^0.3.0 - version: 0.3.0(viem@2.40.2(bufferutil@4.0.9)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@3.25.76)) '@heroicons/react': specifier: ^2.2.0 version: 2.2.0(react@18.3.1) @@ -910,61 +901,6 @@ packages: '@coinbase/wallet-sdk@4.3.7': resolution: {integrity: sha512-z6e5XDw6EF06RqkeyEa+qD0dZ2ZbLci99vx3zwDY//XO8X7166tqKJrR2XlQnzVmtcUuJtCd5fCvr9Cu6zzX7w==} - '@cowprotocol/cow-sdk@7.2.9': - resolution: {integrity: sha512-rAy7cG2xz+1z/jUU6avKNmViJWNE//JYxUojPz44kzwkMlrfistFHqO3J6CWyzrfoKdLcN1ZI5PiBX+sgxb1og==} - peerDependencies: - '@openzeppelin/merkle-tree': ^1.x - cross-fetch: ^3.x - ipfs-only-hash: ^4.x - multiformats: ^9.x - peerDependenciesMeta: - '@openzeppelin/merkle-tree': - optional: true - ipfs-only-hash: - optional: true - multiformats: - optional: true - - '@cowprotocol/sdk-app-data@4.5.0': - resolution: {integrity: sha512-+MDjZei/Seb724fyU6UgIDkXfAD9DDVMH1PmgQc8W/Z4Cq4k3PPL8abYBMl2IWP8RvdipMxUnDdAx8rlQp0FXw==} - peerDependencies: - ajv: ^8.x - cross-fetch: ^3.x - ipfs-only-hash: ^4.x - multiformats: ^9.x - - '@cowprotocol/sdk-bridging@1.2.0': - resolution: {integrity: sha512-tt2MOEIO2+Wg/u0vEgAkKpp130yI/vpAkESsp1mWBEndtWsXjrK4/2UloKtM8wW6+47zYZHTRfIrhGIF5YtfRg==} - - '@cowprotocol/sdk-common@0.4.0': - resolution: {integrity: sha512-ciXiHzTzj7LKZqMKssgyNooZp1nS/mvRE9oO/6DlMQcxVA7/4ajPmk/XzseX/rjdRbSbYyXIEF1oY5w0tcbVjQ==} - - '@cowprotocol/sdk-config@0.6.2': - resolution: {integrity: sha512-L6cPT3pQCrHChRUjWffuYielUlw7TXPbI0o3IvimF/DSpPiGuW+A1g7XSLEGUhNcoKNfQf5YUZPaoenAKl9MNg==} - - '@cowprotocol/sdk-contracts-ts@1.0.0': - resolution: {integrity: sha512-i5clnrOZlixHiWUGrSJXlckx5gHXnQu7hcwiL4ut9PpwmqBpFXitS5mYbFr8Py5UAJZ86QHmiXUodL/392QAAA==} - - '@cowprotocol/sdk-cow-shed@0.2.9': - resolution: {integrity: sha512-nv+/ALXU0ur3NSKmislq0e+2zl2XCyXqylJil7XfXlbcrzn7AaF1SkTJHgmwZ3P3R4nCYGS/nWfYkgRyBdoaug==} - - '@cowprotocol/sdk-order-book@0.5.0': - resolution: {integrity: sha512-teFVH9FILYGPOpj5v539ogeqAk97kxMxeIG93Dj5G7Qfp+V97/gOKcE8/8Rw0quKt2KQYtyUspspQFhYF4g6SQ==} - - '@cowprotocol/sdk-order-signing@0.1.23': - resolution: {integrity: sha512-TnmGElnPlaUvp5GV+aaJrwXY/csyJZWETeIg56AxBj5xKXC555uamOVCTFO4rM9sHfCVLwS71JZR3WrQcOgfkw==} - - '@cowprotocol/sdk-trading@0.8.0': - resolution: {integrity: sha512-aNxnUBkbxXEkcQyCiL+WZQAFptORtOmQFH7cZq+dDNgOzFIy7xErtQ7PVnDZP497We3+EcJVq50Q7D6b6Gc+BA==} - - '@cowprotocol/sdk-viem-adapter@0.3.0': - resolution: {integrity: sha512-IDCrdSBKaLJsgJR8kPFb5wjIt8Hk3dw4hqZg7nIkHAz36N+feHA2DYucgGOC0r1iuAy0EC+F/XXiemqGMb+nXg==} - peerDependencies: - viem: ^2.28.4 - - '@cowprotocol/sdk-weiroll@0.1.11': - resolution: {integrity: sha512-l1lNaN46VnQrPsCqLPiD4yxOSBhRUDT1Am8lWsUN8U6xD8NPdTJWE/UbpkrHxBd0nvpW246zWlLm31FJcz5XXA==} - '@csstools/css-parser-algorithms@3.0.5': resolution: {integrity: sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ==} engines: {node: '>=18'} @@ -7669,115 +7605,6 @@ snapshots: - zod optional: true - '@cowprotocol/cow-sdk@7.2.9(ajv@8.17.1)(cross-fetch@4.1.0)(ipfs-only-hash@4.0.0)(multiformats@9.9.0)': - dependencies: - '@cowprotocol/sdk-app-data': 4.5.0(ajv@8.17.1)(cross-fetch@4.1.0)(ipfs-only-hash@4.0.0)(multiformats@9.9.0) - '@cowprotocol/sdk-common': 0.4.0 - '@cowprotocol/sdk-config': 0.6.2 - '@cowprotocol/sdk-contracts-ts': 1.0.0 - '@cowprotocol/sdk-order-book': 0.5.0 - '@cowprotocol/sdk-order-signing': 0.1.23 - '@cowprotocol/sdk-trading': 0.8.0(ajv@8.17.1)(cross-fetch@4.1.0)(ipfs-only-hash@4.0.0)(multiformats@9.9.0) - cross-fetch: 4.1.0 - optionalDependencies: - ipfs-only-hash: 4.0.0 - multiformats: 9.9.0 - transitivePeerDependencies: - - ajv - - encoding - - '@cowprotocol/sdk-app-data@4.5.0(ajv@8.17.1)(cross-fetch@4.1.0)(ipfs-only-hash@4.0.0)(multiformats@9.9.0)': - dependencies: - '@cowprotocol/sdk-common': 0.4.0 - ajv: 8.17.1 - cross-fetch: 4.1.0 - ipfs-only-hash: 4.0.0 - json-stringify-deterministic: 1.0.12 - multiformats: 9.9.0 - - '@cowprotocol/sdk-bridging@1.2.0(ajv@8.17.1)(cross-fetch@4.1.0)(ipfs-only-hash@4.0.0)(multiformats@9.9.0)': - dependencies: - '@cowprotocol/sdk-app-data': 4.5.0(ajv@8.17.1)(cross-fetch@4.1.0)(ipfs-only-hash@4.0.0)(multiformats@9.9.0) - '@cowprotocol/sdk-common': 0.4.0 - '@cowprotocol/sdk-config': 0.6.2 - '@cowprotocol/sdk-contracts-ts': 1.0.0 - '@cowprotocol/sdk-cow-shed': 0.2.9 - '@cowprotocol/sdk-order-book': 0.5.0 - '@cowprotocol/sdk-trading': 0.8.0(ajv@8.17.1)(cross-fetch@4.1.0)(ipfs-only-hash@4.0.0)(multiformats@9.9.0) - '@cowprotocol/sdk-weiroll': 0.1.11 - '@defuse-protocol/one-click-sdk-typescript': 0.1.1-0.2 - json-stable-stringify: 1.3.0 - transitivePeerDependencies: - - ajv - - cross-fetch - - debug - - encoding - - ipfs-only-hash - - multiformats - - '@cowprotocol/sdk-common@0.4.0': {} - - '@cowprotocol/sdk-config@0.6.2': - dependencies: - exponential-backoff: 3.1.3 - limiter: 2.1.0 - - '@cowprotocol/sdk-contracts-ts@1.0.0': - dependencies: - '@cowprotocol/sdk-common': 0.4.0 - '@cowprotocol/sdk-config': 0.6.2 - - '@cowprotocol/sdk-cow-shed@0.2.9': - dependencies: - '@cowprotocol/sdk-common': 0.4.0 - '@cowprotocol/sdk-config': 0.6.2 - '@cowprotocol/sdk-contracts-ts': 1.0.0 - - '@cowprotocol/sdk-order-book@0.5.0': - dependencies: - '@cowprotocol/sdk-common': 0.4.0 - '@cowprotocol/sdk-config': 0.6.2 - cross-fetch: 3.2.0 - exponential-backoff: 3.1.3 - limiter: 3.0.0 - transitivePeerDependencies: - - encoding - - '@cowprotocol/sdk-order-signing@0.1.23': - dependencies: - '@cowprotocol/sdk-common': 0.4.0 - '@cowprotocol/sdk-config': 0.6.2 - '@cowprotocol/sdk-contracts-ts': 1.0.0 - '@cowprotocol/sdk-order-book': 0.5.0 - transitivePeerDependencies: - - encoding - - '@cowprotocol/sdk-trading@0.8.0(ajv@8.17.1)(cross-fetch@4.1.0)(ipfs-only-hash@4.0.0)(multiformats@9.9.0)': - dependencies: - '@cowprotocol/sdk-app-data': 4.5.0(ajv@8.17.1)(cross-fetch@4.1.0)(ipfs-only-hash@4.0.0)(multiformats@9.9.0) - '@cowprotocol/sdk-common': 0.4.0 - '@cowprotocol/sdk-config': 0.6.2 - '@cowprotocol/sdk-contracts-ts': 1.0.0 - '@cowprotocol/sdk-order-book': 0.5.0 - '@cowprotocol/sdk-order-signing': 0.1.23 - deepmerge: 4.3.1 - transitivePeerDependencies: - - ajv - - cross-fetch - - encoding - - ipfs-only-hash - - multiformats - - '@cowprotocol/sdk-viem-adapter@0.3.0(viem@2.40.2(bufferutil@4.0.9)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@3.25.76))': - dependencies: - '@cowprotocol/sdk-common': 0.4.0 - viem: 2.40.2(bufferutil@4.0.9)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@3.25.76) - - '@cowprotocol/sdk-weiroll@0.1.11': - dependencies: - '@cowprotocol/sdk-common': 0.4.0 - '@cowprotocol/sdk-config': 0.6.2 - '@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4)': dependencies: '@csstools/css-tokenizer': 3.0.4 diff --git a/public/imgs/protocols/cow.svg b/public/imgs/protocols/cow.svg deleted file mode 100644 index 7e9ab1a9..00000000 --- a/public/imgs/protocols/cow.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - - diff --git a/src/components/Input/Input.tsx b/src/components/Input/Input.tsx index e2e3c095..081cbfe2 100644 --- a/src/components/Input/Input.tsx +++ b/src/components/Input/Input.tsx @@ -2,6 +2,7 @@ import { useCallback, useState, useEffect } from 'react'; import { parseUnits } from 'viem'; import { formatBalance } from '@/utils/balance'; +import { isValidDecimalInput, sanitizeDecimalInput, toParseableDecimalInput } from '@/utils/decimal-input'; import { Button } from '@/components/ui/button'; type InputProps = { @@ -46,11 +47,21 @@ export default function Input({ const onInputChange = useCallback( (e: React.ChangeEvent) => { // update the shown input text regardless - const inputText = e.target.value; - setInputAmount(inputText); + const normalizedInput = sanitizeDecimalInput(e.target.value); + if (!isValidDecimalInput(normalizedInput)) { + return; + } + setInputAmount(normalizedInput); + + const parseableInput = toParseableDecimalInput(normalizedInput); + if (!parseableInput) { + setValue(BigInt(0)); + if (setError) setError(null); + return; + } try { - const inputBigInt = parseUnits(inputText, decimals); + const inputBigInt = parseUnits(parseableInput, decimals); if (max !== undefined && inputBigInt > max && !bypassMax) { if (setError) setError(exceedMaxErrMessage ?? 'Input exceeds max'); @@ -62,9 +73,8 @@ export default function Input({ setValue(inputBigInt); if (setError) setError(null); - } catch (err) { + } catch { if (setError) setError('Invalid input'); - console.log('e', err); } }, [decimals, setError, setInputAmount, setValue, max, exceedMaxErrMessage, allowExceedMax, bypassMax], @@ -74,8 +84,15 @@ export default function Input({ const handleDismissError = useCallback(() => { setBypassMax(true); if (setError) setError(null); + + const parseableInput = toParseableDecimalInput(inputAmount); + if (!parseableInput) { + setValue(BigInt(0)); + return; + } + try { - const inputBigInt = parseUnits(inputAmount, decimals); + const inputBigInt = parseUnits(parseableInput, decimals); setValue(inputBigInt); } catch { // Invalid input, ignore @@ -96,7 +113,9 @@ export default function Input({
- {isSwitching ? ( -
- - Switching... -
- ) : ( - (switchChainText ?? defaultSwitchText) - )} + {switchChainText ?? defaultSwitchText} ); } diff --git a/src/components/ui/button.tsx b/src/components/ui/button.tsx index 9ad1ff6f..4bee92c3 100644 --- a/src/components/ui/button.tsx +++ b/src/components/ui/button.tsx @@ -3,9 +3,10 @@ import { Slot } from '@radix-ui/react-slot'; import { cva, type VariantProps } from 'class-variance-authority'; import { cn } from '@/utils/index'; +import { Spinner } from './spinner'; const buttonVariants = cva( - 'inline-flex items-center justify-center gap-2 whitespace-nowrap text-sm font-medium transition-all duration-200 ease-in-out border-0 outline-0 ring-0 focus:border-0 focus:outline-0 focus:ring-0 active:border-0 active:outline-0 active:ring-0 disabled:pointer-events-none disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0', + 'relative inline-flex items-center justify-center gap-2 whitespace-nowrap text-sm font-medium transition-all duration-200 ease-in-out border-0 outline-0 ring-0 focus:border-0 focus:outline-0 focus:ring-0 active:border-0 active:outline-0 active:ring-0 disabled:pointer-events-none disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0', { variants: { variant: { @@ -53,10 +54,6 @@ const buttonVariants = cva( isLoading: false, }, compoundVariants: [ - { - isLoading: true, - className: 'gap-2 [&>span]:opacity-0 [&>svg]:opacity-0 [&>*:not(.loading-spinner)]:opacity-0', - }, // Ghost button hover effects - subtle background changes with brightness adjustments { variant: 'ghost', @@ -90,15 +87,24 @@ export type ButtonProps = { VariantProps; const Button = forwardRef( - ({ className, variant, size, radius, fullWidth, isLoading, asChild = false, ...props }, ref) => { + ({ className, variant, size, radius, fullWidth, isLoading, asChild = false, children, disabled, ...props }, ref) => { const Comp = asChild ? Slot : 'button'; return ( + disabled={isLoading ? true : disabled} + > + {children} + {isLoading ? ( + + ) : null} + ); }, ); diff --git a/src/features/swap/api/velora.ts b/src/features/swap/api/velora.ts new file mode 100644 index 00000000..1cededd1 --- /dev/null +++ b/src/features/swap/api/velora.ts @@ -0,0 +1,402 @@ +import { isAddress, isHex, type Address } from 'viem'; +import { toCanonicalTokenAddress } from '@/types/token'; +import { SWAP_PARTNER, VELORA_API_BASE_URL, VELORA_PRICES_API_VERSION } from '../constants'; + +export type VeloraSwapSide = 'SELL' | 'BUY'; + +export type VeloraPriceRoute = { + srcToken: string; + destToken: string; + srcAmount: string; + destAmount: string; + tokenTransferProxy?: string; + contractAddress?: string; +}; + +export type VeloraTransactionPayload = { + to: Address; + data: `0x${string}`; + value?: string; + gas?: string; + gasPrice?: string; + maxFeePerGas?: string; + maxPriorityFeePerGas?: string; +}; + +type VeloraPriceResponse = { + priceRoute?: VeloraPriceRoute; + error?: string; + message?: string; + description?: string; +}; + +type VeloraBuildTransactionResponse = { + to?: string; + data?: string; + value?: string; + gas?: string; + gasPrice?: string; + maxFeePerGas?: string; + maxPriorityFeePerGas?: string; + error?: string; + message?: string; + description?: string; +}; + +export type FetchVeloraPriceRouteParams = { + srcToken: string; + srcDecimals: number; + destToken: string; + destDecimals: number; + amount: bigint; + network: number; + userAddress: Address; + partner?: string; + side?: VeloraSwapSide; +}; + +export type BuildVeloraTransactionPayloadParams = { + srcToken: string; + srcDecimals: number; + destToken: string; + destDecimals: number; + srcAmount: bigint; + network: number; + userAddress: Address; + priceRoute: VeloraPriceRoute; + slippageBps: number; + side?: VeloraSwapSide; + partner?: string; + ignoreChecks?: boolean; +}; + +export type PrepareVeloraSwapPayloadParams = { + srcToken: string; + srcDecimals: number; + destToken: string; + destDecimals: number; + amount: bigint; + network: number; + userAddress: Address; + slippageBps: number; + side?: VeloraSwapSide; + partner?: string; + ignoreChecks?: boolean; +}; + +export class VeloraApiError extends Error { + readonly status: number; + readonly details: unknown; + + constructor(message: string, status: number, details: unknown) { + super(message); + this.name = 'VeloraApiError'; + this.status = status; + this.details = details; + } +} + +const extractVeloraErrorMessage = (payload: unknown): string => { + if (!payload) return 'Unknown Velora API error'; + + if (typeof payload === 'string') { + return payload; + } + + if (typeof payload === 'object') { + const objectPayload = payload as Record; + + if (typeof objectPayload.description === 'string') { + return objectPayload.description; + } + if (typeof objectPayload.error === 'string') { + return objectPayload.error; + } + if (typeof objectPayload.message === 'string') { + return objectPayload.message; + } + + const nested = objectPayload.error ?? objectPayload.message; + if (nested && nested !== payload) { + return extractVeloraErrorMessage(nested); + } + } + + return 'Unknown Velora API error'; +}; + +const getVeloraApiErrorMessage = (payload: unknown, fallbackMessage: string): string => { + const message = extractVeloraErrorMessage(payload); + return message === 'Unknown Velora API error' ? fallbackMessage : message; +}; + +const fetchVeloraJson = async (url: string, init?: RequestInit): Promise => { + try { + const response = await fetch(url, init); + const raw = await response.text(); + + let payload: unknown = null; + if (raw) { + try { + payload = JSON.parse(raw) as unknown; + } catch { + payload = raw; + } + } + + if (!response.ok) { + const message = extractVeloraErrorMessage(payload); + throw new VeloraApiError(message, response.status, payload); + } + + return payload as T; + } catch (error: unknown) { + if (error instanceof VeloraApiError) { + throw error; + } + + const message = error instanceof Error ? error.message : 'Unknown network error'; + throw new VeloraApiError(`Failed to fetch Velora API response: ${message}`, 0, error); + } +}; + +const parseVeloraAddressField = (value: unknown, fieldName: string): Address => { + if (typeof value !== 'string' || !isAddress(value)) { + throw new VeloraApiError(`Invalid ${fieldName} address returned by Velora`, 400, { [fieldName]: value }); + } + return value as Address; +}; + +const parseVeloraHexDataField = (value: unknown, fieldName: string): `0x${string}` => { + if (typeof value !== 'string' || !isHex(value) || value.length <= 2) { + throw new VeloraApiError(`Invalid ${fieldName} payload returned by Velora`, 400, { [fieldName]: value }); + } + return value as `0x${string}`; +}; + +const REQUIRED_PRICE_ROUTE_STRING_FIELDS = ['srcToken', 'destToken', 'srcAmount', 'destAmount'] as const; +const PRICE_ROUTE_SOURCE_TOKEN_FIELDS = ['fromTokenAddress', 'inputToken', 'srcToken', 'srcTokenAddress'] as const; +const PRICE_ROUTE_DESTINATION_TOKEN_FIELDS = ['toTokenAddress', 'outputToken', 'destToken', 'destTokenAddress'] as const; + +const validateVeloraPriceRouteShape = (priceRoute: unknown, responsePayload: unknown): VeloraPriceRoute => { + if (!priceRoute || typeof priceRoute !== 'object') { + throw new VeloraApiError(getVeloraApiErrorMessage(responsePayload, 'Invalid price route returned by Velora'), 400, responsePayload); + } + + const routePayload = priceRoute as Record; + for (const field of REQUIRED_PRICE_ROUTE_STRING_FIELDS) { + const value = routePayload[field]; + if (typeof value !== 'string' || value.length === 0) { + throw new VeloraApiError( + getVeloraApiErrorMessage(responsePayload, `Invalid price route returned by Velora: ${field} is missing or invalid`), + 400, + responsePayload, + ); + } + } + + return routePayload as VeloraPriceRoute; +}; + +const resolveCanonicalRouteTokenAddress = (priceRoute: VeloraPriceRoute, fields: readonly string[]): Address | null => { + const routePayload = priceRoute as Record; + + for (const field of fields) { + const rawFieldValue = routePayload[field]; + if (typeof rawFieldValue !== 'string' || rawFieldValue.length === 0) { + continue; + } + + const canonicalAddress = toCanonicalTokenAddress(rawFieldValue); + if (!canonicalAddress) { + throw new VeloraApiError(`Invalid ${field} token address returned by Velora`, 400, { [field]: rawFieldValue, priceRoute }); + } + + return canonicalAddress; + } + + return null; +}; + +export const getVeloraApprovalTarget = (priceRoute: VeloraPriceRoute | null): Address | null => { + const spender = priceRoute?.tokenTransferProxy ?? priceRoute?.contractAddress; + if (!spender || !isAddress(spender)) return null; + return spender as Address; +}; + +export const isVeloraRateChangedError = (error: unknown): boolean => { + const message = error instanceof Error ? error.message.toLowerCase() : ''; + return message.includes('rate has changed') || message.includes('re-query the latest price'); +}; + +export const fetchVeloraPriceRoute = async ({ + srcToken, + srcDecimals, + destToken, + destDecimals, + amount, + network, + userAddress, + partner = SWAP_PARTNER, + side = 'SELL', +}: FetchVeloraPriceRouteParams): Promise => { + const requestedSourceTokenAddress = toCanonicalTokenAddress(srcToken); + const requestedDestinationTokenAddress = toCanonicalTokenAddress(destToken); + if (!requestedSourceTokenAddress || !requestedDestinationTokenAddress) { + throw new VeloraApiError('Invalid source or destination token address provided for Velora quote request', 400, { + srcToken, + destToken, + network, + }); + } + + const query = new URLSearchParams({ + srcToken, + destToken, + srcDecimals: srcDecimals.toString(), + destDecimals: destDecimals.toString(), + amount: amount.toString(), + side, + network: network.toString(), + userAddress, + partner, + version: VELORA_PRICES_API_VERSION, + }); + + const response = await fetchVeloraJson(`${VELORA_API_BASE_URL}/prices?${query.toString()}`, { + method: 'GET', + }); + + if (!response || typeof response !== 'object' || !response.priceRoute) { + throw new VeloraApiError(getVeloraApiErrorMessage(response, 'No price route returned by Velora'), 400, response); + } + + const validatedPriceRoute = validateVeloraPriceRouteShape(response.priceRoute, response); + + const routeSourceTokenAddress = resolveCanonicalRouteTokenAddress(validatedPriceRoute, PRICE_ROUTE_SOURCE_TOKEN_FIELDS); + if (routeSourceTokenAddress && routeSourceTokenAddress !== requestedSourceTokenAddress) { + throw new VeloraApiError('Velora route source token does not match the requested source token', 400, { + requestedSourceTokenAddress, + routeSourceTokenAddress, + response, + }); + } + + const routeDestinationTokenAddress = resolveCanonicalRouteTokenAddress(validatedPriceRoute, PRICE_ROUTE_DESTINATION_TOKEN_FIELDS); + if (routeDestinationTokenAddress && routeDestinationTokenAddress !== requestedDestinationTokenAddress) { + throw new VeloraApiError('Velora route destination token does not match the requested destination token', 400, { + requestedDestinationTokenAddress, + routeDestinationTokenAddress, + response, + }); + } + + return validatedPriceRoute; +}; + +export const buildVeloraTransactionPayload = async ({ + srcToken, + srcDecimals, + destToken, + destDecimals, + srcAmount, + network, + userAddress, + priceRoute, + slippageBps, + side = 'SELL', + partner = SWAP_PARTNER, + ignoreChecks = false, +}: BuildVeloraTransactionPayloadParams): Promise => { + const query = new URLSearchParams(); + if (ignoreChecks) { + query.set('ignoreChecks', 'true'); + } + + const transactionUrl = + query.size > 0 + ? `${VELORA_API_BASE_URL}/transactions/${network}?${query.toString()}` + : `${VELORA_API_BASE_URL}/transactions/${network}`; + + const response = await fetchVeloraJson(transactionUrl, { + method: 'POST', + headers: { + 'content-type': 'application/json', + }, + body: JSON.stringify({ + srcToken, + srcDecimals, + destToken, + destDecimals, + srcAmount: srcAmount.toString(), + side, + slippage: slippageBps, + priceRoute, + userAddress, + partner, + }), + }); + + if (!response || typeof response !== 'object' || !response.to || !response.data) { + throw new VeloraApiError(getVeloraApiErrorMessage(response, 'Invalid transaction payload from Velora'), 400, response); + } + + const to = parseVeloraAddressField(response.to, 'to'); + const data = parseVeloraHexDataField(response.data, 'data'); + + return { + to, + data, + value: response.value, + gas: response.gas, + gasPrice: response.gasPrice, + maxFeePerGas: response.maxFeePerGas, + maxPriorityFeePerGas: response.maxPriorityFeePerGas, + }; +}; + +export const prepareVeloraSwapPayload = async ({ + srcToken, + srcDecimals, + destToken, + destDecimals, + amount, + network, + userAddress, + slippageBps, + side = 'SELL', + partner = SWAP_PARTNER, + ignoreChecks = false, +}: PrepareVeloraSwapPayloadParams): Promise<{ priceRoute: VeloraPriceRoute; txPayload: VeloraTransactionPayload }> => { + const priceRoute = await fetchVeloraPriceRoute({ + srcToken, + srcDecimals, + destToken, + destDecimals, + amount, + network, + userAddress, + side, + partner, + }); + + const txPayload = await buildVeloraTransactionPayload({ + srcToken, + srcDecimals, + destToken, + destDecimals, + srcAmount: amount, + network, + userAddress, + priceRoute, + slippageBps, + side, + partner, + ignoreChecks, + }); + + return { + priceRoute, + txPayload, + }; +}; diff --git a/src/features/swap/components/SwapModal.tsx b/src/features/swap/components/SwapModal.tsx index 69ae5b44..111e0a82 100644 --- a/src/features/swap/components/SwapModal.tsx +++ b/src/features/swap/components/SwapModal.tsx @@ -1,42 +1,80 @@ -import { useCallback, useMemo, useState } from 'react'; -import Image from 'next/image'; -import { ArrowDownIcon, ExternalLinkIcon } from '@radix-ui/react-icons'; -import { formatUnits, parseUnits } from 'viem'; +import { useCallback, useEffect, useMemo, useState } from 'react'; +import { ArrowDownIcon, ChevronDownIcon } from '@radix-ui/react-icons'; +import { IoIosSwap } from 'react-icons/io'; +import { formatUnits, isAddress, parseUnits, zeroAddress } from 'viem'; import { useConnection } from 'wagmi'; -import { motion } from 'framer-motion'; +import { AnimatePresence, motion } from 'framer-motion'; import { Modal, ModalBody, ModalFooter, ModalHeader } from '@/components/common/Modal'; import { Button } from '@/components/ui/button'; import { ExecuteTransactionButton } from '@/components/ui/ExecuteTransactionButton'; +import { Spinner } from '@/components/ui/spinner'; import { useUserBalancesQuery } from '@/hooks/queries/useUserBalancesQuery'; import { useMarketsQuery } from '@/hooks/queries/useMarketsQuery'; import { useTokensQuery } from '@/hooks/queries/useTokensQuery'; import { useAllowance } from '@/hooks/useAllowance'; import { formatBalance } from '@/utils/balance'; -import { getNetworkName } from '@/utils/networks'; -import { useCowSwap } from '../hooks/useCowSwap'; +import { isValidDecimalInput, sanitizeDecimalInput, toParseableDecimalInput } from '@/utils/decimal-input'; +import { formatCompactTokenAmount, formatTokenAmountPreview } from '@/utils/token-amount-format'; +import { useVeloraSwap } from '../hooks/useVeloraSwap'; import { TokenNetworkDropdown } from './TokenNetworkDropdown'; -import { COW_SWAP_CHAINS, COW_VAULT_RELAYER, type SwapToken } from '../types'; +import { SwapTokenAmountField } from './SwapTokenAmountField'; +import { VELORA_SWAP_CHAINS, type SwapToken } from '../types'; import { DEFAULT_SLIPPAGE_PERCENT } from '../constants'; -const img = '/imgs/protocols/cow.svg'; - type SwapModalProps = { isOpen: boolean; onClose: () => void; defaultTargetToken?: SwapToken; }; +const MIN_SLIPPAGE_PERCENT = 0.1; +const MAX_SLIPPAGE_PERCENT = 5; +const DEFAULT_CHAIN_ID = 1; +const RATE_PREVIEW_DECIMALS = 8; + +const formatSlippagePercent = (value: number): string => { + return value.toFixed(2).replace(/\.?0+$/, ''); +}; + +const clampSlippagePercent = (value: number): number => { + return Math.min(MAX_SLIPPAGE_PERCENT, Math.max(MIN_SLIPPAGE_PERCENT, value)); +}; + +const computeUnitRatePreviewAmount = ( + baseAmount: bigint, + baseTokenDecimals: number, + quoteAmount: bigint, + quoteTokenDecimals: number, +): bigint | null => { + if (baseAmount <= 0n || quoteAmount <= 0n) return null; + + const scaledNumerator = quoteAmount * 10n ** BigInt(baseTokenDecimals + RATE_PREVIEW_DECIMALS); + const scaledDenominator = baseAmount * 10n ** BigInt(quoteTokenDecimals); + if (scaledDenominator <= 0n) return null; + + return scaledNumerator / scaledDenominator; +}; + export function SwapModal({ isOpen, onClose, defaultTargetToken }: SwapModalProps) { const { address: account } = useConnection(); const [sourceToken, setSourceToken] = useState(null); const [targetToken, setTargetToken] = useState(defaultTargetToken ?? null); const [inputAmount, setInputAmount] = useState('0'); const [amount, setAmount] = useState(BigInt(0)); - const [slippage, _setSlippage] = useState(DEFAULT_SLIPPAGE_PERCENT); - - // Fetch user balances from CoW-supported chains - const { data: balances = [], isLoading: balancesLoading } = useUserBalancesQuery({ - networkIds: COW_SWAP_CHAINS as unknown as number[], + const [slippage, setSlippage] = useState(DEFAULT_SLIPPAGE_PERCENT); + const [slippageInput, setSlippageInput] = useState(formatSlippagePercent(DEFAULT_SLIPPAGE_PERCENT)); + const [isSettingsOpen, setIsSettingsOpen] = useState(false); + const [isRateInverted, setIsRateInverted] = useState(false); + const amountInputClassName = + 'h-10 w-full rounded bg-hovered px-3 pr-44 text-lg font-medium tabular-nums focus:border-primary focus:outline-none'; + + // Fetch user balances from Velora-supported chains + const { + data: balances = [], + isLoading: balancesLoading, + refetch: refetchBalances, + } = useUserBalancesQuery({ + networkIds: VELORA_SWAP_CHAINS as unknown as number[], }); // Fetch all tokens for target selection @@ -45,15 +83,6 @@ export function SwapModal({ isOpen, onClose, defaultTargetToken }: SwapModalProp // Fetch markets to filter target tokens const { data: markets } = useMarketsQuery(); - // Handle approval for source token - const { allowance, approveInfinite, approvePending } = useAllowance({ - token: (sourceToken?.address ?? '0x0000000000000000000000000000000000000000') as `0x${string}`, - chainId: sourceToken?.chainId, - user: account, - spender: COW_VAULT_RELAYER, - tokenSymbol: sourceToken?.symbol, - }); - // Convert balances to SwapTokens (for source selection) const sourceTokens = useMemo(() => { return balances @@ -72,18 +101,40 @@ export function SwapModal({ isOpen, onClose, defaultTargetToken }: SwapModalProp }); }, [balances]); - // Target tokens: all tokens with Morpho markets on CoW-supported chains + useEffect(() => { + if (balancesLoading) return; + if (!sourceToken) return; + + const refreshedSourceToken = sourceTokens.find( + (token) => token.chainId === sourceToken.chainId && token.address.toLowerCase() === sourceToken.address.toLowerCase(), + ); + + if (!refreshedSourceToken) { + setSourceToken(null); + return; + } + + if ( + refreshedSourceToken.balance !== sourceToken.balance || + refreshedSourceToken.decimals !== sourceToken.decimals || + refreshedSourceToken.symbol !== sourceToken.symbol + ) { + setSourceToken(refreshedSourceToken); + } + }, [balancesLoading, sourceToken, sourceTokens]); + + // Target tokens: all tokens with Morpho markets on Velora-supported chains const targetTokens = useMemo(() => { if (!markets) return []; // Get unique loan asset keys (address-chainId) that have markets const loanAssetKeys = new Set(markets.map((m) => `${m.loanAsset.address.toLowerCase()}-${m.morphoBlue.chain.id}`)); - // Filter allTokens to only those with markets on CoW-supported chains + // Filter allTokens to only those with markets on Velora-supported chains return allTokens .flatMap((token) => token.networks - .filter((net) => COW_SWAP_CHAINS.includes(net.chain.id as (typeof COW_SWAP_CHAINS)[number])) + .filter((net) => VELORA_SWAP_CHAINS.includes(net.chain.id as (typeof VELORA_SWAP_CHAINS)[number])) .filter((net) => loanAssetKeys.has(`${net.address.toLowerCase()}-${net.chain.id}`)) .map((net) => ({ address: net.address, @@ -107,49 +158,118 @@ export function SwapModal({ isOpen, onClose, defaultTargetToken }: SwapModalProp // Filter target tokens: exclude selected source (same token on same chain) const availableTargetTokens = useMemo(() => { if (!sourceToken) return targetTokens; - return targetTokens.filter( - (t) => !(t.chainId === sourceToken.chainId && t.address.toLowerCase() === sourceToken.address.toLowerCase()), - ); + return targetTokens.filter((t) => t.chainId === sourceToken.chainId && t.address.toLowerCase() !== sourceToken.address.toLowerCase()); }, [targetTokens, sourceToken]); - // CoW Swap hook - const { quote, isQuoting, isExecuting, error, orderUid, chainsMatch, executeSwap, reset } = useCowSwap({ + // Velora swap hook + const handleSwapConfirmed = useCallback(() => { + setInputAmount('0'); + setAmount(BigInt(0)); + void refetchBalances(); + }, [refetchBalances]); + + const { quote, isQuoting, isExecuting, error, chainsMatch, approvalTarget, executeSwap, reset } = useVeloraSwap({ sourceToken, targetToken, amount, slippageBps: Math.round(slippage * 100), + onSwapConfirmed: handleSwapConfirmed, }); // Check if approval is needed - const needsApproval = allowance < amount && amount > BigInt(0); + const sourceTokenAddress = + sourceToken?.address && isAddress(sourceToken.address) ? (sourceToken.address as `0x${string}`) : (zeroAddress as `0x${string}`); + const spenderForAllowance = + approvalTarget && isAddress(approvalTarget) ? (approvalTarget as `0x${string}`) : (zeroAddress as `0x${string}`); - // Check if chains match (for showing warning) - const showChainMismatch = sourceToken && targetToken && !chainsMatch; + // Handle approval for source token + const { allowance, approveInfinite, approvePending } = useAllowance({ + token: sourceTokenAddress, + chainId: sourceToken?.chainId, + user: account, + spender: spenderForAllowance, + tokenSymbol: sourceToken?.symbol, + }); + const needsApproval = allowance < amount && amount > BigInt(0); const handleSourceTokenSelect = (token: SwapToken) => { + if (targetToken && targetToken.chainId !== token.chainId) { + setTargetToken(null); + } setSourceToken(token); setAmount(BigInt(0)); setInputAmount('0'); }; const handleTargetTokenSelect = (token: SwapToken) => { + if (sourceToken && sourceToken.chainId !== token.chainId) { + return; + } setTargetToken(token); }; const handleInputChange = (e: React.ChangeEvent) => { - const value = e.target.value; - setInputAmount(value); + const normalizedInput = sanitizeDecimalInput(e.target.value); + if (!isValidDecimalInput(normalizedInput)) { + return; + } + setInputAmount(normalizedInput); if (!sourceToken) return; + const parseableInput = toParseableDecimalInput(normalizedInput); + if (!parseableInput) { + setAmount(BigInt(0)); + return; + } + try { - const parsed = parseUnits(value, sourceToken.decimals); + const parsed = parseUnits(parseableInput, sourceToken.decimals); setAmount(parsed); } catch { - // Invalid input, keep previous amount + // Clear parsed amount to avoid submitting a stale previous value. + setAmount(BigInt(0)); } }; + const handleSlippageChange = (e: React.ChangeEvent) => { + const normalizedInput = sanitizeDecimalInput(e.target.value); + if (!isValidDecimalInput(normalizedInput)) { + return; + } + setSlippageInput(normalizedInput); + + const parseableInput = toParseableDecimalInput(normalizedInput); + if (!parseableInput) { + return; + } + + const parsed = Number(parseableInput); + if (Number.isNaN(parsed)) { + return; + } + + setSlippage(clampSlippagePercent(parsed)); + }; + + const handleSlippageBlur = () => { + const parseableInput = toParseableDecimalInput(slippageInput); + if (!parseableInput) { + setSlippageInput(formatSlippagePercent(slippage)); + return; + } + + const parsed = Number(parseableInput); + if (Number.isNaN(parsed)) { + setSlippageInput(formatSlippagePercent(slippage)); + return; + } + + const normalized = clampSlippagePercent(parsed); + setSlippage(normalized); + setSlippageInput(formatSlippagePercent(normalized)); + }; + const handleMaxClick = () => { if (sourceToken?.balance) { setAmount(sourceToken.balance); @@ -168,11 +288,13 @@ export function SwapModal({ isOpen, onClose, defaultTargetToken }: SwapModalProp // Unified execution handler - handles approve + swap automatically const handleSwap = useCallback(async () => { + if (!sourceToken || !targetToken || !approvalTarget) return; + if (needsApproval) { await approveInfinite(); } await executeSwap(); - }, [needsApproval, approveInfinite, executeSwap]); + }, [sourceToken, targetToken, approvalTarget, needsApproval, approveInfinite, executeSwap]); const isLoading = isQuoting || approvePending || isExecuting; @@ -188,63 +310,79 @@ export function SwapModal({ isOpen, onClose, defaultTargetToken }: SwapModalProp const getOutputDisplay = () => { if (!targetToken) return 'Select token below'; if (!sourceToken) return 'Select token above'; - if (showChainMismatch) { - return Select source on {getNetworkName(targetToken.chainId)}; - } if (amount === BigInt(0)) return '0'; - if (isQuoting) return 'Loading...'; - if (error) return {formatErrorMessage(error)}; - if (quote) return {Number(formatUnits(quote.buyAmount, targetToken.decimals)).toFixed(6)}; + if (isQuoting) { + return ( + + + Quoting + + ); + } + if (quote) return {formatCompactTokenAmount(quote.buyAmount, targetToken.decimals)}; return '0'; }; + const ratePreviewText = useMemo(() => { + if (!quote || !sourceToken || !targetToken || error || !chainsMatch) return null; + + if (isRateInverted) { + const inverseRate = computeUnitRatePreviewAmount( + quote.buyAmount, + targetToken.decimals, + quote.sellAmount, + sourceToken.decimals, + ); + if (!inverseRate) return null; + const inverseRatePreview = formatTokenAmountPreview(inverseRate, RATE_PREVIEW_DECIMALS).compact; + return `1 ${targetToken.symbol} ≈ ${inverseRatePreview} ${sourceToken.symbol}`; + } + + const forwardRate = computeUnitRatePreviewAmount( + quote.sellAmount, + sourceToken.decimals, + quote.buyAmount, + targetToken.decimals, + ); + if (!forwardRate) return null; + const forwardRatePreview = formatTokenAmountPreview(forwardRate, RATE_PREVIEW_DECIMALS).compact; + return `1 ${sourceToken.symbol} ≈ ${forwardRatePreview} ${targetToken.symbol}`; + }, [quote, sourceToken, targetToken, error, chainsMatch, isRateInverted]); + return ( !open && handleClose()} - size="lg" + size="xl" > - CoW Protocol -
+
V
} />
{/* From Section */} -
-
- From - {sourceToken && ( - - )} -
-
+ + } + dropdown={ -
-
+ } + footer={ + sourceToken ? ( +
+ +
+ ) : null + } + /> {/* Arrow */} -
+
{/* To Section */} -
-
To
-
-
{getOutputDisplay()}
+ {getOutputDisplay()}
} + dropdown={ -
-
- {quote && sourceToken && targetToken && !error && chainsMatch && ( - - 1 {sourceToken.symbol} ≈{' '} - {( - Number(formatUnits(quote.buyAmount, targetToken.decimals)) / Number(formatUnits(quote.sellAmount, sourceToken.decimals)) - ).toFixed(6)}{' '} - {targetToken.symbol} + } + footer={ +
+ {ratePreviewText && ( + + )} + {ratePreviewText} +
+ } + /> + + {/* Slippage */} +
+
+ + + {isSettingsOpen && ( + +
+
+ Max slippage +
+ + % +
+
+
+
+ )} +
- {/* Chain Mismatch Warning */} - {showChainMismatch && ( - - - Select a source token on {getNetworkName(targetToken.chainId)} to swap - - - )} - {/* Error Display */} - {error && !showChainMismatch && ( + {error && ( )} - {/* Success Message */} - {orderUid && ( - -
- Order Created - - View in CoW Explorer - - -
-
- )} - {/* Empty State */} {!balancesLoading && sourceTokens.length === 0 && (

No tokens found on supported chains

-

Supported: Ethereum, Base, Arbitrum

+

Supported: Ethereum, Polygon, Unichain, Base, Arbitrum

)}
@@ -351,20 +517,18 @@ export function SwapModal({ isOpen, onClose, defaultTargetToken }: SwapModalProp variant="default" onClick={handleClose} > - {orderUid ? 'Close' : 'Cancel'} + Cancel - {!orderUid && ( - void handleSwap()} - isLoading={isLoading} - disabled={!sourceToken || !targetToken || !quote || amount === BigInt(0) || !!error || !chainsMatch} - variant="primary" - > - {needsApproval ? 'Approve & Swap' : 'Swap'} - - )} + void handleSwap()} + isLoading={isLoading} + disabled={!sourceToken || !targetToken || !quote || amount === BigInt(0) || !!error || !chainsMatch || !approvalTarget} + variant="primary" + > + {needsApproval ? 'Approve & Swap' : 'Swap'} + ); diff --git a/src/features/swap/components/SwapTokenAmountField.tsx b/src/features/swap/components/SwapTokenAmountField.tsx new file mode 100644 index 00000000..9dee5eb4 --- /dev/null +++ b/src/features/swap/components/SwapTokenAmountField.tsx @@ -0,0 +1,21 @@ +import type { ReactNode } from 'react'; + +type SwapTokenAmountFieldProps = { + label: string; + field: ReactNode; + dropdown: ReactNode; + footer?: ReactNode; +}; + +export function SwapTokenAmountField({ label, field, dropdown, footer }: SwapTokenAmountFieldProps) { + return ( +
+

{label}

+
+ {field} +
{dropdown}
+
+ {footer ?
{footer}
: null} +
+ ); +} diff --git a/src/features/swap/components/TokenNetworkDropdown.tsx b/src/features/swap/components/TokenNetworkDropdown.tsx index 156ef292..dbb66bf5 100644 --- a/src/features/swap/components/TokenNetworkDropdown.tsx +++ b/src/features/swap/components/TokenNetworkDropdown.tsx @@ -4,6 +4,7 @@ import { formatUnits } from 'viem'; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/components/ui/dropdown-menu'; import { TokenIcon } from '@/components/shared/token-icon'; import { NetworkIcon } from '@/components/shared/network-icon'; +import { cn } from '@/utils/components'; import { getNetworkName } from '@/utils/networks'; import type { SwapToken } from '../types'; @@ -15,6 +16,8 @@ type TokenNetworkDropdownProps = { disabled?: boolean; /** Optional chain ID to highlight tokens on (e.g., to show matching network) */ highlightChainId?: number; + triggerVariant?: 'default' | 'inline'; + triggerClassName?: string; }; /** @@ -28,6 +31,8 @@ export function TokenNetworkDropdown({ placeholder = 'Select', disabled, highlightChainId, + triggerVariant = 'default', + triggerClassName, }: TokenNetworkDropdownProps) { const [query, setQuery] = useState(''); @@ -54,7 +59,12 @@ export function TokenNetworkDropdown({ diff --git a/src/features/swap/constants.ts b/src/features/swap/constants.ts index eb2849bd..1952bb8f 100644 --- a/src/features/swap/constants.ts +++ b/src/features/swap/constants.ts @@ -1,7 +1,17 @@ /** - * Application identifier for CoW Protocol integration + * Application identifier for Velora integration */ -export const SWAP_APP_CODE = 'monarchlend'; +export const SWAP_PARTNER = 'monarchlend'; + +/** + * Velora API base URL + */ +export const VELORA_API_BASE_URL = 'https://api.paraswap.io'; + +/** + * Velora API version for price quote endpoint + */ +export const VELORA_PRICES_API_VERSION = '6.2'; /** * Default slippage tolerance as a percentage (0.5 = 0.5%) diff --git a/src/features/swap/cowSwapSdk.ts b/src/features/swap/cowSwapSdk.ts deleted file mode 100644 index d4fed126..00000000 --- a/src/features/swap/cowSwapSdk.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { TradingSdk } from '@cowprotocol/cow-sdk'; -import { SWAP_APP_CODE } from './constants'; - -/** - * CoW Protocol Trading SDK for same-chain swaps - * Handles quotes, order signing, and posting - */ -export const tradingSdk = new TradingSdk( - { - chainId: 1, // Default, will be updated per swap - appCode: SWAP_APP_CODE, - }, - { - enableLogging: false, - }, -); diff --git a/src/features/swap/hooks/useCowSwap.ts b/src/features/swap/hooks/useCowSwap.ts deleted file mode 100644 index 18183cc9..00000000 --- a/src/features/swap/hooks/useCowSwap.ts +++ /dev/null @@ -1,211 +0,0 @@ -import { useCallback, useEffect, useState } from 'react'; -import { useConnection, usePublicClient, useWalletClient } from 'wagmi'; -import { OrderKind, setGlobalAdapter, type QuoteAndPost } from '@cowprotocol/cow-sdk'; -import { ViemAdapter } from '@cowprotocol/sdk-viem-adapter'; -import { tradingSdk } from '../cowSwapSdk'; -import type { SwapQuoteDisplay, SwapToken } from '../types'; - -type UseCowSwapParams = { - sourceToken: SwapToken | null; - targetToken: SwapToken | null; - amount: bigint; - slippageBps: number; -}; - -type UseCowSwapReturn = { - quote: SwapQuoteDisplay | null; - isQuoting: boolean; - isExecuting: boolean; - error: string | null; - orderUid: string | null; - chainsMatch: boolean; - - executeSwap: () => Promise; - reset: () => void; -}; - -/** - * Hook for managing CoW Protocol same-chain swaps - */ -export function useCowSwap({ sourceToken, targetToken, amount, slippageBps }: UseCowSwapParams): UseCowSwapReturn { - const { address: account } = useConnection(); - const { data: walletClient } = useWalletClient(); - const publicClient = usePublicClient(); - - const [quote, setQuote] = useState(null); - const [quoteAndPost, setQuoteAndPost] = useState(null); - const [isQuoting, setIsQuoting] = useState(false); - const [isExecuting, setIsExecuting] = useState(false); - const [error, setError] = useState(null); - const [orderUid, setOrderUid] = useState(null); - - // Check if source and target chains match - const chainsMatch = sourceToken && targetToken ? sourceToken.chainId === targetToken.chainId : false; - - // Bind SDK to wagmi - useEffect(() => { - if (!walletClient || !publicClient) return; - - try { - const adapter = new ViemAdapter({ - provider: publicClient, - walletClient, - }); - - setGlobalAdapter(adapter); - - if (sourceToken?.chainId) { - tradingSdk.setTraderParams({ chainId: sourceToken.chainId }); - } - } catch (err) { - console.error('Failed to bind SDK to wagmi:', err); - } - }, [publicClient, walletClient, sourceToken?.chainId]); - - /** - * Parse error to extract description from CoW Protocol API errors - */ - const parseErrorDescription = (err: unknown): string => { - if (err && typeof err === 'object' && 'description' in err && typeof err.description === 'string') { - return err.description; - } - if (err instanceof Error) { - return err.message; - } - return 'An unknown error occurred'; - }; - - /** - * Get quote for swap - */ - const getQuote = useCallback(async () => { - if (!sourceToken || !targetToken || !account || amount === BigInt(0)) { - setQuote(null); - setQuoteAndPost(null); - return; - } - - // Only fetch quote if chains match - if (sourceToken.chainId !== targetToken.chainId) { - setQuote(null); - setQuoteAndPost(null); - return; - } - - setIsQuoting(true); - setError(null); - - try { - // Update SDK chain if needed - tradingSdk.setTraderParams({ chainId: sourceToken.chainId }); - - const result = await tradingSdk.getQuote({ - chainId: sourceToken.chainId, - kind: OrderKind.SELL, - owner: account, - amount: amount.toString(), - sellToken: sourceToken.address, - sellTokenDecimals: sourceToken.decimals, - buyToken: targetToken.address, - buyTokenDecimals: targetToken.decimals, - slippageBps, - }); - - // Store the QuoteAndPost for later execution - setQuoteAndPost(result); - - // Extract display info - setQuote({ - buyAmount: result.quoteResults.amountsAndCosts.afterNetworkCosts.buyAmount, - sellAmount: amount, - }); - } catch (err) { - console.error('Error fetching quote:', err); - setError(parseErrorDescription(err)); - setQuote(null); - setQuoteAndPost(null); - } finally { - setIsQuoting(false); - } - }, [sourceToken, targetToken, account, amount, slippageBps]); - - /** - * Execute the swap - */ - const executeSwap = useCallback(async () => { - if (!quoteAndPost) return; - - setIsExecuting(true); - setError(null); - - try { - const result = await quoteAndPost.postSwapOrderFromQuote({ - appData: { - metadata: { - quote: { - slippageBips: slippageBps, - }, - }, - }, - }); - - if (!result) { - throw new Error('No response from order posting'); - } - - setOrderUid(result.orderId); - } catch (err) { - setError(parseErrorDescription(err)); - } finally { - setIsExecuting(false); - } - }, [quoteAndPost, slippageBps]); - - /** - * Reset state - */ - const reset = useCallback(() => { - setQuote(null); - setQuoteAndPost(null); - setError(null); - setOrderUid(null); - }, []); - - // Auto-fetch quote when parameters change (only if chains match) - useEffect(() => { - if (!sourceToken || !targetToken || amount === BigInt(0)) { - setQuote(null); - setQuoteAndPost(null); - return; - } - - // Don't fetch if chains don't match - if (sourceToken.chainId !== targetToken.chainId) { - setQuote(null); - setQuoteAndPost(null); - return; - } - - // Reset state - setOrderUid(null); - setError(null); - - // Debounce quote fetching - const timeoutId = setTimeout(() => { - void getQuote(); - }, 800); - - return () => clearTimeout(timeoutId); - }, [sourceToken, targetToken, amount, slippageBps, getQuote]); - - return { - quote, - isQuoting, - isExecuting, - error, - orderUid, - chainsMatch, - executeSwap, - reset, - }; -} diff --git a/src/features/swap/hooks/useVeloraSwap.ts b/src/features/swap/hooks/useVeloraSwap.ts new file mode 100644 index 00000000..e54effba --- /dev/null +++ b/src/features/swap/hooks/useVeloraSwap.ts @@ -0,0 +1,262 @@ +import { useCallback, useEffect, useMemo, useState } from 'react'; +import type { Address, Hex } from 'viem'; +import { useConnection, usePublicClient } from 'wagmi'; +import { buildVeloraTransactionPayload, fetchVeloraPriceRoute, getVeloraApprovalTarget, isVeloraRateChangedError } from '../api/velora'; +import type { SwapQuoteDisplay, SwapToken } from '../types'; +import { useTransactionWithToast } from '@/hooks/useTransactionWithToast'; +import { formatBalance } from '@/utils/balance'; +import { toUserFacingTransactionErrorMessage } from '@/utils/transaction-errors'; + +type UseVeloraSwapParams = { + sourceToken: SwapToken | null; + targetToken: SwapToken | null; + amount: bigint; + slippageBps: number; + onSwapConfirmed?: () => void; +}; + +type UseVeloraSwapReturn = { + quote: SwapQuoteDisplay | null; + isQuoting: boolean; + isExecuting: boolean; + error: string | null; + chainsMatch: boolean; + approvalTarget: Address | null; + executeSwap: () => Promise; + reset: () => void; +}; + +const QUOTE_DEBOUNCE_MS = 800; + +const parseErrorMessage = (err: unknown): string => { + return toUserFacingTransactionErrorMessage(err, 'An unknown error occurred'); +}; + +/** + * Hook for managing same-chain swaps through Velora (ParaSwap). + * Quote path: `/prices` + * Execution path: `/transactions/:network` + wallet sendTransaction + */ +export function useVeloraSwap({ + sourceToken, + targetToken, + amount, + slippageBps, + onSwapConfirmed, +}: UseVeloraSwapParams): UseVeloraSwapReturn { + const { address: account } = useConnection(); + const publicClient = usePublicClient({ + chainId: sourceToken?.chainId, + }); + + const [quote, setQuote] = useState(null); + const [priceRoute, setPriceRoute] = useState> | null>(null); + const [isQuoting, setIsQuoting] = useState(false); + const [isExecuting, setIsExecuting] = useState(false); // preparing payload + submitting tx + const [error, setError] = useState(null); + + const chainsMatch = sourceToken && targetToken ? sourceToken.chainId === targetToken.chainId : false; + + const approvalTarget = useMemo(() => getVeloraApprovalTarget(priceRoute), [priceRoute]); + + const pendingText = useMemo(() => { + if (!sourceToken || amount <= 0n) return 'Swapping tokens'; + return `Swapping ${formatBalance(amount, sourceToken.decimals)} ${sourceToken.symbol}`; + }, [sourceToken, amount]); + + const successText = useMemo(() => { + if (!targetToken) return 'Swap completed'; + return `${targetToken.symbol} swapped`; + }, [targetToken]); + + const { sendTransactionAsync, isConfirming: swapPending } = useTransactionWithToast({ + toastId: 'velora-swap', + pendingText, + successText, + errorText: 'Failed to swap', + chainId: sourceToken?.chainId, + pendingDescription: + sourceToken && targetToken ? `${sourceToken.symbol} → ${targetToken.symbol} via Velora` : 'Submitting Velora swap transaction', + successDescription: + sourceToken && targetToken ? `${sourceToken.symbol} → ${targetToken.symbol} swap confirmed` : 'Swap transaction confirmed', + onSuccess: () => { + if (onSwapConfirmed) onSwapConfirmed(); + }, + }); + + const getQuote = useCallback(async () => { + if (!sourceToken || !targetToken || !account || amount <= 0n || sourceToken.chainId !== targetToken.chainId) { + setQuote(null); + setPriceRoute(null); + return; + } + + setIsQuoting(true); + setError(null); + + try { + const nextPriceRoute = await fetchVeloraPriceRoute({ + srcToken: sourceToken.address, + srcDecimals: sourceToken.decimals, + destToken: targetToken.address, + destDecimals: targetToken.decimals, + amount, + network: sourceToken.chainId, + userAddress: account, + }); + + const buyAmount = BigInt(nextPriceRoute.destAmount); + const sellAmount = BigInt(nextPriceRoute.srcAmount); + + setPriceRoute(nextPriceRoute); + setQuote({ + buyAmount, + sellAmount, + }); + } catch (err: unknown) { + console.error('Error fetching Velora quote:', err); + setError(parseErrorMessage(err)); + setQuote(null); + setPriceRoute(null); + } finally { + setIsQuoting(false); + } + }, [sourceToken, targetToken, account, amount]); + + const executeSwap = useCallback(async () => { + if (!sourceToken || !targetToken || !account || !priceRoute) { + return; + } + + setIsExecuting(true); + setError(null); + + try { + let activePriceRoute = priceRoute; + + let txPayload; + try { + txPayload = await buildVeloraTransactionPayload({ + srcToken: sourceToken.address, + srcDecimals: sourceToken.decimals, + destToken: targetToken.address, + destDecimals: targetToken.decimals, + srcAmount: amount, + network: sourceToken.chainId, + userAddress: account, + priceRoute: activePriceRoute, + slippageBps, + }); + } catch (buildError: unknown) { + if (!isVeloraRateChangedError(buildError)) { + throw buildError; + } + + const refreshedRoute = await fetchVeloraPriceRoute({ + srcToken: sourceToken.address, + srcDecimals: sourceToken.decimals, + destToken: targetToken.address, + destDecimals: targetToken.decimals, + amount, + network: sourceToken.chainId, + userAddress: account, + }); + activePriceRoute = refreshedRoute; + setPriceRoute(refreshedRoute); + setQuote({ + buyAmount: BigInt(refreshedRoute.destAmount), + sellAmount: BigInt(refreshedRoute.srcAmount), + }); + + const previousSpender = getVeloraApprovalTarget(priceRoute); + const refreshedSpender = getVeloraApprovalTarget(refreshedRoute); + if (previousSpender && refreshedSpender && previousSpender.toLowerCase() !== refreshedSpender.toLowerCase()) { + throw new Error('Swap route changed and requires approval for a new spender. Please approve and retry.'); + } + + txPayload = await buildVeloraTransactionPayload({ + srcToken: sourceToken.address, + srcDecimals: sourceToken.decimals, + destToken: targetToken.address, + destDecimals: targetToken.decimals, + srcAmount: amount, + network: sourceToken.chainId, + userAddress: account, + priceRoute: activePriceRoute, + slippageBps, + }); + } + + const value = txPayload.value ? BigInt(txPayload.value) : 0n; + const gas = txPayload.gas + ? BigInt(txPayload.gas) + : await publicClient?.estimateGas({ + account, + to: txPayload.to, + data: txPayload.data as Hex, + value, + }); + + const baseTx = { + account, + to: txPayload.to, + data: txPayload.data as Hex, + value, + gas, + }; + + if (txPayload.maxFeePerGas || txPayload.maxPriorityFeePerGas) { + await sendTransactionAsync({ + ...baseTx, + maxFeePerGas: txPayload.maxFeePerGas ? BigInt(txPayload.maxFeePerGas) : undefined, + maxPriorityFeePerGas: txPayload.maxPriorityFeePerGas ? BigInt(txPayload.maxPriorityFeePerGas) : undefined, + }); + } else if (txPayload.gasPrice) { + await sendTransactionAsync({ + ...baseTx, + gasPrice: BigInt(txPayload.gasPrice), + }); + } else { + await sendTransactionAsync(baseTx); + } + } catch (err: unknown) { + console.error('Error executing Velora swap:', err); + setError(parseErrorMessage(err)); + } finally { + setIsExecuting(false); + } + }, [sourceToken, targetToken, account, amount, slippageBps, priceRoute, publicClient, sendTransactionAsync]); + + const reset = useCallback(() => { + setQuote(null); + setPriceRoute(null); + setError(null); + }, []); + + useEffect(() => { + if (!sourceToken || !targetToken || amount <= 0n || sourceToken.chainId !== targetToken.chainId) { + setQuote(null); + setPriceRoute(null); + return; + } + + setError(null); + + const timeoutId = setTimeout(() => { + void getQuote(); + }, QUOTE_DEBOUNCE_MS); + + return () => clearTimeout(timeoutId); + }, [sourceToken, targetToken, amount, slippageBps, getQuote]); + + return { + quote, + isQuoting, + isExecuting: isExecuting || swapPending, + error, + chainsMatch, + approvalTarget, + executeSwap, + reset, + }; +} diff --git a/src/features/swap/index.ts b/src/features/swap/index.ts index 0dff7146..04840876 100644 --- a/src/features/swap/index.ts +++ b/src/features/swap/index.ts @@ -1,13 +1,20 @@ /** - * CoW Protocol Swap Feature + * Velora Swap Feature * - * Provides same-chain token swaps via CoW Protocol + * Provides same-chain token swaps via Velora */ export { SwapModal } from './components/SwapModal'; export { TokenNetworkDropdown } from './components/TokenNetworkDropdown'; -export { useCowSwap } from './hooks/useCowSwap'; -export { tradingSdk } from './cowSwapSdk'; -export type { SwapToken, SwapQuoteDisplay, CowSwapChainId } from './types'; -export { COW_SWAP_CHAINS, COW_VAULT_RELAYER, isCowSwapChain } from './types'; -export { SWAP_APP_CODE, DEFAULT_SLIPPAGE_PERCENT } from './constants'; +export { + buildVeloraTransactionPayload, + fetchVeloraPriceRoute, + getVeloraApprovalTarget, + isVeloraRateChangedError, + prepareVeloraSwapPayload, + VeloraApiError, +} from './api/velora'; +export { useVeloraSwap } from './hooks/useVeloraSwap'; +export type { SwapToken, SwapQuoteDisplay, VeloraSwapChainId } from './types'; +export { VELORA_SWAP_CHAINS, VELORA_NATIVE_TOKEN_ADDRESS, isVeloraSwapChain } from './types'; +export { SWAP_PARTNER, DEFAULT_SLIPPAGE_PERCENT } from './constants'; diff --git a/src/features/swap/types.ts b/src/features/swap/types.ts index a528edc5..7a14580b 100644 --- a/src/features/swap/types.ts +++ b/src/features/swap/types.ts @@ -19,26 +19,24 @@ export type SwapQuoteDisplay = { }; /** - * CoW Protocol supported chains for swaps - * Mainnet (1), Base (8453), Arbitrum (42161) - * Note: These are chains supported by both CoW Protocol and our balance API + * Velora supported chains that overlap with Monarch-supported networks. + * Mainnet (1), Polygon (137), Unichain (130), Base (8453), Arbitrum (42161) */ -export const COW_SWAP_CHAINS = [1, 8453, 42_161] as const; +export const VELORA_SWAP_CHAINS = [1, 137, 130, 8453, 42_161] as const; /** - * CoW Protocol VaultRelayer address (same across all chains) - * This is the address that needs to be approved to spend tokens + * Canonical native-token pseudo address used by Velora API. */ -export const COW_VAULT_RELAYER = '0xC92E8bdf79f0507f65a392b0ab4667716BFE0110' as const; +export const VELORA_NATIVE_TOKEN_ADDRESS = '0xeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee' as const; /** - * Type for CoW swap supported chain IDs + * Type for Velora swap supported chain IDs */ -export type CowSwapChainId = (typeof COW_SWAP_CHAINS)[number]; +export type VeloraSwapChainId = (typeof VELORA_SWAP_CHAINS)[number]; /** - * Check if a chain ID is supported by CoW Swap + * Check if a chain ID is supported by Velora swap */ -export function isCowSwapChain(chainId: number): chainId is CowSwapChainId { - return COW_SWAP_CHAINS.includes(chainId as CowSwapChainId); +export function isVeloraSwapChain(chainId: number): chainId is VeloraSwapChainId { + return VELORA_SWAP_CHAINS.includes(chainId as VeloraSwapChainId); } diff --git a/src/hooks/queries/useUserBalancesQuery.ts b/src/hooks/queries/useUserBalancesQuery.ts index dab37bf3..be7d9af0 100644 --- a/src/hooks/queries/useUserBalancesQuery.ts +++ b/src/hooks/queries/useUserBalancesQuery.ts @@ -79,6 +79,7 @@ export const useUserBalancesQuery = (options: UseUserBalancesOptions = {}) => { isLoading, isError, error, + refetch, } = useReadContracts({ contracts, query: { @@ -117,7 +118,7 @@ export const useUserBalancesQuery = (options: UseUserBalancesOptions = {}) => { return balances; }, [rawResults, tokenEntries, findToken]); - return { data, isLoading, isError, error }; + return { data, isLoading, isError, error, refetch }; }; /** diff --git a/src/hooks/useAllowance.ts b/src/hooks/useAllowance.ts index 86f9b6cb..c5c92dea 100644 --- a/src/hooks/useAllowance.ts +++ b/src/hooks/useAllowance.ts @@ -24,6 +24,7 @@ type Props = { export function useAllowance({ user, spender, chainId = 1, token, refetchInterval = 10_000, tokenSymbol }: Props) { const { chain } = useConnection(); const chainIdFromArgumentOrConnectedWallet = chainId ?? chain?.id; + const hasValidAllowanceRoute = spender !== zeroAddress && token !== zeroAddress; const { data } = useReadContract({ abi: erc20Abi, @@ -31,7 +32,7 @@ export function useAllowance({ user, spender, chainId = 1, token, refetchInterva address: token, args: [user ?? zeroAddress, spender], query: { - enabled: !!user && !!spender && !!token, + enabled: !!user && hasValidAllowanceRoute, refetchInterval, }, chainId, @@ -48,7 +49,7 @@ export function useAllowance({ user, spender, chainId = 1, token, refetchInterva }); const approveInfinite = useCallback(async () => { - if (!user || !spender || !token) throw new Error('User, spender, or token not provided'); + if (!user || !hasValidAllowanceRoute) throw new Error('User, spender, or token not provided'); // some weird bug with writeContract, update to use useSendTransaction await sendTransactionAsync({ account: user, @@ -60,7 +61,7 @@ export function useAllowance({ user, spender, chainId = 1, token, refetchInterva }), chainId: chainIdFromArgumentOrConnectedWallet, }); - }, [user, spender, token, sendTransactionAsync, chainIdFromArgumentOrConnectedWallet]); + }, [user, hasValidAllowanceRoute, sendTransactionAsync, chainIdFromArgumentOrConnectedWallet, token, spender]); const allowance = data ? data : BigInt(0); diff --git a/src/hooks/useMultiMarketSupply.ts b/src/hooks/useMultiMarketSupply.ts index 4e4fcb0e..781e171f 100644 --- a/src/hooks/useMultiMarketSupply.ts +++ b/src/hooks/useMultiMarketSupply.ts @@ -1,5 +1,5 @@ import { useCallback } from 'react'; -import { type Address, encodeFunctionData } from 'viem'; +import { type Address, encodeFunctionData, zeroAddress } from 'viem'; import { useConnection } from 'wagmi'; import morphoBundlerAbi from '@/abis/bundlerV2'; import { usePermit2 } from '@/hooks/usePermit2'; @@ -8,7 +8,6 @@ import { useTransactionTracking } from '@/hooks/useTransactionTracking'; import type { NetworkToken } from '@/types/token'; import { formatBalance } from '@/utils/balance'; import { getBundlerV2, MONARCH_TX_IDENTIFIER } from '@/utils/morpho'; -import { SupportedNetworks } from '@/utils/networks'; import type { Market } from '@/utils/types'; import { GAS_COSTS, GAS_MULTIPLIER_NUMERATOR, GAS_MULTIPLIER_DENOMINATOR } from '@/features/markets/components/constants'; import { useERC20Approval } from './useERC20Approval'; @@ -34,6 +33,11 @@ export function useMultiMarketSupply( const chainId = loanAsset?.network; const tokenSymbol = loanAsset?.symbol; const totalAmount = supplies.reduce((sum, supply) => sum + supply.amount, 0n); + const bundlerAddress = chainId ? getBundlerV2(chainId) : zeroAddress; + const isBundlerAddressValid = chainId !== undefined && bundlerAddress !== zeroAddress; + const bundlerAddressErrorMessage = chainId + ? `No bundler configured for chain ${chainId}.` + : 'No chain selected for multi-market supply.'; const { batchAddUserMarkets } = useUserMarketsCache(account); @@ -44,7 +48,7 @@ export function useMultiMarketSupply( signForBundlers, } = usePermit2({ user: account as `0x${string}`, - spender: getBundlerV2(chainId ?? SupportedNetworks.Mainnet), + spender: isBundlerAddressValid ? bundlerAddress : undefined, token: loanAsset?.address as `0x${string}`, refetchInterval: 10_000, chainId, @@ -54,7 +58,7 @@ export function useMultiMarketSupply( const { isApproved, approve } = useERC20Approval({ token: loanAsset?.address as Address, - spender: getBundlerV2(chainId ?? SupportedNetworks.Mainnet), + spender: bundlerAddress, amount: totalAmount, tokenSymbol: loanAsset?.symbol ?? '', }); @@ -67,18 +71,23 @@ export function useMultiMarketSupply( chainId, pendingDescription: `Supplying to ${supplies.length} market${supplies.length > 1 ? 's' : ''}`, successDescription: `Successfully supplied to ${supplies.length} market${supplies.length > 1 ? 's' : ''}`, - onSuccess, + onSuccess: () => { + if (onSuccess) onSuccess(); + }, }); const executeSupplyTransaction = useCallback(async () => { if (!account) throw new Error('No account connected'); if (!loanAsset || !chainId) throw new Error('Invalid loan asset or chain'); + if (!isBundlerAddressValid) throw new Error(bundlerAddressErrorMessage); const txs: `0x${string}`[] = []; let gas: bigint | undefined = undefined; try { + // Supply flows do not require Morpho/Bundler authorization. + // We only need funding/transfer steps (ETH wrap, Permit2, or ERC20 transferFrom) plus morphoSupply calls. // Handle ETH wrapping if needed if (useEth) { txs.push( @@ -155,7 +164,7 @@ export function useMultiMarketSupply( await sendTransactionAsync({ account, - to: getBundlerV2(chainId), + to: bundlerAddress, data: (encodeFunctionData({ abi: morphoBundlerAbi, functionName: 'multicall', @@ -195,6 +204,9 @@ export function useMultiMarketSupply( signForBundlers, usePermit2Setting, chainId, + bundlerAddress, + bundlerAddressErrorMessage, + isBundlerAddressValid, loanAsset, toast, tracking, @@ -205,6 +217,10 @@ export function useMultiMarketSupply( toast.error('No account connected', 'Please connect your wallet to continue.'); return false; } + if (!isBundlerAddressValid) { + toast.error('Unsupported network', bundlerAddressErrorMessage); + return false; + } try { // Start tracking with appropriate steps based on flow @@ -292,6 +308,8 @@ export function useMultiMarketSupply( tracking, tokenSymbol, supplies, + bundlerAddressErrorMessage, + isBundlerAddressValid, ]); return { diff --git a/src/hooks/useSupplyMarket.ts b/src/hooks/useSupplyMarket.ts index e7556d40..11c78ef5 100644 --- a/src/hooks/useSupplyMarket.ts +++ b/src/hooks/useSupplyMarket.ts @@ -1,5 +1,5 @@ import { useCallback, useState, type Dispatch, type SetStateAction } from 'react'; -import { type Address, encodeFunctionData, erc20Abi } from 'viem'; +import { type Address, encodeFunctionData, erc20Abi, zeroAddress } from 'viem'; import { useConnection, useBalance, useReadContract } from 'wagmi'; import morphoBundlerAbi from '@/abis/bundlerV2'; import { useERC20Approval } from '@/hooks/useERC20Approval'; @@ -65,6 +65,9 @@ export function useSupplyMarket(market: Market, onSuccess?: () => void): UseSupp const { address: account, chainId } = useConnection(); const { batchAddUserMarkets } = useUserMarketsCache(account); const toast = useStyledToast(); + const bundlerAddress = getBundlerV2(market.morphoBlue.chain.id); + const isBundlerAddressValid = bundlerAddress !== zeroAddress; + const bundlerAddressErrorMessage = `No bundler configured for chain ${market.morphoBlue.chain.id}.`; // Get token balance const { @@ -100,7 +103,7 @@ export function useSupplyMarket(market: Market, onSuccess?: () => void): UseSupp signForBundlers, } = usePermit2({ user: account as `0x${string}`, - spender: getBundlerV2(market.morphoBlue.chain.id), + spender: isBundlerAddressValid ? bundlerAddress : undefined, token: market.loanAsset.address as `0x${string}`, refetchInterval: 10_000, chainId: market.morphoBlue.chain.id, @@ -111,7 +114,7 @@ export function useSupplyMarket(market: Market, onSuccess?: () => void): UseSupp // Handle ERC20 approval const { isApproved, approve } = useERC20Approval({ token: market.loanAsset.address as Address, - spender: getBundlerV2(market.morphoBlue.chain.id), + spender: bundlerAddress, amount: supplyAmount, tokenSymbol: market.loanAsset.symbol, }); @@ -130,7 +133,9 @@ export function useSupplyMarket(market: Market, onSuccess?: () => void): UseSupp chainId, pendingDescription: `Supplying to market ${market.uniqueKey.slice(2, 8)}...`, successDescription: `Successfully supplied to market ${market.uniqueKey.slice(2, 8)}`, - onSuccess, + onSuccess: () => { + if (onSuccess) onSuccess(); + }, }); // Helper to generate steps based on flow type @@ -161,10 +166,16 @@ export function useSupplyMarket(market: Market, onSuccess?: () => void): UseSupp // Execute supply transaction const executeSupplyTransaction = useCallback(async () => { try { + if (!isBundlerAddressValid) { + throw new Error(bundlerAddressErrorMessage); + } + const txs: `0x${string}`[] = []; let gas: bigint | undefined = undefined; + // Supply flow does not need Morpho/Bundler authorization. + // We only compose transfer funding steps and then call morphoSupply in the same multicall. if (useEth) { txs.push( encodeFunctionData({ @@ -235,7 +246,7 @@ export function useSupplyMarket(market: Market, onSuccess?: () => void): UseSupp await sendTransactionAsync({ account, - to: getBundlerV2(market.morphoBlue.chain.id), + to: bundlerAddress, data: (encodeFunctionData({ abi: morphoBundlerAbi, functionName: 'multicall', @@ -256,9 +267,13 @@ export function useSupplyMarket(market: Market, onSuccess?: () => void): UseSupp complete(); return true; - } catch (_error: unknown) { + } catch (error: unknown) { fail(); - toast.error('Supply Failed', 'Supply to market failed or cancelled'); + if (error instanceof Error) { + toast.error('Supply Failed', error.message); + } else { + toast.error('Supply Failed', 'Supply to market failed or cancelled'); + } return false; } }, [ @@ -274,6 +289,9 @@ export function useSupplyMarket(market: Market, onSuccess?: () => void): UseSupp update, complete, fail, + bundlerAddress, + bundlerAddressErrorMessage, + isBundlerAddressValid, ]); // Approve and supply handler @@ -282,6 +300,10 @@ export function useSupplyMarket(market: Market, onSuccess?: () => void): UseSupp toast.info('No account connected', 'Please connect your wallet to continue.'); return; } + if (!isBundlerAddressValid) { + toast.error('Unsupported network', bundlerAddressErrorMessage); + return; + } try { const initialStep = useEth ? 'supplying' : 'approve'; @@ -373,6 +395,8 @@ export function useSupplyMarket(market: Market, onSuccess?: () => void): UseSupp getStepsForFlow, market, supplyAmount, + bundlerAddressErrorMessage, + isBundlerAddressValid, ]); // Sign and supply handler @@ -381,6 +405,10 @@ export function useSupplyMarket(market: Market, onSuccess?: () => void): UseSupp toast.info('No account connected', 'Please connect your wallet to continue.'); return; } + if (!isBundlerAddressValid) { + toast.error('Unsupported network', bundlerAddressErrorMessage); + return; + } try { start( @@ -409,7 +437,20 @@ export function useSupplyMarket(market: Market, onSuccess?: () => void): UseSupp toast.error('Transaction Error', 'An unexpected error occurred'); } } - }, [account, executeSupplyTransaction, toast, start, fail, getStepsForFlow, useEth, usePermit2Setting, market, supplyAmount]); + }, [ + account, + executeSupplyTransaction, + toast, + start, + fail, + getStepsForFlow, + useEth, + usePermit2Setting, + market, + supplyAmount, + bundlerAddressErrorMessage, + isBundlerAddressValid, + ]); return { // State diff --git a/src/hooks/useTransactionWithToast.tsx b/src/hooks/useTransactionWithToast.tsx index 5ef2b3c7..66e10865 100644 --- a/src/hooks/useTransactionWithToast.tsx +++ b/src/hooks/useTransactionWithToast.tsx @@ -3,6 +3,7 @@ import { toast } from 'react-toastify'; import { useSendTransaction, useWaitForTransactionReceipt } from 'wagmi'; import { StyledToast, TransactionToast } from '@/components/ui/styled-toast'; import { reportHandledError } from '@/utils/sentry'; +import { toUserFacingTransactionErrorMessage } from '@/utils/transaction-errors'; import { getExplorerTxURL } from '../utils/external'; import type { SupportedNetworks } from '../utils/networks'; @@ -127,7 +128,7 @@ export function useTransactionWithToast({ reportedErrorKeyRef.current = reportKey; } - const errorMessage = (txError ?? receiptError)?.message ?? 'Transaction Failed'; + const errorMessage = toUserFacingTransactionErrorMessage(txError ?? receiptError, 'Transaction failed'); toast.update(toastId, { render: ( diff --git a/src/types/token.ts b/src/types/token.ts index 373d0015..bead2f8c 100644 --- a/src/types/token.ts +++ b/src/types/token.ts @@ -1,4 +1,4 @@ -import type { Address } from 'viem'; +import { isAddress, type Address } from 'viem'; import { SupportedNetworks } from '@/utils/networks'; /** @@ -29,3 +29,12 @@ export const WETH_BY_CHAIN: Partial> = { export const getCanonicalWethAddress = (chainId: number): Address | undefined => { return WETH_BY_CHAIN[chainId as SupportedNetworks]; }; + +/** + * Normalizes an address for canonical token identity checks. + * Returns null when the input is not a valid EVM address. + */ +export const toCanonicalTokenAddress = (address: string | null | undefined): Address | null => { + if (!address || !isAddress(address)) return null; + return address.toLowerCase() as Address; +}; diff --git a/src/utils/decimal-input.ts b/src/utils/decimal-input.ts new file mode 100644 index 00000000..4739bf8d --- /dev/null +++ b/src/utils/decimal-input.ts @@ -0,0 +1,26 @@ +const DECIMAL_INPUT_REGEX = /^\d*\.?\d*$/; + +export const sanitizeDecimalInput = (value: string): string => { + // Normalize decimal separators only; do not strip unsupported characters. + // This keeps invalid formats invalid instead of mutating them into another number. + const normalized = value.trim().replace(/,/g, '.'); + const [wholePart, ...fractionParts] = normalized.split('.'); + if (fractionParts.length === 0) { + return wholePart; + } + return `${wholePart}.${fractionParts.join('')}`; +}; + +export const isValidDecimalInput = (value: string): boolean => { + return DECIMAL_INPUT_REGEX.test(value); +}; + +export const toParseableDecimalInput = (value: string): string | null => { + if (value === '' || value === '.') { + return null; + } + + const withLeadingZero = value.startsWith('.') ? `0${value}` : value; + const trimmedTrailingDot = withLeadingZero.endsWith('.') ? withLeadingZero.slice(0, -1) : withLeadingZero; + return trimmedTrailingDot.length > 0 ? trimmedTrailingDot : null; +}; diff --git a/src/utils/token-amount-format.ts b/src/utils/token-amount-format.ts index d345a0da..6b56d79e 100644 --- a/src/utils/token-amount-format.ts +++ b/src/utils/token-amount-format.ts @@ -1 +1 @@ -export { formatCompactTokenAmount, formatFullTokenAmount } from '@/hooks/leverage/math'; +export { formatCompactTokenAmount, formatFullTokenAmount, formatTokenAmountPreview } from '@/hooks/leverage/math'; diff --git a/src/utils/transaction-errors.ts b/src/utils/transaction-errors.ts new file mode 100644 index 00000000..b780892d --- /dev/null +++ b/src/utils/transaction-errors.ts @@ -0,0 +1,108 @@ +const USER_REJECTED_TRANSACTION_MESSAGE = 'User rejected transaction.'; +const USER_REJECTED_CODE = 4001; +const ACTION_REJECTED_CODE = 'ACTION_REJECTED'; +const ERROR_CAUSE_MAX_DEPTH = 6; + +const USER_REJECTED_PATTERNS = [ + 'user rejected', + 'rejected the request', + 'denied transaction signature', + 'user denied', + 'user cancelled', + 'user canceled', +]; + +type ErrorLike = { + code?: unknown; + message?: unknown; + shortMessage?: unknown; + details?: unknown; + cause?: unknown; +}; + +const isErrorLike = (value: unknown): value is ErrorLike => { + return typeof value === 'object' && value !== null; +}; + +const asNonEmptyString = (value: unknown): string | null => { + if (typeof value !== 'string') { + return null; + } + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : null; +}; + +const sanitizeViemErrorMessage = (message: string): string => { + let nextMessage = message; + for (const marker of ['Request Arguments:', 'Details:', 'Version:']) { + const index = nextMessage.indexOf(marker); + if (index !== -1) { + nextMessage = nextMessage.slice(0, index); + } + } + + const trimmed = nextMessage.trim(); + return trimmed.length > 0 ? trimmed : message; +}; + +const collectErrorChain = (error: unknown): ErrorLike[] => { + const chain: ErrorLike[] = []; + const visited = new Set(); + let current: unknown = error; + let depth = 0; + + while (isErrorLike(current) && !visited.has(current) && depth < ERROR_CAUSE_MAX_DEPTH) { + chain.push(current); + visited.add(current); + current = current.cause; + depth += 1; + } + + return chain; +}; + +export const isUserRejectedTransactionError = (error: unknown): boolean => { + if (!error) return false; + + for (const chainItem of collectErrorChain(error)) { + const code = chainItem.code; + if (code === USER_REJECTED_CODE || code === ACTION_REJECTED_CODE) { + return true; + } + + const messages = [chainItem.shortMessage, chainItem.message, chainItem.details]; + for (const candidate of messages) { + const normalized = asNonEmptyString(candidate)?.toLowerCase(); + if (!normalized) continue; + if (USER_REJECTED_PATTERNS.some((pattern) => normalized.includes(pattern))) { + return true; + } + } + } + + return false; +}; + +export const toUserFacingTransactionErrorMessage = (error: unknown, fallbackMessage: string): string => { + if (isUserRejectedTransactionError(error)) { + return USER_REJECTED_TRANSACTION_MESSAGE; + } + + for (const chainItem of collectErrorChain(error)) { + const shortMessage = asNonEmptyString(chainItem.shortMessage); + if (shortMessage) { + return sanitizeViemErrorMessage(shortMessage); + } + + const message = asNonEmptyString(chainItem.message); + if (message) { + return sanitizeViemErrorMessage(message); + } + } + + if (error instanceof Error && error.message.trim().length > 0) { + return sanitizeViemErrorMessage(error.message); + } + + return fallbackMessage; +};