From 1b6d9b3540527cb8e41518da05e8a4941b615c97 Mon Sep 17 00:00:00 2001 From: Anastasios Date: Wed, 18 Dec 2024 12:37:05 +0400 Subject: [PATCH] feat: redesign send rpc transfer flow --- package.json | 4 +- pnpm-lock.yaml | 134 ++++++-- src/app/common/fees/use-fees.ts | 80 +++++ .../use-switch-account-sheet-context.ts | 1 + .../coinselect/local-coin-selection.ts | 2 +- .../bitcoin/use-generate-bitcoin-tx.ts | 12 +- .../account/account-bitcoin-address.tsx | 16 + ...ove-bitcoin-transaction-switch-account.tsx | 63 ++++ .../approve-transaction-error.tsx | 15 + .../approve-transaction-header.tsx | 27 ++ .../approve-transaction-recipients.tsx | 53 +++ .../approve-transaction-selected-fee.tsx | 38 ++ .../approve-transaction-switch-account.tsx | 14 + .../approve-transaction-title.tsx | 24 ++ .../get-approve-actions.tsx | 60 ++++ .../bitcoin-fees.utils.spec.ts | 82 +++++ .../bitcoin-fees-list/bitcoin-fees.utils.ts | 57 +++ .../format-bitcoin-fee.spec.ts | 52 +++ .../bitcoin-fees-list/format-bitcoin-fee.ts | 36 ++ .../use-bitcoin-fees-data.ts | 130 +++++++ ...e-bitcoin-fees-list-multiple-recipients.ts | 127 ------- .../use-bitcoin-fees-list.ts | 1 + .../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 ++ .../connect-ledger-asset-button.tsx | 17 +- .../send-transfer-confirmation-details.tsx | 32 -- .../components/send-transfer-details.tsx | 40 --- .../components/send-transfer-fallback.tsx | 16 + .../components/send-transfer-header.tsx | 29 -- .../components/send-transfer-wrapper.tsx | 47 +++ .../rpc-send-transfer-choose-fee.tsx | 161 ++++----- .../rpc-send-transfer-confirmation.tsx | 160 --------- .../rpc-send-transfer-container.tsx | 110 +++++- .../rpc-send-transfer-summary.tsx | 99 ------ .../rpc-send-transfer.routes.tsx | 14 +- .../rpc-send-transfer/rpc-send-transfer.tsx | 126 ++++++- .../use-rpc-send-transfer.ts | 7 +- .../use-send-transfer-approve-actions.tsx | 139 ++++++++ src/app/routes/rpc-routes.tsx | 2 + test-app/src/components/bitcoin.tsx | 325 +++++++++--------- tests/specs/send/send-sip10.spec.ts | 2 + 47 files changed, 1808 insertions(+), 818 deletions(-) create mode 100644 src/app/common/fees/use-fees.ts create mode 100644 src/app/components/account/account-bitcoin-address.tsx create mode 100644 src/app/components/approve-transaction/approve-bitcoin-transaction-switch-account.tsx create mode 100644 src/app/components/approve-transaction/approve-transaction-error.tsx create mode 100644 src/app/components/approve-transaction/approve-transaction-header.tsx create mode 100644 src/app/components/approve-transaction/approve-transaction-recipients.tsx create mode 100644 src/app/components/approve-transaction/approve-transaction-selected-fee.tsx create mode 100644 src/app/components/approve-transaction/approve-transaction-switch-account.tsx create mode 100644 src/app/components/approve-transaction/approve-transaction-title.tsx create mode 100644 src/app/components/approve-transaction/get-approve-actions.tsx create mode 100644 src/app/components/bitcoin-fees-list/bitcoin-fees.utils.spec.ts create mode 100644 src/app/components/bitcoin-fees-list/bitcoin-fees.utils.ts create mode 100644 src/app/components/bitcoin-fees-list/format-bitcoin-fee.spec.ts create mode 100644 src/app/components/bitcoin-fees-list/format-bitcoin-fee.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 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-fallback.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-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 create mode 100644 src/app/pages/rpc-send-transfer/use-send-transfer-approve-actions.tsx diff --git a/package.json b/package.json index 39f167a87b1..d24b2d39397 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.44.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.4.0", "@ls-lint/ls-lint": "2.2.3", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6b8e4bbf75f..fb5a7774d11 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.44.2 + version: 1.44.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.15.1': + resolution: {integrity: sha512-qgkUHOz/10jxTsprhwzBb3Iml9BkYFWtWKeomNh0nK3zVto2zb8mn6PXvcthVs5FYIYkxou6DfIAUDeUO0czDQ==} '@leather.io/crypto@1.6.12': resolution: {integrity: sha512-ZEhUVhdq/m2pIhi8PwvRKyjwYdcmXcRJBOLSO1RMSu8N93GTEqfvxkqTlu88c2ReNt5C62+TwWM6hbZkU2BV6Q==} @@ -3305,14 +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.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==} @@ -3328,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.3.1': + resolution: {integrity: sha512-wfkwhDkcVsl8d8GSKDsMZbY+jICzhpibcg/Gvg35fApXXqluBMNPhff9EMcJ/SIBHPPypoY0CJ3hlJqRP+ZjRQ==} '@leather.io/rpc@2.4.0': resolution: {integrity: sha512-S9PYtyOnZ9LJL8ZYsEPHUWmVkVL/E7oAfNLunFY7zVI0tJUl45OXJVCOjYRRKDKOqdx2pRpdqzeUSxRZvJdyVA==} @@ -3340,8 +3337,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.44.2': + resolution: {integrity: sha512-2JnjOni78TlykiEu8NGtivNKbDL1JpFtnhEMeWny0YEREh5B8Im2OK9o4iUsILP6+VnqYW4SJmcxtaSWC7VI5A==} '@leather.io/utils@0.19.1': resolution: {integrity: sha512-bwD3/4Rt3UOL3pvettqNon+zqS5S8K6z3AoAEwkcYS77DI/q4kzH5T/3nOOGpcWda1/R453mqRCIObRxecIWFA==} @@ -3349,8 +3346,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.22.3': + resolution: {integrity: sha512-5dDWacnkjRtT4kILxY5AT6EyBx8HYS3o80ZVvvY6GuTJ4mlC4z+hF7ci9iA5NSF7coPm1eC0sv5Q6nGWVHEiag==} '@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: @@ -14512,12 +14531,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==} @@ -19094,9 +19116,9 @@ snapshots: dependencies: '@leather.io/models': 0.21.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': dependencies: @@ -19134,19 +19156,13 @@ snapshots: bignumber.js: 9.1.2 zod: 3.23.8 - '@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.24.1': 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: @@ -19204,9 +19220,9 @@ snapshots: '@leather.io/models': 0.21.0 zod: 3.23.8 - '@leather.io/rpc@2.1.20': + '@leather.io/rpc@2.3.1': dependencies: - '@leather.io/models': 0.22.0 + '@leather.io/models': 0.24.1 zod: 3.23.8 '@leather.io/rpc@2.4.0(encoding@0.1.13)': @@ -19231,18 +19247,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.44.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.22.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) @@ -19303,11 +19320,11 @@ snapshots: '@leather.io/rpc': 2.1.19 bignumber.js: 9.1.2 - '@leather.io/utils@0.20.0': + '@leather.io/utils@0.22.3': 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.3.1 bignumber.js: 9.1.2 '@ledgerhq/devices@8.4.2': @@ -19897,6 +19914,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 +20004,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 +20092,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 +20731,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 @@ -25015,7 +25075,7 @@ snapshots: aria-hidden@1.2.4: dependencies: - tslib: 2.7.0 + tslib: 2.8.1 aria-query@5.3.0: dependencies: @@ -25729,7 +25789,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 +31583,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..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/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/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/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-fallback.tsx b/src/app/pages/rpc-send-transfer/components/send-transfer-fallback.tsx new file mode 100644 index 00000000000..ec739e92874 --- /dev/null +++ b/src/app/pages/rpc-send-transfer/components/send-transfer-fallback.tsx @@ -0,0 +1,16 @@ +import { Stack, styled } from 'leather-styles/jsx'; + +import { useWalletType } from '@app/common/use-wallet-type'; + +export function SendTransferFallback() { + const { whenWallet } = useWalletType(); + + return whenWallet({ + software: null, + ledger: ( + + Connect Bitcoin app to send BTC + + ), + }); +} 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-wrapper.tsx b/src/app/pages/rpc-send-transfer/components/send-transfer-wrapper.tsx new file mode 100644 index 00000000000..e9180329a36 --- /dev/null +++ b/src/app/pages/rpc-send-transfer/components/send-transfer-wrapper.tsx @@ -0,0 +1,47 @@ +import { motion, useAnimationControls } from 'framer-motion'; +import { Box, Flex, Stack } from 'leather-styles/jsx'; + +import { LeatherLogomarkIcon } from '@leather.io/ui'; + +import { BackgroundOverlay } from '@app/components/loading-overlay'; + +interface RpcSendTransferWrapperProps { + children: React.ReactNode; + showOverlay?: boolean; +} + +export function RpcSendTransferWrapper({ + children, + showOverlay = false, +}: RpcSendTransferWrapperProps) { + 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..53e17852b29 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,105 @@ -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, styled } from 'leather-styles/jsx'; -import { logger } from '@shared/logger'; -import type { TransferRecipient } from '@shared/models/form.model'; -import { RouteUrls } from '@shared/route-urls'; +import { useCryptoCurrencyMarketDataMeanAverage } from '@leather.io/query'; +import { Approver, Button } from '@leather.io/ui'; -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'; +import { RouteUrls } from '@shared/route-urls'; -function useRpcSendTransferFeeState() { - const amountAsMoney = useLocationStateWithCache('amountAsMoney') as Money; - const recipients = useLocationStateWithCache('recipients') as TransferRecipient[]; - const utxos = useLocationStateWithCache('utxos') as UtxoResponseItem[]; +import { type RawFee } from '@app/components/bitcoin-fees-list/bitcoin-fees.utils'; +import { formatBitcoinFeeForDisplay } from '@app/components/bitcoin-fees-list/format-bitcoin-fee'; +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(); + const insufficientBalance = btcBalance.balance.availableBalance.amount.isLessThan( + selectedFeeData?.baseUnitsValue ?? 0 + ); - 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) { + const { type } = rawFee; - const feeRowValue = formFeeRowValue(feeRate, isCustomFee); + const { feeType, titleLeft, captionLeft, titleRight, captionRight } = + formatBitcoinFeeForDisplay({ rawFee, marketData: btcMarketData }); - navigate(RouteUrls.RpcSendTransferConfirmation, { - state: { - fee: feeValue, - recipients, - time, - tx: tx.hex, - feeRowValue, + return { + feeType, + isSelected: editFeeSelected === type, + isInsufficientBalance: insufficientBalance, + onSelect: () => { + setEditFeeSelected(type); }, - }); + titleLeft, + captionLeft, + titleRight, + 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" + + + + + Back + , + , + ]} /> - +
); } 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..5270e566861 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 @@ -2,28 +2,28 @@ import { Route } from 'react-router-dom'; import { RouteUrls } from '@shared/route-urls'; +import { BitcoinNativeSegwitAccountLoader } from '@app/components/loaders/bitcoin-account-loader'; import { ledgerBitcoinTxSigningRoutes } from '@app/features/ledger/flows/bitcoin-tx-signing/ledger-bitcoin-sign-tx-container'; import { AccountGate } from '@app/routes/account-gate'; +import { SendTransferFallback } from './components/send-transfer-fallback'; 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/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 + +
+
); }; 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();