From 7489cae65e7af12f2631aa54b1d767193426441e Mon Sep 17 00:00:00 2001 From: Anastasios Date: Wed, 11 Dec 2024 16:31:02 +0400 Subject: [PATCH] feat: redesign send rpc transfer flow --- package.json | 4 +- pnpm-lock.yaml | 128 +++++-- src/app/common/fees/use-fees.ts | 79 +++++ .../use-switch-account-sheet-context.ts | 1 + .../bitcoin/use-generate-bitcoin-tx.ts | 12 +- .../use-bitcoin-fees-data.ts | 209 +++++++++++ ...e-bitcoin-fees-list-multiple-recipients.ts | 127 ------- .../crypto-asset-item-placeholder.tsx | 6 +- src/app/components/error/form-error.tsx | 15 + src/app/components/fees/custom-fee-item.tsx | 74 ++++ src/app/components/fees/fee-item-icon.tsx | 29 ++ src/app/components/fees/fee-item.tsx | 84 +++++ src/app/components/fees/fees.tsx | 17 + src/app/components/icon-wrapper.tsx | 20 ++ src/app/components/loading-overlay.tsx | 27 ++ src/app/components/rpc/rpc-header.tsx | 27 ++ src/app/components/rpc/rpc-switch-account.tsx | 57 +++ .../send-transfer-confirmation-details.tsx | 32 -- .../components/send-transfer-details.tsx | 40 --- .../components/send-transfer-error.tsx | 15 + .../components/send-transfer-header.tsx | 29 -- .../components/send-transfer-recipients.tsx | 49 +++ .../components/send-transfer-wrapper.tsx | 49 +++ .../rpc-send-transfer-choose-fee.tsx | 161 ++++----- .../rpc-send-transfer-confirmation.tsx | 160 --------- .../rpc-send-transfer-container.tsx | 106 +++++- .../rpc-send-transfer-summary.tsx | 99 ------ .../rpc-send-transfer.routes.tsx | 4 - .../rpc-send-transfer/rpc-send-transfer.tsx | 247 ++++++++++++- .../use-rpc-send-transfer.ts | 7 +- src/app/routes/rpc-routes.tsx | 2 + test-app/src/components/bitcoin.tsx | 325 +++++++++--------- 32 files changed, 1446 insertions(+), 795 deletions(-) create mode 100644 src/app/common/fees/use-fees.ts create mode 100644 src/app/components/bitcoin-fees-list/use-bitcoin-fees-data.ts delete mode 100644 src/app/components/bitcoin-fees-list/use-bitcoin-fees-list-multiple-recipients.ts create mode 100644 src/app/components/error/form-error.tsx create mode 100644 src/app/components/fees/custom-fee-item.tsx create mode 100644 src/app/components/fees/fee-item-icon.tsx create mode 100644 src/app/components/fees/fee-item.tsx create mode 100644 src/app/components/fees/fees.tsx create mode 100644 src/app/components/icon-wrapper.tsx create mode 100644 src/app/components/loading-overlay.tsx create mode 100644 src/app/components/rpc/rpc-header.tsx create mode 100644 src/app/components/rpc/rpc-switch-account.tsx delete mode 100644 src/app/pages/rpc-send-transfer/components/send-transfer-confirmation-details.tsx delete mode 100644 src/app/pages/rpc-send-transfer/components/send-transfer-details.tsx create mode 100644 src/app/pages/rpc-send-transfer/components/send-transfer-error.tsx delete mode 100644 src/app/pages/rpc-send-transfer/components/send-transfer-header.tsx create mode 100644 src/app/pages/rpc-send-transfer/components/send-transfer-recipients.tsx create mode 100644 src/app/pages/rpc-send-transfer/components/send-transfer-wrapper.tsx delete mode 100644 src/app/pages/rpc-send-transfer/rpc-send-transfer-confirmation.tsx delete mode 100644 src/app/pages/rpc-send-transfer/rpc-send-transfer-summary.tsx diff --git a/package.json b/package.json index 375f5e649e7..196cdb1343c 100644 --- a/package.json +++ b/package.json @@ -153,7 +153,7 @@ "@leather.io/query": "2.23.0", "@leather.io/stacks": "1.3.5", "@leather.io/tokens": "0.12.1", - "@leather.io/ui": "1.37.0", + "@leather.io/ui": "1.42.2", "@leather.io/utils": "0.19.1", "@ledgerhq/hw-transport-webusb": "6.27.19", "@noble/hashes": "1.5.0", @@ -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.1.18", "@ls-lint/ls-lint": "2.2.3", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index aa3d5de41cf..e1dad43fcd3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -69,8 +69,8 @@ importers: 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.42.2 + version: 1.42.2(@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 @@ -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) @@ -3287,8 +3287,8 @@ packages: '@leather.io/constants@0.13.4': resolution: {integrity: sha512-Cot8qwwLWeIMr0LFHtzVkeJZ/a+MSMuNPhrXKm5Psaqb2jyjkq5RrlCLtm9/cjwmkBTZeokWubaQUzyPMlQK4w==} - '@leather.io/constants@0.13.5': - resolution: {integrity: sha512-FOh/F/g8WepB8HfoTXsMB/BYcm/F6INPEpyEZc3ljzaN0mLwVLO1kwgMTFU9Pq7tQlITvyWiyGHcB7OYovLoUQ==} + '@leather.io/constants@0.14.1': + resolution: {integrity: sha512-ZY/UniFqlZteCJKhMSlq550UPF41RdKNlsxTUB8/myY3gAzyuuRNAckBUqtgEQiOH9c+pWMDd3uBp82Re4Wk/g==} '@leather.io/crypto@1.6.12': resolution: {integrity: sha512-ZEhUVhdq/m2pIhi8PwvRKyjwYdcmXcRJBOLSO1RMSu8N93GTEqfvxkqTlu88c2ReNt5C62+TwWM6hbZkU2BV6Q==} @@ -3305,11 +3305,11 @@ packages: '@leather.io/models@0.21.0': resolution: {integrity: sha512-cy/WToOVy0ZGHxza5kJL2aeKKnBXL4lSK/j3iig/rDWAgx5Vy7M8sCjFBbo4hlinskb8VgM5woGe7hIFjFZcmA==} - '@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/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==} @@ -3325,8 +3325,8 @@ packages: '@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/stacks@1.3.5': resolution: {integrity: sha512-yqOX6CTcg0Shj3A5ymYtho054PJ2xU+HlyRfwXca8yJ1U2chMSq7jTinJktgI+1liTHsqmtRnrgmufxWY41J7A==} @@ -3334,8 +3334,8 @@ packages: '@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/ui@1.42.2': + resolution: {integrity: sha512-6N03cWKaYCMzDCUaFjAtK48CKz0QH/yl+UzeTuPNQ7PLvXyO7QMZ7qQZyX07/LcZ/Ra3olNHvoV8UrQpoZEdaw==} '@leather.io/utils@0.19.1': resolution: {integrity: sha512-bwD3/4Rt3UOL3pvettqNon+zqS5S8K6z3AoAEwkcYS77DI/q4kzH5T/3nOOGpcWda1/R453mqRCIObRxecIWFA==} @@ -3343,8 +3343,8 @@ packages: '@leather.io/utils@0.19.2': resolution: {integrity: sha512-oLEasUP5BDeDbrB9vxH0C0zrZWcG2bj12KHaI9illCtIqEe9pLM/5R5Ee6vVH1Ft+vCg/HI7WHNu79o0NHFdgQ==} - '@leather.io/utils@0.20.0': - resolution: {integrity: sha512-Ot0oOYMku4oy3218W3Tt0ip0xjMyegOxFONqOyt/WSZe9xzTiXXUq0u3D8jwa851ZEOSCB7TgOO5RMzWK0lkLg==} + '@leather.io/utils@0.21.3': + resolution: {integrity: sha512-zl6QevKt5manHm7ogDocX66NWOZqgqTB5g/j85KgZX3zhMg0OjlEjmjkNvnU7fZG8T1NbHhnRJTxAxbaOm1L3A==} '@ledgerhq/devices@8.4.2': resolution: {integrity: sha512-oWNTp3jCMaEvRHsXNYE/yo+PFMgXAJGFHLOU1UdE4/fYkniHbD9wdxwyZrZvrxr9hNw4/9wHiThyITwPtMzG7g==} @@ -3828,6 +3828,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: @@ -4259,6 +4268,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: @@ -19088,9 +19110,9 @@ snapshots: dependencies: '@leather.io/models': 0.21.0 - '@leather.io/constants@0.13.5': + '@leather.io/constants@0.14.1': dependencies: - '@leather.io/models': 0.22.0 + '@leather.io/models': 0.24.0 '@leather.io/crypto@1.6.12': dependencies: @@ -19128,13 +19150,13 @@ snapshots: 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 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: @@ -19192,9 +19214,9 @@ snapshots: '@leather.io/models': 0.21.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/stacks@1.3.5(encoding@0.1.13)': @@ -19211,18 +19233,19 @@ snapshots: '@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.42.2(@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.21.3 '@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) @@ -19283,11 +19306,11 @@ snapshots: '@leather.io/rpc': 2.1.19 bignumber.js: 9.1.2 - '@leather.io/utils@0.20.0': + '@leather.io/utils@0.21.3': dependencies: - '@leather.io/constants': 0.13.5 - '@leather.io/models': 0.22.0 - '@leather.io/rpc': 2.1.20 + '@leather.io/constants': 0.14.1 + '@leather.io/models': 0.24.0 + '@leather.io/rpc': 2.1.22 bignumber.js: 9.1.2 '@ledgerhq/devices@8.4.2': @@ -19877,6 +19900,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) @@ -19955,6 +19990,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 @@ -20037,6 +20078,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 @@ -20670,6 +20717,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 @@ -24995,7 +25061,7 @@ snapshots: aria-hidden@1.2.4: dependencies: - tslib: 2.7.0 + tslib: 2.8.1 aria-query@5.3.0: dependencies: @@ -25709,7 +25775,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: @@ -31503,7 +31569,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: diff --git a/src/app/common/fees/use-fees.ts b/src/app/common/fees/use-fees.ts new file mode 100644 index 00000000000..0b8e8b94b90 --- /dev/null +++ b/src/app/common/fees/use-fees.ts @@ -0,0 +1,79 @@ +import { useEffect, useMemo, useState } from 'react'; + +import type { MarketData } from '@leather.io/models'; + +import type { FeesRawData, RawFee } from '@app/components/bitcoin-fees-list/use-bitcoin-fees-data'; + +export type FeeType = 'slow' | 'standard' | 'fast' | 'custom'; + +export interface FeeDisplayInfo { + feeType: FeeType; + baseUnitsValue: number; + feeRate: number; + titleLeft: string; + captionLeft: string; + titleRight?: string; + captionRight?: string; +} + +interface UseFeesProps { + defaultFeeType?: FeeType; + fees: FeesRawData; + getCustomFeeData(rate: number): RawFee; + marketData: MarketData; + formatFeeForDisplay({ + rawFee, + marketData, + }: { + rawFee: RawFee; + marketData: MarketData; + }): FeeDisplayInfo; +} + +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/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/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/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..1767adb23a4 --- /dev/null +++ b/src/app/components/bitcoin-fees-list/use-bitcoin-fees-data.ts @@ -0,0 +1,209 @@ +import { useMemo } from 'react'; + +import { type MarketData, Money, btcTxTimeMap } from '@leather.io/models'; +import { type UtxoResponseItem, useAverageBitcoinFeeRates } from '@leather.io/query'; +import { + baseCurrencyAmountInQuote, + capitalize, + createMoney, + formatMoneyPadded, + i18nFormatCurrency, + isUndefined, +} from '@leather.io/utils'; + +import type { TransferRecipient } from '@shared/models/form.model'; + +import type { FeeDisplayInfo, 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'; + +function getFee(determineUtxosForFeeArgs: DetermineUtxosForSpendArgs) { + try { + const { fee } = determineUtxosForSpend(determineUtxosForFeeArgs); + return fee; + } catch (error) { + return null; + } +} + +function getSendMaxFee(determineUtxosForFeeArgs: DetermineUtxosForSpendArgs) { + try { + const { fee } = determineUtxosForSpendAll(determineUtxosForFeeArgs); + return fee; + } catch (error) { + return null; + } +} + +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 function formatBitcoinFeeForDisplay({ + rawFee, + marketData, +}: { + rawFee: RawFee; + marketData: MarketData; +}): 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', + }; +} + +export interface RawFee { + type: FeeType; + baseUnitsFeeValue: number | null; + feeRate: number | null; + time: string; +} + +function getBtcFeeValue(feeValue: number) { + return formatMoneyPadded(createMoney(feeValue, 'BTC')); +} + +interface UseBitcoinFeesListArgs { + amount: Money; + recipients: TransferRecipient[]; + utxos: UtxoResponseItem[]; + isSendingMax?: boolean; +} + +export type FeesRawData = Record, RawFee>; + +export function useBitcoinFeeData({ + amount, + recipients, + utxos, + isSendingMax, +}: UseBitcoinFeesListArgs) { + 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 + ? getSendMaxFee(determineUtxosForFeeArgs) + : getFee(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 + ? getSendMaxFee(determineUtxosForHighFeeArgs) + : getFee(determineUtxosForHighFeeArgs); + const standardFeeValue = isSendingMax + ? getSendMaxFee(determineUtxosForStandardFeeArgs) + : getFee(determineUtxosForStandardFeeArgs); + const lowFeeValue = isSendingMax + ? getSendMaxFee(determineUtxosForLowFeeArgs) + : getFee(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/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..20e66d9422d --- /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..250857c6500 --- /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/rpc/rpc-header.tsx b/src/app/components/rpc/rpc-header.tsx new file mode 100644 index 00000000000..d40c8a7d01f --- /dev/null +++ b/src/app/components/rpc/rpc-header.tsx @@ -0,0 +1,27 @@ +import { styled } from 'leather-styles/jsx'; + +import { Approver, QuestionCircleIcon } from '@leather.io/ui'; + +interface RpcHeaderProps { + title: string; + href?: string; + onPressRequestedByLink(e: React.MouseEvent): void; +} + +export function RpcHeader({ + title, + href = 'https://leather.io/guides/connect-dapps', + onPressRequestedByLink, +}: RpcHeaderProps) { + return ( + + + + } + onPressRequestedByLink={onPressRequestedByLink} + /> + ); +} diff --git a/src/app/components/rpc/rpc-switch-account.tsx b/src/app/components/rpc/rpc-switch-account.tsx new file mode 100644 index 00000000000..62688f58ba6 --- /dev/null +++ b/src/app/components/rpc/rpc-switch-account.tsx @@ -0,0 +1,57 @@ +import { Box } from 'leather-styles/jsx'; + +import { Approver } from '@leather.io/ui'; + +import { useAccountDisplayName } from '@app/common/hooks/account/use-account-names'; +import { AccountTotalBalance } from '@app/components/account-total-balance'; +import { AccountListItemLayout } from '@app/components/account/account-list-item.layout'; +import { AccountNameLayout } from '@app/components/account/account-name'; +import { useCurrentAccountIndex } from '@app/store/accounts/account'; +import { useNativeSegwitSigner } from '@app/store/accounts/blockchain/bitcoin/native-segwit-account.hooks'; +import { useStacksAccounts } from '@app/store/accounts/blockchain/stacks/stacks-account.hooks'; +import { AccountAvatarItem } from '@app/ui/components/account/account-avatar/account-avatar-item'; + +import { AcccountAddresses } from '../account/account-addresses'; + +interface RpcSwitchAccountProps { + toggleSwitchAccount(): void; +} + +export function RpcSwitchAccount({ toggleSwitchAccount }: RpcSwitchAccountProps) { + const index = useCurrentAccountIndex(); + const stacksAccounts = useStacksAccounts(); + const stxAddress = stacksAccounts[index]?.address || ''; + const { data: name = '' } = useAccountDisplayName({ address: stxAddress, index }); + const bitcoinSigner = useNativeSegwitSigner(index); + const bitcoinAddress = bitcoinSigner?.(0).address || ''; + + return ( + + With account + + } + accountName={{name}} + avatar={ + + } + balanceLabel={ + // Hack to center element without adjusting AccountListItemLayout + + + + } + index={0} + isLoading={false} + isSelected={false} + onSelectAccount={() => toggleSwitchAccount()} + /> + + + ); +} diff --git a/src/app/pages/rpc-send-transfer/components/send-transfer-confirmation-details.tsx b/src/app/pages/rpc-send-transfer/components/send-transfer-confirmation-details.tsx deleted file mode 100644 index 2a56aac5703..00000000000 --- a/src/app/pages/rpc-send-transfer/components/send-transfer-confirmation-details.tsx +++ /dev/null @@ -1,32 +0,0 @@ -import { HStack, Stack, styled } from 'leather-styles/jsx'; - -import type { Money } from '@leather.io/models'; -import { formatMoney } from '@leather.io/utils'; - -interface SendTransferConfirmationDetailsProps { - currentAddress: string; - recipient: string; - amount: Money; -} -export function SendTransferConfirmationDetails({ - currentAddress, - recipient, - amount, -}: SendTransferConfirmationDetailsProps) { - return ( - - - From - {currentAddress} - - - To - {recipient} - - - Amount - {formatMoney(amount)} - - - ); -} diff --git a/src/app/pages/rpc-send-transfer/components/send-transfer-details.tsx b/src/app/pages/rpc-send-transfer/components/send-transfer-details.tsx deleted file mode 100644 index 16f9d70cd79..00000000000 --- a/src/app/pages/rpc-send-transfer/components/send-transfer-details.tsx +++ /dev/null @@ -1,40 +0,0 @@ -import { HStack, Stack, styled } from 'leather-styles/jsx'; - -import { formatMoney, truncateMiddle } from '@leather.io/utils'; - -import type { TransferRecipient } from '@shared/models/form.model'; - -interface SendTransferDetailsProps { - recipients: TransferRecipient[]; - currentAddress: string; -} - -export function SendTransferDetails({ recipients, currentAddress }: SendTransferDetailsProps) { - return ( - - {recipients.map(({ address, amount }, index) => ( - - - From - {truncateMiddle(currentAddress)} - - - To - {truncateMiddle(address)} - - - Amount - {formatMoney(amount)} - - - ))} - - ); -} diff --git a/src/app/pages/rpc-send-transfer/components/send-transfer-error.tsx b/src/app/pages/rpc-send-transfer/components/send-transfer-error.tsx new file mode 100644 index 00000000000..4373fb0dd1a --- /dev/null +++ b/src/app/pages/rpc-send-transfer/components/send-transfer-error.tsx @@ -0,0 +1,15 @@ +import { FormError } from '@app/components/error/form-error'; + +interface RpcSendTransferErrorProps { + isLoading: boolean; + isInsufficientBalance: boolean; +} + +export function RpcSendTransferError({ + isLoading, + isInsufficientBalance, +}: RpcSendTransferErrorProps) { + if (isLoading) return null; + if (isInsufficientBalance) return ; + return null; +} diff --git a/src/app/pages/rpc-send-transfer/components/send-transfer-header.tsx b/src/app/pages/rpc-send-transfer/components/send-transfer-header.tsx deleted file mode 100644 index 2297a9a5af4..00000000000 --- a/src/app/pages/rpc-send-transfer/components/send-transfer-header.tsx +++ /dev/null @@ -1,29 +0,0 @@ -import { Flex, styled } from 'leather-styles/jsx'; - -import { Flag } from '@leather.io/ui'; - -import { Favicon } from '@app/components/favicon'; - -interface SendTransferHeaderProps { - amount: string; - origin: string; -} -export function SendTransferHeader({ amount, origin }: SendTransferHeaderProps) { - const title = `Send ${amount}`; - const caption = origin ? `Requested by ${origin}` : null; - - return ( - - - {title} - - {caption && ( - } pl="space.02"> - - {caption} - - - )} - - ); -} diff --git a/src/app/pages/rpc-send-transfer/components/send-transfer-recipients.tsx b/src/app/pages/rpc-send-transfer/components/send-transfer-recipients.tsx new file mode 100644 index 00000000000..6186bd12729 --- /dev/null +++ b/src/app/pages/rpc-send-transfer/components/send-transfer-recipients.tsx @@ -0,0 +1,49 @@ +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'; + +export function SendTransferRecipients({ recipients }: { recipients: TransferRecipient[] }) { + 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/pages/rpc-send-transfer/components/send-transfer-wrapper.tsx b/src/app/pages/rpc-send-transfer/components/send-transfer-wrapper.tsx new file mode 100644 index 00000000000..7382f411a8e --- /dev/null +++ b/src/app/pages/rpc-send-transfer/components/send-transfer-wrapper.tsx @@ -0,0 +1,49 @@ +import { motion, useAnimationControls } from 'framer-motion'; +import { Box, Flex, Stack } from 'leather-styles/jsx'; + +import { LeatherLogomarkIcon } from '@leather.io/ui'; + +import { FaviconDisplayer } from '@app/components/favicon-displayer/favicon-displayer'; +import { BackgroundOverlay } from '@app/components/loading-overlay'; + +export function RpcSendTransferWrapper({ + children, + showOverlay = false, +}: { + children: React.ReactNode; + showOverlay?: boolean; +}) { + const originLogoAnimation = useAnimationControls(); + const checkmarkEnters = useAnimationControls(); + + return ( + + + + + + + + + + + + + + + + + {children} + + ); +} diff --git a/src/app/pages/rpc-send-transfer/rpc-send-transfer-choose-fee.tsx b/src/app/pages/rpc-send-transfer/rpc-send-transfer-choose-fee.tsx index 78dbb733358..a7e660e9ffd 100644 --- a/src/app/pages/rpc-send-transfer/rpc-send-transfer-choose-fee.tsx +++ b/src/app/pages/rpc-send-transfer/rpc-send-transfer-choose-fee.tsx @@ -1,100 +1,107 @@ -import { Outlet, useNavigate } from 'react-router-dom'; +import { useNavigate } from 'react-router-dom'; -import type { BtcFeeType, Money } from '@leather.io/models'; -import type { UtxoResponseItem } from '@leather.io/query'; +import { Center } from 'leather-styles/jsx'; + +import { useCryptoCurrencyMarketDataMeanAverage } from '@leather.io/query'; +import { Approver, Button } from '@leather.io/ui'; -import { logger } from '@shared/logger'; -import type { TransferRecipient } from '@shared/models/form.model'; import { RouteUrls } from '@shared/route-urls'; -import { useLocationStateWithCache } from '@app/common/hooks/use-location-state'; -import { useGenerateUnsignedNativeSegwitTx } from '@app/common/transactions/bitcoin/use-generate-bitcoin-tx'; import { - BitcoinFeesList, - OnChooseFeeArgs, -} from '@app/components/bitcoin-fees-list/bitcoin-fees-list'; -import { useBitcoinFeesList } from '@app/components/bitcoin-fees-list/use-bitcoin-fees-list-multiple-recipients'; -import { BitcoinChooseFee } from '@app/features/bitcoin-choose-fee/bitcoin-choose-fee'; -import { useValidateBitcoinSpend } from '@app/features/bitcoin-choose-fee/hooks/use-validate-bitcoin-spend'; -import { useSignBitcoinTx } from '@app/store/accounts/blockchain/bitcoin/bitcoin.hooks'; - -import { formFeeRowValue } from '../../common/send/utils'; -import { useRpcSendTransferState } from './rpc-send-transfer-container'; - -function useRpcSendTransferFeeState() { - const amountAsMoney = useLocationStateWithCache('amountAsMoney') as Money; - const recipients = useLocationStateWithCache('recipients') as TransferRecipient[]; - const utxos = useLocationStateWithCache('utxos') as UtxoResponseItem[]; + type RawFee, + formatBitcoinFeeForDisplay, +} from '@app/components/bitcoin-fees-list/use-bitcoin-fees-data'; +import type { FeeItemProps } from '@app/components/fees/fee-item'; +import { Fees } from '@app/components/fees/fees'; +import { useCurrentBtcCryptoAssetBalanceNativeSegwit } from '@app/query/bitcoin/balance/btc-balance-native-segwit.hooks'; - return { amountAsMoney, utxos, recipients }; -} +import { useRpcSendTransferState } from './rpc-send-transfer-container'; export function RpcSendTransferChooseFee() { - const { selectedFeeType, setSelectedFeeType } = useRpcSendTransferState(); - const { amountAsMoney, utxos, recipients } = useRpcSendTransferFeeState(); - + const { + fees, + selectedFeeType, + setSelectedFeeType, + editFeeSelected, + setEditFeeSelected, + selectedFeeData, + customFeeRate, + setCustomFeeRate, + getCustomFeeData, + } = useRpcSendTransferState(); const navigate = useNavigate(); + const btcMarketData = useCryptoCurrencyMarketDataMeanAverage('BTC'); - const generateTx = useGenerateUnsignedNativeSegwitTx(); - const signTransaction = useSignBitcoinTx(); - const { feesList, isLoading } = useBitcoinFeesList({ - amount: amountAsMoney, - recipients, - utxos, - }); - const recommendedFeeRate = feesList[1]?.feeRate.toString() || ''; - - const { showInsufficientBalanceError, onValidateBitcoinFeeSpend } = useValidateBitcoinSpend(); - - async function previewTransfer({ feeRate, feeValue, time, isCustomFee }: OnChooseFeeArgs) { - const resp = await generateTx({ amount: amountAsMoney, recipients }, feeRate, utxos); + const btcBalance = useCurrentBtcCryptoAssetBalanceNativeSegwit(); - if (!resp) return logger.error('Attempted to generate raw tx, but no tx exists'); + function onCancel() { + navigate(RouteUrls.RpcSendTransfer); + setCustomFeeRate(selectedFeeData?.feeRate.toString() || ''); + setEditFeeSelected(selectedFeeType); + } - const tx = await signTransaction(resp.psbt); + function onSave() { + setSelectedFeeType(editFeeSelected); + if (editFeeSelected !== 'custom') { + setCustomFeeRate(selectedFeeData?.feeRate.toString() || ''); + } + navigate(RouteUrls.RpcSendTransfer); + } - tx.finalize(); + function getFeeItemProps(rawFee: RawFee): FeeItemProps { + const { type } = rawFee; - const feeRowValue = formFeeRowValue(feeRate, isCustomFee); + const humanFee = formatBitcoinFeeForDisplay({ rawFee, marketData: btcMarketData }); + const isInsufficientBalance = btcBalance.balance.availableBalance.amount.isLessThan( + humanFee.baseUnitsValue + ); - navigate(RouteUrls.RpcSendTransferConfirmation, { - state: { - fee: feeValue, - recipients, - time, - tx: tx.hex, - feeRowValue, + return { + feeType: type, + isSelected: editFeeSelected === type, + isInsufficientBalance, + onSelect: () => { + setEditFeeSelected(type); }, - }); + titleLeft: humanFee.titleLeft, + captionLeft: humanFee.captionLeft, + titleRight: humanFee.titleRight, + captionRight: humanFee.captionRight, + }; } return ( - <> - - setSelectedFeeType(value)} - selectedFeeType={selectedFeeType} + + + +
Edit Fee
+
+
+ + + + + + + + - } - isLoading={isLoading} - isSendingMax={false} - onChooseFee={previewTransfer} - onSetSelectedFeeType={(value: BtcFeeType | null) => setSelectedFeeType(value)} - onValidateBitcoinSpend={onValidateBitcoinFeeSpend} - recipients={recipients} - recommendedFeeRate={recommendedFeeRate} - showError={showInsufficientBalanceError} - maxRecommendedFeeRate={feesList[0]?.feeRate} - px="0" + + + + + Cancel + , + , + ]} /> - +
); } 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..914e2a8d921 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,114 @@ -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, useFeesHandler } from '@app/common/fees/use-fees'; +import { useSwitchAccountSheet } from '@app/common/switch-account/use-switch-account-sheet-context'; +import { + type FeesRawData, + type RawFee, + formatBitcoinFeeForDisplay, + 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(); 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..40a52bb306b 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..371dd0f8455 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,245 @@ +import { useMemo, useState } from 'react'; +import { useNavigate } from 'react-router-dom'; + import BigNumber from 'bignumber.js'; +import { Box, HStack, styled } from 'leather-styles/jsx'; -import { Button } from '@leather.io/ui'; +import { useBitcoinBroadcastTransaction } from '@leather.io/query'; +import { + Approver, + Button, + CheckmarkIcon, + ItemLayout, + Pressable, + SkeletonLoader, +} from '@leather.io/ui'; import { createMoney, formatMoneyPadded } from '@leather.io/utils'; -import { InfoCardFooter } from '@app/components/info-card/info-card'; +import { logger } from '@shared/logger'; +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 { focusTabAndWindow } from '@app/common/focus-tab'; +import { useGenerateUnsignedNativeSegwitTx } from '@app/common/transactions/bitcoin/use-generate-bitcoin-tx'; +import { CryptoAssetItemPlaceholder } from '@app/components/crypto-asset-item/crypto-asset-item-placeholder'; +import { FeeItemIcon } from '@app/components/fees/fee-item-icon'; +import { BackgroundOverlay } from '@app/components/loading-overlay'; +import { RpcHeader } from '@app/components/rpc/rpc-header'; +import { RpcSwitchAccount } from '@app/components/rpc/rpc-switch-account'; +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 { useSignBitcoinTx } from '@app/store/accounts/blockchain/bitcoin/bitcoin.hooks'; -import { SendTransferDetails } from './components/send-transfer-details'; -import { SendTransferHeader } from './components/send-transfer-header'; -import { useRpcSendTransfer } from './use-rpc-send-transfer'; +import { RpcSendTransferError } from './components/send-transfer-error'; +import { SendTransferRecipients } from './components/send-transfer-recipients'; +import { RpcSendTransferWrapper } from './components/send-transfer-wrapper'; +import { useRpcSendTransferState } from './rpc-send-transfer-container'; 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 navigate = useNavigate(); + const amountAsMoney = createMoney(new BigNumber(totalAmount), 'BTC'); const formattedMoney = formatMoneyPadded(amountAsMoney); + const generateTx = useGenerateUnsignedNativeSegwitTx({ throwError: true }); + const signTransaction = useSignBitcoinTx(); + const btcBalance = useCurrentBtcCryptoAssetBalanceNativeSegwit(); + const { broadcastTx } = useBitcoinBroadcastTransaction(); + useBreakOnNonCompliantEntity(recipientsAddresses); + const [isSubmitted, setIsSubmitted] = useState(false); + const [isBroadcasting, setIsBroadcasting] = useState(false); + + const isInsufficientBalance = btcBalance.balance.availableBalance.amount.isLessThan( + amountAsMoney.amount + ); + + const isLoading = btcBalance.isLoadingAllData; + + 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); + } + } + + if (isLoading) { + return [ + , + , + ]; + } + + if (isInsufficientBalance) { + return []; + } + + if (isBroadcasting) { + return [ + , + ]; + } + + if (isSubmitted) { + return [ + , + ]; + } + + return [ + , + , + ]; + }, [ + amountAsMoney, + broadcastTx, + generateTx, + isLoading, + isInsufficientBalance, + isBroadcasting, + isSubmitted, + navigate, + recipients, + requestId, + selectedFeeData.feeRate, + signTransaction, + tabId, + utxos, + ]); + + const showOverlay = isBroadcasting || isSubmitted; + return ( - <> - - - - - - + + + + + + { + e.preventDefault(); + void analytics.track('user_clicked_requested_by_link', { endpoint: 'sendTransfer' }); + focusTabAndWindow(tabId); + }} + /> + + + + + + + Fee + + {isLoadingFees || !selectedFeeData ? ( + + ) : ( + } + titleLeft={selectedFeeData.titleLeft} + captionLeft={selectedFeeData.captionLeft} + titleRight={selectedFeeData.titleRight} + captionRight={selectedFeeData.captionRight} + /> + )} + + + + + + + Total spend + + + {formattedMoney} + + + + + + + ); } 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/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/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 + +
+
); };