diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index c6ab6ce9d..694746aa6 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -68,6 +68,7 @@ jobs: ARTIFACT_URL="https://github.com/$OWNER/$REPO/actions/runs/$WORKFLOW_ID" echo "ARTIFACT_URL=$ARTIFACT_URL" >> $GITHUB_ENV - name: Delete old bot comments + env: PR_ID: ${{ github.event.pull_request.number }} run: | gh api \ diff --git a/package-lock.json b/package-lock.json index 69533e729..4ab9c0e4b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,17 +1,18 @@ { "name": "xverse-web-extension", - "version": "0.27.0", + "version": "0.28.0", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "xverse-web-extension", - "version": "0.27.0", + "version": "0.28.0", "dependencies": { "@ledgerhq/hw-transport-webusb": "^6.27.13", "@phosphor-icons/react": "^2.0.10", "@react-spring/web": "^9.6.1", - "@secretkeylabs/xverse-core": "7.0.0", + "@scure/btc-signer": "^1.1.1", + "@secretkeylabs/xverse-core": "8.0.1", "@stacks/connect": "7.4.1", "@stacks/stacks-blockchain-api-types": "6.1.1", "@stacks/transactions": "6.9.0", @@ -27,6 +28,7 @@ "axios": "^1.1.3", "bignumber.js": "^9.1.0", "bip39": "^3.0.3", + "buffer": "6.0.3", "c32check": "^2.0.0", "classnames": "^2.3.2", "crypto-browserify": "^3.12.0", @@ -117,7 +119,8 @@ "tsc-files": "^1.1.4", "tsconfig-paths-webpack-plugin": "^4.0.0", "type-fest": "^2.19.0", - "typescript": "^4.8.2", + "typescript": "^5.0.0", + "typescript-plugin-styled-components": "^3.0.0", "vitest": "^0.34.6", "webpack": "^5.89.0", "webpack-cli": "^4.0.0", @@ -1286,20 +1289,20 @@ "dev": true }, "node_modules/@noble/curves": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.2.0.tgz", - "integrity": "sha512-oYclrNgRaM9SsBUBVbb8M6DTV7ZHRTKugureoYEncY5c65HOmRzvSiTE3y5CYaPYJA/GVkrhXEoF0M3Ya9PMnw==", + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.3.0.tgz", + "integrity": "sha512-t01iSXPuN+Eqzb4eBX0S5oubSqXbK/xXa1Ne18Hj8f9pStxztHCE2gfboSp/dZRLSqfuLpRK2nDXDK+W9puocA==", "dependencies": { - "@noble/hashes": "1.3.2" + "@noble/hashes": "1.3.3" }, "funding": { "url": "https://paulmillr.com/funding/" } }, "node_modules/@noble/hashes": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.3.2.tgz", - "integrity": "sha512-MVC8EAQp7MvEcm30KWENFjgR+Mkmf+D189XJTkFIlwohU5hcBbn1ZkKq7KVTi2Hme3PMGF390DaL52beVrIihQ==", + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.3.3.tgz", + "integrity": "sha512-V7/fPHgl+jsVPXqqeOzT8egNj2iBIVt+ECeMMG8TdcnTikP3oaBtUVqpT/gYCR68aEBJSF+XbYUxStjbFMqIIA==", "engines": { "node": ">= 16" }, @@ -1657,9 +1660,9 @@ ] }, "node_modules/@scure/base": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/@scure/base/-/base-1.1.3.tgz", - "integrity": "sha512-/+SgoRjLq7Xlf0CWuLHq2LUZeL/w65kfzAPG5NH9pcmBhs+nunQTn4gvdwgMTIXnt9b2C/1SeL2XiysZEyIC9Q==", + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@scure/base/-/base-1.1.4.tgz", + "integrity": "sha512-wznebWtt+ejH8el87yuD4i9xLSbYZXf1Pe4DY0o/zq/eg5I0VQVXVbFs6XIM0pNVCJ/uE3t5wI9kh90mdLUxtw==", "funding": { "url": "https://paulmillr.com/funding/" } @@ -1718,30 +1721,52 @@ ] }, "node_modules/@scure/btc-signer": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@scure/btc-signer/-/btc-signer-1.1.0.tgz", - "integrity": "sha512-kCX7WaaTJr0VZIXDvaY0wNZfzZoZuLnPz4G0qmKXN8bnNx5M86wb1cce9XrZcfzb0jrVAbZJqNpxmE1e7Ka2hA==", + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@scure/btc-signer/-/btc-signer-1.1.1.tgz", + "integrity": "sha512-oXDbQFnGEQNHLNcTxM/MHXaQnZzSmoxunwXQbBr2Eg9ALAjYB9xvUa+EywkUSbU82Gn0/OEm0Gg9dz5HYifAIg==", "dependencies": { - "@noble/curves": "~1.2.0", - "@noble/hashes": "~1.3.1", - "@scure/base": "~1.1.3", - "micro-packed": "~0.3.2" + "@noble/curves": "~1.3.0", + "@noble/hashes": "~1.3.3", + "@scure/base": "~1.1.4", + "micro-packed": "~0.4.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@scure/btc-signer/node_modules/@noble/curves": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.3.0.tgz", + "integrity": "sha512-t01iSXPuN+Eqzb4eBX0S5oubSqXbK/xXa1Ne18Hj8f9pStxztHCE2gfboSp/dZRLSqfuLpRK2nDXDK+W9puocA==", + "dependencies": { + "@noble/hashes": "1.3.3" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@scure/btc-signer/node_modules/@noble/hashes": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.3.3.tgz", + "integrity": "sha512-V7/fPHgl+jsVPXqqeOzT8egNj2iBIVt+ECeMMG8TdcnTikP3oaBtUVqpT/gYCR68aEBJSF+XbYUxStjbFMqIIA==", + "engines": { + "node": ">= 16" }, "funding": { "url": "https://paulmillr.com/funding/" } }, "node_modules/@secretkeylabs/xverse-core": { - "version": "7.0.0", - "resolved": "https://npm.pkg.github.com/download/@secretkeylabs/xverse-core/7.0.0/a9d4470c0bee31b2b751b0f2a3e064d51453ddd4", - "integrity": "sha512-8z5g5dHFin0d9695EwI0t6a/Ji7vzUenCq1AHpTtsj5Z1/SlH6Oa7p95fyJ1usBH99M5NQAYFMBGkjOcNE+jQw==", + "version": "8.0.1", + "resolved": "https://npm.pkg.github.com/download/@secretkeylabs/xverse-core/8.0.1/4ad617c75c0435ca61da77d8d0cf0d4b961d8d87", + "integrity": "sha512-Y6qH74fUZ4Wv8CX/7Ax/9DMf7HfW4GYIgdsn2a8mgCMyAxre7MPcomxmzWSvK8CViHim18b6K2P+wNOOE4l70Q==", "license": "ISC", "dependencies": { "@bitcoinerlab/secp256k1": "^1.0.2", "@noble/curves": "^1.2.0", "@noble/secp256k1": "^1.7.1", "@scure/base": "^1.1.1", - "@scure/btc-signer": "1.1.0", + "@scure/btc-signer": "1.1.1", "@stacks/auth": "^6.9.0", "@stacks/connect": "^7.4.1", "@stacks/encryption": "6.9.0", @@ -1750,6 +1775,7 @@ "@stacks/transactions": "6.9.0", "@stacks/wallet-sdk": "^6.9.0", "@zondax/ledger-stacks": "^1.0.4", + "async-mutex": "^0.4.0", "axios": "1.6.2", "base64url": "^3.0.1", "bip32": "^4.0.0", @@ -1801,22 +1827,58 @@ "dev": true }, "node_modules/@stacks/auth": { - "version": "6.9.0", - "resolved": "https://registry.npmjs.org/@stacks/auth/-/auth-6.9.0.tgz", - "integrity": "sha512-tBOB+H/96TUNK9pKmr1YQoiIItUFp2ms5RCNYPSjy3/lbIYYJYtw/O2fOS78fVQvCCpuObhhO65AVsrE/IzQeg==", - "dependencies": { - "@stacks/common": "^6.8.1", - "@stacks/encryption": "^6.9.0", - "@stacks/network": "^6.8.1", - "@stacks/profile": "^6.9.0", + "version": "6.10.0", + "resolved": "https://registry.npmjs.org/@stacks/auth/-/auth-6.10.0.tgz", + "integrity": "sha512-5/FdD1btPovJTckVfaNdD+J/JxtKwn1jVOcyiuDwOABMOMGykgH8ZdXc0Kho2POZoOwKTIrG8sYH5TolSoH7BA==", + "dependencies": { + "@stacks/common": "^6.10.0", + "@stacks/encryption": "^6.10.0", + "@stacks/network": "^6.10.0", + "@stacks/profile": "^6.10.0", "cross-fetch": "^3.1.5", "jsontokens": "^4.0.1" } }, + "node_modules/@stacks/auth/node_modules/@noble/hashes": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.1.5.tgz", + "integrity": "sha512-LTMZiiLc+V4v1Yi16TD6aX2gmtKszNye0pQgbaLqkvhIqP7nVsSaJsWloGQjJfJ8offaoP5GtX3yY5swbcJxxQ==", + "funding": [ + { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + ] + }, + "node_modules/@stacks/auth/node_modules/@stacks/encryption": { + "version": "6.11.0", + "resolved": "https://registry.npmjs.org/@stacks/encryption/-/encryption-6.11.0.tgz", + "integrity": "sha512-VfBkrwmCRppCasJo+R/hWfC7vgS6GmfPyoTeDsoYlfRRXz/auFbEdRaaruFPtAda/1nKdDOZ9UZEMOp5AIw0IQ==", + "dependencies": { + "@noble/hashes": "1.1.5", + "@noble/secp256k1": "1.7.1", + "@scure/bip39": "1.1.0", + "@stacks/common": "^6.10.0", + "@types/node": "^18.0.4", + "base64-js": "^1.5.1", + "bs58": "^5.0.0", + "ripemd160-min": "^0.0.6", + "varuint-bitcoin": "^1.1.2" + } + }, + "node_modules/@stacks/auth/node_modules/@stacks/network": { + "version": "6.10.0", + "resolved": "https://registry.npmjs.org/@stacks/network/-/network-6.10.0.tgz", + "integrity": "sha512-mbiZ8nlsyy77ndmBdaqhHXii22IFdK4ThRcOQs9j/O00DkAr04jCM4GV5Q+VLUnZ9OBoJq7yOV7Pf6jglh+0hw==", + "dependencies": { + "@stacks/common": "^6.10.0", + "cross-fetch": "^3.1.5" + } + }, "node_modules/@stacks/common": { - "version": "6.8.1", - "resolved": "https://registry.npmjs.org/@stacks/common/-/common-6.8.1.tgz", - "integrity": "sha512-ewL9GLZNQYa5a/3K4xSHlHIgHkD4rwWW/QEaPId8zQIaL+1O9qCaF4LX9orNQeOmEk8kvG0x2xGV54fXKCZeWQ==", + "version": "6.10.0", + "resolved": "https://registry.npmjs.org/@stacks/common/-/common-6.10.0.tgz", + "integrity": "sha512-6x5Z7AKd9/kj3+DYE9xIDIkFLHihBH614i2wqrZIjN02WxVo063hWSjIlUxlx8P4gl6olVzlOy5LzhLJD9OP0A==", "dependencies": { "@types/bn.js": "^5.1.0", "@types/node": "^18.0.4" @@ -1851,7 +1913,7 @@ "@noble/hashes": "1.1.5", "@noble/secp256k1": "1.7.1", "@scure/bip39": "1.1.0", - "@stacks/common": "^6.8.1", + "@stacks/common": "^6.10.0", "@types/node": "^18.0.4", "base64-js": "^1.5.1", "bs58": "^5.0.0", @@ -1880,36 +1942,105 @@ } }, "node_modules/@stacks/profile": { - "version": "6.9.0", - "resolved": "https://registry.npmjs.org/@stacks/profile/-/profile-6.9.0.tgz", - "integrity": "sha512-sIR60DsAHi8C6zGqKqSe1r2hXTMHgwrJkX3fAaP3de40KeplZ2bkE+0B83yismEeU2baNc+AukyVvWJv0PfP0A==", + "version": "6.10.0", + "resolved": "https://registry.npmjs.org/@stacks/profile/-/profile-6.10.0.tgz", + "integrity": "sha512-n2H1Imuu7UfwIvUv7xgHiSb3CKfbP4H+Jzy1+w73njYX7glt60uQ1SjWef7gvMgMFQNTAPELqitweyA+UII6Hg==", "dependencies": { - "@stacks/common": "^6.8.1", - "@stacks/network": "^6.8.1", - "@stacks/transactions": "^6.9.0", + "@stacks/common": "^6.10.0", + "@stacks/network": "^6.10.0", + "@stacks/transactions": "^6.10.0", "jsontokens": "^4.0.1", "schema-inspector": "^2.0.2", "zone-file": "^2.0.0-beta.3" } }, + "node_modules/@stacks/profile/node_modules/@noble/hashes": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.1.5.tgz", + "integrity": "sha512-LTMZiiLc+V4v1Yi16TD6aX2gmtKszNye0pQgbaLqkvhIqP7nVsSaJsWloGQjJfJ8offaoP5GtX3yY5swbcJxxQ==", + "funding": [ + { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + ] + }, + "node_modules/@stacks/profile/node_modules/@stacks/network": { + "version": "6.10.0", + "resolved": "https://registry.npmjs.org/@stacks/network/-/network-6.10.0.tgz", + "integrity": "sha512-mbiZ8nlsyy77ndmBdaqhHXii22IFdK4ThRcOQs9j/O00DkAr04jCM4GV5Q+VLUnZ9OBoJq7yOV7Pf6jglh+0hw==", + "dependencies": { + "@stacks/common": "^6.10.0", + "cross-fetch": "^3.1.5" + } + }, + "node_modules/@stacks/profile/node_modules/@stacks/transactions": { + "version": "6.11.0", + "resolved": "https://registry.npmjs.org/@stacks/transactions/-/transactions-6.11.0.tgz", + "integrity": "sha512-+zIDqn9j4H/+o1ER8C9rFpig1fyrQcj2hVGNIrp+YbpPyja+cxv3fPk6kI/gePzwggzxRgUkIWhBc+mZAXuXyQ==", + "dependencies": { + "@noble/hashes": "1.1.5", + "@noble/secp256k1": "1.7.1", + "@stacks/common": "^6.10.0", + "@stacks/network": "^6.10.0", + "c32check": "^2.0.0", + "lodash.clonedeep": "^4.5.0" + } + }, "node_modules/@stacks/stacks-blockchain-api-types": { "version": "6.1.1", "resolved": "https://registry.npmjs.org/@stacks/stacks-blockchain-api-types/-/stacks-blockchain-api-types-6.1.1.tgz", "integrity": "sha512-Mw5dBPx3DySPupwaq0iBdm1WdEVXIfhjUVaTjI2iSyzWz4Fgs3U7JCaAezLbgNu7Q69c/ZN4JUDWuo9FVjy7oA==" }, "node_modules/@stacks/storage": { - "version": "6.9.0", - "resolved": "https://registry.npmjs.org/@stacks/storage/-/storage-6.9.0.tgz", - "integrity": "sha512-aZ3tOnwRSk5cHQh9ButhfHDvAylNVxPRQzeSB8PydHfyib4XL7fSAJwizzEWNgJV4dovqW2Nsy8gm/4rM/oFKQ==", - "dependencies": { - "@stacks/auth": "^6.9.0", - "@stacks/common": "^6.8.1", - "@stacks/encryption": "^6.9.0", - "@stacks/network": "^6.8.1", + "version": "6.10.0", + "resolved": "https://registry.npmjs.org/@stacks/storage/-/storage-6.10.0.tgz", + "integrity": "sha512-DLinjJkCN9Q7Yu5yelcXfP89CIDZ4TqXisjJYRqRlFfQdoDWDFZT5vpM2C8U2xDmgzVxfjg90HmQpIjTeIMSnw==", + "dependencies": { + "@stacks/auth": "^6.10.0", + "@stacks/common": "^6.10.0", + "@stacks/encryption": "^6.10.0", + "@stacks/network": "^6.10.0", "base64-js": "^1.5.1", "jsontokens": "^4.0.1" } }, + "node_modules/@stacks/storage/node_modules/@noble/hashes": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.1.5.tgz", + "integrity": "sha512-LTMZiiLc+V4v1Yi16TD6aX2gmtKszNye0pQgbaLqkvhIqP7nVsSaJsWloGQjJfJ8offaoP5GtX3yY5swbcJxxQ==", + "funding": [ + { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + ] + }, + "node_modules/@stacks/storage/node_modules/@stacks/encryption": { + "version": "6.11.0", + "resolved": "https://registry.npmjs.org/@stacks/encryption/-/encryption-6.11.0.tgz", + "integrity": "sha512-VfBkrwmCRppCasJo+R/hWfC7vgS6GmfPyoTeDsoYlfRRXz/auFbEdRaaruFPtAda/1nKdDOZ9UZEMOp5AIw0IQ==", + "dependencies": { + "@noble/hashes": "1.1.5", + "@noble/secp256k1": "1.7.1", + "@scure/bip39": "1.1.0", + "@stacks/common": "^6.10.0", + "@types/node": "^18.0.4", + "base64-js": "^1.5.1", + "bs58": "^5.0.0", + "ripemd160-min": "^0.0.6", + "varuint-bitcoin": "^1.1.2" + } + }, + "node_modules/@stacks/storage/node_modules/@stacks/network": { + "version": "6.10.0", + "resolved": "https://registry.npmjs.org/@stacks/network/-/network-6.10.0.tgz", + "integrity": "sha512-mbiZ8nlsyy77ndmBdaqhHXii22IFdK4ThRcOQs9j/O00DkAr04jCM4GV5Q+VLUnZ9OBoJq7yOV7Pf6jglh+0hw==", + "dependencies": { + "@stacks/common": "^6.10.0", + "cross-fetch": "^3.1.5" + } + }, "node_modules/@stacks/transactions": { "version": "6.9.0", "resolved": "https://registry.npmjs.org/@stacks/transactions/-/transactions-6.9.0.tgz", @@ -3494,29 +3625,6 @@ "node": ">=8" } }, - "node_modules/@zondax/ledger-stacks/node_modules/c32check/node_modules/buffer": { - "version": "5.7.1", - "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", - "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "dependencies": { - "base64-js": "^1.3.1", - "ieee754": "^1.1.13" - } - }, "node_modules/abab": { "version": "2.0.6", "resolved": "https://registry.npmjs.org/abab/-/abab-2.0.6.tgz", @@ -3948,6 +4056,14 @@ "lodash": "^4.17.14" } }, + "node_modules/async-mutex": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/async-mutex/-/async-mutex-0.4.0.tgz", + "integrity": "sha512-eJFZ1YhRR8UN8eBLoNzcDPcy/jqjsg6I1AP+KvWQX80BqOSW1oJPJXDylPUEeMr2ZQvHgnQ//Lp6f3RQ1zI7HA==", + "dependencies": { + "tslib": "^2.4.0" + } + }, "node_modules/asynckit": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", @@ -5280,29 +5396,6 @@ "buffer": "^5.6.0" } }, - "node_modules/cross-sha256/node_modules/buffer": { - "version": "5.7.1", - "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", - "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "dependencies": { - "base64-js": "^1.3.1", - "ieee754": "^1.1.13" - } - }, "node_modules/cross-spawn": { "version": "7.0.3", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz", @@ -9717,9 +9810,9 @@ } }, "node_modules/micro-packed": { - "version": "0.3.2", - "resolved": "https://registry.npmjs.org/micro-packed/-/micro-packed-0.3.2.tgz", - "integrity": "sha512-D1Bq0/lVOzdxhnX5vylCxZpdw5LylH7Vd81py0DfRsKUP36XYpwvy8ZIsECVo3UfnoROn8pdKqkOzL7Cd82sGA==", + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/micro-packed/-/micro-packed-0.4.0.tgz", + "integrity": "sha512-H1+8SUMwcm68RXLOj3t5S8wXVf49FuR5m9IAG7XZ1XUOexbnQriyql5lk2I3fx/KyYf48LWNf5Lnbc2OjyQFMw==", "funding": [ { "type": "individual", @@ -9727,7 +9820,7 @@ } ], "dependencies": { - "@scure/base": "~1.1.1" + "@scure/base": "~1.1.3" } }, "node_modules/micromatch": { @@ -13127,8 +13220,7 @@ "node_modules/tslib": { "version": "2.6.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.6.1.tgz", - "integrity": "sha512-t0hLfiEKfMUoqhG+U1oid7Pva4bbDPHYfJNiB7BiIjRkj1pyC++4N3huJfqY6aRH6VTB0rvtzQwjM4K6qpfOig==", - "dev": true + "integrity": "sha512-t0hLfiEKfMUoqhG+U1oid7Pva4bbDPHYfJNiB7BiIjRkj1pyC++4N3huJfqY6aRH6VTB0rvtzQwjM4K6qpfOig==" }, "node_modules/tsutils": { "version": "3.21.0", @@ -13268,16 +13360,25 @@ "integrity": "sha512-7uc1O8h1M1g0rArakJdf0uLRSSgFcYexrVoKo+bzJd32gd4gDy2L/Z+8/FjPnU9ydY3pEnVPtr9FyscYY60K1g==" }, "node_modules/typescript": { - "version": "4.9.5", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.9.5.tgz", - "integrity": "sha512-1FXk9E2Hm+QzZQ7z+McJiHL4NW1F2EzMu9Nq9i3zAaGqibafqYwCVU6WyWAuyQRRzOlxou8xZSyXLEN8oKj24g==", + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.3.3.tgz", + "integrity": "sha512-pXWcraxM0uxAS+tN0AG/BF2TyqmHO014Z070UsJ+pFvYuRSq8KH8DmWpnbXe0pEPDHXZV3FcAbJkijJ5oNEnWw==", "dev": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" }, "engines": { - "node": ">=4.2.0" + "node": ">=14.17" + } + }, + "node_modules/typescript-plugin-styled-components": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/typescript-plugin-styled-components/-/typescript-plugin-styled-components-3.0.0.tgz", + "integrity": "sha512-QWlhTl6NqsFxtJyxn7pJjm3RhgzXSByUftZ3AoQClrMMpa4yAaHuJKTN1gFpH3Ti+Rwm56fNUfG9pXSBU+WW3A==", + "dev": true, + "peerDependencies": { + "typescript": "~4.8 || 5" } }, "node_modules/ufo": { @@ -15114,17 +15215,17 @@ "dev": true }, "@noble/curves": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.2.0.tgz", - "integrity": "sha512-oYclrNgRaM9SsBUBVbb8M6DTV7ZHRTKugureoYEncY5c65HOmRzvSiTE3y5CYaPYJA/GVkrhXEoF0M3Ya9PMnw==", + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.3.0.tgz", + "integrity": "sha512-t01iSXPuN+Eqzb4eBX0S5oubSqXbK/xXa1Ne18Hj8f9pStxztHCE2gfboSp/dZRLSqfuLpRK2nDXDK+W9puocA==", "requires": { - "@noble/hashes": "1.3.2" + "@noble/hashes": "1.3.3" } }, "@noble/hashes": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.3.2.tgz", - "integrity": "sha512-MVC8EAQp7MvEcm30KWENFjgR+Mkmf+D189XJTkFIlwohU5hcBbn1ZkKq7KVTi2Hme3PMGF390DaL52beVrIihQ==" + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.3.3.tgz", + "integrity": "sha512-V7/fPHgl+jsVPXqqeOzT8egNj2iBIVt+ECeMMG8TdcnTikP3oaBtUVqpT/gYCR68aEBJSF+XbYUxStjbFMqIIA==" }, "@noble/secp256k1": { "version": "1.7.1", @@ -15323,9 +15424,9 @@ "optional": true }, "@scure/base": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/@scure/base/-/base-1.1.3.tgz", - "integrity": "sha512-/+SgoRjLq7Xlf0CWuLHq2LUZeL/w65kfzAPG5NH9pcmBhs+nunQTn4gvdwgMTIXnt9b2C/1SeL2XiysZEyIC9Q==" + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@scure/base/-/base-1.1.4.tgz", + "integrity": "sha512-wznebWtt+ejH8el87yuD4i9xLSbYZXf1Pe4DY0o/zq/eg5I0VQVXVbFs6XIM0pNVCJ/uE3t5wI9kh90mdLUxtw==" }, "@scure/bip32": { "version": "1.1.3", @@ -15361,26 +15462,41 @@ } }, "@scure/btc-signer": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@scure/btc-signer/-/btc-signer-1.1.0.tgz", - "integrity": "sha512-kCX7WaaTJr0VZIXDvaY0wNZfzZoZuLnPz4G0qmKXN8bnNx5M86wb1cce9XrZcfzb0jrVAbZJqNpxmE1e7Ka2hA==", + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@scure/btc-signer/-/btc-signer-1.1.1.tgz", + "integrity": "sha512-oXDbQFnGEQNHLNcTxM/MHXaQnZzSmoxunwXQbBr2Eg9ALAjYB9xvUa+EywkUSbU82Gn0/OEm0Gg9dz5HYifAIg==", "requires": { - "@noble/curves": "~1.2.0", - "@noble/hashes": "~1.3.1", - "@scure/base": "~1.1.3", - "micro-packed": "~0.3.2" + "@noble/curves": "~1.3.0", + "@noble/hashes": "~1.3.3", + "@scure/base": "~1.1.4", + "micro-packed": "~0.4.0" + }, + "dependencies": { + "@noble/curves": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.3.0.tgz", + "integrity": "sha512-t01iSXPuN+Eqzb4eBX0S5oubSqXbK/xXa1Ne18Hj8f9pStxztHCE2gfboSp/dZRLSqfuLpRK2nDXDK+W9puocA==", + "requires": { + "@noble/hashes": "1.3.3" + } + }, + "@noble/hashes": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.3.3.tgz", + "integrity": "sha512-V7/fPHgl+jsVPXqqeOzT8egNj2iBIVt+ECeMMG8TdcnTikP3oaBtUVqpT/gYCR68aEBJSF+XbYUxStjbFMqIIA==" + } } }, "@secretkeylabs/xverse-core": { - "version": "7.0.0", - "resolved": "https://npm.pkg.github.com/download/@secretkeylabs/xverse-core/7.0.0/a9d4470c0bee31b2b751b0f2a3e064d51453ddd4", - "integrity": "sha512-8z5g5dHFin0d9695EwI0t6a/Ji7vzUenCq1AHpTtsj5Z1/SlH6Oa7p95fyJ1usBH99M5NQAYFMBGkjOcNE+jQw==", + "version": "8.0.1", + "resolved": "https://npm.pkg.github.com/download/@secretkeylabs/xverse-core/8.0.1/4ad617c75c0435ca61da77d8d0cf0d4b961d8d87", + "integrity": "sha512-Y6qH74fUZ4Wv8CX/7Ax/9DMf7HfW4GYIgdsn2a8mgCMyAxre7MPcomxmzWSvK8CViHim18b6K2P+wNOOE4l70Q==", "requires": { "@bitcoinerlab/secp256k1": "^1.0.2", "@noble/curves": "^1.2.0", "@noble/secp256k1": "^1.7.1", "@scure/base": "^1.1.1", - "@scure/btc-signer": "1.1.0", + "@scure/btc-signer": "1.1.1", "@stacks/auth": "^6.9.0", "@stacks/connect": "^7.4.1", "@stacks/encryption": "6.9.0", @@ -15389,6 +15505,7 @@ "@stacks/transactions": "6.9.0", "@stacks/wallet-sdk": "^6.9.0", "@zondax/ledger-stacks": "^1.0.4", + "async-mutex": "^0.4.0", "axios": "1.6.2", "base64url": "^3.0.1", "bip32": "^4.0.0", @@ -15435,22 +15552,54 @@ "dev": true }, "@stacks/auth": { - "version": "6.9.0", - "resolved": "https://registry.npmjs.org/@stacks/auth/-/auth-6.9.0.tgz", - "integrity": "sha512-tBOB+H/96TUNK9pKmr1YQoiIItUFp2ms5RCNYPSjy3/lbIYYJYtw/O2fOS78fVQvCCpuObhhO65AVsrE/IzQeg==", - "requires": { - "@stacks/common": "^6.8.1", - "@stacks/encryption": "^6.9.0", - "@stacks/network": "^6.8.1", - "@stacks/profile": "^6.9.0", + "version": "6.10.0", + "resolved": "https://registry.npmjs.org/@stacks/auth/-/auth-6.10.0.tgz", + "integrity": "sha512-5/FdD1btPovJTckVfaNdD+J/JxtKwn1jVOcyiuDwOABMOMGykgH8ZdXc0Kho2POZoOwKTIrG8sYH5TolSoH7BA==", + "requires": { + "@stacks/common": "^6.10.0", + "@stacks/encryption": "^6.10.0", + "@stacks/network": "^6.10.0", + "@stacks/profile": "^6.10.0", "cross-fetch": "^3.1.5", "jsontokens": "^4.0.1" + }, + "dependencies": { + "@noble/hashes": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.1.5.tgz", + "integrity": "sha512-LTMZiiLc+V4v1Yi16TD6aX2gmtKszNye0pQgbaLqkvhIqP7nVsSaJsWloGQjJfJ8offaoP5GtX3yY5swbcJxxQ==" + }, + "@stacks/encryption": { + "version": "6.11.0", + "resolved": "https://registry.npmjs.org/@stacks/encryption/-/encryption-6.11.0.tgz", + "integrity": "sha512-VfBkrwmCRppCasJo+R/hWfC7vgS6GmfPyoTeDsoYlfRRXz/auFbEdRaaruFPtAda/1nKdDOZ9UZEMOp5AIw0IQ==", + "requires": { + "@noble/hashes": "1.1.5", + "@noble/secp256k1": "1.7.1", + "@scure/bip39": "1.1.0", + "@stacks/common": "^6.10.0", + "@types/node": "^18.0.4", + "base64-js": "^1.5.1", + "bs58": "^5.0.0", + "ripemd160-min": "^0.0.6", + "varuint-bitcoin": "^1.1.2" + } + }, + "@stacks/network": { + "version": "6.10.0", + "resolved": "https://registry.npmjs.org/@stacks/network/-/network-6.10.0.tgz", + "integrity": "sha512-mbiZ8nlsyy77ndmBdaqhHXii22IFdK4ThRcOQs9j/O00DkAr04jCM4GV5Q+VLUnZ9OBoJq7yOV7Pf6jglh+0hw==", + "requires": { + "@stacks/common": "^6.10.0", + "cross-fetch": "^3.1.5" + } + } } }, "@stacks/common": { - "version": "6.8.1", - "resolved": "https://registry.npmjs.org/@stacks/common/-/common-6.8.1.tgz", - "integrity": "sha512-ewL9GLZNQYa5a/3K4xSHlHIgHkD4rwWW/QEaPId8zQIaL+1O9qCaF4LX9orNQeOmEk8kvG0x2xGV54fXKCZeWQ==", + "version": "6.10.0", + "resolved": "https://registry.npmjs.org/@stacks/common/-/common-6.10.0.tgz", + "integrity": "sha512-6x5Z7AKd9/kj3+DYE9xIDIkFLHihBH614i2wqrZIjN02WxVo063hWSjIlUxlx8P4gl6olVzlOy5LzhLJD9OP0A==", "requires": { "@types/bn.js": "^5.1.0", "@types/node": "^18.0.4" @@ -15485,7 +15634,7 @@ "@noble/hashes": "1.1.5", "@noble/secp256k1": "1.7.1", "@scure/bip39": "1.1.0", - "@stacks/common": "^6.8.1", + "@stacks/common": "^6.10.0", "@types/node": "^18.0.4", "base64-js": "^1.5.1", "bs58": "^5.0.0", @@ -15510,16 +15659,45 @@ } }, "@stacks/profile": { - "version": "6.9.0", - "resolved": "https://registry.npmjs.org/@stacks/profile/-/profile-6.9.0.tgz", - "integrity": "sha512-sIR60DsAHi8C6zGqKqSe1r2hXTMHgwrJkX3fAaP3de40KeplZ2bkE+0B83yismEeU2baNc+AukyVvWJv0PfP0A==", + "version": "6.10.0", + "resolved": "https://registry.npmjs.org/@stacks/profile/-/profile-6.10.0.tgz", + "integrity": "sha512-n2H1Imuu7UfwIvUv7xgHiSb3CKfbP4H+Jzy1+w73njYX7glt60uQ1SjWef7gvMgMFQNTAPELqitweyA+UII6Hg==", "requires": { - "@stacks/common": "^6.8.1", - "@stacks/network": "^6.8.1", - "@stacks/transactions": "^6.9.0", + "@stacks/common": "^6.10.0", + "@stacks/network": "^6.10.0", + "@stacks/transactions": "^6.10.0", "jsontokens": "^4.0.1", "schema-inspector": "^2.0.2", "zone-file": "^2.0.0-beta.3" + }, + "dependencies": { + "@noble/hashes": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.1.5.tgz", + "integrity": "sha512-LTMZiiLc+V4v1Yi16TD6aX2gmtKszNye0pQgbaLqkvhIqP7nVsSaJsWloGQjJfJ8offaoP5GtX3yY5swbcJxxQ==" + }, + "@stacks/network": { + "version": "6.10.0", + "resolved": "https://registry.npmjs.org/@stacks/network/-/network-6.10.0.tgz", + "integrity": "sha512-mbiZ8nlsyy77ndmBdaqhHXii22IFdK4ThRcOQs9j/O00DkAr04jCM4GV5Q+VLUnZ9OBoJq7yOV7Pf6jglh+0hw==", + "requires": { + "@stacks/common": "^6.10.0", + "cross-fetch": "^3.1.5" + } + }, + "@stacks/transactions": { + "version": "6.11.0", + "resolved": "https://registry.npmjs.org/@stacks/transactions/-/transactions-6.11.0.tgz", + "integrity": "sha512-+zIDqn9j4H/+o1ER8C9rFpig1fyrQcj2hVGNIrp+YbpPyja+cxv3fPk6kI/gePzwggzxRgUkIWhBc+mZAXuXyQ==", + "requires": { + "@noble/hashes": "1.1.5", + "@noble/secp256k1": "1.7.1", + "@stacks/common": "^6.10.0", + "@stacks/network": "^6.10.0", + "c32check": "^2.0.0", + "lodash.clonedeep": "^4.5.0" + } + } } }, "@stacks/stacks-blockchain-api-types": { @@ -15528,16 +15706,48 @@ "integrity": "sha512-Mw5dBPx3DySPupwaq0iBdm1WdEVXIfhjUVaTjI2iSyzWz4Fgs3U7JCaAezLbgNu7Q69c/ZN4JUDWuo9FVjy7oA==" }, "@stacks/storage": { - "version": "6.9.0", - "resolved": "https://registry.npmjs.org/@stacks/storage/-/storage-6.9.0.tgz", - "integrity": "sha512-aZ3tOnwRSk5cHQh9ButhfHDvAylNVxPRQzeSB8PydHfyib4XL7fSAJwizzEWNgJV4dovqW2Nsy8gm/4rM/oFKQ==", - "requires": { - "@stacks/auth": "^6.9.0", - "@stacks/common": "^6.8.1", - "@stacks/encryption": "^6.9.0", - "@stacks/network": "^6.8.1", + "version": "6.10.0", + "resolved": "https://registry.npmjs.org/@stacks/storage/-/storage-6.10.0.tgz", + "integrity": "sha512-DLinjJkCN9Q7Yu5yelcXfP89CIDZ4TqXisjJYRqRlFfQdoDWDFZT5vpM2C8U2xDmgzVxfjg90HmQpIjTeIMSnw==", + "requires": { + "@stacks/auth": "^6.10.0", + "@stacks/common": "^6.10.0", + "@stacks/encryption": "^6.10.0", + "@stacks/network": "^6.10.0", "base64-js": "^1.5.1", "jsontokens": "^4.0.1" + }, + "dependencies": { + "@noble/hashes": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.1.5.tgz", + "integrity": "sha512-LTMZiiLc+V4v1Yi16TD6aX2gmtKszNye0pQgbaLqkvhIqP7nVsSaJsWloGQjJfJ8offaoP5GtX3yY5swbcJxxQ==" + }, + "@stacks/encryption": { + "version": "6.11.0", + "resolved": "https://registry.npmjs.org/@stacks/encryption/-/encryption-6.11.0.tgz", + "integrity": "sha512-VfBkrwmCRppCasJo+R/hWfC7vgS6GmfPyoTeDsoYlfRRXz/auFbEdRaaruFPtAda/1nKdDOZ9UZEMOp5AIw0IQ==", + "requires": { + "@noble/hashes": "1.1.5", + "@noble/secp256k1": "1.7.1", + "@scure/bip39": "1.1.0", + "@stacks/common": "^6.10.0", + "@types/node": "^18.0.4", + "base64-js": "^1.5.1", + "bs58": "^5.0.0", + "ripemd160-min": "^0.0.6", + "varuint-bitcoin": "^1.1.2" + } + }, + "@stacks/network": { + "version": "6.10.0", + "resolved": "https://registry.npmjs.org/@stacks/network/-/network-6.10.0.tgz", + "integrity": "sha512-mbiZ8nlsyy77ndmBdaqhHXii22IFdK4ThRcOQs9j/O00DkAr04jCM4GV5Q+VLUnZ9OBoJq7yOV7Pf6jglh+0hw==", + "requires": { + "@stacks/common": "^6.10.0", + "cross-fetch": "^3.1.5" + } + } } }, "@stacks/transactions": { @@ -15574,7 +15784,7 @@ "@stacks/profile": "^6.9.0", "@stacks/storage": "^6.9.0", "@stacks/transactions": "^6.9.0", - "buffer": "^6.0.3", + "buffer": "6.0.3", "c32check": "^2.0.0", "jsontokens": "^4.0.1", "triplesec": "^4.0.3", @@ -16762,7 +16972,7 @@ "requires": { "@types/bn.js": "^5.1.0", "@types/node": "^18.0.4", - "buffer": "^6.0.3" + "buffer": "6.0.3" } }, "@stacks/network": { @@ -16806,19 +17016,8 @@ "integrity": "sha512-ADADE/PjAbJRlwpG3ShaOMbBUlJJZO7xaYSRD5Tub6PixQlgR4s36y9cvMf/YRGpkqX+QOxIdMw216iC320q9A==", "requires": { "base-x": "^3.0.8", - "buffer": "^5.6.0", + "buffer": "6.0.3", "cross-sha256": "^1.2.0" - }, - "dependencies": { - "buffer": { - "version": "5.7.1", - "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", - "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", - "requires": { - "base64-js": "^1.3.1", - "ieee754": "^1.1.13" - } - } } } } @@ -17144,6 +17343,14 @@ "lodash": "^4.17.14" } }, + "async-mutex": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/async-mutex/-/async-mutex-0.4.0.tgz", + "integrity": "sha512-eJFZ1YhRR8UN8eBLoNzcDPcy/jqjsg6I1AP+KvWQX80BqOSW1oJPJXDylPUEeMr2ZQvHgnQ//Lp6f3RQ1zI7HA==", + "requires": { + "tslib": "^2.4.0" + } + }, "asynckit": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", @@ -18190,18 +18397,7 @@ "resolved": "https://registry.npmjs.org/cross-sha256/-/cross-sha256-1.2.0.tgz", "integrity": "sha512-KViLNMDZKV7jwFqjFx+rNhG26amnFYYQ0S+VaFlVvpk8tM+2XbFia/don/SjGHg9WQxnFVi6z64CGPuF3T+nNw==", "requires": { - "buffer": "^5.6.0" - }, - "dependencies": { - "buffer": { - "version": "5.7.1", - "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", - "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", - "requires": { - "base64-js": "^1.3.1", - "ieee754": "^1.1.13" - } - } + "buffer": "6.0.3" } }, "cross-spawn": { @@ -21472,11 +21668,11 @@ "dev": true }, "micro-packed": { - "version": "0.3.2", - "resolved": "https://registry.npmjs.org/micro-packed/-/micro-packed-0.3.2.tgz", - "integrity": "sha512-D1Bq0/lVOzdxhnX5vylCxZpdw5LylH7Vd81py0DfRsKUP36XYpwvy8ZIsECVo3UfnoROn8pdKqkOzL7Cd82sGA==", + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/micro-packed/-/micro-packed-0.4.0.tgz", + "integrity": "sha512-H1+8SUMwcm68RXLOj3t5S8wXVf49FuR5m9IAG7XZ1XUOexbnQriyql5lk2I3fx/KyYf48LWNf5Lnbc2OjyQFMw==", "requires": { - "@scure/base": "~1.1.1" + "@scure/base": "~1.1.3" } }, "micromatch": { @@ -23998,8 +24194,7 @@ "tslib": { "version": "2.6.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.6.1.tgz", - "integrity": "sha512-t0hLfiEKfMUoqhG+U1oid7Pva4bbDPHYfJNiB7BiIjRkj1pyC++4N3huJfqY6aRH6VTB0rvtzQwjM4K6qpfOig==", - "dev": true + "integrity": "sha512-t0hLfiEKfMUoqhG+U1oid7Pva4bbDPHYfJNiB7BiIjRkj1pyC++4N3huJfqY6aRH6VTB0rvtzQwjM4K6qpfOig==" }, "tsutils": { "version": "3.21.0", @@ -24102,11 +24297,18 @@ "integrity": "sha512-7uc1O8h1M1g0rArakJdf0uLRSSgFcYexrVoKo+bzJd32gd4gDy2L/Z+8/FjPnU9ydY3pEnVPtr9FyscYY60K1g==" }, "typescript": { - "version": "4.9.5", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.9.5.tgz", - "integrity": "sha512-1FXk9E2Hm+QzZQ7z+McJiHL4NW1F2EzMu9Nq9i3zAaGqibafqYwCVU6WyWAuyQRRzOlxou8xZSyXLEN8oKj24g==", + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.3.3.tgz", + "integrity": "sha512-pXWcraxM0uxAS+tN0AG/BF2TyqmHO014Z070UsJ+pFvYuRSq8KH8DmWpnbXe0pEPDHXZV3FcAbJkijJ5oNEnWw==", "dev": true }, + "typescript-plugin-styled-components": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/typescript-plugin-styled-components/-/typescript-plugin-styled-components-3.0.0.tgz", + "integrity": "sha512-QWlhTl6NqsFxtJyxn7pJjm3RhgzXSByUftZ3AoQClrMMpa4yAaHuJKTN1gFpH3Ti+Rwm56fNUfG9pXSBU+WW3A==", + "dev": true, + "requires": {} + }, "ufo": { "version": "1.3.2", "resolved": "https://registry.npmjs.org/ufo/-/ufo-1.3.2.tgz", diff --git a/package.json b/package.json index 15f52d634..2a7f1ddf0 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "xverse-web-extension", "description": "A Bitcoin wallet for Web3", - "version": "0.27.0", + "version": "0.28.0", "private": true, "engines": { "node": "^18.18.2" @@ -10,7 +10,8 @@ "@ledgerhq/hw-transport-webusb": "^6.27.13", "@phosphor-icons/react": "^2.0.10", "@react-spring/web": "^9.6.1", - "@secretkeylabs/xverse-core": "7.0.0", + "@scure/btc-signer": "^1.1.1", + "@secretkeylabs/xverse-core": "8.0.1", "@stacks/connect": "7.4.1", "@stacks/stacks-blockchain-api-types": "6.1.1", "@stacks/transactions": "6.9.0", @@ -26,6 +27,7 @@ "axios": "^1.1.3", "bignumber.js": "^9.1.0", "bip39": "^3.0.3", + "buffer": "6.0.3", "c32check": "^2.0.0", "classnames": "^2.3.2", "crypto-browserify": "^3.12.0", @@ -76,8 +78,7 @@ "style": "prettier --write \"src/**/*.{ts,tsx}\"", "prepare": "husky install" }, - "resolutions": { - "styled-components": "^5", + "overrides": { "buffer": "6.0.3" }, "lint-staged": { @@ -138,7 +139,8 @@ "tsc-files": "^1.1.4", "tsconfig-paths-webpack-plugin": "^4.0.0", "type-fest": "^2.19.0", - "typescript": "^4.8.2", + "typescript": "^5.0.0", + "typescript-plugin-styled-components": "^3.0.0", "vitest": "^0.34.6", "webpack": "^5.89.0", "webpack-cli": "^4.0.0", diff --git a/src/app/App.tsx b/src/app/App.tsx index c11c784c1..95a65b586 100644 --- a/src/app/App.tsx +++ b/src/app/App.tsx @@ -1,5 +1,5 @@ import LoadingScreen from '@components/loadingScreen'; -import { CheckCircle } from '@phosphor-icons/react'; +import { CheckCircle, XCircle } from '@phosphor-icons/react'; import rootStore from '@stores/index'; import { QueryClientProvider } from '@tanstack/react-query'; import { ReactQueryDevtools } from '@tanstack/react-query-devtools'; @@ -11,13 +11,20 @@ import { Toaster } from 'react-hot-toast'; import { Provider } from 'react-redux'; import { RouterProvider } from 'react-router-dom'; import { PersistGate } from 'redux-persist/integration/react'; -import { ThemeProvider } from 'styled-components'; +import styled, { ThemeProvider } from 'styled-components'; import '../locales'; import Theme from '../theme'; import GlobalStyle from '../theme/global'; import SessionGuard from './components/guards/session'; import router from './routes'; +// needed to keep the svg icon scale for toasts over multiple lines +const StyledIcon = styled.div` + display: flex; + align-items: center; + justify-content: center; +`; + function App(): JSX.Element { useEffect(() => { if (!MIX_PANEL_TOKEN) { @@ -46,16 +53,33 @@ function App(): JSX.Element { containerStyle={{ bottom: 80 }} toastOptions={{ success: { - icon: , + icon: ( + + + + ), style: { ...Theme.typography.body_medium_m, backgroundColor: Theme.colors.success_medium, borderRadius: Theme.radius(2), - padding: Theme.spacing(4), - paddingLeft: Theme.spacing(6), + padding: Theme.space.s, color: Theme.colors.elevation0, }, }, + error: { + icon: ( + + + + ), + style: { + ...Theme.typography.body_medium_m, + backgroundColor: Theme.colors.danger_dark, + borderRadius: Theme.radius(2), + padding: Theme.space.s, + color: Theme.colors.white_0, + }, + }, }} /> diff --git a/src/app/components/bottomModal/index.tsx b/src/app/components/bottomModal/index.tsx index 959444c61..6fa5be20d 100644 --- a/src/app/components/bottomModal/index.tsx +++ b/src/app/components/bottomModal/index.tsx @@ -1,5 +1,5 @@ -import Cross from '@assets/img/dashboard/X.svg'; import Separator from '@components/separator'; +import { XCircle } from '@phosphor-icons/react'; import Modal from 'react-modal'; import styled, { useTheme } from 'styled-components'; @@ -11,9 +11,9 @@ const BottomModalHeaderText = styled.h1((props) => ({ const RowContainer = styled.div((props) => ({ display: 'flex', flexDirection: 'row', - alignItems: 'space-between', - margin: props.theme.spacing(12), - marginBottom: props.theme.spacing(10), + alignItems: 'center', + justifyContent: 'space-between', + margin: props.theme.space.m, })); const ButtonImage = styled.button({ @@ -87,7 +87,7 @@ function BottomModal({ {header} - cross + {header && } diff --git a/src/app/components/confirmBtcTransaction/index.tsx b/src/app/components/confirmBtcTransaction/index.tsx new file mode 100644 index 000000000..c042ca43f --- /dev/null +++ b/src/app/components/confirmBtcTransaction/index.tsx @@ -0,0 +1,229 @@ +import ledgerConnectDefaultIcon from '@assets/img/ledger/ledger_connect_default.svg'; +import ledgerConnectBtcIcon from '@assets/img/ledger/ledger_import_connect_btc.svg'; +import { delay } from '@common/utils/ledger'; +import BottomModal from '@components/bottomModal'; +import ActionButton from '@components/button'; +import LedgerConnectionView from '@components/ledger/connectLedgerView'; +import useWalletSelector from '@hooks/useWalletSelector'; +import TransportFactory from '@ledgerhq/hw-transport-webusb'; +import { btcTransaction, Transport } from '@secretkeylabs/xverse-core'; +import Callout from '@ui-library/callout'; +import { StickyHorizontalSplitButtonContainer, StyledP } from '@ui-library/common.styled'; +import { isLedgerAccount } from '@utils/helper'; +import { useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { MoonLoader } from 'react-spinners'; +import styled from 'styled-components'; +import SendLayout from '../../layouts/sendLayout'; +import TransactionSummary from './transactionSummary'; + +const LoaderContainer = styled.div(() => ({ + display: 'flex', + flex: 1, + justifyContent: 'center', + alignItems: 'center', +})); + +const ReviewTransactionText = styled(StyledP)` + text-align: left; + margin-bottom: ${(props) => props.theme.space.l}; +`; + +const BroadcastCallout = styled(Callout)` + margin-bottom: ${(props) => props.theme.space.m}; +`; + +const SuccessActionsContainer = styled.div((props) => ({ + width: '100%', + display: 'flex', + flexDirection: 'column', + gap: props.theme.space.s, + paddingLeft: props.theme.space.m, + paddingRight: props.theme.space.m, + marginBottom: props.theme.space.xxl, + marginTop: props.theme.space.xxl, +})); + +type Props = { + inputs: btcTransaction.EnhancedInput[]; + outputs: btcTransaction.EnhancedOutput[]; + feeOutput?: btcTransaction.TransactionFeeOutput; + isLoading: boolean; + isSubmitting: boolean; + isBroadcast?: boolean; + isError?: boolean; + showAccountHeader?: boolean; + hideBottomBar?: boolean; + cancelText: string; + confirmText: string; + onConfirm: (ledgerTransport?: Transport) => void; + onCancel: () => void; + onBackClick?: () => void; + confirmDisabled?: boolean; + getFeeForFeeRate?: (feeRate: number, useEffectiveFeeRate?: boolean) => Promise; + onFeeRateSet?: (feeRate: number) => void; +}; + +function ConfirmBtcTransaction({ + inputs, + outputs, + feeOutput, + isLoading, + isSubmitting, + isBroadcast, + isError = false, + cancelText, + confirmText, + onConfirm, + onCancel, + onBackClick, + showAccountHeader, + hideBottomBar, + confirmDisabled = false, + getFeeForFeeRate, + onFeeRateSet, +}: Props) { + const [isModalVisible, setIsModalVisible] = useState(false); + const [currentStepIndex, setCurrentStepIndex] = useState(0); + const [isButtonDisabled, setIsButtonDisabled] = useState(false); + const [isConnectSuccess, setIsConnectSuccess] = useState(false); + const [isConnectFailed, setIsConnectFailed] = useState(false); + const [isTxRejected, setIsTxRejected] = useState(false); + + const { t } = useTranslation('translation', { keyPrefix: 'CONFIRM_TRANSACTION' }); + const { t: signatureRequestTranslate } = useTranslation('translation', { + keyPrefix: 'SIGNATURE_REQUEST', + }); + const { selectedAccount } = useWalletSelector(); + + const hideBackButton = !onBackClick; + + const onConfirmPress = async () => { + if (!isLedgerAccount(selectedAccount)) { + return onConfirm(); + } + + // show ledger connection screens + setIsModalVisible(true); + }; + + const handleConnectAndConfirm = async () => { + if (!selectedAccount) { + console.error('No account selected'); + return; + } + setIsButtonDisabled(true); + + const transport = await TransportFactory.create(); + + if (!transport) { + setIsConnectSuccess(false); + setIsConnectFailed(true); + setIsButtonDisabled(false); + return; + } + + setIsConnectSuccess(true); + await delay(1500); + setCurrentStepIndex(1); + + try { + onConfirm(transport); + } catch (err) { + console.error(err); + setIsTxRejected(true); + } + }; + + const handleRetry = async () => { + setIsTxRejected(false); + setIsConnectSuccess(false); + setCurrentStepIndex(0); + }; + + // TODO: this is a bit naive, but should be correct. We may want to look at the sig hash types of the inputs instead + const isPartialTransaction = !feeOutput; + + return isLoading ? ( + + + + ) : ( + <> + + + {t('REVIEW_TRANSACTION')} + + {!isBroadcast && } + + {!isLoading && ( + + + + + )} + + setIsModalVisible(false)}> + {currentStepIndex === 0 && ( + + )} + {currentStepIndex === 1 && ( + + )} + + + + + + + ); +} + +export default ConfirmBtcTransaction; diff --git a/src/app/components/confirmBtcTransaction/itemRow/amount.tsx b/src/app/components/confirmBtcTransaction/itemRow/amount.tsx new file mode 100644 index 000000000..23b7a8894 --- /dev/null +++ b/src/app/components/confirmBtcTransaction/itemRow/amount.tsx @@ -0,0 +1,86 @@ +import TokenImage from '@components/tokenImage'; +import useWalletSelector from '@hooks/useWalletSelector'; +import { currencySymbolMap, getBtcFiatEquivalent, satsToBtc } from '@secretkeylabs/xverse-core'; +import Avatar from '@ui-library/avatar'; +import { StyledP } from '@ui-library/common.styled'; +import BigNumber from 'bignumber.js'; +import { useTranslation } from 'react-i18next'; +import { NumericFormat } from 'react-number-format'; +import styled from 'styled-components'; + +type Props = { + amount: number; +}; + +const RowCenter = styled.div<{ spaceBetween?: boolean }>((props) => ({ + display: 'flex', + flex: 1, + flexDirection: 'row', + alignItems: 'center', + justifyContent: props.spaceBetween ? 'space-between' : 'initial', +})); + +const NumberTypeContainer = styled.div` + text-align: right; +`; + +const AvatarContainer = styled.div` + margin-right: ${(props) => props.theme.space.xs}; +`; + +export default function Amount({ amount }: Props) { + const { btcFiatRate, fiatCurrency } = useWalletSelector(); + const { t } = useTranslation('translation'); + + const getFiatAmountString = (amountParam: number, btcFiatRateParam: string) => { + const fiatAmount = getBtcFiatEquivalent( + new BigNumber(amountParam), + BigNumber(btcFiatRateParam), + ); + if (!fiatAmount) { + return ''; + } + + if (fiatAmount.isLessThan(0.01)) { + return `<${currencySymbolMap[fiatCurrency]}0.01 ${fiatCurrency}`; + } + + return ( + `~ ${value}`} + /> + ); + }; + + return ( + + + } /> + + +
+ + {t('CONFIRM_TRANSACTION.AMOUNT')} + +
+ + {value}} + /> + + {getFiatAmountString(amount, btcFiatRate)} + + +
+
+ ); +} diff --git a/src/app/components/confirmBtcTransaction/itemRow/amountWithInscriptionSatribute.tsx b/src/app/components/confirmBtcTransaction/itemRow/amountWithInscriptionSatribute.tsx new file mode 100644 index 000000000..b7f790d4c --- /dev/null +++ b/src/app/components/confirmBtcTransaction/itemRow/amountWithInscriptionSatribute.tsx @@ -0,0 +1,128 @@ +import DropDownIcon from '@assets/img/transactions/dropDownIcon.svg'; +import BundleItem from '@components/confirmBtcTransactionComponent/bundleItem'; +import useWalletSelector from '@hooks/useWalletSelector'; +import { WarningOctagon } from '@phosphor-icons/react'; +import { animated, config, useSpring } from '@react-spring/web'; +import { btcTransaction } from '@secretkeylabs/xverse-core'; +import { StyledP } from '@ui-library/common.styled'; +import Divider from '@ui-library/divider'; +import { useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import styled from 'styled-components'; +import Theme from 'theme'; +import { mapTxSatributeInfoToBundleInfo } from '../utils'; +import Inscription from './inscription'; + +const WarningContainer = styled.div` + display: flex; + flex-direction: column; + border-radius: ${(props) => props.theme.space.s}; +`; + +const WarningButton = styled.button` + display: flex; + flex-direction: row; + align-items: center; + justify-content: space-between; + background-color: ${(props) => props.theme.colors.elevation1}; + padding-top: ${(props) => props.theme.space.m}; +`; + +const ItemsContainer = styled(animated.div)((props) => ({ + borderRadius: props.theme.space.s, + backgroundColor: props.theme.colors.elevation2, + padding: `${props.theme.space.s} 0`, + marginTop: props.theme.space.m, +})); + +const Range = styled.div` + padding: 0 ${(props) => props.theme.space.s}; +`; + +const Row = styled.div` + display: flex; + flex-direction: row; + align-items: center; +`; + +const Title = styled(StyledP)` + margin-left: ${(props) => props.theme.space.xxs}; +`; + +export default function AmountWithInscriptionSatribute({ + satributes, + inscriptions, + onShowInscription, +}: { + satributes: btcTransaction.IOSatribute[]; + inscriptions: btcTransaction.IOInscription[]; + onShowInscription: (inscription: btcTransaction.IOInscription) => void; +}) { + const [showBundleDetail, setShowBundleDetail] = useState(false); + + const { t } = useTranslation('translation'); + const { hasActivatedRareSatsKey } = useWalletSelector(); + + const slideInStyles = useSpring({ + config: { ...config.gentle, duration: 400 }, + from: { opacity: 0, height: 0 }, + to: { + opacity: showBundleDetail ? 1 : 0, + height: showBundleDetail ? 'auto' : 0, + }, + }); + + const arrowRotation = useSpring({ + transform: showBundleDetail ? 'rotate(180deg)' : 'rotate(0deg)', + config: { ...config.stiff }, + }); + + // we only show the satributes if the user has activated the rare sats key + const satributesArray = hasActivatedRareSatsKey ? satributes : []; + const amountOfAssets = satributesArray.length + inscriptions.length; + + return amountOfAssets > 0 ? ( + + setShowBundleDetail((prevState) => !prevState)}> + + + + {t( + `CONFIRM_TRANSACTION.${ + satributesArray.length ? 'INSCRIBED_RARE_SATS' : 'INSCRIBED_SATS' + }`, + )} + + + + + + {showBundleDetail && ( + + {inscriptions.map((item: btcTransaction.IOInscription, index: number) => ( +
+ + + + {(satributesArray.length || inscriptions.length > index + 1) && ( + + )} +
+ ))} + {satributesArray.map((item: btcTransaction.IOSatribute, index: number) => ( +
+ + + + {satributesArray.length > index + 1 && } +
+ ))} +
+ )} +
+ ) : null; +} diff --git a/src/app/components/confirmBtcTransaction/itemRow/bundleTxView.tsx b/src/app/components/confirmBtcTransaction/itemRow/bundleTxView.tsx new file mode 100644 index 000000000..da3b22b90 --- /dev/null +++ b/src/app/components/confirmBtcTransaction/itemRow/bundleTxView.tsx @@ -0,0 +1,97 @@ +import Link from '@assets/img/rareSats/link.svg'; +import { CubeTransparent } from '@phosphor-icons/react'; +import { btcTransaction } from '@secretkeylabs/xverse-core'; +import { StyledP } from '@ui-library/common.styled'; +import { useTranslation } from 'react-i18next'; +import { NumericFormat } from 'react-number-format'; +import styled from 'styled-components'; +import Theme from 'theme'; +import Avatar from '../../../ui-library/avatar'; +import { SatRangeTx } from '../utils'; +import Inscription from './inscription'; +import RareSats from './rareSats'; + +type Props = { + inscriptions: btcTransaction.IOInscription[]; + satributesInfo: { satRanges: SatRangeTx[]; totalExoticSats: number }; + bundleSize: number; + isRareSatsEnabled?: boolean; + onShowInscription: (inscription: btcTransaction.IOInscription) => void; +}; + +const Header = styled.div((props) => ({ + display: 'flex', + flex: 1, + flexDirection: 'row', + alignItems: 'center', + marginBottom: props.theme.space.m, +})); + +const AvatarContainer = styled.div` + margin-right: ${(props) => props.theme.space.xs}; +`; + +const RowsContainer = styled.div` + padding-left: ${(props) => props.theme.space.m}; +`; +const LinkContainer = styled.div` + display: flex; + width: 32px; + justify-content: center; + margin: ${(props) => props.theme.space.xxxs} 0; +`; + +export default function BundleTxView({ + inscriptions, + satributesInfo, + bundleSize, + isRareSatsEnabled, + onShowInscription, +}: Props) { + const { t } = useTranslation('translation'); + + // we only show rare sats if there are any and the user has enabled the feature + const showRareSats = satributesInfo.totalExoticSats > 0 && isRareSatsEnabled; + + return ( + <> +
+ + } /> + +
+ + {t('COMMON.BUNDLE')} + + {bundleSize && ( + ( + + {value} + + )} + /> + )} +
+
+ + {inscriptions.map((inscription, index) => ( +
+ + {!!(inscriptions.length > index + 1 || showRareSats) && ( + + link + + )} +
+ ))} + {showRareSats && } +
+ + ); +} diff --git a/src/app/components/confirmBtcTransaction/itemRow/inscription.tsx b/src/app/components/confirmBtcTransaction/itemRow/inscription.tsx new file mode 100644 index 000000000..400575026 --- /dev/null +++ b/src/app/components/confirmBtcTransaction/itemRow/inscription.tsx @@ -0,0 +1,117 @@ +import OrdinalIcon from '@assets/img/rareSats/ic_ordinal_small_over_card.svg'; +import { Eye } from '@phosphor-icons/react'; +import OrdinalImage from '@screens/ordinals/ordinalImage'; +import { btcTransaction } from '@secretkeylabs/xverse-core'; +import Avatar from '@ui-library/avatar'; +import { StyledP } from '@ui-library/common.styled'; +import { useTranslation } from 'react-i18next'; +import { NumericFormat } from 'react-number-format'; +import styled from 'styled-components'; +import Theme from 'theme'; + +type Props = { + inscription: btcTransaction.IOInscription; + bundleSize?: number; + hideTypeSizeInfo?: boolean; + onShowInscription: (inscription: btcTransaction.IOInscription) => void; +}; + +const RowCenter = styled.div<{ spaceBetween?: boolean; gap?: boolean }>((props) => ({ + display: 'flex', + flex: 1, + flexDirection: 'row', + alignItems: 'center', + justifyContent: props.spaceBetween ? 'space-between' : 'initial', + gap: props.gap ? props.theme.space.xs : 0, +})); + +const InscriptionNumberContainer = styled.button` + background-color: transparent; +`; + +const NumberTypeContainer = styled.div` + text-align: right; +`; + +const AvatarContainer = styled.div` + margin-right: ${(props) => props.theme.space.xs}; +`; + +const InscriptionNumber = styled(StyledP)` + margin-right: ${(props) => props.theme.space.xs}; +`; +const ContentType = styled(StyledP)` + word-break: break-word; +`; + +export default function Inscription({ + inscription, + bundleSize, + hideTypeSizeInfo = false, + onShowInscription, +}: Props) { + const { t } = useTranslation('translation'); + + return ( + + + + } + /> + + +
+ {!hideTypeSizeInfo && ( + <> + + {t('COMMON.INSCRIPTION')} + + {bundleSize && ( + ( + + {value} + + )} + /> + )} + + )} +
+ + { + onShowInscription(inscription); + }} + > + + + {inscription.number} + + + + + + {inscription.contentType} + + +
+
+ ); +} diff --git a/src/app/components/confirmBtcTransaction/itemRow/inscriptionSatributeRow.tsx b/src/app/components/confirmBtcTransaction/itemRow/inscriptionSatributeRow.tsx new file mode 100644 index 000000000..beca09ac1 --- /dev/null +++ b/src/app/components/confirmBtcTransaction/itemRow/inscriptionSatributeRow.tsx @@ -0,0 +1,80 @@ +import useWalletSelector from '@hooks/useWalletSelector'; +import { btcTransaction } from '@secretkeylabs/xverse-core'; +import Divider from '@ui-library/divider'; +import styled from 'styled-components'; +import { getSatRangesWithInscriptions } from '../utils'; +import Amount from './amount'; +import BundleTxView from './bundleTxView'; +import Inscription from './inscription'; +import RareSats from './rareSats'; + +const RowContainer = styled.div((props) => ({ + padding: `0 ${props.theme.space.m}`, +})); + +type Props = { + inscriptions: btcTransaction.IOInscription[]; + satributes: btcTransaction.IOSatribute[]; + amount: number; + showBottomDivider?: boolean; + showTopDivider?: boolean; + onShowInscription: (inscription: btcTransaction.IOInscription) => void; +}; + +function InscriptionSatributeRow({ + inscriptions, + satributes, + amount, + showBottomDivider, + showTopDivider, + onShowInscription, +}: Props) { + const { hasActivatedRareSatsKey } = useWalletSelector(); + + const satributesInfo = getSatRangesWithInscriptions({ + satributes, + inscriptions, + amount, + }); + + const getRow = () => { + if (inscriptions.length > 0 && inscriptions.length + satributes.length > 1) { + return ( + + ); + } + + if (inscriptions.length) { + return ( + + ); + } + + // if rare sats is disabled we show the amount of btc + if (!hasActivatedRareSatsKey) { + return ; + } + + return ; + }; + + return ( + <> + {showTopDivider && } + {getRow()} + {showBottomDivider && } + + ); +} + +export default InscriptionSatributeRow; diff --git a/src/app/components/confirmBtcTransaction/itemRow/rareSats.tsx b/src/app/components/confirmBtcTransaction/itemRow/rareSats.tsx new file mode 100644 index 000000000..ee362347c --- /dev/null +++ b/src/app/components/confirmBtcTransaction/itemRow/rareSats.tsx @@ -0,0 +1,125 @@ +import DropDownIcon from '@assets/img/transactions/dropDownIcon.svg'; +import BundleItem from '@components/confirmBtcTransactionComponent/bundleItem'; +import { Butterfly } from '@phosphor-icons/react'; +import { animated, config, useSpring } from '@react-spring/web'; +import { StyledP } from '@ui-library/common.styled'; +import Divider from '@ui-library/divider'; +import { useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { NumericFormat } from 'react-number-format'; +import styled from 'styled-components'; +import Theme from 'theme'; +import Avatar from '../../../ui-library/avatar'; +import { SatRangeTx, mapTxSatributeInfoToBundleInfo } from '../utils'; + +const SatsBundleContainer = styled.div` + display: flex; + flex-direction: column; + border-radius: ${(props) => props.theme.space.s}; +`; + +const SatsBundleButton = styled.button` + display: flex; + flex-direction: row; + align-items: center; + justify-content: space-between; + background-color: ${(props) => props.theme.colors.elevation1}; +`; + +const RangesContainer = styled(animated.div)((props) => ({ + borderRadius: props.theme.space.s, + backgroundColor: props.theme.colors.elevation2, + padding: `${props.theme.space.s} 0`, + marginTop: props.theme.space.m, +})); + +const Range = styled.div` + padding: 0 ${(props) => props.theme.space.s}; +`; + +const Row = styled.div` + display: flex; + flex-direction: row; + align-items: center; +`; + +const BundleInfo = styled.div` + display: flex; + flex-direction: column; + align-items: flex-start; + margin-left: ${(props) => props.theme.space.s}; +`; + +function RareSats({ + satributesInfo, + bundleSize, +}: { + satributesInfo: { satRanges: SatRangeTx[]; totalExoticSats: number }; + bundleSize?: number; +}) { + const [showBundleDetail, setShowBundleDetail] = useState(false); + + const { t } = useTranslation('translation'); + + const slideInStyles = useSpring({ + config: { ...config.gentle, duration: 400 }, + from: { opacity: 0, height: 0 }, + to: { + opacity: showBundleDetail ? 1 : 0, + height: showBundleDetail ? 'auto' : 0, + }, + }); + + const arrowRotation = useSpring({ + transform: showBundleDetail ? 'rotate(180deg)' : 'rotate(0deg)', + config: { ...config.stiff }, + }); + + return ( + + setShowBundleDetail((prevState) => !prevState)} + > + + } /> + + {`${ + satributesInfo.totalExoticSats + } ${t('NFT_DASHBOARD_SCREEN.RARE_SATS')}`} + {bundleSize && ( + ( + + {value} + + )} + /> + )} + + + + + + {showBundleDetail && ( + + {satributesInfo.satRanges.map((item: SatRangeTx, index: number) => ( +
+ + + + {satributesInfo.satRanges.length > index + 1 && } +
+ ))} +
+ )} +
+ ); +} + +export default RareSats; diff --git a/src/app/components/confirmBtcTransaction/receiveSection.tsx b/src/app/components/confirmBtcTransaction/receiveSection.tsx new file mode 100644 index 000000000..9aa8e6923 --- /dev/null +++ b/src/app/components/confirmBtcTransaction/receiveSection.tsx @@ -0,0 +1,126 @@ +import useWalletSelector from '@hooks/useWalletSelector'; +import { ArrowRight } from '@phosphor-icons/react'; +import { btcTransaction } from '@secretkeylabs/xverse-core'; +import { StyledP } from '@ui-library/common.styled'; +import { useTranslation } from 'react-i18next'; +import styled from 'styled-components'; +import Theme from 'theme'; +import Amount from './itemRow/amount'; +import InscriptionSatributeRow from './itemRow/inscriptionSatributeRow'; +import { getOutputsWithAssetsToUserAddress } from './utils'; + +const Container = styled.div((props) => ({ + display: 'flex', + flexDirection: 'column', + background: props.theme.colors.elevation1, + borderRadius: 12, + padding: `${props.theme.space.m} 0`, + justifyContent: 'center', + marginBottom: props.theme.space.s, +})); + +const RowCenter = styled.div<{ spaceBetween?: boolean }>((props) => ({ + display: 'flex', + flexDirection: 'row', + alignItems: 'center', + justifyContent: props.spaceBetween ? 'space-between' : 'initial', +})); + +const Header = styled(RowCenter)((props) => ({ + marginBottom: props.theme.space.m, + padding: `0 ${props.theme.space.m}`, +})); + +const RowContainer = styled.div((props) => ({ + padding: `0 ${props.theme.space.m}`, +})); +const AddressLabel = styled(StyledP)((props) => ({ + marginLeft: props.theme.space.xxs, +})); + +type Props = { + outputs: btcTransaction.EnhancedOutput[]; + netAmount: number; + onShowInscription: (inscription: btcTransaction.IOInscription) => void; +}; +function ReceiveSection({ outputs, netAmount, onShowInscription }: Props) { + const { btcAddress, ordinalsAddress } = useWalletSelector(); + const { t } = useTranslation('translation', { keyPrefix: 'CONFIRM_TRANSACTION' }); + + const { outputsToPayment, outputsToOrdinal } = getOutputsWithAssetsToUserAddress({ + outputs, + btcAddress, + ordinalsAddress, + }); + + const inscriptionsRareSatsInPayment = outputsToPayment.filter( + (output) => output.inscriptions.length > 0 || output.satributes.length > 0, + ); + const areInscriptionsRareSatsInPayment = inscriptionsRareSatsInPayment.length > 0; + const showPaymentSection = areInscriptionsRareSatsInPayment || netAmount > 0; + + return ( + <> + {!!outputsToOrdinal.length && ( + +
+ + {t('YOU_WILL_RECEIVE')} + + + + {t('YOUR_ORDINAL_ADDRESS')} + +
+ {outputsToOrdinal + .sort((a, b) => b.inscriptions.length - a.inscriptions.length) + .map((output, index) => ( + index + 1} + /> + ))} +
+ )} + {showPaymentSection && ( + +
+ + {t('YOU_WILL_RECEIVE')} + + + + {t('YOUR_PAYMENT_ADDRESS')} + +
+ {netAmount > 0 && ( + + + + )} + {inscriptionsRareSatsInPayment + .sort((a, b) => b.inscriptions.length - a.inscriptions.length) + .map((output, index) => ( + index + 1} + /> + ))} +
+ )} + + ); +} + +export default ReceiveSection; diff --git a/src/app/components/confirmBtcTransaction/transactionSummary.tsx b/src/app/components/confirmBtcTransaction/transactionSummary.tsx new file mode 100644 index 000000000..013071812 --- /dev/null +++ b/src/app/components/confirmBtcTransaction/transactionSummary.tsx @@ -0,0 +1,147 @@ +import TransactionDetailComponent from '@components/transactionDetailComponent'; +import useWalletSelector from '@hooks/useWalletSelector'; + +import AssetModal from '@components/assetModal'; +import TransferFeeView from '@components/transferFeeView'; +import { btcTransaction } from '@secretkeylabs/xverse-core'; +import Callout from '@ui-library/callout'; +import { BLOG_LINK } from '@utils/constants'; +import BigNumber from 'bignumber.js'; +import { useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import styled from 'styled-components'; +import ReceiveSection from './receiveSection'; +import TransferSection from './transferSection'; +import TxInOutput from './txInOutput/txInOutput'; +import { getNetAmount, isScriptOutput, isSpendOutput } from './utils'; + +const ScriptCallout = styled(Callout)` + margin-bottom: ${(props) => props.theme.space.s}; +`; +const InscribedRareSatWarning = styled(Callout)` + margin-bottom: ${(props) => props.theme.space.m}; +`; + +const UnconfirmedInputCallout = styled(Callout)` + margin-bottom: ${(props) => props.theme.space.m}; +`; + +type Props = { + isPartialTransaction: boolean; + + inputs: btcTransaction.EnhancedInput[]; + outputs: btcTransaction.EnhancedOutput[]; + feeOutput?: btcTransaction.TransactionFeeOutput; + + // TODO: these are for txn screens which we will tackle next + // TODO: By having these as generic props here, we can use the generic set fee rate component for all use cases + getFeeForFeeRate?: (feeRate: number, useEffectiveFeeRate?: boolean) => Promise; + onFeeRateSet?: (feeRate: number) => void; + // TODO: use this to disable the edit fee component when it is created + isSubmitting?: boolean; +}; + +function TransactionSummary({ + inputs, + outputs, + feeOutput, + isPartialTransaction, + isSubmitting, + getFeeForFeeRate, + onFeeRateSet, +}: Props) { + const [inscriptionToShow, setInscriptionToShow] = useState< + btcTransaction.IOInscription | undefined + >(undefined); + + const { network } = useWalletSelector(); + const { t } = useTranslation('translation', { keyPrefix: 'CONFIRM_TRANSACTION' }); + const { t: rareSatsT } = useTranslation('translation', { keyPrefix: 'RARE_SATS' }); + const { btcAddress, ordinalsAddress } = useWalletSelector(); + + const hasOutputScript = outputs.some((output) => isScriptOutput(output)); + + const netAmount = getNetAmount({ + inputs, + outputs, + btcAddress, + ordinalsAddress, + }); + + const isUnConfirmedInput = inputs.some((input) => !input.extendedUtxo.utxo.status.confirmed); + + const paymentHasInscribedRareSats = isPartialTransaction + ? inputs.some( + (input) => + input.extendedUtxo.address === btcAddress && + (input.inscriptions.length || input.satributes.length), + ) + : outputs.some( + (output) => + isSpendOutput(output) && + (output.inscriptions.some((inscription) => inscription.fromAddress === btcAddress) || + output.satributes.some((satribute) => satribute.fromAddress === btcAddress)), + ); + const feesHaveInscribedRareSats = feeOutput?.inscriptions.length || feeOutput?.satributes.length; + const showInscribeRareSatWarning = paymentHasInscribedRareSats || feesHaveInscribedRareSats; + + return ( + <> + {inscriptionToShow && ( + setInscriptionToShow(undefined)} + inscription={{ + content_type: inscriptionToShow.contentType, + id: inscriptionToShow.id, + inscription_number: inscriptionToShow.number, + }} + /> + )} + + {!!showInscribeRareSatWarning && ( + + )} + + {isUnConfirmedInput && ( + + )} + + + + + + + + {hasOutputScript && } + + + + {feeOutput && ( + + )} + + ); +} + +export default TransactionSummary; diff --git a/src/app/components/confirmBtcTransaction/transferSection.tsx b/src/app/components/confirmBtcTransaction/transferSection.tsx new file mode 100644 index 000000000..ab3b917d9 --- /dev/null +++ b/src/app/components/confirmBtcTransaction/transferSection.tsx @@ -0,0 +1,122 @@ +import useWalletSelector from '@hooks/useWalletSelector'; +import { btcTransaction } from '@secretkeylabs/xverse-core'; +import { StyledP } from '@ui-library/common.styled'; +import { useTranslation } from 'react-i18next'; +import styled from 'styled-components'; +import Amount from './itemRow/amount'; +import AmountWithInscriptionSatribute from './itemRow/amountWithInscriptionSatribute'; +import InscriptionSatributeRow from './itemRow/inscriptionSatributeRow'; +import { getInputsWitAssetsFromUserAddress, getOutputsWithAssetsFromUserAddress } from './utils'; + +const Container = styled.div((props) => ({ + display: 'flex', + flexDirection: 'column', + background: props.theme.colors.elevation1, + borderRadius: 12, + padding: `${props.theme.space.m} 0`, + justifyContent: 'center', + marginBottom: props.theme.space.s, +})); + +const RowContainer = styled.div((props) => ({ + padding: `0 ${props.theme.space.m}`, +})); + +const RowCenter = styled.div<{ spaceBetween?: boolean }>((props) => ({ + display: 'flex', + flexDirection: 'row', + alignItems: 'center', + justifyContent: props.spaceBetween ? 'space-between' : 'initial', +})); + +const Header = styled(RowCenter)((props) => ({ + marginBottom: props.theme.space.m, + padding: `0 ${props.theme.space.m}`, +})); + +type Props = { + outputs: btcTransaction.EnhancedOutput[]; + inputs: btcTransaction.EnhancedInput[]; + isPartialTransaction: boolean; + netAmount: number; + onShowInscription: (inscription: btcTransaction.IOInscription) => void; +}; + +// if isPartialTransaction, we use inputs instead of outputs +function TransferSection({ + outputs, + inputs, + isPartialTransaction, + netAmount, + onShowInscription, +}: Props) { + const { btcAddress, ordinalsAddress } = useWalletSelector(); + const { t } = useTranslation('translation', { keyPrefix: 'CONFIRM_TRANSACTION' }); + + const { inputFromPayment, inputFromOrdinal } = getInputsWitAssetsFromUserAddress({ + inputs, + btcAddress, + ordinalsAddress, + }); + const { outputsFromPayment, outputsFromOrdinal } = getOutputsWithAssetsFromUserAddress({ + outputs, + btcAddress, + ordinalsAddress, + }); + + const showAmount = netAmount > 0; + + const inscriptionsFromPayment: btcTransaction.IOInscription[] = []; + const satributesFromPayment: btcTransaction.IOSatribute[] = []; + (isPartialTransaction ? inputFromPayment : outputsFromPayment).forEach((item) => { + inscriptionsFromPayment.push(...item.inscriptions); + satributesFromPayment.push(...item.satributes); + }); + + return ( + +
+ + {t('YOU_WILL_TRANSFER')} + +
+ {showAmount && ( + + + + + )} + {isPartialTransaction + ? inputFromOrdinal.map((input, index) => ( + index + 1} + /> + )) + : outputsFromOrdinal.map((output, index) => ( + index + 1} + /> + ))} +
+ ); +} + +export default TransferSection; diff --git a/src/app/components/confirmBtcTransaction/txInOutput/transactionInput.tsx b/src/app/components/confirmBtcTransaction/txInOutput/transactionInput.tsx new file mode 100644 index 000000000..20e9469c1 --- /dev/null +++ b/src/app/components/confirmBtcTransaction/txInOutput/transactionInput.tsx @@ -0,0 +1,85 @@ +import IconBitcoin from '@assets/img/dashboard/bitcoin_icon.svg'; +import TransferDetailView from '@components/transferDetailView'; +import useWalletSelector from '@hooks/useWalletSelector'; +import { btcTransaction, satsToBtc } from '@secretkeylabs/xverse-core'; +import { StyledP } from '@ui-library/common.styled'; +import { getTruncatedAddress } from '@utils/helper'; +import BigNumber from 'bignumber.js'; +import { useTranslation } from 'react-i18next'; +import styled from 'styled-components'; + +const TransferDetailContainer = styled.div((props) => ({ + paddingBottom: props.theme.space.m, +})); + +const SubValueText = styled(StyledP)((props) => ({ + color: props.theme.colors.white_400, +})); + +const TxIdText = styled(StyledP)((props) => ({ + marginLeft: props.theme.space.xxs, +})); + +const YourAddressText = styled(StyledP)((props) => ({ + marginRight: props.theme.space.xxs, +})); + +const TxIdContainer = styled.div({ + display: 'flex', + flexDirection: 'row', + justifyContent: 'space-between', +}); + +type Props = { + input: btcTransaction.EnhancedInput; +}; + +function TransactionInput({ input }: Props) { + const { btcAddress, ordinalsAddress } = useWalletSelector(); + const { t } = useTranslation('translation', { keyPrefix: 'CONFIRM_TRANSACTION' }); + + const isPaymentsAddress = input.extendedUtxo.address === btcAddress; + const isOrdinalsAddress = input.extendedUtxo.address === ordinalsAddress; + const isExternalInput = !isPaymentsAddress && !isOrdinalsAddress; + + // TODO: show this in the UI? + // const insecureInput = + // input.sigHash === btc.SigHash.NONE || input.sigHash === btc.SigHash.NONE_ANYONECANPAY; + + const renderAddress = (addressToBeDisplayed: string) => + addressToBeDisplayed === btcAddress || addressToBeDisplayed === ordinalsAddress ? ( + + ({t('YOUR_ADDRESS')}) + {getTruncatedAddress(addressToBeDisplayed)} + + ) : ( + {getTruncatedAddress(addressToBeDisplayed)} + ); + + return ( + + + {isExternalInput ? ( + + + {getTruncatedAddress(input.extendedUtxo.utxo.txid)} + + (txid) + + ) : ( + renderAddress(input.extendedUtxo.address) + )} + + + ); +} + +export default TransactionInput; diff --git a/src/app/components/confirmBtcTransaction/txInOutput/transactionOutput.tsx b/src/app/components/confirmBtcTransaction/txInOutput/transactionOutput.tsx new file mode 100644 index 000000000..43c8ab099 --- /dev/null +++ b/src/app/components/confirmBtcTransaction/txInOutput/transactionOutput.tsx @@ -0,0 +1,78 @@ +import ScriptIcon from '@assets/img/transactions/ScriptIcon.svg'; +import OutputIcon from '@assets/img/transactions/output.svg'; +import TransferDetailView from '@components/transferDetailView'; +import useWalletSelector from '@hooks/useWalletSelector'; +import { btcTransaction, satsToBtc } from '@secretkeylabs/xverse-core'; +import { getTruncatedAddress } from '@utils/helper'; +import BigNumber from 'bignumber.js'; +import { useTranslation } from 'react-i18next'; +import styled from 'styled-components'; +import { isScriptOutput, isSpendOutput } from '../utils'; + +const TransferDetailContainer = styled.div((props) => ({ + paddingBottom: props.theme.spacing(8), +})); + +const SubValueText = styled.h1((props) => ({ + ...props.theme.typography.body_m, + fontSize: 12, + color: props.theme.colors.white_400, +})); + +const YourAddressText = styled.h1((props) => ({ + ...props.theme.typography.body_m, + fontSize: 12, + color: props.theme.colors.white_0, + marginRight: props.theme.spacing(2), +})); + +const TxIdContainer = styled.div({ + display: 'flex', + flexDirection: 'row', +}); + +type Props = { + output: btcTransaction.EnhancedOutput; + scriptOutputCount?: number; +}; + +function TransactionOutput({ output, scriptOutputCount }: Props) { + const { btcAddress, ordinalsAddress } = useWalletSelector(); + const { t } = useTranslation('translation', { keyPrefix: 'CONFIRM_TRANSACTION' }); + const outputWithScript = isScriptOutput(output); + + const detailViewIcon = outputWithScript ? ScriptIcon : OutputIcon; + const detailViewHideCopyButton = outputWithScript + ? true + : btcAddress === output.address || ordinalsAddress === output.address; + const detailViewValue = outputWithScript ? ( + {`${t('SCRIPT_OUTPUT')} #${scriptOutputCount}`} + ) : output.address === btcAddress || output.address === ordinalsAddress ? ( + + ({t('YOUR_ADDRESS')}) + {getTruncatedAddress(output.address)} + + ) : ( + {getTruncatedAddress(output.address)} + ); + + return ( + + + {detailViewValue} + + + ); +} + +export default TransactionOutput; diff --git a/src/app/components/confirmBtcTransaction/txInOutput/txInOutput.tsx b/src/app/components/confirmBtcTransaction/txInOutput/txInOutput.tsx new file mode 100644 index 000000000..2ff4e71f0 --- /dev/null +++ b/src/app/components/confirmBtcTransaction/txInOutput/txInOutput.tsx @@ -0,0 +1,103 @@ +import DropDownIcon from '@assets/img/transactions/dropDownIcon.svg'; +import { animated, config, useSpring } from '@react-spring/web'; +import { btcTransaction } from '@secretkeylabs/xverse-core'; +import { StyledP } from '@ui-library/common.styled'; +import { useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import styled from 'styled-components'; +import { isScriptOutput } from '../utils'; +import TransactionInput from './transactionInput'; +import TransactionOutput from './transactionOutput'; + +const Container = styled.div((props) => ({ + display: 'flex', + flexDirection: 'column', + borderRadius: props.theme.space.s, + background: props.theme.colors.elevation1, + padding: `${props.theme.space.s} ${props.theme.space.m}`, + marginBottom: props.theme.space.s, +})); + +const Button = styled.button` + display: flex; + flex-direction: row; + align-items: center; + justify-content: space-between; + background-color: ${(props) => props.theme.colors.elevation1}; +`; + +const Row = styled.div` + display: flex; + flex-direction: row; + align-items: center; +`; + +const OutputTitleText = styled(StyledP)((props) => ({ + marginBottom: props.theme.space.s, +})); + +const ExpandedContainer = styled(animated.div)({ + display: 'flex', + flexDirection: 'column', + marginTop: 16, +}); + +type Props = { + inputs: btcTransaction.EnhancedInput[]; + outputs: btcTransaction.EnhancedOutput[]; +}; + +function TxInOutput({ inputs, outputs }: Props) { + const [isExpanded, setIsExpanded] = useState(false); + + const { t } = useTranslation('translation', { keyPrefix: 'CONFIRM_TRANSACTION' }); + + const slideInStyles = useSpring({ + config: { ...config.gentle, duration: 400 }, + from: { opacity: 0, height: 0 }, + to: { + opacity: isExpanded ? 1 : 0, + height: isExpanded ? 'auto' : 0, + }, + }); + + const arrowRotation = useSpring({ + transform: isExpanded ? 'rotate(180deg)' : 'rotate(0deg)', + config: { ...config.stiff }, + }); + + let scriptOutputCount = 1; + + return ( + + + {isExpanded && ( + + {inputs.map((input) => ( + + ))} + + {t('OUTPUT')} + + {outputs.map((output, index) => ( + + ))} + + )} + + ); +} + +export default TxInOutput; diff --git a/src/app/components/confirmBtcTransaction/utils.ts b/src/app/components/confirmBtcTransaction/utils.ts new file mode 100644 index 000000000..ededa2dbb --- /dev/null +++ b/src/app/components/confirmBtcTransaction/utils.ts @@ -0,0 +1,269 @@ +import { btcTransaction, BundleSatRange } from '@secretkeylabs/xverse-core'; + +export type SatRangeTx = { + totalSats: number; + offset: number; + fromAddress: string; + inscriptions: (Omit & { + content_type: string; + inscription_number: number; + })[]; + satributes: btcTransaction.IOSatribute['types']; +}; + +const DUMMY_OFFSET = -1; + +export const isScriptOutput = ( + output: btcTransaction.EnhancedOutput, +): output is btcTransaction.TransactionScriptOutput => + (output as btcTransaction.TransactionScriptOutput).script !== undefined; + +export const isSpendOutput = ( + output: btcTransaction.EnhancedOutput, +): output is btcTransaction.TransactionOutput => + (output as btcTransaction.TransactionOutput).address !== undefined; + +type CommonInputOutputUtilProps = { + inputs: btcTransaction.EnhancedInput[]; + outputs: btcTransaction.EnhancedOutput[]; + btcAddress: string; + ordinalsAddress: string; +}; + +export const getNetAmount = ({ + inputs, + outputs, + btcAddress, + ordinalsAddress, +}: CommonInputOutputUtilProps) => { + const initialValue = 0; + + const totalUserSpend = inputs.reduce((accumulator: number, input) => { + const isFromUserAddress = [btcAddress, ordinalsAddress].includes(input.extendedUtxo.address); + if (isFromUserAddress) { + return accumulator + input.extendedUtxo.utxo.value; + } + return accumulator; + }, initialValue); + + const totalUserReceive = outputs.reduce((accumulator: number, output) => { + const isToUserAddress = + isSpendOutput(output) && [btcAddress, ordinalsAddress].includes(output.address); + if (isToUserAddress) { + return accumulator + output.amount; + } + return accumulator; + }, initialValue); + + return totalUserReceive - totalUserSpend; +}; + +export const getOutputsWithAssetsFromUserAddress = ({ + btcAddress, + ordinalsAddress, + outputs, +}: Omit) => { + // we want to discard outputs that are script, are not from user address and do not have inscriptions or satributes + const outputsFromPayment: btcTransaction.TransactionOutput[] = []; + const outputsFromOrdinal: btcTransaction.TransactionOutput[] = []; + outputs.forEach((output) => { + if (isScriptOutput(output)) { + return; + } + + const itemsFromPayment: (btcTransaction.IOInscription | btcTransaction.IOSatribute)[] = []; + const itemsFromOrdinal: (btcTransaction.IOInscription | btcTransaction.IOSatribute)[] = []; + [...output.inscriptions, ...output.satributes].forEach((item) => { + if (item.fromAddress === btcAddress) { + return itemsFromPayment.push(item); + } + if (item.fromAddress === ordinalsAddress) { + itemsFromOrdinal.push(item); + } + }); + + if (itemsFromOrdinal.length > 0) { + outputsFromOrdinal.push(output); + } + if (itemsFromPayment.length > 0) { + outputsFromPayment.push(output); + } + }); + + return { outputsFromPayment, outputsFromOrdinal }; +}; + +export const getInputsWitAssetsFromUserAddress = ({ + btcAddress, + ordinalsAddress, + inputs, +}: Omit) => { + // we want to discard inputs that are not from user address and do not have inscriptions or satributes + const inputFromPayment: btcTransaction.EnhancedInput[] = []; + const inputFromOrdinal: btcTransaction.EnhancedInput[] = []; + inputs.forEach((input) => { + if (!input.inscriptions.length && !input.satributes.length) { + return; + } + + if (input.extendedUtxo.address === btcAddress) { + return inputFromPayment.push(input); + } + if (input.extendedUtxo.address === ordinalsAddress) { + inputFromOrdinal.push(input); + } + }); + + return { inputFromPayment, inputFromOrdinal }; +}; + +export const getOutputsWithAssetsToUserAddress = ({ + btcAddress, + ordinalsAddress, + outputs, +}: Omit) => { + const outputsToPayment: btcTransaction.TransactionOutput[] = []; + const outputsToOrdinal: btcTransaction.TransactionOutput[] = []; + outputs.forEach((output) => { + // we want to discard outputs that are not spendable or are not to user address + if (isScriptOutput(output) || ![btcAddress, ordinalsAddress].includes(output.address)) { + return; + } + + if (output.address === btcAddress) { + return outputsToPayment.push(output); + } + + // we don't want to show amount to ordinals address, because it's not spendable + if ( + output.address === ordinalsAddress && + (output.inscriptions.length > 0 || output.satributes.length > 0) + ) { + outputsToOrdinal.push(output); + } + }); + + return { outputsToPayment, outputsToOrdinal }; +}; + +export const mapTxSatributeInfoToBundleInfo = (item: btcTransaction.IOSatribute | SatRangeTx) => { + const commonProps = { + offset: item.offset, + block: 0, + range: { + start: '0', + end: '0', + }, + yearMined: 0, + }; + + // SatRangeTx + if ('totalSats' in item) { + return { + ...commonProps, + totalSats: item.totalSats, + inscriptions: item.inscriptions, + satributes: item.satributes, + } as BundleSatRange; + } + + // btcTransaction.IOSatribute + return { + ...commonProps, + totalSats: item.amount, + inscriptions: [], + satributes: item.types, + } as BundleSatRange; +}; + +export const getSatRangesWithInscriptions = ({ + satributes, + inscriptions, + amount, +}: { + inscriptions: btcTransaction.IOInscription[]; + satributes: btcTransaction.IOSatribute[]; + amount: number; +}) => { + const satRanges: { + [offset: number]: SatRangeTx; + } = {}; + + satributes.forEach((satribute) => { + const { types, amount: totalSats, ...rest } = satribute; + satRanges[rest.offset] = { ...rest, satributes: types, totalSats, inscriptions: [] }; + }); + + inscriptions.forEach((inscription) => { + const { contentType, number, ...inscriptionRest } = inscription; + const mappedInscription = { + ...inscriptionRest, + content_type: contentType, + inscription_number: number, + }; + if (satRanges[inscription.offset]) { + satRanges[inscription.offset] = { + ...satRanges[inscription.offset], + inscriptions: [...satRanges[inscription.offset].inscriptions, mappedInscription], + }; + return; + } + + satRanges[inscription.offset] = { + totalSats: 1, + offset: inscription.offset, + fromAddress: inscription.fromAddress, + inscriptions: [mappedInscription], + satributes: ['COMMON'], + }; + }); + + const { amountOfExoticsOrInscribedSats, totalExoticSats } = Object.values(satRanges).reduce( + (acc, range) => ({ + amountOfExoticsOrInscribedSats: acc.amountOfExoticsOrInscribedSats + range.totalSats, + totalExoticSats: + acc.totalExoticSats + (!range.satributes.includes('COMMON') ? range.totalSats : 0), + }), + { + amountOfExoticsOrInscribedSats: 0, + totalExoticSats: 0, + }, + ); + + if (amountOfExoticsOrInscribedSats < amount) { + satRanges[DUMMY_OFFSET] = { + totalSats: amount - amountOfExoticsOrInscribedSats, + offset: DUMMY_OFFSET, + fromAddress: '', + inscriptions: [], + satributes: ['COMMON'], + }; + } + + // sort should be: inscribed rare, rare, inscribed common, common + const satRangesArray = Object.values(satRanges).sort((a, b) => { + // Check conditions for each category + const aHasInscriptions = a.inscriptions.length > 0; + const bHasInscriptions = b.inscriptions.length > 0; + const aHasRareSatributes = a.satributes.some((s) => s !== 'COMMON'); + const bHasRareSatributes = b.satributes.some((s) => s !== 'COMMON'); + + // sats not rare and not inscribed at bottom + if (!aHasInscriptions && !aHasRareSatributes) return 1; + + // sats inscribed and rare at top + if (aHasInscriptions && aHasRareSatributes) return -1; + + // sats not inscribed and rare below inscribed and rare + if (bHasInscriptions && bHasRareSatributes) return 1; + + // sats inscribed and not rare above sats not inscribed and not rare + if (aHasRareSatributes) return -1; + if (bHasRareSatributes) return 1; + + // equal ranges + return 0; + }); + + return { satRanges: satRangesArray, totalExoticSats }; +}; diff --git a/src/app/components/confirmBtcTransactionComponent/bundle.tsx b/src/app/components/confirmBtcTransactionComponent/bundle.tsx index 84b728b71..eeabbce28 100644 --- a/src/app/components/confirmBtcTransactionComponent/bundle.tsx +++ b/src/app/components/confirmBtcTransactionComponent/bundle.tsx @@ -3,6 +3,7 @@ import AssetModal from '@components/assetModal'; import { CaretDown } from '@phosphor-icons/react'; import { Bundle, BundleSatRange, SatRangeInscription } from '@secretkeylabs/xverse-core'; import { StyledP } from '@ui-library/common.styled'; +import Divider from '@ui-library/divider'; import { useState } from 'react'; import { useTranslation } from 'react-i18next'; import styled from 'styled-components'; @@ -97,8 +98,8 @@ function SatsBundle({ bundle, title }: { bundle: Bundle; title?: string }) { // show ordinal modal to show asset setInscriptionToShow(inscription); }} - showDivider={index !== bundle.satRanges.length - 1} /> + {bundle.satRanges.length > index + 1 && } ))} diff --git a/src/app/components/confirmBtcTransactionComponent/bundleItem.tsx b/src/app/components/confirmBtcTransactionComponent/bundleItem.tsx index 009220d0b..8bb38bd77 100644 --- a/src/app/components/confirmBtcTransactionComponent/bundleItem.tsx +++ b/src/app/components/confirmBtcTransactionComponent/bundleItem.tsx @@ -20,19 +20,11 @@ const Range = styled.div` align-items: center; `; -interface ComponentWithDividerProps { - showDivider: boolean; -} - -const Container = styled.div` - padding-top:${(props) => props.theme.space.s}; - padding-bottom:${(props) => props.theme.space.s}; +const Container = styled.div` display: flex; flex-direction: row; justify-content: space-between; align-items: center; - border-bottom: ${(props) => - props.showDivider ? `1px solid ${props.theme.colors.white_900}` : 'transparent'}; width: 100%; }`; @@ -52,6 +44,7 @@ const InscriptionRow = styled.button` flex-direction: row; align-items: center; background-color: transparent; + cursor: ${(props) => (props.disabled ? 'default' : 'pointer')}; `; const InscriptionText = styled(StyledP)` text-wrap: nowrap; @@ -69,11 +62,9 @@ const BundleText = styled(StyledP)` function BundleItem({ item, ordinalEyePressed, - showDivider, }: { item: BundleSatRange; - ordinalEyePressed: (inscription: SatRangeInscription) => void; - showDivider?: boolean; + ordinalEyePressed?: (inscription: SatRangeInscription) => void; }) { const renderedIcons = () => ( @@ -90,7 +81,7 @@ function BundleItem({ ); return ( - + {renderedIcons()} {getSatLabel(item.satributes)} @@ -110,14 +101,15 @@ function BundleItem({ type="button" key={inscription.id} onClick={() => { - ordinalEyePressed(inscription); + ordinalEyePressed?.(inscription); }} + disabled={!ordinalEyePressed} > ordinal {inscription.inscription_number} - + {ordinalEyePressed && } ))} diff --git a/src/app/components/screenContainer/index.tsx b/src/app/components/screenContainer/index.tsx index 233f2df2a..7152b206c 100644 --- a/src/app/components/screenContainer/index.tsx +++ b/src/app/components/screenContainer/index.tsx @@ -8,7 +8,8 @@ const RouteContainer = styled.div` // any route should default to the chrome extension window size display: flex; flex-direction: column; - height: 600px; + height: 100%; + max-height: 600px; width: 360px; margin: auto; background-color: ${(props) => props.theme.colors.elevation0}; diff --git a/src/app/components/speedUpTransaction/btc.tsx b/src/app/components/speedUpTransaction/btc.tsx new file mode 100644 index 000000000..1504438dd --- /dev/null +++ b/src/app/components/speedUpTransaction/btc.tsx @@ -0,0 +1,228 @@ +import FiatAmountText from '@components/fiatAmountText'; +import useWalletSelector from '@hooks/useWalletSelector'; +import { getBtcFiatEquivalent } from '@secretkeylabs/xverse-core'; +import { EMPTY_LABEL } from '@utils/constants'; +import BigNumber from 'bignumber.js'; +import { useTranslation } from 'react-i18next'; +import { NumericFormat } from 'react-number-format'; +import { useTheme } from 'styled-components'; +import { + ButtonContainer, + Container, + ControlsContainer, + CustomFeeIcon, + DetailText, + FeeButton, + FeeButtonLeft, + FeeButtonRight, + HighlightedText, + SecondaryText, + StyledActionButton, + Title, + WarningText, +} from './index.styled'; + +type TierFees = { + enoughFunds: boolean; + fee?: number; + feeRate: number; +}; + +interface Props { + rbfTxSummary?: { + currentFee: number; + currentFeeRate: number; + minimumRbfFee: number; + minimumRbfFeeRate: number; + }; + rbfRecommendedFees?: { + medium?: TierFees; + high?: TierFees; + higher?: TierFees; + highest?: TierFees; + }; + selectedOption?: string; + customFeeRate?: string; + customTotalFee?: string; + feeButtonMapping: { + [key: string]: { + title: string; + icon: React.ReactNode; + }; + }; + handleGoBack: () => void; + handleClickFeeButton: (e: React.MouseEvent) => void; + handleClickSubmit: () => void; + getEstimatedCompletionTime: (feeRate?: number) => string; + isBroadcasting: boolean; +} + +function SpeedUpBtcTransaction({ + rbfTxSummary, + rbfRecommendedFees, + selectedOption, + customFeeRate, + customTotalFee, + feeButtonMapping, + handleGoBack, + handleClickFeeButton, + handleClickSubmit, + getEstimatedCompletionTime, + isBroadcasting, +}: Props) { + const { t } = useTranslation('translation', { keyPrefix: 'SPEED_UP_TRANSACTION' }); + const { btcFiatRate, fiatCurrency } = useWalletSelector(); + const theme = useTheme(); + + return ( + + {t('TITLE')} + {t('FEE_INFO')} + + {t('CURRENT_FEE')}{' '} + + + + + + + {t('ESTIMATED_COMPLETION_TIME')}{' '} + + {getEstimatedCompletionTime(rbfTxSummary?.currentFeeRate)} + + + + {rbfRecommendedFees && + Object.entries(rbfRecommendedFees).map(([key, obj]) => { + const isDisabled = !obj.enoughFunds; + + return ( + + + {feeButtonMapping[key].icon} +
+ {feeButtonMapping[key].title} + {getEstimatedCompletionTime(obj.feeRate)} + + + +
+
+ +
+ {obj.fee ? ( + + ) : ( + EMPTY_LABEL + )} +
+ + {obj.fee ? ( + + ) : ( + `${EMPTY_LABEL} ${fiatCurrency}` + )} + + {isDisabled && {t('INSUFFICIENT_FUNDS')}} +
+
+ ); + })} + + + +
+ {t('CUSTOM')} + {customFeeRate && ( + <> + {getEstimatedCompletionTime(Number(customFeeRate))} + + + + + )} +
+
+ + {customFeeRate && customTotalFee ? ( + <> + {value}} + /> + + + + + ) : ( + t('MANUAL_SETTING') + )} + +
+
+ + + + +
+ ); +} + +export default SpeedUpBtcTransaction; diff --git a/src/app/components/speedUpTransaction/index.styled.ts b/src/app/components/speedUpTransaction/index.styled.ts new file mode 100644 index 000000000..a9fba674e --- /dev/null +++ b/src/app/components/speedUpTransaction/index.styled.ts @@ -0,0 +1,113 @@ +import ActionButton from '@components/button'; +import { Faders } from '@phosphor-icons/react'; +import styled from 'styled-components'; + +export const Title = styled.h1((props) => ({ + ...props.theme.typography.headline_s, + color: props.theme.colors.white_0, + marginTop: props.theme.space.m, + marginBottom: props.theme.space.m, +})); + +export const Container = styled.div((props) => ({ + display: 'flex', + flexDirection: 'column', + paddingLeft: props.theme.space.m, + paddingRight: props.theme.space.m, + ...props.theme.scrollbar, +})); + +export const DetailText = styled.span((props) => ({ + ...props.theme.typography.body_m, + color: props.theme.colors.white_200, + marginBottom: props.theme.space.xs, +})); + +export const HighlightedText = styled.span((props) => ({ + ...props.theme.typography.body_medium_m, + color: props.theme.colors.white_0, +})); + +export const ButtonContainer = styled.div` + display: flex; + flex-direction: column; + margin-top: ${(props) => props.theme.space.s}; + gap: ${(props) => props.theme.space.xs}; +`; + +export const FeeButton = styled.button<{ + isSelected: boolean; + centered?: boolean; +}>((props) => ({ + ...props.theme.typography.body_medium_m, + textAlign: 'left', + color: props.theme.colors.white_0, + backgroundColor: `${props.isSelected ? props.theme.colors.elevation6_600 : 'transparent'}`, + border: `1px solid ${ + props.isSelected ? props.theme.colors.white_800 : props.theme.colors.white_850 + }`, + borderRadius: props.theme.radius(2), + height: 'auto', + display: 'flex', + justifyContent: 'space-between', + alignItems: props.centered ? 'center' : 'flex-start', + transition: 'background-color 0.1s ease-in-out, border 0.1s ease-in-out', + padding: props.theme.space.m, + paddingTop: props.theme.space.s, + paddingBottom: props.theme.space.s, + ':not(:disabled):hover': { + borderColor: props.theme.colors.white_800, + }, + ':disabled': { + cursor: 'not-allowed', + color: props.theme.colors.white_400, + div: { + color: 'inherit', + }, + svg: { + fill: props.theme.colors.white_600, + }, + }, +})); + +export const ControlsContainer = styled.div` + display: flex; + column-gap: ${(props) => props.theme.space.s}; + margin: ${(props) => props.theme.space.xxl} 0px ${(props) => props.theme.space.xxl}; +`; + +export const CustomFeeIcon = styled(Faders)({ + transform: 'rotate(90deg)', +}); + +export const FeeButtonLeft = styled.div((props) => ({ + display: 'flex', + alignItems: 'center', + gap: props.theme.space.s, +})); + +export const FeeButtonRight = styled.div({ + textAlign: 'right', +}); + +export const SecondaryText = styled.div<{ + alignRight?: boolean; +}>((props) => ({ + ...props.theme.typography.body_medium_s, + color: props.theme.colors.white_200, + marginTop: props.theme.space.xxs, + textAlign: props.alignRight ? 'right' : 'left', +})); + +export const StyledActionButton = styled(ActionButton)((props) => ({ + 'div, h1': { + ...props.theme.typography.body_medium_m, + }, +})); + +export const WarningText = styled.span((props) => ({ + ...props.theme.typography.body_medium_s, + display: 'block', + color: props.theme.colors.danger_light, + marginTop: props.theme.space.xxs, +})); diff --git a/src/app/components/speedUpTransaction/stx.tsx b/src/app/components/speedUpTransaction/stx.tsx new file mode 100644 index 000000000..7c7b62ceb --- /dev/null +++ b/src/app/components/speedUpTransaction/stx.tsx @@ -0,0 +1,206 @@ +import FiatAmountText from '@components/fiatAmountText'; +import useWalletSelector from '@hooks/useWalletSelector'; +import { getStxFiatEquivalent, stxToMicrostacks } from '@secretkeylabs/xverse-core'; +import { EMPTY_LABEL } from '@utils/constants'; +import BigNumber from 'bignumber.js'; +import { useTranslation } from 'react-i18next'; +import { NumericFormat } from 'react-number-format'; +import { useTheme } from 'styled-components'; +import { + ButtonContainer, + Container, + ControlsContainer, + CustomFeeIcon, + DetailText, + FeeButton, + FeeButtonLeft, + FeeButtonRight, + HighlightedText, + SecondaryText, + StyledActionButton, + Title, + WarningText, +} from './index.styled'; + +type TierFees = { + enoughFunds: boolean; + fee?: number; + feeRate: number; +}; + +interface Props { + rbfTxSummary?: { + currentFee: number; + currentFeeRate: number; + minimumRbfFee: number; + minimumRbfFeeRate: number; + }; + rbfRecommendedFees?: { + medium?: TierFees; + high?: TierFees; + higher?: TierFees; + highest?: TierFees; + }; + selectedOption?: string; + customFeeRate?: string; + customTotalFee?: string; + feeButtonMapping: { + [key: string]: { + title: string; + icon: React.ReactNode; + }; + }; + handleGoBack: () => void; + handleClickFeeButton: (e: React.MouseEvent) => void; + handleClickSubmit: () => void; + getEstimatedCompletionTime: (feeRate?: number) => string; + isBroadcasting: boolean; +} + +function SpeedUpStxTransaction({ + rbfTxSummary, + rbfRecommendedFees, + selectedOption, + customFeeRate, + customTotalFee, + feeButtonMapping, + handleGoBack, + handleClickFeeButton, + handleClickSubmit, + getEstimatedCompletionTime, + isBroadcasting, +}: Props) { + const { t } = useTranslation('translation', { keyPrefix: 'SPEED_UP_TRANSACTION' }); + const { btcFiatRate, stxBtcRate, fiatCurrency } = useWalletSelector(); + const theme = useTheme(); + + return ( + + {t('TITLE')} + {t('FEE_INFO')} + + {t('CURRENT_FEE')}{' '} + + + + + + {t('ESTIMATED_COMPLETION_TIME')}{' '} + + {getEstimatedCompletionTime(rbfTxSummary?.currentFeeRate)} + + + + {rbfRecommendedFees && + Object.entries(rbfRecommendedFees).map(([key, obj]) => { + const isDisabled = !obj.enoughFunds; + + return ( + + + {feeButtonMapping[key].icon} +
+ {feeButtonMapping[key].title} + {getEstimatedCompletionTime(obj.feeRate)} +
+
+ +
+ {obj.fee ? ( + + ) : ( + EMPTY_LABEL + )} +
+ + {obj.fee ? ( + + ) : ( + `${EMPTY_LABEL} ${fiatCurrency}` + )} + + {isDisabled && {t('INSUFFICIENT_FUNDS')}} +
+
+ ); + })} + + + +
+ {t('CUSTOM')} + {customFeeRate && ( + {getEstimatedCompletionTime(Number(customFeeRate))} + )} +
+
+ + {customFeeRate && customTotalFee ? ( + <> + {value}} + /> + + + + + ) : ( + t('MANUAL_SETTING') + )} + +
+
+ + + + +
+ ); +} + +export default SpeedUpStxTransaction; diff --git a/src/app/components/tokenTile/index.tsx b/src/app/components/tokenTile/index.tsx index cd9fce043..3b605a588 100644 --- a/src/app/components/tokenTile/index.tsx +++ b/src/app/components/tokenTile/index.tsx @@ -124,22 +124,6 @@ const TokenTitleContainer = styled.div({ justifyContent: 'flex-start', }); -const TagContainer = styled.div({ - display: 'flex', - alignItems: 'center', -}); - -const ProtocolText = styled.p((props) => ({ - ...props.theme.headline_category_s, - fontWeight: '700', - textTransform: 'uppercase', - marginLeft: props.theme.spacing(5), - backgroundColor: props.theme.colors.white_400, - padding: '2px 6px 1px', - borderRadius: props.theme.radius(2), - whiteSpace: 'nowrap', -})); - const StyledBarLoader = styled(BetterBarLoader)<{ withMarginBottom?: boolean; }>((props) => ({ @@ -363,13 +347,6 @@ function TokenTile({ {getTickerTitle()} {title} - {fungibleToken?.protocol ? ( - - - {fungibleToken?.protocol === 'stacks' ? 'Sip-10' : fungibleToken?.protocol} - - - ) : null} diff --git a/src/app/components/transactionSetting/editBtcFee.tsx b/src/app/components/transactionSetting/editBtcFee.tsx new file mode 100644 index 000000000..77509d524 --- /dev/null +++ b/src/app/components/transactionSetting/editBtcFee.tsx @@ -0,0 +1,422 @@ +import useBtcClient from '@hooks/useBtcClient'; +import useBtcFees from '@hooks/useBtcFees'; +import useDebounce from '@hooks/useDebounce'; +import useOrdinalsByAddress from '@hooks/useOrdinalsByAddress'; +import useWalletSelector from '@hooks/useWalletSelector'; +import { Faders } from '@phosphor-icons/react'; +import { + currencySymbolMap, + ErrorCodes, + getBtcFees, + getBtcFeesForNonOrdinalBtcSend, + getBtcFeesForOrdinalSend, + getBtcFiatEquivalent, + Recipient, + UTXO, +} from '@secretkeylabs/xverse-core'; +import { StyledP } from '@ui-library/common.styled'; +import BigNumber from 'bignumber.js'; +import { useEffect, useMemo, useRef, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { NumericFormat } from 'react-number-format'; +import styled from 'styled-components'; +import Theme from 'theme'; +import FeeItem from './feeItem'; + +const Container = styled.div((props) => ({ + display: 'flex', + flexDirection: 'column', + marginLeft: props.theme.space.m, + marginRight: props.theme.space.m, + paddingBottom: props.theme.space.m, +})); + +const DetailText = styled.h1((props) => ({ + ...props.theme.typography.body_m, + color: props.theme.colors.white_200, +})); + +interface InputContainerProps { + withError?: boolean; +} +const InputContainer = styled.div((props) => ({ + display: 'flex', + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + marginTop: props.theme.space.xs, + marginBottom: props.theme.space.s, + border: `1px solid ${ + props.withError ? props.theme.colors.danger_medium : props.theme.colors.elevation6 + }`, + backgroundColor: props.theme.colors.elevation1, + borderRadius: props.theme.radius(1), + padding: props.theme.space.s, +})); + +const InputField = styled.input((props) => ({ + ...props.theme.typography.body_m, + backgroundColor: 'transparent', + color: props.theme.colors.white_0, + border: 'transparent', + width: '50%', + '&::-webkit-outer-spin-button': { + '-webkit-appearance': 'none', + margin: 0, + }, + '&::-webkit-inner-spin-button': { + '-webkit-appearance': 'none', + margin: 0, + }, + '&[type=number]': { + '-moz-appearance': 'textfield', + }, +})); + +const FeeText = styled.h1((props) => ({ + ...props.theme.typography.body_m, + color: props.theme.colors.white_0, +})); + +const FeeContainer = styled.div({ + display: 'flex', + flexDirection: 'column', +}); + +const ErrorText = styled.h1((props) => ({ + ...props.theme.typography.body_s, + color: props.theme.colors.danger_light, + marginBottom: props.theme.space.xxs, +})); + +const FeePrioritiesContainer = styled.div` + display: flex; + margin-top: ${(props) => props.theme.space.m}; + flex-direction: column; +`; + +interface FeeContainerProps { + isSelected: boolean; +} + +const FeeItemContainer = styled.button` + display: flex; + padding: ${(props) => props.theme.space.s} ${(props) => props.theme.space.m}; + align-items: center; + gap: ${(props) => props.theme.space.s}; + align-self: stretch; + border-radius: ${(props) => props.theme.space.s}; + border: 1px solid ${(props) => props.theme.colors.elevation6}; + flex-direction: row; + background: ${(props) => (props.isSelected ? props.theme.colors.elevation6_600 : 'transparent')}; + margin-top: ${(props) => props.theme.space.xs}; + flex: 1; +`; + +const TextRow = styled.div` + display: flex; + flex-direction: row; + justify-content: space-between; + flex: 1; +`; + +const CustomTextsContainer = styled.div` + display: flex; + flex-direction: row; + justify-content: space-between; + flex: 1; +`; + +const Row = styled.div` + display: flex; + flex-direction: row; + align-items: center; +`; + +const TotalFeeText = styled(StyledP)` + margin-right: ${(props) => props.theme.space.xxs}; +`; + +interface Props { + type?: string; + fee: string; + feeRate?: BigNumber | string; + btcRecipients?: Recipient[]; + ordinalTxUtxo?: UTXO; + isRestoreFlow?: boolean; + nonOrdinalUtxos?: UTXO[]; + feeMode: string; + error: string; + customFeeSelected: boolean; + setIsLoading: () => void; + setIsNotLoading: () => void; + setFee: (fee: string) => void; + setFeeRate: (feeRate: string) => void; + setFeeMode: (feeMode: string) => void; + setError: (error: string) => void; + setCustomFeeSelected: (selected: boolean) => void; + feeOptionSelected: (feeRate: string, totalFee: string) => void; +} +function EditBtcFee({ + type, + fee, + feeRate, + btcRecipients, + ordinalTxUtxo, + isRestoreFlow, + nonOrdinalUtxos, + feeMode, + error, + customFeeSelected, + setIsLoading, + setIsNotLoading, + setFee, + setFeeRate, + setError, + setFeeMode, + setCustomFeeSelected, + feeOptionSelected, +}: Props) { + const { t } = useTranslation('translation'); + const { network, btcAddress, btcFiatRate, fiatCurrency, selectedAccount, ordinalsAddress } = + useWalletSelector(); + const [totalFee, setTotalFee] = useState(fee); + const [feeRateInput, setFeeRateInput] = useState(feeRate?.toString() ?? ''); + const inputRef = useRef(null); + const debouncedFeeRateInput = useDebounce(feeRateInput, 500); + const { ordinals } = useOrdinalsByAddress(btcAddress); + const ordinalsUtxos = useMemo(() => ordinals?.map((ord) => ord.utxo), [ordinals]); + const btcClient = useBtcClient(); + const { feeData, highFeeError, mediumFeeError } = useBtcFees({ + isRestoreFlow: !!isRestoreFlow, + nonOrdinalUtxos, + btcRecipients, + type, + ordinalTxUtxo, + }); + + useEffect(() => { + setFee(totalFee); + }, [totalFee]); + + const recalculateFees = async () => { + if (type === 'BTC') { + try { + setIsLoading(); + setError(''); + + if (isRestoreFlow) { + const { fee: modifiedFee, selectedFeeRate } = await getBtcFeesForNonOrdinalBtcSend( + btcAddress, + nonOrdinalUtxos!, + ordinalsAddress, + network.type, + feeMode, + feeRateInput, + ); + setFeeRateInput(selectedFeeRate!.toString()); + setTotalFee(modifiedFee.toString()); + } else if (btcRecipients && selectedAccount) { + const { fee: modifiedFee, selectedFeeRate } = await getBtcFees( + btcRecipients, + btcAddress, + btcClient, + network.type, + feeMode, + feeRateInput, + ); + setFeeRateInput(selectedFeeRate!.toString()); + setTotalFee(modifiedFee.toString()); + } + } catch (err: any) { + if (Number(err) === ErrorCodes.InSufficientBalance) { + setError(t('TX_ERRORS.INSUFFICIENT_BALANCE')); + } else if (Number(err) === ErrorCodes.InSufficientBalanceWithTxFee) { + setError(t('TX_ERRORS.INSUFFICIENT_BALANCE_FEES')); + } else setError(err.toString()); + } finally { + setIsNotLoading(); + } + } else if (type === 'Ordinals' && btcRecipients && ordinalTxUtxo) { + try { + setIsLoading(); + setError(''); + + const { fee: modifiedFee, selectedFeeRate } = await getBtcFeesForOrdinalSend( + btcRecipients[0].address, + ordinalTxUtxo, + btcAddress, + btcClient, + network.type, + ordinalsUtxos || [], + feeMode, + feeRateInput, + ); + if (selectedFeeRate) setFeeRateInput(selectedFeeRate.toString()); + setTotalFee(modifiedFee.toString()); + } catch (err: any) { + if (Number(err) === ErrorCodes.InSufficientBalance) { + setError(t('TX_ERRORS.INSUFFICIENT_BALANCE')); + } else if (Number(err) === ErrorCodes.InSufficientBalanceWithTxFee) { + setError(t('TX_ERRORS.INSUFFICIENT_BALANCE_FEES')); + } else setError(err.toString()); + } finally { + setIsNotLoading(); + } + } + }; + + useEffect(() => { + if (feeRateInput) { + setFeeRate(feeRateInput); + } + }, [feeRateInput]); + + useEffect(() => { + if (debouncedFeeRateInput) { + recalculateFees(); + } + }, [debouncedFeeRateInput]); + + function getFiatEquivalent() { + return getBtcFiatEquivalent(new BigNumber(totalFee), BigNumber(btcFiatRate)); + } + + const getFiatAmountString = (fiatAmount: BigNumber) => { + if (fiatAmount) { + if (fiatAmount.isLessThan(0.01)) { + return `<${currencySymbolMap[fiatCurrency]}0.01 ${fiatCurrency}`; + } + return ( + ( + + {value} + + )} + /> + ); + } + return ''; + }; + + const onInputEditFeesChange = ({ target: { value } }: React.ChangeEvent) => { + if (error) { + setError(''); + } + + if (feeMode !== 'custom') { + setFeeMode('custom'); + } + + setFeeRateInput(value); + + if (type !== 'BTC' && type !== 'Ordinals') { + setFeeRateInput(value); + setTotalFee(value); + } + }; + + return ( + + {t('TRANSACTION_SETTING.FEE_INFO')} + + {!customFeeSelected && ( + + { + feeOptionSelected(feeData?.highFeeRate?.toString() || '', feeData?.highTotalFee); + setFeeMode('high'); + }} + selected={feeMode === 'high'} + error={highFeeError} + /> + { + feeOptionSelected( + feeData?.standardFeeRate?.toString() || '', + feeData?.standardTotalFee, + ); + setFeeMode('medium'); + }} + selected={feeMode === 'medium'} + error={mediumFeeError} + /> + { + setCustomFeeSelected(true); + }} + > + + + + {t('TRANSACTION_SETTING.CUSTOM')} + + + {t('TRANSACTION_SETTING.MANUAL_SETTING')} + + + + + )} + + {customFeeSelected && ( + + + + Sats /vByte + + + + + {t('TRANSACTION_SETTING.TOTAL_FEE')} + + ( + + {value} + + )} + /> + + + {getFiatAmountString(getFiatEquivalent())} + + + {error && {error}} + + )} + + ); +} + +export default EditBtcFee; diff --git a/src/app/components/transactionSetting/editFee.tsx b/src/app/components/transactionSetting/editStxFee.tsx similarity index 50% rename from src/app/components/transactionSetting/editFee.tsx rename to src/app/components/transactionSetting/editStxFee.tsx index c58c7fbe5..43b167a89 100644 --- a/src/app/components/transactionSetting/editFee.tsx +++ b/src/app/components/transactionSetting/editStxFee.tsx @@ -1,21 +1,11 @@ -import useBtcClient from '@hooks/useBtcClient'; -import useDebounce from '@hooks/useDebounce'; -import useOrdinalsByAddress from '@hooks/useOrdinalsByAddress'; import useWalletSelector from '@hooks/useWalletSelector'; import { currencySymbolMap, - ErrorCodes, - getBtcFees, - getBtcFeesForNonOrdinalBtcSend, - getBtcFeesForOrdinalSend, - getBtcFiatEquivalent, getStxFiatEquivalent, - Recipient, stxToMicrostacks, - UTXO, } from '@secretkeylabs/xverse-core'; import BigNumber from 'bignumber.js'; -import { useEffect, useMemo, useRef, useState } from 'react'; +import { useEffect, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { NumericFormat } from 'react-number-format'; import styled from 'styled-components'; @@ -36,7 +26,6 @@ const FiatAmountText = styled.h1((props) => ({ const DetailText = styled.h1((props) => ({ ...props.theme.body_m, color: props.theme.colors.white_200, - marginTop: props.theme.spacing(8), })); const Text = styled.h1((props) => ({ @@ -80,11 +69,6 @@ const InputField = styled.input((props) => ({ }, })); -const FeeText = styled.h1((props) => ({ - ...props.theme.body_m, - color: props.theme.colors.white_0, -})); - const SubText = styled.h1((props) => ({ ...props.theme.body_xs, color: props.theme.colors.white_400, @@ -93,7 +77,6 @@ const SubText = styled.h1((props) => ({ interface ButtonProps { isSelected: boolean; isLastInRow?: boolean; - isBtc?: boolean; } const FeeButton = styled.button((props) => ({ ...props.theme.body_medium_m, @@ -101,7 +84,7 @@ const FeeButton = styled.button((props) => ({ background: `${props.isSelected ? props.theme.colors.white : 'transparent'}`, border: `1px solid ${props.isSelected ? 'transparent' : props.theme.colors.elevation6}`, borderRadius: props.theme.radius(9), - width: props.isBtc ? 104 : 82, + width: 82, height: 40, display: 'flex', justifyContent: 'center', @@ -140,55 +123,30 @@ interface Props { type?: string; fee: string; feeRate?: BigNumber | string; - btcRecipients?: Recipient[]; - ordinalTxUtxo?: UTXO; - isRestoreFlow?: boolean; - nonOrdinalUtxos?: UTXO[]; feeMode: string; error: string; - setIsLoading: () => void; - setIsNotLoading: () => void; setFee: (fee: string) => void; setFeeRate: (feeRate: string) => void; setFeeMode: (feeMode: string) => void; setError: (error: string) => void; } -function EditFee({ +function EditStxFee({ type, fee, feeRate, - btcRecipients, - ordinalTxUtxo, - isRestoreFlow, - nonOrdinalUtxos, feeMode, error, - setIsLoading, - setIsNotLoading, setFee, setFeeRate, setError, setFeeMode, }: Props) { const { t } = useTranslation('translation'); - const { - network, - btcAddress, - stxBtcRate, - btcFiatRate, - fiatCurrency, - selectedAccount, - ordinalsAddress, - } = useWalletSelector(); + const { stxBtcRate, btcFiatRate, fiatCurrency } = useWalletSelector(); const [totalFee, setTotalFee] = useState(fee); const [feeRateInput, setFeeRateInput] = useState(feeRate?.toString() ?? ''); const inputRef = useRef(null); - const debouncedFeeRateInput = useDebounce(feeRateInput, 500); - const isBtcOrOrdinals = type === 'BTC' || type === 'Ordinals'; const isStx = type === 'STX'; - const { ordinals } = useOrdinalsByAddress(btcAddress); - const ordinalsUtxos = useMemo(() => ordinals?.map((ord) => ord.utxo), [ordinals]); - const btcClient = useBtcClient(); const modifyStxFees = (mode: string) => { const currentFee = new BigNumber(fee); @@ -199,7 +157,7 @@ function EditFee({ setFeeRateInput(currentFee.dividedBy(2).toString()); setTotalFee(currentFee.dividedBy(2).toString()); break; - case 'standard': + case 'medium': setFeeRateInput(currentFee.toString()); setTotalFee(currentFee.toString()); break; @@ -215,58 +173,6 @@ function EditFee({ } }; - const modifyFees = async (mode: string) => { - try { - setFeeMode(mode); - setIsLoading(); - setError(''); - if (mode === 'custom') inputRef?.current?.focus(); - else if (type === 'BTC') { - if (isRestoreFlow) { - const { fee: modifiedFee, selectedFeeRate } = await getBtcFeesForNonOrdinalBtcSend( - btcAddress, - nonOrdinalUtxos!, - ordinalsAddress, - network.type, - mode, - ); - setFeeRateInput(selectedFeeRate?.toString() || ''); - setTotalFee(modifiedFee.toString()); - } else if (btcRecipients && selectedAccount) { - const { fee: modifiedFee, selectedFeeRate } = await getBtcFees( - btcRecipients, - btcAddress, - btcClient, - network.type, - mode, - ); - setFeeRateInput(selectedFeeRate?.toString() || ''); - setTotalFee(modifiedFee.toString()); - } - } else if (type === 'Ordinals' && btcRecipients && ordinalTxUtxo) { - const { fee: modifiedFee, selectedFeeRate } = await getBtcFeesForOrdinalSend( - btcRecipients[0].address, - ordinalTxUtxo, - btcAddress, - btcClient, - network.type, - ordinalsUtxos || [], - mode, - ); - setFeeRateInput(selectedFeeRate?.toString() || ''); - setTotalFee(modifiedFee.toString()); - } - } catch (err: any) { - if (Number(err) === ErrorCodes.InSufficientBalance) { - setError(t('TX_ERRORS.INSUFFICIENT_BALANCE')); - } else if (Number(err) === ErrorCodes.InSufficientBalanceWithTxFee) { - setError(t('TX_ERRORS.INSUFFICIENT_BALANCE_FEES')); - } else setError(err.toString()); - } finally { - setIsNotLoading(); - } - }; - useEffect(() => { if (isStx && feeMode !== 'custom') { modifyStxFees(feeMode); @@ -277,93 +183,18 @@ function EditFee({ setFee(totalFee); }, [totalFee]); - const recalculateFees = async () => { - if (type === 'BTC') { - try { - setIsLoading(); - setError(''); - - if (isRestoreFlow) { - const { fee: modifiedFee, selectedFeeRate } = await getBtcFeesForNonOrdinalBtcSend( - btcAddress, - nonOrdinalUtxos!, - ordinalsAddress, - network.type, - feeMode, - feeRateInput, - ); - setFeeRateInput(selectedFeeRate!.toString()); - setTotalFee(modifiedFee.toString()); - } else if (btcRecipients && selectedAccount) { - const { fee: modifiedFee, selectedFeeRate } = await getBtcFees( - btcRecipients, - btcAddress, - btcClient, - network.type, - feeMode, - feeRateInput, - ); - setFeeRateInput(selectedFeeRate!.toString()); - setTotalFee(modifiedFee.toString()); - } - } catch (err: any) { - if (Number(err) === ErrorCodes.InSufficientBalance) { - setError(t('TX_ERRORS.INSUFFICIENT_BALANCE')); - } else if (Number(err) === ErrorCodes.InSufficientBalanceWithTxFee) { - setError(t('TX_ERRORS.INSUFFICIENT_BALANCE_FEES')); - } else setError(err.toString()); - } finally { - setIsNotLoading(); - } - } else if (type === 'Ordinals' && btcRecipients && ordinalTxUtxo) { - try { - setIsLoading(); - setError(''); - - const { fee: modifiedFee, selectedFeeRate } = await getBtcFeesForOrdinalSend( - btcRecipients[0].address, - ordinalTxUtxo, - btcAddress, - btcClient, - network.type, - ordinalsUtxos || [], - feeMode, - feeRateInput, - ); - setFeeRateInput(selectedFeeRate!.toString()); - setTotalFee(modifiedFee.toString()); - } catch (err: any) { - if (Number(err) === ErrorCodes.InSufficientBalance) { - setError(t('TX_ERRORS.INSUFFICIENT_BALANCE')); - } else if (Number(err) === ErrorCodes.InSufficientBalanceWithTxFee) { - setError(t('TX_ERRORS.INSUFFICIENT_BALANCE_FEES')); - } else setError(err.toString()); - } finally { - setIsNotLoading(); - } - } - }; - useEffect(() => { if (feeRateInput) { setFeeRate(feeRateInput); } }, [feeRateInput]); - useEffect(() => { - if (debouncedFeeRateInput) { - recalculateFees(); - } - }, [debouncedFeeRateInput]); - function getFiatEquivalent() { - return isStx - ? getStxFiatEquivalent( - stxToMicrostacks(new BigNumber(totalFee)), - BigNumber(stxBtcRate), - BigNumber(btcFiatRate), - ) - : getBtcFiatEquivalent(new BigNumber(totalFee), BigNumber(btcFiatRate)); + return getStxFiatEquivalent( + stxToMicrostacks(new BigNumber(totalFee)), + BigNumber(stxBtcRate), + BigNumber(btcFiatRate), + ); } const getFiatAmountString = (fiatAmount: BigNumber) => { @@ -395,15 +226,12 @@ function EditFee({ } setFeeRateInput(value); - - if (type !== 'BTC' && type !== 'Ordinals') { - setFeeRateInput(value); - setTotalFee(value); - } + setTotalFee(value); }; return ( + {t('TRANSACTION_SETTING.FEE_INFO')} {t('TRANSACTION_SETTING.FEE')} @@ -413,17 +241,7 @@ function EditFee({ value={feeRateInput?.toString()} onChange={onInputEditFeesChange} /> - {isBtcOrOrdinals && sats /vB} - {isBtcOrOrdinals && ( - {value}} - /> - )} {getFiatAmountString(getFiatEquivalent())} @@ -435,32 +253,22 @@ function EditFee({ {t('TRANSACTION_SETTING.LOW')} )} - (isStx ? modifyStxFees('standard') : modifyFees('standard'))} - > + modifyStxFees('medium')}> {t('TRANSACTION_SETTING.STANDARD')} - (isStx ? modifyStxFees('high') : modifyFees('high'))} - > + modifyStxFees('high')}> {t('TRANSACTION_SETTING.HIGH')} (isStx ? modifyStxFees('custom') : modifyFees('custom'))} + onClick={() => modifyStxFees('custom')} > {t('TRANSACTION_SETTING.CUSTOM')} - {t('TRANSACTION_SETTING.FEE_INFO')} ); } -export default EditFee; +export default EditStxFee; diff --git a/src/app/components/transactionSetting/feeItem.tsx b/src/app/components/transactionSetting/feeItem.tsx new file mode 100644 index 000000000..98af21e26 --- /dev/null +++ b/src/app/components/transactionSetting/feeItem.tsx @@ -0,0 +1,171 @@ +import { Bicycle, CarProfile, RocketLaunch } from '@phosphor-icons/react'; +import { ErrorCodes } from '@secretkeylabs/xverse-core'; +import { StyledP } from '@ui-library/common.styled'; +import { useTranslation } from 'react-i18next'; +import { MoonLoader } from 'react-spinners'; +import styled from 'styled-components'; +import Theme from 'theme'; + +interface FeeContainer { + isSelected: boolean; +} + +const FeeItemContainer = styled.button` + display: flex; + padding: ${(props) => props.theme.space.s} ${(props) => props.theme.space.m}; + align-items: center; + gap: ${(props) => props.theme.space.s}; + align-self: stretch; + border-radius: ${(props) => props.theme.space.s}; + border: 1px solid ${(props) => props.theme.colors.elevation6}; + flex-direction: row; + background: ${(props) => (props.isSelected ? props.theme.colors.elevation6_600 : 'transparent')}; + margin-top: ${(props) => props.theme.space.xs}; + flex: 1; +`; + +const IconContainer = styled.div` + display: flex; + align-items: center; + justify-content: center; +`; + +const TextsContainer = styled.div` + display: flex; + flex-direction: row; + justify-content: space-between; + + flex: 1; +`; + +const ColumnsTexts = styled.div` + display: flex; + flex-direction: column; + align-items: flex-start; + flex: 1; +`; +const EndColumnTexts = styled.div` + display: flex; + flex-direction: column; + align-items: flex-end; +`; + +const StyledHeading = styled(StyledP)` + margin-bottom: ${(props) => props.theme.space.xxs}; +`; + +const StyledSubText = styled(StyledP)` + margin-bottom: ${(props) => props.theme.space.xxs}; +`; + +const LoaderContainer = styled.div` + display: flex; + align-items: center; + justify-content: flex-end; + flex: 1; +`; + +type FeePriority = 'high' | 'medium' | 'low'; + +interface FeeItemProps { + priority: FeePriority; + time: string; + feeRate: string; + totalFee: string; + fiat: string | JSX.Element; + selected: boolean; + onClick?: () => void; + error?: string; +} + +function FeeItem({ + priority, + time, + feeRate, + totalFee, + fiat, + selected, + error, + onClick, +}: FeeItemProps) { + const { t } = useTranslation('translation'); + const getIcon = () => { + switch (priority) { + case 'high': + return ; + case 'medium': + return ; + case 'low': + return ; + default: + return ; + } + }; + + const getLabel = () => { + switch (priority) { + case 'high': + return t('SPEED_UP_TRANSACTION.HIGH_PRIORITY'); + case 'medium': + return t('SPEED_UP_TRANSACTION.MED_PRIORITY'); + case 'low': + return t('SPEED_UP_TRANSACTION.LOW_PRIORITY'); + default: + return t('SPEED_UP_TRANSACTION.HIGH_PRIORITY'); + } + }; + + const getErrorMessage = (btcError: string) => { + if ( + Number(btcError) === ErrorCodes.InSufficientBalance || + Number(btcError) === ErrorCodes.InSufficientBalanceWithTxFee + ) { + return t('SEND.ERRORS.INSUFFICIENT_BALANCE'); + } + return btcError; + }; + + return ( + + {getIcon()} + + + + {getLabel()} + + + {time} + + {`${feeRate} Sats/ vByte`} + + + + {totalFee && ( + + {`${totalFee} Sats`} + + )} + + {fiat} + + {error && ( + + {getErrorMessage(error)} + + )} + + + {!totalFee && !error && ( + + + + )} + + + ); +} + +export default FeeItem; diff --git a/src/app/components/transactionSetting/index.tsx b/src/app/components/transactionSetting/index.tsx index 7cbacfa1e..9668f8438 100644 --- a/src/app/components/transactionSetting/index.tsx +++ b/src/app/components/transactionSetting/index.tsx @@ -7,8 +7,10 @@ import BigNumber from 'bignumber.js'; import { useState } from 'react'; import { useTranslation } from 'react-i18next'; import styled from 'styled-components'; -import EditFee from './editFee'; +import Theme from 'theme'; +import EditBtcFee from './editBtcFee'; import EditNonce from './editNonce'; +import EditStxFee from './editStxFee'; const ButtonContainer = styled.div((props) => ({ display: 'flex', @@ -19,6 +21,26 @@ const ButtonContainer = styled.div((props) => ({ marginRight: props.theme.spacing(8), })); +const ButtonsContainer = styled.div` + display: flex; + flex-direction: row; + margin-left: ${(props) => props.theme.space.m}; + margin-right: ${(props) => props.theme.space.m}; + margin-bottom: ${(props) => props.theme.space.m}; +`; + +const LeftButton = styled.div` + display: flex; + margin-right: ${(props) => props.theme.space.xs}; + flex: 1; +`; + +const RightButton = styled.div` + display: flex; + margin-left: ${(props) => props.theme.space.xs}; + flex: 1; +`; + const TransactionSettingOptionText = styled.h1((props) => ({ ...props.theme.body_medium_l, color: props.theme.colors.white_200, @@ -87,9 +109,10 @@ function TransactionSettingAlert({ const [feeRate, setFeeRate] = useState(feePerVByte); const [nonceInput, setNonceInput] = useState(nonce); const [error, setError] = useState(''); - const [selectedOption, setSelectedOption] = useState('standard'); + const [selectedOption, setSelectedOption] = useState('medium'); const [showNonceSettings, setShowNonceSettings] = useState(false); const [isLoading, setIsLoading] = useState(loading); + const [customFeeSelected, setCustomFeeSelected] = useState(false); const { btcBalance, stxAvailableBalance, network } = useWalletSelector(); const applyClickForStx = () => { @@ -134,10 +157,24 @@ function TransactionSettingAlert({ } setShowNonceSettings(false); setShowFeeSettings(false); + setCustomFeeSelected(false); setError(''); onApplyClick({ fee: feeInput.toString(), feeRate: feeRate?.toString() }); }; + const btcFeeOptionSelected = async (selectedFeeRate: string, totalFee: string) => { + const currentFee = new BigNumber(feeInput); + if (currentFee.gt(btcBalance)) { + // show fee exceeds total balance error + setError(t('TRANSACTION_SETTING.GREATER_FEE_ERROR')); + return; + } + setShowNonceSettings(false); + setShowFeeSettings(false); + setError(''); + onApplyClick({ fee: totalFee, feeRate: selectedFeeRate }); + }; + const onEditFeesPress = () => { setShowFeeSettings(true); }; @@ -166,8 +203,23 @@ function TransactionSettingAlert({ } if (showFeeSettings) { + if (type === 'STX') { + return ( + + ); + } return ( - { + setCustomFeeSelected(selected); + }} + customFeeSelected={customFeeSelected} + feeOptionSelected={btcFeeOptionSelected} /> ); } @@ -221,9 +278,14 @@ function TransactionSettingAlert({ overlayStylesOverriding={{ height: 600, }} + contentStylesOverriding={{ + background: Theme.colors.elevation6_600, + backdropFilter: 'blur(10px)', + paddingBottom: Theme.spacing(8), + }} > {renderContent()} - {(showFeeSettings || showNonceSettings) && ( + {type === 'STX' && (showFeeSettings || showNonceSettings) && ( )} + {customFeeSelected && ( + + + { + setCustomFeeSelected(false); + }} + transparent + /> + + + + + + )} ); } diff --git a/src/app/components/transactions/btcTransaction.tsx b/src/app/components/transactions/btcTransaction.tsx index 40194084b..ffec7015c 100644 --- a/src/app/components/transactions/btcTransaction.tsx +++ b/src/app/components/transactions/btcTransaction.tsx @@ -23,8 +23,8 @@ const TransactionContainer = styled.button((props) => ({ alignItems: 'center', width: '100%', padding: props.theme.spacing(5), - paddingLeft: props.theme.spacing(8), - paddingRight: props.theme.spacing(8), + paddingLeft: props.theme.space.m, + paddingRight: props.theme.space.m, background: 'none', ':hover': { background: props.theme.colors.white_900, @@ -45,7 +45,7 @@ const TransactionAmountContainer = styled.div({ const TransactionInfoContainer = styled.div((props) => ({ display: 'flex', flexDirection: 'column', - marginLeft: props.theme.spacing(6), + marginLeft: props.theme.space.s, flex: 1, })); diff --git a/src/app/components/transactions/stxTransaction.tsx b/src/app/components/transactions/stxTransaction.tsx index e1563a7b8..13abf73de 100644 --- a/src/app/components/transactions/stxTransaction.tsx +++ b/src/app/components/transactions/stxTransaction.tsx @@ -3,55 +3,30 @@ import { parseStxTransactionData } from '@secretkeylabs/xverse-core'; import { AddressTransactionWithTransfers } from '@stacks/stacks-blockchain-api-types'; import { CurrencyTypes } from '@utils/constants'; import { isAddressTransactionWithTransfers, Tx } from '@utils/transactions/transactions'; -// import IncreaseFeeIcon from '@assets/img/transactions/increaseFee.svg'; -// import styled from 'styled-components'; -// import { useTranslation } from 'react-i18next'; import StxTransferTransaction from './stxTransferTransaction'; import TxTransfers from './txTransfers'; -// const IncreaseFeeButton = styled.button((props) => ({ -// ...props.theme.body_xs, -// display: 'flex', -// justifyContent: 'center', -// alignItems: 'center', -// alignSelf: 'flex-start', -// background: 'none', -// paddingLeft: props.theme.spacing(8), -// paddingRight: props.theme.spacing(8), -// color: props.theme.colors.white_0, -// border: `0.5px solid ${props.theme.colors.elevation3}`, -// height: 34, -// borderRadius: props.theme.radius(3), -// img: { -// marginRight: props.theme.spacing(3), -// }, -// })); - interface TransactionHistoryItemProps { transaction: AddressTransactionWithTransfers | Tx; transactionCoin: CurrencyTypes; txFilter: string | null; } -export default function StxTransactionHistoryItem(props: TransactionHistoryItemProps) { - const { transaction, transactionCoin, txFilter } = props; +export default function StxTransactionHistoryItem({ + transaction, + transactionCoin, + txFilter, +}: TransactionHistoryItemProps) { const { selectedAccount } = useWalletSelector(); - // const { t } = useTranslation('translation', { keyPrefix: 'COIN_DASHBOARD_SCREEN' }); if (!isAddressTransactionWithTransfers(transaction)) { return ( - <> - - {/* - fee - {t('INCREASE_FEE_BUTTON')} - */} - + ); } // This is a normal Transaction or MempoolTransaction diff --git a/src/app/components/transactions/stxTransferTransaction.tsx b/src/app/components/transactions/stxTransferTransaction.tsx index 51db9c995..92e116bb2 100644 --- a/src/app/components/transactions/stxTransferTransaction.tsx +++ b/src/app/components/transactions/stxTransferTransaction.tsx @@ -1,20 +1,24 @@ +import ActionButton from '@components/button'; import TransactionAmount from '@components/transactions/transactionAmount'; import TransactionRecipient from '@components/transactions/transactionRecipient'; import TransactionStatusIcon from '@components/transactions/transactionStatusIcon'; import TransactionTitle from '@components/transactions/transactionTitle'; import useWalletSelector from '@hooks/useWalletSelector'; +import { FastForward } from '@phosphor-icons/react'; import { StxTransactionData } from '@secretkeylabs/xverse-core'; import { CurrencyTypes } from '@utils/constants'; import { getStxTxStatusUrl } from '@utils/helper'; -import styled from 'styled-components'; +import { useTranslation } from 'react-i18next'; +import { useNavigate } from 'react-router-dom'; +import styled, { useTheme } from 'styled-components'; const TransactionContainer = styled.button((props) => ({ - display: 'flex', width: '100%', - paddingTop: props.theme.spacing(5), - paddingBottom: props.theme.spacing(5), - paddingLeft: props.theme.spacing(8), - paddingRight: props.theme.spacing(8), + display: 'flex', + alignItems: 'center', + padding: props.theme.spacing(5), + paddingLeft: props.theme.space.m, + paddingRight: props.theme.space.m, background: 'none', ':hover': { background: props.theme.colors.white_900, @@ -27,15 +31,16 @@ const TransactionContainer = styled.button((props) => ({ const TransactionInfoContainer = styled.div((props) => ({ display: 'flex', flexDirection: 'column', - marginLeft: props.theme.spacing(6), + marginLeft: props.theme.space.s, flex: 1, })); const TransactionAmountContainer = styled.div({ + width: '100%', display: 'flex', + flexDirection: 'column', + alignItems: 'flex-end', flex: 1, - width: '100%', - justifyContent: 'flex-end', }); const TransactionRow = styled.div((props) => ({ @@ -46,14 +51,38 @@ const TransactionRow = styled.div((props) => ({ ...props.theme.body_bold_m, })); +const StyledButton = styled(ActionButton)((props) => ({ + padding: 0, + border: 'none', + width: 'auto', + height: 'auto', + div: { + ...props.theme.typography.body_medium_m, + color: props.theme.colors.tangerine, + }, + ':hover:enabled': { + backgroundColor: 'transparent', + }, + ':active:enabled': { + backgroundColor: 'transparent', + }, +})); + interface StxTransferTransactionProps { transaction: StxTransactionData; transactionCoin: CurrencyTypes; } -export default function StxTransferTransaction(props: StxTransferTransactionProps) { - const { transaction, transactionCoin } = props; - const { network } = useWalletSelector(); +export default function StxTransferTransaction({ + transaction, + transactionCoin, +}: StxTransferTransactionProps) { + const { network, hasActivatedRBFKey } = useWalletSelector(); + const theme = useTheme(); + const { t } = useTranslation('translation', { keyPrefix: 'COIN_DASHBOARD_SCREEN' }); + const navigate = useNavigate(); + const showAccelerateButton = + hasActivatedRBFKey && transaction.txStatus === 'pending' && !transaction.incoming; const openTxStatusUrl = () => { window.open(getStxTxStatusUrl(transaction.txid, network), '_blank', 'noopener,noreferrer'); @@ -63,12 +92,31 @@ export default function StxTransferTransaction(props: StxTransferTransactionProp - +
+ + +
+ {showAccelerateButton && ( + { + e.stopPropagation(); + + navigate(`/speed-up-tx/${transaction.txid}`, { + state: { + transaction, + }, + }); + }} + icon={} + iconPosition="right" + /> + )}
-
); diff --git a/src/app/components/transferFeeView/index.tsx b/src/app/components/transferFeeView/index.tsx index 5f0848a2e..75ade94fa 100644 --- a/src/app/components/transferFeeView/index.tsx +++ b/src/app/components/transferFeeView/index.tsx @@ -1,29 +1,30 @@ +import AmountWithInscriptionSatribute from '@components/confirmBtcTransaction/itemRow/amountWithInscriptionSatribute'; import { + btcTransaction, currencySymbolMap, getBtcFiatEquivalent, getFiatEquivalent, } from '@secretkeylabs/xverse-core'; import { StoreState } from '@stores/index'; +import { StyledP } from '@ui-library/common.styled'; import BigNumber from 'bignumber.js'; import { useTranslation } from 'react-i18next'; import { NumericFormat } from 'react-number-format'; import { useSelector } from 'react-redux'; import styled from 'styled-components'; -const RowContainer = styled.div((props) => ({ - display: 'flex', - flexDirection: 'row', +const Container = styled.div((props) => ({ background: props.theme.colors.elevation1, borderRadius: 12, padding: '12px 16px', - justifyContent: 'center', marginBottom: 12, })); -const FeeText = styled.h1((props) => ({ - ...props.theme.body_medium_m, - color: props.theme.colors.white_0, -})); +const Row = styled.div({ + display: 'flex', + flexDirection: 'row', + justifyContent: 'center', +}); const FeeTitleContainer = styled.div({ display: 'flex', @@ -37,24 +38,24 @@ const FeeContainer = styled.div({ alignItems: 'flex-end', }); -const TitleText = styled.h1((props) => ({ - ...props.theme.body_medium_m, - color: props.theme.colors.white_200, -})); - -const FiatAmountText = styled.h1((props) => ({ - ...props.theme.body_m, - fontSize: 12, - color: props.theme.colors.white_400, -})); - interface Props { feePerVByte?: BigNumber; fee: BigNumber; currency: string; title?: string; + inscriptions?: btcTransaction.IOInscription[]; + satributes?: btcTransaction.IOSatribute[]; + onShowInscription?: (inscription: btcTransaction.IOInscription) => void; } -function TransferFeeView({ feePerVByte, fee, currency, title }: Props) { +function TransferFeeView({ + feePerVByte, + fee, + currency, + title, + inscriptions = [], + satributes = [], + onShowInscription = () => {}, +}: Props) { const { t } = useTranslation('translation', { keyPrefix: 'CONFIRM_TRANSACTION' }); const { btcFiatRate, stxBtcRate, fiatCurrency } = useSelector( (state: StoreState) => state.walletState, @@ -81,42 +82,59 @@ function TransferFeeView({ feePerVByte, fee, currency, title }: Props) { thousandSeparator prefix={`${currencySymbolMap[fiatCurrency]} `} suffix={` ${fiatCurrency}`} - renderText={(value: string) => {`~ ${value}`}} + renderText={(value: string) => `~ ${value}`} /> ); }; return ( - - - {title ?? t('FEES')} - - - {value}} - /> - {currency === 'sats' && ( + + + + + {title ?? t('FEES')} + + + {value}} + suffix={` ${currency}`} + renderText={(value: string) => ( + + {value} + + )} /> - )} - - {getFiatAmountString( - currency === 'sats' - ? getBtcFiatEquivalent(new BigNumber(fee), BigNumber(btcFiatRate)) - : new BigNumber(fiatRate!), + {currency === 'sats' && feePerVByte && ( + ( + + {value} + + )} + /> )} - - - + + {getFiatAmountString( + currency === 'sats' + ? getBtcFiatEquivalent(new BigNumber(fee), BigNumber(btcFiatRate)) + : new BigNumber(fiatRate!), + )} + +
+ + +
); } diff --git a/src/app/hooks/queries/useAppConfig.ts b/src/app/hooks/queries/useAppConfig.ts index 733d26ece..1a4c53521 100644 --- a/src/app/hooks/queries/useAppConfig.ts +++ b/src/app/hooks/queries/useAppConfig.ts @@ -1,21 +1,14 @@ import useWalletSelector from '@hooks/useWalletSelector'; import { getAppConfig } from '@secretkeylabs/xverse-core'; -import { ChangeNetworkAction } from '@stores/wallet/actions/actionCreators'; import { useQuery } from '@tanstack/react-query'; -import { useDispatch } from 'react-redux'; const useAppConfig = () => { - const { network, networkAddress, btcApiUrl } = useWalletSelector(); - const dispatch = useDispatch(); + const { network } = useWalletSelector(); return useQuery({ - queryKey: ['app-config', network.type, btcApiUrl], + queryKey: ['app-config', network.type], queryFn: async () => { const response = await getAppConfig(network.type); - if (response.data.btcApiURL && network.type === 'Mainnet' && !btcApiUrl) { - const updatedNetwork = { ...network, btcApiUrl: response.data.btcApiURL }; - dispatch(ChangeNetworkAction(updatedNetwork, networkAddress, '')); - } return response; }, }); diff --git a/src/app/hooks/queries/useTransaction.ts b/src/app/hooks/queries/useTransaction.ts index f1dd82be5..30396baf1 100644 --- a/src/app/hooks/queries/useTransaction.ts +++ b/src/app/hooks/queries/useTransaction.ts @@ -3,7 +3,7 @@ import useWalletSelector from '@hooks/useWalletSelector'; import { fetchBtcTransaction } from '@secretkeylabs/xverse-core'; import { useQuery } from '@tanstack/react-query'; -export default function useTransaction(id: string) { +export default function useTransaction(id?: string) { const { selectedAccount } = useWalletSelector(); const btcClient = useBtcClient(); @@ -25,5 +25,6 @@ export default function useTransaction(id: string) { return useQuery({ queryKey: ['transaction', id], queryFn: fetchTransaction, + enabled: id !== undefined, }); } diff --git a/src/app/hooks/useBtcClient.ts b/src/app/hooks/useBtcClient.ts index 33993a117..18320c299 100644 --- a/src/app/hooks/useBtcClient.ts +++ b/src/app/hooks/useBtcClient.ts @@ -3,17 +3,18 @@ import { useMemo } from 'react'; import useWalletSelector from './useWalletSelector'; const useBtcClient = () => { - const { network, btcApiUrl } = useWalletSelector(); - const { type, btcApiUrl: remoteBtcApiURL } = network; - const url = btcApiUrl || remoteBtcApiURL; + const { network } = useWalletSelector(); + const { type, btcApiUrl, fallbackBtcApiUrl } = network; + const url = btcApiUrl; const esploraInstance = useMemo( () => new BitcoinEsploraApiProvider({ url, + fallbackUrl: fallbackBtcApiUrl, network: type, }), - [url, type], + [url, fallbackBtcApiUrl, type], ); return esploraInstance; diff --git a/src/app/hooks/useBtcFees.ts b/src/app/hooks/useBtcFees.ts new file mode 100644 index 000000000..976b26308 --- /dev/null +++ b/src/app/hooks/useBtcFees.ts @@ -0,0 +1,114 @@ +import { + getBtcFees, + getBtcFeesForNonOrdinalBtcSend, + getBtcFeesForOrdinalSend, + Recipient, + UTXO, +} from '@secretkeylabs/xverse-core'; +import { useEffect, useMemo, useState } from 'react'; +import useBtcClient from './useBtcClient'; +import useOrdinalsByAddress from './useOrdinalsByAddress'; +import useWalletSelector from './useWalletSelector'; + +interface Params { + isRestoreFlow: boolean; + nonOrdinalUtxos?: UTXO[]; + btcRecipients?: Recipient[]; + type?: string; + ordinalTxUtxo?: UTXO; +} + +interface FeeData { + standardFeeRate: string; + standardTotalFee: string; + highFeeRate: string; + highTotalFee: string; +} + +const useBtcFees = ({ + isRestoreFlow, + nonOrdinalUtxos, + btcRecipients, + type, + ordinalTxUtxo, +}: Params): { feeData: FeeData; highFeeError?: string; mediumFeeError?: string } => { + const [feeData, setFeeData] = useState({ + standardFeeRate: '', + standardTotalFee: '', + highFeeRate: '', + highTotalFee: '', + }); + const [highFeeError, setHighFeeError] = useState(''); + const [standardFeeError, setStandardFeeError] = useState(''); + const { network, btcAddress, ordinalsAddress } = useWalletSelector(); + const btcClient = useBtcClient(); + const { ordinals } = useOrdinalsByAddress(btcAddress); + const ordinalsUtxos = useMemo(() => ordinals?.map((ord) => ord.utxo), [ordinals]); + + useEffect(() => { + async function fetchFees(mode: 'standard' | 'high') { + try { + setStandardFeeError(''); + setHighFeeError(''); + let feeInfo; + if (isRestoreFlow) { + feeInfo = await getBtcFeesForNonOrdinalBtcSend( + btcAddress, + nonOrdinalUtxos || [], + ordinalsAddress, + network.type, + mode, + ); + } else if (type === 'BTC' && btcRecipients) { + feeInfo = await getBtcFees(btcRecipients, btcAddress, btcClient, network.type, mode); + } else if (type === 'Ordinals' && btcRecipients && ordinalTxUtxo) { + feeInfo = await getBtcFeesForOrdinalSend( + btcRecipients[0].address, + ordinalTxUtxo, + btcAddress, + btcClient, + network.type, + ordinalsUtxos || [], + mode, + ); + } + return { + fee: feeInfo?.fee.toString() || '', + feeRate: feeInfo?.selectedFeeRate.toString() || '', + }; + } catch (error: any) { + if (mode === 'standard') setStandardFeeError(error.toString()); + else if (mode === 'high') setHighFeeError(error.toString()); + return { fee: '', feeRate: '' }; + } + } + + async function calculateFees() { + const standard = await fetchFees('standard'); + const high = await fetchFees('high'); + + setFeeData({ + standardFeeRate: standard.feeRate, + standardTotalFee: standard.fee, + highFeeRate: high.feeRate, + highTotalFee: high.fee, + }); + } + + calculateFees(); + }, [ + isRestoreFlow, + nonOrdinalUtxos, + btcRecipients, + type, + ordinalTxUtxo, + btcAddress, + btcClient, + network, + ordinalsUtxos, + ordinalsAddress, + ]); + return { feeData, highFeeError, mediumFeeError: standardFeeError }; +}; + +export default useBtcFees; diff --git a/src/app/hooks/useNetwork.ts b/src/app/hooks/useNetwork.ts index 0f757c0cc..c3ce19150 100644 --- a/src/app/hooks/useNetwork.ts +++ b/src/app/hooks/useNetwork.ts @@ -3,14 +3,14 @@ import { useMemo } from 'react'; import useWalletSelector from './useWalletSelector'; const useNetworkSelector = () => { - const { network, networkAddress } = useWalletSelector(); + const { network } = useWalletSelector(); const selectedNetwork = useMemo( () => network.type === 'Mainnet' - ? new StacksMainnet({ url: networkAddress }) - : new StacksTestnet({ url: networkAddress }), - [network.type, networkAddress], + ? new StacksMainnet({ url: network.address }) + : new StacksTestnet({ url: network.address }), + [network.type, network.address], ); return selectedNetwork; }; diff --git a/src/app/hooks/useRbfTransactionData.ts b/src/app/hooks/useRbfTransactionData.ts new file mode 100644 index 000000000..8df8b543f --- /dev/null +++ b/src/app/hooks/useRbfTransactionData.ts @@ -0,0 +1,270 @@ +import { + BtcTransactionData, + RecommendedFeeResponse, + SettingsNetwork, + StacksTransaction, + StxTransactionData, + mempoolApi, + microstacksToStx, + rbf, +} from '@secretkeylabs/xverse-core'; +import { deserializeTransaction, estimateTransaction } from '@stacks/transactions'; +import { isLedgerAccount } from '@utils/helper'; +import axios from 'axios'; +import BigNumber from 'bignumber.js'; +import { useCallback, useEffect, useState } from 'react'; +import toast from 'react-hot-toast'; +import { useTranslation } from 'react-i18next'; +import { useNavigate } from 'react-router-dom'; +import useBtcClient from './useBtcClient'; +import useNetworkSelector from './useNetwork'; +import useSeedVault from './useSeedVault'; +import useWalletSelector from './useWalletSelector'; + +// TODO: move the types and helper functions below to xverse-core + +type TierFees = { + enoughFunds: boolean; + fee?: number; + feeRate: number; +}; + +type RbfRecommendedFees = { + medium?: TierFees; + high?: TierFees; + higher?: TierFees; + highest?: TierFees; +}; + +type RbfData = { + rbfTransaction?: InstanceType; + rbfTxSummary?: { + currentFee: number; + currentFeeRate: number; + minimumRbfFee: number; + minimumRbfFeeRate: number; + }; + rbfRecommendedFees?: RbfRecommendedFees; + mempoolFees?: RecommendedFeeResponse; + isLoading?: boolean; +}; + +export const isBtcTransaction = ( + transaction: BtcTransactionData | StxTransactionData, +): transaction is BtcTransactionData => transaction?.txType === 'bitcoin'; + +interface LatestNonceResponse { + last_mempool_tx_nonce: number; + last_executed_tx_nonce: number; + possible_next_nonce: number; + detected_missing_nonces: Array; +} + +export async function getLatestNonce( + stxAddress: string, + network: SettingsNetwork, +): Promise { + const baseUrl = network?.address; + const apiUrl = `${baseUrl}/extended/v1/address/${stxAddress}/nonces`; + return axios.get(apiUrl).then((response) => response.data); +} + +interface RawTransactionResponse { + raw_tx: string; +} + +export async function getRawTransaction(txId: string, network: SettingsNetwork): Promise { + const baseUrl = network?.address; + const apiUrl = `${baseUrl}/extended/v1/tx/${txId}/raw`; + + return axios.get(apiUrl).then((response) => response.data.raw_tx); +} + +const constructRecommendedFees = ( + lowerName: keyof RbfRecommendedFees, + lowerFeeRate: number, + higherName: keyof RbfRecommendedFees, + higherFeeRate: number, + stxAvailableBalance: string, +): RbfRecommendedFees => { + const bigNumLowerFee = BigNumber(lowerFeeRate); + const bigNumHigherFee = BigNumber(higherFeeRate); + + return { + [lowerName]: { + enoughFunds: bigNumLowerFee.lte(BigNumber(stxAvailableBalance)), + feeRate: microstacksToStx(bigNumLowerFee).toNumber(), + fee: microstacksToStx(bigNumLowerFee).toNumber(), + }, + [higherName]: { + enoughFunds: bigNumHigherFee.lte(BigNumber(stxAvailableBalance)), + feeRate: microstacksToStx(bigNumHigherFee).toNumber(), + fee: microstacksToStx(bigNumHigherFee).toNumber(), + }, + }; +}; + +const sortFees = (fees: RbfRecommendedFees) => + Object.fromEntries( + Object.entries(fees).sort((a, b) => { + const priorityOrder = ['highest', 'higher', 'high', 'medium']; + return priorityOrder.indexOf(a[0]) - priorityOrder.indexOf(b[0]); + }), + ); + +const useRbfTransactionData = (transaction?: BtcTransactionData | StxTransactionData): RbfData => { + const [isLoading, setIsLoading] = useState(true); + const [rbfData, setRbfData] = useState({}); + const { accountType, network, selectedAccount, stxAvailableBalance, feeMultipliers } = + useWalletSelector(); + const seedVault = useSeedVault(); + const btcClient = useBtcClient(); + const selectedNetwork = useNetworkSelector(); + const { t } = useTranslation('translation', { keyPrefix: 'SPEED_UP_TRANSACTION' }); + const navigate = useNavigate(); + + // TODO: move the STX RBF calculations to xverse-core and add unit tests + const fetchStxData = useCallback(async () => { + if (!transaction || isBtcTransaction(transaction)) { + return; + } + + try { + setIsLoading(true); + + const { fee } = transaction; + const txRaw: string = await getRawTransaction(transaction.txid, network); + const unsignedTx: StacksTransaction = deserializeTransaction(txRaw); + + const [slow, medium, high] = await estimateTransaction( + unsignedTx.payload, + undefined, + selectedNetwork, + ); + + let feePresets: RbfRecommendedFees = {}; + let mediumFee = medium.fee; + let highFee = high.fee; + const higherFee = fee.multipliedBy(1.25).toNumber(); + const highestFee = fee.multipliedBy(1.5).toNumber(); + + if (feeMultipliers?.thresholdHighStacksFee) { + if (high.fee > feeMultipliers.thresholdHighStacksFee) { + // adding a fee cap + highFee = feeMultipliers.thresholdHighStacksFee * 1.5; + mediumFee = feeMultipliers.thresholdHighStacksFee; + } + } + + let minimumFee = fee.multipliedBy(1.25).toNumber(); + if (!Number.isSafeInteger(minimumFee)) { + // round up the fee to the nearest integer + minimumFee = Math.ceil(minimumFee); + } + + if (fee.lt(BigNumber(mediumFee))) { + feePresets = constructRecommendedFees( + 'medium', + mediumFee, + 'high', + highFee, + stxAvailableBalance, + ); + } else { + feePresets = constructRecommendedFees( + 'higher', + higherFee, + 'highest', + highestFee, + stxAvailableBalance, + ); + } + + setRbfData({ + rbfTransaction: undefined, + rbfTxSummary: { + currentFee: microstacksToStx(fee).toNumber(), + currentFeeRate: microstacksToStx(fee).toNumber(), + minimumRbfFee: microstacksToStx(BigNumber(minimumFee)).toNumber(), + minimumRbfFeeRate: microstacksToStx(BigNumber(minimumFee)).toNumber(), + }, + rbfRecommendedFees: sortFees(feePresets), + mempoolFees: { + fastestFee: microstacksToStx(BigNumber(high.fee)).toNumber(), + halfHourFee: microstacksToStx(BigNumber(medium.fee)).toNumber(), + hourFee: microstacksToStx(BigNumber(slow.fee)).toNumber(), + economyFee: microstacksToStx(BigNumber(slow.fee)).toNumber(), + minimumFee: microstacksToStx(BigNumber(slow.fee)).toNumber(), + }, + }); + } catch (err: any) { + toast.error(t('SOMETHING_WENT_WRONG')); + navigate(-1); + console.error(err); + } finally { + setIsLoading(false); + } + }, [transaction, network, selectedNetwork, feeMultipliers, stxAvailableBalance, t, navigate]); + + const fetchRbfData = useCallback(async () => { + if (!selectedAccount || !transaction) { + return; + } + + if (!isBtcTransaction(transaction)) { + return fetchStxData(); + } + + try { + setIsLoading(true); + + const rbfTx = new rbf.RbfTransaction(transaction, { + ...selectedAccount, + accountType: accountType || 'software', + accountId: + isLedgerAccount(selectedAccount) && selectedAccount.deviceAccountIndex + ? selectedAccount.deviceAccountIndex + : selectedAccount.id, + network: network.type, + esploraProvider: btcClient, + getSeedPhrase: seedVault.getSeed, + }); + + const mempoolFees = await mempoolApi.getRecommendedFees(network.type); + const rbfRecommendedFeesResponse = await rbfTx.getRbfRecommendedFees(mempoolFees); + + const rbfTransactionSummary = await rbf.getRbfTransactionSummary(btcClient, transaction.txid); + + setRbfData({ + rbfTransaction: rbfTx, + rbfTxSummary: rbfTransactionSummary, + rbfRecommendedFees: sortFees(rbfRecommendedFeesResponse), + mempoolFees, + }); + } catch (err: any) { + toast.error(t('SOMETHING_WENT_WRONG')); + navigate(-1); + console.error(err); + } finally { + setIsLoading(false); + } + }, [ + selectedAccount, + transaction, + accountType, + network.type, + seedVault, + btcClient, + fetchStxData, + t, + navigate, + ]); + + useEffect(() => { + fetchRbfData(); + }, [fetchRbfData]); + + return { ...rbfData, isLoading }; +}; + +export default useRbfTransactionData; diff --git a/src/app/hooks/useSignPsbtTx.ts b/src/app/hooks/useSignPsbtTx.ts index 30cf0d0bf..88412b685 100644 --- a/src/app/hooks/useSignPsbtTx.ts +++ b/src/app/hooks/useSignPsbtTx.ts @@ -17,16 +17,21 @@ const useSignPsbtTx = () => { const tabId = params.get('tabId') ?? '0'; const btcClient = useBtcClient(); - const confirmSignPsbt = async () => { - const seedPhrase = await getSeed(); - const signingResponse = await signPsbt( - seedPhrase, - accountsList, - request.payload.inputsToSign, - request.payload.psbtBase64, - request.payload.broadcast, - network.type, - ); + const confirmSignPsbt = async (signingResponseOverride?: string) => { + let signingResponse = signingResponseOverride; + + if (!signingResponse) { + const seedPhrase = await getSeed(); + signingResponse = await signPsbt( + seedPhrase, + accountsList, + request.payload.inputsToSign, + request.payload.psbtBase64, + request.payload.broadcast, + network.type, + ); + } + let txId: string = ''; if (request.payload.broadcast) { const txHex = psbtBase64ToHex(signingResponse); diff --git a/src/app/hooks/useTransactionContext.ts b/src/app/hooks/useTransactionContext.ts new file mode 100644 index 000000000..58fd225c1 --- /dev/null +++ b/src/app/hooks/useTransactionContext.ts @@ -0,0 +1,49 @@ +import { btcTransaction, UtxoCache } from '@secretkeylabs/xverse-core'; +import { useMemo } from 'react'; +import useBtcClient from './useBtcClient'; +import useSeedVault from './useSeedVault'; +import useWalletSelector from './useWalletSelector'; + +const useTransactionContext = () => { + const { selectedAccount, network } = useWalletSelector(); + const seedVault = useSeedVault(); + const btcClient = useBtcClient(); + + const utxoCache = useMemo( + () => + new UtxoCache({ + cacheStorageController: { + get: async (key: string) => { + const value = localStorage.getItem(key); + return value; + }, + set: async (key: string, value: string) => { + localStorage.setItem(key, value); + }, + remove: async (key: string) => { + localStorage.removeItem(key); + }, + }, + network: network.type, + }), + [network.type], + ); + + const transactionContext = useMemo(() => { + if (selectedAccount?.id === undefined) { + throw new Error('No account selected'); + } + + return btcTransaction.createTransactionContext({ + account: selectedAccount, + seedVault, + utxoCache, + network: network.type, + esploraApiProvider: btcClient, + }); + }, [utxoCache, selectedAccount, network, seedVault, btcClient]); + + return transactionContext; +}; + +export default useTransactionContext; diff --git a/src/app/hooks/useWalletReducer.ts b/src/app/hooks/useWalletReducer.ts index 7f642674f..a0a1ad269 100644 --- a/src/app/hooks/useWalletReducer.ts +++ b/src/app/hooks/useWalletReducer.ts @@ -1,22 +1,24 @@ -import { getDeviceAccountIndex } from '@common/utils/ledger'; +import { filterLedgerAccounts, getDeviceAccountIndex } from '@common/utils/ledger'; import useBtcWalletData from '@hooks/queries/useBtcWalletData'; import useStxWalletData from '@hooks/queries/useStxWalletData'; import useNetworkSelector from '@hooks/useNetwork'; import { Account, AnalyticsEvents, + SettingsNetwork, + StacksMainnet, + StacksNetwork, + StacksTestnet, createWalletAccount, decryptSeedPhraseCBC, getBnsName, newWallet, restoreWalletWithAccounts, - SettingsNetwork, - StacksNetwork, walletFromSeedPhrase, } from '@secretkeylabs/xverse-core'; import { - addAccountAction, ChangeNetworkAction, + addAccountAction, fetchAccountAction, getActiveAccountsAction, resetWalletAction, @@ -49,7 +51,8 @@ const useWalletReducer = () => { const dispatch = useDispatch(); const { refetch: refetchStxData } = useStxWalletData(); const { refetch: refetchBtcData } = useBtcWalletData(); - const { setSessionStartTime, clearSessionTime } = useWalletSession(); + const { setSessionStartTime, clearSessionTime, setSessionStartTimeAndMigrate } = + useWalletSession(); const queryClient = useQueryClient(); const loadActiveAccounts = async ( @@ -82,7 +85,16 @@ const useWalletReducer = () => { if (!selectedAccount) { [selectedAccountData] = walletAccounts; } else if (isLedgerAccount(selectedAccount)) { - selectedAccountData = ledgerAccountsList.find((a) => a.id === selectedAccount.id); + const networkLedgerAccounts = filterLedgerAccounts(ledgerAccountsList, currentNetwork.type); + const selectedAccountDataInNetwork = networkLedgerAccounts.find( + (a) => a.id === selectedAccount.id, + ); + + // we try find the specific matching ledger account + // If we can't find it, we default to the first ledger account in the selected network + // If we can't find that, we default to the first software account in the wallet + selectedAccountData = + selectedAccountDataInNetwork ?? networkLedgerAccounts[0] ?? walletAccounts[0]; } else { selectedAccountData = walletAccounts.find((a) => a.id === selectedAccount.id); } @@ -103,6 +115,9 @@ const useWalletReducer = () => { dispatch(fetchAccountAction(selectedAccountData, walletAccounts)); + // ledger accounts initially didn't have a deviceAccountIndex + // this is a migration to add the deviceAccountIndex to the ledger accounts without them + // it should only fire once if ever if (ledgerAccountsList.some((account) => account.deviceAccountIndex === undefined)) { const newLedgerAccountsList = ledgerAccountsList.map((account) => ({ ...account, @@ -142,7 +157,7 @@ const useWalletReducer = () => { dispatch(fetchAccountAction(accountsList[0], accountsList)); dispatch(getActiveAccountsAction(accountsList)); } finally { - setSessionStartTime(); + setSessionStartTimeAndMigrate(); } }; @@ -281,14 +296,9 @@ const useWalletReducer = () => { dispatch(fetchAccountAction(account, accountsList)); }; - const changeNetwork = async ( - changedNetwork: SettingsNetwork, - networkObject: StacksNetwork, - networkAddress: string, - btcApiUrl: string, - ) => { + const changeNetwork = async (changedNetwork: SettingsNetwork) => { const seedPhrase = await seedVault.getSeed(); - dispatch(ChangeNetworkAction(changedNetwork, networkAddress, btcApiUrl)); + dispatch(ChangeNetworkAction(changedNetwork)); const wallet = await walletFromSeedPhrase({ mnemonic: seedPhrase, index: 0n, @@ -305,6 +315,10 @@ const useWalletReducer = () => { stxPublicKey: wallet.stxPublicKey, }; dispatch(setWalletAction(wallet)); + const networkObject = + changedNetwork.type === 'Mainnet' + ? new StacksMainnet({ url: changedNetwork.address }) + : new StacksTestnet({ url: changedNetwork.address }); try { await loadActiveAccounts(wallet.seedPhrase, changedNetwork, networkObject, [account]); } catch (err) { diff --git a/src/app/hooks/useWalletSession.ts b/src/app/hooks/useWalletSession.ts index 95ceb0d5d..730d77b41 100644 --- a/src/app/hooks/useWalletSession.ts +++ b/src/app/hooks/useWalletSession.ts @@ -1,9 +1,9 @@ import useWalletSelector from '@hooks/useWalletSelector'; import { setWalletLockPeriodAction } from '@stores/wallet/actions/actionCreators'; import { WalletSessionPeriods } from '@stores/wallet/actions/types'; +import { chromeSessionStorage } from '@utils/chromeStorage'; import { addMinutes } from 'date-fns'; import { useDispatch } from 'react-redux'; -import { chromeSessionStorage } from '@utils/chromeStorage'; import useSeedVault from './useSeedVault'; const SESSION_START_TIME_KEY = 'sessionStartTime'; @@ -36,11 +36,20 @@ const useWalletSession = () => { setSessionStartTime(); }; + const setSessionStartTimeAndMigrate = () => { + if (walletLockPeriod < WalletSessionPeriods.LOW) { + return setWalletLockPeriod(WalletSessionPeriods.LOW); + } + + setSessionStartTime(); + }; + return { setSessionStartTime, setWalletLockPeriod, shouldLock, clearSessionTime, + setSessionStartTimeAndMigrate, }; }; diff --git a/src/app/screens/accountList/index.tsx b/src/app/screens/accountList/index.tsx index f86106f2d..9e50a792c 100644 --- a/src/app/screens/accountList/index.tsx +++ b/src/app/screens/accountList/index.tsx @@ -1,5 +1,6 @@ import ConnectLedger from '@assets/img/dashboard/connect_ledger.svg'; import Plus from '@assets/img/dashboard/plus.svg'; +import { filterLedgerAccounts } from '@common/utils/ledger'; import AccountRow from '@components/accountRow'; import Separator from '@components/separator'; import TopRow from '@components/topRow'; @@ -85,10 +86,8 @@ function AccountList(): JSX.Element { const { createAccount, switchAccount } = useWalletReducer(); const displayedAccountsList = useMemo(() => { - if (network.type === 'Mainnet') { - return [...ledgerAccountsList, ...accountsList]; - } - return accountsList; + const networkLedgerAccounts = filterLedgerAccounts(ledgerAccountsList, network.type); + return [...networkLedgerAccounts, ...accountsList]; }, [accountsList, ledgerAccountsList, network]); const handleAccountSelect = async (account: Account, goBack = true) => { @@ -140,14 +139,12 @@ function AccountList(): JSX.Element { {t('NEW_ACCOUNT')} - {network.type === 'Mainnet' && ( - - - - - {t('LEDGER_ACCOUNT')} - - )} + + + + + {t('LEDGER_ACCOUNT')} +
); diff --git a/src/app/screens/btcSelectAddressScreen/index.tsx b/src/app/screens/btcSelectAddressScreen/index.tsx index bf2ce535c..5eb6b86a3 100644 --- a/src/app/screens/btcSelectAddressScreen/index.tsx +++ b/src/app/screens/btcSelectAddressScreen/index.tsx @@ -2,6 +2,7 @@ import OrdinalsIcon from '@assets/img/nftDashboard/white_ordinals_icon.svg'; import XverseLogo from '@assets/img/settings/logo.svg'; import DropDownIcon from '@assets/img/transactions/dropDownIcon.svg'; import DappPlaceholderIcon from '@assets/img/webInteractions/authPlaceholder.svg'; +import { filterLedgerAccounts } from '@common/utils/ledger'; import AccountRow from '@components/accountRow'; import ActionButton from '@components/button'; import Separator from '@components/separator'; @@ -234,6 +235,8 @@ function BtcSelectAddressScreen() { switchAccountBasedOnRequest(); }, []); + const networkLedgerAccounts = filterLedgerAccounts(ledgerAccountsList, network.type); + return ( <> @@ -262,7 +265,7 @@ function BtcSelectAddressScreen() { {showAccountList ? ( - {[...ledgerAccountsList, ...accountsList].map((account) => ( + {[...networkLedgerAccounts, ...accountsList].map((account) => ( {getDashboardTitle()} {coin === 'brc20' && BRC-20} + {fungibleToken?.protocol === 'stacks' && SIP-10} ) => { - // only allow positive integers - // disable common special characters, including - and . - // eslint-disable-next-line no-useless-escape - if (e.key.match(/^[!-\/:-@[-`{-~]$/)) { - e.preventDefault(); - } - }; - const handleChangeFeeRateInput = (e: React.ChangeEvent) => { setFeeRateInput(e.target.value); if (selectedOption !== 'custom') { diff --git a/src/app/screens/confirmFtTransaction/index.tsx b/src/app/screens/confirmFtTransaction/index.tsx index 54ebed157..9b62dee13 100644 --- a/src/app/screens/confirmFtTransaction/index.tsx +++ b/src/app/screens/confirmFtTransaction/index.tsx @@ -8,7 +8,7 @@ import TransactionDetailComponent from '@components/transactionDetailComponent'; import useStxWalletData from '@hooks/queries/useStxWalletData'; import useNetworkSelector from '@hooks/useNetwork'; import useWalletSelector from '@hooks/useWalletSelector'; -import { broadcastSignedTransaction, StacksTransaction } from '@secretkeylabs/xverse-core'; +import { StacksTransaction, broadcastSignedTransaction } from '@secretkeylabs/xverse-core'; import { deserializeTransaction } from '@stacks/transactions'; import { useMutation } from '@tanstack/react-query'; import { isLedgerAccount } from '@utils/helper'; diff --git a/src/app/screens/confirmNftTransaction/index.tsx b/src/app/screens/confirmNftTransaction/index.tsx index df0c9ef0d..35807d992 100644 --- a/src/app/screens/confirmNftTransaction/index.tsx +++ b/src/app/screens/confirmNftTransaction/index.tsx @@ -12,7 +12,7 @@ import useNetworkSelector from '@hooks/useNetwork'; import { useResetUserFlow } from '@hooks/useResetUserFlow'; import useWalletSelector from '@hooks/useWalletSelector'; import NftImage from '@screens/nftDashboard/nftImage'; -import { broadcastSignedTransaction, StacksTransaction } from '@secretkeylabs/xverse-core'; +import { StacksTransaction, broadcastSignedTransaction } from '@secretkeylabs/xverse-core'; import { deserializeTransaction } from '@stacks/transactions'; import { useMutation } from '@tanstack/react-query'; import { isLedgerAccount } from '@utils/helper'; diff --git a/src/app/screens/confirmStxTransaction/index.tsx b/src/app/screens/confirmStxTransaction/index.tsx index 62544c0b6..0fd2c680a 100644 --- a/src/app/screens/confirmStxTransaction/index.tsx +++ b/src/app/screens/confirmStxTransaction/index.tsx @@ -14,16 +14,16 @@ import useNetworkSelector from '@hooks/useNetwork'; import useOnOriginTabClose from '@hooks/useOnTabClosed'; import useWalletSelector from '@hooks/useWalletSelector'; import { + StacksTransaction, + TokenTransferPayload, addressToString, broadcastSignedTransaction, buf2hex, getStxFiatEquivalent, isMultiSig, microstacksToStx, - StacksTransaction, - TokenTransferPayload, } from '@secretkeylabs/xverse-core'; -import { deserializeTransaction, MultiSigSpendingCondition } from '@stacks/transactions'; +import { MultiSigSpendingCondition, deserializeTransaction } from '@stacks/transactions'; import { useMutation } from '@tanstack/react-query'; import { isLedgerAccount } from '@utils/helper'; import BigNumber from 'bignumber.js'; diff --git a/src/app/screens/ledger/importLedgerAccount/index.tsx b/src/app/screens/ledger/importLedgerAccount/index.tsx index 9a7e5d83b..ff928558a 100644 --- a/src/app/screens/ledger/importLedgerAccount/index.tsx +++ b/src/app/screens/ledger/importLedgerAccount/index.tsx @@ -6,11 +6,11 @@ import Transport from '@ledgerhq/hw-transport-webusb'; import { useTransition } from '@react-spring/web'; import { Account, + LedgerErrors, getMasterFingerPrint, importNativeSegwitAccountFromLedger, importStacksAccountFromLedger, importTaprootAccountFromLedger, - LedgerErrors, } from '@secretkeylabs/xverse-core'; import { DEFAULT_TRANSITION_OPTIONS } from '@utils/constants'; import { useEffect, useState } from 'react'; @@ -61,6 +61,7 @@ function ImportLedger(): JSX.Element { setAccountId(newAccountId); const deviceNewAccountIndex = getDeviceNewAccountIndex( ledgerAccountsList, + network.type, masterPubKey || masterFingerPrint, ); if (isBitcoinSelected) { @@ -206,6 +207,7 @@ function ImportLedger(): JSX.Element { accountName: `Ledger Account ${newAccountId + 1}`, deviceAccountIndex: getDeviceNewAccountIndex( ledgerAccountsList, + network.type, masterPubKey || masterFingerPrint, ), }; diff --git a/src/app/screens/nftCollection/index.tsx b/src/app/screens/nftCollection/index.tsx index ae55ecaec..96c7f4434 100644 --- a/src/app/screens/nftCollection/index.tsx +++ b/src/app/screens/nftCollection/index.tsx @@ -13,6 +13,7 @@ import Nft from '@screens/nftDashboard/nft'; import NftImage from '@screens/nftDashboard/nftImage'; import { NonFungibleToken, StacksCollectionData } from '@secretkeylabs/xverse-core'; import SnackBar from '@ui-library/snackBar'; +import { EMPTY_LABEL } from '@utils/constants'; import { getFullyQualifiedKey, getNftCollectionsGridItemId, isBnsCollection } from '@utils/nfts'; import { PropsWithChildren, useRef } from 'react'; import toast from 'react-hot-toast'; @@ -219,14 +220,14 @@ function NftCollection() { value={ collectionData?.floor_price ? `${collectionData?.floor_price?.toString()} STX` - : '--' + : EMPTY_LABEL } isColumnAlignment={isGalleryOpen} isLoading={isLoading} /> diff --git a/src/app/screens/nftDetail/index.tsx b/src/app/screens/nftDetail/index.tsx index 1c1c6866d..f06155188 100644 --- a/src/app/screens/nftDetail/index.tsx +++ b/src/app/screens/nftDetail/index.tsx @@ -10,6 +10,7 @@ import TopRow from '@components/topRow'; import { ArrowLeft, ArrowUp, Share } from '@phosphor-icons/react'; import NftImage from '@screens/nftDashboard/nftImage'; import { Attribute } from '@secretkeylabs/xverse-core'; +import { EMPTY_LABEL } from '@utils/constants'; import { useTranslation } from 'react-i18next'; import { Tooltip } from 'react-tooltip'; import styled from 'styled-components'; @@ -371,7 +372,9 @@ function NftDetailScreen() { )} diff --git a/src/app/screens/ordinalDetail/index.tsx b/src/app/screens/ordinalDetail/index.tsx index 13d056b27..e73cb84ee 100644 --- a/src/app/screens/ordinalDetail/index.tsx +++ b/src/app/screens/ordinalDetail/index.tsx @@ -16,6 +16,7 @@ import { ArrowUp, Share } from '@phosphor-icons/react'; import OrdinalImage from '@screens/ordinals/ordinalImage'; import Callout from '@ui-library/callout'; import { StyledP } from '@ui-library/common.styled'; +import { EMPTY_LABEL } from '@utils/constants'; import { getRareSatsColorsByRareSatsType, getRareSatsLabelByType } from '@utils/rareSats'; import { useTranslation } from 'react-i18next'; import { Tooltip } from 'react-tooltip'; @@ -444,7 +445,7 @@ function OrdinalDetailScreen() { @@ -455,7 +456,7 @@ function OrdinalDetailScreen() { value={ ordinal?.inscription_floor_price || ordinal?.inscription_floor_price !== 0 ? ordinal?.inscription_floor_price?.toString() ?? '' - : '--' + : EMPTY_LABEL } allowThousandSeperator={ !!(ordinal?.inscription_floor_price || ordinal?.inscription_floor_price !== 0) diff --git a/src/app/screens/ordinals/ordinalImage.tsx b/src/app/screens/ordinals/ordinalImage.tsx index c9091ea08..adfd48033 100644 --- a/src/app/screens/ordinals/ordinalImage.tsx +++ b/src/app/screens/ordinals/ordinalImage.tsx @@ -3,6 +3,7 @@ import OrdinalsIcon from '@assets/img/nftDashboard/white_ordinals_icon.svg'; import { BetterBarLoader } from '@components/barLoader'; import useTextOrdinalContent from '@hooks/useTextOrdinalContent'; import useWalletSelector from '@hooks/useWalletSelector'; +import { TextT } from '@phosphor-icons/react'; import { CondensedInscription, getErc721Metadata, Inscription } from '@secretkeylabs/xverse-core'; import { getBrc20Details } from '@utils/brc20'; import { XVERSE_ORDIVIEW_URL } from '@utils/constants'; @@ -11,6 +12,7 @@ import Image from 'rc-image'; import { useEffect, useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; import styled from 'styled-components'; +import Theme from 'theme'; import Brc20Tile from './brc20Tile'; interface ContainerProps { @@ -116,6 +118,15 @@ const StyledImage = styled(Image)` border-radius: 8px; object-fit: contain; image-rendering: pixelated; + display: flex; +`; +const ContentTypeThumbnailContainer = styled.div` + display: flex; + justify-content: center; + align-items: center; + width: 100%; + height: 100%; + background-color: ${(props) => props.theme.colors.white_0}; `; export const StyledBarLoader = styled(BetterBarLoader)((props) => ({ @@ -132,6 +143,8 @@ interface Props { isSmallImage?: boolean; withoutSizeIncrease?: boolean; withoutTitles?: boolean; + placeholderIcon?: string; + showContentTypeThumbnail?: boolean; } function OrdinalImage({ @@ -142,6 +155,8 @@ function OrdinalImage({ isSmallImage = false, withoutSizeIncrease = false, withoutTitles = false, + placeholderIcon, + showContentTypeThumbnail = false, }: Props) { const isGalleryOpen: boolean = document.documentElement.clientWidth > 360 && !withoutSizeIncrease; const textContent = useTextOrdinalContent(ordinal); @@ -268,6 +283,16 @@ function OrdinalImage({ ); } + if (showContentTypeThumbnail) { + return ( + + + + + + ); + } + return ( - ordinal + ordinal ); } diff --git a/src/app/screens/ordinalsCollection/index.tsx b/src/app/screens/ordinalsCollection/index.tsx index 1a368089e..ee7f681be 100644 --- a/src/app/screens/ordinalsCollection/index.tsx +++ b/src/app/screens/ordinalsCollection/index.tsx @@ -18,6 +18,7 @@ import { GridContainer } from '@screens/nftDashboard/collectiblesTabs'; import OrdinalImage from '@screens/ordinals/ordinalImage'; import { Inscription } from '@secretkeylabs/xverse-core'; import { StyledHeading, StyledP } from '@ui-library/common.styled'; +import { EMPTY_LABEL } from '@utils/constants'; import { getInscriptionsCollectionGridItemId, getInscriptionsCollectionGridItemSubText, @@ -164,10 +165,10 @@ function OrdinalsCollection() { const estPortfolioValue = data && data?.pages?.[0].portfolio_value !== 0 ? `${data?.pages?.[0].portfolio_value.toFixed(8)} BTC` - : '--'; + : EMPTY_LABEL; const collectionFloorPrice = collectionMarketData?.floor_price ? `${collectionMarketData?.floor_price?.toFixed(8)} BTC` - : '--'; + : EMPTY_LABEL; const handleOnClick = (item: Inscription) => { setSelectedOrdinalDetails(item); diff --git a/src/app/screens/sendFt/index.tsx b/src/app/screens/sendFt/index.tsx index 9cde1843a..94d91cf6d 100644 --- a/src/app/screens/sendFt/index.tsx +++ b/src/app/screens/sendFt/index.tsx @@ -5,10 +5,11 @@ import useStxPendingTxData from '@hooks/queries/useStxPendingTxData'; import useNetworkSelector from '@hooks/useNetwork'; import useWalletSelector from '@hooks/useWalletSelector'; import { - buf2hex, - generateUnsignedTransaction, StacksTransaction, UnsignedStacksTransation, + applyFeeMultiplier, + buf2hex, + generateUnsignedTransaction, validateStxAddress, } from '@secretkeylabs/xverse-core'; import { useMutation } from '@tanstack/react-query'; @@ -20,7 +21,7 @@ import { useLocation, useNavigate } from 'react-router-dom'; function SendFtScreen() { const { t } = useTranslation('translation', { keyPrefix: 'SEND' }); const navigate = useNavigate(); - const { stxAddress, stxPublicKey, network, feeMultipliers, coinsList } = useWalletSelector(); + const { stxAddress, stxPublicKey, network, coinsList, feeMultipliers } = useWalletSelector(); const [amountError, setAmountError] = useState(''); const [addressError, setAddressError] = useState(''); const [memoError, setMemoError] = useState(''); @@ -71,13 +72,8 @@ function SendFtScreen() { pendingTxs: stxPendingTxData?.pendingTransactions ?? [], memo, }; - const unsignedTx: StacksTransaction = await generateUnsignedTransaction(unsginedTx); - - const fee: bigint = BigInt(unsignedTx.auth.spendingCondition.fee.toString()) ?? BigInt(0); - if (feeMultipliers?.stxSendTxMultiplier) { - unsignedTx.setFee(fee * BigInt(feeMultipliers.stxSendTxMultiplier)); - } - + const unsignedTx = await generateUnsignedTransaction(unsginedTx); + applyFeeMultiplier(unsignedTx, feeMultipliers); return unsignedTx; }, }); diff --git a/src/app/screens/sendNft/index.tsx b/src/app/screens/sendNft/index.tsx index bf849992e..a6a06e8e9 100644 --- a/src/app/screens/sendNft/index.tsx +++ b/src/app/screens/sendNft/index.tsx @@ -7,12 +7,13 @@ import useNetworkSelector from '@hooks/useNetwork'; import { useResetUserFlow } from '@hooks/useResetUserFlow'; import useWalletSelector from '@hooks/useWalletSelector'; import { + StacksTransaction, + UnsignedStacksTransation, + applyFeeMultiplier, buf2hex, cvToHex, generateUnsignedTransaction, - StacksTransaction, uintCV, - UnsignedStacksTransation, validateStxAddress, } from '@secretkeylabs/xverse-core'; import { useMutation } from '@tanstack/react-query'; @@ -128,12 +129,8 @@ function SendNft() { memo: '', isNFT: true, }; - const unsignedTx: StacksTransaction = await generateUnsignedTransaction(unsginedTx); - if (feeMultipliers?.stxSendTxMultiplier) { - unsignedTx.setFee( - unsignedTx.auth.spendingCondition.fee * BigInt(feeMultipliers.stxSendTxMultiplier), - ); - } + const unsignedTx = await generateUnsignedTransaction(unsginedTx); + applyFeeMultiplier(unsignedTx, feeMultipliers); setRecipientAddress(address); return unsignedTx; }, diff --git a/src/app/screens/sendStx/index.tsx b/src/app/screens/sendStx/index.tsx index 52eaa3a9d..adff7e75a 100644 --- a/src/app/screens/sendStx/index.tsx +++ b/src/app/screens/sendStx/index.tsx @@ -4,6 +4,7 @@ import useStxPendingTxData from '@hooks/queries/useStxPendingTxData'; import useNetworkSelector from '@hooks/useNetwork'; import useWalletSelector from '@hooks/useWalletSelector'; import { + applyFeeMultiplier, buf2hex, generateUnsignedStxTokenTransferTransaction, microstacksToStx, @@ -22,7 +23,7 @@ import TopRow from '../../components/topRow'; function SendStxScreen() { const { t } = useTranslation('translation', { keyPrefix: 'SEND' }); const navigate = useNavigate(); - const { stxAddress, stxAvailableBalance, stxPublicKey, feeMultipliers, network } = + const { stxAddress, stxAvailableBalance, stxPublicKey, network, feeMultipliers } = useWalletSelector(); const [amountError, setAmountError] = useState(''); const [addressError, setAddressError] = useState(''); @@ -55,12 +56,7 @@ function SendStxScreen() { stxPublicKey, selectedNetwork, ); - // increasing the fees with multiplication factor - const fee: bigint = - BigInt(unsignedSendStxTx.auth.spendingCondition.fee.toString()) ?? BigInt(0); - if (feeMultipliers?.stxSendTxMultiplier) { - unsignedSendStxTx.setFee(fee * BigInt(feeMultipliers.stxSendTxMultiplier)); - } + applyFeeMultiplier(unsignedSendStxTx, feeMultipliers); return unsignedSendStxTx; }, }); diff --git a/src/app/screens/settings/changeNetwork/index.tsx b/src/app/screens/settings/changeNetwork/index.tsx index 312c2b1ed..782d3b6a0 100644 --- a/src/app/screens/settings/changeNetwork/index.tsx +++ b/src/app/screens/settings/changeNetwork/index.tsx @@ -1,19 +1,24 @@ -import Cross from '@assets/img/settings/x.svg'; import ActionButton from '@components/button'; import BottomBar from '@components/tabBar'; import TopRow from '@components/topRow'; import useWalletReducer from '@hooks/useWalletReducer'; import useWalletSelector from '@hooks/useWalletSelector'; -import { SettingsNetwork, StacksMainnet, StacksTestnet } from '@secretkeylabs/xverse-core'; -import { initialNetworksList } from '@utils/constants'; +import { + SettingsNetwork, + defaultMainnet, + defaultTestnet, + initialNetworksList, +} from '@secretkeylabs/xverse-core'; import { isValidBtcApi, isValidStacksApi } from '@utils/helper'; import { useState } from 'react'; import { useTranslation } from 'react-i18next'; import { useNavigate } from 'react-router-dom'; import styled from 'styled-components'; import NetworkRow from './networkRow'; +import NodeInput from './nodeInput'; const Container = styled.div` + ${(props) => props.theme.typography.body_medium_m} display: flex; flex-direction: column; flex: 1; @@ -26,180 +31,167 @@ const Container = styled.div` } `; -const NodeInputHeader = styled.div((props) => ({ - display: 'flex', - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'space-between', - paddingLeft: props.theme.spacing(1), - paddingRight: props.theme.spacing(1), -})); - -const NodeText = styled.h1((props) => ({ - ...props.theme.body_medium_m, - marginTop: props.theme.spacing(6), -})); - -const NodeResetButton = styled.button((props) => ({ - background: 'none', - color: props.theme.colors.action.classicLight, -})); - -const InputContainer = styled.div((props) => ({ - display: 'flex', - alignItems: 'center', - width: '100%', - border: `1px solid ${props.theme.colors.elevation3}`, - backgroundColor: props.theme.colors.elevation_n1, - borderRadius: props.theme.radius(1), - paddingLeft: props.theme.spacing(4), - paddingRight: props.theme.spacing(4), - marginTop: props.theme.spacing(4), - marginBottom: props.theme.spacing(3), -})); - -const ButtonContainer = styled.div((props) => ({ - marginLeft: props.theme.spacing(8), - marginRight: props.theme.spacing(8), - marginBottom: props.theme.spacing(16), -})); - -const ErrorMessage = styled.h2((props) => ({ - ...props.theme.body_medium_m, - textAlign: 'left', - color: props.theme.colors.feedback.error, -})); - -const Input = styled.input((props) => ({ - ...props.theme.body_medium_m, - height: 44, - display: 'flex', - flex: 1, - backgroundColor: props.theme.colors.elevation_n1, - color: props.theme.colors.white_0, - border: 'none', -})); - -const Button = styled.button({ - background: 'none', -}); +const ButtonContainer = styled.div` + margin: ${(props) => props.theme.space.m}; +`; + +const NodeInputsContainer = styled.div` + display: flex; + flex-direction: column; + gap: ${(props) => props.theme.space.s}; + margin-top: ${(props) => props.theme.space.s}; +`; + +type NodeInputKey = keyof Pick; +const nodeInputs: { key: NodeInputKey; labelKey: string }[] = [ + { key: 'address', labelKey: 'STACKS_URL' }, + { key: 'btcApiUrl', labelKey: 'BTC_URL' }, + { key: 'fallbackBtcApiUrl', labelKey: 'FALLBACK_BTC_URL' }, +]; + +type NodeInputErrors = Record; +const initialNodeErrors: NodeInputErrors = { + address: '', + btcApiUrl: '', + fallbackBtcApiUrl: '', +}; function ChangeNetworkScreen() { const { t } = useTranslation('translation', { keyPrefix: 'SETTING_SCREEN' }); - const { network, btcApiUrl, networkAddress } = useWalletSelector(); - const [changedNetwork, setChangedNetwork] = useState(network); - const [stacksUrlError, setStacksUrlError] = useState(''); - const [btcURLError, setBtcURLError] = useState(''); - const [btcUrl, setBtcUrl] = useState(btcApiUrl || network.btcApiUrl); - const [stacksUrl, setStacksUrl] = useState(networkAddress || network.address); - const [isChangingNetwork, setIsChangingNetwork] = useState(false); const navigate = useNavigate(); const { changeNetwork } = useWalletReducer(); + const { network, savedNetworks } = useWalletSelector(); + const [isChangingNetwork, setIsChangingNetwork] = useState(false); + const [formErrors, setFormErrors] = useState(initialNodeErrors); + const [formInputs, setFormInputs] = useState(network); const handleBackButtonClick = () => { navigate('/settings'); }; const onNetworkSelected = (networkSelected: SettingsNetwork) => { - setStacksUrl(networkSelected.address); - setChangedNetwork(networkSelected); - setBtcUrl(networkSelected.btcApiUrl); - setStacksUrlError(''); - setBtcURLError(''); - }; - - const onChangeStacksUrl = (event: React.FormEvent) => { - setStacksUrlError(''); - setStacksUrl(event.currentTarget.value); - }; - - const onChangeBtcApiUrl = (event: React.FormEvent) => { - setBtcURLError(''); - setBtcUrl(event.currentTarget.value); - }; - - const onClearStacksUrl = () => { - setStacksUrl(''); + if (networkSelected.type === formInputs.type) { + return; + } + setFormInputs(networkSelected); + setFormErrors(initialNodeErrors); }; - const onClearBtcUrl = () => { - setBtcUrl(''); + // TODO should validate required fields on change + const onChangeCreator = (key: NodeInputKey) => (event: React.ChangeEvent) => { + setFormErrors((prevErrors) => ({ + ...prevErrors, + [key]: '', + })); + setFormInputs((prevInputs) => ({ + ...prevInputs, + [key]: event.target.value, + })); }; - const onResetBtcUrl = async () => { - setBtcUrl(changedNetwork.btcApiUrl); - setBtcURLError(''); + const onClearCreator = (key: NodeInputKey) => () => { + setFormErrors((prevErrors) => ({ + ...prevErrors, + [key]: '', + })); + setFormInputs((prevInputs) => ({ + ...prevInputs, + [key]: '', + })); }; - const onResetStacks = async () => { - setStacksUrl(changedNetwork.address); - setStacksUrlError(''); + const onResetCreator = (key: NodeInputKey) => () => { + setFormErrors((prevErrors) => ({ + ...prevErrors, + [key]: '', + })); + setFormInputs((prevInputs) => ({ + ...prevInputs, + [key]: initialNetworksList.find((n) => n.type === formInputs.type)?.[key], + })); }; const onSubmit = async () => { setIsChangingNetwork(true); - const [isValidStacksUrl, isValidBtcApiUrl] = await Promise.all([ - isValidStacksApi(stacksUrl, changedNetwork.type), - isValidBtcApi(btcUrl, changedNetwork.type), + // TODO use formik/yup for all validation if form gets more complex + // validate required fields + if (!formInputs.address) { + setFormErrors((prevErrors) => ({ + ...prevErrors, + address: t('REQUIRED'), + })); + setIsChangingNetwork(false); + return; + } + + if (!formInputs.btcApiUrl) { + setFormErrors((prevErrors) => ({ + ...prevErrors, + btcApiUrl: t('REQUIRED'), + })); + setIsChangingNetwork(false); + return; + } + + const isChangedStacksUrl = formInputs.address !== network.address; + const isChangedBtcApiUrl = formInputs.btcApiUrl !== network.btcApiUrl; + const isChangedFallbackBtcApiUrl = formInputs.fallbackBtcApiUrl !== network.fallbackBtcApiUrl; + + // validate against server if inputs were changed + const [isValidStacksUrl, isValidBtcApiUrl, isValidFallbackBtcApiUrl] = await Promise.all([ + !isChangedStacksUrl || isValidStacksApi(formInputs.address, formInputs.type), + !isChangedBtcApiUrl || isValidBtcApi(formInputs.btcApiUrl, formInputs.type), + !formInputs.fallbackBtcApiUrl || + !isChangedFallbackBtcApiUrl || + isValidBtcApi(formInputs.fallbackBtcApiUrl, formInputs.type), ]); - if (isValidStacksUrl && isValidBtcApiUrl) { - const networkObject = - changedNetwork.type === 'Mainnet' - ? new StacksMainnet({ url: stacksUrl }) - : new StacksTestnet({ url: stacksUrl }); - await changeNetwork(changedNetwork, networkObject, stacksUrl, btcUrl); + if (isValidStacksUrl && isValidBtcApiUrl && isValidFallbackBtcApiUrl) { + await changeNetwork(formInputs); navigate('/settings'); } else { - if (!isValidStacksUrl) { - setStacksUrlError(t('INVALID_URL')); - } - if (!isValidBtcApiUrl) { - setBtcURLError(t('INVALID_URL')); - } + setFormErrors({ + address: !isValidStacksUrl ? t('INVALID_URL') : '', + btcApiUrl: !isValidBtcApiUrl ? t('INVALID_URL') : '', + fallbackBtcApiUrl: !isValidFallbackBtcApiUrl ? t('INVALID_URL') : '', + }); setIsChangingNetwork(false); } }; + const savedMainnet = savedNetworks.find((n) => n.type === 'Mainnet'); + const savedTestnet = savedNetworks.find((n) => n.type === 'Testnet'); + return ( <> - - {t('NODE')} - Reset URL - - - - - - {stacksUrlError} - - BTC API URL - Reset URL - - - - - - {btcURLError} + + {nodeInputs.map(({ key, labelKey }) => ( + + ))} + - ); diff --git a/src/app/screens/settings/changeNetwork/networkRow.tsx b/src/app/screens/settings/changeNetwork/networkRow.tsx index 2169c1354..6f7cf65c6 100644 --- a/src/app/screens/settings/changeNetwork/networkRow.tsx +++ b/src/app/screens/settings/changeNetwork/networkRow.tsx @@ -20,7 +20,7 @@ const Button = styled.button((props) => ({ })); const Text = styled.h1((props) => ({ - ...props.theme.body_medium_m, + ...props.theme.typography.body_medium_m, color: props.color, flex: 1, textAlign: 'left', diff --git a/src/app/screens/settings/changeNetwork/nodeInput.tsx b/src/app/screens/settings/changeNetwork/nodeInput.tsx new file mode 100644 index 000000000..f23992727 --- /dev/null +++ b/src/app/screens/settings/changeNetwork/nodeInput.tsx @@ -0,0 +1,92 @@ +import { XCircle } from '@phosphor-icons/react'; +import InputFeedback from '@ui-library/inputFeedback'; +import { useTranslation } from 'react-i18next'; +import styled, { useTheme } from 'styled-components'; + +const NodeInputHeader = styled.div` + display: flex; + flex-direction: row; + align-items: flex-end; + justify-content: space-between; + padding-left: ${(props) => props.theme.spacing(1)}; + padding-right: ${(props) => props.theme.spacing(1)}; +`; + +const NodeText = styled.label` + ${(props) => props.theme.typography.body_medium_m} + color: ${(props) => props.theme.colors.white_200}; +`; + +const NodeResetButton = styled.button` + ${(props) => props.theme.typography.body_medium_m} + background: none; + color: ${(props) => props.theme.colors.white_200}; +`; + +// TODO create and use a ui-library input with proper input box styling +const InputContainer = styled.div` + display: flex; + flex-direction: row; + align-items: center; + width: 100%; + border: 1px solid ${(props) => props.theme.colors.elevation3}; + background-color: ${(props) => props.theme.colors.elevation_n1}; + border-radius: ${(props) => props.theme.radius(1)}px; + padding-left: ${(props) => props.theme.space.m}; + padding-right: ${(props) => props.theme.space.m}; + margin-top: ${(props) => props.theme.space.s}; + margin-bottom: ${(props) => props.theme.space.s}; +`; + +const Input = styled.input` + ${(props) => props.theme.typography.body_medium_m} + height: 44px; + display: flex; + flex: 1; + background-color: ${(props) => props.theme.colors.elevation_n1}; + color: ${(props) => props.theme.colors.white_200}; + border: none; +`; + +const Button = styled.button` + background: none; +`; + +function NodeInput({ + label, + onChange, + value, + onClear, + onReset, + error, +}: { + label: string; + onChange: (event: React.ChangeEvent) => void; + value: string; + onClear: () => void; + onReset: () => void; + error: string; +}) { + const { t } = useTranslation('translation', { keyPrefix: 'SETTING_SCREEN' }); + const theme = useTheme(); + + return ( +
+ + {label} + {t('RESET_TO_DEFAULT')} + + + + {value && ( + + )} + + +
+ ); +} + +export default NodeInput; diff --git a/src/app/screens/settings/index.tsx b/src/app/screens/settings/index.tsx index ce754c35f..cdf5b42f8 100644 --- a/src/app/screens/settings/index.tsx +++ b/src/app/screens/settings/index.tsx @@ -197,14 +197,11 @@ function Setting() { icon={ArrowIcon} showDivider /> - {!isLedgerAccount(selectedAccount) && ( - - )} - + ((props) => ({ - ...props.theme.body_medium_m, - backgroundColor: 'transparent', - border: `1px solid ${props.selected ? props.theme.colors.white_0 : props.theme.colors.grey}`, - color: props.theme.colors.white_0, + ...props.theme.typography.body_medium_m, + backgroundColor: props.selected ? props.theme.colors.white_900 : 'transparent', + border: `1px solid ${props.theme.colors.white_800}`, + color: props.selected ? props.theme.colors.white_0 : props.theme.colors.white_200, borderRadius: props.theme.radius(1), - height: 44, display: 'flex', flexDirection: 'row', alignItems: 'center', justifyContent: 'flex-start', - marginBottom: props.theme.spacing(12), - paddingLeft: props.theme.spacing(6), - paddingRight: props.theme.spacing(6), + padding: props.theme.space.m, + marginBottom: props.theme.space.s, })); -const TimerIcon = styled.img((props) => ({ +const TimerIcon = styled.img((props) => ({ width: 18, height: 21, - marginRight: props.theme.spacing(12), + marginRight: props.theme.space.l, + opacity: props.selected ? 1 : 0.8, })); +const getLabel = (period: number, t: TFunction<'translation', 'SETTING_SCREEN'>) => { + if (period < 60) { + return t('LOCK_COUNTDOWN_MIN', { count: period }); + } + const hours = period / 60; + return t('LOCK_COUNTDOWN_HS', { count: hours }); +}; + function LockCountdown() { const navigate = useNavigate(); const { t } = useTranslation('translation', { keyPrefix: 'SETTING_SCREEN' }); @@ -72,49 +80,41 @@ function LockCountdown() { navigate(-1); }; - const onChooseLow = () => { - setSelectedTime(WalletSessionPeriods.LOW); - }; - - const onChooseStandard = () => { - setSelectedTime(WalletSessionPeriods.STANDARD); - }; - - const onChooseLong = () => { - setSelectedTime(WalletSessionPeriods.LONG); - }; - const onSave = () => { setWalletLockPeriod(selectedTime); navigate(-1); }; + const periodOptions: number[] = Object.keys(WalletSessionPeriods) + .filter((key) => !Number.isNaN(Number(WalletSessionPeriods[key]))) + .map((key) => WalletSessionPeriods[key]); + + const iconsByPeriod = { + [WalletSessionPeriods.LOW]: Timer15, + [WalletSessionPeriods.STANDARD]: Timer30, + [WalletSessionPeriods.LONG]: Timer1, + [WalletSessionPeriods.VERY_LONG]: Timer3, + }; + return ( <> {t('LOCK_COUNTDOWN_TITLE')} - - - {`${WalletSessionPeriods.LOW} minute`} - - - - {`${WalletSessionPeriods.STANDARD} minutes`} - - - - {`${WalletSessionPeriods.LONG} minutes`} - + {periodOptions.map((period) => ( + setSelectedTime(period)} + > + + {getLabel(period, t)} + + ))} diff --git a/src/app/screens/signBatchPsbtRequest/index.tsx b/src/app/screens/signBatchPsbtRequest/index.tsx index a7ee9f476..116b78127 100644 --- a/src/app/screens/signBatchPsbtRequest/index.tsx +++ b/src/app/screens/signBatchPsbtRequest/index.tsx @@ -1,20 +1,23 @@ import { ExternalSatsMethods, MESSAGE_SOURCE } from '@common/types/message-types'; import { delay } from '@common/utils/ledger'; import AccountHeaderComponent from '@components/accountHeader'; +import AssetModal from '@components/assetModal'; import BottomModal from '@components/bottomModal'; import ActionButton from '@components/button'; -import SatsBundle from '@components/confirmBtcTransactionComponent/bundle'; -import InputOutputComponent from '@components/confirmBtcTransactionComponent/inputOutputComponent'; +import ReceiveSection from '@components/confirmBtcTransaction/receiveSection'; +import TransactionSummary from '@components/confirmBtcTransaction/transactionSummary'; +import TransferSection from '@components/confirmBtcTransaction/transferSection'; +import { getNetAmount, isScriptOutput } from '@components/confirmBtcTransaction/utils'; import InfoContainer from '@components/infoContainer'; import LoadingTransactionStatus from '@components/loadingTransactionStatus'; import { ConfirmationStatus } from '@components/loadingTransactionStatus/circularSvgAnimation'; -import RecipientComponent from '@components/recipientComponent'; import TransactionDetailComponent from '@components/transactionDetailComponent'; -import useDetectOrdinalInSignPsbt, { InputsBundle } from '@hooks/useDetectOrdinalInSignPsbt'; import useSignBatchPsbtTx from '@hooks/useSignBatchPsbtTx'; +import useTransactionContext from '@hooks/useTransactionContext'; import useWalletSelector from '@hooks/useWalletSelector'; import { ArrowLeft, ArrowRight } from '@phosphor-icons/react'; -import { Bundle, parsePsbt, satsToBtc } from '@secretkeylabs/xverse-core'; +import { btcTransaction } from '@secretkeylabs/xverse-core'; +import Callout from '@ui-library/callout'; import { isLedgerAccount } from '@utils/helper'; import BigNumber from 'bignumber.js'; import { useCallback, useEffect, useMemo, useState } from 'react'; @@ -113,33 +116,39 @@ interface TxResponse { psbtBase64: string; } +type PsbtSummary = { + inputs: btcTransaction.EnhancedInput[]; + outputs: btcTransaction.EnhancedOutput[]; + feeOutput?: btcTransaction.TransactionFeeOutput | undefined; + hasSigHashNone: boolean; +}; + function SignBatchPsbtRequest() { const { btcAddress, ordinalsAddress, selectedAccount, network } = useWalletSelector(); const navigate = useNavigate(); const { t } = useTranslation('translation', { keyPrefix: 'CONFIRM_TRANSACTION' }); - const { t: tCommon } = useTranslation('translation', { keyPrefix: 'COMMON' }); - const [expandInputOutputView, setExpandInputOutputView] = useState(false); - const { payload, confirmSignPsbt, cancelSignPsbt, getSigningAddresses, requestToken } = - useSignBatchPsbtTx(); + const { payload, confirmSignPsbt, cancelSignPsbt, requestToken } = useSignBatchPsbtTx(); const [isSigning, setIsSigning] = useState(false); const [isSigningComplete, setIsSigningComplete] = useState(false); const [signingPsbtIndex, setSigningPsbtIndex] = useState(1); - const [hasOutputScript, setHasOutputScript] = useState(false); const [currentPsbtIndex, setCurrentPsbtIndex] = useState(0); const [reviewTransaction, setReviewTransaction] = useState(false); const { search } = useLocation(); const params = new URLSearchParams(search); const tabId = params.get('tabId') ?? '0'; - const handleOrdinalAndOrdinalInfo = useDetectOrdinalInSignPsbt(); - const [userReceivesOrdinalArr, setUserReceivesOrdinalArr] = useState< - { bundleItemsData: InputsBundle; userReceivesOrdinal: boolean }[] - >([]); const [isLoading, setIsLoading] = useState(true); + const txnContext = useTransactionContext(); + const [inscriptionToShow, setInscriptionToShow] = useState< + btcTransaction.IOInscription | undefined + >(undefined); + + const [parsedPsbts, setParsedPsbts] = useState([]); const handlePsbtParsing = useCallback( (psbt: SignMultiplePsbtPayload, index: number) => { try { - return parsePsbt(selectedAccount!, psbt.inputsToSign, psbt.psbtBase64, network.type); + const parsedPsbt = new btcTransaction.EnhancedPsbt(txnContext, psbt.psbtBase64); + return parsedPsbt.getSummary(); } catch (err) { navigate('/tx-status', { state: { @@ -153,13 +162,21 @@ function SignBatchPsbtRequest() { return undefined; } }, - [selectedAccount, network.type], + [txnContext], ); - const parsedPsbts = useMemo( - () => payload.psbts.map(handlePsbtParsing), - [handlePsbtParsing, payload.psbts], - ); + useEffect(() => { + (async () => { + const parsedPsbtsResult = await Promise.all(payload.psbts.map(handlePsbtParsing)); + + if (parsedPsbtsResult.some((item) => item === undefined)) { + return setIsLoading(false); + } + + setParsedPsbts(parsedPsbtsResult as PsbtSummary[]); + setIsLoading(false); + })(); + }, [payload.psbts.length, handlePsbtParsing]); const checkAddressMismatch = (input) => { if (input.address !== btcAddress && input.address !== ordinalsAddress) { @@ -189,51 +206,10 @@ function SignBatchPsbtRequest() { payload.psbts.forEach((psbt) => psbt.inputsToSign.forEach(checkAddressMismatch)); }; - const checkIfUserReceivesOrdinals = async () => { - try { - const results = await Promise.all(parsedPsbts.map(handleOrdinalAndOrdinalInfo)); - setUserReceivesOrdinalArr(results); - } catch { - navigate('/tx-status', { - state: { - txid: '', - currency: 'BTC', - errorTitle: t('PSBT_CANT_PARSE_ERROR_TITLE'), - error: t('PSBT_CANT_PARSE_ERROR_DESCRIPTION'), - browserTx: true, - }, - }); - } finally { - setIsLoading(false); - } - }; - - useEffect(() => { - checkIfUserReceivesOrdinals(); - }, []); - useEffect(() => { checkIfMismatch(); }, []); - useEffect(() => { - if (parsedPsbts) { - let outputScriptDetected = false; - - parsedPsbts.forEach((psbt) => { - if (!psbt) { - return; - } - - if (psbt.outputs.some((output) => !!output.outputScript)) { - outputScriptDetected = true; - } - }); - - setHasOutputScript(outputScriptDetected); - } - }, [parsedPsbts]); - const onSignPsbtConfirmed = async () => { try { if (isLedgerAccount(selectedAccount)) { @@ -296,28 +272,35 @@ function SignBatchPsbtRequest() { window.close(); }; - const expandInputOutputSection = () => { - setExpandInputOutputView(!expandInputOutputView); - }; - const closeCallback = () => { window.close(); }; const totalNetAmount = parsedPsbts.reduce( - (sum, psbt) => (psbt ? sum.plus(new BigNumber(psbt.netAmount.toString())) : sum), + (sum, psbt) => + psbt + ? sum.plus( + new BigNumber( + getNetAmount({ + inputs: psbt.inputs, + outputs: psbt.outputs, + btcAddress, + ordinalsAddress, + }), + ), + ) + : sum, new BigNumber(0), ); - - const userReceivesOrdinals = userReceivesOrdinalArr - .filter((item) => item.userReceivesOrdinal) - .map((item) => item.bundleItemsData) - .flat(); - - const userTransfersOrdinals = userReceivesOrdinalArr - .filter((item) => !item.userReceivesOrdinal) - .map((item) => item.bundleItemsData) - .flat(); + const totalFeeAmount = parsedPsbts.reduce((sum, psbt) => { + const feeAmount = psbt.feeOutput?.amount ?? 0; + return sum.plus(new BigNumber(feeAmount)); + }, new BigNumber(0)); + + const hasOutputScript = useMemo( + () => parsedPsbts.some((psbt) => psbt.outputs.some((output) => isScriptOutput(output))), + [parsedPsbts.length], + ); const signingStatus: ConfirmationStatus = isSigningComplete ? 'SUCCESS' : 'LOADING'; @@ -364,51 +347,30 @@ function SignBatchPsbtRequest() { {t('REVIEW_ALL')} - - {userTransfersOrdinals.length > 0 && - userTransfersOrdinals.map((item, index) => ( - - ))} - - {userReceivesOrdinals.length > 0 && - userTransfersOrdinals.map((item, index) => ( - - ))} - - setInscriptionToShow(undefined)} + inscription={{ + content_type: inscriptionToShow.contentType, + id: inscriptionToShow.id, + inscription_number: inscriptionToShow.number, + }} + /> + )} + psbt.inputs).flat()} + outputs={parsedPsbts.map((psbt) => psbt.outputs).flat()} + netAmount={(totalNetAmount.toNumber() + totalFeeAmount.toNumber()) * -1} + isPartialTransaction={parsedPsbts.some((psbt) => !psbt.feeOutput)} + onShowInscription={setInscriptionToShow} + /> + psbt.outputs).flat()} + onShowInscription={setInscriptionToShow} + netAmount={totalNetAmount.toNumber()} /> - - - {hasOutputScript && } + {hasOutputScript && } )} @@ -439,46 +401,14 @@ function SignBatchPsbtRequest() { {t('TRANSACTION')} {currentPsbtIndex + 1}/{parsedPsbts.length} - {Array.isArray(userReceivesOrdinalArr[currentPsbtIndex]?.bundleItemsData) && - userReceivesOrdinalArr[currentPsbtIndex].bundleItemsData.map((bundle, index) => ( - - ))} - - - - - - {hasOutputScript && } + {!!parsedPsbts[currentPsbtIndex] && ( + + )} diff --git a/src/app/screens/signPsbtRequest/bundleItemsComponent.tsx b/src/app/screens/signPsbtRequest/bundleItemsComponent.tsx deleted file mode 100644 index 82d0de52d..000000000 --- a/src/app/screens/signPsbtRequest/bundleItemsComponent.tsx +++ /dev/null @@ -1,219 +0,0 @@ -import Eye from '@assets/img/createPassword/Eye.svg'; -import Cross from '@assets/img/dashboard/X.svg'; -import IconOrdinal from '@assets/img/transactions/ordinal.svg'; -import RareSatAsset from '@components/rareSatAsset/rareSatAsset'; -import { animated, useSpring } from '@react-spring/web'; -import { getTruncatedAddress } from '@utils/helper'; -import { BundleItem, getBundleItemSubText } from '@utils/rareSats'; -import { useState } from 'react'; -import { useTranslation } from 'react-i18next'; -import styled from 'styled-components'; - -const Container = styled.div((props) => ({ - display: 'flex', - flexDirection: 'column', - background: props.theme.colors.elevation1, - borderRadius: 12, - padding: '16px 16px', - justifyContent: 'center', - marginBottom: 12, -})); - -const RecipientTitleText = styled.h1((props) => ({ - ...props.theme.body_medium_m, - color: props.theme.colors.white_200, - marginBottom: 10, -})); - -const RowContainer = styled.div({ - display: 'flex', - flexDirection: 'row', - alignItems: 'center', -}); - -const TransparentButton = styled.button({ - background: 'transparent', - display: 'flex', - alignItems: 'center', - marginLeft: 10, -}); - -const Icon = styled.img((props) => ({ - marginRight: props.theme.spacing(4), - width: 32, - height: 32, - borderRadius: 30, -})); - -const TitleText = styled.h1((props) => ({ - ...props.theme.body_medium_m, - color: props.theme.colors.white_200, -})); - -const ValueText = styled.h1((props) => ({ - ...props.theme.body_medium_m, - color: props.theme.colors.white_0, -})); - -const SubValueText = styled.h1((props) => ({ - ...props.theme.body_m, - fontSize: 12, - color: props.theme.colors.white_400, -})); - -const InscriptionText = styled.h1((props) => ({ - ...props.theme.body_medium_m, - fontSize: 21, - marginTop: 24, - textAlign: 'center', - color: props.theme.colors.white[0], - overflowWrap: 'break-word', - wordWrap: 'break-word', - wordBreak: 'break-word', -})); - -const ColumnContainer = styled.div({ - display: 'flex', - flexDirection: 'column', - flex: 1, - justifyContent: 'flex-end', - alignItems: 'flex-end', - marginTop: 12, -}); - -const CrossContainer = styled.div({ - display: 'flex', - marginTop: 10, - justifyContent: 'flex-end', - alignItems: 'flex-end', -}); - -const OrdinalOuterImageContainer = styled.div({ - justifyContent: 'center', - alignItems: 'center', - borderRadius: 2, - display: 'flex', - flexDirection: 'column', - flex: 1, -}); - -const OrdinalImageContainer = styled.div({ - width: '50%', -}); - -const OrdinalBackgroundContainer = styled(animated.div)({ - width: '100%', - height: '100%', - top: 0, - left: 0, - bottom: 0, - right: 0, - position: 'fixed', - zIndex: 10, - background: 'rgba(18, 21, 30, 0.8)', - backdropFilter: 'blur(16px)', - padding: 16, - display: 'flex', - flexDirection: 'column', -}); - -const EyeIcon = styled.img({ - width: 20, - height: 20, -}); - -interface Props { - item: BundleItem; - userReceivesOrdinal: boolean; -} -function BundleItemsComponent({ item, userReceivesOrdinal }: Props) { - const { t } = useTranslation('translation'); - const [showOrdinal, setShowOrdinal] = useState(false); - const styles = useSpring({ - from: { - opacity: 0, - y: 24, - }, - to: { - y: 0, - opacity: 1, - }, - delay: 100, - }); - const onButtonClick = () => { - setShowOrdinal(true); - }; - - const onCrossClick = () => { - setShowOrdinal(false); - }; - const getItemId = () => { - if (item.type === 'inscription') { - return item.inscription.id; - } - if (item.type === 'inscribed-sat' || item.type === 'rare-sat') { - return item.number; - } - return ''; - }; - const itemSubText = getBundleItemSubText({ - satType: item.type, - rareSatsType: item.rarity_ranking as any, - }); - const getDetail = () => { - if (item.type === 'inscription' || item.type === 'inscribed-sat') { - return item.inscription.content_type; - } - return itemSubText; - }; - const getTitle = () => { - if (item.type === 'inscription') { - return t('COMMON.INSCRIPTION'); - } - if (item.type === 'inscribed-sat') { - return t('RARE_SATS.INSCRIBED_SAT'); - } - return t('RARE_SATS.RARE_SAT'); - }; - return ( - <> - {showOrdinal && ( - - - - cross - - - - - - - {`${getTitle()} ${getItemId()} `} - - - )} - - - {userReceivesOrdinal - ? t('CONFIRM_TRANSACTION.YOU_WILL_RECEIVE') - : t('CONFIRM_TRANSACTION.YOU_WILL_TRANSFER')} - - - - {getTitle()} - - - {getTruncatedAddress(String(getItemId()))} - - - - - {getDetail()} - - - - - ); -} - -export default BundleItemsComponent; diff --git a/src/app/screens/signPsbtRequest/index.tsx b/src/app/screens/signPsbtRequest/index.tsx index e322c6006..fccafc694 100644 --- a/src/app/screens/signPsbtRequest/index.tsx +++ b/src/app/screens/signPsbtRequest/index.tsx @@ -1,231 +1,75 @@ -import ledgerConnectDefaultIcon from '@assets/img/ledger/ledger_connect_default.svg'; -import ledgerConnectBtcIcon from '@assets/img/ledger/ledger_import_connect_btc.svg'; -import { ExternalSatsMethods, MESSAGE_SOURCE } from '@common/types/message-types'; -import { delay } from '@common/utils/ledger'; -import AccountHeaderComponent from '@components/accountHeader'; -import BottomModal from '@components/bottomModal'; -import ActionButton from '@components/button'; -import InputOutputComponent from '@components/confirmBtcTransactionComponent/inputOutputComponent'; -import InfoContainer from '@components/infoContainer'; -import LedgerConnectionView from '@components/ledger/connectLedgerView'; -import RecipientComponent from '@components/recipientComponent'; -import TransactionDetailComponent from '@components/transactionDetailComponent'; -import useBtcClient from '@hooks/useBtcClient'; -import useDetectOrdinalInSignPsbt from '@hooks/useDetectOrdinalInSignPsbt'; +import ConfirmBitcoinTransaction from '@components/confirmBtcTransaction'; import useSignPsbtTx from '@hooks/useSignPsbtTx'; +import useTransactionContext from '@hooks/useTransactionContext'; import useWalletSelector from '@hooks/useWalletSelector'; -import Transport from '@ledgerhq/hw-transport-webusb'; -import { - getBtcFiatEquivalent, - parsePsbt, - psbtBase64ToHex, - satsToBtc, - signLedgerPSBT, - Transport as TransportType, -} from '@secretkeylabs/xverse-core'; -import { isLedgerAccount } from '@utils/helper'; -import { BundleItem, convertV2ToV1Bundle } from '@utils/rareSats'; -import BigNumber from 'bignumber.js'; -import { decodeToken } from 'jsontokens'; +import { btcTransaction, Transport } from '@secretkeylabs/xverse-core'; import { useEffect, useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; -import { NumericFormat } from 'react-number-format'; -import { useLocation, useNavigate } from 'react-router-dom'; -import { MoonLoader } from 'react-spinners'; -import { SignTransactionOptions } from 'sats-connect'; -import styled from 'styled-components'; -import BundleItemsComponent from './bundleItemsComponent'; - -const OuterContainer = styled.div` - display: flex; - flex: 1; - flex-direction: column; - overflow-y: auto; - &::-webkit-scrollbar { - display: none; - } -`; - -const Container = styled.div((props) => ({ - display: 'flex', - flexDirection: 'column', - flex: 1, - marginTop: props.theme.spacing(11), - marginLeft: props.theme.spacing(8), - marginRight: props.theme.spacing(8), -})); - -const LoaderContainer = styled.div((props) => ({ - display: 'flex', - flex: 1, - justifyContent: 'center', - alignItems: 'center', - marginTop: props.theme.spacing(12), -})); - -const ButtonContainer = styled.div((props) => ({ - display: 'flex', - flexDirection: 'row', - marginLeft: props.theme.spacing(8), - marginRight: props.theme.spacing(8), - marginBottom: props.theme.spacing(20), - marginTop: props.theme.spacing(12), -})); - -const TransparentButtonContainer = styled.div((props) => ({ - marginRight: props.theme.spacing(6), - width: '100%', -})); - -const ReviewTransactionText = styled.h1((props) => ({ - ...props.theme.headline_s, - color: props.theme.colors.white_0, - marginBottom: props.theme.spacing(12), - textAlign: 'left', -})); - -const SuccessActionsContainer = styled.div((props) => ({ - width: '100%', - display: 'flex', - flexDirection: 'column', - gap: props.theme.spacing(6), - paddingLeft: props.theme.spacing(8), - paddingRight: props.theme.spacing(8), - marginBottom: props.theme.spacing(20), - marginTop: props.theme.spacing(20), -})); +import { useNavigate } from 'react-router-dom'; +import useSignPsbtValidationGate from './useSignPsbtValidationGate'; function SignPsbtRequest() { - const { - btcAddress, - btcPublicKey, - ordinalsAddress, - ordinalsPublicKey, - selectedAccount, - network, - btcFiatRate, - } = useWalletSelector(); const navigate = useNavigate(); const { t } = useTranslation('translation', { keyPrefix: 'CONFIRM_TRANSACTION' }); - const { t: signatureRequestTranslate } = useTranslation('translation', { - keyPrefix: 'SIGNATURE_REQUEST', - }); - const [expandInputOutputView, setExpandInputOutputView] = useState(false); - const { payload, confirmSignPsbt, cancelSignPsbt, getSigningAddresses } = useSignPsbtTx(); + + const [isLoading, setIsLoading] = useState(true); const [isSigning, setIsSigning] = useState(false); - const [hasOutputScript, setHasOutputScript] = useState(false); - const [isModalVisible, setIsModalVisible] = useState(false); - const [currentStepIndex, setCurrentStepIndex] = useState(0); - const [isButtonDisabled, setIsButtonDisabled] = useState(false); - const [isConnectSuccess, setIsConnectSuccess] = useState(false); - const [isConnectFailed, setIsConnectFailed] = useState(false); - const [isTxRejected, setIsTxRejected] = useState(false); - const { search } = useLocation(); - const params = new URLSearchParams(search); - const tabId = params.get('tabId') ?? '0'; - const requestToken = params.get('signPsbtRequest') ?? ''; - const request = decodeToken(requestToken) as any as SignTransactionOptions; - const btcClient = useBtcClient(); + const [inputs, setInputs] = useState([]); + const [outputs, setOutputs] = useState([]); + const [feeOutput, setFeeOutput] = useState(); + const { payload, confirmSignPsbt, cancelSignPsbt } = useSignPsbtTx(); + const txnContext = useTransactionContext(); const parsedPsbt = useMemo(() => { try { - return parsePsbt(selectedAccount!, payload.inputsToSign, payload.psbtBase64, network.type); + return new btcTransaction.EnhancedPsbt(txnContext, payload.psbtBase64, payload.inputsToSign); } catch (err) { return undefined; } - }, [selectedAccount, payload.inputsToSign, payload.psbtBase64, network.type]); + }, [txnContext, payload.psbtBase64]); - const handleOrdinalAndOrdinalInfo = useDetectOrdinalInSignPsbt(); - const [isLoading, setIsLoading] = useState(true); - const [userReceivesOrdinal, setUserReceivesOrdinal] = useState(false); - const [bundleItemsData, setBundleItemsData] = useState([]); - const signingAddresses = useMemo( - () => getSigningAddresses(payload.inputsToSign), - [payload.inputsToSign], - ); + useSignPsbtValidationGate({ payload, parsedPsbt }); - const checkIfMismatch = () => { - if (!parsedPsbt) { - navigate('/tx-status', { - state: { - txid: '', - currency: 'BTC', - errorTitle: t('PSBT_CANT_PARSE_ERROR_TITLE'), - error: t('PSBT_CANT_PARSE_ERROR_DESCRIPTION'), - browserTx: true, - }, - }); - } - if (payload.network.type !== network.type) { - navigate('/tx-status', { - state: { - txid: '', - currency: 'BTC', - error: t('NETWORK_MISMATCH'), - browserTx: true, - }, - }); - } - if (payload.inputsToSign) { - payload.inputsToSign.forEach((input) => { - if (input.address !== btcAddress && input.address !== ordinalsAddress) { - navigate('/tx-status', { - state: { - txid: '', - currency: 'BTC', - error: t('ADDRESS_MISMATCH'), - browserTx: true, - }, - }); - } + const { btcAddress, ordinalsAddress } = useWalletSelector(); + useEffect(() => { + if (!parsedPsbt) return; + + parsedPsbt + .getSummary() + .then((summary) => { + const { feeOutput: psbtFeeOutput, inputs: psbtInputs, outputs: psbtOutputs } = summary; + setFeeOutput(psbtFeeOutput); + setInputs(psbtInputs); + setOutputs(psbtOutputs); + setIsLoading(false); + }) + .catch((error) => { + console.error(error); + navigate('/tx-status', { + state: { + txid: '', + currency: 'BTC', + errorTitle: t('PSBT_CANT_PARSE_ERROR_TITLE'), + error: t('PSBT_CANT_PARSE_ERROR_DESCRIPTION'), + browserTx: true, + }, + }); }); - } - }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [parsedPsbt]); - const checkIfUserReceivesOrdinal = async () => { + const onConfirm = async (ledgerTransport?: Transport) => { + setIsSigning(true); try { - const result = await handleOrdinalAndOrdinalInfo(parsedPsbt); - setBundleItemsData(convertV2ToV1Bundle(result.bundleItemsData)); - setUserReceivesOrdinal(result.userReceivesOrdinal); - } catch { - navigate('/tx-status', { - state: { - txid: '', - currency: 'BTC', - errorTitle: t('PSBT_CANT_PARSE_ERROR_TITLE'), - error: t('PSBT_CANT_PARSE_ERROR_DESCRIPTION'), - browserTx: true, - }, + const signedPsbt = await parsedPsbt?.getSignedPsbtBase64({ + finalize: payload.broadcast, + ledgerTransport, }); - } finally { - setIsLoading(false); - } - }; - - useEffect(() => { - checkIfUserReceivesOrdinal(); - }, []); - - useEffect(() => { - checkIfMismatch(); - }, []); - useEffect(() => { - if (parsedPsbt) { - const outputScriptDetected = parsedPsbt.outputs.some((output) => !!output.outputScript); - setHasOutputScript(outputScriptDetected); - } - }, [parsedPsbt]); - - const onSignPsbtConfirmed = async () => { - try { - if (isLedgerAccount(selectedAccount)) { - setIsModalVisible(true); - return; + const response = await confirmSignPsbt(signedPsbt); + if (ledgerTransport) { + await ledgerTransport?.close(); } - - setIsSigning(true); - const response = await confirmSignPsbt(); setIsSigning(false); if (payload.broadcast) { navigate('/tx-status', { @@ -240,6 +84,7 @@ function SignPsbtRequest() { window.close(); } } catch (err) { + setIsSigning(false); if (err instanceof Error) { navigate('/tx-status', { state: { @@ -254,226 +99,26 @@ function SignPsbtRequest() { } }; - const onCancelClick = async () => { + const onCancel = () => { cancelSignPsbt(); window.close(); }; - const expandInputOutputSection = () => { - setExpandInputOutputView(!expandInputOutputView); - }; - - const handleLedgerPsbtSigning = async (transport: TransportType) => { - const addressIndex = selectedAccount?.deviceAccountIndex; - const { psbtBase64, broadcast } = payload; - - if (addressIndex === undefined) { - throw new Error('Account not found'); - } - - const signingResponse = await signLedgerPSBT({ - transport, - esploraProvider: btcClient, - network: network.type, - addressIndex, - psbtInputBase64: psbtBase64, - finalize: broadcast ?? false, - nativeSegwitPubKey: btcPublicKey, - taprootPubKey: ordinalsPublicKey, - }); - - let txId: string = ''; - if (request.payload.broadcast) { - const txHex = psbtBase64ToHex(signingResponse); - const response = await btcClient.sendRawTransaction(txHex); - txId = response.tx.hash; - } - - const signingMessage = { - source: MESSAGE_SOURCE, - method: ExternalSatsMethods.signPsbtResponse, - payload: { - signPsbtRequest: requestToken, - signPsbtResponse: { - psbtBase64: signingResponse, - txId, - }, - }, - }; - chrome.tabs.sendMessage(+tabId, signingMessage); - - return { - txId, - signingResponse, - }; - }; - - const handleConnectAndConfirm = async () => { - if (!selectedAccount) { - console.error('No account selected'); - return; - } - setIsButtonDisabled(true); - - const transport = await Transport.create(); - - if (!transport) { - setIsConnectSuccess(false); - setIsConnectFailed(true); - setIsButtonDisabled(false); - return; - } - - setIsConnectSuccess(true); - await delay(1500); - setCurrentStepIndex(1); - - try { - const response = await handleLedgerPsbtSigning(transport); - - if (payload.broadcast) { - navigate('/tx-status', { - state: { - txid: response.txId, - currency: 'BTC', - error: '', - browserTx: true, - }, - }); - } else { - window.close(); - } - } catch (err) { - console.error(err); - setIsTxRejected(true); - } finally { - await transport.close(); - setIsButtonDisabled(false); - } - }; - - const handleRetry = async () => { - setIsTxRejected(false); - setIsConnectSuccess(false); - setCurrentStepIndex(0); - }; - - const cancelCallback = () => { - window.close(); - }; - - const getSatsAmountString = (sats: BigNumber) => ( - - ); - return ( - <> - - {isLoading ? ( - - - - ) : ( - <> - - - {t('REVIEW_TRANSACTION')} - {!payload.broadcast && } - {bundleItemsData && - bundleItemsData.map((bundleItem, index) => ( - - ))} - - - - - {payload.broadcast ? ( - - ) : null} - {hasOutputScript && } - - - - - - - - - - )} - setIsModalVisible(false)}> - {currentStepIndex === 0 && ( - - )} - {currentStepIndex === 1 && ( - - )} - - - - - - + ); } diff --git a/src/app/screens/signPsbtRequest/useSignPsbtValidationGate.ts b/src/app/screens/signPsbtRequest/useSignPsbtValidationGate.ts new file mode 100644 index 000000000..cd81c775a --- /dev/null +++ b/src/app/screens/signPsbtRequest/useSignPsbtValidationGate.ts @@ -0,0 +1,56 @@ +import useWalletSelector from '@hooks/useWalletSelector'; +import { btcTransaction } from '@secretkeylabs/xverse-core'; +import { useEffect } from 'react'; +import { useTranslation } from 'react-i18next'; +import { useNavigate } from 'react-router-dom'; +import { SignTransactionPayload } from 'sats-connect'; + +type Props = { + payload: SignTransactionPayload; + parsedPsbt: btcTransaction.EnhancedPsbt | undefined; +}; +const useSignPsbtValidationGate = ({ payload, parsedPsbt }: Props) => { + const { btcAddress, ordinalsAddress, network } = useWalletSelector(); + const navigate = useNavigate(); + const { t } = useTranslation('translation', { keyPrefix: 'CONFIRM_TRANSACTION' }); + + useEffect(() => { + if (!parsedPsbt) { + navigate('/tx-status', { + state: { + txid: '', + currency: 'BTC', + errorTitle: t('PSBT_CANT_PARSE_ERROR_TITLE'), + error: t('PSBT_CANT_PARSE_ERROR_DESCRIPTION'), + browserTx: true, + }, + }); + } + if (payload.network.type !== network.type) { + navigate('/tx-status', { + state: { + txid: '', + currency: 'BTC', + error: t('NETWORK_MISMATCH'), + browserTx: true, + }, + }); + } + if (payload.inputsToSign) { + payload.inputsToSign.forEach((input) => { + if (input.address !== btcAddress && input.address !== ordinalsAddress) { + navigate('/tx-status', { + state: { + txid: '', + currency: 'BTC', + error: t('ADDRESS_MISMATCH'), + browserTx: true, + }, + }); + } + }); + } + }); +}; + +export default useSignPsbtValidationGate; diff --git a/src/app/screens/speedUpTransaction/customFee.tsx b/src/app/screens/speedUpTransaction/customFee.tsx deleted file mode 100644 index 1eacd7cee..000000000 --- a/src/app/screens/speedUpTransaction/customFee.tsx +++ /dev/null @@ -1,220 +0,0 @@ -import BottomModal from '@components/bottomModal'; -import ActionButton from '@components/button'; -import FiatAmountText from '@components/fiatAmountText'; -import useWalletSelector from '@hooks/useWalletSelector'; -import { getBtcFiatEquivalent } from '@secretkeylabs/xverse-core'; -import InputFeedback from '@ui-library/inputFeedback'; -import BigNumber from 'bignumber.js'; -import { useEffect, useState } from 'react'; -import { useTranslation } from 'react-i18next'; -import { NumericFormat } from 'react-number-format'; -import styled from 'styled-components'; - -const Container = styled.div((props) => ({ - display: 'flex', - flexDirection: 'column', - marginLeft: props.theme.spacing(8), - marginRight: props.theme.spacing(8), -})); - -const InfoContainer = styled.div((props) => ({ - display: 'flex', - justifyContent: 'space-between', - alignItems: 'center', - marginTop: props.theme.spacing(6), - minHeight: 20, -})); - -const TotalFeeText = styled.span((props) => ({ - ...props.theme.typography.body_medium_m, - display: 'flex', - columnGap: props.theme.spacing(2), - color: props.theme.colors.white_200, -})); - -const InputContainer = styled.div<{ withError?: boolean }>((props) => ({ - display: 'flex', - justifyContent: 'space-between', - alignItems: 'center', - border: `1px solid ${ - props.withError ? props.theme.colors.danger_dark_200 : props.theme.colors.white_800 - }`, - backgroundColor: props.theme.colors.elevation1, - borderRadius: props.theme.radius(1), - marginTop: props.theme.spacing(4), - padding: props.theme.spacing(6), - paddingLeft: props.theme.spacing(8), - paddingRight: props.theme.spacing(8), -})); - -const InputField = styled.input((props) => ({ - ...props.theme.typography.body_medium_m, - backgroundColor: 'transparent', - color: props.theme.colors.white_200, - border: 'transparent', - width: '80%', - '&::-webkit-outer-spin-button': { - '-webkit-appearance': 'none', - margin: 0, - }, - '&::-webkit-inner-spin-button': { - '-webkit-appearance': 'none', - margin: 0, - }, - '&[type=number]': { - '-moz-appearance': 'textfield', - }, -})); - -const InputLabel = styled.span((props) => ({ - ...props.theme.typography.body_medium_m, - color: props.theme.colors.white_200, -})); - -const FeeText = styled.span((props) => ({ - ...props.theme.typography.body_medium_m, - color: props.theme.colors.white_0, -})); - -const FeeContainer = styled.div({ - display: 'flex', - flexDirection: 'column', -}); - -const ControlsContainer = styled.div` - display: flex; - gap: 12px; - margin: 24px 16px 40px; -`; - -const StyledInputFeedback = styled(InputFeedback)` - margin-top: ${(props) => props.theme.spacing(2)}px; -`; - -const StyledFiatAmountText = styled(FiatAmountText)((props) => ({ - ...props.theme.typography.body_medium_m, - color: props.theme.colors.white_400, -})); - -const StyledActionButton = styled(ActionButton)((props) => ({ - 'div, h1': { - ...props.theme.typography.body_medium_m, - }, -})); - -export default function CustomFee({ - visible, - onClose, - onClickApply, - calculateTotalFee, - feeRate, - fee, - initialFeeRate, - initialTotalFee, - minimumFeeRate, - isFeeLoading, - error, -}: { - visible: boolean; - onClose: () => void; - onClickApply: (feeRate: string, fee: string) => void; - calculateTotalFee: (feeRate: string) => Promise; - feeRate?: string; - fee?: string; - initialFeeRate: string; - initialTotalFee: string; - minimumFeeRate?: string; - isFeeLoading: boolean; - error: string; -}) { - const { t } = useTranslation('translation'); - const { btcFiatRate, fiatCurrency } = useWalletSelector(); - const [feeRateInput, setFeeRateInput] = useState(feeRate || minimumFeeRate || initialFeeRate); - const [totalFee, setTotalFee] = useState(fee || initialTotalFee); - - const fetchTotalFee = async () => { - const response = await calculateTotalFee(feeRateInput); - - if (response) { - setTotalFee(response.toString()); - } - }; - - useEffect(() => { - fetchTotalFee(); - }, [feeRateInput]); - - /* callbacks */ - const handleKeyDownFeeRateInput = (e: React.KeyboardEvent) => { - // only allow positive integers - // disable common special characters, including - and . - // eslint-disable-next-line no-useless-escape - if (e.key.match(/^[!-\/:-@[-`{-~]$/)) { - e.preventDefault(); - } - }; - - const handleChangeFeeRateInput = (e: React.ChangeEvent) => { - setFeeRateInput(e.target.value); - }; - - const handleClickApply = () => { - // apply state to parent - onClickApply(feeRateInput, totalFee); - }; - - const fiatFee = totalFee - ? getBtcFiatEquivalent(BigNumber(totalFee), BigNumber(btcFiatRate)) - : BigNumber(0); - - return ( - - - - - - Sats /vB - - - - {error && } - {!error && minimumFeeRate && Number(feeRateInput) >= Number(minimumFeeRate) && ( - <> - - {t('TRANSACTION_SETTING.TOTAL_FEE')} - {value}} - /> - - - - )} - - - - - - - - ); -} diff --git a/src/app/screens/speedUpTransaction/customFee/index.styled.ts b/src/app/screens/speedUpTransaction/customFee/index.styled.ts new file mode 100644 index 000000000..f822a83a1 --- /dev/null +++ b/src/app/screens/speedUpTransaction/customFee/index.styled.ts @@ -0,0 +1,96 @@ +import ActionButton from '@components/button'; +import FiatAmountText from '@components/fiatAmountText'; +import InputFeedback from '@ui-library/inputFeedback'; +import styled from 'styled-components'; + +export const Container = styled.div((props) => ({ + display: 'flex', + flexDirection: 'column', + marginLeft: props.theme.spacing(8), + marginRight: props.theme.spacing(8), +})); + +export const InfoContainer = styled.div((props) => ({ + display: 'flex', + justifyContent: 'space-between', + alignItems: 'center', + marginTop: props.theme.spacing(6), + minHeight: 20, +})); + +export const TotalFeeText = styled.span((props) => ({ + ...props.theme.typography.body_medium_m, + display: 'flex', + columnGap: props.theme.spacing(2), + color: props.theme.colors.white_200, +})); + +export const InputContainer = styled.div<{ withError?: boolean }>((props) => ({ + display: 'flex', + justifyContent: 'space-between', + alignItems: 'center', + border: `1px solid ${ + props.withError ? props.theme.colors.danger_dark_200 : props.theme.colors.white_800 + }`, + backgroundColor: props.theme.colors.elevation1, + borderRadius: props.theme.radius(1), + marginTop: props.theme.spacing(4), + padding: props.theme.spacing(6), + paddingLeft: props.theme.spacing(8), + paddingRight: props.theme.spacing(8), +})); + +export const InputField = styled.input((props) => ({ + ...props.theme.typography.body_medium_m, + backgroundColor: 'transparent', + color: props.theme.colors.white_200, + border: 'transparent', + width: '70%', + '&::-webkit-outer-spin-button': { + '-webkit-appearance': 'none', + margin: 0, + }, + '&::-webkit-inner-spin-button': { + '-webkit-appearance': 'none', + margin: 0, + }, + '&[type=number]': { + '-moz-appearance': 'textfield', + }, +})); + +export const InputLabel = styled.span((props) => ({ + ...props.theme.typography.body_medium_m, + color: props.theme.colors.white_200, +})); + +export const FeeText = styled.span((props) => ({ + ...props.theme.typography.body_medium_m, + color: props.theme.colors.white_0, +})); + +export const FeeContainer = styled.div({ + display: 'flex', + flexDirection: 'column', +}); + +export const ControlsContainer = styled.div` + display: flex; + gap: 12px; + margin: 24px 16px 40px; +`; + +export const StyledInputFeedback = styled(InputFeedback)` + margin-top: ${(props) => props.theme.spacing(2)}px; +`; + +export const StyledFiatAmountText = styled(FiatAmountText)((props) => ({ + ...props.theme.typography.body_medium_m, + color: props.theme.colors.white_400, +})); + +export const StyledActionButton = styled(ActionButton)((props) => ({ + 'div, h1': { + ...props.theme.typography.body_medium_m, + }, +})); diff --git a/src/app/screens/speedUpTransaction/customFee/index.tsx b/src/app/screens/speedUpTransaction/customFee/index.tsx new file mode 100644 index 000000000..b0c88a61d --- /dev/null +++ b/src/app/screens/speedUpTransaction/customFee/index.tsx @@ -0,0 +1,149 @@ +import BottomModal from '@components/bottomModal'; +import useWalletSelector from '@hooks/useWalletSelector'; +import { + getBtcFiatEquivalent, + getStxFiatEquivalent, + stxToMicrostacks, +} from '@secretkeylabs/xverse-core'; +import { handleKeyDownFeeRateInput } from '@utils/helper'; +import BigNumber from 'bignumber.js'; +import { useEffect, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { NumericFormat } from 'react-number-format'; +import { + Container, + ControlsContainer, + FeeContainer, + FeeText, + InfoContainer, + InputContainer, + InputField, + InputLabel, + StyledActionButton, + StyledFiatAmountText, + StyledInputFeedback, + TotalFeeText, +} from './index.styled'; + +export default function CustomFee({ + visible, + onClose, + onClickApply, + calculateTotalFee, + feeRate, + fee, + initialTotalFee, + minimumFeeRate, + isFeeLoading, + error, + isBtc, +}: { + visible: boolean; + onClose: () => void; + onClickApply: (feeRate: string, fee: string) => void; + calculateTotalFee: (feeRate: string) => Promise; + feeRate?: string; + fee?: string; + minimumFeeRate: string; + initialTotalFee: string; + isFeeLoading: boolean; + error: string; + isBtc: boolean; +}) { + const { t } = useTranslation('translation', { + keyPrefix: 'TRANSACTION_SETTING', + }); + const { btcFiatRate, stxBtcRate, fiatCurrency } = useWalletSelector(); + const [feeRateInput, setFeeRateInput] = useState(feeRate || minimumFeeRate); + const [totalFee, setTotalFee] = useState(fee || initialTotalFee); + + const fetchTotalFee = async () => { + const response = await calculateTotalFee(feeRateInput); + + if (response) { + setTotalFee(response.toString()); + } + }; + + useEffect(() => { + fetchTotalFee(); + }, [feeRateInput]); + + const handleChangeFeeRateInput = (e: React.ChangeEvent) => { + setFeeRateInput(e.target.value); + }; + + const handleClickApply = () => { + // apply state to parent + onClickApply(feeRateInput, totalFee); + }; + + let fiatFee = BigNumber(0); + + if (totalFee) { + fiatFee = isBtc + ? getBtcFiatEquivalent(BigNumber(totalFee), BigNumber(btcFiatRate)) + : getStxFiatEquivalent( + stxToMicrostacks(BigNumber(totalFee)), + BigNumber(stxBtcRate), + BigNumber(btcFiatRate), + ); + } + + return ( + + + + + + + {isBtc ? ( + 'Sats /vB' + ) : ( + + )} + + + + + {error && } + {!error && isBtc && minimumFeeRate && Number(feeRateInput) >= Number(minimumFeeRate) && ( + <> + + {t('TOTAL_FEE')} + {value}} + /> + + + + )} + + + + + + + + ); +} diff --git a/src/app/screens/speedUpTransaction/index.styled.ts b/src/app/screens/speedUpTransaction/index.styled.ts index 71889cb67..85f76dbb6 100644 --- a/src/app/screens/speedUpTransaction/index.styled.ts +++ b/src/app/screens/speedUpTransaction/index.styled.ts @@ -1,14 +1,5 @@ -import ActionButton from '@components/button'; -import { Faders } from '@phosphor-icons/react'; import styled from 'styled-components'; -export const Title = styled.h1((props) => ({ - ...props.theme.typography.headline_s, - color: props.theme.colors.white_0, - marginTop: props.theme.spacing(8), - marginBottom: props.theme.spacing(8), -})); - export const LoaderContainer = styled.div({ display: 'flex', justifyContent: 'center', @@ -16,115 +7,13 @@ export const LoaderContainer = styled.div({ height: 'inherit', }); -export const Container = styled.div((props) => ({ - display: 'flex', - flexDirection: 'column', - marginLeft: props.theme.spacing(8), - marginRight: props.theme.spacing(8), -})); - -export const DetailText = styled.span((props) => ({ - ...props.theme.typography.body_m, - color: props.theme.colors.white_200, - marginBottom: props.theme.spacing(4), -})); - -export const HighlightedText = styled.span((props) => ({ - ...props.theme.typography.body_medium_m, - color: props.theme.colors.white_0, -})); - -export const ButtonContainer = styled.div` - display: flex; - flex-direction: column; - margin-top: ${(props) => props.theme.spacing(6)}px; - gap: ${(props) => props.theme.spacing(4)}px; -`; - -export const FeeButton = styled.button<{ - isSelected: boolean; - centered?: boolean; -}>((props) => ({ - ...props.theme.body_medium_m, - textAlign: 'left', - color: props.theme.colors.white_0, - backgroundColor: `${props.isSelected ? props.theme.colors.elevation6_600 : 'transparent'}`, - border: `1px solid ${ - props.isSelected ? props.theme.colors.white_800 : props.theme.colors.white_850 - }`, - borderRadius: props.theme.radius(2), - height: 'auto', - display: 'flex', - justifyContent: 'space-between', - alignItems: props.centered ? 'center' : 'flex-start', - transition: 'background-color 0.1s ease-in-out, border 0.1s ease-in-out', - padding: props.theme.spacing(8), - paddingTop: props.theme.spacing(6), - paddingBottom: props.theme.spacing(6), - ':not(:disabled):hover': { - borderColor: props.theme.colors.white_800, - }, - ':disabled': { - cursor: 'not-allowed', - color: props.theme.colors.white_400, - div: { - color: 'inherit', - }, - svg: { - fill: props.theme.colors.white_600, - }, - }, -})); - -export const ControlsContainer = styled.div` - display: flex; - column-gap: 12px; - margin: 38px 16px 40px; -`; - -export const CustomFeeIcon = styled(Faders)({ - transform: 'rotate(90deg)', -}); - -export const FeeButtonLeft = styled.div((props) => ({ - display: 'flex', - alignItems: 'center', - gap: props.theme.spacing(6), -})); - -export const FeeButtonRight = styled.div({ - textAlign: 'right', -}); - -export const SecondaryText = styled.div<{ - alignRight?: boolean; -}>((props) => ({ - ...props.theme.typography.body_medium_s, - color: props.theme.colors.white_200, - marginTop: props.theme.spacing(2), - textAlign: props.alignRight ? 'right' : 'left', -})); - -export const StyledActionButton = styled(ActionButton)((props) => ({ - 'div, h1': { - ...props.theme.typography.body_medium_m, - }, -})); - -export const WarningText = styled.span((props) => ({ - ...props.theme.typography.body_medium_s, - display: 'block', - color: props.theme.colors.danger_light, - marginTop: props.theme.spacing(2), -})); - export const SuccessActionsContainer = styled.div((props) => ({ width: '100%', display: 'flex', flexDirection: 'column', - gap: props.theme.spacing(6), - paddingLeft: props.theme.spacing(8), - paddingRight: props.theme.spacing(8), - marginBottom: props.theme.spacing(20), - marginTop: props.theme.spacing(20), + gap: props.theme.space.s, + paddingLeft: props.theme.space.m, + paddingRight: props.theme.space.m, + marginBottom: props.theme.space.xxl, + marginTop: props.theme.space.xxl, })); diff --git a/src/app/screens/speedUpTransaction/index.tsx b/src/app/screens/speedUpTransaction/index.tsx index b00fdaf78..d1842d925 100644 --- a/src/app/screens/speedUpTransaction/index.tsx +++ b/src/app/screens/speedUpTransaction/index.tsx @@ -1,86 +1,61 @@ import ledgerConnectDefaultIcon from '@assets/img/ledger/ledger_connect_default.svg'; import ledgerConnectBtcIcon from '@assets/img/ledger/ledger_import_connect_btc.svg'; +import ledgerConnectStxIcon from '@assets/img/ledger/ledger_import_connect_stx.svg'; import { delay } from '@common/utils/ledger'; import BottomModal from '@components/bottomModal'; import ActionButton from '@components/button'; import LedgerConnectionView from '@components/ledger/connectLedgerView'; +import SpeedUpBtcTransaction from '@components/speedUpTransaction/btc'; +import SpeedUpStxTransaction from '@components/speedUpTransaction/stx'; import TopRow from '@components/topRow'; import useTransaction from '@hooks/queries/useTransaction'; import useBtcClient from '@hooks/useBtcClient'; +import useNetworkSelector from '@hooks/useNetwork'; +import useRbfTransactionData, { + getLatestNonce, + getRawTransaction, + isBtcTransaction, +} from '@hooks/useRbfTransactionData'; import useSeedVault from '@hooks/useSeedVault'; import useWalletSelector from '@hooks/useWalletSelector'; import Transport from '@ledgerhq/hw-transport-webusb'; import { CarProfile, Lightning, RocketLaunch, ShootingStar } from '@phosphor-icons/react'; import { - currencySymbolMap, - getBtcFiatEquivalent, - mempoolApi, - rbf, - RecommendedFeeResponse, + StacksTransaction, Transport as TransportType, + broadcastSignedTransaction, + signLedgerStxTransaction, + signTransaction, + stxToMicrostacks, } from '@secretkeylabs/xverse-core'; +import { deserializeTransaction } from '@stacks/transactions'; +import { EMPTY_LABEL } from '@utils/constants'; import { isLedgerAccount } from '@utils/helper'; import BigNumber from 'bignumber.js'; -import { useCallback, useEffect, useState } from 'react'; +import { useState } from 'react'; import toast from 'react-hot-toast'; import { useTranslation } from 'react-i18next'; -import { NumericFormat } from 'react-number-format'; -import { useNavigate, useParams } from 'react-router-dom'; +import { useLocation, useNavigate, useParams } from 'react-router-dom'; import { MoonLoader } from 'react-spinners'; import { useTheme } from 'styled-components'; import CustomFee from './customFee'; -import { - ButtonContainer, - Container, - ControlsContainer, - CustomFeeIcon, - DetailText, - FeeButton, - FeeButtonLeft, - FeeButtonRight, - HighlightedText, - LoaderContainer, - SecondaryText, - StyledActionButton, - SuccessActionsContainer, - Title, - WarningText, -} from './index.styled'; - -type TierFees = { - enoughFunds: boolean; - fee?: number; - feeRate: number; -}; - -type RbfRecommendedFees = { - medium?: TierFees; - high?: TierFees; - higher?: TierFees; - highest?: TierFees; -}; +import { LoaderContainer, SuccessActionsContainer } from './index.styled'; function SpeedUpTransactionScreen() { const { t } = useTranslation('translation', { keyPrefix: 'SPEED_UP_TRANSACTION' }); const theme = useTheme(); const navigate = useNavigate(); const [showCustomFee, setShowCustomFee] = useState(false); - const { selectedAccount, accountType, network, btcFiatRate, fiatCurrency } = useWalletSelector(); - const seedVault = useSeedVault(); + const { selectedAccount, stxAddress, network, stxAvailableBalance } = useWalletSelector(); const { id } = useParams(); + const location = useLocation(); const btcClient = useBtcClient(); - const [rbfTxSummary, setRbfTxSummary] = useState<{ - currentFee: number; - currentFeeRate: number; - minimumRbfFee: number; - minimumRbfFeeRate: number; - }>(); const [feeRateInput, setFeeRateInput] = useState(); const [selectedOption, setSelectedOption] = useState(); - const [recommendedFees, setRecommendedFees] = useState(); - const [rbfRecommendedFees, setRbfRecommendedFees] = useState(); - const { data: transaction } = useTransaction(id!); - const [rbfTransaction, setRbfTransaction] = useState(); + const { transaction: stxTransaction } = location.state || {}; + const { data: btcTransaction } = useTransaction(stxTransaction ? undefined : id); + const { isLoading, rbfTransaction, rbfRecommendedFees, rbfTxSummary, mempoolFees } = + useRbfTransactionData(stxTransaction || btcTransaction); const { t: signatureRequestTranslate } = useTranslation('translation', { keyPrefix: 'SIGNATURE_REQUEST', }); @@ -90,49 +65,13 @@ function SpeedUpTransactionScreen() { const [isConnectSuccess, setIsConnectSuccess] = useState(false); const [isConnectFailed, setIsConnectFailed] = useState(false); const [isTxRejected, setIsTxRejected] = useState(false); - const [isLoading, setIsLoading] = useState(true); const [customFeeRate, setCustomFeeRate] = useState(); const [customTotalFee, setCustomTotalFee] = useState(); const [customFeeError, setCustomFeeError] = useState(); - - const fetchRbfData = useCallback(async () => { - if (!selectedAccount || !id || !transaction) { - return; - } - - try { - setIsLoading(true); - const rbfTx = new rbf.RbfTransaction(transaction, { - ...selectedAccount, - accountType: accountType || 'software', - accountId: - isLedgerAccount(selectedAccount) && selectedAccount.deviceAccountIndex - ? selectedAccount.deviceAccountIndex - : selectedAccount.id, - network: network.type, - esploraProvider: btcClient, - seedVault, - }); - setRbfTransaction(rbfTx); - - const rbfTransactionSummary = await rbf.getRbfTransactionSummary(btcClient, transaction.txid); - setRbfTxSummary(rbfTransactionSummary); - - const mempoolFees = await mempoolApi.getRecommendedFees(network.type); - setRecommendedFees(mempoolFees); - - const rbfRecommendedFeesResponse = await rbfTx.getRbfRecommendedFees(mempoolFees); - setRbfRecommendedFees(rbfRecommendedFeesResponse); - } catch (err: any) { - console.error(err); - } finally { - setIsLoading(false); - } - }, [selectedAccount, id, transaction, accountType, network.type, seedVault]); - - useEffect(() => { - fetchRbfData(); - }, [fetchRbfData]); + const { getSeed } = useSeedVault(); + const selectedStacksNetwork = useNetworkSelector(); + const isBtc = isBtcTransaction(stxTransaction || btcTransaction); + const [isBroadcasting, setIsBroadcasting] = useState(false); const handleClickFeeButton = (e: React.MouseEvent) => { if (e.currentTarget.value === 'custom') { @@ -156,9 +95,32 @@ function SpeedUpTransactionScreen() { navigate('/'); }; + const calculateStxTotalFee = async (feeRate: string) => { + if (rbfTxSummary && Number(feeRate) < rbfTxSummary.minimumRbfFeeRate) { + setCustomFeeError(t('FEE_TOO_LOW', { minimumFee: rbfTxSummary.minimumRbfFeeRate })); + return; + } + + if (stxToMicrostacks(BigNumber(feeRate)).gt(BigNumber(stxAvailableBalance))) { + setCustomFeeError(t('INSUFFICIENT_FUNDS')); + } else { + setCustomFeeError(undefined); + } + + return Number(feeRate); + }; + const calculateTotalFee = async (feeRate: string) => { - if (rbfTxSummary && Number(feeRate) < rbfTxSummary?.minimumRbfFeeRate) { - setCustomFeeError(t('FEE_TOO_LOW', { minimumFee: rbfTxSummary?.minimumRbfFeeRate })); + if (!isBtc) { + return calculateStxTotalFee(feeRate); + } + + if (!rbfTransaction) { + return; + } + + if (rbfTxSummary && Number(feeRate) < rbfTxSummary.minimumRbfFeeRate) { + setCustomFeeError(t('FEE_TOO_LOW', { minimumFee: rbfTxSummary.minimumRbfFeeRate })); return; } @@ -177,12 +139,77 @@ function SpeedUpTransactionScreen() { return feeSummary.fee; }; + const signAndBroadcastStxTx = async (transport?: TransportType) => { + if (!feeRateInput || !selectedAccount) { + return; + } + + try { + setIsBroadcasting(true); + const fee = stxToMicrostacks(BigNumber(feeRateInput)).toString(); + const txRaw: string = await getRawTransaction(stxTransaction.txid, network); + const unsignedTx: StacksTransaction = deserializeTransaction(txRaw); + + // check if the transaction exists in microblock + const latestNonceData = await getLatestNonce(stxAddress, network); + if (stxTransaction.nonce > latestNonceData.last_executed_tx_nonce) { + unsignedTx.setFee(BigInt(fee)); + unsignedTx.setNonce(BigInt(stxTransaction.nonce)); + + const seedPhrase = await getSeed(); + + if (isLedgerAccount(selectedAccount)) { + if (!transport || selectedAccount.deviceAccountIndex === undefined) { + return; + } + + const result = await signLedgerStxTransaction({ + transport, + transactionBuffer: Buffer.from(unsignedTx.serialize()), + addressIndex: selectedAccount.deviceAccountIndex, + }); + await delay(1500); + await broadcastSignedTransaction(result, selectedStacksNetwork); + } else { + const signedTx: StacksTransaction = await signTransaction( + unsignedTx, + seedPhrase, + selectedAccount.id, + selectedStacksNetwork, + ); + await broadcastSignedTransaction(signedTx, selectedStacksNetwork); + } + + toast.success(t('TX_FEE_UPDATED')); + handleGoBack(); + return; + } + + toast.error('This transaction has already been confirmed in a block.'); + return; + } catch (err: any) { + console.error(err); + toast.error('Failed to broadcast transaction.'); + } finally { + setIsBroadcasting(false); + } + }; + const signAndBroadcastTx = async (transport?: TransportType) => { + if (!isBtc) { + return signAndBroadcastStxTx(transport); + } + + if (!rbfTransaction) { + return; + } + if (isLedgerAccount(selectedAccount) && !transport) { return; } try { + setIsBroadcasting(true); const signedTx = await rbfTransaction.getReplacementTransaction({ feeRate: Number(feeRateInput), ledgerTransport: transport, @@ -195,14 +222,18 @@ function SpeedUpTransactionScreen() { } catch (err: any) { console.error(err); - if (err?.response?.data && err?.response?.data.includes('insufficient fee')) { - toast.error(t('INSUFFICIENT_FEE')); + if (err?.response?.data) { + if (err.response.data.includes('insufficient fee')) { + toast.error(t('INSUFFICIENT_FEE')); + } } + } finally { + setIsBroadcasting(false); } }; const handleClickSubmit = async () => { - if (!selectedAccount || !id) { + if (!selectedAccount || (!btcTransaction && !stxTransaction)) { return; } @@ -254,8 +285,8 @@ function SpeedUpTransactionScreen() { }; const handleApplyCustomFee = (feeRate: string, fee: string) => { - if (rbfTxSummary && Number(feeRate) < rbfTxSummary?.minimumRbfFeeRate) { - setCustomFeeError(t('FEE_TOO_LOW', { minimumFee: rbfTxSummary?.minimumRbfFeeRate })); + if (rbfTxSummary && Number(feeRate) < rbfTxSummary.minimumRbfFeeRate) { + setCustomFeeError(t('FEE_TOO_LOW', { minimumFee: rbfTxSummary.minimumRbfFeeRate })); return; } @@ -280,65 +311,49 @@ function SpeedUpTransactionScreen() { }; const getEstimatedCompletionTime = (feeRate?: number) => { - if (!feeRate || !recommendedFees) { - return '--'; + if (!feeRate || !mempoolFees) { + return EMPTY_LABEL; } - if (feeRate < recommendedFees?.hourFee) { - return 'several hours or more'; + if (feeRate < mempoolFees.hourFee) { + return t('TIME.SEVERAL_HOURS_OR_MORE'); } - if (feeRate === recommendedFees?.hourFee) { - return '~1 hour'; + if (feeRate === mempoolFees.hourFee) { + return `~1 ${t('TIME.HOUR')}`; } - if (feeRate > recommendedFees?.hourFee && feeRate <= recommendedFees?.halfHourFee) { - return '~30 mins'; + if (feeRate > mempoolFees.hourFee && feeRate <= mempoolFees.halfHourFee) { + return `~30 ${t('TIME.MINUTES')}`; } - return '~10 mins'; + return `~10 ${t('TIME.MINUTES')}`; + }; + + const iconProps = { + size: 20, + color: theme.colors.tangerine, }; const feeButtonMapping = { medium: { - icon: , + icon: , title: t('MED_PRIORITY'), }, high: { - icon: , + icon: , title: t('HIGH_PRIORITY'), }, higher: { - icon: , + icon: , title: t('HIGHER_PRIORITY'), }, highest: { - icon: , + icon: , title: t('HIGHEST_PRIORITY'), }, }; - const getFiatAmountString = (fiatAmount: BigNumber) => { - if (!fiatAmount) { - return ''; - } - - if (fiatAmount.isLessThan(0.01)) { - return `< ${currencySymbolMap[fiatCurrency]}0.01 ${fiatCurrency}`; - } - - return ( - `~ ${value}`} - /> - ); - }; - return ( <> @@ -348,160 +363,49 @@ function SpeedUpTransactionScreen() { ) : ( <> - - {t('TITLE')} - {t('FEE_INFO')} - - {t('CURRENT_FEE')}{' '} - - - - - - - {t('ESTIMATED_COMPLETION_TIME')}{' '} - - {getEstimatedCompletionTime(rbfTxSummary?.currentFeeRate)} - - - - {rbfRecommendedFees && - Object.entries(rbfRecommendedFees) - .sort((a, b) => { - const priorityOrder = ['highest', 'higher', 'high', 'medium']; - return priorityOrder.indexOf(a[0]) - priorityOrder.indexOf(b[0]); - }) - .map(([key, obj]) => ( - - - {feeButtonMapping[key].icon} -
- {feeButtonMapping[key].title} - {getEstimatedCompletionTime(obj.feeRate)} - - - -
-
- -
- {obj.fee ? ( - - ) : ( - '--' - )} -
- {obj.fee ? ( - - {getFiatAmountString( - getBtcFiatEquivalent(BigNumber(obj.fee), BigNumber(btcFiatRate)), - )} - - ) : ( - -- {fiatCurrency} - )} - {!obj.enoughFunds && {t('INSUFFICIENT_FUNDS')}} -
-
- ))} - - - -
- {t('CUSTOM')} - {customFeeRate && ( - <> - - {getEstimatedCompletionTime(Number(customFeeRate))} - - - - - - )} -
-
- {customFeeRate && customTotalFee ? ( -
-
- -
- - {getFiatAmountString( - getBtcFiatEquivalent(BigNumber(customTotalFee), BigNumber(btcFiatRate)), - )} - -
- ) : ( -
{t('MANUAL_SETTING')}
- )} -
-
-
- - - + ) : ( + - + )} - {showCustomFee && ( + {rbfTxSummary && showCustomFee && ( )} @@ -509,10 +413,12 @@ function SpeedUpTransactionScreen() { {currentStepIndex === 0 && ( diff --git a/src/app/screens/swap/swapInfoBlock/index.tsx b/src/app/screens/swap/swapInfoBlock/index.tsx index d005bb233..00c0e6091 100644 --- a/src/app/screens/swap/swapInfoBlock/index.tsx +++ b/src/app/screens/swap/swapInfoBlock/index.tsx @@ -3,7 +3,11 @@ import SlippageEditIcon from '@assets/img/swap/slippageEdit.svg'; import BottomModal from '@components/bottomModal'; import { SlippageModalContent } from '@screens/swap/slippageModal'; import { UseSwap } from '@screens/swap/types'; -import { SUPPORT_URL_TAB_TARGET, SWAP_SPONSOR_DISABLED_SUPPORT_URL } from '@utils/constants'; +import { + EMPTY_LABEL, + SUPPORT_URL_TAB_TARGET, + SWAP_SPONSOR_DISABLED_SUPPORT_URL, +} from '@utils/constants'; import { useState } from 'react'; import { useTranslation } from 'react-i18next'; import Switch from 'react-switch'; @@ -101,11 +105,11 @@ export function SwapInfoBlock({ swap }: { swap: UseSwap }) { -
{swap.swapInfo?.exchangeRate ?? '--'}
+
{swap.swapInfo?.exchangeRate ?? EMPTY_LABEL}
{expandDetail && ( <>
{t('MIN_RECEIVE')}
-
{swap.minReceived ?? '--'}
+
{swap.minReceived ?? EMPTY_LABEL}
{t('SLIPPAGE')}
setShowSlippageModal(true)}> @@ -114,9 +118,9 @@ export function SwapInfoBlock({ swap }: { swap: UseSwap }) {
{t('LP_FEE')}
-
{swap.swapInfo?.lpFee ?? '--'}
+
{swap.swapInfo?.lpFee ?? EMPTY_LABEL}
{t('ROUTE')}
-
{swap.swapInfo?.route ?? '--'}
+
{swap.swapInfo?.route ?? EMPTY_LABEL}
{swap.isServiceRunning && ( <> <> diff --git a/src/app/screens/swap/swapTokenBlock/index.tsx b/src/app/screens/swap/swapTokenBlock/index.tsx index 6978c692c..019d7847d 100644 --- a/src/app/screens/swap/swapTokenBlock/index.tsx +++ b/src/app/screens/swap/swapTokenBlock/index.tsx @@ -3,6 +3,7 @@ import TokenImage from '@components/tokenImage'; import useWalletSelector from '@hooks/useWalletSelector'; import { SwapToken } from '@screens/swap/types'; import { currencySymbolMap } from '@secretkeylabs/xverse-core'; +import { EMPTY_LABEL } from '@utils/constants'; import { useTranslation } from 'react-i18next'; import { NumericFormat } from 'react-number-format'; import styled from 'styled-components'; @@ -125,7 +126,7 @@ function SwapTokenBlock({ {title} {t('BALANCE')}: - {selectedCoin?.balance ?? '--'} + {selectedCoin?.balance ?? EMPTY_LABEL} @@ -146,7 +147,7 @@ function SwapTokenBlock({ n.type !== action.network.type), + action.network, + ], }; case GetActiveAccountsKey: return { diff --git a/src/app/ui-library/avatar.tsx b/src/app/ui-library/avatar.tsx new file mode 100644 index 000000000..e1350f457 --- /dev/null +++ b/src/app/ui-library/avatar.tsx @@ -0,0 +1,33 @@ +import styled from 'styled-components'; + +type Props = { + icon?: React.ReactNode; + src?: React.ReactNode; + size?: number; +}; + +const ImageContainer = styled.div<{ size: number }>((props) => ({ + height: props.size, + width: props.size, + borderRadius: '50%', + backgroundColor: props.color, + overflow: 'hidden', +})); + +const IconContainer = styled.div((props) => ({ + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + height: '100%', + width: '100%', + backgroundColor: props.theme.colors.white_0, +})); + +export default function Avatar({ icon, src, size = 32 }: Props) { + return ( + + {icon && {icon}} + {src && src} + + ); +} diff --git a/src/app/ui-library/callout.tsx b/src/app/ui-library/callout.tsx index 4428021e4..2d7399e61 100644 --- a/src/app/ui-library/callout.tsx +++ b/src/app/ui-library/callout.tsx @@ -56,6 +56,14 @@ const RedirectButton = styled.button` text-transform: capitalize; `; +export const AnchorLink = styled.a((props) => ({ + display: 'inline-flex', + alignItems: 'center', + gap: props.theme.space.xxxs, + textTransform: 'capitalize', + color: props.theme.colors.white_0, +})); + /** * ref: https://zeroheight.com/0683c9fa7/p/051ca8-callout/t/7814dc */ @@ -66,6 +74,7 @@ export type CalloutProps = { variant?: CalloutVariant; redirectText?: string; onClickRedirect?: () => void; + anchorRedirect?: string; }; export function Callout({ className, @@ -73,7 +82,8 @@ export function Callout({ bodyText, variant = 'info', redirectText, - onClickRedirect = () => {}, + onClickRedirect, + anchorRedirect, }: CalloutProps) { const StyledIcon = icons[variant]; return ( @@ -89,12 +99,22 @@ export function Callout({ {bodyText} {redirectText && ( - - - {redirectText} - - - + <> + {onClickRedirect && ( + + + {redirectText} + + + + )} + {anchorRedirect && ( + + {redirectText} + + + )} + )} diff --git a/src/app/ui-library/divider.tsx b/src/app/ui-library/divider.tsx new file mode 100644 index 000000000..fd852ffd7 --- /dev/null +++ b/src/app/ui-library/divider.tsx @@ -0,0 +1,11 @@ +import styled from 'styled-components'; +import Theme from 'theme'; + +const Divider = styled.div<{ verticalMargin: keyof typeof Theme.space }>((props) => ({ + display: 'flex', + width: '100%', + height: 1, + backgroundColor: props.theme.colors.white_900, + margin: `${props.theme.space[props.verticalMargin]} 0`, +})); +export default Divider; diff --git a/src/app/utils/constants.ts b/src/app/utils/constants.ts index 0dceb5afe..cbc11f1e7 100644 --- a/src/app/utils/constants.ts +++ b/src/app/utils/constants.ts @@ -1,11 +1,5 @@ /* eslint-disable prefer-destructuring */ -import type { NetworkType, SettingsNetwork } from '@secretkeylabs/xverse-core'; -import { - BTC_BASE_URI_MAINNET, - BTC_BASE_URI_TESTNET, - HIRO_MAINNET_DEFAULT, - HIRO_TESTNET_DEFAULT, -} from '@secretkeylabs/xverse-core'; +import type { NetworkType } from '@secretkeylabs/xverse-core'; export const GAMMA_URL = 'https://gamma.io/'; export const TERMS_LINK = 'https://xverse.app/terms'; @@ -47,19 +41,6 @@ export const BITCOIN_DUST_AMOUNT_SATS = 1500; export const PAGINATION_LIMIT = 50; export const REFETCH_UNSPENT_UTXO_TIME = 2 * 60 * 60 * 1000; -export const initialNetworksList: SettingsNetwork[] = [ - { - type: 'Mainnet', - address: HIRO_MAINNET_DEFAULT, - btcApiUrl: BTC_BASE_URI_MAINNET, - }, - { - type: 'Testnet', - address: HIRO_TESTNET_DEFAULT, - btcApiUrl: BTC_BASE_URI_TESTNET, - }, -]; - /** * contract id of send_many transaction type */ @@ -80,3 +61,6 @@ export const DEFAULT_TRANSITION_OPTIONS = { opacity: 1, }, }; + +// UI +export const EMPTY_LABEL = '--'; diff --git a/src/app/utils/helper.ts b/src/app/utils/helper.ts index 6c6aec6fe..4e85c5f19 100644 --- a/src/app/utils/helper.ts +++ b/src/app/utils/helper.ts @@ -194,3 +194,12 @@ export const isInOptions = (): boolean => !!window.location?.pathname?.match(/op export function formatNumber(value?: string | number) { return value ? new Intl.NumberFormat().format(Number(value)) : '-'; } + +export const handleKeyDownFeeRateInput = (e: React.KeyboardEvent) => { + // only allow positive integers + // disable common special characters, including - and . + // eslint-disable-next-line no-useless-escape + if (e.key.match(/^[!-\/:-@[-`{-~]$/)) { + e.preventDefault(); + } +}; diff --git a/src/assets/img/rareSats/ic_ordinal_small_over_card.svg b/src/assets/img/rareSats/ic_ordinal_small_over_card.svg new file mode 100644 index 000000000..5ffa566eb --- /dev/null +++ b/src/assets/img/rareSats/ic_ordinal_small_over_card.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/src/assets/img/rareSats/link.svg b/src/assets/img/rareSats/link.svg new file mode 100644 index 000000000..354bf93cb --- /dev/null +++ b/src/assets/img/rareSats/link.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/src/assets/img/settings/Timer.svg b/src/assets/img/settings/Timer.svg deleted file mode 100644 index 504fd5ff2..000000000 --- a/src/assets/img/settings/Timer.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/src/assets/img/settings/Timer15m.svg b/src/assets/img/settings/Timer15m.svg new file mode 100644 index 000000000..b7bbd5c34 --- /dev/null +++ b/src/assets/img/settings/Timer15m.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/src/assets/img/settings/Timer1h.svg b/src/assets/img/settings/Timer1h.svg new file mode 100644 index 000000000..dfdc84e87 --- /dev/null +++ b/src/assets/img/settings/Timer1h.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/src/assets/img/settings/Timer30m.svg b/src/assets/img/settings/Timer30m.svg new file mode 100644 index 000000000..333786f7d --- /dev/null +++ b/src/assets/img/settings/Timer30m.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/src/assets/img/settings/Timer3h.svg b/src/assets/img/settings/Timer3h.svg new file mode 100644 index 000000000..687b5e489 --- /dev/null +++ b/src/assets/img/settings/Timer3h.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/src/assets/img/settings/TimerFull.svg b/src/assets/img/settings/TimerFull.svg deleted file mode 100644 index 5a8b51d50..000000000 --- a/src/assets/img/settings/TimerFull.svg +++ /dev/null @@ -1,4 +0,0 @@ - - - - diff --git a/src/assets/img/settings/TimerHalf.svg b/src/assets/img/settings/TimerHalf.svg deleted file mode 100644 index 914f48708..000000000 --- a/src/assets/img/settings/TimerHalf.svg +++ /dev/null @@ -1,4 +0,0 @@ - - - - diff --git a/src/assets/img/transactions/increaseFee.svg b/src/assets/img/transactions/increaseFee.svg deleted file mode 100644 index 6437b801f..000000000 --- a/src/assets/img/transactions/increaseFee.svg +++ /dev/null @@ -1,8 +0,0 @@ - - - - - - - - diff --git a/src/common/utils/ledger.ts b/src/common/utils/ledger.ts index 3095b9ce3..2eb0de284 100644 --- a/src/common/utils/ledger.ts +++ b/src/common/utils/ledger.ts @@ -1,10 +1,17 @@ -import { Account } from '@secretkeylabs/xverse-core'; +import { Account, NetworkType } from '@secretkeylabs/xverse-core'; export const delay = (ms: number) => new Promise((res) => { setTimeout(res, ms); }); +export const filterLedgerAccounts = (accounts: Account[], network: NetworkType) => + accounts.filter((account) => + account.ordinalsAddress?.startsWith(network === 'Mainnet' ? 'bc1' : 'tb1'), + ); + +// this is used for migrating the old ledger accounts to the new format +// it returns the index of the account in the list, which now maps to the deviceAccountIndex export const getDeviceAccountIndex = ( ledgerAccountsList: Account[], id: number, @@ -31,10 +38,17 @@ export const getNewAccountId = (ledgerAccountsList: Account[]) => { return ledgerAccountsList[ledgerAccountsList.length - 1].id + 1; }; -export const getDeviceNewAccountIndex = (ledgerAccountsList: Account[], masterKey?: string) => { - const ledgerAccountsIndexList = ledgerAccountsList +export const getDeviceNewAccountIndex = ( + ledgerAccountsList: Account[], + network: NetworkType, + masterKey?: string, +) => { + const networkLedgerAccounts = filterLedgerAccounts(ledgerAccountsList, network); + + const ledgerAccountsIndexList = networkLedgerAccounts .filter((account) => masterKey === account.masterPubKey) .map((account, key) => + // ledger accounts initially didn't have deviceAccountIndex, so we map to their list index as as the initial behaviour account.deviceAccountIndex !== undefined ? account.deviceAccountIndex : key, ) .sort((a, b) => a - b); diff --git a/src/locales/en.json b/src/locales/en.json index 18ec77fac..e5bfdf656 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -5,7 +5,9 @@ "COMBO": "Combo", "SATS": "Sats", "SATTRIBUTES": "Sattributes", - "INPUT": "Input" + "INPUT": "Input", + "SIZE": "Size", + "BUNDLE": "Bundle" }, "LANDING_SCREEN": { "SCREEN_TITLE": "Wallet for Stacks & Bitcoin", @@ -376,7 +378,13 @@ "CANCEL_BUTTON": "Cancel", "SEE_TRANSACTION_BUTTON": "See transaction", "RETRY_BUTTON": "Try again" - } + }, + "YOUR_ORDINAL_ADDRESS": "Your ordinals address", + "YOUR_PAYMENT_ADDRESS": "Your payment address", + "INSCRIBED_SATS": "Some of these sats are inscribed", + "INSCRIBED_RARE_SATS": "Some of these sats are rare or inscribed", + "UNCONFIRMED_UTXO_WARNING": "You are spending unconfirmed outputs in this transaction. This may lower the effective fee rate causing delays in transaction confirmation", + "INSCRIBED_RARE_SATS_WARNING": "Your payment wallet holds rare or inscribed sats. To avoid spending them in your transactions and fees, transfer them to your ordinals wallet" }, "TX_ERRORS": { "INSUFFICIENT_BALANCE": "The requested transaction cannot be created due to insufficient balance", @@ -404,7 +412,8 @@ "SAME_FEE_ERROR": "New fee must be greater than current fee", "GREATER_FEE_ERROR": "Fee is more than available balance", "LOWER_THAN_MINIMUM": "Set fee is below minimum", - "NONCE_WARNING": "Entering an erroneous nonce can result in a failed transaction. Only apply changes if you know what you are doing." + "NONCE_WARNING": "Entering an erroneous nonce can result in a failed transaction. Only apply changes if you know what you are doing.", + "MANUAL_SETTING": "Manual setting" }, "TRANSACTION_STATUS": { "BROADCASTED": "Transaction Broadcasted", @@ -702,6 +711,11 @@ "DESCRIPTION": "Help improve the app experience, by allowing Xverse to collect anonymized usage data. This data cannot be used to identify your wallet individually.", "AUTHORIZE_DATA_COLLECTION": "Authorize data collection" }, + "BTC_URL": "BTC URL", + "STACKS_URL": "Stacks URL", + "FALLBACK_BTC_URL": "Fallback BTC URL", + "RESET_TO_DEFAULT": "Reset to default", + "REQUIRED": "Required", "NETWORK": "Network", "SECURITY": "Security", "UPDATE_PASSWORD": "Update Password", @@ -732,6 +746,9 @@ "RECOVER_ASSETS": "Recover assets", "LOCK_COUNTDOWN": "Auto-lock Timer", "LOCK_COUNTDOWN_TITLE": "Select the time duration before the wallet locks automatically.", + "LOCK_COUNTDOWN_MIN": "{{count}} minutes", + "LOCK_COUNTDOWN_HS_one": "{{count}} hour", + "LOCK_COUNTDOWN_HS_other": "{{count}} hours", "ENTER_YOUR_NEW_PASSWORD": "Enter your new password", "CONFIRM_YOUR_NEW_PASSWORD": "Confirm your new password", "TEXT_INPUT_NEW_PASSWORD_LABEL": "New Password", @@ -788,7 +805,6 @@ "FT_CONTRACT_PREFIX": "Token contract", "OPEN_FT_CONTRACT_DEPLOYMENT": "View the contract on", "STACKS_EXPLORER": "Stacks Explorer", - "INCREASE_FEE_BUTTON": "Increase fee", "BALANCE": "Balance", "TRANSACTIONS": "TRANSACTIONS", "CONTRACT": "CONTRACT", @@ -807,12 +823,19 @@ "HIGHER_PRIORITY": "Higher priority", "HIGHEST_PRIORITY": "Highest priority", "MED_PRIORITY": "Medium priority", + "LOW_PRIORITY": "Low priority", "CUSTOM": "Custom", "INSUFFICIENT_FUNDS": "Insufficient funds", "INSUFFICIENT_FEE": "Insufficient fee", "MANUAL_SETTING": "Manual setting", "TX_FEE_UPDATED": "Transaction fee updated", - "FEE_TOO_LOW": "The minimum fee is {{minimumFee}}" + "FEE_TOO_LOW": "The minimum fee is {{minimumFee}}", + "SOMETHING_WENT_WRONG": "Something went wrong", + "TIME": { + "SEVERAL_HOURS_OR_MORE": "several hours or more", + "HOUR": "hour", + "MINUTES": "mins" + } }, "POST_CONDITION_MESSAGE": { "YOU": "You", diff --git a/src/pages/Popup/index.css b/src/pages/Popup/index.css index f9908ff52..f74e9369a 100644 --- a/src/pages/Popup/index.css +++ b/src/pages/Popup/index.css @@ -6,7 +6,7 @@ body { background-color: #181818; } -#app-container { +#app { height: 600px; width: 360px; display: flex; @@ -19,11 +19,6 @@ body { } } -#app { - min-height: 600px; - min-width: 360px; -} - ::-webkit-scrollbar { display: none; } diff --git a/src/pages/Popup/index.html b/src/pages/Popup/index.html index 7b41ffc81..1aba6f4c3 100644 --- a/src/pages/Popup/index.html +++ b/src/pages/Popup/index.html @@ -5,8 +5,6 @@ Xverse Wallet -
-
-
+
diff --git a/tsconfig.json b/tsconfig.json index baba9fda5..e3bbeac91 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -16,7 +16,6 @@ "isolatedModules": true, "noEmit": false, "jsx": "react-jsx", - "rootDir": "src", "outDir": "build/js", "baseUrl": "./src", "paths": { diff --git a/webpack/webpack.config.js b/webpack/webpack.config.js index 6ea404cd4..b6b7210f4 100644 --- a/webpack/webpack.config.js +++ b/webpack/webpack.config.js @@ -9,6 +9,8 @@ const ReactRefreshTypeScript = require('react-refresh-typescript'); const TsconfigPathsPlugin = require('tsconfig-paths-webpack-plugin'); const Dotenv = require('dotenv-webpack'); const ForkTsCheckerWebpackPlugin = require('fork-ts-checker-webpack-plugin'); +const createStyledComponentsTransformer = require('typescript-plugin-styled-components').default; +const styledComponentsTransformer = createStyledComponentsTransformer(); const aliases = { // alias stacks.js packages to their esm (default prefers /dist/polyfill) @@ -68,9 +70,10 @@ var options = { loader: 'ts-loader', options: { getCustomTransformers: () => ({ - before: [env.NODE_ENV === 'development' && ReactRefreshTypeScript()].filter( - Boolean - ), + before: + env.NODE_ENV === 'development' + ? [ReactRefreshTypeScript(), styledComponentsTransformer] + : [], }), transpileOnly: false, }, @@ -91,9 +94,11 @@ var options = { ], }, resolve: { - plugins: [new TsconfigPathsPlugin({ - configFile: path.join(__dirname, '../', 'tsconfig.json') - })], + plugins: [ + new TsconfigPathsPlugin({ + configFile: path.join(__dirname, '../', 'tsconfig.json'), + }), + ], extensions: fileExtensions .map((extension) => '.' + extension) .concat(['.js', '.jsx', '.ts', '.tsx', '.css']), @@ -124,7 +129,7 @@ var options = { description: process.env.npm_package_description, version: process.env.npm_package_version, ...JSON.parse(content.toString()), - }) + }), ); }, }, @@ -139,9 +144,11 @@ var options = { ], }), new CopyWebpackPlugin({ - patterns: [{ - from: 'node_modules/webextension-polyfill/dist/browser-polyfill.js', - }], + patterns: [ + { + from: 'node_modules/webextension-polyfill/dist/browser-polyfill.js', + }, + ], }), new HtmlWebpackPlugin({ template: path.join(SRC_ROOT_PATH, 'pages', 'Options', 'index.html'), @@ -160,7 +167,7 @@ var options = { Buffer: ['buffer', 'Buffer'], }), new webpack.DefinePlugin({ - VERSION: JSON.stringify(require("../package.json").version), + VERSION: JSON.stringify(require('../package.json').version), }), ],