diff --git a/index.html b/index.html index 7bc96c1..c1a5294 100644 --- a/index.html +++ b/index.html @@ -52,6 +52,12 @@ + + + diff --git a/package-lock.json b/package-lock.json index ae74818..017e5c7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,20 +1,20 @@ { - "name": "vite-project", - "version": "0.3.0", + "name": "zoroswap-frontend", + "version": "0.5.0", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "vite-project", - "version": "0.3.0", + "name": "zoroswap-frontend", + "version": "0.5.0", "hasInstallScript": true, "dependencies": { - "@demox-labs/miden-wallet-adapter": "0.10.0", - "@getpara/react-sdk-lite": "^2.2.0", - "@miden-sdk/miden-para": "^0.13.0", - "@miden-sdk/miden-sdk": "^0.13.0", - "@miden-sdk/react": "^0.13.2", - "@miden-sdk/use-miden-para-react": "^0.13.0", + "@demox-labs/miden-wallet-adapter": "^0.10.0", + "@getpara/react-sdk-lite": "^2.15.0", + "@miden-sdk/miden-para": "^0.13.3", + "@miden-sdk/miden-sdk": "^0.13.3", + "@miden-sdk/react": "^0.13.3", + "@miden-sdk/use-miden-para-react": "^0.13.3", "@radix-ui/react-dropdown-menu": "^2.1.16", "@radix-ui/react-slot": "^1.2.4", "@tanstack/react-query": "^5.90.12", @@ -22,6 +22,7 @@ "async-mutex": "^0.5.0", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", + "lightweight-charts": "^5.1.0", "lucide-react": "^0.556.0", "react": "^19.2.1", "react-dom": "^19.2.1", @@ -110,6 +111,7 @@ "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", @@ -652,6 +654,7 @@ "resolved": "https://registry.npmjs.org/@emotion/is-prop-valid/-/is-prop-valid-1.4.0.tgz", "integrity": "sha512-QgD4fyscGcbbKwJmqNvUMSE02OsHUa+lAWKdEUIJKgqe5IwRSKd7+KhibEWdaKwgjLj0DRSHA9biAIqGBk05lw==", "license": "MIT", + "peer": true, "dependencies": { "@emotion/memoize": "^0.9.0" } @@ -1827,9 +1830,9 @@ "license": "MIT" }, "node_modules/@getpara/core-components": { - "version": "2.8.0", - "resolved": "https://registry.npmjs.org/@getpara/core-components/-/core-components-2.8.0.tgz", - "integrity": "sha512-ec3BM4xfahgu4/gAep4sitJZYEkne8vxW1TMVhvcZYimp7g52Xw18tLuF3v45aKPD/JDZgD8Z+3cqvwmyluMxA==", + "version": "2.15.0", + "resolved": "https://registry.npmjs.org/@getpara/core-components/-/core-components-2.15.0.tgz", + "integrity": "sha512-UkxZgPxcMXxIvJfDIxyMxbEfBdHSs1i4hmMLWqNqLRE1qeTJ60DQeeW01mo1pm9ZFjIs2PUWn6fm07+iGdKo5A==", "dependencies": { "@stencil/core": "^4.7.0", "color-blend": "^4.0.0", @@ -1840,28 +1843,29 @@ } }, "node_modules/@getpara/core-sdk": { - "version": "2.8.0", - "resolved": "https://registry.npmjs.org/@getpara/core-sdk/-/core-sdk-2.8.0.tgz", - "integrity": "sha512-zJKl3feRVvZyTMyPDIyxrIAK0kWKFTlQMqLMabTi/gqHWEteXLzJU2WVdeOwIyjxsH5CT5XL4ivuUSL7HAmmcw==", + "version": "2.15.0", + "resolved": "https://registry.npmjs.org/@getpara/core-sdk/-/core-sdk-2.15.0.tgz", + "integrity": "sha512-8yI0VYSjN4N0XtFn6U8KAli0NWt8osdSqTtupgz0zHhtyaaKvYxf94iBWFuL2YpmM6zuBNbIxFAXnGSrj8CTFg==", "dependencies": { "@celo/utils": "^8.0.2", "@cosmjs/encoding": "^0.32.4", "@ethereumjs/util": "^9.1.0", - "@getpara/user-management-client": "2.8.0", + "@getpara/user-management-client": "2.15.0", "@noble/hashes": "^1.5.0", "base64url": "^3.0.1", "libphonenumber-js": "^1.11.7", "node-forge": "^1.3.1", - "uuid": "^11.1.0" + "uuid": "^11.1.0", + "xstate": "^5.24.0" } }, "node_modules/@getpara/react-common": { - "version": "2.8.0", - "resolved": "https://registry.npmjs.org/@getpara/react-common/-/react-common-2.8.0.tgz", - "integrity": "sha512-qBAc2P4ChLZOlvx4YlAkwyQaOche6hMMFlyW+kFQslWPDoH6b+BzFBwuX1/V+xVuB68mgO3tAFmeZQ8KRs9SLQ==", + "version": "2.15.0", + "resolved": "https://registry.npmjs.org/@getpara/react-common/-/react-common-2.15.0.tgz", + "integrity": "sha512-brDyHMXYEyZwiJRoEOI3l7BgmfRTbfj6mI3jcbcmWVGr5AGS1TaiouuMzGPwhjlFTXPSP/UJOrNvt0Tc2eDRMg==", "dependencies": { - "@getpara/react-components": "2.8.0", - "@getpara/web-sdk": "2.8.0", + "@getpara/react-components": "2.15.0", + "@getpara/web-sdk": "2.15.0", "@moonpay/moonpay-react": "^1.10.6", "@ramp-network/ramp-instant-sdk": "^4.0.5", "libphonenumber-js": "^1.11.7", @@ -1874,24 +1878,25 @@ } }, "node_modules/@getpara/react-components": { - "version": "2.8.0", - "resolved": "https://registry.npmjs.org/@getpara/react-components/-/react-components-2.8.0.tgz", - "integrity": "sha512-xjFquu/mmNszeRqoINm7KEh8nIl91BUsAcM8NfHUjfct+FjgGJCOd2TY3ADZj2CwCUZ9z9kM3Zpdx1HEGPZzkg==", + "version": "2.15.0", + "resolved": "https://registry.npmjs.org/@getpara/react-components/-/react-components-2.15.0.tgz", + "integrity": "sha512-PeXUVRYAI9G8WITbao3HSR0fyV5nqK0eNKjqficqc84HjEEan/kJsZUUYwDCn/r2eIlm4MbT5XG7R5sydKz8ug==", "dependencies": { - "@getpara/core-components": "2.8.0" + "@getpara/core-components": "2.15.0" } }, "node_modules/@getpara/react-sdk-lite": { - "version": "2.8.0", - "resolved": "https://registry.npmjs.org/@getpara/react-sdk-lite/-/react-sdk-lite-2.8.0.tgz", - "integrity": "sha512-OG8oT3D4hpaSLMrjk0uRkt7EQ9Xfl5teG8TsTxS9F3ASG8Nc4MWHw7qSinK5JY+FQ8FrGlsKvpyzp6HjvdcOfw==", + "version": "2.15.0", + "resolved": "https://registry.npmjs.org/@getpara/react-sdk-lite/-/react-sdk-lite-2.15.0.tgz", + "integrity": "sha512-/ug8/fChst4zBdC+7wyqQPd9JgItq0Dj2xlGYv9hNaQzdndGWrzz7VmJ804ZDaWHJ1syG7vv4MaMQw/GAxX2BA==", "dependencies": { - "@getpara/react-common": "2.8.0", - "@getpara/react-components": "2.8.0", - "@getpara/web-sdk": "2.8.0", + "@getpara/react-common": "2.15.0", + "@getpara/react-components": "2.15.0", + "@getpara/web-sdk": "2.15.0", "date-fns": "^3.6.0", "framer-motion": "^11.3.31", "libphonenumber-js": "^1.11.7", + "socket.io-client": "^4.5.1", "styled-components": "^6.1.8", "zustand": "^4.5.2", "zustand-sync-tabs": "^0.2.2" @@ -1934,27 +1939,28 @@ } }, "node_modules/@getpara/shared": { - "version": "1.9.0", - "resolved": "https://registry.npmjs.org/@getpara/shared/-/shared-1.9.0.tgz", - "integrity": "sha512-HiW+4tpACqhZeg2VjR428uGm9YMV1/CBh+NFemabKj+3B+fdOcI3y+7/MAhZMT7sa/7PemBdUNOJnc+Tbcxifg==" + "version": "1.11.0", + "resolved": "https://registry.npmjs.org/@getpara/shared/-/shared-1.11.0.tgz", + "integrity": "sha512-B3JCn/K2tNKf4JoXwN2T/MexuZHidqBUtZrM59s2uIHulQDwnW/Vxw2AJzHg/X5P7xxHhhxGI04FbykldMuinw==" }, "node_modules/@getpara/user-management-client": { - "version": "2.8.0", - "resolved": "https://registry.npmjs.org/@getpara/user-management-client/-/user-management-client-2.8.0.tgz", - "integrity": "sha512-b86cDR9PiwEyBGLLUrZnb98M1QKI5Qc35dJfubEYjhuUzhYYgV3qCYkkNPu+9SFxbCE1Jvu65v8M6dPD9E+LyQ==", + "version": "2.15.0", + "resolved": "https://registry.npmjs.org/@getpara/user-management-client/-/user-management-client-2.15.0.tgz", + "integrity": "sha512-MbS/S4jzIm02SuBpC0bSBjzM1NkndOGDOAjxHefcD1v8kG3WMVvDgUTYULhwwZbBZiEeWitXZopxXAXEs/ebAQ==", "dependencies": { - "@getpara/shared": "1.9.0", + "@getpara/shared": "1.11.0", "axios": "^1.8.4", "libphonenumber-js": "^1.11.7" } }, "node_modules/@getpara/web-sdk": { - "version": "2.8.0", - "resolved": "https://registry.npmjs.org/@getpara/web-sdk/-/web-sdk-2.8.0.tgz", - "integrity": "sha512-8czkLtT+yn7nDALDt4lCt43NbC7wHK5lEYf0GHcOWpf+sa45R7o9ObCrsA0O70Gj42a3atvzf2b4GFkRDfJ09w==", + "version": "2.15.0", + "resolved": "https://registry.npmjs.org/@getpara/web-sdk/-/web-sdk-2.15.0.tgz", + "integrity": "sha512-PRYuMk+laF3n+6wMqoncku6aPCG8uCYzS7959uY1899vEhE4OBHWs4Tm09hk+y+Z72htB8gah3nGHFECByN8Pw==", + "peer": true, "dependencies": { - "@getpara/core-sdk": "2.8.0", - "@getpara/user-management-client": "2.8.0", + "@getpara/core-sdk": "2.15.0", + "@getpara/user-management-client": "2.15.0", "base64url": "^3.0.1", "buffer": "6.0.3", "cbor-web": "^9.0.2", @@ -2121,16 +2127,16 @@ } }, "node_modules/@miden-sdk/miden-para": { - "version": "0.13.1", - "resolved": "https://registry.npmjs.org/@miden-sdk/miden-para/-/miden-para-0.13.1.tgz", - "integrity": "sha512-rvFKIy/tmbd5+8KfiZLXBxcF7/P4SPA3rgQYTqVwyeDbkJG3+mysaTfXNNABdc9v03wsCnYkaTa0lE718GOcRA==", + "version": "0.13.3", + "resolved": "https://registry.npmjs.org/@miden-sdk/miden-para/-/miden-para-0.13.3.tgz", + "integrity": "sha512-XJ8x8rRE4TPux1+Mhe8ZB0m6kDwlVCVJVQ3X9EzKz3xL0sOo9d8+HNNQhb6E29B4fwQMZPtntAZZj0l8J9bbsA==", "license": "MIT", "dependencies": { "@noble/hashes": "^2.0.1" }, "peerDependencies": { - "@getpara/web-sdk": "2.0.0-alpha.73", - "@miden-sdk/miden-sdk": "^0.13.0" + "@getpara/web-sdk": "^2.11.0", + "@miden-sdk/miden-sdk": "^0.13.1" } }, "node_modules/@miden-sdk/miden-para/node_modules/@noble/hashes": { @@ -2146,9 +2152,10 @@ } }, "node_modules/@miden-sdk/miden-sdk": { - "version": "0.13.0", - "resolved": "https://registry.npmjs.org/@miden-sdk/miden-sdk/-/miden-sdk-0.13.0.tgz", - "integrity": "sha512-N0qUCZW9Dvk3Oqj37IrGmm0b0v3Nq5qHsX3BtQIzZIwDXKXKPBxy/0lO40oCwDtwI8AfriZQyMLbJR81Fo4Vpg==", + "version": "0.13.3", + "resolved": "https://registry.npmjs.org/@miden-sdk/miden-sdk/-/miden-sdk-0.13.3.tgz", + "integrity": "sha512-K8dk40XyWzMtq0hWTiSra/MigjaPm1hY2i63I1QtVeP0u42RqC8IXIRq08cnPZoQGAjwVIQJhSmI4tIDzWgDPA==", + "peer": true, "dependencies": { "@rollup/plugin-typescript": "^12.3.0", "dexie": "^4.0.1", @@ -2156,10 +2163,11 @@ } }, "node_modules/@miden-sdk/react": { - "version": "0.13.2", - "resolved": "https://registry.npmjs.org/@miden-sdk/react/-/react-0.13.2.tgz", - "integrity": "sha512-78i3/5YUUwitqvJA02HVXZ61RXEOyaiRsm1agVUtOblOmazuIgMEW4P3gdlX9YUiGr06l9O+Rw1ol5FZCOJTXQ==", + "version": "0.13.3", + "resolved": "https://registry.npmjs.org/@miden-sdk/react/-/react-0.13.3.tgz", + "integrity": "sha512-jJ/j61C/53bsDXi80AllZSygoGd5+GSFRFLSMHLIto3zO9IiQ7JTbmHOtKLp7qTh+VozLyE0wE/pTsWYoVtKQg==", "license": "MIT", + "peer": true, "dependencies": { "zustand": "^5.0.0" }, @@ -2169,19 +2177,19 @@ } }, "node_modules/@miden-sdk/use-miden-para-react": { - "version": "0.13.1", - "resolved": "https://registry.npmjs.org/@miden-sdk/use-miden-para-react/-/use-miden-para-react-0.13.1.tgz", - "integrity": "sha512-b/sbG8CXOwedYFUCL/zr2fSugzEdt9mTYSl/2Cf+EUAOwp2JMxDLV5BfdZU9/soV7BIyBdaYXOlNkr+/fxshcw==", + "version": "0.13.3", + "resolved": "https://registry.npmjs.org/@miden-sdk/use-miden-para-react/-/use-miden-para-react-0.13.3.tgz", + "integrity": "sha512-yjBVgQW7oCQQ8xYDRpwhaWbJxIeElem3zDT8V5eFmZgb28mhyieIUFkGbFOktXXYqgWG177g9PVW2PgGRzVrqA==", "license": "MIT", "engines": { "node": ">=18" }, "peerDependencies": { - "@getpara/react-sdk-lite": "^2.2.0", - "@getpara/web-sdk": "2.0.0-alpha.73", - "@miden-sdk/miden-para": "^0.13.0", - "@miden-sdk/miden-sdk": "^0.13.0", - "@miden-sdk/react": "^0.13.1", + "@getpara/react-sdk-lite": "^2.11.0", + "@getpara/web-sdk": "^2.11.0", + "@miden-sdk/miden-para": "^0.13.2", + "@miden-sdk/miden-sdk": "^0.13.1", + "@miden-sdk/react": "^0.13.3", "@tanstack/react-query": "^5.0.0", "react": "^18.0.0 || ^19.0.0" } @@ -2999,7 +3007,6 @@ "cpu": [ "arm" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3013,7 +3020,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3021,9 +3027,9 @@ ] }, "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.34.9", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.34.9.tgz", - "integrity": "sha512-0CY3/K54slrzLDjOA7TOjN1NuLKERBgk9nY5V34mhmuu673YNb+7ghaDUs6N0ujXR7fz5XaS5Aa6d2TNxZd0OQ==", + "version": "4.44.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.44.0.tgz", + "integrity": "sha512-VGF3wy0Eq1gcEIkSCr8Ke03CWT+Pm2yveKLaDvq51pPpZza3JX/ClxXOCmTYYq3us5MvEuNRTaeyFThCKRQhOA==", "cpu": [ "arm64" ], @@ -3034,9 +3040,9 @@ ] }, "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.34.9", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.34.9.tgz", - "integrity": "sha512-eOojSEAi/acnsJVYRxnMkPFqcxSMFfrw7r2iD9Q32SGkb/Q9FpUY1UlAu1DH9T7j++gZ0lHjnm4OyH2vCI7l7Q==", + "version": "4.44.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.44.0.tgz", + "integrity": "sha512-fBkyrDhwquRvrTxSGH/qqt3/T0w5Rg0L7ZIDypvBPc1/gzjJle6acCpZ36blwuwcKD/u6oCE/sRWlUAcxLWQbQ==", "cpu": [ "x64" ], @@ -3053,7 +3059,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3067,7 +3072,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3081,7 +3085,6 @@ "cpu": [ "arm" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3095,7 +3098,6 @@ "cpu": [ "arm" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3103,9 +3105,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.34.9", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.34.9.tgz", - "integrity": "sha512-6TZjPHjKZUQKmVKMUowF3ewHxctrRR09eYyvT5eFv8w/fXarEra83A2mHTVJLA5xU91aCNOUnM+DWFMSbQ0Nxw==", + "version": "4.44.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.44.0.tgz", + "integrity": "sha512-ZTR2mxBHb4tK4wGf9b8SYg0Y6KQPjGpR4UWwTFdnmjB4qRtoATZ5dWn3KsDwGa5Z2ZBOE7K52L36J9LueKBdOQ==", "cpu": [ "arm64" ], @@ -3116,9 +3118,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.34.9", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.34.9.tgz", - "integrity": "sha512-LD2fytxZJZ6xzOKnMbIpgzFOuIKlxVOpiMAXawsAZ2mHBPEYOnLRK5TTEsID6z4eM23DuO88X0Tq1mErHMVq0A==", + "version": "4.44.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.44.0.tgz", + "integrity": "sha512-GFWfAhVhWGd4r6UxmnKRTBwP1qmModHtd5gkraeW2G490BpFOZkFtem8yuX2NyafIP/mGpRJgTJ2PwohQkUY/Q==", "cpu": [ "arm64" ], @@ -3135,7 +3137,6 @@ "cpu": [ "loong64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3149,7 +3150,6 @@ "cpu": [ "loong64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3163,7 +3163,6 @@ "cpu": [ "ppc64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3177,7 +3176,6 @@ "cpu": [ "ppc64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3191,7 +3189,6 @@ "cpu": [ "riscv64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3205,7 +3202,6 @@ "cpu": [ "riscv64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3219,7 +3215,6 @@ "cpu": [ "s390x" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3227,9 +3222,9 @@ ] }, "node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.34.9", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.34.9.tgz", - "integrity": "sha512-FwBHNSOjUTQLP4MG7y6rR6qbGw4MFeQnIBrMe161QGaQoBQLqSUEKlHIiVgF3g/mb3lxlxzJOpIBhaP+C+KP2A==", + "version": "4.44.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.44.0.tgz", + "integrity": "sha512-iUVJc3c0o8l9Sa/qlDL2Z9UP92UZZW1+EmQ4xfjTc1akr0iUFZNfxrXJ/R1T90h/ILm9iXEY6+iPrmYB3pXKjw==", "cpu": [ "x64" ], @@ -3240,9 +3235,9 @@ ] }, "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.34.9", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.34.9.tgz", - "integrity": "sha512-cYRpV4650z2I3/s6+5/LONkjIz8MBeqrk+vPXV10ORBnshpn8S32bPqQ2Utv39jCiDcO2eJTuSlPXpnvmaIgRA==", + "version": "4.44.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.44.0.tgz", + "integrity": "sha512-PQUobbhLTQT5yz/SPg116VJBgz+XOtXt8D1ck+sfJJhuEsMj2jSej5yTdp8CvWBSceu+WW+ibVL6dm0ptG5fcA==", "cpu": [ "x64" ], @@ -3259,7 +3254,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3273,7 +3267,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3281,9 +3274,9 @@ ] }, "node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.34.9", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.34.9.tgz", - "integrity": "sha512-z4mQK9dAN6byRA/vsSgQiPeuO63wdiDxZ9yg9iyX2QTzKuQM7T4xlBoeUP/J8uiFkqxkcWndWi+W7bXdPbt27Q==", + "version": "4.44.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.44.0.tgz", + "integrity": "sha512-M0CpcHf8TWn+4oTxJfh7LQuTuaYeXGbk0eageVjQCKzYLsajWS/lFC94qlRqOlyC2KvRT90ZrfXULYmukeIy7w==", "cpu": [ "arm64" ], @@ -3300,7 +3293,6 @@ "cpu": [ "ia32" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3314,7 +3306,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3322,9 +3313,9 @@ ] }, "node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.34.9", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.34.9.tgz", - "integrity": "sha512-AyleYRPU7+rgkMWbEh71fQlrzRfeP6SyMnRf9XX4fCdDPAJumdSBqYEcWPMzVQ4ScAl7E4oFfK0GUVn77xSwbw==", + "version": "4.44.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.44.0.tgz", + "integrity": "sha512-Q2Mgwt+D8hd5FIPUuPDsvPR7Bguza6yTkJxspDGkZj7tBRn2y4KSWYuIXpftFSjBra76TbKerCV7rgFPQrn+wQ==", "cpu": [ "x64" ], @@ -3406,10 +3397,16 @@ "url": "https://paulmillr.com/funding/" } }, + "node_modules/@socket.io/component-emitter": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@socket.io/component-emitter/-/component-emitter-3.1.2.tgz", + "integrity": "sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA==", + "license": "MIT" + }, "node_modules/@stencil/core": { - "version": "4.42.0", - "resolved": "https://registry.npmjs.org/@stencil/core/-/core-4.42.0.tgz", - "integrity": "sha512-1mMkQ3+5jE343detvyFK+U3tJM8Qp8aaaOnGMo815BKMnFShUpioF9ziaX0dJ+goDKWIWbGZ2438dHIGsvE5ug==", + "version": "4.43.2", + "resolved": "https://registry.npmjs.org/@stencil/core/-/core-4.43.2.tgz", + "integrity": "sha512-hOprMw/n1fyu5OOtZG7N8sqiKxPshR1oss/y1qr6r98cVV9NcVoCMz2x2TT2enkHan6pCMpiQgUU7vmN90lIVw==", "license": "MIT", "bin": { "stencil": "bin/stencil" @@ -3419,14 +3416,14 @@ "npm": ">=7.10.0" }, "optionalDependencies": { - "@rollup/rollup-darwin-arm64": "4.34.9", - "@rollup/rollup-darwin-x64": "4.34.9", - "@rollup/rollup-linux-arm64-gnu": "4.34.9", - "@rollup/rollup-linux-arm64-musl": "4.34.9", - "@rollup/rollup-linux-x64-gnu": "4.34.9", - "@rollup/rollup-linux-x64-musl": "4.34.9", - "@rollup/rollup-win32-arm64-msvc": "4.34.9", - "@rollup/rollup-win32-x64-msvc": "4.34.9" + "@rollup/rollup-darwin-arm64": "4.44.0", + "@rollup/rollup-darwin-x64": "4.44.0", + "@rollup/rollup-linux-arm64-gnu": "4.44.0", + "@rollup/rollup-linux-arm64-musl": "4.44.0", + "@rollup/rollup-linux-x64-gnu": "4.44.0", + "@rollup/rollup-linux-x64-musl": "4.44.0", + "@rollup/rollup-win32-arm64-msvc": "4.44.0", + "@rollup/rollup-win32-x64-msvc": "4.44.0" } }, "node_modules/@tanstack/eslint-plugin-query": { @@ -3467,6 +3464,7 @@ "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.90.20.tgz", "integrity": "sha512-vXBxa+qeyveVO7OA0jX1z+DeyCA4JKnThKv411jd5SORpBKgkcVnYKCiBgECvADvniBX7tobwBmg01qq9JmMJw==", "license": "MIT", + "peer": true, "dependencies": { "@tanstack/query-core": "5.90.20" }, @@ -3602,8 +3600,8 @@ "version": "19.2.10", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.10.tgz", "integrity": "sha512-WPigyYuGhgZ/cTPRXB2EwUw+XvsRA3GqHlsP4qteqrnnjDrApbS7MxcGr/hke5iUoeB7E/gQtrs9I37zAJ0Vjw==", - "dev": true, "license": "MIT", + "peer": true, "dependencies": { "csstype": "^3.2.2" } @@ -3612,8 +3610,8 @@ "version": "19.2.3", "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", - "dev": true, "license": "MIT", + "peer": true, "peerDependencies": { "@types/react": "^19.2.0" } @@ -3687,6 +3685,7 @@ "integrity": "sha512-BtE0k6cjwjLZoZixN0t5AKP0kSzlGu7FctRXYuPAm//aaiZhmfq1JwdYpYr1brzEspYyFeF+8XF5j2VK6oalrA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.54.0", "@typescript-eslint/types": "8.54.0", @@ -3962,6 +3961,7 @@ "resolved": "https://registry.npmjs.org/@wagmi/core/-/core-3.3.2.tgz", "integrity": "sha512-e4aefdzEki657u7P6miuBijp0WKmtSsuY2/NT9e3zfJxr+QX5Edr5EcFF0Cg5OMMQ1y32x+g8ogMDppD9aX3kw==", "license": "MIT", + "peer": true, "dependencies": { "eventemitter3": "5.0.1", "mipd": "0.0.7", @@ -4050,6 +4050,7 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -4444,13 +4445,13 @@ } }, "node_modules/axios": { - "version": "1.13.4", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.13.4.tgz", - "integrity": "sha512-1wVkUaAO6WyaYtCkcYCOx12ZgpGf9Zif+qXa4n+oYzK558YryKqiL6UWwd5DqiH3VRW0GYhTZQ/vlgJrCoNQlg==", + "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.6", - "form-data": "^4.0.4", + "follow-redirects": "^1.15.11", + "form-data": "^4.0.5", "proxy-from-env": "^1.1.0" } }, @@ -4854,6 +4855,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -5588,7 +5590,6 @@ "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "dev": true, "license": "MIT", "dependencies": { "ms": "^2.1.3" @@ -5758,7 +5759,8 @@ "resolved": "https://registry.npmjs.org/devtools-protocol/-/devtools-protocol-0.0.1551306.tgz", "integrity": "sha512-CFx8QdSim8iIv+2ZcEOclBKTQY6BI1IEDa7Tm9YkwAXzEWFndTEzpTo5jAUhSnq24IC7xaDw0wvGcm96+Y3PEg==", "dev": true, - "license": "BSD-3-Clause" + "license": "BSD-3-Clause", + "peer": true }, "node_modules/dexie": { "version": "4.3.0", @@ -5895,6 +5897,49 @@ "once": "^1.4.0" } }, + "node_modules/engine.io-client": { + "version": "6.6.4", + "resolved": "https://registry.npmjs.org/engine.io-client/-/engine.io-client-6.6.4.tgz", + "integrity": "sha512-+kjUJnZGwzewFDw951CDWcwj35vMNf2fcj7xQWOctq1F2i1jkDdVvdFG9kM/BEChymCH36KgjnW0NsL58JYRxw==", + "license": "MIT", + "dependencies": { + "@socket.io/component-emitter": "~3.1.0", + "debug": "~4.4.1", + "engine.io-parser": "~5.2.1", + "ws": "~8.18.3", + "xmlhttprequest-ssl": "~2.1.1" + } + }, + "node_modules/engine.io-client/node_modules/ws": { + "version": "8.18.3", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz", + "integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/engine.io-parser": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/engine.io-parser/-/engine.io-parser-5.2.3.tgz", + "integrity": "sha512-HqD3yTBfnBxIrbnM1DoD6Pcq8NECnh8d4As1Qgh0z5Gg3jRRIqijury0CL3ghu/edArpUYiYqQiDUQBIs4np3Q==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + } + }, "node_modules/env-paths": { "version": "2.2.1", "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz", @@ -6181,6 +6226,7 @@ "integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -6637,6 +6683,12 @@ "@types/yauzl": "^2.9.1" } }, + "node_modules/fancy-canvas": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fancy-canvas/-/fancy-canvas-2.1.0.tgz", + "integrity": "sha512-nifxXJ95JNLFR2NgRV4/MxVP45G9909wJTEKz5fg/TZS20JJZA6hfgRVh/bC9bwl2zBtBNcYPjiBE4njQHVBwQ==", + "license": "MIT" + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -6892,7 +6944,8 @@ "version": "2.16.9", "resolved": "https://registry.npmjs.org/fp-ts/-/fp-ts-2.16.9.tgz", "integrity": "sha512-+I2+FnVB+tVaxcYyQkHUq7ZdKScaBlX53A41mxQtpIccsfyv8PzdzP7fzp2AY832T4aoK6UZ5WRX/ebGd8uZuQ==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/fraction.js": { "version": "5.3.4", @@ -6939,7 +6992,6 @@ "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", - "dev": true, "hasInstallScript": true, "license": "MIT", "optional": true, @@ -8113,6 +8165,7 @@ "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==", "dev": true, "license": "MIT", + "peer": true, "bin": { "jiti": "bin/jiti.js" } @@ -8238,11 +8291,20 @@ } }, "node_modules/libphonenumber-js": { - "version": "1.12.36", - "resolved": "https://registry.npmjs.org/libphonenumber-js/-/libphonenumber-js-1.12.36.tgz", - "integrity": "sha512-woWhKMAVx1fzzUnMCyOzglgSgf6/AFHLASdOBcchYCyvWSGWt12imw3iu2hdI5d4dGZRsNWAmWiz37sDKUPaRQ==", + "version": "1.12.38", + "resolved": "https://registry.npmjs.org/libphonenumber-js/-/libphonenumber-js-1.12.38.tgz", + "integrity": "sha512-vwzxmasAy9hZigxtqTbFEwp8ZdZ975TiqVDwj5bKx5sR+zi5ucUQy9mbVTkKM9GzqdLdxux/hTw2nmN5J7POMA==", "license": "MIT" }, + "node_modules/lightweight-charts": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/lightweight-charts/-/lightweight-charts-5.1.0.tgz", + "integrity": "sha512-jEAYR4ODYeyNZcWUigsoLTl52rbPmgXnvd5FLIv/ZoA/2sSDw63YKnef8n4yhzum7W926yHeFwlm7ididKb7YQ==", + "license": "Apache-2.0", + "dependencies": { + "fancy-canvas": "2.1.0" + } + }, "node_modules/lilconfig": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", @@ -9399,7 +9461,6 @@ "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "dev": true, "license": "MIT" }, "node_modules/mz": { @@ -10082,6 +10143,7 @@ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -10161,6 +10223,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -10747,6 +10810,7 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz", "integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==", "license": "MIT", + "peer": true, "engines": { "node": ">=0.10.0" } @@ -10756,6 +10820,7 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz", "integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==", "license": "MIT", + "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -11145,8 +11210,9 @@ "version": "4.57.1", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.57.1.tgz", "integrity": "sha512-oQL6lgK3e2QZeQ7gcgIkS2YZPg5slw37hYufJ3edKlfQSGGm8ICoxswK15ntSzF/a8+h7ekRy7k7oWc3BQ7y8A==", - "dev": true, + "devOptional": true, "license": "MIT", + "peer": true, "dependencies": { "@types/estree": "1.0.8" }, @@ -11193,7 +11259,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -11207,7 +11272,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -11221,7 +11285,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -11235,7 +11298,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -11249,7 +11311,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -11263,7 +11324,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -11277,7 +11337,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -11291,7 +11350,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -11631,6 +11689,34 @@ "npm": ">= 3.0.0" } }, + "node_modules/socket.io-client": { + "version": "4.8.3", + "resolved": "https://registry.npmjs.org/socket.io-client/-/socket.io-client-4.8.3.tgz", + "integrity": "sha512-uP0bpjWrjQmUt5DTHq9RuoCBdFJF10cdX9X+a368j/Ft0wmaVgxlrjvK3kjvgCODOMMOz9lcaRzxmso0bTWZ/g==", + "license": "MIT", + "dependencies": { + "@socket.io/component-emitter": "~3.1.0", + "debug": "~4.4.1", + "engine.io-client": "~6.6.1", + "socket.io-parser": "~4.2.4" + }, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/socket.io-parser": { + "version": "4.2.5", + "resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-4.2.5.tgz", + "integrity": "sha512-bPMmpy/5WWKHea5Y/jYAP6k74A+hvmRCQaJuJB6I/ML5JZq/KfNieUVo/3Mh7SAqn7TyFdIo6wqYHInG1MU1bQ==", + "license": "MIT", + "dependencies": { + "@socket.io/component-emitter": "~3.1.0", + "debug": "~4.4.1" + }, + "engines": { + "node": ">=10.0.0" + } + }, "node_modules/socks": { "version": "2.8.7", "resolved": "https://registry.npmjs.org/socks/-/socks-2.8.7.tgz", @@ -11962,9 +12048,9 @@ } }, "node_modules/styled-components": { - "version": "6.3.8", - "resolved": "https://registry.npmjs.org/styled-components/-/styled-components-6.3.8.tgz", - "integrity": "sha512-Kq/W41AKQloOqKM39zfaMdJ4BcYDw/N5CIq4/GTI0YjU6pKcZ1KKhk6b4du0a+6RA9pIfOP/eu94Ge7cu+PDCA==", + "version": "6.3.11", + "resolved": "https://registry.npmjs.org/styled-components/-/styled-components-6.3.11.tgz", + "integrity": "sha512-opzgceGlQ5rdZdGwf9ddLW7EM2F4L7tgsgLn6fFzQ2JgE5EVQ4HZwNkcgB1p8WfOBx1GEZP3fa66ajJmtXhSrA==", "license": "MIT", "dependencies": { "@emotion/is-prop-valid": "1.4.0", @@ -12110,6 +12196,7 @@ "integrity": "sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@alloc/quick-lru": "^5.2.0", "arg": "^5.0.2", @@ -12405,8 +12492,8 @@ "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", - "dev": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -12460,9 +12547,9 @@ "license": "MIT" }, "node_modules/ua-parser-js": { - "version": "2.0.8", - "resolved": "https://registry.npmjs.org/ua-parser-js/-/ua-parser-js-2.0.8.tgz", - "integrity": "sha512-BdnBM5waFormdrOFBU+cA90R689V0tWUWlIG2i30UXxElHjuCu5+dOV2Etw3547jcQ/yaLtPm9wrqIuOY2bSJg==", + "version": "2.0.9", + "resolved": "https://registry.npmjs.org/ua-parser-js/-/ua-parser-js-2.0.9.tgz", + "integrity": "sha512-OsqGhxyo/wGdLSXMSJxuMGN6H4gDnKz6Fb3IBm4bxZFMnyy0sdf6MN96Ie8tC6z/btdO+Bsy8guxlvLdwT076w==", "funding": [ { "type": "opencollective", @@ -12739,6 +12826,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "@noble/curves": "1.9.1", "@noble/hashes": "1.8.0", @@ -12836,6 +12924,7 @@ "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", @@ -13238,8 +13327,8 @@ "version": "8.19.0", "resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz", "integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==", - "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=10.0.0" }, @@ -13256,6 +13345,24 @@ } } }, + "node_modules/xmlhttprequest-ssl": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/xmlhttprequest-ssl/-/xmlhttprequest-ssl-2.1.2.tgz", + "integrity": "sha512-TEU+nJVUUnA4CYJFLvK5X9AOeH4KvDvhIfm0vV1GaQRtchnG0hgK5p8hw/xjv8cunWYCsiPCSDzObPyhEwq3KQ==", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/xstate": { + "version": "5.28.0", + "resolved": "https://registry.npmjs.org/xstate/-/xstate-5.28.0.tgz", + "integrity": "sha512-Iaqq6ZrUzqeUtA3hC5LQKZfR8ZLzEFTImMHJM3jWEdVvXWdKvvVLXZEiNQWm3SCA9ZbEou/n5rcsna1wb9t28A==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/xstate" + } + }, "node_modules/xtend": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", @@ -13385,8 +13492,9 @@ "version": "3.25.76", "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", - "dev": true, + "devOptional": true, "license": "MIT", + "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } @@ -13409,6 +13517,7 @@ "resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.11.tgz", "integrity": "sha512-fdZY+dk7zn/vbWNCYmzZULHRrss0jx5pPFiOuMZ/5HJN6Yv3u+1Wswy/4MpZEkEGhtNH+pwxZB8OKgUBPzYAGg==", "license": "MIT", + "peer": true, "engines": { "node": ">=12.20.0" }, diff --git a/package.json b/package.json index 4a033fe..26e2418 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { - "name": "vite-project", + "name": "zoroswap-frontend", "private": true, - "version": "0.3.0", + "version": "0.5.0", "type": "module", "scripts": { "dev": "vite", @@ -11,12 +11,12 @@ "postinstall": "setup-para" }, "dependencies": { - "@demox-labs/miden-wallet-adapter": "0.10.0", - "@getpara/react-sdk-lite": "^2.2.0", - "@miden-sdk/miden-para": "^0.13.0", - "@miden-sdk/miden-sdk": "^0.13.0", - "@miden-sdk/react": "^0.13.2", - "@miden-sdk/use-miden-para-react": "^0.13.0", + "@demox-labs/miden-wallet-adapter": "^0.10.0", + "@getpara/react-sdk-lite": "^2.15.0", + "@miden-sdk/miden-para": "^0.13.3", + "@miden-sdk/miden-sdk": "^0.13.3", + "@miden-sdk/react": "^0.13.3", + "@miden-sdk/use-miden-para-react": "^0.13.3", "@radix-ui/react-dropdown-menu": "^2.1.16", "@radix-ui/react-slot": "^1.2.4", "@tanstack/react-query": "^5.90.12", @@ -24,6 +24,7 @@ "async-mutex": "^0.5.0", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", + "lightweight-charts": "^5.1.0", "lucide-react": "^0.556.0", "react": "^19.2.1", "react-dom": "^19.2.1", @@ -35,7 +36,7 @@ "wagmi": "^3.1.0" }, "overrides": { - "@getpara/web-sdk": "2.8.0" + "@getpara/web-sdk": "2.15.0" }, "devDependencies": { "@eslint/css": "^0.14.1", diff --git a/public/banner.md b/public/banner.md new file mode 100644 index 0000000..601c0be --- /dev/null +++ b/public/banner.md @@ -0,0 +1 @@ +High frequency pools are currently under maintenance. Swaps / deposits & withdrawals may not work. diff --git a/src/App.tsx b/src/App.tsx index cb637ac..561fab5 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -13,11 +13,16 @@ import { ThemeProvider } from './providers/ThemeProvider'; import '@demox-labs/miden-wallet-adapter-reactui/styles.css'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { Bounce, ToastContainer } from 'react-toastify'; -import LiquidityPools from './pages/LiquidityPools'; +import Launchpad from './pages/Launchpad'; +import Explore from './pages/Explore'; +import HfPoolDetail from './pages/HfPoolDetail'; +import XykPoolDetail from './pages/XykPoolDetail'; +import NewXykPool from './pages/NewXykPool'; +import { DisclaimerGate } from './components/Disclaimer'; import ModalProvider from './providers/ModalProvider'; -import { ZoroProvider } from './providers/ZoroProvider'; import { ParaProviderWrapper } from './providers/ParaProviderWrapper'; import { UnifiedWalletProvider } from './providers/UnifiedWalletProvider'; +import { ZoroProvider } from './providers/ZoroProvider'; const queryClient = new QueryClient(); @@ -27,7 +32,11 @@ function AppRouter() { } /> } /> - } /> + } /> + } /> + } /> + } /> + } /> } /> @@ -53,8 +62,9 @@ function App() { - - + + + diff --git a/src/components/AllDropdown.tsx b/src/components/AllDropdown.tsx new file mode 100644 index 0000000..b00bbee --- /dev/null +++ b/src/components/AllDropdown.tsx @@ -0,0 +1,28 @@ +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from '@/components/ui/dropdown-menu'; +import { ChevronDown } from 'lucide-react'; +import { Button } from './ui/button'; + +export function AllDropdown() { + return ( + + + + + + All + + + ); +} diff --git a/src/components/AssetIcon.tsx b/src/components/AssetIcon.tsx index 017ec2a..c8d73ed 100644 --- a/src/components/AssetIcon.tsx +++ b/src/components/AssetIcon.tsx @@ -5,19 +5,40 @@ interface AssetIconProps { size?: 'small' | 'normal' | number; } +/** LP tokens (zBTC, zUSDC) use the same icon as the underlying token (BTC, USDC). */ +const iconSymbol = (s: string) => (s.startsWith('z') ? s.slice(1) : s); + +/** Symbols that have a dedicated icon (must match .icon-* classes in CSS). */ +const SYMBOLS_WITH_ICONS = new Set(['BTC', 'USDC', 'ETH', 'ANY']); + const AssetIcon = ({ symbol, size = 'normal' }: AssetIconProps) => { - const iconSize = size === 'normal' - ? 32 - : size === 'small' - ? 24 - : typeof size === 'number' - ? size - : 32; + const iconSize = + size === 'normal' + ? 32 + : size === 'small' + ? 24 + : typeof size === 'number' + ? size + : 32; + const symbolForIcon = iconSymbol(symbol); + const hasIcon = SYMBOLS_WITH_ICONS.has(symbolForIcon.toUpperCase()); + + if (hasIcon) { + return ( + + ); + } + + const letter = (symbolForIcon || '?')[0].toUpperCase(); return ( + {letter} ); }; diff --git a/src/components/Disclaimer.tsx b/src/components/Disclaimer.tsx new file mode 100644 index 0000000..850bd90 --- /dev/null +++ b/src/components/Disclaimer.tsx @@ -0,0 +1,127 @@ +import { FancyLogo } from '@/components/FancyLogo'; +import { poweredByMiden } from '@/components/PoweredByMiden'; +import { Button } from '@/components/ui/button'; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from '@/components/ui/card'; +import type { ReactNode } from 'react'; +import { useCallback, useEffect, useState } from 'react'; +import { createPortal } from 'react-dom'; + +const DISCLAIMER_STORAGE_KEY = 'zoro-disclaimer-accepted'; + +function hasAcceptedDisclaimer(): boolean { + if (typeof window === 'undefined') return true; + return window.localStorage.getItem(DISCLAIMER_STORAGE_KEY) === 'true'; +} + +function acceptDisclaimer(): void { + if (typeof window === 'undefined') return; + window.localStorage.setItem(DISCLAIMER_STORAGE_KEY, 'true'); +} + +interface DisclaimerModalProps { + onAccept: () => void; +} + +function DisclaimerModal({ onAccept }: DisclaimerModalProps) { + const [visible, setVisible] = useState(false); + + useEffect(() => { + const t = requestAnimationFrame(() => { + requestAnimationFrame(() => setVisible(true)); + }); + return () => cancelAnimationFrame(t); + }, []); + + const handleAccept = useCallback(() => { + setVisible(false); + acceptDisclaimer(); + setTimeout(onAccept, 200); + }, [onAccept]); + + return createPortal( +
+ e.stopPropagation()} + > + + +
+ + Open Alpha + + + ZoroSwap is under active development — features and interfaces may change + without notice, and you may run into bugs. + +
+
+ +

+ The app runs on the Miden testnet. All tokens and assets here are for testing + only and have no monetary value. +

+ +
+ {poweredByMiden} +
+
+
+
, + document.body, + ); +} + +/** + * Renders the disclaimer modal when the user has not yet accepted it (once per + * device via localStorage). Mount once at app root (e.g. inside ModalProvider). + */ +export function DisclaimerGate({ children }: { children: ReactNode }) { + const [showDisclaimer, setShowDisclaimer] = useState(false); + const [mounted, setMounted] = useState(false); + + useEffect(() => { + // eslint-disable-next-line react-hooks/set-state-in-effect + setMounted(true); + }, []); + + useEffect(() => { + if (!mounted) return; + // eslint-disable-next-line react-hooks/set-state-in-effect + setShowDisclaimer(!hasAcceptedDisclaimer()); + }, [mounted]); + + const handleAccept = useCallback(() => { + setShowDisclaimer(false); + }, []); + + return ( + <> + {children} + {showDisclaimer && } + + ); +} diff --git a/src/components/ExchangeRatio.tsx b/src/components/ExchangeRatio.tsx index 94aa77e..1bfc59d 100644 --- a/src/components/ExchangeRatio.tsx +++ b/src/components/ExchangeRatio.tsx @@ -1,11 +1,12 @@ import { OracleContext } from '@/providers/OracleContext'; import type { TokenConfig } from '@/providers/ZoroProvider'; -import { useContext, useEffect, useMemo, useRef, useState } from 'react'; +import { useContext, useEffect, useRef, useState } from 'react'; const ExchangeRatio = ( - { assetA, assetB }: { + { assetA, assetB, overrideRatio }: { assetA: TokenConfig; assetB: TokenConfig; + overrideRatio?: string; }, ) => { const { getWebsocketPrice } = useContext(OracleContext); @@ -13,6 +14,7 @@ const ExchangeRatio = ( const activeRatio = useRef(undefined); useEffect(() => { + if (overrideRatio != null) return; const i = setInterval(() => { const priceA = getWebsocketPrice(assetA.oracleId); const priceB = getWebsocketPrice(assetB.oracleId); @@ -25,13 +27,14 @@ const ExchangeRatio = ( } }, 50); return () => clearInterval(i); - }, [assetA.oracleId, assetB.oracleId, getWebsocketPrice]); + }, [assetA.oracleId, assetB.oracleId, getWebsocketPrice, overrideRatio]); - const html = useMemo(() => { - return <>{ratio?.toFixed(8)}; - }, [ratio]); + if (overrideRatio != null) { + return <>{overrideRatio}; + } + + return <>{ratio?.toFixed(8)}; - return html; }; export default ExchangeRatio; diff --git a/src/components/FancyLogo.tsx b/src/components/FancyLogo.tsx new file mode 100644 index 0000000..16da960 --- /dev/null +++ b/src/components/FancyLogo.tsx @@ -0,0 +1,24 @@ +import { cn } from '@/lib/utils'; + +interface FancyLogoProps { + className?: string; + size?: number; +} + +export function FancyLogo({ className, size = 56 }: FancyLogoProps) { + return ( +
+ ZoroSwap +
+ ); +} diff --git a/src/components/Header.tsx b/src/components/Header.tsx index 3e932c0..03447a0 100644 --- a/src/components/Header.tsx +++ b/src/components/Header.tsx @@ -1,43 +1,126 @@ -import { Link } from 'react-router-dom'; +import { Menu, X } from 'lucide-react'; +import { useState } from 'react'; +import { Link, useLocation } from 'react-router-dom'; import { ModeToggle } from './ModeToggle'; +import { StatusBanner } from './StatusBanner'; import { UnifiedWalletButton } from './UnifiedWalletButton'; export function Header() { + const location = useLocation(); + const [mobileMenuOpen, setMobileMenuOpen] = useState(false); + + const navLinkClass = (path: string) => + `px-4 py-2 rounded-md text-sm font-medium transition-colors h-10 inline-flex items-center ${ + location.pathname === path || location.pathname.startsWith(path + '/') + ? 'text-foreground' + : 'text-muted-foreground hover:text-foreground' + }`; + + const mobileNavLinkClass = (path: string) => + `block px-4 py-3 text-base font-medium transition-colors ${ + location.pathname === path || location.pathname.startsWith(path + '/') + ? 'text-foreground' + : 'text-muted-foreground hover:text-foreground' + }`; + return ( -
- -

- Zoro logo -

- -
- -
- Swap + <> +
+ {/* Desktop */} +
+ + Zoro + + +
+ +
- - - - Pools - - - - - Faucet - - -
-
- -
-
-
-
+ + {/* Mobile */} +
+ + Zoro + +
+ +
+ +
+ + {/* Mobile menu dropdown */} + {mobileMenuOpen && ( + + )} + + + ); } diff --git a/src/components/IlRiskCard.tsx b/src/components/IlRiskCard.tsx new file mode 100644 index 0000000..33ed657 --- /dev/null +++ b/src/components/IlRiskCard.tsx @@ -0,0 +1,21 @@ +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { AlertTriangle } from 'lucide-react'; + +export function IlRiskCard() { + return ( + + + + + IL Risk + + + +

+ This pool's tokens may have price correlation. Impermanent loss/gain is + possible when prices move. +

+
+
+ ); +} diff --git a/src/components/LiquidityPoolRow.tsx b/src/components/LiquidityPoolRow.tsx index f976a9f..f4fe62a 100644 --- a/src/components/LiquidityPoolRow.tsx +++ b/src/components/LiquidityPoolRow.tsx @@ -1,81 +1,167 @@ import type { PoolBalance } from '@/hooks/usePoolsBalances'; import type { PoolInfo } from '@/hooks/usePoolsInfo'; import { useUnifiedWallet } from '@/hooks/useUnifiedWallet'; +import { prettyBigintFormat } from '@/lib/format'; +import { cn } from '@/lib/utils'; import type { TokenConfig } from '@/providers/ZoroProvider'; -import { formalBigIntFormat, prettyBigintFormat } from '@/utils/format'; import AssetIcon from './AssetIcon'; -import Price from './Price'; import { Button } from './ui/button'; +const feeTierForSymbol = (symbol: string) => + /USDC|USDT|DAI|BUSD/i.test(symbol) ? '0.01%' : '0.30%'; + +/** Saturation = (reserve / total_liabilities) as percentage (can exceed 100) */ +function getSaturationPercent(poolBalances: PoolBalance): number | null { + const { reserve, totalLiabilities } = poolBalances; + if (totalLiabilities === BigInt(0)) return null; + return (Number(reserve) / Number(totalLiabilities)) * 100; +} + const LiquidityPoolRow = ({ pool, - tokenConfig, poolBalances, managePool, className, lpBalance, + variant = 'manage', + onRowClick, + showSaturation = false, }: { pool: PoolInfo; - poolBalances: PoolBalance; tokenConfig?: TokenConfig; + poolBalances: PoolBalance; lpBalance: bigint; managePool: (pool: PoolInfo) => void; className?: string; + variant?: 'manage' | 'addLiquidity'; + onRowClick?: (pool: PoolInfo) => void; + showSaturation?: boolean; }) => { const { connected: isConnected } = useUnifiedWallet(); const decimals = pool.decimals; + const feeTier = feeTierForSymbol(pool.symbol); + const tvlFormatted = prettyBigintFormat({ + value: poolBalances.totalLiabilities, + expo: decimals, + }); + const isHfAmm = pool.poolType === 'hfAMM'; - const saturation = - ((poolBalances.reserve * BigInt(1e8)) / poolBalances.totalLiabilities) - / BigInt(1e6); + const isRowClickable = variant === 'addLiquidity' && onRowClick; + + const saturationPercent = getSaturationPercent(poolBalances); + const saturationColor = saturationPercent != null + ? getSaturationColorClass(saturationPercent) + : ''; + + if (variant === 'addLiquidity') { + return ( + onRowClick?.(pool) : undefined} + onKeyDown={isRowClickable + ? (e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + onRowClick?.(pool); + } + } + : undefined} + > + +
+ {isHfAmm + ? ( + + + + ) + : ( +
+ + + + + + +
+ )} +
+ {pool.name} + {pool.poolType && ( + + {pool.poolType} + + )} + {feeTier} +
+
+ + ${tvlFormatted} + {showSaturation && ( + + {saturationPercent != null + ? ( + + {saturationPercent.toFixed(2)}% + + ) + : } + + )} + — + — + — + e.stopPropagation()} + > + + + + ); + } return ( - - + +

{pool.name}

-

- ${tokenConfig && } -

- n / a - - {prettyBigintFormat({ value: poolBalances.totalLiabilities, expo: decimals })} - {' '} - - / Inf - - - - {formalBigIntFormat({ - val: saturation, - expo: 0, - round: 2, - })} % + ${tvlFormatted} + n / a + — + — + + {prettyBigintFormat({ value: lpBalance, expo: decimals })}{' '} + z{pool.symbol} - { - /* - {pool.apr24h === 0 ? '<0.01' : pool.apr24h} % /{' '} - {pool.apr7d === 0 ? '<0.01' : pool.apr7d} % - */ - } - - {prettyBigintFormat({ value: lpBalance, expo: decimals })}{' '} - z{pool.symbol} - - + @@ -85,3 +171,12 @@ const LiquidityPoolRow = ({ }; export default LiquidityPoolRow; + +function getSaturationColorClass(pct: number) { + if (pct < 15 || pct > 185) return 'text-red-600 border-red-600/30 bg-red-500/10'; + if ((pct >= 15 && pct < 30) || (pct >= 170 && pct <= 185)) { + return 'text-yellow-600 border-yellow-600/30 bg-yellow-500/10'; + } + if (pct >= 30 && pct < 170) return 'text-green-600 border-green-600/30 bg-green-500/10'; + return 'text-muted-foreground border-border bg-muted/30'; +} diff --git a/src/components/LiquidityPoolsTable.tsx b/src/components/LiquidityPoolsTable.tsx index 688053b..e9fd1d5 100644 --- a/src/components/LiquidityPoolsTable.tsx +++ b/src/components/LiquidityPoolsTable.tsx @@ -1,134 +1,196 @@ -import { useLPBalances } from '@/hooks/useLPBalances'; -import { usePoolsBalances } from '@/hooks/usePoolsBalances'; -import { type PoolInfo, usePoolsInfo } from '@/hooks/usePoolsInfo'; -import { useOrderUpdates } from '@/hooks/useWebSocket'; -import { ModalContext } from '@/providers/ModalContext'; -import { ZoroContext } from '@/providers/ZoroContext'; -import { useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react'; +import type { PoolBalance } from '@/hooks/usePoolsBalances'; +import { type PoolInfo } from '@/hooks/usePoolsInfo'; +import type { TokenConfig } from '@/providers/ZoroProvider'; +import { Clock, Droplets, Flame, Loader2, Search, Star } from 'lucide-react'; +import { useMemo, useState } from 'react'; import LiquidityPoolRow from './LiquidityPoolRow'; -import { type LpDetails, OrderStatus, type TxResult } from './OrderStatus'; -import PoolModal from './PoolModal'; -import { poweredByMiden } from './PoweredByMiden'; +import { Button } from './ui/button'; import { Card } from './ui/card'; +import { Input } from './ui/input'; -const LiquidityPoolsTable = () => { - const { data: poolsInfo, refetch: refetchPoolsInfo } = usePoolsInfo(); - const { data: poolBalances, refetch: refetchPoolBalances } = usePoolsBalances(); - const modalContext = useContext(ModalContext); - const lastShownNoteId = useRef(undefined); - const [txResult, setTxResult] = useState(); - const [lpDetails, setLpDetails] = useState(undefined); - const [isSuccessModalOpen, setIsSuccessModalOpen] = useState(false); - const { orderStatus, registerCallback } = useOrderUpdates(); - const { tokens } = useContext(ZoroContext); +export interface LiquidityPoolsTableProps { + poolsInfo?: { poolAccountId?: string; liquidityPools?: PoolInfo[] } | null; + poolBalances?: PoolBalance[] | null; + lpBalances: Record; + tokenConfigs?: (TokenConfig | undefined)[]; + openPoolModal: (pool: PoolInfo) => void; + onPoolRowClick?: (pool: PoolInfo) => void; + onCreatePool?: () => void; + isLoading?: boolean; +} - const tokenConfigs = useMemo( - () => poolsInfo?.liquidityPools?.map(p => tokens[p.faucetIdBech32]), - [tokens, poolsInfo?.liquidityPools], - ); - - const openOrderStatusModal = useCallback((noteId: string) => { - lastShownNoteId.current = noteId; - setIsSuccessModalOpen(true); - }, []); - - const { balances: lpBalances, refetch: refetchLpBalances } = useLPBalances({ - tokens: tokenConfigs, - }); +const LiquidityPoolsTable = ({ + poolsInfo, + poolBalances, + lpBalances, + tokenConfigs, + openPoolModal, + onPoolRowClick, + onCreatePool, + isLoading = false, +}: LiquidityPoolsTableProps) => { + const [search, setSearch] = useState(''); + const [poolFilter, setPoolFilter] = useState<'all' | 'hot' | 'new' | 'stables'>('all'); - useEffect(() => { - if (txResult?.noteId) { - registerCallback(txResult.noteId, status => { - if (status === 'executed') { - refetchLpBalances(); - refetchPoolBalances(); - } - }); + const filteredPools = useMemo(() => { + const pools = poolsInfo?.liquidityPools ?? []; + let list = pools; + if (search.trim()) { + const q = search.trim().toLowerCase(); + list = list.filter( + p => + p.name.toLowerCase().includes(q) + || p.symbol.toLowerCase().includes(q), + ); + } + if (poolFilter === 'stables') { + list = list.filter(p => /USDC|USDT|DAI|BUSD/i.test(p.symbol)); } - }, [ - orderStatus, - txResult?.noteId, - refetchPoolBalances, - refetchLpBalances, - registerCallback, - ]); + return list; + }, [poolsInfo?.liquidityPools, search, poolFilter]); - const openPoolManagementModal = useCallback( - (pool: PoolInfo) => { - modalContext.openModal( - , - ); - }, - [modalContext, refetchPoolsInfo, openOrderStatusModal, lpBalances], - ); + const pools = poolsInfo?.liquidityPools ?? []; + const isEmpty = filteredPools.length === 0; + const noPoolsAtAll = pools.length === 0; return ( -
- -

Liquidity Pools

-
- - - - - - - - - - - - - {poolsInfo?.liquidityPools?.map(p => { - const balances = poolBalances?.find(b => - b.faucetIdBech32 == p.faucetIdBech32 - ); - const tokenConfig = tokenConfigs?.find(c => - c.faucetIdBech32 === p.faucetIdBech32 - ); - return balances - ? ( - - ) - : ( - - - - ); - })} - -
Apr(24h / 7d)TVL / CapSaturationMy position
-
+
+
+
+ + setSearch(e.target.value)} + className='pl-9 rounded-lg bg-muted/50 border-muted-foreground/20' + /> +
+
+ + +
- -
- {poweredByMiden}
- {isSuccessModalOpen && ( - setIsSuccessModalOpen(false)} - swapResult={txResult} - lpDetails={lpDetails} - orderStatus={txResult?.noteId - ? orderStatus[txResult.noteId]?.status - : undefined} - /> - )} + + {isLoading + ? ( + +
+ +
+
+ ) + : isEmpty + ? ( + +
+
+ +
+

+ {noPoolsAtAll ? 'No liquidity pools yet' : 'No pools match your search'} +

+

+ {noPoolsAtAll + ? 'Be the first to create a pool and earn fees from trades. Create a new XYK pool or add liquidity once pools exist.' + : 'Try a different search term or filter to find pools.'} +

+ {noPoolsAtAll && onCreatePool && ( + + )} +
+
+ ) + : ( + +
+ + + + + + + + + + + + + + {filteredPools.map(pool => { + const balances = poolBalances?.find(b => + b.faucetIdBech32 === pool.faucetIdBech32 + ); + const tokenConfig = tokenConfigs?.find(c => + c?.faucetIdBech32 === pool.faucetIdBech32 + ); + return balances + ? ( + + ) + : ( + + + ); + })} + +
PoolTVLSaturationAPR1D VOL7D VOL +
+
+
+
+ )}
); }; diff --git a/src/components/OrderStatus.tsx b/src/components/OrderStatus.tsx index ebece2b..5ebaa47 100644 --- a/src/components/OrderStatus.tsx +++ b/src/components/OrderStatus.tsx @@ -1,9 +1,10 @@ import { Button } from '@/components/ui/button'; +import { formalBigIntFormat, truncateId } from '@/lib/format'; import type { TokenConfig } from '@/providers/ZoroProvider'; import type { OrderStatus } from '@/services/websocket'; -import { formalBigIntFormat, truncateId } from '@/utils/format'; import { CheckCircle, Clock, ExternalLink, Loader2, X, XCircle } from 'lucide-react'; import { useCallback, useEffect, useState } from 'react'; +import AssetIcon from './AssetIcon'; import type { LpActionType } from './PoolModal'; const AnimatedDots = () => ( @@ -129,10 +130,24 @@ export function OrderStatus({ if (!swapResult) return null; + const isLpSuccess = orderStatus === 'executed' && lpDetails; + const successTitle = lpDetails?.actionType === 'Withdraw' + ? 'Withdrawal Successful!' + : 'Deposit Successful!'; + const successMessage = lpDetails?.actionType === 'Withdraw' + ? 'Your liquidity has been removed from the pool.' + : 'Your liquidity has been added to the pool.'; + const amountFormatted = lpDetails + ? formalBigIntFormat({ + val: lpDetails.amount, + expo: lpDetails.token?.decimals ?? 6, + }) + : ''; + return ( <>
-
+
-
-
- {title} - -
- {/* Order Status */} -
-
- - - Order {statusDisplay.text} - -
- {orderStatus === 'executed' && ( -

- Your order has been completed successfully! -

- )} - {orderStatus === 'matching' && ( -

- Finding the best price for your order -

- )} - {orderStatus === 'pending' && ( -

- Your order is waiting to be processed -

- )} - {!orderStatus && ( -

- Waiting for order confirmation -

- )} -
- - {lpDetails && ( -
-
- - {lpDetails.actionType} - -
- {formalBigIntFormat({ - val: lpDetails.amount ?? BigInt(0), - expo: lpDetails.token?.decimals || 6, - })} {lpDetails.token?.symbol} -
-
-
- )} - {swapDetails && ( -
-
-
- {formalBigIntFormat({ - val: swapDetails.sellAmount ?? BigInt(0), - expo: swapDetails.sellToken?.decimals || 6, - })} {swapDetails?.sellToken?.symbol} +
+ {isLpSuccess + ? ( + <> +
+
- -
- {formalBigIntFormat({ - val: swapDetails.buyAmount ?? BigInt(0), - expo: swapDetails.buyToken?.decimals || 6, - })} {swapDetails?.buyToken?.symbol} +
+
+ +
+

+ {successTitle} +

+

+ {successMessage} +

-
-
- )} - {orderStatus === 'executed' && !lpDetails && ( -
- Claim your tokens in the wallet. -
- )} -
-
- -
- - - - -
- {orderStatus === 'executed' && ( - + + + +
+
+ + + ) + : ( + <> +
+ {title} + +
+
- Close - - )} -
-
+
+ + + Order {statusDisplay.text} + +
+ {orderStatus === 'executed' && ( +

+ Your order has been completed successfully! +

+ )} + {orderStatus === 'matching' && ( +

+ Finding the best price for your order +

+ )} + {orderStatus === 'pending' && ( +

+ Your order is waiting to be processed +

+ )} + {!orderStatus && ( +

+ Waiting for order confirmation +

+ )} +
+ + {lpDetails && ( +
+
+ + {lpDetails.actionType} + +
+ {formalBigIntFormat({ + val: lpDetails.amount ?? BigInt(0), + expo: lpDetails.token?.decimals || 6, + })} {lpDetails.token?.symbol} +
+
+
+ )} + {swapDetails && ( +
+
+
+ {formalBigIntFormat({ + val: swapDetails.sellAmount ?? BigInt(0), + expo: swapDetails.sellToken?.decimals || 6, + })} {swapDetails?.sellToken?.symbol} +
+ +
+ {formalBigIntFormat({ + val: swapDetails.buyAmount ?? BigInt(0), + expo: swapDetails.buyToken?.decimals || 6, + })} {swapDetails?.buyToken?.symbol} +
+
+
+ )} + {orderStatus === 'executed' && !lpDetails && ( +
+ Claim your tokens in the wallet. +
+ )} +
+
+ +
+ + + + +
+ {orderStatus === 'executed' && ( + + )} +
+
+ + )}
diff --git a/src/components/PoolCompositionCard.tsx b/src/components/PoolCompositionCard.tsx new file mode 100644 index 0000000..706d4d9 --- /dev/null +++ b/src/components/PoolCompositionCard.tsx @@ -0,0 +1,114 @@ +import AssetIcon from '@/components/AssetIcon'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { prettyBigintFormat } from '@/lib/format'; + +export interface PoolCompositionCardHfProps { + variant: 'hf'; + symbol: string; +} + +export interface PoolCompositionCardXykProps { + variant: 'xyk'; + token0Symbol: string; + token1Symbol: string; + reserve0: bigint; + reserve1: bigint; + decimals0: number; + decimals1: number; +} + +export type PoolCompositionCardProps = + | PoolCompositionCardHfProps + | PoolCompositionCardXykProps; + +export function PoolCompositionCard(props: PoolCompositionCardProps) { + return ( + + + Pool Composition + + + {props.variant === 'hf' ? ( + <> +
+
+ + {props.symbol} +
+
+

+

Single-sided

+
+
+

+ hfAMM pools are single-sided. +

+ + ) : ( + <> +
+
+ + {props.token0Symbol} +
+
+

+ {prettyBigintFormat({ + value: props.reserve0, + expo: props.decimals0, + })} +

+

+
+
+
+
+ + {props.token1Symbol} +
+
+

+ {prettyBigintFormat({ + value: props.reserve1, + expo: props.decimals1, + })} +

+

+
+
+ {(() => { + const total = props.reserve0 + props.reserve1; + const reserve0Pct = + total > 0n + ? Number((props.reserve0 * 100n) / total) + : 50; + const reserve1Pct = + total > 0n + ? Number((props.reserve1 * 100n) / total) + : 50; + return ( + <> +
+
+
+
+

+ {total > 0n + ? `${props.token0Symbol} ${reserve0Pct}% · ${props.token1Symbol} ${reserve1Pct}%` + : '—'} +

+ + ); + })()} + + )} + + + ); +} diff --git a/src/components/PoolDetailHeader.tsx b/src/components/PoolDetailHeader.tsx new file mode 100644 index 0000000..3920517 --- /dev/null +++ b/src/components/PoolDetailHeader.tsx @@ -0,0 +1,64 @@ +import { Button } from '@/components/ui/button'; +import { getMidenscanAccountUrl } from '@/hooks/useLaunchpad'; +import { truncateId } from '@/lib/format'; +import { ExternalLink } from 'lucide-react'; +import type { ReactNode } from 'react'; + +export interface PoolDetailHeaderProps { + pairLabel: string; + feeTier: string; + poolIdBech32: string; + onAddLiquidity: () => void; + onWithdraw: () => void; + hasPosition: boolean; + /** Single icon (HF) or stacked pair (XYK). */ + headerIcons: ReactNode; +} + +export function PoolDetailHeader({ + pairLabel, + feeTier, + poolIdBech32, + onAddLiquidity, + onWithdraw, + hasPosition, + headerIcons, +}: PoolDetailHeaderProps) { + return ( +
+
+ {headerIcons} +
+

{pairLabel}

+ +
+
+
+ + +
+
+ ); +} diff --git a/src/components/PoolDetailLayout.tsx b/src/components/PoolDetailLayout.tsx new file mode 100644 index 0000000..feffeed --- /dev/null +++ b/src/components/PoolDetailLayout.tsx @@ -0,0 +1,37 @@ +import { Footer } from '@/components/Footer'; +import { Header } from '@/components/Header'; +import { ArrowLeft } from 'lucide-react'; +import type { ReactNode } from 'react'; +import { Link } from 'react-router-dom'; + +export interface PoolDetailLayoutProps { + backTo: string; + backLabel?: string; + title?: string; + children: ReactNode; +} + +export function PoolDetailLayout({ + backTo, + backLabel = 'Back to pools', + title, + children, +}: PoolDetailLayoutProps) { + return ( +
+ {title && {title} - ZoroSwap} +
+
+ + + {backLabel} + + {children} +
+
+
+ ); +} diff --git a/src/components/PoolDetailStats.tsx b/src/components/PoolDetailStats.tsx new file mode 100644 index 0000000..16e1be4 --- /dev/null +++ b/src/components/PoolDetailStats.tsx @@ -0,0 +1,81 @@ +import { Card, CardContent } from '@/components/ui/card'; +import { cn } from '@/lib/utils'; + +export interface PoolDetailStatsProps { + /** Main stat value (e.g. TVL or LP supply). */ + tvlFormatted: string; + /** Main stat label (e.g. "Total Value Locked" or "Total LP Supply"). */ + mainLabel?: string; + /** Optional first card: label (e.g. "Price"). */ + priceLabel?: string; + /** Optional first card: value (e.g. "1 ETH = 2000 USDC"). */ + priceValue?: string; + /** HF only: saturation percentage. When null, the Saturation card is not rendered. */ + saturationPercent?: number | null; + saturationColorClass?: string; +} + +function getSaturationColorClass(pct: number): string { + if (pct < 15 || pct > 185) return 'text-red-600 border-red-600/30 bg-red-500/10'; + if ((pct >= 15 && pct < 30) || (pct >= 170 && pct <= 185)) { + return 'text-yellow-600 border-yellow-600/30 bg-yellow-500/10'; + } + if (pct >= 30 && pct < 170) return 'text-green-600 border-green-600/30 bg-green-500/10'; + return 'text-muted-foreground border-border bg-muted/30'; +} + +export function PoolDetailStats({ + tvlFormatted, + mainLabel = 'Total Value Locked', + priceLabel, + priceValue, + saturationPercent = null, + saturationColorClass, +}: PoolDetailStatsProps) { + const saturationColor = + saturationColorClass ?? + (saturationPercent != null ? getSaturationColorClass(saturationPercent) : ''); + + return ( +
+ {priceLabel != null && priceValue != null && ( + + +

+ {priceLabel} +

+

{priceValue}

+
+
+ )} + + +

+ {mainLabel} +

+

{mainLabel === 'Total Value Locked' ? `$${tvlFormatted}` : tvlFormatted}

+
+
+ {saturationPercent != null && ( + + +

+ Saturation +

+

+ + {saturationPercent.toFixed(2)}% + +

+
+
+ )} +
+ ); +} diff --git a/src/components/PoolDetailView.tsx b/src/components/PoolDetailView.tsx new file mode 100644 index 0000000..67bc438 --- /dev/null +++ b/src/components/PoolDetailView.tsx @@ -0,0 +1,137 @@ +import type { PoolBalance } from '@/hooks/usePoolsBalances'; +import type { PoolInfo } from '@/hooks/usePoolsInfo'; +import { prettyBigintFormat } from '@/lib/format'; +import { X } from 'lucide-react'; +import AssetIcon from './AssetIcon'; +import { Button } from './ui/button'; + +interface PoolDetailViewProps { + pool: PoolInfo; + poolBalance: PoolBalance; + lpBalance: bigint; + onAddLiquidity: () => void; + onWithdraw: () => void; + onClose: () => void; +} + +export function PoolDetailView({ + pool, + poolBalance, + lpBalance, + onAddLiquidity, + onWithdraw, + onClose, +}: PoolDetailViewProps) { + const decimals = pool.decimals; + const isHfAmm = pool.poolType === 'hfAMM'; + const tvlFormatted = prettyBigintFormat({ + value: poolBalance.totalLiabilities, + expo: decimals, + }); + const hasPosition = lpBalance > BigInt(0); + + return ( +
+
+
+ {isHfAmm + ? ( + + + + ) + : ( +
+ + + + + + +
+ )} +
+
+

{pool.name}

+ {pool.poolType && ( + + {pool.poolType} + + )} +
+

+ {isHfAmm ? pool.symbol : `${pool.symbol} / USDC`} +

+
+
+ +
+ +
+
+

+ TVL +

+

${tvlFormatted}

+
+
+

+ APR +

+

+
+
+

+ 1D VOL +

+

+
+
+

+ 7D VOL +

+

+
+
+ + {hasPosition && ( +
+

+ Your position +

+

+ {prettyBigintFormat({ value: lpBalance, expo: decimals })}{' '} + z{pool.symbol} +

+
+ )} + +
+ + {hasPosition && ( + + )} +
+
+ ); +} diff --git a/src/components/PoolInfoCard.tsx b/src/components/PoolInfoCard.tsx new file mode 100644 index 0000000..bd860e0 --- /dev/null +++ b/src/components/PoolInfoCard.tsx @@ -0,0 +1,43 @@ +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; + +export interface PoolInfoRow { + label: string; + value: React.ReactNode; +} + +export interface PoolInfoCardProps { + tvlFormatted: string; + /** First row label (e.g. "Total Liquidity" or "Total LP Supply"). */ + firstRowLabel?: string; + /** Whether first row value is prefixed with $. */ + firstRowIsUsd?: boolean; + /** Extra rows (e.g. HF: Total Liabilities, Reserve). */ + extraRows?: PoolInfoRow[]; +} + +export function PoolInfoCard({ + tvlFormatted, + firstRowLabel = 'Total Liquidity', + firstRowIsUsd = true, + extraRows = [], +}: PoolInfoCardProps) { + return ( + + + Pool Info + + +
+ {firstRowLabel} + {firstRowIsUsd ? `$${tvlFormatted}` : tvlFormatted} +
+ {extraRows.map((row, i) => ( +
+ {row.label} + {row.value} +
+ ))} +
+
+ ); +} diff --git a/src/components/PoolModal.tsx b/src/components/PoolModal.tsx index b571632..becd371 100644 --- a/src/components/PoolModal.tsx +++ b/src/components/PoolModal.tsx @@ -1,5 +1,9 @@ import { useDeposit } from '@/hooks/useDeposit'; +import { usePoolsBalances } from '@/hooks/usePoolsBalances'; import { useWithdraw } from '@/hooks/useWithdraw'; +import { DEFAULT_SLIPPAGE } from '@/lib/config'; +import { formatTokenAmount, formatTokenAmountForInput, formatUsd } from '@/lib/format'; +import { useOraclePrices } from '@/providers/OracleContext'; import { ZoroContext } from '@/providers/ZoroContext'; import type { TokenConfig } from '@/providers/ZoroProvider'; import { NoteType } from '@miden-sdk/miden-sdk'; @@ -9,7 +13,7 @@ import { parseUnits } from 'viem'; import { useBalance } from '../hooks/useBalance'; import { type PoolInfo } from '../hooks/usePoolsInfo'; import { ModalContext } from '../providers/ModalContext'; -import { formatTokenAmount } from '../utils/format'; +import AssetIcon from './AssetIcon'; import type { LpDetails, TxResult } from './OrderStatus'; import Slippage from './Slippage'; import { Button } from './ui/button'; @@ -22,56 +26,96 @@ interface PoolModalProps { setLpDetails: (lpDetails: LpDetails) => void; onSuccess: (noteId: string) => void; lpBalance: bigint; + initialMode?: LpActionType; } -const validateValue = (val: bigint, max: bigint) => { - return val > max - ? 'Amount too large' - : val <= 0 - ? 'Invalid value' - : undefined; -}; +const validateValue = (val: bigint, max: bigint) => + val > max ? 'Amount too large' : val <= BigInt(0) ? 'Invalid value' : undefined; export type LpActionType = 'Deposit' | 'Withdraw'; -const PoolModal = ( - { pool, refetchPoolInfo, setTxResult, setLpDetails, onSuccess, lpBalance }: - PoolModalProps, -) => { +const PERCENTAGES = [25, 50, 75, 100] as const; + +export default function PoolModal({ + pool, + refetchPoolInfo, + setTxResult, + setLpDetails, + onSuccess, + lpBalance, + initialMode = 'Deposit', +}: PoolModalProps) { const modalContext = useContext(ModalContext); const { tokens } = useContext(ZoroContext); - const [mode, setMode] = useState('Deposit'); + const [mode, setMode] = useState(initialMode); + const [slippage, setSlippage] = useState(DEFAULT_SLIPPAGE); const [rawValue, setRawValue] = useState(BigInt(0)); const [inputError, setInputError] = useState(undefined); const [inputValue, setInputValue] = useState(''); - const [slippage, setSlippage] = useState(0.5); + const [depositPct, setDepositPct] = useState(100); + const [withdrawPct, setWithdrawPct] = useState(100); + + const { data: poolBalancesData } = usePoolsBalances(); + const poolBalance = useMemo( + () => + poolBalancesData?.find((b) => b.faucetIdBech32 === pool.faucetIdBech32) + ?? null, + [poolBalancesData, pool.faucetIdBech32], + ); + const token = useMemo( - () => Object.values(tokens).find(t => t.faucetIdBech32 === pool.faucetIdBech32), + () => Object.values(tokens).find((t) => t.faucetIdBech32 === pool.faucetIdBech32), [tokens, pool.faucetIdBech32], ); + const quoteToken = useMemo( + () => Object.values(tokens).find((t) => t.symbol === 'USDC'), + [tokens], + ); + /** For hfAMM: LP symbol is "z" + underlying (e.g. zETH → ETH). Use underlying token for deposit/withdraw. */ + const underlyingSymbol = pool.symbol.startsWith('z') + ? pool.symbol.slice(1) + : pool.symbol; + const underlyingToken = useMemo( + () => + Object.values(tokens).find((t) => t.symbol === underlyingSymbol) ?? quoteToken + ?? null, + [tokens, underlyingSymbol, quoteToken], + ); + const oracleIds = useMemo( + () => + [pool.oracleId, underlyingToken?.oracleId, quoteToken?.oracleId].filter( + (id): id is string => typeof id === 'string' && id.length > 0, + ), + [pool.oracleId, underlyingToken?.oracleId, quoteToken?.oracleId], + ); + const oraclePrices = useOraclePrices(oracleIds); const { balance: balanceToken, refetch: refetchBalanceToken } = useBalance({ token, }); - const balance = useMemo( - () => - mode === 'Withdraw' - ? lpBalance ?? BigInt(0) - : balanceToken ?? BigInt(0), - [balanceToken, lpBalance, mode], - ); - const decimals = pool.decimals; + const { balance: balanceQuote } = useBalance({ token: quoteToken ?? undefined }); + const { balance: balanceUnderlying } = useBalance({ + token: underlyingToken ?? undefined, + }); + const isHfAmm = pool.poolType === 'hfAMM'; + const balance = mode === 'Withdraw' + ? lpBalance ?? BigInt(0) + : isHfAmm + ? (balanceUnderlying ?? balanceQuote ?? BigInt(0)) + : (balanceToken ?? BigInt(0)); + const decimals = mode === 'Deposit' && isHfAmm + ? (underlyingToken?.decimals ?? quoteToken?.decimals ?? 6) + : pool.decimals; + const depositWithdrawToken = isHfAmm ? (underlyingToken ?? quoteToken ?? token) : token; const clearForm = useCallback(() => { setInputValue(''); setRawValue(BigInt(0)); + setDepositPct(100); + setWithdrawPct(100); refetchBalanceToken().catch(console.error); refetchPoolInfo?.(); - }, [ - refetchBalanceToken, - refetchPoolInfo, - ]); + }, [refetchBalanceToken, refetchPoolInfo]); - // DEPOSITING const { deposit, isLoading: isDepositLoading, @@ -79,8 +123,6 @@ const PoolModal = ( txId: depositTxId, noteId: depositNoteId, } = useDeposit(); - - // WITHDRAWING const { withdraw, isLoading: isWithdrawLoading, @@ -119,192 +161,535 @@ const PoolModal = ( onSuccess, ]); + const setAmountPercentage = useCallback( + (percentage: number) => { + const newValue = (BigInt(percentage) * balance) / BigInt(100); + setRawValue(newValue); + setInputError(undefined); + setInputValue( + formatTokenAmountForInput({ value: newValue, expo: decimals }), + ); + if (mode === 'Deposit') setDepositPct(percentage); + if (mode === 'Withdraw') setWithdrawPct(percentage); + }, + [decimals, balance, mode], + ); + + const onInputChange = useCallback( + (val: string) => { + const s = typeof val === 'string' ? val : ''; + setInputValue(s); + if (s === '') { + setInputError(undefined); + setRawValue(BigInt(0)); + if (mode === 'Deposit') setDepositPct(0); + if (mode === 'Withdraw') setWithdrawPct(0); + return; + } + const parsed = parseUnits(s, decimals); + const validationError = validateValue(parsed, balance); + if (validationError) setInputError(validationError); + else { + setInputError(undefined); + setRawValue(parsed); + if (balance > BigInt(0)) { + const pct = Number((parsed * BigInt(100)) / balance); + const clamped = Math.min(100, Math.max(0, pct)); + if (mode === 'Deposit') setDepositPct(clamped); + if (mode === 'Withdraw') setWithdrawPct(clamped); + } + } + }, + [decimals, balance, mode], + ); + + const handleClose = useCallback(() => modalContext.closeModal(), [modalContext]); + + const poolLabel = pool.name || (isHfAmm ? `${pool.symbol}` : `${pool.symbol} / USDC`); + const withdrawReceiveAmount = rawValue; + const withdrawReceiveFormatted = formatTokenAmount({ + value: withdrawReceiveAmount, + expo: decimals, + }); + // Withdraw: (lp_token / lp_total_supply) * total_liabilities = asset amount out (use totalLiabilities) + const withdrawAssetOut = useMemo(() => { + if (!poolBalance || poolBalance.totalLiabilities === BigInt(0)) { + return BigInt(0); + } + const lpTotalSupply = poolBalance.totalLiabilities; + return (rawValue * lpTotalSupply) / lpTotalSupply; + }, [poolBalance, rawValue]); + const assetDecimals = isHfAmm + ? (underlyingToken?.decimals ?? quoteToken?.decimals ?? 6) + : decimals; + const withdrawAssetOutFormatted = + formatTokenAmount({ value: withdrawAssetOut, expo: assetDecimals }) ?? '0'; + const totalValueUsd = useMemo(() => { + if (!isHfAmm) return null; + const oracleId = underlyingToken?.oracleId ?? quoteToken?.oracleId ?? pool.oracleId; + const price = oracleId ? oraclePrices[oracleId]?.value : undefined; + if (price == null || price === 0) return null; + const amount = mode === 'Deposit' ? rawValue : withdrawAssetOut; + const expo = underlyingToken?.decimals ?? quoteToken?.decimals ?? 6; + const value = Number(amount) / 10 ** expo; + const usd = value * price; + return usd; + }, [ + isHfAmm, + mode, + underlyingToken, + quoteToken, + pool.oracleId, + oraclePrices, + rawValue, + withdrawAssetOut, + ]); + + // Deposit: LP amount uses total_liabilities (not reserve) + const expectedLp = useMemo(() => { + if ( + !poolBalance || poolBalance.totalLiabilities === BigInt(0) || rawValue === BigInt(0) + ) { + return BigInt(0); + } + return (rawValue * poolBalance.totalLiabilities) / poolBalance.totalLiabilities; + }, [poolBalance, rawValue]); + + const expectedLpFormatted = formatTokenAmount({ + value: expectedLp, + expo: decimals, + }); + + // Pool share (deposit): use total_liabilities, share = expectedLp / (totalLiabilities + expectedLp) + const poolSharePct = useMemo(() => { + if (!poolBalance || rawValue === BigInt(0)) return null; + const tl = poolBalance.totalLiabilities; + const newTotalLp = tl + expectedLp; + if (newTotalLp === BigInt(0)) return null; + const pct = (Number(expectedLp) / Number(newTotalLp)) * 100; + return pct; + }, [poolBalance, rawValue, expectedLp]); + const poolShareDisplay = poolSharePct != null + ? poolSharePct < 0.01 + ? `${poolSharePct.toFixed(6)}%` + : `${poolSharePct.toFixed(2)}%` + : '—'; + + /** After withdraw: your new share = (your LP - withdrawn) / (total LP supply - withdrawn). */ + const withdrawPoolSharePct = useMemo(() => { + if (!poolBalance || rawValue === BigInt(0)) return null; + const totalSupply = poolBalance.totalLiabilities; + const totalAfter = totalSupply - rawValue; + if (totalAfter <= BigInt(0)) return null; + const userAfter = lpBalance >= rawValue ? lpBalance - rawValue : BigInt(0); + const pct = (Number(userAfter) / Number(totalAfter)) * 100; + const clamped = Math.min(100, Math.max(0, pct)); + return clamped; + }, [poolBalance, rawValue, lpBalance]); + const withdrawPoolShareDisplay = withdrawPoolSharePct != null + ? withdrawPoolSharePct < 0.01 + ? `${withdrawPoolSharePct.toFixed(6)}%` + : `${withdrawPoolSharePct.toFixed(2)}%` + : '—'; + + const minAmountOutDeposit = useMemo(() => { + if (expectedLp === BigInt(0)) return BigInt(1); + const slippageMultiplier = BigInt(Math.round((100 - slippage) * 1e6)); + const min = (expectedLp * slippageMultiplier) / BigInt(1e8); + return min > BigInt(0) ? min : BigInt(1); + }, [expectedLp, slippage]); + + const minAmountOutWithdraw = useMemo(() => { + if ( + !poolBalance || poolBalance.totalLiabilities === BigInt(0) || rawValue === BigInt(0) + ) { + return BigInt(1); + } + const estimatedAssetOut = (rawValue * poolBalance.totalLiabilities) + / poolBalance.totalLiabilities; + if (estimatedAssetOut === BigInt(0)) return BigInt(1); + const slippageMultiplier = BigInt(Math.round((100 - slippage) * 1e6)); + const min = (estimatedAssetOut * slippageMultiplier) / BigInt(1e8); + return min > BigInt(0) ? min : BigInt(1); + }, [poolBalance, rawValue, slippage]); + + const minLpFormatted = formatTokenAmount({ + value: minAmountOutDeposit, + expo: decimals, + }) ?? '0'; + const minWithdrawAssetFormatted = + formatTokenAmount({ value: minAmountOutWithdraw, expo: assetDecimals }) ?? '0'; + const writeDeposit = useCallback(async () => { - if (token == null) return; + if (depositWithdrawToken == null) return; await deposit({ amount: rawValue, - - // TODO: This needs to be the correct LP amount, not token amount - // BigInt(rawValue * BigInt(1e8 - 1e6 * slippage) / BigInt(1e8)) - // find a way to simulate it properly - minAmountOut: BigInt(1), - token, + minAmountOut: minAmountOutDeposit, + token: depositWithdrawToken, noteType: NoteType.Public, }); - }, [rawValue, deposit, token]); + }, [rawValue, minAmountOutDeposit, deposit, depositWithdrawToken]); const writeWithdraw = useCallback(async () => { if (token == null) return; - await withdraw({ amount: rawValue, - // TODO: This needs to be the correct LP amount, not token amount - // BigInt(rawValue * BigInt(1e8 - 1e6 * slippage) / BigInt(1e8)) - // find a way to simulate it properly - minAmountOut: BigInt(1), + minAmountOut: minAmountOutWithdraw, token, noteType: NoteType.Public, }); - }, [rawValue, withdraw, token]); - - const setAmountPercentage = useCallback( - (percentage: number) => { - const newValue = (BigInt(percentage) * balance) / BigInt(100); - setRawValue(newValue); - setInputError(undefined); - setInputValue( - (formatTokenAmount({ value: newValue, expo: decimals }) ?? '').toString(), - ); - }, - [decimals, balance], - ); - const onInputChange = useCallback((val: string) => { - setInputValue(val); - if (val === '') { - setInputError(undefined); - setRawValue(BigInt(0)); - return; - } - const parsed = parseUnits(val, decimals); - const validationError = validateValue(parsed, balance); - if (validationError) { - setInputError(validationError); - } else { - setInputError(undefined); - setRawValue(parseUnits(val, decimals)); - } - }, [decimals, balance]); - - const handleClose = useCallback(() => { - modalContext.closeModal(); - }, [modalContext]); + }, [rawValue, minAmountOutWithdraw, withdraw, token]); return ( -
-
-
{ - setMode('Deposit'); - clearForm(); - }} - > - Deposit -
-
{ - setMode('Withdraw'); - clearForm(); - }} - > - Withdraw +
+
+
+ {isHfAmm + ? ( + + + + ) + : ( +
+ + + + + + +
+ )} + {poolLabel}
-
-
{' '} -
-

- {mode === 'Deposit' ? 'Deposit amount' : 'Withdrawal amount'} -

-
- { - onInputChange(e.target.value); +
+ + {/* Tabs */} +
+
+
- {inputError ?

{inputError}

: null} -
- {[25, 50, 75, 100].map(n => ( - - ))} + className={`flex-1 py-2 text-sm font-medium rounded-lg transition-all ${ + mode === 'Deposit' + ? 'bg-primary text-primary-foreground shadow-sm' + : 'text-muted-foreground hover:text-foreground' + }`} + > + Deposit + +
-
-
-

Max slippage

-
+ + {mode === 'Deposit' && ( + <> +
+

+ {isHfAmm ? 'Deposit amount' : 'Deposit amounts'} +

- {slippage} %
-
-

- Balance - - {formatTokenAmount({ - value: balanceToken, - expo: pool.decimals, - })} {pool.symbol} - -

-

- My position - - {formatTokenAmount({ - value: lpBalance, - expo: pool.decimals, - })} z{pool.symbol} - +

+
+

Amount

+
+ onInputChange(e.target.value)} + /> +
+
+
+ + Balance: {formatTokenAmount({ value: balance, expo: decimals })}{' '} + {isHfAmm ? (underlyingToken?.symbol ?? underlyingSymbol) : pool.symbol} + +
+
+ {PERCENTAGES.map((n) => ( + + ))} +
+
+ + {inputError &&

{inputError}

} + + {/* Details */} +
+
+ Max slippage + + + {slippage} % + +
+
+ Balance + + {formatTokenAmount({ value: balance, expo: decimals })}{' '} + {isHfAmm ? (underlyingToken?.symbol ?? underlyingSymbol) : pool.symbol} + +
+
+ My position + + {formatTokenAmount({ value: lpBalance, expo: pool.decimals })} {isHfAmm + ? (pool.symbol.startsWith('z') ? pool.symbol : `z${pool.symbol}`) + : pool.symbol} + +
+
+ Pool share + {poolShareDisplay} +
+
+ + {/* Receive row — inline with details */} +
+
+ You receive (min) + + {minLpFormatted ?? '0'}{' '} + + +
+ {expectedLpFormatted != null + && expectedLpFormatted !== (minLpFormatted ?? '0.00') && ( +

+ Expected: {expectedLpFormatted} +

+ )} + {isHfAmm && ( +
+ Total Value + {formatUsd(totalValueUsd)} +
+ )} +
+ + {/* CTA */} + + + )} + + {mode === 'Withdraw' && ( + <> + {/* Input card */} +
+
+ onInputChange(e.target.value)} + /> + + + +
+
+ + Balance: {formatTokenAmount({ value: lpBalance, expo: decimals })}{' '} + {isHfAmm + ? (pool.symbol.startsWith('z') ? pool.symbol : `z${pool.symbol}`) + : 'LP'} + +
+
+ {PERCENTAGES.map((n) => ( + + ))} +
+
+
+
+

Amount

+
+ onInputChange(e.target.value)} + /> +
+ + Balance: {formatTokenAmount({ + value: lpBalance, + expo: decimals, + })} {isHfAmm + ? (pool.symbol.startsWith('z') ? pool.symbol : `z${pool.symbol}`) + : 'LP'} + + + + +
+
+
+ {PERCENTAGES.map((n) => ( + + ))} +
+
+
+ Balance + + {formatTokenAmount({ value: lpBalance, expo: pool.decimals })} {isHfAmm + ? (pool.symbol.startsWith('z') ? pool.symbol : `z${pool.symbol}`) + : 'LP'} + +
+
+ My position + + {formatTokenAmount({ value: lpBalance, expo: pool.decimals })} {isHfAmm + ? (pool.symbol.startsWith('z') ? pool.symbol : `z${pool.symbol}`) + : pool.symbol} + +
+
+ Pool share after + {withdrawPoolShareDisplay} +
+
+
+

+ You receive (min) +

+ {isHfAmm + ? ( +
+
+ + {underlyingToken?.symbol ?? underlyingSymbol} +
+ {minWithdrawAssetFormatted} +
+ ) + : ( + <> +

+ LP: {withdrawReceiveFormatted ?? '0'} +

+
+
+ + {pool.symbol} +
+ + {withdrawAssetOutFormatted} (min: {minWithdrawAssetFormatted}) + +
+
+
+ + USDC +
+ +
+ + )} + {isHfAmm && ( +
+ Total Value + {formatUsd(totalValueUsd)} +
+ )} +
+ + {/* CTA */} + + + )} + + {(depositError || withdrawError) && ( +

+ {depositError ?? withdrawError}

-
-
- {mode === 'Deposit' - ? ( - - ) - : null} - {mode === 'Withdraw' - ? ( - - ) - : null} -
- {depositError ?

{depositError}

: null} - {withdrawError ?

{withdrawError}

: null} + )}
); -}; - -export default PoolModal; +} diff --git a/src/components/PositionCard.tsx b/src/components/PositionCard.tsx new file mode 100644 index 0000000..163433e --- /dev/null +++ b/src/components/PositionCard.tsx @@ -0,0 +1,174 @@ +import type { PoolBalance } from '@/hooks/usePoolsBalances'; +import type { PoolInfo } from '@/hooks/usePoolsInfo'; +import { formatUsd, prettyBigintFormat } from '@/lib/format'; +import { cn } from '@/lib/utils'; +import { useOraclePrices } from '@/providers/OracleContext'; +import { useMemo } from 'react'; +import AssetIcon from './AssetIcon'; +import { Button } from './ui/button'; +import { Card, CardContent } from './ui/card'; + +interface PositionCardProps { + pool: PoolInfo; + poolBalance: PoolBalance; + lpBalance: bigint; + feeTier?: string; + variant?: 'slim' | 'full'; + onDeposit: () => void; + onWithdraw: () => void; + disabled?: boolean; +} + +export function PositionCard({ + pool, + poolBalance, + lpBalance, + feeTier = '0.30%', + variant = 'full', + onDeposit, + onWithdraw, + disabled = false, +}: PositionCardProps) { + const decimals = pool.decimals; + const isHfAmm = pool.poolType === 'hfAMM'; + const oraclePrices = useOraclePrices(pool.oracleId ? [pool.oracleId] : []); + const price = pool.oracleId ? oraclePrices[pool.oracleId]?.value : undefined; + const positionUsd = useMemo(() => { + if ( + !isHfAmm || price == null || price === 0 + || poolBalance.totalLiabilities === BigInt(0) + ) { + return null; + } + const valueInAsset = (lpBalance * poolBalance.reserve) / poolBalance.totalLiabilities; + const valueHuman = Number(valueInAsset) / 10 ** decimals; + return valueHuman * price; + }, [ + isHfAmm, + price, + lpBalance, + poolBalance.reserve, + poolBalance.totalLiabilities, + decimals, + ]); + const liquidityFormatted = prettyBigintFormat({ + value: lpBalance, + expo: decimals, + }); + const tvlFormatted = prettyBigintFormat({ + value: poolBalance.totalLiabilities, + expo: decimals, + }); + const isSlim = variant === 'slim'; + + return ( + + +
+ {isHfAmm + ? ( + + + + ) + : ( +
+ + + + + + +
+ )} + + {pool.name} + + {isHfAmm && ( + + hfAMM + + )} + {feeTier} +
+
+ {!isSlim && ( + <> +
+ + Liquidity + + ${tvlFormatted} +
+ {isHfAmm && positionUsd != null && ( +
+ + Value + + {formatUsd(positionUsd)} +
+ )} +
+ + Fees earned + + $0.00 +
+
+ {pool.symbol} + {liquidityFormatted} +
+
+ USDC + +
+ + )} + {isSlim && ( + <> +
+ Your deposit + {liquidityFormatted} +
+ {isHfAmm && positionUsd != null && ( +
+ Value + {formatUsd(positionUsd)} +
+ )} + + )} +
+
+ + +
+
+
+ ); +} diff --git a/src/components/Price.tsx b/src/components/Price.tsx index 395a08f..a7c14e8 100644 --- a/src/components/Price.tsx +++ b/src/components/Price.tsx @@ -1,6 +1,6 @@ +import { formalNumberFormat, formatTokenAmount } from '@/lib/format'; import { OracleContext } from '@/providers/OracleContext'; import type { TokenConfig } from '@/providers/ZoroProvider'; -import { formalNumberFormat, formatTokenAmount } from '@/utils/format'; import { useContext, useEffect, useMemo, useRef, useState } from 'react'; const Price = ( diff --git a/src/components/PriceTvlChartCard.tsx b/src/components/PriceTvlChartCard.tsx new file mode 100644 index 0000000..f7b83f9 --- /dev/null +++ b/src/components/PriceTvlChartCard.tsx @@ -0,0 +1,44 @@ +import { TradingViewCandlesChart } from '@/components/TradingViewCandlesChart'; +import { Button } from '@/components/ui/button'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import type { MockCandle } from '@/mocks/poolDetailMocks'; + +export type ChartRange = '1D' | '1W' | '1M' | 'ALL'; + +export interface PriceTvlChartCardProps { + candles: MockCandle[]; + chartRange: ChartRange; + onChartRangeChange: (range: ChartRange) => void; +} + +export function PriceTvlChartCard({ + candles, + chartRange, + onChartRangeChange, +}: PriceTvlChartCardProps) { + return ( + + + Price & TVL +
+ {(['1D', '1W', '1M', 'ALL'] as const).map((r) => ( + + ))} +
+
+ +
+ +
+
+
+ ); +} diff --git a/src/components/ProgressBar.tsx b/src/components/ProgressBar.tsx new file mode 100644 index 0000000..161ece4 --- /dev/null +++ b/src/components/ProgressBar.tsx @@ -0,0 +1,80 @@ +import { CheckCircle2, Loader2 } from 'lucide-react'; + +export interface ProgressBarProps { + /** Step labels shown in order. */ + steps: readonly string[]; + /** 0-based index of the current step. When null, the bar is typically hidden by the parent. */ + currentStepIndex: number | null; + /** Optional title above the bar (e.g. "Progress"). */ + title?: string; + /** Optional className for the wrapper. */ + className?: string; +} + +export function ProgressBar({ + steps, + currentStepIndex, + title = 'Progress', + className = '', +}: ProgressBarProps) { + if (currentStepIndex === null) return null; + + return ( +
+

{title}

+
+
+
+
    + {steps.map((label, i) => { + const done = i < currentStepIndex; + const current = i === currentStepIndex; + return ( +
  • + {done ? ( + + ) : current ? ( + + ) : ( + + )} + + {label} + +
  • + ); + })} +
+
+ ); +} diff --git a/src/components/RecentTransactionsCard.tsx b/src/components/RecentTransactionsCard.tsx new file mode 100644 index 0000000..c8a223e --- /dev/null +++ b/src/components/RecentTransactionsCard.tsx @@ -0,0 +1,60 @@ +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import type { MockRecentTx } from '@/mocks/poolDetailMocks'; + +const TYPE_COLORS: Record = { + Swap: 'text-primary', + Add: 'text-green-600', + Remove: 'text-amber-600', +}; + +export interface RecentTransactionsCardProps { + transactions: MockRecentTx[]; +} + +export function RecentTransactionsCard({ transactions }: RecentTransactionsCardProps) { + return ( + + + + Recent Transactions + + + +
+ + + + + + + + + + + + {transactions.map((tx, i) => ( + + + + + + + + ))} + +
TypeAmount inAmount outAccountTime
+ {tx.type} + {tx.amountIn}{tx.amountOut} + {tx.account} + + {tx.timeAgo} +
+
+
+
+ ); +} diff --git a/src/components/SelectPoolModal.tsx b/src/components/SelectPoolModal.tsx new file mode 100644 index 0000000..e512c22 --- /dev/null +++ b/src/components/SelectPoolModal.tsx @@ -0,0 +1,65 @@ +import type { PoolInfo } from '@/hooks/usePoolsInfo'; +import { ModalContext } from '@/providers/ModalContext'; +import { useContext } from 'react'; +import AssetIcon from './AssetIcon'; +import { Button } from './ui/button'; +import { X } from 'lucide-react'; + +const feeTierForSymbol = (symbol: string) => + /USDC|USDT|DAI|BUSD/i.test(symbol) ? '0.01%' : '0.30%'; + +export function SelectPoolModal({ + pools, + onSelect, + onClose, +}: { + pools: PoolInfo[]; + onSelect: (pool: PoolInfo) => void; + onClose: () => void; +}) { + const { closeModal } = useContext(ModalContext); + + const handleSelect = (pool: PoolInfo) => { + closeModal(); + onSelect(pool); + }; + + return ( +
+
+

+ Choose a pool to add liquidity +

+ +
+

+ Select a pool to open the deposit modal and add liquidity. +

+
+ {pools.length === 0 ? ( +

No pools available.

+ ) : ( + pools.map((pool) => ( + + )) + )} +
+
+ ); +} diff --git a/src/components/Slippage.tsx b/src/components/Slippage.tsx index d74739a..63a489f 100644 --- a/src/components/Slippage.tsx +++ b/src/components/Slippage.tsx @@ -1,7 +1,7 @@ import { Button } from '@/components/ui/button'; import { Card, CardContent } from '@/components/ui/card'; import { Input } from '@/components/ui/input'; -import { Info, Settings, X } from 'lucide-react'; +import { Settings, X } from 'lucide-react'; import { useCallback, useEffect, useState } from 'react'; interface SlippageProps { @@ -39,94 +39,58 @@ const Slippage = ({ slippage, onSlippageChange }: SlippageProps) => { }, [slippage]); return ( -
+ <> {isOpen && ( <> - {/* Backdrop */}
- {/* Settings Panel */} - - - {/* Header */} + +
-
-

Max slippage

-
- - {/* Tooltip */} -
-
- Your transaction will revert if the price changes unfavorably by - more than this percentage -
-
-
-
-
-
- + +
- {/* Slippage Input */} -
-
- handleSlippageChange(e.target.value)} - className='text-center text-sm pr-8' - min='0' - max='50' - step='0.1' - placeholder='0.5' - /> - - % - -
- - {/* Conditional Warnings */} - {slippage > 5 && ( -
- High slippage risk -
- )} - - {slippage < 0.1 && slippage > 0 && ( -
- May fail due to low slippage -
- )} +
+ handleSlippageChange(e.target.value)} + className='text-center text-lg font-medium pr-10 h-12 rounded-xl' + min='0' + max='50' + step='0.1' + placeholder='0.5' + /> + + % +
)} -
+ ); }; diff --git a/src/components/StatusBanner.tsx b/src/components/StatusBanner.tsx new file mode 100644 index 0000000..c26be5d --- /dev/null +++ b/src/components/StatusBanner.tsx @@ -0,0 +1,100 @@ +import { AlertTriangle, Info, X } from 'lucide-react'; +import { useCallback, useEffect, useState } from 'react'; + +const BANNER_URL = '/banner.md'; +const BANNER_POLL_MS = 180_000; + +type BannerLevel = 'info' | 'warning' | 'error'; + +interface ParsedBanner { + level: BannerLevel; + text: string; +} + +function parseBanner(raw: string): ParsedBanner | null { + const trimmed = raw.trim(); + if (!trimmed) return null; + + const lines = trimmed.split('\n'); + const firstLine = lines[0].trim(); + + let level: BannerLevel = 'info'; + let text = trimmed; + + // Support optional front-matter style: `level: warning` + const levelMatch = firstLine.match(/^level:\s*(info|warning|error)$/i); + if (levelMatch) { + level = levelMatch[1].toLowerCase() as BannerLevel; + text = lines.slice(1).join('\n').trim(); + } + + if (!text) return null; + return { level, text }; +} + +const levelStyles: Record = { + info: { + bg: 'bg-blue-500/10', + text: 'text-blue-700 dark:text-blue-300', + border: 'border-blue-500/20', + }, + warning: { + bg: 'bg-yellow-500/10', + text: 'text-yellow-700 dark:text-yellow-300', + border: 'border-yellow-500/20', + }, + error: { + bg: 'bg-red-500/10', + text: 'text-red-700 dark:text-red-300', + border: 'border-red-500/20', + }, +}; + +export function StatusBanner() { + const [banner, setBanner] = useState(null); + const [dismissed, setDismissed] = useState(false); + + const fetchBanner = useCallback(async () => { + try { + const res = await fetch(BANNER_URL, { cache: 'no-store' }); + if (!res.ok) { + setBanner(null); + return; + } + const text = await res.text(); + setBanner(parseBanner(text)); + } catch { + setBanner(null); + } + }, []); + + useEffect(() => { + // eslint-disable-next-line react-hooks/set-state-in-effect + fetchBanner(); + const id = setInterval(fetchBanner, BANNER_POLL_MS); + return () => clearInterval(id); + }, [fetchBanner]); + + if (!banner || dismissed) return null; + + const styles = levelStyles[banner.level]; + const Icon = banner.level === 'info' ? Info : AlertTriangle; + + return ( +
+
+ +

+ {banner.text} +

+ +
+
+ ); +} diff --git a/src/components/SwapInputBuy.tsx b/src/components/SwapInputBuy.tsx index 8f43fdf..5392099 100644 --- a/src/components/SwapInputBuy.tsx +++ b/src/components/SwapInputBuy.tsx @@ -5,17 +5,30 @@ import { formatUnits } from 'viem'; import { Input } from './ui/input'; const SwapInputBuy = ( - { amountSell, assetBuy, assetSell }: { + { amountSell, assetBuy, assetSell, overrideAmount }: { amountSell?: bigint; assetBuy?: TokenConfig; assetSell?: TokenConfig; + overrideAmount?: bigint; }, ) => { const { getWebsocketPrice } = useContext(OracleContext); const [stringBuy, setStringBuy] = useState(''); const activeStringBuy = useRef(undefined); + // When overrideAmount is provided, display it directly (XYK mode) useEffect(() => { + if (overrideAmount == null || !assetBuy) return; + const formatted = formatUnits(overrideAmount, assetBuy.decimals); + if (formatted !== activeStringBuy.current) { + setStringBuy(formatted); + activeStringBuy.current = formatted; + } + }, [overrideAmount, assetBuy]); + + // Oracle-based estimation (hfAMM mode) + useEffect(() => { + if (overrideAmount != null) return; const i = setInterval(() => { if (!amountSell || !assetBuy || !assetSell) { const newStringBuy = ''; @@ -42,7 +55,7 @@ const SwapInputBuy = ( } }, 50); return () => clearInterval(i); - }, [assetBuy, assetSell, amountSell, getWebsocketPrice]); + }, [assetBuy, assetSell, amountSell, getWebsocketPrice, overrideAmount]); const html = useMemo(() => { return ( @@ -51,7 +64,7 @@ const SwapInputBuy = ( value={stringBuy} disabled placeholder='0' - className='border-none text-3xl sm:text-4xl font-light outline-none flex-1 p-0 h-auto focus-visible:ring-0 no-spinner bg-transparent' + className='border-none text-4xl sm:text-6xl font-semibold text-muted-foreground outline-none flex-1 p-0 h-auto focus-visible:ring-0 no-spinner bg-transparent placeholder:text-muted-foreground' /> ); }, [stringBuy]); diff --git a/src/components/SwapPairs.tsx b/src/components/SwapPairs.tsx index 88a59f9..80322ae 100644 --- a/src/components/SwapPairs.tsx +++ b/src/components/SwapPairs.tsx @@ -1,52 +1,27 @@ export const SwapPairs = ( { swapPairs, disabled }: { swapPairs: () => void; disabled: boolean }, ) => { + const handleClick = () => { + swapPairs(); + }; + return ( ); diff --git a/src/components/TokenAutocomplete.tsx b/src/components/TokenAutocomplete.tsx new file mode 100644 index 0000000..0130b84 --- /dev/null +++ b/src/components/TokenAutocomplete.tsx @@ -0,0 +1,192 @@ +import AssetIcon from '@/components/AssetIcon'; +import { Button } from '@/components/ui/button'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from '@/components/ui/dropdown-menu'; +import { Input } from '@/components/ui/input'; +import { cn } from '@/lib/utils'; +import type { TokenConfig } from '@/providers/ZoroProvider'; +import { ChevronDown, Search } from 'lucide-react'; +import { useEffect, useMemo, useRef, useState } from 'react'; + +const DEFAULT_MAX_VISIBLE = 10; + +export function TokenAutocomplete({ + tokens, + value, + onChange, + disabled, + excludeFaucetIdBech32, + disabledBech32s, + priorityBech32s, + maxVisible = DEFAULT_MAX_VISIBLE, + placeholder = 'Select token', + className, +}: { + tokens: TokenConfig[]; + value?: TokenConfig; + onChange: (faucetIdBech32: string) => void; + disabled?: boolean; + /** Hide this token entirely from the list (legacy behaviour). */ + excludeFaucetIdBech32?: string; + /** Tokens in this set are shown but greyed out with "Not available". */ + disabledBech32s?: ReadonlySet; + /** Tokens in this set are sorted to the top. */ + priorityBech32s?: ReadonlySet; + /** Max tokens shown in the dropdown (default 10). */ + maxVisible?: number; + placeholder?: string; + className?: string; +}) { + const [open, setOpen] = useState(false); + const [query, setQuery] = useState(''); + const [activeIndex, setActiveIndex] = useState(0); + const inputRef = useRef(null); + + const filtered = useMemo(() => { + const q = query.trim().toLowerCase(); + let list = tokens.filter(t => t.faucetIdBech32 !== excludeFaucetIdBech32); + if (q) { + list = list.filter(t => + t.symbol.toLowerCase().includes(q) || t.name.toLowerCase().includes(q), + ); + } + + if (priorityBech32s && priorityBech32s.size > 0) { + const priority: TokenConfig[] = []; + const rest: TokenConfig[] = []; + for (const t of list) { + if (priorityBech32s.has(t.faucetIdBech32)) { + priority.push(t); + } else { + rest.push(t); + } + } + list = [...priority, ...rest]; + } + + if (maxVisible > 0 && list.length > maxVisible) { + list = list.slice(0, maxVisible); + } + + return list; + }, [excludeFaucetIdBech32, query, tokens, priorityBech32s, maxVisible]); + + useEffect(() => { + if (!open) return; + setQuery(''); + setActiveIndex(0); + const t = window.setTimeout(() => inputRef.current?.focus(), 0); + return () => window.clearTimeout(t); + }, [open]); + + useEffect(() => { + if (activeIndex >= filtered.length) setActiveIndex(0); + }, [activeIndex, filtered.length]); + + return ( + + + + + e.preventDefault()} + > +
+ + setQuery(e.target.value)} + placeholder='Search token...' + className='pl-9 rounded-lg bg-muted/40 border-muted-foreground/20' + onKeyDown={(e) => { + if (e.key === 'ArrowDown') { + e.preventDefault(); + setActiveIndex(i => Math.min(filtered.length - 1, i + 1)); + } else if (e.key === 'ArrowUp') { + e.preventDefault(); + setActiveIndex(i => Math.max(0, i - 1)); + } else if (e.key === 'Enter') { + e.preventDefault(); + const item = filtered[activeIndex]; + if (!item || disabledBech32s?.has(item.faucetIdBech32)) return; + onChange(item.faucetIdBech32); + setOpen(false); + } else if (e.key === 'Escape') { + setOpen(false); + } + }} + /> +
+ +
+ {filtered.length === 0 && ( +
+ No tokens found +
+ )} + {filtered.map((t, idx) => { + const isUnavailable = disabledBech32s?.has(t.faucetIdBech32) ?? false; + return ( + setActiveIndex(idx)} + onSelect={(e) => { + if (isUnavailable) { + e.preventDefault(); + return; + } + onChange(t.faucetIdBech32); + setOpen(false); + }} + > + +
+ {t.symbol} + + {t.name} + +
+ {isUnavailable && ( + + Not available + + )} +
+ ); + })} +
+
+
+ ); +} diff --git a/src/components/TradingViewCandlesChart.tsx b/src/components/TradingViewCandlesChart.tsx new file mode 100644 index 0000000..25a58cf --- /dev/null +++ b/src/components/TradingViewCandlesChart.tsx @@ -0,0 +1,177 @@ +import { ThemeContext } from '@/providers/ThemeContext'; +import { + CandlestickSeries, + ColorType, + CrosshairMode, + HistogramSeries, + type IChartApi, + type ISeriesApi, + type UTCTimestamp, + createChart, +} from 'lightweight-charts'; +import { useContext, useEffect, useMemo, useRef } from 'react'; + +export type TradingViewCandle = { + time: UTCTimestamp; + open: number; + high: number; + low: number; + close: number; + volume?: number; +}; + +export function TradingViewCandlesChart({ + candles, + height = 256, + className, +}: { + candles: TradingViewCandle[]; + height?: number; + className?: string; +}) { + const { theme } = useContext(ThemeContext); + const rootRef = useRef(null); + const chartRef = useRef(null); + const candleSeriesRef = useRef | null>(null); + const volumeSeriesRef = useRef | null>(null); + + const isDark = useMemo(() => { + if (theme === 'dark') return true; + if (theme === 'light') return false; + return document.documentElement.classList.contains('dark'); + }, [theme]); + + useEffect(() => { + const el = rootRef.current; + if (!el) return; + + const bg = isDark ? '#131722' : '#ffffff'; + const fg = isDark ? '#d1d4dc' : '#1f2937'; + const grid = isDark ? 'rgba(42, 46, 57, 0.6)' : 'rgba(229, 231, 235, 0.8)'; + const border = isDark ? 'rgba(42, 46, 57, 0.9)' : 'rgba(229, 231, 235, 1)'; + + const chart = createChart(el, { + height, + layout: { + background: { type: ColorType.Solid, color: bg }, + textColor: fg, + fontFamily: 'ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial', + fontSize: 12, + }, + grid: { + vertLines: { color: grid }, + horzLines: { color: grid }, + }, + rightPriceScale: { + borderColor: border, + }, + timeScale: { + borderColor: border, + timeVisible: true, + secondsVisible: false, + }, + crosshair: { + mode: CrosshairMode.Magnet, + vertLine: { + color: isDark ? 'rgba(122, 146, 202, 0.45)' : 'rgba(37, 99, 235, 0.25)', + width: 1, + style: 0, + labelBackgroundColor: isDark ? '#1f2a44' : '#2563eb', + }, + horzLine: { + color: isDark ? 'rgba(122, 146, 202, 0.45)' : 'rgba(37, 99, 235, 0.25)', + width: 1, + style: 0, + labelBackgroundColor: isDark ? '#1f2a44' : '#2563eb', + }, + }, + handleScroll: true, + handleScale: true, + }); + + const candleSeries = chart.addSeries(CandlestickSeries, { + upColor: isDark ? '#22c55e' : '#16a34a', + downColor: isDark ? '#ef4444' : '#dc2626', + borderUpColor: isDark ? '#22c55e' : '#16a34a', + borderDownColor: isDark ? '#ef4444' : '#dc2626', + wickUpColor: isDark ? '#22c55e' : '#16a34a', + wickDownColor: isDark ? '#ef4444' : '#dc2626', + }); + + const volumeSeries = chart.addSeries(HistogramSeries, { + priceFormat: { type: 'volume' }, + priceScaleId: '', + color: isDark ? 'rgba(78, 140, 255, 0.35)' : 'rgba(37, 99, 235, 0.25)', + base: 0, + }); + + volumeSeries.priceScale().applyOptions({ + scaleMargins: { top: 0.8, bottom: 0 }, + }); + + chartRef.current = chart; + candleSeriesRef.current = candleSeries; + volumeSeriesRef.current = volumeSeries; + + const resize = () => { + if (!rootRef.current || !chartRef.current) return; + const w = rootRef.current.clientWidth; + chartRef.current.applyOptions({ width: w, height }); + }; + resize(); + + const ro = new ResizeObserver(() => resize()); + ro.observe(el); + + return () => { + ro.disconnect(); + chart.remove(); + chartRef.current = null; + candleSeriesRef.current = null; + volumeSeriesRef.current = null; + }; + }, [height, isDark]); + + useEffect(() => { + if (!candleSeriesRef.current || !volumeSeriesRef.current) return; + if (!candles?.length) { + candleSeriesRef.current.setData([]); + volumeSeriesRef.current.setData([]); + return; + } + + candleSeriesRef.current.setData( + candles.map(c => ({ + time: c.time, + open: c.open, + high: c.high, + low: c.low, + close: c.close, + })), + ); + + volumeSeriesRef.current.setData( + candles.map(c => { + const up = c.close >= c.open; + return { + time: c.time, + value: c.volume ?? 0, + color: up + ? (isDark ? 'rgba(34,197,94,0.35)' : 'rgba(22,163,74,0.25)') + : (isDark ? 'rgba(239,68,68,0.35)' : 'rgba(220,38,38,0.25)'), + }; + }), + ); + + chartRef.current?.timeScale().fitContent(); + }, [candles, isDark]); + + return ( +
+ ); +} + diff --git a/src/components/UnifiedWalletButton.tsx b/src/components/UnifiedWalletButton.tsx index 8fb3b78..64394ff 100644 --- a/src/components/UnifiedWalletButton.tsx +++ b/src/components/UnifiedWalletButton.tsx @@ -1,6 +1,6 @@ import { useClaimNotes } from '@/hooks/useClaimNotes'; import { useUnifiedWallet } from '@/hooks/useUnifiedWallet'; -import { truncateAddress } from '@/utils/format'; +import { truncateAddress } from '@/lib/format'; import { useWalletModal } from '@demox-labs/miden-wallet-adapter'; import { useModal } from '@getpara/react-sdk-lite'; import { ChevronDown, Download, Loader2, LogOut, Wallet } from 'lucide-react'; @@ -61,25 +61,25 @@ export function UnifiedWalletButton({ className }: UnifiedWalletButtonProps) {
{showDropdown && ( @@ -144,7 +144,7 @@ export function UnifiedWalletButton({ className }: UnifiedWalletButtonProps) { onClick={handleOpenSelectionModal} className={`flex items-center gap-2 p-3 rounded-xl font-medium text-sm text-muted-foreground border-none hover:text-foreground hover:bg-gray-500/10 dark:bg-muted/30 dark:hover:bg-muted/70 ${className}`} > - + Connect Wallet diff --git a/src/components/XykPairIcon.tsx b/src/components/XykPairIcon.tsx new file mode 100644 index 0000000..7175f7c --- /dev/null +++ b/src/components/XykPairIcon.tsx @@ -0,0 +1,23 @@ +import AssetIcon from '@/components/AssetIcon'; + +export interface XykPairIconProps { + symbolA: string; + symbolB: string; + size?: number; +} + +export function XykPairIcon({ symbolA, symbolB, size = 24 }: XykPairIconProps) { + return ( + + + + + + + + + ); +} diff --git a/src/components/XykPoolModal.tsx b/src/components/XykPoolModal.tsx new file mode 100644 index 0000000..3530f99 --- /dev/null +++ b/src/components/XykPoolModal.tsx @@ -0,0 +1,954 @@ +import AssetIcon from '@/components/AssetIcon'; +import { ProgressBar } from '@/components/ProgressBar'; +import Slippage from '@/components/Slippage'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { getMidenscanNoteUrl, getMidenscanTxUrl } from '@/hooks/useLaunchpad'; +import { useTokens } from '@/hooks/useTokens'; +import { useWaitForNoteConsumed } from '@/hooks/useWaitForNoteConsumed'; +import { useXykDeposit } from '@/hooks/useXykDeposit'; +import { useXykLpBalance } from '@/hooks/useXykLpBalance'; +import { useXykPool } from '@/hooks/useXykPool'; +import type { XykTokenInfo } from '@/hooks/useXykPool'; +import { useXykWithdraw } from '@/hooks/useXykWithdraw'; +import { DEFAULT_SLIPPAGE } from '@/lib/config'; +import { formatTokenAmount, formatTokenAmountForInput } from '@/lib/format'; +import { computeExpectedLp, computeExpectedWithdraw } from '@/lib/xykMath'; +import { ModalContext } from '@/providers/ModalContext'; +import type { TokenConfig } from '@/providers/ZoroProvider'; +import { AlertTriangle, ArrowRight, ExternalLink, Info, Loader, X } from 'lucide-react'; +import { useCallback, useContext, useEffect, useMemo, useState } from 'react'; +import { parseUnits } from 'viem'; +import { useBalance } from '../hooks/useBalance'; + +const LP_PROGRESS_STEPS = [ + 'Creating note', + 'Sending note', + 'Waiting for confirmation on network', +] as const; + +/** LP shares have no decimals (raw integer). */ +const LP_EXPO = 0; +const PERCENTAGES = [25, 50, 75, 100] as const; + +const validateValue = (val: bigint, max: bigint) => + val > max ? 'Amount too large' : val <= 0n ? 'Invalid value' : undefined; + +export type LpActionType = 'Deposit' | 'Withdraw'; + +export interface XykPoolModalProps { + poolId: string; + onSuccess?: () => void; + onClose?: () => void; + initialMode?: LpActionType; +} + +export function XykPoolModal({ + poolId, + onSuccess, + onClose, + initialMode = 'Deposit', +}: XykPoolModalProps) { + const modalContext = useContext(ModalContext); + const { data: poolData, isLoading: poolLoading } = useXykPool(poolId); + const { lpBalance, refetch: refetchLpBalance } = useXykLpBalance(poolId); + const faucetIds = useMemo( + () => + poolData + ? [ + poolData.token0.faucetIdBech32, + poolData.token1.faucetIdBech32, + ] + : [], + [poolData], + ); + const { tokens: tokensMetadata } = useTokens(faucetIds); + + const { + deposit, + isLoading: isDepositLoading, + error: depositError, + } = useXykDeposit(poolId); + const { + withdraw, + isLoading: isWithdrawLoading, + error: withdrawError, + } = useXykWithdraw(poolId); + + const xykTokenToConfig = useCallback((t: XykTokenInfo): TokenConfig => ({ + symbol: t.symbol, + name: t.name ?? t.symbol, + decimals: t.decimals, + faucetId: t.faucetId, + faucetIdBech32: t.faucetIdBech32, + oracleId: '', + }), []); + + const token0Config = useMemo( + () => + poolData + ? (tokensMetadata[poolData.token0.faucetIdBech32] + ?? xykTokenToConfig(poolData.token0)) + : undefined, + [poolData, tokensMetadata, xykTokenToConfig], + ); + const token1Config = useMemo( + () => + poolData + ? (tokensMetadata[poolData.token1.faucetIdBech32] + ?? xykTokenToConfig(poolData.token1)) + : undefined, + [poolData, tokensMetadata, xykTokenToConfig], + ); + + const { balance: balance0 } = useBalance({ token: token0Config }); + const { balance: balance1 } = useBalance({ token: token1Config }); + + const [mode, setMode] = useState(initialMode); + const [slippage, setSlippage] = useState(DEFAULT_SLIPPAGE); + const [amount0Str, setAmount0Str] = useState(''); + const [amount1Str, setAmount1Str] = useState(''); + const [lpAmountStr, setLpAmountStr] = useState(''); + const [inputError, setInputError] = useState(); + const [depositPct, setDepositPct] = useState(100); + const [withdrawPct, setWithdrawPct] = useState(100); + const [lpProgressStep, setLpProgressStep] = useState(null); + const [lastLpAction, setLastLpAction] = useState< + { + type: 'Deposit' | 'Withdraw'; + noteId: string; + txId: string | undefined; + amount0: bigint; + amount1: bigint; + token0Symbol: string; + token1Symbol: string; + token0Decimals: number; + token1Decimals: number; + lpAmount?: bigint; + } | null + >(null); + + const waitForNoteConsumed = useWaitForNoteConsumed({ timeoutMs: 60_000 }); + + useEffect(() => { + if (!isDepositLoading && !isWithdrawLoading) setLpProgressStep(null); + }, [isDepositLoading, isWithdrawLoading]); + + const clearForm = useCallback(() => { + setAmount0Str(''); + setAmount1Str(''); + setLpAmountStr(''); + setDepositPct(100); + setWithdrawPct(100); + setInputError(undefined); + }, []); + + const handleClose = useCallback(() => { + modalContext.closeModal(); + onClose?.(); + }, [modalContext, onClose]); + + const amount0 = useMemo(() => { + const s = typeof amount0Str === 'string' ? amount0Str.trim() : ''; + if (!poolData || !s) return 0n; + try { + return parseUnits(s, poolData.token0.decimals); + } catch { + return 0n; + } + }, [poolData, amount0Str]); + + const amount1 = useMemo(() => { + const s = typeof amount1Str === 'string' ? amount1Str.trim() : ''; + if (!poolData || !s) return 0n; + try { + return parseUnits(s, poolData.token1.decimals); + } catch { + return 0n; + } + }, [poolData, amount1Str]); + + const lpAmount = useMemo(() => { + const s = typeof lpAmountStr === 'string' ? lpAmountStr.trim() : ''; + if (!s) return 0n; + try { + return parseUnits(s, LP_EXPO); + } catch { + return 0n; + } + }, [lpAmountStr]); + + const expectedLp = useMemo(() => { + if (!poolData || (amount0 === 0n && amount1 === 0n)) return 0n; + return computeExpectedLp( + amount0, + amount1, + poolData.reserve0, + poolData.reserve1, + poolData.totalSupply, + ); + }, [poolData, amount0, amount1]); + + const minAmountOutDeposit = useMemo(() => { + if (expectedLp === 0n) return 1n; + const slippageMultiplier = BigInt(Math.round((100 - slippage) * 1e6)); + const min = (expectedLp * slippageMultiplier) / BigInt(1e8); + return min > 0n ? min : 1n; + }, [expectedLp, slippage]); + + const [expectedWithdraw0, expectedWithdraw1] = useMemo(() => { + if (!poolData || lpAmount === 0n || poolData.totalSupply === 0n) { + return [0n, 0n]; + } + return computeExpectedWithdraw( + poolData.totalSupply, + lpAmount, + poolData.reserve0, + poolData.reserve1, + ); + }, [poolData, lpAmount]); + + const minAmountOutWithdraw0 = useMemo(() => { + if (expectedWithdraw0 === 0n) return 1n; + const slippageMultiplier = BigInt(Math.round((100 - slippage) * 1e6)); + const min = (expectedWithdraw0 * slippageMultiplier) / BigInt(1e8); + return min > 0n ? min : 1n; + }, [expectedWithdraw0, slippage]); + + const minAmountOutWithdraw1 = useMemo(() => { + if (expectedWithdraw1 === 0n) return 1n; + const slippageMultiplier = BigInt(Math.round((100 - slippage) * 1e6)); + const min = (expectedWithdraw1 * slippageMultiplier) / BigInt(1e8); + return min > 0n ? min : 1n; + }, [expectedWithdraw1, slippage]); + + const poolSharePct = useMemo(() => { + if (!poolData || expectedLp === 0n) return null; + const newTotalLp = poolData.totalSupply + expectedLp; + if (newTotalLp === 0n) return null; + return (Number(expectedLp) / Number(newTotalLp)) * 100; + }, [poolData, expectedLp]); + + const poolShareDisplay = poolSharePct != null + ? poolSharePct < 0.01 + ? `${poolSharePct.toFixed(6)}%` + : `${poolSharePct.toFixed(2)}%` + : '—'; + + const withdrawPoolSharePct = useMemo(() => { + if (!poolData || lpAmount === 0n) return null; + const totalAfter = poolData.totalSupply - lpAmount; + if (totalAfter <= 0n) return null; + const userAfter = (lpBalance ?? 0n) >= lpAmount ? (lpBalance ?? 0n) - lpAmount : 0n; + const pct = (Number(userAfter) / Number(totalAfter)) * 100; + return Math.min(100, Math.max(0, pct)); + }, [poolData, lpAmount, lpBalance]); + + const withdrawPoolShareDisplay = withdrawPoolSharePct != null + ? withdrawPoolSharePct < 0.01 + ? `${withdrawPoolSharePct.toFixed(6)}%` + : `${withdrawPoolSharePct.toFixed(2)}%` + : '—'; + + const maxDepositA0 = useMemo(() => { + if (!poolData) return 0n; + const b0 = balance0 ?? 0n; + const b1 = balance1 ?? 0n; + const r0 = poolData.reserve0; + const r1 = poolData.reserve1; + if (r1 === 0n) return b0; + const fromB1 = (b1 * r0) / r1; + return b0 < fromB1 ? b0 : fromB1; + }, [poolData, balance0, balance1]); + + const setDepositPercentage = useCallback( + (percentage: number) => { + if (!poolData || maxDepositA0 === 0n) return; + const r0 = poolData.reserve0; + const r1 = poolData.reserve1; + const a0 = (maxDepositA0 * BigInt(percentage)) / 100n; + const a1 = (a0 * r1) / r0; + setAmount0Str( + formatTokenAmountForInput({ + value: a0, + expo: poolData.token0.decimals, + }), + ); + setAmount1Str( + formatTokenAmountForInput({ + value: a1, + expo: poolData.token1.decimals, + }), + ); + setDepositPct(percentage); + setInputError(undefined); + }, + [poolData, maxDepositA0], + ); + + const onDepositAmount0Change = useCallback( + (val: string) => { + const s = typeof val === 'string' ? val : ''; + setAmount0Str(s); + if (s === '') { + setAmount1Str(''); + setDepositPct(0); + setInputError(undefined); + return; + } + try { + const parsed = parseUnits(s, poolData?.token0.decimals ?? 18); + const b0 = balance0 ?? 0n; + const err = validateValue(parsed, b0); + if (err) { + setInputError(err); + return; + } + setInputError(undefined); + if (maxDepositA0 > 0n) { + const pct = Number((parsed * 100n) / maxDepositA0); + setDepositPct(Math.min(100, Math.max(0, pct))); + } + if (poolData && poolData.reserve0 > 0n && parsed > 0n) { + const a1 = (parsed * poolData.reserve1) / poolData.reserve0; + setAmount1Str( + formatTokenAmountForInput({ + value: a1, + expo: poolData.token1.decimals, + }), + ); + } + } catch { + setInputError('Invalid value'); + } + }, + [poolData, balance0, maxDepositA0], + ); + + const onDepositAmount1Change = useCallback( + (val: string) => { + const s = typeof val === 'string' ? val : ''; + setAmount1Str(s); + if (s === '') { + setAmount0Str(''); + setDepositPct(0); + setInputError(undefined); + return; + } + try { + const parsed = parseUnits(s, poolData?.token1.decimals ?? 18); + const b1 = balance1 ?? 0n; + const err = validateValue(parsed, b1); + if (err) { + setInputError(err); + return; + } + setInputError(undefined); + if (poolData && poolData.reserve1 > 0n) { + const maxA1 = (maxDepositA0 * poolData.reserve1) / poolData.reserve0; + if (maxA1 > 0n) { + const pct = Number((parsed * 100n) / maxA1); + setDepositPct(Math.min(100, Math.max(0, pct))); + } + } + if (poolData && poolData.reserve1 > 0n && parsed > 0n) { + const a0 = (parsed * poolData.reserve0) / poolData.reserve1; + setAmount0Str( + formatTokenAmountForInput({ + value: a0, + expo: poolData.token0.decimals, + }), + ); + } + } catch { + setInputError('Invalid value'); + } + }, + [poolData, balance1, maxDepositA0], + ); + + const setWithdrawPercentage = useCallback( + (percentage: number) => { + const bal = lpBalance ?? 0n; + const newValue = (bal * BigInt(percentage)) / 100n; + setLpAmountStr( + formatTokenAmountForInput({ value: newValue, expo: LP_EXPO }), + ); + setWithdrawPct(percentage); + setInputError(undefined); + }, + [lpBalance], + ); + + const onWithdrawInputChange = useCallback( + (val: string) => { + const s = typeof val === 'string' ? val : ''; + setLpAmountStr(s); + if (s === '') { + setWithdrawPct(0); + setInputError(undefined); + return; + } + try { + const parsed = parseUnits(s, LP_EXPO); + const bal = lpBalance ?? 0n; + const err = validateValue(parsed, bal); + if (err) setInputError(err); + else { + setInputError(undefined); + if (bal > 0n) { + const pct = Number((parsed * 100n) / bal); + setWithdrawPct(Math.min(100, Math.max(0, pct))); + } + } + } catch { + setInputError('Invalid value'); + } + }, + [lpBalance], + ); + + const writeDeposit = useCallback(async () => { + if (!poolData) return; + const b0 = balance0 ?? 0n; + const b1 = balance1 ?? 0n; + if (amount0 > b0 || amount1 > b1) { + setInputError('Insufficient balance'); + return; + } + if (amount0 <= 0n && amount1 <= 0n) { + setInputError('Enter amounts'); + return; + } + setInputError(undefined); + setLpProgressStep(null); + const result = await deposit(amount0, amount1, { + onProgress: (step) => setLpProgressStep(step), + waitForNoteConsumed, + }); + if (result) { + clearForm(); + refetchLpBalance(); + onSuccess?.(); + setLastLpAction({ + type: 'Deposit', + noteId: result.noteId, + txId: result.txId, + amount0, + amount1, + token0Symbol: poolData.token0.symbol, + token1Symbol: poolData.token1.symbol, + token0Decimals: poolData.token0.decimals, + token1Decimals: poolData.token1.decimals, + }); + } + }, [ + poolData, + amount0, + amount1, + balance0, + balance1, + deposit, + clearForm, + refetchLpBalance, + onSuccess, + waitForNoteConsumed, + ]); + + const writeWithdraw = useCallback(async () => { + if (!poolData) return; + const bal = lpBalance ?? 0n; + if (lpAmount > bal) { + setInputError('Insufficient LP balance'); + return; + } + if (lpAmount <= 0n) { + setInputError('Enter amount'); + return; + } + setInputError(undefined); + setLpProgressStep(null); + const result = await withdraw(lpAmount, { + onProgress: (step) => setLpProgressStep(step), + waitForNoteConsumed, + }); + if (result) { + clearForm(); + refetchLpBalance(); + onSuccess?.(); + setLastLpAction({ + type: 'Withdraw', + noteId: result.noteId, + txId: result.txId, + amount0: expectedWithdraw0, + amount1: expectedWithdraw1, + token0Symbol: poolData.token0.symbol, + token1Symbol: poolData.token1.symbol, + token0Decimals: poolData.token0.decimals, + token1Decimals: poolData.token1.decimals, + lpAmount, + }); + } + }, [ + poolData, + lpAmount, + lpBalance, + expectedWithdraw0, + expectedWithdraw1, + withdraw, + clearForm, + refetchLpBalance, + onSuccess, + waitForNoteConsumed, + ]); + + if (poolLoading || !poolData) { + return ( +
+

Loading pool…

+
+ ); + } + + const pairLabel = `${poolData.token0.symbol} / ${poolData.token1.symbol}`; + const expectedLpFormatted = formatTokenAmount({ + value: expectedLp, + expo: LP_EXPO, + }); + const minLpFormatted = formatTokenAmount({ + value: minAmountOutDeposit, + expo: LP_EXPO, + }) ?? '0'; + const minWithdraw0Formatted = formatTokenAmount({ + value: minAmountOutWithdraw0, + expo: poolData.token0.decimals, + }) ?? '0'; + const minWithdraw1Formatted = formatTokenAmount({ + value: minAmountOutWithdraw1, + expo: poolData.token1.decimals, + }) ?? '0'; + const withdrawReceiveFormatted = formatTokenAmount({ value: lpAmount, expo: LP_EXPO }) + ?? '0'; + + return ( +
+
+
+
+ + + + + + +
+ + {mode === 'Withdraw' ? `Withdraw from ${pairLabel}` : pairLabel} + +
+ +
+ +
+ + +
+ + {mode === 'Deposit' && ( + <> +
+

+ Deposit amounts +

+ +
+
+
+

+ {poolData.token0.symbol} +

+
+ onDepositAmount0Change(e.target.value)} + /> +
+ + Balance: {formatTokenAmount({ + value: balance0 ?? 0n, + expo: poolData.token0.decimals, + })} {poolData.token0.symbol} + + + + +
+
+
+
+

+ {poolData.token1.symbol} +

+
+ onDepositAmount1Change(e.target.value)} + /> +
+ + Balance: {formatTokenAmount({ + value: balance1 ?? 0n, + expo: poolData.token1.decimals, + })} {poolData.token1.symbol} + + + + +
+
+
+
+ {PERCENTAGES.map((n) => ( + + ))} +
+
+ Deposit percentage + {depositPct}% +
+
+
+
+
+
+

+ You receive (min) +

+
+
+ LP +
+ {minLpFormatted} +
+ {expectedLpFormatted != null + && expectedLpFormatted !== minLpFormatted && ( +

+ Expected: {expectedLpFormatted} +

+ )} +
+ {inputError &&

{inputError}

} +
+ +
+ Pool Share + {poolShareDisplay} +
+
+ + + )} + + {mode === 'Withdraw' && ( + <> +
+

+ Withdraw amount +

+ +
+
+
+

Amount

+
+ onWithdrawInputChange(e.target.value)} + /> +
+ + Balance: {formatTokenAmount({ + value: lpBalance ?? 0n, + expo: LP_EXPO, + })} LP + + + + +
+
+
+ {PERCENTAGES.map((n) => ( + + ))} +
+
+
+ Withdraw percentage + {withdrawPct}% +
+
+
+
+
+
+

+ You receive (min) +

+

+ LP: {withdrawReceiveFormatted} +

+
+
+ + {poolData.token0.symbol} +
+ + {formatTokenAmount({ + value: expectedWithdraw0, + expo: poolData.token0.decimals, + })} (min: {minWithdraw0Formatted}) + +
+
+
+ + {poolData.token1.symbol} +
+ + {formatTokenAmount({ + value: expectedWithdraw1, + expo: poolData.token1.decimals, + })} (min: {minWithdraw1Formatted}) + +
+
+
+ +
+ + Remaining pool share + + {withdrawPoolShareDisplay} +
+
+
+ +
+

+ Impermanent Loss Notice +

+

+ Withdrawing now realizes any impermanent loss or gain. Your position may + have experienced IL since deposit. If you deposited at a different price + ratio, you may receive fewer tokens than expected. +

+
+
+ {inputError &&

{inputError}

} + + + )} + + {(isDepositLoading || isWithdrawLoading) && lpProgressStep !== null && ( + + )} + {lastLpAction && ( +
+
+ + Last {lastLpAction.type.toLowerCase()} + +
+
+
+ {lastLpAction.type === 'Deposit' + ? ( + <> +
+ + + {formatTokenAmount({ + value: lastLpAction.amount0, + expo: lastLpAction.token0Decimals, + })} + + + {lastLpAction.token0Symbol} + +
+ +
+ + + {formatTokenAmount({ + value: lastLpAction.amount1, + expo: lastLpAction.token1Decimals, + })} + + + {lastLpAction.token1Symbol} + +
+ + ) + : ( + <> +
+ + {lastLpAction.lpAmount != null + ? formatTokenAmount({ value: lastLpAction.lpAmount, expo: 0 }) + : '—'} + + LP +
+ +
+ + + {formatTokenAmount({ + value: lastLpAction.amount0, + expo: lastLpAction.token0Decimals, + })} + + + {lastLpAction.token0Symbol} + +
+ + +
+ + + {formatTokenAmount({ + value: lastLpAction.amount1, + expo: lastLpAction.token1Decimals, + })} + + + {lastLpAction.token1Symbol} + +
+ + )} +
+ +
+
+ )} + + {(depositError || withdrawError) && ( +

+ {depositError ?? withdrawError} +

+ )} +
+ ); +} diff --git a/src/components/XykTable/XykPoolTable.tsx b/src/components/XykTable/XykPoolTable.tsx new file mode 100644 index 0000000..63eefc7 --- /dev/null +++ b/src/components/XykTable/XykPoolTable.tsx @@ -0,0 +1,136 @@ +import { useXykPools } from '@/hooks/useXykPools'; +import { accountIdToBech32 } from '@/lib/utils'; +import { ChevronLeft, ChevronRight, Droplets } from 'lucide-react'; +import { useEffect, useMemo, useState } from 'react'; +import { Button } from '../ui/button'; +import { Card } from '../ui/card'; +import XykPoolTableRow from './XykPoolTableRow'; + +const PAGE_SIZE = 10; + +export interface XykPoolTableProps { + search?: string; +} + +const normalize = (s: string) => s.trim().toLowerCase(); + +const XykPoolTable = ({ search }: XykPoolTableProps) => { + const { xykPools } = useXykPools(); + const [page, setPage] = useState(0); + + const filteredPools = useMemo(() => { + const q = normalize(search ?? ''); + if (!q) return xykPools; + const qNo0x = q.startsWith('0x') ? q.slice(2) : q; + return xykPools.filter((p) => { + const bech = accountIdToBech32(p.xykPoolId).toLowerCase(); + const hex = ((p.xykPoolId as unknown as { toHex?: () => string }).toHex?.() ?? '') + .toLowerCase(); + const hexNo0x = hex.startsWith('0x') ? hex.slice(2) : hex; + return bech.includes(q) || hex.includes(q) || hexNo0x.includes(qNo0x); + }); + }, [search, xykPools]); + + useEffect(() => { + // eslint-disable-next-line react-hooks/set-state-in-effect + setPage(0); + }, [search]); + + const totalPages = useMemo( + () => Math.max(1, Math.ceil(filteredPools.length / PAGE_SIZE)), + [filteredPools.length], + ); + const paginatedPools = useMemo(() => { + const start = page * PAGE_SIZE; + return filteredPools.slice(start, start + PAGE_SIZE); + }, [filteredPools, page]); + + const isEmpty = filteredPools.length === 0; + const canPrev = page > 0; + const canNext = page < totalPages - 1; + + return ( +
+ +
+ + + + + + + + + + + + + {isEmpty + ? ( + + + + ) + : ( + paginatedPools.map(pool => ( + + )) + )} + +
PoolTVLPriceAPR1D VOL7D VOL
+
+
+ +
+

+ No XYK pools on registry +

+

+ XYK pools registered on this chain will appear here. +

+
+
+
+ + {!isEmpty && totalPages > 1 && ( +
+ + {page * PAGE_SIZE + 1}–{Math.min( + (page + 1) * PAGE_SIZE, + filteredPools.length, + )} of {filteredPools.length} + +
+ + + {page + 1} / {totalPages} + + +
+
+ )} +
+
+ ); +}; + +export default XykPoolTable; diff --git a/src/components/XykTable/XykPoolTableRow.tsx b/src/components/XykTable/XykPoolTableRow.tsx new file mode 100644 index 0000000..e7fa33d --- /dev/null +++ b/src/components/XykTable/XykPoolTableRow.tsx @@ -0,0 +1,169 @@ +import { useXykPool } from '@/hooks/useXykPool'; +import type { XykPool } from '@/hooks/useXykPools'; +import { prettyBigintFormat } from '@/lib/format'; +import { accountIdToBech32 } from '@/lib/utils'; +import { Loader2 } from 'lucide-react'; +import { useEffect, useMemo, useState } from 'react'; +import { useNavigate } from 'react-router-dom'; +import AssetIcon from '../AssetIcon'; +import { Skeleton } from '../ui/skeleton'; + +const truncateId = (bech32: string, head = 6, tail = 4) => + bech32.length <= head + tail + ? bech32 + : `${bech32.slice(0, head)}…${bech32.slice(-tail)}`; + +export interface XykPoolTableRowProps { + pool: XykPool; + /** Token0 symbol if known (e.g. from config); otherwise fallback to truncated id */ + token0Symbol?: string; + /** Token1 symbol if known */ + token1Symbol?: string; +} + +const XykPoolTableRow = ({ pool, token0Symbol, token1Symbol }: XykPoolTableRowProps) => { + const navigate = useNavigate(); + const poolIdBech32 = useMemo(() => accountIdToBech32(pool.xykPoolId), [pool.xykPoolId]); + const { data: poolData, isLoading } = useXykPool(poolIdBech32); + const [hasLoadedOnce, setHasLoadedOnce] = useState(false); + + useEffect(() => { + if (!isLoading && !hasLoadedOnce) { + setHasLoadedOnce(true); + } + }, [isLoading, hasLoadedOnce]); + + const fallbackId0 = accountIdToBech32(pool.token0); + const fallbackId1 = accountIdToBech32(pool.token1); + + const sym0 = poolData?.token0.symbol ?? token0Symbol; + const sym1 = poolData?.token1.symbol ?? token1Symbol; + const label0 = sym0 ?? truncateId(fallbackId0); + const label1 = sym1 ?? truncateId(fallbackId1); + const pairLabel = `${label0} / ${label1}`; + + const reserve0Str = poolData + ? prettyBigintFormat({ value: poolData.reserve0, expo: poolData.token0.decimals }) + : '—'; + const reserve1Str = poolData + ? prettyBigintFormat({ value: poolData.reserve1, expo: poolData.token1.decimals }) + : '—'; + + const priceDisplay = poolData && poolData.priceToken0InToken1 > 0 + ? `1 ${poolData.token0.symbol} = ${ + poolData.priceToken0InToken1.toFixed(6) + } ${poolData.token1.symbol}` + : '—'; + + const onOpen = () => { + navigate(`/pools/xyk/${encodeURIComponent(poolIdBech32)}`); + }; + + const showInitialSkeleton = !hasLoadedOnce && isLoading && !poolData; + if (showInitialSkeleton) return ; + + return ( + { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + onOpen(); + } + }} + > + +
+
+ + + + + + +
+
+ {pairLabel} + {isLoading && ( + + + + )} +
+
+ + + {isLoading && !poolData + ? + : ( + <> + {reserve0Str} + {sym0 + ? {sym0} + : null} + / + {reserve1Str} + {sym1 + ? {sym1} + : null} + + )} + + + {isLoading && !poolData + ? + : {priceDisplay}} + + + {isLoading && !poolData ? : '—'} + + + {isLoading && !poolData + ? + : '—'} + + + {isLoading && !poolData + ? + : '—'} + + + ); +}; + +export const XykPoolTableRowSkeleton = () => ( + + +
+
+ + +
+
+ + +
+
+ + + + + + + + + + + + + + + + + +); + +export default XykPoolTableRow; diff --git a/src/components/ui/skeleton.tsx b/src/components/ui/skeleton.tsx index d7e45f7..86ca9ae 100644 --- a/src/components/ui/skeleton.tsx +++ b/src/components/ui/skeleton.tsx @@ -6,7 +6,7 @@ function Skeleton({ }: React.HTMLAttributes) { return (
) diff --git a/src/components/xyk-wizard/TokenInput.tsx b/src/components/xyk-wizard/TokenInput.tsx new file mode 100644 index 0000000..da03302 --- /dev/null +++ b/src/components/xyk-wizard/TokenInput.tsx @@ -0,0 +1,70 @@ +import { Input } from '@/components/ui/input'; + +export interface TokenInputProps { + value: string; + onChange: (value: string) => void; + symbol: string; + balanceText: string; + onMaxClick: () => void; + placeholder?: string; + disabled?: boolean; + /** Shown when value is out of range (e.g. exceeds balance). Empty input is not coerced to 0. */ + error?: string; + /** Rendered under the input row, aligned left (e.g. 25%, 50%, 75%, 100% buttons). */ + bottomLeft?: React.ReactNode; +} + +export function TokenInput({ + value, + onChange, + symbol, + balanceText, + onMaxClick, + placeholder = '0', + disabled = false, + error, + bottomLeft, +}: TokenInputProps) { + const letter = (symbol || '?')[0].toUpperCase(); + + return ( +
+
+ onChange(e.target.value)} + disabled={disabled} + className='flex-1 min-w-0 border-0 bg-transparent p-0 shadow-none focus-visible:ring-0 h-auto text-[50px]' + /> +
+ + {letter} + + {symbol} +
+
+
+
+ {bottomLeft} +
+
+ + {error && ( + + {error} + + )} +
+
+
+ ); +} diff --git a/src/components/xyk-wizard/XykWizard.tsx b/src/components/xyk-wizard/XykWizard.tsx new file mode 100644 index 0000000..01e2d90 --- /dev/null +++ b/src/components/xyk-wizard/XykWizard.tsx @@ -0,0 +1,534 @@ +import { ProgressBar } from '@/components/ProgressBar'; +import { Button } from '@/components/ui/button'; +import { Card, CardContent } from '@/components/ui/card'; +import { UnifiedWalletButton } from '@/components/UnifiedWalletButton'; +import useTokensWithBalance from '@/hooks/useTokensWithBalance'; +import { useUnifiedWallet } from '@/hooks/useUnifiedWallet'; +import { useXykPools } from '@/hooks/useXykPools'; +import { deployNewPool, registerPool } from '@/lib/DeployXykPool'; +import { accountIdToBech32 } from '@/lib/utils'; +import { compileXykDepositTransaction } from '@/lib/XykDepositNote'; +import { type TokenConfigWithBalance, ZoroContext } from '@/providers/ZoroContext'; +import { type TokenConfig } from '@/providers/ZoroProvider'; +import { TransactionType } from '@demox-labs/miden-wallet-adapter'; +import { AccountId } from '@miden-sdk/miden-sdk'; +import { AlertCircle, ChevronLeft, Loader2 } from 'lucide-react'; +import { useCallback, useContext, useEffect, useMemo, useState } from 'react'; +import { Link } from 'react-router-dom'; +import { orderPairByHex } from './steps/XykWizardStep1'; +import XykStep1 from './steps/XykWizardStep1'; +import XykStep2 from './steps/XykWizardStep2'; +import XykStep3 from './steps/XykWizardStep3'; +import XykStep4 from './steps/XykWizardStep4'; + +export const XYK_WIZARD_STORAGE_KEY = 'zoro-xyk-wizard'; + +type PersistedForm = { + tokenABech32?: string; + tokenBBech32?: string; + amountA?: string; + amountB?: string; + feeBps?: number; +}; + +function readPersistedWizard(): { + step: number; + form: XykWizardForm; +} { + const defaultForm: XykWizardForm = { + amountA: BigInt(0), + amountB: BigInt(0), + feeBps: 30, + }; + try { + const raw = localStorage.getItem(XYK_WIZARD_STORAGE_KEY); + if (!raw) return { step: 0, form: defaultForm }; + const parsed = JSON.parse(raw) as { step?: number; form?: PersistedForm }; + const step = typeof parsed.step === 'number' && parsed.step >= 0 && parsed.step <= 3 + ? parsed.step + : 0; + const f = parsed.form ?? {}; + const form: XykWizardForm = { + amountA: BigInt(0), + amountB: BigInt(0), + }; + if (typeof f.tokenABech32 === 'string' && f.tokenABech32) { + try { + form.tokenA = AccountId.fromBech32(f.tokenABech32); + } catch { + // ignore invalid + } + } + if (typeof f.tokenBBech32 === 'string' && f.tokenBBech32) { + try { + form.tokenB = AccountId.fromBech32(f.tokenBBech32); + } catch { + // ignore invalid + } + } + if (typeof f.amountA === 'string') { + try { + form.amountA = BigInt(f.amountA); + } catch { + // ignore + } + } + if (typeof f.amountB === 'string') { + try { + form.amountB = BigInt(f.amountB); + } catch { + // ignore + } + } + form.feeBps = 30; + return { step, form }; + } catch { + return { step: 0, form: defaultForm }; + } +} + +function writePersistedWizard(step: number, form: XykWizardForm) { + try { + const persisted: PersistedForm = {}; + if (form.tokenA != null) persisted.tokenABech32 = accountIdToBech32(form.tokenA); + if (form.tokenB != null) persisted.tokenBBech32 = accountIdToBech32(form.tokenB); + if (form.amountA != null) persisted.amountA = String(form.amountA); + if (form.amountB != null) persisted.amountB = String(form.amountB); + if (form.feeBps != null) persisted.feeBps = form.feeBps; + localStorage.setItem( + XYK_WIZARD_STORAGE_KEY, + JSON.stringify({ step, form: persisted }), + ); + } catch { + // ignore + } +} + +function clearPersistedWizard() { + try { + localStorage.removeItem(XYK_WIZARD_STORAGE_KEY); + } catch { + // ignore + } +} + +const wizardSteps = [XykStep1, XykStep2, XykStep3, XykStep4]; + +export const XYK_CREATE_STEPS = [ + 'Deploying pool', + 'Adding liquidity', + 'Registering with central registry', + 'Finalizing', +] as const; + +export { XykPairIcon } from '@/components/XykPairIcon'; + +export interface XykWizardForm { + tokenA?: AccountId; + tokenB?: AccountId; + amountA?: bigint; + amountB?: bigint; + feeBps?: number; +} + +export interface XykStepProps { + tokensWithBalance: TokenConfigWithBalance[]; + tokenMetadata: Record; + loading: boolean; + form: XykWizardForm; + setForm: (newForm: XykWizardForm) => void; + restart: () => void; + /** Set after successful deploy; used by step 4 for "View pool" link. */ + lastDeployedPoolIdBech32?: string; + /** bech32 IDs of hfAMM tokens — pairing two of these is forbidden. */ + hfAmmBech32s?: ReadonlySet; + /** Set of "tokenA|tokenB" keys (both orderings) for existing XYK pool pairs. */ + registeredPairs?: ReadonlySet; + /** Validation error for the current token pair selection. */ + pairError?: string; +} + +const XykWizard = () => { + const { connected, requestTransaction } = useUnifiedWallet(); + const { client, accountId, tokens: hfAmmTokens } = useContext(ZoroContext); + const [form, setForm] = useState(() => readPersistedWizard().form); + const [step, setStep] = useState(() => readPersistedWizard().step); + const [lastDeployedPoolIdBech32, setLastDeployedPoolIdBech32] = useState< + string | undefined + >(undefined); + const tokensWithBalance = useTokensWithBalance(); + const { xykPools } = useXykPools(); + + const hfAmmBech32s = useMemo(() => { + const s = new Set(); + for (const t of Object.values(hfAmmTokens)) { + if (t.oracleId && t.oracleId !== '0x' && t.oracleId !== '') { + s.add(t.faucetIdBech32); + } + } + return s; + }, [hfAmmTokens]); + + const registeredPairs = useMemo(() => { + const s = new Set(); + for (const pool of xykPools) { + const t0 = accountIdToBech32(pool.token0); + const t1 = accountIdToBech32(pool.token1); + s.add(`${t0}|${t1}`); + s.add(`${t1}|${t0}`); + } + return s; + }, [xykPools]); + + const pairError = useMemo(() => { + if (!form.tokenA || !form.tokenB) return undefined; + const aBech = accountIdToBech32(form.tokenA); + const bBech = accountIdToBech32(form.tokenB); + if (hfAmmBech32s.has(aBech) && hfAmmBech32s.has(bBech)) { + return 'Two hfAMM tokens cannot be paired together. Use one hfAMM token with one non-hfAMM token.'; + } + if (registeredPairs.has(`${aBech}|${bBech}`)) { + return 'This pair already exists in the registry. You cannot create a duplicate pool.'; + } + return undefined; + }, [form.tokenA, form.tokenB, hfAmmBech32s, registeredPairs]); + + useEffect(() => { + writePersistedWizard(step, form); + }, [step, form]); + + useEffect(() => { + if (step !== 2) setCreateError(null); + }, [step]); + + // Never show step 4 (success) unless we have a deployed pool id (e.g. after refresh we might have step 3 but no pool id). + useEffect(() => { + if (step === 3 && !lastDeployedPoolIdBech32) { + setStep(2); + } + }, [step, lastDeployedPoolIdBech32]); + + const canContinueWizard = useMemo(() => { + switch (step) { + case 0: + return form.tokenA != null && form.tokenB != null && form.tokenA != form.tokenB + && form.feeBps != null && form.feeBps > 0 && !pairError; + case 1: + return form.amountA != null && form.amountA > BigInt(0) && form.amountB != null + && form.amountB > BigInt(0); + case 2: + return true; + default: + return false; + } + }, [step, form, pairError]); + + const canGoBackInWizard = useMemo(() => step > 0 && step < wizardSteps.length - 1, [ + step, + ]); + + const next = useCallback(() => { + if (canContinueWizard) { + setStep(Math.min(Math.max(step + 1, 0), wizardSteps.length - 1)); + } + }, [canContinueWizard, step]); + + const back = useCallback(() => { + if (canGoBackInWizard) { + setStep(Math.min(Math.max(step - 1, 0), wizardSteps.length - 1)); + } + }, [canGoBackInWizard, step]); + + const [isCreating, setIsCreating] = useState(false); + const [createStep, setCreateStep] = useState(null); + const [createError, setCreateError] = useState(null); + + const launchXykPool = useCallback( + async ( + { token0, token1, amount0, amount1 }: { + token0: AccountId; + token1: AccountId; + amount0: bigint; + amount1: bigint; + }, + options?: { onProgress?: (step: number) => void }, + ): Promise => { + const onProgress = options?.onProgress; + try { + if (!client) { + throw new Error('Client not initialized'); + } + if (!accountId) { + throw new Error('User not logged in'); + } + + onProgress?.(0); + const { newPoolId } = await deployNewPool({ + client, + token0, + token1, + }); + + onProgress?.(1); + const { tx } = await compileXykDepositTransaction({ + token0, + token1, + amount0, + amount1, + userAccountId: accountId, + poolAccountId: newPoolId, + client, + }); + + await requestTransaction({ + type: TransactionType.Custom, + payload: tx, + }); + onProgress?.(2); + + await registerPool({ + token0, + token1, + pool_acc: newPoolId, + requestTransaction, + client, + sender: accountId, + }); + await client.syncState(); + await new Promise(r => setTimeout(r, 5000)); + onProgress?.(3); + + return newPoolId; + } catch (e) { + console.error(e); + throw e; + } + }, + [client, requestTransaction, accountId], + ); + + const onCreate = useCallback(async () => { + if ( + form.amountA == null || form.amountB == null || form.tokenA == null + || form.tokenB == null || form.feeBps == null + ) { + return; + } + setCreateError(null); + setIsCreating(true); + setCreateStep(null); + const [token0, token1] = orderPairByHex(form.tokenA, form.tokenB); + const amount0 = form.tokenA.toString() === token0.toString() + ? form.amountA + : form.amountB; + const amount1 = form.tokenA.toString() === token0.toString() + ? form.amountB + : form.amountA; + try { + const newPoolId = await launchXykPool( + { + token0, + token1, + amount0, + amount1, + }, + { onProgress: (s) => setCreateStep(s) }, + ); + + if (newPoolId == null) { + setCreateError('Pool creation failed. Please try again.'); + return; + } + const poolIdBech32 = accountIdToBech32(newPoolId); + setLastDeployedPoolIdBech32(poolIdBech32); + setStep(3); + clearPersistedWizard(); + } catch (err) { + const message = err instanceof Error + ? err.message + : 'Pool creation failed. Please try again.'; + setCreateError(message); + } finally { + setIsCreating(false); + setCreateStep(null); + } + }, [form, launchXykPool]); + + const stepTitle = step === 0 + ? 'Create a new Liquidity Pool' + : step === 1 + ? 'Initial liquidity' + : step === 2 + ? 'Confirm your details' + : 'Congratulations'; + + const restart = useCallback(() => { + clearPersistedWizard(); + setStep(0); + setForm(readPersistedWizard().form); + }, []); + + const activeStep = useMemo(() => { + const Step = wizardSteps[step]; + return ( + + ); + }, [ + step, + form, + tokensWithBalance, + restart, + lastDeployedPoolIdBech32, + hfAmmBech32s, + registeredPairs, + pairError, + ]); + + if (!connected) { + return ( + + +

+ Connect your wallet to create a new liquidity pool. +

+ +
+
+ ); + } + + if (tokensWithBalance.loading) { + return ( + + +

Loading your tokens…

+
+
+ ); + } + + if (tokensWithBalance.tokensWithBalance.length === 0) { + return ( + + +

+ Missing tokens to launch a new pool +

+

+ You need tokens to create a liquidity pool. Launch your token on the + launchpad, or get tokens from the faucet, then come back here to create a + pool. +

+ +
+
+ ); + } + + return ( +
+
+ {step !== wizardSteps.length - 1 + && ( + + )} +
+ {step < 3 && ( + + Step {step + 1} of 3 + + )} +

+ {stepTitle} +

+ {step !== wizardSteps.length - 1 && ( +
+
+ {step > 0 + ?
+ :
} + {step > 1 + ?
+ :
} +
+ )} +
+
+
+ {activeStep} +
+
+ {step !== 2 && step < wizardSteps.length - 1 + && ( + + )} + {step === 2 && ( + <> + {isCreating && createStep !== null && ( +
+ +
+ )} + + {createError && ( +
+ + {createError} +
+ )} + + )} +
+
+ ); +}; + +export default XykWizard; diff --git a/src/components/xyk-wizard/steps/XykWizardStep1.tsx b/src/components/xyk-wizard/steps/XykWizardStep1.tsx new file mode 100644 index 0000000..44c53d9 --- /dev/null +++ b/src/components/xyk-wizard/steps/XykWizardStep1.tsx @@ -0,0 +1,263 @@ +import { TokenAutocomplete } from '@/components/TokenAutocomplete'; +import { accountIdToBech32, cn } from '@/lib/utils'; +import { AccountId } from '@miden-sdk/miden-sdk'; +import { AlertCircle, ArrowRight, Info } from 'lucide-react'; +import { useCallback, useMemo } from 'react'; +import type { XykStepProps } from '../XykWizard'; + +/** Order two account IDs by hex: lower hex = token0 (base), higher = token1 (quote). */ +export function orderPairByHex(a: AccountId, b: AccountId): [AccountId, AccountId] { + const hexA = a.toString(); + const hexB = b.toString(); + return hexA <= hexB ? [a, b] : [b, a]; +} + +const FEE_TIERS = [ + { bps: 1, label: '0.01%', hint: 'Best for stable pairs' }, + { bps: 5, label: '0.05%', hint: 'Best for stable pairs' }, + { bps: 30, label: '0.30%', hint: 'Best for most pairs' }, + { bps: 100, label: '1.00%', hint: 'Best for exotic pairs' }, +] as const; + +const XykStep1 = ( + { + tokensWithBalance, + tokenMetadata, + form, + setForm, + loading, + hfAmmBech32s, + registeredPairs, + pairError, + }: XykStepProps, +) => { + const availableTokens = useMemo(() => { + return Object.values(tokenMetadata ?? {}); + }, [tokenMetadata]); + + // For B: disable tokens that would form a duplicate pair with A, + // or other hfAMM tokens when A is hfAMM. + const disabledForB = useMemo(() => { + const disabled = new Set(); + if (!form.tokenA) return disabled; + const aBech = accountIdToBech32(form.tokenA); + if (hfAmmBech32s?.has(aBech)) { + for (const b of hfAmmBech32s) { + if (b !== aBech) disabled.add(b); + } + } + if (registeredPairs) { + for (const t of availableTokens) { + if (registeredPairs.has(`${aBech}|${t.faucetIdBech32}`)) { + disabled.add(t.faucetIdBech32); + } + } + } + return disabled; + }, [form.tokenA, hfAmmBech32s, registeredPairs, availableTokens]); + + // For A: disable tokens that would form a duplicate pair with B, + // or other hfAMM tokens when B is hfAMM. + const disabledForA = useMemo(() => { + const disabled = new Set(); + if (!form.tokenB) return disabled; + const bBech = accountIdToBech32(form.tokenB); + if (hfAmmBech32s?.has(bBech)) { + for (const a of hfAmmBech32s) { + if (a !== bBech) disabled.add(a); + } + } + if (registeredPairs) { + for (const t of availableTokens) { + if (registeredPairs.has(`${t.faucetIdBech32}|${bBech}`)) { + disabled.add(t.faucetIdBech32); + } + } + } + return disabled; + }, [form.tokenB, hfAmmBech32s, registeredPairs, availableTokens]); + + const setToken = useCallback((which: 'a' | 'b', id: AccountId) => { + setForm({ ...form, ...(which === 'a' ? { tokenA: id } : { tokenB: id }) }); + }, [form, setForm]); + const setFeeBps = useCallback((feeBps: number) => { + setForm({ ...form, feeBps }); + }, [form, setForm]); + + const orderedPair = useMemo((): { + base: AccountId; + quote: AccountId; + baseMeta?: (typeof availableTokens)[number]; + quoteMeta?: (typeof availableTokens)[number]; + } | null => { + if (!form.tokenA || !form.tokenB || !tokenMetadata) return null; + const [base, quote] = orderPairByHex(form.tokenA, form.tokenB); + const baseBech = accountIdToBech32(base); + const quoteBech = accountIdToBech32(quote); + return { + base, + quote, + baseMeta: tokenMetadata[baseBech], + quoteMeta: tokenMetadata[quoteBech], + }; + }, [form.tokenA, form.tokenB, tokenMetadata]); + + return ( +
+ {/* Select pair */} +
+

Select pair

+

+ Choose the tokens you want to provide liquidity for. You can select tokens on + all supported networks. +

+ {loading + ?

Loading your tokens…

+ : tokensWithBalance.length === 0 + ? ( +

+ You have no token balance. Get tokens from the faucet first. +

+ ) + : ( + <> +
+
+ + setToken('a', AccountId.fromBech32(val))} + excludeFaucetIdBech32={form.tokenB + ? accountIdToBech32(form.tokenB) + : undefined} + disabledBech32s={disabledForA} + placeholder='Select a token' + className='w-full' + /> +
+ + + +
+ + setToken('b', AccountId.fromBech32(val))} + excludeFaucetIdBech32={form.tokenA + ? accountIdToBech32(form.tokenA) + : undefined} + disabledBech32s={disabledForB} + placeholder='Select a token' + className='w-full' + /> +
+
+ {pairError && ( +
+ + {pairError} +
+ )} + + )} +
+ +

+ Base and quote order (token0 / token1) is based on the tokens' faucet account + IDs. +

+
+ + {orderedPair && ( +
+ + Pool order + + + Base + {orderedPair.baseMeta?.symbol ?? '…'} + + + + Quote + {orderedPair.quoteMeta?.symbol ?? '…'} + +
+ )} +
+ + {/* Fee tier */} +
+

Fee tier

+

+ The amount earned providing liquidity. Choose an amount that suits your risk + tolerance and strategy. +

+
+ {FEE_TIERS.map(({ bps, label, hint }) => { + const is30 = bps === 30; + return ( + + ); + })} +
+

+ Constant product pools are fixed to 30BP fee for the time being. +

+
+
+ ); +}; + +export default XykStep1; diff --git a/src/components/xyk-wizard/steps/XykWizardStep2.tsx b/src/components/xyk-wizard/steps/XykWizardStep2.tsx new file mode 100644 index 0000000..e7382e2 --- /dev/null +++ b/src/components/xyk-wizard/steps/XykWizardStep2.tsx @@ -0,0 +1,228 @@ +import { fullNumberBigintFormat } from '@/lib/format'; +import { accountIdToBech32 } from '@/lib/utils'; +import { useCallback, useEffect, useMemo, useState } from 'react'; +import { formatUnits, parseUnits } from 'viem'; +import { TokenInput } from '../TokenInput'; +import { type XykStepProps } from '../XykWizard'; + +const PERCENTAGES = [25, 50, 75, 100] as const; + +const XykStep2 = ( + { tokensWithBalance, tokenMetadata, form, setForm }: XykStepProps, +) => { + const tokenA = useMemo(() => { + return tokenMetadata[form.tokenA ? accountIdToBech32(form.tokenA) : '']; + }, [form.tokenA, tokenMetadata]); + const tokenB = useMemo(() => { + return tokenMetadata[form.tokenB ? accountIdToBech32(form.tokenB) : '']; + }, [form.tokenB, tokenMetadata]); + + const [amountAStr, setAmountAStr] = useState(''); + const [amountBStr, setAmountBStr] = useState(''); + + useEffect(() => { + if (tokenA) { + setAmountAStr( + form.amountA != null ? formatUnits(form.amountA, tokenA.decimals) : '', + ); + } + }, [tokenA, form.amountA]); + useEffect(() => { + if (tokenB) { + setAmountBStr( + form.amountB != null ? formatUnits(form.amountB, tokenB.decimals) : '', + ); + } + }, [tokenB, form.amountB]); + + const setAmountA = useCallback( + (raw: string) => { + setAmountAStr(raw); + if (raw === '') { + setForm({ ...form, amountA: undefined }); + return; + } + if (tokenA) { + try { + const val = parseUnits(raw, tokenA.decimals); + setForm({ ...form, amountA: val }); + } catch { + setForm({ ...form, amountA: undefined }); + } + } + }, + [form, setForm, tokenA], + ); + const setAmountB = useCallback( + (raw: string) => { + setAmountBStr(raw); + if (raw === '') { + setForm({ ...form, amountB: undefined }); + return; + } + if (tokenB) { + try { + const val = parseUnits(raw, tokenB.decimals); + setForm({ ...form, amountB: val }); + } catch { + setForm({ ...form, amountB: undefined }); + } + } + }, + [form, setForm, tokenB], + ); + + const setMaxA = useCallback(() => { + if (tokenA) { + const token = tokensWithBalance.find( + ({ config }) => tokenA != null && config.faucetIdBech32 === tokenA.faucetIdBech32, + ); + const amount = token?.amount ?? BigInt(0); + setForm({ ...form, amountA: amount }); + setAmountAStr(formatUnits(amount, tokenA.decimals)); + } + }, [form, setForm, tokenA, tokensWithBalance]); + + const setMaxB = useCallback(() => { + if (tokenB) { + const token = tokensWithBalance.find( + ({ config }) => tokenB != null && config.faucetIdBech32 === tokenB.faucetIdBech32, + ); + const amount = token?.amount ?? BigInt(0); + setForm({ ...form, amountB: amount }); + setAmountBStr(formatUnits(amount, tokenB.decimals)); + } + }, [form, setForm, tokenB, tokensWithBalance]); + + const balanceABigint = useMemo(() => { + const token = tokensWithBalance.find( + ({ config }) => tokenA != null && config.faucetIdBech32 === tokenA.faucetIdBech32, + ); + return token?.amount ?? BigInt(0); + }, [tokenA, tokensWithBalance]); + const balanceBBigint = useMemo(() => { + const token = tokensWithBalance.find( + ({ config }) => tokenB != null && config.faucetIdBech32 === tokenB.faucetIdBech32, + ); + return token?.amount ?? BigInt(0); + }, [tokenB, tokensWithBalance]); + + const setPercentA = useCallback( + (pct: number) => { + if (!tokenA) return; + const amount = (balanceABigint * BigInt(pct)) / BigInt(100); + setForm({ ...form, amountA: amount }); + setAmountAStr(formatUnits(amount, tokenA.decimals)); + }, + [form, setForm, tokenA, balanceABigint], + ); + const setPercentB = useCallback( + (pct: number) => { + if (!tokenB) return; + const amount = (balanceBBigint * BigInt(pct)) / BigInt(100); + setForm({ ...form, amountB: amount }); + setAmountBStr(formatUnits(amount, tokenB.decimals)); + }, + [form, setForm, tokenB, balanceBBigint], + ); + + const formattedBalanceA = useMemo(() => { + const token = tokensWithBalance.find( + ({ config }) => tokenA != null && config.faucetIdBech32 === tokenA.faucetIdBech32, + ); + return fullNumberBigintFormat({ + value: token?.amount || BigInt(0), + expo: tokenA?.decimals, + }); + }, [tokenA, tokensWithBalance]); + const formattedBalanceB = useMemo(() => { + const token = tokensWithBalance.find( + ({ config }) => tokenB != null && config.faucetIdBech32 === tokenB.faucetIdBech32, + ); + return fullNumberBigintFormat({ + value: token?.amount || BigInt(0), + expo: tokenB?.decimals, + }); + }, [tokenB, tokensWithBalance]); + + const balanceAText = `${formattedBalanceA ?? '0.00'} ${tokenA?.symbol ?? ''}`.trim(); + const balanceBText = `${formattedBalanceB ?? '0.00'} ${tokenB?.symbol ?? ''}`.trim(); + + const errorA = + amountAStr !== '' && form.amountA != null && form.amountA > balanceABigint + ? 'Exceeds your balance' + : amountAStr !== '' + && form.amountA != null + && form.amountA < BigInt(0) + ? 'Must be at least 0' + : undefined; + const errorB = + amountBStr !== '' && form.amountB != null && form.amountB > balanceBBigint + ? 'Exceeds your balance' + : amountBStr !== '' + && form.amountB != null + && form.amountB < BigInt(0) + ? 'Must be at least 0' + : undefined; + + return ( +
+
+

+ Deposit tokens +

+

+ Specify the token amounts for your liquidity contribution. +

+
+ + {PERCENTAGES.map((pct) => ( + + ))} +
+ } + /> + + {PERCENTAGES.map((pct) => ( + + ))} +
+ } + /> +
+ ); +}; + +export default XykStep2; diff --git a/src/components/xyk-wizard/steps/XykWizardStep3.tsx b/src/components/xyk-wizard/steps/XykWizardStep3.tsx new file mode 100644 index 0000000..82a1a80 --- /dev/null +++ b/src/components/xyk-wizard/steps/XykWizardStep3.tsx @@ -0,0 +1,60 @@ +import { accountIdToBech32 } from '@/lib/utils'; +import { useMemo } from 'react'; +import { formatUnits } from 'viem'; +import type { XykStepProps } from '../XykWizard'; + +const XykStep3 = ({ tokenMetadata, form }: XykStepProps) => { + const tokenA = useMemo(() => { + return tokenMetadata[form.tokenA ? accountIdToBech32(form.tokenA) : '']; + }, [form.tokenA, tokenMetadata]); + const tokenB = useMemo(() => { + return tokenMetadata[form.tokenB ? accountIdToBech32(form.tokenB) : '']; + }, [form.tokenB, tokenMetadata]); + const formattedAmountA = useMemo(() => { + if (tokenA) { + return parseFloat(formatUnits(form.amountA ?? BigInt(0), tokenA.decimals)); + } else { + return 0; + } + }, [form.amountA, tokenA]); + const formattedAmountB = useMemo(() => { + if (tokenB) { + return parseFloat(formatUnits(form.amountB ?? BigInt(0), tokenB.decimals)); + } else { + return 0; + } + }, [form.amountB, tokenB]); + + return ( +
+
+
+ Pair + + {tokenA.symbol}/{tokenB.symbol} + +
+
+ Fee tier + + {(form.feeBps ? form.feeBps / 100 : 0).toFixed(2)}% + +
+
+ Base token deposit + + {formattedAmountA} + +
+
+ Quote token deposit + + {formattedAmountB} + +
+
+
+ ); +}; + +export default XykStep3; diff --git a/src/components/xyk-wizard/steps/XykWizardStep4.tsx b/src/components/xyk-wizard/steps/XykWizardStep4.tsx new file mode 100644 index 0000000..e100cba --- /dev/null +++ b/src/components/xyk-wizard/steps/XykWizardStep4.tsx @@ -0,0 +1,98 @@ +import { Button } from '@/components/ui/button'; +import { emptyFn } from '@/lib/shared'; +import { accountIdToBech32 } from '@/lib/utils'; +import { getMidenscanAccountUrl } from '@/hooks/useLaunchpad'; +import { Check, ExternalLink } from 'lucide-react'; +import { useEffect, useMemo } from 'react'; +import { Link } from 'react-router-dom'; +import { XYK_WIZARD_STORAGE_KEY, type XykStepProps } from '../XykWizard'; + +function clearPersistedWizard() { + try { + localStorage.removeItem(XYK_WIZARD_STORAGE_KEY); + } catch { + // ignore + } +} + +const XykStep4 = ({ + form, + tokenMetadata, + restart, + lastDeployedPoolIdBech32, +}: XykStepProps) => { + const tokenA = useMemo(() => { + return tokenMetadata[form.tokenA ? accountIdToBech32(form.tokenA) : '']; + }, [form.tokenA, tokenMetadata]); + const tokenB = useMemo(() => { + return tokenMetadata[form.tokenB ? accountIdToBech32(form.tokenB) : '']; + }, [form.tokenB, tokenMetadata]); + const poolName = useMemo(() => { + return `${tokenA.name}/${tokenB.name}`; + }, [tokenA, tokenB]); + useEffect(() => { + clearPersistedWizard(); + }, []); + return ( +
+
+
+
+ +
+
+

+ Pool {poolName} created successfully! +

+ {lastDeployedPoolIdBech32 && ( +
+ Pool address + + {lastDeployedPoolIdBech32} + + + View on MidenScan + + +
+ )} +
+
+ {lastDeployedPoolIdBech32 + ? ( + + + + ) + : ( + + + + )} + +
+
+ ); +}; + +export default XykStep4; diff --git a/src/hooks/useBalance.ts b/src/hooks/useBalance.ts index 90a9083..09a6d50 100644 --- a/src/hooks/useBalance.ts +++ b/src/hooks/useBalance.ts @@ -1,6 +1,6 @@ +import { formalBigIntFormat, prettyBigintFormat } from '@/lib/format'; import { ZoroContext } from '@/providers/ZoroContext'; import { type TokenConfig } from '@/providers/ZoroProvider'; -import { formalBigIntFormat, prettyBigintFormat } from '@/utils/format'; import { useCallback, useContext, useEffect, useMemo, useState } from 'react'; interface BalanceParams { diff --git a/src/hooks/useClaimNotes.ts b/src/hooks/useClaimNotes.ts index 2b6e2af..cb41b2f 100644 --- a/src/hooks/useClaimNotes.ts +++ b/src/hooks/useClaimNotes.ts @@ -1,5 +1,6 @@ import { useUnifiedWallet } from '@/hooks/useUnifiedWallet'; import { ZoroContext } from '@/providers/ZoroContext'; +import type { Note } from '@miden-sdk/miden-sdk'; import { useCallback, useContext, useState } from 'react'; export function useClaimNotes() { @@ -17,7 +18,10 @@ export function useClaimNotes() { const claimNotes = useCallback(async () => { if (walletType !== 'para' || !accountId) { - console.log('useClaimNotes: missing requirements', { walletType, hasAccountId: !!accountId }); + console.log('useClaimNotes: missing requirements', { + walletType, + hasAccountId: !!accountId, + }); return { claimed: 0 }; } @@ -36,10 +40,24 @@ export function useClaimNotes() { return { claimed: 0 }; } - // Convert consumable note records to Note objects - const noteObjects = notes.map((n) => n.inputNoteRecord().toNote()); + // Convert consumable note records to Note objects; skip any that fail (e.g. wrong note type) + const noteObjects: Note[] = []; + for (const n of notes) { + try { + const note = n.inputNoteRecord().toNote(); + if (note) noteObjects.push(note); + } catch (err) { + console.warn('useClaimNotes: skipped a note that could not be converted', err); + } + } + + if (noteObjects.length === 0) { + console.warn('useClaimNotes: no notes could be converted to consumable format'); + setClaiming(false); + return { claimed: 0 }; + } - console.log('useClaimNotes: consuming', noteObjects.length, 'notes'); + console.log('useClaimNotes: consuming', noteObjects[0].assets(), 'notes'); // Consume the notes (locking handled internally) const txHash = await consumeNotes(accountId, noteObjects); @@ -53,12 +71,27 @@ export function useClaimNotes() { return { claimed: notes.length }; } catch (e) { console.error('useClaimNotes: error', e); - const errorMessage = e instanceof Error ? e.message : 'Failed to claim notes'; + const rawMessage = e instanceof Error ? e.message : String(e); + const isExecutorError = + /transaction executor error|failed to execute transaction|error during processing of event/i + .test( + rawMessage, + ); + const errorMessage = isExecutorError + ? 'The node rejected the claim transaction. This can happen if a note was already consumed, the note type is not supported for claiming here, or the node is out of sync. Try syncing again or reconnecting your wallet.' + : rawMessage || 'Failed to claim notes'; setError(errorMessage); setClaiming(false); - throw e; + throw new Error(errorMessage); } - }, [accountId, walletType, syncState, getConsumableNotes, consumeNotes, refreshPendingNotes]); + }, [ + accountId, + walletType, + syncState, + getConsumableNotes, + consumeNotes, + refreshPendingNotes, + ]); return { claimNotes, diff --git a/src/hooks/useDeposit.tsx b/src/hooks/useDeposit.tsx index f303bd3..37f046b 100644 --- a/src/hooks/useDeposit.tsx +++ b/src/hooks/useDeposit.tsx @@ -4,6 +4,7 @@ import { API } from '@/lib/config'; import { compileDepositTransaction } from '@/lib/ZoroDepositNote'; import { ZoroContext } from '@/providers/ZoroContext'; import { type TokenConfig } from '@/providers/ZoroProvider'; +import { TransactionType } from '@demox-labs/miden-wallet-adapter'; import { NoteType } from '@miden-sdk/miden-sdk'; import { useCallback, useContext, useMemo, useState } from 'react'; import { toast } from 'react-toastify'; @@ -44,9 +45,12 @@ export const useDeposit = () => { userAccountId: accountId, client, noteType, - }), + }) ); - const txId = await requestTransaction({ type: 'Custom', payload: tx }); + const txId = await requestTransaction({ + type: TransactionType.Custom, + payload: tx, + }); await syncState(); if (noteType === NoteType.Private) { const serialized = btoa( diff --git a/src/hooks/useLPBalances.ts b/src/hooks/useLPBalances.ts index 0e072bd..8ec6074 100644 --- a/src/hooks/useLPBalances.ts +++ b/src/hooks/useLPBalances.ts @@ -1,46 +1,47 @@ -import { bech32ToAccountId, accountIdToBech32 } from '@/lib/utils'; +import { accountIdToBech32 } from '@/lib/utils'; import { ZoroContext } from '@/providers/ZoroContext'; import type { TokenConfig } from '@/providers/ZoroProvider'; -import { Felt, Word } from '@miden-sdk/miden-sdk'; +import type { SlotMapItemResult, SlotQuery } from '@/workers/rpcWorkerTypes'; import { useCallback, useContext, useEffect, useMemo, useState } from 'react'; +import { useRpcWorker } from './useRpcWorker'; export const useLPBalances = ({ tokens }: { tokens?: TokenConfig[] }) => { - const { rpcClient, poolAccountId, accountId } = useContext(ZoroContext); + const { poolAccountId, accountId } = useContext(ZoroContext); const [balances, setBalances] = useState>({}); + const { getAccountStorage } = useRpcWorker(); const refetch = useCallback(async () => { - if (!poolAccountId || !rpcClient || !accountId || !tokens) return; - const balances: Record = {}; - // Clone poolAccountId since getAccountDetails() consumes the AccountId argument - const poolIdClone = bech32ToAccountId(accountIdToBech32(poolAccountId))!; - const fetched = await rpcClient.getAccountDetails(poolIdClone); - const storage = fetched.account()?.storage(); - for (const token of tokens) { - const lp = storage?.getMapItem( - "zoroswap::user_deposits", - Word.newFromFelts([ - new Felt(accountId.suffix().asInt()), - new Felt(accountId.prefix().asInt()), - new Felt(token.faucetId.suffix().asInt()), - new Felt(token.faucetId.prefix().asInt()), - ]), - )?.toFelts(); - const balance = BigInt(lp?.[0].asInt() || BigInt(0)) ?? BigInt(0); - balances[token.faucetIdBech32] = balance; + if (!poolAccountId || !accountId || !tokens?.length) return; + + const poolBech32 = accountIdToBech32(poolAccountId); + const accSuffix = accountId.suffix().asInt().toString(); + const accPrefix = accountId.prefix().asInt().toString(); + + const queries: SlotQuery[] = tokens.map((token) => ({ + kind: 'mapItem' as const, + slotName: 'zoroswap::user_deposits', + key: [ + accSuffix, + accPrefix, + token.faucetId.suffix().asInt().toString(), + token.faucetId.prefix().asInt().toString(), + ] as [string, string, string, string], + })); + + const results = await getAccountStorage(poolBech32, queries); + const newBalances: Record = {}; + for (let i = 0; i < tokens.length; i++) { + const word = (results[i] as SlotMapItemResult).value; + newBalances[tokens[i].faucetIdBech32] = word ? BigInt(word[0]) : 0n; } - setBalances(balances); - }, [poolAccountId, rpcClient, accountId, tokens]); + setBalances(newBalances); + }, [poolAccountId, accountId, tokens, getAccountStorage]); useEffect(() => { - // eslint-disable-next-line refetch(); - const refresh = setInterval(refetch, 10000); + const refresh = setInterval(refetch, 180000); return () => clearInterval(refresh); }, [refetch]); - const value = useMemo(() => ({ - balances, - refetch, - }), [balances, refetch]); - return value; + return useMemo(() => ({ balances, refetch }), [balances, refetch]); }; diff --git a/src/hooks/useLaunchpad.ts b/src/hooks/useLaunchpad.ts new file mode 100644 index 0000000..9b4b915 --- /dev/null +++ b/src/hooks/useLaunchpad.ts @@ -0,0 +1,83 @@ +import { accountIdToBech32 } from '@/lib/utils'; +import { ZoroContext } from '@/providers/ZoroContext'; +import { useCallback, useContext, useMemo, useState } from 'react'; + +export interface FaucetParams { + symbol: string; + decimals: number; + initialSupply: bigint; +} + +export interface LaunchSuccess { + txId: string; + faucetIdBech32: string; +} + +const MIDENSCAN_BASE = 'https://testnet.midenscan.com'; + +export function getMidenscanTxUrl(txId: string): string { + return `${MIDENSCAN_BASE}/tx/${txId}`; +} + +export function getMidenscanAccountUrl(accountBech32: string): string { + return `${MIDENSCAN_BASE}/account/${accountBech32}`; +} + +export function getMidenscanNoteUrl(noteId: string): string { + return `${MIDENSCAN_BASE}/note/${noteId}`; +} + +export const LAUNCH_STEPS = [ + 'Creating faucet', + 'Minting initial supply', + 'Finalizing', +] as const; + +export type LaunchStepIndex = 0 | 1 | 2; + +const useLaunchpad = () => { + const [error, setError] = useState(''); + const { accountId, createFaucet, mintFromFaucet } = useContext(ZoroContext); + + const clearError = useCallback(() => setError(''), []); + + const launchToken = useCallback( + async ( + params: FaucetParams, + options?: { onProgress?: (step: LaunchStepIndex) => void }, + ): Promise => { + setError(''); + const onProgress = options?.onProgress; + try { + if (!accountId) { + throw new Error('Connect your wallet to use the launchpad'); + } + onProgress?.(0); + const faucet = await createFaucet(params); + if (!faucet) { + throw new Error('Faucet creation failed'); + } + onProgress?.(1); + const txId = await mintFromFaucet(faucet.id(), accountId, params.initialSupply); + onProgress?.(2); + const faucetIdBech32 = accountIdToBech32(faucet.id()); + return { txId, faucetIdBech32 }; + } catch (e) { + const message = e instanceof Error ? e.message : typeof e === 'string' ? e : 'Launch failed. Check the console for details.'; + setError(message); + console.error('Launchpad error:', e); + return undefined; + } + }, + [accountId, createFaucet, mintFromFaucet], + ); + + const value = useMemo(() => ({ + launchToken, + error, + clearError, + }), [launchToken, error, clearError]); + return value; +}; + +export default useLaunchpad; diff --git a/src/hooks/usePoolsBalances.tsx b/src/hooks/usePoolsBalances.tsx index 13ec543..57965b8 100644 --- a/src/hooks/usePoolsBalances.tsx +++ b/src/hooks/usePoolsBalances.tsx @@ -20,7 +20,7 @@ export interface RawPoolBalance { } export const usePoolsBalances = () => { - const { data, refetch } = useQuery({ + const { data, refetch, isLoading } = useQuery({ queryKey: ['pools-balances'], queryFn: fetchPoolBalance, staleTime: 15000, @@ -36,7 +36,8 @@ export const usePoolsBalances = () => { } as PoolBalance), ), refetch, - }), [data, refetch]); + isLoading, + }), [data, refetch, isLoading]); return value; }; diff --git a/src/hooks/usePoolsInfo.tsx b/src/hooks/usePoolsInfo.tsx index 7f2a6cf..da1f135 100644 --- a/src/hooks/usePoolsInfo.tsx +++ b/src/hooks/usePoolsInfo.tsx @@ -4,6 +4,8 @@ import type { AccountId } from '@miden-sdk/miden-sdk'; import { useQuery } from '@tanstack/react-query'; import { useMemo } from 'react'; +export type PoolType = 'hfAMM' | 'xyk'; + export interface RawPoolInfo { decimals: number; faucet_id: string; @@ -18,6 +20,7 @@ export interface PoolInfo { name: string; oracleId: string; symbol: string; + poolType: PoolType; } export const usePoolsInfo = () => { @@ -32,12 +35,14 @@ export const usePoolsInfo = () => { data: { poolAccountId: data?.pool_account_id, liquidityPools: data?.liquidity_pools.map( - p => ({ - ...p, - oracleId: p.oracle_id, - faucetId: bech32ToAccountId(p.faucet_id), - faucetIdBech32: p.faucet_id, - } as PoolInfo), + p => + ({ + ...p, + oracleId: p.oracle_id, + faucetId: bech32ToAccountId(p.faucet_id), + faucetIdBech32: p.faucet_id, + poolType: 'hfAMM' as const, + }) as PoolInfo, ), }, refetch: refetch, diff --git a/src/hooks/useRpcWorker.ts b/src/hooks/useRpcWorker.ts new file mode 100644 index 0000000..10773a5 --- /dev/null +++ b/src/hooks/useRpcWorker.ts @@ -0,0 +1,181 @@ +import { NETWORK } from '@/lib/config'; +import type { + GetAccountStorageResponse, + GetFaucetInfoResponse, + InvalidateCacheResponse, + SlotQuery, + SlotResult, + WorkerOutgoing, + WorkerRequest, + WorkerResponse, +} from '@/workers/rpcWorkerTypes'; +import { useCallback } from 'react'; + +type Pending = { + resolve: (value: WorkerResponse) => void; + reject: (reason: Error) => void; +}; + +let worker: Worker | null = null; +let readyPromise: Promise | null = null; +const pending = new Map(); +let nextId = 1; + +function getWorker(): { worker: Worker; ready: Promise } { + if (!worker) { + worker = new Worker( + new URL('../workers/rpc.worker.ts', import.meta.url), + { type: 'module' }, + ); + + let resolveReady: () => void; + readyPromise = new Promise((r) => { + resolveReady = r; + }); + + worker.onmessage = (e: MessageEvent) => { + const data = e.data; + if (data.type === 'ready') { + resolveReady(); + return; + } + const p = pending.get(data.id); + if (!p) return; + pending.delete(data.id); + + if (data.type === 'error') { + p.reject(new Error(data.message)); + } else { + p.resolve(data); + } + }; + } + return { worker, ready: readyPromise! }; +} + +async function send( + request: Omit & { queries?: SlotQuery[] }, +): Promise { + const { worker: w, ready } = getWorker(); + await ready; + const id = nextId++; + return new Promise((resolve, reject) => { + pending.set(id, { + resolve: resolve as (v: WorkerResponse) => void, + reject, + }); + w.postMessage({ ...request, id, rpcEndpoint: NETWORK.rpcEndpoint }); + }); +} + +// --- Main-thread caches (survive component unmount/remount) --- + +const STORAGE_CACHE_TTL = 30_000; + +interface StorageCacheEntry { + results: SlotResult[]; + ts: number; +} + +function makeStorageCacheKey(accountBech32: string, queries: SlotQuery[]): string { + return accountBech32 + '|' + JSON.stringify(queries); +} + +const storageCacheMain = new Map(); +const inflightMain = new Map>(); + +const faucetCacheMain = new Map(); +const inflightFaucet = new Map< + string, + Promise<{ symbol: string; decimals: number } | null> +>(); + +export function useRpcWorker() { + const getAccountStorage = useCallback( + async ( + accountBech32: string, + queries: SlotQuery[], + ): Promise => { + const key = makeStorageCacheKey(accountBech32, queries); + + const cached = storageCacheMain.get(key); + if (cached && Date.now() - cached.ts < STORAGE_CACHE_TTL) { + return cached.results; + } + + const existing = inflightMain.get(key); + if (existing) return existing; + + const promise = send({ + type: 'getAccountStorage', + accountBech32, + queries, + }).then(res => { + storageCacheMain.set(key, { results: res.results, ts: Date.now() }); + return res.results; + }).finally(() => inflightMain.delete(key)); + + inflightMain.set(key, promise); + return promise; + }, + [], + ); + + const getFaucetInfo = useCallback( + async ( + accountBech32: string, + ): Promise<{ symbol: string; decimals: number } | null> => { + const cached = faucetCacheMain.get(accountBech32); + if (cached) return cached; + + const existing = inflightFaucet.get(accountBech32); + if (existing) return existing; + + const promise = send({ + type: 'getFaucetInfo', + accountBech32, + }).then(res => { + if (res.result) faucetCacheMain.set(accountBech32, res.result); + return res.result; + }).finally(() => inflightFaucet.delete(accountBech32)); + + inflightFaucet.set(accountBech32, promise); + return promise; + }, + [], + ); + + const invalidateCache = useCallback( + async (accountBech32: string) => { + // Tell the worker to evict + re-fetch from network (warms worker cache) + await send({ + type: 'invalidateCache', + accountBech32, + }); + + // Now re-fetch every cached query set for this account so main-thread + // cache gets updated with fresh data instead of being empty. + const keysToRefresh: { key: string; queries: SlotQuery[] }[] = []; + for (const [key] of storageCacheMain.entries()) { + if (key.startsWith(accountBech32 + '|')) { + const queries: SlotQuery[] = JSON.parse(key.slice(accountBech32.length + 1)); + keysToRefresh.push({ key, queries }); + } + } + + await Promise.all( + keysToRefresh.map(async ({ key, queries }) => { + const res = await send({ + type: 'getAccountStorage', + accountBech32, + queries, + }); + storageCacheMain.set(key, { results: res.results, ts: Date.now() }); + }), + ); + }, + [], + ); + + return { getAccountStorage, getFaucetInfo, invalidateCache }; +} diff --git a/src/hooks/useTokens.ts b/src/hooks/useTokens.ts new file mode 100644 index 0000000..73c3791 --- /dev/null +++ b/src/hooks/useTokens.ts @@ -0,0 +1,53 @@ +import type { TokenConfig } from '@/providers/ZoroProvider'; +import { bech32ToAccountId } from '@/lib/utils'; +import { useCallback, useEffect, useMemo, useState } from 'react'; +import { useRpcWorker } from './useRpcWorker'; + +/** + * Fetches token metadata from the network for the given faucet IDs. + * Returns tokens keyed by faucetIdBech32. + */ +export function useTokens(faucetIds: string[] | undefined) { + const { getFaucetInfo } = useRpcWorker(); + const [tokens, setTokens] = useState>({}); + const [loading, setLoading] = useState(false); + + const refresh = useCallback(async () => { + if (!faucetIds?.length) { + setTokens({}); + setLoading(false); + return; + } + setLoading(true); + try { + const result: Record = {}; + for (const bech32 of faucetIds) { + const faucetId = bech32ToAccountId(bech32); + if (!faucetId) continue; + try { + const info = await getFaucetInfo(bech32); + if (!info) continue; + result[bech32] = { + symbol: info.symbol, + decimals: info.decimals, + name: info.symbol, + faucetId, + faucetIdBech32: bech32, + oracleId: '0x', + }; + } catch { + // skip failed faucet + } + } + setTokens(result); + } finally { + setLoading(false); + } + }, [faucetIds, getFaucetInfo]); + + useEffect(() => { + refresh(); + }, [refresh]); + + return useMemo(() => ({ tokens, loading, refresh }), [tokens, loading, refresh]); +} diff --git a/src/hooks/useTokensWithBalance.ts b/src/hooks/useTokensWithBalance.ts new file mode 100644 index 0000000..8a2a37f --- /dev/null +++ b/src/hooks/useTokensWithBalance.ts @@ -0,0 +1,36 @@ +import { type TokenConfigWithBalance, ZoroContext } from '@/providers/ZoroContext'; +import type { TokenConfig } from '@/providers/ZoroProvider'; +import { useCallback, useContext, useEffect, useMemo, useState } from 'react'; + +const useTokensWithBalance = () => { + const { getAvailableTokens, accountId } = useContext(ZoroContext); + const [tokensWithBalance, setTokensWithBalance] = useState< + TokenConfigWithBalance[] + >([]); + const [loading, setLoading] = useState(true); + + const refresh = useCallback(async () => { + setLoading(true); + const tokens = await getAvailableTokens(); + setTokensWithBalance(tokens); + setLoading(false); + }, [getAvailableTokens]); + + const metadata = useMemo(() => { + return tokensWithBalance.reduce((acc, t) => { + return { ...acc, [t.config.faucetIdBech32]: t.config }; + }, {} as Record); + }, [tokensWithBalance]); + + useEffect(() => { + if (accountId) { + // eslint-disable-next-line react-hooks/set-state-in-effect + setLoading(true); + refresh(); + } + }, [refresh, accountId]); + + return { tokensWithBalance, loading, metadata }; +}; + +export default useTokensWithBalance; diff --git a/src/hooks/useWaitForNoteConsumed.ts b/src/hooks/useWaitForNoteConsumed.ts new file mode 100644 index 0000000..36f5b76 --- /dev/null +++ b/src/hooks/useWaitForNoteConsumed.ts @@ -0,0 +1,65 @@ +import { ZoroContext } from '@/providers/ZoroContext'; +import { NoteId } from '@miden-sdk/miden-sdk'; +import { useCallback, useContext } from 'react'; + +const DEFAULT_POLL_INTERVAL_MS = 3000; +const DEFAULT_TIMEOUT_MS = 120_000; + +export interface WaitForNoteConsumedOptions { + pollIntervalMs?: number; + timeoutMs?: number; +} + +/** + * Returns a function that polls the Miden node via RPC to check whether + * a note has been included in a block (i.e. the transaction was processed). + * + * Uses `rpcClient.getNotesById` directly instead of the local WebClient, + * which may not know about notes submitted through a wallet adapter. + */ +export function useWaitForNoteConsumed(options: WaitForNoteConsumedOptions = {}) { + const { rpcClient } = useContext(ZoroContext); + const pollIntervalMs = options.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS; + const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS; + + const waitForNoteConsumed = useCallback( + (noteId: string): Promise => + new Promise((resolve, reject) => { + if (!rpcClient) { + reject(new Error('RPC client not available')); + return; + } + + const deadline = Date.now() + timeoutMs; + const timeout = setTimeout(() => { + reject(new Error(`Transaction not confirmed within ${timeoutMs / 1000} seconds`)); + }, timeoutMs); + + const poll = async () => { + if (Date.now() >= deadline) { + clearTimeout(timeout); + reject(new Error(`Transaction not confirmed within ${timeoutMs / 1000} seconds`)); + return; + } + try { + // Create a fresh NoteId each poll — WASM objects can't be reused after + // being passed to another WASM function. + const notes = await rpcClient.getNotesById([NoteId.fromHex(noteId)]); + if (notes.length > 0) { + clearTimeout(timeout); + resolve(); + return; + } + } catch { + // Note not found yet — keep polling + } + setTimeout(poll, pollIntervalMs); + }; + + poll(); + }), + [rpcClient, pollIntervalMs, timeoutMs], + ); + + return waitForNoteConsumed; +} diff --git a/src/hooks/useWithdraw.tsx b/src/hooks/useWithdraw.tsx index d88ec1b..27b3f63 100644 --- a/src/hooks/useWithdraw.tsx +++ b/src/hooks/useWithdraw.tsx @@ -4,6 +4,7 @@ import { API } from '@/lib/config'; import { compileWithdrawTransaction } from '@/lib/ZoroWithdrawNote'; import { ZoroContext } from '@/providers/ZoroContext'; import { type TokenConfig } from '@/providers/ZoroProvider'; +import { TransactionType } from '@demox-labs/miden-wallet-adapter'; import { NoteType } from '@miden-sdk/miden-sdk'; import { useCallback, useContext, useMemo, useState } from 'react'; import { toast } from 'react-toastify'; @@ -44,9 +45,12 @@ export const useWithdraw = () => { userAccountId: accountId, client, noteType, - }), + }) ); - const txId = await requestTransaction({ type: 'Custom', payload: tx }); + const txId = await requestTransaction({ + type: TransactionType.Custom, + payload: tx, + }); await syncState(); if (noteType === NoteType.Private) { diff --git a/src/hooks/useXykDeposit.ts b/src/hooks/useXykDeposit.ts new file mode 100644 index 0000000..189aebc --- /dev/null +++ b/src/hooks/useXykDeposit.ts @@ -0,0 +1,99 @@ +import { clientMutex } from '@/lib/clientMutex'; +import { compileXykDepositTransaction } from '@/lib/XykDepositNote'; +import { bech32ToAccountId } from '@/lib/utils'; +import { useRpcWorker } from '@/hooks/useRpcWorker'; +import { useUnifiedWallet } from '@/hooks/useUnifiedWallet'; +import { useXykPool } from '@/hooks/useXykPool'; +import { ZoroContext } from '@/providers/ZoroContext'; +import { TransactionType } from '@demox-labs/miden-wallet-adapter'; +import { useCallback, useContext, useMemo, useState } from 'react'; +import { toast } from 'react-toastify'; + +export function useXykDeposit(poolId: string | undefined) { + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(); + const [txId, setTxId] = useState(); + const [noteId, setNoteId] = useState(); + const { requestTransaction } = useUnifiedWallet(); + const { client, accountId, syncState } = useContext(ZoroContext); + const { data: poolData } = useXykPool(poolId); + const { invalidateCache } = useRpcWorker(); + + const deposit = useCallback( + async ( + amount0: bigint, + amount1: bigint, + options?: { + onProgress?: (step: number) => void; + waitForNoteConsumed?: (noteId: string) => Promise; + }, + ): Promise<{ noteId: string; txId: string | undefined } | undefined> => { + if ( + !poolId || + !poolData || + !client || + !accountId || + !requestTransaction + ) { + return undefined; + } + const poolAccountId = bech32ToAccountId(poolId); + if (!poolAccountId) return undefined; + const onProgress = options?.onProgress; + const waitForNoteConsumed = options?.waitForNoteConsumed; + setError(''); + setIsLoading(true); + try { + await syncState(); + onProgress?.(0); + const { tx, noteId: nid } = await clientMutex.runExclusive(() => + compileXykDepositTransaction({ + poolAccountId, + userAccountId: accountId, + token0: poolData.token0.faucetId, + token1: poolData.token1.faucetId, + amount0, + amount1, + client, + }), + ); + onProgress?.(1); + const txIdResult = await requestTransaction({ + type: TransactionType.Custom, + payload: tx, + }); + setNoteId(nid); + setTxId(txIdResult); + onProgress?.(2); + if (waitForNoteConsumed) { + await waitForNoteConsumed(nid); + } + if (poolId) await invalidateCache(poolId); + await syncState(); + return { noteId: nid, txId: txIdResult }; + } catch (err) { + console.error(err); + const message = err instanceof Error ? err.message : String(err); + setError(message); + toast.error(`Error adding liquidity: ${message}`); + } finally { + setIsLoading(false); + } + return undefined; + }, + [ + poolId, + poolData, + client, + accountId, + requestTransaction, + syncState, + invalidateCache, + ], + ); + + return useMemo( + () => ({ deposit, isLoading, error, txId, noteId }), + [deposit, isLoading, error, txId, noteId], + ); +} diff --git a/src/hooks/useXykLpBalance.ts b/src/hooks/useXykLpBalance.ts new file mode 100644 index 0000000..57dec90 --- /dev/null +++ b/src/hooks/useXykLpBalance.ts @@ -0,0 +1,53 @@ +import { ZoroContext } from '@/providers/ZoroContext'; +import type { SlotMapItemResult } from '@/workers/rpcWorkerTypes'; +import { useCallback, useContext, useEffect, useMemo, useState } from 'react'; +import { useRpcWorker } from './useRpcWorker'; + +/** + * Returns the user's LP share balance for a single XYK pool. + * Reads from pool storage slot "zoro::lp_local::user_deposits_mapping" + * with key = (0, 0, accountId.suffix, accountId.prefix); value's first felt = LP shares. + */ +export function useXykLpBalance(poolId: string | undefined) { + const { accountId } = useContext(ZoroContext); + const { getAccountStorage } = useRpcWorker(); + const [lpBalance, setLpBalance] = useState(0n); + const [isLoading, setIsLoading] = useState(false); + + const refetch = useCallback(async () => { + if (!poolId || !accountId) { + setLpBalance(0n); + return; + } + setIsLoading(true); + try { + const results = await getAccountStorage(poolId, [{ + kind: 'mapItem', + slotName: 'zoro::lp_local::user_deposits_mapping', + key: [ + '0', + '0', + accountId.suffix().asInt().toString(), + accountId.prefix().asInt().toString(), + ], + }]); + const word = (results[0] as SlotMapItemResult).value; + setLpBalance(word ? BigInt(word[0]) : 0n); + } catch { + setLpBalance(0n); + } finally { + setIsLoading(false); + } + }, [poolId, accountId, getAccountStorage]); + + useEffect(() => { + refetch(); + const interval = setInterval(refetch, 180000); + return () => clearInterval(interval); + }, [refetch]); + + return useMemo( + () => ({ lpBalance, refetch, isLoading }), + [lpBalance, refetch, isLoading], + ); +} diff --git a/src/hooks/useXykPool.tsx b/src/hooks/useXykPool.tsx new file mode 100644 index 0000000..125016d --- /dev/null +++ b/src/hooks/useXykPool.tsx @@ -0,0 +1,118 @@ +import { accountIdFromPrefixSuffix, accountIdToBech32 } from '@/lib/utils'; +import type { SlotItemResult, SlotMapItemResult } from '@/workers/rpcWorkerTypes'; +import { AccountId, Felt } from '@miden-sdk/miden-sdk'; +import { useCallback, useEffect, useMemo, useState } from 'react'; +import { useRpcWorker } from './useRpcWorker'; + +export interface XykTokenInfo { + symbol: string; + decimals: number; + name: string; + faucetId: AccountId; + faucetIdBech32: string; +} + +export interface XykPoolData { + token0: XykTokenInfo; + token1: XykTokenInfo; + totalSupply: bigint; + reserve0: bigint; + reserve1: bigint; + priceToken0InToken1: number; +} + +export function useXykPool(poolId: string | undefined) { + const { getAccountStorage, getFaucetInfo } = useRpcWorker(); + const [data, setData] = useState(null); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); + + const refetch = useCallback(async () => { + if (!poolId) { + setData(null); + setIsLoading(false); + return; + } + setIsLoading(true); + setError(null); + try { + const results = await getAccountStorage(poolId, [ + { + kind: 'mapItem', + slotName: 'zoro::lp_local::assets_mapping', + key: ['0', '0', '0', '0'], + }, + { kind: 'item', slotName: 'zoro::lp_local::total_supply' }, + { kind: 'item', slotName: 'zoro::lp_local::reserve' }, + ]); + + const assetsWord = (results[0] as SlotMapItemResult).value; + if (!assetsWord) { + setData(null); + return; + } + + const token0Id = accountIdFromPrefixSuffix( + new Felt(BigInt(assetsWord[1])), + new Felt(BigInt(assetsWord[0])), + ); + const token1Id = accountIdFromPrefixSuffix( + new Felt(BigInt(assetsWord[3])), + new Felt(BigInt(assetsWord[2])), + ); + + const totalSupplyWord = (results[1] as SlotItemResult).value; + const reserveWord = (results[2] as SlotItemResult).value; + const totalSupply = totalSupplyWord ? BigInt(totalSupplyWord[0]) : 0n; + const reserve0 = reserveWord ? BigInt(reserveWord[0]) : 0n; + const reserve1 = reserveWord ? BigInt(reserveWord[1]) : 0n; + + const fetchTokenInfo = async (faucetId: AccountId): Promise => { + const bech32 = accountIdToBech32(faucetId); + const info = await getFaucetInfo(bech32); + if (!info) { + return { + symbol: '???', + decimals: 18, + name: 'Unknown', + faucetId, + faucetIdBech32: bech32, + }; + } + return { + symbol: info.symbol, + decimals: info.decimals, + name: info.symbol, + faucetId, + faucetIdBech32: bech32, + }; + }; + + const [token0, token1] = await Promise.all([ + fetchTokenInfo(token0Id), + fetchTokenInfo(token1Id), + ]); + + const priceToken0InToken1 = reserve1 > 0n + ? Number(reserve1) / 10 ** token1.decimals + / (Number(reserve0) / 10 ** token0.decimals) + : 0; + + setData({ token0, token1, totalSupply, reserve0, reserve1, priceToken0InToken1 }); + } catch (e) { + setError(e instanceof Error ? e : new Error(String(e))); + setData(null); + } finally { + setIsLoading(false); + } + }, [poolId, getAccountStorage, getFaucetInfo]); + + useEffect(() => { + refetch(); + }, [refetch]); + + return useMemo( + () => ({ data, isLoading, error, refetch }), + [data, isLoading, error, refetch], + ); +} diff --git a/src/hooks/useXykPoolNotes.ts b/src/hooks/useXykPoolNotes.ts new file mode 100644 index 0000000..a49e5ef --- /dev/null +++ b/src/hooks/useXykPoolNotes.ts @@ -0,0 +1,98 @@ +import { accountIdToBech32, bech32ToAccountId } from '@/lib/utils'; +import { ZoroContext } from '@/providers/ZoroContext'; +import type { XykPoolData } from '@/hooks/useXykPool'; +import { NoteTag } from '@miden-sdk/miden-sdk'; +import { useCallback, useContext, useEffect, useMemo, useState } from 'react'; + +export interface XykPoolNoteAsset { + faucetIdBech32: string; + amount: bigint; +} + +export interface XykPoolNoteRow { + noteId: string; + assets: XykPoolNoteAsset[]; + /** Implied price (token1 per token0) from asset ratio if note has both pool tokens. */ + impliedPrice?: number; +} + +export function useXykPoolNotes( + poolId: string | undefined, + poolData: XykPoolData | null, +) { + const { rpcClient } = useContext(ZoroContext); + const [notes, setNotes] = useState([]); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); + + const refetch = useCallback(async () => { + if (!poolId || !rpcClient || !poolData) { + setNotes([]); + setIsLoading(false); + return; + } + setIsLoading(true); + setError(null); + try { + const poolAccountId = bech32ToAccountId(poolId); + if (!poolAccountId) { + setNotes([]); + return; + } + const tag = NoteTag.withAccountTarget(poolAccountId); + const syncInfo = await rpcClient.syncNotes(0, null, [tag]); + const committed = syncInfo.notes(); + if (committed.length === 0) { + setNotes([]); + return; + } + const noteIds = committed.map((c) => c.noteId()); + const fetched = await rpcClient.getNotesById(noteIds); + const rows: XykPoolNoteRow[] = fetched.map((f) => { + const note = f.note; + const noteId = f.noteId.toString(); + const assets: XykPoolNoteAsset[] = []; + let impliedPrice: number | undefined; + if (note) { + const noteAssets = note.assets(); + if (noteAssets) { + const fungible = noteAssets.fungibleAssets(); + const token0Bech32 = poolData.token0.faucetIdBech32; + const token1Bech32 = poolData.token1.faucetIdBech32; + let amt0: bigint | undefined; + let amt1: bigint | undefined; + for (let j = 0; j < fungible.length; j++) { + const fa = fungible[j]; + const fid = accountIdToBech32(fa.faucetId()); + const amt = fa.amount(); + assets.push({ faucetIdBech32: fid, amount: amt }); + if (fid === token0Bech32) amt0 = amt; + if (fid === token1Bech32) amt1 = amt; + } + if (amt0 != null && amt1 != null && amt0 > 0n) { + const h0 = Number(amt0) / 10 ** poolData.token0.decimals; + const h1 = Number(amt1) / 10 ** poolData.token1.decimals; + impliedPrice = h1 / h0; + } + } + } + return { noteId, assets, impliedPrice }; + }); + setNotes(rows); + } catch (e) { + setError(e instanceof Error ? e : new Error(String(e))); + setNotes([]); + } finally { + setIsLoading(false); + } + }, [poolId, rpcClient, poolData]); + + useEffect(() => { + refetch(); + }, [refetch]); + + return useMemo( + () => ({ notes, isLoading, error, refetch }), + [notes, isLoading, error, refetch], + ); +} diff --git a/src/hooks/useXykPools.tsx b/src/hooks/useXykPools.tsx new file mode 100644 index 0000000..c858c8d --- /dev/null +++ b/src/hooks/useXykPools.tsx @@ -0,0 +1,54 @@ +import { XYK_REGISTRY_BECH32 } from '@/lib/config'; +import { accountIdFromPrefixSuffix } from '@/lib/utils'; +import type { SlotMapEntriesResult } from '@/workers/rpcWorkerTypes'; +import { AccountId, Word } from '@miden-sdk/miden-sdk'; +import { useCallback, useEffect, useMemo, useState } from 'react'; +import { useRpcWorker } from './useRpcWorker'; + +export interface XykPool { + token0: AccountId; + token1: AccountId; + xykPoolId: AccountId; +} + +export const useXykPools = () => { + const { getAccountStorage } = useRpcWorker(); + const [xykPools, setXykPools] = useState([]); + + const refetch = useCallback(async () => { + try { + if (!XYK_REGISTRY_BECH32) return; + + const results = await getAccountStorage(XYK_REGISTRY_BECH32, [ + { kind: 'mapEntries', slotName: 'zoro::registry::assets_to_pool_mapping' }, + ]); + + const entries = (results[0] as SlotMapEntriesResult).entries; + const pools: XykPool[] = []; + + for (const entry of entries) { + const keyword = Word.fromHex(entry.key).toFelts(); + const valueword = Word.fromHex(entry.value).toFelts(); + + const token0 = accountIdFromPrefixSuffix(valueword[1], valueword[0]); + const token1 = accountIdFromPrefixSuffix(valueword[3], valueword[2]); + const xykPoolId = accountIdFromPrefixSuffix(keyword[1], keyword[0]); + + pools.push({ token0, token1, xykPoolId }); + } + + setXykPools(pools); + } catch (e) { + console.error(e); + } + }, [getAccountStorage]); + + useEffect(() => { + // eslint-disable-next-line react-hooks/set-state-in-effect + refetch(); + const refresh = setInterval(refetch, 60000); + return () => clearInterval(refresh); + }, [refetch]); + + return useMemo(() => ({ xykPools, refetch }), [xykPools, refetch]); +}; diff --git a/src/hooks/useXykSwap.ts b/src/hooks/useXykSwap.ts new file mode 100644 index 0000000..e27c6ad --- /dev/null +++ b/src/hooks/useXykSwap.ts @@ -0,0 +1,102 @@ +import { clientMutex } from '@/lib/clientMutex'; +import { compileXykSwapTransaction } from '@/lib/XykSwapNote'; +import { bech32ToAccountId } from '@/lib/utils'; +import { useRpcWorker } from '@/hooks/useRpcWorker'; +import { useUnifiedWallet } from '@/hooks/useUnifiedWallet'; +import { useXykPool } from '@/hooks/useXykPool'; +import { ZoroContext } from '@/providers/ZoroContext'; +import { TransactionType } from '@demox-labs/miden-wallet-adapter'; +import type { AccountId } from '@miden-sdk/miden-sdk'; +import { useCallback, useContext, useMemo, useState } from 'react'; +import { toast } from 'react-toastify'; + +export function useXykSwap(poolId: string | undefined) { + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(); + const [txId, setTxId] = useState(); + const [noteId, setNoteId] = useState(); + const { requestTransaction } = useUnifiedWallet(); + const { client, accountId, syncState } = useContext(ZoroContext); + const { data: poolData } = useXykPool(poolId); + const { invalidateCache } = useRpcWorker(); + + const swap = useCallback( + async ( + sellToken: AccountId, + buyToken: AccountId, + amount: bigint, + minAmountOut: bigint, + options?: { + onProgress?: (step: number) => void; + waitForNoteConsumed?: (noteId: string) => Promise; + }, + ): Promise<{ noteId: string; txId: string | undefined } | undefined> => { + if ( + !poolId || + !poolData || + !client || + !accountId || + !requestTransaction + ) { + return undefined; + } + const poolAccountId = bech32ToAccountId(poolId); + if (!poolAccountId) return undefined; + const onProgress = options?.onProgress; + const waitForNoteConsumed = options?.waitForNoteConsumed; + setError(''); + setIsLoading(true); + try { + await syncState(); + onProgress?.(0); + const { tx, noteId: nid } = await clientMutex.runExclusive(() => + compileXykSwapTransaction({ + poolAccountId, + userAccountId: accountId, + sellToken, + buyToken, + amount, + minAmountOut, + client, + }), + ); + onProgress?.(1); + const txIdResult = await requestTransaction({ + type: TransactionType.Custom, + payload: tx, + }); + setNoteId(nid); + setTxId(txIdResult); + onProgress?.(2); + if (waitForNoteConsumed) { + await waitForNoteConsumed(nid); + } + if (poolId) await invalidateCache(poolId); + await syncState(); + return { noteId: nid, txId: txIdResult }; + } catch (err) { + console.error(err); + const message = err instanceof Error ? err.message : String(err); + setError(message); + toast.error(`Error swapping: ${message}`); + } finally { + setIsLoading(false); + } + return undefined; + }, + [ + poolId, + poolData, + client, + accountId, + requestTransaction, + syncState, + invalidateCache, + ], + ); + + return useMemo( + () => ({ swap, isLoading, error, txId, noteId }), + [swap, isLoading, error, txId, noteId], + ); +} diff --git a/src/hooks/useXykTokens.ts b/src/hooks/useXykTokens.ts new file mode 100644 index 0000000..8f43458 --- /dev/null +++ b/src/hooks/useXykTokens.ts @@ -0,0 +1,72 @@ +import { accountIdToBech32 } from '@/lib/utils'; +import type { TokenConfig } from '@/providers/ZoroProvider'; +import { useCallback, useMemo } from 'react'; +import { useTokens } from './useTokens'; +import { useXykPools } from './useXykPools'; + +/** + * Provides XYK token metadata, pair-filtering, and pool lookup + * for use on the Swap page. + */ +export function useXykTokens() { + const { xykPools } = useXykPools(); + + const allFaucetBech32s = useMemo(() => { + const set = new Set(); + for (const pool of xykPools) { + set.add(accountIdToBech32(pool.token0)); + set.add(accountIdToBech32(pool.token1)); + } + return [...set]; + }, [xykPools]); + + const { tokens: xykTokenMap } = useTokens(allFaucetBech32s); + + const xykTokens = useMemo( + () => Object.values(xykTokenMap), + [xykTokenMap], + ); + + // Map from faucet bech32 -> set of counterpart faucet bech32s + const pairMap = useMemo(() => { + const m = new Map>(); + for (const pool of xykPools) { + const t0 = accountIdToBech32(pool.token0); + const t1 = accountIdToBech32(pool.token1); + if (!m.has(t0)) m.set(t0, new Set()); + if (!m.has(t1)) m.set(t1, new Set()); + m.get(t0)!.add(t1); + m.get(t1)!.add(t0); + } + return m; + }, [xykPools]); + + const getXykPairTokens = useCallback( + (faucetIdBech32: string): string[] => { + return [...(pairMap.get(faucetIdBech32) ?? [])]; + }, + [pairMap], + ); + + // Pool bech32 map keyed by "tokenA|tokenB" (both orderings) + const poolLookup = useMemo(() => { + const m = new Map(); + for (const pool of xykPools) { + const t0 = accountIdToBech32(pool.token0); + const t1 = accountIdToBech32(pool.token1); + const poolBech32 = accountIdToBech32(pool.xykPoolId); + m.set(`${t0}|${t1}`, poolBech32); + m.set(`${t1}|${t0}`, poolBech32); + } + return m; + }, [xykPools]); + + const findXykPool = useCallback( + (sellBech32: string, buyBech32: string): string | undefined => { + return poolLookup.get(`${sellBech32}|${buyBech32}`); + }, + [poolLookup], + ); + + return { xykTokens, getXykPairTokens, findXykPool }; +} diff --git a/src/hooks/useXykWithdraw.ts b/src/hooks/useXykWithdraw.ts new file mode 100644 index 0000000..cb08140 --- /dev/null +++ b/src/hooks/useXykWithdraw.ts @@ -0,0 +1,97 @@ +import { useRpcWorker } from '@/hooks/useRpcWorker'; +import { useUnifiedWallet } from '@/hooks/useUnifiedWallet'; +import { useXykPool } from '@/hooks/useXykPool'; +import { clientMutex } from '@/lib/clientMutex'; +import { bech32ToAccountId } from '@/lib/utils'; +import { compileXykWithdrawTransaction } from '@/lib/XykWithdrawNote'; +import { ZoroContext } from '@/providers/ZoroContext'; +import { TransactionType } from '@demox-labs/miden-wallet-adapter'; +import { useCallback, useContext, useMemo, useState } from 'react'; +import { toast } from 'react-toastify'; + +export function useXykWithdraw(poolId: string | undefined) { + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(); + const [txId, setTxId] = useState(); + const [noteId, setNoteId] = useState(); + const { requestTransaction } = useUnifiedWallet(); + const { client, accountId, syncState } = useContext(ZoroContext); + const { data: poolData } = useXykPool(poolId); + const { invalidateCache } = useRpcWorker(); + + const withdraw = useCallback( + async ( + lpAmount: bigint, + options?: { + onProgress?: (step: number) => void; + waitForNoteConsumed?: (noteId: string) => Promise; + }, + ): Promise<{ noteId: string; txId: string | undefined } | undefined> => { + if ( + !poolId + || !poolData + || !client + || !accountId + || !requestTransaction + ) { + return undefined; + } + const poolAccountId = bech32ToAccountId(poolId); + if (!poolAccountId) return undefined; + const onProgress = options?.onProgress; + const waitForNoteConsumed = options?.waitForNoteConsumed; + setError(''); + setIsLoading(true); + try { + await syncState(); + onProgress?.(0); + const { tx, noteId: nid } = await clientMutex.runExclusive(() => + compileXykWithdrawTransaction({ + poolAccountId, + userAccountId: accountId, + token0: poolData.token0.faucetId, + token1: poolData.token1.faucetId, + lpAmount, + client, + }) + ); + onProgress?.(1); + const txIdResult = await requestTransaction({ + type: TransactionType.Custom, + payload: tx, + }); + setNoteId(nid); + setTxId(txIdResult); + onProgress?.(2); + if (waitForNoteConsumed) { + await waitForNoteConsumed(nid); + } + if (poolId) await invalidateCache(poolId); + await syncState(); + return { noteId: nid, txId: txIdResult }; + } catch (err) { + console.error(err); + const message = err instanceof Error ? err.message : String(err); + setError(message); + toast.error(`Error withdrawing liquidity: ${message}`); + } finally { + setIsLoading(false); + } + return undefined; + }, + [ + poolId, + poolData, + client, + accountId, + requestTransaction, + syncState, + invalidateCache, + ], + ); + + return useMemo( + () => ({ withdraw, isLoading, error, txId, noteId }), + [withdraw, isLoading, error, txId, noteId], + ); +} diff --git a/src/index.css b/src/index.css index 7e64e30..3739f77 100644 --- a/src/index.css +++ b/src/index.css @@ -40,7 +40,7 @@ --chart-4: 43 74% 66%; --chart-5: 27 87% 67%; --footer: 0, 0%, 98%; - --bg-dots: 130, 15%, 92.2%; + --bg-dots: 130, 10%, 96%; } .dark { @@ -95,6 +95,8 @@ body { @apply bg-background text-foreground; + font-family: 'Manrope', system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', + sans-serif; } } diff --git a/src/lib/DeployXykPool.ts b/src/lib/DeployXykPool.ts new file mode 100644 index 0000000..11e5090 --- /dev/null +++ b/src/lib/DeployXykPool.ts @@ -0,0 +1,318 @@ +import { + AccountId, + AccountType, + Felt, + FeltArray, + MidenArrays, + Note, + NoteAssets, + NoteAttachment, + NoteExecutionHint, + NoteInputs, + NoteMetadata, + NoteRecipient, + NoteTag, + NoteType, + OutputNote, + StorageSlot, + TransactionRequestBuilder, + WebClient, + Word, +} from '@miden-sdk/miden-sdk'; + +import lp_local from '@/masm/accounts/lp_local.masm?raw'; +import math from '@/masm/accounts/math.masm?raw'; +import storage_utils from '@/masm/accounts/storage_utils.masm?raw'; +import xyk_pool from '@/masm/accounts/xyk_pool.masm?raw'; +import xyk_registry from '@/masm/accounts/xyk_registry.masm?raw'; +import XYK_REGISTER_SCRIPT from '@/masm/notes/xyk_register.masm?raw'; +import network_account_masm from '@/masm/vendor/network_account_target.masm?raw'; + +import type { TransactionRequest } from '@/providers/UnifiedWalletContext'; +import { CustomTransaction, TransactionType } from '@demox-labs/miden-wallet-adapter'; +import { StorageMap } from '@miden-sdk/miden-sdk'; +import { AccountComponent } from '@miden-sdk/miden-sdk'; +import { AccountBuilder } from '@miden-sdk/miden-sdk'; +import { AccountStorageMode } from '@miden-sdk/miden-sdk'; +import { REGISTRY_ACCOUNT } from './config'; +import { accountIdToBech32, generateRandomSerialNumber } from './utils'; + +export interface DeployNewPoolParams { + token0: AccountId; + token1: AccountId; + client: WebClient; +} + +export interface DeployResult { + txId: string; + noteId: string; + newPool: AccountId; +} + +export const build_math_lib = (client: WebClient) => { + const builder = client.createCodeBuilder(); + return builder.buildLibrary('zoro::math', math); +}; +export const build_storage_utils = (client: WebClient) => { + const math_lib = build_math_lib(client); + const builder = client.createCodeBuilder(); + builder.linkStaticLibrary(math_lib); + return builder.buildLibrary('zoro::storage_utils', storage_utils); +}; +export const build_network_account_lib = (client: WebClient) => { + const builder = client.createCodeBuilder(); + return builder.buildLibrary( + 'biden::standards::attachments::network_account_target', + network_account_masm, + ); +}; + +export const build_lp_local_lib = (client: WebClient) => { + const math_lib = build_math_lib(client); + const storage_utils = build_storage_utils(client); + // const network_account_lib = build_network_account_lib(client); + const builder = client.createCodeBuilder(); + // builder.linkStaticLibrary(network_account_lib); + builder.linkStaticLibrary(math_lib); + builder.linkStaticLibrary(storage_utils); + return builder.buildLibrary('zoro::lp_local', lp_local); +}; +export const build_xyk_pool_lib = (client: WebClient) => { + const math_lib = build_math_lib(client); + const storage_utils = build_storage_utils(client); + const lp_local = build_lp_local_lib(client); + const builder = client.createCodeBuilder(); + builder.linkStaticLibrary(math_lib); + builder.linkStaticLibrary(storage_utils); + builder.linkStaticLibrary(lp_local); + return builder.buildLibrary('zoro::xyk_pool', xyk_pool); +}; +export const build_registry_library = (client: WebClient) => { + const math_lib = build_math_lib(client); + const storage_utils = build_storage_utils(client); + const lp_local = build_lp_local_lib(client); + const xyk_pool = build_xyk_pool_lib(client); + const builder = client.createCodeBuilder(); + builder.linkStaticLibrary(math_lib); + builder.linkStaticLibrary(storage_utils); + builder.linkStaticLibrary(lp_local); + builder.linkStaticLibrary(xyk_pool); + return builder.buildLibrary('zoro::registry', xyk_registry); +}; + +export const compile_xyk_register_note_script = (client: WebClient) => { + const xyk_registry = build_registry_library(client); + const builder = client.createCodeBuilder(); + builder.linkStaticLibrary(xyk_registry); + const script = builder.compileNoteScript( + XYK_REGISTER_SCRIPT, + ); + return script; +}; + +export const get_register_note_root_hash = (client: WebClient) => { + const note_script = compile_xyk_register_note_script(client); + return note_script.root(); +}; + +export const get_pool_account_code_commitment = async (client: WebClient) => { + if (REGISTRY_ACCOUNT == null) { + throw 'REGISTRY_ACCOUNT needs to be available in .env'; + } + const pool = await buildXykPool({ + client, + token0: REGISTRY_ACCOUNT, + token1: REGISTRY_ACCOUNT, + }); + return pool.account.code().commitment(); +}; + +const buildXykPool = async ({ + client, + token0, + token1, +}: DeployNewPoolParams) => { + if (REGISTRY_ACCOUNT == null) { + throw 'REGISTRY_ACCOUNT needs to be available in .env'; + } + const lp_local_lib = build_lp_local_lib(client); + const xyk_pool_lib = build_xyk_pool_lib(client); + + const assets = new StorageMap(); + assets.insert( + Word.newFromFelts([ + new Felt(BigInt(0)), + new Felt(BigInt(0)), + new Felt(BigInt(0)), + new Felt(BigInt(0)), + ]), + Word.newFromFelts([ + token0.suffix(), + token0.prefix(), + token1.suffix(), + token1.prefix(), + ]), + ); + + const assets_mapping_slot = StorageSlot.map('zoro::lp_local::assets_mapping', assets); + const reserve_slot = StorageSlot.emptyValue('zoro::lp_local::reserve'); + const total_supply_slot = StorageSlot.emptyValue('zoro::lp_local::total_supply'); + + const user_deposits_mapping = new StorageMap(); + user_deposits_mapping.insert( + Word.newFromFelts([ + new Felt(BigInt(0)), + new Felt(BigInt(0)), + new Felt(BigInt(0)), + new Felt(BigInt(1)), + ]), + Word.newFromFelts([ + new Felt(BigInt(0)), + new Felt(BigInt(0)), + new Felt(BigInt(0)), + new Felt(BigInt(1)), + ]), + ); + const user_deposits_slot = StorageSlot.map( + 'zoro::lp_local::user_deposits_mapping', + user_deposits_mapping, + ); + + const registry_id_slot = StorageSlot.fromValue( + 'zoro::lp_local::registry_id', + Word.newFromFelts([ + REGISTRY_ACCOUNT.suffix(), + REGISTRY_ACCOUNT.prefix(), + new Felt(BigInt(0)), + new Felt(BigInt(0)), + ]), + ); + + const register_note_root = StorageSlot.fromValue( + 'zoro::lp_local::register_note_root', + get_register_note_root_hash(client), + ); + + const xyk_pool_component = AccountComponent.fromLibrary(xyk_pool_lib, []) + .withSupportsAllTypes(); + + const lp_local_component = AccountComponent.fromLibrary( + lp_local_lib, + [ + assets_mapping_slot, + reserve_slot, + total_supply_slot, + user_deposits_slot, + registry_id_slot, + register_note_root, + ], + ).withSupportsAllTypes(); + + const walletSeed = new Uint8Array(32); + crypto.getRandomValues(walletSeed); + + const contract = new AccountBuilder(walletSeed) + .accountType(AccountType.RegularAccountImmutableCode) + .storageMode(AccountStorageMode.network()) + // .storageMode(AccountStorageMode.public()) + .withComponent(lp_local_component) + .withComponent(xyk_pool_component) + .withNoAuthComponent() + // .withAuthComponent(authComponent) + .withBasicWalletComponent() + .build(); + + return contract; +}; + +export async function deployNewPool({ + client, + token0, + token1, +}: DeployNewPoolParams) { + const contract = await buildXykPool({ client, token0, token1 }); + await client.newAccount(contract.account, true); + await client.syncState(); + console.log('Deployed new XYK pool at: ', accountIdToBech32(contract.account.id())); + const initTx = new TransactionRequestBuilder() + .build(); + await client.submitNewTransaction(contract.account.id(), initTx); + await client.syncState(); + client.syncState(); + + return { + newPoolId: contract.account.id(), + }; +} + +export const registerPool = async ({ + client, + token0, + token1, + pool_acc, + sender, + requestTransaction, +}: DeployNewPoolParams & { + pool_acc: AccountId; + sender: AccountId; + requestTransaction: (tx: TransactionRequest) => void; +}) => { + if (!REGISTRY_ACCOUNT) return; + + await client.importAccountById(REGISTRY_ACCOUNT); + await client.importAccountById(pool_acc); + await client.syncState(); + + const script = compile_xyk_register_note_script(client); + const noteTag = NoteTag.withAccountTarget(REGISTRY_ACCOUNT); + const attachment = NoteAttachment.newNetworkAccountTarget( + REGISTRY_ACCOUNT, + NoteExecutionHint.always(), + ); + const metadata = new NoteMetadata( + sender, + NoteType.Public, + noteTag, + ).withAttachment(attachment); + + const inputs = new NoteInputs( + new FeltArray([ + token0.prefix(), + token0.suffix(), + token1.prefix(), + token1.suffix(), + pool_acc.prefix(), + pool_acc.suffix(), + REGISTRY_ACCOUNT.prefix(), + REGISTRY_ACCOUNT.suffix(), + ]), + ); + + const noteAssets = new NoteAssets([]); + const note = new Note( + noteAssets, + metadata, + new NoteRecipient(generateRandomSerialNumber(), script, inputs), + ); + + const noteId = note.id().toString(); + + console.log('REGISTRY note: ', noteId, note); + + const transactionRequest = new TransactionRequestBuilder() + .withOwnOutputNotes(new MidenArrays.OutputNoteArray([OutputNote.full(note)])) + .build(); + + const tx = new CustomTransaction( + accountIdToBech32(sender), + accountIdToBech32(REGISTRY_ACCOUNT), + transactionRequest, + [], + [], + ); + + requestTransaction({ + type: TransactionType.Custom, + payload: tx, + }); +}; diff --git a/src/lib/XykDepositNote.ts b/src/lib/XykDepositNote.ts new file mode 100644 index 0000000..1dd0a12 --- /dev/null +++ b/src/lib/XykDepositNote.ts @@ -0,0 +1,107 @@ +import { CustomTransaction } from '@demox-labs/miden-wallet-adapter'; +import { + AccountId, + FeltArray, + FungibleAsset, + MidenArrays, + Note, + NoteAssets, + NoteAttachment, + NoteExecutionHint, + NoteInputs, + NoteMetadata, + NoteRecipient, + NoteTag, + NoteType, + OutputNote, + TransactionRequestBuilder, + WebClient, +} from '@miden-sdk/miden-sdk'; + +import SCRIPT from '@/masm/notes/xyk_deposit.masm?raw'; +import { build_lp_local_lib } from './DeployXykPool'; +import { accountIdToBech32, generateRandomSerialNumber } from './utils'; + +export interface DepositParams { + token0: AccountId; + token1: AccountId; + amount0: bigint; + amount1: bigint; + userAccountId: AccountId; + poolAccountId: AccountId; + client: WebClient; +} + +export interface SwapResult { + readonly txId: string; + readonly noteId: string; +} + +export async function compileXykDepositTransaction({ + poolAccountId, + userAccountId, + token0, + token1, + amount0, + amount1, + client, +}: DepositParams) { + const lp_local_lib = build_lp_local_lib(client); + const builder = client.createCodeBuilder(); + builder.linkStaticLibrary(lp_local_lib); + const script = builder.compileNoteScript( + SCRIPT, + ); + + const noteTag = NoteTag.withAccountTarget(poolAccountId); + + const attachment = NoteAttachment.newNetworkAccountTarget( + poolAccountId, + NoteExecutionHint.always(), + ); + + const metadata = new NoteMetadata( + userAccountId, + NoteType.Public, + noteTag, + ).withAttachment(attachment); + + const inputs = new NoteInputs( + new FeltArray([ + userAccountId.prefix(), + userAccountId.suffix(), + ]), + ); + + const asset0 = new FungibleAsset(token0, amount0); + const asset1 = new FungibleAsset(token1, amount1); + const noteAssets = new NoteAssets([asset0, asset1]); + + const note = new Note( + noteAssets, + metadata, + new NoteRecipient(generateRandomSerialNumber(), script, inputs), + ); + + const noteId = note.id().toString(); + + console.log('Deposit note: ', noteId); + + const transactionRequest = new TransactionRequestBuilder() + .withOwnOutputNotes(new MidenArrays.OutputNoteArray([OutputNote.full(note)])) + .build(); + + const tx = new CustomTransaction( + accountIdToBech32(userAccountId), + accountIdToBech32(poolAccountId), + transactionRequest, + [], + [], + ); + + return { + tx, + noteId, + note, + }; +} diff --git a/src/lib/XykSwapNote.ts b/src/lib/XykSwapNote.ts new file mode 100644 index 0000000..9e2f814 --- /dev/null +++ b/src/lib/XykSwapNote.ts @@ -0,0 +1,124 @@ +import { CustomTransaction } from '@demox-labs/miden-wallet-adapter'; +import { + AccountId, + Felt, + FeltArray, + FungibleAsset, + MidenArrays, + Note, + NoteAssets, + NoteAttachment, + NoteExecutionHint, + NoteInputs, + NoteMetadata, + NoteRecipient, + NoteTag, + NoteType, + OutputNote, + TransactionRequestBuilder, + WebClient, +} from '@miden-sdk/miden-sdk'; + +import SCRIPT from '@/masm/notes/xyk_swap_exact_tokens_for_tokens.masm?raw'; +import { build_xyk_pool_lib } from './DeployXykPool'; +import { accountIdToBech32, generateRandomSerialNumber } from './utils'; + +export interface XykSwapParams { + poolAccountId: AccountId; + userAccountId: AccountId; + sellToken: AccountId; + buyToken: AccountId; + amount: bigint; + minAmountOut: bigint; + client: WebClient; +} + +export async function compileXykSwapTransaction({ + poolAccountId, + userAccountId, + sellToken, + buyToken, + amount, + minAmountOut, + client, +}: XykSwapParams) { + const xyk_pool_lib = build_xyk_pool_lib(client); + const builder = client.createCodeBuilder(); + builder.linkStaticLibrary(xyk_pool_lib); + const script = builder.compileNoteScript(SCRIPT); + + const noteTag = NoteTag.withAccountTarget(poolAccountId); + const attachment = NoteAttachment.newNetworkAccountTarget( + poolAccountId, + NoteExecutionHint.always(), + ); + const metadata = new NoteMetadata( + userAccountId, + NoteType.Public, + noteTag, + ).withAttachment(attachment); + + // Return note: P2ID to user's public account (no network attachment). + const returnNoteTag = NoteTag.withAccountTarget(userAccountId); + const returnNoteType = NoteType.Public; + const returnNoteAssets = new NoteAssets([ + new FungibleAsset(buyToken, BigInt(1)), + ]); + const returnNote = Note.createP2IDNote( + poolAccountId, + userAccountId, + returnNoteAssets, + returnNoteType, + new NoteAttachment(), + ); + const p2id_root = returnNote.script().root().toFelts(); + const deadline = Date.now() + 120_000; // 2 min + + const inputs = new NoteInputs( + new FeltArray([ + new Felt(buyToken.prefix().asInt()), + new Felt(buyToken.suffix().asInt()), + new Felt(BigInt(0)), + new Felt(minAmountOut), + new Felt(BigInt(deadline)), + new Felt(BigInt(returnNoteTag.asU32())), + new Felt(BigInt(NoteType.Public)), + new Felt(BigInt(0)), + p2id_root[0], + p2id_root[1], + p2id_root[2], + p2id_root[3], + ]), + ); + + const noteAssets = new NoteAssets([ + new FungibleAsset(sellToken, amount), + ]); + const note = new Note( + noteAssets, + metadata, + new NoteRecipient(generateRandomSerialNumber(), script, inputs), + ); + + const noteId = note.id().toString(); + + console.log('Swap note: ', noteId); + + const transactionRequest = new TransactionRequestBuilder() + .withOwnOutputNotes(new MidenArrays.OutputNoteArray([OutputNote.full(note)])) + .build(); + + const tx = new CustomTransaction( + accountIdToBech32(userAccountId), + accountIdToBech32(poolAccountId), + transactionRequest, + [], + [], + ); + + return { + tx, + noteId, + note, + }; +} diff --git a/src/lib/XykWithdrawNote.ts b/src/lib/XykWithdrawNote.ts new file mode 100644 index 0000000..6ec2e22 --- /dev/null +++ b/src/lib/XykWithdrawNote.ts @@ -0,0 +1,127 @@ +import { CustomTransaction } from '@demox-labs/miden-wallet-adapter'; +import { + AccountId, + Felt, + FeltArray, + FungibleAsset, + MidenArrays, + Note, + NoteAssets, + NoteAttachment, + NoteExecutionHint, + NoteInputs, + NoteMetadata, + NoteRecipient, + NoteTag, + NoteType, + OutputNote, + TransactionRequestBuilder, + WebClient, +} from '@miden-sdk/miden-sdk'; + +import SCRIPT from '@/masm/notes/xyk_withdraw.masm?raw'; +import { build_lp_local_lib } from './DeployXykPool'; +import { accountIdToBech32, generateRandomSerialNumber } from './utils'; + +export interface WithdrawParams { + token0: AccountId; + token1: AccountId; + lpAmount: bigint; + userAccountId: AccountId; + poolAccountId: AccountId; + client: WebClient; +} + +export interface SwapResult { + readonly txId: string; + readonly noteId: string; +} + +export async function compileXykWithdrawTransaction({ + poolAccountId, + userAccountId, + token0, + token1, + lpAmount, + client, +}: WithdrawParams) { + const lp_local_lib = build_lp_local_lib(client); + const builder = client.createCodeBuilder(); + builder.linkStaticLibrary(lp_local_lib); + const script = builder.compileNoteScript( + SCRIPT, + ); + + const noteTag = NoteTag.withAccountTarget(poolAccountId); + const attachment = NoteAttachment.newNetworkAccountTarget( + poolAccountId, + NoteExecutionHint.always(), + ); + const metadata = new NoteMetadata( + userAccountId, + NoteType.Public, + noteTag, + ).withAttachment(attachment); + + const returnNoteTag = NoteTag.withAccountTarget(userAccountId); + const returnNoteType = NoteType.Public; + const returnNoteAssets = new NoteAssets([ + new FungibleAsset(token0, BigInt(1)), + new FungibleAsset(token1, BigInt(1)), + ]); + const returnNote = Note.createP2IDNote( + poolAccountId, + userAccountId, + returnNoteAssets, + returnNoteType, + new NoteAttachment(), + ); + const root = returnNote.script().root().toFelts(); + + const inputs = new NoteInputs( + new FeltArray([ + new Felt(BigInt(0)), + new Felt(BigInt(0)), + new Felt(BigInt(0)), + new Felt(lpAmount), + new Felt(BigInt(returnNoteTag.asU32())), + new Felt(BigInt(NoteType.Public)), + new Felt(BigInt(0)), + new Felt(BigInt(0)), + root[0], + root[1], + root[2], + root[3], + ]), + ); + + const noteAssets = new NoteAssets([]); + const note = new Note( + noteAssets, + metadata, + new NoteRecipient(generateRandomSerialNumber(), script, inputs), + ); + + const noteId = note.id().toString(); + + console.log('Withdraw note: ', noteId); + + const transactionRequest = new TransactionRequestBuilder() + .withOwnOutputNotes(new MidenArrays.OutputNoteArray([OutputNote.full(note)])) + .build(); + + const tx = new CustomTransaction( + accountIdToBech32(userAccountId), + accountIdToBech32(poolAccountId), + transactionRequest, + [], + [], + ); + + return { + tx, + noteId, + note, + returnNote, + }; +} diff --git a/src/lib/ZoroDepositNote.ts b/src/lib/ZoroDepositNote.ts index e58c8b0..b1258e5 100644 --- a/src/lib/ZoroDepositNote.ts +++ b/src/lib/ZoroDepositNote.ts @@ -17,10 +17,10 @@ import { WebClient, } from '@miden-sdk/miden-sdk'; +import zoropool from '@/masm/accounts/zoropool.masm?raw'; +import DEPOSIT_SCRIPT from '@/masm/notes/DEPOSIT.masm?raw'; import type { TokenConfig } from '@/providers/ZoroProvider'; -import DEPOSIT_SCRIPT from './DEPOSIT.masm?raw'; import { accountIdToBech32, generateRandomSerialNumber } from './utils'; -import zoropool from './zoropool.masm?raw'; export interface DepositParams { poolAccountId: AccountId; diff --git a/src/lib/ZoroSwapNote.ts b/src/lib/ZoroSwapNote.ts index 3bf57e8..bf8e6b6 100644 --- a/src/lib/ZoroSwapNote.ts +++ b/src/lib/ZoroSwapNote.ts @@ -1,3 +1,4 @@ +import { CustomTransaction } from '@demox-labs/miden-wallet-adapter'; import { AccountId, Felt, @@ -15,13 +16,11 @@ import { TransactionRequestBuilder, WebClient, } from '@miden-sdk/miden-sdk'; -import { CustomTransaction } from '@demox-labs/miden-wallet-adapter'; +import zoropool from '@/masm/accounts/zoropool.masm?raw'; +import ZOROSWAP_SCRIPT from '@/masm/notes/ZOROSWAP.masm?raw'; import type { TokenConfig } from '@/providers/ZoroProvider'; import { accountIdToBech32, generateRandomSerialNumber } from './utils'; -import ZOROSWAP_SCRIPT from './ZOROSWAP.masm?raw'; - -import zoropool from './zoropool.masm?raw'; export interface SwapParams { poolAccountId: AccountId; @@ -81,9 +80,9 @@ export async function compileSwapTransaction({ new Felt(BigInt(p2idTag)), new Felt(BigInt(0)), new Felt(BigInt(0)), - userAccountId.suffix(), // beneficiary + userAccountId.suffix(), // beneficiary userAccountId.prefix(), - userAccountId.suffix(), // creator + userAccountId.suffix(), // creator userAccountId.prefix(), ]), ); @@ -96,6 +95,8 @@ export async function compileSwapTransaction({ const noteId = note.id().toString(); + console.log('Swap note: ', noteId); + const transactionRequest = new TransactionRequestBuilder() .withOwnOutputNotes(new MidenArrays.OutputNoteArray([OutputNote.full(note)])) .build(); diff --git a/src/lib/ZoroWithdrawNote.ts b/src/lib/ZoroWithdrawNote.ts index 6418517..6fb1c54 100644 --- a/src/lib/ZoroWithdrawNote.ts +++ b/src/lib/ZoroWithdrawNote.ts @@ -17,10 +17,10 @@ import { WebClient, } from '@miden-sdk/miden-sdk'; +import zoropool from '@/masm/accounts/zoropool.masm?raw'; +import WITHDRAW_SCRIPT from '@/masm/notes/WITHDRAW.masm?raw'; import type { TokenConfig } from '@/providers/ZoroProvider'; import { accountIdToBech32, generateRandomSerialNumber } from './utils'; -import WITHDRAW_SCRIPT from './WITHDRAW.masm?raw'; -import zoropool from './zoropool.masm?raw'; export interface WithdrawParams { poolAccountId: AccountId; diff --git a/src/lib/config.ts b/src/lib/config.ts index 91ee4bd..568e048 100644 --- a/src/lib/config.ts +++ b/src/lib/config.ts @@ -1,4 +1,5 @@ import { NetworkId } from '@miden-sdk/miden-sdk'; +import { bech32ToAccountId } from './utils'; export interface NetworkConfig { readonly rpcEndpoint: string; @@ -70,6 +71,10 @@ export const API: ApiConfig = { // UI Configuration export const DEFAULT_SLIPPAGE = getNumericEnvVar('VITE_DEFAULT_SLIPPAGE', 0.5); +// Xyk configuration +export const XYK_REGISTRY_BECH32 = getEnvVar('VITE_XYK_REGISTRY'); +export const REGISTRY_ACCOUNT = bech32ToAccountId(XYK_REGISTRY_BECH32); + /** Create a fresh NetworkId matching the configured network (toBech32 consumes the NetworkId) */ export const createNetworkId = (): NetworkId => { switch (import.meta.env.VITE_NETWORK_ID) { diff --git a/src/utils/format.ts b/src/lib/format.ts similarity index 80% rename from src/utils/format.ts rename to src/lib/format.ts index 5ceb6a3..19b80e6 100644 --- a/src/utils/format.ts +++ b/src/lib/format.ts @@ -17,6 +17,14 @@ export const formatTokenAmount = ( if (value == null) return undefined; return roundDown(formatUnits(BigInt(value), expo), digits); }; + +/** Always returns a string for use in controlled inputs. formatTokenAmount may return number. */ +export const formatTokenAmountForInput = ( + opts: { value?: bigint | null; expo: number; digits?: number }, +): string => { + const v = formatTokenAmount(opts); + return v != null ? String(v) : ''; +}; export const prettyBigintFormat = ( { value, expo }: { value?: bigint; expo: number }, ) => { @@ -67,6 +75,18 @@ export const formalNumberFormat = ( })).format(val); }; +/** Format USD for display (e.g. $1,234.56). Use for hfAMM total value. */ +export const formatUsd = (val?: number | null): string => { + if (val == null || Number.isNaN(val)) return '—'; + if (val < 0.01 && val > 0) return '<$0.01'; + return new Intl.NumberFormat('en-US', { + style: 'currency', + currency: 'USD', + minimumFractionDigits: 2, + maximumFractionDigits: 2, + }).format(val); +}; + export const formalBigIntFormat = ({ val, expo, round }: { val?: bigint; expo: number; @@ -77,6 +97,15 @@ export const formalBigIntFormat = ({ val, expo, round }: { return formalNumberFormat(numval, round); }; +/** Format bigint as full number (no K/M/B shortening). Use for TVL when you want exact values. */ +export const fullNumberBigintFormat = ( + { value, expo }: { value?: bigint; expo: number }, +) => { + if (value == null) return ''; + const numval = Number(formatUnits(BigInt(value), expo)); + return formalNumberFormat(numval); +}; + export const base64ToHex = (b64: string) => Array.from( atob( diff --git a/src/utils/shared.ts b/src/lib/shared.ts similarity index 100% rename from src/utils/shared.ts rename to src/lib/shared.ts diff --git a/src/lib/utils.ts b/src/lib/utils.ts index 07f1ce8..13719cd 100644 --- a/src/lib/utils.ts +++ b/src/lib/utils.ts @@ -1,11 +1,27 @@ import { - type AccountId, + AccountId, AccountInterface, Address, Felt, WebClient, Word, } from '@miden-sdk/miden-sdk'; + +export function accountIdFromPrefixSuffix( + prefix: Felt, + suffix: Felt, +): AccountId { + const prefixHex = prefix.asInt().toString(16).padStart(16, '0'); + const suffixHex = (suffix.asInt() >> 8n).toString(16).padStart(14, '0'); + const accountId = AccountId.fromHex('0x' + prefixHex + suffixHex); + + // const prefixValue = prefix.asInt(); + // const suffixValue = suffix.asInt(); + // const combined = (prefixValue << 64n) | suffixValue; + // const hex = '0x' + combined.toString(16); + // const accountId = AccountId.fromHex(hex); + return accountId; +} import { type ClassValue, clsx } from 'clsx'; import { twMerge } from 'tailwind-merge'; import { createNetworkId, NETWORK } from './config'; @@ -17,16 +33,12 @@ export function cn(...inputs: ClassValue[]) { export const instantiateClient = async ( { accountsToImport }: { accountsToImport: (AccountId | undefined)[] }, ) => { - const { Address: DynamicAddress, WebClient } = await import( - '@miden-sdk/miden-sdk' - ); const client = await WebClient.createClient(NETWORK.rpcEndpoint); for (const acc of accountsToImport) { if (!acc) continue; try { - // Convert to bech32 and back to ensure we have an AccountId from the same module instance const bech32 = acc.toBech32(createNetworkId(), AccountInterface.BasicWallet); - const accountId = DynamicAddress.fromBech32(bech32).accountId(); + const accountId = Address.fromBech32(bech32).accountId(); await safeAccountImport(client, accountId); } catch (e) { console.error(e); @@ -50,7 +62,9 @@ export const safeAccountImport = async (client: WebClient, accountId: AccountId) export const accountIdToBech32 = ( accountId: AccountId, ) => { - return accountId.toBech32(createNetworkId(), AccountInterface.BasicWallet).split('_')[0]; + return accountId.toBech32(createNetworkId(), AccountInterface.BasicWallet).split( + '_', + )[0]; }; export const bech32ToAccountId = (bech32str?: string) => { diff --git a/src/lib/xykMath.ts b/src/lib/xykMath.ts new file mode 100644 index 0000000..a65c24e --- /dev/null +++ b/src/lib/xykMath.ts @@ -0,0 +1,86 @@ +/** + * XYK pool math matching on-chain logic (constant product, 30BP fee). + * All amounts are in token/LP raw units (e.g. 10^decimals). + */ + +/** Integer square root (floor). */ +export function isqrt(n: bigint): bigint { + if (n < 0n) throw new Error('isqrt: negative'); + if (n < 2n) return n; + let x = n; + let y = (x + 1n) / 2n; + while (y < x) { + x = y; + y = (x + n / x) / 2n; + } + return x; +} + +/** + * Expected LP tokens received for depositing amount0 and amount1. + * When total_lp === 0 (first deposit): isqrt(amount0 * amount1) - 100. + * Otherwise: min(amount0 * total_lp / reserve0, amount1 * total_lp / reserve1). + */ +export function computeExpectedLp( + amount0: bigint, + amount1: bigint, + reserve0: bigint, + reserve1: bigint, + totalLp: bigint, +): bigint { + if (totalLp === 0n) { + const product = amount0 * amount1; + const root = isqrt(product); + return root > 100n ? root - 100n : 0n; + } + const lp0 = (amount0 * totalLp) / reserve0; + const lp1 = (amount1 * totalLp) / reserve1; + return lp0 < lp1 ? lp0 : lp1; +} + +/** + * Expected token amounts received when burning lpAmount. + * amount0 = lp_amount * reserve_0 / total_supply, amount1 = lp_amount * reserve_1 / total_supply. + */ +export function computeExpectedWithdraw( + totalSupply: bigint, + lpAmount: bigint, + reserve0: bigint, + reserve1: bigint, +): [bigint, bigint] { + if (totalSupply === 0n) return [0n, 0n]; + const amount0 = (lpAmount * reserve0) / totalSupply; + const amount1 = (lpAmount * reserve1) / totalSupply; + return [amount0, amount1]; +} + +/** + * Exact tokens for tokens: amount out for a given amount in (30BP fee). + * get_amount_out(amount_in, reserve_in, reserve_out). + */ +export function getAmountOut( + amountIn: bigint, + reserveIn: bigint, + reserveOut: bigint, +): bigint { + const feeAdjusted = amountIn * 997n; + const numerator = reserveOut * feeAdjusted; + const denominator = reserveIn * 1000n + feeAdjusted; + return numerator / denominator; +} + +/** + * Expected tokens for exact tokens (reverse quote): amount in needed to get amount_out. + * get_amount_in(amount_out, reserve_in, reserve_out). + */ +export function getAmountIn( + amountOut: bigint, + reserveIn: bigint, + reserveOut: bigint, +): bigint { + const amountOutScaled = amountOut * 1000n; + const numerator = reserveIn * amountOutScaled; + const denominator = (reserveOut - amountOut) * 997n; + if (denominator <= 0n) return 0n; + return numerator / denominator; +} diff --git a/src/masm/accounts/lp_local.masm b/src/masm/accounts/lp_local.masm new file mode 100644 index 0000000..1b3c3b9 --- /dev/null +++ b/src/masm/accounts/lp_local.masm @@ -0,0 +1,629 @@ +use miden::core::math::u64 +use miden::core::crypto::hashes::rpo256 +use miden::protocol::note +use miden::protocol::native_account +use miden::protocol::active_note +use miden::protocol::active_account +use miden::protocol::output_note +use miden::core::sys +use miden::protocol::account_id +use miden::standards::attachments::network_account_target + +use zoro::math +use zoro::storage_utils + +pub const DEFAULT_TAG = 0 +pub const DEFAULT_EXEC_HINT = 1 + +const MINIMUM_LIQUIDITY = 100 + +### MEMORY LOCATIONS +const SCRATCH_SPACE = 0 + +const OUTPUT_NOTE_INPUTS_START = SCRATCH_SPACE +const OUTPUT_NOTE_INPUT_0 = SCRATCH_SPACE +const OUTPUT_NOTE_INPUT_1 = SCRATCH_SPACE + 1 +const OUTPUT_NOTE_INPUT_2 = SCRATCH_SPACE + 2 +const OUTPUT_NOTE_INPUT_3 = SCRATCH_SPACE + 3 +const OUTPUT_NOTE_INPUT_4 = SCRATCH_SPACE + 4 +const OUTPUT_NOTE_INPUT_5 = SCRATCH_SPACE + 5 +const OUTPUT_NOTE_INPUT_6 = SCRATCH_SPACE + 6 +const OUTPUT_NOTE_INPUT_7 = SCRATCH_SPACE + 7 + + +const DYNAMIC_PROC_ADDR = 16 + +const USER_ACCOUNT_ID_WORD = 20 +const USER_ACCOUNT_ID_PREFIX = 20 +const USER_ACCOUNT_ID_SUFFIX = 21 + +const NOTE_DETAILS_WORD = 24 +const NOTE_TAG = 24 +const NOTE_TYPE = 25 + +const OUTPUT_NOTE_ROOT_HASH = 28 +const INPUT_NOTE_SERIAL = 32 + +### ERROR CODES +const ERR_ZERO_ADDRESS = "Zero address" +const ERR_UNKNOWN_ASSET = "Unknown asset" +const ERR_BOTH_ASSETS_ARE_THE_SAME = "Both assets are the same" + +### STORAGE SLOTS +const ASSETS_MAPPING_SLOT = word("zoro::lp_local::assets_mapping") +const RESERVE_SLOT = word("zoro::lp_local::reserve") +const TOTAL_SUPPLY_SLOT = word("zoro::lp_local::total_supply") +const USER_DEPOSITS_MAPPING_SLOT = word("zoro::lp_local::user_deposits_mapping") + +const REGISTRY_ID_SLOT = word("zoro::lp_local::registry_id") +const REGISTER_NOTE_ROOT_SLOT = word("zoro::lp_local::register_note_root") + +################# +#! Computes the LP amount out for a deposit of amount_0 and amount_1. +#! For initial deposit (the total supply is 0), it subracts the minimum liquidity. +#! Inputs: [total_supply, amount_0, amount_1, reserve_0, reserve_1] +#! Outputs: [lp_amount_out] +@locals(5) +pub proc get_lp_amount_out(total_supply: felt, amount_0: felt, amount_1: felt, reserve_0: felt, reserve_1: felt) -> felt + # loc.0: total_supply + # loc.1: amount_0 + # loc.2: amount_1 + # loc.3: reserve_0 + # loc.4: reserve_1 + loc_store.0 loc_store.1 loc_store.2 loc_store.3 loc_store.4 + # => [] + ### if total_supply == 0 + loc_load.0 eq.0 + if.true + ### initial deposit: - MINIMUM_LIQUIDITY + loc_load.1 loc_load.2 mul exec.math::sqrt + # => [lp_amount] + push.MINIMUM_LIQUIDITY exec.math::safe_sub + # => [lp_amount_out] + ### return lp_amount_out + else + ### subsequent deposits: min(lp_amount_0, lp_amount_1) + ### lp_amount_0 = amount_0 * total_supply / reserve_0 + loc_load.1 u32split loc_load.0 u32split + # => [amount_0_high, amount_0_low, total_supply_high, total_supply_low] + exec.u64::wrapping_mul + # => [numerator_high, numerator_low] + loc_load.3 u32split + # => [reserve_0_hi, reserve_0_low, numerator_hi, numerator_low] + exec.u64::div + # => [lp_amount_0_high, lp_amount_0_low] + loc_load.2 u32split loc_load.0 u32split exec.u64::wrapping_mul loc_load.4 u32split exec.u64::div + # => [lp_amount_1_high, lp_amount_1_low, lp_amount_0_high, lp_amount_0_low] + exec.u64::min + # => [lp_amount_out_high, lp_amount_out_low] + exec.math::safe_cast_u64_into_felt + # => [lp_amount_out] + ### return lp_amount_out + end + exec.sys::truncate_stack +end + +@locals(5) +pub proc simulate_withdraw#(total_suppply: felt, lp_amount: felt, reserve_0: felt, reserve_1: felt) -> (felt, felt) + # loc.0: total_suppply + # loc.1: lp_amount + # loc.2: reserve_0 + # loc.3: reserve_1 + # loc.4: amount_0_out + loc_store.0 loc_store.1 loc_store.2 loc_store.3 + # => [] + ### amount0 = lp_amount.mul(reserve) / total_suppply; // using balances ensures pro-rata distribution + loc_load.1 u32split loc_load.2 u32split exec.u64::wrapping_mul loc_load.0 u32split exec.u64::div + # => [amount_0_out_high, amount_0_out_low] + exec.math::safe_cast_u64_into_felt loc_store.4 + loc_load.1 u32split loc_load.3 u32split exec.u64::wrapping_mul loc_load.0 u32split exec.u64::div + # => [amount_1_out_high, amount_1_out_low] + exec.math::safe_cast_u64_into_felt + # => [amount_1_out] + loc_load.4 + # => [amount_0_out, amount_1_out] + ### truncate stack + exec.sys::truncate_stack +end + +proc assert_non_zero_address(prefix: felt, suffix: felt) + neq.0 swap.1 neq.0 + # => [is_suffix_non_zero, is_prefix_non_zero] + or assert.err=ERR_ZERO_ADDRESS +end +proc get_current_user_deposit_key() -> Word + mem_load.USER_ACCOUNT_ID_SUFFIX mem_load.USER_ACCOUNT_ID_PREFIX + exec.assert_non_zero_address + mem_load.USER_ACCOUNT_ID_SUFFIX mem_load.USER_ACCOUNT_ID_PREFIX + # => [prefix, suffix] + exec.get_user_deposit_key + # => [USER_DEPOSIT_KEY] + exec.sys::truncate_stack +end + +proc get_user_deposit_key#(prefix: felt, suffix: felt) -> Word + push.0.0 movup.3 movup.3 + # => [prefix, suffix, 0 , 0 ] == [KEY] +end + +pub proc total_supply#() -> felt + push.TOTAL_SUPPLY_SLOT[0..2] exec.active_account::get_item + drop drop drop + exec.sys::truncate_stack +end + +pub proc get_reserves#() -> (felt, felt) + push.RESERVE_SLOT[0..2] exec.active_account::get_item + drop drop +end + +pub proc set_reserves(reserve_0: felt, reserve_1: felt) + ### amount validation vs vault + push.0.0 push.RESERVE_SLOT[0..2] exec.native_account::set_item + dropw +end + +pub proc get_pool_asset_ids() -> word + padw push.ASSETS_MAPPING_SLOT[0..2] exec.active_account::get_map_item + exec.sys::truncate_stack +end + +pub proc get_registry_id() -> (felt, felt) + push.REGISTRY_ID_SLOT[0..2] exec.active_account::get_item + drop drop + exec.sys::truncate_stack +end + +#! Returns user deposit (least significant felt) for verification in fuzz tests. +#! Inputs: [prefix, suffix] +pub proc get_user_deposit#(prefix: felt, suffix: felt) -> felt + exec.get_user_deposit_key + push.USER_DEPOSITS_MAPPING_SLOT[0..2] exec.active_account::get_map_item + drop drop drop + exec.sys::truncate_stack +end + +#### PROCEDURES BY ASSET ID #### +@locals(2) +pub proc get_reserve_by_asset_id(asset_id_prefix: felt, asset_id_suffix: felt) -> felt + exec.get_asset_index + eq.0 + if.true # asset 0 + exec.get_reserves + # => [reserve_0, reserve_1] + swap drop + # => [reserve_0] + else # asset 1 + exec.get_reserves + # => [reserve_0, reserve_1] + drop + # => [reserve_1] + end + exec.sys::truncate_stack +end + + +pub proc is_token_0(asset_id_prefix: felt, asset_id_suffix: felt) -> i1 + exec.get_pool_asset_ids movup.2 drop movup.2 drop + # => [asset_0_id_prefix, asset_0_id_suffix, asset_id_prefix, asset_id_suffix] + exec.account_id::is_equal + # => + exec.sys::truncate_stack +end +pub proc is_token_1(asset_id_prefix: felt, asset_id_suffix: felt) -> i1 + exec.get_pool_asset_ids drop drop + # => [asset_1_id_prefix, asset_1_id_suffix, asset_id_prefix, asset_id_suffix] + exec.account_id::is_equal + # => + exec.sys::truncate_stack +end + +@locals(2) +pub proc get_asset_index(asset_id_prefix: felt, asset_id_suffix: felt) -> felt + # loc.0: asset_id_prefix + # loc.1: asset_id_suffix + loc_store.0 loc_store.1 + loc_load.1 loc_load.0 + # => [asset_id_suffix, asset_id_prefix] + exec.is_token_0 + if.true + ### index = 0 + push.0 + # => [0] + else + loc_load.1 loc_load.0 + # => [asset_id_suffix, asset_id_prefix] + exec.is_token_1 + # => [is_one] + ### index not found + assert.err=ERR_UNKNOWN_ASSET + ### index = 1 + push.1 + # => [1] + end + exec.sys::truncate_stack +end + + +@locals(2) +pub proc add_to_reserves#(amount_0: felt, amount_1: felt) + # loc.0: amount_0 + # loc.1: amount_1 + loc_store.0 loc_store.1 + # => [] + exec.get_reserves + # => [reserve_0, reserve_1] + loc_load.0 exec.math::safe_add + # => [new_reserve_0, reserve_1] + swap + loc_load.1 exec.math::safe_add + # => [new_reserve_1, new_reserve_0] + swap + # => [new_reserve_0, new_reserve_1] + exec.set_reserves + # => [] + exec.sys::truncate_stack +end + +@locals(2) +pub proc sub_from_reserves#(amount_0: felt, amount_1: felt) + # loc.0: amount_0 + # loc.1: amount_1 + loc_store.0 loc_store.1 + # => [] + exec.get_reserves + # => [reserve_0, reserve_1] + loc_load.0 exec.math::safe_sub + # => [new_reserve_0, reserve_1] + swap + loc_load.1 exec.math::safe_sub + # => [new_reserve_1, new_reserve_0] + swap + # => [new_reserve_0, new_reserve_1] + exec.set_reserves +end + +@locals(4) +pub proc set_reserve_by_asset_id(amount: felt, asset_id_prefix: felt, asset_id_suffix: felt) + # loc.0: amount + # loc.1: asset_id_prefix + # loc.2: asset_id_suffix + # loc.3: asset_index + loc_store.0 loc_store.1 loc_store.2 + # => [] + loc_load.2 loc_load.1 + # => [asset_id_prefix, asset_id_suffix] + exec.get_asset_index loc_store.3 + exec.get_reserves + # => [reserve_0, reserve_1] + loc_load.3 + # => [asset_index, reserve_0, reserve_1] + ### if asset_index == 0 set reserve_0 + eq.0 if.true + # => [reserve_0, reserve_1] + drop loc_load.0 + # => [new_reserve_0, reserve_1] + else ### check if asset_index == 1 + # => [reserve_0, reserve_1] + loc_load.3 eq.1 if.true + # => [reserve_0, reserve_1] + loc_load.0 + # => [new_reserve_1, reserve_0, reserve_1] + swap.2 drop + # => [reserve_0, new_reserve_1] + else + ### asset index not found + assert.err=ERR_UNKNOWN_ASSET + end + end + exec.set_reserves + # => [] + exec.sys::truncate_stack +end + +@locals(4) +pub proc add_to_reserve_by_asset_id(amount: felt, asset_id_prefix: felt, asset_id_suffix: felt) + # loc.0: amount + # loc.1: asset_id_prefix + # loc.2: asset_id_suffix + # loc.3: new_reserve + loc_store.0 loc_store.1 loc_store.2 + # => [] + loc_load.2 loc_load.1 + # => [asset_id_prefix, asset_id_suffix] + exec.get_reserve_by_asset_id loc_load.0 + # => [amount, reserve] + exec.math::safe_add + # => [new_reserve] + loc_store.3 + # => [] + loc_load.2 loc_load.1 loc_load.3 + # => [ new_reserve, asset_id_suffix, asset_id_prefix] + exec.set_reserve_by_asset_id + # => [] +end + +@locals(3) +pub proc sub_from_reserve_by_asset_id(amount: felt, asset_id_prefix: felt, asset_id_suffix: felt) + # loc.0: amount + # loc.1: asset_id_prefix + # loc.2: asset_id_suffix + loc_store.0 loc_store.1 loc_store.2 + # => [] + loc_load.2 loc_load.1 + # => [asset_id_prefix, asset_id_suffix] + exec.get_reserve_by_asset_id loc_load.0 + # => [amount, reserve] + exec.math::safe_sub + # => [new_reserve] + loc_load.2 loc_load.1 movup.2 + # => [ new_reserve, asset_id_suffix, asset_id_prefix] + exec.set_reserve_by_asset_id + # => [] +end + +@locals(3) +proc mint#(amount: felt, beneficiary_prefix: felt, beneficiary_suffix: felt) + # loc.0: amount + # loc.1: beneficiary_prefix + # loc.2: beneficiary_suffix + loc_store.0 loc_store.1 loc_store.2 + # => [] + push.0.0.0 loc_load.0 + # => [AMOUNT] + ### add to mapping USER_DEPOSITS_MAPPING_SLOT + loc_load.2 loc_load.1 exec.get_user_deposit_key + # => [USER_DEPOSIT_KEY, AMOUNT] + push.USER_DEPOSITS_MAPPING_SLOT[0..2] + # => [slot_id_prefix, slot_id_suffix, USER_DEPOSIT_KEY, AMOUNT] + exec.storage_utils::add_to_map_item + ### updating total supply + loc_load.0 push.TOTAL_SUPPLY_SLOT[0..2] exec.storage_utils::add_to_storage_item + drop +end + +@locals(3) +proc burn#(amount: felt, own_prefix: felt, own_suffix: felt) + # loc.0: amount + # loc.1: owner_prefix + # loc.2: own_suffix + loc_store.0 loc_store.1 loc_store.2 + # => [] + push.0.0.0 loc_load.0 + # => [AMOUNT] + ### sub from mapping USER_DEPOSITS_MAPPING_SLOT + loc_load.2 loc_load.1 exec.get_user_deposit_key + # => [USER_DEPOSIT_KEY, AMOUNT] + push.USER_DEPOSITS_MAPPING_SLOT[0..2] + # => [slot_id_prefix, slot_id_suffix, USER_DEPOSIT_KEY, AMOUNT] + exec.storage_utils::sub_from_map_item + drop + ### updating total supply + loc_load.0 push.TOTAL_SUPPLY_SLOT[0..2] exec.storage_utils::sub_from_storage_item + drop +end + +#! add new deposit: receive asset, update deposit mapping +#! +#! Inputs: [ASSET0, ASSET1, user_id_prefix, user_id_suffix] +#! Outputs: [share_amount_out] +#! +@locals(9) +pub proc deposit + # loc.0: ASSET0 + # loc.4: ASSET1 + # loc.8: lp_amount_out + loc_storew_le.0 dropw loc_storew_le.4 dropw mem_store.USER_ACCOUNT_ID_PREFIX mem_store.USER_ACCOUNT_ID_SUFFIX + ### get_lp_amount_out (total_supply: felt, amount_0: felt, amount_1: felt, reserve_0: felt, reserve_1: felt) + exec.get_reserves + # => [reserve_0, reserve_1] + loc_load.7 loc_load.3 + # => [amount_0, amount_1, reserve_0, reserve_1] + exec.total_supply + # => [total_supply, amount_0, amount_1, reserve_0, reserve_1] + exec.get_lp_amount_out + # => [lp_amount_out] + loc_store.8 + ### check if initial deposit + exec.total_supply eq.0 + if.true + ### burn minimum liquidity + push.0.0 push.MINIMUM_LIQUIDITY + exec.mint + + ### send register pool note to the registry + # exec.create_register_pool_note + end + ### mint to user + + mem_load.USER_ACCOUNT_ID_SUFFIX mem_load.USER_ACCOUNT_ID_PREFIX + loc_load.8 + # => [lp_amount_out,user_id_prefix, user_id_suffix] + exec.mint + + ### receive assets + ### reserve needs to be updated + #padw padw padw loc_loadw_be.0 exec.receive_asset + #padw padw padw loc_loadw_be.4 exec.receive_asset + padw loc_loadw_le.0 exec.native_account::add_asset dropw + padw loc_loadw_le.4 exec.native_account::add_asset dropw + ## make sure asset order correct + loc_load.7 loc_load.3 + # => [amount_0, amount_1] + exec.add_to_reserves +end + +#! add new deposit: receive asset, update deposit mapping +#! amount0 = liquidity.mul(balance0) / _totalSupply; // using balances ensures pro-rata distribution +#! amount1 = liquidity.mul(balance1) / _totalSupply; // using balances ensures pro-rata distribution +#! Inputs: [LP_AMOUNT user_id_prefix, user_id_suffix] +#! Outputs: [amount_0_out, amount_1_out] +#! +@locals(6) +pub proc withdraw(lp_amount: word, user_id_prefix: felt, user_id_suffix: felt, note_tag: felt, note_type: felt, output_note_root: word, input_note_serial: word) -> (felt, felt) + # loc.0: LP_AMOUNT = EMPTY, EMPTY, EMPTY, LP_AMOUNT loc.3 lp_amount + # loc.4: amount_0_out + # loc.5: amount_1_out + ## LP_AMOUNT to local + loc_storew_le.0 dropw + ## user, note tag and type, output note root hash, and input note serial to memory + mem_store.USER_ACCOUNT_ID_PREFIX mem_store.USER_ACCOUNT_ID_SUFFIX + mem_store.NOTE_TAG mem_store.NOTE_TYPE + mem_storew_le.OUTPUT_NOTE_ROOT_HASH dropw + mem_storew_le.INPUT_NOTE_SERIAL dropw + + # => [] + ### get_lp_amount_out (total_supply: felt, amount_0: felt, amount_1: felt, reserve_0: felt, reserve_1: felt) + exec.get_reserves + # => [reserve_0, reserve_1] + loc_load.3 + # => [lp_amount, reserve_0, reserve_1] + exec.total_supply + ## => [total_supply, lp_amount, reserve_0, reserve_1] + exec.simulate_withdraw + ## => [amount_0_out, amount_1_out] + loc_store.4 loc_store.5 + loc_load.5 loc_load.4 + ## => [amount_0_out, amount_1_out] + exec.create_withdraw_return_note + ## => [note_id] + drop + + #### burn: updates user_deposits_mapping and total_supply + mem_load.USER_ACCOUNT_ID_SUFFIX mem_load.USER_ACCOUNT_ID_PREFIX loc_load.3 + ## => [lp_amount, user_id_prefix, user_id_suffix] + exec.burn + # + loc_load.5 loc_load.4 + ## => [amount_0_out, amount_1_out] + exec.sub_from_reserves + # => [] + loc_load.5 loc_load.4 + exec.sys::truncate_stack + +end + + +### MEMORY SETTING PROCEDURES aka. global variables +proc mem_set_user_id(user_id_prefix: felt, user_id_suffix: felt) + mem_store.USER_ACCOUNT_ID_PREFIX mem_store.USER_ACCOUNT_ID_SUFFIX +end + +proc mem_get_user_id() -> (felt, felt) + mem_load.USER_ACCOUNT_ID_PREFIX mem_load.USER_ACCOUNT_ID_SUFFIX + exec.sys::truncate_stack +end +proc mem_get_note_tag() -> felt + mem_load.NOTE_TAG + exec.sys::truncate_stack +end +proc mem_get_note_type() -> felt + mem_load.NOTE_TYPE + exec.sys::truncate_stack +end + + +@locals(8) +proc create_withdraw_return_note(amount_0_out: felt, amount_1_out: felt) -> felt + # loc.0: amount_0_out + # loc.1: amount_1_out + # loc.3: asset_0_id_prefix + # loc.4: asset_0_id_suffix + + loc_store.0 loc_store.1 + ##### compute recipient hash + padw mem_loadw_le.OUTPUT_NOTE_ROOT_HASH exec.compute_return_note_serial_num + # => [WITHDRAW_SERIAL_NUM, OUTPUT_NOTE_ROOT_HASH] + + mem_load.USER_ACCOUNT_ID_SUFFIX mem_store.0 + mem_load.USER_ACCOUNT_ID_PREFIX mem_store.1 +# + push.2 push.0 + ## => [inputs_ptr, number_of_inputs, WITHDRAW_SERIAL_NUM, OUTPUT_NOTE_ROOT_HASH] + exec.note::build_recipient + # => [RECIPIENT] + mem_load.NOTE_TYPE mem_load.NOTE_TAG + # => [note_type, note_tag, RECIPIENT] + exec.output_note::create + # => [note_id] + loc_store.2 + # => [] + ### get assets ids + push.0.0.0.0 push.ASSETS_MAPPING_SLOT[0..2] exec.active_account::get_map_item + # => [asset_0_id_prefix, asset_0_id_suffix, asset_1_id_prefix, asset_1_id_suffix] + loc_store.3 loc_store.4 + push.0 movdn.2 loc_load.1 movdn.3 + # => [ASSET1_OUT] + exec.native_account::remove_asset + # => [ASSET1_uOUT] + loc_load.2 movdn.4 + # => [ASSET1_OUT, note_id] + exec.output_note::add_asset + # => [] + loc_load.0 + # => [asset_0_out] + push.0 loc_load.4 loc_load.3 + # => [ASSET0_OUT] + exec.native_account::remove_asset + # => [ASSET0_OUT] + loc_load.2 movdn.4 + # => [ASSET0_OUT, note_id] + exec.output_note::add_asset + # => [] +end + + +proc compute_return_note_serial_num() -> word + exec.active_note::get_serial_number + # padw mem_loadw_le.INPUT_NOTE_SERIAL + # => [INPUT_NOTE_SERIAL] + add.1 + # => [SERIAL_NUM] +end + +@locals(1) +proc create_register_pool_note() + #loc.0: note_id + push.0 drop + ### store note inputs in memory + exec.active_account::get_id + # =>[pool_id_prefix, pool_id_suffix] + exec.get_pool_asset_ids + # =>[asset_0_id_prefix, asset_0_id_suffix, asset_1_id_prefix, asset_1_id_suffix, pool_id_prefix, pool_id_suffix] + mem_store.OUTPUT_NOTE_INPUT_0 mem_store.OUTPUT_NOTE_INPUT_1 mem_store.OUTPUT_NOTE_INPUT_2 + mem_store.OUTPUT_NOTE_INPUT_3 mem_store.OUTPUT_NOTE_INPUT_4 mem_store.OUTPUT_NOTE_INPUT_5 + # => [] + exec.get_registry_id + # => [registry_id_prefix, registry_id_suffix] + mem_store.OUTPUT_NOTE_INPUT_6 mem_store.OUTPUT_NOTE_INPUT_7 + # => [] + ### load root hash and serial + push.REGISTER_NOTE_ROOT_SLOT[0..2] + exec.active_account::get_item + # => [ REGISTER_NOTE_ROOT ] + exec.compute_return_note_serial_num + ## => [REGISTER_SERIAL_NUM, REGISTER_NOTE_ROOT ] + ### push nr of inputs + push.8 + push.OUTPUT_NOTE_INPUTS_START + ## => [inputs_ptr, number_of_inputs, REGISTER_SERIAL_NUM, REGISTER_NOTE_ROOT] + exec.note::build_recipient + # => [RECIPIENT] + push.1 + # => [note_type, RECIPIENT] + push.0 + # => [note_tag, note_type, RECIPIENT] + exec.output_note::create loc_store.0 + # => [] + ### add network tx attachment + push.DEFAULT_EXEC_HINT + exec.get_registry_id swap + ## => [target_id_suffix, target_id_prefix, exec_hint] + exec.network_account_target::new + ## => [attachment_scheme, attachment_kind, NOTE_ATTACHMENT] + loc_load.0 + ## => [note_id, attachment_scheme, attachment_kind, NOTE_ATTACHMENT] + exec.output_note::set_attachment + # => [] + exec.sys::truncate_stack +end diff --git a/src/masm/accounts/math.masm b/src/masm/accounts/math.masm new file mode 100644 index 0000000..533940c --- /dev/null +++ b/src/masm/accounts/math.masm @@ -0,0 +1,172 @@ +use miden::core::math::u64 +use miden::core::sys + +const ERR_UNDERFLOW = "Underflow" +const ERR_OVERFLOW = "Overflow" + + +const U32_MAX = 4294967295 +const FELT_MAX = 0xFFFFFFFF00000000 +const U32_BOUND = 0x100000000 + + +########## math +@locals(4) +pub proc sqrt_u32(n: u32) -> u32 # floor(sqrt(n)) via bit-by-bit construction. + # loc.0: n loc.1: result loc.2: bit loc.3: tmp + loc_store.0 # save n + push.0 loc_store.1 # result starts as 0 + push.0x40000000 loc_store.2 # bit starts as 1<<30 + push.0 loc_store.3 # tmp starts as 0 + + repeat.16 + loc_load.1 loc_load.2 u32wrapping_add loc_store.3 # tmp = result + bit + + loc_load.0 loc_load.3 u32gte # if n >= tmp + if.true + loc_load.0 loc_load.3 u32wrapping_sub loc_store.0 # n = n - tmp + loc_load.1 u32shr.1 loc_load.2 u32wrapping_add loc_store.1 # result = (result >> 1) + bit + else + loc_load.1 u32shr.1 loc_store.1 # result = result >> 1 + end + loc_load.2 u32shr.2 loc_store.2 # bit = bit >> 2 + end + + loc_load.1 + exec.sys::truncate_stack +end + + +@locals(8) +pub proc sqrt(n: felt) -> felt + # floor(sqrt(n)) via bit-by-bit construction. + # For any u32 candidate c, c^2 < field prime p, so felt mul is exact. + # + # loc.0: n_hi loc.1: n_lo loc.2: result_hi loc.3: result_lo + # loc.4: bit_hi loc.5: bit_lo loc.6: tmp_hi loc.7: tmp_lo + u32split loc_store.0 loc_store.1 + push.0.0 loc_store.2 loc_store.3# result starts as 0 + push.0x4000000000000000 u32split loc_store.4 loc_store.5 # bit starts as 1<<62 + push.0.0 loc_store.6 loc_store.7 # tmp starts as 0 + + repeat.32 + ### tmp = result + bit + loc_load.3 loc_load.2 + # => [res_hi, res_lo] + loc_load.5 loc_load.4 + # => [bit_hi, bit_lo, res_hi, res_lo] + exec.u64::wrapping_add loc_store.6 loc_store.7 + + ### compare n >= result + bit (n >= tmp) + loc_load.1 loc_load.0 + # => [n_hi, n_lo] + loc_load.7 loc_load.6 + # => [tmp_hi, tmp_lo, n_hi, n_lo] + exec.u64::gte + if.true + ### n = n - (result + bit) + ### n = n - tmp + loc_load.1 loc_load.0 + # => [n_hi, n_lo] + loc_load.7 loc_load.6 + # => [tmp_hi, tmp_lo, n_hi, n_lo] + exec.u64::wrapping_sub loc_store.0 loc_store.1 + + ### result = result + bit + loc_load.5 loc_load.4 + # => [bit_hi, bit_lo] + loc_load.3 loc_load.2 push.1 exec.u64::shr + # => [res_hi, res_lo, bit_hi, bit_lo] + exec.u64::wrapping_add loc_store.2 loc_store.3 + + else + ### result = result >> 1 + loc_load.3 loc_load.2 push.1 exec.u64::shr loc_store.2 loc_store.3 + + end + + ### bit = bit >> 2 + loc_load.5 loc_load.4 + # => [bit_hi, bit_lo] + push.2 exec.u64::shr loc_store.4 loc_store.5 + + end + ### return result + loc_load.3 loc_load.2 + exec.safe_cast_u64_into_felt + + # => [result] + exec.sys::truncate_stack +end + +@locals(3) +pub proc safe_cast_u64_into_felt(a: u64) -> felt + ### loc.0: a_hi + ### loc.1: a_hi_scaled + ### loc.2: res + # => [a_hi, a_lo] + loc_store.0 loc_load.0 + mul.U32_BOUND loc_store.1 + loc_load.1 loc_load.0 gte assert.err=ERR_OVERFLOW + # => [a_lo] + loc_load.1 + # => [a_hi_scaled, a_lo] + add loc_store.2 + ### if res < a_hi_scaled => overflow + loc_load.2 loc_load.1 + # => [a_hi_scaled, res] + gte assert.err=ERR_OVERFLOW + loc_load.2 +end + + + +@locals(2) +pub proc safe_sub(b: felt, a: felt) -> felt + # loc.0: a + # loc.1: res + swap loc_store.0 + loc_load.0 swap + sub loc_store.1 + loc_load.1 loc_load.0 + # [a, res] + lte assert.err=ERR_UNDERFLOW + loc_load.1 +end + +@locals(2) +pub proc safe_add(b: felt, a: felt) -> felt + # loc.0: b + # loc.1: res + loc_store.0 + loc_load.0 + add loc_store.1 + loc_load.1 loc_load.0 + # [a, res] + gte assert.err=ERR_OVERFLOW + loc_load.1 +end + + +#! retuns 0 if equal, 1 if felt_1 smaller, 2 if felt_1 bigger +@locals(2) +pub proc compare_felts(felt_0: felt, felt_1: felt) -> u8 + # loc.0: felt_0 + # loc.1: felt_1 + loc_store.0 loc_store.1 + # => [] + loc_load.1 u32split loc_load.0 u32split exec.u64::lt + # => [is_felt_0_smaller ] + if.true # felt_1 smaller + push.1 + else # if felt_1 is bigger + loc_load.1 u32split loc_load.0 u32split exec.u64::gt + # => [is_felt_0_bigger ] + if.true # felt_0 bigger + push.2 + else # felt_0 and felt_1 are equal + push.0 + end + end + +end diff --git a/src/masm/accounts/storage_utils.masm b/src/masm/accounts/storage_utils.masm new file mode 100644 index 0000000..b7c9540 --- /dev/null +++ b/src/masm/accounts/storage_utils.masm @@ -0,0 +1,133 @@ +use miden::protocol::active_account +use miden::protocol::native_account +use miden::core::sys + +use zoro::math + + +@locals(3) +pub proc add_to_storage_item(slot_id_prefix: felt, slot_id_suffix: felt, increment_by: felt) -> felt + # loc.0: slot_id_prefix + # loc.1: slot_id_suffix + # loc.2: new_value + loc_store.0 loc_store.1 + # => [increment_by] + loc_load.1 loc_load.0 exec.active_account::get_item + drop drop drop + # => [current_value, increment_by] + exec.math::safe_add + # => [new_value] + loc_store.2 + ### get current value + loc_load.2 push.0.0.0 + # => [NEW_VALUE] + loc_load.1 loc_load.0 exec.native_account::set_item + dropw + # => [] + loc_load.2 + # => [new_value] + exec.sys::truncate_stack +end + +@locals(3) +pub proc sub_from_storage_item(slot_id_prefix: felt, slot_id_suffix: felt, decrement_by: felt) -> felt + # loc.0: slot_id_prefix + # loc.1: slot_id_suffix + # loc.2: new_value + loc_store.0 loc_store.1 + # => [sub_by] + loc_load.1 loc_load.0 exec.active_account::get_item + drop drop drop + # => [current_value, sub_by] + swap + exec.math::safe_sub + # => [new_value] + loc_store.2 + ### get current value + loc_load.2 push.0.0.0 + # => [NEW_VALUE] + loc_load.1 loc_load.0 exec.native_account::set_item + dropw + # => [] + loc_load.2 + # => [new_value] + exec.sys::truncate_stack +end + + + + +#! Atomically increments a storage map entry: map[KEY] += VALUE. +#! Reads the current value via get_map_item, adds VALUE, writes back via set_map_item. +#! +#! Inputs: [slot_id_prefix, slot_id_suffix, KEY, VALUE] +#! Outputs: [] +@locals(9) +pub proc sub_from_map_item (slot_id_prefix: felt, slot_id_suffix: felt, key: word, sub_by: felt) -> felt + # loc.0: slot_id_prefix + # loc.1: slot_id_suffix + # loc.4-7: key + # loc.8: new_value + ### save inputs + loc_store.0 loc_store.1 loc_storew_be.4 movup.4 loc_store.8 + # => [KEY] + loc_load.1 loc_load.0 + # => [slot_id_prefix, slot_id_suffix, KEY] + exec.active_account::get_map_item + drop drop drop + # => [old_value] + loc_load.8 + # => [sub_by,old_value] + exec.math::safe_sub + # => [new_value] + loc_store.8 + + ### set + loc_load.8 padw loc_loadw_be.4 loc_load.1 loc_load.0 exec.set_map_item + exec.sys::truncate_stack + # => [old_value] +end + +@locals(9) +pub proc add_to_map_item #(slot_id_prefix: felt, slot_id_suffix: felt, key: Word, increment_by: felt) -> felt + # loc.0: slot_id_prefix + # loc.1: slot_id_suffix + # loc.4-7: key + # loc.8: new_value + ### save inputs + loc_store.0 loc_store.1 loc_storew_be.4 movup.4 loc_store.8 + # => [KEY] + loc_load.1 loc_load.0 + # => [slot_id_prefix, slot_id_suffix, KEY] + exec.active_account::get_map_item + drop drop drop + # => [old_value] + loc_load.8 + # => [old_value] + exec.math::safe_add + # => [new_value] + loc_store.8 + + ### set + loc_load.8 padw loc_loadw_be.4 loc_load.1 loc_load.0 exec.set_map_item + exec.sys::truncate_stack + # => [old_value] +end + + +@locals(9) +pub proc set_map_item #(slot_id_prefix: felt, slot_id_suffix: felt, key: Word, value: felt) -> felt + # loc.0: slot_id_prefix + # loc.1: slot_id_suffix + # loc.4-7: key + # loc.8: value + loc_store.0 loc_store.1 loc_storew_be.4 dropw loc_store.8 + ### fill out value with 0 to full word + loc_load.8 push.0.0.0 + ### load slot id and key + padw loc_loadw_be.4 loc_load.1 loc_load.0 + exec.native_account::set_map_item + ### remove trailing zeros + dropw + loc_load.8 +end diff --git a/src/masm/accounts/xyk_pool.masm b/src/masm/accounts/xyk_pool.masm new file mode 100644 index 0000000..51ebd11 --- /dev/null +++ b/src/masm/accounts/xyk_pool.masm @@ -0,0 +1,358 @@ +use miden::protocol::native_account +use miden::protocol::active_account +use miden::protocol::active_note +use miden::protocol::output_note +#use miden::protocol::asset +use miden::core::sys +use miden::core::math::u64 + +use miden::protocol::note +use miden::protocol::account_id + + +use zoro::math +#use zoro::storage_utils +use zoro::lp_local + +#use lp_local::set_reserves +pub use lp_local::get_pool_asset_ids +use lp_local::add_to_reserve_by_asset_id +use lp_local::sub_from_reserve_by_asset_id +pub use lp_local::get_reserve_by_asset_id +pub use lp_local::is_token_0 +pub use lp_local::is_token_1 + + +### STORAGE SLOTS +const ASSETS_MAPPING_SLOT = word("zoro::lp_local::assets_mapping") +const RESERVE_SLOT = word("zoro::lp_local::reserve") + + +const ERR_INSUFFICIENT_OUTPUT = "Insufficient output" +const ERR_ZERO_AMOUNT = "Zero amount" + + +### MEMORY LOCATIONS +const ASSET_IN_WORD = 20 +const ASSET_IN_ID_PREFIX = 20 +const ASSET_IN_ID_SUFFIX = 21 +const AMOUNT_IN = 23 + +const ASSET_OUT_WORD = 28 +const ASSET_OUT_ID_PREFIX = 28 +const ASSET_OUT_ID_SUFFIX = 29 +const AMOUNT_OUT = 31 + +## NOTE METADATA +const RETURN_NOTE_DETAILS_WORD = 32 +const RETURN_NOTE_TAG = 32 +const RETURN_NOTE_TYPE = 33 + +const P2ID_SCRIPT_ROOT = 36 + +const RETURN_NOTE_ASSET_WORD = 40 +const RETURN_NOTE_ASSET_ID_PREFIX = 40 +const RETURN_NOTE_ASSET_ID_SUFFIX = 41 +const RETURN_NOTE_AMOUNT = 43 + +const USER_ACCOUNT_ID_WORD = 48 +const USER_ACCOUNT_ID_PREFIX = 48 +const USER_ACCOUNT_ID_SUFFIX = 49 +const INPUT_NOTE_SERIAL = 52 + +pub proc quote(amount_A: felt, reserve_A: felt, reserve_B: felt) -> felt + # => [amount_A, reserve_A, reserve_B] + u32split + # => [amount_A_high, amount_A_low, reserve_A, reserve_B] + movup.3 + # => [ reserve_B, amount_A_hi, amount_A_lo, reserve_A] + u32split + # => [reserve_B_high, reserve_B_low, amount_A_hi, amount_A_lo, reserve_A] + exec.u64::wrapping_mul + # => [numerator_high, numerator_low, reserve_A] + movup.2 + # => [reserve_A, numerator_high, numerator_low] + u32split + # => [reserve_A_high, reserve_A_low, numerator_high, numerator_low] + exec.u64::div + # => [amount_B_high, amount_B_low] + exec.math::safe_cast_u64_into_felt + # => [amount_B] + exec.sys::truncate_stack +end + + +#@todo factor out fee amount and make it configurable +@locals(6) +pub proc get_amount_out_u64(amount_in: felt, reserve_in: felt, reserve_out: felt) -> felt + # loc.0: reserve_in + # loc.1: reserve_out + # loc.2: amount_in + # loc.3: unused + # loc.4: amount_in_with_fee_high + # loc.5: amount_in_with_fee_low + + loc_store.2 loc_store.0 loc_store.1 + + # amount_in + loc_load.2 u32split + # => [amount_in_high, amount_in_low] + push.997 u32split + # => [997_high, 997_low, amount_in_high, amount_in_low] + # @note: consider overflowing mul + exec.u64::wrapping_mul + # => [amount_in_with_fee_high, amount_in_with_fee_low] + loc_store.4 loc_store.5 loc_load.5 loc_load.4 + # => [amount_in_with_fee_high, amount_in_with_fee_low] + ## reserve_out + loc_load.1 u32split + # => [reserve_out_high, reserve_out_low, amount_in_with_fee_high, amount_in_with_fee_low] + # numerator = amount_in_with_fee * reserve_out + exec.u64::wrapping_mul + # => [numerator_high, numerator_low] + # reserve_in + loc_load.0 u32split push.1000 u32split exec.u64::wrapping_mul + # => [reserve_in_scaled_high, reserve_in_scaled_low, numerator_high, numerator_low] + # amount_in_with_fee + loc_load.5 loc_load.4 + # => [amount_in_with_fee_high, amount_in_with_fee_low, reserve_in_scaled_high, reserve_in_scaled_low, numerator_high, numerator_low] + # denominator = reserve_in_scaled + amount_in_with_fee + exec.u64::wrapping_add + # => [denominator_high, denominator_low, numerator_high, numerator_low] + exec.u64::div + # => [amount_out_high, amount_out_low] + exec.math::safe_cast_u64_into_felt + # => [amount_out] + exec.sys::truncate_stack +end + +@locals(3) +pub proc get_amount_in_u64(amount_out: felt, reserve_in: felt, reserve_out: felt) -> felt + # loc.0: reserve_in + # loc.1: reserve_out + # loc.2: amount_out + + loc_store.2 loc_store.0 loc_store.1 + + # amount_out + loc_load.2 u32split + # => [amount_out_high, amount_out_low] + push.1000 u32split + # => [1000_high, 1000_low, amount_out_high, amount_out_low] + # @note: consider overflowing mul + exec.u64::wrapping_mul + # => [amount_out_scaled_high, amount_out_scaled_low] + ## reserve_in + loc_load.0 u32split + # => [reserve_in_high, reserve_in_low, amount_out_scaled_high, amount_out_scaled_low] + # numerator = amount_out_scaled * reserve_in + exec.u64::wrapping_mul + # => [numerator_scaled_high, numerator_scaled_low] + ## denominator = (reserve_out - amount_out) * (1-fee) + ## reserve_out + loc_load.1 u32split + # => [reserve_out_high, reserve_out_low, numerator_scaled_high, numerator_scaled_low] + ## amount_out + loc_load.2 u32split + # => [amount_out_high, amount_out_low, reserve_out_high, reserve_out_low, numerator_scaled_high, numerator_scaled_low] + exec.u64::wrapping_sub + # => [denominator_high, denominator_low, numerator_scaled_high, numerator_scaled_low] + push.997 u32split exec.u64::wrapping_mul + # => [denominator_with_fee_high, denominator_with_fee_low, numerator_high, numerator_low] + exec.u64::div + # => [amount_in_high, amount_in_low] + exec.math::safe_cast_u64_into_felt + # => [amount_in] + exec.sys::truncate_stack +end + + + +@locals(2) +pub proc swap_exact_tokens_for_tokens(note_serial: word, user_id_prefix: felt, user_id_suffix: felt, asset_in: word, min_asset_out: word, note_tag: felt, note_type: felt, p2id_script_root: word) -> felt + + # Initial stack + # => [sender_prefix, sender_suffix, ASSET_IN, MIN_ASSET_OUT_WORD, note_tag, note_type, P2ID_SCRIPT_ROOT] + + # get SERIAL of this note + exec.active_note::get_serial_number + # => [serial_num, sender_prefix, sender_suffix, ASSET_IN, MIN_ASSET_OUT_WORD, note_tag, note_type, P2ID_SCRIPT_ROOT] + + # store user account id + mem_storew_le.INPUT_NOTE_SERIAL dropw + + # store user account id + mem_store.USER_ACCOUNT_ID_PREFIX mem_store.USER_ACCOUNT_ID_SUFFIX + + ### mem store asset in, min asset out + mem_storew_le.ASSET_IN_WORD dropw mem_storew_le.ASSET_OUT_WORD dropw + + ### store note metadata in memory + mem_store.RETURN_NOTE_TAG mem_store.RETURN_NOTE_TYPE mem_storew_le.P2ID_SCRIPT_ROOT dropw + + mem_load.ASSET_OUT_ID_SUFFIX mem_load.ASSET_OUT_ID_PREFIX + exec.get_reserve_by_asset_id + # => [reserve_out] + mem_load.ASSET_IN_ID_SUFFIX mem_load.ASSET_IN_ID_PREFIX + exec.get_reserve_by_asset_id + # => [reserve_in, reserve_out] + mem_load.AMOUNT_IN + # => [amount_in, reserve_in, reserve_out] + exec.get_amount_out_u64 loc_store.1 loc_load.1 + # => [amount_out] + mem_load.AMOUNT_OUT + # => [min_amount_out, amount_out] + gte + if.true # sufficient amount out + ### update amount_out + loc_load.1 mem_store.AMOUNT_OUT + ### set asset out to be sent to user + padw mem_loadw_le.ASSET_OUT_WORD + mem_storew_le.RETURN_NOTE_ASSET_WORD dropw + ### add ASSEET IN to the pool + padw mem_loadw_le.ASSET_IN_WORD + exec.native_account::add_asset dropw + # => [] + ### update reserve + padw mem_loadw_le.ASSET_OUT_WORD + # => [asset_out_id_prefix, asset_out_id_suffix, 0, amount_out] + movup.3 + # => [amount_out, asset_out_id_prefix, asset_out_id_suffix] + exec.sub_from_reserve_by_asset_id + padw mem_loadw_le.ASSET_IN_WORD movup.3 + # => [amount_in, asset_in_id_prefix, asset_in_id_suffix] + exec.add_to_reserve_by_asset_id + # => [] + else # insufficient amount out + ### send asset in back to user + padw mem_loadw_le.ASSET_IN_WORD + mem_storew_le.RETURN_NOTE_ASSET_WORD dropw + end + exec._create_p2id_note + # => [note_id] + drop + # => [] +end + +@locals(3) +pub proc swap_tokens_for_exact_tokens(user_id_prefix: felt, user_id_suffix: felt, asset_out: word, max_asset_in: word, note_tag: felt, note_type: felt, p2id_script_root: word) -> felt + + # Initial stack + # => [sender_prefix, sender_suffix, ASSET_IN, MIN_ASSET_OUT_WORD, note_tag, note_type, P2ID_SCRIPT_ROOT] + + # get SERIAL of this note + exec.active_note::get_serial_number + # => [serial_num, sender_prefix, sender_suffix, ASSET_IN, MIN_ASSET_OUT_WORD, note_tag, note_type, P2ID_SCRIPT_ROOT] + + # store user account id + mem_storew_le.INPUT_NOTE_SERIAL dropw + + # store user account id + mem_store.USER_ACCOUNT_ID_PREFIX mem_store.USER_ACCOUNT_ID_SUFFIX + + ### mem store asset in, min asset out + mem_storew_le.ASSET_OUT_WORD dropw mem_storew_le.ASSET_IN_WORD dropw + + ### store note metadata in memory + mem_store.RETURN_NOTE_TAG mem_store.RETURN_NOTE_TYPE mem_storew_le.P2ID_SCRIPT_ROOT dropw + + mem_load.ASSET_OUT_ID_SUFFIX mem_load.ASSET_OUT_ID_PREFIX + exec.get_reserve_by_asset_id + # => [reserve_out] + mem_load.ASSET_IN_ID_SUFFIX mem_load.ASSET_IN_ID_PREFIX + exec.get_reserve_by_asset_id + # => [reserve_in, reserve_out] + mem_load.AMOUNT_OUT + # => [amount_out, reserve_in, reserve_out] + exec.get_amount_in_u64 loc_store.1 loc_load.1 + # => [amount_in] + mem_load.AMOUNT_IN + # => [max_amount_in, amount_in] + lte + if.true # acceptable amount in + ### loc store remaining amount in + mem_load.AMOUNT_IN loc_load.1 sub + # => [remaining_amount_in] + loc_store.2 + # => [] + ### update amount_in + loc_load.1 mem_store.AMOUNT_IN + ### set asset out to be sent to user + padw mem_loadw_le.ASSET_OUT_WORD + mem_storew_le.RETURN_NOTE_ASSET_WORD dropw + ### add ASSEET IN to the pool + padw mem_loadw_le.ASSET_IN_WORD + exec.native_account::add_asset dropw + # => [] + ### update reserve + padw mem_loadw_le.ASSET_OUT_WORD + # => [asset_out_id_prefix, asset_out_id_suffix, 0, amount_out] + movup.3 + # => [amount_out, asset_out_id_prefix, asset_out_id_suffix] + exec.sub_from_reserve_by_asset_id + padw mem_loadw_le.ASSET_IN_WORD movup.3 + # => [amount_in, asset_in_id_prefix, asset_in_id_suffix] + exec.add_to_reserve_by_asset_id + # => [] + else # insufficient amount out + ### send asset in back to user + padw mem_loadw_le.ASSET_IN_WORD + mem_storew_le.RETURN_NOTE_ASSET_WORD dropw + end + exec._create_p2id_note + # => [note_id] + ### add remaining amount in to the note to be returned to the user + padw mem_loadw_le.ASSET_IN_WORD + # => [ASSET_IN, note_id] + loc_load.2 + # => [remaining_amount_in, ASSET_IN, note_id] + swap.4 drop + # => [REMAINING_ASSET_IN, note_id] + exec.output_note::add_asset + # => [note_id] + drop +end + +proc compute_return_note_serial_num() -> word + padw mem_loadw_le.INPUT_NOTE_SERIAL + # => [INPUT_NOTE_SERIAL] + add.1 + # => [SERIAL_NUM] +end + +#### HELPER PROCEDURES +@locals(1) +proc _create_p2id_note() -> felt + padw mem_loadw_le.P2ID_SCRIPT_ROOT + + exec.compute_return_note_serial_num + mem_load.USER_ACCOUNT_ID_SUFFIX mem_store.0 + mem_load.USER_ACCOUNT_ID_PREFIX mem_store.1 + push.2 push.0 + + ## => [inputs_ptr, number_of_inputs, RETURN_SERIAL_NUM, P2ID_SCRIPT_ROOT] + exec.note::build_recipient + # => [RECIPIENT] + + mem_load.RETURN_NOTE_TYPE mem_load.RETURN_NOTE_TAG + # => [note_type, note_tag, RECIPIENT] + + exec.output_note::create loc_store.0 + # => [] + + padw mem_loadw_le.RETURN_NOTE_ASSET_WORD + exec.native_account::remove_asset + # => [ASSET] + loc_load.0 movdn.4 + # => [ASSET1_OUT, note_id] + exec.output_note::add_asset + # => [note_id] + loc_load.0 + # => [note_id] + exec.sys::truncate_stack +end + +pub proc get_account_code_commitment() -> word + exec.active_account::get_code_commitment + exec.sys::truncate_stack +end diff --git a/src/masm/accounts/xyk_registry.masm b/src/masm/accounts/xyk_registry.masm new file mode 100644 index 0000000..c93c0e5 --- /dev/null +++ b/src/masm/accounts/xyk_registry.masm @@ -0,0 +1,196 @@ +use miden::protocol::active_account +use miden::protocol::native_account +use miden::protocol::tx +#use miden::protocol::asset_id +use miden::core::math::u64 + +use miden::core::sys + + +use zoro::storage_utils +use zoro::math +use zoro::xyk_pool + +##### STORAGE SLOTS + +### ACCEPTED_CODE_HASHES_MAPPING: code_hash -> bool +const ACCEPTED_CODE_HASHES_MAPPING_SLOT = word("zoro::registry::accepted_code_hashes_mapping") + +### POOLS MAPPING: pool_id -> pool_code_hash +const POOLS_MAPPING = word("zoro::registry::pools_mapping") +### ASSETS_TO_POOL_MAPPING: (asset_0_id, asset_1_id) -> pool_id +const ASSETS_TO_POOL_MAPPING = word("zoro::registry::assets_to_pool_mapping") + +### ERRORS +const ERR_POOL_ID_ALREADY_REGISTERED = "Pool id already registered" +const ERR_POOL_FOR_ASSETS_ALREADY_REGISTERED = "Pool for these assets already registered" +const ERR_POOL_CODE_HASH_NOT_ACCEPTED = "Pool code hash not accepted" +const ERR_BOTH_ASSETS_ARE_THE_SAME = "Both assets are the same" + + +#### MEMORY LOCATIONS +const POOL_ID_WORD = 20 +const POOL_ID_PREFIX = 20 +const POOL_ID_SUFFIX = 21 + + +const POOL_ASSETS_WORD = 24 +const ASSET_0_ID_PREFIX = 24 +const ASSET_0_ID_SUFFIX = 25 +const ASSET_1_ID_PREFIX = 26 +const ASSET_1_ID_SUFFIX = 27 + +@locals(4) +pub proc register_pool( + pool_id_prefix: felt, pool_id_suffix: felt, + asset_0_id_prefix: felt, asset_0_id_suffix: felt, + asset_1_id_prefix: felt, asset_1_id_suffix: felt +) + + push.0 drop + + # loc.0-3: pool_code_hash + mem_store.POOL_ID_PREFIX mem_store.POOL_ID_SUFFIX + exec.order_assets mem_storew_le.POOL_ASSETS_WORD dropw + # => [] + + ## check of pool id already registered + padw mem_load.POOL_ID_SUFFIX mem_load.POOL_ID_PREFIX exec.is_pool_registered + ## => [is_pool_registered] + eq.0 assert.err=ERR_POOL_ID_ALREADY_REGISTERED + + ### check if pool for assets already registered + padw mem_loadw_le.POOL_ASSETS_WORD exec.get_pool_for_assets swap drop + # => [pool_id_prefix] + eq.0 assert.err=ERR_POOL_FOR_ASSETS_ALREADY_REGISTERED + + ### get pool code hash via FPI + # procref.xyk_pool::get_account_code_commitment mem_load.POOL_ID_SUFFIX mem_load.POOL_ID_PREFIX + + # => [pool_id_prefix, pool_id_suffix, GET_CODE_COMMITMENT_HASH] + # exec.tx::execute_foreign_procedure loc_storew_le.0 + + # => [POOL_CODE_HASH] + ### validate pool code hash + # exec.validate_pool_code_hash + + # => [is_code_hash_accepted] + # assert.err=ERR_POOL_CODE_HASH_NOT_ACCEPTED + + ##### update state + ### update pool mapping, + + # padw loc_loadw_le.0 mem_load.POOL_ID_SUFFIX mem_load.POOL_ID_PREFIX + # TODO: FPI doesnt work on network accounts so we just make up a word below + push.13.37.69.420 mem_load.POOL_ID_SUFFIX mem_load.POOL_ID_PREFIX + + # => [pool_id_prefix, pool_id_suffix, POOL_CODE_HASH] + exec.set_pool_mapping + # => [] + + ### update assets to pool mapping + mem_load.POOL_ID_SUFFIX mem_load.POOL_ID_PREFIX exec.pool_id_to_key + # => [POOL_ID_KEY] + padw mem_loadw_le.POOL_ASSETS_WORD + # => [POOL_ASSETS_WORD, POOL_ID_KEY] + exec.set_assets_to_pool_mapping + ## => [] + exec.sys::truncate_stack + +end + +##### SETTERS +proc set_pool_mapping(pool_id_preffix: felt, pool_id_suffix: felt, pool_code_hash: word) + exec.pool_id_to_key + # => [POOL_ID_KEY, POOL_CODE_HASH] + push.POOLS_MAPPING[0..2] exec.native_account::set_map_item + exec.sys::truncate_stack +end + +proc set_assets_to_pool_mapping(pool_assets_word: word, pool_id_key: word) + exec.order_assets swapw + # => [POOL_ID_KEY,POOL_ASSETS_WORD] + push.ASSETS_TO_POOL_MAPPING[0..2] exec.native_account::set_map_item + exec.sys::truncate_stack +end + + + + +##### VALIDATORS +pub proc validate_pool_code_hash(pool_code_hash: word) -> i1 + push.ACCEPTED_CODE_HASHES_MAPPING_SLOT[0..2] exec.active_account::get_map_item + drop drop drop + # => [is_code_hash_accepted] + exec.sys::truncate_stack +end + +pub proc is_pool_registered(pool_id_prefix: felt, pool_id_suffix: felt) -> i1 + exec.get_pool_code_hash + # => [POOL_CODE_HASH] + ### check if empty + drop drop drop neq.0 + if.true # code hash not empty - return true + push.1 + else # code hash empty - return false + push.0 + end + exec.sys::truncate_stack +end + +##### HELPER PROCEDURES +@locals(5) +pub proc order_assets(asset_0_id_prefix: felt, asset_0_id_suffix: felt, asset_1_id_prefix: felt, asset_1_id_suffix: felt) -> word + # loc.0: asset_0_id_prefix + # loc.1: asset_0_id_suffix + # loc.2: asset_1_id_prefix + # loc.3: asset_1_id_suffix + # loc.4: comparison result + loc_store.0 loc_store.1 loc_store.2 loc_store.3 + + ##### compare prefixes + loc_load.0 loc_load.2 exec.math::compare_felts loc_store.4 loc_load.4 + # => [prefix comparison result] + eq.1 if.true # prefix 1 smaller -> keep order + padw loc_loadw_le.0 + else + loc_load.4 eq.2 if.true # prefix 1 bigger -> reverse order + loc_load.1 loc_load.0 loc_load.3 loc_load.2 + else # prefixes are equal - compare suffixes + loc_load.1 loc_load.3 exec.math::compare_felts loc_store.4 loc_load.4 + # => [suffix comparison result] + eq.1 if.true # suffix 1 smaller -> keep order + padw loc_loadw_le.0 + else + loc_load.4 eq.2 if.true # suffix 1 bigger -> reverse order + loc_load.1 loc_load.0 loc_load.3 loc_load.2 + else # suffixes are equal and prefixes are equal - both are the same asset - revert + push.0 assert.err=ERR_BOTH_ASSETS_ARE_THE_SAME + end + + end + end + end + + exec.sys::truncate_stack +end + + +pub proc get_pool_for_assets(pool_assets_word: word) -> (felt, felt) + exec.order_assets + push.ASSETS_TO_POOL_MAPPING[0..2] exec.active_account::get_map_item + drop drop + exec.sys::truncate_stack +end + +pub proc get_pool_code_hash(pool_id_prefix: felt, pool_id_suffix: felt) -> word + push.0.0 + # => [0, 0, pool_id_prefix, pool_id_suffix] == [KEY] + push.POOLS_MAPPING[0..2] exec.active_account::get_map_item + exec.sys::truncate_stack +end + +pub proc pool_id_to_key(pool_id_prefix: felt, pool_id_suffix: felt) -> word + push.0.0 + # => [0, 0, pool_id_prefix, pool_id_suffix] == [KEY] +end diff --git a/src/lib/zoropool.masm b/src/masm/accounts/zoropool.masm similarity index 100% rename from src/lib/zoropool.masm rename to src/masm/accounts/zoropool.masm diff --git a/src/lib/DEPOSIT.masm b/src/masm/notes/DEPOSIT.masm similarity index 100% rename from src/lib/DEPOSIT.masm rename to src/masm/notes/DEPOSIT.masm diff --git a/src/lib/WITHDRAW.masm b/src/masm/notes/WITHDRAW.masm similarity index 100% rename from src/lib/WITHDRAW.masm rename to src/masm/notes/WITHDRAW.masm diff --git a/src/lib/ZOROSWAP.masm b/src/masm/notes/ZOROSWAP.masm similarity index 100% rename from src/lib/ZOROSWAP.masm rename to src/masm/notes/ZOROSWAP.masm diff --git a/src/masm/notes/xyk_deposit.masm b/src/masm/notes/xyk_deposit.masm new file mode 100644 index 0000000..6c2f68f --- /dev/null +++ b/src/masm/notes/xyk_deposit.masm @@ -0,0 +1,24 @@ +use miden::protocol::active_note +use zoro::lp_local +use miden::core::sys + + +const EXPECTED_NUM_ASSETS = 2 +const ERR_INVALID_NUM_ASSETS = "Invalid number of assets" + + +begin + # Load assets from note (dest_ptr=0) + push.0 exec.active_note::get_assets + # => [num_assets, 0] + swap drop + # => [num_assets] + push.EXPECTED_NUM_ASSETS eq assert.err=ERR_INVALID_NUM_ASSETS + # => [] + exec.active_note::get_sender + # => [sender_prefix, sender_suffix] + padw mem_loadw_be.0 padw mem_loadw_be.4 + # => [ASSET0, ASSET1, sender_prefix, sender_suffix] + call.lp_local::deposit + exec.sys::truncate_stack +end diff --git a/src/masm/notes/xyk_register.masm b/src/masm/notes/xyk_register.masm new file mode 100644 index 0000000..6e717ba --- /dev/null +++ b/src/masm/notes/xyk_register.masm @@ -0,0 +1,57 @@ +use miden::protocol::active_account +use miden::protocol::account_id + +use zoro::registry +use miden::core::sys +use miden::protocol::active_note + + +const ERR_REGISTER_TARGET_ACCT_MISMATCH = "Register target account mismatch" +const ERR_INVALID_NUM_INPUTS = "Invalid number of inputs" + + +const EXPECTED_NUM_INPUTS = 8 + +const INPUTS_START = 0 +const ASSET0_PREFIX = 0 +const ASSET0_SUFFIX = 1 +const ASSET1_PREFIX = 2 +const ASSET1_SUFFIX = 3 +const XYK_POOL_PREFIX = 4 +const XYK_POOL_SUFFIX = 5 +const REGISTRY_PREFIX = 6 +const REGISTRY_SUFFIX = 7 + +begin + push.0 drop + + push.INPUTS_START + exec.active_note::get_inputs + + push.EXPECTED_NUM_INPUTS eq assert.err=ERR_INVALID_NUM_INPUTS + # => [dest_ptr] + drop + exec.validate_consumer + + mem_load.ASSET1_SUFFIX mem_load.ASSET1_PREFIX + mem_load.ASSET0_SUFFIX mem_load.ASSET0_PREFIX + mem_load.XYK_POOL_SUFFIX mem_load.XYK_POOL_PREFIX + + # => [xyk_pool_prefix, xyk_pool_suffix, asset0_prefix, asset0_suffix, asset1_prefix, asset1_suffix] + + call.registry::register_pool + + exec.sys::truncate_stack +end + + + +proc validate_consumer() + exec.active_account::get_id + # => [consumer_prefix, consumer_suffix] + mem_load.REGISTRY_SUFFIX mem_load.REGISTRY_PREFIX + # => [registry_prefix, registry_suffix, consumer_prefix, consumer_suffix] + exec.account_id::is_equal assert.err=ERR_REGISTER_TARGET_ACCT_MISMATCH +end + + diff --git a/src/masm/notes/xyk_swap_exact_tokens_for_tokens.masm b/src/masm/notes/xyk_swap_exact_tokens_for_tokens.masm new file mode 100644 index 0000000..1314066 --- /dev/null +++ b/src/masm/notes/xyk_swap_exact_tokens_for_tokens.masm @@ -0,0 +1,47 @@ +use miden::protocol::active_note +use zoro::xyk_pool +use miden::core::sys + +const EXPECTED_NUM_ASSETS = 1 +const ERR_INVALID_NUM_ASSETS = "Expected exactly 1 asset for swap" + +#! Note inputs layout (12 felts stored at dest_ptr=0): +#! mem[0..3] = [0, 0, 0, min_amount_out] → MIN_ASSET_OUT word +#! mem[4..7] = [deadline, note_tag, note_type, 0] +#! mem[8..11] = [r0, r1, r2, r3] → P2ID_SCRIPT_ROOT + +begin + exec.active_note::get_inputs + # => [num_inputs, dest_ptr] + drop drop + + # Push P2ID_SCRIPT_ROOT word (mem[8..11]) + padw mem_loadw_be.8 + # => [P2ID_SCRIPT_ROOT] + + # Push note_type, note_tag and (mem_load.4 is deadline but then we run out of space if we use it) + mem_load.6 mem_load.5 + # => [note_tag, note_type, P2ID_SCRIPT_ROOT] + + # Push MIN_ASSET_OUT word (mem[0..3]) + padw mem_loadw_le.0 + # => [MIN_ASSET_OUT_WORD, note_tag, note_type, P2ID_SCRIPT_ROOT] + + # Load note asset to memory at ptr=40 + push.40 exec.active_note::get_assets + # => [num_assets, 40, ...] + swap drop + push.EXPECTED_NUM_ASSETS eq assert.err=ERR_INVALID_NUM_ASSETS + # => [MIN_ASSET_OUT_WORD, deadline, note_tag, note_type, P2ID_SCRIPT_ROOT] + + # Load ASSET_IN word + padw mem_loadw_be.40 + # => [ASSET_IN, MIN_ASSET_OUT_WORD, note_tag, note_type, P2ID_SCRIPT_ROOT] + + # load user_id (sender) + exec.active_note::get_sender + # => [sender_prefix, sender_suffix, ASSET_IN, MIN_ASSET_OUT_WORD, note_tag, note_type, P2ID_SCRIPT_ROOT] + + call.xyk_pool::swap_exact_tokens_for_tokens + exec.sys::truncate_stack +end diff --git a/src/masm/notes/xyk_swap_tokens_for_exact_tokens.masm b/src/masm/notes/xyk_swap_tokens_for_exact_tokens.masm new file mode 100644 index 0000000..472aff0 --- /dev/null +++ b/src/masm/notes/xyk_swap_tokens_for_exact_tokens.masm @@ -0,0 +1,49 @@ +use miden::protocol::active_note +use zoro::xyk_pool +use miden::core::sys + +const EXPECTED_NUM_ASSETS = 1 +const ERR_INVALID_NUM_ASSETS = "Expected exactly 1 asset for swap" + +#! Note inputs layout (12 felts stored at dest_ptr=0): +#! mem[0..3] = [aset_out_prefix, aset_out_suffix, 0, amount_out] → ASSET_OUT word +#! mem[4..7] = [deadline, note_tag, note_type, 0] +#! mem[8..11] = [r0, r1, r2, r3] → P2ID_SCRIPT_ROOT + +begin + exec.active_note::get_inputs + # => [num_inputs, dest_ptr] + drop drop + + # Push RECIPIENT word (mem[8..11]) + padw mem_loadw_be.8 + # => [P2ID_SCRIPT_ROOT] + + # Push note_type, note_tag + # deadline we ignore, no space + mem_load.6 mem_load.5 + # => [deadline, note_tag, note_type, P2ID_SCRIPT_ROOT] + + # Push ASSET_OUT word (mem[0..3]) + padw mem_loadw_le.0 + # => [ASSET_OUT_WORD, deadline, note_tag, note_type, P2ID_SCRIPT_ROOT] + + # Load note asset to memory at ptr=40 + push.40 exec.active_note::get_assets + # => [num_assets, 40, ...] + swap drop + push.EXPECTED_NUM_ASSETS eq assert.err=ERR_INVALID_NUM_ASSETS + + # Load ASSET_IN word + padw mem_loadw_be.40 + # => [MAX_ASSET_IN, ASSET_OUT, note_tag, note_type, P2ID_SCRIPT_ROOT] + swapw + # => [ASSET_OUT, MAX_ASSET_IN, note_tag, note_type, P2ID_SCRIPT_ROOT] + + # load user_id (sender) + exec.active_note::get_sender + # => [sender_prefix, sender_suffix, ASSET_IN, MIN_ASSET_OUT_WORD, note_tag, note_type, P2ID_SCRIPT_ROOT] + + call.xyk_pool::swap_tokens_for_exact_tokens + exec.sys::truncate_stack +end diff --git a/src/masm/notes/xyk_withdraw.masm b/src/masm/notes/xyk_withdraw.masm new file mode 100644 index 0000000..b8e45a8 --- /dev/null +++ b/src/masm/notes/xyk_withdraw.masm @@ -0,0 +1,35 @@ +use miden::protocol::active_note +use zoro::lp_local +use miden::core::sys + +const EXPECTED_NUM_INPUTS = 12 +const ERR_INVALID_NUM_INPUTS = "Invalid number of inputs" + +begin + exec.active_note::get_inputs + # => [num_inputs, dest_ptr] + push.EXPECTED_NUM_INPUTS eq assert.err=ERR_INVALID_NUM_INPUTS + + # note_inputs layout (dest_ptr = 0): + # mem[0] = [lp_amount, 0, 0, 0] (word 0) + # mem[4] = [note_tag, note_type, 0, 0] (word 1) + # mem[8] = [OUTPUT_NOTE_ROOT_HASH] (word 2) + + # get SERIAL of this note + exec.active_note::get_serial_number + # => [SERIAL_NUM] + # load OUTPUT NOTE ROOT HASH + padw mem_loadw_be.8 + # => [OUTPUT_NOTE_ROOT_HASH, SERIAL_NUM] + # load note_type and note_tag + mem_load.5 mem_load.4 + # => [note_tag, note_type, OUTPUT_NOTE_ROOT_HASH, SERIAL_NUM] + # load user_id (sender) + exec.active_note::get_sender + # => [sender_prefix, sender_suffix, note_tag, note_type, OUTPUT_NOTE_ROOT_HASH] + # load LP_AMOUNT as a word [0, 0, 0, lp_amount] + padw mem_loadw_le.0 + # => [LP_AMOUNT_WORD, sender_prefix, sender_suffix, note_tag, note_type, OUTPUT_NOTE_ROOT_HASH, SERIAL_NUM] + call.lp_local::withdraw + exec.sys::truncate_stack +end diff --git a/src/masm/vendor/network_account_target.masm b/src/masm/vendor/network_account_target.masm new file mode 100644 index 0000000..d00e34e --- /dev/null +++ b/src/masm/vendor/network_account_target.masm @@ -0,0 +1,130 @@ +#! miden::standards::attachments::network_account_target +#! +#! Provides a standardized way to work with network account targets. + +use miden::protocol::account_id +use miden::protocol::active_account +use miden::protocol::active_note +use miden::protocol::note + +# CONSTANTS +# ================================================================================================ + +#! The attachment scheme for NetworkAccountTarget attachments. +#! This is a valid u32 that can be compared against an extracted attachment scheme. +pub const NETWORK_ACCOUNT_TARGET_ATTACHMENT_SCHEME = 1 + +#! The attachment kind for NetworkAccountTarget attachments (Word = 1). +#! This is a valid u32 that can be compared against an extracted attachment kind. +pub const NETWORK_ACCOUNT_TARGET_ATTACHMENT_KIND = 1 + +# ERRORS +# ================================================================================================ +const ERR_NOT_NETWORK_ACCOUNT_TARGET = "attachment is not a valid network account target" + +#! Returns a boolean indicating whether the attachment scheme and kind match the expected +#! values for a NetworkAccountTarget attachment. +#! +#! Inputs: [attachment_scheme, attachment_kind] +#! Outputs: [is_network_account_target] +#! +#! Invocation: exec +pub proc is_network_account_target + eq.NETWORK_ACCOUNT_TARGET_ATTACHMENT_SCHEME + # => [is_scheme_valid, attachment_kind] + + swap eq.NETWORK_ACCOUNT_TARGET_ATTACHMENT_KIND + # => [is_kind_valid, is_scheme_valid] + + and + # => [is_network_account_target] +end + +#! Returns the account ID encoded in the attachment. +#! +#! The attachment is expected to have the following layout: +#! [account_id_suffix, account_id_prefix, exec_hint_tag, 0] +#! +#! WARNING: This procedure does not validate the attachment scheme or kind. The caller +#! should validate these using `is_network_account_target` before calling this procedure. +#! +#! WARNING: This procedure does not validate that the returned account ID is well-formed. +#! The caller should validate the account ID if needed using `account_id::validate`. +#! +#! Inputs: [NOTE_ATTACHMENT] +#! Outputs: [account_id_suffix, account_id_prefix] +#! +#! Where: +#! - account_id_{suffix,prefix} are the suffix and prefix felts of an account ID. +#! +#! Invocation: exec +pub proc get_id + # => [NOTE_ATTACHMENT] = [account_id_suffix, account_id_prefix, exec_hint_tag, 0] + + movup.2 drop movup.2 drop + # => [account_id_suffix, account_id_prefix] +end + +#! Creates a new attachment of type NetworkAccountTarget with the following layout: +#! [account_id_suffix, account_id_prefix, exec_hint_tag, 0] +#! +#! Inputs: [account_id_suffix, account_id_prefix, exec_hint_tag] +#! Outputs: [attachment_scheme, attachment_kind, NOTE_ATTACHMENT] +#! +#! Where: +#! - account_id_{suffix,prefix} are the suffix and prefix felts of an account ID. +#! - exec_hint_tag is the encoded execution hint for the note with its tag. +#! - attachment_kind is the attachment kind (Word = 1) for use with `output_note::set_attachment`. +#! - attachment_scheme is the attachment scheme (1) for use with `output_note::set_attachment`. +#! +#! Invocation: exec +pub proc new + # => [account_id_suffix, account_id_prefix, exec_hint_tag] + push.0 movdn.3 + # => [NOTE_ATTACHMENT] = [account_id_suffix, account_id_prefix, exec_hint_tag, 0] + push.NETWORK_ACCOUNT_TARGET_ATTACHMENT_KIND + push.NETWORK_ACCOUNT_TARGET_ATTACHMENT_SCHEME + # => [attachment_scheme, attachment_kind, NOTE_ATTACHMENT] +end + +#! Returns a boolean indicating whether the active account matches the target account +#! encoded in the active note's attachment. +#! +#! Inputs: [] +#! Outputs: [is_equal] +#! +#! Where: +#! - is_equal is a boolean indicating whether the active account matches the target account. +#! +#! Panics if: +#! - the attachment is not a valid network account target. +#! +#! Invocation: exec +pub proc active_account_matches_target_account + # ensure note attachment targets the consuming bridge account + exec.active_note::get_metadata + # => [NOTE_ATTACHMENT, METADATA_HEADER] + + swapw + # => [METADATA_HEADER, NOTE_ATTACHMENT] + + exec.note::extract_attachment_info_from_metadata + # => [attachment_kind, attachment_scheme, NOTE_ATTACHMENT] + + swap + # => [attachment_scheme, attachment_kind, NOTE_ATTACHMENT] + + # ensure the attachment is a network account target + exec.is_network_account_target assert.err=ERR_NOT_NETWORK_ACCOUNT_TARGET + # => [NOTE_ATTACHMENT] = [target_id_suffix, target_id_prefix, exec_hint_tag, 0] + + exec.get_id + # => [target_id_suffix, target_id_prefix] + + exec.active_account::get_id + # => [active_account_id_suffix, active_account_id_prefix, target_id_suffix, target_id_prefix] + + exec.account_id::is_equal + # => [is_equal] +end + diff --git a/src/mocks/poolDetailMocks.ts b/src/mocks/poolDetailMocks.ts new file mode 100644 index 0000000..f3d6a7b --- /dev/null +++ b/src/mocks/poolDetailMocks.ts @@ -0,0 +1,160 @@ +import type { UTCTimestamp } from 'lightweight-charts'; + +export type MockCandle = { + time: UTCTimestamp; + open: number; + high: number; + low: number; + close: number; + volume: number; +}; + +export type MockRecentTx = { + type: 'Swap' | 'Add' | 'Remove'; + amountIn: string; + amountOut: string; + account: string; + timeAgo: string; +}; + +type Range = '1D' | '1W' | '1M' | 'ALL'; + +function hashStringToSeed(input: string) { + // Deterministic, small, and good enough for mock data. + let h = 2166136261; + for (let i = 0; i < input.length; i++) { + h ^= input.charCodeAt(i); + h = Math.imul(h, 16777619); + } + return h >>> 0; +} + +function mulberry32(seed: number) { + return () => { + let t = seed += 0x6D2B79F5; + t = Math.imul(t ^ (t >>> 15), t | 1); + t ^= t + Math.imul(t ^ (t >>> 7), t | 61); + return ((t ^ (t >>> 14)) >>> 0) / 4294967296; + }; +} + +function clamp(n: number, min: number, max: number) { + return Math.max(min, Math.min(max, n)); +} + +function formatCompact(n: number) { + const abs = Math.abs(n); + if (abs >= 1_000_000) return `${(n / 1_000_000).toFixed(2)}M`; + if (abs >= 1_000) return `${(n / 1_000).toFixed(2)}K`; + return n.toFixed(2); +} + +function getRangeSpec(range: Range) { + switch (range) { + case '1D': + return { intervalSec: 5 * 60, points: 24 * 12 }; + case '1W': + return { intervalSec: 60 * 60, points: 24 * 7 }; + case '1M': + return { intervalSec: 4 * 60 * 60, points: 30 * 6 }; + case 'ALL': + default: + return { intervalSec: 24 * 60 * 60, points: 365 }; + } +} + +export function getMockPoolCandles({ + seedKey, + range, +}: { + seedKey: string; + range: Range; +}): MockCandle[] { + const seed = hashStringToSeed(`${seedKey}:${range}`); + const rand = mulberry32(seed); + + const { intervalSec, points } = getRangeSpec(range); + const nowSec = Math.floor(Date.now() / 1000); + const end = nowSec - (nowSec % intervalSec); + const start = end - intervalSec * points; + + // Pick a stable-ish base so different pools “feel” different. + const base = 0.5 + rand() * 250; + let prevClose = base * (0.95 + rand() * 0.1); + + const candles: MockCandle[] = []; + for (let i = 0; i < points; i++) { + const t = start + i * intervalSec; + + // Gentle drift + noise + occasional impulse to mimic TV candles. + const drift = (rand() - 0.5) * 0.0012; + const noise = (rand() - 0.5) * 0.006; + const impulse = rand() < 0.03 ? (rand() - 0.5) * 0.05 : 0; + const change = clamp(drift + noise + impulse, -0.08, 0.08); + + const open = prevClose; + const close = Math.max(0.0001, open * (1 + change)); + const wick = 0.002 + rand() * 0.02; + const high = Math.max(open, close) * (1 + wick * (0.4 + rand())); + const low = Math.min(open, close) * (1 - wick * (0.4 + rand())); + + const volBase = 300 + rand() * 4000; + const vol = volBase * (1 + Math.min(2.5, Math.abs(change) * 25)); + + candles.push({ + time: t as UTCTimestamp, + open: Number(open.toFixed(6)), + high: Number(high.toFixed(6)), + low: Number(low.toFixed(6)), + close: Number(close.toFixed(6)), + volume: Math.round(vol), + }); + + prevClose = close; + } + + return candles; +} + +export function getMockRecentTransactions({ + seedKey, + baseSymbol, +}: { + seedKey: string; + baseSymbol: string; +}): MockRecentTx[] { + const seed = hashStringToSeed(`tx:${seedKey}`); + const rand = mulberry32(seed); + + const mkAcct = () => { + const hex = Array.from({ length: 8 }, () => Math.floor(rand() * 16).toString(16)).join(''); + const hex2 = Array.from({ length: 8 }, () => Math.floor(rand() * 16).toString(16)).join(''); + return `0x${hex}...${hex2}`; + }; + + const ago = (mins: number) => mins < 60 ? `${mins} min ago` : `${Math.round(mins / 60)} hr ago`; + + const types: MockRecentTx['type'][] = ['Swap', 'Add', 'Remove', 'Swap', 'Swap']; + return types.map((type, i) => { + const mins = 2 + i * (5 + Math.floor(rand() * 7)); + const amountA = 0.05 + rand() * 4; + const price = 0.5 + rand() * 250; + const amountB = amountA * price; + + const inStr = type === 'Swap' + ? `${formatCompact(amountA)} ${baseSymbol}` + : `${formatCompact(amountA)} ${baseSymbol}`; + const outStr = type === 'Swap' + ? `${formatCompact(amountB)} USDC` + : `${formatCompact(amountB)} USDC`; + + return { + type, + amountIn: inStr, + amountOut: outStr, + account: mkAcct(), + timeAgo: ago(mins), + }; + }); +} + diff --git a/src/pages/Explore.tsx b/src/pages/Explore.tsx new file mode 100644 index 0000000..272f5e9 --- /dev/null +++ b/src/pages/Explore.tsx @@ -0,0 +1,228 @@ +import { AllDropdown } from '@/components/AllDropdown'; +import { Footer } from '@/components/Footer'; +import { Header } from '@/components/Header'; +import LiquidityPoolsTable from '@/components/LiquidityPoolsTable'; +import { poweredByMiden } from '@/components/PoweredByMiden'; +import XykPoolTable from '@/components/XykTable/XykPoolTable'; +import { type LpDetails, OrderStatus, type TxResult } from '@/components/OrderStatus'; +import PoolModal from '@/components/PoolModal'; +import type { LpActionType } from '@/components/PoolModal'; +import { PositionCard } from '@/components/PositionCard'; +import { SelectPoolModal } from '@/components/SelectPoolModal'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { useLPBalances } from '@/hooks/useLPBalances'; +import { usePoolsBalances } from '@/hooks/usePoolsBalances'; +import { type PoolInfo, usePoolsInfo } from '@/hooks/usePoolsInfo'; +import { useOrderUpdates } from '@/hooks/useWebSocket'; +import { ModalContext } from '@/providers/ModalContext'; +import { ZoroContext } from '@/providers/ZoroContext'; +import { Search } from 'lucide-react'; +import { useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react'; +import { Link, useNavigate } from 'react-router-dom'; + +function Explore() { + const navigate = useNavigate(); + const { data: poolsInfo, refetch: refetchPoolsInfo, isLoading: isLoadingPools } = + usePoolsInfo(); + const { + data: poolBalances, + refetch: refetchPoolBalances, + isLoading: isLoadingBalances, + } = usePoolsBalances(); + const modalContext = useContext(ModalContext); + const { tokens } = useContext(ZoroContext); + const { orderStatus, registerCallback } = useOrderUpdates(); + const lastShownNoteId = useRef(undefined); + const [txResult, setTxResult] = useState(); + const [lpDetails, setLpDetails] = useState(undefined); + const [isSuccessModalOpen, setIsSuccessModalOpen] = useState(false); + const [communityPoolsSearch, setCommunityPoolsSearch] = useState(''); + + const tokenConfigs = useMemo( + () => poolsInfo?.liquidityPools?.map(p => tokens[p.faucetIdBech32]), + [tokens, poolsInfo?.liquidityPools], + ); + + const { balances: lpBalances, refetch: refetchLpBalances } = useLPBalances({ + tokens: tokenConfigs, + }); + + const openOrderStatusModal = useCallback((noteId: string) => { + lastShownNoteId.current = noteId; + setIsSuccessModalOpen(true); + }, []); + + useEffect(() => { + if (txResult?.noteId) { + registerCallback(txResult.noteId, status => { + if (status === 'executed') { + refetchLpBalances(); + refetchPoolBalances(); + } + }); + } + }, [ + txResult?.noteId, + refetchPoolBalances, + refetchLpBalances, + registerCallback, + ]); + + const openPoolModal = useCallback( + (pool: PoolInfo, initialMode?: LpActionType) => { + modalContext.openModal( + , + ); + }, + [modalContext, refetchPoolsInfo, openOrderStatusModal, lpBalances], + ); + + const onPoolRowClick = useCallback( + (pool: PoolInfo) => { + navigate(`/pools/hf/${encodeURIComponent(pool.faucetIdBech32)}`); + }, + [navigate], + ); + + const openNewPositionModal = useCallback(() => { + const pools = poolsInfo?.liquidityPools ?? []; + modalContext.openModal( + { + setTimeout(() => openPoolModal(pool, 'Deposit'), 0); + }} + onClose={() => modalContext.closeModal()} + />, + ); + }, [modalContext, poolsInfo?.liquidityPools, openPoolModal]); + + const userPositions = useMemo(() => { + const liquidityPools = poolsInfo?.liquidityPools; + if (!liquidityPools || !poolBalances) return []; + return liquidityPools + .filter((pool) => pool.poolType === 'hfAMM') + .map((pool) => { + const balance = poolBalances.find((b) => + b.faucetIdBech32 === pool.faucetIdBech32 + ); + const lp = lpBalances[pool.faucetIdBech32] ?? BigInt(0); + if (!balance || lp <= BigInt(0)) return null; + return { pool, poolBalance: balance, lpBalance: lp }; + }) + .filter((x): x is NonNullable => x !== null); + }, [poolsInfo?.liquidityPools, poolBalances, lpBalances]); + + return ( +
+ Explore - ZoroSwap | DeFi on Miden + +
+
+
+
+

+ Your positions +

+
+ + + +
+
+
+ {userPositions.length > 0 + ? userPositions.map(({ pool, poolBalance, lpBalance }) => ( + openPoolModal(pool, 'Deposit')} + onWithdraw={() => openPoolModal(pool, 'Withdraw')} + /> + )) + : ( +
+ No positions yet. Add liquidity in High frequency pools below. +
+ )} +
+
+ +
+

+ High frequency pools +

+ +
+ {poweredByMiden} +
+
+

+ Community pools +

+ +
+
+ + setCommunityPoolsSearch(e.target.value)} + className='pl-9 rounded-lg bg-muted/50 border-muted-foreground/20' + /> +
+ +
+
+
+ {isSuccessModalOpen && ( + setIsSuccessModalOpen(false)} + swapResult={txResult} + lpDetails={lpDetails} + orderStatus={txResult?.noteId + ? orderStatus[txResult.noteId]?.status + : undefined} + /> + )} +
+ ); +} + +export default Explore; diff --git a/src/pages/HfPoolDetail.tsx b/src/pages/HfPoolDetail.tsx new file mode 100644 index 0000000..c94d262 --- /dev/null +++ b/src/pages/HfPoolDetail.tsx @@ -0,0 +1,254 @@ +import AssetIcon from '@/components/AssetIcon'; +import { type LpDetails, OrderStatus, type TxResult } from '@/components/OrderStatus'; +import PoolModal from '@/components/PoolModal'; +import type { LpActionType } from '@/components/PoolModal'; +import { PoolCompositionCard } from '@/components/PoolCompositionCard'; +import { PoolDetailHeader } from '@/components/PoolDetailHeader'; +import { PoolDetailLayout } from '@/components/PoolDetailLayout'; +import { PoolDetailStats } from '@/components/PoolDetailStats'; +import { PoolInfoCard } from '@/components/PoolInfoCard'; +import { Button } from '@/components/ui/button'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { useLPBalances } from '@/hooks/useLPBalances'; +import { usePoolsBalances } from '@/hooks/usePoolsBalances'; +import { type PoolInfo, usePoolsInfo } from '@/hooks/usePoolsInfo'; +import { useOrderUpdates } from '@/hooks/useWebSocket'; +import { formatTokenAmount, fullNumberBigintFormat } from '@/lib/format'; +import { ModalContext } from '@/providers/ModalContext'; +import { ZoroContext } from '@/providers/ZoroContext'; +import { useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react'; +import { Link, useParams } from 'react-router-dom'; + +const feeTierForSymbol = (symbol: string) => + /USDC|USDT|DAI|BUSD/i.test(symbol) ? '0.01%' : '0.30%'; + +function getSaturationPercent( + poolBalance: { reserve: bigint; totalLiabilities: bigint }, +): number | null { + const { reserve, totalLiabilities } = poolBalance; + if (totalLiabilities === BigInt(0)) return null; + return (Number(reserve) / Number(totalLiabilities)) * 100; +} + +export default function HfPoolDetail() { + const { poolId } = useParams<{ poolId: string }>(); + const decodedPoolId = poolId ? decodeURIComponent(poolId) : undefined; + const { data: poolsInfo, refetch: refetchPoolsInfo } = usePoolsInfo(); + const { data: poolBalances } = usePoolsBalances(); + const modalContext = useContext(ModalContext); + const { tokens } = useContext(ZoroContext); + const { orderStatus, registerCallback } = useOrderUpdates(); + const lastShownNoteId = useRef(undefined); + const [txResult, setTxResult] = useState(); + const [lpDetails, setLpDetails] = useState(undefined); + const [isSuccessModalOpen, setIsSuccessModalOpen] = useState(false); + + const tokenConfigs = useMemo( + () => poolsInfo?.liquidityPools?.map((p) => tokens[p.faucetIdBech32]), + [tokens, poolsInfo?.liquidityPools], + ); + const { balances: lpBalances, refetch: refetchLpBalances } = useLPBalances({ + tokens: tokenConfigs, + }); + + const pool = useMemo(() => { + if (!decodedPoolId || !poolsInfo?.liquidityPools) return null; + return ( + poolsInfo.liquidityPools.find((p) => p.faucetIdBech32 === decodedPoolId) ?? + null + ); + }, [decodedPoolId, poolsInfo?.liquidityPools]); + + const poolBalance = useMemo(() => { + if (!pool || !poolBalances) return null; + return poolBalances.find((b) => b.faucetIdBech32 === pool.faucetIdBech32) ?? null; + }, [pool, poolBalances]); + + const lpBalance = pool ? lpBalances[pool.faucetIdBech32] ?? BigInt(0) : BigInt(0); + const hasPosition = lpBalance > BigInt(0); + + const poolSharePct = useMemo(() => { + if (!pool || !poolBalance || !hasPosition || poolBalance.totalLiabilities === 0n) return null; + return (Number(lpBalance) / Number(poolBalance.totalLiabilities)) * 100; + }, [pool, poolBalance, lpBalance, hasPosition]); + + const openOrderStatusModal = useCallback((noteId: string) => { + lastShownNoteId.current = noteId; + setIsSuccessModalOpen(true); + }, []); + + useEffect(() => { + if (txResult?.noteId) { + registerCallback(txResult.noteId, (status) => { + if (status === 'executed') { + refetchLpBalances(); + } + }); + } + }, [txResult?.noteId, refetchLpBalances, registerCallback]); + + const openPoolModal = useCallback( + (p: PoolInfo, initialMode?: LpActionType) => { + modalContext.openModal( + , + ); + }, + [modalContext, refetchPoolsInfo, openOrderStatusModal, lpBalances], + ); + + if (!pool || !poolBalance) { + return ( + +

Pool not found.

+ + ← Back to pools + +
+ ); + } + + const decimals = pool.decimals; + const feeTier = feeTierForSymbol(pool.symbol); + const tvlFormatted = fullNumberBigintFormat({ + value: poolBalance.totalLiabilities, + expo: decimals, + }); + const saturationPercent = getSaturationPercent(poolBalance); + const pairLabel = pool.symbol; + + return ( + + openPoolModal(pool, 'Deposit')} + onWithdraw={() => openPoolModal(pool, 'Withdraw')} + hasPosition={hasPosition} + headerIcons={ + + + + } + /> + + + +
+
+ {hasPosition && pool && ( + + + Your Position + + +
+ LP Balance + + {formatTokenAmount({ value: lpBalance, expo: decimals })} + +
+
+ Pool Share + + {poolSharePct != null + ? poolSharePct < 0.01 + ? `${poolSharePct.toFixed(6)}%` + : `${poolSharePct.toFixed(2)}%` + : '—'} + +
+
+
+ + + {pool.symbol} + + + {formatTokenAmount({ value: lpBalance, expo: decimals })} + +
+
+
+ + +
+
+
+ )} + + +
+ + {/* Chart commented out for now +
+ +
+ */} +
+ + {isSuccessModalOpen && ( + setIsSuccessModalOpen(false)} + swapResult={txResult} + lpDetails={lpDetails} + orderStatus={ + txResult?.noteId ? orderStatus[txResult.noteId]?.status : undefined + } + /> + )} +
+ ); +} diff --git a/src/pages/Launchpad.tsx b/src/pages/Launchpad.tsx new file mode 100644 index 0000000..818d815 --- /dev/null +++ b/src/pages/Launchpad.tsx @@ -0,0 +1,435 @@ +import { Footer } from '@/components/Footer'; +import { Header } from '@/components/Header'; +import { ProgressBar } from '@/components/ProgressBar'; +import { Button } from '@/components/ui/button'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { Input } from '@/components/ui/input'; +import { UnifiedWalletButton } from '@/components/UnifiedWalletButton'; +import useLaunchpad, { + getMidenscanAccountUrl, + getMidenscanTxUrl, + LAUNCH_STEPS, + type LaunchStepIndex, + type LaunchSuccess, +} from '@/hooks/useLaunchpad'; +import { useUnifiedWallet } from '@/hooks/useUnifiedWallet'; +import { truncateId } from '@/lib/format'; +import { ArrowLeft, CheckCircle, ExternalLink, Info, Loader2 } from 'lucide-react'; +import { useCallback, useEffect, useState } from 'react'; +import { Link } from 'react-router-dom'; +import { parseUnits } from 'viem'; + +const SYMBOL_MIN = 3; +const SYMBOL_MAX = 6; +const DECIMALS_MIN = 0; +const DECIMALS_MAX = 8; +const TOTAL_SUPPLY_MAX = 100_000_000; + +const MAX_SUPPLY_DISPLAY = TOTAL_SUPPLY_MAX.toLocaleString('en-US'); + +function validateSymbol(s: string): string | null { + const trimmed = s.trim().toUpperCase(); + if (trimmed.length < SYMBOL_MIN) { + return `Symbol must be at least ${SYMBOL_MIN} characters`; + } + if (trimmed.length > SYMBOL_MAX) { + return `Symbol must be at most ${SYMBOL_MAX} characters`; + } + if (!/^[A-Z0-9]+$/.test(trimmed)) return 'Symbol must be letters and numbers only'; + return null; +} + +function validateDecimals(n: number): string | null { + if (!Number.isInteger(n) || n < DECIMALS_MIN) { + return `Decimals must be at least ${DECIMALS_MIN}`; + } + if (n > DECIMALS_MAX) return `Decimals must be at most ${DECIMALS_MAX}`; + return null; +} + +function validateInitialSupply(raw: string, decimals: number): string | null { + if (!raw.trim()) return 'Total supply is required'; + let amount: bigint; + try { + amount = parseUnits(raw.trim(), decimals); + } catch { + return 'Invalid amount'; + } + if (amount <= 0n) return 'Total supply must be greater than 0'; + if (amount > TOTAL_SUPPLY_MAX) { + return `Total supply must not exceed ${MAX_SUPPLY_DISPLAY} (raw units)`; + } + return null; +} + +const bodyClass = 'text-sm text-muted-foreground leading-relaxed'; +const labelClass = 'text-sm font-medium text-foreground'; +const hintClass = 'text-sm text-muted-foreground'; + +export default function Launchpad() { + const { connected } = useUnifiedWallet(); + const { launchToken, error, clearError } = useLaunchpad(); + const [symbol, setSymbol] = useState(''); + const [decimals, setDecimals] = useState('4'); + const [initialSupply, setInitialSupply] = useState(''); + const [touched, setTouched] = useState({ + symbol: false, + decimals: false, + supply: false, + }); + const [isSubmitting, setIsSubmitting] = useState(false); + const [launchStep, setLaunchStep] = useState(null); + const [successResult, setSuccessResult] = useState(null); + const [copiedId, setCopiedId] = useState<'tx' | 'faucet' | null>(null); + + const symbolError = touched.symbol ? validateSymbol(symbol) : null; + const decimalsNum = parseInt(decimals, 10); + const decimalsError = touched.decimals + ? validateDecimals(decimalsNum) + : (Number.isNaN(decimalsNum) ? 'Invalid number' : null); + const supplyError = touched.supply + ? validateInitialSupply(initialSupply, Number.isNaN(decimalsNum) ? 4 : decimalsNum) + : null; + + const canSubmit = connected + && !symbolError + && !decimalsError + && !supplyError + && symbol.trim().length >= SYMBOL_MIN + && symbol.trim().length <= SYMBOL_MAX + && !Number.isNaN(decimalsNum) + && decimalsNum >= DECIMALS_MIN + && decimalsNum <= DECIMALS_MAX + && initialSupply.trim() !== '' + && validateInitialSupply(initialSupply, decimalsNum) === null; + + const handleSubmit = useCallback(async (e: React.FormEvent) => { + e.preventDefault(); + setTouched({ symbol: true, decimals: true, supply: true }); + if (!canSubmit) return; + const sym = symbol.trim().toUpperCase(); + const dec = decimalsNum; + let supplyBigint: bigint; + try { + supplyBigint = parseUnits(initialSupply.trim(), dec); + } catch { + return; + } + if (supplyBigint <= 0n) return; + setIsSubmitting(true); + setLaunchStep(null); + clearError(); + const result = await launchToken( + { + symbol: sym, + decimals: dec, + initialSupply: supplyBigint, + }, + { + onProgress: (step) => setLaunchStep(step), + }, + ); + setIsSubmitting(false); + setLaunchStep(null); + if (result) { + setSuccessResult(result); + setSymbol(''); + setDecimals('4'); + setInitialSupply(''); + setTouched({ symbol: false, decimals: false, supply: false }); + clearError(); + } + }, [canSubmit, symbol, decimalsNum, initialSupply, launchToken, clearError]); + + useEffect(() => { + if (error) setSuccessResult(null); + }, [error]); + + const copyToClipboard = useCallback((text: string, kind: 'tx' | 'faucet') => { + navigator.clipboard.writeText(text); + setCopiedId(kind); + setTimeout(() => setCopiedId(null), 2000); + }, []); + + return ( +
+ Launchpad - ZoroSwap | DeFi on Miden + +
+
+ + + Back to Swap + + +
+

+ Token launchpad +

+

+ Deploy a new faucet token on Miden and mint the whole initial supply to your + account. You will need to consume the tokens in your wallet. +

+
+ + + + + Configure your token + +

+ Choose a symbol, decimals, and how many whole tokens to mint at launch. + Launch usually takes a few seconds—keep this tab open until it finishes. +

+
+ + {successResult + ? ( +
+
+ + + Token launched successfully + +
+

+ Claim the note in your wallet to receive your tokens. You can launch + another token using the form below when you’re ready. +

+
+
+ Faucet ID +
+ + + + +
+ + + View faucet on MidenScan + +
+
+ Transaction ID +
+ + + + +
+ + + View transaction on MidenScan + +
+
+

+ Open your wallet and claim the pending note so the minted supply + appears in your balance. +

+
+
+
+ ) + : null} + + {!connected + ? ( +
+

+ Connect your wallet to deploy a token on Miden testnet. +

+ +
+ ) + : ( +
+
+ + { + setSymbol(e.target.value.toUpperCase().slice(0, SYMBOL_MAX)); + if (error) clearError(); + }} + onBlur={() => + setTouched((t) => ({ ...t, symbol: true }))} + maxLength={SYMBOL_MAX} + className={`h-11 rounded-xl text-sm ${ + symbolError ? 'border-destructive' : '' + }`} + aria-invalid={!!symbolError} + aria-describedby={symbolError + ? 'launchpad-symbol-error' + : undefined} + /> + {symbolError && ( +

+ {symbolError} +

+ )} +

+ {SYMBOL_MIN}–{SYMBOL_MAX} characters, letters and numbers only. +

+
+ +
+ + { + setDecimals(e.target.value); + if (error) clearError(); + }} + onBlur={() => setTouched((t) => ({ ...t, decimals: true }))} + className={`h-11 rounded-xl text-sm ${ + decimalsError ? 'border-destructive' : '' + }`} + aria-invalid={!!decimalsError} + /> + {decimalsError && ( +

{decimalsError}

+ )} +

+ Between {DECIMALS_MIN} and {DECIMALS_MAX}. +

+
+ +
+ + { + setInitialSupply(e.target.value); + if (error) clearError(); + }} + onBlur={() => setTouched((t) => ({ ...t, supply: true }))} + className={`h-11 rounded-xl text-sm ${ + supplyError ? 'border-destructive' : '' + }`} + aria-invalid={!!supplyError} + /> + {supplyError && ( +

{supplyError}

+ )} +

+ Whole number of tokens to mint (without decimals) +

+
+ + {isSubmitting && launchStep !== null && ( + + )} + + {error && ( +
+ {error} +
+ )} + + + + )} + +
+ +

+ Token supply{' '} + is capped at{' '} + + {MAX_SUPPLY_DISPLAY} + {' '} + raw units. Operations with large token amounts paired with high decimals + may fail due to current network limitations. +

+
+
+
+
+
+
+ ); +} diff --git a/src/pages/LiquidityPools.tsx b/src/pages/LiquidityPools.tsx deleted file mode 100644 index 7181bbb..0000000 --- a/src/pages/LiquidityPools.tsx +++ /dev/null @@ -1,32 +0,0 @@ -import { Footer } from '@/components/Footer'; -import { Header } from '@/components/Header'; -import LiquidityPoolsTable from '@/components/LiquidityPoolsTable'; - -function LiquidityPools() { - return ( -
- Pools - ZoroSwap | DeFi on Miden - - - - - -
-
- -
-
-
- ); -} - -export default LiquidityPools; diff --git a/src/pages/NewXykPool.tsx b/src/pages/NewXykPool.tsx new file mode 100644 index 0000000..c8db395 --- /dev/null +++ b/src/pages/NewXykPool.tsx @@ -0,0 +1,16 @@ +import { Footer } from '@/components/Footer'; +import { Header } from '@/components/Header'; +import XykWizard from '@/components/xyk-wizard/XykWizard'; + +export default function NewXykPool() { + return ( +
+ Create pool - ZoroSwap | DeFi on Miden +
+
+ +
+
+
+ ); +} diff --git a/src/pages/Swap.tsx b/src/pages/Swap.tsx index 1af1c55..b435c71 100644 --- a/src/pages/Swap.tsx +++ b/src/pages/Swap.tsx @@ -2,27 +2,35 @@ import AssetIcon from '@/components/AssetIcon'; import ExchangeRatio from '@/components/ExchangeRatio'; import { Footer } from '@/components/Footer'; import { Header } from '@/components/Header'; -import { OrderStatus } from '@/components/OrderStatus'; import { poweredByMiden } from '@/components/PoweredByMiden'; import Price from '@/components/Price'; import Slippage from '@/components/Slippage'; import SwapInputBuy from '@/components/SwapInputBuy'; import SwapPairs from '@/components/SwapPairs'; +import { TokenAutocomplete } from '@/components/TokenAutocomplete'; import { Button } from '@/components/ui/button'; import { Card, CardContent } from '@/components/ui/card'; import { Input } from '@/components/ui/input'; import { UnifiedWalletButton } from '@/components/UnifiedWalletButton'; import { useBalance } from '@/hooks/useBalance'; +import { useRpcWorker } from '@/hooks/useRpcWorker'; import { useSwap } from '@/hooks/useSwap'; import { useUnifiedWallet } from '@/hooks/useUnifiedWallet'; +import { useWaitForNoteConsumed } from '@/hooks/useWaitForNoteConsumed'; import { useOrderUpdates } from '@/hooks/useWebSocket'; +import { useXykPool } from '@/hooks/useXykPool'; +import { useXykTokens } from '@/hooks/useXykTokens'; +import { clientMutex } from '@/lib/clientMutex'; import { DEFAULT_SLIPPAGE } from '@/lib/config'; +import { formalBigIntFormat, truncateId } from '@/lib/format'; import { bech32ToAccountId } from '@/lib/utils'; +import { getAmountOut } from '@/lib/xykMath'; +import { compileXykSwapTransaction } from '@/lib/XykSwapNote'; import { OracleContext, useOraclePrices } from '@/providers/OracleContext'; import { ZoroContext } from '@/providers/ZoroContext'; import { type TokenConfig } from '@/providers/ZoroProvider.tsx'; -import type { AccountId } from '@miden-sdk/miden-sdk'; -import { Loader2 } from 'lucide-react'; +import { TransactionType } from '@demox-labs/miden-wallet-adapter'; +import { CheckCircle, Clock, ExternalLink, Loader2, X, XCircle } from 'lucide-react'; import { useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react'; import { toast } from 'react-toastify'; import { formatUnits, parseUnits } from 'viem'; @@ -37,26 +45,72 @@ const validateValue = (val: bigint, max: bigint) => { : undefined; }; +function hasOracle(token: TokenConfig | undefined): boolean { + return !!token?.oracleId && token.oracleId !== '0x' && token.oracleId !== ''; +} + +type XykTxStatus = 'submitting' | 'confirming' | 'confirmed' | 'failed'; + +interface SwapTxInfo { + noteId: string; + txId?: string; + sellToken: TokenConfig; + buyToken: TokenConfig; + sellAmount: bigint; + buyAmount: bigint; + isXyk: boolean; + xykStatus?: XykTxStatus; +} + function Swap() { - const { tokens, client, accountId } = useContext( - ZoroContext, - ); + const { tokens, client, accountId, syncState } = useContext(ZoroContext); + const { requestTransaction } = useUnifiedWallet(); const { - swap, - isLoading: isLoadingSwap, - txId, - noteId, + swap: hfAmmSwap, + isLoading: isLoadingHfAmmSwap, + txId: hfAmmTxId, + noteId: hfAmmNoteId, } = useSwap(); - // Subscribe to all order updates from the start const { orderStatus, registerCallback } = useOrderUpdates(); const { connecting, connected } = useUnifiedWallet(); + const { xykTokens, getXykPairTokens, findXykPool } = useXykTokens(); + const waitForNoteConsumed = useWaitForNoteConsumed(); + const { invalidateCache } = useRpcWorker(); + + // --- XYK swap state --- + const [isLoadingXykSwap, setIsLoadingXykSwap] = useState(false); + + const isLoadingSwap = isLoadingHfAmmSwap || isLoadingXykSwap; + + // --- Inline transaction status --- + const [txInfo, setTxInfo] = useState(null); + + // Track hfAMM noteId → txInfo + const lastHfAmmNoteRef = useRef(undefined); + const lastSellRef = useRef< + { token: TokenConfig; buy: TokenConfig; sellAmt: bigint; buyAmt: bigint } | null + >(null); + + // --- Merged token list (hfAMM + XYK, deduplicated) --- + const allTokens = useMemo(() => { + const map = new Map(); + for (const t of Object.values(tokens)) { + map.set(t.faucetIdBech32, t); + } + for (const t of xykTokens) { + if (!map.has(t.faucetIdBech32)) { + map.set(t.faucetIdBech32, t); + } + } + return [...map.values()]; + }, [tokens, xykTokens]); + const [selectedAssetBuy, setSelectedAssetBuy] = useState( () => getLocalStoredToken('buy'), ); const [selectedAssetSell, setSelectedAssetSell] = useState( () => getLocalStoredToken('sell'), ); - const [isSuccessModalOpen, setIsSuccessModalOpen] = useState(false); const { balance: balanceSell, formattedLong: balanceSellFmt, @@ -64,57 +118,134 @@ function Swap() { } = useBalance({ token: selectedAssetSell, }); - const { - balance: balancebuy, - formattedLong: balanceBuyFmt, - } = useBalance({ + useBalance({ token: selectedAssetBuy, }); const [rawSell, setRawSell] = useState(BigInt(0)); - const [rawBuy, setRawBuy] = useState(BigInt(0)); + const [, setRawBuy] = useState(BigInt(0)); const [slippage, setSlippage] = useState(DEFAULT_SLIPPAGE); const [stringSell, setStringSell] = useState(''); const [sellInputError, setSellInputError] = useState(undefined); const { getWebsocketPrice } = useContext(OracleContext); + // --- XYK swap detection --- + const xykPoolId = useMemo(() => { + if (!selectedAssetSell || !selectedAssetBuy) return undefined; + return findXykPool(selectedAssetSell.faucetIdBech32, selectedAssetBuy.faucetIdBech32); + }, [selectedAssetSell, selectedAssetBuy, findXykPool]); + + const isXykSwap = xykPoolId != null; + + const { data: xykPoolData } = useXykPool(xykPoolId); + + // --- XYK buy amount estimation --- + const xykBuyAmount = useMemo(() => { + if ( + !isXykSwap || !xykPoolData || !selectedAssetSell || !selectedAssetBuy + || rawSell <= 0n + ) { + return undefined; + } + const sellIst0 = + selectedAssetSell.faucetIdBech32 === xykPoolData.token0.faucetIdBech32; + const [reserveIn, reserveOut] = sellIst0 + ? [xykPoolData.reserve0, xykPoolData.reserve1] + : [xykPoolData.reserve1, xykPoolData.reserve0]; + return getAmountOut(rawSell, reserveIn, reserveOut); + }, [isXykSwap, xykPoolData, selectedAssetSell, selectedAssetBuy, rawSell]); + + // --- XYK exchange ratio (1 unit of sell -> ? buy) --- + const xykExchangeRatio = useMemo(() => { + if (!isXykSwap || !xykPoolData || !selectedAssetSell) return undefined; + const sellIst0 = + selectedAssetSell.faucetIdBech32 === xykPoolData.token0.faucetIdBech32; + const [reserveIn, reserveOut] = sellIst0 + ? [xykPoolData.reserve0, xykPoolData.reserve1] + : [xykPoolData.reserve1, xykPoolData.reserve0]; + const oneUnit = 10n ** BigInt(selectedAssetSell.decimals); + if (reserveIn === 0n) return undefined; + const out = getAmountOut(oneUnit, reserveIn, reserveOut); + const buyDecimals = sellIst0 + ? xykPoolData.token1.decimals + : xykPoolData.token0.decimals; + return formatUnits(out, buyDecimals); + }, [isXykSwap, xykPoolData, selectedAssetSell]); + + // --- hfAMM tokens get priority (sorted to top) --- + const hfAmmBech32s = useMemo(() => { + const s = new Set(); + for (const t of allTokens) { + if (hasOracle(t)) s.add(t.faucetIdBech32); + } + return s; + }, [allTokens]); + + // --- Disabled (unavailable) tokens for buy side only --- + const buyDisabled = useMemo(() => { + if (!selectedAssetSell) return new Set(); + const sellBech32 = selectedAssetSell.faucetIdBech32; + const sellIsHfAmm = hasOracle(selectedAssetSell); + const xykPairs = new Set(getXykPairTokens(sellBech32)); + + const disabled = new Set(); + for (const t of allTokens) { + if (t.faucetIdBech32 === sellBech32) continue; + const canPairWith = sellIsHfAmm + ? (hasOracle(t) || xykPairs.has(t.faucetIdBech32)) + : xykPairs.has(t.faucetIdBech32); + if (!canPairWith) disabled.add(t.faucetIdBech32); + } + return disabled; + }, [selectedAssetSell, allTokens, getXykPairTokens]); + const priceIds = useMemo(() => [ ...(selectedAssetBuy?.oracleId ? [selectedAssetBuy.oracleId] : []), ...(selectedAssetSell?.oracleId ? [selectedAssetSell.oracleId] : []), ], [selectedAssetBuy?.oracleId, selectedAssetSell?.oracleId]); const prices = useOraclePrices(priceIds); + useEffect(() => { - if (!selectedAssetBuy && !selectedAssetSell && tokens) { - setSelectedAssetSell(Object.values(tokens)[0]); - setSelectedAssetBuy(Object.values(tokens)[1]); + if (!selectedAssetBuy && !selectedAssetSell && allTokens.length > 0) { + setSelectedAssetSell(allTokens[0]); + setSelectedAssetBuy(allTokens[1]); } - }, [tokens, selectedAssetBuy, selectedAssetSell]); + }, [allTokens, selectedAssetBuy, selectedAssetSell]); + + const canPair = useCallback((a: TokenConfig, b: TokenConfig) => { + if (a.faucetIdBech32 === b.faucetIdBech32) return false; + const aHas = hasOracle(a); + const bHas = hasOracle(b); + if (aHas && bHas) return true; + return !!findXykPool(a.faucetIdBech32, b.faucetIdBech32); + }, [findXykPool]); const setAsset = useCallback((side: 'buy' | 'sell', faucetIdBech32: string) => { - const asset = Object.values(tokens).find(a => a.faucetIdBech32 === faucetIdBech32); + const asset = allTokens.find(a => a.faucetIdBech32 === faucetIdBech32); if (asset == null) return; if (side === 'buy') { - if (selectedAssetSell?.symbol === asset.symbol) { + if (selectedAssetSell?.faucetIdBech32 === asset.faucetIdBech32) { setSelectedAssetSell(selectedAssetBuy); setLocalStoredToken('sell', selectedAssetBuy); } setSelectedAssetBuy(asset); setLocalStoredToken('buy', asset); } else { - if (selectedAssetBuy?.symbol === asset.symbol) { + if (selectedAssetBuy?.faucetIdBech32 === asset.faucetIdBech32) { setSelectedAssetBuy(selectedAssetSell); setLocalStoredToken('buy', selectedAssetSell); + } else if (selectedAssetBuy && !canPair(asset, selectedAssetBuy)) { + setSelectedAssetBuy(undefined); + setLocalStoredToken('buy', undefined); } - setSelectedAssetSell(asset); setLocalStoredToken('sell', asset); } - }, [selectedAssetBuy, selectedAssetSell, tokens]); + }, [selectedAssetBuy, selectedAssetSell, allTokens, canPair]); const onInputChange = useCallback((val: string) => { val = val.replace(/[^0-9.]/g, '').replace(/(\..*)\./g, '$1'); - const setError = setSellInputError; const decimalsSell = selectedAssetSell?.decimals || 6; if (!selectedAssetBuy || !selectedAssetSell) { return; @@ -122,36 +253,25 @@ function Swap() { setStringSell(val); if (val === '' || val === '.') { - setError(undefined); + setSellInputError(undefined); setRawSell(BigInt(0)); return; } const newSell = parseUnits(val, decimalsSell); const validationError = validateValue(newSell, balanceSell ?? BigInt(0)); if (validationError) { - setError(validationError); + setSellInputError(validationError); } else { - setError(undefined); + setSellInputError(undefined); setRawSell(newSell); } - }, [ - selectedAssetBuy, - selectedAssetSell, - balanceSell, - setStringSell, - setRawSell, - setSellInputError, - ]); + }, [selectedAssetBuy, selectedAssetSell, balanceSell]); const clearForm = useCallback(() => { setSellInputError(undefined); setRawSell(BigInt(0)); setStringSell(''); - }, [ - setSellInputError, - setRawSell, - setStringSell, - ]); + }, []); useEffect(() => { onInputChange(stringSell ?? ''); @@ -162,47 +282,176 @@ function Swap() { const newAssetBuy = selectedAssetSell; setSelectedAssetBuy(newAssetBuy); setSelectedAssetSell(newAssetSell); - }, [ - selectedAssetBuy, - selectedAssetSell, - ]); + }, [selectedAssetBuy, selectedAssetSell]); + + // --- hfAMM: pick up noteId from useSwap and create inline txInfo --- + useEffect(() => { + if (hfAmmNoteId && hfAmmNoteId !== lastHfAmmNoteRef.current && lastSellRef.current) { + lastHfAmmNoteRef.current = hfAmmNoteId; + const s = lastSellRef.current; + setTxInfo({ + noteId: hfAmmNoteId, + txId: hfAmmTxId, + sellToken: s.token, + buyToken: s.buy, + sellAmount: s.sellAmt, + buyAmount: s.buyAmt, + isXyk: false, + }); + } + }, [hfAmmNoteId, hfAmmTxId]); + // --- hfAMM: register websocket callback --- useEffect(() => { - if (noteId) { - registerCallback(noteId, status => { + if (hfAmmNoteId) { + registerCallback(hfAmmNoteId, status => { if (status === 'pending') { refetchBalanceSell(); } }); } - }, [noteId, registerCallback, refetchBalanceSell]); + }, [hfAmmNoteId, registerCallback, refetchBalanceSell]); - const onSwap = useCallback(() => { - if (!selectedAssetBuy || !selectedAssetSell) { - return; + // --- hfAMM: toast on failure --- + useEffect(() => { + if (txInfo && !txInfo.isXyk && orderStatus[txInfo.noteId]?.status === 'failed') { + toast.error('Swap order failed'); } - // Calculate minimum output with slippage protection - // minAmountOut = rawBuy * (1 - slippage/100) + }, [txInfo, orderStatus]); + + // --- XYK swap execution --- + const onXykSwap = useCallback(async () => { + if ( + !selectedAssetBuy || !selectedAssetSell || !xykPoolId || !client || !accountId + || !requestTransaction + ) return; + + const poolAccountId = bech32ToAccountId(xykPoolId); + if (!poolAccountId) return; + + const expectedOut = xykBuyAmount ?? 0n; const slippageFactor = BigInt(Math.round((100 - slippage) * 1e6)); + const minAmountOut = expectedOut * slippageFactor / BigInt(1e8); + const info: SwapTxInfo = { + noteId: '', + sellToken: selectedAssetSell, + buyToken: selectedAssetBuy, + sellAmount: rawSell, + buyAmount: expectedOut, + isXyk: true, + xykStatus: 'submitting', + }; + setTxInfo(info); + setIsLoadingXykSwap(true); + + try { + await syncState(); + const { tx, noteId: nid } = await clientMutex.runExclusive(() => + compileXykSwapTransaction({ + poolAccountId, + userAccountId: accountId, + sellToken: selectedAssetSell.faucetId, + buyToken: selectedAssetBuy.faucetId, + amount: rawSell, + minAmountOut: minAmountOut > 0n ? minAmountOut : 1n, + client, + }) + ); + const newTxId = await requestTransaction({ + type: TransactionType.Custom, + payload: tx, + }); + await syncState(); + + const updated: SwapTxInfo = { + ...info, + noteId: nid, + txId: newTxId, + xykStatus: 'confirming', + }; + setTxInfo(updated); + setIsLoadingXykSwap(false); + setRawBuy(expectedOut); + + try { + await waitForNoteConsumed(nid); + setTxInfo(prev => + prev?.noteId === nid ? { ...prev, xykStatus: 'confirmed' } : prev + ); + if (xykPoolId) await invalidateCache(xykPoolId); + refetchBalanceSell(); + } catch { + setTxInfo(prev => prev?.noteId === nid ? { ...prev, xykStatus: 'failed' } : prev); + } + } catch (err) { + console.error(err); + const message = err instanceof Error ? err.message : String(err); + toast.error(`Error swapping: ${message}`); + setTxInfo(prev => + prev?.xykStatus === 'submitting' ? { ...prev, xykStatus: 'failed' } : prev + ); + setIsLoadingXykSwap(false); + } + }, [ + selectedAssetBuy, + selectedAssetSell, + xykPoolId, + client, + accountId, + requestTransaction, + xykBuyAmount, + slippage, + rawSell, + syncState, + waitForNoteConsumed, + invalidateCache, + refetchBalanceSell, + ]); + + // --- hfAMM swap execution --- + const onHfAmmSwap = useCallback(() => { + if (!selectedAssetBuy || !selectedAssetSell) return; + + const slippageFactor = BigInt(Math.round((100 - slippage) * 1e6)); const priceA = getWebsocketPrice(selectedAssetBuy.oracleId); const priceB = getWebsocketPrice(selectedAssetSell.oracleId); - const ratio = Number(priceB?.priceFeed.value ?? 0) / Number(priceA?.priceFeed.value ?? 1); - - const rawBuy = BigInt(Math.floor((ratio ?? 1) * 1e12)) * rawSell + const computedBuy = BigInt(Math.floor((ratio ?? 1) * 1e12)) * rawSell / BigInt(10 ** (selectedAssetBuy.decimals - selectedAssetSell.decimals + 12)); + const minAmountOut = computedBuy * slippageFactor / BigInt(1e8); + + lastSellRef.current = { + token: selectedAssetSell, + buy: selectedAssetBuy, + sellAmt: rawSell, + buyAmt: computedBuy, + }; - const minAmountOut = rawBuy * slippageFactor / BigInt(1e8); - swap({ + hfAmmSwap({ amount: rawSell, minAmountOut, buyToken: selectedAssetBuy, sellToken: selectedAssetSell, }); - setRawBuy(rawBuy); - }, [rawSell, slippage, selectedAssetBuy, selectedAssetSell, swap, getWebsocketPrice]); + setRawBuy(computedBuy); + }, [ + rawSell, + slippage, + selectedAssetBuy, + selectedAssetSell, + hfAmmSwap, + getWebsocketPrice, + ]); + + const onSwap = useCallback(() => { + if (isXykSwap) { + onXykSwap(); + } else { + onHfAmmSwap(); + } + }, [isXykSwap, onXykSwap, onHfAmmSwap]); const handleMaxClick = useCallback(() => { onInputChange( @@ -211,195 +460,159 @@ function Swap() { }, [onInputChange, balanceSell, selectedAssetSell?.decimals]); const buttonText = useMemo(() => { + if (!selectedAssetBuy) return 'Select a token'; const showInsufficientBalance = Boolean( rawSell > (balanceSell || BigInt(0)), ); if (showInsufficientBalance) { return `Insufficient ${selectedAssetSell?.symbol} balance`; - } else return 'Swap'; - }, [ - rawSell, - balanceSell, - selectedAssetSell?.symbol, - ]); + } + return 'Swap'; + }, [rawSell, balanceSell, selectedAssetSell?.symbol, selectedAssetBuy]); + + const swapDisabled = connecting || isLoadingSwap || !client + || stringSell === '' || !!sellInputError || !selectedAssetBuy; - const lastShownNoteId = useRef(undefined); + // --- Inline tx status --- + const hfAmmOrderStatus = txInfo && !txInfo.isXyk + ? orderStatus[txInfo.noteId]?.status + : undefined; + const showTxStatus = txInfo != null; - const onCloseSuccessModal = useCallback(() => { + const dismissTxInfo = useCallback(() => { clearForm(); - setIsSuccessModalOpen(false); + setTxInfo(null); }, [clearForm]); - useEffect(() => { - if (noteId && noteId !== lastShownNoteId.current) { - lastShownNoteId.current = noteId; - setIsSuccessModalOpen(true); - // Note: Already subscribed to all orders in useOrderUpdates([]) - } - }, [noteId]); - - // Handle order status updates, show toast on failure - useEffect(() => { - if (noteId && orderStatus[noteId]?.status === 'failed') { - toast.error('Swap order failed'); - } - }, [noteId, orderStatus]); - return (
Swap - ZoroSwap | DeFi on Miden
-
-
+
+
+

Swap Tokens

+ +
+ +
+ {/* Sell Card */} - - -

Swap Tokens

-
-
-
Sell
- + + +
+ Sell +
+
+ onInputChange(e.target.value)} + placeholder='0' + aria-errormessage={sellInputError} + className={`border-none bg-transparent text-4xl sm:text-6xl font-semibold text-foreground outline-none flex-1 p-0 h-auto focus-visible:ring-0 no-spinner placeholder:text-foreground/70 ${ + sellInputError + ? 'text-orange-600 placeholder:text-destructive/50' + : '' + }`} + /> +
+ setAsset('sell', id)} + priorityBech32s={hfAmmBech32s} + />
- - -
- onInputChange(e.target.value)} - placeholder='0' - aria-errormessage={sellInputError} - className={`border-none text-3xl sm:text-4xl font-light outline-none flex-1 p-0 h-auto focus-visible:ring-0 no-spinner ${ - sellInputError - ? 'text-orange-600 placeholder:text-destructive/50' - : '' - }`} - /> -
-
- -
- -
-
- {sellInputError && ( -
-

- {sellInputError} -

-
- )} -
-
- {rawSell > BigInt(0) && selectedAssetSell && ( - <> - = $ - - - )} -
- {accountId && balanceSell !== null && balanceSell !== undefined - && ( -
- -
- )} -
-
-
+ {balanceSellFmt} {selectedAssetSell.symbol} + + ) + )} +
{/* Swap Pairs */} -
- +
+
+ +
{/* Buy Card */} - - -
-
Buy
- - -
- -
-
- -
- -
-
-
- {balancebuy !== null && balancebuy !== undefined && ( -
- {balanceBuyFmt} {selectedAssetBuy?.symbol ?? ''} -
- )} -
-
-
+ + +
+ Buy +
+
+ +
+ setAsset('buy', id)} + disabledBech32s={buyDisabled} + priorityBech32s={hfAmmBech32s} + /> +
{/* Main Action Button */} -
+
{connected ? ( ) : ( -
+
{connecting && ( )}
- +
)}
-

+ + {/* Exchange ratio */} +

{selectedAssetBuy && selectedAssetSell ? ( 1 {selectedAssetSell.symbol} ={' '} - - {' '} + {' '} {selectedAssetBuy.symbol} ) : null}

- {/* Powered by MIDEN */} -
- {poweredByMiden} -
+ + {/* Inline transaction status */} + {showTxStatus && txInfo && ( + + )}
+
+ {poweredByMiden} +