diff --git a/package-lock.json b/package-lock.json index 2156a00ff..9c79f4bcb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,9 @@ "version": "1.0.0", "dependencies": { "@binance/w3w-rainbow-connector-v2": "^1.0.8", + "@cowprotocol/cow-sdk": "^7.4.1", + "@cowprotocol/sdk-app-data": "^4.6.7", + "@cowprotocol/sdk-viem-adapter": "^0.3.10", "@dnd-kit/core": "^6.0.5", "@dnd-kit/sortable": "^7.0.1", "@dnd-kit/utilities": "^3.2.0", @@ -63,6 +66,7 @@ "lucide-react": "^0.461.0", "mixpanel-browser": "2.56.0", "next-themes": "^0.4.4", + "p-limit": "^6.2.0", "react": "18.3.1", "react-dom": "18.3.1", "react-dropzone": "^14.3.5", @@ -80,6 +84,7 @@ "sonner": "^1.7.4", "tailwind-merge": "^2.5.5", "tailwindcss-animate": "^1.0.7", + "universal-sdk": "^0.1.34", "vaul": "^1.1.1", "viem": "^2.45.1", "wagmi": "2.19.5", @@ -110,6 +115,7 @@ "typescript": "5.6.3", "vite": "^7.3.1", "vite-bundle-visualizer": "1.2.1", + "vite-plugin-node-polyfills": "^0.25.0", "vite-plugin-static-copy": "^3.1.5", "vite-tsconfig-paths": "^6.0.4", "vitest": "^3.1.3", @@ -2094,18 +2100,18 @@ } }, "node_modules/@cowprotocol/cow-sdk": { - "version": "7.3.5", - "resolved": "https://registry.npmjs.org/@cowprotocol/cow-sdk/-/cow-sdk-7.3.5.tgz", - "integrity": "sha512-l7V5MPcj3zT1S7ClJk3uqoNlk3hvAFo11/5xfP61O9oMwdC8nkYemeY1gTD+qHUCXR3z/842GlYayfC82Taqog==", + "version": "7.4.1", + "resolved": "https://registry.npmjs.org/@cowprotocol/cow-sdk/-/cow-sdk-7.4.1.tgz", + "integrity": "sha512-eUo0lbnwGzby0i5qPbBcGtEbuhZQR6jEhERtGD9+Y8PWKivwss3aO1WKEtbLzuutHhgTF+UXdRXuuBo0hT6OlQ==", "license": "(MIT OR Apache-2.0)", "dependencies": { - "@cowprotocol/sdk-app-data": "4.6.3", - "@cowprotocol/sdk-common": "0.6.0", - "@cowprotocol/sdk-config": "0.7.3", - "@cowprotocol/sdk-contracts-ts": "1.5.0", - "@cowprotocol/sdk-order-book": "0.6.4", - "@cowprotocol/sdk-order-signing": "0.1.31", - "@cowprotocol/sdk-trading": "0.10.1" + "@cowprotocol/sdk-app-data": "4.6.7", + "@cowprotocol/sdk-common": "0.7.0", + "@cowprotocol/sdk-config": "0.10.0", + "@cowprotocol/sdk-contracts-ts": "1.8.0", + "@cowprotocol/sdk-order-book": "1.1.1", + "@cowprotocol/sdk-order-signing": "0.1.36", + "@cowprotocol/sdk-trading": "1.0.2" }, "peerDependencies": { "@openzeppelin/merkle-tree": "^1.x", @@ -2129,12 +2135,12 @@ } }, "node_modules/@cowprotocol/sdk-app-data": { - "version": "4.6.3", - "resolved": "https://registry.npmjs.org/@cowprotocol/sdk-app-data/-/sdk-app-data-4.6.3.tgz", - "integrity": "sha512-IY+RoiSyZZth7211EdxUlkSgztNXZx2PQNCXkymd35V5rfa76vw0/0Ug55ZpHHkHDnC3A6ILgXtRo8qkn9xM/g==", + "version": "4.6.7", + "resolved": "https://registry.npmjs.org/@cowprotocol/sdk-app-data/-/sdk-app-data-4.6.7.tgz", + "integrity": "sha512-5jI2xjuTEYvN8gAlzBqxsz5yWWhD11faRbnpvANhfDFIeFzEhhXryQxm6WQlBDoXEehZFG7CL033u91u4IVMAA==", "license": "MIT", "dependencies": { - "@cowprotocol/sdk-common": "0.6.0", + "@cowprotocol/sdk-common": "0.7.0", "ajv": "^8.11.0", "cross-fetch": "^3.1.5", "ipfs-only-hash": "^4.0.0", @@ -2149,18 +2155,18 @@ } }, "node_modules/@cowprotocol/sdk-common": { - "version": "0.6.0", - "resolved": "https://registry.npmjs.org/@cowprotocol/sdk-common/-/sdk-common-0.6.0.tgz", - "integrity": "sha512-USTxkoqhINZFPzJTyggE9HGD88vl0rE9N4On83WUDm/jTX0YqVZyHsA5116FTTUr83kDL+q9n+OALI49LW379A==", + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/@cowprotocol/sdk-common/-/sdk-common-0.7.0.tgz", + "integrity": "sha512-cJZ8qdDXFu4JWNFdbGvM9nspfTTxyeuQvhbbh20boztJ+O7KfanjuW4/pePkZRJAs0O8e187Vgih2nKqQnH3EA==", "license": "MIT", "dependencies": { - "@cowprotocol/sdk-config": "0.7.3" + "@cowprotocol/sdk-config": "0.10.0" } }, "node_modules/@cowprotocol/sdk-config": { - "version": "0.7.3", - "resolved": "https://registry.npmjs.org/@cowprotocol/sdk-config/-/sdk-config-0.7.3.tgz", - "integrity": "sha512-rDkld/1JRTBXzAKgI1a/GH2YnXdJtMTSX7VwSIUnxviySbUuUnx7JqA4BciAqoJfdoPw5yhwmxPr8TLf1trdhA==", + "version": "0.10.0", + "resolved": "https://registry.npmjs.org/@cowprotocol/sdk-config/-/sdk-config-0.10.0.tgz", + "integrity": "sha512-pLOsrqF6vs8EUCv8VpwEJ4VPcu2MPIphWo8nwdnvFg7ck23nseV3zBGlpyuftyae+fE9SnG71dPZNp8YlCZTPQ==", "license": "MIT", "dependencies": { "exponential-backoff": "^3.1.1", @@ -2168,23 +2174,23 @@ } }, "node_modules/@cowprotocol/sdk-contracts-ts": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/@cowprotocol/sdk-contracts-ts/-/sdk-contracts-ts-1.5.0.tgz", - "integrity": "sha512-JWDITbwQmNPdJU2emy67GVhWFih8grqOpt3+5/OBrLszhckcb7/O8jI8hUXOClUYC+qzghXZPsmfL/r6gefjFQ==", + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@cowprotocol/sdk-contracts-ts/-/sdk-contracts-ts-1.8.0.tgz", + "integrity": "sha512-C+h34iYCYZWbuWT1u+taCegtqxewPaBmKGLLU7YLX7IqOGHqZ9VM2Opavy0iS52BLmqyXH0V2deb+g9QbDcq1Q==", "license": "MIT", "dependencies": { - "@cowprotocol/sdk-common": "0.6.0", - "@cowprotocol/sdk-config": "0.7.3" + "@cowprotocol/sdk-common": "0.7.0", + "@cowprotocol/sdk-config": "0.10.0" } }, "node_modules/@cowprotocol/sdk-order-book": { - "version": "0.6.4", - "resolved": "https://registry.npmjs.org/@cowprotocol/sdk-order-book/-/sdk-order-book-0.6.4.tgz", - "integrity": "sha512-jViQ3Eed4fwpI3e8tCToGYWvmoiODlyxRyaNQKk4G32hi5VguzObN81vQpoksxP5xoUL8WAjedUhR+TEZuUwVg==", + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@cowprotocol/sdk-order-book/-/sdk-order-book-1.1.1.tgz", + "integrity": "sha512-aFeM3mkAC2gkBuEAvTQ9FcELfu/Zu9cir9s0An5oLDZR36lrfoNW31ma57biHDLjeDsbj81xrev/gS8/z3q4Gw==", "license": "MIT", "dependencies": { - "@cowprotocol/sdk-common": "0.6.0", - "@cowprotocol/sdk-config": "0.7.3", + "@cowprotocol/sdk-common": "0.7.0", + "@cowprotocol/sdk-config": "0.10.0", "cross-fetch": "^3.2.0", "exponential-backoff": "^3.1.2", "limiter": "^3.0.0" @@ -2197,39 +2203,39 @@ "license": "MIT" }, "node_modules/@cowprotocol/sdk-order-signing": { - "version": "0.1.31", - "resolved": "https://registry.npmjs.org/@cowprotocol/sdk-order-signing/-/sdk-order-signing-0.1.31.tgz", - "integrity": "sha512-fLnQy/kOwmWE6KTFYz0zUYksSFfkLEIo/lowhLz4FCLy5GDmgmmVm5cSeF1simmYlHa42Di9ZGYFrbJZHN8ORQ==", + "version": "0.1.36", + "resolved": "https://registry.npmjs.org/@cowprotocol/sdk-order-signing/-/sdk-order-signing-0.1.36.tgz", + "integrity": "sha512-Z76mUUjhIbGnJYww9o2s7P/ZUoOjKyJSoRtvbpwQ36Tcqjmdc3nkTeFdU67tbaPJiwr3/BMhKkSSX+bIg/VUSg==", "license": "MIT", "dependencies": { - "@cowprotocol/sdk-common": "0.6.0", - "@cowprotocol/sdk-config": "0.7.3", - "@cowprotocol/sdk-contracts-ts": "1.5.0", - "@cowprotocol/sdk-order-book": "0.6.4" + "@cowprotocol/sdk-common": "0.7.0", + "@cowprotocol/sdk-config": "0.10.0", + "@cowprotocol/sdk-contracts-ts": "1.8.0", + "@cowprotocol/sdk-order-book": "1.1.1" } }, "node_modules/@cowprotocol/sdk-trading": { - "version": "0.10.1", - "resolved": "https://registry.npmjs.org/@cowprotocol/sdk-trading/-/sdk-trading-0.10.1.tgz", - "integrity": "sha512-yaDYV2+HrCtIF2OofS0ngnvlMBj5dgL/6ul4lE1o8A2Cv44ueq4TVbQ3myFwu+5jWdIqDMy9Qz3WfTtwafNt3w==", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@cowprotocol/sdk-trading/-/sdk-trading-1.0.2.tgz", + "integrity": "sha512-bVngbTpSLDSetK4LOsE4WKrQIQazuiHVLR215abGGsBxvvrC17TZZ7WC+bzbPVUY042/sat+T2g/nByWvHg+4Q==", "license": "MIT", "dependencies": { - "@cowprotocol/sdk-app-data": "4.6.3", - "@cowprotocol/sdk-common": "0.6.0", - "@cowprotocol/sdk-config": "0.7.3", - "@cowprotocol/sdk-contracts-ts": "1.5.0", - "@cowprotocol/sdk-order-book": "0.6.4", - "@cowprotocol/sdk-order-signing": "0.1.31", + "@cowprotocol/sdk-app-data": "4.6.7", + "@cowprotocol/sdk-common": "0.7.0", + "@cowprotocol/sdk-config": "0.10.0", + "@cowprotocol/sdk-contracts-ts": "1.8.0", + "@cowprotocol/sdk-order-book": "1.1.1", + "@cowprotocol/sdk-order-signing": "0.1.36", "deepmerge": "^4.3.1" } }, "node_modules/@cowprotocol/sdk-viem-adapter": { - "version": "0.3.6", - "resolved": "https://registry.npmjs.org/@cowprotocol/sdk-viem-adapter/-/sdk-viem-adapter-0.3.6.tgz", - "integrity": "sha512-pCmx6NgZb2XCfKH77Gnvkk0SaUT58YtpT/PMCmJ3WiefJqA8UhKIA5GNXnpXRjqoqwBbltKu/J49ybW9KuP1DQ==", + "version": "0.3.10", + "resolved": "https://registry.npmjs.org/@cowprotocol/sdk-viem-adapter/-/sdk-viem-adapter-0.3.10.tgz", + "integrity": "sha512-94jspduAor+aPMcbSO1zIFKDHQFJ3SSLEtyT+/BRaLToTDWUjXO7ePYsf4+catzN4vUax5wOxUx8S3PLHc7meg==", "license": "MIT", "dependencies": { - "@cowprotocol/sdk-common": "0.6.0" + "@cowprotocol/sdk-common": "0.7.0" }, "peerDependencies": { "viem": "^2.28.4" @@ -2611,7 +2617,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "@ethersproject/address": "^5.8.0", "@ethersproject/bignumber": "^5.8.0", @@ -2639,7 +2644,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "@ethersproject/bignumber": "^5.8.0", "@ethersproject/bytes": "^5.8.0", @@ -2665,7 +2669,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "@ethersproject/abstract-provider": "^5.8.0", "@ethersproject/bignumber": "^5.8.0", @@ -2689,7 +2692,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "@ethersproject/bignumber": "^5.8.0", "@ethersproject/bytes": "^5.8.0", @@ -2713,7 +2715,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "@ethersproject/bytes": "^5.8.0" } @@ -2733,7 +2734,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "@ethersproject/bytes": "^5.8.0", "@ethersproject/properties": "^5.8.0" @@ -2754,7 +2754,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "@ethersproject/bytes": "^5.8.0", "@ethersproject/logger": "^5.8.0", @@ -2776,7 +2775,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "@ethersproject/logger": "^5.8.0" } @@ -2796,7 +2794,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "@ethersproject/bignumber": "^5.8.0" } @@ -2816,7 +2813,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "@ethersproject/abi": "^5.8.0", "@ethersproject/abstract-provider": "^5.8.0", @@ -2845,7 +2841,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "@ethersproject/abstract-signer": "^5.8.0", "@ethersproject/address": "^5.8.0", @@ -2873,7 +2868,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "@ethersproject/abstract-signer": "^5.8.0", "@ethersproject/basex": "^5.8.0", @@ -2904,7 +2898,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "@ethersproject/abstract-signer": "^5.8.0", "@ethersproject/address": "^5.8.0", @@ -2936,7 +2929,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "@ethersproject/bytes": "^5.8.0", "js-sha3": "0.8.0" @@ -2956,8 +2948,7 @@ "url": "https://www.buymeacoffee.com/ricmoo" } ], - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/@ethersproject/networks": { "version": "5.8.0", @@ -2974,7 +2965,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "@ethersproject/logger": "^5.8.0" } @@ -2994,7 +2984,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "@ethersproject/bytes": "^5.8.0", "@ethersproject/sha2": "^5.8.0" @@ -3015,7 +3004,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "@ethersproject/logger": "^5.8.0" } @@ -3035,7 +3023,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "@ethersproject/abstract-provider": "^5.8.0", "@ethersproject/abstract-signer": "^5.8.0", @@ -3064,7 +3051,6 @@ "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.0.tgz", "integrity": "sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw==", "license": "MIT", - "peer": true, "engines": { "node": ">=10.0.0" }, @@ -3096,7 +3082,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "@ethersproject/bytes": "^5.8.0", "@ethersproject/logger": "^5.8.0" @@ -3117,7 +3102,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "@ethersproject/bytes": "^5.8.0", "@ethersproject/logger": "^5.8.0" @@ -3138,7 +3122,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "@ethersproject/bytes": "^5.8.0", "@ethersproject/logger": "^5.8.0", @@ -3160,7 +3143,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "@ethersproject/bytes": "^5.8.0", "@ethersproject/logger": "^5.8.0", @@ -3185,7 +3167,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "@ethersproject/bignumber": "^5.8.0", "@ethersproject/bytes": "^5.8.0", @@ -3210,7 +3191,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "@ethersproject/bytes": "^5.8.0", "@ethersproject/constants": "^5.8.0", @@ -3232,7 +3212,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "@ethersproject/address": "^5.8.0", "@ethersproject/bignumber": "^5.8.0", @@ -3260,7 +3239,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "@ethersproject/bignumber": "^5.8.0", "@ethersproject/constants": "^5.8.0", @@ -3282,7 +3260,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "@ethersproject/abstract-provider": "^5.8.0", "@ethersproject/abstract-signer": "^5.8.0", @@ -3316,7 +3293,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "@ethersproject/base64": "^5.8.0", "@ethersproject/bytes": "^5.8.0", @@ -3340,7 +3316,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "@ethersproject/bytes": "^5.8.0", "@ethersproject/hash": "^5.8.0", @@ -7574,23 +7549,84 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/@reserve-protocol/trusted-fillers-sdk/node_modules/yocto-queue": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-1.2.2.tgz", - "integrity": "sha512-4LCcse/U2MHZ63HAJVE+v71o7yOdIe4cZ70Wpf8D/IyjDKYQLV5GD46B+hSTjJsvV5PztjvHoU580EftxjDZFQ==", + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-beta.53", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/plugin-inject": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/@rollup/plugin-inject/-/plugin-inject-5.0.5.tgz", + "integrity": "sha512-2+DEJbNBoPROPkgTDNe8/1YXWcqxbN5DTjASVIOx8HS+pITXushyNiBV56RB08zuptzz8gT3YfkqriTBVycepg==", + "dev": true, "license": "MIT", + "dependencies": { + "@rollup/pluginutils": "^5.0.1", + "estree-walker": "^2.0.2", + "magic-string": "^0.30.3" + }, "engines": { - "node": ">=12.20" + "node": ">=14.0.0" }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "peerDependencies": { + "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0" + }, + "peerDependenciesMeta": { + "rollup": { + "optional": true + } } }, - "node_modules/@rolldown/pluginutils": { - "version": "1.0.0-beta.53", + "node_modules/@rollup/plugin-inject/node_modules/estree-walker": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", + "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/pluginutils": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-5.3.0.tgz", + "integrity": "sha512-5EdhGZtnu3V88ces7s53hhfK5KSASnJZv8Lulpc04cWO3REESroJXg73DFsOmgbU2BhwV0E20bu2IDZb3VKW4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0", + "estree-walker": "^2.0.2", + "picomatch": "^4.0.2" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0" + }, + "peerDependenciesMeta": { + "rollup": { + "optional": true + } + } + }, + "node_modules/@rollup/pluginutils/node_modules/estree-walker": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", + "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", "dev": true, "license": "MIT" }, + "node_modules/@rollup/pluginutils/node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, "node_modules/@rollup/rollup-darwin-arm64": { "version": "4.55.3", "cpu": [ @@ -9427,6 +9463,58 @@ "react-dom": ">=16.8.0" } }, + "node_modules/@uniswap/permit2-sdk": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@uniswap/permit2-sdk/-/permit2-sdk-1.4.0.tgz", + "integrity": "sha512-l/aGhfhB93M76vXs4eB8QNwhELE6bs66kh7F1cyobaPtINaVpMmlJv+j3KmHeHwAZIsh7QXyYzhDxs07u0Pe4Q==", + "license": "MIT", + "dependencies": { + "ethers": "^5.7.0", + "tiny-invariant": "^1.1.0" + } + }, + "node_modules/@uniswap/sdk-core": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@uniswap/sdk-core/-/sdk-core-4.2.1.tgz", + "integrity": "sha512-hr7vwYrXScg+V8/rRc2UL/Ixc/p0P7yqe4D/OxzUdMRYr8RZd+8z5Iu9+WembjZT/DCdbTjde6lsph4Og0n1BQ==", + "license": "MIT", + "dependencies": { + "@ethersproject/address": "^5.0.2", + "big.js": "^5.2.2", + "decimal.js-light": "^2.5.0", + "jsbi": "^3.1.4", + "tiny-invariant": "^1.1.0", + "toformat": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@uniswap/sdk-core/node_modules/big.js": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/big.js/-/big.js-5.2.2.tgz", + "integrity": "sha512-vyL2OymJxmarO8gxMr0mhChsO9QGwhynfuu4+MHTAW6czfq9humCB7rKpUjDd9YUiDPU4mzpyupFSvOClAwbmQ==", + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/@uniswap/uniswapx-sdk": { + "version": "2.0.1-alpha.10", + "resolved": "https://registry.npmjs.org/@uniswap/uniswapx-sdk/-/uniswapx-sdk-2.0.1-alpha.10.tgz", + "integrity": "sha512-nDWJ9qLFBLId2lxJ8TMy15HIBlzQe2yFE6LEJSzEF1T5EGDFv1G/ioKQSm2LJg4cO11UfVouHKsn6rwYS6P9wA==", + "license": "MIT", + "dependencies": { + "@ethersproject/bytes": "^5.7.0", + "@ethersproject/providers": "^5.7.0", + "@uniswap/permit2-sdk": "^1.2.0", + "@uniswap/sdk-core": "^4.0.3", + "ethers": "^5.7.0" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/@vanilla-extract/css": { "version": "1.17.3", "license": "MIT", @@ -11022,8 +11110,7 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/aes-js/-/aes-js-3.0.0.tgz", "integrity": "sha512-H7wUZRn8WpTq9jocdxQ2c8x2sKo9ZVmzfRE13GiNJXfp7NcKYEdvl3vspKjXox6RIG2VtaRe4JFvxG4rqp2Zuw==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/agent-base": { "version": "6.0.2", @@ -11147,6 +11234,39 @@ "node": ">=0.10.0" } }, + "node_modules/asn1.js": { + "version": "4.10.1", + "resolved": "https://registry.npmjs.org/asn1.js/-/asn1.js-4.10.1.tgz", + "integrity": "sha512-p32cOF5q0Zqs9uBiONKYLm6BClCoBCM5O9JfeUSlnQLBTxYdTK+pW+nXflm8UkKd2UYlEbYz5qEi0JuZR9ckSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "bn.js": "^4.0.0", + "inherits": "^2.0.1", + "minimalistic-assert": "^1.0.0" + } + }, + "node_modules/asn1.js/node_modules/bn.js": { + "version": "4.12.3", + "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.3.tgz", + "integrity": "sha512-fGTi3gxV/23FTYdAoUtLYp6qySe2KE3teyZitipKNRuVYcBkoP/bB3guXN/XVKUe9mxCHXnc9C4ocyz8OmgN0g==", + "dev": true, + "license": "MIT" + }, + "node_modules/assert": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/assert/-/assert-2.1.0.tgz", + "integrity": "sha512-eLHpSK/Y4nhMJ07gDaAzoX/XAKS8PSaojml3M0DM4JpV1LAi5JOJ/p6H/XWrl8L+DzVEvVCW1z3vWAaB9oTsQw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.2", + "is-nan": "^1.3.2", + "object-is": "^1.1.5", + "object.assign": "^4.1.4", + "util": "^0.12.5" + } + }, "node_modules/assertion-error": { "version": "2.0.1", "dev": true, @@ -11392,8 +11512,7 @@ "version": "1.1.4", "resolved": "https://registry.npmjs.org/bech32/-/bech32-1.1.4.tgz", "integrity": "sha512-s0IrSOzLlbvX7yp4WBfPITzpAU8sqQcpsmwXDiKwrG4r491vwCO/XpejasRNl0piBMe/DvP4Tz0mIS/X1DPJBQ==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/bidi-js": { "version": "1.0.3", @@ -11514,8 +11633,150 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/brorand/-/brorand-1.1.0.tgz", "integrity": "sha512-cKV8tMCEpQs4hK/ik71d6LrPOnpkpGBR0wzxqr68g2m/LB2GxVYQroAjMJZRVM1Y4BCjCKc3vAamxSzOY2RP+w==", + "license": "MIT" + }, + "node_modules/browser-resolve": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/browser-resolve/-/browser-resolve-2.0.0.tgz", + "integrity": "sha512-7sWsQlYL2rGLy2IWm8WL8DCTJvYLc/qlOnsakDac87SOoCd16WLsaAMdCiAqsTNHIe+SXfaqyxyo6THoWqs8WQ==", + "dev": true, "license": "MIT", - "peer": true + "dependencies": { + "resolve": "^1.17.0" + } + }, + "node_modules/browserify-aes": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/browserify-aes/-/browserify-aes-1.2.0.tgz", + "integrity": "sha512-+7CHXqGuspUn/Sl5aO7Ea0xWGAtETPXNSAjHo48JfLdPWcMng33Xe4znFvQweqc/uzk5zSOI3H52CYnjCfb5hA==", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer-xor": "^1.0.3", + "cipher-base": "^1.0.0", + "create-hash": "^1.1.0", + "evp_bytestokey": "^1.0.3", + "inherits": "^2.0.1", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/browserify-cipher": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/browserify-cipher/-/browserify-cipher-1.0.1.tgz", + "integrity": "sha512-sPhkz0ARKbf4rRQt2hTpAHqn47X3llLkUGn+xEJzLjwY8LRs2p0v7ljvI5EyoRO/mexrNunNECisZs+gw2zz1w==", + "dev": true, + "license": "MIT", + "dependencies": { + "browserify-aes": "^1.0.4", + "browserify-des": "^1.0.0", + "evp_bytestokey": "^1.0.0" + } + }, + "node_modules/browserify-des": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/browserify-des/-/browserify-des-1.0.2.tgz", + "integrity": "sha512-BioO1xf3hFwz4kc6iBhI3ieDFompMhrMlnDFC4/0/vd5MokpuAc3R+LYbwTA9A5Yc9pq9UYPqffKpW2ObuwX5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "cipher-base": "^1.0.1", + "des.js": "^1.0.0", + "inherits": "^2.0.1", + "safe-buffer": "^5.1.2" + } + }, + "node_modules/browserify-rsa": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/browserify-rsa/-/browserify-rsa-4.1.1.tgz", + "integrity": "sha512-YBjSAiTqM04ZVei6sXighu679a3SqWORA3qZTEqZImnlkDIFtKc6pNutpjyZ8RJTjQtuYfeetkxM11GwoYXMIQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "bn.js": "^5.2.1", + "randombytes": "^2.1.0", + "safe-buffer": "^5.2.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/browserify-sign": { + "version": "4.2.5", + "resolved": "https://registry.npmjs.org/browserify-sign/-/browserify-sign-4.2.5.tgz", + "integrity": "sha512-C2AUdAJg6rlM2W5QMp2Q4KGQMVBwR1lIimTsUnutJ8bMpW5B52pGpR2gEnNBNwijumDo5FojQ0L9JrXA8m4YEw==", + "dev": true, + "license": "ISC", + "dependencies": { + "bn.js": "^5.2.2", + "browserify-rsa": "^4.1.1", + "create-hash": "^1.2.0", + "create-hmac": "^1.1.7", + "elliptic": "^6.6.1", + "inherits": "^2.0.4", + "parse-asn1": "^5.1.9", + "readable-stream": "^2.3.8", + "safe-buffer": "^5.2.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/browserify-sign/node_modules/isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/browserify-sign/node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "dev": true, + "license": "MIT", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/browserify-sign/node_modules/readable-stream/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "dev": true, + "license": "MIT" + }, + "node_modules/browserify-sign/node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "dev": true, + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, + "node_modules/browserify-sign/node_modules/string_decoder/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "dev": true, + "license": "MIT" + }, + "node_modules/browserify-zlib": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/browserify-zlib/-/browserify-zlib-0.2.0.tgz", + "integrity": "sha512-Z942RysHXmJrhqk88FmKBVq/v5tqmSkDz7p54G/MGyjMnCFFnC79XWNbg+Vta8W6Wb2qtSZTSxIGkJrRpCFEiA==", + "dev": true, + "license": "MIT", + "dependencies": { + "pako": "~1.0.5" + } }, "node_modules/browserslist": { "version": "4.28.1", @@ -11577,6 +11838,13 @@ "ieee754": "^1.2.1" } }, + "node_modules/buffer-xor": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/buffer-xor/-/buffer-xor-1.0.3.tgz", + "integrity": "sha512-571s0T7nZWK6vB67HI5dyUF7wXiNcfaPPPTl6zYCNApANjIvYJTg7hlud/+cJpdAhS7dVzqMLmfhfHR3rAcOjQ==", + "dev": true, + "license": "MIT" + }, "node_modules/bufferutil": { "version": "4.1.0", "hasInstallScript": true, @@ -11588,6 +11856,13 @@ "node": ">=6.14.2" } }, + "node_modules/builtin-status-codes": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/builtin-status-codes/-/builtin-status-codes-3.0.0.tgz", + "integrity": "sha512-HpGFw18DgFWlncDfjTa2rcQ4W88O1mC8e8yZ2AvQY5KDaktSTwo+KRf6nHK6FRI5FyRyb/5T6+TSxfP7QyGsmQ==", + "dev": true, + "license": "MIT" + }, "node_modules/cac": { "version": "6.7.14", "dev": true, @@ -11862,6 +12137,21 @@ "npm": ">=3.0.0" } }, + "node_modules/cipher-base": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/cipher-base/-/cipher-base-1.0.7.tgz", + "integrity": "sha512-Mz9QMT5fJe7bKI7MH31UilT5cEK5EHHRCccw/YRFsRY47AuNgaV6HY3rscp0/I4Q+tTW/5zoqpSeRRI54TkDWA==", + "dev": true, + "license": "MIT", + "dependencies": { + "inherits": "^2.0.4", + "safe-buffer": "^5.2.1", + "to-buffer": "^1.2.2" + }, + "engines": { + "node": ">= 0.10" + } + }, "node_modules/class-variance-authority": { "version": "0.7.1", "license": "Apache-2.0", @@ -12033,6 +12323,19 @@ "node": ">=14" } }, + "node_modules/console-browserify": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/console-browserify/-/console-browserify-1.2.0.tgz", + "integrity": "sha512-ZMkYO/LkF17QvCPqM0gxw8yUzigAOZOSWSHg91FH6orS7vcEj5dVZTidN2fQ14yBSdg97RqhSNwLUXInd52OTA==", + "dev": true + }, + "node_modules/constants-browserify": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/constants-browserify/-/constants-browserify-1.0.0.tgz", + "integrity": "sha512-xFxOwqIzR/e1k1gLiWEophSCMqXcwVHIH7akf7b/vxcUeGunlj3hvZaaqxwHsTgn+IndtkQJgSztIDWeumWJDQ==", + "dev": true, + "license": "MIT" + }, "node_modules/convert-source-map": { "version": "2.0.0", "license": "MIT" @@ -12103,8 +12406,62 @@ "node": ">=0.8" } }, - "node_modules/cross-fetch": { - "version": "3.2.0", + "node_modules/create-ecdh": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/create-ecdh/-/create-ecdh-4.0.4.tgz", + "integrity": "sha512-mf+TCx8wWc9VpuxfP2ht0iSISLZnt0JgWlrOKZiNqyUZWnjIaCIVNQArMHnCZKfEYRg6IM7A+NeJoN8gf/Ws0A==", + "dev": true, + "license": "MIT", + "dependencies": { + "bn.js": "^4.1.0", + "elliptic": "^6.5.3" + } + }, + "node_modules/create-ecdh/node_modules/bn.js": { + "version": "4.12.3", + "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.3.tgz", + "integrity": "sha512-fGTi3gxV/23FTYdAoUtLYp6qySe2KE3teyZitipKNRuVYcBkoP/bB3guXN/XVKUe9mxCHXnc9C4ocyz8OmgN0g==", + "dev": true, + "license": "MIT" + }, + "node_modules/create-hash": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/create-hash/-/create-hash-1.2.0.tgz", + "integrity": "sha512-z00bCGNHDG8mHAkP7CtT1qVu+bFQUPjYq/4Iv3C3kWjTFV10zIjfSoeqXo9Asws8gwSHDGj/hl2u4OGIjapeCg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cipher-base": "^1.0.1", + "inherits": "^2.0.1", + "md5.js": "^1.3.4", + "ripemd160": "^2.0.1", + "sha.js": "^2.4.0" + } + }, + "node_modules/create-hmac": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/create-hmac/-/create-hmac-1.1.7.tgz", + "integrity": "sha512-MJG9liiZ+ogc4TzUwuvbER1JRdgvUFSB5+VR/g5h82fGaIRWMWddtKBHi7/sVhfjQZ6SehlyhvQYrcYkaUIpLg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cipher-base": "^1.0.3", + "create-hash": "^1.1.0", + "inherits": "^2.0.1", + "ripemd160": "^2.0.0", + "safe-buffer": "^5.0.1", + "sha.js": "^2.4.8" + } + }, + "node_modules/create-require": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz", + "integrity": "sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/cross-fetch": { + "version": "3.2.0", "license": "MIT", "dependencies": { "node-fetch": "^2.7.0" @@ -12137,6 +12494,33 @@ "node": "*" } }, + "node_modules/crypto-browserify": { + "version": "3.12.1", + "resolved": "https://registry.npmjs.org/crypto-browserify/-/crypto-browserify-3.12.1.tgz", + "integrity": "sha512-r4ESw/IlusD17lgQi1O20Fa3qNnsckR126TdUuBgAu7GBYSIPvdNyONd3Zrxh0xCwA4+6w/TDArBPsMvhur+KQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "browserify-cipher": "^1.0.1", + "browserify-sign": "^4.2.3", + "create-ecdh": "^4.0.4", + "create-hash": "^1.2.0", + "create-hmac": "^1.1.7", + "diffie-hellman": "^5.0.3", + "hash-base": "~3.0.4", + "inherits": "^2.0.4", + "pbkdf2": "^3.1.2", + "public-encrypt": "^4.0.3", + "randombytes": "^2.1.0", + "randomfill": "^1.0.4" + }, + "engines": { + "node": ">= 0.10" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/css-selector-parser": { "version": "1.4.1", "license": "MIT" @@ -12580,6 +12964,17 @@ "valtio": "*" } }, + "node_modules/des.js": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/des.js/-/des.js-1.1.0.tgz", + "integrity": "sha512-r17GxjhUCjSRy8aiJpr8/UadFIzMzJGexI3Nmz4ADi9LYSFx4gTBp80+NaX/YsXWWLhpZ7v/v/ubEc/bCNfKwg==", + "dev": true, + "license": "MIT", + "dependencies": { + "inherits": "^2.0.1", + "minimalistic-assert": "^1.0.0" + } + }, "node_modules/destr": { "version": "2.0.5", "license": "MIT" @@ -12611,6 +13006,25 @@ "node": ">=0.3.1" } }, + "node_modules/diffie-hellman": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/diffie-hellman/-/diffie-hellman-5.0.3.tgz", + "integrity": "sha512-kqag/Nl+f3GwyK25fhUMYj81BUOrZ9IuJsjIcDE5icNM9FJHAVm3VcUDxdLPoQtTuUylWm6ZIknYJwwaPxsUzg==", + "dev": true, + "license": "MIT", + "dependencies": { + "bn.js": "^4.1.0", + "miller-rabin": "^4.0.0", + "randombytes": "^2.0.0" + } + }, + "node_modules/diffie-hellman/node_modules/bn.js": { + "version": "4.12.3", + "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.3.tgz", + "integrity": "sha512-fGTi3gxV/23FTYdAoUtLYp6qySe2KE3teyZitipKNRuVYcBkoP/bB3guXN/XVKUe9mxCHXnc9C4ocyz8OmgN0g==", + "dev": true, + "license": "MIT" + }, "node_modules/dijkstrajs": { "version": "1.0.3", "license": "MIT" @@ -12643,6 +13057,19 @@ "csstype": "^3.0.2" } }, + "node_modules/domain-browser": { + "version": "4.22.0", + "resolved": "https://registry.npmjs.org/domain-browser/-/domain-browser-4.22.0.tgz", + "integrity": "sha512-IGBwjF7tNk3cwypFNH/7bfzBcgSCbaMOD3GsaY1AU/JRrnHnYgEM0+9kQt52iZxjNsjBtJYtao146V+f8jFZNw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://bevry.me/fund" + } + }, "node_modules/dotenv": { "version": "16.6.1", "license": "BSD-2-Clause", @@ -12724,7 +13151,6 @@ "resolved": "https://registry.npmjs.org/elliptic/-/elliptic-6.6.1.tgz", "integrity": "sha512-RaddvvMatK2LJHqFJ+YA4WysVN5Ita9E35botqIYspQ4TkRAlCicdzKOjlyv/1Za5RyTNn7di//eEV0uTAfe3g==", "license": "MIT", - "peer": true, "dependencies": { "bn.js": "^4.11.9", "brorand": "^1.1.0", @@ -12739,8 +13165,7 @@ "version": "4.12.2", "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.2.tgz", "integrity": "sha512-n4DSx829VRTRByMRGdjQ9iqsN0Bh4OolPsFnaZBLcbi8iXcB+kJ9s7EnRt4wILZNV3kPLHkRVfOc/HvhC3ovDw==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/emoji-regex": { "version": "9.2.2", @@ -13155,7 +13580,55 @@ } ], "license": "MIT", - "peer": true, + "dependencies": { + "@ethersproject/abi": "5.8.0", + "@ethersproject/abstract-provider": "5.8.0", + "@ethersproject/abstract-signer": "5.8.0", + "@ethersproject/address": "5.8.0", + "@ethersproject/base64": "5.8.0", + "@ethersproject/basex": "5.8.0", + "@ethersproject/bignumber": "5.8.0", + "@ethersproject/bytes": "5.8.0", + "@ethersproject/constants": "5.8.0", + "@ethersproject/contracts": "5.8.0", + "@ethersproject/hash": "5.8.0", + "@ethersproject/hdnode": "5.8.0", + "@ethersproject/json-wallets": "5.8.0", + "@ethersproject/keccak256": "5.8.0", + "@ethersproject/logger": "5.8.0", + "@ethersproject/networks": "5.8.0", + "@ethersproject/pbkdf2": "5.8.0", + "@ethersproject/properties": "5.8.0", + "@ethersproject/providers": "5.8.0", + "@ethersproject/random": "5.8.0", + "@ethersproject/rlp": "5.8.0", + "@ethersproject/sha2": "5.8.0", + "@ethersproject/signing-key": "5.8.0", + "@ethersproject/solidity": "5.8.0", + "@ethersproject/strings": "5.8.0", + "@ethersproject/transactions": "5.8.0", + "@ethersproject/units": "5.8.0", + "@ethersproject/wallet": "5.8.0", + "@ethersproject/web": "5.8.0", + "@ethersproject/wordlists": "5.8.0" + } + }, + "node_modules/ethers5": { + "name": "ethers", + "version": "5.8.0", + "resolved": "https://registry.npmjs.org/ethers/-/ethers-5.8.0.tgz", + "integrity": "sha512-DUq+7fHrCg1aPDFCHx6UIPb3nmt2XMpM7Y/g2gLhsl3lIBqeAfOJIl1qEvRf2uq3BiKxmh6Fh5pfp2ieyek7Kg==", + "funding": [ + { + "type": "individual", + "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" + }, + { + "type": "individual", + "url": "https://www.buymeacoffee.com/ricmoo" + } + ], + "license": "MIT", "dependencies": { "@ethersproject/abi": "5.8.0", "@ethersproject/abstract-provider": "5.8.0", @@ -13204,6 +13677,17 @@ "node": ">=0.8.x" } }, + "node_modules/evp_bytestokey": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/evp_bytestokey/-/evp_bytestokey-1.0.3.tgz", + "integrity": "sha512-/f2Go4TognH/KvCISP7OUsHn85hT9nUkxxA9BEWxFn+Oj9o8ZNLm/40hdlgSLyuOimsrTKLUMEorQexp/aPQeA==", + "dev": true, + "license": "MIT", + "dependencies": { + "md5.js": "^1.3.4", + "safe-buffer": "^5.1.1" + } + }, "node_modules/execa": { "version": "7.2.0", "dev": true, @@ -13748,6 +14232,20 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/hash-base": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/hash-base/-/hash-base-3.0.5.tgz", + "integrity": "sha512-vXm0l45VbcHEVlTCzs8M+s0VeYsB2lnlAaThoLKGXr3bE/VWDOelNUnycUPEhKEaXARL2TEFjBOyUiM6+55KBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "inherits": "^2.0.4", + "safe-buffer": "^5.2.1" + }, + "engines": { + "node": ">= 0.10" + } + }, "node_modules/hash.js": { "version": "1.1.7", "license": "MIT", @@ -13951,7 +14449,6 @@ "resolved": "https://registry.npmjs.org/hmac-drbg/-/hmac-drbg-1.0.1.tgz", "integrity": "sha512-Tti3gMqLdZfhOQY1Mzf/AanLiqh1WTiJgEj26ZuYQ9fbkLomzGchCws4FyrSd4VkpBfiNhaE1On+lOz894jvXg==", "license": "MIT", - "peer": true, "dependencies": { "hash.js": "^1.0.3", "minimalistic-assert": "^1.0.0", @@ -14059,6 +14556,13 @@ "dev": true, "license": "MIT" }, + "node_modules/https-browserify": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/https-browserify/-/https-browserify-1.0.0.tgz", + "integrity": "sha512-J+FkSdyD+0mA0N+81tMotaRMfSL9SGi+xpD3T6YApKsc3bGSXJlfXri3VyFOeYkfLRQisDk1W+jIFFKBeUBbBg==", + "dev": true, + "license": "MIT" + }, "node_modules/https-proxy-agent": { "version": "5.0.1", "license": "MIT", @@ -14554,6 +15058,23 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-nan": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/is-nan/-/is-nan-1.3.2.tgz", + "integrity": "sha512-E+zBKpQ2t6MEo1VsonYmluk9NxGrbzpeeLC2xIViuO2EjU2xsXsBPwTr3Ykv9l08UYEVEdWeRZNouaZqF6RN0w==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.0", + "define-properties": "^1.1.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/is-number": { "version": "7.0.0", "license": "MIT", @@ -14764,6 +15285,16 @@ "version": "2.0.0", "license": "ISC" }, + "node_modules/isomorphic-timers-promises": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/isomorphic-timers-promises/-/isomorphic-timers-promises-1.0.1.tgz", + "integrity": "sha512-u4sej9B1LPSxTGKB/HiuzvEQnXH0ECYkSVQU39koSwmFAxhlEAFl9RdTvLv4TOTQUgBS5O3O5fwUxk6byBZ+IQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, "node_modules/isomorphic-ws": { "version": "4.0.1", "license": "MIT", @@ -15296,6 +15827,12 @@ "js-yaml": "bin/js-yaml.js" } }, + "node_modules/jsbi": { + "version": "3.2.5", + "resolved": "https://registry.npmjs.org/jsbi/-/jsbi-3.2.5.tgz", + "integrity": "sha512-aBE4n43IPvjaddScbvWRA2YlTzKEynHzu7MqOyTipdHucf/VxS63ViCjxYRg86M8Rxwbt/GfzHl1kKERkt45fQ==", + "license": "Apache-2.0" + }, "node_modules/jsdom": { "version": "27.4.0", "dev": true, @@ -15672,6 +16209,18 @@ "is-buffer": "~1.1.6" } }, + "node_modules/md5.js": { + "version": "1.3.5", + "resolved": "https://registry.npmjs.org/md5.js/-/md5.js-1.3.5.tgz", + "integrity": "sha512-xitP+WxNPcTTOgnTJcrhM0xvdPepipPSf3I8EIpGKeFLjt3PlJLIDG3u8EX53ZIubkb+5U2+3rELYpEhHhzdkg==", + "dev": true, + "license": "MIT", + "dependencies": { + "hash-base": "^3.0.0", + "inherits": "^2.0.1", + "safe-buffer": "^5.1.2" + } + }, "node_modules/md5/node_modules/is-buffer": { "version": "1.1.6", "license": "MIT" @@ -16482,6 +17031,27 @@ "node": ">=8.6" } }, + "node_modules/miller-rabin": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/miller-rabin/-/miller-rabin-4.0.1.tgz", + "integrity": "sha512-115fLhvZVqWwHPbClyntxEVfVDfl9DLLTuJvq3g2O/Oxi8AiNouAHvDSzHS0viUJc+V5vm3eq91Xwqn9dp4jRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "bn.js": "^4.0.0", + "brorand": "^1.0.1" + }, + "bin": { + "miller-rabin": "bin/miller-rabin" + } + }, + "node_modules/miller-rabin/node_modules/bn.js": { + "version": "4.12.3", + "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.3.tgz", + "integrity": "sha512-fGTi3gxV/23FTYdAoUtLYp6qySe2KE3teyZitipKNRuVYcBkoP/bB3guXN/XVKUe9mxCHXnc9C4ocyz8OmgN0g==", + "dev": true, + "license": "MIT" + }, "node_modules/mime-db": { "version": "1.52.0", "license": "MIT", @@ -16573,8 +17143,7 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/minimalistic-crypto-utils/-/minimalistic-crypto-utils-1.0.1.tgz", "integrity": "sha512-JIYlbt6g8i5jKfJ3xz7rF0LXmv2TkDxBLUkiBeZ7bAx4GnnNMr8xFpGnOxn6GhTEHx3SjRrZEoU+j04prX1ktg==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/minimatch": { "version": "10.1.1", @@ -16926,6 +17495,77 @@ "version": "2.0.27", "license": "MIT" }, + "node_modules/node-stdlib-browser": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/node-stdlib-browser/-/node-stdlib-browser-1.3.1.tgz", + "integrity": "sha512-X75ZN8DCLftGM5iKwoYLA3rjnrAEs97MkzvSd4q2746Tgpg8b8XWiBGiBG4ZpgcAqBgtgPHTiAc8ZMCvZuikDw==", + "dev": true, + "license": "MIT", + "dependencies": { + "assert": "^2.0.0", + "browser-resolve": "^2.0.0", + "browserify-zlib": "^0.2.0", + "buffer": "^5.7.1", + "console-browserify": "^1.1.0", + "constants-browserify": "^1.0.0", + "create-require": "^1.1.1", + "crypto-browserify": "^3.12.1", + "domain-browser": "4.22.0", + "events": "^3.0.0", + "https-browserify": "^1.0.0", + "isomorphic-timers-promises": "^1.0.1", + "os-browserify": "^0.3.0", + "path-browserify": "^1.0.1", + "pkg-dir": "^5.0.0", + "process": "^0.11.10", + "punycode": "^1.4.1", + "querystring-es3": "^0.2.1", + "readable-stream": "^3.6.0", + "stream-browserify": "^3.0.0", + "stream-http": "^3.2.0", + "string_decoder": "^1.0.0", + "timers-browserify": "^2.0.4", + "tty-browserify": "0.0.1", + "url": "^0.11.4", + "util": "^0.12.4", + "vm-browserify": "^1.0.1" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/node-stdlib-browser/node_modules/buffer": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", + "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.1.13" + } + }, + "node_modules/node-stdlib-browser/node_modules/punycode": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-1.4.1.tgz", + "integrity": "sha512-jmYNElW7yvO7TV33CjSmvSiE2yco3bV2czu/OzDKdMNVZQWfxCblURLhf+47syQRBntjfLdd/H0egrzIG+oaFQ==", + "dev": true, + "license": "MIT" + }, "node_modules/normalize-package-data": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/normalize-package-data/-/normalize-package-data-3.0.3.tgz", @@ -17216,6 +17856,13 @@ "node": ">=8" } }, + "node_modules/os-browserify": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/os-browserify/-/os-browserify-0.3.0.tgz", + "integrity": "sha512-gjcpUc3clBf9+210TRaDWbf+rZZZEshZ+DlXMRCeAjp0xhTrnQsKHypIy1J3d5hKdUzj69t708EHtU8P6bUn0A==", + "dev": true, + "license": "MIT" + }, "node_modules/ox": { "version": "0.12.4", "resolved": "https://registry.npmjs.org/ox/-/ox-0.12.4.tgz", @@ -17253,13 +17900,15 @@ "license": "MIT" }, "node_modules/p-limit": { - "version": "3.1.0", + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-6.2.0.tgz", + "integrity": "sha512-kuUqqHNUqoIWp/c467RI4X6mmyuojY5jGutNU0wVTmEOOfcuwLqyMVoAi9MKi2Ak+5i9+nhmrK4ufZE8069kHA==", "license": "MIT", "dependencies": { - "yocto-queue": "^0.1.0" + "yocto-queue": "^1.1.1" }, "engines": { - "node": ">=10" + "node": ">=18" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" @@ -17278,6 +17927,33 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/p-locate/node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate/node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/p-map": { "version": "7.0.4", "dev": true, @@ -17301,6 +17977,13 @@ "dev": true, "license": "BlueOak-1.0.0" }, + "node_modules/pako": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz", + "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==", + "dev": true, + "license": "(MIT AND Zlib)" + }, "node_modules/parent-module": { "version": "1.0.1", "license": "MIT", @@ -17311,6 +17994,23 @@ "node": ">=6" } }, + "node_modules/parse-asn1": { + "version": "5.1.9", + "resolved": "https://registry.npmjs.org/parse-asn1/-/parse-asn1-5.1.9.tgz", + "integrity": "sha512-fIYNuZ/HastSb80baGOuPRo1O9cf4baWw5WsAp7dBuUzeTD/BoaG8sVTdlPFksBE2lF21dN+A1AnrpIjSWqHHg==", + "dev": true, + "license": "ISC", + "dependencies": { + "asn1.js": "^4.10.1", + "browserify-aes": "^1.2.0", + "evp_bytestokey": "^1.0.3", + "pbkdf2": "^3.1.5", + "safe-buffer": "^5.2.1" + }, + "engines": { + "node": ">= 0.10" + } + }, "node_modules/parse-entities": { "version": "4.0.2", "license": "MIT", @@ -17359,6 +18059,13 @@ "url": "https://github.com/inikulin/parse5?sponsor=1" } }, + "node_modules/path-browserify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-browserify/-/path-browserify-1.0.1.tgz", + "integrity": "sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==", + "dev": true, + "license": "MIT" + }, "node_modules/path-exists": { "version": "4.0.0", "license": "MIT", @@ -17426,6 +18133,24 @@ "node": ">= 14.16" } }, + "node_modules/pbkdf2": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/pbkdf2/-/pbkdf2-3.1.5.tgz", + "integrity": "sha512-Q3CG/cYvCO1ye4QKkuH7EXxs3VC/rI1/trd+qX2+PolbaKG0H+bgcZzrTt96mMyRtejk+JMCiLUn3y29W8qmFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "create-hash": "^1.2.0", + "create-hmac": "^1.1.7", + "ripemd160": "^2.0.3", + "safe-buffer": "^5.2.1", + "sha.js": "^2.4.12", + "to-buffer": "^1.2.1" + }, + "engines": { + "node": ">= 0.10" + } + }, "node_modules/picocolors": { "version": "1.1.1", "license": "ISC" @@ -17486,6 +18211,19 @@ "node": ">= 6" } }, + "node_modules/pkg-dir": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/pkg-dir/-/pkg-dir-5.0.0.tgz", + "integrity": "sha512-NPE8TDbzl/3YQYY7CSS228s3g2ollTFnc+Qi3tqmqJp9Vg2ovUpixcJEo2HJScN2Ez+kEaal6y70c0ehqJBJeA==", + "dev": true, + "license": "MIT", + "dependencies": { + "find-up": "^5.0.0" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/playwright": { "version": "1.57.0", "devOptional": true, @@ -17745,6 +18483,16 @@ "dev": true, "license": "MIT" }, + "node_modules/process": { + "version": "0.11.10", + "resolved": "https://registry.npmjs.org/process/-/process-0.11.10.tgz", + "integrity": "sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6.0" + } + }, "node_modules/process-nextick-args": { "version": "2.0.1", "license": "MIT" @@ -17825,6 +18573,28 @@ "node": ">=16.0.0" } }, + "node_modules/public-encrypt": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/public-encrypt/-/public-encrypt-4.0.3.tgz", + "integrity": "sha512-zVpa8oKZSz5bTMTFClc1fQOnyyEzpl5ozpi1B5YcvBrdohMjH2rfsBtyXcuNuwjsDIXmBYlF2N5FlJYhR29t8Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "bn.js": "^4.1.0", + "browserify-rsa": "^4.0.0", + "create-hash": "^1.1.0", + "parse-asn1": "^5.0.0", + "randombytes": "^2.0.1", + "safe-buffer": "^5.1.2" + } + }, + "node_modules/public-encrypt/node_modules/bn.js": { + "version": "4.12.3", + "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.3.tgz", + "integrity": "sha512-fGTi3gxV/23FTYdAoUtLYp6qySe2KE3teyZitipKNRuVYcBkoP/bB3guXN/XVKUe9mxCHXnc9C4ocyz8OmgN0g==", + "dev": true, + "license": "MIT" + }, "node_modules/pump": { "version": "3.0.3", "license": "MIT", @@ -17870,6 +18640,22 @@ "react": "^16.8.0 || ^17.0.0 || ^18.0.0" } }, + "node_modules/qs": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.0.tgz", + "integrity": "sha512-mAZTtNCeetKMH+pSjrb76NAM8V9a05I9aBZOHztWy/UqcJdQYNsf59vrRKWnojAT9Y+GbIvoTBC++CPHqpDBhQ==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/query-string": { "version": "7.1.3", "license": "MIT", @@ -17886,6 +18672,15 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/querystring-es3": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/querystring-es3/-/querystring-es3-0.2.1.tgz", + "integrity": "sha512-773xhDQnZBMFobEiztv8LIl70ch5MSF/jUQVlhwFyBILqq96anmoctVIYz+ZRp0qbCKATTn6ev02M3r7Ga5vqA==", + "dev": true, + "engines": { + "node": ">=0.4.x" + } + }, "node_modules/queue-microtask": { "version": "1.2.3", "funding": [ @@ -17949,6 +18744,27 @@ "version": "1.1.2", "license": "MIT" }, + "node_modules/randombytes": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz", + "integrity": "sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "safe-buffer": "^5.1.0" + } + }, + "node_modules/randomfill": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/randomfill/-/randomfill-1.0.4.tgz", + "integrity": "sha512-87lcbR8+MhcWcUiQ+9e+Rwx8MyR2P7qnt15ynUlbm3TU/fjbgz4GsvfSUDTemtCCtVCqb4ZcEFlyPNTh9bBTLw==", + "dev": true, + "license": "MIT", + "dependencies": { + "randombytes": "^2.0.5", + "safe-buffer": "^5.1.0" + } + }, "node_modules/react": { "version": "18.3.1", "license": "MIT", @@ -18836,6 +19652,83 @@ "node": ">=0.10.0" } }, + "node_modules/ripemd160": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/ripemd160/-/ripemd160-2.0.3.tgz", + "integrity": "sha512-5Di9UC0+8h1L6ZD2d7awM7E/T4uA1fJRlx6zk/NvdCCVEoAnFqvHmCuNeIKoCeIixBX/q8uM+6ycDvF8woqosA==", + "dev": true, + "license": "MIT", + "dependencies": { + "hash-base": "^3.1.2", + "inherits": "^2.0.4" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/ripemd160/node_modules/hash-base": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/hash-base/-/hash-base-3.1.2.tgz", + "integrity": "sha512-Bb33KbowVTIj5s7Ked1OsqHUeCpz//tPwR+E2zJgJKo9Z5XolZ9b6bdUgjmYlwnWhoOQKoTd1TYToZGn5mAYOg==", + "dev": true, + "license": "MIT", + "dependencies": { + "inherits": "^2.0.4", + "readable-stream": "^2.3.8", + "safe-buffer": "^5.2.1", + "to-buffer": "^1.2.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/ripemd160/node_modules/isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/ripemd160/node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "dev": true, + "license": "MIT", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/ripemd160/node_modules/readable-stream/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "dev": true, + "license": "MIT" + }, + "node_modules/ripemd160/node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "dev": true, + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, + "node_modules/ripemd160/node_modules/string_decoder/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "dev": true, + "license": "MIT" + }, "node_modules/rollup": { "version": "4.55.3", "dev": true, @@ -19170,8 +20063,7 @@ "version": "3.0.1", "resolved": "https://registry.npmjs.org/scrypt-js/-/scrypt-js-3.0.1.tgz", "integrity": "sha512-cdwTTnqPu0Hyvf5in5asVdZocVDTNRmR7XEcJuIzMjJeSHybHl7vpB66AzwTaIg6CLSbtjcxc8fqcySfnTkccA==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/semver": { "version": "6.3.1", @@ -19236,6 +20128,13 @@ "node": ">= 0.4" } }, + "node_modules/setimmediate": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz", + "integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==", + "dev": true, + "license": "MIT" + }, "node_modules/sha.js": { "version": "2.4.12", "license": "(MIT AND BSD-3-Clause)", @@ -19589,10 +20488,34 @@ "node": ">= 0.4" } }, + "node_modules/stream-browserify": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/stream-browserify/-/stream-browserify-3.0.0.tgz", + "integrity": "sha512-H73RAHsVBapbim0tU2JwwOiXUj+fikfiaoYAKHF3VJfA0pe2BCzkhAHBlLG6REzE+2WNZcxOXjK7lkso+9euLA==", + "dev": true, + "license": "MIT", + "dependencies": { + "inherits": "~2.0.4", + "readable-stream": "^3.5.0" + } + }, "node_modules/stream-chain": { "version": "2.2.5", "license": "BSD-3-Clause" }, + "node_modules/stream-http": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/stream-http/-/stream-http-3.2.0.tgz", + "integrity": "sha512-Oq1bLqisTyK3TSCXpPbT4sdeYNdmyZJv1LxpEm2vu1ZhK89kSE5YXwZc3cWk0MagGaKriBh9mCFbVGtO+vY29A==", + "dev": true, + "license": "MIT", + "dependencies": { + "builtin-status-codes": "^3.0.0", + "inherits": "^2.0.4", + "readable-stream": "^3.6.0", + "xtend": "^4.0.2" + } + }, "node_modules/stream-json": { "version": "1.9.1", "license": "BSD-3-Clause", @@ -19996,6 +20919,19 @@ "tiny-worker": ">= 2" } }, + "node_modules/timers-browserify": { + "version": "2.0.12", + "resolved": "https://registry.npmjs.org/timers-browserify/-/timers-browserify-2.0.12.tgz", + "integrity": "sha512-9phl76Cqm6FhSX9Xe1ZUAMLtm1BLkKj2Qd5ApyWkXzsMRaA7dgr81kf4wJmQf/hAvg8EEyJxDo3du/0KlhPiKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "setimmediate": "^1.0.4" + }, + "engines": { + "node": ">=0.6.0" + } + }, "node_modules/tiny-invariant": { "version": "1.3.3", "license": "MIT" @@ -20128,6 +21064,12 @@ "node": ">=8.0" } }, + "node_modules/toformat": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/toformat/-/toformat-2.0.0.tgz", + "integrity": "sha512-03SWBVop6nU8bpyZCx7SodpYznbZF5R4ljwNLBcTQzKOD9xuihRo/psX58llS1BMFhhAI08H3luot5GoXJz2pQ==", + "license": "MIT" + }, "node_modules/tough-cookie": { "version": "6.0.0", "dev": true, @@ -20202,6 +21144,13 @@ "version": "2.8.1", "license": "0BSD" }, + "node_modules/tty-browserify": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/tty-browserify/-/tty-browserify-0.0.1.tgz", + "integrity": "sha512-C3TaO7K81YvjCgQH9Q1S3R3P3BtN3RIM8n+OvX4il1K1zgE8ZhI0op7kClgkxtutIE8hQrcrHBXvIheqKUUCxw==", + "dev": true, + "license": "MIT" + }, "node_modules/turbo-stream": { "version": "2.4.0", "license": "ISC" @@ -20428,6 +21377,28 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/universal-sdk": { + "version": "0.1.43", + "resolved": "https://registry.npmjs.org/universal-sdk/-/universal-sdk-0.1.43.tgz", + "integrity": "sha512-TGg0Tdvb6Vt9ySrgiVTU3iE2OhUT/9j2TZhgfIvCcoEPXJ1WyQf5uzDCf3YzvM77J5TUn+PE7vAO0HqXM0o8fA==", + "license": "ISC", + "dependencies": { + "@uniswap/uniswapx-sdk": "^2.0.1-alpha.10", + "axios": "^1.7.9", + "ethers5": "npm:ethers@5" + } + }, + "node_modules/universal-sdk/node_modules/axios": { + "version": "1.13.6", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.13.6.tgz", + "integrity": "sha512-ChTCHMouEe2kn713WHbQGcuYrr6fXTBiu460OTwWrWob16g1bXn4vtz07Ope7ewMozJAnEquLk5lWQWtBig9DQ==", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.15.11", + "form-data": "^4.0.5", + "proxy-from-env": "^1.1.0" + } + }, "node_modules/unplugin": { "version": "1.0.1", "license": "MIT", @@ -20498,6 +21469,27 @@ "browserslist": ">= 4.21.0" } }, + "node_modules/url": { + "version": "0.11.4", + "resolved": "https://registry.npmjs.org/url/-/url-0.11.4.tgz", + "integrity": "sha512-oCwdVC7mTuWiPyjLUz/COz5TLk6wgp0RCsN+wHZ2Ekneac9w8uuV0njcbbie2ME+Vs+d6duwmYuR3HgQXs1fOg==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^1.4.1", + "qs": "^6.12.3" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/url/node_modules/punycode": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-1.4.1.tgz", + "integrity": "sha512-jmYNElW7yvO7TV33CjSmvSiE2yco3bV2czu/OzDKdMNVZQWfxCblURLhf+47syQRBntjfLdd/H0egrzIG+oaFQ==", + "dev": true, + "license": "MIT" + }, "node_modules/use-callback-ref": { "version": "1.3.3", "license": "MIT", @@ -20885,6 +21877,23 @@ "url": "https://opencollective.com/vitest" } }, + "node_modules/vite-plugin-node-polyfills": { + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/vite-plugin-node-polyfills/-/vite-plugin-node-polyfills-0.25.0.tgz", + "integrity": "sha512-rHZ324W3LhfGPxWwQb2N048TThB6nVvnipsqBUJEzh3R9xeK9KI3si+GMQxCuAcpPJBVf0LpDtJ+beYzB3/chg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rollup/plugin-inject": "^5.0.5", + "node-stdlib-browser": "^1.3.1" + }, + "funding": { + "url": "https://github.com/sponsors/davidmyersdev" + }, + "peerDependencies": { + "vite": "^2.0.0 || ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" + } + }, "node_modules/vite-plugin-static-copy": { "version": "3.1.5", "dev": true, @@ -21127,6 +22136,13 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/vm-browserify": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vm-browserify/-/vm-browserify-1.1.2.tgz", + "integrity": "sha512-2ham8XPWTONajOR0ohOKOHXkm3+gaBmGut3SRuu75xLd/RRaY6vqgh8NBYYk7+RW3u5AtzPQZG8F10LHkl0lAQ==", + "dev": true, + "license": "MIT" + }, "node_modules/w3c-xmlserializer": { "version": "5.0.0", "dev": true, @@ -21673,10 +22689,12 @@ } }, "node_modules/yocto-queue": { - "version": "0.1.0", + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-1.2.2.tgz", + "integrity": "sha512-4LCcse/U2MHZ63HAJVE+v71o7yOdIe4cZ70Wpf8D/IyjDKYQLV5GD46B+hSTjJsvV5PztjvHoU580EftxjDZFQ==", "license": "MIT", "engines": { - "node": ">=10" + "node": ">=12.20" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" diff --git a/package.json b/package.json index a58e87145..87665d310 100644 --- a/package.json +++ b/package.json @@ -4,6 +4,9 @@ "type": "module", "dependencies": { "@binance/w3w-rainbow-connector-v2": "^1.0.8", + "@cowprotocol/cow-sdk": "^7.4.1", + "@cowprotocol/sdk-app-data": "^4.6.7", + "@cowprotocol/sdk-viem-adapter": "^0.3.10", "@dnd-kit/core": "^6.0.5", "@dnd-kit/sortable": "^7.0.1", "@dnd-kit/utilities": "^3.2.0", @@ -58,6 +61,7 @@ "lucide-react": "^0.461.0", "mixpanel-browser": "2.56.0", "next-themes": "^0.4.4", + "p-limit": "^6.2.0", "react": "18.3.1", "react-dom": "18.3.1", "react-dropzone": "^14.3.5", @@ -75,6 +79,7 @@ "sonner": "^1.7.4", "tailwind-merge": "^2.5.5", "tailwindcss-animate": "^1.0.7", + "universal-sdk": "^0.1.34", "vaul": "^1.1.1", "viem": "^2.45.1", "wagmi": "2.19.5", @@ -146,6 +151,7 @@ "typescript": "5.6.3", "vite": "^7.3.1", "vite-bundle-visualizer": "1.2.1", + "vite-plugin-node-polyfills": "^0.25.0", "vite-plugin-static-copy": "^3.1.5", "vite-tsconfig-paths": "^6.0.4", "vitest": "^3.1.3", diff --git a/src/abis/CowSwapSettlement.ts b/src/abis/CowSwapSettlement.ts new file mode 100644 index 000000000..e160e3d62 --- /dev/null +++ b/src/abis/CowSwapSettlement.ts @@ -0,0 +1,329 @@ +export default [ + { + inputs: [ + { + internalType: 'contract GPv2Authentication', + name: 'authenticator_', + type: 'address', + }, + { internalType: 'contract IVault', name: 'vault_', type: 'address' }, + ], + stateMutability: 'nonpayable', + type: 'constructor', + }, + { + anonymous: false, + inputs: [ + { + indexed: true, + internalType: 'address', + name: 'target', + type: 'address', + }, + { + indexed: false, + internalType: 'uint256', + name: 'value', + type: 'uint256', + }, + { + indexed: false, + internalType: 'bytes4', + name: 'selector', + type: 'bytes4', + }, + ], + name: 'Interaction', + type: 'event', + }, + { + anonymous: false, + inputs: [ + { + indexed: true, + internalType: 'address', + name: 'owner', + type: 'address', + }, + { + indexed: false, + internalType: 'bytes', + name: 'orderUid', + type: 'bytes', + }, + ], + name: 'OrderInvalidated', + type: 'event', + }, + { + anonymous: false, + inputs: [ + { + indexed: true, + internalType: 'address', + name: 'owner', + type: 'address', + }, + { + indexed: false, + internalType: 'bytes', + name: 'orderUid', + type: 'bytes', + }, + { indexed: false, internalType: 'bool', name: 'signed', type: 'bool' }, + ], + name: 'PreSignature', + type: 'event', + }, + { + anonymous: false, + inputs: [ + { + indexed: true, + internalType: 'address', + name: 'solver', + type: 'address', + }, + ], + name: 'Settlement', + type: 'event', + }, + { + anonymous: false, + inputs: [ + { + indexed: true, + internalType: 'address', + name: 'owner', + type: 'address', + }, + { + indexed: false, + internalType: 'contract IERC20', + name: 'sellToken', + type: 'address', + }, + { + indexed: false, + internalType: 'contract IERC20', + name: 'buyToken', + type: 'address', + }, + { + indexed: false, + internalType: 'uint256', + name: 'sellAmount', + type: 'uint256', + }, + { + indexed: false, + internalType: 'uint256', + name: 'buyAmount', + type: 'uint256', + }, + { + indexed: false, + internalType: 'uint256', + name: 'feeAmount', + type: 'uint256', + }, + { + indexed: false, + internalType: 'bytes', + name: 'orderUid', + type: 'bytes', + }, + ], + name: 'Trade', + type: 'event', + }, + { + inputs: [], + name: 'authenticator', + outputs: [ + { + internalType: 'contract GPv2Authentication', + name: '', + type: 'address', + }, + ], + stateMutability: 'view', + type: 'function', + }, + { + inputs: [], + name: 'domainSeparator', + outputs: [{ internalType: 'bytes32', name: '', type: 'bytes32' }], + stateMutability: 'view', + type: 'function', + }, + { + inputs: [{ internalType: 'bytes', name: '', type: 'bytes' }], + name: 'filledAmount', + outputs: [{ internalType: 'uint256', name: '', type: 'uint256' }], + stateMutability: 'view', + type: 'function', + }, + { + inputs: [{ internalType: 'bytes[]', name: 'orderUids', type: 'bytes[]' }], + name: 'freeFilledAmountStorage', + outputs: [], + stateMutability: 'nonpayable', + type: 'function', + }, + { + inputs: [{ internalType: 'bytes[]', name: 'orderUids', type: 'bytes[]' }], + name: 'freePreSignatureStorage', + outputs: [], + stateMutability: 'nonpayable', + type: 'function', + }, + { + inputs: [ + { internalType: 'uint256', name: 'offset', type: 'uint256' }, + { internalType: 'uint256', name: 'length', type: 'uint256' }, + ], + name: 'getStorageAt', + outputs: [{ internalType: 'bytes', name: '', type: 'bytes' }], + stateMutability: 'view', + type: 'function', + }, + { + inputs: [{ internalType: 'bytes', name: 'orderUid', type: 'bytes' }], + name: 'invalidateOrder', + outputs: [], + stateMutability: 'nonpayable', + type: 'function', + }, + { + inputs: [{ internalType: 'bytes', name: '', type: 'bytes' }], + name: 'preSignature', + outputs: [{ internalType: 'uint256', name: '', type: 'uint256' }], + stateMutability: 'view', + type: 'function', + }, + { + inputs: [ + { internalType: 'bytes', name: 'orderUid', type: 'bytes' }, + { internalType: 'bool', name: 'signed', type: 'bool' }, + ], + name: 'setPreSignature', + outputs: [], + stateMutability: 'nonpayable', + type: 'function', + }, + { + inputs: [ + { internalType: 'contract IERC20[]', name: 'tokens', type: 'address[]' }, + { internalType: 'uint256[]', name: 'clearingPrices', type: 'uint256[]' }, + { + components: [ + { internalType: 'uint256', name: 'sellTokenIndex', type: 'uint256' }, + { internalType: 'uint256', name: 'buyTokenIndex', type: 'uint256' }, + { internalType: 'address', name: 'receiver', type: 'address' }, + { internalType: 'uint256', name: 'sellAmount', type: 'uint256' }, + { internalType: 'uint256', name: 'buyAmount', type: 'uint256' }, + { internalType: 'uint32', name: 'validTo', type: 'uint32' }, + { internalType: 'bytes32', name: 'appData', type: 'bytes32' }, + { internalType: 'uint256', name: 'feeAmount', type: 'uint256' }, + { internalType: 'uint256', name: 'flags', type: 'uint256' }, + { internalType: 'uint256', name: 'executedAmount', type: 'uint256' }, + { internalType: 'bytes', name: 'signature', type: 'bytes' }, + ], + internalType: 'struct GPv2Trade.Data[]', + name: 'trades', + type: 'tuple[]', + }, + { + components: [ + { internalType: 'address', name: 'target', type: 'address' }, + { internalType: 'uint256', name: 'value', type: 'uint256' }, + { internalType: 'bytes', name: 'callData', type: 'bytes' }, + ], + internalType: 'struct GPv2Interaction.Data[][3]', + name: 'interactions', + type: 'tuple[][3]', + }, + ], + name: 'settle', + outputs: [], + stateMutability: 'nonpayable', + type: 'function', + }, + { + inputs: [ + { internalType: 'address', name: 'targetContract', type: 'address' }, + { internalType: 'bytes', name: 'calldataPayload', type: 'bytes' }, + ], + name: 'simulateDelegatecall', + outputs: [{ internalType: 'bytes', name: 'response', type: 'bytes' }], + stateMutability: 'nonpayable', + type: 'function', + }, + { + inputs: [ + { internalType: 'address', name: 'targetContract', type: 'address' }, + { internalType: 'bytes', name: 'calldataPayload', type: 'bytes' }, + ], + name: 'simulateDelegatecallInternal', + outputs: [{ internalType: 'bytes', name: 'response', type: 'bytes' }], + stateMutability: 'nonpayable', + type: 'function', + }, + { + inputs: [ + { + components: [ + { internalType: 'bytes32', name: 'poolId', type: 'bytes32' }, + { internalType: 'uint256', name: 'assetInIndex', type: 'uint256' }, + { internalType: 'uint256', name: 'assetOutIndex', type: 'uint256' }, + { internalType: 'uint256', name: 'amount', type: 'uint256' }, + { internalType: 'bytes', name: 'userData', type: 'bytes' }, + ], + internalType: 'struct IVault.BatchSwapStep[]', + name: 'swaps', + type: 'tuple[]', + }, + { internalType: 'contract IERC20[]', name: 'tokens', type: 'address[]' }, + { + components: [ + { internalType: 'uint256', name: 'sellTokenIndex', type: 'uint256' }, + { internalType: 'uint256', name: 'buyTokenIndex', type: 'uint256' }, + { internalType: 'address', name: 'receiver', type: 'address' }, + { internalType: 'uint256', name: 'sellAmount', type: 'uint256' }, + { internalType: 'uint256', name: 'buyAmount', type: 'uint256' }, + { internalType: 'uint32', name: 'validTo', type: 'uint32' }, + { internalType: 'bytes32', name: 'appData', type: 'bytes32' }, + { internalType: 'uint256', name: 'feeAmount', type: 'uint256' }, + { internalType: 'uint256', name: 'flags', type: 'uint256' }, + { internalType: 'uint256', name: 'executedAmount', type: 'uint256' }, + { internalType: 'bytes', name: 'signature', type: 'bytes' }, + ], + internalType: 'struct GPv2Trade.Data', + name: 'trade', + type: 'tuple', + }, + ], + name: 'swap', + outputs: [], + stateMutability: 'nonpayable', + type: 'function', + }, + { + inputs: [], + name: 'vault', + outputs: [{ internalType: 'contract IVault', name: '', type: 'address' }], + stateMutability: 'view', + type: 'function', + }, + { + inputs: [], + name: 'vaultRelayer', + outputs: [ + { internalType: 'contract GPv2VaultRelayer', name: '', type: 'address' }, + ], + stateMutability: 'view', + type: 'function', + }, + { stateMutability: 'payable', type: 'receive' }, +] as const diff --git a/src/app-routes.tsx b/src/app-routes.tsx index 5865aaeec..3376da515 100644 --- a/src/app-routes.tsx +++ b/src/app-routes.tsx @@ -9,6 +9,8 @@ import Issuance from '@/views/yield-dtf/issuance' import Overview from '@/views/yield-dtf/overview' import Settings from '@/views/yield-dtf/settings' import Staking from '@/views/yield-dtf/staking' +import Spinner from '@/components/ui/spinner' +import { lazy, Suspense } from 'react' import { Navigate, Route, Routes } from 'react-router-dom' import RTokenContainer from 'state/rtoken/RTokenContainer' import { GOVERNANCE_PROPOSAL_TYPES, ROUTES } from 'utils/constants' @@ -49,6 +51,7 @@ import IndexDTFFactsheet from './views/index-dtf/factsheet' import EarnIndexDTF from './views/earn/views/index-dtf' import EarnYieldDTF from './views/earn/views/yield-dtf' import EarnDefi from './views/earn/views/defi' +const AsyncMintWizard = lazy(() => import('./views/index-dtf/issuance/async-mint')) // TODO: Fix recoll call on yield dtf auction page const AppRoutes = () => ( @@ -104,6 +107,20 @@ const AppRoutes = () => ( path={`${ROUTES.ISSUANCE}/manual`} element={} /> + + + + } + > + + + } + /> } diff --git a/src/components/ui/label.tsx b/src/components/ui/label.tsx index 534182176..841669ee9 100644 --- a/src/components/ui/label.tsx +++ b/src/components/ui/label.tsx @@ -1,13 +1,11 @@ -"use client" +import * as React from 'react' +import * as LabelPrimitive from '@radix-ui/react-label' +import { cva, type VariantProps } from 'class-variance-authority' -import * as React from "react" -import * as LabelPrimitive from "@radix-ui/react-label" -import { cva, type VariantProps } from "class-variance-authority" - -import { cn } from "@/lib/utils" +import { cn } from '@/lib/utils' const labelVariants = cva( - "text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70" + 'text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70' ) const Label = React.forwardRef< diff --git a/src/components/ui/sonner.tsx b/src/components/ui/sonner.tsx index cbeb72969..fe4196ad1 100644 --- a/src/components/ui/sonner.tsx +++ b/src/components/ui/sonner.tsx @@ -7,11 +7,12 @@ type ToasterProps = React.ComponentProps const Toaster = ({ ...props }: ToasterProps) => { const { theme = 'system' } = useTheme() const isDesktop = useMediaQuery('(min-width: 1400px)') + const isTablet = useMediaQuery('(min-width: 768px) and (max-width: 1400px)') return ( { marginLeft: '516px', marginTop: '46px', } - : {}), + : isTablet + ? { + position: 'fixed', + right: '0%', + marginBottom: '44px', + marginRight: '10px', + } + : {}), }} theme={theme as ToasterProps['theme']} className="toaster group" diff --git a/src/components/ui/swap.tsx b/src/components/ui/swap.tsx index c4a0447ee..3fddfcef9 100644 --- a/src/components/ui/swap.tsx +++ b/src/components/ui/swap.tsx @@ -7,8 +7,10 @@ import { DropdownMenuTrigger, } from '@/components/ui/dropdown-menu' import { Input, NumericalInput } from '@/components/ui/input' +import useMediaQuery from '@/hooks/useMediaQuery' import { cn } from '@/lib/utils' import { chainIdAtom } from '@/state/atoms' +import { indexDTFAtom, indexDTFBrandAtom } from '@/state/dtf/atoms' import { Token } from '@/types' import { formatCurrency } from '@/utils' import { useAtomValue } from 'jotai' @@ -27,18 +29,16 @@ import React, { useState, } from 'react' import GaugeIcon from '../icons/GaugeIcon' -import { ToggleGroup, ToggleGroupItem } from './toggle-group' -import { Skeleton } from './skeleton' import { Accordion, AccordionContent, AccordionItem, AccordionTrigger, } from './accordion' -import { Separator } from './separator' import Help from './help' -import { indexDTFAtom, indexDTFBrandAtom } from '@/state/dtf/atoms' -import useMediaQuery from '@/hooks/useMediaQuery' +import { Separator } from './separator' +import { Skeleton } from './skeleton' +import { ToggleGroup, ToggleGroupItem } from './toggle-group' type TokenWithBalance = Token & { balance?: string } @@ -53,6 +53,8 @@ type SwapItem = { onChange?: (value: string) => void tokens?: TokenWithBalance[] onTokenSelect?: (token: Token) => void + disabled?: boolean + className?: string } type SwapProps = { @@ -61,10 +63,12 @@ type SwapProps = { onSwap?: () => void loading?: boolean } + const TokenInput = ({ value = '', onChange = () => {}, -}: Pick) => { + disabled = false, +}: Pick & { disabled?: boolean }) => { const ref = useRef(null) const isDesktop = useMediaQuery('(min-width: 768px)') @@ -86,6 +90,7 @@ const TokenInput = ({ className="placeholder:text-primary/70 text-primary" ref={ref} autoFocus={isDesktop} + disabled={disabled} /> ) } @@ -187,7 +192,11 @@ const PriceValue = ({ price }: Pick) => ( ) -const MaxButton = ({ balance, onMax }: Pick) => ( +const MaxButton = ({ + balance, + onMax, + disabled, +}: Pick & { disabled?: boolean }) => (
Balance {balance} @@ -196,19 +205,25 @@ const MaxButton = ({ balance, onMax }: Pick) => ( className="h-6 rounded-full ml-1 bg-primary/15 text-primary/80 hover:bg-primary/15 hover:text-primary/80 font-semibold" size="xs" onClick={onMax} + disabled={disabled} > Max
) -const TokenInputBox = ({ from }: Pick) => { +export const TokenInputBox = ({ from }: Pick) => { return ( -
+

{from?.title || 'You use:'}

- +
@@ -217,7 +232,11 @@ const TokenInputBox = ({ from }: Pick) => {
- +
@@ -282,7 +301,10 @@ const SlowLoading = ({ enabled }: { enabled: boolean }) => { ) } -const TokenOutputBox = ({ to, loading }: Pick) => { +export const TokenOutputBox = ({ + to, + loading, +}: Pick) => { const [slowLoading, setSlowLoading] = useState(false) useEffect(() => { @@ -311,7 +333,12 @@ const TokenOutputBox = ({ to, loading }: Pick) => { }, [loading]) return ( -
+

{to.title || 'You receive:'}

@@ -343,11 +370,17 @@ const TokenOutputBox = ({ to, loading }: Pick) => { ) } -const ArrowSeparator = ({ onSwap }: Pick) => { +export const ArrowSeparator = ({ + onSwap, + className, +}: Pick & { className?: string }) => { if (onSwap) { return ( + + {showBreakdown && ( +
+ {Object.entries(allocation).map(([address, alloc]) => { + const token = basket?.find( + (t) => t.address.toLowerCase() === address.toLowerCase() + ) + const decimals = token?.decimals ?? 18 + const totalAmount = alloc.fromWallet + alloc.fromSwap + + let badge = '' + if (alloc.explanation === 'Token at its maximum weight') + badge = 'At weight' + else if (alloc.explanation === 'Using your full balance') + badge = 'Max bal' + + return ( +
+
+ + + {formatTokenAmount( + Number(formatUnits(totalAmount, decimals)) + )}{' '} + {token?.symbol} + + + / ${formatCurrency(alloc.usdValue)} + +
+ {badge && ( + + {badge} + + )} +
+ ) + })} +
+ )} + + ) +} + +export default AllocationBreakdown diff --git a/src/views/index-dtf/issuance/async-mint/components/mint-execute.tsx b/src/views/index-dtf/issuance/async-mint/components/mint-execute.tsx new file mode 100644 index 000000000..fc553b000 --- /dev/null +++ b/src/views/index-dtf/issuance/async-mint/components/mint-execute.tsx @@ -0,0 +1,178 @@ +import dtfIndexAbi from '@/abis/dtf-index-abi' +import dtfIndexAbiV2 from '@/abis/dtf-index-abi-v2' +import TokenLogo from '@/components/token-logo' +import { Button } from '@/components/ui/button' +import { TransactionButtonContainer } from '@/components/ui/transaction-button' +import { useERC20Balances } from '@/hooks/useERC20Balance' +import { notifyError } from '@/hooks/useNotification' +import { chainIdAtom, walletAtom } from '@/state/atoms' +import { indexDTFAtom, indexDTFPriceAtom, indexDTFVersionAtom } from '@/state/dtf/atoms' +import { useAtomValue, useSetAtom } from 'jotai' +import { Loader } from 'lucide-react' +import { useMemo, useState } from 'react' +import { Address, encodeFunctionData, erc20Abi, parseEther } from 'viem' +import { useSendCalls } from 'wagmi' +import { useFolioDetails } from '../../async-swaps/hooks/useFolioDetails' +import { sendCallsWithRetry } from '../../async-swaps/hooks/utils' +import { ASYNC_MINT_BUFFER, actualMintedSharesAtom, mintAmountAtom, mintTxHashAtom, wizardStepAtom } from '../atoms' + +const MintExecute = () => { + const account = useAtomValue(walletAtom) + const indexDTF = useAtomValue(indexDTFAtom) + const chainId = useAtomValue(chainIdAtom) + const indexDTFVersion = useAtomValue(indexDTFVersionAtom) + const dtfPrice = useAtomValue(indexDTFPriceAtom) + const mintAmount = useAtomValue(mintAmountAtom) + const setMintTxHash = useSetAtom(mintTxHashAtom) + const setActualMintedShares = useSetAtom(actualMintedSharesAtom) + const setStep = useSetAtom(wizardStepAtom) + + const [isMinting, setIsMinting] = useState(false) + + // Calculate mint value with buffer + const parsedAmount = Number(mintAmount) || 0 + const safeDtfPrice = dtfPrice && dtfPrice > 0 ? dtfPrice : 0 + const mintValue = safeDtfPrice > 0 + ? (parsedAmount / safeDtfPrice) * (1 - ASYNC_MINT_BUFFER) + : 0 + const folioAmount = parseEther( + Math.max(mintValue, 0.000001).toFixed(18) + ) + + const { data: folioDetails } = useFolioDetails({ shares: safeDtfPrice > 0 ? folioAmount : undefined }) + const { sendCallsAsync } = useSendCalls() + + const { data: balanceData, isFetching: isFetchingBalanceData } = + useERC20Balances( + folioDetails?.assets.map((address) => ({ + address, + chainId, + })) || [] + ) + + const maxMintableAmount = useMemo(() => { + if (!folioDetails?.mintValues || !balanceData || !balanceData.length) { + return 0n + } + + const mintableAmounts = folioDetails.mintValues.map((mintValue, index) => { + if (mintValue === 0n) return 0n + const balance = balanceData[index] as bigint + return (balance * folioAmount) / mintValue + }) + + const participating = mintableAmounts.filter((_, i) => folioDetails.mintValues[i] > 0n) + return participating.length > 0 + ? participating.reduce((min, amount) => (amount < min ? amount : min)) + : 0n + }, [folioDetails?.mintValues, balanceData, folioAmount]) + + const handleMint = async () => { + if (!account || !folioDetails || !indexDTF || maxMintableAmount === 0n) return + + setIsMinting(true) + + try { + // WHY: Scale approvals from folioAmount to maxMintableAmount + 1% buffer + // mintValues are proportional to folioAmount, but we mint maxMintableAmount + const approvalCalls = folioDetails.assets.map((asset, i) => { + const scaledAmount = folioAmount > 0n + ? (folioDetails.mintValues[i] * maxMintableAmount) / folioAmount + : folioDetails.mintValues[i] + return { + to: asset as Address, + value: 0n, + data: encodeFunctionData({ + abi: erc20Abi, + functionName: 'approve', + args: [indexDTF.id, (scaledAmount * 101n) / 100n], + }), + } + }) + + const mintCall = { + to: indexDTF.id as Address, + value: 0n, + data: + indexDTFVersion === '2.0.0' + ? encodeFunctionData({ + abi: dtfIndexAbiV2, + functionName: 'mint', + args: [maxMintableAmount, account, (maxMintableAmount * 99n) / 100n], + }) + : encodeFunctionData({ + abi: dtfIndexAbi, + functionName: 'mint', + args: [maxMintableAmount, account, (maxMintableAmount * 99n) / 100n], + }), + } + + const result = await sendCallsWithRetry( + sendCallsAsync, + chainId, + [...approvalCalls, mintCall], + account as Address + ) + + setActualMintedShares(maxMintableAmount) + // WHY: wallet_sendCalls returns a bundle ID, not a tx hash. + // Store it — success-header validates format before linking to explorer. + setMintTxHash(result?.id) + setStep('success') + } catch (error) { + console.error('Mint execution failed:', error) + if (error instanceof Error && error.message !== 'USER_CANCELLED_TX') { + notifyError('Mint failed', 'Transaction failed. Please try again.') + } + } finally { + setIsMinting(false) + } + } + + if (!indexDTF) return null + + if (isMinting) { + return ( +
+
+
+ +
Confirm Mint
+
+
+ +
+
+
+ ) + } + + const canMint = maxMintableAmount > 0n && !isFetchingBalanceData + + return ( + + + + ) +} + +export default MintExecute diff --git a/src/views/index-dtf/issuance/async-mint/components/order-row.tsx b/src/views/index-dtf/issuance/async-mint/components/order-row.tsx new file mode 100644 index 000000000..073a4fe03 --- /dev/null +++ b/src/views/index-dtf/issuance/async-mint/components/order-row.tsx @@ -0,0 +1,129 @@ +import TokenLogo from '@/components/token-logo' +import { Skeleton } from '@/components/ui/skeleton' +import { cn } from '@/lib/utils' +import { chainIdAtom } from '@/state/atoms' +import { indexDTFBasketAtom } from '@/state/dtf/atoms' +import { formatCurrency, formatTokenAmount } from '@/utils' +import { ChainId } from '@/utils/chains' +import { OrderStatus as CowSwapOrderStatus } from '@cowprotocol/cow-sdk' +import { useAtomValue } from 'jotai' +import { ArrowUpRight, Check, Loader } from 'lucide-react' +import { Link } from 'react-router-dom' +import { formatUnits } from 'viem' +import { useOrderStatus } from '../hooks/use-order-status' +import { inputTokenAtom } from '../atoms' + +// CoW explorer uses no prefix for mainnet, chain name for others +const COW_EXPLORER_NETWORK: Record = { + [ChainId.Mainnet]: '', + [ChainId.Base]: 'base', +} + +function getCowExplorerUrl(chainId: number, orderId: string) { + const network = COW_EXPLORER_NETWORK[chainId] ?? 'base' + const prefix = network ? `/${network}` : '' + return `https://explorer.cow.fi${prefix}/orders/${orderId}` +} + +const STATUS_MAP: Record = { + [CowSwapOrderStatus.PRESIGNATURE_PENDING]: 'Processing', + [CowSwapOrderStatus.OPEN]: 'Processing', + [CowSwapOrderStatus.FULFILLED]: 'Order Filled', + [CowSwapOrderStatus.CANCELLED]: 'Not Filled', + [CowSwapOrderStatus.EXPIRED]: 'Not Filled', +} + +const OrderStatusBadge = ({ + status, + orderId, + chainId, +}: { + status: CowSwapOrderStatus + orderId: string + chainId: number +}) => { + const label = STATUS_MAP[status] + return ( +
+ {label === 'Order Filled' && } + {label === 'Processing' && ( + + )} + {label} + + + +
+ ) +} + +const OrderRow = ({ + orderId, + disableFetch, +}: { + orderId: string + disableFetch?: boolean +}) => { + const chainId = useAtomValue(chainIdAtom) + const { data } = useOrderStatus({ orderId, disabled: disableFetch }) + const basket = useAtomValue(indexDTFBasketAtom) + const inputToken = useAtomValue(inputTokenAtom) + + const token = data?.buyToken + const buyAmount = data?.buyAmount + const sellAmount = data?.sellAmount + const tokenInfo = basket?.find( + (t) => t.address.toLowerCase() === token?.toLowerCase() + ) + + return ( +
+
+ +
+ {sellAmount ? ( +
+ - {formatCurrency(Number(formatUnits(BigInt(sellAmount), inputToken.decimals)))} {inputToken.symbol} +
+ ) : ( + + )} + {buyAmount ? ( +
+ +{' '} + {formatTokenAmount( + Number(formatUnits(BigInt(buyAmount), tokenInfo?.decimals || 18)) + )}{' '} + {tokenInfo?.symbol || ''} +
+ ) : ( + + )} +
+
+ {data?.status ? ( + + ) : ( + + )} +
+ ) +} + +export default OrderRow diff --git a/src/views/index-dtf/issuance/async-mint/components/success-header.tsx b/src/views/index-dtf/issuance/async-mint/components/success-header.tsx new file mode 100644 index 000000000..9141bc892 --- /dev/null +++ b/src/views/index-dtf/issuance/async-mint/components/success-header.tsx @@ -0,0 +1,118 @@ +import StackTokenLogo from '@/components/token-logo/StackTokenLogo' +import { Button } from '@/components/ui/button' +import { chainIdAtom } from '@/state/atoms' +import { indexDTFAtom, indexDTFBasketAtom } from '@/state/dtf/atoms' +import { shortenAddress } from '@/utils' +import { ExplorerDataType, getExplorerLink } from '@/utils/getExplorerLink' +import { useAtomValue } from 'jotai' +import { ArrowUpRight, Check, ChevronRight, X } from 'lucide-react' +import { useEffect, useState } from 'react' +import { Link } from 'react-router-dom' + +const SuccessHeader = ({ + showTxs, + onToggleTxs, + onClose, + txHash, +}: { + showTxs: boolean + onToggleTxs: () => void + onClose: () => void + txHash?: string +}) => { + const indexDTF = useAtomValue(indexDTFAtom) + const basket = useAtomValue(indexDTFBasketAtom) + const chainId = useAtomValue(chainIdAtom) + const [showConfetti, setShowConfetti] = useState(true) + + // WHY: wallet_sendCalls returns a bundle ID, not always a tx hash + const isValidTxHash = txHash && /^0x[a-fA-F0-9]{64}$/.test(txHash) + + useEffect(() => { + const timer = setTimeout(() => setShowConfetti(false), 2000) + return () => clearTimeout(timer) + }, []) + + const txBadge = ( +
+
+ +
+ {isValidTxHash && ( +
+ + {shortenAddress(txHash)} + + +
+ )} +
+ ) + + return ( + <> + {showConfetti && ( +
+ )} +
+
+ {isValidTxHash ? ( + + {txBadge} + + ) : ( + txBadge + )} +
+ + +
+
+
+ + ) +} + +export default SuccessHeader diff --git a/src/views/index-dtf/issuance/async-mint/components/wizard-shell.tsx b/src/views/index-dtf/issuance/async-mint/components/wizard-shell.tsx new file mode 100644 index 000000000..e443ad4ea --- /dev/null +++ b/src/views/index-dtf/issuance/async-mint/components/wizard-shell.tsx @@ -0,0 +1,34 @@ +import { Button } from '@/components/ui/button' +import { ArrowLeft } from 'lucide-react' +import { ReactNode } from 'react' + +interface WizardShellProps { + children: ReactNode + onBack?: () => void + badge?: ReactNode +} + +const WizardShell = ({ children, onBack, badge }: WizardShellProps) => { + return ( +
+
+ {onBack ? ( + + ) : ( +
+ )} + {badge &&
{badge}
} +
+ {children} +
+ ) +} + +export default WizardShell diff --git a/src/views/index-dtf/issuance/async-mint/hooks/use-collateral-allocation.ts b/src/views/index-dtf/issuance/async-mint/hooks/use-collateral-allocation.ts new file mode 100644 index 000000000..5a401b71d --- /dev/null +++ b/src/views/index-dtf/issuance/async-mint/hooks/use-collateral-allocation.ts @@ -0,0 +1,94 @@ +import { useERC20Balances } from '@/hooks/useERC20Balance' +import { chainIdAtom, walletAtom } from '@/state/atoms' +import { RESERVE_API } from '@/utils/constants' +import { useQuery } from '@tanstack/react-query' +import { useAtomValue, useSetAtom } from 'jotai' +import { useEffect } from 'react' +import { Address } from 'viem' +import { useFolioDetails } from '../../async-swaps/hooks/useFolioDetails' +import { + folioDetailsAtom, + mintSharesAtom, + tokenPricesAtom, + walletBalancesAtom, +} from '../atoms' + +// Re-export so existing test imports keep working +export { calculateCollateralAllocation } from '../utils' + +// ─── Updater hook: fetches balances + prices + folio details ────────── +// NOTE: Must be mounted for walletBalancesAtom/tokenPricesAtom/folioDetailsAtom to have data +export function useAllocationData() { + const chainId = useAtomValue(chainIdAtom) + const mintShares = useAtomValue(mintSharesAtom) + const wallet = useAtomValue(walletAtom) + const setWalletBalances = useSetAtom(walletBalancesAtom) + const setTokenPrices = useSetAtom(tokenPricesAtom) + const setFolioDetails = useSetAtom(folioDetailsAtom) + + const folioResult = useFolioDetails({ + shares: mintShares > 0n ? mintShares : undefined, + }) + const folioData = folioResult.data + const isFolioLoading = 'isLoading' in folioResult ? (folioResult as any).isLoading : false + + const { data: balanceData, isLoading: isBalanceLoading } = useERC20Balances( + (folioData?.assets || []).map((address) => ({ + address, + chainId, + })) + ) + + // Sync folio details to atom + useEffect(() => { + if (folioData?.assets?.length) { + setFolioDetails({ + assets: [...folioData.assets], + mintValues: [...folioData.mintValues], + }) + } + }, [folioData, setFolioDetails]) + + // Sync balances to atom + useEffect(() => { + if (!folioData?.assets || !balanceData) return + const balances: Record = {} + folioData.assets.forEach((asset, i) => { + balances[asset.toLowerCase() as Address] = + (balanceData as bigint[])?.[i] ?? 0n + }) + setWalletBalances(balances) + }, [folioData?.assets, balanceData, setWalletBalances]) + + // Batch price fetch with 30s auto-refresh + const assetAddresses = (folioData?.assets || []).map(String) + const { data: priceData } = useQuery({ + queryKey: ['async-mint/prices', chainId, assetAddresses.join(',')], + queryFn: async () => { + const url = `${RESERVE_API}current/prices?chainId=${chainId}&tokens=${assetAddresses.join(',')}` + const res = await fetch(url) + if (!res.ok) throw new Error('Price fetch failed') + const data = await res.json() + if (data?.statusCode) throw new Error(data.message) + return data as { address: Address; price?: number }[] + }, + staleTime: 30_000, + refetchInterval: 30_000, + retry: 2, + enabled: assetAddresses.length > 0 && !!chainId, + }) + + useEffect(() => { + if (!priceData?.length) return + const prices: Record = {} + for (const p of priceData) { + if (p.price !== undefined) prices[p.address.toLowerCase() as Address] = p.price + } + setTokenPrices(prices) + }, [priceData, setTokenPrices]) + + return { + isLoading: isFolioLoading || isBalanceLoading, + folioDetails: folioData, + } +} diff --git a/src/views/index-dtf/issuance/async-mint/hooks/use-mint-quotes.ts b/src/views/index-dtf/issuance/async-mint/hooks/use-mint-quotes.ts new file mode 100644 index 000000000..891620ede --- /dev/null +++ b/src/views/index-dtf/issuance/async-mint/hooks/use-mint-quotes.ts @@ -0,0 +1,89 @@ +import { chainIdAtom, walletAtom } from '@/state/atoms' +import { useQuery } from '@tanstack/react-query' +import { useAtom, useAtomValue, useSetAtom } from 'jotai' +import { Address } from 'viem' +import { getCowswapQuote } from '../../async-swaps/hooks/useQuote' +import { useGlobalProtocolKit } from '../../async-swaps/providers/GlobalProtocolKitProvider' +import { + collateralAllocationAtom, + inputTokenAtom, + mintAmountAtom, + mintQuotesAtom, + quotesTimestampAtom, +} from '../atoms' +import { QuoteResult } from '../types' + +export function useMintQuotes() { + const address = useAtomValue(walletAtom) + const chainId = useAtomValue(chainIdAtom) + const inputToken = useAtomValue(inputTokenAtom) + const allocation = useAtomValue(collateralAllocationAtom) + const mintAmount = useAtomValue(mintAmountAtom) + const [quotes, setQuotes] = useAtom(mintQuotesAtom) + const setQuotesTimestamp = useSetAtom(quotesTimestampAtom) + const { orderBookApi } = useGlobalProtocolKit() + + // Only request quotes for tokens that need swaps + const tokensNeedingSwap = Object.entries(allocation).filter( + ([_, alloc]) => alloc.fromSwap > 0n + ) + + const query = useQuery({ + queryKey: [ + 'async-mint/quotes', + tokensNeedingSwap.map(([addr]) => addr), + mintAmount, + ], + queryFn: async () => { + if (!address || !orderBookApi || tokensNeedingSwap.length === 0) { + return {} + } + + const results: Record = {} + + await Promise.all( + tokensNeedingSwap.map(async ([tokenAddress, alloc]) => { + try { + const quote = await getCowswapQuote({ + sellToken: inputToken.address, + buyToken: tokenAddress as Address, + amount: alloc.fromSwap, + address: address as Address, + operation: 'mint', + orderBookApi, + }) + + if (quote) { + results[tokenAddress as Address] = { + success: true, + data: quote, + } + } else { + results[tokenAddress as Address] = { + success: false, + error: 'Quote returned null', + } + } + } catch (error) { + results[tokenAddress as Address] = { + success: false, + error: String(error), + } + } + }) + ) + + setQuotes(results) + setQuotesTimestamp(Date.now()) + return results + }, + enabled: false, // Manual trigger via refetch + }) + + return { + quotes, + refetch: query.refetch, + isLoading: query.isLoading, + isFetching: query.isFetching, + } +} diff --git a/src/views/index-dtf/issuance/async-mint/hooks/use-order-status.ts b/src/views/index-dtf/issuance/async-mint/hooks/use-order-status.ts new file mode 100644 index 000000000..71de8f961 --- /dev/null +++ b/src/views/index-dtf/issuance/async-mint/hooks/use-order-status.ts @@ -0,0 +1,47 @@ +import { OrderStatus } from '@cowprotocol/cow-sdk' +import { useQuery } from '@tanstack/react-query' +import { useSetAtom } from 'jotai' +import { useGlobalProtocolKit } from '../../async-swaps/providers/GlobalProtocolKitProvider' +import { ordersAtom } from '../atoms' + +interface UseOrderStatusParams { + orderId: string + disabled?: boolean +} + +const isOrderCompleted = (status: OrderStatus) => { + return [ + OrderStatus.FULFILLED, + OrderStatus.EXPIRED, + OrderStatus.CANCELLED, + ].includes(status) +} + +export function useOrderStatus({ orderId, disabled }: UseOrderStatusParams) { + const { orderBookApi } = useGlobalProtocolKit() + const setOrders = useSetAtom(ordersAtom) + + return useQuery({ + queryKey: ['async-mint/order-status', orderId], + enabled: !!orderId && !!orderBookApi && !disabled, + queryFn: async () => { + if (!orderBookApi) { + throw new Error('OrderBookApi not initialized') + } + + const order = await orderBookApi.getOrder(orderId) + setOrders((prev) => [ + ...prev.filter((o) => o.orderId !== orderId), + { ...order, orderId }, + ]) + + return order + }, + refetchInterval(query) { + if (query.state.data && isOrderCompleted(query.state.data.status)) { + return false + } + return 5_000 + }, + }) +} diff --git a/src/views/index-dtf/issuance/async-mint/hooks/use-recovery.ts b/src/views/index-dtf/issuance/async-mint/hooks/use-recovery.ts new file mode 100644 index 000000000..8ec25c7ad --- /dev/null +++ b/src/views/index-dtf/issuance/async-mint/hooks/use-recovery.ts @@ -0,0 +1,133 @@ +import { Address, formatUnits } from 'viem' + +// ─── Pure functions for testability ─────────────────────────────────── + +/** + * Check if acquired collateral is sufficient to mint the target amount. + * Used to distinguish Level 1 (simple retry) from Level 2 (decision needed). + */ +export function checkMintFeasibility( + acquiredBalances: Record, + targetMintValues: bigint[], + assets: Address[] +): boolean { + if (assets.length === 0 || targetMintValues.length === 0) return false + + for (let i = 0; i < assets.length; i++) { + const asset = assets[i] + const required = targetMintValues[i] + const acquired = acquiredBalances[asset.toLowerCase() as Address] ?? 0n + + // If any token is short, can't mint target amount + if (acquired < required) { + return false + } + } + return true +} + +/** + * Calculate how much extra USDC is needed to mint the full original amount. + */ +export function calculateTopUp( + totalNeeded: number, + totalAcquired: number +): { topUpAmount: number; description: string } { + const shortfall = Math.max(0, totalNeeded - totalAcquired) + return { + topUpAmount: shortfall, + description: `Approve an additional $${shortfall.toFixed(2)} USDC`, + } +} + +/** + * Calculate the max DTF mintable from only the acquired collateral. + * Uses the same logic as mint-button.tsx: min((balance * folioAmount) / mintValue) across all tokens. + */ +export function calculateReducedMint({ + acquiredBalances, + assets, + mintValues, + folioAmount, + dtfPrice, + slippageBps, +}: { + acquiredBalances: Record + assets: Address[] + mintValues: bigint[] + folioAmount: bigint + dtfPrice: number + slippageBps: number +}): { + reducedShares: bigint + unusedCollateral: Record + swapLossEstimate: number +} { + if (assets.length === 0 || folioAmount === 0n) { + return { reducedShares: 0n, unusedCollateral: {}, swapLossEstimate: 0 } + } + + // For each token, calculate how many folio tokens we can mint based on acquired balance + const mintableAmounts: bigint[] = [] + for (let i = 0; i < assets.length; i++) { + const balance = + acquiredBalances[assets[i].toLowerCase() as Address] ?? 0n + const mintValue = mintValues[i] + if (mintValue === 0n) { + mintableAmounts.push(0n) + continue + } + mintableAmounts.push((balance * folioAmount) / mintValue) + } + + // Take the minimum (bottleneck token) — only tokens with mintValue > 0n participate + const participatingAmounts = mintableAmounts.filter((_, i) => mintValues[i] > 0n) + const reducedShares = participatingAmounts.length > 0 + ? participatingAmounts.reduce((min, amount) => (amount < min ? amount : min)) + : 0n + + // Calculate unused collateral per token + const unusedCollateral: Record = {} + for (let i = 0; i < assets.length; i++) { + const balance = + acquiredBalances[assets[i].toLowerCase() as Address] ?? 0n + const usedForMint = + mintValues[i] > 0n ? (reducedShares * mintValues[i]) / folioAmount : 0n + const unused = balance - usedForMint + if (unused > 0n) { + unusedCollateral[assets[i]] = unused + } + } + + // Estimate swap loss for selling unused collateral back + const slippageMultiplier = 1 - slippageBps / 10000 + const swapLossEstimate = (1 - slippageMultiplier) * 100 + + return { reducedShares, unusedCollateral, swapLossEstimate } +} + +/** + * Estimate return from selling acquired collateral back to USDC. + */ +export function calculateReversalEstimate( + acquiredCollateral: Record, + prices: Record, + decimals: Record, + slippageBps: number +): { estimatedReturn: number; loss: number } { + const slippageMultiplier = 1 - slippageBps / 10000 + let totalBeforeSlippage = 0 + + for (const [address, amount] of Object.entries(acquiredCollateral)) { + const price = prices[address.toLowerCase() as Address] ?? 0 + const dec = decimals[address.toLowerCase() as Address] ?? 18 + totalBeforeSlippage += Number(formatUnits(amount, dec)) * price + } + + const estimatedReturn = totalBeforeSlippage * slippageMultiplier + + return { + estimatedReturn, + loss: totalBeforeSlippage - estimatedReturn, + } +} diff --git a/src/views/index-dtf/issuance/async-mint/hooks/use-reverse-orders.ts b/src/views/index-dtf/issuance/async-mint/hooks/use-reverse-orders.ts new file mode 100644 index 000000000..d5dc43689 --- /dev/null +++ b/src/views/index-dtf/issuance/async-mint/hooks/use-reverse-orders.ts @@ -0,0 +1,183 @@ +import { notifyError } from '@/hooks/useNotification' +import { chainIdAtom, walletAtom } from '@/state/atoms' +import { MetadataApi } from '@cowprotocol/sdk-app-data' +import { AppDataHash, SigningScheme } from '@cowprotocol/cow-sdk' +import { ViemAdapter } from '@cowprotocol/sdk-viem-adapter' +import { useMutation } from '@tanstack/react-query' +import { useAtomValue, useSetAtom } from 'jotai' +import { Address, maxUint256 } from 'viem' +import { usePublicClient, useSendCalls } from 'wagmi' +import { getCowswapQuote } from '../../async-swaps/hooks/useQuote' +import { + getCowswapPreSignTx, + COWSWAP_SETTLEMENT, + COWSWAP_VAULT, +} from '../../async-swaps/hooks/useQuoteSignatures' +import { + getApprovalCallIfNeeded, + sendCallsWithRetry, +} from '../../async-swaps/hooks/utils' +import { useGlobalProtocolKit } from '../../async-swaps/providers/GlobalProtocolKitProvider' +import { inputTokenAtom, reverseOrderIdsAtom, slippageAtom } from '../atoms' + +/** + * Shared hook for selling collateral back to USDC. + * Used by both "Cancel and reverse swaps" and "Convert leftover tokens". + */ +export function useReverseOrders() { + const chainId = useAtomValue(chainIdAtom) + const address = useAtomValue(walletAtom) + const inputToken = useAtomValue(inputTokenAtom) + const slippage = useAtomValue(slippageAtom) + const setReverseOrderIds = useSetAtom(reverseOrderIdsAtom) + const { orderBookApi } = useGlobalProtocolKit() + const { sendCallsAsync } = useSendCalls() + const publicClient = usePublicClient() + + const mutation = useMutation({ + mutationKey: ['async-mint/reverse-orders'], + mutationFn: async (tokensToSell: Record) => { + if (!address || !orderBookApi || !chainId) { + return { orderIds: [] as string[], totalEstimatedReturn: 0 } + } + + // Get quotes for selling each token back to input token + const entries = Object.entries(tokensToSell).filter( + ([_, amount]) => amount > 0n + ) + + const quotes = await Promise.all( + entries.map(async ([tokenAddress, amount]) => { + const quote = await getCowswapQuote({ + sellToken: tokenAddress as Address, + buyToken: inputToken.address, + amount, + address: address as Address, + operation: 'redeem', + orderBookApi, + }) + return { tokenAddress: tokenAddress as Address, quote, amount } + }) + ) + + const successfulQuotes = quotes.filter((q) => q.quote !== null) + + if (successfulQuotes.length === 0) { + notifyError('No quotes available', 'Could not get quotes for selling tokens back') + return { orderIds: [] as string[], totalEstimatedReturn: 0 } + } + + // Generate app data + let metadataApi: MetadataApi + if (publicClient) { + const viemAdapter = new ViemAdapter({ provider: publicClient }) + metadataApi = new MetadataApi(viemAdapter) + } else { + metadataApi = new MetadataApi() + } + + let appDataContent: string + let appDataHex: AppDataHash + + try { + const appDataDoc = await metadataApi.generateAppDataDoc({ + appCode: 'Reserve Protocol', + environment: 'production', + }) + const appDataInfo = await metadataApi.getAppDataInfo(appDataDoc) + appDataContent = appDataInfo.appDataContent + appDataHex = appDataInfo.appDataHex + } catch { + appDataContent = JSON.stringify({ + appCode: 'Reserve Protocol', + environment: 'production', + version: '1.0.0', + }) + appDataHex = ('0x' + '0'.repeat(64)) as AppDataHash + } + + // Generate pre-sign txs + const orderData = ( + await Promise.all( + successfulQuotes.map(async ({ quote }) => { + if (!quote) return undefined + return getCowswapPreSignTx({ + chainId, + orderQuote: quote, + operation: 'redeem', + address, + appDataHex, + slippageBps: Number(slippage), + }) + }) + ) + ).filter(Boolean) as any[] + + // Build tx array: approvals for each sell token + pre-signs + const txData: { to: Address; data: `0x${string}`; value: bigint }[] = [] + + for (const q of successfulQuotes) { + const approval = await getApprovalCallIfNeeded({ + chainId, + address: address as Address, + token: q.tokenAddress, + requiredAmount: q.amount, + approvalAmount: maxUint256, + spender: COWSWAP_VAULT as Address, + }) + if (approval) txData.push(approval) + } + + for (const data of orderData) { + txData.push({ + to: COWSWAP_SETTLEMENT as Address, + data: data.preSignTx, + value: 0n, + }) + } + + // Send atomic batch + if (txData.length > 0) { + await sendCallsWithRetry(sendCallsAsync, chainId, txData, address) + } + + // Submit to CowSwap with retries (best-effort — don't throw on failure) + const orderIds: string[] = [] + for (const data of orderData) { + let submitted = false + for (let attempt = 0; attempt < 3; attempt++) { + try { + const orderId = await orderBookApi.sendOrder({ + ...data.quote, + from: address, + signature: address, + signingScheme: SigningScheme.PRESIGN, + appData: appDataContent, + }) + orderIds.push(orderId) + submitted = true + break + } catch (error) { + console.error(`Reverse sendOrder attempt ${attempt + 1} failed:`, error) + if (attempt < 2) await new Promise(r => setTimeout(r, 1000 * Math.pow(2, attempt))) + } + } + if (!submitted) { + notifyError('Order submission failed', 'Could not submit reverse order. Your tokens are safe.') + } + } + + setReverseOrderIds(prev => [...prev, ...orderIds]) + + return { orderIds, totalEstimatedReturn: 0 } + }, + retry: false, + }) + + return { + reverse: mutation.mutate, + reverseAsync: mutation.mutateAsync, + isPending: mutation.isPending, + data: mutation.data, + } +} diff --git a/src/views/index-dtf/issuance/async-mint/hooks/use-submit-orders.ts b/src/views/index-dtf/issuance/async-mint/hooks/use-submit-orders.ts new file mode 100644 index 000000000..c816667a8 --- /dev/null +++ b/src/views/index-dtf/issuance/async-mint/hooks/use-submit-orders.ts @@ -0,0 +1,239 @@ +import { notifyError } from '@/hooks/useNotification' +import { chainIdAtom, walletAtom } from '@/state/atoms' +import { indexDTFAtom } from '@/state/dtf/atoms' +import { MetadataApi } from '@cowprotocol/sdk-app-data' +import { AppDataHash, SigningScheme } from '@cowprotocol/cow-sdk' +import { ViemAdapter } from '@cowprotocol/sdk-viem-adapter' +import { useMutation } from '@tanstack/react-query' +import { useAtomValue, useSetAtom } from 'jotai' +import { Address, maxUint256, parseUnits } from 'viem' +import { usePublicClient, useSendCalls } from 'wagmi' +import { + getCowswapPreSignTx, + COWSWAP_SETTLEMENT, + COWSWAP_VAULT, +} from '../../async-swaps/hooks/useQuoteSignatures' +import { + getApprovalCallIfNeeded, + sendCallsWithRetry, +} from '../../async-swaps/hooks/utils' +import { useGlobalProtocolKit } from '../../async-swaps/providers/GlobalProtocolKitProvider' +import { + failedOrdersAtom, + inputTokenAtom, + mintAmountAtom, + mintQuotesAtom, + orderIdsAtom, + ordersAtom, + ordersCreatedAtAtom, + slippageAtom, + wizardStepAtom, +} from '../atoms' + +type CowswapPreSignTx = { + orderId: string + quote: any + preSignTx: `0x${string}` + sellToken: string + amount: string +} + +export function useSubmitOrders(refresh = false) { + const chainId = useAtomValue(chainIdAtom) + const address = useAtomValue(walletAtom) + const indexDTF = useAtomValue(indexDTFAtom) + const inputToken = useAtomValue(inputTokenAtom) + const mintAmount = useAtomValue(mintAmountAtom) + const quotes = useAtomValue(mintQuotesAtom) + const slippage = useAtomValue(slippageAtom) + const failedOrders = useAtomValue(failedOrdersAtom) + const { orderBookApi } = useGlobalProtocolKit() + const { sendCallsAsync } = useSendCalls() + const publicClient = usePublicClient() + + const setOrderIds = useSetAtom(orderIdsAtom) + const setOrders = useSetAtom(ordersAtom) + const setOrdersCreatedAt = useSetAtom(ordersCreatedAtAtom) + const setQuotes = useSetAtom(mintQuotesAtom) + const setStep = useSetAtom(wizardStepAtom) + + const mutation = useMutation({ + mutationKey: ['async-mint/submit-orders', chainId, address, refresh], + mutationFn: async () => { + if (!address || !orderBookApi || !chainId || !indexDTF) { + console.error('Submit orders: missing prerequisites', { address: !!address, orderBookApi: !!orderBookApi, chainId, indexDTF: !!indexDTF }) + notifyError('Cannot submit', 'Wallet or protocol not ready. Please try again.') + return { orders: [] } + } + + // Generate app data + let metadataApi: MetadataApi + if (publicClient) { + const viemAdapter = new ViemAdapter({ provider: publicClient }) + metadataApi = new MetadataApi(viemAdapter) + } else { + metadataApi = new MetadataApi() + } + + let appDataContent: string + let appDataHex: AppDataHash + + try { + const appDataDoc = await metadataApi.generateAppDataDoc({ + appCode: 'Reserve Protocol', + environment: 'production', + }) + const appDataInfo = await metadataApi.getAppDataInfo(appDataDoc) + appDataContent = appDataInfo.appDataContent + appDataHex = appDataInfo.appDataHex + } catch (error) { + console.error('Failed to calculate appDataHex', error) + appDataContent = JSON.stringify({ + appCode: 'Reserve Protocol', + environment: 'production', + version: '1.0.0', + }) + appDataHex = ('0x' + '0'.repeat(64)) as AppDataHash + } + + // Filter successful quotes + const successfulQuotes = Object.entries(quotes).filter( + ([_, q]) => q.success + ) + + if (successfulQuotes.length === 0) { + return { orders: [] } + } + + // Generate pre-sign transactions + const orderData = ( + await Promise.all( + successfulQuotes.map(async ([_, quote]) => { + if (!quote.success) return undefined + return getCowswapPreSignTx({ + chainId, + orderQuote: quote.data, + operation: 'mint', + address, + appDataHex, + slippageBps: Number(slippage), + }) + }) + ) + ).filter((data): data is CowswapPreSignTx => !!data) + + // Build transaction array: pre-sign calls + const txData: { to: Address; data: `0x${string}`; value: bigint }[] = + orderData.map((data) => ({ + to: COWSWAP_SETTLEMENT as Address, + data: data.preSignTx, + value: 0n, + })) + + // Add input token approval (at the beginning) + const requiredAmount = parseUnits(mintAmount, inputToken.decimals) + const approvalCall = await getApprovalCallIfNeeded({ + chainId, + address: address as Address, + token: inputToken.address as Address, + requiredAmount, + approvalAmount: maxUint256, + spender: COWSWAP_VAULT as Address, + }) + if (approvalCall) txData.unshift(approvalCall) + + // Send atomic batch + if (txData.length > 0) { + try { + await sendCallsWithRetry(sendCallsAsync, chainId, txData, address) + } catch (error) { + console.error('sendCallsAsync failed', error) + notifyError('Transaction failed', 'Please try again') + throw error + } + } + + // Submit orders to CowSwap orderbook with retries + // WHY: Save IDs as we go — if one order fails, we still track the rest + const orderIds: string[] = [] + let failedCount = 0 + for (const data of orderData) { + let submitted = false + for (let attempt = 0; attempt < 3; attempt++) { + try { + const orderId = await orderBookApi.sendOrder({ + ...data.quote, + from: address, + signature: address, + signingScheme: SigningScheme.PRESIGN, + appData: appDataContent, + }) + orderIds.push(orderId) + submitted = true + break + } catch (error) { + console.error( + `sendOrder attempt ${attempt + 1} failed:`, + error + ) + if (attempt < 2) { + await new Promise((r) => + setTimeout(r, 1000 * Math.pow(2, attempt)) + ) + } + } + } + if (!submitted) { + failedCount++ + } + } + + if (failedCount > 0) { + notifyError( + 'Some orders failed', + `${failedCount} of ${orderData.length} orders could not be submitted. Your tokens are safe.` + ) + } + + // Update state — proceed to processing even with partial success + if (orderIds.length > 0) { + if (refresh) { + setOrderIds((prev) => [ + ...prev.filter( + (id) => + !failedOrders.map((o) => o.orderId).includes(id) + ), + ...orderIds, + ]) + setOrders((prev) => + prev.filter( + (o) => + !failedOrders.map((fo) => fo.orderId).includes(o.orderId) + ) + ) + } else { + setOrderIds(orderIds) + } + setOrdersCreatedAt(new Date().toISOString()) + setQuotes({}) + setStep('processing') + } else { + // All orders failed — don't transition + throw new Error( + 'Orders signed on-chain but not submitted to CowSwap. Your tokens are safe.' + ) + } + + return { orders: orderIds } + }, + onError(error) { + console.error('Submit orders error:', error) + }, + retry: false, + }) + + return { + submit: mutation.mutate, + isPending: mutation.isPending, + } +} diff --git a/src/views/index-dtf/issuance/async-mint/index.tsx b/src/views/index-dtf/issuance/async-mint/index.tsx new file mode 100644 index 000000000..3c7a6bb69 --- /dev/null +++ b/src/views/index-dtf/issuance/async-mint/index.tsx @@ -0,0 +1,95 @@ +import useAtomicBatch from '@/hooks/use-atomic-batch' +import { Skeleton } from '@/components/ui/skeleton' +import { indexDTFAtom } from '@/state/dtf/atoms' +import { useAtomValue, useSetAtom } from 'jotai' +import { useEffect } from 'react' +import useTrackIndexDTFPage from '../../hooks/useTrackIndexDTFPage' +import { GlobalProtocolKitProvider } from '../async-swaps/providers/GlobalProtocolKitProvider' +import { wizardStepAtom } from './atoms' +import { useAllocationData } from './hooks/use-collateral-allocation' +import GnosisRequired from './steps/gnosis-required' +import OperationSelect from './steps/operation-select' +import CollateralDecision from './steps/collateral-decision' +import TokenSelection from './steps/token-selection' +import AmountInput from './steps/amount-input' +import ReviewInputs from './steps/review-inputs' +import QuoteSummary from './steps/quote-summary' +import Processing from './steps/processing' +import RecoveryOptions from './steps/recovery-options' +import Success from './steps/success' + +const WizardRouter = () => { + const step = useAtomValue(wizardStepAtom) + const { atomicSupported, isLoading } = useAtomicBatch() + const setStep = useSetAtom(wizardStepAtom) + + // Auto-skip gnosis check when wallet supports atomic batch + useEffect(() => { + if (step === 'gnosis-check' && atomicSupported && !isLoading) { + setStep('operation-select') + } + }, [step, atomicSupported, isLoading, setStep]) + + if (isLoading) { + return + } + + switch (step) { + case 'gnosis-check': + return + case 'operation-select': + return + case 'collateral-decision': + return + case 'token-selection': + return + case 'amount-input': + return + case 'review': + return + case 'quote-summary': + return + case 'processing': + return + case 'recovery-options': + return + case 'success': + return + default: + return + } +} + +// Keeps balance/price syncing alive across all wizard steps +const DataSync = () => { + useAllocationData() + return null +} + +const AsyncMintWizard = () => { + useTrackIndexDTFPage('mint-async-wizard') + const indexDTF = useAtomValue(indexDTFAtom) + + if (!indexDTF) return null + + return ( +
+
+
+ + +
+
+
+ ) +} + +const AsyncMintWithProvider = () => { + return ( + + + + ) +} + +export default AsyncMintWithProvider diff --git a/src/views/index-dtf/issuance/async-mint/steps/amount-input.tsx b/src/views/index-dtf/issuance/async-mint/steps/amount-input.tsx new file mode 100644 index 000000000..589398532 --- /dev/null +++ b/src/views/index-dtf/issuance/async-mint/steps/amount-input.tsx @@ -0,0 +1,206 @@ +import { Button } from '@/components/ui/button' +import { NumericalInput } from '@/components/ui/input' +import { balancesAtom } from '@/state/atoms' +import { indexDTFBasketAtom } from '@/state/dtf/atoms' +import { formatCurrency } from '@/utils' +import { useAtom, useAtomValue, useSetAtom } from 'jotai' +import { ArrowLeft, Minus, Plus } from 'lucide-react' +import { useEffect, useMemo, useState } from 'react' +import { Address, formatUnits } from 'viem' +import { + inputTokenAtom, + mintAmountAtom, + mintStrategyAtom, + selectedCollateralsAtom, + tokenPricesAtom, + walletBalancesAtom, + wizardStepAtom, +} from '../atoms' +import { calculateMaxMintAmount } from '../utils' +const AmountInput = () => { + const setStep = useSetAtom(wizardStepAtom) + const strategy = useAtomValue(mintStrategyAtom) + const inputToken = useAtomValue(inputTokenAtom) + const [amount, setAmount] = useAtom(mintAmountAtom) + const balances = useAtomValue(balancesAtom) + const [showExplanation, setShowExplanation] = useState(false) + + const walletBalances = useAtomValue(walletBalancesAtom) + const tokenPrices = useAtomValue(tokenPricesAtom) + const selectedCollaterals = useAtomValue(selectedCollateralsAtom) + const basket = useAtomValue(indexDTFBasketAtom) + + const inputBalance = balances[inputToken.address] + const availableBalance = inputBalance + ? Number(formatUnits(inputBalance.value ?? 0n, inputToken.decimals)) + : 0 + + const decimalsMap = useMemo(() => { + const map: Record = {} + if (basket) { + for (const token of basket) { + map[token.address.toLowerCase() as Address] = token.decimals + } + } + return map + }, [basket]) + + const maxMintAmount = useMemo( + () => + calculateMaxMintAmount({ + inputTokenBalance: availableBalance, + walletBalances, + tokenPrices, + tokenDecimals: decimalsMap, + selectedCollaterals, + strategy, + inputTokenAddress: inputToken.address as Address, + }), + [ + availableBalance, + walletBalances, + tokenPrices, + decimalsMap, + selectedCollaterals, + strategy, + inputToken, + ] + ) + + // Default to max balance on mount + useEffect(() => { + if (!amount && maxMintAmount > 0) { + setAmount(maxMintAmount.toString()) + } + }, [maxMintAmount]) // eslint-disable-line react-hooks/exhaustive-deps + + const MIN_MINT_AMOUNT = 1 + const parsedAmount = Number(amount) || 0 + const isValid = parsedAmount >= MIN_MINT_AMOUNT + const exceedsBalance = parsedAmount > maxMintAmount + + const handleMax = () => { + setAmount(maxMintAmount.toString()) + } + + const backStep = + strategy === 'partial' ? 'token-selection' : 'collateral-decision' + + const subtitle = + strategy === 'partial' + ? `Enter the total amount across all your selected tokens. We'll use your collateral first, then ${inputToken.symbol} for the rest.` + : `Enter the amount of ${inputToken.symbol} you'd like to use.` + + // Show "Why can't I mint more?" when using partial strategy + const showConstraintInfo = strategy === 'partial' + + return ( +
+ {/* Header */} +
+
+ + +
+

+ How much would you like to use? +

+

{subtitle}

+
+
+
+ + {/* Input area */} +
+
+
+
+ {amount && $} + +
+
+ + Avbl. + + + ${formatCurrency(maxMintAmount)} + + +
+
+ + {/* Expandable constraint explanation */} + {showConstraintInfo && ( +
+ + {showExplanation && ( +

+ Your collateral tokens can only be used up to their weight in + the DTF. The max includes your {inputToken.symbol} balance + plus selected collateral value ($ + {formatCurrency(maxMintAmount)}). +

+ )} +
+ )} +
+
+ + {exceedsBalance && ( +
+ Exceeds available balance +
+ )} + + {parsedAmount > 0 && parsedAmount < MIN_MINT_AMOUNT && ( +
+ Minimum mint amount is $1 +
+ )} + + {/* Continue button */} + +
+ ) +} + +export default AmountInput diff --git a/src/views/index-dtf/issuance/async-mint/steps/collateral-decision.tsx b/src/views/index-dtf/issuance/async-mint/steps/collateral-decision.tsx new file mode 100644 index 000000000..5b6c7bbfc --- /dev/null +++ b/src/views/index-dtf/issuance/async-mint/steps/collateral-decision.tsx @@ -0,0 +1,169 @@ +import TokenLogo from '@/components/token-logo' +import { Button } from '@/components/ui/button' +import { Skeleton } from '@/components/ui/skeleton' +import { useERC20Balances } from '@/hooks/useERC20Balance' +import { chainIdAtom } from '@/state/atoms' +import { indexDTFBasketAtom } from '@/state/dtf/atoms' +import { useAtomValue, useSetAtom } from 'jotai' +import { Address } from 'viem' +import { ArrowLeft, ArrowRight } from 'lucide-react' +import { useEffect } from 'react' +import { + inputTokenAtom, + mintStrategyAtom, + selectedCollateralsAtom, + wizardStepAtom, +} from '../atoms' + +const TokenGrid = ({ + tokens, + chainId, +}: { + tokens: { address: string; symbol: string }[] + chainId: number +}) => ( +
+ {tokens.slice(0, 4).map((token) => ( + + ))} +
+) + +const CollateralDecision = () => { + const setStep = useSetAtom(wizardStepAtom) + const setStrategy = useSetAtom(mintStrategyAtom) + const setSelectedCollaterals = useSetAtom(selectedCollateralsAtom) + const basket = useAtomValue(indexDTFBasketAtom) + const chainId = useAtomValue(chainIdAtom) + const inputToken = useAtomValue(inputTokenAtom) + + const { data: balances, isLoading } = useERC20Balances( + (basket || []).map((token) => ({ + address: token.address, + chainId, + })) + ) + + // If user holds NO basket tokens (excluding input token), skip to single strategy + const hasBasketTokens = + balances && + basket && + (balances as bigint[]).some( + (b, i) => + b > 0n && + basket[i].address.toLowerCase() !== inputToken.address.toLowerCase() + ) + + useEffect(() => { + if (!isLoading && balances && !hasBasketTokens) { + setStrategy('single') + setStep('amount-input') + } + }, [isLoading, balances, hasBasketTokens, setStrategy, setStep]) + + if (isLoading || !basket) { + return + } + + const handlePartial = () => { + setStrategy('partial') + setStep('token-selection') + } + + const handleSingle = () => { + setStrategy('single') + setSelectedCollaterals(new Set
()) + setStep('quote-summary') + } + + return ( +
+ {/* Header area */} +
+
+ + +
+

+ Would you like to use tokens you already have that are part of the + DTF? +

+

+ If you hold tokens that back this DTF, we can include them in your + mint. +

+
+
+
+ + {/* Option cards */} +
+ + + +
+
+ ) +} + +export default CollateralDecision diff --git a/src/views/index-dtf/issuance/async-mint/steps/gnosis-required.tsx b/src/views/index-dtf/issuance/async-mint/steps/gnosis-required.tsx new file mode 100644 index 000000000..5bd1333a2 --- /dev/null +++ b/src/views/index-dtf/issuance/async-mint/steps/gnosis-required.tsx @@ -0,0 +1,112 @@ +import { Button } from '@/components/ui/button' +import Help from '@/components/ui/help' +import { chainIdAtom } from '@/state/atoms' +import { useConnectModal } from '@rainbow-me/rainbowkit' +import { useAtomValue } from 'jotai' +import { ArrowUpRight, OctagonAlert } from 'lucide-react' +import { useEffect, useState } from 'react' +import { useAccount, useDisconnect } from 'wagmi' + +const GnosisRequired = () => { + const { openConnectModal } = useConnectModal() + const { disconnect } = useDisconnect() + const { isConnected } = useAccount() + const [shouldOpenModal, setShouldOpenModal] = useState(false) + const chainId = useAtomValue(chainIdAtom) + + useEffect(() => { + if (shouldOpenModal && !isConnected && openConnectModal) { + openConnectModal() + setShouldOpenModal(false) + } + }, [shouldOpenModal, isConnected, openConnectModal]) + + const handleSwitchWallet = async () => { + try { + setShouldOpenModal(true) + await disconnect() + } catch (error) { + console.error('Error switching wallets:', error) + setShouldOpenModal(false) + } + } + + return ( +
+
+
+ {/* Header: icons + badge */} +
+
+ CoW Protocol + Universal Protocol +
+
+ + Gnosis Safe Required + +
+
+ + {/* Title + description */} +
+

+ Get better prices by accessing off-chain liquidity +

+

+ Automated Slow Mints can provide better quotes for minting or + redeeming a DTF, especially when dealing with significant amounts + of capital or DTFs with bridged or low DEX liquidity collateral + assets. +

+
+
+ + {/* Buttons */} +
+ + + + +
+
+
+ ) +} + +export default GnosisRequired diff --git a/src/views/index-dtf/issuance/async-mint/steps/operation-select.tsx b/src/views/index-dtf/issuance/async-mint/steps/operation-select.tsx new file mode 100644 index 000000000..69a9e1ca7 --- /dev/null +++ b/src/views/index-dtf/issuance/async-mint/steps/operation-select.tsx @@ -0,0 +1,109 @@ +import Help from '@/components/ui/help' +import { useSetAtom } from 'jotai' +import { ArrowRight, Coins, OctagonAlert, Wallet } from 'lucide-react' +import { wizardStepAtom } from '../atoms' + +const OperationCard = ({ + title, + description, + icon: Icon, + onClick, + disabled, +}: { + title: string + description: string + icon: React.ElementType + onClick: () => void + disabled?: boolean +}) => ( + +) + +const OperationSelect = () => { + const setStep = useSetAtom(wizardStepAtom) + + return ( +
+ {/* Header area — same as gnosis-required */} +
+
+
+
+ CoW Protocol + Universal Protocol +
+
+ + Gnosis Safe Required + +
+
+ +
+

+ Get better prices by accessing off-chain liquidity +

+

+ Automated Slow Mints can provide better quotes for minting or + redeeming a DTF, especially when dealing with significant amounts + of capital or DTFs with bridged or low DEX liquidity collateral + assets. +

+
+
+
+ + {/* Operation cards */} +
+ setStep('collateral-decision')} + /> + {}} + disabled + /> +
+
+ ) +} + +export default OperationSelect diff --git a/src/views/index-dtf/issuance/async-mint/steps/processing.tsx b/src/views/index-dtf/issuance/async-mint/steps/processing.tsx new file mode 100644 index 000000000..b8076a630 --- /dev/null +++ b/src/views/index-dtf/issuance/async-mint/steps/processing.tsx @@ -0,0 +1,346 @@ +import StackTokenLogo from '@/components/token-logo/StackTokenLogo' +import TokenLogo from '@/components/token-logo' +import { Button } from '@/components/ui/button' +import { chainIdAtom } from '@/state/atoms' +import { + indexDTFAtom, + indexDTFBasketAtom, + indexDTFPriceAtom, +} from '@/state/dtf/atoms' +import { formatCurrency, formatTokenAmount, getTimerFormat } from '@/utils' +import { useAtomValue, useSetAtom } from 'jotai' +import { + ArrowDown, + Check, + ChevronDown, + ChevronRight, + ChevronUp, + Loader, + RefreshCw, + Settings, + X, +} from 'lucide-react' +import { useEffect, useMemo, useState } from 'react' +import { + ASYNC_MINT_BUFFER, + allOrdersFulfilledAtom, + failedOrdersAtom, + folioDetailsAtom, + inputTokenAtom, + mintAmountAtom, + orderIdsAtom, + ordersCreatedAtAtom, + pendingOrdersAtom, + walletBalancesAtom, + wizardStepAtom, +} from '../atoms' +import MintExecute from '../components/mint-execute' +import OrderRow from '../components/order-row' +import { checkMintFeasibility } from '../hooks/use-recovery' +import { useMintQuotes } from '../hooks/use-mint-quotes' +import { useSubmitOrders } from '../hooks/use-submit-orders' + +const Processing = () => { + const setStep = useSetAtom(wizardStepAtom) + const indexDTF = useAtomValue(indexDTFAtom) + const chainId = useAtomValue(chainIdAtom) + const dtfPrice = useAtomValue(indexDTFPriceAtom) + const inputToken = useAtomValue(inputTokenAtom) + const mintAmount = useAtomValue(mintAmountAtom) + const orderIds = useAtomValue(orderIdsAtom) + const ordersCreatedAt = useAtomValue(ordersCreatedAtAtom) + const allFulfilled = useAtomValue(allOrdersFulfilledAtom) + const failedOrders = useAtomValue(failedOrdersAtom) + const pendingOrders = useAtomValue(pendingOrdersAtom) + const basket = useAtomValue(indexDTFBasketAtom) + const walletBalances = useAtomValue(walletBalancesAtom) + const folioDetails = useAtomValue(folioDetailsAtom) + + const [elapsedTime, setElapsedTime] = useState(0) + const [showOrders, setShowOrders] = useState(false) + + const { refetch, isFetching } = useMintQuotes() + const { submit: retrySubmit, isPending: isRetrying } = useSubmitOrders(true) + + // Timer + useEffect(() => { + if (!ordersCreatedAt) return + const interval = setInterval(() => { + const elapsed = + (Date.now() - new Date(ordersCreatedAt).getTime()) / 1000 + setElapsedTime(elapsed) + }, 1000) + return () => clearInterval(interval) + }, [ordersCreatedAt]) + + // Failure severity + const allResolved = failedOrders.length > 0 && pendingOrders.length === 0 + const canStillMintTarget = useMemo(() => { + if (!allResolved || !folioDetails) return true + return checkMintFeasibility( + walletBalances, + folioDetails.mintValues, + folioDetails.assets + ) + }, [allResolved, walletBalances, folioDetails]) + + const isSimpleRetry = allResolved && canStillMintTarget + const isSeriousFailure = allResolved && !canStillMintTarget + + const parsedAmount = Number(mintAmount) + const dtfAmount = dtfPrice + ? (parsedAmount / dtfPrice) * (1 - ASYNC_MINT_BUFFER) + : 0 + const dtfValue = dtfPrice ? dtfAmount * dtfPrice : 0 + const spreadPct = + parsedAmount > 0 ? ((parsedAmount - dtfValue) / parsedAmount) * 100 : 0 + const mintFee = indexDTF?.mintingFee + ? (indexDTF.mintingFee * 100).toFixed(2) + : '0' + + const tokenToggle = ( + + ) + + return ( +
+ {/* Toolbar */} +
+
+ + +
+ {(isSeriousFailure || isSimpleRetry) && ( + + )} +
+ + {/* You use — static display */} +
+
+
You use
+
+
+ ${formatCurrency(parsedAmount)} +
+
+ +
+ +
+
+
+
+
+ + {/* Arrow separator */} +
+
+ +
+
+ + {/* You receive + status + fee */} +
+
+
You receive:
+
+
+ {formatTokenAmount(dtfAmount)} +
+
+ + + {indexDTF?.token.symbol} + +
+
+
+
+ + + ${formatCurrency(dtfValue)} + +
+ +
+
+ + (-{spreadPct.toFixed(2)}%) + +
+
+ + {/* Status area */} + {/* WHY: orderIds.length === 0 means all tokens came from wallet, no CowSwap needed */} + {orderIds.length === 0 ? ( + + ) : allFulfilled ? ( + <> +
+
+
+ +
+ Collateral Acquired +
+ {tokenToggle} +
+ {showOrders && ( +
+ {orderIds.map((id) => ( + + ))} +
+ )} + + + ) : isSeriousFailure ? ( + <> +
+
+ + Your order needs attention + + + Review and choose how to proceed. + +
+ {tokenToggle} +
+ {showOrders && ( +
+ {orderIds.map((id) => ( + + ))} +
+ )} + + + ) : isSimpleRetry ? ( + <> +
+ Prices have moved + +
+ + ) : ( + <> + {/* Progress bar */} +
+
+
+
+
+
+
+ +
+ Acquiring Collateral +
+
+ + {getTimerFormat(elapsedTime)} + + {tokenToggle} +
+
+
+ {showOrders && ( +
+ {orderIds.map((id) => ( + + ))} +
+ )} + + )} + + {/* Rate + fee info */} +
+ + ≈{dtfPrice ? formatTokenAmount(1 / dtfPrice) : '...'}{' '} + {indexDTF?.token.symbol} = $1 + +
+ Fee + {mintFee}% +
+ +
+
+
+
+
+ ) +} + +export default Processing diff --git a/src/views/index-dtf/issuance/async-mint/steps/quote-summary.tsx b/src/views/index-dtf/issuance/async-mint/steps/quote-summary.tsx new file mode 100644 index 000000000..d32eca6c7 --- /dev/null +++ b/src/views/index-dtf/issuance/async-mint/steps/quote-summary.tsx @@ -0,0 +1,380 @@ +import TokenLogo from '@/components/token-logo' +import { Button } from '@/components/ui/button' +import { TransactionButtonContainer } from '@/components/ui/transaction-button' +import { NumericalInput } from '@/components/ui/input' +import { Skeleton } from '@/components/ui/skeleton' +import useDebounce from '@/hooks/useDebounce' +import { balancesAtom, chainIdAtom } from '@/state/atoms' +import { indexDTFAtom, indexDTFBasketAtom, indexDTFPriceAtom } from '@/state/dtf/atoms' +import { formatCurrency, formatTokenAmount } from '@/utils' +import { useAtom, useAtomValue, useSetAtom } from 'jotai' +import { + ArrowDown, + ArrowLeft, + ChevronDown, + ChevronRight, + RefreshCw, + Settings, +} from 'lucide-react' +import { useEffect, useMemo, useRef } from 'react' +import { Address, formatUnits } from 'viem' +import { + ASYNC_MINT_BUFFER, + collateralAllocationAtom, + inputTokenAtom, + mintAmountAtom, + mintQuotesAtom, + mintStrategyAtom, + priceMovedAtom, + quotesStaleAtom, + recoveryChoiceAtom, + selectedCollateralsAtom, + tokenPricesAtom, + walletBalancesAtom, + wizardStepAtom, +} from '../atoms' +import { calculateMaxMintAmount } from '../utils' +import { useMintQuotes } from '../hooks/use-mint-quotes' +import { useSubmitOrders } from '../hooks/use-submit-orders' + +const QuoteSummary = () => { + const setStep = useSetAtom(wizardStepAtom) + const indexDTF = useAtomValue(indexDTFAtom) + const chainId = useAtomValue(chainIdAtom) + const dtfPrice = useAtomValue(indexDTFPriceAtom) + const inputToken = useAtomValue(inputTokenAtom) + const [mintAmount, setMintAmount] = useAtom(mintAmountAtom) + const quotes = useAtomValue(mintQuotesAtom) + const priceMoved = useAtomValue(priceMovedAtom) + const quotesStale = useAtomValue(quotesStaleAtom) + const recoveryChoice = useAtomValue(recoveryChoiceAtom) + const strategy = useAtomValue(mintStrategyAtom) + const balances = useAtomValue(balancesAtom) + + const allocation = useAtomValue(collateralAllocationAtom) + const { refetch, isFetching } = useMintQuotes() + const { submit, isPending } = useSubmitOrders() + + // WHY: In multi-token flow, all tokens may come from wallet (no swaps needed) + const allocationLoaded = Object.keys(allocation).length > 0 + const noSwapsNeeded = allocationLoaded && + Object.values(allocation).every((a) => a.fromSwap === 0n) + + const walletBalances = useAtomValue(walletBalancesAtom) + const tokenPrices = useAtomValue(tokenPricesAtom) + const selectedCollaterals = useAtomValue(selectedCollateralsAtom) + const basket = useAtomValue(indexDTFBasketAtom) + + // Balance + const inputBalance = balances[inputToken.address] + const availableBalance = inputBalance + ? Number(formatUnits(inputBalance.value ?? 0n, inputToken.decimals)) + : 0 + + const decimalsMap = useMemo(() => { + const map: Record = {} + if (basket) { + for (const token of basket) { + map[token.address.toLowerCase() as Address] = token.decimals + } + } + return map + }, [basket]) + + const maxMintAmount = useMemo( + () => + calculateMaxMintAmount({ + inputTokenBalance: availableBalance, + walletBalances, + tokenPrices, + tokenDecimals: decimalsMap, + selectedCollaterals, + strategy, + inputTokenAddress: inputToken.address as Address, + }), + [ + availableBalance, + walletBalances, + tokenPrices, + decimalsMap, + selectedCollaterals, + strategy, + inputToken, + ] + ) + + // Default to max on mount + useEffect(() => { + if (!mintAmount && maxMintAmount > 0) { + setMintAmount(maxMintAmount.toString()) + } + }, [maxMintAmount]) // eslint-disable-line react-hooks/exhaustive-deps + + const debouncedAmount = useDebounce(mintAmount, 500) + + const parsedAmount = Number(mintAmount) || 0 + const dtfAmount = dtfPrice + ? (parsedAmount / dtfPrice) * (1 - ASYNC_MINT_BUFFER) + : 0 + const dtfValue = dtfPrice ? dtfAmount * dtfPrice : 0 + const spreadPct = + parsedAmount > 0 ? ((parsedAmount - dtfValue) / parsedAmount) * 100 : 0 + const hasQuotes = Object.keys(quotes).length > 0 + const successfulQuotes = Object.values(quotes).filter((q) => q.success) + const quoteLoading = noSwapsNeeded + ? false + : isFetching || debouncedAmount !== mintAmount + const exceedsBalance = parsedAmount > maxMintAmount + const isValidAmount = parsedAmount >= 1 + const mintFee = indexDTF?.mintingFee + ? (indexDTF.mintingFee * 100).toFixed(2) + : '0' + + // Debounced refetch when amount changes + const prevDebouncedRef = useRef(debouncedAmount) + useEffect(() => { + if ( + debouncedAmount !== prevDebouncedRef.current && + Number(debouncedAmount) >= 1 + ) { + prevDebouncedRef.current = debouncedAmount + refetch() + } + }, [debouncedAmount, refetch]) + + // WHY: Auto-fetch when allocation is ready (not just on mount) — allocation + // may still be loading if folioDetails hasn't propagated yet + const hasFetchedRef = useRef(false) + useEffect(() => { + if (hasFetchedRef.current || hasQuotes || noSwapsNeeded) return + if (parsedAmount >= 1 && allocationLoaded) { + hasFetchedRef.current = true + refetch() + } + }, [allocationLoaded, parsedAmount, hasQuotes, noSwapsNeeded, refetch]) + + const handleMax = () => { + setMintAmount(maxMintAmount.toString()) + } + + const backStep = + strategy === 'single' ? 'collateral-decision' : 'review' + + return ( +
+ {/* Toolbar */} +
+ +
+ + +
+
+ + {/* Recovery banners */} + {recoveryChoice === 'top-up' && ( +
+ Approving additional {inputToken.symbol} to mint your full amount +
+ )} + {recoveryChoice === 'mint-reduced' && ( +
+ Minting with your original inputs — reduced output +
+ )} + + {/* Stale quotes banner */} + {quotesStale && !priceMoved && ( +
+ Quotes may be outdated + +
+ )} + + {/* Price moved banner */} + {priceMoved && ( +
+ Prices have moved + +
+ )} + + {/* You use — editable input */} +
+
+
+ You use: +
+
+
+ {mintAmount && ( + + $ + + )} + +
+
+ +
+ +
+
+
+
+
+ + Avbl. ${formatCurrency(maxMintAmount)} + +
+ {exceedsBalance && ( + + Exceeds balance + + )} + +
+
+
+ + {/* Arrow separator */} +
+
+ +
+
+ + {/* You receive — button + fee inside this card */} +
+
+
You receive:
+
+ {quoteLoading ? ( + + ) : ( +
+ {formatTokenAmount(dtfAmount)} +
+ )} +
+ + + {indexDTF?.token.symbol} + +
+
+
+ {quoteLoading ? ( + + ) : ( +
+ + + ${formatCurrency(dtfValue)} + +
+ +
+
+ )} + + (-{spreadPct.toFixed(2)}%) + +
+
+ + {/* Submit button */} + + + + + {/* Rate + fee info */} +
+ + ≈{dtfPrice ? formatTokenAmount(1 / dtfPrice) : '...'}{' '} + {indexDTF?.token.symbol} = $1 + +
+ Fee + {mintFee}% +
+ +
+
+
+
+
+ ) +} + +export default QuoteSummary diff --git a/src/views/index-dtf/issuance/async-mint/steps/recovery-options.tsx b/src/views/index-dtf/issuance/async-mint/steps/recovery-options.tsx new file mode 100644 index 000000000..88ffc5439 --- /dev/null +++ b/src/views/index-dtf/issuance/async-mint/steps/recovery-options.tsx @@ -0,0 +1,284 @@ +import { Button } from '@/components/ui/button' +import { balancesAtom } from '@/state/atoms' +import { indexDTFAtom, indexDTFPriceAtom } from '@/state/dtf/atoms' +import { indexDTFBasketAtom } from '@/state/dtf/atoms' +import { formatCurrency, formatTokenAmount } from '@/utils' +import { useAtomValue, useSetAtom } from 'jotai' +import { ArrowLeft, ArrowRight, Check, Plus, Undo2 } from 'lucide-react' +import { useMemo } from 'react' +import { Address, formatEther, formatUnits } from 'viem' +import { + acquiredBalancesAtom, + folioDetailsAtom, + inputTokenAtom, + leftoverCollateralAtom, + mintAmountAtom, + mintSharesAtom, + recoveryChoiceAtom, + slippageAtom, + tokenPricesAtom, + walletBalancesAtom, + wizardStepAtom, +} from '../atoms' +import { + calculateReducedMint, + calculateReversalEstimate, + calculateTopUp, +} from '../hooks/use-recovery' +import { useReverseOrders } from '../hooks/use-reverse-orders' + +const RecoveryOptions = () => { + const setStep = useSetAtom(wizardStepAtom) + const setRecoveryChoice = useSetAtom(recoveryChoiceAtom) + const setLeftoverCollateral = useSetAtom(leftoverCollateralAtom) + + const indexDTF = useAtomValue(indexDTFAtom) + const dtfPrice = useAtomValue(indexDTFPriceAtom) || 0 + const mintAmount = useAtomValue(mintAmountAtom) + const mintShares = useAtomValue(mintSharesAtom) + const inputToken = useAtomValue(inputTokenAtom) + const walletBalances = useAtomValue(walletBalancesAtom) + const tokenPrices = useAtomValue(tokenPricesAtom) + const folioDetails = useAtomValue(folioDetailsAtom) + const slippage = useAtomValue(slippageAtom) + const basket = useAtomValue(indexDTFBasketAtom) + + const balances = useAtomValue(balancesAtom) + const acquiredBalances = useAtomValue(acquiredBalancesAtom) + const { reverseAsync, isPending: isReversing } = useReverseOrders() + + const parsedAmount = Number(mintAmount) + const slippageBps = Number(slippage) + + const inputTokenBalance = useMemo(() => { + const bal = balances[inputToken.address] + return bal ? Number(formatUnits(bal.value ?? 0n, inputToken.decimals)) : 0 + }, [balances, inputToken]) + + const decimalsMap = useMemo(() => { + const map: Record = {} + if (basket) { + for (const token of basket) { + map[token.address.toLowerCase() as Address] = token.decimals + } + } + return map + }, [basket]) + + const totalAcquiredUsd = useMemo(() => { + let total = 0 + for (const [addr, amount] of Object.entries(walletBalances)) { + const price = tokenPrices[addr as Address] ?? 0 + const dec = decimalsMap[addr as Address] ?? 18 + total += Number(formatUnits(amount, dec)) * price + } + return total + }, [walletBalances, tokenPrices, decimalsMap]) + + const topUp = useMemo(() => { + return calculateTopUp(parsedAmount, totalAcquiredUsd) + }, [parsedAmount, totalAcquiredUsd]) + + const canTopUp = inputTokenBalance >= topUp.topUpAmount + + const reduced = useMemo(() => { + if (!folioDetails) { + return { reducedShares: 0n, unusedCollateral: {}, swapLossEstimate: 0 } + } + return calculateReducedMint({ + acquiredBalances: walletBalances, + assets: folioDetails.assets, + mintValues: folioDetails.mintValues, + folioAmount: mintShares, + dtfPrice, + slippageBps, + }) + }, [walletBalances, folioDetails, mintShares, dtfPrice, slippageBps]) + + const reducedDtfAmount = Number(formatEther(reduced.reducedShares)) + + // WHY: Only estimate reversal for what we acquired through orders, not pre-existing wallet balances + const reversal = useMemo(() => { + return calculateReversalEstimate( + acquiredBalances, + tokenPrices, + decimalsMap as Record, + slippageBps + ) + }, [acquiredBalances, tokenPrices, decimalsMap, slippageBps]) + + const unusedCollateralValue = useMemo(() => { + let total = 0 + for (const [addr, amount] of Object.entries(reduced.unusedCollateral)) { + const price = tokenPrices[addr as Address] ?? 0 + const dec = decimalsMap[addr as Address] ?? 18 + total += Number(formatUnits(amount, dec)) * price + } + return total + }, [reduced.unusedCollateral, tokenPrices, decimalsMap]) + + const handleTopUp = () => { + setRecoveryChoice('top-up') + setStep('quote-summary') + } + + const handleReducedMint = () => { + setRecoveryChoice('mint-reduced') + setLeftoverCollateral(reduced.unusedCollateral) + setStep('quote-summary') + } + + const handleCancel = async () => { + setRecoveryChoice('cancel') + try { + // WHY: Only reverse what we acquired through orders, not pre-existing wallet balances + await reverseAsync(acquiredBalances) + setStep('success') + } catch { + // User rejected or tx failed — stay on recovery screen + } + } + + return ( +
+ {/* Header */} +
+ +
+

+ Prices have moved +

+

+ Your funds are safe, but we couldn't complete the mint as + quoted. Choose how you'd like to proceed. +

+
+
+ + {/* Option cards */} +
+ {/* Option 1: Top up */} + + + {/* Option 2: Mint reduced */} +
+ + {unusedCollateralValue > 0 && ( +

+ ≈${formatCurrency(unusedCollateralValue)} {inputToken.symbol}{' '} + returned from unused collateral + {reduced.swapLossEstimate > 0 && + ` (~${reduced.swapLossEstimate.toFixed(1)}% swap loss)`} + . +

+ )} +
+ + {/* Option 3: Cancel and reverse */} + +
+
+ ) +} + +export default RecoveryOptions diff --git a/src/views/index-dtf/issuance/async-mint/steps/review-inputs.tsx b/src/views/index-dtf/issuance/async-mint/steps/review-inputs.tsx new file mode 100644 index 000000000..773927b7c --- /dev/null +++ b/src/views/index-dtf/issuance/async-mint/steps/review-inputs.tsx @@ -0,0 +1,170 @@ +import TokenLogoWithChain from '@/components/token-logo/TokenLogoWithChain' +import { Button } from '@/components/ui/button' +import { Skeleton } from '@/components/ui/skeleton' +import { chainIdAtom } from '@/state/atoms' +import { indexDTFBasketAtom } from '@/state/dtf/atoms' +import { formatCurrency, formatTokenAmount } from '@/utils' +import { useAtomValue, useSetAtom } from 'jotai' +import { ArrowLeft } from 'lucide-react' +import { useMemo } from 'react' +import { formatUnits } from 'viem' +import { Address } from 'viem' +import { + ASYNC_MINT_BUFFER, + collateralAllocationAtom, + inputTokenAtom, + slippageAtom, + tokenPricesAtom, + wizardStepAtom, +} from '../atoms' +import { useAllocationData } from '../hooks/use-collateral-allocation' + +const ReviewInputs = () => { + const setStep = useSetAtom(wizardStepAtom) + const allocation = useAtomValue(collateralAllocationAtom) + const basket = useAtomValue(indexDTFBasketAtom) + const chainId = useAtomValue(chainIdAtom) + const inputToken = useAtomValue(inputTokenAtom) + const slippage = useAtomValue(slippageAtom) + const tokenPrices = useAtomValue(tokenPricesAtom) + + // Ensure allocation data is loaded + useAllocationData() + + const entries = Object.entries(allocation) + + // Split: wallet tokens shown individually, swap tokens summarized as USDC + const walletEntries = entries.filter(([_, alloc]) => alloc.fromWallet > 0n) + const totalSwapUsd = useMemo( + () => entries.reduce((sum, [_, alloc]) => sum + alloc.usdValue, 0), + [entries] + ) + const slippagePct = Number(slippage) / 10000 + const bufferReturn = totalSwapUsd * (ASYNC_MINT_BUFFER + slippagePct) + + return ( +
+ {/* Header */} +
+ +
+

+ Review your inputs +

+

+ Your collateral is used up to each token's basket weight or + your available balance, whichever is less. The remaining amount is + covered by {inputToken.symbol}. +

+
+
+ + {/* Token cards */} +
+
+ {entries.length === 0 ? ( + + ) : ( + <> + {/* Wallet token cards */} + {walletEntries.map(([address, alloc]) => { + const token = basket?.find( + (t) => t.address.toLowerCase() === address.toLowerCase() + ) + const decimals = token?.decimals ?? 18 + const price = tokenPrices[address.toLowerCase() as Address] ?? 0 + const walletAmount = Number(formatUnits(alloc.fromWallet, decimals)) + const formattedAmount = formatTokenAmount(walletAmount) + const walletUsd = walletAmount * price + + return ( +
+
+ +
+ + ${formatCurrency(walletUsd)} + + + {formattedAmount} {token?.symbol} + +
+
+
+ + {alloc.explanation} + +
+
+ ) + })} + + {/* USDC covering the remainder */} + {totalSwapUsd > 0 && ( +
+
+ +
+ + ${formatCurrency(totalSwapUsd)} + + + {formatCurrency(totalSwapUsd)} {inputToken.symbol} + +
+
+
+ + Covering the remainder + + + Up to {formatCurrency(totalSwapUsd)} {inputToken.symbol}{' '} + will be used to complete the mint. + {bufferReturn > 0 && ( + <> Up to ${formatCurrency(bufferReturn)}{' '} + {inputToken.symbol} may be returned. + )} + +
+
+ )} + + )} +
+
+ + {/* CTA */} +
+ +
+
+ ) +} + +export default ReviewInputs diff --git a/src/views/index-dtf/issuance/async-mint/steps/success.tsx b/src/views/index-dtf/issuance/async-mint/steps/success.tsx new file mode 100644 index 000000000..bf3873269 --- /dev/null +++ b/src/views/index-dtf/issuance/async-mint/steps/success.tsx @@ -0,0 +1,268 @@ +import StackTokenLogo from '@/components/token-logo/StackTokenLogo' +import TokenLogo from '@/components/token-logo' +import { Button } from '@/components/ui/button' +import { chainIdAtom } from '@/state/atoms' +import { + indexDTFAtom, + indexDTFBasketAtom, + indexDTFPriceAtom, +} from '@/state/dtf/atoms' +import { formatCurrency, formatTokenAmount } from '@/utils' +import { useAtomValue, useSetAtom } from 'jotai' +import { Check, ChevronDown, ChevronRight, X } from 'lucide-react' +import { useState } from 'react' +import { formatEther, formatUnits } from 'viem' +import { + ASYNC_MINT_BUFFER, + actualMintedSharesAtom, + inputTokenAtom, + leftoverCollateralAtom, + mintAmountAtom, + mintTxHashAtom, + orderIdsAtom, + recoveryChoiceAtom, + resetWizardAtom, + slippageAtom, + tokenPricesAtom, +} from '../atoms' +import OrderRow from '../components/order-row' +import SuccessHeader from '../components/success-header' +import { useReverseOrders } from '../hooks/use-reverse-orders' + +const Success = () => { + const indexDTF = useAtomValue(indexDTFAtom) + const chainId = useAtomValue(chainIdAtom) + const dtfPrice = useAtomValue(indexDTFPriceAtom) + const inputToken = useAtomValue(inputTokenAtom) + const mintAmount = useAtomValue(mintAmountAtom) + const slippage = useAtomValue(slippageAtom) + const orderIds = useAtomValue(orderIdsAtom) + const recoveryChoice = useAtomValue(recoveryChoiceAtom) + const leftoverCollateral = useAtomValue(leftoverCollateralAtom) + const tokenPrices = useAtomValue(tokenPricesAtom) + const basket = useAtomValue(indexDTFBasketAtom) + const mintTxHash = useAtomValue(mintTxHashAtom) + const actualMintedShares = useAtomValue(actualMintedSharesAtom) + const resetWizard = useSetAtom(resetWizardAtom) + + const [showTxs, setShowTxs] = useState(false) + const [conversionDone, setConversionDone] = useState(false) + + const { reverseAsync, isPending: isConverting } = useReverseOrders() + + const parsedAmount = Number(mintAmount) + // WHY: Use actual shares from mint tx when available, fall back to estimate + const dtfAmount = actualMintedShares > 0n + ? Number(formatEther(actualMintedShares)) + : dtfPrice + ? (parsedAmount / dtfPrice) * (1 - ASYNC_MINT_BUFFER) + : 0 + const dtfValue = dtfPrice ? dtfAmount * dtfPrice : 0 + const spreadPct = + parsedAmount > 0 ? ((parsedAmount - dtfValue) / parsedAmount) * 100 : 0 + const slippagePct = Number(slippage) / 100 + const originalDtfAmount = dtfPrice ? parsedAmount / dtfPrice : 0 + const isReduced = recoveryChoice === 'mint-reduced' + const mintFee = indexDTF?.mintingFee + ? (indexDTF.mintingFee * 100).toFixed(2) + : '0' + + const hasLeftovers = + isReduced && Object.keys(leftoverCollateral).length > 0 + + const leftoverCount = Object.keys(leftoverCollateral).length + const leftoverValue = Object.entries(leftoverCollateral).reduce( + (sum, [addr, amount]) => { + const token = basket?.find( + (t) => t.address.toLowerCase() === addr.toLowerCase() + ) + const price = tokenPrices[addr.toLowerCase() as `0x${string}`] ?? 0 + const dec = token?.decimals ?? 18 + return sum + Number(formatUnits(amount, dec)) * price + }, + 0 + ) + const estimatedReturn = leftoverValue * (1 - slippagePct / 100) + + const handleConvert = async () => { + try { + await reverseAsync(leftoverCollateral) + setConversionDone(true) + } catch { + // User rejected or tx failed — button re-enables via isPending + } + } + + const handleClose = () => { + resetWizard() + } + + return ( +
+
+ setShowTxs(!showTxs)} + onClose={handleClose} + txHash={mintTxHash} + /> + +
+ {/* Convert leftover tokens banner */} + {hasLeftovers && !conversionDone && ( +
+
+ + Convert {leftoverCount} tokens ($ + {formatCurrency(leftoverValue)}) + + + ≈{formatCurrency(estimatedReturn)} {inputToken.symbol} (- + {slippagePct}%) + +
+
+
+ + Object.keys(leftoverCollateral).some( + (a) => a.toLowerCase() === t.address.toLowerCase() + ) + ) + .slice(0, 4) + .map((t) => ({ + ...t, + chain: indexDTF?.chainId, + }))} + size={32} + overlap={8} + reverseStack + outsource + /> +
+ +
+
+ +
+
+ )} + + {/* Conversion completed banner */} + {hasLeftovers && conversionDone && ( +
+
+
+ + Converted {leftoverCount} tokens +
+
+ Received + + {formatCurrency(estimatedReturn)} {inputToken.symbol} + +
+
+
+ )} + + {/* All Txs expandable */} + {showTxs && ( +
+ {orderIds.map((id) => ( + + ))} +
+ )} + + {/* You Minted */} +
+
+ + You Minted: + + {isReduced && ( + + {formatTokenAmount(originalDtfAmount)}{' '} + {indexDTF?.token.symbol} + + )} +
+
+
+ + {formatTokenAmount(dtfAmount)} + + + {indexDTF?.token.symbol} + +
+ +
+
+ ${formatCurrency(dtfValue)} + + (-{spreadPct.toFixed(2)}%) + +
+
+ + {/* You Used */} +
+
+
+ You Used: +
+
+
+ + {formatCurrency(parsedAmount)} + + + {inputToken.symbol} + +
+ +
+
+ ${formatCurrency(parsedAmount)} +
+
+ + {/* Rate + fee info */} +
+ + ≈{dtfPrice ? formatTokenAmount(1 / dtfPrice) : '...'}{' '} + {indexDTF?.token.symbol} = $1 + +
+ Fee + {mintFee}% +
+ +
+
+
+
+
+
+
+ ) +} + +export default Success diff --git a/src/views/index-dtf/issuance/async-mint/steps/token-selection.tsx b/src/views/index-dtf/issuance/async-mint/steps/token-selection.tsx new file mode 100644 index 000000000..8a8388311 --- /dev/null +++ b/src/views/index-dtf/issuance/async-mint/steps/token-selection.tsx @@ -0,0 +1,148 @@ +import TokenLogoWithChain from '@/components/token-logo/TokenLogoWithChain' +import { Button } from '@/components/ui/button' +import { Switch } from '@/components/ui/switch' +import { useERC20Balances } from '@/hooks/useERC20Balance' +import { chainIdAtom } from '@/state/atoms' +import { indexDTFBasketAtom } from '@/state/dtf/atoms' +import { useAtom, useAtomValue, useSetAtom } from 'jotai' +import { ArrowLeft, Check } from 'lucide-react' +import { useEffect } from 'react' +import { Address } from 'viem' +import { + inputTokenAtom, + selectedCollateralsAtom, + wizardStepAtom, +} from '../atoms' + +const TokenSelection = () => { + const setStep = useSetAtom(wizardStepAtom) + const basket = useAtomValue(indexDTFBasketAtom) + const chainId = useAtomValue(chainIdAtom) + const inputToken = useAtomValue(inputTokenAtom) + const [selected, setSelected] = useAtom(selectedCollateralsAtom) + + const { data: balances } = useERC20Balances( + (basket || []).map((token) => ({ + address: token.address, + chainId, + })) + ) + + // Initialize selection: tokens with balance are on by default + useEffect(() => { + if (!basket || !balances || selected.size > 0) return + const initial = new Set
() + basket.forEach((token, i) => { + const balance = (balances as bigint[])?.[i] ?? 0n + if ( + balance > 0n && + token.address.toLowerCase() !== inputToken.address.toLowerCase() + ) { + initial.add(token.address) + } + }) + setSelected(initial) + }, [basket, balances, inputToken.address]) + + const toggleToken = (address: Address) => { + setSelected((prev: Set
) => { + const next = new Set(prev) + if (next.has(address)) { + next.delete(address) + } else { + next.add(address) + } + return next + }) + } + + if (!basket) return null + + const isInputToken = (address: string) => + address.toLowerCase() === inputToken.address.toLowerCase() + + return ( +
+ {/* Header */} +
+ +
+

+ What should we use? +

+

+ {inputToken.symbol} is always used. Choose which collateral tokens + to include alongside it. +

+
+
+ + {/* Token rows */} +
+
+ {basket.map((token) => { + const isInput = isInputToken(token.address) + const isSelected = selected.has(token.address) + + return ( +
+
+
+ +
+ + {token.name || token.symbol} + + + {token.symbol} + +
+
+ {isInput ? ( +
+ + Always included +
+ ) : ( + toggleToken(token.address)} + /> + )} +
+
+ ) + })} +
+
+ + {/* Continue button */} +
+ +
+
+ ) +} + +export default TokenSelection diff --git a/src/views/index-dtf/issuance/async-mint/tests/atoms.test.ts b/src/views/index-dtf/issuance/async-mint/tests/atoms.test.ts new file mode 100644 index 000000000..d71384887 --- /dev/null +++ b/src/views/index-dtf/issuance/async-mint/tests/atoms.test.ts @@ -0,0 +1,210 @@ +import { createStore } from 'jotai' +import { describe, it, expect } from 'vitest' +import { + allOrdersFulfilledAtom, + failedOrdersAtom, + folioDetailsAtom, + leftoverCollateralAtom, + mintAmountAtom, + mintQuotesAtom, + mintStrategyAtom, + mintTxHashAtom, + orderIdsAtom, + ordersAtom, + ordersCreatedAtAtom, + ordersSubmittedAtom, + pendingOrdersAtom, + priceMovedAtom, + recoveryChoiceAtom, + resetWizardAtom, + selectedCollateralsAtom, + tokenPricesAtom, + walletBalancesAtom, + wizardStepAtom, +} from '../atoms' +import { OrderStatus } from '@cowprotocol/cow-sdk' +import { Address } from 'viem' + +const makeOrder = ( + orderId: string, + status: OrderStatus +) => + ({ + orderId, + status, + sellToken: '0x1', + buyToken: '0x2', + sellAmount: '1000', + buyAmount: '500', + }) as any + +describe('Wizard Atoms', () => { + it('wizardStepAtom initial state is gnosis-check', () => { + const store = createStore() + expect(store.get(wizardStepAtom)).toBe('gnosis-check') + }) + + it('resetWizardAtom clears all state', () => { + const store = createStore() + + // Set various state + store.set(wizardStepAtom, 'processing') + store.set(mintStrategyAtom, 'partial') + store.set( + selectedCollateralsAtom, + new Set
(['0x123' as Address]) + ) + store.set(mintAmountAtom, '1000') + store.set(orderIdsAtom, ['order-1']) + store.set(ordersCreatedAtAtom, '2024-01-01') + store.set(mintTxHashAtom, '0xabc') + store.set(recoveryChoiceAtom, 'top-up') + + // Reset + store.set(resetWizardAtom) + + // Verify all cleared + expect(store.get(wizardStepAtom)).toBe('gnosis-check') + expect(store.get(mintStrategyAtom)).toBe('single') + expect(store.get(selectedCollateralsAtom).size).toBe(0) + expect(store.get(mintAmountAtom)).toBe('') + expect(store.get(mintQuotesAtom)).toEqual({}) + expect(store.get(orderIdsAtom)).toEqual([]) + expect(store.get(ordersAtom)).toEqual([]) + expect(store.get(ordersCreatedAtAtom)).toBeUndefined() + expect(store.get(mintTxHashAtom)).toBeUndefined() + expect(store.get(recoveryChoiceAtom)).toBeNull() + }) + + it('allOrdersFulfilledAtom returns true when all fulfilled', () => { + const store = createStore() + store.set(ordersAtom, [ + makeOrder('1', OrderStatus.FULFILLED), + makeOrder('2', OrderStatus.FULFILLED), + ]) + expect(store.get(allOrdersFulfilledAtom)).toBe(true) + }) + + it('allOrdersFulfilledAtom returns false when any pending', () => { + const store = createStore() + store.set(ordersAtom, [ + makeOrder('1', OrderStatus.FULFILLED), + makeOrder('2', OrderStatus.OPEN), + ]) + expect(store.get(allOrdersFulfilledAtom)).toBe(false) + }) + + it('allOrdersFulfilledAtom returns false with empty orders', () => { + const store = createStore() + expect(store.get(allOrdersFulfilledAtom)).toBe(false) + }) + + it('failedOrdersAtom filters cancelled/expired', () => { + const store = createStore() + store.set(ordersAtom, [ + makeOrder('1', OrderStatus.FULFILLED), + makeOrder('2', OrderStatus.CANCELLED), + makeOrder('3', OrderStatus.EXPIRED), + makeOrder('4', OrderStatus.OPEN), + ]) + const failed = store.get(failedOrdersAtom) + expect(failed).toHaveLength(2) + expect(failed.map((o) => o.orderId)).toEqual(['2', '3']) + }) + + it('pendingOrdersAtom filters open/presignature_pending', () => { + const store = createStore() + store.set(ordersAtom, [ + makeOrder('1', OrderStatus.FULFILLED), + makeOrder('2', OrderStatus.OPEN), + makeOrder('3', OrderStatus.PRESIGNATURE_PENDING), + makeOrder('4', OrderStatus.CANCELLED), + ]) + const pending = store.get(pendingOrdersAtom) + expect(pending).toHaveLength(2) + expect(pending.map((o) => o.orderId)).toEqual(['2', '3']) + }) + + it('priceMovedAtom true when failed > 0 and pending === 0', () => { + const store = createStore() + store.set(ordersAtom, [ + makeOrder('1', OrderStatus.FULFILLED), + makeOrder('2', OrderStatus.CANCELLED), + ]) + expect(store.get(priceMovedAtom)).toBe(true) + }) + + it('priceMovedAtom false when still pending', () => { + const store = createStore() + store.set(ordersAtom, [ + makeOrder('1', OrderStatus.CANCELLED), + makeOrder('2', OrderStatus.OPEN), + ]) + expect(store.get(priceMovedAtom)).toBe(false) + }) + + it('priceMovedAtom false when no failures', () => { + const store = createStore() + store.set(ordersAtom, [ + makeOrder('1', OrderStatus.FULFILLED), + makeOrder('2', OrderStatus.FULFILLED), + ]) + expect(store.get(priceMovedAtom)).toBe(false) + }) + + it('ordersSubmittedAtom true when ordersCreatedAt set', () => { + const store = createStore() + expect(store.get(ordersSubmittedAtom)).toBe(false) + store.set(ordersCreatedAtAtom, '2024-01-01') + expect(store.get(ordersSubmittedAtom)).toBe(true) + }) + + it('resetWizardAtom clears async data atoms', () => { + const store = createStore() + const addr = '0x123' as Address + + store.set(walletBalancesAtom, { [addr]: 1000n }) + store.set(tokenPricesAtom, { [addr]: 2000 }) + store.set(folioDetailsAtom, { + assets: [addr], + mintValues: [1000n], + }) + store.set(leftoverCollateralAtom, { [addr]: 500n }) + + store.set(resetWizardAtom) + + expect(store.get(walletBalancesAtom)).toEqual({}) + expect(store.get(tokenPricesAtom)).toEqual({}) + expect(store.get(folioDetailsAtom)).toBeNull() + expect(store.get(leftoverCollateralAtom)).toEqual({}) + }) + + it('priceMovedAtom false with empty orders', () => { + const store = createStore() + expect(store.get(priceMovedAtom)).toBe(false) + }) + + it('allOrdersFulfilledAtom handles single order', () => { + const store = createStore() + store.set(ordersAtom, [makeOrder('1', OrderStatus.FULFILLED)]) + expect(store.get(allOrdersFulfilledAtom)).toBe(true) + }) + + it('failedOrdersAtom returns empty when all fulfilled', () => { + const store = createStore() + store.set(ordersAtom, [ + makeOrder('1', OrderStatus.FULFILLED), + makeOrder('2', OrderStatus.FULFILLED), + ]) + expect(store.get(failedOrdersAtom)).toHaveLength(0) + }) + + it('pendingOrdersAtom returns empty when all resolved', () => { + const store = createStore() + store.set(ordersAtom, [ + makeOrder('1', OrderStatus.FULFILLED), + makeOrder('2', OrderStatus.CANCELLED), + ]) + expect(store.get(pendingOrdersAtom)).toHaveLength(0) + }) +}) diff --git a/src/views/index-dtf/issuance/async-mint/tests/collateral-allocation.test.ts b/src/views/index-dtf/issuance/async-mint/tests/collateral-allocation.test.ts new file mode 100644 index 000000000..b7795c61d --- /dev/null +++ b/src/views/index-dtf/issuance/async-mint/tests/collateral-allocation.test.ts @@ -0,0 +1,444 @@ +import { describe, it, expect } from 'vitest' +import { Address, parseEther, parseUnits } from 'viem' +import { calculateCollateralAllocation, calculateMaxMintAmount } from '../utils' + +const USDC = '0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48' as Address +const WETH = '0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2' as Address +const WBTC = '0x2260fac5e5542a773aa44fbcfedf7c193bc2c599' as Address + +const INPUT_TOKEN = { address: USDC, decimals: 6, symbol: 'USDC' } + +const DECIMALS: Record = { + [USDC.toLowerCase() as Address]: 6, + [WETH.toLowerCase() as Address]: 18, + [WBTC.toLowerCase() as Address]: 8, +} + +describe('calculateCollateralAllocation', () => { + it('returns empty for zero mint shares', () => { + const result = calculateCollateralAllocation({ + mintShares: 0n, + assets: [WETH], + mintValues: [parseEther('1')], + balances: {}, + prices: {}, + decimals: DECIMALS, + selectedCollaterals: new Set
(), + strategy: 'single', + inputToken: INPUT_TOKEN, + }) + expect(result).toEqual({}) + }) + + it('returns empty for empty assets', () => { + const result = calculateCollateralAllocation({ + mintShares: parseEther('1'), + assets: [], + mintValues: [], + balances: {}, + prices: {}, + decimals: DECIMALS, + selectedCollaterals: new Set
(), + strategy: 'single', + inputToken: INPUT_TOKEN, + }) + expect(result).toEqual({}) + }) + + it('single strategy: all tokens come from swaps', () => { + const result = calculateCollateralAllocation({ + mintShares: parseEther('1'), + assets: [WETH, WBTC], + mintValues: [parseEther('0.5'), parseUnits('0.01', 8)], + balances: { + [WETH.toLowerCase() as Address]: parseEther('10'), // User has WETH + [WBTC.toLowerCase() as Address]: parseUnits('1', 8), + }, + prices: { + [WETH.toLowerCase() as Address]: 2000, + [WBTC.toLowerCase() as Address]: 40000, + }, + decimals: DECIMALS, + selectedCollaterals: new Set
([WETH, WBTC]), + strategy: 'single', + inputToken: INPUT_TOKEN, + }) + + // With single strategy, no wallet balance should be used + expect(result[WETH].fromWallet).toBe(0n) + expect(result[WETH].fromSwap).toBe(parseEther('0.5')) + expect(result[WBTC].fromWallet).toBe(0n) + expect(result[WBTC].fromSwap).toBe(parseUnits('0.01', 8)) + }) + + it('partial strategy with full wallet coverage: no swaps needed', () => { + const wethRequired = parseEther('0.5') + const wbtcRequired = parseUnits('0.01', 8) + + const result = calculateCollateralAllocation({ + mintShares: parseEther('1'), + assets: [WETH, WBTC], + mintValues: [wethRequired, wbtcRequired], + balances: { + [WETH.toLowerCase() as Address]: parseEther('10'), + [WBTC.toLowerCase() as Address]: parseUnits('1', 8), + }, + prices: { + [WETH.toLowerCase() as Address]: 2000, + [WBTC.toLowerCase() as Address]: 40000, + }, + decimals: DECIMALS, + selectedCollaterals: new Set
([WETH, WBTC]), + strategy: 'partial', + inputToken: INPUT_TOKEN, + }) + + expect(result[WETH].fromWallet).toBe(wethRequired) + expect(result[WETH].fromSwap).toBe(0n) + expect(result[WETH].explanation).toBe('Token at its maximum weight') + + expect(result[WBTC].fromWallet).toBe(wbtcRequired) + expect(result[WBTC].fromSwap).toBe(0n) + }) + + it('partial strategy with mixed coverage: some from wallet, rest from swap', () => { + const wethRequired = parseEther('1') + const walletWeth = parseEther('0.3') // Only 30% coverage + + const result = calculateCollateralAllocation({ + mintShares: parseEther('1'), + assets: [WETH, WBTC], + mintValues: [wethRequired, parseUnits('0.01', 8)], + balances: { + [WETH.toLowerCase() as Address]: walletWeth, + [WBTC.toLowerCase() as Address]: 0n, // No WBTC + }, + prices: { + [WETH.toLowerCase() as Address]: 2000, + [WBTC.toLowerCase() as Address]: 40000, + }, + decimals: DECIMALS, + selectedCollaterals: new Set
([WETH, WBTC]), + strategy: 'partial', + inputToken: INPUT_TOKEN, + }) + + // WETH: partial from wallet + expect(result[WETH].fromWallet).toBe(walletWeth) + expect(result[WETH].fromSwap).toBe(wethRequired - walletWeth) + expect(result[WETH].explanation).toBe('Using your full balance') + + // WBTC: all from swap (0 balance) + expect(result[WBTC].fromWallet).toBe(0n) + expect(result[WBTC].fromSwap).toBe(parseUnits('0.01', 8)) + expect(result[WBTC].explanation).toBe('Covering the remainder') + }) + + it('weight capping: wallet has more than DTF weight allows', () => { + const wethRequired = parseEther('0.5') + + const result = calculateCollateralAllocation({ + mintShares: parseEther('1'), + assets: [WETH], + mintValues: [wethRequired], + balances: { + [WETH.toLowerCase() as Address]: parseEther('100'), // Way more than needed + }, + prices: { + [WETH.toLowerCase() as Address]: 2000, + }, + decimals: DECIMALS, + selectedCollaterals: new Set
([WETH]), + strategy: 'partial', + inputToken: INPUT_TOKEN, + }) + + // Capped at required amount + expect(result[WETH].fromWallet).toBe(wethRequired) + expect(result[WETH].fromSwap).toBe(0n) + expect(result[WETH].explanation).toBe('Token at its maximum weight') + }) + + it('skips input token from allocation', () => { + const result = calculateCollateralAllocation({ + mintShares: parseEther('1'), + assets: [USDC, WETH], // USDC is the input token + mintValues: [parseUnits('100', 6), parseEther('0.5')], + balances: {}, + prices: {}, + decimals: DECIMALS, + selectedCollaterals: new Set
(), + strategy: 'single', + inputToken: INPUT_TOKEN, + }) + + // USDC should be skipped + expect(result[USDC]).toBeUndefined() + // WETH should be present + expect(result[WETH]).toBeDefined() + }) + + it('unselected tokens in partial mode are treated as swap-only', () => { + const result = calculateCollateralAllocation({ + mintShares: parseEther('1'), + assets: [WETH, WBTC], + mintValues: [parseEther('0.5'), parseUnits('0.01', 8)], + balances: { + [WETH.toLowerCase() as Address]: parseEther('10'), + [WBTC.toLowerCase() as Address]: parseUnits('1', 8), + }, + prices: {}, + decimals: DECIMALS, + selectedCollaterals: new Set
([WETH]), // Only WETH selected + strategy: 'partial', + inputToken: INPUT_TOKEN, + }) + + // WETH: selected, uses wallet + expect(result[WETH].fromWallet).toBe(parseEther('0.5')) + + // WBTC: not selected, all from swap despite having balance + expect(result[WBTC].fromWallet).toBe(0n) + expect(result[WBTC].fromSwap).toBe(parseUnits('0.01', 8)) + }) + + it('calculates usdValue for swap portions using prices', () => { + const result = calculateCollateralAllocation({ + mintShares: parseEther('1'), + assets: [WETH], + mintValues: [parseEther('0.5')], + balances: {}, + prices: { [WETH.toLowerCase() as Address]: 2000 }, + decimals: DECIMALS, + selectedCollaterals: new Set
(), + strategy: 'single', + inputToken: INPUT_TOKEN, + }) + + // 0.5 WETH * $2000 = $1000 + expect(result[WETH].usdValue).toBeCloseTo(1000, 0) + }) + + it('returns usdValue 0 when price is missing', () => { + const result = calculateCollateralAllocation({ + mintShares: parseEther('1'), + assets: [WETH], + mintValues: [parseEther('0.5')], + balances: {}, + prices: {}, // No prices + decimals: DECIMALS, + selectedCollaterals: new Set
(), + strategy: 'single', + inputToken: INPUT_TOKEN, + }) + + expect(result[WETH].usdValue).toBe(0) + }) + + it('returns usdValue 0 when fromSwap is 0 (full wallet coverage)', () => { + const result = calculateCollateralAllocation({ + mintShares: parseEther('1'), + assets: [WETH], + mintValues: [parseEther('0.5')], + balances: { [WETH.toLowerCase() as Address]: parseEther('10') }, + prices: { [WETH.toLowerCase() as Address]: 2000 }, + decimals: DECIMALS, + selectedCollaterals: new Set
([WETH]), + strategy: 'partial', + inputToken: INPUT_TOKEN, + }) + + // No swap needed — usdValue should be 0 + expect(result[WETH].fromSwap).toBe(0n) + expect(result[WETH].usdValue).toBe(0) + }) + + it('handles WBTC 8-decimal usdValue correctly', () => { + const wbtcRequired = parseUnits('0.1', 8) // 0.1 BTC + + const result = calculateCollateralAllocation({ + mintShares: parseEther('1'), + assets: [WBTC], + mintValues: [wbtcRequired], + balances: {}, + prices: { [WBTC.toLowerCase() as Address]: 60000 }, + decimals: DECIMALS, + selectedCollaterals: new Set
(), + strategy: 'single', + inputToken: INPUT_TOKEN, + }) + + // 0.1 BTC * $60000 = $6000 + expect(result[WBTC].usdValue).toBeCloseTo(6000, 0) + }) + + it('handles case-insensitive address matching for selectedCollaterals', () => { + const wethChecksummed = '0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2' as Address + const wethLower = wethChecksummed.toLowerCase() as Address + + const result = calculateCollateralAllocation({ + mintShares: parseEther('1'), + assets: [wethChecksummed], + mintValues: [parseEther('0.5')], + balances: { [wethLower]: parseEther('10') }, + prices: {}, + decimals: { [wethLower]: 18 }, + selectedCollaterals: new Set
([wethLower]), // lowercase in set + strategy: 'partial', + inputToken: INPUT_TOKEN, + }) + + // Should still match and use wallet + expect(result[wethChecksummed].fromWallet).toBe(parseEther('0.5')) + expect(result[wethChecksummed].fromSwap).toBe(0n) + }) + + it('handles partial strategy with zero wallet balance for selected token', () => { + const result = calculateCollateralAllocation({ + mintShares: parseEther('1'), + assets: [WETH], + mintValues: [parseEther('0.5')], + balances: { [WETH.toLowerCase() as Address]: 0n }, + prices: { [WETH.toLowerCase() as Address]: 2000 }, + decimals: DECIMALS, + selectedCollaterals: new Set
([WETH]), + strategy: 'partial', + inputToken: INPUT_TOKEN, + }) + + // Zero balance means everything from swap + expect(result[WETH].fromWallet).toBe(0n) + expect(result[WETH].fromSwap).toBe(parseEther('0.5')) + expect(result[WETH].explanation).toBe('Covering the remainder') + }) + + it('falls back to 18 decimals for unknown token', () => { + const UNKNOWN = '0x1111111111111111111111111111111111111111' as Address + const result = calculateCollateralAllocation({ + mintShares: parseEther('1'), + assets: [UNKNOWN], + mintValues: [parseEther('100')], + balances: {}, + prices: { [UNKNOWN.toLowerCase() as Address]: 10 }, + decimals: {}, // No decimals entry — falls back to 18 + selectedCollaterals: new Set
(), + strategy: 'single', + inputToken: INPUT_TOKEN, + }) + + // 100 tokens * $10 = $1000 (using 18 decimals fallback) + expect(result[UNKNOWN].usdValue).toBeCloseTo(1000, 0) + }) +}) + +describe('calculateMaxMintAmount', () => { + it('returns input token balance for single strategy', () => { + const result = calculateMaxMintAmount({ + inputTokenBalance: 3000, + walletBalances: { + [WETH.toLowerCase() as Address]: parseEther('1'), + }, + tokenPrices: { [WETH.toLowerCase() as Address]: 2000 }, + tokenDecimals: DECIMALS, + selectedCollaterals: new Set
([WETH]), + strategy: 'single', + inputTokenAddress: USDC, + }) + expect(result).toBe(3000) + }) + + it('adds selected collateral value for partial strategy', () => { + const result = calculateMaxMintAmount({ + inputTokenBalance: 3000, // $3k USDC + walletBalances: { + [WETH.toLowerCase() as Address]: parseEther('1'), // 1 WETH = $2000 + [WBTC.toLowerCase() as Address]: parseUnits('0.125', 8), // 0.125 BTC = $5000 + }, + tokenPrices: { + [WETH.toLowerCase() as Address]: 2000, + [WBTC.toLowerCase() as Address]: 40000, + }, + tokenDecimals: DECIMALS, + selectedCollaterals: new Set
([WETH, WBTC]), + strategy: 'partial', + inputTokenAddress: USDC, + }) + // $3000 + $2000 + $5000 = $10000 + expect(result).toBe(10000) + }) + + it('skips input token in collateral value (no double-counting)', () => { + const result = calculateMaxMintAmount({ + inputTokenBalance: 3000, + walletBalances: { + [USDC.toLowerCase() as Address]: parseUnits('3000', 6), // input token + [WETH.toLowerCase() as Address]: parseEther('1'), + }, + tokenPrices: { + [USDC.toLowerCase() as Address]: 1, + [WETH.toLowerCase() as Address]: 2000, + }, + tokenDecimals: DECIMALS, + selectedCollaterals: new Set
([USDC, WETH]), + strategy: 'partial', + inputTokenAddress: USDC, + }) + // $3000 (input) + $2000 (WETH) — USDC not double-counted + expect(result).toBe(5000) + }) + + it('skips unselected tokens', () => { + const result = calculateMaxMintAmount({ + inputTokenBalance: 3000, + walletBalances: { + [WETH.toLowerCase() as Address]: parseEther('1'), + [WBTC.toLowerCase() as Address]: parseUnits('1', 8), + }, + tokenPrices: { + [WETH.toLowerCase() as Address]: 2000, + [WBTC.toLowerCase() as Address]: 40000, + }, + tokenDecimals: DECIMALS, + selectedCollaterals: new Set
([WETH]), // Only WETH selected + strategy: 'partial', + inputTokenAddress: USDC, + }) + // $3000 + $2000 (WETH only, WBTC not selected) + expect(result).toBe(5000) + }) + + it('returns input balance when wallet data is empty', () => { + const result = calculateMaxMintAmount({ + inputTokenBalance: 3000, + walletBalances: {}, + tokenPrices: {}, + tokenDecimals: {}, + selectedCollaterals: new Set
([WETH]), + strategy: 'partial', + inputTokenAddress: USDC, + }) + expect(result).toBe(3000) + }) + + it('handles case-insensitive addresses', () => { + const wethChecksummed = + '0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2' as Address + const wethLower = wethChecksummed.toLowerCase() as Address + + const result = calculateMaxMintAmount({ + inputTokenBalance: 1000, + walletBalances: { + [wethLower]: parseEther('1'), + }, + tokenPrices: { + [wethLower]: 2000, + }, + tokenDecimals: { [wethLower]: 18 }, + selectedCollaterals: new Set
([wethChecksummed]), // checksummed in set + strategy: 'partial', + inputTokenAddress: USDC, + }) + // $1000 + $2000 = $3000 + expect(result).toBe(3000) + }) +}) diff --git a/src/views/index-dtf/issuance/async-mint/tests/recovery.test.ts b/src/views/index-dtf/issuance/async-mint/tests/recovery.test.ts new file mode 100644 index 000000000..9388b0b89 --- /dev/null +++ b/src/views/index-dtf/issuance/async-mint/tests/recovery.test.ts @@ -0,0 +1,317 @@ +import { describe, it, expect } from 'vitest' +import { Address, parseEther, parseUnits } from 'viem' +import { + checkMintFeasibility, + calculateTopUp, + calculateReducedMint, + calculateReversalEstimate, +} from '../hooks/use-recovery' + +const WETH = '0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2' as Address +const WBTC = '0x2260fac5e5542a773aa44fbcfedf7c193bc2c599' as Address +const DAI = '0x6b175474e89094c44da98b954eedeac495271d0f' as Address + +describe('checkMintFeasibility', () => { + it('returns true when all assets have sufficient balance', () => { + const result = checkMintFeasibility( + { + [WETH.toLowerCase() as Address]: parseEther('1'), + [WBTC.toLowerCase() as Address]: parseUnits('0.1', 8), + }, + [parseEther('0.5'), parseUnits('0.05', 8)], + [WETH, WBTC] + ) + expect(result).toBe(true) + }) + + it('returns false when any asset is short', () => { + const result = checkMintFeasibility( + { + [WETH.toLowerCase() as Address]: parseEther('0.3'), // Short! + [WBTC.toLowerCase() as Address]: parseUnits('0.1', 8), + }, + [parseEther('0.5'), parseUnits('0.05', 8)], + [WETH, WBTC] + ) + expect(result).toBe(false) + }) + + it('returns false with empty assets', () => { + expect(checkMintFeasibility({}, [], [])).toBe(false) + }) + + it('returns false when acquired balance is zero', () => { + const result = checkMintFeasibility( + {}, + [parseEther('0.5')], + [WETH] + ) + expect(result).toBe(false) + }) + + it('returns true when balance exactly matches required', () => { + const result = checkMintFeasibility( + { [WETH.toLowerCase() as Address]: parseEther('0.5') }, + [parseEther('0.5')], + [WETH] + ) + expect(result).toBe(true) + }) + + it('handles mixed-decimal tokens (WBTC 8 decimals)', () => { + const result = checkMintFeasibility( + { + [WETH.toLowerCase() as Address]: parseEther('1'), + [WBTC.toLowerCase() as Address]: parseUnits('0.04', 8), // Short — 0.04 < 0.05 + }, + [parseEther('0.5'), parseUnits('0.05', 8)], + [WETH, WBTC] + ) + expect(result).toBe(false) + }) +}) + +describe('calculateTopUp', () => { + it('calculates correct shortfall', () => { + const result = calculateTopUp(10000, 9650) + expect(result.topUpAmount).toBe(350) + }) + + it('returns zero when no shortfall', () => { + const result = calculateTopUp(10000, 10000) + expect(result.topUpAmount).toBe(0) + }) + + it('handles over-acquired (no negative)', () => { + const result = calculateTopUp(10000, 11000) + expect(result.topUpAmount).toBe(0) + }) +}) + +describe('calculateReducedMint', () => { + it('calculates reduced shares from acquired balances', () => { + const folioAmount = parseEther('10') + const result = calculateReducedMint({ + acquiredBalances: { + [WETH.toLowerCase() as Address]: parseEther('5'), // Half of what's needed + [DAI.toLowerCase() as Address]: parseEther('10000'), // Full + }, + assets: [WETH, DAI], + mintValues: [parseEther('10'), parseEther('10000')], + folioAmount, + dtfPrice: 100, + slippageBps: 100, + }) + + // WETH is the bottleneck: can mint 5 * 10 / 10 = 5 + expect(result.reducedShares).toBe(parseEther('5')) + }) + + it('returns empty for empty assets', () => { + const result = calculateReducedMint({ + acquiredBalances: {}, + assets: [], + mintValues: [], + folioAmount: 0n, + dtfPrice: 100, + slippageBps: 100, + }) + expect(result.reducedShares).toBe(0n) + expect(result.unusedCollateral).toEqual({}) + }) + + it('calculates unused collateral correctly', () => { + const folioAmount = parseEther('10') + const result = calculateReducedMint({ + acquiredBalances: { + [WETH.toLowerCase() as Address]: parseEther('3'), // Bottleneck + [DAI.toLowerCase() as Address]: parseEther('10000'), + }, + assets: [WETH, DAI], + mintValues: [parseEther('10'), parseEther('10000')], + folioAmount, + dtfPrice: 100, + slippageBps: 100, + }) + + // reducedShares = 3 * 10 / 10 = 3 + // DAI used = 3 * 10000 / 10 = 3000, unused = 10000 - 3000 = 7000 + expect(result.unusedCollateral[DAI]).toBe(parseEther('7000')) + }) + + it('correctly handles first asset with zero mintValue', () => { + const ZERO_TOKEN = '0x0000000000000000000000000000000000000001' as Address + const folioAmount = parseEther('10') + const result = calculateReducedMint({ + acquiredBalances: { + [ZERO_TOKEN.toLowerCase() as Address]: parseEther('100'), + [WETH.toLowerCase() as Address]: parseEther('5'), + [DAI.toLowerCase() as Address]: parseEther('10000'), + }, + assets: [ZERO_TOKEN, WETH, DAI], + mintValues: [0n, parseEther('10'), parseEther('10000')], + folioAmount, + dtfPrice: 100, + slippageBps: 100, + }) + + // WETH is the bottleneck: 5 * 10 / 10 = 5 (zero-mintValue token excluded) + expect(result.reducedShares).toBe(parseEther('5')) + }) + + it('returns 0n when a participating token has zero balance', () => { + const folioAmount = parseEther('10') + const result = calculateReducedMint({ + acquiredBalances: { + [WETH.toLowerCase() as Address]: parseEther('5'), + // DAI not acquired (0 balance) — this is the bottleneck + }, + assets: [WETH, DAI], + mintValues: [parseEther('10'), parseEther('10000')], + folioAmount, + dtfPrice: 100, + slippageBps: 100, + }) + + // No DAI = can't mint anything + expect(result.reducedShares).toBe(0n) + }) + + it('calculates swapLossEstimate from slippageBps', () => { + const result = calculateReducedMint({ + acquiredBalances: { [WETH.toLowerCase() as Address]: parseEther('10') }, + assets: [WETH], + mintValues: [parseEther('10')], + folioAmount: parseEther('10'), + dtfPrice: 100, + slippageBps: 250, // 2.5% + }) + + expect(result.swapLossEstimate).toBeCloseTo(2.5, 1) + }) + + it('handles single-token DTF', () => { + const folioAmount = parseEther('10') + const result = calculateReducedMint({ + acquiredBalances: { + [WETH.toLowerCase() as Address]: parseEther('7'), + }, + assets: [WETH], + mintValues: [parseEther('10')], + folioAmount, + dtfPrice: 100, + slippageBps: 100, + }) + + // 7 * 10 / 10 = 7 + expect(result.reducedShares).toBe(parseEther('7')) + expect(result.unusedCollateral).toEqual({}) + }) +}) + +describe('calculateReversalEstimate', () => { + it('estimates return with slippage', () => { + const result = calculateReversalEstimate( + { [WETH.toLowerCase() as Address]: parseEther('1') }, + { [WETH.toLowerCase() as Address]: 2000 }, + { [WETH.toLowerCase() as Address]: 18 }, + 100 // 1% slippage + ) + + // 1 ETH * $2000 * 0.99 = $1980 + expect(result.estimatedReturn).toBeCloseTo(1980, 0) + }) + + it('returns zero for empty collateral', () => { + const result = calculateReversalEstimate({}, {}, {}, 100) + expect(result.estimatedReturn).toBe(0) + }) + + it('handles multiple tokens', () => { + const result = calculateReversalEstimate( + { + [WETH.toLowerCase() as Address]: parseEther('1'), + [DAI.toLowerCase() as Address]: parseEther('1000'), + }, + { + [WETH.toLowerCase() as Address]: 2000, + [DAI.toLowerCase() as Address]: 1, + }, + { + [WETH.toLowerCase() as Address]: 18, + [DAI.toLowerCase() as Address]: 18, + }, + 100 + ) + + // 1 ETH * $2000 * 0.99 + 1000 DAI * $1 * 0.99 = $1980 + $990 = $2970 + expect(result.estimatedReturn).toBeCloseTo(2970, 0) + }) + + it('handles missing price for a token (defaults to 0)', () => { + const result = calculateReversalEstimate( + { + [WETH.toLowerCase() as Address]: parseEther('1'), + [WBTC.toLowerCase() as Address]: parseUnits('0.1', 8), + }, + { [WETH.toLowerCase() as Address]: 2000 }, // No WBTC price + { + [WETH.toLowerCase() as Address]: 18, + [WBTC.toLowerCase() as Address]: 8, + }, + 100 + ) + + // Only WETH contributes: 1 * 2000 * 0.99 = 1980 + expect(result.estimatedReturn).toBeCloseTo(1980, 0) + }) + + it('handles missing decimals for a token (defaults to 18)', () => { + const UNKNOWN = '0x1111111111111111111111111111111111111111' as Address + const result = calculateReversalEstimate( + { [UNKNOWN.toLowerCase() as Address]: parseEther('100') }, + { [UNKNOWN.toLowerCase() as Address]: 10 }, + {}, // No decimals entry — defaults to 18 + 100 + ) + + // 100 * $10 * 0.99 = $990 + expect(result.estimatedReturn).toBeCloseTo(990, 0) + }) + + it('calculates loss correctly', () => { + const result = calculateReversalEstimate( + { [WETH.toLowerCase() as Address]: parseEther('1') }, + { [WETH.toLowerCase() as Address]: 2000 }, + { [WETH.toLowerCase() as Address]: 18 }, + 100 + ) + + // loss = 2000 - 1980 = 20 + expect(result.loss).toBeCloseTo(20, 0) + }) + + it('handles zero slippage', () => { + const result = calculateReversalEstimate( + { [WETH.toLowerCase() as Address]: parseEther('1') }, + { [WETH.toLowerCase() as Address]: 2000 }, + { [WETH.toLowerCase() as Address]: 18 }, + 0 + ) + + expect(result.estimatedReturn).toBeCloseTo(2000, 0) + expect(result.loss).toBeCloseTo(0, 0) + }) + + it('handles WBTC 8-decimal token correctly', () => { + const result = calculateReversalEstimate( + { [WBTC.toLowerCase() as Address]: parseUnits('0.5', 8) }, + { [WBTC.toLowerCase() as Address]: 60000 }, + { [WBTC.toLowerCase() as Address]: 8 }, + 100 + ) + + // 0.5 BTC * $60000 * 0.99 = $29700 + expect(result.estimatedReturn).toBeCloseTo(29700, 0) + }) +}) diff --git a/src/views/index-dtf/issuance/async-mint/types.ts b/src/views/index-dtf/issuance/async-mint/types.ts new file mode 100644 index 000000000..ed3fe8800 --- /dev/null +++ b/src/views/index-dtf/issuance/async-mint/types.ts @@ -0,0 +1,28 @@ +import { OrderQuoteResponse } from '@cowprotocol/cow-sdk' + +export type WizardStep = + | 'gnosis-check' + | 'operation-select' + | 'collateral-decision' + | 'token-selection' + | 'amount-input' + | 'review' + | 'quote-summary' + | 'processing' + | 'recovery-options' + | 'success' + +export type MintStrategy = 'partial' | 'single' + +export type RecoveryChoice = 'top-up' | 'mint-reduced' | 'cancel' | null + +export type QuoteResult = + | { success: true; data: OrderQuoteResponse } + | { success: false; error?: string } + +export type CollateralAllocation = { + fromWallet: bigint + fromSwap: bigint + usdValue: number + explanation: string +} diff --git a/src/views/index-dtf/issuance/async-mint/utils.ts b/src/views/index-dtf/issuance/async-mint/utils.ts new file mode 100644 index 000000000..bd4ceb805 --- /dev/null +++ b/src/views/index-dtf/issuance/async-mint/utils.ts @@ -0,0 +1,123 @@ +import { Address, formatUnits } from 'viem' +import { CollateralAllocation, MintStrategy } from './types' + +// ─── Max mint amount (accounts for wallet collateral) ──────────────── +export function calculateMaxMintAmount({ + inputTokenBalance, + walletBalances, + tokenPrices, + tokenDecimals, + selectedCollaterals, + strategy, + inputTokenAddress, +}: { + inputTokenBalance: number + walletBalances: Record + tokenPrices: Record + tokenDecimals: Record + selectedCollaterals: Set
+ strategy: MintStrategy + inputTokenAddress: Address +}): number { + if (strategy === 'single') return inputTokenBalance + + // Normalize set to lowercase for consistent matching + const normalizedSelected = new Set
( + [...selectedCollaterals].map((a) => a.toLowerCase() as Address) + ) + + let collateralValue = 0 + for (const [addr, balance] of Object.entries(walletBalances)) { + const normalizedAddr = addr.toLowerCase() as Address + if (normalizedAddr === inputTokenAddress.toLowerCase()) continue + if (!normalizedSelected.has(normalizedAddr)) continue + + const price = tokenPrices[normalizedAddr] ?? 0 + const decimals = tokenDecimals[normalizedAddr] ?? 18 + collateralValue += Number(formatUnits(balance, decimals)) * price + } + + return inputTokenBalance + collateralValue +} + +// ─── Pure collateral allocation calculation ────────────────────────── +export function calculateCollateralAllocation({ + mintShares, + assets, + mintValues, + balances, + prices, + decimals, + selectedCollaterals, + strategy, + inputToken, +}: { + mintShares: bigint + assets: Address[] + mintValues: bigint[] + balances: Record + prices: Record + decimals: Record + selectedCollaterals: Set
+ strategy: MintStrategy + inputToken: { address: Address; decimals: number; symbol: string } +}): Record { + if (mintShares === 0n || assets.length === 0) return {} + + const result: Record = {} + + for (let i = 0; i < assets.length; i++) { + const asset = assets[i] + const required = mintValues[i] + const walletBalance = balances[asset.toLowerCase() as Address] ?? 0n + const isInputToken = + asset.toLowerCase() === inputToken.address.toLowerCase() + const isSelected = + selectedCollaterals.has(asset) || + selectedCollaterals.has(asset.toLowerCase() as Address) + const useWallet = strategy === 'partial' && isSelected && !isInputToken + + let fromWallet = 0n + let fromSwap = required + let explanation = 'Covering the remainder' + + if (useWallet && walletBalance > 0n) { + if (walletBalance >= required) { + fromWallet = required + fromSwap = 0n + explanation = 'Using your full balance' + } else { + fromWallet = walletBalance + fromSwap = required - walletBalance + explanation = 'Using your full balance' + } + + // WHY: If wallet has more than required, it means the token is capped at its DTF weight + if (walletBalance > required) { + explanation = 'Token at its maximum weight' + } + } + + // Skip input token — it's the one we're spending, not acquiring + if (isInputToken) { + continue + } + + // Estimate USD value for fromSwap portion + const price = prices[asset.toLowerCase() as Address] ?? 0 + const dec = decimals[asset.toLowerCase() as Address] ?? 18 + const swapUsdValue = + fromSwap > 0n && price > 0 + ? Number(formatUnits(fromSwap, dec)) * price + : 0 + + result[asset] = { + fromWallet, + fromSwap, + usdValue: swapUsdValue, + explanation, + } + } + + return result +} diff --git a/src/views/index-dtf/issuance/async-swaps/async-mint/index.tsx b/src/views/index-dtf/issuance/async-swaps/async-mint/index.tsx new file mode 100644 index 000000000..0f5218809 --- /dev/null +++ b/src/views/index-dtf/issuance/async-swaps/async-mint/index.tsx @@ -0,0 +1,104 @@ +import { + ArrowSeparator, + TokenInputBox, + TokenOutputBox, +} from '@/components/ui/swap' +import { useChainlinkPrice } from '@/hooks/useChainlinkPrice' +import { cn } from '@/lib/utils' +import { chainIdAtom } from '@/state/atoms' +import { indexDTFAtom } from '@/state/dtf/atoms' +import { formatCurrencyCompact } from '@/utils' +import { useAtom, useAtomValue } from 'jotai' +import { + collateralAcquiredAtom, + isMintingAtom, + mintValueAtom, + mintValueUSDAtom, + ordersSubmittedAtom, + selectedTokenAtom, + selectedTokenBalanceAtom, + userInputAtom, +} from '../atom' +import CollateralAcquisition from '../collateral-acquisition' +import Details from '../details' +import { useQuotesForMint } from '../hooks/useQuote' +import SubmitMint from './submit-mint-orders' + +const AsyncMint = () => { + const chainId = useAtomValue(chainIdAtom) + const indexDTF = useAtomValue(indexDTFAtom) + const [inputAmount, setInputAmount] = useAtom(userInputAtom) + const selectedToken = useAtomValue(selectedTokenAtom) + const selectedTokenBalance = useAtomValue(selectedTokenBalanceAtom) + const isMinting = useAtomValue(isMintingAtom) + const selectedTokenPrice = useChainlinkPrice(chainId, selectedToken.address) + const inputValueUSD = (selectedTokenPrice || 0) * Number(inputAmount) + const onMax = () => setInputAmount(selectedTokenBalance?.balance || '0') + const ordersSubmitted = useAtomValue(ordersSubmittedAtom) + const collateralAcquired = useAtomValue(collateralAcquiredAtom) + const amountOut = useAtomValue(mintValueAtom) + const amountOutValue = useAtomValue(mintValueUSDAtom) + + const { isLoading, isFetching } = useQuotesForMint() + + const awaitingQuote = isLoading || isFetching + + if (!indexDTF) return null + + return ( +
+ + + ${formatCurrencyCompact(amountOutValue)} + ) : undefined, + value: amountOut.toString(), + className: cn( + 'rounded-3xl border-8 border-card rounded-b-none pb-2', + ordersSubmitted && 'border-background bg-background', + collateralAcquired && !isMinting && 'border-card bg-card' + ), + }} + loading={isLoading} + /> +
+ {!ordersSubmitted && } + {ordersSubmitted && } + {!collateralAcquired &&
} +
+
+ ) +} + +export default AsyncMint diff --git a/src/views/index-dtf/issuance/async-swaps/async-mint/submit-mint-orders.tsx b/src/views/index-dtf/issuance/async-swaps/async-mint/submit-mint-orders.tsx new file mode 100644 index 000000000..a73b19ac1 --- /dev/null +++ b/src/views/index-dtf/issuance/async-swaps/async-mint/submit-mint-orders.tsx @@ -0,0 +1,102 @@ +import { Button } from '@/components/ui/button' +import { cn } from '@/lib/utils' +import { useAtomValue, useSetAtom } from 'jotai' +import { useCallback, useMemo } from 'react' +import { + balanceAfterSwapAtom, + infoMessageAtom, + insufficientBalanceAtom, + selectedTokenBalanceAtom, + userInputAtom, +} from '../atom' +import { useStableQuoteSignatures } from '../hooks/useQuoteSignatures' +import { TransactionButtonContainer } from '@/components/ui/transaction' +import { chainIdAtom } from '@/state/atoms' + +type SubmitMintProps = { + loadingQuote?: boolean + insufficientBalance?: boolean +} + +const SubmitMintButton = ({ + mutate, + isPending, + loadingQuote, +}: { + mutate: () => void + isPending: boolean + loadingQuote?: boolean +}) => { + const chainId = useAtomValue(chainIdAtom) + const insufficientBalance = useAtomValue(insufficientBalanceAtom) + const selectedTokenBalance = useAtomValue(selectedTokenBalanceAtom) + const inputAmount = useAtomValue(userInputAtom) + const infoMessage = useAtomValue(infoMessageAtom) + const setBalanceAfterSwap = useSetAtom(balanceAfterSwapAtom) + + const handleSubmit = useCallback(() => { + setBalanceAfterSwap(selectedTokenBalance?.value || 0n) + mutate() + }, [mutate, selectedTokenBalance?.value, setBalanceAfterSwap]) + + const disabled = useMemo( + () => + isPending || loadingQuote || !inputAmount || isNaN(Number(inputAmount)), + [isPending, loadingQuote, inputAmount] + ) + + const buttonText = useMemo(() => { + if (loadingQuote) { + return 'Awaiting Quote' + } + if (infoMessage) { + return infoMessage + } + if (isPending) { + return 'Signing...' + } + if (insufficientBalance) { + return 'Insufficient Balance' + } + return ( + + Start Mint + - Step 1/2 + + ) + }, [insufficientBalance, isPending, loadingQuote, infoMessage]) + + return ( + + + + ) +} + +const SubmitMint = ({ loadingQuote }: SubmitMintProps) => { + const { mutate, isPending } = useStableQuoteSignatures() + + return ( + + ) +} + +export default SubmitMint diff --git a/src/views/index-dtf/issuance/async-swaps/async-redeem/index.tsx b/src/views/index-dtf/issuance/async-swaps/async-redeem/index.tsx new file mode 100644 index 000000000..605be8288 --- /dev/null +++ b/src/views/index-dtf/issuance/async-swaps/async-redeem/index.tsx @@ -0,0 +1,162 @@ +import TokenLogo from '@/components/token-logo' +import StackTokenLogo from '@/components/token-logo/StackTokenLogo' +import { + ArrowSeparator, + TokenInputBox, + TokenOutputBox, +} from '@/components/ui/swap' +import { cn } from '@/lib/utils' +import { + indexDTFAtom, + indexDTFBasketAtom, + indexDTFPriceAtom, +} from '@/state/dtf/atoms' +import { formatCurrencyCompact } from '@/utils' +import { useAtom, useAtomValue } from 'jotai' +import { useMemo } from 'react' +import { formatEther } from 'viem' +import { + collateralAcquiredAtom, + indexDTFBalanceAtom, + isMintingAtom, + ordersSubmittedAtom, + redeemAssetsAtom, + selectedTokenAtom, + userInputAtom, +} from '../atom' +import CollateralAcquisition from '../collateral-acquisition' +import { useQuotesForRedeem } from '../hooks/useQuote' +import SubmitRedeem from './submit-redeem' +import SubmitRedeemOrders from './submit-redeem-orders' + +const CustomInputBox = () => { + const indexDTF = useAtomValue(indexDTFAtom) + const inputAmount = useAtomValue(userInputAtom) + const basket = useAtomValue(indexDTFBasketAtom) + + if (!indexDTF) return null + + return ( +
+ +
+ You Redeemed: + + {inputAmount} + {indexDTF.token.symbol} + + for {basket?.length} underlying collateral tokens +
+
+
+ ({ + ...r, + chain: indexDTF.chainId, + }))} + size={24} + overlap={-2} + reverseStack + outsource + /> +
+
+ ) +} + +const AsyncRedeem = () => { + const indexDTF = useAtomValue(indexDTFAtom) + const [inputAmount, setInputAmount] = useAtom(userInputAtom) + const selectedToken = useAtomValue(selectedTokenAtom) + const isMinting = useAtomValue(isMintingAtom) + const indexDTFPrice = useAtomValue(indexDTFPriceAtom) + const inputPrice = (indexDTFPrice || 0) * Number(inputAmount) + const indexDTFBalance = useAtomValue(indexDTFBalanceAtom) + const indxDTFParsedBalance = formatEther(indexDTFBalance) + const onMax = () => setInputAmount(indxDTFParsedBalance) + const ordersSubmitted = useAtomValue(ordersSubmittedAtom) + const collateralAcquired = useAtomValue(collateralAcquiredAtom) + const amountOut = inputPrice + const amountOutValue = inputPrice + const redeemAssets = useAtomValue(redeemAssetsAtom) + + const { isLoading, isFetching } = useQuotesForRedeem() + + const awaitingQuote = isLoading || isFetching + + if (!indexDTF) return null + + const assetsRedeemed = useMemo( + () => Object.keys(redeemAssets).length > 0, + [redeemAssets] + ) + + return ( +
+
+ {assetsRedeemed ? ( + + ) : ( + + )} + + ${formatCurrencyCompact(amountOutValue)} + ) : undefined, + value: amountOut.toString(), + className: cn( + 'rounded-3xl border-8 border-card rounded-b-none pb-2', + ordersSubmitted && 'border-background bg-background', + collateralAcquired && !isMinting && 'border-card bg-card' + ), + }} + loading={isLoading} + /> +
+
+ {!assetsRedeemed && } + {assetsRedeemed && !ordersSubmitted && ( + + )} + {ordersSubmitted && } +
+
+ ) +} + +export default AsyncRedeem diff --git a/src/views/index-dtf/issuance/async-swaps/async-redeem/submit-redeem-orders.tsx b/src/views/index-dtf/issuance/async-swaps/async-redeem/submit-redeem-orders.tsx new file mode 100644 index 000000000..aed9f8fea --- /dev/null +++ b/src/views/index-dtf/issuance/async-swaps/async-redeem/submit-redeem-orders.tsx @@ -0,0 +1,90 @@ +import { Button } from '@/components/ui/button' +import { cn } from '@/lib/utils' +import { useAtomValue, useSetAtom } from 'jotai' +import { useCallback, useMemo } from 'react' +import { + balanceAfterSwapAtom, + infoMessageAtom, + redeemAssetsAtom, + selectedTokenBalanceAtom, +} from '../atom' +import { useStableQuoteSignatures } from '../hooks/useQuoteSignatures' + +type SubmitRedeemOrdersProps = { + loadingQuote?: boolean +} + +const SubmitRedeemButton = ({ + mutate, + isPending, + loadingQuote, +}: { + mutate: () => void + isPending: boolean + loadingQuote?: boolean +}) => { + const selectedTokenBalance = useAtomValue(selectedTokenBalanceAtom) + const redeemAssets = useAtomValue(redeemAssetsAtom) + const infoMessage = useAtomValue(infoMessageAtom) + const setBalanceAfterSwap = useSetAtom(balanceAfterSwapAtom) + + const handleSubmit = useCallback(() => { + setBalanceAfterSwap(selectedTokenBalance?.value || 0n) + mutate() + }, [mutate, selectedTokenBalance?.value, setBalanceAfterSwap]) + + const disabled = useMemo( + () => + isPending || + loadingQuote || + !redeemAssets || + Object.keys(redeemAssets).length === 0, + [isPending, loadingQuote, redeemAssets] + ) + + const buttonText = useMemo(() => { + if (loadingQuote) { + return 'Awaiting Quote' + } + if (infoMessage) { + return infoMessage + } + if (isPending) { + return 'Signing...' + } + return ( + + Sell Collateral for USDC + - Step 2/2 + + ) + }, [isPending, loadingQuote, infoMessage]) + + return ( + + ) +} + +const SubmitRedeemOrders = ({ loadingQuote }: SubmitRedeemOrdersProps) => { + const { mutate, isPending } = useStableQuoteSignatures() + + return ( + + ) +} + +export default SubmitRedeemOrders diff --git a/src/views/index-dtf/issuance/async-swaps/async-redeem/submit-redeem.tsx b/src/views/index-dtf/issuance/async-swaps/async-redeem/submit-redeem.tsx new file mode 100644 index 000000000..dbcccf029 --- /dev/null +++ b/src/views/index-dtf/issuance/async-swaps/async-redeem/submit-redeem.tsx @@ -0,0 +1,125 @@ +import dtfIndexAbi from '@/abis/dtf-index-abi' +import { Button } from '@/components/ui/button' +import { cn } from '@/lib/utils' +import { chainIdAtom, walletAtom } from '@/state/atoms' +import { indexDTFAtom } from '@/state/dtf/atoms' +import { safeParseEther } from '@/utils' +import { useAtomValue, useSetAtom } from 'jotai' +import { useCallback, useEffect } from 'react' +import { Address, parseEventLogs } from 'viem' +import { useSendCalls, useWaitForCallsStatus, useWalletClient } from 'wagmi' +import { + txHashAtom, + redeemAssetsAtom, + userInputAtom, + insufficientBalanceAtom, +} from '../atom' +import { useFolioDetails } from '../hooks/useFolioDetails' + +const SubmitRedeem = () => { + const chainId = useAtomValue(chainIdAtom) + const account = useAtomValue(walletAtom) + const indexDTF = useAtomValue(indexDTFAtom) + const inputAmount = useAtomValue(userInputAtom) + const insufficientBalance = useAtomValue(insufficientBalanceAtom) + const setRedeemAssets = useSetAtom(redeemAssetsAtom) + const setRedeemTxHash = useSetAtom(txHashAtom) + + const sharesToRedeem = safeParseEther(inputAmount) + const { data: walletClient } = useWalletClient() + const { data: folioDetails } = useFolioDetails({ shares: sharesToRedeem }) + + const { data, sendCalls, isPending } = useSendCalls() + + const { data: callsStatus, isLoading: isReceiptLoading } = + useWaitForCallsStatus({ + id: data?.id || '', + }) + + useEffect(() => { + if (callsStatus?.status === 'success') { + const receipt = callsStatus.receipts?.[0] + + setRedeemTxHash(receipt?.transactionHash || 'tx-hash-not-found') + + const events = parseEventLogs({ + abi: dtfIndexAbi, + logs: receipt?.logs as any, + eventName: 'Transfer', + }) + + events + .filter((event) => event.address !== indexDTF?.id) + .forEach((event) => { + const assetAddress = event.address as Address + const amount = event.args.value as bigint + setRedeemAssets((prev: Record) => ({ + ...prev, + [assetAddress]: amount, + })) + }) + } + }, [callsStatus]) + + const handleSubmit = useCallback(() => { + if ( + !chainId || + !walletClient || + !account || + !indexDTF || + !inputAmount || + !folioDetails + ) + return + + try { + sendCalls({ + calls: [ + { + to: indexDTF.id, + abi: dtfIndexAbi, + functionName: 'redeem', + args: [ + sharesToRedeem, + account, + folioDetails.assets ?? [], + folioDetails.redeemValues.map((e) => (e * 95n) / 100n), + ], + }, + ], + }) + } catch (error) { + console.error('Error processing orders:', error) + } + }, [chainId, walletClient, account, indexDTF, inputAmount, folioDetails]) + + const disabled = + isPending || isReceiptLoading || !inputAmount || insufficientBalance + + return ( +
+ +
+ ) +} + +export default SubmitRedeem diff --git a/src/views/index-dtf/issuance/async-swaps/atom.ts b/src/views/index-dtf/issuance/async-swaps/atom.ts new file mode 100644 index 000000000..ae2515e6d --- /dev/null +++ b/src/views/index-dtf/issuance/async-swaps/atom.ts @@ -0,0 +1,150 @@ +import { balancesAtom, chainIdAtom, TokenBalance } from '@/state/atoms' +import { indexDTFPriceAtom } from '@/state/dtf/atoms' +import { Token } from '@/types' +import { reducedZappableTokens } from '@/views/yield-dtf/issuance/components/zapV2/constants' +import { + EnrichedOrder, + OrderStatus, +} from '@cowprotocol/cow-sdk' +import { atom } from 'jotai' +import { atomWithReset } from 'jotai/utils' +import { Address, formatUnits, parseEther, parseUnits } from 'viem' +import { + AsyncSwapOrderResponse, + QuoteAggregated, +} from './types' + +const ASYNC_MINT_BUFFER = 0.01 + +// Main Atoms +export const operationAtom = atom<'mint' | 'redeem'>('mint') +export const userInputAtom = atomWithReset('') +export const indexDTFBalanceAtom = atom(0n) +export const txHashAtom = atom('') // tx hash for minting or redeeming +export const redeemAssetsAtom = atom>({}) +export const quotesAtom = atom>({}) +export const cowswapOrderIdsAtom = atom([]) +export const cowswapOrdersCreatedAtAtom = atom(undefined) +export const cowswapOrdersAtom = atom<(EnrichedOrder & { orderId: string })[]>( + [] +) + +export const slippageAtom = atom('100') +export const applyWalletBalanceAtom = atom(true) + +export const refetchQuotesAtom = atom<{ fn: () => void }>({ fn: () => {} }) +export const fetchingQuotesAtom = atom(false) + +export const isMintingAtom = atom(false) +export const successAtom = atom(false) + +export const infoMessageAtom = atom(undefined) + +export const balanceAfterSwapAtom = atom(0n) + +// Render Atoms +export const openCollateralPanelAtom = atom(true) +export const showSettingsAtom = atom(false) + +// Computed Atoms +export const selectedTokenAtom = atom((get) => { + const chainId = get(chainIdAtom) + return reducedZappableTokens[chainId][2] // USDC +}) + +export const selectedTokenBalanceAtom = atom( + (get) => { + const balances = get(balancesAtom) + const token = get(selectedTokenAtom) + return balances[token.address] + } +) + +export const insufficientBalanceAtom = atom((get) => { + const inputAmount = get(userInputAtom) + const operation = get(operationAtom) + const selectedToken = get(selectedTokenAtom) + const selectedTokenBalance = get(selectedTokenBalanceAtom) + const indexDTFParsedBalance = get(indexDTFBalanceAtom) + return operation === 'mint' + ? parseUnits(inputAmount, selectedToken.decimals) > + (selectedTokenBalance?.value || 0n) + : parseEther(inputAmount) > indexDTFParsedBalance +}) + +export const collateralAcquiredAtom = atom((get) => { + const cowswapOrders = get(cowswapOrdersAtom) + return ( + cowswapOrders.length > 0 && + cowswapOrders.every((order) => order.status === OrderStatus.FULFILLED) + ) +}) + +export const mintValueAtom = atom((get) => { + const inputAmount = get(userInputAtom) + const dtfPrice = get(indexDTFPriceAtom) + const result = + ((Number(inputAmount) || 0) / (dtfPrice ?? 1)) * (1 - ASYNC_MINT_BUFFER) + + // 0.000001 is the minimum to avoid exponential notation when converting to string + return result > 0 && result < 0.000001 ? 0.000001 : result +}) + +export const mintValueUSDAtom = atom((get) => { + const inputAmount = get(userInputAtom) + return (Number(inputAmount) || 0) * (1 - ASYNC_MINT_BUFFER) +}) + +export const savedAmountAtom = atom((get) => { + const inputAmount = get(userInputAtom) + const balanceDifference = get(balanceDifferenceAtom) + return (Number(inputAmount) || 0) - balanceDifference +}) + +export const mintValueWeiAtom = atom((get) => { + const amountOut = get(mintValueAtom) + return parseEther(amountOut.toString()) +}) + +// Only Cowswap Orders +export const failedOrdersAtom = atom( + (get) => { + const cowswapOrders = get(cowswapOrdersAtom) + return ( + cowswapOrders.filter((order) => + [OrderStatus.CANCELLED, OrderStatus.EXPIRED].includes(order.status) + ) || [] + ) + } +) + +// Only Cowswap Orders +export const pendingOrdersAtom = atom( + (get) => { + const cowswapOrders = get(cowswapOrdersAtom) + return ( + cowswapOrders.filter((order) => + [OrderStatus.OPEN, OrderStatus.PRESIGNATURE_PENDING].includes( + order.status + ) + ) || [] + ) + } +) + +export const ordersSubmittedAtom = atom((get) => { + const cowswapOrdersCreatedAt = get(cowswapOrdersCreatedAtAtom) + return Boolean(cowswapOrdersCreatedAt) +}) + +export const balanceDifferenceAtom = atom((get) => { + const selectedToken = get(selectedTokenAtom) + const balanceAfterSwap = get(balanceAfterSwapAtom) + const selectedTokenBalance = get(selectedTokenBalanceAtom) + const operation = get(operationAtom) + const result = + operation === 'mint' + ? balanceAfterSwap - (selectedTokenBalance?.value || 0n) + : selectedTokenBalance?.value || 0n - balanceAfterSwap + return Number(formatUnits(result, selectedToken.decimals)) +}) diff --git a/src/views/index-dtf/issuance/async-swaps/atomic-batch-required.tsx b/src/views/index-dtf/issuance/async-swaps/atomic-batch-required.tsx new file mode 100644 index 000000000..052ebf913 --- /dev/null +++ b/src/views/index-dtf/issuance/async-swaps/atomic-batch-required.tsx @@ -0,0 +1,105 @@ +import { Button } from '@/components/ui/button' +import Help from '@/components/ui/help' +import { TransactionButtonContainer } from '@/components/ui/transaction' +import { chainIdAtom } from '@/state/atoms' +import { useConnectModal } from '@rainbow-me/rainbowkit' +import { useAtomValue } from 'jotai' +import { ExternalLink, OctagonAlert } from 'lucide-react' +import { useEffect, useState } from 'react' +import { useAccount, useDisconnect } from 'wagmi' + +const AtomicBatchRequired = () => { + const { openConnectModal } = useConnectModal() + const { disconnect } = useDisconnect() + const { isConnected } = useAccount() + const [shouldOpenModal, setShouldOpenModal] = useState(false) + const chainId = useAtomValue(chainIdAtom) + + useEffect(() => { + if (shouldOpenModal && !isConnected && openConnectModal) { + openConnectModal() + setShouldOpenModal(false) + } + }, [shouldOpenModal, isConnected, openConnectModal]) + + const handleSwitchWallet = async () => { + try { + setShouldOpenModal(true) + await disconnect() + } catch (error) { + console.error('Error switching wallets:', error) + setShouldOpenModal(false) + } + } + + return ( +
+
+
+
+ CoW Protocol + Universal Protocol +
+
+ +
Atomic Batch Required
+ +
+
+
+
+ Get better prices by accessing off-chain liquidity +
+
+ Automated Slow Mints can provide better quotes for minting or + redeeming a DTF, particularly when dealing with significant amounts + of capital or DTFs that involve bridged or low DEX liquidity + collateral assets. +
+
+
+
+ + + + + Create a new Gnosis Safe + + +
+
+ ) +} + +export default AtomicBatchRequired diff --git a/src/views/index-dtf/issuance/async-swaps/collateral-acquisition.tsx b/src/views/index-dtf/issuance/async-swaps/collateral-acquisition.tsx new file mode 100644 index 000000000..90cc42708 --- /dev/null +++ b/src/views/index-dtf/issuance/async-swaps/collateral-acquisition.tsx @@ -0,0 +1,187 @@ +import StackTokenLogo from '@/components/token-logo/StackTokenLogo' +import { Button } from '@/components/ui/button' +import { indexDTFAtom, indexDTFBasketAtom } from '@/state/dtf/atoms' +import { getTimerFormat } from '@/utils' +import { useAtom, useAtomValue, useSetAtom } from 'jotai' +import { ArrowLeft, ArrowRight, Check, Loader, RefreshCw } from 'lucide-react' +import { useEffect, useMemo, useState } from 'react' +import { + collateralAcquiredAtom, + cowswapOrdersCreatedAtAtom, + failedOrdersAtom, + openCollateralPanelAtom, + operationAtom, + pendingOrdersAtom, + successAtom, +} from './atom' +import { useRefreshQuotes } from './hooks/useQuote' +import { useStableQuoteSignatures } from './hooks/useQuoteSignatures' +import MintButton from './mint-button' + +const OpenCollateralPanel = () => { + const indexDTF = useAtomValue(indexDTFAtom) + const basket = useAtomValue(indexDTFBasketAtom) + const [open, setOpen] = useAtom(openCollateralPanelAtom) + + return ( + + ) +} + +const RequoteFailedOrdersButton = ({ + mutate, + isPending, + isFetching, +}: { + mutate: () => void + isPending: boolean + isFetching: boolean +}) => { + const failedOrdersQty = useAtomValue(failedOrdersAtom).length + + const buttonText = useMemo(() => { + if (isFetching) { + return 'Awaiting Quotes' + } + if (isPending) { + return 'Signing...' + } + return 'Accept New Quotes' + }, [isFetching, isPending]) + + return ( +
+
+
+
+ +
+
+
Prices have moved
+
+ Accept the new quotes for {failedOrdersQty} tokens. +
+
+
+ {/* */} +
+ +
+ ) +} + +const RequoteFailedOrders = () => { + const { isFetching } = useRefreshQuotes() + const { mutate: signQuotes, isPending: isSigning } = + useStableQuoteSignatures(true) + + return ( + + ) +} + +const CollateralAcquisition = () => { + const operation = useAtomValue(operationAtom) + const cowswapOrdersCreatedAt = useAtomValue(cowswapOrdersCreatedAtAtom) + const [elapsedTime, setElapsedTime] = useState(0) + const setSuccess = useSetAtom(successAtom) + const failedOrders = useAtomValue(failedOrdersAtom) + const pendingOrders = useAtomValue(pendingOrdersAtom) + const collateralAcquired = useAtomValue(collateralAcquiredAtom) + + const refreshQuotes = useMemo( + () => failedOrders.length > 0 && pendingOrders.length === 0, + [failedOrders, pendingOrders] + ) + + useEffect(() => { + if (!cowswapOrdersCreatedAt) return + + const interval = setInterval(() => { + const now = new Date() + const createdAt = new Date(cowswapOrdersCreatedAt) + const elapsed = now.getTime() - createdAt.getTime() + setElapsedTime(elapsed / 1000) + }, 1000) + + return () => clearInterval(interval) + }, [cowswapOrdersCreatedAt]) + + useEffect(() => { + if (collateralAcquired && operation === 'redeem') { + setSuccess(true) + } + }, [collateralAcquired, setSuccess, operation]) + + if (!cowswapOrdersCreatedAt) return null + + if (collateralAcquired && operation === 'mint') { + return ( +
+
+
+
+ +
+
Collateral Acquired
+
+ +
+ +
+ ) + } + + if (refreshQuotes) return + + return ( +
+
+
+
+ +
+
+ {operation === 'mint' + ? 'Acquiring Collateral' + : 'Selling collateral for USDC'} +
+
+
+ {getTimerFormat(elapsedTime)} +
+
+
+ ) +} + +export default CollateralAcquisition diff --git a/src/views/index-dtf/issuance/async-swaps/collaterals.tsx b/src/views/index-dtf/issuance/async-swaps/collaterals.tsx new file mode 100644 index 000000000..e2d31ddb5 --- /dev/null +++ b/src/views/index-dtf/issuance/async-swaps/collaterals.tsx @@ -0,0 +1,84 @@ +import { cn } from '@/lib/utils' +import { OrderStatus } from '@cowprotocol/cow-sdk' +import { atom, useAtom, useAtomValue } from 'jotai' +import { useEffect, useMemo } from 'react' +import { + cowswapOrderIdsAtom, + cowswapOrdersAtom, + openCollateralPanelAtom, + operationAtom, + ordersSubmittedAtom, +} from './atom' +import CowSwapOrder from './cowswap-order' + +const STATUS_PRIORITY: Record = { + [OrderStatus.CANCELLED]: 0, + [OrderStatus.EXPIRED]: 0, + [OrderStatus.PRESIGNATURE_PENDING]: 1, + [OrderStatus.OPEN]: 1, + [OrderStatus.FULFILLED]: 2, +} + +const isVisibleAtom = atom(false) +const shouldRenderAtom = atom(false) +export const showCollateralsAtom = atom((get) => { + const ordersSubmitted = get(ordersSubmittedAtom) + const open = get(openCollateralPanelAtom) + return ordersSubmitted && open +}) + +const Collaterals = () => { + const operation = useAtomValue(operationAtom) + const cowswapOrderIds = useAtomValue(cowswapOrderIdsAtom) + const cowswapOrders = useAtomValue(cowswapOrdersAtom) + const ordersSubmitted = useAtomValue(ordersSubmittedAtom) + const open = useAtomValue(openCollateralPanelAtom) + const [isVisible, setIsVisible] = useAtom(isVisibleAtom) + const [shouldRender, setShouldRender] = useAtom(shouldRenderAtom) + + useEffect(() => { + if (ordersSubmitted && open) { + setShouldRender(true) + const timer = setTimeout(() => setIsVisible(true), 0) + return () => clearTimeout(timer) + } else { + setIsVisible(false) + const timer = setTimeout(() => setShouldRender(false), 300) + return () => clearTimeout(timer) + } + }, [ordersSubmitted, open, setIsVisible, setShouldRender]) + + const sortedCowswapOrderIds = useMemo( + () => + cowswapOrderIds.sort((a, b) => { + const orderA = cowswapOrders.find((o) => o.orderId === a) + const orderB = cowswapOrders.find((o) => o.orderId === b) + + if (!orderA?.status || !orderB?.status) return 0 + + const orderAPriority = STATUS_PRIORITY[orderA.status] + const orderBPriority = STATUS_PRIORITY[orderB.status] + + return orderAPriority - orderBPriority + }), + [cowswapOrders, cowswapOrderIds] + ) + + if (!shouldRender) return null + + return ( +
+ {sortedCowswapOrderIds.map((orderId) => ( + + ))} +
+ ) +} + +export default Collaterals diff --git a/src/views/index-dtf/issuance/async-swaps/cowswap-order.tsx b/src/views/index-dtf/issuance/async-swaps/cowswap-order.tsx new file mode 100644 index 000000000..e60d9e95f --- /dev/null +++ b/src/views/index-dtf/issuance/async-swaps/cowswap-order.tsx @@ -0,0 +1,140 @@ +import TokenLogo from '@/components/token-logo' +import Help from '@/components/ui/help' +import { cn } from '@/lib/utils' +import { indexDTFBasketAtom } from '@/state/dtf/atoms' +import { formatCurrency, formatTokenAmount } from '@/utils' +import { OrderStatus as CowSwapOrderStatus } from '@cowprotocol/cow-sdk' +import { useAtomValue } from 'jotai' +import { ArrowUpRight, Check, Loader } from 'lucide-react' +import { Link } from 'react-router-dom' +import { formatUnits } from 'viem' +import { operationAtom } from './atom' +import { useOrderStatus } from './hooks/useOrderStatus' +import { Skeleton } from '@/components/ui/skeleton' +import { useMemo } from 'react' +import { chainIdAtom } from '@/state/atoms' + +const STATUS_MAP: Record = { + [CowSwapOrderStatus.PRESIGNATURE_PENDING]: 'Processing', + [CowSwapOrderStatus.OPEN]: 'Processing', + [CowSwapOrderStatus.FULFILLED]: 'Order Filled', + [CowSwapOrderStatus.CANCELLED]: 'Not Filled', + [CowSwapOrderStatus.EXPIRED]: 'Not Filled', +} + +const OrderStatus = ({ + status, + orderId, +}: { + orderId: string + status: CowSwapOrderStatus +}) => { + return ( +
+ {STATUS_MAP[status] === 'Order Filled' && ( + + )} + {STATUS_MAP[status] === 'Processing' && ( + + )} +
{STATUS_MAP[status]}
+ {STATUS_MAP[status] === 'Not Filled' && ( + + )} + + + +
+ ) +} + +const CowSwapOrder = ({ + orderId, + disableFetch, +}: { + orderId: string + disableFetch?: boolean +}) => { + const chainId = useAtomValue(chainIdAtom) + const { data } = useOrderStatus({ orderId, disabled: disableFetch }) + const operation = useAtomValue(operationAtom) + const indexDTFBasket = useAtomValue(indexDTFBasketAtom) + + const { token, firstAmount, secondAmount } = useMemo(() => { + return operation === 'redeem' + ? { + token: data?.sellToken, + firstAmount: data?.sellAmount, + secondAmount: data?.buyAmount, + } + : { + token: data?.buyToken, + firstAmount: data?.buyAmount, + secondAmount: data?.sellAmount, + } + }, [data, operation]) + + return ( +
+
+ t.address === token)?.symbol || '' + } + chain={chainId} + size="xl" + /> +
+ {secondAmount ? ( +
+ {operation === 'mint' ? '-' : '+'}{' '} + {formatCurrency(Number(formatUnits(BigInt(secondAmount), 6)))}{' '} + USDC +
+ ) : ( + + )} + {firstAmount ? ( +
+ {operation === 'mint' ? '+' : '-'}{' '} + {formatTokenAmount( + Number( + formatUnits( + BigInt(firstAmount), + indexDTFBasket?.find((t) => t.address === token) + ?.decimals || 18 + ) + ) + )}{' '} + {indexDTFBasket?.find((t) => t.address === token)?.symbol || ''} +
+ ) : ( + + )} +
+
+ {data?.status ? ( + + ) : ( + + )} +
+ ) +} + +export default CowSwapOrder diff --git a/src/views/index-dtf/issuance/async-swaps/details.tsx b/src/views/index-dtf/issuance/async-swaps/details.tsx new file mode 100644 index 000000000..8fd049c9d --- /dev/null +++ b/src/views/index-dtf/issuance/async-swaps/details.tsx @@ -0,0 +1,51 @@ +import { SwapDetails } from '@/components/ui/swap' +import { indexDTFAtom, indexDTFPriceAtom } from '@/state/dtf/atoms' +import { formatCurrency, formatPercentage } from '@/utils' +import { useAtomValue } from 'jotai' +import { mintValueUSDAtom } from './atom' + +const Details = () => { + const indexDTF = useAtomValue(indexDTFAtom) + const indexDTFPrice = useAtomValue(indexDTFPriceAtom) + const mintValueUSD = useAtomValue(mintValueUSDAtom) + + const ratioText = `1 ${indexDTF?.token?.symbol} = ${formatCurrency(indexDTFPrice || 0)} USDC` + const mintFeeValue = mintValueUSD * (indexDTF?.mintingFee || 0) + + if (!indexDTF) return null + + return ( + + Fee{' '} + {formatPercentage(indexDTF.mintingFee * 100)} + + ), + }} + details={[ + { + left: Mint Fee, + right: ( + + ${formatCurrency(mintFeeValue)}{' '} + + ({formatPercentage(indexDTF.mintingFee * 100)}) + + + ), + help: 'A one-time fee deduction from the tokens you are using to create a share of the DTF. This fee is set by the Governors of the DTF.', + }, + // { + // left: Price Impact, + // right: , + // help: 'The impact your trade has on the market price.', + // }, + ]} + /> + ) +} + +export default Details diff --git a/src/views/index-dtf/issuance/async-swaps/hooks/useFolioDetails.ts b/src/views/index-dtf/issuance/async-swaps/hooks/useFolioDetails.ts new file mode 100644 index 000000000..25adbc75b --- /dev/null +++ b/src/views/index-dtf/issuance/async-swaps/hooks/useFolioDetails.ts @@ -0,0 +1,56 @@ +import dtfIndexAbi from '@/abis/dtf-index-abi' +import { chainIdAtom } from '@/state/atoms' +import { indexDTFAtom } from '@/state/dtf/atoms' +import { useAtomValue } from 'jotai' +import { parseUnits } from 'viem' +import { useReadContracts } from 'wagmi' + +interface UseFolioDetailsProps { + shares?: bigint +} + +export function useFolioDetails({ + shares = parseUnits('1', 18), +}: UseFolioDetailsProps) { + const indexDTF = useAtomValue(indexDTFAtom) + const chainId = useAtomValue(chainIdAtom) + + if (!indexDTF) { + return { + data: { + assets: [], + redeemValues: [], + mintValues: [], + }, + } + } + + return useReadContracts({ + allowFailure: false, + contracts: [ + { + abi: dtfIndexAbi, + address: indexDTF.id, + functionName: 'toAssets', + args: [shares, 0], + chainId, + }, + { + abi: dtfIndexAbi, + address: indexDTF.id, + functionName: 'toAssets', + args: [shares, 1], + chainId, + }, + ], + query: { + select(data) { + return { + assets: data[0][0], + redeemValues: data[0][1], + mintValues: data[1][1], + } + }, + }, + }) +} diff --git a/src/views/index-dtf/issuance/async-swaps/hooks/useGetMintTx.ts b/src/views/index-dtf/issuance/async-swaps/hooks/useGetMintTx.ts new file mode 100644 index 000000000..1f27129c7 --- /dev/null +++ b/src/views/index-dtf/issuance/async-swaps/hooks/useGetMintTx.ts @@ -0,0 +1,89 @@ +import { indexDTFAtom } from '@/state/dtf/atoms' +import { walletAtom } from '@/state/atoms' +import { useAtomValue } from 'jotai' +import { useEffect, useState } from 'react' +import { Address, parseEventLogs, zeroAddress } from 'viem' +import { usePublicClient } from 'wagmi' +import dtfIndexAbi from '@/abis/dtf-index-abi' + +export const useGetMintTx = () => { + const [mintTxHash, setMintTxHash] = useState(null) + const [isLoading, setIsLoading] = useState(false) + const indexDTF = useAtomValue(indexDTFAtom) + const walletAddress = useAtomValue(walletAtom) + const publicClient = usePublicClient() + + useEffect(() => { + if (!indexDTF?.id || !walletAddress || !publicClient) { + return + } + + setIsLoading(true) + + const checkForMintEvents = async () => { + try { + // Get the latest block number + const latestBlock = await publicClient.getBlockNumber() + + // Look back 10 blocks for Transfer events + const fromBlock = latestBlock - 10n + + const logs = await publicClient.getLogs({ + address: indexDTF.id as Address, + event: { + type: 'event', + name: 'Transfer', + inputs: [ + { type: 'address', name: 'from', indexed: true }, + { type: 'address', name: 'to', indexed: true }, + { type: 'uint256', name: 'value', indexed: false }, + ], + }, + fromBlock, + toBlock: latestBlock, + }) + + // Filter for Transfer events from zero address to connected wallet + const mintEvents = logs.filter((log) => { + const parsedLog = parseEventLogs({ + abi: dtfIndexAbi, + logs: [log], + eventName: 'Transfer', + })[0] + + return ( + parsedLog && + parsedLog.args.from === zeroAddress && + parsedLog.args.to === walletAddress && + parsedLog.args.value > 0n + ) + }) + + if (mintEvents.length > 0) { + // Get the most recent mint event + const latestMintEvent = mintEvents[mintEvents.length - 1] + setMintTxHash(latestMintEvent.transactionHash) + } + } catch (error) { + console.error('Error checking for mint events:', error) + } finally { + setIsLoading(false) + } + } + + // Check immediately + checkForMintEvents() + + // Set up polling every 2 seconds + const interval = setInterval(checkForMintEvents, 2000) + + return () => { + clearInterval(interval) + } + }, [indexDTF?.id, walletAddress, publicClient]) + + return { + mintTxHash, + isLoading, + } +} diff --git a/src/views/index-dtf/issuance/async-swaps/hooks/useOrderStatus.ts b/src/views/index-dtf/issuance/async-swaps/hooks/useOrderStatus.ts new file mode 100644 index 000000000..a5588c9b3 --- /dev/null +++ b/src/views/index-dtf/issuance/async-swaps/hooks/useOrderStatus.ts @@ -0,0 +1,48 @@ +import { OrderStatus } from '@cowprotocol/cow-sdk' +import { useQuery } from '@tanstack/react-query' +import { useSetAtom } from 'jotai' +import { cowswapOrdersAtom } from '../atom' +import { useGlobalProtocolKit } from '../providers/GlobalProtocolKitProvider' + +interface UseOrderStatusParams { + orderId: string + disabled?: boolean +} + +const isOrderCompleted = (status: OrderStatus) => { + return [ + OrderStatus.FULFILLED, + OrderStatus.EXPIRED, + OrderStatus.CANCELLED, + ].includes(status) +} + +export function useOrderStatus({ orderId, disabled }: UseOrderStatusParams) { + const { orderBookApi } = useGlobalProtocolKit() + const setCowswapOrders = useSetAtom(cowswapOrdersAtom) + + return useQuery({ + queryKey: ['order/status', orderId], + enabled: !!orderId && !!orderBookApi && !disabled, + queryFn: async () => { + if (!orderBookApi) { + throw new Error('OrderBookApi not initialized') + } + + const order = await orderBookApi.getOrder(orderId) + setCowswapOrders((prev) => [ + ...prev.filter((o) => o.orderId !== orderId), + { ...order, orderId }, + ]) + + return order + }, + refetchInterval(query) { + if (query.state.data && isOrderCompleted(query.state.data.status)) { + return false + } + + return 3_000 + }, + }) +} diff --git a/src/views/index-dtf/issuance/async-swaps/hooks/useQuote.ts b/src/views/index-dtf/issuance/async-swaps/hooks/useQuote.ts new file mode 100644 index 000000000..9e930d95e --- /dev/null +++ b/src/views/index-dtf/issuance/async-swaps/hooks/useQuote.ts @@ -0,0 +1,323 @@ +import { useERC20Balances } from '@/hooks/useERC20Balance' +import useTokensInfo from '@/hooks/useTokensInfo' +import { chainIdAtom, walletAtom } from '@/state/atoms' +import { + OrderBookApi, + OrderQuoteSideKindBuy, + OrderQuoteSideKindSell, + PriceQuality, + SigningScheme, +} from '@cowprotocol/cow-sdk' +import { useQuery } from '@tanstack/react-query' +import { useAtom, useAtomValue, useSetAtom } from 'jotai' +import { useEffect } from 'react' +import { Address, parseEther, zeroAddress } from 'viem' +import { + applyWalletBalanceAtom, + failedOrdersAtom, + fetchingQuotesAtom, + mintValueAtom, + operationAtom, + quotesAtom, + redeemAssetsAtom, + refetchQuotesAtom, + selectedTokenAtom, +} from '../atom' +import { useGlobalProtocolKit } from '../providers/GlobalProtocolKitProvider' +import { useFolioDetails } from './useFolioDetails' + +export async function getCowswapQuote({ + sellToken, + buyToken, + amount, + address, + operation, + orderBookApi, +}: { + sellToken: Address + buyToken: Address + amount: bigint + address: Address + operation: 'redeem' | 'mint' + orderBookApi: OrderBookApi +}) { + try { + const quote = await orderBookApi.getQuote({ + sellToken, + buyToken, + from: address, + receiver: address, + validFor: 60 * 10, // 10 minutes + priceQuality: PriceQuality.VERIFIED, + ...(operation === 'redeem' + ? { + kind: OrderQuoteSideKindSell.SELL, + sellAmountBeforeFee: amount.toString(), + } + : { + kind: OrderQuoteSideKindBuy.BUY, + buyAmountAfterFee: amount.toString(), + }), + signingScheme: SigningScheme.PRESIGN, + }) + + // CowSwap orders sometimes return every so slightly different amounts than requested. + if (operation === 'redeem') { + quote.quote.sellAmount = amount.toString() + } else { + quote.quote.buyAmount = amount.toString() + } + + return quote + } catch (error) { + console.error(`Error getting cowswap quote:`, error) + return null + } +} + +export const useQuotesForMint = () => { + const chainId = useAtomValue(chainIdAtom) + const selectedToken = useAtomValue(selectedTokenAtom) + const mintValue = useAtomValue(mintValueAtom) + const folioAmount = parseEther(mintValue.toString()) + const address = useAtomValue(walletAtom) + const applyWalletBalance = useAtomValue(applyWalletBalanceAtom) + const [quotes, setQuotes] = useAtom(quotesAtom) + const setRefetchQuotes = useSetAtom(refetchQuotesAtom) + const setFetchingQuotes = useSetAtom(fetchingQuotesAtom) + + const { orderBookApi } = useGlobalProtocolKit() + const { data: folioDetails } = useFolioDetails({ shares: folioAmount }) + const { data: balances } = useERC20Balances( + (folioDetails?.assets || []).map((address) => ({ + address, + chainId, + })) + ) + + const { data: tokensInfo } = useTokensInfo( + folioDetails?.assets.map((address) => address as Address) || [] + ) + + const query = useQuery({ + queryKey: ['quotes/mint', folioDetails?.assets, folioDetails?.mintValues], + queryFn: async ({ signal }) => { + if (!folioDetails || !tokensInfo || !orderBookApi) { + return {} + } + + const quotePromises = + folioDetails?.assets.map(async (asset, i) => { + const token = tokensInfo[asset.toLowerCase()] + const mintValue = folioDetails?.mintValues[i] + const walletValue = applyWalletBalance + ? ((balances?.[i] as bigint) ?? 0n) + : 0n + const amount = mintValue - walletValue + + if (amount <= 0n || token.address === selectedToken.address) { + return null + } + + try { + const cowswapQuote = await getCowswapQuote({ + sellToken: selectedToken.address, + buyToken: token.address, + amount, + address: address as Address, + operation: 'mint', + orderBookApi, + }) + + return cowswapQuote + } catch (error) { + console.error(`Error getting quote for ${asset}:`, error) + return null + } + }) || [] + + const results = await Promise.all(quotePromises) + + folioDetails.assets.forEach((asset, i) => { + const token = tokensInfo[asset.toLowerCase()] + const quote = results[i] + + try { + if (!signal.aborted) { + setQuotes((prev) => ({ + ...prev, + [token.address as string]: { + success: !!quote, + data: quote, + }, + })) + } + } catch { + if (!signal.aborted) { + setQuotes((prev) => ({ + ...prev, + [token.address as string]: { + token, + success: false, + }, + })) + } + } + }) + + return quotes + }, + enabled: !!folioDetails?.mintValues && !!tokensInfo && !!folioAmount, + }) + + useEffect(() => { + setRefetchQuotes({ fn: query.refetch }) + setFetchingQuotes(query.isFetching) + }, [query.refetch, query.isFetching, setRefetchQuotes, setFetchingQuotes]) + + return query +} + +export const useQuotesForRedeem = () => { + const chainId = useAtomValue(chainIdAtom) + const selectedToken = useAtomValue(selectedTokenAtom) + const redeemAssets = useAtomValue(redeemAssetsAtom) + const address = useAtomValue(walletAtom) + const [quotes, setQuotes] = useAtom(quotesAtom) + const setRefetchQuotes = useSetAtom(refetchQuotesAtom) + const setFetchingQuotes = useSetAtom(fetchingQuotesAtom) + + const { orderBookApi } = useGlobalProtocolKit() + + const assets = Object.keys(redeemAssets) + + const { data: tokensInfo } = useTokensInfo( + assets.map((address) => address as Address) + ) + + const query = useQuery({ + queryKey: ['quotes/redeem', assets], + queryFn: async ({ signal }) => { + if ( + !redeemAssets || + !assets || + !assets.length || + !tokensInfo || + !orderBookApi + ) { + return {} + } + + const quotePromises = + assets.map(async (asset) => { + const token = tokensInfo[asset.toLowerCase()] + const amount = redeemAssets[asset as Address] + + if (token.address === selectedToken.address) { + return null + } + + try { + const cowswapQuote = await getCowswapQuote({ + sellToken: token.address, + buyToken: selectedToken.address, + amount, + address: address as Address, + operation: 'redeem', + orderBookApi, + }) + + return cowswapQuote + } catch (error) { + console.error(`Error getting quote for ${asset}:`, error) + return null + } + }) || [] + + const results = await Promise.all(quotePromises) + + assets.forEach((asset, i) => { + const token = tokensInfo[asset.toLowerCase()] + const quote = results[i] + + try { + if (!signal.aborted) { + setQuotes((prev) => ({ + ...prev, + [token.address as string]: { + success: !!quote, + data: quote, + }, + })) + } + } catch { + if (!signal.aborted) { + setQuotes((prev) => ({ + ...prev, + [token.address as string]: { + token, + success: false, + }, + })) + } + } + }) + + return quotes + }, + enabled: !!assets && !!tokensInfo && !!redeemAssets && assets.length > 0, + }) + + useEffect(() => { + setRefetchQuotes({ fn: query.refetch }) + setFetchingQuotes(query.isFetching) + }, [query.refetch, query.isFetching, setRefetchQuotes, setFetchingQuotes]) + + return query +} + +export const useRefreshQuotes = () => { + const address = useAtomValue(walletAtom) + const { orderBookApi } = useGlobalProtocolKit() + const [quotes, setQuotes] = useAtom(quotesAtom) + const operation = useAtomValue(operationAtom) + const failedOrders = useAtomValue(failedOrdersAtom) + + const query = useQuery({ + queryKey: ['refresh-quotes', failedOrders], + queryFn: async () => { + if (!failedOrders || !orderBookApi || !address) { + return + } + + const quotePromises = failedOrders.map(async (order) => { + return await getCowswapQuote({ + sellToken: order.sellToken as Address, + buyToken: order.buyToken as Address, + amount: BigInt( + operation === 'redeem' ? order.sellAmount : order.buyAmount + ), + address: address as Address, + operation, + orderBookApi, + }) + }) + + const results = await Promise.all(quotePromises) + + failedOrders.forEach((order, i) => { + setQuotes((prev) => ({ + ...prev, + [operation === 'redeem' ? order.sellToken : order.buyToken]: { + success: !!results[i], + data: results[i], + }, + })) + }) + + return quotes + }, + }) + + return query +} diff --git a/src/views/index-dtf/issuance/async-swaps/hooks/useQuoteSignatures.ts b/src/views/index-dtf/issuance/async-swaps/hooks/useQuoteSignatures.ts new file mode 100644 index 000000000..99efc4ae4 --- /dev/null +++ b/src/views/index-dtf/issuance/async-swaps/hooks/useQuoteSignatures.ts @@ -0,0 +1,333 @@ +import CowswapSettlement from '@/abis/CowSwapSettlement' +import { notifyError } from '@/hooks/useNotification' +import { chainIdAtom, walletAtom } from '@/state/atoms' +import { indexDTFAtom } from '@/state/dtf/atoms' +import { MetadataApi } from '@cowprotocol/sdk-app-data' +import { + AppDataHash, + OrderBalance, + OrderCreation, + OrderQuoteResponse, + OrderSigningUtils, + SigningScheme, +} from '@cowprotocol/cow-sdk' +import { ViemAdapter } from '@cowprotocol/sdk-viem-adapter' +import { useMutation } from '@tanstack/react-query' +import { useAtom, useAtomValue, useSetAtom } from 'jotai' +import { useCallback, useRef } from 'react' +import { Address, encodeFunctionData, Hex, maxUint256, parseUnits } from 'viem' +import { useSendCalls, usePublicClient } from 'wagmi' +import { + cowswapOrderIdsAtom, + cowswapOrdersAtom, + cowswapOrdersCreatedAtAtom, + failedOrdersAtom, + infoMessageAtom, + operationAtom, + quotesAtom, + selectedTokenAtom, + userInputAtom, +} from '../atom' +import { useGlobalProtocolKit } from '../providers/GlobalProtocolKitProvider' +import { + getApprovalCallIfNeeded, + getCowswapOrdersInfoMessage, + getTransactionInfoMessage, + sendCallsWithRetry, +} from './utils' + +export const COWSWAP_SETTLEMENT = + '0x9008D19f58AAbD9eD0D60971565AA8510560ab41' as const +export const COWSWAP_VAULT = '0xC92E8bdf79f0507f65a392b0ab4667716BFE0110' as const + +type CowswapPreSignTx = { + orderId: string + quote: OrderCreation + preSignTx: Hex + sellToken: string + amount: string +} + +export const getCowswapPreSignTx = async ({ + chainId, + orderQuote, + operation, + address, + appDataHex, + slippageBps, +}: { + chainId: number + orderQuote: OrderQuoteResponse + operation: string + address: Address + appDataHex: AppDataHash + slippageBps?: number +}): Promise => { + if (orderQuote.quote.sellAmount === '0') { + return undefined + } + + const modifiedQuote = { + ...orderQuote.quote, + feeAmount: '0', + sellAmount: + operation === 'mint' + ? slippageBps !== undefined + ? ((BigInt(orderQuote.quote.sellAmount) * (10000n + BigInt(slippageBps))) / 10000n).toString() + : ((BigInt(orderQuote.quote.sellAmount) * 101n) / 100n).toString() + : orderQuote.quote.sellAmount, + buyAmount: + operation === 'mint' + ? orderQuote.quote.buyAmount + : slippageBps !== undefined + ? ((BigInt(orderQuote.quote.buyAmount) * (10000n - BigInt(slippageBps))) / 10000n).toString() + : ((BigInt(orderQuote.quote.buyAmount) * 98n) / 100n).toString(), + from: address, + receiver: address!, + signature: address!, + signingScheme: SigningScheme.PRESIGN, + } as const satisfies OrderCreation + + const orderId = await OrderSigningUtils.generateOrderId( + chainId, + { + ...modifiedQuote, + sellTokenBalance: OrderBalance.ERC20, + buyTokenBalance: OrderBalance.ERC20, + appData: appDataHex, + }, + { + owner: address, + } + ) + + const encodedPreSignTx = encodeFunctionData({ + abi: CowswapSettlement, + functionName: 'setPreSignature', + args: [orderId.orderId as Hex, true], + }) + + return { + orderId: orderId.orderId, + quote: modifiedQuote, + preSignTx: encodedPreSignTx, + sellToken: modifiedQuote.sellToken, + amount: modifiedQuote.sellAmount, + } +} + +export function useQuoteSignatures(refresh = false) { + const indexDTF = useAtomValue(indexDTFAtom) + const chainId = useAtomValue(chainIdAtom) + const address = useAtomValue(walletAtom) + const operation = useAtomValue(operationAtom) + const inputAmount = useAtomValue(userInputAtom) + const quoteToken = useAtomValue(selectedTokenAtom) + const [quotes, setQuotes] = useAtom(quotesAtom) + const setCowswapOrderIds = useSetAtom(cowswapOrderIdsAtom) + const setCowswapOrders = useSetAtom(cowswapOrdersAtom) + const setCowswapOrdersCreatedAt = useSetAtom(cowswapOrdersCreatedAtAtom) + const setInfoMessage = useSetAtom(infoMessageAtom) + const { orderBookApi } = useGlobalProtocolKit() + const { sendCallsAsync } = useSendCalls() + const failedOrders = useAtomValue(failedOrdersAtom) + const publicClient = usePublicClient() + + return useMutation({ + mutationKey: [ + 'quote-signatures', + chainId, + address, + operation, + JSON.stringify(quotes, (_, value) => + typeof value === 'bigint' ? value.toString() : value + ), + refresh, + ], + mutationFn: async () => { + if (!address || !orderBookApi || !chainId || !indexDTF) { + console.error('No global kit') + return { + orders: [], + } + } + + // Create a Viem adapter for the MetadataApi + let metadataApi: MetadataApi + if (publicClient) { + const viemAdapter = new ViemAdapter({ provider: publicClient }) + metadataApi = new MetadataApi(viemAdapter) + } else { + metadataApi = new MetadataApi() + } + + let appDataContent: string + let appDataHex: AppDataHash + + try { + const appDataDoc = await metadataApi.generateAppDataDoc({ + appCode: 'Reserve Protocol', + environment: 'production', + }) + const appDataInfo = await metadataApi.getAppDataInfo(appDataDoc) + appDataContent = appDataInfo.appDataContent + appDataHex = appDataInfo.appDataHex + } catch (error) { + console.error('Failed to calculate appDataHex', error) + appDataContent = JSON.stringify({ + appCode: 'Reserve Protocol', + environment: 'production', + version: '1.0.0', + }) + appDataHex = ('0x' + '0'.repeat(64)) as AppDataHash + console.warn('Using fallback appData due to MetadataApi error') + } + + const successfulQuotes = Object.values(quotes).filter( + (quote) => quote.success + ) + + if (successfulQuotes.length === 0) { + return { + orders: [], + } + } + + // Generate pre-sign transactions for all quotes + const orderData = ( + await Promise.all( + successfulQuotes.map(async (quote) => { + if (!quote.success) return undefined + return getCowswapPreSignTx({ + chainId, + orderQuote: quote.data, + operation, + address, + appDataHex, + }) + }) + ) + ).filter((data): data is CowswapPreSignTx => !!data) + + const txData = await Promise.all( + orderData.map(async (data) => { + return [ + operation === 'redeem' + ? await getApprovalCallIfNeeded({ + chainId, + address: address, + token: data.sellToken as Address, + requiredAmount: BigInt(data.amount as string), + spender: COWSWAP_VAULT, + }) + : null, + { + to: COWSWAP_SETTLEMENT, + data: data.preSignTx, + value: 0n, + }, + ].filter((e) => e !== null) + }) + ).then((results) => results.flat()) + + if (operation === 'mint') { + const requiredAmount = parseUnits(inputAmount, quoteToken.decimals) + const approvalCall = await getApprovalCallIfNeeded({ + chainId, + address: address as Address, + token: quoteToken.address as Address, + requiredAmount, + approvalAmount: maxUint256, + spender: COWSWAP_VAULT, + }) + if (approvalCall) txData.unshift(approvalCall) + } + + if (txData.length > 0) { + try { + const infoMessage = getTransactionInfoMessage(txData) + setInfoMessage((prev) => infoMessage || prev) + + await sendCallsWithRetry(sendCallsAsync, chainId, txData, address) + } catch (error) { + console.error('sendCallsAsync', error) + notifyError('Transaction failed', 'Please try again') + } + } + + const cowswapInfoMessage = getCowswapOrdersInfoMessage(orderData) + setInfoMessage((prev) => cowswapInfoMessage || prev) + + const orderIds = ( + await Promise.all( + orderData.map(async (data) => { + const order = data.quote as OrderCreation + try { + const orderId = await orderBookApi.sendOrder({ + ...order, + from: address, + signature: address, + signingScheme: SigningScheme.PRESIGN, + appData: appDataContent, + }) + return orderId + } catch (error) { + console.error('orderBookApi.sendOrder', error) + notifyError( + 'Cowswap order failed', + `Failed to submit Cowswap order` + ) + return undefined + } + }) + ) + ).filter((orderId) => orderId !== undefined) + + if (refresh) { + setCowswapOrderIds((prev) => [ + ...prev.filter( + (orderId) => !failedOrders.map((o) => o.orderId).includes(orderId) + ), + ...orderIds, + ]) + setCowswapOrders((prev) => [ + ...prev.filter( + (o) => !failedOrders.map((fo) => fo.orderId).includes(o.orderId) + ), + ]) + } else { + setCowswapOrderIds(orderIds) + } + setCowswapOrdersCreatedAt(new Date().toISOString()) + setQuotes({}) + setInfoMessage(undefined) + + return { + orders: orderIds, + } + }, + onError(error) { + console.error(error) + }, + retry: false, + retryDelay: 0, + gcTime: 5 * 60 * 1000, + }) +} + +// Custom hook to stabilize dependencies +export const useStableQuoteSignatures = (refresh = false) => { + const { mutate, isPending } = useQuoteSignatures(refresh) + + const mutateRef = useRef(mutate) + mutateRef.current = mutate + + const stableMutate = useCallback(() => { + mutateRef.current() + }, []) + + return { + mutate: stableMutate, + isPending, + } +} diff --git a/src/views/index-dtf/issuance/async-swaps/hooks/utils.ts b/src/views/index-dtf/issuance/async-swaps/hooks/utils.ts new file mode 100644 index 000000000..b7da53bf6 --- /dev/null +++ b/src/views/index-dtf/issuance/async-swaps/hooks/utils.ts @@ -0,0 +1,157 @@ +import { wagmiConfig } from '@/state/chain' +import { AvailableChain } from '@/utils/chains' +import { RESERVE_API } from '@/utils/constants' +import { Address, encodeFunctionData, erc20Abi, Hex } from 'viem' +import { getPublicClient } from 'wagmi/actions' +import { COWSWAP_SETTLEMENT } from './useQuoteSignatures' +import { notifyError } from '@/hooks/useNotification' + +export async function getApprovalCallIfNeeded({ + chainId, + address, + token, + requiredAmount, + approvalAmount = requiredAmount, + spender, +}: { + chainId: AvailableChain + address: Address + token: Address + requiredAmount: bigint + approvalAmount?: bigint + spender: Address +}): Promise<{ to: Address; data: Hex; value: bigint } | null> { + const publicClient = getPublicClient(wagmiConfig, { chainId }) + const allowance = await publicClient.readContract({ + address: token, + abi: erc20Abi, + functionName: 'allowance', + args: [address, spender], + }) + if (allowance < requiredAmount) { + return { + to: token, + data: encodeFunctionData({ + abi: erc20Abi, + functionName: 'approve', + args: [spender, approvalAmount], + }), + value: 0n, + } + } + return null +} + +type TokenPrice = { + address: Address + price: number + timestamp: number + source: string +} + +export async function getAssetPrice( + chainId: number, + token: Address +): Promise { + const response = await fetch( + `${RESERVE_API}current/prices?tokens=${token}&chainId=${chainId}` + ) + const data = (await response.json()) as TokenPrice[] + return data[0] +} + +// Function to handle sendCallsAsync with retry logic for user rejections +export const sendCallsWithRetry = async ( + sendCallsAsync: any, + chainId: number, + calls: any[], + account: Address, + maxRetries: number = 2 +) => { + let lastError: Error | null = null + + for (let attempt = 0; attempt <= maxRetries; attempt++) { + try { + const txBundle = await sendCallsAsync({ + chainId, + calls, + account, + forceAtomic: true, + }) + console.log({ txBundle }) + return txBundle + } catch (error) { + lastError = error as Error + + // Check if it's a user rejection error + if ( + error instanceof Error && + error.message.includes('User rejected transaction') + ) { + notifyError( + 'Transaction rejected', + `User rejected transaction (attempt ${attempt + 1}/${maxRetries + 1})` + ) + + // If this was the last attempt, throw the error + if (attempt === maxRetries) { + throw new Error('USER_CANCELLED_TX') + } + + // Otherwise, continue to next attempt + continue + } + + // For other errors, throw immediately + throw error + } + } + + // This should never be reached, but just in case + throw lastError || new Error('Unknown error in sendCallsWithRetry') +} + +// Helper functions for info messages +export const getTransactionInfoMessage = ( + txData: any[], + isFallback = false +): string | undefined => { + const approvals = txData.filter( + (tx) => tx.to !== COWSWAP_SETTLEMENT && tx.data?.includes('0xa9059cbb') // approve function signature + ).length + const cowswapSwaps = txData.filter( + (tx) => tx.to === COWSWAP_SETTLEMENT + ).length + + if (approvals === 0 && cowswapSwaps === 0) { + return undefined + } + + let message = '' + + if (approvals > 0) { + message += `Signing ${approvals} approval${approvals !== 1 ? 's' : ''}` + } + + if (cowswapSwaps > 0) { + message += approvals > 0 ? ' and ' : 'Signing ' + const fallbackText = isFallback + ? ` (fallback${cowswapSwaps !== 1 ? 's' : ''})` + : '' + message += `${cowswapSwaps} swap${cowswapSwaps !== 1 ? 's' : ''} via Cowswap${fallbackText}` + } + + message += `...` + + return message +} + +export const getCowswapOrdersInfoMessage = ( + cowswapOrders: any[] +): string | undefined => { + if (cowswapOrders.length > 0) { + return `Sending ${cowswapOrders.length} swap${cowswapOrders.length !== 1 ? 's' : ''} via Cowswap...` + } + return undefined +} + diff --git a/src/views/index-dtf/issuance/async-swaps/index.tsx b/src/views/index-dtf/issuance/async-swaps/index.tsx new file mode 100644 index 000000000..34fe81cc8 --- /dev/null +++ b/src/views/index-dtf/issuance/async-swaps/index.tsx @@ -0,0 +1,210 @@ +import { Button } from '@/components/ui/button' +import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs' +import useAtomicBatch from '@/hooks/use-atomic-batch' +import { cn } from '@/lib/utils' +import { indexDTFAtom } from '@/state/dtf/atoms' +import { useAtom, useAtomValue } from 'jotai' +import { ArrowLeft, Settings } from 'lucide-react' +import { useEffect } from 'react' +import useTrackIndexDTFPage from '../../hooks/useTrackIndexDTFPage' +import RefreshQuote from '../../overview/components/zap-mint/refresh-quote' +import Updater from '../manual/updater' +import AsyncMint from './async-mint' +import AsyncRedeem from './async-redeem' +import { + fetchingQuotesAtom, + operationAtom, + ordersSubmittedAtom, + redeemAssetsAtom, + refetchQuotesAtom, + showSettingsAtom, + successAtom, + userInputAtom, +} from './atom' +import AtomicBatchRequired from './atomic-batch-required' +import Collaterals, { showCollateralsAtom } from './collaterals' +import { GlobalProtocolKitProvider } from './providers/GlobalProtocolKitProvider' +import Config from './settings' +import Success from './success' +import { Skeleton } from '@/components/ui/skeleton' + +function Content() { + const showSettings = useAtomValue(showSettingsAtom) + const showCollaterals = useAtomValue(showCollateralsAtom) + + return ( +
+
+
+ + + + + + +
+
+
+ +
+
+ ) +} + +const Header = () => { + const [showSettings, setShowSettings] = useAtom(showSettingsAtom) + const refetchQuote = useAtomValue(refetchQuotesAtom) + const fetchingQuotes = useAtomValue(fetchingQuotesAtom) + const input = useAtomValue(userInputAtom) + const invalidInput = isNaN(Number(input)) || Number(input) === 0 + const ordersSubmitted = useAtomValue(ordersSubmittedAtom) + const redeemAssets = useAtomValue(redeemAssetsAtom) + const disableActions = + !!ordersSubmitted || Object.keys(redeemAssets).length > 0 + + return ( +
+ {showSettings ? ( +
+ +
+ ) : ( + <> +
+ + + Auto Mint + + + Auto Redeem + + +
+
+ + +
+ + )} +
+ ) +} + +const AsyncSwaps = () => { + useTrackIndexDTFPage('mint-async-swap') + const [currentTab, setCurrentTab] = useAtom(operationAtom) + const [showSettings, setShowSettings] = useAtom(showSettingsAtom) + const indexDTF = useAtomValue(indexDTFAtom) + const success = useAtomValue(successAtom) + const { atomicSupported, isLoading } = useAtomicBatch() + + const reset = () => { + setShowSettings(false) + } + + const changeTab = (tab: string) => { + setCurrentTab(tab as 'mint' | 'redeem') + } + + useEffect(() => { + return () => { + reset() + } + }, []) + + if (!indexDTF) return null + + if (isLoading || !atomicSupported) { + return ( +
+ {isLoading ? ( + + ) : ( + + )} +
+ ) + } + + if (success) { + return ( +
+
+
+ +
+
+
+ ) + } + + return ( +
+
+ +
+ +
+ + {showSettings && ( +
+ +
+ )} + + + +
+
+
+ ) +} + +const AsyncSwapsWithProvider = () => { + return ( + + + + ) +} + +export default AsyncSwapsWithProvider diff --git a/src/views/index-dtf/issuance/async-swaps/mint-button.tsx b/src/views/index-dtf/issuance/async-swaps/mint-button.tsx new file mode 100644 index 000000000..fd5b16c9d --- /dev/null +++ b/src/views/index-dtf/issuance/async-swaps/mint-button.tsx @@ -0,0 +1,187 @@ +import dtfIndexAbi from '@/abis/dtf-index-abi' +import dtfIndexAbiV2 from '@/abis/dtf-index-abi-v2' +import TokenLogo from '@/components/token-logo' +import { Button } from '@/components/ui/button' +import { useERC20Balances } from '@/hooks/useERC20Balance' +import { chainIdAtom, walletAtom } from '@/state/atoms' +import { indexDTFAtom, indexDTFVersionAtom } from '@/state/dtf/atoms' +import { useAtom, useAtomValue, useSetAtom } from 'jotai' +import { Loader } from 'lucide-react' +import { useEffect, useMemo } from 'react' +import { encodeFunctionData, erc20Abi, maxUint256, parseEther } from 'viem' +import { useSendCalls, useWaitForCallsStatus } from 'wagmi' +import { isMintingAtom, txHashAtom, mintValueAtom, successAtom } from './atom' +import { useFolioDetails } from './hooks/useFolioDetails' +import { useGetMintTx } from './hooks/useGetMintTx' + +const MintButton = () => { + const account = useAtomValue(walletAtom) + const indexDTF = useAtomValue(indexDTFAtom) + const mintAmount = useAtomValue(mintValueAtom) + const folioAmount = parseEther(mintAmount.toString()) + const chainId = useAtomValue(chainIdAtom) + const indexDTFVersion = useAtomValue(indexDTFVersionAtom) + const setMintTxHash = useSetAtom(txHashAtom) + const setSuccess = useSetAtom(successAtom) + + const [isMinting, setIsMinting] = useAtom(isMintingAtom) + const { data: folioDetails } = useFolioDetails({ shares: folioAmount }) + const { data, sendCalls, isPending, isError } = useSendCalls() + + const { data: callsStatus } = useWaitForCallsStatus({ + id: data?.id || '', + }) + + // New hook to detect mint events from Transfer logs + const { mintTxHash: eventMintTxHash } = useGetMintTx() + + const { data: balanceData, isFetching: isFetchingBalanceData } = + useERC20Balances( + folioDetails?.assets.map((address) => ({ + address, + chainId, + })) || [] + ) + + const maxMintableAmount = useMemo(() => { + if (!folioDetails?.mintValues || !balanceData || !balanceData.length) { + return 0n + } + + // For each token, calculate how many folio tokens we can mint based on available balance + const mintableAmounts = folioDetails.mintValues.map((mintValue, index) => { + if (mintValue === 0n) { + return 0n + } + const balance = balanceData[index] as bigint + return (balance * folioAmount) / mintValue + }) + + // Return the minimum amount (as we need all tokens to mint) + return mintableAmounts.reduce((min, amount) => { + if (amount === 0n) { + return min + } + + return amount < min ? amount : min + }, mintableAmounts[0] || 0n) + }, [folioDetails?.mintValues, balanceData, folioAmount]) + + const handleMaxMint = () => { + if (!account || !folioDetails || !indexDTF) { + return + } + + setIsMinting(true) + + sendCalls({ + calls: [ + ...folioDetails.assets.map((e) => ({ + to: e, + value: 0n, + data: encodeFunctionData({ + abi: erc20Abi, + functionName: 'approve', + args: [indexDTF.id, maxUint256], + }), + })), + { + to: indexDTF.id, + data: + indexDTFVersion === '2.0.0' + ? encodeFunctionData({ + abi: dtfIndexAbiV2, + functionName: 'mint', + args: [ + maxMintableAmount, + account, + (maxMintableAmount * 99n) / 100n, + ], + }) + : encodeFunctionData({ + abi: dtfIndexAbi, + functionName: 'mint', + args: [ + maxMintableAmount, + account, + (maxMintableAmount * 99n) / 100n, + ], + }), + value: 0n, + }, + ], + forceAtomic: true, + }) + } + + useEffect(() => { + const isSuccess = callsStatus?.status === 'success' + const isFailure = callsStatus?.status === 'failure' + + if (isSuccess) { + const receipts = callsStatus?.receipts ?? [] + let mintTxHash = receipts.slice(-1)[0]?.transactionHash || 'tx' + setMintTxHash(mintTxHash) + setSuccess(true) + setIsMinting(false) + } + + if (isFailure) { + setIsMinting(false) + } + }, [callsStatus?.receipts, callsStatus?.status]) + + // New effect to handle mint detection from Transfer events + useEffect(() => { + if (eventMintTxHash && isMinting) { + setMintTxHash(eventMintTxHash) + setSuccess(true) + setIsMinting(false) + } + }, [eventMintTxHash, isMinting, setMintTxHash, setSuccess, setIsMinting]) + + useEffect(() => { + if (isError) { + setIsMinting(false) + } + }, [isError]) + + if (!indexDTF) return null + + if (isMinting) { + return ( +
+
+
+ +
Confirm Mint
+
+
+ +
+
+
+ ) + } + + return ( + + ) +} + +export default MintButton diff --git a/src/views/index-dtf/issuance/async-swaps/providers/GlobalProtocolKitProvider.tsx b/src/views/index-dtf/issuance/async-swaps/providers/GlobalProtocolKitProvider.tsx new file mode 100644 index 000000000..f0b489899 --- /dev/null +++ b/src/views/index-dtf/issuance/async-swaps/providers/GlobalProtocolKitProvider.tsx @@ -0,0 +1,60 @@ +import { chainIdAtom } from '@/state/atoms' +import { OrderBookApi } from '@cowprotocol/cow-sdk' +import { useAtomValue } from 'jotai' +import { + createContext, + ReactNode, + useContext, + useEffect, + useState, +} from 'react' +import { ChainId } from '@/utils/chains' + +type GlobalProtocolKitContextType = { + orderBookApi: OrderBookApi | null +} + +const GlobalProtocolKitContext = createContext({ + orderBookApi: null, +}) + +export const useGlobalProtocolKit = () => useContext(GlobalProtocolKitContext) + +interface GlobalProtocolKitProviderProps { + children: ReactNode +} + +export function GlobalProtocolKitProvider({ + children, +}: GlobalProtocolKitProviderProps) { + const chainId = useAtomValue(chainIdAtom) + + const [orderBookApi, setOrderBookApi] = useState(null) + + useEffect(() => { + setOrderBookApi( + new OrderBookApi({ + chainId: chainId || ChainId.Base, + limiterOpts: { + tokensPerInterval: 4, + interval: 'second', + }, + backoffOpts: { + numOfAttempts: 3, + maxDelay: Infinity, + jitter: 'full', + }, + }) + ) + }, [chainId]) + + return ( + + {children} + + ) +} diff --git a/src/views/index-dtf/issuance/async-swaps/settings.tsx b/src/views/index-dtf/issuance/async-swaps/settings.tsx new file mode 100644 index 000000000..5cd5726f9 --- /dev/null +++ b/src/views/index-dtf/issuance/async-swaps/settings.tsx @@ -0,0 +1,59 @@ +import { Checkbox } from '@/components/ui/checkbox' +import Help from '@/components/ui/help' +import { SlippageSelector } from '@/components/ui/swap' +import { useAtom } from 'jotai' +import { Wallet } from 'lucide-react' +import { slippageAtom, applyWalletBalanceAtom } from './atom' + +const SettingsRowTitle = ({ title, help }: { title: string; help: string }) => ( +
+
{title}
+ +
+) + +const Settings = () => { + const [slippage, setSlippage] = useAtom(slippageAtom) + const [applyWalletBalance, setApplyWalletBalance] = useAtom( + applyWalletBalanceAtom + ) + + return ( +
+
+ +
+
+ +
Use wallet balance
+
+ + setApplyWalletBalance( + checked === 'indeterminate' ? true : checked + ) + } + /> +
+
+
+ + +
+
+ ) +} + +export default Settings diff --git a/src/views/index-dtf/issuance/async-swaps/success.tsx b/src/views/index-dtf/issuance/async-swaps/success.tsx new file mode 100644 index 000000000..8fe488ed0 --- /dev/null +++ b/src/views/index-dtf/issuance/async-swaps/success.tsx @@ -0,0 +1,394 @@ +import TokenLogo from '@/components/token-logo' +import StackTokenLogo from '@/components/token-logo/StackTokenLogo' +import { Button } from '@/components/ui/button' +import Copy from '@/components/ui/copy' +import Help from '@/components/ui/help' +import { chainIdAtom } from '@/state/atoms' +import { + indexDTFAtom, + indexDTFBasketAtom, + indexDTFPriceAtom, +} from '@/state/dtf/atoms' +import { + formatCurrency, + formatPercentage, + formatTokenAmount, + shortenAddress, +} from '@/utils' +import { ExplorerDataType, getExplorerLink } from '@/utils/getExplorerLink' +import { atom, useAtomValue, useSetAtom } from 'jotai' +import { + ArrowLeft, + ArrowUpRight, + Check, + ChevronRight, + HandCoins, + X, +} from 'lucide-react' +import { useEffect, useState } from 'react' +import { Link } from 'react-router-dom' +import { + balanceDifferenceAtom, + cowswapOrderIdsAtom, + cowswapOrdersAtom, + cowswapOrdersCreatedAtAtom, + mintValueAtom, + operationAtom, + quotesAtom, + redeemAssetsAtom, + savedAmountAtom, + successAtom, + txHashAtom, + userInputAtom, +} from './atom' +import CowSwapOrder from './cowswap-order' +import Details from './details' + +const viewTransactionsAtom = atom(false) + +const CloseButton = () => { + const setTxHashAtom = useSetAtom(txHashAtom) + const setSuccess = useSetAtom(successAtom) + const setUserInputAtom = useSetAtom(userInputAtom) + const setRedeemAssets = useSetAtom(redeemAssetsAtom) + const setCowswapOrderIdsAtom = useSetAtom(cowswapOrderIdsAtom) + const setCowswapOrdersCreatedAtAtom = useSetAtom(cowswapOrdersCreatedAtAtom) + const setCowswapOrdersAtom = useSetAtom(cowswapOrdersAtom) + const setQuotesAtom = useSetAtom(quotesAtom) + + const handleClose = () => { + setTxHashAtom(undefined) + setSuccess(false) + setUserInputAtom('') + setRedeemAssets({}) + setQuotesAtom({}) + setCowswapOrderIdsAtom([]) + setCowswapOrdersCreatedAtAtom(undefined) + setCowswapOrdersAtom([]) + } + + return ( + + ) +} + +const SuccessHeader = () => { + const chainId = useAtomValue(chainIdAtom) + const indexDTF = useAtomValue(indexDTFAtom) + const basket = useAtomValue(indexDTFBasketAtom) + const setViewTransactions = useSetAtom(viewTransactionsAtom) + + return ( +
+ + + +
+ + +
+
+ ) +} + +const DTFAmount = () => { + const chainId = useAtomValue(chainIdAtom) + const indexDTF = useAtomValue(indexDTFAtom) + const indexDTFPrice = useAtomValue(indexDTFPriceAtom) + const balanceDifference = useAtomValue(balanceDifferenceAtom) + const operation = useAtomValue(operationAtom) + const sharesRedeemed = useAtomValue(userInputAtom) + const mintValue = useAtomValue(mintValueAtom) + const valueMinted = (indexDTFPrice || 0) * mintValue + const valueRedeemed = (indexDTFPrice || 0) * Number(sharesRedeemed) + + const priceImpact = balanceDifference + ? (valueMinted * 100) / balanceDifference - 100 + : 0 + + return ( +
+
+ {operation === 'mint' ? 'You Minted:' : 'You Redeemed:'} +
+
+
+
+ {operation === 'mint' + ? formatTokenAmount(mintValue) + : formatTokenAmount(Number(sharesRedeemed))} +
+
{indexDTF?.token.symbol || ''}
+
+ +
+
+ ${formatCurrency(operation === 'mint' ? valueMinted : valueRedeemed)}{' '} + {operation === 'mint' && ( + + ({formatPercentage(priceImpact)}) + + )} +
+
+ ) +} + +const USDCAmount = () => { + const inputAmount = useAtomValue(userInputAtom) + const operation = useAtomValue(operationAtom) + const balanceDifference = useAtomValue(balanceDifferenceAtom) + const indexDTFPrice = useAtomValue(indexDTFPriceAtom) + const valueRedeemed = (indexDTFPrice || 0) * Number(inputAmount) + const priceImpact = valueRedeemed + ? (balanceDifference * 100) / valueRedeemed - 100 + : 0 + + return ( +
+
+ {operation === 'mint' ? 'You Used:' : 'You Received:'} +
+
+
+
+ {formatCurrency(balanceDifference)} +
+
USDC
+
+ {operation === 'mint' && ( +
+ + {formatCurrency(Number(inputAmount))} USDC + + +
+ )} +
+
+ ${formatCurrency(balanceDifference)} + {operation === 'mint' && ( + + ${formatCurrency(Number(inputAmount))} + + )} + {operation === 'redeem' && ( + + ({priceImpact > 0 ? '+' : ''} + {formatPercentage(priceImpact)}) + + )} +
+ {operation === 'mint' && ( +
+ +
+
+ )} +
+ ) +} + +const BufferInfo = () => { + const savedAmount = useAtomValue(savedAmountAtom) + + return ( +
+
+ +
You Saved:
+ {' '} +
+
+ $ + {formatCurrency(savedAmount)} +
+
+ ) +} + +const MainTransaction = () => { + const indexDTF = useAtomValue(indexDTFAtom) + const txHash = useAtomValue(txHashAtom) + const operation = useAtomValue(operationAtom) + + return ( +
+
+ +
+ + {operation === 'mint' + ? `${indexDTF?.token.symbol} Minted` + : `${indexDTF?.token.symbol} Redeemed`} + +
+ {shortenAddress(indexDTF?.id || '')} +
+
+
+
+
+ +
+ + + +
+
+ ) +} + +const Transactions = () => { + const setViewTransactions = useSetAtom(viewTransactionsAtom) + const cowswapOrders = useAtomValue(cowswapOrdersAtom) + + return ( +
+
+ +
+ All Transactions +
+ +
+
+ +
+ {cowswapOrders.map(({ orderId }, index) => ( + + ))} +
+
+
+ ) +} + +const Success = () => { + const viewTransactions = useAtomValue(viewTransactionsAtom) + const [showConfetti, setShowConfetti] = useState(true) + + useEffect(() => { + const timer = setTimeout(() => { + setShowConfetti(false) + }, 2000) + + return () => clearTimeout(timer) + }, []) + + if (viewTransactions) { + return + } + + return ( +
+ {showConfetti && ( +
+ )} +
+
+ +
+
+ + +
+
+
+ ) +} + +export default Success diff --git a/src/views/index-dtf/issuance/async-swaps/types.ts b/src/views/index-dtf/issuance/async-swaps/types.ts new file mode 100644 index 000000000..025347774 --- /dev/null +++ b/src/views/index-dtf/issuance/async-swaps/types.ts @@ -0,0 +1,17 @@ +import { Token } from '@/types' +import { EnrichedOrder, OrderQuoteResponse } from '@cowprotocol/cow-sdk' + +export type QuoteAggregated = + | { + token: Token + success: false + } + | { + token: Token + success: true + data: OrderQuoteResponse + } + +export type AsyncSwapOrderResponse = { + cowswapOrders: (EnrichedOrder & { orderId: string })[] +} diff --git a/src/views/index-dtf/issuance/manual/index.tsx b/src/views/index-dtf/issuance/manual/index.tsx index 1ec4b330f..61dc79b61 100644 --- a/src/views/index-dtf/issuance/manual/index.tsx +++ b/src/views/index-dtf/issuance/manual/index.tsx @@ -1,8 +1,22 @@ +import { useSetAtom } from 'jotai' import AssetList from './components/asset-list' import IndexManualIssuance from './components/index-manual-issuance' import Updater from './updater' +import { amountAtom } from './atoms' +import { useEffect } from 'react' +import { useSearchParams } from 'react-router-dom' const IndexDTFManualIssuance = () => { + const setAmount = useSetAtom(amountAtom) + const [searchParams] = useSearchParams() + const amountIn = searchParams.get('amountIn') + + useEffect(() => { + if (amountIn) { + setAmount(amountIn) + } + }, []) + return ( <>
diff --git a/src/views/index-dtf/issuance/manual/updater.tsx b/src/views/index-dtf/issuance/manual/updater.tsx index c8a2d98ae..3c13100db 100644 --- a/src/views/index-dtf/issuance/manual/updater.tsx +++ b/src/views/index-dtf/issuance/manual/updater.tsx @@ -11,6 +11,8 @@ import { assetDistributionAtom, balanceMapAtom, } from './atoms' +import useERC20Balance from '@/hooks/useERC20Balance' +import { indexDTFBalanceAtom } from '../async-swaps/atom' const balanceCallsAtom = atom((get) => { const wallet = get(walletAtom) @@ -57,6 +59,9 @@ const Updater = () => { const setAssetDistribution = useSetAtom(assetDistributionAtom) const setAllowances = useSetAtom(allowanceMapAtom) const chainId = useAtomValue(chainIdAtom) + const setIndexDTFBalance = useSetAtom(indexDTFBalanceAtom) + + const { data: balance } = useERC20Balance(indexDTF?.id) const { data } = useWatchReadContracts({ contracts: calls, @@ -110,6 +115,10 @@ const Updater = () => { }, }) + useEffect(() => { + setIndexDTFBalance(balance || 0n) + }, [balance, setIndexDTFBalance]) + useEffect(() => { if (data) { setBalance(data) diff --git a/tailwind.config.ts b/tailwind.config.ts index 26e5d6ae2..399fb5c08 100644 --- a/tailwind.config.ts +++ b/tailwind.config.ts @@ -131,6 +131,10 @@ const config = { transform: 'rotate(360deg)', }, }, + shimmer: { + '0%': { backgroundPosition: '200% 0' }, + '100%': { backgroundPosition: '-200% 0' }, + }, 'slide-left': { from: { left: '50%' }, to: { left: 'calc(50% - 150px)' }, @@ -148,6 +152,7 @@ const config = { 'width-expand': 'width-expand 0.4s cubic-bezier(0.25, 0.8, 0.25, 1) forwards', 'spin-slow': 'spin-slow 4s linear infinite', + shimmer: 'shimmer 2s linear infinite', 'slide-left': 'slide-left 0.5s forwards', 'slide-out-right': 'slide-out-right 0.5s forwards', }, diff --git a/vite.config.ts b/vite.config.ts index 7b9989d55..2ecb8738e 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -65,7 +65,12 @@ export default defineConfig({ }, }, - // Aliases handled by viteTsconfigPaths - no need to duplicate here + resolve: { + alias: { + // Polyfill for @cowprotocol/cow-sdk + 'node-fetch': 'cross-fetch', + }, + }, optimizeDeps: { exclude: ['ts-node'],