diff --git a/config/wallet-config.json b/config/wallet-config.json index 382ad4de5b0..9e72752b879 100644 --- a/config/wallet-config.json +++ b/config/wallet-config.json @@ -94,7 +94,14 @@ "runesEnabled": true, "swapsEnabled": true, "sbtc": { - "enabled": false, + "enabled": true, + "emilyApiUrl": "https://sbtc-emily.com", + "showPromoLinkOnNetworks": ["mainnet", "testnet", "sbtcTestnet"], + "sponsorshipsEnabled": true, + "sponsorshipApiUrl": { + "mainnet": "https://sponsor.leather.io", + "testnet": "http://testnet-13-60-14-218.nip.io" + }, "contracts": { "mainnet": { "address": "SM3VDXK3WZZSA84XXFKAFAF15NNZX32CTSG82JFQ4.sbtc-token::sbtc-token" @@ -102,8 +109,7 @@ "testnet": { "address": "SNGWPN3XDAQE673MXYXF81016M50NHF5X5PWWM70.sbtc-token::sbtc-token" } - }, - "showPromoLinkOnNetworks": ["testnet", "sbtcTestnet"] + } }, "tokensEnabledByDefault": [ "DOGGOTOTHEMOON", diff --git a/config/wallet-config.schema.json b/config/wallet-config.schema.json index 5bfdfdffd55..1756875564e 100644 --- a/config/wallet-config.schema.json +++ b/config/wallet-config.schema.json @@ -158,6 +158,27 @@ "type": "boolean", "description": "Determines whether or not SBTC is enabled" }, + "sponsorshipsEnabled": { + "type": "boolean", + "description": "Determines whether or not sponsored sBTC transactions are enabled" + }, + "emilyApiUrl": { + "type": "string", + "description": "URL for the Emily API" + }, + "sponsorshipApiUrl": { + "type": "object", + "properties": { + "mainnet": { + "type": "string", + "description": "Mainnet URL for the Leather Sponsor API" + }, + "testnet": { + "type": "string", + "description": "Testnet URL for the Leather Sponsor API" + } + } + }, "showPromoLinkOnNetworks": { "type": "array", "description": "Networks on which the promo link should be shown", diff --git a/package.json b/package.json index 39f167a87b1..98238b5ec87 100644 --- a/package.json +++ b/package.json @@ -146,15 +146,15 @@ "@hirosystems/token-metadata-api-client": "1.2.0", "@hookform/resolvers": "3.9.1", "@leather.io/analytics": "2.0.1", - "@leather.io/bitcoin": "0.16.5", - "@leather.io/constants": "0.13.3", - "@leather.io/crypto": "1.6.12", - "@leather.io/models": "0.21.0", - "@leather.io/query": "2.23.0", - "@leather.io/stacks": "1.3.5", + "@leather.io/bitcoin": "0.17.0", + "@leather.io/constants": "0.13.5", + "@leather.io/crypto": "1.6.14", + "@leather.io/models": "0.22.0", + "@leather.io/query": "2.26.1", + "@leather.io/stacks": "1.4.0", "@leather.io/tokens": "0.12.1", - "@leather.io/ui": "1.37.0", - "@leather.io/utils": "0.19.1", + "@leather.io/ui": "1.44.4", + "@leather.io/utils": "0.21.1", "@ledgerhq/hw-transport-webusb": "6.27.19", "@noble/hashes": "1.5.0", "@noble/secp256k1": "2.1.0", @@ -193,7 +193,6 @@ "@types/lodash.uniqby": "4.7.7", "@typescript-eslint/eslint-plugin": "7.5.0", "@zondax/ledger-stacks": "1.0.4", - "alex-sdk": "2.1.4", "are-passive-events-supported": "1.1.1", "argon2-browser": "1.18.0", "assert": "2.1.0", @@ -249,6 +248,7 @@ "redux-persist": "6.0.0", "remark-gfm": "4.0.0", "rxjs": "7.8.1", + "sbtc": "0.3.1", "style-loader": "3.3.4", "ts-debounce": "4.0.0", "url": "0.11.3", @@ -268,7 +268,7 @@ "@btckit/types": "0.0.19", "@chromatic-com/storybook": "3.2.2", "@leather.io/eslint-config": "0.7.0", - "@leather.io/panda-preset": "0.5.2", + "@leather.io/panda-preset": "0.8.0", "@leather.io/prettier-config": "0.6.0", "@leather.io/rpc": "2.4.0", "@ls-lint/ls-lint": "2.2.3", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6b8e4bbf75f..e334d1d4669 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -48,32 +48,32 @@ importers: specifier: 2.0.1 version: 2.0.1 '@leather.io/bitcoin': - specifier: 0.16.5 - version: 0.16.5(encoding@0.1.13) + specifier: 0.17.0 + version: 0.17.0(encoding@0.1.13) '@leather.io/constants': - specifier: 0.13.3 - version: 0.13.3 + specifier: 0.13.5 + version: 0.13.5 '@leather.io/crypto': - specifier: 1.6.12 - version: 1.6.12 + specifier: 1.6.14 + version: 1.6.14 '@leather.io/models': - specifier: 0.21.0 - version: 0.21.0 + specifier: 0.22.0 + version: 0.22.0 '@leather.io/query': - specifier: 2.23.0 - version: 2.23.0(encoding@0.1.13)(react@18.3.1) + specifier: 2.26.1 + version: 2.26.1(encoding@0.1.13)(react@18.3.1) '@leather.io/stacks': - specifier: 1.3.5 - version: 1.3.5(encoding@0.1.13) + specifier: 1.4.0 + version: 1.4.0(encoding@0.1.13) '@leather.io/tokens': specifier: 0.12.1 version: 0.12.1 '@leather.io/ui': - specifier: 1.37.0 - version: 1.37.0(@babel/core@7.26.0)(@babel/preset-env@7.26.0(@babel/core@7.26.0))(@emotion/is-prop-valid@1.3.1)(@types/react-dom@18.3.0)(@types/react@18.3.10)(encoding@0.1.13)(expo-modules-autolinking@1.11.1) + specifier: 1.44.4 + version: 1.44.4(@babel/core@7.26.0)(@babel/preset-env@7.26.0(@babel/core@7.26.0))(@emotion/is-prop-valid@1.3.1)(@types/react-dom@18.3.0)(@types/react@18.3.10)(encoding@0.1.13)(expo-modules-autolinking@1.11.1) '@leather.io/utils': - specifier: 0.19.1 - version: 0.19.1 + specifier: 0.21.1 + version: 0.21.1 '@ledgerhq/hw-transport-webusb': specifier: 6.27.19 version: 6.27.19 @@ -188,9 +188,6 @@ importers: '@zondax/ledger-stacks': specifier: 1.0.4 version: 1.0.4(encoding@0.1.13) - alex-sdk: - specifier: 2.1.4 - version: 2.1.4(@stacks/network@6.13.0(encoding@0.1.13))(@stacks/transactions@6.17.0(encoding@0.1.13)) are-passive-events-supported: specifier: 1.1.1 version: 1.1.1 @@ -356,6 +353,9 @@ importers: rxjs: specifier: 7.8.1 version: 7.8.1 + sbtc: + specifier: 0.3.1 + version: 0.3.1(encoding@0.1.13) style-loader: specifier: 3.3.4 version: 3.3.4(webpack@5.94.0(@swc/core@1.7.18)(esbuild@0.24.0)(webpack-cli@5.1.4(webpack-bundle-analyzer@4.10.2)(webpack-dev-server@4.15.1)(webpack@5.94.0))) @@ -409,8 +409,8 @@ importers: specifier: 0.7.0 version: 0.7.0(typescript@5.4.5) '@leather.io/panda-preset': - specifier: 0.5.2 - version: 0.5.2(jsdom@22.1.0)(typescript@5.4.5) + specifier: 0.8.0 + version: 0.8.0(jsdom@22.1.0)(typescript@5.4.5) '@leather.io/prettier-config': specifier: 0.6.0 version: 0.6.0(@vue/compiler-sfc@3.5.13) @@ -2177,6 +2177,9 @@ packages: '@blockstack/stacks-transactions@0.7.0': resolution: {integrity: sha512-9IUR641PJpigYDCWdtKFgBIk0Oxyoc2M2XVdyaEOEcRhI+4lcacDhsuseAmGbkB9FhJgJcNicWPujUPNFl1XCw==} + '@btc-helpers/rpc@2.0.0': + resolution: {integrity: sha512-Y5T3MUCW3lMENJiOJEk4DOwTW6GY/b/xmcf+kxoPZZw2uCyY+m3a9wV9wrJdIceqv3ET2iLFbGHjLwl25UYwGA==} + '@btckit/types@0.0.19': resolution: {integrity: sha512-APoOfYSg9SRR4CMXL606IDtpgh+ZD3kS/+iY0BkUALD6HvXo2pVw20L5YYIc+HrgMcF6WN0TH7TXdVs+Vu+kww==} @@ -3015,7 +3018,7 @@ packages: '@expo/bunyan@4.0.1': resolution: {integrity: sha512-+Lla7nYSiHZirgK+U/uYzsLv/X+HaJienbD5AKX1UQZHYfWaP+9uuQluRB4GrEVWF0GZ7vEVp/jzaOT9k/SQlg==} - engines: {'0': node >=0.10.0} + engines: {node: '>=0.10.0'} '@expo/cli@0.18.28': resolution: {integrity: sha512-fvbVPId6s6etindzP6Nzos/CS1NurMVy4JKozjebArHr63tBid5i/UY5Pp+4wTCAM20gB2SjRdwcwoL6HFC4Iw==} @@ -3275,83 +3278,77 @@ packages: '@leather.io/analytics@2.0.1': resolution: {integrity: sha512-C4fcEZZ9la+UMhqDqEZIIMDMmmedDuhhlnqb7HFlGVUg5/EKrBbS4Kvcp36sv/Uz02h+DuoubTsoJ/rFkqq6Bw==} - '@leather.io/bitcoin@0.16.5': - resolution: {integrity: sha512-YzNep7Mkb4Be3p0RF52y53yhbn9/7JOd5N45FE+52N2H8l3N2OgTk68rdCF7ZGPLIgYD8ws9dR/4rSc1lCAV8g==} + '@leather.io/bitcoin@0.17.0': + resolution: {integrity: sha512-Nc4Bl2HWmxWvgIXbpa6Gs8EpqR1UJHDzXPu+R+2TIyOsjxFoGXoIQwLohxySnQa4i0UodrCXBrqDOqOTHtwbMQ==} - '@leather.io/bitcoin@0.16.6': - resolution: {integrity: sha512-P5OmvUTsoHQDxWcjAOqGvnbHNCjd2/XWYsMUZCrxGXCaPAQdhShLfvilzSUIOnUS/MkwxOMB28f2hGgs8lfeJA==} - - '@leather.io/constants@0.13.3': - resolution: {integrity: sha512-kVMOb0QXDUspjlz8wycpCVrtu5IdJAiWgegRV9WdWcCdO1uiMAtqXsgd5K/CvafRw+/XYmYSLllDsC26rVBqQw==} - - '@leather.io/constants@0.13.4': - resolution: {integrity: sha512-Cot8qwwLWeIMr0LFHtzVkeJZ/a+MSMuNPhrXKm5Psaqb2jyjkq5RrlCLtm9/cjwmkBTZeokWubaQUzyPMlQK4w==} + '@leather.io/bitcoin@0.19.0': + resolution: {integrity: sha512-fqE1peFL3kgOnHcQgo8s/ClmLTIWRZrOBoFMWjM+8x8vcZQWvBXC5oTWDSGWsrxabzQhh0jKBcj8HXMOhcf3ZA==} '@leather.io/constants@0.13.5': resolution: {integrity: sha512-FOh/F/g8WepB8HfoTXsMB/BYcm/F6INPEpyEZc3ljzaN0mLwVLO1kwgMTFU9Pq7tQlITvyWiyGHcB7OYovLoUQ==} - '@leather.io/crypto@1.6.12': - resolution: {integrity: sha512-ZEhUVhdq/m2pIhi8PwvRKyjwYdcmXcRJBOLSO1RMSu8N93GTEqfvxkqTlu88c2ReNt5C62+TwWM6hbZkU2BV6Q==} + '@leather.io/constants@0.14.0': + resolution: {integrity: sha512-kSrrbxmt7uxX9MZKrzHu71XL2vF1oig8PoMlBC6Vh/xpU5TGTySmpOLhfEES2FUqh1mRa/JP/ZgOYX6dQvoboQ==} - '@leather.io/crypto@1.6.13': - resolution: {integrity: sha512-wSQctUprSg8slWi5A5KskrXRKOEDP3M9TxY+SqQa9M6CsSFR212YLu4K6ZsbG4yKnhDUelegeg2kwpdpyT1pXg==} + '@leather.io/constants@0.15.1': + resolution: {integrity: sha512-qgkUHOz/10jxTsprhwzBb3Iml9BkYFWtWKeomNh0nK3zVto2zb8mn6PXvcthVs5FYIYkxou6DfIAUDeUO0czDQ==} - '@leather.io/eslint-config@0.7.0': - resolution: {integrity: sha512-4K7olfSC+mJnG90TSaLIlytp14yDprGXwe1+oP9TLQbuPFpJai3/+g5Bp/FeUC4NZ23UVbAlGXFCav2amBb77w==} + '@leather.io/crypto@1.6.14': + resolution: {integrity: sha512-D9Z0EgvXhdDSJdaQX+KCm2czqNCRDe3Kj7YJ3Hn1GiHet+wOy68sBJzG6yzESeC0Z7f1UBuDhmN9Edq5u1Zz2g==} - '@leather.io/models@0.20.0': - resolution: {integrity: sha512-plsF1fzBC32OukQtijoeQgaszDrh0dqkPJByiA/ygHT8Jay8dNYclvVznpKCSsOhdt6iywJzgLY3yIO4MiKTKg==} + '@leather.io/crypto@1.6.17': + resolution: {integrity: sha512-CctP7QMqL6DvniYTCetG92H1vYzJeJCdbyuDhwe29v2oqmEnjwRJsX5X6ue0o2sEihtjJtvXJG1HPwd+D17XGw==} - '@leather.io/models@0.21.0': - resolution: {integrity: sha512-cy/WToOVy0ZGHxza5kJL2aeKKnBXL4lSK/j3iig/rDWAgx5Vy7M8sCjFBbo4hlinskb8VgM5woGe7hIFjFZcmA==} + '@leather.io/eslint-config@0.7.0': + resolution: {integrity: sha512-4K7olfSC+mJnG90TSaLIlytp14yDprGXwe1+oP9TLQbuPFpJai3/+g5Bp/FeUC4NZ23UVbAlGXFCav2amBb77w==} '@leather.io/models@0.22.0': resolution: {integrity: sha512-MmFmWdKN3T+L9euo+rq8JCr5Ku0mNulzVa0mYqXclB9vLa4NyhUsGHA3lWz8e05cMW9CsrPNg+eWpVg6AKTkeQ==} + '@leather.io/models@0.24.0': + resolution: {integrity: sha512-WB4CI/RWag10unxQSqdN3wIlmLaww5we4KmIyVroX3QfQyRMSN98srs8b507RITc+deD2xrIGbS0M/HOPG85tw==} + '@leather.io/models@0.24.1': resolution: {integrity: sha512-BRjiX7N/LUlg5MMe3r5mkjUGLGtGRSpd21LPgo0lhUUSO1cBMAdp7rLACGM9LFPw+/dnUw1yMpT2hURC3pe3Gg==} - '@leather.io/panda-preset@0.5.2': - resolution: {integrity: sha512-JxPGX7hEUKWLp4gYc2S5irK6QXVMFEGn2F7bXEv2cr8324DYa07i5iUF6/UVeRhrzVivcXEdd/6u0wlKc39Rcw==} + '@leather.io/panda-preset@0.8.0': + resolution: {integrity: sha512-DnDSxZ5AJPYBdykTNpTeuB4WewwWUGWPoDWQdeX4/Th5gfekUwvnybLM9D3iVCkvmUgv+BZgMgCzHnd5cGO2FA==} '@leather.io/prettier-config@0.6.0': resolution: {integrity: sha512-QBKtLanfxFxXBlR58U/j8a6lBI0xzJzqqi36fXpGVp+9mJoEf6Ro6xrtFrixjW6seY6EOva4OApVnnPBsvOC/w==} - '@leather.io/query@2.23.0': - resolution: {integrity: sha512-1I4Vm2wDQWz4vK3fFFioh82sNrHKeSrDudRCUukoTH3wLREn/oLqIE24GDTGri4HcbxB60Qu+LcVTGl1XcU2QQ==} + '@leather.io/query@2.26.1': + resolution: {integrity: sha512-/F0ddwk552/XAoP0dN+Yk//7m1qjvkHMZ+v6N4KFkwbNSNDh+1p5ehZ3GNf6AX1zT6pFc+6OhD4LPR7dmKdlFw==} peerDependencies: react: '*' - '@leather.io/rpc@2.1.18': - resolution: {integrity: sha512-KC5QvjR0O/9QDwD1liPXo+aV7yDIA82OFjrveG7JYzWNSn2PBkkX5NJ1KqE42BILX3tfBuV/rab6zN19KvRGbw==} - - '@leather.io/rpc@2.1.19': - resolution: {integrity: sha512-IUcXnmIuPyu5TK3x46ReSw+2/As2cWa6mzA+tofe/e59K9E5SMpEXZAg+WhvgPRFaXWaA3W8BzeH0BVEoRwEtw==} - '@leather.io/rpc@2.1.20': resolution: {integrity: sha512-BE56W5yzdOPdVWHo2G+ZrXsEt3Jki47/noMxy+9On0sNW5B90M+pULm65szp5/vzqJuY7xIrQJ1AOyG22cd82g==} + '@leather.io/rpc@2.1.22': + resolution: {integrity: sha512-1LHCs251qDSOw9vWbkkhaJ7YiYbsZ8KtwDajpJFLJ9mcqBGqS8TJhqUBlWGZqe2LQEak2s7usMU6M19QR8NyqQ==} + '@leather.io/rpc@2.4.0': resolution: {integrity: sha512-S9PYtyOnZ9LJL8ZYsEPHUWmVkVL/E7oAfNLunFY7zVI0tJUl45OXJVCOjYRRKDKOqdx2pRpdqzeUSxRZvJdyVA==} - '@leather.io/stacks@1.3.5': - resolution: {integrity: sha512-yqOX6CTcg0Shj3A5ymYtho054PJ2xU+HlyRfwXca8yJ1U2chMSq7jTinJktgI+1liTHsqmtRnrgmufxWY41J7A==} + '@leather.io/stacks@1.4.0': + resolution: {integrity: sha512-vF3eQljr+dsfg8DhlEFgQKvr9NHn9CKwt8XT51kWnULTtZH6syrABiarHGwhtE/AZz9weg5n5q/+m8b4lN6bGw==} '@leather.io/tokens@0.12.1': resolution: {integrity: sha512-XoP9PT7uuzHIk9HFFkGTuMeG8KL94PVHQfdWtvb19qQC9YVMzwj+QSK45KUFYtXyfI+Ejsn7EFZmZ/C4vStDrg==} - '@leather.io/ui@1.37.0': - resolution: {integrity: sha512-ryylwG9m9hjA7MUhXcN0idCtXZYwfhLHd+4T+/vXCvXEaSpFro4DF+ih7msJGCv+B0R0y/PaQvu+KJDgLhrYrA==} - - '@leather.io/utils@0.19.1': - resolution: {integrity: sha512-bwD3/4Rt3UOL3pvettqNon+zqS5S8K6z3AoAEwkcYS77DI/q4kzH5T/3nOOGpcWda1/R453mqRCIObRxecIWFA==} - - '@leather.io/utils@0.19.2': - resolution: {integrity: sha512-oLEasUP5BDeDbrB9vxH0C0zrZWcG2bj12KHaI9illCtIqEe9pLM/5R5Ee6vVH1Ft+vCg/HI7WHNu79o0NHFdgQ==} + '@leather.io/ui@1.44.4': + resolution: {integrity: sha512-6RqXh0Ig4o12EECjGtdUOtdPEpxgQlm5mQxGdAaz2U4FckZQ7DTS0v5A7dh+0jJb3Z1zad9qjWyJuY31hoYjgg==} '@leather.io/utils@0.20.0': resolution: {integrity: sha512-Ot0oOYMku4oy3218W3Tt0ip0xjMyegOxFONqOyt/WSZe9xzTiXXUq0u3D8jwa851ZEOSCB7TgOO5RMzWK0lkLg==} + '@leather.io/utils@0.21.1': + resolution: {integrity: sha512-l2epiM2cYqImWO0okuWMAF2Q13g8gNaz6PYlzMejccfgGvPwj/XDsRqCHPzNhKmQ7cTsirJQ8I5uJOpUPg8BIA==} + + '@leather.io/utils@0.22.4': + resolution: {integrity: sha512-7KW+SzpjaFqeB75Y7LaC9CUK0k5rDl1y1fammMUOchaPVdLrsJ+6bzLW2p+7Vh6EnoRAKGk3V4/tML9eHpH7Gg==} + '@ledgerhq/devices@8.4.2': resolution: {integrity: sha512-oWNTp3jCMaEvRHsXNYE/yo+PFMgXAJGFHLOU1UdE4/fYkniHbD9wdxwyZrZvrxr9hNw4/9wHiThyITwPtMzG7g==} @@ -3834,6 +3831,15 @@ packages: '@types/react': optional: true + '@radix-ui/react-context@1.1.1': + resolution: {integrity: sha512-UASk9zi+crv9WteK/NU4PLvOoL3OuE6BWVKNF6hPRBtYBDXQ2u5iu3O59zUlJiTVvkyuycnqrztsHVJwcK9K+Q==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@radix-ui/react-dialog@1.0.5': resolution: {integrity: sha512-GjWJX/AUpB703eEBanuBnIWdIXg6NvJFCXcNlSZk4xdszCdhrJgBoUd1cGk67vFO+WdA2pfI/plOpqz/5GUP6Q==} peerDependencies: @@ -4265,6 +4271,19 @@ packages: '@types/react-dom': optional: true + '@radix-ui/react-slider@1.2.1': + resolution: {integrity: sha512-bEzQoDW0XP+h/oGbutF5VMWJPAl/UU8IJjr7h02SOHDIIIxq+cep8nItVNoBV+OMmahCdqdF38FTpmXoqQUGvw==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@radix-ui/react-slot@1.0.2': resolution: {integrity: sha512-YeTpuq4deV+6DusvVUW4ivBgnkHwECUu0BiN43L5UCDFgdhsRUWAghhTF5MbvNTPzmiFOx90asDSUjWuCNapwg==} peerDependencies: @@ -5175,6 +5194,9 @@ packages: '@stacks/common@6.16.0': resolution: {integrity: sha512-PnzvhrdGRMVZvxTulitlYafSK4l02gPCBBoI9QEoTqgSnv62oaOXhYAUUkTMFKxdHW1seVEwZsrahuXiZPIAwg==} + '@stacks/common@7.0.2': + resolution: {integrity: sha512-+RSecHdkxOtswmE4tDDoZlYEuULpnTQVeDIG5eZ32opK8cFxf4EugAcK9CsIsHx/Se1yTEaQ21WGATmJGK84lQ==} + '@stacks/connect-react@22.2.0': resolution: {integrity: sha512-sH4yzndaTzQ+NrdNKYbpYFmBSeATYAwFJ6PZM81/m1iTh4DSQIVGR1nTIFpbuC0ynIMcLB45/hhyPO8O/fYr9Q==} peerDependencies: @@ -5193,6 +5215,9 @@ packages: '@stacks/encryption@6.16.1': resolution: {integrity: sha512-DtVNNW/iipyxxRDz73S9DbLfRmBMqQCCog89F1Q1i6JUnl2kBB1PR9SPQfYv9zcAJ37oHoNB4i4b2tJWYr01vg==} + '@stacks/encryption@7.0.2': + resolution: {integrity: sha512-3evRvxPqVzQAhcZ8uacQrVfAETUMIV8VyKkHGsd4QZroGWlvXQheLV3CFeDttFb304QcKq/oKv1clOvQ2shaAw==} + '@stacks/network@4.3.5': resolution: {integrity: sha512-TC4+AkuT6qi3MoEGxTftA+4BNp99QvGnI+qtKQkoA1m0KDr8b9hSBUhugJHRhQbWuo7D6q0+JagYEGxLID29Kw==} @@ -5202,6 +5227,9 @@ packages: '@stacks/network@6.17.0': resolution: {integrity: sha512-numHbfKjwco/rbkGPOEz8+FcJ2nBnS/tdJ8R422Q70h3SiA9eqk9RjSzB8p4JP8yW1SZvW+eihADHfMpBuZyfw==} + '@stacks/network@7.0.2': + resolution: {integrity: sha512-XzHnoWqku/jRrTgMXhmh3c+I0O9vDH24KlhzGDZtBu+8CGGyHNPAZzGwvoUShonMXrXjEnfO9IYQwV5aJhfv6g==} + '@stacks/profile@6.15.0': resolution: {integrity: sha512-+m11HYHU45+f98FySsVmofeLFWxnhnwZ5jbREoD2f53fmBulsSbJpDUVg3w4aPdj6hg4+o7rkg09gbirIXNWBw==} @@ -5220,6 +5248,9 @@ packages: '@stacks/transactions@6.17.0': resolution: {integrity: sha512-FUah2BRgV66ApLcEXGNGhwyFTRXqX5Zco3LpiM3essw8PF0NQlHwwdPgtDko5RfrJl3LhGXXe/30nwsfNnB3+g==} + '@stacks/transactions@7.0.2': + resolution: {integrity: sha512-m2bvchqUeYv1ttXuC0EukW8UX4xBXTDcYb8bXmfI1RG89HXAvvCCgr5aiadU6zbutgoXvm8mquDt3nww0PO4Jg==} + '@stacks/wallet-sdk@6.15.0': resolution: {integrity: sha512-VBMiWe5UAyDnvc2w8/XN7QuSkbXTnAJ5rvtzedb7yXKgIBMSjE+gQnUm0XasbNDRHc58Ag76IAMAIKh4ZAMC4w==} @@ -7564,6 +7595,9 @@ packages: bitcoin-address-validation@2.2.1: resolution: {integrity: sha512-f6LXNpvRKlTbHWb37N9tHoAbYGbshzM8FPWvCtloh++hxZ0/dmkokvKNVLz6HkG82zVwo8w6Sq4JmfO2timzyg==} + bitcoin-address-validation@2.2.3: + resolution: {integrity: sha512-1uGCGl26Ye8JG5qcExtFLQfuib6qEZWNDo1ZlLlwp/z7ygUFby3IxolgEfgMGaC+LG9csbVASLcH8fRLv7DIOg==} + bitcoinjs-lib@6.1.5: resolution: {integrity: sha512-yuf6xs9QX/E8LWE2aMJPNd0IxGofwfuVOiYdNUESkc+2bHHVKjhJd8qewqapeoolh9fihzHGoDCB5Vkr57RZCQ==} engines: {node: '>=8.0.0'} @@ -13985,6 +14019,9 @@ packages: resolution: {integrity: sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==} engines: {node: '>=v12.22.7'} + sbtc@0.3.1: + resolution: {integrity: sha512-dAd0hxgIS1qchzb6tWbBTgoYio/v0Rln9pq3L23cX52DBSI4WpoMr2MRsM8z3lORTeqhIPLd3Tn/IdB+jKYdIA==} + sc-errors@3.0.0: resolution: {integrity: sha512-rIqv2HTPb9DVreZwK/DV0ytRUqyw2DbDcoB9XTKjEQL7oMEQKsfPA8V8dGGr7p8ZYfmvaRIGZ4Wu5qwvs/hGDA==} @@ -14512,12 +14549,15 @@ packages: sudo-prompt@8.2.5: resolution: {integrity: sha512-rlBo3HU/1zAJUrkY6jNxDOC9eVYliG6nS4JA8u8KAshITd07tafMc/Br7xQwCSseXwJ2iCcHCE8SNWX3q8Z+kw==} + deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info. sudo-prompt@9.1.1: resolution: {integrity: sha512-es33J1g2HjMpyAhz8lOR+ICmXXAqTuKbuXuUWLhOLew20oN9oUCgCJx615U/v7aioZg7IX5lIh9x34vwneu4pA==} + deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info. sudo-prompt@9.2.1: resolution: {integrity: sha512-Mu7R0g4ig9TUuGSxJavny5Rv0egCEtpZRNMrZaYS1vxkiIxGiGUwoezU3LazIQ+KE04hTrTfNPgxU5gzi7F5Pw==} + deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info. sumchecker@3.0.1: resolution: {integrity: sha512-MvjXzkz/BOfyVDkG0oFOtBxHX2u3gKbMHIF/dXblZsgD3BWOFLmHovIpZY7BykJdAjcqRCBi1WYBNdEC9yI7vg==} @@ -17827,6 +17867,13 @@ snapshots: transitivePeerDependencies: - encoding + '@btc-helpers/rpc@2.0.0(encoding@0.1.13)': + dependencies: + '@scure/base': 1.1.9 + cross-fetch: 3.1.8(encoding@0.1.13) + transitivePeerDependencies: + - encoding + '@btckit/types@0.0.19': {} '@chromatic-com/storybook@3.2.2(react@18.3.1)(storybook@8.4.4(prettier@3.3.3))': @@ -19040,13 +19087,13 @@ snapshots: '@leather.io/analytics@2.0.1': {} - '@leather.io/bitcoin@0.16.5(encoding@0.1.13)': + '@leather.io/bitcoin@0.17.0(encoding@0.1.13)': dependencies: '@bitcoinerlab/secp256k1': 1.0.2 - '@leather.io/constants': 0.13.3 - '@leather.io/crypto': 1.6.12 - '@leather.io/models': 0.20.0 - '@leather.io/utils': 0.19.1 + '@leather.io/constants': 0.13.5 + '@leather.io/crypto': 1.6.14 + '@leather.io/models': 0.22.0 + '@leather.io/utils': 0.20.0 '@noble/hashes': 1.5.0 '@noble/secp256k1': 2.1.0 '@scure/base': 1.1.9 @@ -19055,7 +19102,9 @@ snapshots: '@scure/btc-signer': 1.4.0 '@stacks/common': 6.13.0 '@stacks/transactions': 6.17.0(encoding@0.1.13) + bignumber.js: 9.1.2 bip32: 4.0.0 + bitcoin-address-validation: 2.2.3 bitcoinjs-lib: 6.1.5 ecpair: 2.1.0 just-memoize: 2.2.0 @@ -19063,13 +19112,13 @@ snapshots: transitivePeerDependencies: - encoding - '@leather.io/bitcoin@0.16.6(encoding@0.1.13)': + '@leather.io/bitcoin@0.19.0(encoding@0.1.13)': dependencies: '@bitcoinerlab/secp256k1': 1.0.2 - '@leather.io/constants': 0.13.4 - '@leather.io/crypto': 1.6.13 - '@leather.io/models': 0.21.0 - '@leather.io/utils': 0.19.2 + '@leather.io/constants': 0.14.0 + '@leather.io/crypto': 1.6.17 + '@leather.io/models': 0.24.0 + '@leather.io/utils': 0.21.1 '@noble/hashes': 1.5.0 '@noble/secp256k1': 2.1.0 '@scure/base': 1.1.9 @@ -19078,7 +19127,9 @@ snapshots: '@scure/btc-signer': 1.4.0 '@stacks/common': 6.13.0 '@stacks/transactions': 6.17.0(encoding@0.1.13) + bignumber.js: 9.1.2 bip32: 4.0.0 + bitcoin-address-validation: 2.2.3 bitcoinjs-lib: 6.1.5 ecpair: 2.1.0 just-memoize: 2.2.0 @@ -19086,28 +19137,28 @@ snapshots: transitivePeerDependencies: - encoding - '@leather.io/constants@0.13.3': + '@leather.io/constants@0.13.5': dependencies: - '@leather.io/models': 0.20.0 + '@leather.io/models': 0.22.0 - '@leather.io/constants@0.13.4': + '@leather.io/constants@0.14.0': dependencies: - '@leather.io/models': 0.21.0 + '@leather.io/models': 0.24.0 - '@leather.io/constants@0.13.5': + '@leather.io/constants@0.15.1': dependencies: - '@leather.io/models': 0.22.0 + '@leather.io/models': 0.24.1 - '@leather.io/crypto@1.6.12': + '@leather.io/crypto@1.6.14': dependencies: - '@leather.io/utils': 0.19.1 + '@leather.io/utils': 0.20.0 '@scure/bip32': 1.5.0 '@scure/bip39': 1.4.0 just-memoize: 2.2.0 - '@leather.io/crypto@1.6.13': + '@leather.io/crypto@1.6.17': dependencies: - '@leather.io/utils': 0.19.2 + '@leather.io/utils': 0.21.1 '@scure/bip32': 1.5.0 '@scure/bip39': 1.4.0 just-memoize: 2.2.0 @@ -19122,19 +19173,13 @@ snapshots: - supports-color - typescript - '@leather.io/models@0.20.0': - dependencies: - '@stacks/stacks-blockchain-api-types': 7.8.2 - bignumber.js: 9.1.2 - zod: 3.23.8 - - '@leather.io/models@0.21.0': + '@leather.io/models@0.22.0': dependencies: '@stacks/stacks-blockchain-api-types': 7.8.2 bignumber.js: 9.1.2 zod: 3.23.8 - '@leather.io/models@0.22.0': + '@leather.io/models@0.24.0': dependencies: '@stacks/stacks-blockchain-api-types': 7.8.2 bignumber.js: 9.1.2 @@ -19146,7 +19191,7 @@ snapshots: bignumber.js: 9.1.2 zod: 3.23.8 - '@leather.io/panda-preset@0.5.2(jsdom@22.1.0)(typescript@5.4.5)': + '@leather.io/panda-preset@0.8.0(jsdom@22.1.0)(typescript@5.4.5)': dependencies: '@pandacss/dev': 0.46.1(jsdom@22.1.0)(typescript@5.4.5) transitivePeerDependencies: @@ -19161,15 +19206,15 @@ snapshots: - '@vue/compiler-sfc' - supports-color - '@leather.io/query@2.23.0(encoding@0.1.13)(react@18.3.1)': + '@leather.io/query@2.26.1(encoding@0.1.13)(react@18.3.1)': dependencies: '@fungible-systems/zone-file': 2.0.0 '@hirosystems/token-metadata-api-client': 1.2.0(encoding@0.1.13) - '@leather.io/bitcoin': 0.16.6(encoding@0.1.13) - '@leather.io/constants': 0.13.4 - '@leather.io/models': 0.21.0 - '@leather.io/rpc': 2.1.19 - '@leather.io/utils': 0.19.2 + '@leather.io/bitcoin': 0.19.0(encoding@0.1.13) + '@leather.io/constants': 0.14.0 + '@leather.io/models': 0.24.0 + '@leather.io/rpc': 2.1.22 + '@leather.io/utils': 0.21.1 '@noble/hashes': 1.5.0 '@scure/base': 1.1.9 '@scure/bip32': 1.5.0 @@ -19194,19 +19239,14 @@ snapshots: - debug - encoding - '@leather.io/rpc@2.1.18': - dependencies: - '@leather.io/models': 0.20.0 - zod: 3.23.8 - - '@leather.io/rpc@2.1.19': + '@leather.io/rpc@2.1.20': dependencies: - '@leather.io/models': 0.21.0 + '@leather.io/models': 0.22.0 zod: 3.23.8 - '@leather.io/rpc@2.1.20': + '@leather.io/rpc@2.1.22': dependencies: - '@leather.io/models': 0.22.0 + '@leather.io/models': 0.24.0 zod: 3.23.8 '@leather.io/rpc@2.4.0(encoding@0.1.13)': @@ -19217,32 +19257,37 @@ snapshots: transitivePeerDependencies: - encoding - '@leather.io/stacks@1.3.5(encoding@0.1.13)': + '@leather.io/stacks@1.4.0(encoding@0.1.13)': dependencies: - '@leather.io/crypto': 1.6.12 - '@leather.io/models': 0.20.0 - '@leather.io/utils': 0.19.1 + '@leather.io/constants': 0.13.5 + '@leather.io/crypto': 1.6.14 + '@leather.io/models': 0.22.0 + '@leather.io/utils': 0.20.0 '@noble/hashes': 1.5.0 '@scure/bip32': 1.5.0 '@stacks/encryption': 6.16.1 + '@stacks/network': 6.13.0(encoding@0.1.13) + '@stacks/stacks-blockchain-api-types': 7.8.2 '@stacks/transactions': 6.17.0(encoding@0.1.13) + bignumber.js: 9.1.2 transitivePeerDependencies: - encoding '@leather.io/tokens@0.12.1': {} - '@leather.io/ui@1.37.0(@babel/core@7.26.0)(@babel/preset-env@7.26.0(@babel/core@7.26.0))(@emotion/is-prop-valid@1.3.1)(@types/react-dom@18.3.0)(@types/react@18.3.10)(encoding@0.1.13)(expo-modules-autolinking@1.11.1)': + '@leather.io/ui@1.44.4(@babel/core@7.26.0)(@babel/preset-env@7.26.0(@babel/core@7.26.0))(@emotion/is-prop-valid@1.3.1)(@types/react-dom@18.3.0)(@types/react@18.3.10)(encoding@0.1.13)(expo-modules-autolinking@1.11.1)': dependencies: '@expo/vector-icons': 14.0.0 '@gorhom/bottom-sheet': 4.6.3(@types/react@18.3.10)(react-native-gesture-handler@2.16.1(react-native@0.74.1(@babel/core@7.26.0)(@babel/preset-env@7.26.0(@babel/core@7.26.0))(@types/react@18.3.10)(encoding@0.1.13)(react@18.2.0))(react@18.2.0))(react-native-reanimated@3.10.1(@babel/core@7.26.0)(react-native@0.74.1(@babel/core@7.26.0)(@babel/preset-env@7.26.0(@babel/core@7.26.0))(@types/react@18.3.10)(encoding@0.1.13)(react@18.2.0))(react@18.2.0))(react-native@0.74.1(@babel/core@7.26.0)(@babel/preset-env@7.26.0(@babel/core@7.26.0))(@types/react@18.3.10)(encoding@0.1.13)(react@18.2.0))(react@18.2.0) '@leather.io/tokens': 0.12.1 - '@leather.io/utils': 0.20.0 + '@leather.io/utils': 0.22.4(encoding@0.1.13) '@radix-ui/react-accessible-icon': 1.0.3(@types/react-dom@18.3.0)(@types/react@18.3.10)(react-dom@18.2.0(react@18.2.0))(react@18.2.0) '@radix-ui/react-accordion': 1.1.2(@types/react-dom@18.3.0)(@types/react@18.3.10)(react-dom@18.2.0(react@18.2.0))(react@18.2.0) '@radix-ui/react-avatar': 1.0.4(@types/react-dom@18.3.0)(@types/react@18.3.10)(react-dom@18.2.0(react@18.2.0))(react@18.2.0) '@radix-ui/react-dialog': 1.0.5(@types/react-dom@18.3.0)(@types/react@18.3.10)(react-dom@18.2.0(react@18.2.0))(react@18.2.0) '@radix-ui/react-dropdown-menu': 2.0.6(@types/react-dom@18.3.0)(@types/react@18.3.10)(react-dom@18.2.0(react@18.2.0))(react@18.2.0) '@radix-ui/react-select': 2.0.0(@types/react-dom@18.3.0)(@types/react@18.3.10)(react-dom@18.2.0(react@18.2.0))(react@18.2.0) + '@radix-ui/react-slider': 1.2.1(@types/react-dom@18.3.0)(@types/react@18.3.10)(react-dom@18.2.0(react@18.2.0))(react@18.2.0) '@radix-ui/react-switch': 1.1.0(@types/react-dom@18.3.0)(@types/react@18.3.10)(react-dom@18.2.0(react@18.2.0))(react@18.2.0) '@radix-ui/react-tabs': 1.0.4(@types/react-dom@18.3.0)(@types/react@18.3.10)(react-dom@18.2.0(react@18.2.0))(react@18.2.0) '@radix-ui/react-toast': 1.1.5(@types/react-dom@18.3.0)(@types/react@18.3.10)(react-dom@18.2.0(react@18.2.0))(react@18.2.0) @@ -19289,26 +19334,28 @@ snapshots: - supports-color - utf-8-validate - '@leather.io/utils@0.19.1': + '@leather.io/utils@0.20.0': dependencies: - '@leather.io/constants': 0.13.3 - '@leather.io/models': 0.20.0 - '@leather.io/rpc': 2.1.18 + '@leather.io/constants': 0.13.5 + '@leather.io/models': 0.22.0 + '@leather.io/rpc': 2.1.20 bignumber.js: 9.1.2 - '@leather.io/utils@0.19.2': + '@leather.io/utils@0.21.1': dependencies: - '@leather.io/constants': 0.13.4 - '@leather.io/models': 0.21.0 - '@leather.io/rpc': 2.1.19 + '@leather.io/constants': 0.14.0 + '@leather.io/models': 0.24.0 + '@leather.io/rpc': 2.1.22 bignumber.js: 9.1.2 - '@leather.io/utils@0.20.0': + '@leather.io/utils@0.22.4(encoding@0.1.13)': dependencies: - '@leather.io/constants': 0.13.5 - '@leather.io/models': 0.22.0 - '@leather.io/rpc': 2.1.20 + '@leather.io/constants': 0.15.1 + '@leather.io/models': 0.24.1 + '@leather.io/rpc': 2.4.0(encoding@0.1.13) bignumber.js: 9.1.2 + transitivePeerDependencies: + - encoding '@ledgerhq/devices@8.4.2': dependencies: @@ -19897,6 +19944,18 @@ snapshots: '@types/react': 18.3.10 '@types/react-dom': 18.3.0 + '@radix-ui/react-collection@1.1.0(@types/react-dom@18.3.0)(@types/react@18.3.10)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)': + dependencies: + '@radix-ui/react-compose-refs': 1.1.0(@types/react@18.3.10)(react@18.2.0) + '@radix-ui/react-context': 1.1.0(@types/react@18.3.10)(react@18.2.0) + '@radix-ui/react-primitive': 2.0.0(@types/react-dom@18.3.0)(@types/react@18.3.10)(react-dom@18.2.0(react@18.2.0))(react@18.2.0) + '@radix-ui/react-slot': 1.1.0(@types/react@18.3.10)(react@18.2.0) + react: 18.2.0 + react-dom: 18.2.0(react@18.2.0) + optionalDependencies: + '@types/react': 18.3.10 + '@types/react-dom': 18.3.0 + '@radix-ui/react-collection@1.1.0(@types/react-dom@18.3.0)(@types/react@18.3.10)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': dependencies: '@radix-ui/react-compose-refs': 1.1.0(@types/react@18.3.10)(react@18.3.1) @@ -19975,6 +20034,12 @@ snapshots: optionalDependencies: '@types/react': 18.3.10 + '@radix-ui/react-context@1.1.1(@types/react@18.3.10)(react@18.2.0)': + dependencies: + react: 18.2.0 + optionalDependencies: + '@types/react': 18.3.10 + '@radix-ui/react-dialog@1.0.5(@types/react-dom@18.3.0)(@types/react@18.3.10)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)': dependencies: '@babel/runtime': 7.25.4 @@ -20057,6 +20122,12 @@ snapshots: optionalDependencies: '@types/react': 18.3.10 + '@radix-ui/react-direction@1.1.0(@types/react@18.3.10)(react@18.2.0)': + dependencies: + react: 18.2.0 + optionalDependencies: + '@types/react': 18.3.10 + '@radix-ui/react-direction@1.1.0(@types/react@18.3.10)(react@18.3.1)': dependencies: react: 18.3.1 @@ -20690,6 +20761,25 @@ snapshots: '@types/react': 18.3.10 '@types/react-dom': 18.3.0 + '@radix-ui/react-slider@1.2.1(@types/react-dom@18.3.0)(@types/react@18.3.10)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)': + dependencies: + '@radix-ui/number': 1.1.0 + '@radix-ui/primitive': 1.1.0 + '@radix-ui/react-collection': 1.1.0(@types/react-dom@18.3.0)(@types/react@18.3.10)(react-dom@18.2.0(react@18.2.0))(react@18.2.0) + '@radix-ui/react-compose-refs': 1.1.0(@types/react@18.3.10)(react@18.2.0) + '@radix-ui/react-context': 1.1.1(@types/react@18.3.10)(react@18.2.0) + '@radix-ui/react-direction': 1.1.0(@types/react@18.3.10)(react@18.2.0) + '@radix-ui/react-primitive': 2.0.0(@types/react-dom@18.3.0)(@types/react@18.3.10)(react-dom@18.2.0(react@18.2.0))(react@18.2.0) + '@radix-ui/react-use-controllable-state': 1.1.0(@types/react@18.3.10)(react@18.2.0) + '@radix-ui/react-use-layout-effect': 1.1.0(@types/react@18.3.10)(react@18.2.0) + '@radix-ui/react-use-previous': 1.1.0(@types/react@18.3.10)(react@18.2.0) + '@radix-ui/react-use-size': 1.1.0(@types/react@18.3.10)(react@18.2.0) + react: 18.2.0 + react-dom: 18.2.0(react@18.2.0) + optionalDependencies: + '@types/react': 18.3.10 + '@types/react-dom': 18.3.0 + '@radix-ui/react-slot@1.0.2(@types/react@18.3.10)(react@18.2.0)': dependencies: '@babel/runtime': 7.26.0 @@ -22301,6 +22391,8 @@ snapshots: '@types/bn.js': 5.1.6 '@types/node': 18.19.56 + '@stacks/common@7.0.2': {} + '@stacks/connect-react@22.2.0(encoding@0.1.13)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': dependencies: '@stacks/connect': 7.4.0(encoding@0.1.13) @@ -22349,6 +22441,17 @@ snapshots: ripemd160-min: 0.0.6 varuint-bitcoin: 1.1.2 + '@stacks/encryption@7.0.2': + dependencies: + '@noble/hashes': 1.1.5 + '@noble/secp256k1': 1.7.1 + '@scure/bip39': 1.1.0 + '@stacks/common': 7.0.2 + base64-js: 1.5.1 + bs58: 5.0.0 + ripemd160-min: 0.0.6 + varuint-bitcoin: 1.1.2 + '@stacks/network@4.3.5(encoding@0.1.13)': dependencies: '@stacks/common': 4.3.5 @@ -22370,6 +22473,13 @@ snapshots: transitivePeerDependencies: - encoding + '@stacks/network@7.0.2(encoding@0.1.13)': + dependencies: + '@stacks/common': 7.0.2 + cross-fetch: 3.1.8(encoding@0.1.13) + transitivePeerDependencies: + - encoding + '@stacks/profile@6.15.0(encoding@0.1.13)': dependencies: '@stacks/common': 6.13.0 @@ -22428,6 +22538,17 @@ snapshots: transitivePeerDependencies: - encoding + '@stacks/transactions@7.0.2(encoding@0.1.13)': + dependencies: + '@noble/hashes': 1.1.5 + '@noble/secp256k1': 1.7.1 + '@stacks/common': 7.0.2 + '@stacks/network': 7.0.2(encoding@0.1.13) + c32check: 2.0.0 + lodash.clonedeep: 4.5.0 + transitivePeerDependencies: + - encoding + '@stacks/wallet-sdk@6.15.0(encoding@0.1.13)': dependencies: '@scure/bip32': 1.1.3 @@ -25015,7 +25136,7 @@ snapshots: aria-hidden@1.2.4: dependencies: - tslib: 2.7.0 + tslib: 2.8.1 aria-query@5.3.0: dependencies: @@ -25374,6 +25495,12 @@ snapshots: bech32: 2.0.0 sha256-uint8array: 0.10.7 + bitcoin-address-validation@2.2.3: + dependencies: + base58-js: 1.0.5 + bech32: 2.0.0 + sha256-uint8array: 0.10.7 + bitcoinjs-lib@6.1.5: dependencies: '@noble/hashes': 1.5.0 @@ -25729,7 +25856,7 @@ snapshots: camel-case@4.1.2: dependencies: pascal-case: 3.1.2 - tslib: 2.7.0 + tslib: 2.8.1 camelcase-keys@8.0.2: dependencies: @@ -31523,7 +31650,7 @@ snapshots: param-case@3.0.4: dependencies: dot-case: 3.0.4 - tslib: 2.7.0 + tslib: 2.8.1 parent-module@1.0.1: dependencies: @@ -33206,6 +33333,21 @@ snapshots: dependencies: xmlchars: 2.2.0 + sbtc@0.3.1(encoding@0.1.13): + dependencies: + '@btc-helpers/rpc': 2.0.0(encoding@0.1.13) + '@noble/secp256k1': 2.1.0 + '@scure/base': 1.1.9 + '@scure/btc-signer': 1.4.0 + '@stacks/common': 7.0.2 + '@stacks/encryption': 7.0.2 + '@stacks/network': 7.0.2(encoding@0.1.13) + '@stacks/transactions': 7.0.2(encoding@0.1.13) + c32check: 2.0.0 + micro-packed: 0.6.3 + transitivePeerDependencies: + - encoding + sc-errors@3.0.0: {} sc-formatter@4.0.0: {} diff --git a/public/assets/avatars/btc-avatar-icon.png b/public/assets/avatars/btc-avatar-icon.png new file mode 100644 index 00000000000..a5a60b100dc Binary files /dev/null and b/public/assets/avatars/btc-avatar-icon.png differ diff --git a/public/assets/avatars/placeholder-avatar-icon.png b/public/assets/avatars/placeholder-avatar-icon.png new file mode 100644 index 00000000000..2f3671720f7 Binary files /dev/null and b/public/assets/avatars/placeholder-avatar-icon.png differ diff --git a/public/assets/avatars/sbtc-avatar-icon.png b/public/assets/avatars/sbtc-avatar-icon.png new file mode 100644 index 00000000000..ae8cb4c2cc4 Binary files /dev/null and b/public/assets/avatars/sbtc-avatar-icon.png differ diff --git a/public/assets/illustrations/sbtc-earn-promo.svg b/public/assets/illustrations/sbtc-earn-promo.svg new file mode 100644 index 00000000000..6efea428b17 --- /dev/null +++ b/public/assets/illustrations/sbtc-earn-promo.svg @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/src/app/common/asset-utils.ts b/src/app/common/asset-utils.ts index 5c9d1411390..211c8e390e0 100644 --- a/src/app/common/asset-utils.ts +++ b/src/app/common/asset-utils.ts @@ -5,12 +5,6 @@ import { isMoneyGreaterThanZero, } from '@leather.io/utils'; -export function migratePositiveAssetBalancesToTop(assets: T) { - const assetsWithPositiveBalance = assets.filter(asset => asset.balance.amount.isGreaterThan(0)); - const assetsWithZeroBalance = assets.filter(asset => asset.balance.amount.isEqualTo(0)); - return [...assetsWithPositiveBalance, ...assetsWithZeroBalance] as T; -} - export function convertAssetBalanceToFiat< T extends { balance: Money | null; marketData: MarketData | null }, >(asset: T) { diff --git a/src/app/common/authentication/use-legacy-auth-bitcoin-addresses.ts b/src/app/common/authentication/use-legacy-auth-bitcoin-addresses.ts index 84b2a314fd7..17e2e8b5181 100644 --- a/src/app/common/authentication/use-legacy-auth-bitcoin-addresses.ts +++ b/src/app/common/authentication/use-legacy-auth-bitcoin-addresses.ts @@ -14,16 +14,16 @@ export function useGetLegacyAuthBitcoinAddresses() { return { btcAddress: { p2tr: { - mainnet: taprootAccount?.mainnet?.payment.address, - testnet: taprootAccount?.testnet?.payment.address, - regtest: taprootAccount?.regtest?.payment.address, - signet: taprootAccount?.signet?.payment.address, + mainnet: taprootAccount?.mainnet?.payment?.address, + testnet: taprootAccount?.testnet?.payment?.address, + regtest: taprootAccount?.regtest?.payment?.address, + signet: taprootAccount?.signet?.payment?.address, }, p2wpkh: { - mainnet: nativeSegwitAccount?.mainnet?.payment.address, - testnet: nativeSegwitAccount?.testnet?.payment.address, - regtest: nativeSegwitAccount?.regtest?.payment.address, - signet: nativeSegwitAccount?.signet?.payment.address, + mainnet: nativeSegwitAccount?.mainnet?.payment?.address, + testnet: nativeSegwitAccount?.testnet?.payment?.address, + regtest: nativeSegwitAccount?.regtest?.payment?.address, + signet: nativeSegwitAccount?.signet?.payment?.address, }, }, btcPublicKey: { diff --git a/src/app/common/fees/use-fees.ts b/src/app/common/fees/use-fees.ts new file mode 100644 index 00000000000..16684159bc1 --- /dev/null +++ b/src/app/common/fees/use-fees.ts @@ -0,0 +1,80 @@ +import { useEffect, useMemo, useState } from 'react'; + +import type { MarketData } from '@leather.io/models'; + +import type { RawFee } from '@app/components/bitcoin-fees-list/bitcoin-fees.utils'; + +export type FeeType = 'slow' | 'standard' | 'fast' | 'custom'; + +export interface FeeDisplayInfo { + feeType: FeeType; + baseUnitsValue: number; + feeRate: number; + titleLeft: string; + captionLeft: string; + titleRight?: string; + captionRight?: string; +} + +export interface FormatFeeForDisplayArgs { + rawFee: RawFee; + marketData: MarketData; +} + +interface UseFeesProps { + defaultFeeType?: FeeType; + fees: FeesRawData; + getCustomFeeData(rate: number): RawFee; + marketData: MarketData; + formatFeeForDisplay({ rawFee, marketData }: FormatFeeForDisplayArgs): FeeDisplayInfo; +} + +export type FeesRawData = Record, RawFee>; + +export function useFeesHandler({ + defaultFeeType = 'standard', + fees, + getCustomFeeData, + marketData, + formatFeeForDisplay, +}: UseFeesProps) { + const [selectedFeeType, setSelectedFeeType] = useState(defaultFeeType); + const [editFeeSelected, setEditFeeSelected] = useState(selectedFeeType); + const [customFeeRate, setCustomFeeRate] = useState(''); + + const customFeeData = getCustomFeeData(Number(customFeeRate)); + + const selectedFeeData = useMemo(() => { + if (selectedFeeType === 'custom') { + return formatFeeForDisplay({ rawFee: customFeeData, marketData }); + } + + const rawFee = fees[selectedFeeType]; + + if (!rawFee) { + return {}; + } + + return formatFeeForDisplay({ rawFee, marketData }); + }, [fees, selectedFeeType, customFeeData, marketData, formatFeeForDisplay]); + + useEffect(() => { + if (customFeeRate === '' && selectedFeeType !== 'custom') { + const data = fees[selectedFeeType]; + if (data && data.feeRate) { + setCustomFeeRate(data.feeRate.toString()); + } + } + }, [fees, selectedFeeType, customFeeRate]); + + return { + selectedFeeType, + setSelectedFeeType, + selectedFeeData, + editFeeSelected, + setEditFeeSelected, + customFeeRate, + setCustomFeeRate, + customFeeData, + }; +} diff --git a/src/app/common/hooks/use-calculate-sip10-fiat-value.ts b/src/app/common/hooks/use-calculate-sip10-fiat-value.ts new file mode 100644 index 00000000000..1c98f119cd4 --- /dev/null +++ b/src/app/common/hooks/use-calculate-sip10-fiat-value.ts @@ -0,0 +1,42 @@ +import { useMemo } from 'react'; + +import { type MarketData, createMarketData, createMarketPair } from '@leather.io/models'; +import { + useAlexCurrencyPriceAsMarketData, + useCryptoCurrencyMarketDataMeanAverage, +} from '@leather.io/query'; +import { createMoney } from '@leather.io/utils'; + +import { useConfigSbtc } from '@app/query/common/remote-config/remote-config.query'; + +import { getPrincipalFromContractId } from '../utils'; + +function castBitcoinMarketDataToSbtcMarketData(bitcoinMarketData: MarketData) { + return createMarketData( + createMarketPair('sBTC', 'USD'), + createMoney(bitcoinMarketData.price.amount.toNumber(), 'USD') + ); +} + +export function useSip10FiatMarketData() { + const priceAsMarketData = useAlexCurrencyPriceAsMarketData(); + const bitcoinMarketData = useCryptoCurrencyMarketDataMeanAverage('BTC'); + const { isSbtcContract } = useConfigSbtc(); + + return useMemo( + () => ({ + getTokenMarketData(principal: string, symbol: string) { + const lookupIdentifier = principal.includes('::') + ? getPrincipalFromContractId(principal) + : principal; + + if (isSbtcContract(lookupIdentifier)) { + return castBitcoinMarketDataToSbtcMarketData(bitcoinMarketData); + } + + return priceAsMarketData(lookupIdentifier, symbol); + }, + }), + [bitcoinMarketData, isSbtcContract, priceAsMarketData] + ); +} diff --git a/src/app/common/hooks/use-manage-tokens.ts b/src/app/common/hooks/use-manage-tokens.ts index de5bf4ff60e..e4761b18938 100644 --- a/src/app/common/hooks/use-manage-tokens.ts +++ b/src/app/common/hooks/use-manage-tokens.ts @@ -1,5 +1,6 @@ import { useConfigTokensEnabledByDefault } from '@leather.io/query'; +import { useConfigSbtc } from '@app/query/common/remote-config/remote-config.query'; import { useCurrentAccountIndex } from '@app/store/accounts/account'; import { useUserAllTokens } from '@app/store/manage-tokens/manage-tokens.slice'; @@ -17,6 +18,7 @@ interface FilterTokensArgs { export function useManageTokens() { const configEnabledTokens = useConfigTokensEnabledByDefault(); + const { contractId: sbtcContractId, isSbtcEnabled } = useConfigSbtc(); const accountIndex = useCurrentAccountIndex(); const userTokensList = useUserAllTokens(); @@ -29,6 +31,14 @@ export function useManageTokens() { return token?.enabled ?? isEnabledByDefault; } + function sortTokens(tokens: any[]) { + return tokens.sort((a, b) => { + if (a.info.contractId === sbtcContractId) return -1; + if (b.info.contractId === sbtcContractId) return 1; + return 0; + }); + } + function filterTokens({ tokens, filter = 'all', @@ -37,7 +47,9 @@ export function useManageTokens() { }: FilterTokensArgs): T[] { if (filter === 'all') return tokens; - return tokens.filter(t => { + const sortedTokens = isSbtcEnabled ? sortTokens(tokens) : tokens; + + return sortedTokens.filter(t => { const tokenId = getTokenId(t); const tokenEnabled = isTokenEnabled({ tokenId, preEnabledTokensIds }); diff --git a/src/app/common/switch-account/use-switch-account-sheet-context.ts b/src/app/common/switch-account/use-switch-account-sheet-context.ts index 1f9f43ad1ca..94cc15c1aeb 100644 --- a/src/app/common/switch-account/use-switch-account-sheet-context.ts +++ b/src/app/common/switch-account/use-switch-account-sheet-context.ts @@ -5,6 +5,7 @@ import type { SwitchAccountOutletContext } from './switch-account'; export function useSwitchAccountSheet() { const { isShowingSwitchAccount, setIsShowingSwitchAccount } = useOutletContext(); + return { isShowingSwitchAccount, setIsShowingSwitchAccount, diff --git a/src/app/common/transactions/bitcoin/coinselect/local-coin-selection.ts b/src/app/common/transactions/bitcoin/coinselect/local-coin-selection.ts index 306e0279531..112f61b5689 100644 --- a/src/app/common/transactions/bitcoin/coinselect/local-coin-selection.ts +++ b/src/app/common/transactions/bitcoin/coinselect/local-coin-selection.ts @@ -40,7 +40,7 @@ export function determineUtxosForSpendAll({ throw new Error('Cannot calculate spend of invalid address type'); }); const filteredUtxos = filterUneconomicalUtxos({ utxos, feeRate, recipients }); - + if (!filteredUtxos.length) throw new InsufficientFundsError(); const sizeInfo = getSizeInfo({ inputLength: filteredUtxos.length, isSendMax: true, diff --git a/src/app/common/transactions/bitcoin/use-generate-bitcoin-tx.ts b/src/app/common/transactions/bitcoin/use-generate-bitcoin-tx.ts index a3cdde6df42..977c37ea3c9 100644 --- a/src/app/common/transactions/bitcoin/use-generate-bitcoin-tx.ts +++ b/src/app/common/transactions/bitcoin/use-generate-bitcoin-tx.ts @@ -20,7 +20,14 @@ interface GenerateNativeSegwitTxValues { recipients: TransferRecipient[]; } -export function useGenerateUnsignedNativeSegwitTx() { +interface UseGenerateUnsignedNativeSegwitTxProps { + throwError?: boolean; +} + +// temp arg before refactoring all flows to new design +export function useGenerateUnsignedNativeSegwitTx({ + throwError = false, +}: UseGenerateUnsignedNativeSegwitTxProps = {}) { const signer = useCurrentAccountNativeSegwitIndexZeroSigner(); const networkMode = useBitcoinScureLibNetworkConfig(); @@ -87,9 +94,10 @@ export function useGenerateUnsignedNativeSegwitTx() { } catch (e) { // eslint-disable-next-line no-console console.log('Error signing bitcoin transaction', e); + if (throwError) throw e; return null; } }, - [networkMode, signer.address, signer.publicKey] + [networkMode, signer.address, signer.publicKey, throwError] ); } diff --git a/src/app/components/account/account-bitcoin-address.tsx b/src/app/components/account/account-bitcoin-address.tsx new file mode 100644 index 00000000000..0babced219e --- /dev/null +++ b/src/app/components/account/account-bitcoin-address.tsx @@ -0,0 +1,16 @@ +import { Caption } from '@leather.io/ui'; +import { truncateMiddle } from '@leather.io/utils'; + +import { BitcoinNativeSegwitAccountLoader } from '../loaders/bitcoin-account-loader'; + +interface AccountBitcoinAddressProps { + index: number; +} + +export function AccountBitcoinAddress({ index }: AccountBitcoinAddressProps) { + return ( + + {signer => {truncateMiddle(signer.address, 4)}} + + ); +} diff --git a/src/app/components/approve-transaction/approve-bitcoin-transaction-switch-account.tsx b/src/app/components/approve-transaction/approve-bitcoin-transaction-switch-account.tsx new file mode 100644 index 00000000000..4f611af95cb --- /dev/null +++ b/src/app/components/approve-transaction/approve-bitcoin-transaction-switch-account.tsx @@ -0,0 +1,63 @@ +import { styled } from 'leather-styles/jsx'; + +import { Caption, ItemLayout, Pressable, SkeletonLoader } from '@leather.io/ui'; +import { formatDustUsdAmounts, formatMoneyPadded, i18nFormatCurrency } from '@leather.io/utils'; + +import { useAccountDisplayName } from '@app/common/hooks/account/use-account-names'; +import { useConvertCryptoCurrencyToFiatAmount } from '@app/common/hooks/use-convert-to-fiat-amount'; +import { useCurrentBtcCryptoAssetBalanceNativeSegwit } from '@app/query/bitcoin/balance/btc-balance-native-segwit.hooks'; +import { useCurrentAccountIndex } from '@app/store/accounts/account'; +import { useStacksAccounts } from '@app/store/accounts/blockchain/stacks/stacks-account.hooks'; +import { AccountAvatarItem } from '@app/ui/components/account/account-avatar/account-avatar-item'; + +import { AccountBitcoinAddress } from '../account/account-bitcoin-address'; +import { AccountNameLayout } from '../account/account-name'; +import { ApproveTransactionSwitchAccount } from './approve-transaction-switch-account'; + +interface ApproveBitcoinTransactionSwitchAccountProps { + toggleSwitchAccount(): void; +} + +export function ApproveBitcoinTransactionSwitchAccount({ + toggleSwitchAccount, +}: ApproveBitcoinTransactionSwitchAccountProps) { + const index = useCurrentAccountIndex(); + const stacksAccounts = useStacksAccounts(); + const { balance, isLoading: isLoadingBalance } = useCurrentBtcCryptoAssetBalanceNativeSegwit(); + + const convertToFiatAmount = useConvertCryptoCurrencyToFiatAmount('BTC'); + const fiatAmount = convertToFiatAmount(balance.availableBalance); + + const stxAddress = stacksAccounts[index]?.address || ''; + const { data: name = '', isLoading: isLoadingName } = useAccountDisplayName({ + address: stxAddress, + index, + }); + + const titleRight = ( + + {formatMoneyPadded(balance.availableBalance)} + + ); + + const captionRight = ( + + {formatDustUsdAmounts(i18nFormatCurrency(fiatAmount))} + + ); + + return ( + + + } + titleLeft={{name}} + captionLeft={} + titleRight={titleRight} + captionRight={captionRight} + /> + + + ); +} diff --git a/src/app/components/approve-transaction/approve-transaction-error.tsx b/src/app/components/approve-transaction/approve-transaction-error.tsx new file mode 100644 index 00000000000..c21998d76a1 --- /dev/null +++ b/src/app/components/approve-transaction/approve-transaction-error.tsx @@ -0,0 +1,15 @@ +import { FormError } from '@app/components/error/form-error'; + +interface ApproveTransactionErrorProps { + isLoading: boolean; + isInsufficientBalance: boolean; +} + +export function ApproveTransactionError({ + isLoading, + isInsufficientBalance, +}: ApproveTransactionErrorProps) { + if (isLoading) return null; + if (isInsufficientBalance) return ; + return null; +} diff --git a/src/app/components/approve-transaction/approve-transaction-header.tsx b/src/app/components/approve-transaction/approve-transaction-header.tsx new file mode 100644 index 00000000000..b7ec4b5ec83 --- /dev/null +++ b/src/app/components/approve-transaction/approve-transaction-header.tsx @@ -0,0 +1,27 @@ +import { styled } from 'leather-styles/jsx'; + +import { Approver, QuestionCircleIcon } from '@leather.io/ui'; + +interface ApproveTransactionHeaderProps { + title: string; + href?: string; + onPressRequestedByLink(e: React.MouseEvent): void; +} + +export function ApproveTransactionHeader({ + title, + href = 'https://leather.io/guides/connect-dapps', + onPressRequestedByLink, +}: ApproveTransactionHeaderProps) { + return ( + + + + } + onPressRequestedByLink={onPressRequestedByLink} + /> + ); +} diff --git a/src/app/components/approve-transaction/approve-transaction-recipients.tsx b/src/app/components/approve-transaction/approve-transaction-recipients.tsx new file mode 100644 index 00000000000..ce02ebbc152 --- /dev/null +++ b/src/app/components/approve-transaction/approve-transaction-recipients.tsx @@ -0,0 +1,53 @@ +import { HStack, styled } from 'leather-styles/jsx'; + +import { AddressDisplayer, Approver, BtcAvatarIcon, ItemLayout, UserIcon } from '@leather.io/ui'; +import { formatDustUsdAmounts, formatMoneyPadded, i18nFormatCurrency } from '@leather.io/utils'; + +import type { TransferRecipient } from '@shared/models/form.model'; + +import { useConvertCryptoCurrencyToFiatAmount } from '@app/common/hooks/use-convert-to-fiat-amount'; +import { IconWrapper } from '@app/components/icon-wrapper'; +import { Divider } from '@app/components/layout/divider'; + +interface ApproveTransactionRecipientsProps { + recipients: TransferRecipient[]; +} + +export function ApproveTransactionRecipients({ recipients }: ApproveTransactionRecipientsProps) { + const convertToFiatAmount = useConvertCryptoCurrencyToFiatAmount('BTC'); + + return recipients.map(({ address, amount }) => { + const fiatAmount = convertToFiatAmount(amount); + + const titleRight = formatMoneyPadded(amount); + const captionRight = formatDustUsdAmounts(i18nFormatCurrency(fiatAmount)); + + return ( + + + You'll send + + + } + titleLeft="Bitcoin" + captionLeft="Bitcoin blockchain" + titleRight={titleRight} + captionRight={captionRight} + /> + + + + + To address + + + + + + + + + ); + }); +} diff --git a/src/app/components/approve-transaction/approve-transaction-selected-fee.tsx b/src/app/components/approve-transaction/approve-transaction-selected-fee.tsx new file mode 100644 index 00000000000..2e239e41bf2 --- /dev/null +++ b/src/app/components/approve-transaction/approve-transaction-selected-fee.tsx @@ -0,0 +1,38 @@ +import { Approver, ItemLayout, Pressable } from '@leather.io/ui'; + +import type { FeeDisplayInfo } from '@app/common/fees/use-fees'; + +import { CryptoAssetItemPlaceholder } from '../crypto-asset-item/crypto-asset-item-placeholder'; +import { FeeItemIcon } from '../fees/fee-item-icon'; + +interface ApproveTransactionSelectedFeeProps { + isLoading: boolean; + selectedFeeData: FeeDisplayInfo; + onChooseTransferFee(): void; +} + +export function ApproveTransactionSelectedFee({ + isLoading, + selectedFeeData, + onChooseTransferFee, +}: ApproveTransactionSelectedFeeProps) { + return ( + + Fee + + {isLoading || !selectedFeeData ? ( + + ) : ( + } + titleLeft={selectedFeeData.titleLeft} + captionLeft={selectedFeeData.captionLeft} + titleRight={selectedFeeData.titleRight} + captionRight={selectedFeeData.captionRight} + showChevron + /> + )} + + + ); +} diff --git a/src/app/components/approve-transaction/approve-transaction-switch-account.tsx b/src/app/components/approve-transaction/approve-transaction-switch-account.tsx new file mode 100644 index 00000000000..085c59d41ee --- /dev/null +++ b/src/app/components/approve-transaction/approve-transaction-switch-account.tsx @@ -0,0 +1,14 @@ +import { Box } from 'leather-styles/jsx'; + +import { Approver } from '@leather.io/ui'; + +import type { HasChildren } from '@app/common/has-children'; + +export function ApproveTransactionSwitchAccount({ children }: HasChildren) { + return ( + + With account + {children} + + ); +} diff --git a/src/app/components/approve-transaction/approve-transaction-title.tsx b/src/app/components/approve-transaction/approve-transaction-title.tsx new file mode 100644 index 00000000000..5b69fda1536 --- /dev/null +++ b/src/app/components/approve-transaction/approve-transaction-title.tsx @@ -0,0 +1,24 @@ +import { HStack, styled } from 'leather-styles/jsx'; + +import { SkeletonLoader } from '@leather.io/ui'; + +interface ApproveTransactionActionsTitleProps { + isLoading: boolean; + amount: string; +} + +export function ApproveTransactionActionsTitle({ + isLoading, + amount, +}: ApproveTransactionActionsTitleProps) { + return ( + + + Total spend + + + {amount} + + + ); +} diff --git a/src/app/components/approve-transaction/get-approve-actions.tsx b/src/app/components/approve-transaction/get-approve-actions.tsx new file mode 100644 index 00000000000..d5b50a14a2a --- /dev/null +++ b/src/app/components/approve-transaction/get-approve-actions.tsx @@ -0,0 +1,60 @@ +import { HStack, styled } from 'leather-styles/jsx'; + +import { Button, CheckmarkIcon, SkeletonLoader } from '@leather.io/ui'; + +interface GetApproveActionsArgs { + isLoading: boolean; + isInsufficientBalance: boolean; + isBroadcasting: boolean; + isSubmitted: boolean; + onCancel(): void; + onApprove(): void; +} + +export function getApproveActions({ + isLoading, + isInsufficientBalance, + isBroadcasting, + isSubmitted, + onCancel, + onApprove, +}: GetApproveActionsArgs) { + if (isLoading) { + return [ + , + , + ]; + } + + if (isInsufficientBalance) { + return []; + } + + if (isBroadcasting) { + return [ + , + ]; + } + + if (isSubmitted) { + return [ + , + ]; + } + + return [ + , + , + ]; +} diff --git a/src/app/components/bitcoin-fees-list/bitcoin-fees.utils.spec.ts b/src/app/components/bitcoin-fees-list/bitcoin-fees.utils.spec.ts new file mode 100644 index 00000000000..954c9dbd208 --- /dev/null +++ b/src/app/components/bitcoin-fees-list/bitcoin-fees.utils.spec.ts @@ -0,0 +1,82 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { mockUtxos } from '@leather.io/query'; +import { createMoney } from '@leather.io/utils'; + +import { + getApproximateFee, + getBitcoinFee, + getBitcoinSendMaxFee, + getBtcFeeValue, +} from './bitcoin-fees.utils'; + +describe('bitcoin-fees.utils', () => { + const mockRecipients = [ + { + address: 'bc1qps90ws94pvk548y9jg03gn5lwjqnyud4lg6y56', + amount: createMoney(300, 'BTC'), + }, + ]; + + describe('getBitcoinFee', () => { + it('returns fee when calculation succeeds', () => { + const result = getBitcoinFee({ + recipients: mockRecipients, + utxos: mockUtxos, + feeRate: 1, + }); + expect(result).toBe(141); + }); + + it('returns null when calculation fails', () => { + const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + const result = getBitcoinFee({ + recipients: [], + utxos: [], + feeRate: 0, + }); + expect(result).toBeNull(); + consoleSpy.mockRestore(); + }); + }); + + describe('getBitcoinSendMaxFee', () => { + it('returns fee when calculation succeeds', () => { + const result = getBitcoinSendMaxFee({ + recipients: mockRecipients, + utxos: mockUtxos, + feeRate: 1, + }); + expect(result).toBe(110); + }); + + it('returns null when calculation fails', () => { + const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + const result = getBitcoinSendMaxFee({ + recipients: mockRecipients, + utxos: [], + feeRate: 1, + }); + expect(result).toBeNull(); + consoleSpy.mockRestore(); + }); + }); + + describe('getApproximateFee', () => { + it('calculates approximate fee correctly', () => { + const result = getApproximateFee({ + feeRate: 10, + recipients: mockRecipients, + utxos: mockUtxos, + }); + expect(result).toBeGreaterThan(0); + }); + }); + + describe('getBtcFeeValue', () => { + it('formats BTC fee value correctly', () => { + const result = getBtcFeeValue(1000); + expect(result).toBe('0.00001000 BTC'); + }); + }); +}); diff --git a/src/app/components/bitcoin-fees-list/bitcoin-fees.utils.ts b/src/app/components/bitcoin-fees-list/bitcoin-fees.utils.ts new file mode 100644 index 00000000000..fb3332a178d --- /dev/null +++ b/src/app/components/bitcoin-fees-list/bitcoin-fees.utils.ts @@ -0,0 +1,57 @@ +import { type UtxoResponseItem } from '@leather.io/query'; +import { createMoney, formatMoneyPadded } from '@leather.io/utils'; + +import type { TransferRecipient } from '@shared/models/form.model'; + +import type { FeeType } from '@app/common/fees/use-fees'; +import { + type DetermineUtxosForSpendArgs, + determineUtxosForSpend, + determineUtxosForSpendAll, +} from '@app/common/transactions/bitcoin/coinselect/local-coin-selection'; +import { getSizeInfo } from '@app/common/transactions/bitcoin/utils'; + +export function getBitcoinFee(determineUtxosForFeeArgs: DetermineUtxosForSpendArgs) { + try { + const { fee } = determineUtxosForSpend(determineUtxosForFeeArgs); + return fee; + } catch (error) { + return null; + } +} + +export function getBitcoinSendMaxFee(determineUtxosForFeeArgs: DetermineUtxosForSpendArgs) { + try { + const { fee } = determineUtxosForSpendAll(determineUtxosForFeeArgs); + return fee; + } catch (error) { + return null; + } +} + +export function getApproximateFee({ + feeRate, + recipients, + utxos, +}: { + feeRate: number; + recipients: TransferRecipient[]; + utxos: UtxoResponseItem[]; +}) { + const size = getSizeInfo({ + inputLength: utxos.length + 1, + recipients, + }); + return Math.ceil(size.txVBytes * feeRate); +} + +export interface RawFee { + type: FeeType; + baseUnitsFeeValue: number | null; + feeRate: number | null; + time: string; +} + +export function getBtcFeeValue(feeValue: number) { + return formatMoneyPadded(createMoney(feeValue, 'BTC')); +} diff --git a/src/app/components/bitcoin-fees-list/format-bitcoin-fee.spec.ts b/src/app/components/bitcoin-fees-list/format-bitcoin-fee.spec.ts new file mode 100644 index 00000000000..7b5f3c170a5 --- /dev/null +++ b/src/app/components/bitcoin-fees-list/format-bitcoin-fee.spec.ts @@ -0,0 +1,52 @@ +import { createMarketPair } from '@leather.io/models'; +import { createMoneyFromDecimal } from '@leather.io/utils'; + +import type { RawFee } from './bitcoin-fees.utils'; +import { formatBitcoinFeeForDisplay } from './format-bitcoin-fee'; + +describe('formatBitcoinFeeForDisplay', () => { + const mockMarketData = { + current_price: 50000, + currency: 'USD', + pair: createMarketPair('BTC', 'USD'), + price: createMoneyFromDecimal(50000, 'USD'), + }; + + const mockRawFee: RawFee = { + type: 'standard', + baseUnitsFeeValue: 1000, + feeRate: 5, + time: '~10 minutes', + }; + + it('formats fee information correctly', () => { + const result = formatBitcoinFeeForDisplay({ + rawFee: mockRawFee, + marketData: mockMarketData, + }); + + expect(result).toEqual({ + feeType: 'standard', + feeRate: 5, + baseUnitsValue: 1000, + titleLeft: 'Standard', + captionLeft: '~10 minutes', + titleRight: '0.00001000 BTC', + captionRight: '5 sats/vB · $0.50', + }); + }); + + it('handles null fee values', () => { + const result = formatBitcoinFeeForDisplay({ + rawFee: { + ...mockRawFee, + baseUnitsFeeValue: null, + feeRate: null, + }, + marketData: mockMarketData, + }); + + expect(result.titleRight).toBe('N/A'); + expect(result.captionRight).toBe('N/A'); + }); +}); diff --git a/src/app/components/bitcoin-fees-list/format-bitcoin-fee.ts b/src/app/components/bitcoin-fees-list/format-bitcoin-fee.ts new file mode 100644 index 00000000000..7fb510d4f93 --- /dev/null +++ b/src/app/components/bitcoin-fees-list/format-bitcoin-fee.ts @@ -0,0 +1,36 @@ +import { + baseCurrencyAmountInQuote, + capitalize, + createMoney, + i18nFormatCurrency, +} from '@leather.io/utils'; + +import type { FeeDisplayInfo, FormatFeeForDisplayArgs } from '@app/common/fees/use-fees'; + +import { getBtcFeeValue } from './bitcoin-fees.utils'; + +export function formatBitcoinFeeForDisplay({ + rawFee, + marketData, +}: FormatFeeForDisplayArgs): FeeDisplayInfo { + function getFiatFeeValue(fee: number) { + return i18nFormatCurrency( + baseCurrencyAmountInQuote(createMoney(Math.ceil(fee), 'BTC'), marketData) + ); + } + + const { type, baseUnitsFeeValue, feeRate, time } = rawFee; + + return { + feeType: type, + feeRate: feeRate ?? 0, + baseUnitsValue: baseUnitsFeeValue ?? 0, + + titleLeft: capitalize(type), + captionLeft: time, + titleRight: baseUnitsFeeValue ? getBtcFeeValue(baseUnitsFeeValue) : 'N/A', + captionRight: baseUnitsFeeValue + ? `${feeRate} sats/vB · ${getFiatFeeValue(baseUnitsFeeValue)}` + : 'N/A', + }; +} diff --git a/src/app/components/bitcoin-fees-list/use-bitcoin-fees-data.ts b/src/app/components/bitcoin-fees-list/use-bitcoin-fees-data.ts new file mode 100644 index 00000000000..c7ded20a9f6 --- /dev/null +++ b/src/app/components/bitcoin-fees-list/use-bitcoin-fees-data.ts @@ -0,0 +1,130 @@ +import { useMemo } from 'react'; + +import { type Money, btcTxTimeMap } from '@leather.io/models'; +import { type UtxoResponseItem, useAverageBitcoinFeeRates } from '@leather.io/query'; +import { isUndefined } from '@leather.io/utils'; + +import type { TransferRecipient } from '@shared/models/form.model'; + +import type { FeesRawData } from '@app/common/fees/use-fees'; + +import { + type RawFee, + getApproximateFee, + getBitcoinFee, + getBitcoinSendMaxFee, +} from './bitcoin-fees.utils'; + +interface UseBitcoinFeeDataArgs { + amount: Money; + isSendingMax?: boolean; + recipients: TransferRecipient[]; + utxos: UtxoResponseItem[]; +} + +export function useBitcoinFeeData({ + amount, + recipients, + utxos, + isSendingMax, +}: UseBitcoinFeeDataArgs) { + const { data: feeRates, isLoading } = useAverageBitcoinFeeRates(); + + const satAmount = amount.amount.toNumber(); + + const determineUtxosDefaultArgs = useMemo(() => { + return { + amount: satAmount, + recipients, + utxos, + }; + }, [satAmount, recipients, utxos]); + + function getCustomFeeData(feeRate: number): RawFee { + const determineUtxosForFeeArgs = { + ...determineUtxosDefaultArgs, + feeRate, + }; + const fee = isSendingMax + ? getBitcoinSendMaxFee(determineUtxosForFeeArgs) + : getBitcoinFee(determineUtxosForFeeArgs); + + return { + type: 'custom', + baseUnitsFeeValue: fee, + feeRate, + time: '', + }; + } + + const fees = useMemo(() => { + if (isUndefined(feeRates)) { + return {} as FeesRawData; + } + + const determineUtxosForHighFeeArgs = { + ...determineUtxosDefaultArgs, + feeRate: feeRates.fastestFee.toNumber(), + }; + + const determineUtxosForStandardFeeArgs = { + ...determineUtxosDefaultArgs, + feeRate: feeRates.halfHourFee.toNumber(), + }; + + const determineUtxosForLowFeeArgs = { + ...determineUtxosDefaultArgs, + feeRate: feeRates.hourFee.toNumber(), + }; + + const highFeeValue = isSendingMax + ? getBitcoinSendMaxFee(determineUtxosForHighFeeArgs) + : getBitcoinFee(determineUtxosForHighFeeArgs); + + const standardFeeValue = isSendingMax + ? getBitcoinSendMaxFee(determineUtxosForStandardFeeArgs) + : getBitcoinFee(determineUtxosForStandardFeeArgs); + + const lowFeeValue = isSendingMax + ? getBitcoinSendMaxFee(determineUtxosForLowFeeArgs) + : getBitcoinFee(determineUtxosForLowFeeArgs); + + const highFee = + highFeeValue ?? + getApproximateFee({ feeRate: feeRates.fastestFee.toNumber(), recipients, utxos }); + + const standardFee = + standardFeeValue ?? + getApproximateFee({ feeRate: feeRates.halfHourFee.toNumber(), recipients, utxos }); + + const lowFee = + lowFeeValue ?? getApproximateFee({ feeRate: feeRates.hourFee.toNumber(), recipients, utxos }); + + return { + slow: { + type: 'slow', + baseUnitsFeeValue: lowFee, + feeRate: feeRates.hourFee.toNumber(), + time: btcTxTimeMap.hourFee, + }, + standard: { + type: 'standard', + baseUnitsFeeValue: standardFee, + feeRate: feeRates.halfHourFee.toNumber(), + time: btcTxTimeMap.halfHourFee, + }, + fast: { + type: 'fast', + baseUnitsFeeValue: highFee, + feeRate: feeRates.fastestFee.toNumber(), + time: btcTxTimeMap.fastestFee, + }, + }; + }, [feeRates, recipients, utxos, determineUtxosDefaultArgs, isSendingMax]); + + return { + getCustomFeeData, + fees, + isLoading, + }; +} diff --git a/src/app/components/bitcoin-fees-list/use-bitcoin-fees-list-multiple-recipients.ts b/src/app/components/bitcoin-fees-list/use-bitcoin-fees-list-multiple-recipients.ts deleted file mode 100644 index 526f89bf976..00000000000 --- a/src/app/components/bitcoin-fees-list/use-bitcoin-fees-list-multiple-recipients.ts +++ /dev/null @@ -1,127 +0,0 @@ -import { useMemo } from 'react'; - -import { BtcFeeType, Money, btcTxTimeMap } from '@leather.io/models'; -import { - type UtxoResponseItem, - useAverageBitcoinFeeRates, - useCryptoCurrencyMarketDataMeanAverage, -} from '@leather.io/query'; -import { - baseCurrencyAmountInQuote, - createMoney, - formatMoneyPadded, - i18nFormatCurrency, -} from '@leather.io/utils'; - -import type { TransferRecipient } from '@shared/models/form.model'; - -import { - type DetermineUtxosForSpendArgs, - determineUtxosForSpend, - determineUtxosForSpendAll, -} from '@app/common/transactions/bitcoin/coinselect/local-coin-selection'; - -import { FeesListItem } from './bitcoin-fees-list'; - -function getFeeForList( - determineUtxosForFeeArgs: DetermineUtxosForSpendArgs, - isSendingMax?: boolean -) { - try { - const { fee } = isSendingMax - ? determineUtxosForSpendAll(determineUtxosForFeeArgs) - : determineUtxosForSpend(determineUtxosForFeeArgs); - return fee; - } catch (error) { - return null; - } -} - -interface UseBitcoinFeesListArgs { - amount: Money; - recipients: TransferRecipient[]; - utxos: UtxoResponseItem[]; -} -export function useBitcoinFeesList({ amount, recipients, utxos }: UseBitcoinFeesListArgs) { - const btcMarketData = useCryptoCurrencyMarketDataMeanAverage('BTC'); - const { data: feeRates, isLoading } = useAverageBitcoinFeeRates(); - - const feesList: FeesListItem[] = useMemo(() => { - function getFiatFeeValue(fee: number) { - return `~ ${i18nFormatCurrency( - baseCurrencyAmountInQuote(createMoney(Math.ceil(fee), 'BTC'), btcMarketData) - )}`; - } - - if (!feeRates || !utxos.length) return []; - - const satAmount = amount.amount.toNumber(); - - const determineUtxosDefaultArgs = { - amount: satAmount, - recipients, - utxos, - }; - - const determineUtxosForHighFeeArgs = { - ...determineUtxosDefaultArgs, - feeRate: feeRates.fastestFee.toNumber(), - }; - - const determineUtxosForStandardFeeArgs = { - ...determineUtxosDefaultArgs, - feeRate: feeRates.halfHourFee.toNumber(), - }; - - const determineUtxosForLowFeeArgs = { - ...determineUtxosDefaultArgs, - feeRate: feeRates.hourFee.toNumber(), - }; - - const feesArr = []; - - const highFeeValue = getFeeForList(determineUtxosForHighFeeArgs); - const standardFeeValue = getFeeForList(determineUtxosForStandardFeeArgs); - const lowFeeValue = getFeeForList(determineUtxosForLowFeeArgs); - - if (highFeeValue) { - feesArr.push({ - label: BtcFeeType.High, - value: highFeeValue, - btcValue: formatMoneyPadded(createMoney(highFeeValue, 'BTC')), - time: btcTxTimeMap.fastestFee, - fiatValue: getFiatFeeValue(highFeeValue), - feeRate: feeRates.fastestFee.toNumber(), - }); - } - - if (standardFeeValue) { - feesArr.push({ - label: BtcFeeType.Standard, - value: standardFeeValue, - btcValue: formatMoneyPadded(createMoney(standardFeeValue, 'BTC')), - time: btcTxTimeMap.halfHourFee, - fiatValue: getFiatFeeValue(standardFeeValue), - feeRate: feeRates.halfHourFee.toNumber(), - }); - } - - if (lowFeeValue) { - feesArr.push({ - label: BtcFeeType.Low, - value: lowFeeValue, - btcValue: formatMoneyPadded(createMoney(lowFeeValue, 'BTC')), - time: btcTxTimeMap.hourFee, - fiatValue: getFiatFeeValue(lowFeeValue), - feeRate: feeRates.hourFee.toNumber(), - }); - } - - return feesArr; - }, [amount.amount, btcMarketData, feeRates, recipients, utxos]); - - return { - feesList, - isLoading, - }; -} diff --git a/src/app/components/bitcoin-fees-list/use-bitcoin-fees-list.ts b/src/app/components/bitcoin-fees-list/use-bitcoin-fees-list.ts index 0b170281390..83f0007eff1 100644 --- a/src/app/components/bitcoin-fees-list/use-bitcoin-fees-list.ts +++ b/src/app/components/bitcoin-fees-list/use-bitcoin-fees-list.ts @@ -42,6 +42,7 @@ interface UseBitcoinFeesListArgs { recipient: string; utxos: UtxoResponseItem[]; } + export function useBitcoinFeesList({ amount, isSendingMax, diff --git a/src/app/components/crypto-asset-item/crypto-asset-item-placeholder.tsx b/src/app/components/crypto-asset-item/crypto-asset-item-placeholder.tsx index 5d299e96728..8b99c04ef42 100644 --- a/src/app/components/crypto-asset-item/crypto-asset-item-placeholder.tsx +++ b/src/app/components/crypto-asset-item/crypto-asset-item-placeholder.tsx @@ -1,10 +1,10 @@ -import { Box, Circle } from 'leather-styles/jsx'; +import { Box, type BoxProps, Circle } from 'leather-styles/jsx'; import { ItemLayout, SkeletonLoader } from '@leather.io/ui'; -export function CryptoAssetItemPlaceholder() { +export function CryptoAssetItemPlaceholder({ ...props }: BoxProps) { return ( - + } titleLeft={} diff --git a/src/app/components/error/form-error.tsx b/src/app/components/error/form-error.tsx new file mode 100644 index 00000000000..b53fa69f210 --- /dev/null +++ b/src/app/components/error/form-error.tsx @@ -0,0 +1,15 @@ +import { Box, styled } from 'leather-styles/jsx'; + +export function FormError({ text }: { text: string }) { + return ( + + {text} + + ); +} diff --git a/src/app/components/fees/custom-fee-item.tsx b/src/app/components/fees/custom-fee-item.tsx new file mode 100644 index 00000000000..c5779eb5b17 --- /dev/null +++ b/src/app/components/fees/custom-fee-item.tsx @@ -0,0 +1,74 @@ +import { useRef } from 'react'; + +import { AnimatePresence, motion } from 'framer-motion'; +import { Stack } from 'leather-styles/jsx'; + +import { Button, Input, ItemLayout } from '@leather.io/ui'; + +import type { FeeItemProps } from './fee-item'; +import { FeeItemIcon } from './fee-item-icon'; + +interface CustomFeeItemProps extends FeeItemProps { + fee: string | null; + setFee(fee: string): void; +} + +export function CustomFeeItem({ + fee, + setFee, + feeType, + onSelect, + isSelected, + captionLeft, + titleRight, + captionRight, +}: CustomFeeItemProps) { + const inputRef = useRef(null); + + return ( + + ); +} diff --git a/src/app/components/fees/fee-item-icon.tsx b/src/app/components/fees/fee-item-icon.tsx new file mode 100644 index 00000000000..ba4923517b1 --- /dev/null +++ b/src/app/components/fees/fee-item-icon.tsx @@ -0,0 +1,29 @@ +import type { ReactNode } from 'react'; + +import { + AnimalChameleonIcon, + AnimalEagleIcon, + AnimalRabbitIcon, + AnimalSnailIcon, +} from '@leather.io/ui'; + +import type { FeeType } from '@app/common/fees/use-fees'; + +import { IconWrapper } from '../icon-wrapper'; + +const feeTypeToIconMap: Record = { + slow: , + standard: , + fast: , + custom: , +}; + +export function FeeItemIcon({ feeType }: { feeType: FeeType }) { + const icon = feeTypeToIconMap[feeType] || null; + + if (!icon) { + throw new Error('Invalid fee type'); + } + + return {icon}; +} diff --git a/src/app/components/fees/fee-item.tsx b/src/app/components/fees/fee-item.tsx new file mode 100644 index 00000000000..c744e2a0d04 --- /dev/null +++ b/src/app/components/fees/fee-item.tsx @@ -0,0 +1,84 @@ +import { useState } from 'react'; + +import { AnimatePresence, motion } from 'framer-motion'; +import { Stack } from 'leather-styles/jsx'; + +import { Button, ItemLayout } from '@leather.io/ui'; + +import type { FeeType } from '@app/common/fees/use-fees'; + +import { FormError } from '../error/form-error'; +import { FeeItemIcon } from './fee-item-icon'; + +export interface FeeItemProps { + feeType: FeeType; + isSelected: boolean; + isInsufficientBalance: boolean; + onSelect(feeType: FeeType): void; + titleLeft: string; + captionLeft: string; + titleRight?: string; + captionRight?: string; +} + +export function FeeItem({ + feeType, + onSelect, + isInsufficientBalance, + isSelected, + titleLeft, + captionLeft, + titleRight, + captionRight, +}: FeeItemProps) { + const [isTouched, setIsTouched] = useState(false); + const showInsufficientBalanceError = isTouched && isInsufficientBalance; + + return ( + + ); +} diff --git a/src/app/components/fees/fees.tsx b/src/app/components/fees/fees.tsx new file mode 100644 index 00000000000..47262eac440 --- /dev/null +++ b/src/app/components/fees/fees.tsx @@ -0,0 +1,17 @@ +import { Stack } from 'leather-styles/jsx'; + +import { CustomFeeItem } from './custom-fee-item'; +import { FeeItem } from './fee-item'; + +interface FeesProps { + children: React.ReactNode; +} + +function Fees({ children }: FeesProps) { + return {children}; +} + +Fees.Item = FeeItem; +Fees.CustomItem = CustomFeeItem; + +export { Fees }; diff --git a/src/app/components/icon-wrapper.tsx b/src/app/components/icon-wrapper.tsx new file mode 100644 index 00000000000..44c832a4237 --- /dev/null +++ b/src/app/components/icon-wrapper.tsx @@ -0,0 +1,20 @@ +import { Box } from 'leather-styles/jsx'; + +export function IconWrapper({ children }: { children: React.ReactNode }) { + return ( + + {children} + + ); +} diff --git a/src/app/components/loading-overlay.tsx b/src/app/components/loading-overlay.tsx new file mode 100644 index 00000000000..b2edf1b141d --- /dev/null +++ b/src/app/components/loading-overlay.tsx @@ -0,0 +1,27 @@ +import { AnimatePresence, motion } from 'framer-motion'; +import { token } from 'leather-styles/tokens'; + +export function BackgroundOverlay({ show = false }: { show?: boolean }) { + return ( + + {show && ( + + )} + + ); +} diff --git a/src/app/components/nonce-setter.tsx b/src/app/components/nonce-setter.tsx index 530cba5f41f..b0ee3e83771 100644 --- a/src/app/components/nonce-setter.tsx +++ b/src/app/components/nonce-setter.tsx @@ -4,13 +4,17 @@ import { useFormikContext } from 'formik'; import { useNextNonce } from '@leather.io/query'; -import { StacksSendFormValues, StacksTransactionFormValues } from '@shared/models/form.model'; +import { + StacksSendFormValues, + StacksTransactionFormValues, + type SwapFormValues, +} from '@shared/models/form.model'; import { useCurrentStacksAccountAddress } from '@app/store/accounts/blockchain/stacks/stacks-account.hooks'; export function NonceSetter() { const { setFieldValue, touched, values } = useFormikContext< - StacksSendFormValues | StacksTransactionFormValues + StacksSendFormValues | StacksTransactionFormValues | SwapFormValues >(); const stxAddress = useCurrentStacksAccountAddress(); const { data: nextNonce } = useNextNonce(stxAddress); diff --git a/src/app/components/sbtc-deposit-status-item/sbtc-deposit-status-item.tsx b/src/app/components/sbtc-deposit-status-item/sbtc-deposit-status-item.tsx new file mode 100644 index 00000000000..f89988aace0 --- /dev/null +++ b/src/app/components/sbtc-deposit-status-item/sbtc-deposit-status-item.tsx @@ -0,0 +1,57 @@ +import SbtcAvatarIconSrc from '@assets/avatars/sbtc-avatar-icon.png'; + +import { Avatar, Caption, Title } from '@leather.io/ui'; +import { truncateMiddle } from '@leather.io/utils'; + +import { analytics } from '@shared/utils/analytics'; + +import { useBitcoinExplorerLink } from '@app/common/hooks/use-bitcoin-explorer-link'; +import type { SbtcDepositInfo, SbtcStatus } from '@app/query/sbtc/sbtc-deposits.query'; + +import { TransactionItemLayout } from '../transaction-item/transaction-item.layout'; + +function getDepositStatus(status: SbtcStatus) { + switch (status) { + case 'pending': + case 'reprocessing': + return 'Pending deposit'; + case 'accepted': + return 'Pending mint'; + case 'confirmed': + return 'Done'; + case 'failed': + return 'Failed'; + default: + return ''; + } +} + +interface SbtcDepositTransactionItemProps { + deposit: SbtcDepositInfo; +} +export function SbtcDepositTransactionItem({ deposit }: SbtcDepositTransactionItemProps) { + const { handleOpenBitcoinTxLink: handleOpenTxLink } = useBitcoinExplorerLink(); + + const openTxLink = () => { + void analytics.track('view_bitcoin_transaction'); + handleOpenTxLink({ txid: deposit.bitcoinTxid }); + }; + + return ( + + + + } + txStatus={ + {getDepositStatus(deposit.status)} + } + txTitle={BTC → sBTC} + // Api is only returning 0 right now + txValue={''} // deposit.amount.toString() + /> + ); +} diff --git a/src/app/components/stacks-asset-avatar.tsx b/src/app/components/stacks-asset-avatar.tsx index 706602c3d75..84bd5467fa3 100644 --- a/src/app/components/stacks-asset-avatar.tsx +++ b/src/app/components/stacks-asset-avatar.tsx @@ -3,15 +3,15 @@ import { Box, BoxProps } from 'leather-styles/jsx'; import { Avatar, DynamicColorCircle, StxAvatarIcon, defaultFallbackDelay } from '@leather.io/ui'; interface StacksAssetAvatarProps extends BoxProps { - gradientString?: string; - imageCanonicalUri?: string; + img?: string; + gradientString: string; isStx?: boolean; size?: string; } export function StacksAssetAvatar({ children, gradientString, - imageCanonicalUri, + img, isStx, size = '36', ...props @@ -20,10 +20,10 @@ export function StacksAssetAvatar({ const { color } = props; - if (imageCanonicalUri) + if (img) return ( - + FT ); diff --git a/src/app/components/tx-asset-item.tsx b/src/app/components/tx-asset-item.tsx index 13a06f2f713..be7e9fe051f 100644 --- a/src/app/components/tx-asset-item.tsx +++ b/src/app/components/tx-asset-item.tsx @@ -18,7 +18,7 @@ export function TxAssetItem(props: TxAssetItemProps) { diff --git a/src/app/features/activity-list/activity-list.tsx b/src/app/features/activity-list/activity-list.tsx index be85aceeb24..b9c50e337dc 100644 --- a/src/app/features/activity-list/activity-list.tsx +++ b/src/app/features/activity-list/activity-list.tsx @@ -12,6 +12,7 @@ import { import { LoadingSpinner } from '@app/components/loading-spinner'; import { useConfigBitcoinEnabled } from '@app/query/common/remote-config/remote-config.query'; +import { useSbtcPendingDeposits } from '@app/query/sbtc/sbtc-deposits.query'; import { useZeroIndexTaprootAddress } from '@app/store/accounts/blockchain/bitcoin/bitcoin.hooks'; import { useCurrentAccountNativeSegwitIndexZeroSigner } from '@app/store/accounts/blockchain/bitcoin/native-segwit-account.hooks'; import { useCurrentStacksAccountAddress } from '@app/store/accounts/blockchain/stacks/stacks-account.hooks'; @@ -63,6 +64,9 @@ export function ActivityList() { [nsPendingTxs, trPendingTxs] ); + const { isLoading: isLoadingSbtcDeposits, pendingSbtcDeposits } = + useSbtcPendingDeposits(stxAddress); + const { isLoading: isLoadingStacksTransactions, data: stacksTransactionsWithTransfers } = useGetAccountTransactionsWithTransfersQuery(stxAddress); const { @@ -80,7 +84,8 @@ export function ActivityList() { isLoadingNsBitcoinTransactions || isLoadingTrBitcoinTransactions || isLoadingStacksTransactions || - isLoadingStacksPendingTransactions; + isLoadingStacksPendingTransactions || + isLoadingSbtcDeposits; const transactionListBitcoinTxs = useMemo(() => { return convertBitcoinTxsToListType( @@ -99,7 +104,9 @@ export function ActivityList() { const hasSubmittedTransactions = submittedTransactions.length > 0; const hasPendingTransactions = - bitcoinPendingTxs.length > 0 || stacksPendingTransactions.length > 0; + bitcoinPendingTxs.length > 0 || + stacksPendingTransactions.length > 0 || + pendingSbtcDeposits.length > 0; const hasTransactions = transactionListBitcoinTxs.length > 0 || transactionListStacksTxs.length > 0; @@ -128,6 +135,7 @@ export function ActivityList() { {hasPendingTransactions && ( )} diff --git a/src/app/features/activity-list/components/pending-transaction-list/pending-transaction-list.tsx b/src/app/features/activity-list/components/pending-transaction-list/pending-transaction-list.tsx index 3b0f2763907..3174080c244 100644 --- a/src/app/features/activity-list/components/pending-transaction-list/pending-transaction-list.tsx +++ b/src/app/features/activity-list/components/pending-transaction-list/pending-transaction-list.tsx @@ -3,20 +3,30 @@ import { MempoolTransaction } from '@stacks/stacks-blockchain-api-types'; import type { BitcoinTx } from '@leather.io/models'; import { BitcoinTransactionItem } from '@app/components/bitcoin-transaction-item/bitcoin-transaction-item'; +import { SbtcDepositTransactionItem } from '@app/components/sbtc-deposit-status-item/sbtc-deposit-status-item'; import { StacksTransactionItem } from '@app/components/stacks-transaction-item/stacks-transaction-item'; +import type { SbtcDepositInfo } from '@app/query/sbtc/sbtc-deposits.query'; import { PendingTransactionListLayout } from './pending-transaction-list.layout'; interface PendingTransactionListProps { bitcoinTxs: BitcoinTx[]; + sBtcDeposits: SbtcDepositInfo[]; stacksTxs: MempoolTransaction[]; } -export function PendingTransactionList({ bitcoinTxs, stacksTxs }: PendingTransactionListProps) { +export function PendingTransactionList({ + bitcoinTxs, + sBtcDeposits, + stacksTxs, +}: PendingTransactionListProps) { return ( {bitcoinTxs.map(tx => ( ))} + {sBtcDeposits.map(deposit => ( + + ))} {stacksTxs.map(tx => ( ))} diff --git a/src/app/features/activity-list/components/transaction-list/stacks-transaction/ft-transfer-item.tsx b/src/app/features/activity-list/components/transaction-list/stacks-transaction/ft-transfer-item.tsx index af332960f5f..d68b5f2c3a4 100644 --- a/src/app/features/activity-list/components/transaction-list/stacks-transaction/ft-transfer-item.tsx +++ b/src/app/features/activity-list/components/transaction-list/stacks-transaction/ft-transfer-item.tsx @@ -53,11 +53,7 @@ export function FtTransferItem({ ftTransfer, parentTx }: FtTransferItemProps) { const title = `${assetMetadata.name || 'Token'} Transfer`; const value = `${isOriginator ? '-' : ''}${displayAmount.toFormat()}`; const transferIcon = ftImageCanonicalUri ? ( - + {title} ) : ( diff --git a/src/app/features/asset-list/_components/connect-ledger-asset-button.tsx b/src/app/features/asset-list/_components/connect-ledger-asset-button.tsx index 743693683d0..c9b3077ec23 100644 --- a/src/app/features/asset-list/_components/connect-ledger-asset-button.tsx +++ b/src/app/features/asset-list/_components/connect-ledger-asset-button.tsx @@ -1,6 +1,6 @@ import { useNavigate } from 'react-router-dom'; -import { styled } from 'leather-styles/jsx'; +import { HStack, styled } from 'leather-styles/jsx'; import type { Blockchain } from '@leather.io/models'; import { Button, LedgerIcon } from '@leather.io/ui'; @@ -27,16 +27,11 @@ export function ConnectLedgerButton({ chain }: ConnectLedgerButtonProps) { }; return ( - ); } diff --git a/src/app/features/asset-list/stacks/sip10-token-asset-list/sip10-token-asset-item.tsx b/src/app/features/asset-list/stacks/sip10-token-asset-list/sip10-token-asset-item.tsx index e47654cd0f0..fd37f9a59e2 100644 --- a/src/app/features/asset-list/stacks/sip10-token-asset-list/sip10-token-asset-item.tsx +++ b/src/app/features/asset-list/stacks/sip10-token-asset-list/sip10-token-asset-item.tsx @@ -37,13 +37,15 @@ export function Sip10TokenAssetItem({ const { contractId, imageCanonicalUri, name, symbol } = info; const icon = ( - - {name[0]} - + <> + + {name[0]} + + ); const captionLeft = symbol; diff --git a/src/app/features/asset-list/stacks/sip10-token-asset-list/sip10-token-asset-list.tsx b/src/app/features/asset-list/stacks/sip10-token-asset-list/sip10-token-asset-list.tsx index 044c93c755f..8f0a098cfaa 100644 --- a/src/app/features/asset-list/stacks/sip10-token-asset-list/sip10-token-asset-list.tsx +++ b/src/app/features/asset-list/stacks/sip10-token-asset-list/sip10-token-asset-list.tsx @@ -2,9 +2,9 @@ import { type Dispatch, type SetStateAction, useEffect } from 'react'; import { Stack } from 'leather-styles/jsx'; -import { type Sip10TokenAssetDetails, useAlexCurrencyPriceAsMarketData } from '@leather.io/query'; +import { type Sip10TokenAssetDetails } from '@leather.io/query'; -import { getPrincipalFromContractId } from '@app/common/utils'; +import { useSip10FiatMarketData } from '@app/common/hooks/use-calculate-sip10-fiat-value'; import type { AssetRightElementVariant } from '../../asset-list'; import { Sip10TokenAssetItem } from './sip10-token-asset-item'; @@ -25,7 +25,7 @@ export function Sip10TokenAssetList({ preEnabledTokensIds, setHasManageableTokens, }: Sip10TokenAssetListProps) { - const priceAsMarketData = useAlexCurrencyPriceAsMarketData(); + const { getTokenMarketData } = useSip10FiatMarketData(); useEffect(() => { if (tokens.length > 0 && setHasManageableTokens) { @@ -44,8 +44,8 @@ export function Sip10TokenAssetList({ key={token.info.name + token.info.contractId} info={token.info} isLoading={isLoading} - marketData={priceAsMarketData( - getPrincipalFromContractId(token.info.contractId), + marketData={getTokenMarketData( + token.info.contractId, token.balance.availableBalance.symbol )} onSelectAsset={onSelectAsset} diff --git a/src/app/features/dialogs/transaction-action-dialog/increase-btc-fee-dialog.tsx b/src/app/features/dialogs/transaction-action-dialog/increase-btc-fee-dialog.tsx index b1ca91bd968..f44c20d5311 100644 --- a/src/app/features/dialogs/transaction-action-dialog/increase-btc-fee-dialog.tsx +++ b/src/app/features/dialogs/transaction-action-dialog/increase-btc-fee-dialog.tsx @@ -59,51 +59,54 @@ export function IncreaseBtcFeeSheet() { validateOnMount={false} validationSchema={validationSchema} > - <> - } - footer={ - navigate(RouteUrls.Home)} - /> - } - > - - - - - } - > - - If your transaction is pending for a long time, its fee might not be high enough - to be included in a block. Update the fee for a higher value and try again. - - - {btcTx && } - - - - + {({ submitForm }) => ( + <> + } + footer={ + navigate(RouteUrls.Home)} + /> + } + > + + + + + } + > + + If your transaction is pending for a long time, its fee might not be high enough + to be included in a block. Update the fee for a higher value and try again. + + + {btcTx && } + + + + - {balance && Balance: {btcBalance}} + {balance && Balance: {btcBalance}} + - - - - - - + + + + + + )} ); diff --git a/src/app/features/errors/app-error-boundary.tsx b/src/app/features/errors/app-error-boundary.tsx index 3972cc49647..5d741c80b41 100644 --- a/src/app/features/errors/app-error-boundary.tsx +++ b/src/app/features/errors/app-error-boundary.tsx @@ -2,7 +2,7 @@ import { useRouteError } from 'react-router-dom'; import BroadcastError from '@assets/images/unhappy-face-ui.png'; import { SharedComponentsSelectors } from '@tests/selectors/shared-component.selectors'; -import { Box, Flex, HStack, styled } from 'leather-styles/jsx'; +import { Box, Center, Flex, HStack, styled } from 'leather-styles/jsx'; import { Button, CopyIcon, Link } from '@leather.io/ui'; import { isError } from '@leather.io/utils'; @@ -102,17 +102,11 @@ export function RouterErrorBoundary() { )} - , + , + ]} /> - + ); } diff --git a/src/app/pages/rpc-send-transfer/rpc-send-transfer-confirmation.tsx b/src/app/pages/rpc-send-transfer/rpc-send-transfer-confirmation.tsx deleted file mode 100644 index 1c946193db9..00000000000 --- a/src/app/pages/rpc-send-transfer/rpc-send-transfer-confirmation.tsx +++ /dev/null @@ -1,160 +0,0 @@ -import { useLocation, useNavigate } from 'react-router-dom'; - -import { HStack, Stack, styled } from 'leather-styles/jsx'; -import get from 'lodash.get'; - -import type { CryptoCurrency } from '@leather.io/models'; -import { - useBitcoinBroadcastTransaction, - useCryptoCurrencyMarketDataMeanAverage, -} from '@leather.io/query'; -import { Button } from '@leather.io/ui'; -import { - baseCurrencyAmountInQuote, - createMoney, - formatMoney, - formatMoneyPadded, - formatMoneyWithoutSymbol, - i18nFormatCurrency, - sumMoney, - truncateMiddle, -} from '@leather.io/utils'; - -import { logger } from '@shared/logger'; -import type { TransferRecipient } from '@shared/models/form.model'; -import { RouteUrls } from '@shared/route-urls'; -import { makeRpcSuccessResponse } from '@shared/rpc/rpc-methods'; -import { analytics } from '@shared/utils/analytics'; - -import { InfoCardFooter } from '@app/components/info-card/info-card'; -import { useCurrentNativeSegwitUtxos } from '@app/query/bitcoin/address/utxos-by-address.hooks'; -import { useCurrentAccountNativeSegwitAddressIndexZero } from '@app/store/accounts/blockchain/bitcoin/native-segwit-account.hooks'; - -import { SendTransferConfirmationDetails } from './components/send-transfer-confirmation-details'; -import { useRpcSendTransferRequestParams } from './use-rpc-send-transfer'; - -const symbol: CryptoCurrency = 'BTC'; - -function useRpcSendTransferConfirmationState() { - const location = useLocation(); - return { - fee: get(location.state, 'fee') as string, - recipients: get(location.state, 'recipients') as TransferRecipient[], - time: get(location.state, 'time') as string, - tx: get(location.state, 'tx') as string, - feeRowValue: get(location.state, 'feeRowValue') as string, - }; -} - -export function RpcSendTransferConfirmation() { - const navigate = useNavigate(); - const { origin, requestId, tabId } = useRpcSendTransferRequestParams(); - const { fee, recipients, time, tx, feeRowValue } = useRpcSendTransferConfirmationState(); - const bitcoinAddress = useCurrentAccountNativeSegwitAddressIndexZero(); - const { broadcastTx, isBroadcasting } = useBitcoinBroadcastTransaction(); - const { filteredUtxosQuery } = useCurrentNativeSegwitUtxos(); - const btcMarketData = useCryptoCurrencyMarketDataMeanAverage('BTC'); - - const transferAmount = sumMoney(recipients.map(r => r.amount)); - const txFiatValue = i18nFormatCurrency(baseCurrencyAmountInQuote(transferAmount, btcMarketData)); - const txFiatValueSymbol = btcMarketData.price.symbol; - const feeMoney = createMoney(Number(fee), symbol); - const summaryFee = formatMoneyPadded(feeMoney); - const totalSpend = sumMoney([transferAmount, feeMoney]); - - function formBtcTxSummaryState(txId: string) { - return { - txLink: { - blockchain: 'bitcoin', - txid: txId || '', - }, - txId, - recipients, - fee: summaryFee, - txValue: formatMoneyWithoutSymbol(transferAmount), - arrivesIn: time, - totalSpend: formatMoney(totalSpend), - symbol, - sendingValue: formatMoney(transferAmount), - txFiatValue, - txFiatValueSymbol, - feeRowValue, - }; - } - - async function onUserApproveSendTransferRequest() { - if (!tabId || !origin) { - logger.error('Cannot send transfer: missing tabId, origin'); - return; - } - void analytics.track('user_approved_send_transfer', { origin }); - - await broadcastTx({ - tx, - async onSuccess(txid) { - void analytics.track('broadcast_transaction', { - symbol: 'btc', - amount: transferAmount.amount.toNumber(), - }); - await filteredUtxosQuery.refetch(); - - chrome.tabs.sendMessage( - tabId, - makeRpcSuccessResponse('sendTransfer', { - id: requestId, - result: { txid }, - }) - ); - - navigate(RouteUrls.RpcSendTransferSummary, { - state: formBtcTxSummaryState(txid), - }); - }, - onError(e) { - logger.error('Error broadcasting tx', e); - }, - }); - } - - return ( - <> - - {recipients.map((recipient, index) => ( - - ))} - - - Fee - {feeRowValue} - - - Total amount - {formatMoney(totalSpend)} - - {time && ( - - Estimated time - {time} - - )} - - - - - - - - ); -} diff --git a/src/app/pages/rpc-send-transfer/rpc-send-transfer-container.tsx b/src/app/pages/rpc-send-transfer/rpc-send-transfer-container.tsx index 4fe359d610e..d799cbd7540 100644 --- a/src/app/pages/rpc-send-transfer/rpc-send-transfer-container.tsx +++ b/src/app/pages/rpc-send-transfer/rpc-send-transfer-container.tsx @@ -1,40 +1,116 @@ -import { useState } from 'react'; import { Outlet, useOutletContext } from 'react-router-dom'; -import { Flex } from 'leather-styles/jsx'; - -import type { BtcFeeType } from '@leather.io/models'; +import type { Money } from '@leather.io/models'; +import { type UtxoResponseItem, useCryptoCurrencyMarketDataMeanAverage } from '@leather.io/query'; +import type { TransferRecipient } from '@shared/models/form.model'; import { closeWindow } from '@shared/utils'; -import { PopupHeader } from '@app/features/container/headers/popup.header'; +import { + type FeeDisplayInfo, + type FeeType, + type FeesRawData, + useFeesHandler, +} from '@app/common/fees/use-fees'; +import { useSwitchAccountSheet } from '@app/common/switch-account/use-switch-account-sheet-context'; +import { type RawFee } from '@app/components/bitcoin-fees-list/bitcoin-fees.utils'; +import { formatBitcoinFeeForDisplay } from '@app/components/bitcoin-fees-list/format-bitcoin-fee'; +import { useBitcoinFeeData } from '@app/components/bitcoin-fees-list/use-bitcoin-fees-data'; import { useRpcSendTransfer } from './use-rpc-send-transfer'; interface RpcSendTransferContextState { - selectedFeeType: BtcFeeType; - setSelectedFeeType(value: BtcFeeType | null): void; + selectedFeeType: FeeType; + setSelectedFeeType(value: FeeType | null): void; + + availableBalance: number; + recipients: TransferRecipient[]; + totalAmount: number; + amountAsMoney: Money; + recipientsAddresses: string[]; + utxos: UtxoResponseItem[]; + onChooseTransferFee(): void; + fees: FeesRawData; + getCustomFeeData(rate: number): RawFee; + + origin: string; + selectedFeeData: FeeDisplayInfo; + + editFeeSelected: FeeType; + setEditFeeSelected(value: FeeType): void; + + customFeeRate: string; + setCustomFeeRate(value: string | null): void; + + customFeeData: FeeDisplayInfo | null; + + isLoadingFees: boolean; + + tabId: number | null; + requestId: string; + toggleSwitchAccount(): void; } + export function useRpcSendTransferState() { - const context = useOutletContext(); + const context: RpcSendTransferContextState = useOutletContext(); return { ...context }; } export function RpcSendTransferContainer() { - const [selectedFeeType, setSelectedFeeType] = useState(null); - const { origin } = useRpcSendTransfer(); + const sendTransferState = useRpcSendTransfer(); + const { toggleSwitchAccount } = useSwitchAccountSheet(); + const btcMarketData = useCryptoCurrencyMarketDataMeanAverage('BTC'); - if (origin === null) { + if (sendTransferState.origin === null) { closeWindow(); throw new Error('Origin is null'); } + const { recipients, utxos, amountAsMoney } = sendTransferState; + + const { + fees, + getCustomFeeData, + isLoading: isLoadingFees, + } = useBitcoinFeeData({ + amount: amountAsMoney, + recipients, + utxos, + }); + + const { + selectedFeeType, + setSelectedFeeType, + customFeeRate, + setCustomFeeRate, + customFeeData, + selectedFeeData, + editFeeSelected, + setEditFeeSelected, + } = useFeesHandler({ + defaultFeeType: 'standard', + fees, + getCustomFeeData, + marketData: btcMarketData, + formatFeeForDisplay: formatBitcoinFeeForDisplay, + }); return ( - <> - - - - - + ); } diff --git a/src/app/pages/rpc-send-transfer/rpc-send-transfer-summary.tsx b/src/app/pages/rpc-send-transfer/rpc-send-transfer-summary.tsx deleted file mode 100644 index 976d42cb1b0..00000000000 --- a/src/app/pages/rpc-send-transfer/rpc-send-transfer-summary.tsx +++ /dev/null @@ -1,99 +0,0 @@ -import { useLocation } from 'react-router-dom'; - -import { HStack, Stack } from 'leather-styles/jsx'; - -import { CheckmarkIcon, CopyIcon, ExternalLinkIcon } from '@leather.io/ui'; - -import type { TransferRecipient } from '@shared/models/form.model'; -import { analytics } from '@shared/utils/analytics'; - -import { useBitcoinExplorerLink } from '@app/common/hooks/use-bitcoin-explorer-link'; -import { useClipboard } from '@app/common/hooks/use-copy-to-clipboard'; -import { FormAddressDisplayer } from '@app/components/address-displayer/form-address-displayer'; -import { - InfoCardAssetValue, - InfoCardBtn, - InfoCardFooter, - InfoCardRow, - InfoCardSeparator, -} from '@app/components/info-card/info-card'; -import { Card } from '@app/components/layout'; -import { useToast } from '@app/features/toasts/use-toast'; - -export function RpcSendTransferSummary() { - const { state } = useLocation(); - const { handleOpenBitcoinTxLink: handleOpenTxLink } = useBitcoinExplorerLink(); - - const toast = useToast(); - - const { - txId, - txValue, - txFiatValue, - txFiatValueSymbol, - symbol, - txLink, - arrivesIn, - sendingValue, - recipients, - feeRowValue, - totalSpend, - } = state; - - const { onCopy } = useClipboard(txId); - - function onClickLink() { - void analytics.track('view_rpc_send_transfer_confirmation', { symbol: 'BTC' }); - handleOpenTxLink(txLink); - } - - function onClickCopy() { - onCopy(); - toast.success('ID copied!'); - } - - return ( - <> - - } - mb="space.05" - symbol={symbol} - value={txValue} - /> - - - {recipients.map((recipient: TransferRecipient, index: number) => ( - } - /> - ))} - - - - - - {arrivesIn && } - - - - - } - label="View details" - onClick={onClickLink} - /> - } - label="Copy ID" - onClick={onClickCopy} - /> - - - - ); -} diff --git a/src/app/pages/rpc-send-transfer/rpc-send-transfer.routes.tsx b/src/app/pages/rpc-send-transfer/rpc-send-transfer.routes.tsx index 104ea009fe4..26d01e196df 100644 --- a/src/app/pages/rpc-send-transfer/rpc-send-transfer.routes.tsx +++ b/src/app/pages/rpc-send-transfer/rpc-send-transfer.routes.tsx @@ -7,9 +7,7 @@ import { AccountGate } from '@app/routes/account-gate'; import { RpcSendTransfer } from './rpc-send-transfer'; import { RpcSendTransferChooseFee } from './rpc-send-transfer-choose-fee'; -import { RpcSendTransferConfirmation } from './rpc-send-transfer-confirmation'; import { RpcSendTransferContainer } from './rpc-send-transfer-container'; -import { RpcSendTransferSummary } from './rpc-send-transfer-summary'; export const rpcSendTransferRoutes = ( } > - } /> - }> + }> {ledgerBitcoinTxSigningRoutes} - } /> - } /> + } /> ); diff --git a/src/app/pages/rpc-send-transfer/rpc-send-transfer.tsx b/src/app/pages/rpc-send-transfer/rpc-send-transfer.tsx index 27db7563cd5..8f73d5dcfb4 100644 --- a/src/app/pages/rpc-send-transfer/rpc-send-transfer.tsx +++ b/src/app/pages/rpc-send-transfer/rpc-send-transfer.tsx @@ -1,34 +1,124 @@ +import { useMemo } from 'react'; +import { Outlet } from 'react-router-dom'; + import BigNumber from 'bignumber.js'; +import { Box } from 'leather-styles/jsx'; + +import { useCryptoCurrencyMarketDataMeanAverage } from '@leather.io/query'; +import { Approver } from '@leather.io/ui'; +import { + baseCurrencyAmountInQuote, + createMoney, + i18nFormatCurrency, + sumMoney, +} from '@leather.io/utils'; -import { Button } from '@leather.io/ui'; -import { createMoney, formatMoneyPadded } from '@leather.io/utils'; +import { analytics } from '@shared/utils/analytics'; -import { InfoCardFooter } from '@app/components/info-card/info-card'; +import { focusTabAndWindow } from '@app/common/focus-tab'; +import { ApproveBitcoinTransactionSwitchAccount } from '@app/components/approve-transaction/approve-bitcoin-transaction-switch-account'; +import { ApproveTransactionError } from '@app/components/approve-transaction/approve-transaction-error'; +import { ApproveTransactionHeader } from '@app/components/approve-transaction/approve-transaction-header'; +import { ApproveTransactionRecipients } from '@app/components/approve-transaction/approve-transaction-recipients'; +import { ApproveTransactionSelectedFee } from '@app/components/approve-transaction/approve-transaction-selected-fee'; +import { ApproveTransactionActionsTitle } from '@app/components/approve-transaction/approve-transaction-title'; +import { BackgroundOverlay } from '@app/components/loading-overlay'; +import { useCurrentBtcCryptoAssetBalanceNativeSegwit } from '@app/query/bitcoin/balance/btc-balance-native-segwit.hooks'; import { useBreakOnNonCompliantEntity } from '@app/query/common/compliance-checker/compliance-checker.query'; -import { useCurrentAccountNativeSegwitIndexZeroSigner } from '@app/store/accounts/blockchain/bitcoin/native-segwit-account.hooks'; -import { SendTransferDetails } from './components/send-transfer-details'; -import { SendTransferHeader } from './components/send-transfer-header'; -import { useRpcSendTransfer } from './use-rpc-send-transfer'; +import { RpcSendTransferWrapper } from './components/send-transfer-wrapper'; +import { useRpcSendTransferState } from './rpc-send-transfer-container'; +import { useSendTransferApproveActions } from './use-send-transfer-approve-actions'; export function RpcSendTransfer() { - const nativeSegwitSigner = useCurrentAccountNativeSegwitIndexZeroSigner(); - const { recipients, recipientsAddresses, totalAmount, onChooseTransferFee, origin } = - useRpcSendTransfer(); + const { + selectedFeeData, + recipients, + recipientsAddresses, + totalAmount, + onChooseTransferFee, + origin, + isLoadingFees, + utxos, + tabId, + requestId, + toggleSwitchAccount, + } = useRpcSendTransferState(); + const amountAsMoney = createMoney(new BigNumber(totalAmount), 'BTC'); - const formattedMoney = formatMoneyPadded(amountAsMoney); + + const btcBalance = useCurrentBtcCryptoAssetBalanceNativeSegwit(); useBreakOnNonCompliantEntity(recipientsAddresses); + const isInsufficientBalance = btcBalance.balance.availableBalance.amount.isLessThan( + amountAsMoney.amount + ); + + const isLoading = btcBalance.isLoadingAllData; + + const { approverActions, isBroadcasting, isSubmitted } = useSendTransferApproveActions({ + amountAsMoney, + recipients, + utxos, + selectedFeeData, + requestId, + tabId, + }); + + const showOverlay = isBroadcasting || isSubmitted; + + const btcMarketData = useCryptoCurrencyMarketDataMeanAverage('BTC'); + + const totalFiatValue = useMemo(() => { + const fee = selectedFeeData?.baseUnitsValue; + const feeAsMoney = createMoney(fee, 'BTC'); + + return i18nFormatCurrency( + baseCurrencyAmountInQuote(sumMoney([amountAsMoney, feeAsMoney]), btcMarketData) + ); + }, [amountAsMoney, selectedFeeData, btcMarketData]); + return ( <> - - - - - + + + + + + { + e.preventDefault(); + void analytics.track('user_clicked_requested_by_link', { + endpoint: 'sendTransfer', + }); + focusTabAndWindow(tabId); + }} + /> + + + + + + + + + + + + + + + ); } diff --git a/src/app/pages/rpc-send-transfer/use-rpc-send-transfer.ts b/src/app/pages/rpc-send-transfer/use-rpc-send-transfer.ts index 95528dc1e9c..f335cc225f4 100644 --- a/src/app/pages/rpc-send-transfer/use-rpc-send-transfer.ts +++ b/src/app/pages/rpc-send-transfer/use-rpc-send-transfer.ts @@ -12,7 +12,7 @@ import { useOnMount } from '@app/common/hooks/use-on-mount'; import { initialSearchParams } from '@app/common/initial-search-params'; import { useCurrentNativeSegwitUtxos } from '@app/query/bitcoin/address/utxos-by-address.hooks'; -export function useRpcSendTransferRequestParams() { +function useRpcSendTransferRequestParams() { const defaultParams = useDefaultRequestParams(); return useMemo( () => ({ @@ -27,7 +27,8 @@ export function useRpcSendTransferRequestParams() { export function useRpcSendTransfer() { const navigate = useNavigate(); - const { origin, recipientsAddresses, amounts } = useRpcSendTransferRequestParams(); + const { origin, recipientsAddresses, amounts, tabId, requestId } = + useRpcSendTransferRequestParams(); const { data: utxos = [], filteredUtxosQuery } = useCurrentNativeSegwitUtxos(); const totalAmount = sumNumbers(amounts.map(Number)); const amountAsMoney = createMoney(new BigNumber(totalAmount), 'BTC'); @@ -43,6 +44,8 @@ export function useRpcSendTransfer() { })); return { + requestId, + tabId, recipients, origin, utxos, diff --git a/src/app/pages/rpc-send-transfer/use-send-transfer-approve-actions.tsx b/src/app/pages/rpc-send-transfer/use-send-transfer-approve-actions.tsx new file mode 100644 index 00000000000..a3799238bdd --- /dev/null +++ b/src/app/pages/rpc-send-transfer/use-send-transfer-approve-actions.tsx @@ -0,0 +1,139 @@ +import { useMemo, useState } from 'react'; +import { useNavigate } from 'react-router-dom'; + +import type { Money } from '@leather.io/models'; +import { type UtxoResponseItem, useBitcoinBroadcastTransaction } from '@leather.io/query'; + +import { logger } from '@shared/logger'; +import type { TransferRecipient } from '@shared/models/form.model'; +import { RouteUrls } from '@shared/route-urls'; +import { makeRpcSuccessResponse } from '@shared/rpc/rpc-methods'; +import { closeWindow } from '@shared/utils'; +import { analytics } from '@shared/utils/analytics'; + +import type { FeeDisplayInfo } from '@app/common/fees/use-fees'; +import { useGenerateUnsignedNativeSegwitTx } from '@app/common/transactions/bitcoin/use-generate-bitcoin-tx'; +import { getApproveActions } from '@app/components/approve-transaction/get-approve-actions'; +import { useCurrentBtcCryptoAssetBalanceNativeSegwit } from '@app/query/bitcoin/balance/btc-balance-native-segwit.hooks'; +import { useSignBitcoinTx } from '@app/store/accounts/blockchain/bitcoin/bitcoin.hooks'; + +interface UseSendTransferApproveActionsArgs { + amountAsMoney: Money; + recipients: TransferRecipient[]; + utxos: UtxoResponseItem[]; + selectedFeeData: FeeDisplayInfo; + requestId: string; + tabId: number | null; +} + +export function useSendTransferApproveActions({ + amountAsMoney, + recipients, + utxos, + selectedFeeData, + requestId, + tabId, +}: UseSendTransferApproveActionsArgs) { + const navigate = useNavigate(); + + const [isSubmitted, setIsSubmitted] = useState(false); + const [isBroadcasting, setIsBroadcasting] = useState(false); + const generateTx = useGenerateUnsignedNativeSegwitTx({ throwError: true }); + const signTransaction = useSignBitcoinTx(); + const btcBalance = useCurrentBtcCryptoAssetBalanceNativeSegwit(); + const { broadcastTx } = useBitcoinBroadcastTransaction(); + + const isLoading = btcBalance.isLoadingAllData; + + const isInsufficientBalance = btcBalance.balance.availableBalance.amount.isLessThan( + amountAsMoney.amount + ); + + const approverActions = useMemo(() => { + function onCancel() { + closeWindow(); + } + + async function onApprove() { + setIsBroadcasting(true); + + function onError(e: unknown) { + setIsBroadcasting(false); + logger.error('Error broadcasting tx', e); + navigate(RouteUrls.SendBtcError, { + state: { + error: e, + }, + }); + } + + try { + const feeRate = selectedFeeData.feeRate; + const resp = await generateTx({ amount: amountAsMoney, recipients }, feeRate, utxos); + + if (!resp) return logger.error('Attempted to generate raw tx, but no tx exists'); + + const tx = await signTransaction(resp.psbt); + + tx.finalize(); + + await broadcastTx({ + tx: tx.hex, + async onSuccess(txid) { + setIsBroadcasting(false); + + void analytics.track('broadcast_transaction', { + symbol: 'btc', + amount: amountAsMoney.amount.toNumber(), + }); + + chrome.tabs.sendMessage( + tabId ?? 0, + makeRpcSuccessResponse('sendTransfer', { + id: requestId, + result: { txid }, + }) + ); + + setIsSubmitted(true); + }, + onError, + }); + } catch (error) { + onError(error); + } finally { + setIsBroadcasting(false); + } + } + + return getApproveActions({ + isLoading, + isInsufficientBalance, + isBroadcasting, + isSubmitted, + onCancel, + onApprove, + }); + }, [ + amountAsMoney, + broadcastTx, + generateTx, + isLoading, + isInsufficientBalance, + isBroadcasting, + isSubmitted, + navigate, + recipients, + requestId, + selectedFeeData.feeRate, + signTransaction, + tabId, + utxos, + ]); + + return { + approverActions, + isBroadcasting, + isSubmitted, + }; +} diff --git a/src/app/pages/send/send-crypto-asset-form/form/sip10/sip10-token-send-form-container.tsx b/src/app/pages/send/send-crypto-asset-form/form/sip10/sip10-token-send-form-container.tsx index 97a7059f7ca..19892f6e4b8 100644 --- a/src/app/pages/send/send-crypto-asset-form/form/sip10/sip10-token-send-form-container.tsx +++ b/src/app/pages/send/send-crypto-asset-form/form/sip10/sip10-token-send-form-container.tsx @@ -51,10 +51,7 @@ export function Sip10TokenSendFormContainer({ + ) : ( ) diff --git a/src/app/pages/send/send-crypto-asset-form/form/sip10/sip10-token-send-form.tsx b/src/app/pages/send/send-crypto-asset-form/form/sip10/sip10-token-send-form.tsx index da2a81f0a63..3c7fa17878c 100644 --- a/src/app/pages/send/send-crypto-asset-form/form/sip10/sip10-token-send-form.tsx +++ b/src/app/pages/send/send-crypto-asset-form/form/sip10/sip10-token-send-form.tsx @@ -1,10 +1,11 @@ import { useNavigate, useParams } from 'react-router-dom'; import type { CryptoAssetBalance, MarketData, Sip10CryptoAssetInfo } from '@leather.io/models'; -import { useAlexCurrencyPriceAsMarketData, useSip10Token } from '@leather.io/query'; +import { useSip10Token } from '@leather.io/query'; import { RouteUrls } from '@shared/route-urls'; +import { useSip10FiatMarketData } from '@app/common/hooks/use-calculate-sip10-fiat-value'; import { Content } from '@app/components/layout'; import { PageHeader } from '@app/features/container/headers/page.header'; import { useToast } from '@app/features/toasts/use-toast'; @@ -23,7 +24,7 @@ function Sip10TokenSendFormLoader({ children }: Sip10TokenSendFormLoaderProps) { const { contractId } = useParams(); const stxAddress = useCurrentStacksAccountAddress(); const token = useSip10Token(stxAddress, contractId ?? ''); - const priceAsMarketData = useAlexCurrencyPriceAsMarketData(); + const { getTokenMarketData } = useSip10FiatMarketData(); const toast = useToast(); const navigate = useNavigate(); @@ -37,7 +38,7 @@ function Sip10TokenSendFormLoader({ children }: Sip10TokenSendFormLoaderProps) { return children({ ...token, - marketData: priceAsMarketData(token.info.contractId, token.balance.availableBalance.symbol), + marketData: getTokenMarketData(token.info.contractId, token.balance.availableBalance.symbol), }); } diff --git a/src/app/pages/swap/bitflow-swap-container.tsx b/src/app/pages/swap/bitflow-swap-container.tsx index 7ac3e2523ee..baf8ec3ad9a 100644 --- a/src/app/pages/swap/bitflow-swap-container.tsx +++ b/src/app/pages/swap/bitflow-swap-container.tsx @@ -1,6 +1,7 @@ -import { useState } from 'react'; +import { useCallback, useState } from 'react'; import { Outlet, useNavigate } from 'react-router-dom'; +import type { P2Ret } from '@scure/btc-signer/payment'; import { bytesToHex } from '@stacks/common'; import { type ContractCallPayload, TransactionTypes } from '@stacks/connect'; import { @@ -10,34 +11,50 @@ import { serializePostCondition, } from '@stacks/transactions'; -import { defaultSwapFee } from '@leather.io/query'; -import { isDefined, isError, isUndefined } from '@leather.io/utils'; +import { isError, isUndefined } from '@leather.io/utils'; import { logger } from '@shared/logger'; +import type { SwapFormValues } from '@shared/models/form.model'; import { RouteUrls } from '@shared/route-urls'; import { bitflow } from '@shared/utils/bitflow-sdk'; -import { migratePositiveAssetBalancesToTop } from '@app/common/asset-utils'; import { LoadingKeys, useLoading } from '@app/common/hooks/use-loading'; import { Content, Page } from '@app/components/layout'; +import { BitcoinNativeSegwitAccountLoader } from '@app/components/loaders/bitcoin-account-loader'; import { PageHeader } from '@app/features/container/headers/page.header'; +import type { + SbtcSponsorshipEligibility, + TransactionBase, +} from '@app/query/sbtc/sponsored-transactions.query'; +import type { Signer } from '@app/store/accounts/blockchain/bitcoin/bitcoin-signer'; import { useCurrentStacksAccount } from '@app/store/accounts/blockchain/stacks/stacks-account.hooks'; import { useGenerateStacksContractCallUnsignedTx } from '@app/store/transactions/contract-call.hooks'; import { useSignStacksTransaction } from '@app/store/transactions/transaction.hooks'; -import { estimateLiquidityFee, formatDexPathItem } from './bitflow-swap.utils'; +import { getCrossChainSwapSubmissionData, getStacksSwapSubmissionData } from './bitflow-swap.utils'; import { SwapForm } from './components/swap-form'; import { generateSwapRoutes } from './generate-swap-routes'; import { useBitflowSwap } from './hooks/use-bitflow-swap'; +import { useSbtcDepositTransaction } from './hooks/use-sbtc-deposit-transaction'; +import { useSponsorTransactionFees } from './hooks/use-sponsor-tx-fees'; import { useStacksBroadcastSwap } from './hooks/use-stacks-broadcast-swap'; -import { SwapFormValues } from './hooks/use-swap-form'; import { useSwapNavigate } from './hooks/use-swap-navigate'; import { SwapContext, SwapProvider } from './swap.context'; -export const bitflowSwapRoutes = generateSwapRoutes(); +// TODO: Refactor coupled Bitflow and Bitcoin swap containers, they should be separate +export const bitflowSwapRoutes = generateSwapRoutes( + }> + {signer => } + +); -function BitflowSwapContainer() { +interface BitflowSwapContainerProps { + btcSigner?: Signer; +} +function BitflowSwapContainer({ btcSigner }: BitflowSwapContainerProps) { + const [unsignedTx, setUnsignedTx] = useState(); const [isSendingMax, setIsSendingMax] = useState(false); + const [isPreparingSwapReview, setIsPreparingSwapReview] = useState(false); const navigate = useNavigate(); const swapNavigate = useSwapNavigate(); const { setIsLoading, setIsIdle, isLoading } = useLoading(LoadingKeys.SUBMIT_SWAP_TRANSACTION); @@ -45,59 +62,138 @@ function BitflowSwapContainer() { const generateUnsignedTx = useGenerateStacksContractCallUnsignedTx(); const signTx = useSignStacksTransaction(); const broadcastStacksSwap = useStacksBroadcastSwap(); - const [isPreparingSwapReview, setIsPreparingSwapReview] = useState(false); + const { onDepositSbtc, onReviewDepositSbtc } = useSbtcDepositTransaction(btcSigner); + + const [sponsorshipEligibility, setSponsorshipEligibility] = useState< + SbtcSponsorshipEligibility | undefined + >(); + + const { checkEligibilityForSponsor, submitSponsoredTx } = useSponsorTransactionFees(); + const { fetchRouteQuote, fetchQuoteAmount, + isCrossChainSwap, isFetchingExchangeRate, + onSetIsCrossChainSwap, onSetIsFetchingExchangeRate, onSetSwapSubmissionData, slippage, - swapAssets, + bitflowSwapAssets, + swappableAssetsBase, + swappableAssetsQuote, swapSubmissionData, - } = useBitflowSwap(); + } = useBitflowSwap(btcSigner); - async function onSubmitSwapForReview(values: SwapFormValues) { - try { - setIsPreparingSwapReview(true); - if (isUndefined(values.swapAssetBase) || isUndefined(values.swapAssetQuote)) { - logger.error('Error submitting swap for review'); - return; - } + const onSubmitSwapForReview = useCallback( + async (values: SwapFormValues) => { + try { + setIsPreparingSwapReview(true); + if ( + isUndefined(currentAccount) || + isUndefined(values.swapAssetBase) || + isUndefined(values.swapAssetQuote) + ) { + logger.error('Error submitting swap for review'); + return; + } - const routeQuote = await fetchRouteQuote( - values.swapAssetBase, - values.swapAssetQuote, - values.swapAmountBase - ); - if (!routeQuote) return; - - onSetSwapSubmissionData({ - fee: defaultSwapFee.amount.toString(), - feeCurrency: values.feeCurrency, - feeType: values.feeType, - liquidityFee: estimateLiquidityFee(routeQuote.route.dex_path), - nonce: values.nonce, - protocol: 'Bitflow', - dexPath: routeQuote.route.dex_path.map(formatDexPathItem), - router: routeQuote.route.token_path - .map(x => swapAssets.find(asset => asset.currency === x)) - .filter(isDefined), - slippage, - sponsored: false, - swapAmountBase: values.swapAmountBase, - swapAmountQuote: values.swapAmountQuote, - swapAssetBase: values.swapAssetBase, - swapAssetQuote: values.swapAssetQuote, - timestamp: new Date().toISOString(), - }); - swapNavigate(RouteUrls.SwapReview); - } finally { - setIsPreparingSwapReview(false); - } - } + if (isCrossChainSwap) { + const swapData = getCrossChainSwapSubmissionData(values); + const sBtcDepositData = await onReviewDepositSbtc(swapData, isSendingMax); + onSetSwapSubmissionData({ + ...swapData, + fee: sBtcDepositData?.fee ?? 0, + maxSignerFee: sBtcDepositData?.maxSignerFee, + txData: { deposit: sBtcDepositData?.deposit }, + }); + return swapNavigate(RouteUrls.SwapReview); + } + + const routeQuote = await fetchRouteQuote( + values.swapAssetBase, + values.swapAssetQuote, + values.swapAmountBase + ); + + if (!routeQuote) return; + + const stacksSwapData = getStacksSwapSubmissionData({ + bitflowSwapAssets, + routeQuote, + slippage, + values, + }); + + const swapExecutionData = { + route: routeQuote.route, + amount: Number(stacksSwapData.swapAmountBase), + tokenXDecimals: routeQuote.tokenXDecimals, + tokenYDecimals: routeQuote.tokenYDecimals, + }; + + const swapParams = await bitflow.getSwapParams( + swapExecutionData, + currentAccount.address, + slippage + ); - async function onSubmitSwap() { + if (!routeQuote) return; + + const formValues = { + fee: stacksSwapData.fee, + feeCurrency: stacksSwapData.feeCurrency, + feeType: stacksSwapData.feeType, + nonce: stacksSwapData.nonce, + }; + + const payload: ContractCallPayload = { + anchorMode: AnchorMode.Any, + contractAddress: swapParams.contractAddress, + contractName: swapParams.contractName, + functionName: swapParams.functionName, + functionArgs: swapParams.functionArgs.map(x => bytesToHex(serializeCV(x))), + postConditionMode: PostConditionMode.Deny, + postConditions: swapParams.postConditions.map(pc => + bytesToHex(serializePostCondition(pc)) + ), + publicKey: currentAccount?.stxPublicKey, + sponsored: false, + txType: TransactionTypes.ContractCall, + }; + + const unsignedTx = await generateUnsignedTx(payload, formValues); + if (!unsignedTx) + return logger.error('Attempted to generate unsigned tx, but tx is undefined'); + + const sponsorshipEligibility = await checkEligibilityForSponsor(values, unsignedTx); + stacksSwapData.sponsored = sponsorshipEligibility.isEligible; + + setUnsignedTx(unsignedTx); + setSponsorshipEligibility(sponsorshipEligibility); + onSetSwapSubmissionData(stacksSwapData); + + swapNavigate(RouteUrls.SwapReview); + } finally { + setIsPreparingSwapReview(false); + } + }, + [ + currentAccount, + isCrossChainSwap, + fetchRouteQuote, + bitflowSwapAssets, + slippage, + generateUnsignedTx, + checkEligibilityForSponsor, + onSetSwapSubmissionData, + swapNavigate, + onReviewDepositSbtc, + isSendingMax, + ] + ); + + const onSubmitSwap = useCallback(async () => { if (isLoading) return; if (isUndefined(currentAccount) || isUndefined(swapSubmissionData)) { @@ -115,54 +211,20 @@ function BitflowSwapContainer() { setIsLoading(); + if (isCrossChainSwap) { + return await onDepositSbtc(swapSubmissionData); + } + try { - const routeQuote = await fetchRouteQuote( - swapSubmissionData.swapAssetBase, - swapSubmissionData.swapAssetQuote, - swapSubmissionData.swapAmountBase - ); - if (!routeQuote) return; - - const swapExecutionData = { - route: routeQuote.route, - amount: Number(swapSubmissionData.swapAmountBase), - tokenXDecimals: routeQuote.tokenXDecimals, - tokenYDecimals: routeQuote.tokenYDecimals, - }; - - const swapParams = await bitflow.getSwapParams( - swapExecutionData, - currentAccount.address, - swapSubmissionData.slippage - ); - - const tempFormValues = { - fee: swapSubmissionData.fee, - feeCurrency: swapSubmissionData.feeCurrency, - feeType: swapSubmissionData.feeType, - nonce: swapSubmissionData.nonce, - }; - - const payload: ContractCallPayload = { - anchorMode: AnchorMode.Any, - contractAddress: swapParams.contractAddress, - contractName: swapParams.contractName, - functionName: swapParams.functionName, - functionArgs: swapParams.functionArgs.map(x => bytesToHex(serializeCV(x))), - postConditionMode: PostConditionMode.Deny, - postConditions: swapParams.postConditions.map(pc => bytesToHex(serializePostCondition(pc))), - publicKey: currentAccount?.stxPublicKey, - sponsored: swapSubmissionData.sponsored, - txType: TransactionTypes.ContractCall, - }; - - const unsignedTx = await generateUnsignedTx(payload, tempFormValues); - if (!unsignedTx) - return logger.error('Attempted to generate unsigned tx, but tx is undefined'); - - const signedTx = await signTx(unsignedTx); + if (sponsorshipEligibility?.isEligible) + return await submitSponsoredTx(sponsorshipEligibility.unsignedSponsoredTx!); + + if (!unsignedTx?.transaction) return logger.error('No unsigned tx to sign'); + + const signedTx = await signTx(unsignedTx.transaction); if (!signedTx) return logger.error('Attempted to generate raw tx, but signed tx is undefined'); + return await broadcastStacksSwap(signedTx); } catch (e) { navigate(RouteUrls.SwapError, { @@ -174,19 +236,35 @@ function BitflowSwapContainer() { } finally { setIsIdle(); } - } + }, [ + broadcastStacksSwap, + currentAccount, + isCrossChainSwap, + isLoading, + navigate, + onDepositSbtc, + setIsIdle, + setIsLoading, + signTx, + sponsorshipEligibility, + submitSponsoredTx, + swapSubmissionData, + unsignedTx, + ]); const swapContextValue: SwapContext = { fetchQuoteAmount, + isCrossChainSwap, isFetchingExchangeRate, isSendingMax, isPreparingSwapReview, + onSetIsCrossChainSwap, onSetIsFetchingExchangeRate, onSetIsSendingMax: value => setIsSendingMax(value), onSubmitSwapForReview, onSubmitSwap, - swappableAssetsBase: migratePositiveAssetBalancesToTop(swapAssets), - swappableAssetsQuote: swapAssets, + swappableAssetsBase, + swappableAssetsQuote, swapSubmissionData, }; diff --git a/src/app/pages/swap/bitflow-swap.utils.ts b/src/app/pages/swap/bitflow-swap.utils.ts index a8643b224f9..22b7cbf2e3d 100644 --- a/src/app/pages/swap/bitflow-swap.utils.ts +++ b/src/app/pages/swap/bitflow-swap.utils.ts @@ -1,12 +1,70 @@ import BigNumber from 'bignumber.js'; +import type { RouteQuote } from 'bitflow-sdk'; -import { capitalize } from '@leather.io/utils'; +import { BtcFeeType, FeeTypes } from '@leather.io/models'; +import { type SwapAsset, defaultSwapFee } from '@leather.io/query'; +import { capitalize, isDefined } from '@leather.io/utils'; -export function estimateLiquidityFee(dexPath: string[]) { +import type { SwapFormValues } from '@shared/models/form.model'; + +import type { SwapSubmissionData } from './swap.context'; + +function estimateLiquidityFee(dexPath: string[]) { return new BigNumber(dexPath.length).times(0.3).toNumber(); } -export function formatDexPathItem(dex: string) { +function formatDexPathItem(dex: string) { const name = dex.split('_')[0]; return name === 'ALEX' ? name : capitalize(name.toLowerCase()); } + +interface getStacksSwapSubmissionDataArgs { + bitflowSwapAssets: SwapAsset[]; + routeQuote: RouteQuote; + slippage: number; + values: SwapFormValues; +} +export function getStacksSwapSubmissionData({ + bitflowSwapAssets, + routeQuote, + slippage, + values, +}: getStacksSwapSubmissionDataArgs): SwapSubmissionData { + return { + fee: defaultSwapFee.amount.toString(), + feeCurrency: 'STX', + feeType: FeeTypes[FeeTypes.Middle], + liquidityFee: estimateLiquidityFee(routeQuote.route.dex_path), + nonce: values.nonce, + protocol: 'Bitflow', + dexPath: routeQuote.route.dex_path.map(formatDexPathItem), + router: routeQuote.route.token_path + .map(x => bitflowSwapAssets.find(asset => asset.tokenId === x)) + .filter(isDefined), + slippage, + swapAmountBase: values.swapAmountBase, + swapAmountQuote: values.swapAmountQuote, + swapAssetBase: values.swapAssetBase, + swapAssetQuote: values.swapAssetQuote, + timestamp: new Date().toISOString(), + }; +} + +export function getCrossChainSwapSubmissionData(values: SwapFormValues): SwapSubmissionData { + return { + fee: 0, + feeCurrency: 'BTC', + feeType: BtcFeeType.Standard, + liquidityFee: 0, + maxSignerFee: 0, + protocol: 'Bitcoin L2 Labs', + dexPath: [], + router: [values.swapAssetBase, values.swapAssetQuote].filter(isDefined), + slippage: 0, + swapAmountBase: values.swapAmountBase, + swapAmountQuote: values.swapAmountQuote, + swapAssetBase: values.swapAssetBase, + swapAssetQuote: values.swapAssetQuote, + timestamp: new Date().toISOString(), + }; +} diff --git a/src/app/pages/swap/components/swap-asset-dialog/components/swap-asset-item.tsx b/src/app/pages/swap/components/swap-asset-dialog/components/swap-asset-item.tsx index 6bb9d1d3cff..33b59491ee8 100644 --- a/src/app/pages/swap/components/swap-asset-dialog/components/swap-asset-item.tsx +++ b/src/app/pages/swap/components/swap-asset-dialog/components/swap-asset-item.tsx @@ -1,4 +1,5 @@ import { SwapSelectors } from '@tests/selectors/swap.selectors'; +import { sanitize } from 'dompurify'; import { type SwapAsset, isFtAsset, useGetFungibleTokenMetadataQuery } from '@leather.io/query'; import { @@ -8,7 +9,7 @@ import { defaultFallbackDelay, getAvatarFallback, } from '@leather.io/ui'; -import { formatMoneyWithoutSymbol } from '@leather.io/utils'; +import { formatMoneyWithoutSymbol, isString } from '@leather.io/utils'; import { convertSwapAssetBalanceToFiat } from '@app/pages/swap/swap.utils'; @@ -28,10 +29,14 @@ export function SwapAssetItem({ asset, onClick }: SwapAssetItemProps) { - - {fallback} - + isString(asset.icon) ? ( + + + {fallback} + + ) : ( + asset.icon + ) } titleLeft={displayName} captionLeft={asset.name} diff --git a/src/app/pages/swap/components/swap-asset-dialog/components/swap-asset-list.tsx b/src/app/pages/swap/components/swap-asset-dialog/components/swap-asset-list.tsx index 6e613c7d929..40fcd4eda29 100644 --- a/src/app/pages/swap/components/swap-asset-dialog/components/swap-asset-list.tsx +++ b/src/app/pages/swap/components/swap-asset-dialog/components/swap-asset-list.tsx @@ -1,79 +1,22 @@ -import { useNavigate, useParams } from 'react-router-dom'; - import { SwapSelectors } from '@tests/selectors/swap.selectors'; -import BigNumber from 'bignumber.js'; -import { useFormikContext } from 'formik'; import { Stack } from 'leather-styles/jsx'; import type { SwapAsset } from '@leather.io/query'; -import { - convertAmountToFractionalUnit, - createMoney, - formatMoneyWithoutSymbol, - isUndefined, -} from '@leather.io/utils'; - -import { RouteUrls } from '@shared/route-urls'; -import { useSwapContext } from '@app/pages/swap/swap.context'; - -import { SwapFormValues } from '../../../hooks/use-swap-form'; import { SwapAssetItem } from './swap-asset-item'; +import { useSwapAssetList } from './use-swap-asset-list'; -interface SwapAssetList { +export interface SwapAssetListProps { assets: SwapAsset[]; type: string; } -export function SwapAssetList({ assets, type }: SwapAssetList) { - const { fetchQuoteAmount } = useSwapContext(); - const { setFieldError, setFieldValue, values } = useFormikContext(); - const navigate = useNavigate(); - const { base, quote } = useParams(); - const isBaseList = type === 'base'; - const isQuoteList = type === 'quote'; - - const selectableAssets = assets.filter( - asset => - (isBaseList && asset.name !== values.swapAssetQuote?.name) || - (isQuoteList && asset.name !== values.swapAssetBase?.name) - ); - - async function onSelectAsset(asset: SwapAsset) { - let baseAsset: SwapAsset | undefined; - let quoteAsset: SwapAsset | undefined; - if (isBaseList) { - baseAsset = asset; - quoteAsset = values.swapAssetQuote; - await setFieldValue('swapAssetBase', asset); - navigate(RouteUrls.Swap.replace(':base', baseAsset.name).replace(':quote', quote ?? '')); - } else if (isQuoteList) { - baseAsset = values.swapAssetBase; - quoteAsset = asset; - await setFieldValue('swapAssetQuote', asset); - setFieldError('swapAssetQuote', undefined); - navigate(RouteUrls.Swap.replace(':base', base ?? '').replace(':quote', quoteAsset.name)); - } - - if (baseAsset && quoteAsset && values.swapAmountBase) { - const quoteAmount = await fetchQuoteAmount(baseAsset, quoteAsset, values.swapAmountBase); - if (isUndefined(quoteAmount)) { - await setFieldValue('swapAmountQuote', ''); - return; - } - const quoteAmountAsMoney = createMoney( - convertAmountToFractionalUnit(new BigNumber(quoteAmount), quoteAsset?.balance.decimals), - quoteAsset?.balance.symbol ?? '', - quoteAsset?.balance.decimals - ); - await setFieldValue('swapAmountQuote', formatMoneyWithoutSymbol(quoteAmountAsMoney)); - setFieldError('swapAmountQuote', undefined); - } - } +export function SwapAssetList({ assets, type }: SwapAssetListProps) { + const { selectableAssets, onSelectAsset } = useSwapAssetList({ assets, type }); return ( {selectableAssets.map(asset => ( - onSelectAsset(asset)} /> + onSelectAsset(asset)} /> ))} ); diff --git a/src/app/pages/swap/components/swap-asset-dialog/components/use-swap-asset-list.tsx b/src/app/pages/swap/components/swap-asset-dialog/components/use-swap-asset-list.tsx new file mode 100644 index 00000000000..0b266ca1206 --- /dev/null +++ b/src/app/pages/swap/components/swap-asset-dialog/components/use-swap-asset-list.tsx @@ -0,0 +1,113 @@ +import { useCallback } from 'react'; +import { useNavigate, useParams } from 'react-router-dom'; + +import BigNumber from 'bignumber.js'; +import { useFormikContext } from 'formik'; + +import type { SwapAsset } from '@leather.io/query'; +import { + convertAmountToFractionalUnit, + createMoney, + formatMoneyWithoutSymbol, + isUndefined, +} from '@leather.io/utils'; + +import type { SwapFormValues } from '@shared/models/form.model'; +import { RouteUrls } from '@shared/route-urls'; + +import { useSwapContext } from '@app/pages/swap/swap.context'; + +import type { SwapAssetListProps } from './swap-asset-list'; + +export function useSwapAssetList({ assets, type }: SwapAssetListProps) { + const { setFieldError, setFieldValue, values } = useFormikContext(); + const { fetchQuoteAmount, onSetIsCrossChainSwap } = useSwapContext(); + const navigate = useNavigate(); + const { base, quote } = useParams(); + + const isBaseList = type === 'base'; + const isQuoteList = type === 'quote'; + + // Filter out selected asset from selectable assets + const selectableAssets = assets + .filter( + asset => + (isBaseList && asset.name !== values.swapAssetQuote?.name) || + (isQuoteList && asset.name !== values.swapAssetBase?.name) + ) + // Only show sBTC as quote option if BTC is selected as base + .filter( + asset => + isBaseList || + (isQuoteList && values.swapAssetBase?.name !== 'BTC') || + (isQuoteList && values.swapAssetBase?.name === 'BTC' && asset.name === 'sBTC') + ); + + const onSelectBaseAsset = useCallback( + (baseAsset: SwapAsset) => { + void setFieldValue('swapAssetBase', baseAsset); + // Handle bridge assets + if (baseAsset.name === 'BTC') { + onSetIsCrossChainSwap(true); + return navigate(RouteUrls.Swap.replace(':base', baseAsset.name).replace(':quote', 'sBTC')); + } + // Handle swap assets + onSetIsCrossChainSwap(false); + navigate(RouteUrls.Swap.replace(':base', baseAsset.name).replace(':quote', quote ?? '')); + }, + [navigate, onSetIsCrossChainSwap, quote, setFieldValue] + ); + + const onSelectQuoteAsset = useCallback( + (quoteAsset: SwapAsset) => { + void setFieldValue('swapAssetQuote', quoteAsset); + setFieldError('swapAssetQuote', undefined); + navigate(RouteUrls.Swap.replace(':base', base ?? '').replace(':quote', quoteAsset.name)); + }, + [base, navigate, setFieldError, setFieldValue] + ); + + const onFetchQuoteAmount = useCallback( + async (baseAsset: SwapAsset, quoteAsset: SwapAsset) => { + const quoteAmount = await fetchQuoteAmount(baseAsset, quoteAsset, values.swapAmountBase); + // Handle race condition; make sure quote amount is 1:1 + if (baseAsset.name === 'BTC') { + void setFieldValue('swapAmountQuote', values.swapAmountBase); + return; + } + if (isUndefined(quoteAmount)) { + void setFieldValue('swapAmountQuote', ''); + return; + } + const quoteAmountAsMoney = createMoney( + convertAmountToFractionalUnit(new BigNumber(quoteAmount), quoteAsset?.balance.decimals), + quoteAsset?.balance.symbol ?? '', + quoteAsset?.balance.decimals + ); + void setFieldValue('swapAmountQuote', formatMoneyWithoutSymbol(quoteAmountAsMoney)); + setFieldError('swapAmountQuote', undefined); + }, + [fetchQuoteAmount, setFieldError, setFieldValue, values.swapAmountBase] + ); + + return { + selectableAssets, + async onSelectAsset(asset: SwapAsset) { + let baseAsset: SwapAsset | undefined; + let quoteAsset: SwapAsset | undefined; + if (isBaseList) { + baseAsset = asset; + quoteAsset = values.swapAssetQuote; + onSelectBaseAsset(baseAsset); + } + if (isQuoteList) { + baseAsset = values.swapAssetBase; + quoteAsset = asset; + onSelectQuoteAsset(quoteAsset); + } + if (baseAsset && quoteAsset && values.swapAmountBase) { + await onFetchQuoteAmount(baseAsset, quoteAsset); + } + }, + }; +} diff --git a/src/app/pages/swap/components/swap-asset-select/components/select-asset-trigger-button.tsx b/src/app/pages/swap/components/swap-asset-select/components/select-asset-trigger-button.tsx index 43702abb1da..2be96d1decd 100644 --- a/src/app/pages/swap/components/swap-asset-select/components/select-asset-trigger-button.tsx +++ b/src/app/pages/swap/components/swap-asset-select/components/select-asset-trigger-button.tsx @@ -1,3 +1,5 @@ +import type React from 'react'; + import { SwapSelectors } from '@tests/selectors/swap.selectors'; import { useField } from 'formik'; import { HStack, styled } from 'leather-styles/jsx'; @@ -9,9 +11,10 @@ import { defaultFallbackDelay, getAvatarFallback, } from '@leather.io/ui'; +import { isString } from '@leather.io/utils'; interface SelectAssetTriggerButtonProps { - icon?: string; + icon?: React.ReactNode; name: string; onSelectAsset(): void; symbol: string; @@ -34,11 +37,13 @@ export function SelectAssetTriggerButton({ {...field} > - {icon && ( + {icon && isString(icon) ? ( {fallback} + ) : ( + icon )} {symbol} diff --git a/src/app/pages/swap/components/swap-asset-select/components/swap-amount-field.tsx b/src/app/pages/swap/components/swap-asset-select/components/swap-amount-field.tsx index 7320e1a8f23..d87d2907cca 100644 --- a/src/app/pages/swap/components/swap-asset-select/components/swap-amount-field.tsx +++ b/src/app/pages/swap/components/swap-asset-select/components/swap-amount-field.tsx @@ -1,4 +1,4 @@ -import { ChangeEvent } from 'react'; +import { ChangeEvent, useEffect } from 'react'; import { SwapSelectors } from '@tests/selectors/swap.selectors'; import BigNumber from 'bignumber.js'; @@ -13,9 +13,10 @@ import { isUndefined, } from '@leather.io/utils'; +import type { SwapFormValues } from '@shared/models/form.model'; + import { useShowFieldError } from '@app/common/form-utils'; -import { SwapFormValues } from '../../../hooks/use-swap-form'; import { useSwapContext } from '../../../swap.context'; function getPlaceholderValue(name: string, values: SwapFormValues) { @@ -35,6 +36,13 @@ export function SwapAmountField({ amountAsFiat, isDisabled, name }: SwapAmountFi const [field] = useField(name); const showError = useShowFieldError(name) && name === 'swapAmountBase' && values.swapAssetQuote; + useEffect(() => { + // Clear quote amount if quote asset is reset + if (isUndefined(values.swapAssetQuote)) { + void setFieldValue('swapAmountQuote', ''); + } + }, [setFieldValue, values]); + async function onBlur(event: ChangeEvent) { const { swapAssetBase, swapAssetQuote } = values; if (isUndefined(swapAssetBase) || isUndefined(swapAssetQuote)) return; diff --git a/src/app/pages/swap/components/swap-asset-select/components/swap-toggle-button.tsx b/src/app/pages/swap/components/swap-asset-select/components/swap-toggle-button.tsx index bf07bf9e7c9..0a219465afe 100644 --- a/src/app/pages/swap/components/swap-asset-select/components/swap-toggle-button.tsx +++ b/src/app/pages/swap/components/swap-asset-select/components/swap-toggle-button.tsx @@ -6,9 +6,9 @@ import { styled } from 'leather-styles/jsx'; import { ArrowsRepeatLeftRightIcon } from '@leather.io/ui'; import { isDefined, isUndefined } from '@leather.io/utils'; +import type { SwapFormValues } from '@shared/models/form.model'; import { RouteUrls } from '@shared/route-urls'; -import { SwapFormValues } from '../../../hooks/use-swap-form'; import { useSwapContext } from '../../../swap.context'; export function SwapToggleButton() { diff --git a/src/app/pages/swap/components/swap-asset-select/swap-asset-select-base.tsx b/src/app/pages/swap/components/swap-asset-select/swap-asset-select-base.tsx index a8b899623d3..a48abfb16de 100644 --- a/src/app/pages/swap/components/swap-asset-select/swap-asset-select-base.tsx +++ b/src/app/pages/swap/components/swap-asset-select/swap-asset-select-base.tsx @@ -11,11 +11,11 @@ import { isUndefined, } from '@leather.io/utils'; +import type { SwapFormValues } from '@shared/models/form.model'; import { RouteUrls } from '@shared/route-urls'; import { useShowFieldError } from '@app/common/form-utils'; -import { SwapFormValues } from '../../hooks/use-swap-form'; import { useSwapNavigate } from '../../hooks/use-swap-navigate'; import { useSwapContext } from '../../swap.context'; import { convertInputAmountValueToFiat } from '../../swap.utils'; diff --git a/src/app/pages/swap/components/swap-asset-select/swap-asset-select-quote.tsx b/src/app/pages/swap/components/swap-asset-select/swap-asset-select-quote.tsx index 0793cae9287..d9cda93070d 100644 --- a/src/app/pages/swap/components/swap-asset-select/swap-asset-select-quote.tsx +++ b/src/app/pages/swap/components/swap-asset-select/swap-asset-select-quote.tsx @@ -18,7 +18,7 @@ import { SwapAmountField } from './components/swap-amount-field'; import { SwapAssetSelectLayout } from './components/swap-asset-select.layout'; export function SwapAssetSelectQuote() { - const { isFetchingExchangeRate } = useSwapContext(); + const { isCrossChainSwap, isFetchingExchangeRate } = useSwapContext(); const [amountField] = useField('swapAmountQuote'); const [assetField] = useField('swapAssetQuote'); const navigate = useSwapNavigate(); @@ -37,7 +37,7 @@ export function SwapAssetSelectQuote() { icon={assetField.value?.icon} name="swapAmountQuote" onSelectAsset={() => navigate(RouteUrls.SwapAssetSelectQuote)} - showToggle + showToggle={!isCrossChainSwap} swapAmountInput={ isFetchingExchangeRate ? ( diff --git a/src/app/pages/swap/components/swap-assets-pair/swap-asset-item.layout.tsx b/src/app/pages/swap/components/swap-assets-pair/swap-asset-item.layout.tsx index e8ab720c26f..2983d0f745b 100644 --- a/src/app/pages/swap/components/swap-assets-pair/swap-asset-item.layout.tsx +++ b/src/app/pages/swap/components/swap-assets-pair/swap-asset-item.layout.tsx @@ -1,18 +1,35 @@ +import type React from 'react'; + import { SwapSelectors } from '@tests/selectors/swap.selectors'; +import { sanitize } from 'dompurify'; import { HStack, styled } from 'leather-styles/jsx'; +import type { Money } from '@leather.io/models'; import { Flag } from '@leather.io/ui'; +import { formatMoneyWithoutSymbol, isString } from '@leather.io/utils'; interface SwapAssetItemLayoutProps { caption: string; - icon: string; + icon: React.ReactNode; symbol: string; - value: string; + value: Money; } export function SwapAssetItemLayout({ caption, icon, symbol, value }: SwapAssetItemLayoutProps) { return ( } + img={ + isString(icon) ? ( + + ) : ( + icon + ) + } spacing="space.03" width="100%" > @@ -24,7 +41,7 @@ export function SwapAssetItemLayout({ caption, icon, symbol, value }: SwapAssetI {symbol} - {value} + {formatMoneyWithoutSymbol(value)} diff --git a/src/app/pages/swap/components/swap-assets-pair/swap-assets-pair.tsx b/src/app/pages/swap/components/swap-assets-pair/swap-assets-pair.tsx index d7a832dcdbf..3c6d931ad77 100644 --- a/src/app/pages/swap/components/swap-assets-pair/swap-assets-pair.tsx +++ b/src/app/pages/swap/components/swap-assets-pair/swap-assets-pair.tsx @@ -2,11 +2,11 @@ import { useNavigate } from 'react-router-dom'; import { useFormikContext } from 'formik'; -import { isUndefined } from '@leather.io/utils'; +import { createMoneyFromDecimal, isUndefined } from '@leather.io/utils'; +import type { SwapFormValues } from '@shared/models/form.model'; import { RouteUrls } from '@shared/route-urls'; -import { SwapFormValues } from '../../hooks/use-swap-form'; import { SwapAssetItemLayout } from './swap-asset-item.layout'; import { SwapAssetsPairLayout } from './swap-assets-pair.layout'; @@ -21,6 +21,17 @@ export function SwapAssetsPair() { return null; } + const swapAmountBaseAsMoney = createMoneyFromDecimal( + Number(swapAmountBase), + swapAssetBase.name, + swapAssetBase.balance.decimals + ); + const swapAmountQuoteAsMoney = createMoneyFromDecimal( + Number(swapAmountQuote), + swapAssetBase.name, + swapAssetBase.balance.decimals + ); + return ( } swapAssetQuote={ @@ -36,7 +47,7 @@ export function SwapAssetsPair() { caption="You will receive" icon={swapAssetQuote.icon} symbol={swapAssetQuote.name} - value={swapAmountQuote} + value={swapAmountQuoteAsMoney} /> } /> diff --git a/src/app/pages/swap/components/swap-details/swap-details.tsx b/src/app/pages/swap/components/swap-details/swap-details.tsx index 591d39b5e83..c7ae3980cb5 100644 --- a/src/app/pages/swap/components/swap-details/swap-details.tsx +++ b/src/app/pages/swap/components/swap-details/swap-details.tsx @@ -4,11 +4,13 @@ import { HStack, styled } from 'leather-styles/jsx'; import { ChevronRightIcon } from '@leather.io/ui'; import { + convertAmountToBaseUnit, + createMoney, createMoneyFromDecimal, formatMoneyPadded, isDefined, isUndefined, - microStxToStx, + satToBtc, } from '@leather.io/utils'; import { SwapSubmissionData, useSwapContext } from '@app/pages/swap/swap.context'; @@ -42,9 +44,13 @@ export function SwapDetails() { ) return null; + const maxSignerFee = satToBtc(swapSubmissionData.maxSignerFee ?? 0); + const formattedMinToReceive = formatMoneyPadded( createMoneyFromDecimal( - new BigNumber(swapSubmissionData.swapAmountQuote).times(1 - swapSubmissionData.slippage), + new BigNumber(swapSubmissionData.swapAmountQuote) + .times(1 - swapSubmissionData.slippage) + .minus(maxSignerFee), swapSubmissionData.swapAssetQuote.balance.symbol, swapSubmissionData.swapAssetQuote.balance.decimals ) @@ -75,6 +81,7 @@ export function SwapDetails() { } /> + + {maxSignerFee ? ( + + ) : null} - + {Number(swapSubmissionData?.nonce) >= 0 ? ( + + ) : null} ); } diff --git a/src/app/pages/swap/components/swap-form.tsx b/src/app/pages/swap/components/swap-form.tsx index 150c862776b..4faefe3572e 100644 --- a/src/app/pages/swap/components/swap-form.tsx +++ b/src/app/pages/swap/components/swap-form.tsx @@ -5,16 +5,15 @@ import { HasChildren } from '@app/common/has-children'; import { NonceSetter } from '@app/components/nonce-setter'; import { useSwapForm } from '../hooks/use-swap-form'; -import { useSwapContext } from '../swap.context'; export function SwapForm({ children }: HasChildren) { const { initialValues, validationSchema } = useSwapForm(); - const { onSubmitSwapForReview } = useSwapContext(); return ( {}} validateOnChange={false} validateOnMount validationSchema={validationSchema} diff --git a/src/app/pages/swap/components/swap-review.tsx b/src/app/pages/swap/components/swap-review.tsx index 347045b207d..0f55a275206 100644 --- a/src/app/pages/swap/components/swap-review.tsx +++ b/src/app/pages/swap/components/swap-review.tsx @@ -2,7 +2,7 @@ import { Outlet } from 'react-router-dom'; import { SwapSelectors } from '@tests/selectors/swap.selectors'; -import { Button } from '@leather.io/ui'; +import { Button, Callout } from '@leather.io/ui'; import { LoadingKeys, useLoading } from '@app/common/hooks/use-loading'; import { Card } from '@app/components/layout'; @@ -12,7 +12,7 @@ import { SwapAssetsPair } from './swap-assets-pair/swap-assets-pair'; import { SwapDetails } from './swap-details/swap-details'; export function SwapReview() { - const { onSubmitSwap } = useSwapContext(); + const { isCrossChainSwap, onSubmitSwap } = useSwapContext(); const { isLoading } = useLoading(LoadingKeys.SUBMIT_SWAP_TRANSACTION); return ( @@ -32,6 +32,11 @@ export function SwapReview() { } > + {isCrossChainSwap && ( + + Note that bridging from sBTC back to BTC is currently unavailable. + + )} diff --git a/src/app/pages/swap/generate-swap-routes.tsx b/src/app/pages/swap/generate-swap-routes.tsx index 18798311d38..b8084376a95 100644 --- a/src/app/pages/swap/generate-swap-routes.tsx +++ b/src/app/pages/swap/generate-swap-routes.tsx @@ -2,6 +2,7 @@ import { Route } from 'react-router-dom'; import { RouteUrls } from '@shared/route-urls'; +import { ledgerBitcoinTxSigningRoutes } from '@app/features/ledger/flows/bitcoin-tx-signing/ledger-bitcoin-sign-tx-container'; import { ledgerStacksTxSigningRoutes } from '@app/features/ledger/flows/stacks-tx-signing/ledger-sign-stacks-tx-container'; import { AccountGate } from '@app/routes/account-gate'; @@ -20,6 +21,7 @@ export function generateSwapRoutes(container: React.ReactNode) { } /> }> + {ledgerBitcoinTxSigningRoutes} {ledgerStacksTxSigningRoutes} diff --git a/src/app/pages/swap/hooks/use-bitflow-swap.tsx b/src/app/pages/swap/hooks/use-bitflow-swap.tsx index 7a0a7bfeb04..1b5f01d3000 100644 --- a/src/app/pages/swap/hooks/use-bitflow-swap.tsx +++ b/src/app/pages/swap/hooks/use-bitflow-swap.tsx @@ -1,67 +1,108 @@ -import { useState } from 'react'; +import { useCallback, useMemo, useState } from 'react'; +import type { P2Ret } from '@scure/btc-signer/payment'; import type { RouteQuote } from 'bitflow-sdk'; import { type SwapAsset } from '@leather.io/query'; +import { isDefined, migratePositiveAssetBalancesToTop } from '@leather.io/utils'; import { logger } from '@shared/logger'; import { bitflow } from '@shared/utils/bitflow-sdk'; +import { useConfigSbtc } from '@app/query/common/remote-config/remote-config.query'; +import type { Signer } from '@app/store/accounts/blockchain/bitcoin/bitcoin-signer'; import { useCurrentStacksAccountAddress } from '@app/store/accounts/blockchain/stacks/stacks-account.hooks'; +import { useHasLedgerKeys } from '@app/store/ledger/ledger.selectors'; import { SwapSubmissionData } from '../swap.context'; import { useBitflowSwappableAssets } from './use-bitflow-swappable-assets'; +import { useBtcSwapAsset } from './use-btc-bridge-asset'; -export function useBitflowSwap() { +const bitflowSBtcTokenId = 'token-sbtc'; + +function getBitflowSwappableAssetsWithSbtcAtTop(assets: SwapAsset[]) { + const bitflowSbtcAsset = assets.find(asset => asset.tokenId === bitflowSBtcTokenId); + const bitflowAssetsWithSbtcRemoved = assets.filter(asset => asset.tokenId !== bitflowSBtcTokenId); + return [ + bitflowSbtcAsset, + ...migratePositiveAssetBalancesToTop(bitflowAssetsWithSbtcRemoved), + ].filter(isDefined); +} + +export function useBitflowSwap(btcSigner?: Signer) { + const [isCrossChainSwap, setIsCrossChainSwap] = useState(false); const [swapSubmissionData, setSwapSubmissionData] = useState(); const [slippage, _setSlippage] = useState(0.04); const [isFetchingExchangeRate, setIsFetchingExchangeRate] = useState(false); const address = useCurrentStacksAccountAddress(); - const { data: swapAssets = [] } = useBitflowSwappableAssets(address); + const { data: bitflowSwapAssets = [] } = useBitflowSwappableAssets(address); + const { isSbtcEnabled } = useConfigSbtc(); + const isLedger = useHasLedgerKeys(); + + const createBtcAsset = useBtcSwapAsset(btcSigner); + const btcAsset = createBtcAsset(); + + const swappableAssetsBase = useMemo(() => { + if (!isSbtcEnabled || !btcSigner || isLedger) + return migratePositiveAssetBalancesToTop(bitflowSwapAssets); + return [btcAsset, ...getBitflowSwappableAssetsWithSbtcAtTop(bitflowSwapAssets)]; + }, [bitflowSwapAssets, btcAsset, btcSigner, isLedger, isSbtcEnabled]); + + const swappableAssetsQuote = useMemo(() => { + if (!isSbtcEnabled) return bitflowSwapAssets; + return getBitflowSwappableAssetsWithSbtcAtTop(bitflowSwapAssets); + }, [bitflowSwapAssets, isSbtcEnabled]); - async function fetchRouteQuote( - base: SwapAsset, - quote: SwapAsset, - baseAmount: string - ): Promise { - if (!baseAmount || !base || !quote) return; - try { - const result = await bitflow.getQuoteForRoute( - base.currency, - quote.currency, - Number(baseAmount) - ); - if (!result.bestRoute) { - logger.error('No swap route found'); + const fetchRouteQuote = useCallback( + async ( + base: SwapAsset, + quote: SwapAsset, + baseAmount: string + ): Promise => { + if (!baseAmount || !base || !quote || isCrossChainSwap) return; + try { + const result = await bitflow.getQuoteForRoute( + base.tokenId, + quote.tokenId, + Number(baseAmount) + ); + if (!result.bestRoute) { + logger.error('No swap route found'); + return; + } + return result.bestRoute; + } catch (e) { + logger.error('Error fetching exchange rate from Bitflow', e); return; } - return result.bestRoute; - } catch (e) { - logger.error('Error fetching exchange rate from Bitflow', e); - return; - } - } + }, + [isCrossChainSwap] + ); - async function fetchQuoteAmount( - base: SwapAsset, - quote: SwapAsset, - baseAmount: string - ): Promise { - setIsFetchingExchangeRate(true); - const routeQuote = await fetchRouteQuote(base, quote, baseAmount); - setIsFetchingExchangeRate(false); - if (!routeQuote) return; - return String(routeQuote.quote); - } + const fetchQuoteAmount = useCallback( + async (base: SwapAsset, quote: SwapAsset, baseAmount: string): Promise => { + setIsFetchingExchangeRate(true); + const routeQuote = await fetchRouteQuote(base, quote, baseAmount); + setIsFetchingExchangeRate(false); + if (isCrossChainSwap) return baseAmount; // 1:1 swap + if (!routeQuote) return; + return String(routeQuote.quote); + }, + [fetchRouteQuote, isCrossChainSwap] + ); return { fetchRouteQuote, fetchQuoteAmount, + isCrossChainSwap, isFetchingExchangeRate, + onSetIsCrossChainSwap: (value: boolean) => setIsCrossChainSwap(value), onSetIsFetchingExchangeRate: (value: boolean) => setIsFetchingExchangeRate(value), onSetSwapSubmissionData: (value: SwapSubmissionData) => setSwapSubmissionData(value), slippage, - swapAssets, + bitflowSwapAssets, + swappableAssetsBase, + swappableAssetsQuote, swapSubmissionData, }; } diff --git a/src/app/pages/swap/hooks/use-bitflow-swappable-assets.tsx b/src/app/pages/swap/hooks/use-bitflow-swappable-assets.tsx index bbff32bded6..b88be77c571 100644 --- a/src/app/pages/swap/hooks/use-bitflow-swappable-assets.tsx +++ b/src/app/pages/swap/hooks/use-bitflow-swappable-assets.tsx @@ -1,14 +1,12 @@ import { useCallback } from 'react'; import { useQuery } from '@tanstack/react-query'; -import { Currency } from 'alex-sdk'; import BigNumber from 'bignumber.js'; import type { Token } from 'bitflow-sdk'; -import { createMarketData, createMarketPair } from '@leather.io/models'; +import { type Currency, createMarketData, createMarketPair } from '@leather.io/models'; import { type SwapAsset, - useAlexCurrencyPriceAsMarketData, useAlexSdkLatestPricesQuery, useStxAvailableUnlockedBalance, useTransferableSip10Tokens, @@ -20,25 +18,29 @@ import { isDefined, } from '@leather.io/utils'; +import { useSip10FiatMarketData } from '@app/common/hooks/use-calculate-sip10-fiat-value'; import { createGetBitflowAvailableTokensQueryOptions } from '@app/query/bitflow-sdk/bitflow-available-tokens.query'; import { sortSwapAssets } from '../swap.utils'; -const BITFLOW_STX_CURRENCY: Currency = 'token-stx' as Currency; -const USD_DECIMAL_PRECISION = 2; +const alexStxTokenId: Currency = 'token-wstx'; +const bitflowStxTokenId: Currency = 'token-stx'; +const usdDecimalPrecision = 2; function useCreateSwapAsset(address: string) { const { data: prices } = useAlexSdkLatestPricesQuery(); - const priceAsMarketData = useAlexCurrencyPriceAsMarketData(); + const { getTokenMarketData } = useSip10FiatMarketData(); const availableUnlockedBalance = useStxAvailableUnlockedBalance(address); const sip10Tokens = useTransferableSip10Tokens(address); return useCallback( (token?: Token): SwapAsset | undefined => { if (!prices || !token || !token.tokenContract) return; + const pricesKeyedByCurrency = prices as Record; + const stxPrice = pricesKeyedByCurrency[alexStxTokenId] ?? 0; const swapAsset = { - currency: token.tokenId as Currency, + tokenId: token.tokenId, fallback: token.symbol.slice(0, 2), icon: token.icon, name: token.symbol, @@ -46,11 +48,8 @@ function useCreateSwapAsset(address: string) { principal: token.tokenContract, }; - if (token.tokenId === BITFLOW_STX_CURRENCY) { - const price = convertAmountToFractionalUnit( - new BigNumber(prices[Currency.STX] ?? 0), - USD_DECIMAL_PRECISION - ); + if (token.tokenId === bitflowStxTokenId) { + const price = convertAmountToFractionalUnit(new BigNumber(stxPrice), usdDecimalPrecision); return { ...swapAsset, balance: availableUnlockedBalance, @@ -70,11 +69,11 @@ function useCreateSwapAsset(address: string) { ...swapAsset, balance: availableBalance ?? createMoney(0, token.symbol, token.tokenDecimals), marketData: availableBalance - ? priceAsMarketData(swapAsset.principal, availableBalance.symbol) - : priceAsMarketData(swapAsset.principal, token.symbol), + ? getTokenMarketData(swapAsset.principal, availableBalance.symbol) + : getTokenMarketData(swapAsset.principal, token.symbol), }; }, - [availableUnlockedBalance, priceAsMarketData, prices, sip10Tokens] + [availableUnlockedBalance, getTokenMarketData, prices, sip10Tokens] ); } diff --git a/src/app/pages/swap/hooks/use-btc-bridge-asset.tsx b/src/app/pages/swap/hooks/use-btc-bridge-asset.tsx new file mode 100644 index 00000000000..ea578dd5a5e --- /dev/null +++ b/src/app/pages/swap/hooks/use-btc-bridge-asset.tsx @@ -0,0 +1,28 @@ +import { useCallback } from 'react'; + +import BtcAvatarIconSrc from '@assets/avatars/btc-avatar-icon.png'; +import type { P2Ret } from '@scure/btc-signer/payment'; + +import { type SwapAsset, useCryptoCurrencyMarketDataMeanAverage } from '@leather.io/query'; + +import { useBtcCryptoAssetBalanceNativeSegwit } from '@app/query/bitcoin/balance/btc-balance-native-segwit.hooks'; +import type { Signer } from '@app/store/accounts/blockchain/bitcoin/bitcoin-signer'; + +export function useBtcSwapAsset(btcSigner?: Signer) { + const currentBitcoinAddress = btcSigner?.address ?? ''; + const { balance } = useBtcCryptoAssetBalanceNativeSegwit(currentBitcoinAddress); + const bitcoinMarketData = useCryptoCurrencyMarketDataMeanAverage('BTC'); + + return useCallback((): SwapAsset => { + return { + balance: balance.availableBalance, + tokenId: 'token-btc', + displayName: 'Bitcoin', + fallback: 'BT', + icon: BtcAvatarIconSrc, + name: 'BTC', + marketData: bitcoinMarketData, + principal: '', + }; + }, [balance.availableBalance, bitcoinMarketData]); +} diff --git a/src/app/pages/swap/hooks/use-sbtc-deposit-transaction.tsx b/src/app/pages/swap/hooks/use-sbtc-deposit-transaction.tsx new file mode 100644 index 00000000000..c85c274d51c --- /dev/null +++ b/src/app/pages/swap/hooks/use-sbtc-deposit-transaction.tsx @@ -0,0 +1,171 @@ +import { useMemo } from 'react'; +import { useNavigate } from 'react-router-dom'; + +import { bytesToHex } from '@noble/hashes/utils'; +import * as btc from '@scure/btc-signer'; +import type { P2Ret, P2TROut } from '@scure/btc-signer/payment'; +import { + MAINNET, + REGTEST, + SbtcApiClientMainnet, + SbtcApiClientTestnet, + TESTNET, + buildSbtcDepositTx, +} from 'sbtc'; + +import type { BitcoinNetworkModes } from '@leather.io/models'; +import { useAverageBitcoinFeeRates } from '@leather.io/query'; +import { btcToSat, createMoney } from '@leather.io/utils'; + +import { logger } from '@shared/logger'; +import { RouteUrls } from '@shared/route-urls'; + +import { LoadingKeys, useLoading } from '@app/common/hooks/use-loading'; +import { + determineUtxosForSpend, + determineUtxosForSpendAll, +} from '@app/common/transactions/bitcoin/coinselect/local-coin-selection'; +import { useToast } from '@app/features/toasts/use-toast'; +import { useCurrentNativeSegwitUtxos } from '@app/query/bitcoin/address/utxos-by-address.hooks'; +import { useBreakOnNonCompliantEntity } from '@app/query/common/compliance-checker/compliance-checker.query'; +import { useBitcoinScureLibNetworkConfig } from '@app/store/accounts/blockchain/bitcoin/bitcoin-keychain'; +import type { Signer } from '@app/store/accounts/blockchain/bitcoin/bitcoin-signer'; +import { useCurrentStacksAccount } from '@app/store/accounts/blockchain/stacks/stacks-account.hooks'; +import { useCurrentNetwork } from '@app/store/networks/networks.selectors'; + +import type { SwapSubmissionData } from '../swap.context'; + +// Also set as defaults in sbtc lib +const maxSignerFee = 80_000; +const reclaimLockTime = 144; + +interface SbtcDeposit { + address: string; + depositScript: string; + reclaimScript: string; + transaction: btc.Transaction; + trOut: P2TROut; +} + +function getSbtcNetworkConfig(network: BitcoinNetworkModes) { + const networkMap = { + mainnet: MAINNET, + testnet: TESTNET, + regtest: REGTEST, + // Signet supported not tested, but likely uses same values as testnet + signet: TESTNET, + }; + return networkMap[network]; +} + +const clientMainnet = new SbtcApiClientMainnet(); +const clientTestnet = new SbtcApiClientTestnet(); + +export function useSbtcDepositTransaction(btcSigner?: Signer) { + const toast = useToast(); + const { setIsIdle } = useLoading(LoadingKeys.SUBMIT_SWAP_TRANSACTION); + const stacksAccount = useCurrentStacksAccount(); + const { data: utxos } = useCurrentNativeSegwitUtxos(); + const { data: feeRates } = useAverageBitcoinFeeRates(); + const networkMode = useBitcoinScureLibNetworkConfig(); + const navigate = useNavigate(); + const network = useCurrentNetwork(); + // TODO: Use with Ledger integration + // const sign = useSignBitcoinTx(); + + const client = useMemo( + () => (network.chain.bitcoin.mode === 'mainnet' ? clientMainnet : clientTestnet), + [network] + ); + + // Check if the signer is compliant + useBreakOnNonCompliantEntity(); + + return { + async onReviewDepositSbtc(swapData: SwapSubmissionData, isSendingMax: boolean) { + if (!stacksAccount || !utxos || !btcSigner) return; + + try { + const deposit: SbtcDeposit = buildSbtcDepositTx({ + amountSats: btcToSat(swapData.swapAmountQuote).toNumber(), + network: getSbtcNetworkConfig(network.chain.bitcoin.mode), + stacksAddress: stacksAccount.address, + signersPublicKey: await client.fetchSignersPublicKey(), + maxSignerFee, + reclaimLockTime, + reclaimPublicKey: bytesToHex(btcSigner.publicKey).slice(2), + }); + + const determineUtxosArgs = { + feeRate: feeRates?.halfHourFee.toNumber() ?? 0, + recipients: [ + { + address: deposit.address, + amount: createMoney(Number(deposit.transaction.getOutput(0).amount), 'BTC'), + }, + ], + utxos, + }; + + const { inputs, outputs, fee } = isSendingMax + ? determineUtxosForSpendAll(determineUtxosArgs) + : determineUtxosForSpend(determineUtxosArgs); + + const p2wpkh = btc.p2wpkh(btcSigner.publicKey, networkMode); + + for (const input of inputs) { + deposit.transaction.addInput({ + txid: input.txid, + index: input.vout, + sequence: 0, + witnessUtxo: { + // script = 0014 + pubKeyHash + script: p2wpkh.script, + amount: BigInt(input.value), + }, + }); + } + + outputs.forEach(output => { + // Add change output + if (!output.address) { + deposit.transaction.addOutputAddress( + btcSigner.address, + BigInt(output.value), + networkMode + ); + return; + } + }); + + return { deposit, fee, maxSignerFee }; + } catch (error) { + logger.error('Error generating deposit transaction', error); + return null; + } + }, + async onDepositSbtc(swapSubmissionData: SwapSubmissionData) { + if (!stacksAccount || !btcSigner) return; + const sBtcDeposit = swapSubmissionData.txData?.deposit as SbtcDeposit; + + try { + btcSigner.sign(sBtcDeposit.transaction); + sBtcDeposit.transaction.finalize(); + logger.info('Deposit', { deposit: sBtcDeposit }); + + const txid = await client.broadcastTx(sBtcDeposit.transaction); + logger.info('Broadcasted tx', txid); + + await client.notifySbtc(sBtcDeposit); + toast.success('Transaction submitted!'); + setIsIdle(); + navigate(RouteUrls.Activity); + } catch (error) { + setIsIdle(); + logger.error(`Deposit error: ${error}`); + } finally { + setIsIdle(); + } + }, + }; +} diff --git a/src/app/pages/swap/hooks/use-sponsor-tx-fees.tsx b/src/app/pages/swap/hooks/use-sponsor-tx-fees.tsx new file mode 100644 index 00000000000..29b9ef4553c --- /dev/null +++ b/src/app/pages/swap/hooks/use-sponsor-tx-fees.tsx @@ -0,0 +1,65 @@ +import { useCallback } from 'react'; +import { useNavigate } from 'react-router-dom'; + +import type { StacksTransaction } from '@stacks/transactions'; + +import { FeeTypes } from '@leather.io/models'; +import { defaultFeesMaxValuesAsMoney } from '@leather.io/query'; + +import { logger } from '@shared/logger'; +import type { SwapFormValues } from '@shared/models/form.model'; +import { RouteUrls } from '@shared/route-urls'; + +import { LoadingKeys, useLoading } from '@app/common/hooks/use-loading'; +import { useToast } from '@app/features/toasts/use-toast'; +import { useConfigSbtc } from '@app/query/common/remote-config/remote-config.query'; +import { + type TransactionBase, + submitSponsoredSbtcTransaction, + verifySponsoredSbtcTransaction, +} from '@app/query/sbtc/sponsored-transactions.query'; +import { useSignStacksTransaction } from '@app/store/transactions/transaction.hooks'; + +export function useSponsorTransactionFees() { + const { sponsorshipApiUrl } = useConfigSbtc(); + const { setIsIdle } = useLoading(LoadingKeys.SUBMIT_SWAP_TRANSACTION); + const signTx = useSignStacksTransaction(); + const navigate = useNavigate(); + const toast = useToast(); + + const checkEligibilityForSponsor = async (values: SwapFormValues, baseTx: TransactionBase) => { + return await verifySponsoredSbtcTransaction({ + apiUrl: sponsorshipApiUrl, + baseTx, + nonce: Number(values.nonce), + fee: defaultFeesMaxValuesAsMoney[FeeTypes.Middle].amount.toNumber(), + }); + }; + + const submitSponsoredTx = useCallback( + async (unsignedSponsoredTx: StacksTransaction) => { + try { + const signedSponsoredTx = await signTx(unsignedSponsoredTx); + if (!signedSponsoredTx) return logger.error('Unable to sign sponsored transaction!'); + + const result = await submitSponsoredSbtcTransaction(sponsorshipApiUrl, signedSponsoredTx); + if (!result.txid) { + navigate(RouteUrls.SwapError, { state: { message: result.error } }); + return; + } + + toast.success('Transaction submitted!'); + setIsIdle(); + navigate(RouteUrls.Activity); + } catch (error) { + return logger.error('Failed to submit sponsor transaction', error); + } + }, + [navigate, setIsIdle, signTx, toast, sponsorshipApiUrl] + ); + + return { + checkEligibilityForSponsor, + submitSponsoredTx, + }; +} diff --git a/src/app/pages/swap/hooks/use-swap-assets-from-route.ts b/src/app/pages/swap/hooks/use-swap-assets-from-route.ts new file mode 100644 index 00000000000..dfb3c4dedc2 --- /dev/null +++ b/src/app/pages/swap/hooks/use-swap-assets-from-route.ts @@ -0,0 +1,46 @@ +import { useEffect } from 'react'; +import { useNavigate, useParams } from 'react-router-dom'; + +import { useFormikContext } from 'formik'; + +import type { SwapFormValues } from '@shared/models/form.model'; +import { RouteUrls } from '@shared/route-urls'; + +import { useSwapContext } from '../swap.context'; + +export function useSwapAssetsFromRoute() { + const { swappableAssetsBase, swappableAssetsQuote } = useSwapContext(); + const { setFieldValue, values, validateForm } = useFormikContext(); + const { base, quote } = useParams(); + const navigate = useNavigate(); + + useEffect(() => { + // Handle if same asset selected; reset assets + // Should not happen bc of list filtering + if (base === quote) { + void setFieldValue('swapAssetQuote', undefined); + void setFieldValue('swapAmountQuote', ''); + return navigate(RouteUrls.Swap.replace(':base', 'STX').replace(':quote', '')); + } + if (base) + void setFieldValue( + 'swapAssetBase', + swappableAssetsBase.find(asset => asset.name === base) + ); + if (quote) + void setFieldValue( + 'swapAssetQuote', + swappableAssetsQuote.find(asset => asset.name === quote) + ); + void validateForm(); + }, [ + base, + navigate, + quote, + setFieldValue, + swappableAssetsBase, + swappableAssetsQuote, + validateForm, + values.swapAssetBase, + ]); +} diff --git a/src/app/pages/swap/hooks/use-swap-form.tsx b/src/app/pages/swap/hooks/use-swap-form.tsx index 597834daa93..b9856277120 100644 --- a/src/app/pages/swap/hooks/use-swap-form.tsx +++ b/src/app/pages/swap/hooks/use-swap-form.tsx @@ -1,34 +1,56 @@ +import { useMemo } from 'react'; + +import { cvToValue, hexToCV } from '@stacks/transactions'; import BigNumber from 'bignumber.js'; import * as yup from 'yup'; -import { FeeTypes } from '@leather.io/models'; -import { type SwapAsset, useNextNonce } from '@leather.io/query'; -import { convertAmountToFractionalUnit, createMoney } from '@leather.io/utils'; +import { BTC_DECIMALS } from '@leather.io/constants'; +import { type SwapAsset } from '@leather.io/query'; +import { + convertAmountToBaseUnit, + convertAmountToFractionalUnit, + createMoney, +} from '@leather.io/utils'; import { FormErrorMessages } from '@shared/error-messages'; -import { StacksTransactionFormValues } from '@shared/models/form.model'; +import { type SwapFormValues } from '@shared/models/form.model'; -import { useCurrentStacksAccountAddress } from '@app/store/accounts/blockchain/stacks/stacks-account.hooks'; +import { + defaultSbtcLimits, + useGetCurrentSbtcSupply, + useGetSbtcLimits, +} from '@app/query/sbtc/sbtc-limits.query'; import { useSwapContext } from '../swap.context'; -export interface SwapFormValues extends StacksTransactionFormValues { - swapAmountBase: string; - swapAmountQuote: string; - swapAssetBase?: SwapAsset; - swapAssetQuote?: SwapAsset; -} - export function useSwapForm() { - const { isFetchingExchangeRate } = useSwapContext(); - const stxAddress = useCurrentStacksAccountAddress(); - const { data: nextNonce } = useNextNonce(stxAddress); + const { isCrossChainSwap, isFetchingExchangeRate } = useSwapContext(); + const { data: sBtcLimits } = useGetSbtcLimits(); + const { data: supply } = useGetCurrentSbtcSupply(); + + const remainingSbtcPegCapSupply = useMemo(() => { + const sBtcPegCap = sBtcLimits?.pegCap; + if (!sBtcPegCap) return; + const currentSupplyValue = supply?.result && cvToValue(hexToCV(supply?.result)); + return convertAmountToFractionalUnit( + createMoney(new BigNumber(Number(sBtcPegCap - currentSupplyValue)), 'BTC', BTC_DECIMALS) + ); + }, [sBtcLimits?.pegCap, supply?.result]); + + const sBtcDepositCapMin = createMoney( + new BigNumber(sBtcLimits?.perDepositMinimum ?? defaultSbtcLimits.perDepositMinimum), + 'BTC' + ); + const sBtcDepositCapMax = createMoney( + new BigNumber(sBtcLimits?.perDepositCap ?? defaultSbtcLimits.perDepositCap), + 'BTC' + ); const initialValues: SwapFormValues = { fee: '0', - feeCurrency: 'STX', - feeType: FeeTypes[FeeTypes.Middle], - nonce: nextNonce?.nonce, + feeCurrency: '', + feeType: '', + nonce: 0, swapAmountBase: '', swapAmountQuote: '', swapAssetBase: undefined, @@ -56,6 +78,55 @@ export function useSwapForm() { return true; }, }) + .test({ + message: `Min amount is ${convertAmountToBaseUnit(sBtcDepositCapMin).toString()} BTC`, + test(value) { + if (!isCrossChainSwap) return true; + const { swapAssetBase } = this.parent; + const valueInFractionalUnit = convertAmountToFractionalUnit( + createMoney( + new BigNumber(Number(value)), + swapAssetBase.balance.symbol, + swapAssetBase.balance.decimals + ) + ); + if (valueInFractionalUnit.isLessThan(sBtcDepositCapMin.amount)) return false; + return true; + }, + }) + .test({ + message: `Max amount is ${convertAmountToBaseUnit(sBtcDepositCapMax).toString()} BTC`, + test(value) { + if (!isCrossChainSwap) return true; + const { swapAssetBase } = this.parent; + const valueInFractionalUnit = convertAmountToFractionalUnit( + createMoney( + new BigNumber(Number(value)), + swapAssetBase.balance.symbol, + swapAssetBase.balance.decimals + ) + ); + if (valueInFractionalUnit.isGreaterThan(sBtcDepositCapMax.amount)) return false; + return true; + }, + }) + .test({ + message: 'Amount exceeds capped supply', + test(value) { + if (!isCrossChainSwap) return true; + const { swapAssetBase } = this.parent; + const valueInFractionalUnit = convertAmountToFractionalUnit( + createMoney( + new BigNumber(Number(value)), + swapAssetBase.balance.symbol, + swapAssetBase.balance.decimals + ) + ); + if (!remainingSbtcPegCapSupply) return true; + if (valueInFractionalUnit.isGreaterThan(remainingSbtcPegCapSupply)) return false; + return true; + }, + }) .required(FormErrorMessages.AmountRequired) .typeError(FormErrorMessages.MustBeNumber) .positive(FormErrorMessages.MustBePositive), diff --git a/src/app/pages/swap/hooks/use-swap-navigate.ts b/src/app/pages/swap/hooks/use-swap-navigate.ts index 72890163875..b981c90d67f 100644 --- a/src/app/pages/swap/hooks/use-swap-navigate.ts +++ b/src/app/pages/swap/hooks/use-swap-navigate.ts @@ -4,6 +4,7 @@ import { useNavigate, useParams } from 'react-router-dom'; export function useSwapNavigate() { const navigate = useNavigate(); const { base, quote } = useParams(); + return useCallback( (route: string) => { navigate(route.replace(':base', base ?? '').replace(':quote', quote ?? '')); diff --git a/src/app/pages/swap/swap.context.ts b/src/app/pages/swap/swap.context.ts index bdabc0d7493..2ff547145e6 100644 --- a/src/app/pages/swap/swap.context.ts +++ b/src/app/pages/swap/swap.context.ts @@ -2,23 +2,27 @@ import { createContext, useContext } from 'react'; import type { SwapAsset } from '@leather.io/query'; -import { SwapFormValues } from './hooks/use-swap-form'; +import type { SwapFormValues } from '@shared/models/form.model'; export interface SwapSubmissionData extends SwapFormValues { liquidityFee: number; + maxSignerFee?: number; protocol: string; router: SwapAsset[]; - dexPath: string[]; + dexPath?: string[]; slippage: number; - sponsored: boolean; + sponsored?: boolean; timestamp: string; + txData?: Record; } export interface SwapContext { fetchQuoteAmount(from: SwapAsset, to: SwapAsset, fromAmount: string): Promise; + isCrossChainSwap: boolean; isFetchingExchangeRate: boolean; isSendingMax: boolean; isPreparingSwapReview: boolean; + onSetIsCrossChainSwap(value: boolean): void; onSetIsFetchingExchangeRate(value: boolean): void; onSetIsSendingMax(value: boolean): void; onSubmitSwapForReview(values: SwapFormValues): Promise | void; diff --git a/src/app/pages/swap/swap.tsx b/src/app/pages/swap/swap.tsx index 7320294e540..5ec9c44116d 100644 --- a/src/app/pages/swap/swap.tsx +++ b/src/app/pages/swap/swap.tsx @@ -1,5 +1,4 @@ -import { useEffect } from 'react'; -import { Outlet, useParams } from 'react-router-dom'; +import { Outlet } from 'react-router-dom'; import { SwapSelectors } from '@tests/selectors/swap.selectors'; import { useFormikContext } from 'formik'; @@ -7,46 +6,21 @@ import { useFormikContext } from 'formik'; import { Button } from '@leather.io/ui'; import { isUndefined } from '@leather.io/utils'; +import type { SwapFormValues } from '@shared/models/form.model'; + import { Card } from '@app/components/layout'; import { LoadingSpinner } from '@app/components/loading-spinner'; import { SwapAssetSelectBase } from './components/swap-asset-select/swap-asset-select-base'; import { SwapAssetSelectQuote } from './components/swap-asset-select/swap-asset-select-quote'; -import { SwapFormValues } from './hooks/use-swap-form'; +import { useSwapAssetsFromRoute } from './hooks/use-swap-assets-from-route'; import { useSwapContext } from './swap.context'; export function Swap() { - const { - isFetchingExchangeRate, - isPreparingSwapReview, - swappableAssetsBase, - swappableAssetsQuote, - } = useSwapContext(); - const { dirty, isValid, setFieldValue, values, validateForm } = - useFormikContext(); - const { base, quote } = useParams(); + const { isFetchingExchangeRate, isPreparingSwapReview, onSubmitSwapForReview } = useSwapContext(); + const { dirty, isValid, values, submitForm } = useFormikContext(); - useEffect(() => { - if (base) - void setFieldValue( - 'swapAssetBase', - swappableAssetsBase.find(asset => asset.name === base) - ); - if (quote) - void setFieldValue( - 'swapAssetQuote', - swappableAssetsQuote.find(asset => asset.name === quote) - ); - void validateForm(); - }, [ - base, - quote, - setFieldValue, - swappableAssetsBase, - swappableAssetsQuote, - validateForm, - values.swapAssetBase, - ]); + useSwapAssetsFromRoute(); if (isUndefined(values.swapAssetBase)) return ; @@ -58,6 +32,10 @@ export function Swap() { data-testid={SwapSelectors.SwapReviewBtn} aria-busy={isPreparingSwapReview} disabled={!(dirty && isValid) || isFetchingExchangeRate || isPreparingSwapReview} + onClick={async () => { + await submitForm(); // Validate form + await onSubmitSwapForReview(values); + }} type="submit" fullWidth > diff --git a/src/app/pages/transaction-request/transaction-request.tsx b/src/app/pages/transaction-request/transaction-request.tsx index e34a72c4de0..f910f912e32 100644 --- a/src/app/pages/transaction-request/transaction-request.tsx +++ b/src/app/pages/transaction-request/transaction-request.tsx @@ -13,13 +13,17 @@ import { useStxCryptoAssetBalance, } from '@leather.io/query'; import { Link } from '@leather.io/ui'; +import { isString } from '@leather.io/utils'; +import { finalizeTxSignature } from '@shared/actions/finalize-tx-signature'; import { logger } from '@shared/logger'; import { StacksTransactionFormValues } from '@shared/models/form.model'; import { RouteUrls } from '@shared/route-urls'; import { analytics } from '@shared/utils/analytics'; +import { useDefaultRequestParams } from '@app/common/hooks/use-default-request-search-params'; import { useOnMount } from '@app/common/hooks/use-on-mount'; +import { stacksTransactionToHex } from '@app/common/transactions/stacks/transaction.utils'; import { stxFeeValidator } from '@app/common/validation/forms/fee-validators'; import { nonceValidator } from '@app/common/validation/nonce-validators'; import { NonceSetter } from '@app/components/nonce-setter'; @@ -37,28 +41,48 @@ import { PostConditions } from '@app/features/stacks-transaction-request/post-co import { StxTransferDetails } from '@app/features/stacks-transaction-request/stx-transfer-details/stx-transfer-details'; import { StacksTxSubmitAction } from '@app/features/stacks-transaction-request/submit-action'; import { TransactionError } from '@app/features/stacks-transaction-request/transaction-error/transaction-error'; +import { useConfigSbtc } from '@app/query/common/remote-config/remote-config.query'; +import { useCheckSbtcSponsorshipEligible } from '@app/query/sbtc/sponsored-transactions.hooks'; +import { submitSponsoredSbtcTransaction } from '@app/query/sbtc/sponsored-transactions.query'; import { useCurrentStacksAccountAddress } from '@app/store/accounts/blockchain/stacks/stacks-account.hooks'; -import { useTransactionRequestState } from '@app/store/transactions/requests.hooks'; +import { + useTransactionRequest, + useTransactionRequestState, +} from '@app/store/transactions/requests.hooks'; import { useGenerateUnsignedStacksTransaction, + useSignStacksTransaction, useUnsignedStacksTransactionBaseState, } from '@app/store/transactions/transaction.hooks'; function TransactionRequestBase() { + const sbtcConfig = useConfigSbtc(); + const { tabId } = useDefaultRequestParams(); + const requestToken = useTransactionRequest(); + const transactionRequest = useTransactionRequestState(); const unsignedTx = useUnsignedStacksTransactionBaseState(); const { data: stxFees } = useCalculateStacksTxFees(unsignedTx.transaction); const generateUnsignedTx = useGenerateUnsignedStacksTransaction(); const stxAddress = useCurrentStacksAccountAddress(); + const { filteredBalanceQuery } = useStxCryptoAssetBalance(stxAddress); const availableUnlockedBalance = filteredBalanceQuery.data?.availableUnlockedBalance; const { data: nextNonce, status: nonceQueryStatus } = useNextNonce(stxAddress); - const canSubmit = filteredBalanceQuery.status === 'success' && nonceQueryStatus === 'success'; + + const { isVerifying: isVerifyingSbtcSponsorship, result: sbtcSponsorshipEligibility } = + useCheckSbtcSponsorshipEligible(unsignedTx, nextNonce, stxFees); + + const canSubmit = + filteredBalanceQuery.status === 'success' && + nonceQueryStatus === 'success' && + !isVerifyingSbtcSponsorship; const navigate = useNavigate(); const { stacksBroadcastTransaction } = useStacksBroadcastTransaction({ token: 'STX' }); + const signStacksTransaction = useSignStacksTransaction(); useOnMount(() => void analytics.track('view_transaction_signing')); @@ -67,12 +91,42 @@ function TransactionRequestBase() { formikHelpers: FormikHelpers ) { formikHelpers.setSubmitting(true); - const unsignedTx = await generateUnsignedTx(values); - - if (!unsignedTx) - return logger.error('Failed to generate unsigned transaction in transaction-request'); - - await stacksBroadcastTransaction(unsignedTx); + if (sbtcSponsorshipEligibility?.isEligible) { + try { + const signedSponsoredTx = await signStacksTransaction( + sbtcSponsorshipEligibility.unsignedSponsoredTx! + ); + if (!signedSponsoredTx) throw new Error('Unable to sign sponsored transaction!'); + const result = await submitSponsoredSbtcTransaction( + sbtcConfig.sponsorshipApiUrl, + signedSponsoredTx + ); + if (!result.txid) { + navigate(RouteUrls.TransactionBroadcastError, { state: { message: result.error } }); + return; + } + if (requestToken && tabId) { + finalizeTxSignature({ + requestPayload: requestToken, + tabId: tabId, + data: { + txRaw: stacksTransactionToHex(signedSponsoredTx), + txId: result.txid, + }, + }); + } + } catch (e: any) { + const message = isString(e) ? e : e.message; + navigate(RouteUrls.TransactionBroadcastError, { state: { message } }); + } + } else { + const unsignedTx = await generateUnsignedTx(values); + + if (!unsignedTx) + return logger.error('Failed to generate unsigned transaction in transaction-request'); + + await stacksBroadcastTransaction(unsignedTx); + } void analytics.track('submit_fee_for_transaction', { calculation: stxFees?.calculation || 'unknown', @@ -128,7 +182,7 @@ function TransactionRequestBase() { {transactionRequest.txType === 'smart_contract' && } - + ; + emilyApiUrl: string; + sponsorshipApiUrl: { + mainnet: string; + testnet: string; + }; + swapsEnabled: boolean; + sponsorshipsEnabled: boolean; +} + +export function useConfigSbtc() { + const config = useRemoteConfig(); + const network = useCurrentNetwork(); + const sbtc = config?.sbtc as SbtcConfig; + + return useMemo(() => { + const displayPromoCardOnNetworks = (sbtc as any)?.showPromoLinkOnNetworks ?? []; + const contractIdMainnet = sbtc?.contracts.mainnet.address ?? ''; + const contractIdTestnet = sbtc?.contracts.testnet.address ?? ''; + const apiUrlMainnet = sbtc?.sponsorshipApiUrl.mainnet ?? ''; + const apiUrlTestnet = sbtc?.sponsorshipApiUrl.testnet ?? ''; + + return { + configLoading: !sbtc, + isSbtcEnabled: sbtc?.enabled ?? false, + isSbtcSponsorshipsEnabled: (sbtc?.enabled && sbtc?.sponsorshipsEnabled) ?? false, + emilyApiUrl: sbtc?.emilyApiUrl ?? '', + contractId: network.chain.bitcoin.mode === 'mainnet' ? contractIdMainnet : contractIdTestnet, + sponsorshipApiUrl: network.chain.bitcoin.mode === 'mainnet' ? apiUrlMainnet : apiUrlTestnet, + isSbtcContract(contract: string) { + return ( + contract === getPrincipalFromContractId(contractIdMainnet) || + contract === getPrincipalFromContractId(contractIdTestnet) + ); + }, + shouldDisplayPromoCard: displayPromoCardOnNetworks.includes(network.id), + }; + }, [network.chain.bitcoin.mode, network.id, sbtc]); +} diff --git a/src/app/query/sbtc/sbtc-deposits.query.ts b/src/app/query/sbtc/sbtc-deposits.query.ts new file mode 100644 index 00000000000..44599d1816c --- /dev/null +++ b/src/app/query/sbtc/sbtc-deposits.query.ts @@ -0,0 +1,73 @@ +import { hexToBytes } from '@stacks/common'; +import { BytesReader, addressToString, deserializeAddress } from '@stacks/transactions'; +import { useQuery } from '@tanstack/react-query'; +import axios from 'axios'; + +import { useConfigSbtc } from '../common/remote-config/remote-config.query'; + +export enum SbtcStatus { + Pending = 'pending', + Reprocessing = 'reprocessing', + Accepted = 'accepted', + Confirmed = 'confirmed', + Failed = 'failed', +} + +export interface SbtcDepositInfo { + amount: number; + bitcoinTxOutputIndex: number; + bitcoinTxid: string; + depositScript: string; + lastUpdateBlockHash: string; + lastUpdateHeight: number; + recipient: string; // Stacks address + reclaimScript: string; + status: SbtcStatus; +} + +interface GetSbtcDepositsResponse { + deposits: SbtcDepositInfo[]; + nextToken?: string; +} + +async function getSbtcDeposits(apiUrl: string, status: string): Promise { + const resp = await axios.get(`${apiUrl}/deposit?status=${status}`, { + headers: { + 'Content-Type': 'application/json', + }, + }); + return resp.data; +} + +function useGetSbtcDeposits(stxAddress: string, status: string) { + const { emilyApiUrl } = useConfigSbtc(); + return useQuery({ + queryKey: ['get-sbtc-deposits', stxAddress, status], + queryFn: () => getSbtcDeposits(emilyApiUrl, status), + select: resp => + resp.deposits.filter(deposit => { + const recipient = addressToString( + deserializeAddress(new BytesReader(hexToBytes(deposit.recipient.slice(2)))) + ); + return recipient === stxAddress; + }), + }); +} + +export function useSbtcPendingDeposits(stxAddress: string) { + const { data: pendingDeposits = [], isLoading: isLoadingStatusPending } = useGetSbtcDeposits( + stxAddress, + 'pending' + ); + const { data: reprocessingDeposits = [], isLoading: isLoadingStatusReprocessing } = + useGetSbtcDeposits(stxAddress, 'reprocessing'); + const { data: acceptedDeposits = [], isLoading: isLoadingStatusAccepted } = useGetSbtcDeposits( + stxAddress, + 'accepted' + ); + + return { + isLoading: isLoadingStatusPending || isLoadingStatusReprocessing || isLoadingStatusAccepted, + pendingSbtcDeposits: [...pendingDeposits, ...reprocessingDeposits, ...acceptedDeposits], + }; +} diff --git a/src/app/query/sbtc/sbtc-limits.query.ts b/src/app/query/sbtc/sbtc-limits.query.ts new file mode 100644 index 00000000000..df0e931af9e --- /dev/null +++ b/src/app/query/sbtc/sbtc-limits.query.ts @@ -0,0 +1,60 @@ +import { useQuery } from '@tanstack/react-query'; +import axios from 'axios'; + +import { useStacksClient } from '@leather.io/query'; +import { getStacksContractIdStringParts } from '@leather.io/stacks'; + +import { useCurrentStacksAccountAddress } from '@app/store/accounts/blockchain/stacks/stacks-account.hooks'; + +import { useConfigSbtc } from '../common/remote-config/remote-config.query'; + +export const defaultSbtcLimits = { + pegCap: 1000000000000, + perDepositMinimum: 100000, + perDepositCap: 100000000, + perWithdrawalCap: 100000000, + accountCaps: {}, +}; + +interface GetSbtcLimitsResponse { + pegCap: number; + perDepositCap: number; + perWithdrawalCap: number; + perDepositMinimum: number; + accountCaps: Record; +} + +async function getSbtcLimits(apiUrl: string): Promise { + const resp = await axios.get(`${apiUrl}/limits`, { + headers: { + 'Content-Type': 'application/json', + }, + }); + return resp.data; +} + +export function useGetSbtcLimits() { + const { emilyApiUrl } = useConfigSbtc(); + return useQuery({ + queryKey: ['get-sbtc-limits'], + queryFn: () => getSbtcLimits(emilyApiUrl), + }); +} + +export function useGetCurrentSbtcSupply() { + const client = useStacksClient(); + const { contractId } = useConfigSbtc(); + const { contractAddress } = getStacksContractIdStringParts(contractId); + const stxAddress = useCurrentStacksAccountAddress(); + + return useQuery({ + queryKey: ['get-current-sbtc-supply'], + queryFn: () => + client.callReadOnlyFunction({ + contractAddress, + contractName: 'sbtc-token', + functionName: 'get-total-supply', + readOnlyFunctionArgs: { sender: stxAddress, arguments: [] }, + }), + }); +} diff --git a/src/app/query/sbtc/sponsored-transactions.hooks.ts b/src/app/query/sbtc/sponsored-transactions.hooks.ts new file mode 100644 index 00000000000..3a6335806ad --- /dev/null +++ b/src/app/query/sbtc/sponsored-transactions.hooks.ts @@ -0,0 +1,65 @@ +import { useEffect, useState } from 'react'; + +import { FeeTypes, type Fees } from '@leather.io/models'; +import type { NextNonce } from '@leather.io/query'; + +import { logger } from '@shared/logger'; + +import { useCurrentStacksAccountAddress } from '@app/store/accounts/blockchain/stacks/stacks-account.hooks'; + +import { useConfigSbtc } from '../common/remote-config/remote-config.query'; +import { + type SbtcSponsorshipEligibility, + type SbtcSponsorshipVerificationResult, + type TransactionBase, + verifySponsoredSbtcTransaction, +} from './sponsored-transactions.query'; + +export function useCheckSbtcSponsorshipEligible( + baseTx?: TransactionBase, + nextNonce?: NextNonce, + stxFees?: Fees +): SbtcSponsorshipVerificationResult { + const sbtcConfig = useConfigSbtc(); + const stxAddress = useCurrentStacksAccountAddress(); + const [isLoading, setIsLoading] = useState(true); + const [result, setResult] = useState(); + const [lastAddressChecked, setLastAddressChecked] = useState(); + + useEffect(() => { + if (!sbtcConfig.configLoading && !sbtcConfig.isSbtcSponsorshipsEnabled) { + if (isLoading) setIsLoading(false); + return; + } + if (!(sbtcConfig && baseTx && nextNonce && stxFees)) { + return; + } + if (result && stxAddress === lastAddressChecked) { + return; + } + // use the standard recommended fee from estimates + const standardFeeEstimate = stxFees.estimates[FeeTypes.Middle].fee.amount.toNumber(); + verifySponsoredSbtcTransaction({ + apiUrl: sbtcConfig.sponsorshipApiUrl, + baseTx, + nonce: nextNonce.nonce, + fee: standardFeeEstimate, + }) + .then(result => { + setResult(result); + setLastAddressChecked(stxAddress); + }) + .catch(e => { + logger.error('Verification failure: ', e); + setResult({ isEligible: false }); + }) + .finally(() => { + setIsLoading(false); + }); + }, [baseTx, stxFees, result, stxAddress, lastAddressChecked, nextNonce, isLoading, sbtcConfig]); + + return { + isVerifying: isLoading, + result, + }; +} diff --git a/src/app/query/sbtc/sponsored-transactions.query.ts b/src/app/query/sbtc/sponsored-transactions.query.ts new file mode 100644 index 00000000000..1010ff607dc --- /dev/null +++ b/src/app/query/sbtc/sponsored-transactions.query.ts @@ -0,0 +1,91 @@ +import { bytesToHex } from '@stacks/common'; +import { StacksTransaction } from '@stacks/transactions'; +import axios from 'axios'; + +import { logger } from '@shared/logger'; + +import { queryClient } from '@app/common/persistence'; +import { generateUnsignedTransaction } from '@app/common/transactions/stacks/generate-unsigned-txs'; + +export interface TransactionBase { + options?: any; + transaction: StacksTransaction | undefined; +} + +export interface SbtcSponsorshipVerificationResult { + isVerifying: boolean; + result: SbtcSponsorshipEligibility | undefined; +} + +export interface SbtcSponsorshipEligibility { + isEligible: boolean; + unsignedSponsoredTx?: StacksTransaction; +} + +interface SbtcSponsorshipSubmissionResult { + txid?: string; + error?: string; +} + +export async function submitSponsoredSbtcTransaction( + apiUrl: string, + sponsoredTx: StacksTransaction +): Promise { + try { + const { data } = await axios.post(`${apiUrl}/submit`, { + tx: bytesToHex(sponsoredTx.serialize()), + }); + return { + txid: data.txid, + }; + } catch (error: any) { + const errMsg = `sBTC Sponsorship Failure (${error?.response?.data?.error || 'Unknown'})`; + return { + error: errMsg, + }; + } +} + +interface VerifySponsoredSbtcTransactionArgs { + apiUrl: string; + baseTx: TransactionBase; + nonce?: number; + fee?: number; +} +export async function verifySponsoredSbtcTransaction({ + apiUrl, + baseTx, + nonce, + fee, +}: VerifySponsoredSbtcTransactionArgs): Promise { + try { + // add sponsorship option + const { options } = baseTx as any; + options.txData.sponsored = true; + const sponsoredTx = await generateUnsignedTransaction({ + ...options, + fee, + nonce, + }); + const serializedTx = bytesToHex(sponsoredTx.serialize()); + + const result = await queryClient.fetchQuery({ + queryKey: ['verify-sponsored-sbtc-transaction', serializedTx], + queryFn: async () => { + const { data } = await axios.post( + `${apiUrl}/verify`, + { + tx: serializedTx, + }, + { timeout: 5000 } + ); + return data; + }, + }); + + return { isEligible: result, unsignedSponsoredTx: sponsoredTx }; + } catch (error) { + logger.error('Transaction verification failed:', error); + return { isEligible: false }; + } +} diff --git a/src/app/routes/rpc-routes.tsx b/src/app/routes/rpc-routes.tsx index 6b7c68bced8..23cdc38aedc 100644 --- a/src/app/routes/rpc-routes.tsx +++ b/src/app/routes/rpc-routes.tsx @@ -27,7 +27,9 @@ export const rpcRequestRoutes = ( } /> + {rpcSendTransferRoutes} + { diff --git a/src/app/store/transactions/contract-call.hooks.ts b/src/app/store/transactions/contract-call.hooks.ts index 6f758edd864..7579c0ff23f 100644 --- a/src/app/store/transactions/contract-call.hooks.ts +++ b/src/app/store/transactions/contract-call.hooks.ts @@ -30,7 +30,8 @@ export function useGenerateStacksContractCallUnsignedTx() { fee: values.fee ?? 0, txData: { ...payload, network }, }; - return generateUnsignedTransaction(options); + const transaction = await generateUnsignedTransaction(options); + return { transaction, options }; }, [account, network, nextNonce?.nonce] ); diff --git a/src/background/messaging/messaging-utils.ts b/src/background/messaging/messaging-utils.ts index c3daa8e5722..25019dab805 100644 --- a/src/background/messaging/messaging-utils.ts +++ b/src/background/messaging/messaging-utils.ts @@ -74,5 +74,5 @@ export async function triggerRequestWindowOpen(path: RouteUrls, urlParams: URLSe export async function triggerSwapWindowOpen(path: To, urlParams: URLSearchParams) { if (IS_TEST_ENV) return openRequestInFullPage(path, urlParams); - return popup({ url: `/swap.html#${path}?${urlParams.toString()}` }); + return popup({ url: `/popup.html#${path}?${urlParams.toString()}` }); } diff --git a/src/background/messaging/rpc-methods/supported-methods.ts b/src/background/messaging/rpc-methods/supported-methods.ts index 722214b166c..4f699dd7690 100644 --- a/src/background/messaging/rpc-methods/supported-methods.ts +++ b/src/background/messaging/rpc-methods/supported-methods.ts @@ -39,6 +39,10 @@ export function rpcSupportedMethods(message: SupportedMethodsRequest, port: chro docsUrl: 'https://leather.gitbook.io/developers/bitcoin/sign-transactions/partially-signed-bitcoin-transactions-psbts', }, + { + name: 'openSwap', + docsUrl: 'https://leather.gitbook.io/developers/bitcoin/swaps/open-swap', + }, ], }, }) diff --git a/src/shared/models/form.model.ts b/src/shared/models/form.model.ts index b48e1f8f68e..5d485c2789d 100644 --- a/src/shared/models/form.model.ts +++ b/src/shared/models/form.model.ts @@ -1,4 +1,5 @@ import type { Inscription, Money } from '@leather.io/models'; +import type { SwapAsset } from '@leather.io/query'; export interface BitcoinSendFormValues { amount: number | string; @@ -42,3 +43,10 @@ export interface TransferRecipient { address: string; amount: Money; } + +export interface SwapFormValues extends StacksTransactionFormValues { + swapAmountBase: string; + swapAmountQuote: string; + swapAssetBase?: SwapAsset; + swapAssetQuote?: SwapAsset; +} diff --git a/test-app/src/components/bitcoin.tsx b/test-app/src/components/bitcoin.tsx index 789ba8d2d42..4230cf47d78 100644 --- a/test-app/src/components/bitcoin.tsx +++ b/test-app/src/components/bitcoin.tsx @@ -7,7 +7,7 @@ import { bytesToHex, hexToBytes } from '@stacks/common'; import { PsbtData, PsbtRequestOptions } from '@stacks/connect'; import { useConnect } from '@stacks/connect-react'; import { StacksNetwork } from '@stacks/network'; -import { Box, styled } from 'leather-styles/jsx'; +import { styled } from 'leather-styles/jsx'; interface BitcoinNetwork { bech32: string; @@ -351,167 +351,186 @@ export const Bitcoin = () => { }; return ( - +
Bitcoin Testnet Try testing Partially Signed Bitcoin Transactions. - signTx(buildTestNativeSegwitPsbtRequest(segwitPubKey), stacksTestnetNetwork)} - > - Sign PSBT (Segwit) - - - signTx(buildTestNativeSegwitPsbtRequestWithIndexes(segwitPubKey), stacksTestnetNetwork) - } - > - Sign PSBT at indexes (SegWit) - - signTx(buildTestTaprootPsbtRequest(taprootPubKey), stacksTestnetNetwork)} - > - Sign PSBT (Taproot) - - - signTx(buildTestTaprootPsbtRequestWithIndex(taprootPubKey), stacksTestnetNetwork) - } - > - Sign PSBT at index failure (Taproot) - - { - console.log('requesting'); - window.btc - ?.request('sendTransfer', { - address: TEST_TESTNET_ACCOUNT_2_BTC_ADDRESS, - amount: '10000', - network: 'testnet', - }) - .then(resp => { - console.log({ sucesss: resp }); - }) - .catch(error => { - console.log({ error }); - }); + +
- Send transfer - - { - console.log('requesting'); - (window as any).LeatherProvider?.request('sendTransfer', { - recipients: [ - { - address: TEST_TESTNET_ACCOUNT_2_BTC_ADDRESS, - amount: '800', - }, - { + + signTx(buildTestNativeSegwitPsbtRequest(segwitPubKey), stacksTestnetNetwork) + } + > + Sign PSBT (Segwit) + + + signTx(buildTestNativeSegwitPsbtRequestWithIndexes(segwitPubKey), stacksTestnetNetwork) + } + > + Sign PSBT at indexes (SegWit) + + signTx(buildTestTaprootPsbtRequest(taprootPubKey), stacksTestnetNetwork)} + > + Sign PSBT (Taproot) + + + signTx(buildTestTaprootPsbtRequestWithIndex(taprootPubKey), stacksTestnetNetwork) + } + > + Sign PSBT at index failure (Taproot) + + { + console.log('requesting'); + window.btc + ?.request('sendTransfer', { address: TEST_TESTNET_ACCOUNT_2_BTC_ADDRESS, amount: '10000', - }, - ], - network: 'testnet', - }) - .then((resp: any) => { - console.log({ sucesss: resp }); + network: 'testnet', + }) + .then(resp => { + console.log({ sucesss: resp }); + }) + .catch(error => { + console.log({ error }); + }); + }} + > + Send transfer + + { + console.log('requesting'); + (window as any).LeatherProvider?.request('sendTransfer', { + recipients: [ + { + address: TEST_TESTNET_ACCOUNT_2_BTC_ADDRESS, + amount: '800', + }, + { + address: TEST_TESTNET_ACCOUNT_2_BTC_ADDRESS, + amount: '900', + }, + ], + network: 'testnet', }) - .catch((error: Error) => { - console.log({ error }); - }); - }} - > - Send transfer to multiple addresses - - { - console.log('requesting'); - (window as any).LeatherProvider?.request('sendTransfer', { - recipients: [ - { - address: 'bc1qps90ws94pvk548y9jg03gn5lwjqnyud4lg6y56', - amount: '800', - }, - { - address: 'bc1qps90ws94pvk548y9jg03gn5lwjqnyud4lg6y56', - amount: '10000', - }, - ], - network: 'mainnet', - }) - .then((resp: any) => { - console.log({ sucesss: resp }); + .then((resp: any) => { + console.log({ sucesss: resp }); + }) + .catch((error: Error) => { + console.log({ error }); + }); + }} + > + Send transfer to multiple addresses + + { + console.log('requesting'); + (window as any).LeatherProvider?.request('sendTransfer', { + recipients: [ + { + address: 'bc1qps90ws94pvk548y9jg03gn5lwjqnyud4lg6y56', + amount: '800', + }, + { + address: 'bc1qyrtw5v0rkmytg0gu34f06fxpyfk24x7jevtvx3', + amount: '10000', + }, + ], + network: 'mainnet', }) - .catch((error: Error) => { - console.log({ error }); - }); - }} - > - Send native segwit transfer to multiple addresses - - { - console.log('requesting'); - (window as any).LeatherProvider?.request('sendTransfer', { - recipients: [ - { - address: 'bc1p8nyc4sl8agqfjs2rq4yer6wnhd89naw05s0ha8hpmg8j36ht6yvswqyaxm', - amount: '800', - }, - { - address: 'bc1p8nyc4sl8agqfjs2rq4yer6wnhd89naw05s0ha8hpmg8j36ht6yvswqyaxm', - amount: '10000', - }, - ], - network: 'mainnet', - }) - .then((resp: any) => { - console.log({ sucesss: resp }); + .then((resp: any) => { + console.log({ sucesss: resp }); + }) + .catch((error: Error) => { + console.log({ error }); + }); + }} + > + Send native segwit transfer to multiple addresses + + { + console.log('requesting'); + (window as any).LeatherProvider?.request('sendTransfer', { + recipients: [ + { + address: 'bc1p8nyc4sl8agqfjs2rq4yer6wnhd89naw05s0ha8hpmg8j36ht6yvswqyaxm', + amount: '800', + }, + { + address: 'bc1p8nyc4sl8agqfjs2rq4yer6wnhd89naw05s0ha8hpmg8j36ht6yvswqyaxm', + amount: '10000', + }, + ], + network: 'mainnet', }) - .catch((error: Error) => { - console.log({ error }); - }); - }} - > - Send taproot transfer to multiple addresses - - { - console.log('requesting'); - (window as any).LeatherProvider?.request('sendTransfer', { - recipients: [ - { - address: TEST_TESTNET_ACCOUNT_2_BTC_ADDRESS, - amount: '10000', - }, - { - address: TEST_TESTNET_ACCOUNT_2_BTC_ADDRESS, - amount: '10000', - }, - ], - }) - .then((resp: any) => { - console.log({ sucesss: resp }); + .then((resp: any) => { + console.log({ sucesss: resp }); + }) + .catch((error: Error) => { + console.log({ error }); + }); + }} + > + Send taproot transfer to multiple addresses + + { + console.log('requesting'); + (window as any).LeatherProvider?.request('sendTransfer', { + recipients: [ + { + address: TEST_TESTNET_ACCOUNT_2_BTC_ADDRESS, + amount: '10000', + }, + { + address: TEST_TESTNET_ACCOUNT_2_BTC_ADDRESS, + amount: '10000', + }, + ], }) - .catch((error: Error) => { - console.log({ error }); - }); - }} - > - Send transfer validate error - - + .then((resp: any) => { + console.log({ sucesss: resp }); + }) + .catch((error: Error) => { + console.log({ error }); + }); + }} + > + Send transfer validate error + +
+
); }; diff --git a/tests/page-object-models/swap.page.ts b/tests/page-object-models/swap.page.ts index 99a8be88d85..bc49da46c03 100644 --- a/tests/page-object-models/swap.page.ts +++ b/tests/page-object-models/swap.page.ts @@ -33,11 +33,26 @@ export class SwapPage { const swapAssetSelectors = await this.page.locator(this.selectAssetBtn).all(); await swapAssetSelectors[1].click(); await this.page.locator(this.chooseAssetList).waitFor(); - await this.page.locator('text="ALEX Token"').click(); + await this.page.locator('text="ALEX"').click(); } async inputSwapAmountBase() { const swapAmountInputs = await this.page.locator(this.swapAmountInput).all(); await swapAmountInputs[0].fill('1'); } + + async selectBtcAsBaseAsset() { + const swapAssetSelectors = await this.page.locator(this.selectAssetBtn).all(); + await swapAssetSelectors[0].click(); + await this.page.locator(this.chooseAssetList).waitFor(); + await this.page.locator('text="BTC"').click(); + } + + async selectQuoteAsset() { + const swapAssetSelectors = await this.page.locator(this.selectAssetBtn).all(); + await swapAssetSelectors[1].click(); + // await this.page.locator(this.chooseAssetList).waitFor(); + // const quoteAssets = await this.page.locator(this.chooseAssetListItem).all(); + // await quoteAssets[0].click(); + } } diff --git a/tests/specs/send/send-sip10.spec.ts b/tests/specs/send/send-sip10.spec.ts index 1cae579be13..0fd0a660f39 100644 --- a/tests/specs/send/send-sip10.spec.ts +++ b/tests/specs/send/send-sip10.spec.ts @@ -20,6 +20,8 @@ test.describe('Send sip10', () => { test('can send sip10 token', async ({ sendPage }) => { await sendPage.amountInput.fill(amount); await sendPage.recipientInput.fill(TEST_ACCOUNT_2_STX_ADDRESS); + await sendPage.recipientInput.blur(); + await sendPage.previewSendTxButton.click(); const details = await sendPage.confirmationDetails.allInnerTexts(); diff --git a/tests/specs/swap/swap.spec.ts b/tests/specs/swap/swap.spec.ts index 494f4fa500a..8058b19f0a1 100644 --- a/tests/specs/swap/swap.spec.ts +++ b/tests/specs/swap/swap.spec.ts @@ -1,11 +1,12 @@ -import { test } from '../../fixtures/fixtures'; +import { expect } from '@playwright/test'; +import { mockStacksBroadcastTransaction } from '@tests/mocks/mock-stacks-txs'; -const hiroApiPostRoute = '*/**/v2/transactions'; +import { test } from '../../fixtures/fixtures'; -// Skip as swaps feature is disabled -test.skip('Swaps', () => { +test.describe('Swaps', () => { test.beforeEach(async ({ extensionId, globalPage, homePage, onboardingPage, swapPage }) => { await globalPage.setupAndUseApiCalls(extensionId); + await mockStacksBroadcastTransaction(globalPage.page); await onboardingPage.signInWithTestAccount(extensionId); await homePage.swapButton.click(); await swapPage.waitForSwapPageReady(); @@ -21,7 +22,7 @@ test.skip('Swaps', () => { await swapPage.swapReviewBtn.click({ delay: 2000 }); const swapProtocol = await swapPage.swapDetailsProtocol.innerText(); - test.expect(swapProtocol).toEqual('ALEX'); + test.expect(swapProtocol).toContain('Bitflow'); const swapAssets = await swapPage.swapDetailsSymbol.all(); const swapAssetBase = await swapAssets[0].innerText(); @@ -34,25 +35,29 @@ test.skip('Swaps', () => { test.expect(swapAmountBase).toEqual('1'); }); - // This test isn't working bc there are multiple requests being made - // to the same endpoint. We need to know why this happening before - // enabling it again bc swaps keep occurring which create insufficient - // balance errors in our integration tests. - test.skip('that the swap is broadcast', async ({ swapPage }) => { - const requestPromise = swapPage.page.waitForRequest(hiroApiPostRoute); - - await swapPage.page.route(hiroApiPostRoute, async route => { - await route.abort(); - }); - + test('that the swap is broadcast', async ({ swapPage }) => { await swapPage.inputSwapAmountBase(); await swapPage.selectAssetToReceive(); await swapPage.swapReviewBtn.click({ delay: 2000 }); await swapPage.swapSubmitBtn.click(); - const request = await requestPromise; - const requestBody = request.postDataBuffer(); - test.expect(requestBody).toBeDefined(); + const toastMessage = 'Transaction submitted!'; + const toast = swapPage.page.getByText(toastMessage, { exact: true }); + await expect(toast).toBeVisible(); + }); + + test('that it preselects cross chain swap assets and restricts quote list', async ({ + swapPage, + }) => { + await swapPage.selectBtcAsBaseAsset(); + + const quoteAsset = await swapPage.page.locator('text="sBTC"').innerText(); + test.expect(quoteAsset).toEqual('sBTC'); + + await swapPage.selectQuoteAsset(); + await swapPage.page.locator(swapPage.chooseAssetList).waitFor(); + const quoteAssets = await swapPage.page.locator(swapPage.chooseAssetListItem).all(); + test.expect(quoteAssets.length).toEqual(1); }); });