diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b6f95fb3..6df6e6a6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -156,6 +156,15 @@ jobs: node-version: 20 cache: npm + - name: Install Linux native USB build dependency + if: runner.os == 'Linux' + # node-hid builds against libudev (hidraw) and libusb; install both. + # `apt-get update` first — the image's pre-seeded index can name a + # build the mirrors already rotated out (install 404s). + run: | + sudo apt-get update + sudo apt-get install -y libudev-dev libusb-1.0-0-dev + - name: Install dependencies run: npm ci @@ -222,6 +231,15 @@ jobs: node-version: 20 cache: npm + - name: Install Linux native USB build dependency + if: runner.os == 'Linux' + # node-hid builds against libudev (hidraw) and libusb; install both. + # `apt-get update` first — the image's pre-seeded index can name a + # build the mirrors already rotated out (install 404s). + run: | + sudo apt-get update + sudo apt-get install -y libudev-dev libusb-1.0-0-dev + - name: Install dependencies run: npm ci @@ -272,6 +290,15 @@ jobs: node-version: 20 cache: npm + - name: Install Linux native USB build dependency + if: runner.os == 'Linux' + # node-hid builds against libudev (hidraw) and libusb; install both. + # `apt-get update` first — the image's pre-seeded index can name a + # build the mirrors already rotated out (install 404s). + run: | + sudo apt-get update + sudo apt-get install -y libudev-dev libusb-1.0-0-dev + - name: Install dependencies run: npm ci @@ -320,6 +347,15 @@ jobs: node-version: 20 cache: npm + - name: Install Linux native USB build dependency + if: runner.os == 'Linux' + # node-hid builds against libudev (hidraw) and libusb; install both. + # `apt-get update` first — the image's pre-seeded index can name a + # build the mirrors already rotated out (install 404s). + run: | + sudo apt-get update + sudo apt-get install -y libudev-dev libusb-1.0-0-dev + - name: Install dependencies run: npm ci diff --git a/package-lock.json b/package-lock.json index 2b61fd7c..ca580b2f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,6 +15,8 @@ "@ensdomains/content-hash": "^3.0.0", "@ethersphere/bee-js": "^12.2.1", "@ghostery/adblocker": "^2.18.1", + "@ledgerhq/hw-app-eth": "^7.8.8", + "@ledgerhq/hw-transport-node-hid": "^6.33.5", "@metamask/browser-passworder": "^6.0.0", "@scure/bip39": "^2.2.0", "@x402/core": "^2.12.0", @@ -1523,9 +1525,9 @@ "license": "MIT" }, "node_modules/@corpus-core/colibri-stateless": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/@corpus-core/colibri-stateless/-/colibri-stateless-2.0.1.tgz", - "integrity": "sha512-gXh5TI2BsjZ/6CpZfs2YEzOPisr8flbtpSXgxzX0Yw8YmBdjFBS+4RKMg795eskjhvZiHdLDIRp+vRgidxHkqQ==", + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/@corpus-core/colibri-stateless/-/colibri-stateless-2.0.4.tgz", + "integrity": "sha512-zezKWKQ3JVLtUcIleZ2Axf0UzEfAaaHDkseDIHuHOuIp6bpdryy/mLkO77ikryLphW5XYM9ODkUhdxOBPeRj7w==", "license": "MIT" }, "node_modules/@electron-internal/extract-zip": { @@ -1793,45 +1795,6 @@ "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/@electron/windows-sign": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/@electron/windows-sign/-/windows-sign-1.2.2.tgz", - "integrity": "sha512-dfZeox66AvdPtb2lD8OsIIQh12Tp0GNCRUDfBHIKGpbmopZto2/A8nSpYYLoedPIHpqkeblZ/k8OV0Gy7PYuyQ==", - "dev": true, - "license": "BSD-2-Clause", - "optional": true, - "peer": true, - "dependencies": { - "cross-dirname": "^0.1.0", - "debug": "^4.3.4", - "fs-extra": "^11.1.1", - "minimist": "^1.2.8", - "postject": "^1.0.0-alpha.6" - }, - "bin": { - "electron-windows-sign": "bin/electron-windows-sign.js" - }, - "engines": { - "node": ">=14.14" - } - }, - "node_modules/@electron/windows-sign/node_modules/fs-extra": { - "version": "11.3.6", - "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.3.6.tgz", - "integrity": "sha512-w8ZNZr2mKIc7qeNaQ9AVPT1+iFaI+Avd4xudVOvdDJ8VytREi1Ft5Ih7hd9jjehod8vAM5GMsfQ/TpPf4EyoEA==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "graceful-fs": "^4.2.0", - "jsonfile": "^6.0.1", - "universalify": "^2.0.0" - }, - "engines": { - "node": ">=14.14" - } - }, "node_modules/@emnapi/core": { "version": "1.10.0", "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", @@ -2076,34 +2039,432 @@ "beeApiVersion": "7.2.0" } }, + "node_modules/@ethersproject/abi": { + "version": "5.8.0", + "resolved": "https://registry.npmjs.org/@ethersproject/abi/-/abi-5.8.0.tgz", + "integrity": "sha512-b9YS/43ObplgyV6SlyQsG53/vkSal0MNA1fskSC4mbnCMi8R+NkcH8K9FPYNESf6jUefBUniE4SOKms0E/KK1Q==", + "funding": [ + { + "type": "individual", + "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" + }, + { + "type": "individual", + "url": "https://www.buymeacoffee.com/ricmoo" + } + ], + "license": "MIT", + "dependencies": { + "@ethersproject/address": "^5.8.0", + "@ethersproject/bignumber": "^5.8.0", + "@ethersproject/bytes": "^5.8.0", + "@ethersproject/constants": "^5.8.0", + "@ethersproject/hash": "^5.8.0", + "@ethersproject/keccak256": "^5.8.0", + "@ethersproject/logger": "^5.8.0", + "@ethersproject/properties": "^5.8.0", + "@ethersproject/strings": "^5.8.0" + } + }, + "node_modules/@ethersproject/abstract-provider": { + "version": "5.8.0", + "resolved": "https://registry.npmjs.org/@ethersproject/abstract-provider/-/abstract-provider-5.8.0.tgz", + "integrity": "sha512-wC9SFcmh4UK0oKuLJQItoQdzS/qZ51EJegK6EmAWlh+OptpQ/npECOR3QqECd8iGHC0RJb4WKbVdSfif4ammrg==", + "funding": [ + { + "type": "individual", + "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" + }, + { + "type": "individual", + "url": "https://www.buymeacoffee.com/ricmoo" + } + ], + "license": "MIT", + "dependencies": { + "@ethersproject/bignumber": "^5.8.0", + "@ethersproject/bytes": "^5.8.0", + "@ethersproject/logger": "^5.8.0", + "@ethersproject/networks": "^5.8.0", + "@ethersproject/properties": "^5.8.0", + "@ethersproject/transactions": "^5.8.0", + "@ethersproject/web": "^5.8.0" + } + }, + "node_modules/@ethersproject/abstract-signer": { + "version": "5.8.0", + "resolved": "https://registry.npmjs.org/@ethersproject/abstract-signer/-/abstract-signer-5.8.0.tgz", + "integrity": "sha512-N0XhZTswXcmIZQdYtUnd79VJzvEwXQw6PK0dTl9VoYrEBxxCPXqS0Eod7q5TNKRxe1/5WUMuR0u0nqTF/avdCA==", + "funding": [ + { + "type": "individual", + "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" + }, + { + "type": "individual", + "url": "https://www.buymeacoffee.com/ricmoo" + } + ], + "license": "MIT", + "dependencies": { + "@ethersproject/abstract-provider": "^5.8.0", + "@ethersproject/bignumber": "^5.8.0", + "@ethersproject/bytes": "^5.8.0", + "@ethersproject/logger": "^5.8.0", + "@ethersproject/properties": "^5.8.0" + } + }, + "node_modules/@ethersproject/address": { + "version": "5.8.0", + "resolved": "https://registry.npmjs.org/@ethersproject/address/-/address-5.8.0.tgz", + "integrity": "sha512-GhH/abcC46LJwshoN+uBNoKVFPxUuZm6dA257z0vZkKmU1+t8xTn8oK7B9qrj8W2rFRMch4gbJl6PmVxjxBEBA==", + "funding": [ + { + "type": "individual", + "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" + }, + { + "type": "individual", + "url": "https://www.buymeacoffee.com/ricmoo" + } + ], + "license": "MIT", + "dependencies": { + "@ethersproject/bignumber": "^5.8.0", + "@ethersproject/bytes": "^5.8.0", + "@ethersproject/keccak256": "^5.8.0", + "@ethersproject/logger": "^5.8.0", + "@ethersproject/rlp": "^5.8.0" + } + }, + "node_modules/@ethersproject/base64": { + "version": "5.8.0", + "resolved": "https://registry.npmjs.org/@ethersproject/base64/-/base64-5.8.0.tgz", + "integrity": "sha512-lN0oIwfkYj9LbPx4xEkie6rAMJtySbpOAFXSDVQaBnAzYfB4X2Qr+FXJGxMoc3Bxp2Sm8OwvzMrywxyw0gLjIQ==", + "funding": [ + { + "type": "individual", + "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" + }, + { + "type": "individual", + "url": "https://www.buymeacoffee.com/ricmoo" + } + ], + "license": "MIT", + "dependencies": { + "@ethersproject/bytes": "^5.8.0" + } + }, + "node_modules/@ethersproject/bignumber": { + "version": "5.8.0", + "resolved": "https://registry.npmjs.org/@ethersproject/bignumber/-/bignumber-5.8.0.tgz", + "integrity": "sha512-ZyaT24bHaSeJon2tGPKIiHszWjD/54Sz8t57Toch475lCLljC6MgPmxk7Gtzz+ddNN5LuHea9qhAe0x3D+uYPA==", + "funding": [ + { + "type": "individual", + "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" + }, + { + "type": "individual", + "url": "https://www.buymeacoffee.com/ricmoo" + } + ], + "license": "MIT", + "dependencies": { + "@ethersproject/bytes": "^5.8.0", + "@ethersproject/logger": "^5.8.0", + "bn.js": "^5.2.1" + } + }, + "node_modules/@ethersproject/bytes": { + "version": "5.8.0", + "resolved": "https://registry.npmjs.org/@ethersproject/bytes/-/bytes-5.8.0.tgz", + "integrity": "sha512-vTkeohgJVCPVHu5c25XWaWQOZ4v+DkGoC42/TS2ond+PARCxTJvgTFUNDZovyQ/uAQ4EcpqqowKydcdmRKjg7A==", + "funding": [ + { + "type": "individual", + "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" + }, + { + "type": "individual", + "url": "https://www.buymeacoffee.com/ricmoo" + } + ], + "license": "MIT", + "dependencies": { + "@ethersproject/logger": "^5.8.0" + } + }, + "node_modules/@ethersproject/constants": { + "version": "5.8.0", + "resolved": "https://registry.npmjs.org/@ethersproject/constants/-/constants-5.8.0.tgz", + "integrity": "sha512-wigX4lrf5Vu+axVTIvNsuL6YrV4O5AXl5ubcURKMEME5TnWBouUh0CDTWxZ2GpnRn1kcCgE7l8O5+VbV9QTTcg==", + "funding": [ + { + "type": "individual", + "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" + }, + { + "type": "individual", + "url": "https://www.buymeacoffee.com/ricmoo" + } + ], + "license": "MIT", + "dependencies": { + "@ethersproject/bignumber": "^5.8.0" + } + }, + "node_modules/@ethersproject/hash": { + "version": "5.8.0", + "resolved": "https://registry.npmjs.org/@ethersproject/hash/-/hash-5.8.0.tgz", + "integrity": "sha512-ac/lBcTbEWW/VGJij0CNSw/wPcw9bSRgCB0AIBz8CvED/jfvDoV9hsIIiWfvWmFEi8RcXtlNwp2jv6ozWOsooA==", + "funding": [ + { + "type": "individual", + "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" + }, + { + "type": "individual", + "url": "https://www.buymeacoffee.com/ricmoo" + } + ], + "license": "MIT", + "dependencies": { + "@ethersproject/abstract-signer": "^5.8.0", + "@ethersproject/address": "^5.8.0", + "@ethersproject/base64": "^5.8.0", + "@ethersproject/bignumber": "^5.8.0", + "@ethersproject/bytes": "^5.8.0", + "@ethersproject/keccak256": "^5.8.0", + "@ethersproject/logger": "^5.8.0", + "@ethersproject/properties": "^5.8.0", + "@ethersproject/strings": "^5.8.0" + } + }, + "node_modules/@ethersproject/keccak256": { + "version": "5.8.0", + "resolved": "https://registry.npmjs.org/@ethersproject/keccak256/-/keccak256-5.8.0.tgz", + "integrity": "sha512-A1pkKLZSz8pDaQ1ftutZoaN46I6+jvuqugx5KYNeQOPqq+JZ0Txm7dlWesCHB5cndJSu5vP2VKptKf7cksERng==", + "funding": [ + { + "type": "individual", + "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" + }, + { + "type": "individual", + "url": "https://www.buymeacoffee.com/ricmoo" + } + ], + "license": "MIT", + "dependencies": { + "@ethersproject/bytes": "^5.8.0", + "js-sha3": "0.8.0" + } + }, + "node_modules/@ethersproject/keccak256/node_modules/js-sha3": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/js-sha3/-/js-sha3-0.8.0.tgz", + "integrity": "sha512-gF1cRrHhIzNfToc802P800N8PpXS+evLLXfsVpowqmAFR9uwbi89WvXg2QspOmXL8QL86J4T1EpFu+yUkwJY3Q==", + "license": "MIT" + }, + "node_modules/@ethersproject/logger": { + "version": "5.8.0", + "resolved": "https://registry.npmjs.org/@ethersproject/logger/-/logger-5.8.0.tgz", + "integrity": "sha512-Qe6knGmY+zPPWTC+wQrpitodgBfH7XoceCGL5bJVejmH+yCS3R8jJm8iiWuvWbG76RUmyEG53oqv6GMVWqunjA==", + "funding": [ + { + "type": "individual", + "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" + }, + { + "type": "individual", + "url": "https://www.buymeacoffee.com/ricmoo" + } + ], + "license": "MIT" + }, + "node_modules/@ethersproject/networks": { + "version": "5.8.0", + "resolved": "https://registry.npmjs.org/@ethersproject/networks/-/networks-5.8.0.tgz", + "integrity": "sha512-egPJh3aPVAzbHwq8DD7Po53J4OUSsA1MjQp8Vf/OZPav5rlmWUaFLiq8cvQiGK0Z5K6LYzm29+VA/p4RL1FzNg==", + "funding": [ + { + "type": "individual", + "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" + }, + { + "type": "individual", + "url": "https://www.buymeacoffee.com/ricmoo" + } + ], + "license": "MIT", + "dependencies": { + "@ethersproject/logger": "^5.8.0" + } + }, + "node_modules/@ethersproject/properties": { + "version": "5.8.0", + "resolved": "https://registry.npmjs.org/@ethersproject/properties/-/properties-5.8.0.tgz", + "integrity": "sha512-PYuiEoQ+FMaZZNGrStmN7+lWjlsoufGIHdww7454FIaGdbe/p5rnaCXTr5MtBYl3NkeoVhHZuyzChPeGeKIpQw==", + "funding": [ + { + "type": "individual", + "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" + }, + { + "type": "individual", + "url": "https://www.buymeacoffee.com/ricmoo" + } + ], + "license": "MIT", + "dependencies": { + "@ethersproject/logger": "^5.8.0" + } + }, + "node_modules/@ethersproject/rlp": { + "version": "5.8.0", + "resolved": "https://registry.npmjs.org/@ethersproject/rlp/-/rlp-5.8.0.tgz", + "integrity": "sha512-LqZgAznqDbiEunaUvykH2JAoXTT9NV0Atqk8rQN9nx9SEgThA/WMx5DnW8a9FOufo//6FZOCHZ+XiClzgbqV9Q==", + "funding": [ + { + "type": "individual", + "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" + }, + { + "type": "individual", + "url": "https://www.buymeacoffee.com/ricmoo" + } + ], + "license": "MIT", + "dependencies": { + "@ethersproject/bytes": "^5.8.0", + "@ethersproject/logger": "^5.8.0" + } + }, + "node_modules/@ethersproject/signing-key": { + "version": "5.8.0", + "resolved": "https://registry.npmjs.org/@ethersproject/signing-key/-/signing-key-5.8.0.tgz", + "integrity": "sha512-LrPW2ZxoigFi6U6aVkFN/fa9Yx/+4AtIUe4/HACTvKJdhm0eeb107EVCIQcrLZkxaSIgc/eCrX8Q1GtbH+9n3w==", + "funding": [ + { + "type": "individual", + "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" + }, + { + "type": "individual", + "url": "https://www.buymeacoffee.com/ricmoo" + } + ], + "license": "MIT", + "dependencies": { + "@ethersproject/bytes": "^5.8.0", + "@ethersproject/logger": "^5.8.0", + "@ethersproject/properties": "^5.8.0", + "bn.js": "^5.2.1", + "elliptic": "6.6.1", + "hash.js": "1.1.7" + } + }, + "node_modules/@ethersproject/strings": { + "version": "5.8.0", + "resolved": "https://registry.npmjs.org/@ethersproject/strings/-/strings-5.8.0.tgz", + "integrity": "sha512-qWEAk0MAvl0LszjdfnZ2uC8xbR2wdv4cDabyHiBh3Cldq/T8dPH3V4BbBsAYJUeonwD+8afVXld274Ls+Y1xXg==", + "funding": [ + { + "type": "individual", + "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" + }, + { + "type": "individual", + "url": "https://www.buymeacoffee.com/ricmoo" + } + ], + "license": "MIT", + "dependencies": { + "@ethersproject/bytes": "^5.8.0", + "@ethersproject/constants": "^5.8.0", + "@ethersproject/logger": "^5.8.0" + } + }, + "node_modules/@ethersproject/transactions": { + "version": "5.8.0", + "resolved": "https://registry.npmjs.org/@ethersproject/transactions/-/transactions-5.8.0.tgz", + "integrity": "sha512-UglxSDjByHG0TuU17bDfCemZ3AnKO2vYrL5/2n2oXvKzvb7Cz+W9gOWXKARjp2URVwcWlQlPOEQyAviKwT4AHg==", + "funding": [ + { + "type": "individual", + "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" + }, + { + "type": "individual", + "url": "https://www.buymeacoffee.com/ricmoo" + } + ], + "license": "MIT", + "dependencies": { + "@ethersproject/address": "^5.8.0", + "@ethersproject/bignumber": "^5.8.0", + "@ethersproject/bytes": "^5.8.0", + "@ethersproject/constants": "^5.8.0", + "@ethersproject/keccak256": "^5.8.0", + "@ethersproject/logger": "^5.8.0", + "@ethersproject/properties": "^5.8.0", + "@ethersproject/rlp": "^5.8.0", + "@ethersproject/signing-key": "^5.8.0" + } + }, + "node_modules/@ethersproject/web": { + "version": "5.8.0", + "resolved": "https://registry.npmjs.org/@ethersproject/web/-/web-5.8.0.tgz", + "integrity": "sha512-j7+Ksi/9KfGviws6Qtf9Q7KCqRhpwrYKQPs+JBA/rKVFF/yaWLHJEH3zfVP2plVu+eys0d2DlFmhoQJayFewcw==", + "funding": [ + { + "type": "individual", + "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" + }, + { + "type": "individual", + "url": "https://www.buymeacoffee.com/ricmoo" + } + ], + "license": "MIT", + "dependencies": { + "@ethersproject/base64": "^5.8.0", + "@ethersproject/bytes": "^5.8.0", + "@ethersproject/logger": "^5.8.0", + "@ethersproject/properties": "^5.8.0", + "@ethersproject/strings": "^5.8.0" + } + }, "node_modules/@ghostery/adblocker": { - "version": "2.18.1", - "resolved": "https://registry.npmjs.org/@ghostery/adblocker/-/adblocker-2.18.1.tgz", - "integrity": "sha512-pWMJqpXpuqIlcF3ZZQ2E7ixnC7xhtYn/bpOn3N69vFrAm4wrZCIbKzz1+xSxKWCmJvDT4fg7rN2R6jQwFXm5BA==", + "version": "2.18.2", + "resolved": "https://registry.npmjs.org/@ghostery/adblocker/-/adblocker-2.18.2.tgz", + "integrity": "sha512-x6zEkf1dHSN0t4ExLmuN6nIgH0oAi6laoKe5yGsiIB4jkEvutea1PJgxgRUIjWfNbDRmkf4GjUtuqQFZgvQG1w==", "license": "MPL-2.0", "dependencies": { - "@ghostery/adblocker-content": "^2.18.1", - "@ghostery/adblocker-extended-selectors": "^2.18.1", + "@ghostery/adblocker-content": "^2.18.2", + "@ghostery/adblocker-extended-selectors": "^2.18.2", "@ghostery/url-parser": "^1.3.1", "@remusao/guess-url-type": "^2.1.0", "@remusao/small": "^2.1.0", "@remusao/smaz": "^2.2.0", - "tldts-experimental": "^7.4.3" + "tldts-experimental": "^7.4.9" } }, "node_modules/@ghostery/adblocker-content": { - "version": "2.18.1", - "resolved": "https://registry.npmjs.org/@ghostery/adblocker-content/-/adblocker-content-2.18.1.tgz", - "integrity": "sha512-7OwXvqssrSqceXDMccq1SG2LEp0P899TWv0J52kA4PCjCgdsffdj7wMO98LbiVWEKJ9e00X4V2ZptC4VGYsX/w==", + "version": "2.18.2", + "resolved": "https://registry.npmjs.org/@ghostery/adblocker-content/-/adblocker-content-2.18.2.tgz", + "integrity": "sha512-TOe/b4KOBFEc9OjLG+wvgamGcSTIPSDBAL5YSa8lzjBsoSl8IDnfOjeJDldlLacsC/JEUMLkZIDDNknEolJVqg==", "license": "MPL-2.0", "dependencies": { - "@ghostery/adblocker-extended-selectors": "^2.18.1" + "@ghostery/adblocker-extended-selectors": "^2.18.2" } }, "node_modules/@ghostery/adblocker-extended-selectors": { - "version": "2.18.1", - "resolved": "https://registry.npmjs.org/@ghostery/adblocker-extended-selectors/-/adblocker-extended-selectors-2.18.1.tgz", - "integrity": "sha512-FruZMMUhdi0KG8GTTS9hs4njeQu1FyrzVtm9p3JKY6GpCNspy3kioP48JU3ASNzSDYZb5Xu8xbwPoSHJ4RQBeg==", + "version": "2.18.2", + "resolved": "https://registry.npmjs.org/@ghostery/adblocker-extended-selectors/-/adblocker-extended-selectors-2.18.2.tgz", + "integrity": "sha512-doKNShBk3+czQL08UkrysHkCPeG/kNPlXvz3DH7SXxD9Y78ovWkEAhjeR+upoan3fomJJuK3dD+IrlofnjckYw==", "license": "MPL-2.0" }, "node_modules/@ghostery/url-parser": { @@ -2955,93 +3316,336 @@ "dev": true, "license": "ISC", "dependencies": { - "yallist": "^3.0.2" + "yallist": "^3.0.2" + } + }, + "node_modules/@jest/transform/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/@jest/transform/node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + }, + "node_modules/@jest/types": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/@jest/types/-/types-30.4.1.tgz", + "integrity": "sha512-f1x/vJXIfjOlEmejYpbkbgw1gOqpPECwMvMEtBqe47j7H2Hg8h8w3o3ikhSXq3MI15kg+oQ0exWO0uCtTNJLoQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/pattern": "30.4.0", + "@jest/schemas": "30.4.1", + "@types/istanbul-lib-coverage": "^2.0.6", + "@types/istanbul-reports": "^3.0.4", + "@types/node": "*", + "@types/yargs": "^17.0.33", + "chalk": "^4.1.2" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@ledgerhq/client-ids": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/@ledgerhq/client-ids/-/client-ids-0.11.0.tgz", + "integrity": "sha512-/lXsAdtu3kuR+lWP94fiNT9BE4pXtqFE/j3wKAWXuPoLsZ5VYwrj2NcvKtUgNzzoSGOpernfLP/8ys4xlv18JQ==", + "license": "Apache-2.0", + "dependencies": { + "@ledgerhq/live-env": "^2.40.0", + "@reduxjs/toolkit": "2.11.2", + "uuid": "^9.0.0" + } + }, + "node_modules/@ledgerhq/client-ids/node_modules/uuid": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz", + "integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==", + "deprecated": "uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028).", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist/bin/uuid" + } + }, + "node_modules/@ledgerhq/devices": { + "version": "8.16.0", + "resolved": "https://registry.npmjs.org/@ledgerhq/devices/-/devices-8.16.0.tgz", + "integrity": "sha512-brXLPzkvGM3D5YNsWQ25P5G4SmWdSNBed9W8wKoOIRLGdRfvE+bg9mzFty0iZ+aRLBkLoXwX7xKIL9zUi6LBKQ==", + "license": "Apache-2.0", + "dependencies": { + "semver": "7.7.3" + } + }, + "node_modules/@ledgerhq/devices/node_modules/semver": { + "version": "7.7.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", + "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@ledgerhq/domain-service": { + "version": "1.8.8", + "resolved": "https://registry.npmjs.org/@ledgerhq/domain-service/-/domain-service-1.8.8.tgz", + "integrity": "sha512-n/ikZTLEx5ssmnbu6YnzjcjC/9W80x6Q41wwzes7QoV2GqjWscy6dqKTMpa4DOoe85w0KgVNDiFnJh7XXjQHMg==", + "license": "Apache-2.0", + "dependencies": { + "@ledgerhq/errors": "^6.37.0", + "@ledgerhq/logs": "^6.17.0", + "@ledgerhq/types-live": "^6.113.0", + "axios": "1.13.5", + "eip55": "^2.1.1", + "react": "19.0.0", + "react-dom": "19.0.0" + } + }, + "node_modules/@ledgerhq/domain-service/node_modules/axios": { + "version": "1.13.5", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.13.5.tgz", + "integrity": "sha512-cz4ur7Vb0xS4/KUN0tPWe44eqxrIu31me+fbang3ijiNscE129POzipJJA6zniq2C/Z6sJCjMimjS8Lc/GAs8Q==", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.15.11", + "form-data": "^4.0.5", + "proxy-from-env": "^1.1.0" + } + }, + "node_modules/@ledgerhq/domain-service/node_modules/proxy-from-env": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", + "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", + "license": "MIT" + }, + "node_modules/@ledgerhq/errors": { + "version": "6.37.0", + "resolved": "https://registry.npmjs.org/@ledgerhq/errors/-/errors-6.37.0.tgz", + "integrity": "sha512-T5yiKI5UX7ugeocdTF3TUsCIN2BH41Bio4ZeN410YFjFOf3es08n/5JyMzzKwzRgP0blG3HfBf7s7vJKqCSAeg==", + "license": "Apache-2.0" + }, + "node_modules/@ledgerhq/evm-tools": { + "version": "1.12.11", + "resolved": "https://registry.npmjs.org/@ledgerhq/evm-tools/-/evm-tools-1.12.11.tgz", + "integrity": "sha512-qh+uM7DNBOsPMv0SJmM97xmAHtrwHAwDGpgEJMpIZYKYoAcbt7Fusw0QK7hgEahopphjcMh34CNBCEMSb/2nQw==", + "license": "Apache-2.0", + "dependencies": { + "@ethersproject/constants": "^5.7.0", + "@ethersproject/hash": "^5.7.0", + "@ledgerhq/live-env": "^2.40.0", + "axios": "1.13.5", + "crypto-js": "4.2.0" + } + }, + "node_modules/@ledgerhq/evm-tools/node_modules/axios": { + "version": "1.13.5", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.13.5.tgz", + "integrity": "sha512-cz4ur7Vb0xS4/KUN0tPWe44eqxrIu31me+fbang3ijiNscE129POzipJJA6zniq2C/Z6sJCjMimjS8Lc/GAs8Q==", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.15.11", + "form-data": "^4.0.5", + "proxy-from-env": "^1.1.0" + } + }, + "node_modules/@ledgerhq/evm-tools/node_modules/proxy-from-env": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", + "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", + "license": "MIT" + }, + "node_modules/@ledgerhq/hw-app-eth": { + "version": "7.8.8", + "resolved": "https://registry.npmjs.org/@ledgerhq/hw-app-eth/-/hw-app-eth-7.8.8.tgz", + "integrity": "sha512-EUT8J9g/QaYZDhdQtMfFJoTN1PEVHgZtpp0heU+vWmQotOGWpjXlT8fjkh02n45hym6irdJob3vYrt3qOCAUlg==", + "license": "Apache-2.0", + "dependencies": { + "@ethersproject/abi": "^5.7.0", + "@ethersproject/rlp": "^5.7.0", + "@ethersproject/transactions": "^5.7.0", + "@ledgerhq/domain-service": "^1.8.8", + "@ledgerhq/errors": "^6.37.0", + "@ledgerhq/evm-tools": "^1.12.11", + "@ledgerhq/hw-transport": "6.35.5", + "@ledgerhq/hw-transport-mocker": "^6.34.5", + "@ledgerhq/logs": "^6.17.0", + "@ledgerhq/types-live": "^6.113.0", + "axios": "1.13.5", + "bignumber.js": "^9.1.2", + "semver": "7.7.3" + } + }, + "node_modules/@ledgerhq/hw-app-eth/node_modules/axios": { + "version": "1.13.5", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.13.5.tgz", + "integrity": "sha512-cz4ur7Vb0xS4/KUN0tPWe44eqxrIu31me+fbang3ijiNscE129POzipJJA6zniq2C/Z6sJCjMimjS8Lc/GAs8Q==", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.15.11", + "form-data": "^4.0.5", + "proxy-from-env": "^1.1.0" } }, - "node_modules/@jest/transform/node_modules/semver": { - "version": "6.3.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", - "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", - "dev": true, + "node_modules/@ledgerhq/hw-app-eth/node_modules/proxy-from-env": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", + "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", + "license": "MIT" + }, + "node_modules/@ledgerhq/hw-app-eth/node_modules/semver": { + "version": "7.7.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", + "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", "license": "ISC", "bin": { "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" } }, - "node_modules/@jest/transform/node_modules/yallist": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", - "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", - "dev": true, - "license": "ISC" + "node_modules/@ledgerhq/hw-transport": { + "version": "6.35.5", + "resolved": "https://registry.npmjs.org/@ledgerhq/hw-transport/-/hw-transport-6.35.5.tgz", + "integrity": "sha512-P4+wtLewLWgxPtIb90h5kjpzXVlC6f4IBQBmvowVFkInvZt34ffXkX7wa5KfMzu4l3cqCcpNSqtPSCMp+0vuqg==", + "license": "Apache-2.0", + "dependencies": { + "@ledgerhq/devices": "8.16.0", + "@ledgerhq/errors": "^6.37.0", + "@ledgerhq/logs": "^6.17.0", + "events": "^3.3.0" + } }, - "node_modules/@jest/types": { - "version": "30.4.1", - "resolved": "https://registry.npmjs.org/@jest/types/-/types-30.4.1.tgz", - "integrity": "sha512-f1x/vJXIfjOlEmejYpbkbgw1gOqpPECwMvMEtBqe47j7H2Hg8h8w3o3ikhSXq3MI15kg+oQ0exWO0uCtTNJLoQ==", - "dev": true, - "license": "MIT", + "node_modules/@ledgerhq/hw-transport-mocker": { + "version": "6.34.5", + "resolved": "https://registry.npmjs.org/@ledgerhq/hw-transport-mocker/-/hw-transport-mocker-6.34.5.tgz", + "integrity": "sha512-CSo8bRkyzYgrIpMIP7jgP+tB3+meC+orUJSnKwmLL1eT5XdOKlYc3Xl3yB6CRNUZ6LAqQPas0a/Oi9krwkCV1A==", + "license": "Apache-2.0", "dependencies": { - "@jest/pattern": "30.4.0", - "@jest/schemas": "30.4.1", - "@types/istanbul-lib-coverage": "^2.0.6", - "@types/istanbul-reports": "^3.0.4", - "@types/node": "*", - "@types/yargs": "^17.0.33", - "chalk": "^4.1.2" - }, - "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + "@ledgerhq/hw-transport": "6.35.5", + "@ledgerhq/logs": "^6.17.0", + "rxjs": "7.8.2" } }, - "node_modules/@jridgewell/gen-mapping": { - "version": "0.3.13", - "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", - "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", - "dev": true, - "license": "MIT", + "node_modules/@ledgerhq/hw-transport-node-hid": { + "version": "6.33.5", + "resolved": "https://registry.npmjs.org/@ledgerhq/hw-transport-node-hid/-/hw-transport-node-hid-6.33.5.tgz", + "integrity": "sha512-uJ7VztZEDdhCAmCByMltH9Zg6qrU1CrTjVpLrO7IQD5paQbMhZNOLSrtBhWSoP8LYNo/gx5VtLTdtUIIedjsVA==", + "license": "Apache-2.0", "dependencies": { - "@jridgewell/sourcemap-codec": "^1.5.0", - "@jridgewell/trace-mapping": "^0.3.24" + "@ledgerhq/devices": "8.16.0", + "@ledgerhq/errors": "^6.37.0", + "@ledgerhq/hw-transport": "6.35.5", + "@ledgerhq/hw-transport-node-hid-noevents": "^6.36.0", + "@ledgerhq/logs": "^6.17.0", + "lodash": "^4.17.21", + "node-hid": "2.1.2", + "usb": "2.9.0" } }, - "node_modules/@jridgewell/remapping": { - "version": "2.3.5", - "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", - "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", - "dev": true, - "license": "MIT", + "node_modules/@ledgerhq/hw-transport-node-hid-noevents": { + "version": "6.36.0", + "resolved": "https://registry.npmjs.org/@ledgerhq/hw-transport-node-hid-noevents/-/hw-transport-node-hid-noevents-6.36.0.tgz", + "integrity": "sha512-hsqybDCDtpNWeWVY2b/emrsFDqVfc3Rkv4+2b6h5ubPGsKWFd+7t0u6oy0sJEl5v6C+V/uxl03shBjWEcSoxgw==", + "license": "Apache-2.0", "dependencies": { - "@jridgewell/gen-mapping": "^0.3.5", - "@jridgewell/trace-mapping": "^0.3.24" + "@ledgerhq/devices": "8.16.0", + "@ledgerhq/errors": "^6.37.0", + "@ledgerhq/hw-transport": "6.35.5", + "@ledgerhq/logs": "^6.17.0", + "node-hid": "2.1.2" } }, - "node_modules/@jridgewell/resolve-uri": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", - "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.0.0" + "node_modules/@ledgerhq/live-env": { + "version": "2.40.0", + "resolved": "https://registry.npmjs.org/@ledgerhq/live-env/-/live-env-2.40.0.tgz", + "integrity": "sha512-TwiFiw+eLk/ONCp1v89j8fddzgohLqVlnclbLjDvkrkpZ6h2tigGstuzd+pgNfHi5ixEapg3WJF4naZgu40TSg==", + "license": "Apache-2.0", + "dependencies": { + "rxjs": "7.8.2", + "utility-types": "^3.10.0" } }, - "node_modules/@jridgewell/sourcemap-codec": { - "version": "1.5.5", - "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", - "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", - "dev": true, - "license": "MIT" + "node_modules/@ledgerhq/logs": { + "version": "6.17.0", + "resolved": "https://registry.npmjs.org/@ledgerhq/logs/-/logs-6.17.0.tgz", + "integrity": "sha512-yra33g5q/AU7+PwAws+GaVpQGUuxnDREjVBnviJjcaJLVKuLzI4pnj8Bd3nY3fypM5k1yZEYKEXfUuGFUjP2+w==", + "license": "Apache-2.0" }, - "node_modules/@jridgewell/trace-mapping": { - "version": "0.3.31", - "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", - "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", - "dev": true, - "license": "MIT", + "node_modules/@ledgerhq/types-live": { + "version": "6.113.0", + "resolved": "https://registry.npmjs.org/@ledgerhq/types-live/-/types-live-6.113.0.tgz", + "integrity": "sha512-gfCGfCl1yUBmJ35AqKbVy4nKIVPQXK0Hd+F8tPRNhJ2C8Y8SsKlYoG/SJOMRLYDPgckUqc0BCVWBhSBQC4TKBQ==", + "license": "Apache-2.0", "dependencies": { - "@jridgewell/resolve-uri": "^3.1.0", - "@jridgewell/sourcemap-codec": "^1.4.14" + "@ledgerhq/client-ids": "0.11.0", + "bignumber.js": "^9.1.2", + "rxjs": "7.8.2" } }, "node_modules/@malept/cross-spawn-promise": { @@ -3317,6 +3921,32 @@ "node": ">=18" } }, + "node_modules/@reduxjs/toolkit": { + "version": "2.11.2", + "resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.11.2.tgz", + "integrity": "sha512-Kd6kAHTA6/nUpp8mySPqj3en3dm0tdMIgbttnQ1xFMVpufoj+ADi8pXLBsd4xzTRHQa7t/Jv8W5UnCuW4kuWMQ==", + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.0.0", + "@standard-schema/utils": "^0.3.0", + "immer": "^11.0.0", + "redux": "^5.0.1", + "redux-thunk": "^3.1.0", + "reselect": "^5.1.0" + }, + "peerDependencies": { + "react": "^16.9.0 || ^17.0.0 || ^18 || ^19", + "react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0" + }, + "peerDependenciesMeta": { + "react": { + "optional": true + }, + "react-redux": { + "optional": true + } + } + }, "node_modules/@remusao/guess-url-type": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/@remusao/guess-url-type/-/guess-url-type-2.1.0.tgz", @@ -3478,6 +4108,18 @@ "@sinonjs/commons": "^3.0.1" } }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "license": "MIT" + }, + "node_modules/@standard-schema/utils": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz", + "integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==", + "license": "MIT" + }, "node_modules/@szmarczak/http-timer": { "version": "4.0.6", "resolved": "https://registry.npmjs.org/@szmarczak/http-timer/-/http-timer-4.0.6.tgz", @@ -3865,6 +4507,12 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/w3c-web-usb": { + "version": "1.0.14", + "resolved": "https://registry.npmjs.org/@types/w3c-web-usb/-/w3c-web-usb-1.0.14.tgz", + "integrity": "sha512-Qu3Nn6JFuF4+sHKYl+IcX9vYiI40ogleXzFFSxoE1W94rG98o/kXs8uJ0QSfFzuwBCZWlGfUGpPkgwuuX4PchA==", + "license": "MIT" + }, "node_modules/@types/yargs": { "version": "17.0.35", "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.35.tgz", @@ -5124,6 +5772,15 @@ "node": "20.x || 22.x || 23.x || 24.x || 25.x || 26.x" } }, + "node_modules/bignumber.js": { + "version": "9.3.1", + "resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-9.3.1.tgz", + "integrity": "sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ==", + "license": "MIT", + "engines": { + "node": "*" + } + }, "node_modules/bindings": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz", @@ -5151,6 +5808,12 @@ "dev": true, "license": "MIT" }, + "node_modules/bn.js": { + "version": "5.2.4", + "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-5.2.4.tgz", + "integrity": "sha512-QL7sb18rJ1PbdsKsqPA0guxL563vIMwRHgzNrW/uzQuRGN1Cjqd/wonUBAVqHox9KwzHA6vCbM0lXx3k4iQMow==", + "license": "MIT" + }, "node_modules/boolean": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/boolean/-/boolean-3.2.0.tgz", @@ -5173,6 +5836,12 @@ "node": "18 || 20 || >=22" } }, + "node_modules/brorand": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/brorand/-/brorand-1.1.0.tgz", + "integrity": "sha512-cKV8tMCEpQs4hK/ik71d6LrPOnpkpGBR0wzxqr68g2m/LB2GxVYQroAjMJZRVM1Y4BCjCKc3vAamxSzOY2RP+w==", + "license": "MIT" + }, "node_modules/browserslist": { "version": "4.28.4", "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.4.tgz", @@ -5665,15 +6334,6 @@ "node": ">=0.8" } }, - "node_modules/cross-dirname": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/cross-dirname/-/cross-dirname-0.1.0.tgz", - "integrity": "sha512-+R08/oI0nl3vfPcqftZRpytksBXDzOUveBq/NBVx0sUp1axwzPQrKinNx5yd5sxPu8j1wIy8AfnVQ+5eFdha6Q==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true - }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -5689,6 +6349,12 @@ "node": ">= 8" } }, + "node_modules/crypto-js": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/crypto-js/-/crypto-js-4.2.0.tgz", + "integrity": "sha512-KALDyEYgpY+Rlob/iriUtjV6d5Eq+Y191A5g4UqLAi8CyGP9N1+FdVbkc1SxKc2r4YAYqG8JzO2KGL+AizD70Q==", + "license": "MIT" + }, "node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", @@ -6068,6 +6734,15 @@ "dev": true, "license": "MIT" }, + "node_modules/eip55": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/eip55/-/eip55-2.1.1.tgz", + "integrity": "sha512-WcagVAmNu2Ww2cDUfzuWVntYwFxbvZ5MvIyLZpMjTTkjD6sCvkGOiS86jTppzu9/gWsc8isLHAeMBWK02OnZmA==", + "license": "MIT", + "dependencies": { + "keccak": "^3.0.3" + } + }, "node_modules/ejs": { "version": "3.1.10", "resolved": "https://registry.npmjs.org/ejs/-/ejs-3.1.10.tgz", @@ -6309,6 +6984,27 @@ "dev": true, "license": "MIT" }, + "node_modules/elliptic": { + "version": "6.6.1", + "resolved": "https://registry.npmjs.org/elliptic/-/elliptic-6.6.1.tgz", + "integrity": "sha512-RaddvvMatK2LJHqFJ+YA4WysVN5Ita9E35botqIYspQ4TkRAlCicdzKOjlyv/1Za5RyTNn7di//eEV0uTAfe3g==", + "license": "MIT", + "dependencies": { + "bn.js": "^4.11.9", + "brorand": "^1.1.0", + "hash.js": "^1.0.0", + "hmac-drbg": "^1.0.1", + "inherits": "^2.0.4", + "minimalistic-assert": "^1.0.1", + "minimalistic-crypto-utils": "^1.0.1" + } + }, + "node_modules/elliptic/node_modules/bn.js": { + "version": "4.12.4", + "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.4.tgz", + "integrity": "sha512-njR1b+ixG2ufvL9Zn9JGneW+b5GV6jqpYyPPpg4QVt723b5kJPGUczkUyWEH9BwEA74UakJZ43I4FDLBF7ci0g==", + "license": "MIT" + }, "node_modules/emittery": { "version": "0.13.1", "resolved": "https://registry.npmjs.org/emittery/-/emittery-0.13.1.tgz", @@ -6820,6 +7516,15 @@ "integrity": "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==", "license": "MIT" }, + "node_modules/events": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", + "integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==", + "license": "MIT", + "engines": { + "node": ">=0.8.x" + } + }, "node_modules/execa": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/execa/-/execa-5.1.1.tgz", @@ -7460,6 +8165,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/hash.js": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/hash.js/-/hash.js-1.1.7.tgz", + "integrity": "sha512-taOaskGt4z4SOANNseOviYDvjEJinIkRgmp7LbKP2YTTmVxWBl87s/uzK9r+44BclBSp2X7K1hqeNfz9JbBeXA==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "minimalistic-assert": "^1.0.1" + } + }, "node_modules/hasown": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", @@ -7472,6 +8187,17 @@ "node": ">= 0.4" } }, + "node_modules/hmac-drbg": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/hmac-drbg/-/hmac-drbg-1.0.1.tgz", + "integrity": "sha512-Tti3gMqLdZfhOQY1Mzf/AanLiqh1WTiJgEj26ZuYQ9fbkLomzGchCws4FyrSd4VkpBfiNhaE1On+lOz894jvXg==", + "license": "MIT", + "dependencies": { + "hash.js": "^1.0.3", + "minimalistic-assert": "^1.0.0", + "minimalistic-crypto-utils": "^1.0.1" + } + }, "node_modules/hosted-git-info": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-4.1.0.tgz", @@ -7612,6 +8338,16 @@ "node": ">= 4" } }, + "node_modules/immer": { + "version": "11.1.11", + "resolved": "https://registry.npmjs.org/immer/-/immer-11.1.11.tgz", + "integrity": "sha512-qzXuyXAkPySAGYkfsAwodDPWT8Zm7/Uo5BNt4BjhMhG5WlWyZZ4wQqnWwdS8kjlQ1Cwu6gjw3A6+0gTQwlyYtw==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/immer" + } + }, "node_modules/import-local": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/import-local/-/import-local-3.2.0.tgz", @@ -9623,6 +10359,21 @@ "graceful-fs": "^4.1.6" } }, + "node_modules/keccak": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/keccak/-/keccak-3.0.4.tgz", + "integrity": "sha512-3vKuW0jV8J3XNTzvfyicFR5qvxrSAGl7KIhvgOu5cmWwM7tZRj3fMbj/pfIf4be7aznbc+prBWGjywox/g2Y6Q==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "node-addon-api": "^2.0.0", + "node-gyp-build": "^4.2.0", + "readable-stream": "^3.6.0" + }, + "engines": { + "node": ">=10.0.0" + } + }, "node_modules/keyv": { "version": "4.5.4", "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", @@ -9925,6 +10676,18 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/minimalistic-assert": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz", + "integrity": "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==", + "license": "ISC" + }, + "node_modules/minimalistic-crypto-utils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/minimalistic-crypto-utils/-/minimalistic-crypto-utils-1.0.1.tgz", + "integrity": "sha512-JIYlbt6g8i5jKfJ3xz7rF0LXmv2TkDxBLUkiBeZ7bAx4GnnNMr8xFpGnOxn6GhTEHx3SjRrZEoU+j04prX1ktg==", + "license": "MIT" + }, "node_modules/minimatch": { "version": "10.2.5", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", @@ -10047,6 +10810,12 @@ "node": ">=22.12.0" } }, + "node_modules/node-addon-api": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-2.0.2.tgz", + "integrity": "sha512-Ntyt4AIXyaLIuMHF6IOoTakB3K+RWxwtsHNRxllEoA6vPwP9o4866g6YWDLUdnucilZhmkxiHwHr11gAENw+QA==", + "license": "MIT" + }, "node_modules/node-api-version": { "version": "0.2.1", "resolved": "https://registry.npmjs.org/node-api-version/-/node-api-version-0.2.1.tgz", @@ -10082,6 +10851,17 @@ "node": "^20.17.0 || >=22.9.0" } }, + "node_modules/node-gyp-build": { + "version": "4.8.4", + "resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.8.4.tgz", + "integrity": "sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==", + "license": "MIT", + "bin": { + "node-gyp-build": "bin.js", + "node-gyp-build-optional": "optional.js", + "node-gyp-build-test": "build-test.js" + } + }, "node_modules/node-gyp/node_modules/env-paths": { "version": "2.2.1", "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz", @@ -10128,6 +10908,30 @@ "node": "^20.17.0 || >=22.9.0" } }, + "node_modules/node-hid": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/node-hid/-/node-hid-2.1.2.tgz", + "integrity": "sha512-qhCyQqrPpP93F/6Wc/xUR7L8mAJW0Z6R7HMQV8jCHHksAxNDe/4z4Un/H9CpLOT+5K39OPyt9tIQlavxWES3lg==", + "hasInstallScript": true, + "license": "(MIT OR X11)", + "dependencies": { + "bindings": "^1.5.0", + "node-addon-api": "^3.0.2", + "prebuild-install": "^7.1.1" + }, + "bin": { + "hid-showdevices": "src/show-devices.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/node-hid/node_modules/node-addon-api": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-3.2.1.tgz", + "integrity": "sha512-mmcei9JghVNDYydghQmeDX8KoAm0FAiYyIcUt/N4nhyAipB17pllZQDOJD2fotxABnt4Mdz+dKTO7eftLg4d0A==", + "license": "MIT" + }, "node_modules/node-int64": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz", @@ -10691,36 +11495,6 @@ "node": ">=12.0.0" } }, - "node_modules/postject": { - "version": "1.0.0-alpha.6", - "resolved": "https://registry.npmjs.org/postject/-/postject-1.0.0-alpha.6.tgz", - "integrity": "sha512-b9Eb8h2eVqNE8edvKdwqkrY6O7kAwmI8kcnBv1NScolYJbo59XUF0noFq+lxbC1yN20bmC0WBEbDC5H/7ASb0A==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "commander": "^9.4.0" - }, - "bin": { - "postject": "dist/cli.js" - }, - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/postject/node_modules/commander": { - "version": "9.5.0", - "resolved": "https://registry.npmjs.org/commander/-/commander-9.5.0.tgz", - "integrity": "sha512-KRs7WVDKg86PWiuAqhDrAQnTXZKraVcCc6vFdL14qrZ/DcWwuRo7VoiYXalXO7S5GKpqYiVEwCbgFDfxNHKJBQ==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "engines": { - "node": "^12.20.0 || >=14" - } - }, "node_modules/prebuild-install": { "version": "7.1.3", "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz", @@ -11053,6 +11827,27 @@ "node": ">=0.10.0" } }, + "node_modules/react": { + "version": "19.0.0", + "resolved": "https://registry.npmjs.org/react/-/react-19.0.0.tgz", + "integrity": "sha512-V8AVnmPIICiWpGfm6GLzCR/W5FXLchHop40W4nXBmdlEceh16rCN8O8LNWm5bh5XUX91fh7KpA+W0TgMKmgTpQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.0.0", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.0.0.tgz", + "integrity": "sha512-4GV5sHFG0e/0AD4X+ySy6UJd3jVl1iNsNHdpad0qhABJ11twS3TTBnseqsKurKcsNqCEFeGL3uLpVChpIO3QfQ==", + "license": "MIT", + "dependencies": { + "scheduler": "^0.25.0" + }, + "peerDependencies": { + "react": "^19.0.0" + } + }, "node_modules/react-is-18": { "name": "react-is", "version": "18.3.1", @@ -11096,6 +11891,21 @@ "node": ">= 6" } }, + "node_modules/redux": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz", + "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==", + "license": "MIT" + }, + "node_modules/redux-thunk": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-3.1.0.tgz", + "integrity": "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==", + "license": "MIT", + "peerDependencies": { + "redux": "^5.0.0" + } + }, "node_modules/regenerate": { "version": "1.4.2", "resolved": "https://registry.npmjs.org/regenerate/-/regenerate-1.4.2.tgz", @@ -11197,6 +12007,12 @@ "url": "https://github.com/sponsors/jet2jet" } }, + "node_modules/reselect": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/reselect/-/reselect-5.2.0.tgz", + "integrity": "sha512-AgZ3UOZm3YndfrJ4OYjgrT7bmCm/1iqkjvEfH/oYjzh6PD2qw4QuT3jjnXIrpdt4MTpMXclMT3lXbmRY+XRakw==", + "license": "MIT" + }, "node_modules/resolve-alpn": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/resolve-alpn/-/resolve-alpn-1.2.1.tgz", @@ -11291,6 +12107,15 @@ "license": "BSD-3-Clause", "optional": true }, + "node_modules/rxjs": { + "version": "7.8.2", + "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz", + "integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.1.0" + } + }, "node_modules/safe-buffer": { "version": "5.2.1", "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", @@ -11330,6 +12155,12 @@ "node": ">=11.0.0" } }, + "node_modules/scheduler": { + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.25.0.tgz", + "integrity": "sha512-xFVuu11jh+xcO7JOAGJNOXld8/TcEHK/4CituBUeUb5hqxJLj9YuemAEuvm9gQ/+pgXYfbQuqAkiYu+u7YEsNA==", + "license": "MIT" + }, "node_modules/semver": { "version": "7.8.5", "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", @@ -11866,18 +12697,18 @@ } }, "node_modules/tldts-core": { - "version": "7.4.6", - "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.4.6.tgz", - "integrity": "sha512-TkQNGJIhlEphpHCjKodMTSe23egUZr/g+flI2qkLgiJ/maAzSgXypSLRTNH3nCmqgayEmtcJBiLcfODSAr1xoA==", + "version": "7.4.10", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.4.10.tgz", + "integrity": "sha512-KnQjp53ZekKgm/r3l+u8kJGGzYgrWdP8+Mql7a4vijh2WE0IrZWspQj/TpTxDho/YxO+AnOZnIjQcCD+q6iJsw==", "license": "MIT" }, "node_modules/tldts-experimental": { - "version": "7.4.6", - "resolved": "https://registry.npmjs.org/tldts-experimental/-/tldts-experimental-7.4.6.tgz", - "integrity": "sha512-229vm2Ce4YwD/Tys0Y9WEfKG8gSAfuH9n22rntRvFqfzydJSLk/ihl1aD2kp0Q7d0dktwf1YzYhZ5FmVQwGCCQ==", + "version": "7.4.10", + "resolved": "https://registry.npmjs.org/tldts-experimental/-/tldts-experimental-7.4.10.tgz", + "integrity": "sha512-l+FUGTqw3cYLnM6uL45IhdIG1ue9N7ZnMe9ZFC0S3/ROTL6xkJrUFQvsjfqSD9YzywDvgWj9uRZTXib3v3bnHw==", "license": "MIT", "dependencies": { - "tldts-core": "^7.4.6" + "tldts-core": "^7.4.10" } }, "node_modules/tmp": { @@ -11921,7 +12752,6 @@ "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", - "dev": true, "license": "0BSD" }, "node_modules/tunnel-agent": { @@ -12151,6 +12981,27 @@ "punycode": "^2.1.0" } }, + "node_modules/usb": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/usb/-/usb-2.9.0.tgz", + "integrity": "sha512-G0I/fPgfHUzWH8xo2KkDxTTFruUWfppgSFJ+bQxz/kVY2x15EQ/XDB7dqD1G432G4gBG4jYQuF3U7j/orSs5nw==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "@types/w3c-web-usb": "^1.0.6", + "node-addon-api": "^6.0.0", + "node-gyp-build": "^4.5.0" + }, + "engines": { + "node": ">=10.20.0 <11.x || >=12.17.0 <13.0 || >=14.0.0" + } + }, + "node_modules/usb/node_modules/node-addon-api": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-6.1.0.tgz", + "integrity": "sha512-+eawOlIgy680F0kBzPUNFhMZGtJ1YmqM6l4+Crf4IkImjYrO/mqPwRMh352g23uIaQKFItcQ64I7KMaJxHgAVA==", + "license": "MIT" + }, "node_modules/utf8-byte-length": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/utf8-byte-length/-/utf8-byte-length-1.0.5.tgz", @@ -12164,6 +13015,15 @@ "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", "license": "MIT" }, + "node_modules/utility-types": { + "version": "3.11.0", + "resolved": "https://registry.npmjs.org/utility-types/-/utility-types-3.11.0.tgz", + "integrity": "sha512-6Z7Ma2aVEWisaL6TvBCy7P8rm2LQoPv6dJ7ecIaIixHcwfbJ0x7mWdbcwlIM5IGQxPZSFYeqRCqlOOeKoJYMkw==", + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, "node_modules/uuid": { "version": "14.0.1", "resolved": "https://registry.npmjs.org/uuid/-/uuid-14.0.1.tgz", diff --git a/package.json b/package.json index 299dc343..fc1b8a64 100644 --- a/package.json +++ b/package.json @@ -201,6 +201,8 @@ "@ensdomains/content-hash": "^3.0.0", "@ethersphere/bee-js": "^12.2.1", "@ghostery/adblocker": "^2.18.1", + "@ledgerhq/hw-app-eth": "^7.8.8", + "@ledgerhq/hw-transport-node-hid": "^6.33.5", "@metamask/browser-passworder": "^6.0.0", "@scure/bip39": "^2.2.0", "@x402/core": "^2.12.0", diff --git a/src/main/identity-manager.js b/src/main/identity-manager.js index ac56e0ff..5b56dd4f 100644 --- a/src/main/identity-manager.js +++ b/src/main/identity-manager.js @@ -281,10 +281,13 @@ async function getUserWalletKey(walletIndex) { throw new Error('Wallet index must be a non-negative integer'); } - const wallets = await getDerivedWallets(); - if (!wallets.some((wallet) => wallet.index === walletIndex)) { + const record = getWalletRecord(walletIndex); + if (!record) { throw new Error(`Wallet with index ${walletIndex} does not exist`); } + if (record.type !== WALLET_TYPES.MNEMONIC) { + throw new Error('Hardware wallet accounts have no derivable private key'); + } const identity = await loadIdentityModule(); const mnemonic = identity.getMnemonic(); @@ -783,9 +786,113 @@ async function exportMnemonic() { // Multi-Wallet Support // ============================================ +/** + * Wallet account types. Entries in vault-meta's `derivedWallets[]` without + * a `type` field predate hardware-wallet support and are mnemonic-derived. + */ +const WALLET_TYPES = { + MNEMONIC: 'mnemonic', + LEDGER: 'ledger', +}; + +/** + * Hardware accounts are allocated from a disjoint, never-reused slice of + * the wallet index space, starting here. + * + * A wallet's `index` is two things at once: the account id every + * persisted reference stores (dApp permissions, Swarm publisher + * identities, `activeWalletIndex`) and — for mnemonic accounts — the + * BIP-44 account index its key is derived at. Letting hardware accounts + * take ids from that same pool breaks both roles: the mnemonic account + * at the squatted derivation index can never be re-created (the hardware + * guards block derivation at that index), stranding any funds it holds, + * and every persisted reference to that index silently rebinds to a + * different address and signing backend. + * + * @see nextHardwareWalletIndex + */ +const HARDWARE_INDEX_BASE = 1000000; + +function isHardwareWalletIndex(index) { + return Number.isInteger(index) && index >= HARDWARE_INDEX_BASE; +} + +/** + * Allocate the index for a new hardware account: monotonic and never + * reused, so deleting a Ledger does not hand its index — and with it + * every dApp permission and publisher identity pinned to that index — to + * the next device account that gets added. + * + * The counter lives in vault-meta; the on-disk wallet list is used as a + * high-water mark so a missing or stale counter can never produce a + * collision. + * + * @param {Object} meta - Parsed vault-meta + * @param {Array} wallets - Current wallet list + * @returns {number} + */ +function nextHardwareWalletIndex(meta, wallets) { + const counter = Number.isInteger(meta.nextHardwareWalletIndex) + ? meta.nextHardwareWalletIndex + : HARDWARE_INDEX_BASE; + const highWater = wallets.reduce( + (max, wallet) => (isHardwareWalletIndex(wallet.index) ? Math.max(max, wallet.index + 1) : max), + HARDWARE_INDEX_BASE + ); + return Math.max(counter, highWater); +} + +/** + * The wallet list stored in vault-meta, with the implicit pre-multi-wallet + * default (just the main wallet) when `derivedWallets` was never written. + * + * @param {Object} meta - Parsed vault-meta + * @returns {Array} Raw derivedWallets entries + */ +function getWalletList(meta) { + return ( + meta.derivedWallets || [ + { index: 0, name: 'Main Wallet', address: meta.addresses?.userWallet || null }, + ] + ); +} + +/** + * Look up a single wallet account record by index, normalized: `type` + * always present, address falling back to the stored main-wallet address + * for index 0. Returns null when the index is unknown. + * + * Used by the signer factory and the vault-access guard to decide which + * signing backend an index resolves to — must stay synchronous and cheap. + * + * @param {number} walletIndex + * @param {Object} [meta] - Already-loaded vault-meta, to skip the disk read + * @returns {{index: number, name: string, address: string|null, type: string, path?: string}|null} + */ +function getWalletRecord(walletIndex, meta = getVaultMeta()) { + if (!meta) { + return null; + } + const record = getWalletList(meta).find((wallet) => wallet.index === walletIndex); + if (!record) { + return null; + } + let address = record.address || null; + if (!address && record.index === 0) { + address = meta.addresses?.userWallet || null; + } + return { + index: record.index, + name: record.name, + address, + type: record.type || WALLET_TYPES.MNEMONIC, + ...(record.path ? { path: record.path } : {}), + }; +} + /** * Get list of derived user wallets - * @returns {Array<{index: number, name: string, address: string}>} + * @returns {Array<{index: number, name: string, address: string, type: string}>} */ async function getDerivedWallets() { const identity = await loadIdentityModule(); @@ -813,7 +920,7 @@ async function getDerivedWallets() { activeWalletIndex: 0, }); - return wallets; + return wallets.map((wallet) => ({ ...wallet, type: WALLET_TYPES.MNEMONIC })); } // If vault is unlocked, derive addresses; otherwise use stored addresses @@ -821,9 +928,14 @@ async function getDerivedWallets() { const wallets = []; for (const wallet of meta.derivedWallets) { + const type = wallet.type || WALLET_TYPES.MNEMONIC; let address = null; - if (mnemonic) { + if (type === WALLET_TYPES.LEDGER) { + // Hardware accounts: the address was read from the device when the + // account was added; there is nothing to derive locally. + address = wallet.address || null; + } else if (mnemonic) { // Derive address from mnemonic const derived = identity.deriveUserWallet(mnemonic, wallet.index); address = derived.address; @@ -840,12 +952,70 @@ async function getDerivedWallets() { index: wallet.index, name: wallet.name, address, + type, + ...(wallet.path ? { path: wallet.path } : {}), }); } return wallets; } +/** + * Add a Ledger hardware-wallet account to the wallet list. + * + * The address comes from the device during account discovery and is + * persisted — it can never be re-derived locally. Does not require the + * vault to be unlocked (no mnemonic involved), only that a vault exists + * so there is a wallet list to add to. + * + * @param {string} name - Display name ('' → auto "Ledger N") + * @param {string} address - Checksummed address read from the device + * @param {string} path - Derivation path in device format (e.g. "44'/60'/0'/0/0") + * @returns {Promise<{index: number, name: string, address: string, type: string, path: string}>} + */ +async function addLedgerWallet(name, address, path) { + const { isAddress } = require('ethers'); + if (typeof address !== 'string' || !isAddress(address)) { + throw new Error('Invalid Ledger account address'); + } + if (typeof path !== 'string' || !path) { + throw new Error('Missing derivation path for Ledger account'); + } + + const meta = getVaultMeta(); + if (!meta) { + throw new Error('No vault found'); + } + + const wallets = getWalletList(meta); + + const duplicate = wallets.find( + (wallet) => wallet.address && wallet.address.toLowerCase() === address.toLowerCase() + ); + if (duplicate) { + throw new Error(`This account is already in your wallet list as "${duplicate.name}"`); + } + + const newIndex = nextHardwareWalletIndex(meta, wallets); + const ledgerCount = wallets.filter((w) => w.type === WALLET_TYPES.LEDGER).length; + const newWallet = { + index: newIndex, + name: (name || '').trim() || `Ledger ${ledgerCount + 1}`, + address, + type: WALLET_TYPES.LEDGER, + path, + }; + wallets.push(newWallet); + + saveVaultMeta({ + ...meta, + derivedWallets: wallets, + nextHardwareWalletIndex: newIndex + 1, + }); + + return { ...newWallet }; +} + /** * Get the active wallet index * @returns {number} @@ -866,7 +1036,7 @@ async function setActiveWalletIndex(index) { } // Verify wallet exists - const wallets = meta.derivedWallets || [{ index: 0, name: 'Main Wallet' }]; + const wallets = getWalletList(meta); const walletExists = wallets.some((w) => w.index === index); if (!walletExists) { @@ -898,11 +1068,23 @@ async function createDerivedWallet(name) { } // Get current wallets - const wallets = meta.derivedWallets || [{ index: 0, name: 'Main Wallet' }]; - - // Find next available index (use account index, starting from max + 1) - const maxIndex = wallets.reduce((max, w) => Math.max(max, w.index), -1); - const newIndex = maxIndex + 1; + const wallets = getWalletList(meta); + + // Find next available index (use account index, starting from max + 1). + // Only mnemonic accounts constrain it — this index *is* the BIP-44 + // account index the key is derived at, and hardware accounts live in + // their own range (see HARDWARE_INDEX_BASE). The taken-index skip is a + // safety net for vault-meta written before that split, where a Ledger + // may still sit on a low index. + const taken = new Set(wallets.map((w) => w.index)); + const maxIndex = wallets.reduce( + (max, w) => (isHardwareWalletIndex(w.index) ? max : Math.max(max, w.index)), + -1 + ); + let newIndex = maxIndex + 1; + while (taken.has(newIndex)) { + newIndex += 1; + } // Derive the new wallet const derived = identity.deriveUserWallet(mnemonic, newIndex); @@ -939,7 +1121,7 @@ async function renameDerivedWallet(index, newName) { throw new Error('No vault found'); } - const wallets = meta.derivedWallets || [{ index: 0, name: 'Main Wallet' }]; + const wallets = getWalletList(meta); const walletIndex = wallets.findIndex((w) => w.index === index); if (walletIndex === -1) { @@ -981,7 +1163,7 @@ async function deleteDerivedWallet(index) { throw new Error('No vault found'); } - const wallets = meta.derivedWallets || [{ index: 0, name: 'Main Wallet' }]; + const wallets = getWalletList(meta); const walletIndex = wallets.findIndex((w) => w.index === index); if (walletIndex === -1) { @@ -1010,6 +1192,15 @@ async function deleteDerivedWallet(index) { derivedWallets: wallets, activeWalletIndex: activeIndex, }); + + // A dApp permission is a standing authorisation to sign with this one + // account (plus any auto-approve rules on top). It cannot outlive the + // account: the stored index would dangle, and for a hardware account it + // would dangle into an index that has no signer at all. + // Lazy require: dapp-permissions pulls in electron's `app` for its + // storage path, which identity-manager must not need at load time. + const { revokePermissionsForWalletIndex } = require('./wallet/dapp-permissions'); + revokePermissionsForWalletIndex(index); } /** @@ -1025,6 +1216,14 @@ async function getActiveWalletAddress() { } const activeIndex = meta.activeWalletIndex ?? 0; + + // Hardware accounts always use the stored device address — there is + // no local derivation, unlocked vault or not. + const record = getWalletRecord(activeIndex, meta); + if (record && record.type !== WALLET_TYPES.MNEMONIC) { + return record.address; + } + const mnemonic = identity.getMnemonic(); if (mnemonic) { @@ -1033,11 +1232,7 @@ async function getActiveWalletAddress() { } // Vault locked - can only return main wallet address from stored meta - if (activeIndex === 0) { - return meta.addresses?.userWallet || null; - } - - return null; + return activeIndex === 0 ? (record?.address ?? null) : null; } /** @@ -1190,6 +1385,16 @@ function registerIdentityIpc() { if (!password) { return { success: false, error: 'Password is required to export private key' }; } + // Same two-part guard as withVaultPrivateKey: the index range alone + // is decisive, so a deleted device account (no record) cannot export + // a phantom mnemonic key derived at its index. + const record = getWalletRecord(accountIndex); + if (isHardwareWalletIndex(accountIndex) || (record && record.type !== WALLET_TYPES.MNEMONIC)) { + return { + success: false, + error: 'Hardware wallet accounts have no exportable private key — the key never leaves the device', + }; + } const identity = await loadIdentityModule(); const dataDir = getIdentityDataDir(); await identity.verifyPassword(dataDir, password); @@ -1274,6 +1479,16 @@ function registerIdentityIpc() { } }); + // Add a Ledger hardware-wallet account (address read from the device) + ipcMain.handle('wallet:add-ledger-wallet', async (_event, name, address, path) => { + try { + const wallet = await addLedgerWallet(name, address, path); + return { success: true, wallet }; + } catch (err) { + return { success: false, error: err.message }; + } + }); + // Rename wallet ipcMain.handle('wallet:rename-wallet', async (_event, index, newName) => { try { @@ -1329,10 +1544,15 @@ module.exports = { getUserWalletKey, // Multi-wallet operations + WALLET_TYPES, + HARDWARE_INDEX_BASE, + isHardwareWalletIndex, + getWalletRecord, getDerivedWallets, getActiveWalletIndex, setActiveWalletIndex, createDerivedWallet, + addLedgerWallet, renameDerivedWallet, deleteDerivedWallet, getActiveWalletAddress, diff --git a/src/main/identity-manager.test.js b/src/main/identity-manager.test.js index 2382cbec..34758ecd 100644 --- a/src/main/identity-manager.test.js +++ b/src/main/identity-manager.test.js @@ -234,6 +234,324 @@ describe('identity-manager wallet deletion', () => { expect(readVaultMeta().derivedWallets.map((wallet) => wallet.index)).toEqual([0]); expect(readVaultMeta().activeWalletIndex).toBe(0); }); + + test('revokes dApp permissions bound to the deleted wallet', async () => { + // A permission is a standing authorisation to sign with one account + // (plus its auto-approve rules). Left behind, its walletIndex dangles + // — for a deleted hardware account, at an index with no signer at all. + fs.writeFileSync( + path.join(tmpDir, 'dapp-permissions.json'), + JSON.stringify({ + 'https://swap.example': { + origin: 'https://swap.example', + walletIndex: 2, + chainId: 1, + autoApprove: { signing: true, transactions: [] }, + }, + 'https://keep.example': { + origin: 'https://keep.example', + walletIndex: 0, + chainId: 1, + autoApprove: { signing: false, transactions: [] }, + }, + }, null, 2), + 'utf-8' + ); + + writeVaultMeta({ + activeWalletIndex: 0, + derivedWallets: [ + { index: 0, name: 'Main Wallet', address: '0x0' }, + { index: 2, name: 'Trading Wallet', address: '0x2' }, + ], + }); + + await identityManager.deleteDerivedWallet(2); + + const stored = JSON.parse( + fs.readFileSync(path.join(tmpDir, 'dapp-permissions.json'), 'utf-8') + ); + expect(Object.keys(stored)).toEqual(['https://keep.example']); + }); +}); + +describe('identity-manager ledger accounts', () => { + let tmpDir; + let envSnapshot; + let identityManager; + + const LEDGER_ADDRESS = '0x209693Bc6afc0C5328bA36FaF03C514EF312287C'; + const LEDGER_PATH = "44'/60'/0'/0/0"; + const HARDWARE_INDEX_BASE = 1000000; + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'identity-manager-ledger-')); + envSnapshot = snapshotEnv(); + process.env.FREEDOM_IDENTITY_DATA = tmpDir; + identityManager = loadMainModule(require.resolve('./identity-manager'), { + userDataDir: tmpDir, + extraMocks: { + [require.resolve('./identity')]: () => ({ + getMnemonic: jest.fn(() => null), // vault locked — ledger ops must not need it + isUnlocked: jest.fn(() => false), + }), + }, + }).mod; + }); + + afterEach(() => { + restoreEnv(envSnapshot); + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + function writeVaultMeta(meta) { + fs.writeFileSync(path.join(tmpDir, 'vault-meta.json'), JSON.stringify(meta, null, 2), 'utf-8'); + } + + function readVaultMeta() { + return JSON.parse(fs.readFileSync(path.join(tmpDir, 'vault-meta.json'), 'utf-8')); + } + + function seedMainWallet() { + writeVaultMeta({ + activeWalletIndex: 0, + addresses: { userWallet: '0x0000000000000000000000000000000000000001' }, + derivedWallets: [ + { index: 0, name: 'Main Wallet', address: '0x0000000000000000000000000000000000000001' }, + ], + }); + } + + test('addLedgerWallet appends a typed record with device address and path', async () => { + seedMainWallet(); + + const wallet = await identityManager.addLedgerWallet('My Stax', LEDGER_ADDRESS, LEDGER_PATH); + + expect(wallet).toEqual({ + index: HARDWARE_INDEX_BASE, + name: 'My Stax', + address: LEDGER_ADDRESS, + type: 'ledger', + path: LEDGER_PATH, + }); + expect(readVaultMeta().derivedWallets).toHaveLength(2); + expect(readVaultMeta().derivedWallets[1]).toMatchObject({ type: 'ledger', path: LEDGER_PATH }); + }); + + test('addLedgerWallet auto-names and works with the vault locked', async () => { + seedMainWallet(); + const wallet = await identityManager.addLedgerWallet('', LEDGER_ADDRESS, LEDGER_PATH); + expect(wallet.name).toBe('Ledger 1'); + }); + + test('addLedgerWallet rejects duplicates and bad input', async () => { + seedMainWallet(); + await identityManager.addLedgerWallet('My Stax', LEDGER_ADDRESS, LEDGER_PATH); + + await expect(identityManager.addLedgerWallet('Again', LEDGER_ADDRESS.toLowerCase(), LEDGER_PATH)) + .rejects.toThrow(/already in your wallet list/); + await expect(identityManager.addLedgerWallet('Bad', '0x123', LEDGER_PATH)) + .rejects.toThrow('Invalid Ledger account address'); + // Mixed-case address with a broken EIP-55 checksum must be rejected too + await expect(identityManager.addLedgerWallet('Bad', LEDGER_ADDRESS.replace('9', 'a'), LEDGER_PATH)) + .rejects.toThrow('Invalid Ledger account address'); + await expect(identityManager.addLedgerWallet('Bad', '0x833589fcd6edb6e08f4c7c32d4f71b54bda02913', '')) + .rejects.toThrow('Missing derivation path'); + }); + + test('getDerivedWallets returns the stored device address without derivation', async () => { + seedMainWallet(); + await identityManager.addLedgerWallet('My Stax', LEDGER_ADDRESS, LEDGER_PATH); + + const wallets = await identityManager.getDerivedWallets(); + + expect(wallets).toEqual([ + expect.objectContaining({ index: 0, type: 'mnemonic' }), + expect.objectContaining({ + index: HARDWARE_INDEX_BASE, + type: 'ledger', + address: LEDGER_ADDRESS, + path: LEDGER_PATH, + }), + ]); + }); + + test('getWalletRecord normalizes type and exposes the ledger path', async () => { + seedMainWallet(); + await identityManager.addLedgerWallet('My Stax', LEDGER_ADDRESS, LEDGER_PATH); + + expect(identityManager.getWalletRecord(0)).toMatchObject({ type: 'mnemonic' }); + expect(identityManager.getWalletRecord(HARDWARE_INDEX_BASE)).toMatchObject({ + type: 'ledger', + address: LEDGER_ADDRESS, + path: LEDGER_PATH, + }); + expect(identityManager.getWalletRecord(99)).toBeNull(); + }); + + test('getUserWalletKey refuses to derive for a ledger account', async () => { + seedMainWallet(); + await identityManager.addLedgerWallet('My Stax', LEDGER_ADDRESS, LEDGER_PATH); + + await expect(identityManager.getUserWalletKey(HARDWARE_INDEX_BASE)) + .rejects.toThrow('Hardware wallet accounts have no derivable private key'); + }); + + test('getActiveWalletAddress returns the device address for an active ledger account', async () => { + seedMainWallet(); + const wallet = await identityManager.addLedgerWallet('My Stax', LEDGER_ADDRESS, LEDGER_PATH); + await identityManager.setActiveWalletIndex(wallet.index); + + await expect(identityManager.getActiveWalletAddress()).resolves.toBe(LEDGER_ADDRESS); + }); +}); + +/** + * A wallet's `index` is both the account id every persisted reference + * stores (dApp permissions, Swarm publisher identities, activeWalletIndex) + * and — for mnemonic accounts — the BIP-44 account index the key is + * derived at. Hardware accounts must therefore never take an index out of + * the mnemonic range, and a freed hardware index must never be handed to + * another device account: either would silently rebind persisted + * references to a different address and signing backend, and squatting a + * derivation index strands whatever the mnemonic account there holds. + */ +describe('identity-manager wallet index allocation', () => { + let tmpDir; + let envSnapshot; + let identityManager; + + const LEDGER_A = '0x209693Bc6afc0C5328bA36FaF03C514EF312287C'; + const LEDGER_B = '0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913'; + const LEDGER_PATH = "44'/60'/0'/0/0"; + const HARDWARE_INDEX_BASE = 1000000; + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'identity-manager-index-')); + envSnapshot = snapshotEnv(); + process.env.FREEDOM_IDENTITY_DATA = tmpDir; + identityManager = loadMainModule(require.resolve('./identity-manager'), { + userDataDir: tmpDir, + extraMocks: { + [require.resolve('./identity')]: () => ({ + getMnemonic: jest.fn(() => 'test mnemonic'), + isUnlocked: jest.fn(() => true), + deriveUserWallet: jest.fn((_mnemonic, index) => ({ + address: `0xderived${index}`, + })), + }), + [require.resolve('./swarm/feed-store')]: () => ({ + getEthereumWalletIdentityReferences: jest.fn(() => []), + }), + }, + }).mod; + }); + + afterEach(() => { + restoreEnv(envSnapshot); + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + function readVaultMeta() { + return JSON.parse(fs.readFileSync(path.join(tmpDir, 'vault-meta.json'), 'utf-8')); + } + + function seedMainWallet() { + fs.writeFileSync( + path.join(tmpDir, 'vault-meta.json'), + JSON.stringify({ + activeWalletIndex: 0, + addresses: { userWallet: '0xderived0' }, + derivedWallets: [{ index: 0, name: 'Main Wallet', address: '0xderived0' }], + }, null, 2), + 'utf-8' + ); + } + + test('a ledger never takes a freed mnemonic derivation index', async () => { + seedMainWallet(); + const second = await identityManager.createDerivedWallet('Wallet 2'); + expect(second.index).toBe(1); + + await identityManager.deleteDerivedWallet(1); + const ledger = await identityManager.addLedgerWallet('', LEDGER_A, LEDGER_PATH); + + expect(ledger.index).toBe(HARDWARE_INDEX_BASE); + + // Derivation index 1 is still mintable, so funds sent to it before the + // delete stay reachable. + const recreated = await identityManager.createDerivedWallet('Wallet 2 again'); + expect(recreated.index).toBe(1); + expect(recreated.address).toBe(second.address); + expect(identityManager.getWalletRecord(1)).toMatchObject({ type: 'mnemonic' }); + }); + + test('mnemonic accounts keep allocating from the low range once a ledger exists', async () => { + seedMainWallet(); + await identityManager.addLedgerWallet('', LEDGER_A, LEDGER_PATH); + + const next = await identityManager.createDerivedWallet('Wallet 2'); + + expect(next.index).toBe(1); + expect(readVaultMeta().derivedWallets.map((wallet) => wallet.index)) + .toEqual([0, HARDWARE_INDEX_BASE, 1]); + }); + + test('a deleted ledger does not hand its index to the next device account', async () => { + seedMainWallet(); + const first = await identityManager.addLedgerWallet('', LEDGER_A, LEDGER_PATH); + await identityManager.deleteDerivedWallet(first.index); + + const second = await identityManager.addLedgerWallet('', LEDGER_B, LEDGER_PATH); + + expect(second.index).toBe(HARDWARE_INDEX_BASE + 1); + expect(readVaultMeta().nextHardwareWalletIndex).toBe(HARDWARE_INDEX_BASE + 2); + }); + + test('stays above ledger indexes already on disk when the counter is missing', async () => { + fs.writeFileSync( + path.join(tmpDir, 'vault-meta.json'), + JSON.stringify({ + activeWalletIndex: 0, + addresses: { userWallet: '0xderived0' }, + derivedWallets: [ + { index: 0, name: 'Main Wallet', address: '0xderived0' }, + { + index: HARDWARE_INDEX_BASE + 4, + name: 'Ledger 1', + address: LEDGER_A, + type: 'ledger', + path: LEDGER_PATH, + }, + ], + }, null, 2), + 'utf-8' + ); + + const added = await identityManager.addLedgerWallet('', LEDGER_B, LEDGER_PATH); + + expect(added.index).toBe(HARDWARE_INDEX_BASE + 5); + }); + + test('skips a legacy low-index ledger when minting a mnemonic wallet', async () => { + fs.writeFileSync( + path.join(tmpDir, 'vault-meta.json'), + JSON.stringify({ + activeWalletIndex: 0, + addresses: { userWallet: '0xderived0' }, + derivedWallets: [ + { index: 0, name: 'Main Wallet', address: '0xderived0' }, + { index: 1, name: 'Ledger 1', address: LEDGER_A, type: 'ledger', path: LEDGER_PATH }, + ], + }, null, 2), + 'utf-8' + ); + + const next = await identityManager.createDerivedWallet('Wallet 2'); + + expect(next.index).toBe(2); + expect(identityManager.getWalletRecord(1)).toMatchObject({ type: 'ledger' }); + }); }); /** diff --git a/src/main/index.js b/src/main/index.js index 27b87826..c1a04fb5 100644 --- a/src/main/index.js +++ b/src/main/index.js @@ -207,6 +207,7 @@ const { const { registerIdentityIpc, hasVault, setBeeLifecycle } = require('./identity-manager'); const { registerQuickUnlockIpc } = require('./quick-unlock'); const { registerWalletIpc } = require('./wallet/wallet-ipc'); +const { registerLedgerIpc } = require('./wallet/ledger/ipc'); const { registerTokenRegistryIpc } = require('./token-registry'); const { registerRpcManagerIpc } = require('./wallet/rpc-manager'); const { registerNetworkConfigIpc } = require('./networks/network-ipc'); @@ -289,6 +290,7 @@ async function bootstrap() { registerIdentityIpc(); registerQuickUnlockIpc(); registerWalletIpc(); + registerLedgerIpc(); // Let identity (re)injection stop the Bee node before wiping its statestore // (which it holds a LevelDB lock on) and restart it with the new key. Without diff --git a/src/main/preload.js b/src/main/preload.js index e4704578..81d047dd 100644 --- a/src/main/preload.js +++ b/src/main/preload.js @@ -441,6 +441,11 @@ contextBridge.exposeInMainWorld('wallet', { ipcRenderer.invoke('wallet:proxy-rpc', { rpcUrl, method, params }), }); +contextBridge.exposeInMainWorld('ledger', { + getAccounts: (options) => ipcRenderer.invoke('ledger:get-accounts', options), + addAccount: (name, address, path) => ipcRenderer.invoke('wallet:add-ledger-wallet', name, address, path), +}); + contextBridge.exposeInMainWorld('swarmNode', { getStamps: () => ipcRenderer.invoke('swarm:get-stamps'), getStorageCost: (sizeGB, durationDays) => diff --git a/src/main/preload.test.js b/src/main/preload.test.js index a47cb3c5..4056b8b7 100644 --- a/src/main/preload.test.js +++ b/src/main/preload.test.js @@ -83,7 +83,7 @@ describe('preload', () => { beeApiEnv: 'http://127.0.0.1:1700', }); - expect(contextBridge.exposeInMainWorld).toHaveBeenCalledTimes(23); + expect(contextBridge.exposeInMainWorld).toHaveBeenCalledTimes(24); expect(Object.keys(exposures)).toEqual([ 'nodeConfig', 'internalPages', @@ -96,6 +96,7 @@ describe('preload', () => { 'identity', 'quickUnlock', 'wallet', + 'ledger', 'swarmNode', 'networks', 'payments', diff --git a/src/main/swarm/feed-store.js b/src/main/swarm/feed-store.js index 1c5665cb..e4c7c88b 100644 --- a/src/main/swarm/feed-store.js +++ b/src/main/swarm/feed-store.js @@ -28,7 +28,12 @@ const path = require('path'); const fs = require('fs'); const IPC = require('../../shared/ipc-channels'); const { normalizeOrigin } = require('../../shared/origin-utils'); -const { getDerivedKeys, getPublisherKey, getDerivedWallets } = require('../identity-manager'); +const { + getDerivedKeys, + getPublisherKey, + getDerivedWallets, + WALLET_TYPES, +} = require('../identity-manager'); const log = require('electron-log'); const FEEDS_FILE = 'swarm-feeds.json'; @@ -49,6 +54,19 @@ class PreserveFeedStoreError extends Error { } } +/** + * Swarm feed/SOC signing needs a raw private key (see + * swarm-provider-ipc.js resolveSignerKey), which hardware accounts can + * never hand out — the device signs, it does not export. Such wallets are + * therefore not usable as publisher identities and must never be offered + * or persisted as one. + * @param {{type?: string}} wallet + * @returns {boolean} + */ +function isSwarmSignableWallet(wallet) { + return !!wallet && (wallet.type || WALLET_TYPES.MNEMONIC) === WALLET_TYPES.MNEMONIC; +} + function getFeedsPath() { return path.join(app.getPath('userData'), FEEDS_FILE); } @@ -527,7 +545,10 @@ async function enrichIdentityOwner(identity, options = {}) { label: wallet?.name || identity.label || getIdentityLabel('ethereum-wallet', null, identity.walletIndex), owner: wallet?.address || null, stored, - unavailable: !wallet, + // Hardware-backed accounts can't sign feeds; flag them so an entry + // persisted before this guard existed renders as unusable instead of + // failing with an opaque error on every feed write. + unavailable: !wallet || !isSwarmSignableWallet(wallet), }; } @@ -566,7 +587,7 @@ async function getOriginIdentityStateWithOwners(origin) { const knownIds = new Set(identities.map((identity) => identity.id)); const wallets = await getDerivedWallets(); - for (const wallet of wallets) { + for (const wallet of wallets.filter(isSwarmSignableWallet)) { const walletIdentity = createIdentity( 'ethereum-wallet', null, @@ -656,6 +677,12 @@ async function ensureEthereumWalletIdentity(origin, walletIndex, options = {}) { if (!wallet) { throw new Error(`Wallet with index ${walletIndex} does not exist`); } + if (!isSwarmSignableWallet(wallet)) { + throw new Error( + 'Hardware wallet accounts cannot be used as a Swarm publisher identity. ' + + 'Choose an app-scoped identity or a wallet account held in this browser.' + ); + } const store = loadFeeds(); const key = normalizeOrigin(origin); diff --git a/src/main/swarm/feed-store.test.js b/src/main/swarm/feed-store.test.js index 8bc2dc38..f90e5246 100644 --- a/src/main/swarm/feed-store.test.js +++ b/src/main/swarm/feed-store.test.js @@ -27,6 +27,7 @@ jest.mock('../identity-manager', () => ({ getDerivedKeys: (...args) => mockGetDerivedKeys(...args), getPublisherKey: (...args) => mockGetPublisherKey(...args), getDerivedWallets: (...args) => mockGetDerivedWallets(...args), + WALLET_TYPES: { MNEMONIC: 'mnemonic', LEDGER: 'ledger' }, })); const { app } = require('electron'); @@ -49,6 +50,7 @@ const { setOriginEntry, allocatePublisherKeyIndex, getOriginIdentityState, + getOriginIdentityStateWithOwners, previewAppScopedIdentity, createAppScopedIdentity, ensureAntWalletIdentity, @@ -252,6 +254,53 @@ describe('feed-store', () => { .rejects.toThrow('Wallet with index 99 does not exist'); }); + test('ensureEthereumWalletIdentity rejects hardware wallet accounts', async () => { + mockGetDerivedWallets.mockResolvedValue([ + { index: 0, name: 'Main Wallet', address: '0xWallet000000000000000000000000000000000000', type: 'mnemonic' }, + { index: 1, name: 'Ledger 1', address: '0xLedger11111111111111111111111111111111111', type: 'ledger' }, + ]); + + await expect(ensureEthereumWalletIdentity('myapp.eth', 1, { activate: true })) + .rejects.toThrow('Hardware wallet accounts cannot be used as a Swarm publisher identity'); + // Nothing persisted: the origin never gets a broken active identity. + expect(getOriginEntry('myapp.eth')).toBeNull(); + }); + + test('hardware wallet accounts are not offered as publisher identities', async () => { + mockGetDerivedWallets.mockResolvedValue([ + { index: 0, name: 'Main Wallet', address: '0xWallet000000000000000000000000000000000000', type: 'mnemonic' }, + { index: 1, name: 'Ledger 1', address: '0xLedger11111111111111111111111111111111111', type: 'ledger' }, + ]); + setOriginEntry('myapp.eth', { identityMode: 'app-scoped', publisherKeyIndex: 0 }); + + const state = await getOriginIdentityStateWithOwners('myapp.eth'); + const walletIndexes = state.identities + .filter((identity) => identity.mode === 'ethereum-wallet') + .map((identity) => identity.walletIndex); + + expect(walletIndexes).toEqual([0]); + }); + + test('an already-stored hardware wallet identity is reported as unavailable', async () => { + mockGetDerivedWallets.mockResolvedValue([ + { index: 0, name: 'Main Wallet', address: '0xWallet000000000000000000000000000000000000', type: 'mnemonic' }, + { index: 1, name: 'Ledger 1', address: '0xLedger11111111111111111111111111111111111', type: 'mnemonic' }, + ]); + setOriginEntry('myapp.eth', { identityMode: 'app-scoped', publisherKeyIndex: 0 }); + await ensureEthereumWalletIdentity('myapp.eth', 1, { activate: true }); + + // The account later turns out to be (or is replaced by) a Ledger one. + mockGetDerivedWallets.mockResolvedValue([ + { index: 0, name: 'Main Wallet', address: '0xWallet000000000000000000000000000000000000', type: 'mnemonic' }, + { index: 1, name: 'Ledger 1', address: '0xLedger11111111111111111111111111111111111', type: 'ledger' }, + ]); + + const state = await getOriginIdentityStateWithOwners('myapp.eth'); + const stored = state.identities.find((identity) => identity.id === 'ethereum-wallet:1'); + + expect(stored).toMatchObject({ unavailable: true }); + }); + test('getEthereumWalletIdentityReferences lists active and feed-pinned identities', async () => { setOriginEntry('myapp.eth', { identityMode: 'app-scoped', publisherKeyIndex: 0 }); await ensureEthereumWalletIdentity('myapp.eth', 2, { activate: true }); diff --git a/src/main/wallet/dapp-permissions.js b/src/main/wallet/dapp-permissions.js index 2385c54d..c1099927 100644 --- a/src/main/wallet/dapp-permissions.js +++ b/src/main/wallet/dapp-permissions.js @@ -124,6 +124,44 @@ function revokePermission(origin) { return false; } +/** + * Revoke every permission bound to a wallet index. + * + * Called when an account is deleted: a permission is a standing + * authorisation to sign with one specific account, so it cannot outlive + * that account. Left behind, the stored `walletIndex` becomes a dangling + * reference — for a deleted hardware account it points at an index with + * no device and no vault key, for a deleted mnemonic account at an + * account the user believes is gone. Either way the origin keeps its + * auto-approve rules and would sign against an account the user can no + * longer see. Origins must reconnect and pick a live account. + * + * @param {number} walletIndex + * @returns {string[]} Origins whose permission was revoked + */ +function revokePermissionsForWalletIndex(walletIndex) { + const permissions = loadPermissions(); + const revoked = Object.keys(permissions).filter( + (origin) => permissions[origin]?.walletIndex === walletIndex + ); + + if (revoked.length === 0) { + return []; + } + + for (const origin of revoked) { + delete permissions[origin]; + } + permissionsCache = permissions; + savePermissions(); + + console.log( + `[DAppPermissions] Revoked ${revoked.length} permission(s) for deleted wallet ${walletIndex}:`, + revoked.join(', ') + ); + return revoked; +} + /** * Get all granted permissions * @returns {Object[]} Array of permission objects @@ -366,6 +404,7 @@ module.exports = { getPermission, grantPermission, revokePermission, + revokePermissionsForWalletIndex, getAllPermissions, updateLastUsed, updateWalletIndex, diff --git a/src/main/wallet/dapp-permissions.test.js b/src/main/wallet/dapp-permissions.test.js index ddcbc864..0290ea27 100644 --- a/src/main/wallet/dapp-permissions.test.js +++ b/src/main/wallet/dapp-permissions.test.js @@ -31,6 +31,7 @@ const { getPermission, grantPermission, revokePermission, + revokePermissionsForWalletIndex, getAllPermissions, _resetCache, } = require('./dapp-permissions'); @@ -159,3 +160,34 @@ describe('dapp-permissions — basic CRUD round-trip', () => { expect(getPermission('ens://vitalik.eth')).toBeNull(); }); }); + +describe('dapp-permissions — revoke by wallet index', () => { + const LEDGER_INDEX = 1000000; + + test('drops every origin bound to the deleted account and keeps the rest', () => { + grantPermission('https://swap.example', LEDGER_INDEX, 8453); + grantPermission('vitalik.eth', LEDGER_INDEX, 1); + grantPermission('https://other.example', 0, 1); + + const revoked = revokePermissionsForWalletIndex(LEDGER_INDEX); + + expect(revoked.sort()).toEqual(['https://swap.example', 'vitalik.eth']); + expect(getPermission('https://swap.example')).toBeNull(); + expect(getPermission('vitalik.eth')).toBeNull(); + expect(getPermission('https://other.example')).not.toBeNull(); + }); + + test('persists the removal to disk, so a stale index cannot survive a restart', () => { + grantPermission('https://swap.example', LEDGER_INDEX, 8453); + revokePermissionsForWalletIndex(LEDGER_INDEX); + + _resetCache(); + expect(getPermission('https://swap.example')).toBeNull(); + }); + + test('is a no-op when no origin uses the index', () => { + grantPermission('https://other.example', 0, 1); + expect(revokePermissionsForWalletIndex(7)).toEqual([]); + expect(getPermission('https://other.example')).not.toBeNull(); + }); +}); diff --git a/src/main/wallet/ledger/errors.js b/src/main/wallet/ledger/errors.js new file mode 100644 index 00000000..230d43d1 --- /dev/null +++ b/src/main/wallet/ledger/errors.js @@ -0,0 +1,129 @@ +/** + * Ledger error mapping. + * + * Transport and app errors from `@ledgerhq/*` come as TransportStatusError + * (with a `statusCode` APDU status word), named transport errors, or plain + * node-hid failures. Map them to stable machine codes + user-facing + * messages so the renderer can drive the connect/sign UX ("unlock your + * Ledger", "open the Ethereum app", "request rejected on device") without + * string-matching library internals. + */ + +const LEDGER_ERROR_CODES = { + DEVICE_NOT_FOUND: 'LEDGER_DEVICE_NOT_FOUND', + DEVICE_LOCKED: 'LEDGER_DEVICE_LOCKED', + ETH_APP_NOT_OPEN: 'LEDGER_ETH_APP_NOT_OPEN', + USER_REJECTED: 'LEDGER_USER_REJECTED', + DISCONNECTED: 'LEDGER_DISCONNECTED', + BUSY: 'LEDGER_BUSY', + WRONG_DEVICE: 'LEDGER_WRONG_DEVICE', + BLIND_SIGNING_REQUIRED: 'LEDGER_BLIND_SIGNING_REQUIRED', + UNKNOWN: 'LEDGER_UNKNOWN', +}; + +const MESSAGES = { + [LEDGER_ERROR_CODES.DEVICE_NOT_FOUND]: 'No Ledger device found. Connect it via USB and unlock it.', + [LEDGER_ERROR_CODES.DEVICE_LOCKED]: 'Ledger is locked. Unlock it with your PIN.', + [LEDGER_ERROR_CODES.ETH_APP_NOT_OPEN]: 'Open the Ethereum app on your Ledger.', + [LEDGER_ERROR_CODES.USER_REJECTED]: 'Request rejected on the Ledger device.', + [LEDGER_ERROR_CODES.DISCONNECTED]: 'Ledger was disconnected. Reconnect it and try again.', + [LEDGER_ERROR_CODES.BUSY]: 'Ledger is busy with another request. Finish or dismiss it on the device.', + [LEDGER_ERROR_CODES.WRONG_DEVICE]: + 'This Ledger does not hold the selected account. Connect the device this account was added from.', + [LEDGER_ERROR_CODES.BLIND_SIGNING_REQUIRED]: + 'Ledger cannot display this contract call. Enable "Blind signing" (called "Contract data" on older app versions) in the Ethereum app settings, then try again.', + [LEDGER_ERROR_CODES.UNKNOWN]: 'Ledger error. Reconnect the device and try again.', +}; + +/** + * Mint an error from the code registry — the only way LEDGER_* errors + * should be created outside of `mapLedgerError`. + * + * @param {string} code - One of LEDGER_ERROR_CODES + * @returns {Error & {code: string}} + */ +function createLedgerError(code) { + const err = new Error(MESSAGES[code] || MESSAGES[LEDGER_ERROR_CODES.UNKNOWN]); + err.code = code; + return err; +} + +// APDU status words (TransportStatusError.statusCode) +const STATUS_TO_CODE = { + 0x5515: LEDGER_ERROR_CODES.DEVICE_LOCKED, + 0x6982: LEDGER_ERROR_CODES.DEVICE_LOCKED, // security not satisfied (locked mid-session) + 0x6985: LEDGER_ERROR_CODES.USER_REJECTED, // conditions of use not satisfied + 0x5501: LEDGER_ERROR_CODES.USER_REJECTED, // user refused on device + // "incorrect data" from the Ethereum app while signing means it refused + // to sign data it cannot decode: blind signing is off. Unavoidable here + // because we never resolve clear-signing metadata (see signer.js), and + // it is the factory default, so it needs its own actionable message. + 0x6a80: LEDGER_ERROR_CODES.BLIND_SIGNING_REQUIRED, + 0x6511: LEDGER_ERROR_CODES.ETH_APP_NOT_OPEN, // app not started + 0x6d00: LEDGER_ERROR_CODES.ETH_APP_NOT_OPEN, // INS not supported (wrong app) + 0x6e00: LEDGER_ERROR_CODES.ETH_APP_NOT_OPEN, // CLA not supported (wrong app) + 0x6e01: LEDGER_ERROR_CODES.ETH_APP_NOT_OPEN, +}; + +// Named errors from @ledgerhq/errors / node-hid +const NAME_TO_CODE = { + TransportOpenUserCancelled: LEDGER_ERROR_CODES.DEVICE_NOT_FOUND, + CantOpenDevice: LEDGER_ERROR_CODES.DEVICE_NOT_FOUND, + NoDeviceFound: LEDGER_ERROR_CODES.DEVICE_NOT_FOUND, + DisconnectedDevice: LEDGER_ERROR_CODES.DISCONNECTED, + DisconnectedDeviceDuringOperation: LEDGER_ERROR_CODES.DISCONNECTED, + TransportRaceCondition: LEDGER_ERROR_CODES.BUSY, + TransportInterfaceNotAvailable: LEDGER_ERROR_CODES.BUSY, + LockedDeviceError: LEDGER_ERROR_CODES.DEVICE_LOCKED, + // hw-app-eth rewrites 0x6a80 from the tx-signing path into this named + // error (it carries no statusCode of its own). + EthAppPleaseEnableContractData: LEDGER_ERROR_CODES.BLIND_SIGNING_REQUIRED, +}; + +/** + * Classify a raw `@ledgerhq/*` / node-hid error into a stable code. + * + * @param {unknown} err + * @returns {string} One of LEDGER_ERROR_CODES + */ +function classifyLedgerError(err) { + if (!err || typeof err !== 'object') return LEDGER_ERROR_CODES.UNKNOWN; + if (typeof err.statusCode === 'number' && STATUS_TO_CODE[err.statusCode]) { + return STATUS_TO_CODE[err.statusCode]; + } + if (err.name && NAME_TO_CODE[err.name]) { + return NAME_TO_CODE[err.name]; + } + const message = String(err.message || ''); + if (/cannot open device|no device/i.test(message)) { + return LEDGER_ERROR_CODES.DEVICE_NOT_FOUND; + } + if (/disconnected/i.test(message)) { + return LEDGER_ERROR_CODES.DISCONNECTED; + } + return LEDGER_ERROR_CODES.UNKNOWN; +} + +/** + * Wrap a raw Ledger error into an Error with a stable `.code` and a + * user-facing message. Errors that already carry a LEDGER_* code pass + * through unchanged. + * + * @param {unknown} err + * @returns {Error & {code: string}} + */ +function mapLedgerError(err) { + if (err && typeof err === 'object' && typeof err.code === 'string' && err.code.startsWith('LEDGER_')) { + return err; + } + const code = classifyLedgerError(err); + const mapped = new Error(MESSAGES[code], { cause: err }); + mapped.code = code; + return mapped; +} + +function isLedgerUserRejection(err) { + return mapLedgerError(err).code === LEDGER_ERROR_CODES.USER_REJECTED; +} + +module.exports = { LEDGER_ERROR_CODES, createLedgerError, mapLedgerError, isLedgerUserRejection }; diff --git a/src/main/wallet/ledger/errors.test.js b/src/main/wallet/ledger/errors.test.js new file mode 100644 index 00000000..2acca54e --- /dev/null +++ b/src/main/wallet/ledger/errors.test.js @@ -0,0 +1,95 @@ +const { LEDGER_ERROR_CODES, createLedgerError, mapLedgerError, isLedgerUserRejection } = require('./errors'); + +function statusError(statusCode) { + const err = new Error(`status ${statusCode.toString(16)}`); + err.statusCode = statusCode; + return err; +} + +function namedError(name) { + const err = new Error(name); + err.name = name; + return err; +} + +describe('mapLedgerError', () => { + test.each([ + [0x6985, LEDGER_ERROR_CODES.USER_REJECTED], + [0x5501, LEDGER_ERROR_CODES.USER_REJECTED], + [0x5515, LEDGER_ERROR_CODES.DEVICE_LOCKED], + [0x6982, LEDGER_ERROR_CODES.DEVICE_LOCKED], + [0x6511, LEDGER_ERROR_CODES.ETH_APP_NOT_OPEN], + [0x6d00, LEDGER_ERROR_CODES.ETH_APP_NOT_OPEN], + [0x6e00, LEDGER_ERROR_CODES.ETH_APP_NOT_OPEN], + [0x6a80, LEDGER_ERROR_CODES.BLIND_SIGNING_REQUIRED], + ])('maps APDU status 0x%s to a stable code', (statusCode, expected) => { + const mapped = mapLedgerError(statusError(statusCode)); + expect(mapped.code).toBe(expected); + expect(mapped.message).not.toMatch(/status/); // user-facing, not raw + expect(mapped.cause).toBeDefined(); + }); + + test.each([ + ['CantOpenDevice', LEDGER_ERROR_CODES.DEVICE_NOT_FOUND], + ['DisconnectedDeviceDuringOperation', LEDGER_ERROR_CODES.DISCONNECTED], + ['TransportRaceCondition', LEDGER_ERROR_CODES.BUSY], + ['LockedDeviceError', LEDGER_ERROR_CODES.DEVICE_LOCKED], + ])('maps named transport error %s', (name, expected) => { + expect(mapLedgerError(namedError(name)).code).toBe(expected); + }); + + // Signing without hosted clear-signing resolution means a factory- + // default device (blind signing off) refuses every contract call — + // the instruction has to name the setting, not "reconnect the device". + test('blind-signing refusals tell the user which setting to enable', () => { + const mapped = mapLedgerError(statusError(0x6a80)); + expect(mapped.code).toBe(LEDGER_ERROR_CODES.BLIND_SIGNING_REQUIRED); + expect(mapped.message).toMatch(/blind signing/i); + expect(mapped.message).not.toMatch(/reconnect/i); + }); + + test('maps the blind-signing error class hw-app-eth actually throws', () => { + // The tx-signing path rewrites 0x6a80 into this named error, which + // carries no statusCode of its own. + const { EthAppPleaseEnableContractData } = require('@ledgerhq/hw-app-eth/lib/errors'); + const err = new EthAppPleaseEnableContractData( + 'Please enable Blind signing or Contract data in the Ethereum app Settings' + ); + expect(err.statusCode).toBeUndefined(); + expect(mapLedgerError(err).code).toBe(LEDGER_ERROR_CODES.BLIND_SIGNING_REQUIRED); + }); + + test('maps node-hid "cannot open device" message', () => { + expect(mapLedgerError(new Error('cannot open device with path X')).code) + .toBe(LEDGER_ERROR_CODES.DEVICE_NOT_FOUND); + }); + + test('unknown errors get the UNKNOWN code, never throw', () => { + expect(mapLedgerError(new Error('wat')).code).toBe(LEDGER_ERROR_CODES.UNKNOWN); + expect(mapLedgerError(undefined).code).toBe(LEDGER_ERROR_CODES.UNKNOWN); + expect(mapLedgerError('string').code).toBe(LEDGER_ERROR_CODES.UNKNOWN); + }); + + test('already-mapped errors pass through unchanged', () => { + const mapped = mapLedgerError(statusError(0x6985)); + expect(mapLedgerError(mapped)).toBe(mapped); + }); +}); + +describe('createLedgerError', () => { + test('mints an error with the registry code and message', () => { + const err = createLedgerError(LEDGER_ERROR_CODES.WRONG_DEVICE); + expect(err.code).toBe('LEDGER_WRONG_DEVICE'); + expect(err.message).toMatch(/does not hold/i); + // Round-trips through mapLedgerError unchanged (LEDGER_ prefix pass-through) + expect(mapLedgerError(err)).toBe(err); + }); +}); + +describe('isLedgerUserRejection', () => { + test('true only for user rejection', () => { + expect(isLedgerUserRejection(statusError(0x6985))).toBe(true); + expect(isLedgerUserRejection(statusError(0x6511))).toBe(false); + expect(isLedgerUserRejection(new Error('x'))).toBe(false); + }); +}); diff --git a/src/main/wallet/ledger/ipc.js b/src/main/wallet/ledger/ipc.js new file mode 100644 index 00000000..9013b6a2 --- /dev/null +++ b/src/main/wallet/ledger/ipc.js @@ -0,0 +1,29 @@ +/** + * Ledger IPC handlers. + * + * Device discovery for the "Connect hardware wallet" flow. Adding the + * chosen account to the wallet list goes through identity-manager's + * `wallet:add-ledger-wallet` handler, next to the other wallet-list + * mutations. + */ + +const { ipcMain } = require('electron'); +const { listAccounts } = require('./transport'); + +function registerLedgerIpc() { + // Requires an attached, unlocked device with the Ethereum app open; + // errors carry a stable LEDGER_* code the renderer turns into + // instructions ("plug it in", "open the app", …). The connect screen + // polls this while waiting for the user to get the device ready. + ipcMain.handle('ledger:get-accounts', async (_event, options = {}) => { + try { + const accounts = await listAccounts(options); + return { success: true, accounts }; + } catch (err) { + console.error('[LedgerIPC] Account discovery failed:', err.message); + return { success: false, error: err.message, code: err.code }; + } + }); +} + +module.exports = { registerLedgerIpc }; diff --git a/src/main/wallet/ledger/signer.js b/src/main/wallet/ledger/signer.js new file mode 100644 index 00000000..1b503d49 --- /dev/null +++ b/src/main/wallet/ledger/signer.js @@ -0,0 +1,120 @@ +/** + * Ledger signing backend for the wallet signer factory. + * + * getAddress serves the address stored on the account record (read from + * the device when the account was added) — no device round-trip, no vault. + * + * Every signing method opens the device (serialized via withEthApp), + * verifies the attached device still derives the account's address at + * the stored path (a different Ledger with a different seed would + * otherwise silently sign from an address the user never approved), + * then asks for an on-device confirmation. Errors surface with stable + * LEDGER_* codes (see ./errors.js) — user rejection included. + * + * Transactions and typed data are signed WITHOUT @ledgerhq's hosted + * clear-signing resolution: resolving token/plugin/EIP-712 metadata posts + * the transaction contents (or the typed-data schema hash, chain id and + * verifying contract) to Ledger's registry before signing, which violates + * this project's rule against routing user data through hosted services. + * `signTransaction` passes a null resolution; the typed-data path is + * covered by the offline load config the transport builds Eth with. + * Consequence: plain ETH transfers display normally on the device, but + * contract calls (e.g. ERC-20 transfers) show as raw data and may + * require "blind signing" to be enabled in the device's Ethereum app. + * An opt-in setting (or a bundled offline token registry) can lift this + * later — that decision belongs to the user, not this module. + */ + +const { Transaction, Signature, TypedDataEncoder } = require('ethers'); + +const { withEthApp } = require('./transport'); +const { LEDGER_ERROR_CODES, createLedgerError } = require('./errors'); + +/** hw-app-eth returns r/s as bare hex and v as hex string or number. */ +function toEthersSignature(sig) { + return Signature.from({ + r: '0x' + sig.r, + s: '0x' + sig.s, + v: typeof sig.v === 'string' ? BigInt('0x' + sig.v) : BigInt(sig.v), + }); +} + +/** Types with EIP712Domain stripped, as ethers' hashing helpers expect. */ +function withoutDomainType(types) { + const stripped = { ...types }; + delete stripped.EIP712Domain; + return stripped; +} + +/** + * @param {{address: string, path: string}} record - Ledger wallet record from vault-meta + * @returns {import('../signers').Signer} + */ +function createLedgerBackend(record) { + /** + * Run a device task after verifying the attached Ledger derives this + * account's address at the stored path — signing with a different + * device/seed must fail, not silently produce a foreign signature. + */ + const withVerifiedDevice = (task) => + withEthApp(async (eth) => { + const { address } = await eth.getAddress(record.path, false); + if (address.toLowerCase() !== record.address.toLowerCase()) { + throw createLedgerError(LEDGER_ERROR_CODES.WRONG_DEVICE); + } + return task(eth); + }); + + return { + getAddress: async () => record.address, + + signTransaction: async (tx) => { + // Serialize outside the device session — nothing here needs the + // transport, and withEthApp holds the exclusive device queue. + const unsigned = Transaction.from(tx); + const rawHex = unsigned.unsignedSerialized.slice(2); + + return withVerifiedDevice(async (eth) => { + const sig = await eth.signTransaction(record.path, rawHex, null); + unsigned.signature = toEthersSignature(sig); + return unsigned.serialized; + }); + }, + + signMessage: async (message) => { + const messageHex = Buffer.isBuffer(message) + ? message.toString('hex') + : Buffer.from(String(message), 'utf8').toString('hex'); + + return withVerifiedDevice(async (eth) => { + const sig = await eth.signPersonalMessage(record.path, messageHex); + return toEthersSignature(sig).serialized; + }); + }, + + signTypedData: async (typedData) => { + // The device wants the full EIP-712 wire payload (EIP712Domain in + // types + explicit primaryType); ethers-style callers may omit + // both, and getPayload reconstructs them canonically. + const strippedTypes = withoutDomainType(typedData.types); + const payload = TypedDataEncoder.getPayload(typedData.domain || {}, strippedTypes, typedData.message); + + return withVerifiedDevice(async (eth) => { + let sig; + try { + sig = await eth.signEIP712Message(record.path, payload); + } catch (err) { + // Older Ethereum apps only support signing the pre-hashed + // struct (no field-by-field review). 0x6d00 = INS not supported. + if (err?.statusCode !== 0x6d00) throw err; + const domainSeparator = TypedDataEncoder.hashDomain(payload.domain); + const structHash = TypedDataEncoder.from(strippedTypes).hash(typedData.message); + sig = await eth.signEIP712HashedMessage(record.path, domainSeparator.slice(2), structHash.slice(2)); + } + return toEthersSignature(sig).serialized; + }); + }, + }; +} + +module.exports = { createLedgerBackend }; diff --git a/src/main/wallet/ledger/signer.test.js b/src/main/wallet/ledger/signer.test.js new file mode 100644 index 00000000..7238c554 --- /dev/null +++ b/src/main/wallet/ledger/signer.test.js @@ -0,0 +1,208 @@ +/** + * The fake device signs with a real (well-known test) key, so every + * assertion verifies actual cryptographic round-trips: what the backend + * hands to hw-app-eth, and that the reassembled signature recovers to + * the device address. + */ + +const { + Wallet, + Transaction, + Signature, + TypedDataEncoder, + verifyMessage, + verifyTypedData, + hashMessage, +} = require('ethers'); + +// Anvil/Hardhat-default test key — well-known, never funded on mainnet. +const TEST_PRIVATE_KEY = '0x59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d'; +const deviceWallet = new Wallet(TEST_PRIVATE_KEY); + +const RECORD = { + index: 2, + name: 'My Stax', + address: deviceWallet.address, + type: 'ledger', + path: "44'/60'/0'/0/0", +}; + +/** Split an ethers Signature into the {r, s, v} shape hw-app-eth returns. */ +function toDeviceSig(signature, { vAsHexString = false } = {}) { + const sig = Signature.from(signature); + return { + r: sig.r.slice(2), + s: sig.s.slice(2), + v: vAsHexString ? sig.v.toString(16) : sig.v, + }; +} + +// Fake hw-app-eth instance backed by the test key. +const mockEth = { + getAddress: jest.fn(), + signTransaction: jest.fn(), + signPersonalMessage: jest.fn(), + signEIP712Message: jest.fn(), + signEIP712HashedMessage: jest.fn(), +}; + +jest.mock('./transport', () => ({ + withEthApp: (task) => task(mockEth), +})); + +const { createLedgerBackend } = require('./signer'); + +beforeEach(() => { + mockEth.getAddress.mockReset().mockResolvedValue({ address: deviceWallet.address }); + mockEth.signTransaction.mockReset(); + mockEth.signPersonalMessage.mockReset(); + mockEth.signEIP712Message.mockReset(); + mockEth.signEIP712HashedMessage.mockReset(); +}); + +describe('createLedgerBackend / signTransaction', () => { + const TX = { + to: '0x209693Bc6afc0C5328bA36FaF03C514EF312287C', + value: '1000', + gasLimit: '21000', + maxFeePerGas: '2000000000', + maxPriorityFeePerGas: '1000000000', + nonce: 7, + chainId: 8453, + type: 2, + }; + + test('sends the unsigned serialization to the device and reassembles a valid signed tx', async () => { + mockEth.signTransaction.mockImplementation(async (path, rawHex) => { + expect(path).toBe(RECORD.path); + // The device signs exactly what ethers considers the unsigned tx. + expect('0x' + rawHex).toBe(Transaction.from(TX).unsignedSerialized); + const signed = Transaction.from(await deviceWallet.signTransaction(TX)); + return toDeviceSig(signed.signature, { vAsHexString: true }); + }); + + const backend = createLedgerBackend(RECORD); + const signedTx = await backend.signTransaction(TX); + + const parsed = Transaction.from(signedTx); + expect(parsed.from).toBe(deviceWallet.address); + expect(parsed.nonce).toBe(7); + expect(parsed.chainId).toBe(8453n); + }); + + test('refuses to sign when the attached device derives a different address', async () => { + mockEth.getAddress.mockResolvedValue({ address: '0x0000000000000000000000000000000000000bad' }); + + const backend = createLedgerBackend(RECORD); + await expect(backend.signTransaction(TX)).rejects.toMatchObject({ code: 'LEDGER_WRONG_DEVICE' }); + expect(mockEth.signTransaction).not.toHaveBeenCalled(); + }); + + test('surfaces on-device rejection with its stable code', async () => { + const rejection = new Error('denied'); + rejection.statusCode = 0x6985; + mockEth.signTransaction.mockRejectedValue(rejection); + + const backend = createLedgerBackend(RECORD); + // withEthApp is mocked pass-through here, so the raw APDU error + // surfaces; in production the transport queue maps it. Assert the + // status code is preserved for that mapping. + await expect(backend.signTransaction(TX)).rejects.toMatchObject({ statusCode: 0x6985 }); + }); +}); + +describe('createLedgerBackend / signMessage', () => { + beforeEach(() => { + mockEth.signPersonalMessage.mockImplementation(async (path, messageHex) => { + expect(path).toBe(RECORD.path); + const bytes = Buffer.from(messageHex, 'hex'); + const sig = deviceWallet.signingKey.sign(hashMessage(bytes)); + return toDeviceSig(sig); + }); + }); + + test('signs UTF-8 string messages (EIP-191)', async () => { + const backend = createLedgerBackend(RECORD); + const signature = await backend.signMessage('hello freedom'); + expect(verifyMessage('hello freedom', signature)).toBe(deviceWallet.address); + }); + + test('signs raw bytes (pre-normalized 0x-hex dApp messages)', async () => { + const backend = createLedgerBackend(RECORD); + const bytes = Buffer.from('fffefd00010203deadbeef', 'hex'); + const signature = await backend.signMessage(bytes); + expect(verifyMessage(bytes, signature)).toBe(deviceWallet.address); + }); +}); + +describe('createLedgerBackend / signTypedData', () => { + const domain = { + name: 'USD Coin', + version: '2', + chainId: 8453, + verifyingContract: '0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913', + }; + const types = { + TransferWithAuthorization: [ + { name: 'from', type: 'address' }, + { name: 'to', type: 'address' }, + { name: 'value', type: 'uint256' }, + ], + }; + const message = { + from: deviceWallet.address, + to: '0x209693Bc6afc0C5328bA36FaF03C514EF312287C', + value: 10000n, + }; + + function deviceTypedSig() { + const sig = deviceWallet.signingKey.sign(TypedDataEncoder.hash(domain, types, message)); + return toDeviceSig(sig); + } + + test('passes the full payload (EIP712Domain + primaryType) to the device', async () => { + mockEth.signEIP712Message.mockImplementation(async (path, payload) => { + expect(path).toBe(RECORD.path); + expect(payload.primaryType).toBe('TransferWithAuthorization'); + expect(payload.types.EIP712Domain).toEqual([ + { name: 'name', type: 'string' }, + { name: 'version', type: 'string' }, + { name: 'chainId', type: 'uint256' }, + { name: 'verifyingContract', type: 'address' }, + ]); + return deviceTypedSig(); + }); + + const backend = createLedgerBackend(RECORD); + // ethers-style payload: no EIP712Domain in types, no primaryType — + // the backend must fill both in for the device. + const signature = await backend.signTypedData({ domain, types, message }); + expect(verifyTypedData(domain, types, message, signature)).toBe(deviceWallet.address); + }); + + test('falls back to hashed-message signing on apps without full EIP-712 support', async () => { + const noFullSupport = new Error('INS not supported'); + noFullSupport.statusCode = 0x6d00; + mockEth.signEIP712Message.mockRejectedValue(noFullSupport); + mockEth.signEIP712HashedMessage.mockImplementation(async (path, domainSeparatorHex, structHashHex) => { + expect('0x' + domainSeparatorHex).toBe(TypedDataEncoder.hashDomain(domain)); + expect('0x' + structHashHex).toBe(TypedDataEncoder.from(types).hash(message)); + return deviceTypedSig(); + }); + + const backend = createLedgerBackend(RECORD); + const signature = await backend.signTypedData({ domain, types, primaryType: 'TransferWithAuthorization', message }); + expect(verifyTypedData(domain, types, message, signature)).toBe(deviceWallet.address); + expect(mockEth.signEIP712HashedMessage).toHaveBeenCalledTimes(1); + }); + + test('non-fallback device errors propagate', async () => { + const rejection = new Error('denied'); + rejection.statusCode = 0x6985; + mockEth.signEIP712Message.mockRejectedValue(rejection); + + const backend = createLedgerBackend(RECORD); + await expect(backend.signTypedData({ domain, types, message })).rejects.toMatchObject({ statusCode: 0x6985 }); + expect(mockEth.signEIP712HashedMessage).not.toHaveBeenCalled(); + }); +}); diff --git a/src/main/wallet/ledger/transport.js b/src/main/wallet/ledger/transport.js new file mode 100644 index 00000000..d6cf387b --- /dev/null +++ b/src/main/wallet/ledger/transport.js @@ -0,0 +1,131 @@ +/** + * Ledger device transport (main process). + * + * Wraps `@ledgerhq/hw-transport-node-hid` + `@ledgerhq/hw-app-eth` behind + * a small API. The device speaks one APDU exchange at a time, so all + * access is serialized through a queue — concurrent IPC calls (e.g. a + * dApp signing request racing account discovery) wait their turn instead + * of corrupting the exchange. + * + * The transport is opened per operation and closed afterwards: cheap, + * and it keeps the device usable by other apps (Ledger Live) between + * our calls. + * + * Every Eth instance is built with hosted clear-signing DISABLED (see + * OFFLINE_LOAD_CONFIG) — same rule as the signer's null transaction + * resolution: no user data leaves the machine to sign. + */ + +const { mapLedgerError } = require('./errors'); + +// Lazy-required so the app doesn't pay node-hid's native-module load cost +// (or crash on unsupported platforms) until a Ledger feature is touched. +let TransportNodeHid = null; +let EthApp = null; +function loadLedgerLibs() { + if (!TransportNodeHid) { + TransportNodeHid = require('@ledgerhq/hw-transport-node-hid').default; + EthApp = require('@ledgerhq/hw-app-eth').default; + } +} + +// Derivation path schemes offered during account discovery. Paths are in +// device format (no leading "m/") — exactly what hw-app-eth consumes and +// what we persist on the account record. +const PATH_SCHEMES = { + live: { + label: 'Ledger Live', + buildPath: (i) => `44'/60'/${i}'/0/0`, + }, + legacy: { + label: 'Legacy (MEW / MyCrypto)', + buildPath: (i) => `44'/60'/0'/${i}`, + }, +}; + +// hw-app-eth resolves clear-signing metadata through Ledger's hosted +// services by default (crypto-assets-service.api.ledger.com, +// cdn.live.ledger.com, nft.api.live.ledger.com). Those lookups are not +// limited to `signTransaction`'s opt-in resolution: `signEIP712Message` +// posts the chain id, the verifying contract and a schema hash of the +// typed data to the registry on every EIP-712 signature, from the user's +// IP, before the device is touched — and it does so while this module +// holds the exclusive device queue, so a hung request stalls all device +// access. Nulling every service URL makes the library skip the lookups +// entirely (each call site is guarded by its URL), keeping signing +// fully offline. Consequence is the same as the signer's null +// resolution: contract calls show as raw data on the device. +const OFFLINE_LOAD_CONFIG = { + nftExplorerBaseURL: null, + pluginBaseURL: null, + extraPlugins: null, + cryptoassetsBaseURL: null, + calServiceURL: null, +}; + +let deviceQueue = Promise.resolve(); + +/** + * Run `task` with an open Ethereum app instance, serialized against all + * other device access. The transport is always closed afterwards. + * + * @template T + * @param {(eth: import('@ledgerhq/hw-app-eth').default) => Promise} task + * @returns {Promise} + */ +function withEthApp(task) { + const run = deviceQueue.then(async () => { + loadLedgerLibs(); + let transport; + try { + transport = await TransportNodeHid.open(''); + } catch (err) { + throw mapLedgerError(err); + } + try { + // scrambleKey stays at hw-app-eth's default; the third argument is + // the load config that keeps clear-signing lookups offline. + return await task(new EthApp(transport, undefined, OFFLINE_LOAD_CONFIG)); + } catch (err) { + throw mapLedgerError(err); + } finally { + await transport.close().catch(() => {}); + } + }); + // Keep the queue alive after failures; errors surface to the caller only. + deviceQueue = run.catch(() => {}); + return run; +} + +/** + * List addresses on the device for a derivation-path scheme. + * + * Requires the device to be unlocked with the Ethereum app open; + * otherwise rejects with a mapped LEDGER_* error the UI can act on. + * Addresses are NOT shown on the device screen during discovery + * (`display: false`) — verification on-device happens when the user + * confirms their first signature. + * + * @param {{scheme?: string, start?: number, count?: number}} [options] + * @returns {Promise>} + */ +async function listAccounts({ scheme = 'live', start = 0, count = 5 } = {}) { + const pathScheme = PATH_SCHEMES[scheme]; + if (!pathScheme) { + throw new Error(`Unknown derivation scheme: ${scheme}`); + } + const safeStart = Math.max(0, Math.trunc(start)); + const safeCount = Math.min(20, Math.max(1, Math.trunc(count))); + + return withEthApp(async (eth) => { + const accounts = []; + for (let i = safeStart; i < safeStart + safeCount; i++) { + const path = pathScheme.buildPath(i); + const { address } = await eth.getAddress(path, false); + accounts.push({ path, address }); + } + return accounts; + }); +} + +module.exports = { withEthApp, listAccounts, PATH_SCHEMES, OFFLINE_LOAD_CONFIG }; diff --git a/src/main/wallet/ledger/transport.test.js b/src/main/wallet/ledger/transport.test.js new file mode 100644 index 00000000..64a85298 --- /dev/null +++ b/src/main/wallet/ledger/transport.test.js @@ -0,0 +1,147 @@ +const mockGetAddress = jest.fn(); +const mockClose = jest.fn(async () => {}); +const mockOpen = jest.fn(); +const mockList = jest.fn(); +const mockEthConstructed = jest.fn(); + +jest.mock('@ledgerhq/hw-transport-node-hid', () => ({ + default: { + open: (...args) => mockOpen(...args), + list: (...args) => mockList(...args), + }, +})); +jest.mock('@ledgerhq/hw-app-eth', () => ({ + default: class MockEth { + constructor(transport, scrambleKey, loadConfig) { + this.transport = transport; + mockEthConstructed({ scrambleKey, loadConfig }); + } + getAddress(...args) { + return mockGetAddress(...args); + } + }, +})); + +const { listAccounts, withEthApp, OFFLINE_LOAD_CONFIG } = require('./transport'); +const { LEDGER_ERROR_CODES } = require('./errors'); + +beforeEach(() => { + mockOpen.mockReset().mockResolvedValue({ close: mockClose }); + mockList.mockReset(); + mockGetAddress.mockReset(); + mockClose.mockClear(); + mockEthConstructed.mockClear(); +}); + +describe('listAccounts', () => { + test('walks the Ledger Live path scheme without on-device display', async () => { + mockGetAddress.mockImplementation(async (path) => ({ address: `0xaddr:${path}` })); + + const accounts = await listAccounts({ scheme: 'live', start: 0, count: 3 }); + + expect(accounts).toEqual([ + { path: "44'/60'/0'/0/0", address: "0xaddr:44'/60'/0'/0/0" }, + { path: "44'/60'/1'/0/0", address: "0xaddr:44'/60'/1'/0/0" }, + { path: "44'/60'/2'/0/0", address: "0xaddr:44'/60'/2'/0/0" }, + ]); + expect(mockGetAddress).toHaveBeenCalledWith("44'/60'/0'/0/0", false); + expect(mockClose).toHaveBeenCalledTimes(1); + }); + + test('supports the legacy path scheme and paging', async () => { + mockGetAddress.mockImplementation(async (path) => ({ address: `0xaddr:${path}` })); + + const accounts = await listAccounts({ scheme: 'legacy', start: 5, count: 2 }); + + expect(accounts.map((a) => a.path)).toEqual(["44'/60'/0'/5", "44'/60'/0'/6"]); + }); + + test('rejects unknown schemes before touching the device', async () => { + await expect(listAccounts({ scheme: 'nope' })).rejects.toThrow('Unknown derivation scheme'); + expect(mockOpen).not.toHaveBeenCalled(); + }); + + test('maps app-not-open APDU errors and still closes the transport', async () => { + const apduError = new Error('0x6511'); + apduError.statusCode = 0x6511; + mockGetAddress.mockRejectedValue(apduError); + + await expect(listAccounts()).rejects.toMatchObject({ + code: LEDGER_ERROR_CODES.ETH_APP_NOT_OPEN, + }); + expect(mockClose).toHaveBeenCalledTimes(1); + }); + + test('maps transport-open failures to DEVICE_NOT_FOUND', async () => { + mockOpen.mockRejectedValue(new Error('cannot open device with path')); + await expect(listAccounts()).rejects.toMatchObject({ + code: LEDGER_ERROR_CODES.DEVICE_NOT_FOUND, + }); + }); +}); + +describe('hosted clear-signing services', () => { + test('every Eth app is built with the offline load config', async () => { + mockGetAddress.mockResolvedValue({ address: '0xok' }); + + await withEthApp((eth) => eth.getAddress('x', false)); + + expect(mockEthConstructed).toHaveBeenCalledTimes(1); + expect(mockEthConstructed.mock.calls[0][0].loadConfig).toBe(OFFLINE_LOAD_CONFIG); + // scrambleKey stays at hw-app-eth's own default. + expect(mockEthConstructed.mock.calls[0][0].scrambleKey).toBeUndefined(); + }); + + test('the offline config nulls every service URL the installed hw-app-eth would call', () => { + // Resolved against the library's own defaults: a URL we forget to + // null (or one a future version adds) falls back to Ledger's hosted + // endpoint and would leak signing metadata off the machine. + const { getLoadConfig } = require('@ledgerhq/hw-app-eth/lib/services/ledger/loadConfig'); + const resolved = getLoadConfig(OFFLINE_LOAD_CONFIG); + + const urlKeys = Object.keys(resolved).filter((key) => /URL$/.test(key)); + expect(urlKeys.length).toBeGreaterThan(0); + for (const key of urlKeys) { + expect(resolved[key]).toBeNull(); + } + }); +}); + +describe('withEthApp serialization', () => { + test('device operations run one at a time, in order', async () => { + const events = []; + let releaseFirst; + const firstGate = new Promise((resolve) => { + releaseFirst = resolve; + }); + + const first = withEthApp(async () => { + events.push('first:start'); + await firstGate; + events.push('first:end'); + return 1; + }); + const second = withEthApp(async () => { + events.push('second:start'); + return 2; + }); + + // Give the second task a chance to (incorrectly) start early. + await new Promise((resolve) => setImmediate(resolve)); + expect(events).toEqual(['first:start']); + + releaseFirst(); + await expect(Promise.all([first, second])).resolves.toEqual([1, 2]); + expect(events).toEqual(['first:start', 'first:end', 'second:start']); + }); + + test('a failed operation does not wedge the queue', async () => { + mockGetAddress.mockRejectedValueOnce(Object.assign(new Error('rejected'), { statusCode: 0x6985 })); + await expect(withEthApp((eth) => eth.getAddress('x', false))).rejects.toMatchObject({ + code: LEDGER_ERROR_CODES.USER_REJECTED, + }); + + mockGetAddress.mockResolvedValueOnce({ address: '0xok' }); + await expect(withEthApp((eth) => eth.getAddress('x', false))).resolves.toEqual({ address: '0xok' }); + }); +}); diff --git a/src/main/wallet/signers.js b/src/main/wallet/signers.js new file mode 100644 index 00000000..bace7be2 --- /dev/null +++ b/src/main/wallet/signers.js @@ -0,0 +1,131 @@ +/** + * Signer factory. + * + * Resolves a wallet index to a signer object so callers never touch raw + * private keys. The account's `type` (from vault-meta, see + * identity-manager's WALLET_TYPES) picks the backend: vault-backed + * mnemonic accounts sign locally with a borrowed key, Ledger accounts + * sign on the device. + * + * Input normalization (0x-hex personal messages → raw bytes, JSON-string + * typed data → object) happens once in the factory, so backends always + * receive the same shapes. The EIP-712 payload keeps its full dApp wire + * shape (EIP712Domain in types) because backends genuinely diverge there: + * ethers wants the domain stripped, a Ledger app consumes the full payload. + * + * Error contract: vault-locked errors keep their identity (check with + * `isVaultLockedError`); hardware backends must surface user-rejection + * distinctly so approval UIs can tell "declined on device" from failure. + */ + +const { Wallet, computeAddress } = require('ethers'); + +const { withVaultPrivateKey, isValidWalletIndex } = require('./vault-access'); +const { getWalletRecord, isHardwareWalletIndex, WALLET_TYPES } = require('../identity-manager'); +const { createLedgerBackend } = require('./ledger/signer'); + +/** + * @typedef {Object} Signer + * @property {() => Promise} getAddress + * Checksummed address of the account (cached after first resolution). + * @property {(tx: object) => Promise} signTransaction + * Complete unsigned tx (nonce, gas, fees, chainId — no population) → + * serialized signed tx. + * @property {(message: string|Uint8Array) => Promise} signMessage + * EIP-191 signature over raw bytes (0x-hex is pre-decoded by the factory). + * @property {(typedData: object) => Promise} signTypedData + * EIP-712 signature; receives the parsed full payload + * ({domain, types, message}, EIP712Domain in types allowed). + */ + +/** 0x-hex dApp messages are signatures over the bytes, not the hex text. */ +function normalizeMessage(message) { + if (typeof message === 'string' && message.startsWith('0x')) { + return Buffer.from(message.slice(2), 'hex'); + } + return message; +} + +/** dApps send typed data either as an object or a JSON string. */ +function normalizeTypedData(typedData) { + return typeof typedData === 'string' ? JSON.parse(typedData) : typedData; +} + +function createVaultBackend(walletIndex) { + return { + getAddress: () => withVaultPrivateKey(walletIndex, (privateKey) => computeAddress(privateKey)), + signTransaction: (tx) => + withVaultPrivateKey(walletIndex, (privateKey) => new Wallet(privateKey).signTransaction(tx)), + signMessage: (message) => + withVaultPrivateKey(walletIndex, async (privateKey) => { + try { + // signMessage applies the EIP-191 prefix + return await new Wallet(privateKey).signMessage(message); + } catch (err) { + throw new Error(`Message signing failed: ${err.message}`, { cause: err }); + } + }), + signTypedData: (typedData) => + withVaultPrivateKey(walletIndex, async (privateKey) => { + try { + const { domain, types, message } = typedData; + // ethers computes the domain separator itself + const typesWithoutDomain = { ...types }; + delete typesWithoutDomain.EIP712Domain; + return await new Wallet(privateKey).signTypedData(domain, typesWithoutDomain, message); + } catch (err) { + throw new Error(`Typed data signing failed: ${err.message}`, { cause: err }); + } + }), + }; +} + +/** + * Build a signer for the given wallet index. + * + * Construction is cheap and does not touch the vault; each method borrows + * the key per call, so a locked vault fails at signing time with + * `VAULT_LOCKED_MESSAGE` (same behaviour callers relied on before). The + * address is memoized after the first successful resolution — it is public + * and immutable for a given index, and callers like the send flow need it + * (for the nonce) right before signing. + * + * @param {number} walletIndex + * @returns {Signer} + */ +function getSigner(walletIndex) { + if (!isValidWalletIndex(walletIndex)) { + throw new Error('Invalid wallet index'); + } + + // Unknown mnemonic-range indexes fall through to the vault backend, + // which fails with its own vault-derivation errors — the + // pre-hardware-wallet behaviour. An unknown index in the *hardware* + // range is different: it is a stale reference to a device account that + // was deleted (dApp permission, publisher identity, an index replayed + // by a dApp), and the vault backend would happily derive a mnemonic key + // there. Fail loudly instead. + const record = getWalletRecord(walletIndex); + if (!record && isHardwareWalletIndex(walletIndex)) { + throw new Error('Hardware wallet account no longer exists; reconnect the device'); + } + const backend = + record && record.type === WALLET_TYPES.LEDGER + ? createLedgerBackend(record) + : createVaultBackend(walletIndex); + + let address = null; + return { + getAddress: async () => { + if (address === null) { + address = await backend.getAddress(); + } + return address; + }, + signTransaction: (tx) => backend.signTransaction(tx), + signMessage: (message) => backend.signMessage(normalizeMessage(message)), + signTypedData: (typedData) => backend.signTypedData(normalizeTypedData(typedData)), + }; +} + +module.exports = { getSigner }; diff --git a/src/main/wallet/signers.test.js b/src/main/wallet/signers.test.js new file mode 100644 index 00000000..87ed8222 --- /dev/null +++ b/src/main/wallet/signers.test.js @@ -0,0 +1,199 @@ +const { Wallet, Transaction, verifyMessage, verifyTypedData, getBytes } = require('ethers'); + +// Anvil/Hardhat-default test key — well-known, never funded on mainnet. +const TEST_PRIVATE_KEY = '0x59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d'; +const testWallet = new Wallet(TEST_PRIVATE_KEY); + +const mockIdentity = { + isUnlocked: jest.fn(), + exportPrivateKey: jest.fn(), +}; +const mockResetVaultAutoLockTimer = jest.fn(); + +const mockGetWalletRecord = jest.fn(); +const mockLedgerBackend = { + getAddress: jest.fn(), + signTransaction: jest.fn(), + signMessage: jest.fn(), + signTypedData: jest.fn(), +}; +const mockCreateLedgerBackend = jest.fn(() => mockLedgerBackend); + +jest.mock('../identity-manager', () => ({ + loadIdentityModule: jest.fn(async () => mockIdentity), + getWalletRecord: (...args) => mockGetWalletRecord(...args), + // Mirrors identity-manager's HARDWARE_INDEX_BASE (inlined: jest.mock + // factories may not close over out-of-scope constants). + isHardwareWalletIndex: (index) => Number.isInteger(index) && index >= 1000000, + WALLET_TYPES: { MNEMONIC: 'mnemonic', LEDGER: 'ledger' }, +})); +jest.mock('../vault-timer', () => ({ + resetVaultAutoLockTimer: mockResetVaultAutoLockTimer, +})); +jest.mock('./ledger/signer', () => ({ + createLedgerBackend: (...args) => mockCreateLedgerBackend(...args), +})); + +const { getSigner } = require('./signers'); + +beforeEach(() => { + mockIdentity.isUnlocked.mockReset().mockReturnValue(true); + mockIdentity.exportPrivateKey.mockReset().mockReturnValue(TEST_PRIVATE_KEY); + mockGetWalletRecord.mockReset().mockReturnValue({ index: 0, name: 'Main Wallet', type: 'mnemonic' }); + mockResetVaultAutoLockTimer.mockClear(); +}); + +describe('getSigner (vault-backed)', () => { + test('rejects an invalid wallet index up front', () => { + expect(() => getSigner(-1)).toThrow('Invalid wallet index'); + expect(() => getSigner('0')).toThrow('Invalid wallet index'); + }); + + test('getAddress resolves the address for the wallet index', async () => { + const signer = getSigner(0); + await expect(signer.getAddress()).resolves.toBe(testWallet.address); + expect(mockIdentity.exportPrivateKey).toHaveBeenCalledWith(0); + }); + + test('signMessage matches ethers Wallet.signMessage for plain text', async () => { + const signer = getSigner(0); + const signature = await signer.signMessage('hello freedom'); + expect(signature).toBe(await testWallet.signMessage('hello freedom')); + expect(verifyMessage('hello freedom', signature)).toBe(testWallet.address); + }); + + test.each([ + ['hex-encoded text', '0x48656c6c6f'], + ['binary data that is not valid UTF-8', '0xfffefd00010203deadbeef'], + ['a 32-byte hash', '0x' + 'ab'.repeat(32)], + ])('signMessage treats 0x-hex input as raw bytes: %s', async (_label, hexMessage) => { + const signer = getSigner(0); + const signature = await signer.signMessage(hexMessage); + expect(signature).toBe(await testWallet.signMessage(getBytes(hexMessage))); + expect(verifyMessage(getBytes(hexMessage), signature)).toBe(testWallet.address); + }); + + test('signTypedData accepts a full EIP-712 payload including EIP712Domain in types', async () => { + const signer = getSigner(0); + const domain = { name: 'Test', version: '1', chainId: 1 }; + const types = { + EIP712Domain: [ + { name: 'name', type: 'string' }, + { name: 'version', type: 'string' }, + { name: 'chainId', type: 'uint256' }, + ], + Mail: [ + { name: 'contents', type: 'string' }, + ], + }; + const message = { contents: 'gm' }; + + const signature = await signer.signTypedData({ domain, types, primaryType: 'Mail', message }); + expect(verifyTypedData(domain, { Mail: types.Mail }, message, signature)).toBe(testWallet.address); + + // dApps also send the payload as a JSON string (eth_signTypedData_v4) + const fromJson = await signer.signTypedData(JSON.stringify({ domain, types, primaryType: 'Mail', message })); + expect(fromJson).toBe(signature); + }); + + test('getAddress memoizes: repeated calls borrow the vault key once', async () => { + const signer = getSigner(0); + await signer.getAddress(); + await signer.getAddress(); + expect(mockIdentity.exportPrivateKey).toHaveBeenCalledTimes(1); + }); + + test('signTransaction returns a serialized signed tx recoverable to the wallet', async () => { + const signer = getSigner(0); + const signedTx = await signer.signTransaction({ + to: '0x209693Bc6afc0C5328bA36FaF03C514EF312287C', + value: '1000', + gasLimit: '21000', + maxFeePerGas: '2000000000', + maxPriorityFeePerGas: '1000000000', + nonce: 7, + chainId: 8453, + type: 2, + }); + + const parsed = Transaction.from(signedTx); + expect(parsed.from).toBe(testWallet.address); + expect(parsed.nonce).toBe(7); + expect(parsed.chainId).toBe(8453n); + expect(parsed.to).toBe('0x209693Bc6afc0C5328bA36FaF03C514EF312287C'); + }); + + test('every method rejects when the vault is locked', async () => { + mockIdentity.isUnlocked.mockReturnValue(false); + const signer = getSigner(0); + await expect(signer.getAddress()).rejects.toThrow(/locked/i); + await expect(signer.signMessage('x')).rejects.toThrow(/locked/i); + await expect(signer.signTypedData({ domain: {}, types: {}, message: {} })).rejects.toThrow(/locked/i); + await expect(signer.signTransaction({ chainId: 1 })).rejects.toThrow(/locked/i); + expect(mockResetVaultAutoLockTimer).not.toHaveBeenCalled(); + }); + + test('successful signing resets the vault auto-lock timer', async () => { + const signer = getSigner(0); + await signer.signMessage('keep the vault alive'); + expect(mockResetVaultAutoLockTimer).toHaveBeenCalledTimes(1); + }); + + test('an unknown wallet record falls through to the vault backend', async () => { + mockGetWalletRecord.mockReturnValue(null); + const signer = getSigner(3); + await expect(signer.getAddress()).resolves.toBe(testWallet.address); + expect(mockIdentity.exportPrivateKey).toHaveBeenCalledWith(3); + }); +}); + +describe('getSigner (ledger-backed dispatch)', () => { + const LEDGER_RECORD = { + index: 2, + name: 'My Stax', + address: '0x209693Bc6afc0C5328bA36FaF03C514EF312287C', + type: 'ledger', + path: "44'/60'/0'/0/0", + }; + + beforeEach(() => { + mockGetWalletRecord.mockReturnValue(LEDGER_RECORD); + mockLedgerBackend.getAddress.mockReset().mockResolvedValue(LEDGER_RECORD.address); + mockLedgerBackend.signTransaction.mockReset().mockResolvedValue('0xsignedtx'); + mockLedgerBackend.signMessage.mockReset().mockResolvedValue('0xsig'); + mockLedgerBackend.signTypedData.mockReset().mockResolvedValue('0xsig712'); + mockCreateLedgerBackend.mockClear(); + }); + + test('routes to the ledger backend built from the wallet record, never the vault', async () => { + const signer = getSigner(2); + await expect(signer.getAddress()).resolves.toBe(LEDGER_RECORD.address); + await expect(signer.signTransaction({ chainId: 1 })).resolves.toBe('0xsignedtx'); + expect(mockCreateLedgerBackend).toHaveBeenCalledWith(LEDGER_RECORD); + expect(mockIdentity.isUnlocked).not.toHaveBeenCalled(); + expect(mockIdentity.exportPrivateKey).not.toHaveBeenCalled(); + }); + + test('factory-level normalization applies to ledger backends too', async () => { + const signer = getSigner(2); + + // 0x-hex message reaches the backend as raw bytes + await signer.signMessage('0x48656c6c6f'); + expect(mockLedgerBackend.signMessage).toHaveBeenCalledWith(Buffer.from('Hello', 'utf8')); + + // JSON-string typed data reaches the backend parsed + await signer.signTypedData('{"domain":{},"types":{},"message":{}}'); + expect(mockLedgerBackend.signTypedData).toHaveBeenCalledWith({ domain: {}, types: {}, message: {} }); + }); + + test('a deleted hardware account fails loudly instead of falling through to the vault', async () => { + // Deleting a Ledger leaves no record, so the type check can't fire. + // A stale reference to its index (dApp permission, publisher identity) + // must not reach the vault backend, which would derive and sign with a + // phantom mnemonic key at that index. + mockGetWalletRecord.mockReturnValue(null); + expect(() => getSigner(1000000)).toThrow('Hardware wallet account no longer exists'); + expect(mockIdentity.exportPrivateKey).not.toHaveBeenCalled(); + expect(mockCreateLedgerBackend).not.toHaveBeenCalled(); + }); +}); diff --git a/src/main/wallet/transaction-service.js b/src/main/wallet/transaction-service.js index 79fc770b..9c81ba30 100644 --- a/src/main/wallet/transaction-service.js +++ b/src/main/wallet/transaction-service.js @@ -1,11 +1,12 @@ /** * Transaction Service * - * Handles gas estimation, transaction building, signing, and broadcasting. - * Uses the vault's derived keys for signing. + * Handles gas estimation, transaction building, and broadcasting. + * Signing is delegated to a Signer (see ./signers.js), so this module + * never touches key material. */ -const { parseUnits, formatUnits, Interface, Wallet } = require('ethers'); +const { parseUnits, formatUnits, Interface } = require('ethers'); const { getProvider, withRetry } = require('./provider-manager'); const { getTxExplorerUrl } = require('./chains'); @@ -178,7 +179,54 @@ function buildTransaction({ } /** - * Sign and broadcast a transaction + * Fill in fee parameters the caller didn't supply. + * + * ethers' Wallet.sendTransaction used to populate missing fees from the + * network before signing. Now that signing and broadcasting are separate + * steps nothing does, so an unpriced tx would be signed with + * maxFeePerGas = 0 and rejected by every node as underpriced — on a + * hardware wallet, only after the user confirmed it on-device. Populate + * (or refuse) here, before the signer is ever asked to sign. + * + * @param {Object} params + * @returns {Promise<{maxFeePerGas?: string, maxPriorityFeePerGas?: string, gasPrice?: string}>} + */ +async function resolveFeeParams({ maxFeePerGas, maxPriorityFeePerGas, gasPrice, chainId }) { + if ((maxFeePerGas && maxPriorityFeePerGas) || gasPrice) { + return { maxFeePerGas, maxPriorityFeePerGas, gasPrice }; + } + + const fees = await getGasPrices(chainId); + + if (fees.type === 'eip1559' && isPositiveFee(fees.maxFeePerGas) && isPositiveFee(fees.maxPriorityFeePerGas)) { + return { maxFeePerGas: fees.maxFeePerGas, maxPriorityFeePerGas: fees.maxPriorityFeePerGas }; + } + if (isPositiveFee(fees.gasPrice)) { + return { gasPrice: fees.gasPrice }; + } + + throw new Error('Unable to determine a gas price for this transaction. Please try again.'); +} + +function isPositiveFee(value) { + try { + return value !== undefined && value !== null && BigInt(value) > 0n; + } catch { + return false; + } +} + +/** + * Sign and broadcast a transaction. + * + * Signing and broadcasting are separate steps so the signer can be + * anything implementing the signer interface (vault key, hardware + * device) — the provider only ever sees the serialized signed tx. + * + * Fee parameters are optional: when the caller supplies none they are + * fetched from the network (see resolveFeeParams) rather than signed as + * zero. + * * @param {Object} params - Transaction parameters * @param {string} params.to - Recipient (or token contract for ERC-20) * @param {string} params.value - Value in wei @@ -188,10 +236,10 @@ function buildTransaction({ * @param {string} [params.maxPriorityFeePerGas] - Max priority fee (EIP-1559) * @param {string} [params.gasPrice] - Gas price (legacy) * @param {number} params.chainId - Chain ID - * @param {string} privateKey - Private key for signing (0x-prefixed) + * @param {import('./signers').Signer} signer - Signer for the sending account * @returns {Promise} Transaction result */ -async function signAndSendTransaction(params, privateKey) { +async function signAndSendTransaction(params, signer) { const { to, value, data, gasLimit, maxFeePerGas, maxPriorityFeePerGas, gasPrice, chainId } = params; const provider = getProvider(chainId); @@ -199,12 +247,15 @@ async function signAndSendTransaction(params, privateKey) { throw new Error(`No provider available for chain ${chainId}`); } + // Outside the try: fee-resolution failures should surface as-is instead + // of being remapped to the generic "gas estimation" message below. + const fees = await resolveFeeParams({ maxFeePerGas, maxPriorityFeePerGas, gasPrice, chainId }); + try { - // Create wallet from private key - const wallet = new Wallet(privateKey, provider); + const from = await signer.getAddress(); // Get nonce - const nonce = await withRetry(() => provider.getTransactionCount(wallet.address, 'pending'), 2, chainId); + const nonce = await withRetry(() => provider.getTransactionCount(from, 'pending'), 2, chainId); // Build transaction const tx = buildTransaction({ @@ -212,9 +263,9 @@ async function signAndSendTransaction(params, privateKey) { value, data, gasLimit, - maxFeePerGas, - maxPriorityFeePerGas, - gasPrice, + maxFeePerGas: fees.maxFeePerGas, + maxPriorityFeePerGas: fees.maxPriorityFeePerGas, + gasPrice: fees.gasPrice, nonce, chainId, }); @@ -227,8 +278,8 @@ async function signAndSendTransaction(params, privateKey) { nonce: tx.nonce, }); - // Sign and send - const txResponse = await wallet.sendTransaction(tx); + const signedTx = await signer.signTransaction(tx); + const txResponse = await provider.broadcastTransaction(signedTx); console.log('[TransactionService] Transaction sent:', txResponse.hash); @@ -339,64 +390,6 @@ async function waitForTransaction(txHash, chainId, confirmations = 1) { } } -/** - * Sign a personal message (EIP-191) - * @param {string} message - Message to sign (hex string or UTF-8) - * @param {string} privateKey - Private key for signing - * @returns {Promise} Signature (hex string) - */ -async function signPersonalMessage(message, privateKey) { - try { - const wallet = new Wallet(privateKey); - - // If message is hex-encoded, convert to raw bytes (not UTF-8 string) - let messageToSign = message; - if (message.startsWith('0x')) { - messageToSign = Buffer.from(message.slice(2), 'hex'); - } - - // signMessage automatically applies EIP-191 prefix - const signature = await wallet.signMessage(messageToSign); - - console.log('[TransactionService] Message signed'); - return signature; - } catch (err) { - console.error('[TransactionService] Message signing failed:', err); - throw new Error(`Message signing failed: ${err.message}`, { cause: err }); - } -} - -/** - * Sign typed data (EIP-712) - * @param {Object} typedData - EIP-712 typed data object - * @param {string} privateKey - Private key for signing - * @returns {Promise} Signature (hex string) - */ -async function signTypedData(typedData, privateKey) { - try { - const wallet = new Wallet(privateKey); - - // Parse if string - const data = typeof typedData === 'string' ? JSON.parse(typedData) : typedData; - - // Extract domain, types, and message from EIP-712 structure - const { domain, types, message } = data; - - // Remove EIP712Domain from types (ethers handles it internally) - const typesWithoutDomain = { ...types }; - delete typesWithoutDomain.EIP712Domain; - - // Sign using ethers' signTypedData - const signature = await wallet.signTypedData(domain, typesWithoutDomain, message); - - console.log('[TransactionService] Typed data signed'); - return signature; - } catch (err) { - console.error('[TransactionService] Typed data signing failed:', err); - throw new Error(`Typed data signing failed: ${err.message}`, { cause: err }); - } -} - module.exports = { estimateGas, getGasPrices, @@ -407,6 +400,4 @@ module.exports = { signAndSendTransaction, getTransactionStatus, waitForTransaction, - signPersonalMessage, - signTypedData, }; diff --git a/src/main/wallet/transaction-service.test.js b/src/main/wallet/transaction-service.test.js index 585f9a9d..7bd9efaf 100644 --- a/src/main/wallet/transaction-service.test.js +++ b/src/main/wallet/transaction-service.test.js @@ -1,71 +1,165 @@ -const { Wallet, verifyMessage, getBytes } = require('ethers'); -const { signPersonalMessage } = require('./transaction-service'); +const mockGetProvider = jest.fn(); +jest.mock('./provider-manager', () => ({ + getProvider: (...args) => mockGetProvider(...args), + withRetry: (fn) => fn(), +})); +jest.mock('./chains', () => ({ + getTxExplorerUrl: (chainId, hash) => `https://explorer.test/${chainId}/${hash}`, +})); + +const { Wallet, Transaction } = require('ethers'); +const { signAndSendTransaction } = require('./transaction-service'); // Deterministic test key (not a real wallet) const TEST_PRIVATE_KEY = '0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80'; const testWallet = new Wallet(TEST_PRIVATE_KEY); -describe('signPersonalMessage', () => { - it('signs a plain text message', async () => { - const message = 'Hello, this is a test message'; - const signature = await signPersonalMessage(message, TEST_PRIVATE_KEY); +describe('signAndSendTransaction (signer-based)', () => { + // A signer that signs locally with the test key — mirrors what the + // vault signer produces, without the vault plumbing. Ledger signers + // implement the same interface, so this proves the sign/broadcast + // split works for any signer that can't hand out a raw key. + const signer = { + getAddress: async () => testWallet.address, + signTransaction: (tx) => testWallet.signTransaction(tx), + }; - expect(signature).toMatch(/^0x[0-9a-f]{130}$/); - // Verify the signature recovers to the correct address - const recovered = verifyMessage(message, signature); - expect(recovered.toLowerCase()).toBe(testWallet.address.toLowerCase()); + let broadcastedRaw; + beforeEach(() => { + broadcastedRaw = null; + mockGetProvider.mockReset().mockReturnValue({ + getTransactionCount: async () => 5, + broadcastTransaction: async (raw) => { + broadcastedRaw = raw; + const parsed = Transaction.from(raw); + return { + hash: parsed.hash, + nonce: parsed.nonce, + from: parsed.from, + to: parsed.to, + value: parsed.value, + }; + }, + }); }); - it('signs a hex-encoded text message (0x prefix)', async () => { - // "Hello" in hex - const hexMessage = '0x48656c6c6f'; - const signature = await signPersonalMessage(hexMessage, TEST_PRIVATE_KEY); + it('fetches the nonce for the signer address, signs, and broadcasts', async () => { + const result = await signAndSendTransaction( + { + to: '0x209693Bc6afc0C5328bA36FaF03C514EF312287C', + value: '1000', + gasLimit: '21000', + maxFeePerGas: '2000000000', + maxPriorityFeePerGas: '1000000000', + chainId: 8453, + }, + signer, + ); + + const parsed = Transaction.from(broadcastedRaw); + expect(parsed.from).toBe(testWallet.address); + expect(parsed.nonce).toBe(5); + expect(parsed.chainId).toBe(8453n); - expect(signature).toMatch(/^0x[0-9a-f]{130}$/); - // Verify: ethers.verifyMessage with raw bytes should recover the same address - const rawBytes = getBytes(hexMessage); - const recovered = verifyMessage(rawBytes, signature); - expect(recovered.toLowerCase()).toBe(testWallet.address.toLowerCase()); + expect(result).toMatchObject({ + hash: parsed.hash, + nonce: 5, + from: testWallet.address, + to: '0x209693Bc6afc0C5328bA36FaF03C514EF312287C', + value: '1000', + chainId: 8453, + explorerUrl: `https://explorer.test/8453/${parsed.hash}`, + }); }); - it('signs hex-encoded binary data containing non-UTF-8 bytes', async () => { - // Arbitrary binary data that is NOT valid UTF-8 - // 0xff 0xfe are invalid UTF-8 lead bytes - const hexMessage = '0xfffefd00010203deadbeef'; - const signature = await signPersonalMessage(hexMessage, TEST_PRIVATE_KEY); - - expect(signature).toMatch(/^0x[0-9a-f]{130}$/); - // Verify the signature matches signing the raw bytes directly - const rawBytes = getBytes(hexMessage); - const recovered = verifyMessage(rawBytes, signature); - expect(recovered.toLowerCase()).toBe(testWallet.address.toLowerCase()); + it('populates EIP-1559 fees from the network when the caller supplies none', async () => { + const provider = mockGetProvider(); + provider.getFeeData = async () => ({ + maxFeePerGas: 4_000_000_000n, + maxPriorityFeePerGas: 1_000_000_000n, + gasPrice: 3_000_000_000n, + }); + provider.getBlock = async () => ({ baseFeePerGas: 2_000_000_000n }); + + await signAndSendTransaction( + { + to: '0x209693Bc6afc0C5328bA36FaF03C514EF312287C', + value: '1000', + gasLimit: '21000', + chainId: 8453, + }, + signer, + ); + + const parsed = Transaction.from(broadcastedRaw); + expect(parsed.type).toBe(2); + // 2 * baseFee + priority — never the zero-fee tx every node rejects. + expect(parsed.maxFeePerGas).toBe(5_000_000_000n); + expect(parsed.maxPriorityFeePerGas).toBe(1_000_000_000n); }); - it('signs a hex-encoded hash (32 bytes)', async () => { - // A keccak256 hash — common in dApp signing flows - const hashMessage = '0x' + 'ab'.repeat(32); - const signature = await signPersonalMessage(hashMessage, TEST_PRIVATE_KEY); + it('falls back to the legacy gas price when the chain has no EIP-1559 fee data', async () => { + const provider = mockGetProvider(); + provider.getFeeData = async () => ({ maxFeePerGas: null, maxPriorityFeePerGas: null, gasPrice: 7_000_000_000n }); + provider.getBlock = async () => ({ baseFeePerGas: null }); + + await signAndSendTransaction( + { to: '0x209693Bc6afc0C5328bA36FaF03C514EF312287C', value: '1', gasLimit: '21000', chainId: 100 }, + signer, + ); + + const parsed = Transaction.from(broadcastedRaw); + expect(parsed.type).toBe(0); + expect(parsed.gasPrice).toBe(7_000_000_000n); + }); + + it('refuses to sign when the network reports no usable gas price', async () => { + const signTransaction = jest.fn(); + const provider = mockGetProvider(); + provider.getFeeData = async () => ({ maxFeePerGas: null, maxPriorityFeePerGas: null, gasPrice: 0n }); + provider.getBlock = async () => ({ baseFeePerGas: null }); + + await expect( + signAndSendTransaction( + { to: '0x209693Bc6afc0C5328bA36FaF03C514EF312287C', value: '1', gasLimit: '21000', chainId: 100 }, + { ...signer, signTransaction }, + ), + ).rejects.toThrow('Unable to determine a gas price'); - expect(signature).toMatch(/^0x[0-9a-f]{130}$/); - const rawBytes = getBytes(hashMessage); - const recovered = verifyMessage(rawBytes, signature); - expect(recovered.toLowerCase()).toBe(testWallet.address.toLowerCase()); + // Critically: the device is never asked to confirm an unbroadcastable tx. + expect(signTransaction).not.toHaveBeenCalled(); + expect(broadcastedRaw).toBeNull(); }); - it('produces matching signatures for hex and equivalent raw bytes', async () => { - // Sign "Hello" as plain text hex - const hexSig = await signPersonalMessage('0x48656c6c6f', TEST_PRIVATE_KEY); - // Sign "Hello" by passing the same bytes through ethers directly - const directSig = await testWallet.signMessage(getBytes('0x48656c6c6f')); + it('maps insufficient-funds broadcast errors to a friendly message', async () => { + mockGetProvider.mockReturnValue({ + getTransactionCount: async () => 0, + broadcastTransaction: async () => { + throw new Error('insufficient funds for gas * price + value'); + }, + }); - expect(hexSig).toBe(directSig); + await expect( + signAndSendTransaction( + { to: '0x209693Bc6afc0C5328bA36FaF03C514EF312287C', value: '1', gasLimit: '21000', gasPrice: '7', chainId: 1 }, + signer, + ), + ).rejects.toThrow('Insufficient funds for transaction'); }); - it('treats non-0x messages as plain strings', async () => { - const message = 'no hex prefix here'; - const signature = await signPersonalMessage(message, TEST_PRIVATE_KEY); + it('surfaces signer rejection (e.g. user declined on device) unchanged', async () => { + const decliningSigner = { + ...signer, + signTransaction: async () => { + throw new Error('User rejected on device'); + }, + }; - const recovered = verifyMessage(message, signature); - expect(recovered.toLowerCase()).toBe(testWallet.address.toLowerCase()); + await expect( + signAndSendTransaction( + { to: '0x209693Bc6afc0C5328bA36FaF03C514EF312287C', value: '1', gasLimit: '21000', gasPrice: '7', chainId: 1 }, + decliningSigner, + ), + ).rejects.toThrow(/User rejected on device/); }); }); diff --git a/src/main/wallet/tx-recorder.js b/src/main/wallet/tx-recorder.js index 1e0ea87d..9c1f9c18 100644 --- a/src/main/wallet/tx-recorder.js +++ b/src/main/wallet/tx-recorder.js @@ -20,7 +20,7 @@ function toAtomicDecimal(value) { /** * @param {object} params Same shape as signAndSendTransaction. - * @param {string} privateKey + * @param {import('./signers').Signer} signer * @param {object} context * @param {string} context.kind paymentHistory.KINDS member * @param {string} [context.origin] normalised origin (dapp sends only) @@ -36,8 +36,8 @@ function toAtomicDecimal(value) { * you have the real recipient) * @param {object} [context.metadata] free-form per-kind extras */ -async function signAndRecord(params, privateKey, context) { - const response = await signAndSendTransaction(params, privateKey); +async function signAndRecord(params, signer, context) { + const response = await signAndSendTransaction(params, signer); let row; try { diff --git a/src/main/wallet/tx-recorder.test.js b/src/main/wallet/tx-recorder.test.js index bda382db..5277be65 100644 --- a/src/main/wallet/tx-recorder.test.js +++ b/src/main/wallet/tx-recorder.test.js @@ -23,6 +23,8 @@ jest.mock('../payment-history', () => ({ const { signAndRecord } = require('./tx-recorder'); +const fakeSigner = { getAddress: async () => '0xfrom' }; + describe('tx-recorder', () => { beforeEach(() => { mockSignAndSendTransaction.mockReset().mockResolvedValue({ @@ -44,7 +46,7 @@ describe('tx-recorder', () => { to: '0xtoken', value: '0', chainId: 8453, - }, '0xprivate', { + }, fakeSigner, { kind: 'dapp-send', origin: 'https://app.example', asset: '0xtoken', @@ -74,7 +76,7 @@ describe('tx-recorder', () => { to: '0xrecipient', value: '0x2a', chainId: 1, - }, '0xprivate', { + }, fakeSigner, { kind: 'wallet-send', }); diff --git a/src/main/wallet/vault-access.js b/src/main/wallet/vault-access.js index 1aa9d70f..4a75e9cc 100644 --- a/src/main/wallet/vault-access.js +++ b/src/main/wallet/vault-access.js @@ -16,7 +16,12 @@ * incidentally by the consolidation. */ -const { loadIdentityModule } = require('../identity-manager'); +const { + loadIdentityModule, + getWalletRecord, + isHardwareWalletIndex, + WALLET_TYPES, +} = require('../identity-manager'); const { resetVaultAutoLockTimer } = require('../vault-timer'); const { VAULT_LOCKED_MESSAGE } = require('./vault-errors'); @@ -54,6 +59,19 @@ async function withVaultPrivateKey(walletIndex, callback) { if (!isValidWalletIndex(walletIndex)) { throw new Error('Invalid wallet index'); } + // Hard stop for non-mnemonic accounts at the key-derivation chokepoint: + // deriving a mnemonic key at a hardware account's index would silently + // sign with a key whose address the user has never seen. + // + // The index-range half of the check stands on its own and must come + // first: a *deleted* device account has no record at all, and a stale + // reference to it (dApp permission, publisher identity, an index from + // untrusted renderer code) would otherwise fall through to derivation + // at m/44'/60'/'/0/0 — a phantom account. + const record = getWalletRecord(walletIndex); + if (isHardwareWalletIndex(walletIndex) || (record && record.type !== WALLET_TYPES.MNEMONIC)) { + throw new Error('Hardware wallet accounts have no vault key; sign via their device signer'); + } const identity = await loadIdentityModule(); if (!identity.isUnlocked()) { throw new Error(VAULT_LOCKED_MESSAGE); diff --git a/src/main/wallet/vault-access.test.js b/src/main/wallet/vault-access.test.js index 6e075695..a49df580 100644 --- a/src/main/wallet/vault-access.test.js +++ b/src/main/wallet/vault-access.test.js @@ -3,9 +3,15 @@ const mockIdentity = { exportPrivateKey: jest.fn(), }; const mockResetVaultAutoLockTimer = jest.fn(); +const mockGetWalletRecord = jest.fn(); jest.mock('../identity-manager', () => ({ loadIdentityModule: jest.fn(async () => mockIdentity), + getWalletRecord: (...args) => mockGetWalletRecord(...args), + // Mirrors identity-manager's HARDWARE_INDEX_BASE (inlined: jest.mock + // factories may not close over out-of-scope constants). + isHardwareWalletIndex: (index) => Number.isInteger(index) && index >= 1000000, + WALLET_TYPES: { MNEMONIC: 'mnemonic', LEDGER: 'ledger' }, })); jest.mock('../vault-timer', () => ({ resetVaultAutoLockTimer: mockResetVaultAutoLockTimer, @@ -22,6 +28,7 @@ beforeEach(() => { // test's tampering to leak into the next. mockIdentity.isUnlocked.mockReset().mockReturnValue(true); mockIdentity.exportPrivateKey.mockReset().mockReturnValue(TEST_KEY); + mockGetWalletRecord.mockReset().mockReturnValue(null); mockResetVaultAutoLockTimer.mockClear(); }); @@ -68,6 +75,28 @@ describe('withVaultPrivateKey', () => { expect(mockIdentity.exportPrivateKey).toHaveBeenCalledWith(3); }); + test('refuses to derive a vault key for a hardware-wallet index', async () => { + // The chokepoint guard: even a caller that bypasses the signer + // factory must never get a mnemonic key at a ledger account's index. + mockGetWalletRecord.mockReturnValue({ index: 3, type: 'ledger', address: '0xstax' }); + await expect(withVaultPrivateKey(3, () => 'unreachable')) + .rejects.toThrow('Hardware wallet accounts have no vault key'); + expect(mockIdentity.exportPrivateKey).not.toHaveBeenCalled(); + }); + + test('refuses to derive a vault key for a deleted hardware account', async () => { + // Deleting a Ledger removes its record, so the record-type guard above + // no longer fires. The index range must be decisive on its own — + // otherwise a stale reference (dApp permission, publisher identity) + // derives a phantom mnemonic key at m/44'/60'/1000000'/0/0 and signs + // with an address the user has never seen. + mockGetWalletRecord.mockReturnValue(null); + await expect(withVaultPrivateKey(1000000, () => 'unreachable')) + .rejects.toThrow('Hardware wallet accounts have no vault key'); + expect(mockIdentity.exportPrivateKey).not.toHaveBeenCalled(); + expect(mockIdentity.isUnlocked).not.toHaveBeenCalled(); + }); + test.each([ ['negative', -1], ['non-integer', 1.5], diff --git a/src/main/wallet/wallet-ipc.js b/src/main/wallet/wallet-ipc.js index 9552f77f..87980bf0 100644 --- a/src/main/wallet/wallet-ipc.js +++ b/src/main/wallet/wallet-ipc.js @@ -16,13 +16,11 @@ const { parseAmount, getTransactionStatus, waitForTransaction, - signPersonalMessage, - signTypedData, } = require('./transaction-service'); const { signAndRecord, KINDS: PAYMENT_KINDS } = require('./tx-recorder'); const { getActiveWalletIndex } = require('../identity-manager'); const { getEffectiveRpcUrls } = require('./rpc-manager'); -const { withVaultPrivateKey } = require('./vault-access'); +const { getSigner } = require('./signers'); /** * Validate that an RPC URL is a known, trusted endpoint. @@ -63,12 +61,10 @@ async function handleSendTransaction(walletIndex, params, kind, context = {}) { if (!to || chainId === undefined || !gasLimit) { return { success: false, error: 'Missing required parameters: to, chainId, gasLimit' }; } - const result = await withVaultPrivateKey(walletIndex, (privateKey) => - signAndRecord( - { to, value, data, gasLimit, maxFeePerGas, maxPriorityFeePerGas, gasPrice, chainId }, - privateKey, - buildTxRecordContext(kind, context), - ) + const result = await signAndRecord( + { to, value, data, gasLimit, maxFeePerGas, maxPriorityFeePerGas, gasPrice, chainId }, + getSigner(walletIndex), + buildTxRecordContext(kind, context), ); return { success: true, ...result }; } catch (err) { @@ -282,9 +278,7 @@ function registerWalletIpc() { return { success: false, error: 'Message is required' }; } - const signature = await withVaultPrivateKey(walletIndex, (privateKey) => - signPersonalMessage(message, privateKey) - ); + const signature = await getSigner(walletIndex).signMessage(message); return { success: true, signature }; } catch (err) { @@ -300,9 +294,7 @@ function registerWalletIpc() { return { success: false, error: 'Typed data is required' }; } - const signature = await withVaultPrivateKey(walletIndex, (privateKey) => - signTypedData(typedData, privateKey) - ); + const signature = await getSigner(walletIndex).signTypedData(typedData); return { success: true, signature }; } catch (err) { diff --git a/src/main/wallet/wallet-ipc.test.js b/src/main/wallet/wallet-ipc.test.js index 40572add..405e421c 100644 --- a/src/main/wallet/wallet-ipc.test.js +++ b/src/main/wallet/wallet-ipc.test.js @@ -13,7 +13,7 @@ jest.mock('./tx-recorder', () => ({ })); jest.mock('../identity-manager', () => ({})); jest.mock('./rpc-manager', () => ({})); -jest.mock('./vault-access', () => ({})); +jest.mock('./signers', () => ({})); const { buildTxRecordContext } = require('./wallet-ipc'); diff --git a/src/main/x402/client.js b/src/main/x402/client.js index a66cf4a3..e49a5408 100644 --- a/src/main/x402/client.js +++ b/src/main/x402/client.js @@ -1,24 +1,23 @@ /** - * x402 vault-backed payment client. + * x402 payment client. * - * Wires `@x402/core`'s `x402Client` to the freedom-browser vault so payment - * authorizations get signed inside the existing wallet — no raw keys leave - * the main process, and the same auto-lock UX that protects dApp signing - * protects x402 payments. + * Wires `@x402/core`'s `x402Client` to the wallet's signer factory so + * payment authorizations are signed by whatever backend the wallet index + * resolves to (vault key today, hardware wallet later) — no raw keys + * leave the main process, and the same auto-lock UX that protects dApp + * signing protects x402 payments. * - * Higher layers (the navigation interceptor / interstitial in WP3) call - * `createVaultBackedX402Client(walletIndex)` after the user has approved a - * payment, then drive the returned client to produce the `PAYMENT-SIGNATURE` + * Higher layers (the navigation interceptor / interstitial) call + * `createX402Client(walletIndex)` after the user has approved a payment, + * then drive the returned client to produce the `PAYMENT-SIGNATURE` * header value. */ const { x402Client } = require('@x402/core/client'); const { ExactEvmScheme } = require('@x402/evm/exact/client'); const { ExactEvmSchemeV1 } = require('@x402/evm/exact/v1/client'); -const { Wallet } = require('ethers'); -const { withVaultPrivateKey } = require('../wallet/vault-access'); -const { signTypedData: signTypedDataWithKey } = require('../wallet/transaction-service'); +const { getSigner } = require('../wallet/signers'); // V1 servers use string network names (not CAIP-2); unknown ones fall // through to whichever V2 `accepts[]` entry the server also exposed. @@ -30,46 +29,30 @@ const { signTypedData: signTypedDataWithKey } = require('../wallet/transaction-s const V1_NETWORKS = ['base', 'ethereum']; /** - * Build a vault-backed `ClientEvmSigner` for the given wallet index. - * - * The returned signer matches `@x402/evm`'s `ClientEvmSigner` shape — just - * `address` + `signTypedData`. No `readContract` etc., so EIP-2612 / ERC-20- - * approval extensions aren't supported on this signer; the base USDC / - * EIP-3009 flow doesn't need them. - * - * @param {number} walletIndex - * @returns {Promise<{ address: string, signTypedData: (msg: object) => Promise }>} - */ -async function buildVaultSigner(walletIndex) { - // Resolve the address once at construction. withVaultPrivateKey also - // resets the auto-lock timer, so the typical "build then immediately - // sign" flow doesn't race the timeout. Each subsequent signTypedData - // call re-runs the same unlock check in case the vault re-locked. - const address = await withVaultPrivateKey(walletIndex, (privateKey) => - new Wallet(privateKey).address - ); - - return { - address, - signTypedData: (msg) => - withVaultPrivateKey(walletIndex, (privateKey) => - signTypedDataWithKey(msg, privateKey) - ), - }; -} - -/** - * Construct an `x402Client` whose signing flows through the vault. + * Construct an `x402Client` whose signing flows through the wallet's + * signer for the given index. * * Both V2 (CAIP-2, registered with the `eip155:*` glob) and V1 (legacy * string network names) schemes are wired so the client can produce * payment payloads against either flavour of x402 server. * + * The schemes receive `@x402/evm`'s `ClientEvmSigner` shape — just + * `address` + `signTypedData`. No `readContract` etc., so EIP-2612 / + * ERC-20-approval extensions aren't supported; the base USDC / EIP-3009 + * flow doesn't need them. The address is resolved once at construction — + * this also resets the vault auto-lock timer, so the typical "build then + * immediately sign" flow doesn't race the timeout; each signTypedData + * call re-runs the unlock check in case the vault re-locked. + * * @param {number} [walletIndex=0] * @returns {Promise} */ -async function createVaultBackedX402Client(walletIndex = 0) { - const signer = await buildVaultSigner(walletIndex); +async function createX402Client(walletIndex = 0) { + const walletSigner = getSigner(walletIndex); + const signer = { + address: await walletSigner.getAddress(), + signTypedData: walletSigner.signTypedData, + }; const client = new x402Client(); client.register('eip155:*', new ExactEvmScheme(signer)); @@ -83,7 +66,6 @@ async function createVaultBackedX402Client(walletIndex = 0) { } module.exports = { - buildVaultSigner, - createVaultBackedX402Client, + createX402Client, V1_NETWORKS, }; diff --git a/src/main/x402/client.test.js b/src/main/x402/client.test.js index a2d3b015..f6b82182 100644 --- a/src/main/x402/client.test.js +++ b/src/main/x402/client.test.js @@ -12,14 +12,16 @@ const mockResetVaultAutoLockTimer = jest.fn(); jest.mock('../identity-manager', () => ({ loadIdentityModule: jest.fn(async () => mockIdentity), + getWalletRecord: jest.fn(() => null), + isHardwareWalletIndex: (index) => Number.isInteger(index) && index >= 1000000, + WALLET_TYPES: { MNEMONIC: 'mnemonic', LEDGER: 'ledger' }, })); jest.mock('../vault-timer', () => ({ resetVaultAutoLockTimer: mockResetVaultAutoLockTimer, })); const { - buildVaultSigner, - createVaultBackedX402Client, + createX402Client, V1_NETWORKS, } = require('./client'); @@ -29,77 +31,43 @@ beforeEach(() => { mockResetVaultAutoLockTimer.mockClear(); }); -// === buildVaultSigner ==================================================== - -describe('buildVaultSigner', () => { +describe('createX402Client', () => { test('rejects when the vault is locked', async () => { mockIdentity.isUnlocked.mockReturnValue(false); - await expect(buildVaultSigner(0)).rejects.toThrow(/locked/i); + await expect(createX402Client(0)).rejects.toThrow(/locked/i); }); - test('exposes the address derived from the wallet index', async () => { - const signer = await buildVaultSigner(0); - expect(signer.address).toBe(TEST_ADDRESS); + test('exposes the signer address on the client so callers can stamp from_address', async () => { + const client = await createX402Client(0); + expect(client.address).toBe(TEST_ADDRESS); expect(mockIdentity.exportPrivateKey).toHaveBeenCalledWith(0); }); - test('signTypedData produces a signature recoverable to the wallet address', async () => { - // Mirrors the EIP-3009 shape `@x402/evm`'s exact/eip3009 client emits — - // the round-trip proves bigint values flow through ethers.signTypedData - // without us needing a stringification shim. - const signer = await buildVaultSigner(0); - const domain = { - name: 'USD Coin', - version: '2', - chainId: 8453, - verifyingContract: '0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913', - }; - const types = { - TransferWithAuthorization: [ - { name: 'from', type: 'address' }, - { name: 'to', type: 'address' }, - { name: 'value', type: 'uint256' }, - { name: 'validAfter', type: 'uint256' }, - { name: 'validBefore', type: 'uint256' }, - { name: 'nonce', type: 'bytes32' }, - ], - }; - const message = { - from: TEST_ADDRESS, - to: '0x209693Bc6afc0C5328bA36FaF03C514EF312287C', - value: 10000n, - validAfter: 1700000000n, - validBefore: 1700000600n, - nonce: '0xf3746613c2d920b5fdabc0856f2aeb2d4f88ee6037b8cc5d04a71a4462f13480', - }; - - const sig = await signer.signTypedData({ domain, types, primaryType: 'TransferWithAuthorization', message }); - expect(sig).toMatch(/^0x[0-9a-f]{130}$/); - expect(verifyTypedData(domain, types, message, sig)).toBe(TEST_ADDRESS); - // Twice: once at signer construction (address derivation), once on sign. - expect(mockResetVaultAutoLockTimer).toHaveBeenCalledTimes(2); - }); - - test('signTypedData throws if the vault re-locks between construction and signing', async () => { - const signer = await buildVaultSigner(0); + test('payment signing throws if the vault re-locks after client construction', async () => { + const client = await createX402Client(0); mockResetVaultAutoLockTimer.mockClear(); // ignore the construction reset mockIdentity.isUnlocked.mockReturnValue(false); - await expect(signer.signTypedData({ domain: {}, types: {}, primaryType: 'X', message: {} })) - .rejects.toThrow(/locked/i); + await expect(client.createPaymentPayload({ + x402Version: 2, + resource: 'https://api.example/article', + accepts: [ + { + scheme: 'exact', + network: 'eip155:8453', + amount: '10000', + asset: '0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913', + payTo: '0x209693Bc6afc0C5328bA36FaF03C514EF312287C', + maxTimeoutSeconds: 60, + resource: 'https://api.example/article', + extra: { name: 'USD Coin', version: '2' }, + }, + ], + })).rejects.toThrow(/locked/i); expect(mockResetVaultAutoLockTimer).not.toHaveBeenCalled(); }); -}); - -// === createVaultBackedX402Client ========================================= - -describe('createVaultBackedX402Client', () => { - test('exposes the signer address on the client so callers can stamp from_address', async () => { - const client = await createVaultBackedX402Client(0); - expect(client.address).toBe(TEST_ADDRESS); - }); test('returns an x402Client with V2 and V1 schemes wired', async () => { - const client = await createVaultBackedX402Client(0); + const client = await createX402Client(0); // selectPaymentRequirements returns the picked accepts[] entry; assert // on its scheme + network to prove the right scheme was matched, not @@ -142,7 +110,7 @@ describe('createVaultBackedX402Client', () => { }); test('produces a verifiable V2 payment payload end-to-end (Base / USDC)', async () => { - const client = await createVaultBackedX402Client(0); + const client = await createX402Client(0); // Shape of the parsed `PAYMENT-REQUIRED` header for a Base USDC 402. const paymentRequired = { diff --git a/src/main/x402/intercept.js b/src/main/x402/intercept.js index 0ad94d74..d256f37f 100644 --- a/src/main/x402/intercept.js +++ b/src/main/x402/intercept.js @@ -423,6 +423,9 @@ function setPendingApproval(detectionId, ctx) { webContentsId: ctx.webContentsId, url: ctx.url, requirements: ctx.requirements, + // Bound while the webview is alive so the TTL message still reaches + // the sidebar if the tab has gone in the meantime. + notifyHost: ctx.notifyHost ?? hostSenderFor(ctx.webContentsId), timer, }); }); @@ -467,7 +470,7 @@ function expirePendingApproval(detectionId) { } const reason = new Error('approval expired'); entry.reject(reason); - sendToHost(entry.webContentsId, 'x402:approval-result', { + entry.notifyHost('x402:approval-result', { detectionId, success: false, error: 'Approval expired. Reload the resource to retry.', @@ -537,7 +540,39 @@ function finalizeAwaitingResponsesForWebContents(webContentsId, cleanupReason) { } } +/** + * Tell the sidebar that every approval card bound to this tab is dead. + * + * Aborting the pending-approval entries is not enough: a card whose Pay + * click already went through has NO entry left (the IPC settled it) and + * is sitting on an open-ended device confirmation, holding the sidebar's + * signature-flight lock. That lock is released only by an + * `x402:approval-result`, so if the tab dies mid-signature the sidebar + * stays locked for the rest of the session. `cancelled` tells the card + * the request itself is gone — tear down rather than offer a retry + * against a detection nothing can settle. + */ +function notifyApprovalCardsCancelled(webContentsId, error) { + const detectionIds = new Set(); + const detected = detectedPayments.get(webContentsId); + if (detected?.detectionId) detectionIds.add(detected.detectionId); + for (const [detectionId, entry] of pendingApprovals) { + if (entry.webContentsId === webContentsId) detectionIds.add(detectionId); + } + if (detectionIds.size === 0) return; + const notify = hostSenderFor(webContentsId); + for (const detectionId of detectionIds) { + notify('x402:approval-result', { + detectionId, + success: false, + cancelled: true, + error, + }); + } +} + function cleanupWebContents(webContentsId) { + notifyApprovalCardsCancelled(webContentsId, 'The tab that requested this payment was closed.'); detectedPayments.delete(webContentsId); pendingUnlockResume.delete(webContentsId); // Abort any in-flight awaits for this tab — without this the detector @@ -552,6 +587,11 @@ function cleanupWebContents(webContentsId) { } finalizeAwaitingResponsesForWebContents(webContentsId, 'tab destroyed'); deleteByWebContents(requestContext, webContentsId); + // Last event for this tab has gone out — drop the host binding so the + // map doesn't grow one entry per tab over the session. A signature + // still on the device keeps its own reference to the bound sender, so + // its result event survives this. + hostSenders.delete(webContentsId); } // === Header parsing ====================================================== @@ -641,14 +681,53 @@ function isStatus2xx(statusLine) { // owns the sidebar). The webview that hit the 402 is a child of the // host webContents; sending to host puts the event in front of the // wallet sidebar UI. +// +// The host is resolved THROUGH the webview, so once the tab is closed +// `webContents.fromId()` returns nothing and every later event for that +// tab is dropped — including `x402:approval-result`, the sidebar's only +// release for a signature already sitting on the device. The shell that +// owns the card outlives its tabs, so we remember the host we resolved +// while the webview was alive and fall back to it once the live lookup +// fails. Remembered hosts are dropped in `cleanupWebContents`, after the +// tab's last event has gone out; a sender bound earlier (the detector's, +// for a signature still on the device) keeps its own reference. +const hostSenders = new Map(); + +function resolveHost(webviewWebContentsId) { + return webContents.fromId(webviewWebContentsId)?.hostWebContents ?? null; +} + +/** + * Bind a sender for one webview. Call it while the webview is still alive + * (the detector does, at detection time) and the events it fires later + * survive the tab being closed. + */ +function hostSenderFor(webviewWebContentsId) { + let remembered = resolveHost(webviewWebContentsId) ?? hostSenders.get(webviewWebContentsId); + if (remembered) hostSenders.set(webviewWebContentsId, remembered); + return (channel, payload) => { + // Live lookup wins — the remembered host is only a fallback for a + // webview that has since gone away. + const live = resolveHost(webviewWebContentsId); + if (live) { + remembered = live; + hostSenders.set(webviewWebContentsId, live); + } + const host = live ?? remembered; + if (!host || host.isDestroyed?.()) { + log.warn(`[x402] no host webContents for ${webviewWebContentsId}; ${channel} dropped`); + return; + } + host.send(channel, payload); + }; +} + function sendToHost(webviewWebContentsId, channel, payload) { - const wc = webContents.fromId(webviewWebContentsId); - const host = wc?.hostWebContents; - if (!host) { - log.warn(`[x402] no host webContents for ${webviewWebContentsId}; ${channel} dropped`); - return; - } - host.send(channel, payload); + hostSenderFor(webviewWebContentsId)(channel, payload); +} + +function clearAllHostSenders() { + hostSenders.clear(); } // === Dispatcher handlers ================================================= @@ -891,10 +970,16 @@ async function detectPaymentRequiredHandler(details) { ...detectedPayments.get(details.webContentsId), detectionId, }); + // Bind the sidebar's host once, here, while the webview is certainly + // alive: the sign below is an open-ended device confirmation and the + // card holds the sidebar's signature lock until its result event + // arrives, so that event must not be addressed through a tab the user + // can close in the meantime. + const notifyHost = hostSenderFor(details.webContentsId); // Event payload includes `resourceType` so the renderer can pick the // right teardown IPC (subresource → x402:reject; mainFrame → x402:cancel // which also goBacks the webview). - sendToHost(details.webContentsId, 'x402:approval-needed', { + notifyHost('x402:approval-needed', { webContentsId: details.webContentsId, detectionId, url: details.url, @@ -924,6 +1009,7 @@ async function detectPaymentRequiredHandler(details) { webContentsId: details.webContentsId, url: details.url, requirements, + notifyHost, }); } catch (err) { if (err?.message && /aborted|cancelled/i.test(err.message)) { @@ -956,7 +1042,7 @@ async function detectPaymentRequiredHandler(details) { grant: decision.grant, }); log.info(`[x402:approval] subresource ${sanitizeUrlForLog(details.url)} signed; returning 307`); - sendToHost(details.webContentsId, 'x402:approval-result', { + notifyHost('x402:approval-result', { detectionId, success: true, }); @@ -977,7 +1063,7 @@ async function detectPaymentRequiredHandler(details) { `${err.message}\n stack: ${err.stack}` ); } - sendToHost(details.webContentsId, 'x402:approval-result', { + notifyHost('x402:approval-result', { detectionId, success: false, error: err.message, @@ -1185,5 +1271,6 @@ module.exports = { abortPendingApproval, abortPendingApprovalsForTab, clearAllPendingApprovals, + clearAllHostSenders, cleanupWebContents, }; diff --git a/src/main/x402/intercept.test.js b/src/main/x402/intercept.test.js index b1cc30e9..f44a7e69 100644 --- a/src/main/x402/intercept.test.js +++ b/src/main/x402/intercept.test.js @@ -41,6 +41,7 @@ jest.mock('./permissions', () => ({ tryConsume: (...args) => mockTryConsume(...args), })); +const { webContents } = require('electron'); const { VAULT_LOCKED_MESSAGE } = require('../wallet/vault-errors'); const { X402_HEADERS, @@ -67,6 +68,7 @@ const { settlePendingApproval, abortPendingApproval, clearAllPendingApprovals, + clearAllHostSenders, cleanupWebContents, } = require('./intercept'); @@ -87,6 +89,10 @@ beforeEach(() => { clearAllPendingUnlockResume(); clearAllPendingUnlockWaits(); clearAllPendingApprovals(); + clearAllHostSenders(); + webContents.fromId.mockReset().mockImplementation(() => ({ + hostWebContents: { send: mockHostSend }, + })); mockRegister.mockClear(); mockAppendReceipt.mockReset(); mockHostSend.mockClear(); @@ -649,6 +655,89 @@ describe('approval-card subresource path (await user decision, then 307)', () => }); }); + // The sidebar's signature-flight lock is released ONLY by + // x402:approval-result. Addressing that event through the paying tab's + // webContents (the old sendToHost) meant closing the tab while the + // device prompt was up dropped it — leaking the lock and bricking the + // whole sidebar until restart. The host is bound at detection time and + // outlives the tab, so the settle always lands. + test('delivers approval-result after the paying tab is destroyed mid-signature', async () => { + let settleSign; + mockSignAndQueueRetry.mockReset().mockImplementation( + () => new Promise((resolve) => { settleSign = resolve; }) + ); + + const handlerPromise = detectPaymentRequiredHandler(detail()); + await Promise.resolve(); + settlePendingApproval('req-1001', { approved: true }); + await flushRetryMicrotasks(); + // Signature is on the device: nothing left to abort in main. + expect(hasPendingApproval('req-1001')).toBe(false); + + // User closes the paying tab. The guest webContents is gone, so the + // tab can no longer be used to find the sidebar's window. + cleanupWebContents(7); + webContents.fromId.mockReturnValue(undefined); + mockHostSend.mockClear(); + + settleSign(); + await handlerPromise; + + expect(mockHostSend).toHaveBeenCalledWith('x402:approval-result', { + detectionId: 'req-1001', + success: true, + }); + }); + + test('destroying the paying tab cancels its card so a held signature lock is released', async () => { + let settleSign; + mockSignAndQueueRetry.mockReset().mockImplementation( + () => new Promise((resolve) => { settleSign = resolve; }) + ); + + const handlerPromise = detectPaymentRequiredHandler(detail()); + await Promise.resolve(); + settlePendingApproval('req-1001', { approved: true }); + await flushRetryMicrotasks(); + mockHostSend.mockClear(); + + cleanupWebContents(7); + + // `cancelled` tells the card to tear down rather than offer a retry + // against a detection nothing can settle — and that teardown is what + // releases the sidebar, without waiting on the device. + expect(mockHostSend).toHaveBeenCalledWith('x402:approval-result', { + detectionId: 'req-1001', + success: false, + cancelled: true, + error: 'The tab that requested this payment was closed.', + }); + + settleSign(); + await handlerPromise; + }); + + test('destroying the tab while the user is still deciding cancels the card too', async () => { + const handlerPromise = detectPaymentRequiredHandler(detail()); + await Promise.resolve(); + expect(hasPendingApproval('req-1001')).toBe(true); + mockHostSend.mockClear(); + + cleanupWebContents(7); + + expect(mockHostSend).toHaveBeenCalledWith('x402:approval-result', expect.objectContaining({ + detectionId: 'req-1001', + cancelled: true, + })); + expect(hasPendingApproval('req-1001')).toBe(false); + await expect(handlerPromise).resolves.toBeNull(); + }); + + test('a tab with no x402 card in play sends nothing on destroy', () => { + cleanupWebContents(99); + expect(mockHostSend).not.toHaveBeenCalled(); + }); + test('manual approval preserves captured Range requestShape for the signed retry key', async () => { captureRequestContextHandler({ id: 1001, diff --git a/src/main/x402/ipc.test.js b/src/main/x402/ipc.test.js index bd882b17..12653754 100644 --- a/src/main/x402/ipc.test.js +++ b/src/main/x402/ipc.test.js @@ -23,7 +23,7 @@ const mockClient = { }; const mockCreateClient = jest.fn(async () => mockClient); jest.mock('./client', () => ({ - createVaultBackedX402Client: (idx) => mockCreateClient(idx), + createX402Client: (idx) => mockCreateClient(idx), })); const mockGetActiveWalletIndex = jest.fn(() => 0); diff --git a/src/main/x402/sign-flow.js b/src/main/x402/sign-flow.js index 4cbcd920..b92c6859 100644 --- a/src/main/x402/sign-flow.js +++ b/src/main/x402/sign-flow.js @@ -18,7 +18,7 @@ const { webContents } = require('electron'); const log = require('../logger'); -const { createVaultBackedX402Client } = require('./client'); +const { createX402Client } = require('./client'); const { getActiveWalletIndex } = require('../identity-manager'); const { normalizeOrigin } = require('../../shared/origin-utils'); const { @@ -32,10 +32,38 @@ const { const { grant: grantPermission } = require('./permissions'); const { tupleFromAccept } = require('./payment-utils'); +/** + * Runway an EIP-3009 authorization must still have when we hand it to the + * dispatcher. `@x402/evm`'s exact facilitator rejects anything with + * `validBefore < now + 6`, so a payload with less than that left is dead on + * arrival however valid the signature is. We gate on the *client's* clock at + * sign-completion, but the facilitator re-checks the same rule on *its* clock + * seconds later — after the mainFrame re-navigation, the server round-trip and + * the server→facilitator verify hop — so matching the bare 6 leaves no margin + * for transit or clock skew and can still be refused server-side (the exact + * burned-charge case this gate exists to prevent). Keep comfortably above it; + * a false positive only re-shows the same "try again" card. + */ +const MIN_AUTHORIZATION_RUNWAY_SECONDS = 20; + +/** + * Seconds left on the signed authorization, or `null` when the payload has + * no `validBefore` to check (non-EIP-3009 scheme, or a test double). + * + * Both the V1 and V2 exact schemes nest the authorization the same way: + * `{ payload: { authorization: { validBefore } } }`. + */ +function authorizationRunwaySeconds(payload) { + const validBefore = Number(payload?.payload?.authorization?.validBefore); + if (!Number.isFinite(validBefore)) return null; + return validBefore - Math.floor(Date.now() / 1000); +} + /** * Sign the currently-detected payment for `webContentsId` and queue a * retry of the original URL. Throws on any failure (vault locked, URL - * unparseable, client error). Caller decides how to surface the error. + * unparseable, client error, an authorization that expired while it was + * being confirmed). Caller decides how to surface the error. * * `opts.detection` lets the caller pass an explicit snapshot of the * detection to sign — used by the auto-pay path so a second 402 firing @@ -90,7 +118,7 @@ async function signAndQueueRetry(webContentsId, opts = {}) { catch { throw new Error('Refusing to pay: unparseable URL'); } if (!origin) throw new Error('Refusing to pay: unnormalisable origin'); - const client = await createVaultBackedX402Client(getActiveWalletIndex()); + const client = await createX402Client(getActiveWalletIndex()); // Pre-filter `accepts[]` down to the chosen entry so the SDK's default // first-of-filtered selector signs the right one. Avoids registering a // custom paymentRequirementsSelector for what is effectively a one- @@ -100,6 +128,37 @@ async function signAndQueueRetry(webContentsId, opts = {}) { accepts: [selectedAccept], }); + // The SDK stamps `validBefore = now + maxTimeoutSeconds` BEFORE it asks the + // signer for a signature, and on a hardware wallet that call blocks for as + // long as the user takes to review the EIP-712 payload on the device. A + // confirmation slower than the server's window (often ~60s) therefore + // produces a perfectly valid signature over an already-expired + // authorization. Dispatching it burns the charge: the facilitator refuses + // it, the server re-402s, the loop guard in intercept.js declines to + // re-sign, and a `failed` row lands in payment history after the user + // physically confirmed on the device. + // + // Widening the window instead (inflating `maxTimeoutSeconds` on the accept + // we sign) is NOT an option: for x402 v2 the client echoes the selected + // requirements back as `payload.accepted`, and @x402/core's server does a + // deep-equal against its own advertised accepts — a padded window would be + // rejected as a non-matching requirement. + // + // So bail before anything is stashed or navigated. The detection is still + // in the map and no pending payment is armed, which means the caller's + // error surfaces on the approval card with Pay live again — a second + // device prompt, but no reload and no bogus failure row. + const runway = authorizationRunwaySeconds(payload); + if (runway !== null && runway < MIN_AUTHORIZATION_RUNWAY_SECONDS) { + log.warn( + `[x402:sign] authorization expired during signing (${runway}s of runway left); ` + + `not dispatching a payment the facilitator will refuse` + ); + throw new Error( + 'Payment authorization expired while it was being confirmed. Please try again.' + ); + } + const headerValue = Buffer.from(JSON.stringify(payload)).toString('base64'); const headerName = outgoingHeaderForVersion(detected.requirements.x402Version); const tuple = tupleFromAccept(selectedAccept); diff --git a/src/main/x402/sign-flow.test.js b/src/main/x402/sign-flow.test.js index 7ff64686..3c4cedbd 100644 --- a/src/main/x402/sign-flow.test.js +++ b/src/main/x402/sign-flow.test.js @@ -17,7 +17,7 @@ const mockClient = { }; const mockCreateClient = jest.fn(async () => mockClient); jest.mock('./client', () => ({ - createVaultBackedX402Client: (idx) => mockCreateClient(idx), + createX402Client: (idx) => mockCreateClient(idx), })); jest.mock('../identity-manager', () => ({ @@ -164,3 +164,87 @@ describe('signAndQueueRetry — selectedAccept resolution', () => { ); }); }); + +// The SDK stamps validBefore before it asks the signer to sign, so a slow +// on-device confirmation can outlive the authorization. Anything the +// facilitator would refuse (`validBefore < now + 6`) must not be dispatched. +describe('signAndQueueRetry — authorization freshness', () => { + const nowSeconds = () => Math.floor(Date.now() / 1000); + + function payloadValidUntil(validBefore) { + return { + x402Version: 2, + payload: { authorization: { validBefore: String(validBefore) }, signature: '0xsig' }, + }; + } + + function detection() { + return { + url: 'https://api.example/article', + requirements: requirementsWith(baseAccept), + resourceType: 'mainFrame', + }; + } + + test('rejects — and stashes nothing — when the device confirmation outlived validBefore', async () => { + mockClient.createPaymentPayload.mockResolvedValue(payloadValidUntil(nowSeconds() - 5)); + + await expect(signAndQueueRetry(7, { detection: detection() })) + .rejects.toThrow(/expired while it was being confirmed/i); + + // No signature armed for the injector, no re-navigation: the doomed + // retry that would trip the loop guard never goes out. + expect(consumePending(7, 'https://api.example/article')).toBeNull(); + expect(mockLoadURL).not.toHaveBeenCalled(); + }); + + test('rejects inside the facilitator skew window even though validBefore is future', async () => { + mockClient.createPaymentPayload.mockResolvedValue(payloadValidUntil(nowSeconds() + 2)); + + await expect(signAndQueueRetry(7, { detection: detection() })) + .rejects.toThrow(/expired while it was being confirmed/i); + }); + + test('keeps the detection so the user can retry from the same card', async () => { + // Seed through the real detector — there is no public setter for the + // detected-payments map, and this is the entry point production uses. + const requirements = { + x402Version: 2, + resource: { url: 'https://api.example/article' }, + accepts: [{ + scheme: 'exact', + network: 'eip155:8453', + amount: '10000', + asset: BASE_USDC, + payTo: '0x209693Bc6afc0C5328bA36FaF03C514EF312287C', + maxTimeoutSeconds: 60, + extra: { name: 'USD Coin', version: '2' }, + }], + }; + intercept.detectPaymentRequiredHandler({ + webContentsId: 7, + url: 'https://api.example/article', + statusLine: 'HTTP/1.1 402 Payment Required', + responseHeaders: { + 'PAYMENT-REQUIRED': [Buffer.from(JSON.stringify(requirements)).toString('base64')], + }, + }); + expect(intercept.getDetectedPayment(7)).toBeTruthy(); + + mockClient.createPaymentPayload.mockResolvedValue(payloadValidUntil(nowSeconds() - 1)); + + await expect(signAndQueueRetry(7)).rejects.toThrow(/expired while it was being confirmed/i); + + expect(intercept.getDetectedPayment(7)).toBeTruthy(); + }); + + test('dispatches normally when the authorization still has runway', async () => { + mockClient.createPaymentPayload.mockResolvedValue(payloadValidUntil(nowSeconds() + 60)); + + await signAndQueueRetry(7, { detection: detection() }); + + expect(consumePending(7, 'https://api.example/article')?.requestHeaders['PAYMENT-SIGNATURE']) + .toBeDefined(); + expect(mockLoadURL).toHaveBeenCalledWith('https://api.example/article'); + }); +}); diff --git a/src/renderer/index.html b/src/renderer/index.html index 7c509343..85b217a7 100644 --- a/src/renderer/index.html +++ b/src/renderer/index.html @@ -1132,6 +1132,15 @@

Set Up Your Identity

Create New Wallet + @@ -1698,6 +1707,95 @@

Wallet Created!

+ + +