From d3274222f9aaa99456ed8759687c2915d21eeac8 Mon Sep 17 00:00:00 2001 From: Uncle Fatso Date: Thu, 8 Oct 2026 18:25:56 +0300 Subject: [PATCH] rewrite extension around new UX Signed-off-by: Uncle Fatso --- assets/chainspecs/casper_staging_testnet.json | 10 +- assets/logos/casper.svg | 32 + assets/logos/ghost.svg | 33 + package.json | 4 +- pnpm-lock.yaml | 18 + src/background/createBackgroundRpc.ts | 747 ++++++++++-------- src/background/index.ts | 54 +- src/background/keyring.ts | 622 ++++++++------- src/background/keystore/types.ts | 56 +- src/background/pjs.ts | 33 +- src/background/rpc/chainspec.ts | 38 - src/background/rpc/types.ts | 1 + src/background/storage.ts | 35 +- src/background/types.ts | 136 ++-- src/components/Bootnodes.tsx | 23 +- src/components/BraveModal.tsx | 4 +- src/components/Layout.tsx | 76 +- src/components/Layout2.tsx | 35 +- src/components/Logo.tsx | 2 +- src/components/Title.tsx | 1 + src/components/ui/button.tsx | 19 +- src/components/ui/checkbox.tsx | 6 +- src/components/ui/pagination.tsx | 4 +- src/components/ui/radio-group.tsx | 5 +- src/constants.ts | 18 +- src/containers/Options.tsx | 22 +- src/containers/WalletPopup.tsx | 51 +- .../WalletPopup/api/decodeCallData.ts | 7 +- .../WalletPopup/components/BottomNavBar.tsx | 63 +- .../components/DecodedCallData.tsx | 55 +- .../WalletPopup/components/JohnMcAffee.tsx | 302 +++++++ .../WalletPopup/components/NetworkLogo.tsx | 88 +++ .../WalletPopup/components/ProtectedRoute.tsx | 33 - .../components/UserSignedExtensionInputs.tsx | 30 +- .../components/UserSignedExtensions.tsx | 1 + .../WalletPopup/components/index.ts | 3 +- src/containers/WalletPopup/hooks/index.ts | 3 +- .../WalletPopup/hooks/useAccounts.tsx | 271 +++++++ src/containers/WalletPopup/hooks/useChain.tsx | 69 ++ .../WalletPopup/hooks/useKeyring.tsx | 75 -- .../pages/Accounts/AccountDetails.tsx | 282 ++++--- .../WalletPopup/pages/Accounts/Accounts.tsx | 407 ++++++---- .../WalletPopup/pages/Accounts/AddAccount.tsx | 478 ++++++----- .../pages/Accounts/DeleteAccount.tsx | 254 ++++++ .../pages/Accounts/DeriveAccount.tsx | 179 +++++ .../pages/Accounts/ExportAccounts.tsx | 213 +++++ .../pages/Accounts/ImportAccounts.tsx | 528 ++++++------- .../pages/Accounts/SwitchAccount.tsx | 247 ++++-- .../WalletPopup/pages/Accounts/index.ts | 3 + .../WalletPopup/pages/ChangePassword.tsx | 2 +- .../WalletPopup/pages/CreatePassword.tsx | 79 +- .../WalletPopup/pages/DeleteWallet.tsx | 4 +- src/containers/WalletPopup/pages/Networks.tsx | 1 - src/containers/WalletPopup/pages/Settings.tsx | 98 ++- .../WalletPopup/pages/SignRequest.tsx | 256 +++--- .../WalletPopup/pages/SwitchNetwork.tsx | 123 +++ .../WalletPopup/pages/UnlockKeyring.tsx | 142 ---- src/containers/WalletPopup/pages/Welcome.tsx | 103 +-- src/containers/WalletPopup/pages/index.ts | 2 +- src/inpage/index.ts | 6 +- src/style.css | 60 +- tailwind.config.js | 13 +- 62 files changed, 4252 insertions(+), 2313 deletions(-) create mode 100644 assets/logos/casper.svg create mode 100644 assets/logos/ghost.svg create mode 100644 src/containers/WalletPopup/components/JohnMcAffee.tsx create mode 100644 src/containers/WalletPopup/components/NetworkLogo.tsx delete mode 100644 src/containers/WalletPopup/components/ProtectedRoute.tsx create mode 100644 src/containers/WalletPopup/hooks/useAccounts.tsx create mode 100644 src/containers/WalletPopup/hooks/useChain.tsx delete mode 100644 src/containers/WalletPopup/hooks/useKeyring.tsx create mode 100644 src/containers/WalletPopup/pages/Accounts/DeleteAccount.tsx create mode 100644 src/containers/WalletPopup/pages/Accounts/DeriveAccount.tsx create mode 100644 src/containers/WalletPopup/pages/Accounts/ExportAccounts.tsx create mode 100644 src/containers/WalletPopup/pages/SwitchNetwork.tsx delete mode 100644 src/containers/WalletPopup/pages/UnlockKeyring.tsx diff --git a/assets/chainspecs/casper_staging_testnet.json b/assets/chainspecs/casper_staging_testnet.json index 0e62287..cae5de8 100644 --- a/assets/chainspecs/casper_staging_testnet.json +++ b/assets/chainspecs/casper_staging_testnet.json @@ -11,14 +11,14 @@ "chainType": "Live", "forkBlocks": null, "genesis": { - "stateRootHash": "0xfcb09113f5c18bb681bd0376bdb01a1d672030cf021ff24f017f9c90d6340669" + "stateRootHash": "0xb4784a3c8f2f29ba3f191d563f1166f08f4e0337e114679c0f24ef77d06e10e4" }, "id": "casper_staging_testnet", "lightSyncState": { - "babeEpochChanges": "0x0486e94817613148af08d25b61d10de83b314aad94478565e9d5070ad5bf1384de181c00000164dc951100000000c4e59511000000000468c4943cf7c7ced2abbe900614a80a5e8a3029eca80186350ff93745379523007825000001c4e595110000000024ef951100000000047460d5a9745e7ac79c62165f29bfc35ca816684a91e0afe30464781ec663ffd3d82e00000124ef95110000000084f895110000000000000c68c4943cf7c7ced2abbe900614a80a5e8a3029eca80186350ff937453795230078250000010500000000000000c4e5951100000000600900000000000010daaaaab6a6e574099e24ae9bb75b543610edef9d374fa85a378edb573b47615f010000000000000074fa7381a7a74b316afb6793a00387eed9d95d46a69866cbb316b5d9c918af0e01000000000000006c4dd88b43e2011cf9a6a73d53446336ac9e04cdd4ca23587df63187ac455e4901000000000000003c944c704cae203619b9e7a5a4b6742736da6a8e76c762291bebdc7652cfec2f01000000000000000e662d4ad2b3bf5eb96ec0fade8662d2225d4717319580d1c4eb932e8bce917d01000000000000000400000000000000027460d5a9745e7ac79c62165f29bfc35ca816684a91e0afe30464781ec663ffd3d82e000001060000000000000024ef951100000000600900000000000028426ab855ba3d0eaade067c62f197989e13c28641948d018d657c13c139da9e450100000000000000daaaaab6a6e574099e24ae9bb75b543610edef9d374fa85a378edb573b47615f010000000000000074fa7381a7a74b316afb6793a00387eed9d95d46a69866cbb316b5d9c918af0e0100000000000000dee45a9f88b2829e831f26908a7769db5fd38e7c0fc0cfec3fd83cede451fc120100000000000000c66631a4b6220beb0ad83498ddd36b2cb53fc0e345edd50e6d40dcfc74cc6b7b01000000000000006c4dd88b43e2011cf9a6a73d53446336ac9e04cdd4ca23587df63187ac455e4901000000000000003c944c704cae203619b9e7a5a4b6742736da6a8e76c762291bebdc7652cfec2f01000000000000002c4d7c0e011f53f81f31a071f3d4c42f1e85ad60e50918bc841a5001b9763f4f01000000000000003a9afa87f78c79af14753c30851f66414604987dc5ba85dd568403e0963c927d01000000000000001e06092d5210b61cadd1118e3142ecafc4d53de4267c1d7ad8205d50e64c5d4601000000000000003584ab6c4870be3cb13b2b69b7569fd593d8354b202917d61a6f139f70187b24010000000000000004000000000000000286e94817613148af08d25b61d10de83b314aad94478565e9d5070ad5bf1384de181c000001040000000000000064dc951100000000600900000000000010daaaaab6a6e574099e24ae9bb75b543610edef9d374fa85a378edb573b47615f010000000000000074fa7381a7a74b316afb6793a00387eed9d95d46a69866cbb316b5d9c918af0e01000000000000006c4dd88b43e2011cf9a6a73d53446336ac9e04cdd4ca23587df63187ac455e4901000000000000003c944c704cae203619b9e7a5a4b6742736da6a8e76c762291bebdc7652cfec2f010000000000000005cfbdbd5ccbf25f2bc82b8488ef493b8ee1205d2d1e0b2a52689d8ed252c1470100000000000000040000000000000002", - "babeFinalizedBlockWeight": 3115, - "finalizedBlockHeader": "0x40ab762007d6e2d1dddb18a10073e637712b425bac09fe2d87e50373afa1fcb111bd1cd1b45c2547cc5123ca78564a1fe0bcaced95cf3e0c7cf305b7cb443e2c037d6a40040ac0406b97a19a74625c9a717e9a0a922e41d733a88e8ce860fce8e278080642414245b501030100000030e69511000000006279297ea7ba3278c5a8fcbd16a7fd02d14fa771983443ff8d1fc4d536c8684a00ac542d13f1b03c54f5c4e51f473d326bad1feae09b95a632aa87da9c491c0cae7d5981d364a093b341db9a0028af5540957f6a01b952d3c0ef18b52c2f490d054241424501016e1a35643f1fe6e1f08e8ef0b2165368d2ae2a576a0bb10584c58a27005e2c5b5ef4d86c1f2dd98ac498c8cb3b314def9e72aaaf1b446c36f345339091a76487", - "grandpaAuthoritySet": "0x1055446f9a7aa99ced06b317c80ce90d56b84e56526775683af2525969e8da0b640100000000000000236d2fa03f4ed8cb65de7e514d7540159b328f1c170dd402b094ad7fbf54721801000000000000008f9ea20bf4a807a8e710f7559dece86e94672b5b361de157bdaa5c1f37849f8d01000000000000000aa3a88f6b777c95c3dfe7e997b76798413f16aa325f34824cae0c9102b281d5010000000000000000000000000000000001442f00000000" + "babeEpochChanges": "0x042c315b655c7144df4bfdc41837eeacb84d80aee06eaba51f6941a0c7a26cb1bf5d090000015e9de408000000000ea2e40800000000049b2e88b8ce43a677c1ca90f62134800b7e510c0c353dad719cad82faf6136cfb0d0e0000010ea2e40800000000bea6e408000000000000082c315b655c7144df4bfdc41837eeacb84d80aee06eaba51f6941a0c7a26cb1bf5d0900000103000000000000005e9de40800000000b00400000000000014daaaaab6a6e574099e24ae9bb75b543610edef9d374fa85a378edb573b47615f010000000000000074fa7381a7a74b316afb6793a00387eed9d95d46a69866cbb316b5d9c918af0e0100000000000000e0f0a776ecc9fa5e1f22e2fa001fe3fba5aea52b9444bc894b45589d4213247501000000000000006c4dd88b43e2011cf9a6a73d53446336ac9e04cdd4ca23587df63187ac455e4901000000000000003c944c704cae203619b9e7a5a4b6742736da6a8e76c762291bebdc7652cfec2f01000000000000002ff7c765405e9c11a59525a08173bb1fa59e5e281090f8e4d1824469f513d0cc01000000000000000400000000000000029b2e88b8ce43a677c1ca90f62134800b7e510c0c353dad719cad82faf6136cfb0d0e00000104000000000000000ea2e40800000000b00400000000000014daaaaab6a6e574099e24ae9bb75b543610edef9d374fa85a378edb573b47615f010000000000000074fa7381a7a74b316afb6793a00387eed9d95d46a69866cbb316b5d9c918af0e0100000000000000e0f0a776ecc9fa5e1f22e2fa001fe3fba5aea52b9444bc894b45589d4213247501000000000000006c4dd88b43e2011cf9a6a73d53446336ac9e04cdd4ca23587df63187ac455e4901000000000000003c944c704cae203619b9e7a5a4b6742736da6a8e76c762291bebdc7652cfec2f0100000000000000dea465ff8bf16938cc9ead89bf9fdb5d9c495fad61fd952b2067ebdc51c4e1c10100000000000000040000000000000002", + "babeFinalizedBlockWeight": 1039, + "finalizedBlockHeader": "0x6ed6c2debc2e5fd4e7db0d14426d1b904e6c101f2c62a71b8e891c217ba79079f9433280c0257a860f793833bd6bed6b9f86094400aa8e5ea24a81752f0f046ff5ed560f7231760c622ae35d3968fbab033ed34d950b57a7937ae45c411d1d384748080642414245b50103000000004fa0e40800000000a2c7d71b4e1f94c0208c9730ab93955b08c8d7ac86d2fc6ec5f62cc3272a5f1ec0e28c798461372109ee3e12f03ff66ea5ad9cac5b1c37d7280d2333d0226f0865ea77a801bad79785a8ca83c87f0dbf1e36936a6f65cd7b125a41c52f38f80105424142450101caaa6fe5f9df57930e2ac08aef43b75d5477d410c73c3a8e6dea9d5503b9fd25eae1d12b6604194389e22f3f4265d581eb162d1dc5a9034247fe33765dddbe8c", + "grandpaAuthoritySet": "0x1455446f9a7aa99ced06b317c80ce90d56b84e56526775683af2525969e8da0b640100000000000000236d2fa03f4ed8cb65de7e514d7540159b328f1c170dd402b094ad7fbf5472180100000000000000b63c5a0cf342b9b04931bc8ed74d7d0165ab99ab5f8a4514797d4b299a4501fe01000000000000008f9ea20bf4a807a8e710f7559dece86e94672b5b361de157bdaa5c1f37849f8d01000000000000000aa3a88f6b777c95c3dfe7e997b76798413f16aa325f34824cae0c9102b281d5010000000000000000000000000000000001fe1000000000" }, "name": "Casper Staging Testnet", "properties": { diff --git a/assets/logos/casper.svg b/assets/logos/casper.svg new file mode 100644 index 0000000..2930411 --- /dev/null +++ b/assets/logos/casper.svg @@ -0,0 +1,32 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/assets/logos/ghost.svg b/assets/logos/ghost.svg new file mode 100644 index 0000000..53b7b34 --- /dev/null +++ b/assets/logos/ghost.svg @@ -0,0 +1,33 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/package.json b/package.json index b4d36de..7314ae8 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "ghost-wallet", - "version": "0.3.0", + "version": "0.4.0", "description": "Browser extension to manage ghost blockchain light clients.", "main": "dist/src/index.js", "author": "Uncle f4ts0 ", @@ -140,11 +140,13 @@ "@zag-js/clipboard": "^0.47.0", "@zag-js/react": "^0.47.0", "@zag-js/tabs": "^0.74.2", + "@zip.js/zip.js": "^2.18.2", "class-variance-authority": "^0.7.0", "clsx": "^2.1.1", "cmdk": "^1.0.4", "date-fns": "^4.1.0", "embla-carousel-react": "^8.5.1", + "fuse.js": "^7.5.0", "input-otp": "^1.2.4", "lodash": "^4.17.21", "lucide-react": "^0.468.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 25be211..1e40d4b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -170,6 +170,9 @@ importers: '@zag-js/tabs': specifier: ^0.74.2 version: 0.74.2 + '@zip.js/zip.js': + specifier: ^2.18.2 + version: 2.18.2 class-variance-authority: specifier: ^0.7.0 version: 0.7.1 @@ -185,6 +188,9 @@ importers: embla-carousel-react: specifier: ^8.5.1 version: 8.6.0(react@18.3.1) + fuse.js: + specifier: ^7.5.0 + version: 7.5.0 input-otp: specifier: ^1.2.4 version: 1.4.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1) @@ -3536,6 +3542,10 @@ packages: '@zag-js/utils@0.74.2': resolution: {integrity: sha512-WtIsNyDvnslCjtIIP/bRzx3bJMaT0cIgI3f+TgiFWhtQMlUZMpBkwkKVfvUwI5qcZ+ZOMeoonAWFqFECCb3h3g==} + '@zip.js/zip.js@2.18.2': + resolution: {integrity: sha512-J1jwg0Kv5Aab50VlVZ6hp2rzGL/J0YLjBnkF6oJoIjmpxx9+tHgjRELGKryKnDZEcGXKjv7ibwxAziFiWHdR2A==} + engines: {bun: '>=0.7.0', deno: '>=1.0.0', node: '>=18.0.0'} + abort-controller@3.0.0: resolution: {integrity: sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==} engines: {node: '>=6.5'} @@ -4805,6 +4815,10 @@ packages: functions-have-names@1.2.3: resolution: {integrity: sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==} + fuse.js@7.5.0: + resolution: {integrity: sha512-sQtrEfA+ez/3G0cCZecF70oqpCRttCexYUG4mUrtWL49ULUzUyxokt5kyqwtKzj1270RaKih+hcP3qLcumccow==} + engines: {node: '>=10'} + fx-runner@1.4.0: resolution: {integrity: sha512-rci1g6U0rdTg6bAaBboP7XdRu01dzTAaKXxFf+PUqGuCv6Xu7o8NZdY1D5MvKGIjb6EdS1g3VlXOgksir1uGkg==} hasBin: true @@ -11255,6 +11269,8 @@ snapshots: '@zag-js/utils@0.74.2': {} + '@zip.js/zip.js@2.18.2': {} + abort-controller@3.0.0: dependencies: event-target-shim: 5.0.1 @@ -12866,6 +12882,8 @@ snapshots: functions-have-names@1.2.3: {} + fuse.js@7.5.0: {} + fx-runner@1.4.0: dependencies: commander: 2.9.0 diff --git a/src/background/createBackgroundRpc.ts b/src/background/createBackgroundRpc.ts index 922cbd5..f8d12f7 100644 --- a/src/background/createBackgroundRpc.ts +++ b/src/background/createBackgroundRpc.ts @@ -26,342 +26,455 @@ import { wellKnownDecimalsByGenesisHash } from "../constants" -import { - addChainSpecHandler, - listChainSpecsHandler, - removeChainSpecHandler, -} from "./rpc/chainspec" +import { listChainSpecsHandler } from "./rpc/chainspec" const isUserSignedExtensionName = (s: string): s is UserSignedExtensionName => { - return ( - s === "CheckMortality" || - s === "ChargeTransactionPayment" || - s === "ChargeAssetTxPayment" - ) + return ( + s === "CheckMortality" || + s === "ChargeTransactionPayment" || + s === "ChargeAssetTxPayment" + ) } const keyring = createKeyring() - +const requestCooldowns = new Map() +const COOLDOWN_MS = 3000 let nextSignRequestId = 0 export const createBackgroundRpc = ( - sendMessage: (message: RpcMessage) => void + sendMessage: (message: RpcMessage) => void ) => { - const getAccounts: RpcMethodHandlers< - BackgroundRpcSpec, - Context - >["getAccounts"] = async ([chainId], { lightClientPageHelper }) => { - const chains = await lightClientPageHelper.getChains() - const chain = chains.find(({ genesisHash }) => genesisHash === chainId) - if (!chain) throw new Error("unknown chain") - return (await keyring.getAccounts(chain.genesisHash)).map( - ({ publicKey }) => ({ - address: ss58Address( - publicKey, - wellKnownPrefixByGenesisHash[chain.genesisHash] - ), - }) - ) - } + const getAccounts: RpcMethodHandlers< + BackgroundRpcSpec, + Context + >["getAccounts"] = async ([chainId], { lightClientPageHelper }) => { + const chains = await lightClientPageHelper.getChains() + const chain = chains.find(({ genesisHash }) => genesisHash === chainId) + if (!chain) return [] - const notifyOnAccountsChanged = async (context: Context) => - context.notifyOnAccountsChanged( - ( - await Promise.all( - (await context.lightClientPageHelper.getChains()).map( - ({ genesisHash }) => getAccounts([genesisHash], context) - ), - ) - ).flatMap((accounts) => accounts), - ) + const accounts = await keyring.getAccounts(chainId) + const prefix = wellKnownPrefixByGenesisHash[chain.genesisHash] - const handlers: RpcMethodHandlers = { - getAccounts, - async createTx( - [chainId, from, callData], - { lightClientPageHelper, signRequests, port }, - ) { - const url = port.sender?.url - if (!url) throw new Error("unknown url") - const chains = await lightClientPageHelper.getChains() - const chain = chains.find(({ genesisHash }) => genesisHash === chainId) - if (!chain) throw new Error("unknown chain") + return accounts.map(acc => ({ + ...acc, + address: ss58Address(acc.publicKey, prefix) + })) + } - const id = nextSignRequestId++ - const client = getObservableClient(createClient(chain.provider)) - const chainHead$ = client.chainHead$() + const getActiveAccount: RpcMethodHandlers< + BackgroundRpcSpec, + Context + >["getActiveAccount"] = async ([chainId], { lightClientPageHelper }) => { + const chains = await lightClientPageHelper.getChains() + const chain = chains.find(({ genesisHash }) => genesisHash === chainId) + if (!chain) return undefined - const userSignedExtensionNames = await firstValueFrom( - chainHead$.best$.pipe( - mergeMap((blockInfo) => - chainHead$.getRuntimeContext$(blockInfo.hash).pipe( - take(1), - map(({ lookup: { metadata } }) => - metadata.extrinsic.signedExtensions - .map(({ identifier }) => identifier) - .filter(isUserSignedExtensionName) - ), + return await keyring.getActiveAccount(chainId) + } + + const getAccountDerivations: RpcMethodHandlers< + BackgroundRpcSpec, + Context + >["getAccountDerivations"] = async ([metadata], { lightClientPageHelper }) => { + if (!metadata) return undefined + const chains = await lightClientPageHelper.getChains() + const chain = chains.find(({ genesisHash }) => genesisHash === metadata.chainId) + if (!chain) return undefined + + const derivations = await keyring.getAccountDerivations(metadata) + const prefix = wellKnownPrefixByGenesisHash[chain.genesisHash] + + return { + ...metadata, + address: ss58Address(metadata.publicKey, prefix), + derivations: derivations.map(derivation => ({ + ...derivation, + address: ss58Address(derivation.publicKey, prefix), + })) + } + } + + const getAccount: RpcMethodHandlers< + BackgroundRpcSpec, + Context + >["getAccount"] = async ([chainId], { lightClientPageHelper }) => { + const chains = await lightClientPageHelper.getChains() + const chain = chains.find(({ genesisHash }) => genesisHash === chainId) + if (!chain) return undefined + + const activeAccount = await keyring.getActiveAccount(chainId) + if (!activeAccount || !activeAccount.publicKey) return undefined + + const derivations = await keyring.getAccountDerivations({ + // name and scheme not used for derivation extraction + publicKey: activeAccount.publicKey, chainId, name: "", scheme: "Sr25519" + }) + const derivation = derivations.find(derivation => derivation.path == activeAccount.path) + const publicKey = derivation ? derivation.publicKey : activeAccount.publicKey + + return { + publicKey: publicKey, + address: ss58Address( + publicKey, + wellKnownPrefixByGenesisHash[chainId] ), - ), - filter(Boolean) - ) - ) + } + } - try { - const signRequest = new Promise< - Parameters[0] - >( - (resolve, reject) => - (signRequests[id] = { - resolve, - reject, - chainId, - url, - address: ss58Address( - from, - wellKnownPrefixByGenesisHash[chain.genesisHash] - ), - callData, - userSignedExtensions: { - type: "names", - names: userSignedExtensionNames, - }, + const notifyOnAccountsChanged = async (context: Context) => + context.notifyOnAccountsChanged( + ( + await Promise.all( + (await context.lightClientPageHelper.getChains()).map( + ({ genesisHash }) => getAccounts([genesisHash], context) + ), + ) + ).flatMap((accounts) => accounts), + ) + + const handlers: RpcMethodHandlers = { + getAccountDerivations, + getAccounts, + getAccount, + getActiveAccount, + async createTx( + [chainId, from, callData], + { lightClientPageHelper, signRequests, port }, + ) { + const url = port.sender?.url + if (!url) throw new Error("unknown url provided") + + const now = Date.now() + const lastApprovedRequestTime = requestCooldowns.get(url) || 0 + + if (now - lastApprovedRequestTime < COOLDOWN_MS) { + throw new Error("wallet interaction is rate-limited") + + } + + requestCooldowns.set(url, now) + + const chains = await lightClientPageHelper.getChains() + const chain = chains.find(({ genesisHash }) => genesisHash === chainId) + if (!chain) throw new Error("unknown chain provided") + + const id = nextSignRequestId++ + + const client = getObservableClient(createClient(chain.provider)) + const chainHead$ = client.chainHead$() + + const userSignedExtensionNames = await firstValueFrom( + chainHead$.best$.pipe( + mergeMap((blockInfo: { hash: string; [key: string]: any }) => + chainHead$.getRuntimeContext$(blockInfo.hash).pipe( + take(1), + map(({ lookup: { metadata } }: { lookup: { metadata: any } }) => + metadata.extrinsic.signedExtensions + .map(({ identifier }: { identifier: string }) => identifier) + .filter(isUserSignedExtensionName) + ), + ), + ), + filter(Boolean) + ) + ) + .finally(() => { + chainHead$.unfollow() + client.destroy() }) - ) - const window = await chrome.windows.create({ - focused: true, - height: 640, - width: 530, - left: 0, - top: 0, - type: "popup", - url: chrome.runtime.getURL( - `ui/assets/wallet-popup.html#/sign-request/${id}` - ), - }) + const currentRequestId = id - const removeWindow = () => chrome.windows.remove(window.id!) - port.onDisconnect.addListener(removeWindow) - const onWindowsRemoved = (windowId: number) => { - if (windowId !== window.id) return - const signRequest = signRequests[id] - if (!signRequest) return - signRequest.reject() + try { + const signRequest = new Promise<{ + userSignedExtensions: any; + password?: string; + }>((resolve, reject) => { + signRequests[id] = { + resolve, + reject, + chainId, + url, + address: ss58Address(from, wellKnownPrefixByGenesisHash[chain.genesisHash]), + callData, + userSignedExtensions: { + type: "names", + names: userSignedExtensionNames, + }, + } as any + }) + + let windowId: number | undefined = undefined + + const createdWindow = await chrome.windows.create({ + focused: true, + height: 680, + width: 460, + type: "popup", + url: chrome.runtime.getURL(`ui/assets/wallet-popup.html#/sign-request/${id}`), + }) + + windowId = createdWindow.id + const targetWindowId = createdWindow.id + + const removeWindow = () => { + if (windowId && windowId === targetWindowId) { + chrome.windows.remove(windowId).catch(() => {}) + windowId = undefined + } + } + + const onWindowsRemoved = (closedWindowId: number) => { + if (closedWindowId === windowId) { + if (signRequests[currentRequestId]) { + signRequests[currentRequestId].reject(new Error("user closed connection window")) + } + cleanUpWindowListeners(port) + windowId = undefined + } + } + + const cleanUpWindowListeners = (port: any) => { + port.onDisconnect.removeListener(removeWindow) + chrome.windows.onRemoved.removeListener(onWindowsRemoved) + } + + port.onDisconnect.addListener(removeWindow) + chrome.windows.onRemoved.addListener(onWindowsRemoved) + + try { + const { userSignedExtensions, password } = await signRequest + cleanUpWindowListeners(port) + + if (!password) { + throw new Error("password verification is strictly required for this action") + } + + let [keypair, scheme] = await keyring.getKeypairWithPassword(chainId, password) + let signer = getPolkadotSigner(keypair.publicKey, scheme, keypair.sign) + + const mortality = userSignedExtensions.CheckMortality ?? { mortal: true, period: 64 } + const asset = userSignedExtensions.ChargeAssetTxPayment?.asset + const tip = (asset + ? userSignedExtensions.ChargeAssetTxPayment?.tip + : userSignedExtensions.ChargeTransactionPayment + ) ?? 0n + + const tx = await createTx(chain.provider)({ + signer, + callData: fromHex(callData), + hinted: { mortality, asset, tip }, + }) + + ;(keypair as any) = null; + ;(signer as any) = null; + + return toHex(tx) + + } catch (err) { + cleanUpWindowListeners(port) + throw err + } finally { + delete signRequests[id] + if (windowId) { + chrome.windows.remove(windowId).catch(() => {}) + } + } + } catch (error) { + const msgError = error instanceof Error ? error.message : String(error) + throw new Error(`Transaction signing context failed: ${msgError}`) + } + }, + async pjsSignPayload( + [payload], + { port, lightClientPageHelper, signRequests } + ) { + const url = port.sender?.url + if (!url) throw new Error("unknown url") + + const now = Date.now() + const lastApprovedRequestTime = requestCooldowns.get(url) || 0 + + if (now - lastApprovedRequestTime < COOLDOWN_MS) { + throw new Error("wallet interaction is rate-limited. Please wait 3 seconds.") + } + + requestCooldowns.set(url, now) + + const chains = await lightClientPageHelper.getChains() + const chain = chains.find( + ({ genesisHash }) => genesisHash === payload.genesisHash + ) + + if (!chain) throw new Error("unknown chain") + + const id = nextSignRequestId++ + const currentRequestId = id + + const signRequest = new Promise< + Parameters[0] + >((resolve, reject) => + (signRequests[id] = { + resolve, + reject, + chainId: payload.genesisHash, + url, + address: payload.address, + callData: payload.method, + userSignedExtensions: { + type: "values", + values: pjs.getUserSignedExtensions(payload), + }, + }) + ) + + let windowId: number | undefined = undefined + + const createdWindow = await chrome.windows.create({ + focused: true, + height: 680, + width: 460, + type: "popup", + url: chrome.runtime.getURL(`ui/assets/wallet-popup.html#/sign-request/${id}`), + }) + + windowId = createdWindow.id + const targetWindowId = createdWindow.id + + const removeWindow = () => { + if (windowId && windowId === targetWindowId) { + chrome.windows.remove(windowId).catch(() => {}) + windowId = undefined + } + } + + const onWindowsRemoved = (closedWindowId: number) => { + if (closedWindowId === windowId) { + if (signRequests[currentRequestId]) { + signRequests[currentRequestId].reject(new Error("user closed connection window")) + } + cleanUpWindowListeners(port) + windowId = undefined + } + } + + const cleanUpWindowListeners = (port: any) => { + port.onDisconnect.removeListener(removeWindow) + chrome.windows.onRemoved.removeListener(onWindowsRemoved) + } + + port.onDisconnect.addListener(removeWindow) + chrome.windows.onRemoved.addListener(onWindowsRemoved) + + try { + const { password } = await signRequest + cleanUpWindowListeners(port) + + if (!password) { + throw new Error("password verification is strictly required for this action") + } + + const signaturePayload = await pjs.getSignaturePayload(chain.provider, payload) + const multiSignatureEncoder = Variant({ + Ed25519: Bytes(64), + Sr25519: Bytes(64), + Ecdsa: Bytes(65), + }).enc + + let [keypair, scheme] = await keyring.getKeypairWithPassword(payload.genesisHash, password) + + const result = multiSignatureEncoder({ + type: scheme, + value: keypair.sign(signaturePayload), + }) + + ;(keypair as any) = null; + + return toHex(result) + + } catch (err) { + cleanUpWindowListeners(port) + throw err + } finally { + delete signRequests[id] + if (windowId) { + chrome.windows.remove(windowId).catch(() => {}) + } + } + }, + async getSignRequests(_, { signRequests }) { + return signRequests + }, + async approveSignRequest([id, userSignedExtensions, password], { signRequests }) { + signRequests[id]?.resolve({ + userSignedExtensions, + password + }) + }, + async cancelSignRequest([id], { signRequests }) { + signRequests[id]?.reject() + }, + async changePassword([args]) { + return keyring.changePassword(args) + }, + async insertAccount([args], context) { + await keyring.insertAccount(args) + await notifyOnAccountsChanged(context) + }, + async deriveAccount([args], context) { + await keyring.deriveAccount(args) + await notifyOnAccountsChanged(context) + }, + async revealSecretSeed([args]) { + return await keyring.revealSecretSeed(args) + }, + async prepareBackup([args]) { + return await keyring.prepareBackup(args) + }, + async extendKeystore([args]) { + await keyring.extendKeystore(args) + }, + async renameAccount([args]) { + await keyring.renameAccount(args) + }, + async removeAccount([args]) { + await keyring.removeAccount(args) + }, + async setActiveAccount([args], context) { + await keyring.setActiveAccount(args) + await notifyOnAccountsChanged(context) + }, + getChainSpecs: listChainSpecsHandler, + } + + type Method = keyof BackgroundRpcSpec + + const ALLOWED_WEB_METHODS: Method[] = [ + "createTx", + "getAccount", + "pjsSignPayload", + ] + + const allowedMethodsMiddleware: RpcMethodMiddleware = async ( + next, + request, + context + ) => { + const { port } = context + + const isWebChannel = port.name === CHANNEL_ID + const isInternalSource = port.sender?.url?.startsWith(chrome.runtime.getURL("")) + + if (isWebChannel) { + if (!ALLOWED_WEB_METHODS.includes(request.method as Method)) { + throw new RpcError("Method not found or access denied", -32601) + } + } else if (!isInternalSource) { + throw new RpcError("Unauthorized port origin access detected", -32601) } - chrome.windows.onRemoved.addListener(onWindowsRemoved) - try { - const { userSignedExtensions } = await signRequest - const [keypair, scheme] = await keyring.getKeypair(chainId, from) - const signer = getPolkadotSigner( - keypair.publicKey, - scheme, - keypair.sign - ) + return next(request, context) + } - const mortality = userSignedExtensions.CheckMortality ?? { - mortal: true, - period: 64, - } - const decimals = wellKnownDecimalsByGenesisHash[chainId] ?? 0 - const asset = userSignedExtensions.ChargeAssetTxPayment?.asset - const tip = - (asset - ? userSignedExtensions.ChargeAssetTxPayment?.tip - : userSignedExtensions.ChargeTransactionPayment) ?? 0n - - const tipWithDecimals = BigInt(tip * Math.pow(10, decimals)) - - const tx = await createTx(chain.provider)({ - signer, - callData: fromHex(callData), - hinted: { - mortality, - asset, - tipWithDecimals, - }, - }) - - return toHex(tx) - } finally { - delete signRequests[id] - chrome.windows.remove(window.id!) - port.onDisconnect.removeListener(removeWindow) - chrome.windows.onRemoved.removeListener(onWindowsRemoved) - } - } finally { - chainHead$.unfollow() - client.destroy() - } - }, - async pjsSignPayload( - [payload], - { port, lightClientPageHelper, signRequests } - ) { - const url = port.sender?.url - if (!url) throw new Error("unknown url") - const chains = await lightClientPageHelper.getChains() - const chain = chains.find( - ({ genesisHash }) => genesisHash === payload.genesisHash - ) - if (!chain) throw new Error("unknown chain") - const id = nextSignRequestId++ - const signRequest = new Promise< - Parameters[0] - >( - (resolve, reject) => - (signRequests[id] = { - resolve, - reject, - chainId: payload.genesisHash, - url, - address: payload.address, - callData: payload.method, - userSignedExtensions: { - type: "values", - values: pjs.getUserSignedExtensions(payload), - }, - }) - ) - const window = await chrome.windows.create({ - focused: true, - height: 640, - width: 530, - left: 0, - top: 0, - type: "popup", - url: chrome.runtime.getURL( - `ui/assets/wallet-popup.html#/sign-request/${id}` - ), - }) - const removeWindow = () => chrome.windows.remove(window.id!) - port.onDisconnect.addListener(removeWindow) - const onWindowsRemoved = (windowId: number) => { - if (windowId !== window.id) return - const signRequest = signRequests[id] - if (!signRequest) return - signRequest.reject() - } - chrome.windows.onRemoved.addListener(onWindowsRemoved) - try { - await signRequest - } finally { - delete signRequests[id] - chrome.windows.remove(window.id!) - port.onDisconnect.removeListener(removeWindow) - chrome.windows.onRemoved.removeListener(onWindowsRemoved) - } - const signaturePayload = await pjs.getSignaturePayload( - chain.provider, - payload - ) - const multiSignatureEncoder = Variant({ - Ed25519: Bytes(64), - Sr25519: Bytes(64), - Ecdsa: Bytes(65), - }).enc - const [keypair, scheme] = await keyring.getKeypair( - payload.genesisHash, - toHex(ss58Decode(payload.address)[0]) - ) - return toHex( - multiSignatureEncoder({ - type: scheme, - value: keypair.sign(signaturePayload), - }) - ) - }, - async getSignRequests(_, { signRequests }) { - return signRequests - }, - async approveSignRequest([id, userSignedExtensions], { signRequests }) { - signRequests[id]?.resolve({ - userSignedExtensions, - }) - }, - async cancelSignRequest([id], { signRequests }) { - signRequests[id]?.reject() - }, - async lockKeyring() { - return keyring.lock() - }, - async resetKeyring([password]) { - return keyring.reset(password) - }, - async unlockKeyring([password]) { - return keyring.unlock(password) - }, - async changePassword([currentPassword, newPassword]) { - return keyring.changePassword(currentPassword, newPassword) - }, - async createPassword([password]) { - return keyring.setup(password) - }, - async insertCryptoKey([args], context) { - const existingKey = await keyring.getCryptoKey(args.name) - - if (existingKey) - throw new Error(`crypto key "${args.name}" already exists`) - await keyring.insertCryptoKey(args) - notifyOnAccountsChanged(context) - }, - async updateCryptoKey([args], context) { - const existingKey = await keyring.getCryptoKey(args.name) - - if (!existingKey) - throw new Error(`crypto key "${args.name}" does not exist`) - await keyring.updateCryptoKey(args) - notifyOnAccountsChanged(context) - }, - async getCryptoKey([name]) { - return keyring.getCryptoKey(name) - }, - async getCryptoKeys() { - return keyring.getCryptoKeys() - }, - async revealCryptoKey([name, index]) { - return keyring.revealCryptoKey(name, index) - }, - async clearCryptoKeys([name], context) { - await keyring.clearCryptoKeys(name) - notifyOnAccountsChanged(context) - }, - async clearCryptoKey([name, index], context) { - await keyring.clearCryptoKey(name, index) - notifyOnAccountsChanged(context) - }, - async getKeyringState() { - return { - isLocked: await keyring.isLocked(), - hasPassword: await keyring.hasPassword(), - } - }, - getChainSpecs: listChainSpecsHandler, - addChainSpec: addChainSpecHandler, - removeChainSpec: removeChainSpecHandler, - } - - type Method = keyof BackgroundRpcSpec - const ALLOWED_WEB_METHODS: Method[] = [ - "createTx", - "getAccounts", - "pjsSignPayload", - ] - const allowedMethodsMiddleware: RpcMethodMiddleware = async ( - next, - request, - context - ) => { - const { port } = context - if ( - port.name === CHANNEL_ID && - !ALLOWED_WEB_METHODS.includes(request.method as Method) - ) - throw new RpcError("Method not found", -32601) - return next(request, context) - } - return createRpc(sendMessage, handlers, [ - allowedMethodsMiddleware, - ]).withClient() + return createRpc(sendMessage, handlers, [allowedMethodsMiddleware]).withClient() } diff --git a/src/background/index.ts b/src/background/index.ts index 4661cc4..d8e4da8 100644 --- a/src/background/index.ts +++ b/src/background/index.ts @@ -9,7 +9,7 @@ import type { Account } from "./types" import { startHeartbeat } from "./heartbeat" import { CHANNEL_ID } from "../constants" -const { lightClientPageHelper, addOnAddChainByUserListener } = register({ +const { lightClientPageHelper } = register({ smoldotClient: start({ maxLogLevel: 4 }), getWellKnownChainSpecs: () => // Note that this list doesn't necessarily always have to match the list of well-known @@ -32,17 +32,23 @@ const signRequests = {} type BackgroundRpc = ReturnType const connectedRpcs: BackgroundRpc[] = [] const notifyOnAccountsChanged = (accounts: Account[]) => -connectedRpcs.forEach((rpc) => rpc.notify("onAccountsChanged", [accounts])) + connectedRpcs.forEach((rpc) => rpc.notify("onAccountsChanged", [accounts])) + const subscribeOnAccountsChanged = (rpc: BackgroundRpc) => { connectedRpcs.push(rpc) return () => { - connectedRpcs.splice(connectedRpcs.indexOf(rpc), 1) + const index = connectedRpcs.indexOf(rpc) + if (index !== -1) { + connectedRpcs.splice(index, 1) + } } } chrome.runtime.onConnect.addListener((port) => { if (!port.name.startsWith(CHANNEL_ID)) return + const rpc = createBackgroundRpc((msg) => port.postMessage(msg)) + port.onMessage.addListener((msg) => rpc.handle(msg, { lightClientPageHelper, @@ -73,46 +79,4 @@ chrome.runtime.onInstalled.addListener(async ({ reason }) => { } }) -addOnAddChainByUserListener(async (inputChain) => { - const isRelayChain = !inputChain.relayChainGenesisHash - const existingChain = await lightClientPageHelper.getChain( - inputChain.genesisHash, - ) - if (isRelayChain && !existingChain) { - await waitForAddChainApproval(inputChain) - - const persistedChain = await lightClientPageHelper.getChain( - inputChain.genesisHash, - ) - - if (!persistedChain) { - throw new Error("User rejected") - } - } -}) - -const waitForAddChainApproval = async (inputChain: InputChain) => { - const window = await chrome.windows.create({ - focused: true, - width: 400, - height: 600, - left: 150, - top: 150, - type: "popup", - url: chrome.runtime.getURL( - `ui/assets/wallet-popup.html#/add-chain-by-user?params=${encodeURIComponent(JSON.stringify(inputChain))}`, - ), - }) - const onWindowsRemoved = (windowId: number) => { - if (windowId !== window.id) return - resolveWindowClosed() - } - chrome.windows.onRemoved.addListener(onWindowsRemoved) - - const { promise: windowClosedPromise, resolve: resolveWindowClosed } = - Promise.withResolvers() - - await windowClosedPromise -} - startHeartbeat() diff --git a/src/background/keyring.ts b/src/background/keyring.ts index e8aaed9..6bfdbd8 100644 --- a/src/background/keyring.ts +++ b/src/background/keyring.ts @@ -11,16 +11,29 @@ import { KeyPair, Curve, } from "@polkadot-labs/hdkd-helpers" -import { KeystoreMeta, keystoreV4, type KeystoreV4WithMeta } from "./keystore" +import { + KeyringStorage, + KeystoreMeta, + FullKeystoreMeta, + Derivation, + ActiveAccount, + KeystoreV4WithMeta, + FullKeystoreMetaWithVault, + keystoreV4, +} from "./keystore" +import { + InsertAccountArgs, + RemoveAccountArgs, + RenameAccountArgs, + ChangePasswordArgs, + RevealSecretSeedArgs, + ExtendKeystoreArgs, + DeriveAccountArgs, + ActiveAccountArgs, +} from "./types" + import { assert } from "./utils" import * as storage from "./storage" -import { - InsertCryptoKeyArgs, - UpdateCryptoKeyArgs, - RemoveCryptoKeyArgs, - KeystoreAccount, - RevealCryptoKeyArgs, -} from "./types" import { toHex, fromHex } from "@polkadot-api/utils" import { wellKnownChainIdByGenesisHash } from "../constants" @@ -36,321 +49,378 @@ const curveFnMap: Record = { Ecdsa: ecdsa, } -const createKeyPair = (privateKey: string, scheme: string): KeyPair => { - const curve = curveFnMap[scheme] - if (!curve) throw new Error("unsupported signature scheme") +const createKeyPair = (miniSecret: string | Uint8Array, scheme: string, path: string): KeyPair => { + const miniSecretBytes = typeof miniSecret === "string" ? fromHex(miniSecret) : miniSecret + const createDeriveFn = createDeriveFnMap[scheme] + const derive = createDeriveFn(miniSecretBytes) + const keyPair = derive(path) return { - publicKey: curve.getPublicKey(privateKey), + publicKey: keyPair.publicKey, sign(message) { - return curve.sign(message, privateKey) + return keyPair.sign(message) }, } } +const ACCOUNT_DERIVATION_PREFIX = "derivation_storage" +const KEYRING_STORAGE_PREFIX = "keyring_storage" +const ACTIVE_ACCOUNT_PREFIX = "active_account" + export const createKeyring = () => { - const getKeystore = () => storage.get("keystore") - const setKeystore = (keystore: KeystoreV4WithMeta) => - storage.set("keystore", keystore) - const removeKeystore = () => storage.remove("keystore") - - const getKeystoreAccounts = ( - keystoreMeta: KeystoreMeta, - ): KeystoreAccount[] => { - switch (keystoreMeta.type) { - case "KeysetKeystore": - return keystoreMeta.derivationPaths.map((d) => ({ - ...d, - type: "Keyset", - })) - case "KeypairKeystore": - return [ - { - type: "Keypair", - publicKey: keystoreMeta.publicKey, - }, - ] + const getKeystoreStorage = async (): Promise => { + let keyring = await storage.get(KEYRING_STORAGE_PREFIX) + if (!keyring || !keyring.keystores) { + keyring = { keystores: [] } + await setKeystoreStorage(keyring) } + return keyring } - const getCryptoKeys = async () => { - const keys = (await getKeystore())?.meta ?? [] - - return keys.map((meta) => ({ - name: meta.name, - scheme: meta.scheme, - accounts: getKeystoreAccounts(meta), - createdAt: meta.createdAt, - })) + const setKeystoreStorage = async (keyringData: KeyringStorage): Promise => { + await storage.set(KEYRING_STORAGE_PREFIX, keyringData) } - const decodeSecrets = (secrets: Uint8Array) => - JSON.parse(new TextDecoder().decode(secrets)) as string[] - const encodeSecrets = (secrets: string[]) => - new TextEncoder().encode(JSON.stringify(secrets)) - - const getAccounts = async (chainId: string) => { - const keystore = await getKeystore() - if (!keystore) return [] - - return keystore.meta - .flatMap(getKeystoreAccounts) - .filter( - (account) => - (account.type === "Keyset" && account.chainId === chainId) || - account.type !== "Keyset", - ) + const getAccountDerivations = async (args: KeystoreMeta): Promise => { + const derivations = await storage.get(`${ACCOUNT_DERIVATION_PREFIX}_${args.chainId}_${args.publicKey}`) + return derivations ?? [] } - const insertCryptoKey = async (args: InsertCryptoKeyArgs) => { - const keystore = await getKeystore() - assert(keystore, "keyring must be setup") - assert(currentPassword, "keyring must be unlocked") + const setAccountDerivations = async (chainId: string, publicKey: string, derivations: Derivation[]): Promise => { + await storage.set(`${ACCOUNT_DERIVATION_PREFIX}_${chainId}_${publicKey}`, derivations) + } + + const getActiveAccount = async (chainId: string): Promise => { + const activeAccount = await storage.get(`${ACTIVE_ACCOUNT_PREFIX}_${chainId}`) + return activeAccount + } + + const setActiveAccount = async (args: ActiveAccountArgs): Promise => { + await storage.set(`${ACTIVE_ACCOUNT_PREFIX}_${args.chainId}`, args.activeAccount) + } + + const getAccounts = async (chainId: string): Promise => { + const keystoreStorage = await getKeystoreStorage() + + const accounts = keystoreStorage.keystores + .map((keystore: KeystoreV4WithMeta) => keystore.metadata) + .filter((account: KeystoreMeta) => account.chainId === chainId) + + return accounts + } + + const decodeSecret = (secret: Uint8Array) => JSON.parse(new TextDecoder().decode(secret)) as string + const encodeSecret = (secret: string) => new TextEncoder().encode(JSON.stringify(secret)) + + const insertAccount = async (args: InsertAccountArgs): Promise => { + assert(args.password, "password is required to modify the keystore") assert( ["Sr25519", "Ed25519", "Ecdsa"].includes(args.scheme), "invalid signature scheme", ) - const secrets = decodeSecrets(keystoreV4.decrypt(keystore, currentPassword)) - const secret = args.type === "Keyset" ? args.miniSecret : args.privatekey - const newKeystore = keystoreV4.create( - currentPassword, - encodeSecrets([...secrets, secret]), + const keystoreStorage = await getKeystoreStorage() + const keystore = keystoreStorage.keystores.find( + (keystore: KeystoreV4WithMeta) => keystore.metadata.name === args.name ) + assert(!keystore, "account with this name already exists") - const newCryptoKey = - args.type === "Keyset" - ? { - type: "KeysetKeystore" as const, - derivationPaths: args.derivationPaths, - } - : args.type === "Keypair" - ? { - type: "KeypairKeystore" as const, - publicKey: toHex( - createKeyPair(args.privatekey, args.scheme).publicKey, - ), - } - : undefined - if (!newCryptoKey) throw new Error("invalid keystore type") + const publicKeyHex = toHex(createKeyPair(args.miniSecret, args.scheme, "").publicKey) - setKeystore({ - ...newKeystore, - meta: [ - ...keystore.meta, - { - name: args.name, - scheme: args.scheme, - createdAt: args.createdAt, - ...newCryptoKey, - }, - ], + await setActiveAccount({ + chainId: args.chainId, + activeAccount: { publicKey: publicKeyHex } + }) + + const newVault = keystoreV4.create(args.password, encodeSecret(args.miniSecret)) + const newKeystoreItem: KeystoreV4WithMeta = { + metadata: { + name: args.name, + scheme: args.scheme, + chainId: args.chainId, + publicKey: publicKeyHex, + }, + vault: newVault, + } + + await setKeystoreStorage({ + keystores: [...(keystoreStorage.keystores ?? []), newKeystoreItem], }) } - const updateCryptoKey = async (args: UpdateCryptoKeyArgs) => { - const keystore = await getKeystore() - assert(keystore, "keyring must be setup") - assert(currentPassword, "keyring must be unlocked") - const cryptoKeyIndex = keystore.meta.findIndex((k) => k.name === args.name) - assert(cryptoKeyIndex != -1, "keyring name not found") + const deriveAccount = async (args: DeriveAccountArgs): Promise => { + assert(args.password, "password is required to modify the keystore") - const uniqueNetworks = new Map() - keystore.meta[cryptoKeyIndex].derivationPaths.forEach((obj: any) => - uniqueNetworks.set(wellKnownChainIdByGenesisHash[obj.chainId], { - network: wellKnownChainIdByGenesisHash[obj.chainId], - chainId: obj.chainId, - }) + const keystoreStorage = await getKeystoreStorage() + const keystore = keystoreStorage.keystores.find( + (keystore: KeystoreV4WithMeta) => keystore.metadata.publicKey === args.publicKey + ) + assert(keystore, "keystore with this public key does not exist") + + const derivations = await getAccountDerivations(keystore.metadata) + const derivationIndex = derivations.findIndex( + (d) => d.name === args.name || d.path === args.path + ) + assert(derivationIndex === -1, "derivation path or name already occupied") + + assert( + keystoreV4.verifyPassword(keystore.vault, args.password), + "invalid password for keystore provided" ) - const secrets = decodeSecrets(keystoreV4.decrypt(keystore, currentPassword)) - const derive = createDeriveFnMap[keystore.meta[cryptoKeyIndex].scheme](secrets[cryptoKeyIndex]) - const newDerivationPaths = Array.from(uniqueNetworks.values()) - .map(({ network, chainId }) => { - return { - chainId, - path: `//${network}/${args.deviation}`, - publicKey: toHex(derive(`//${network}/${args.deviation}`).publicKey), - } + const createDeriveFn = createDeriveFnMap[keystore.metadata.scheme] + const decryptedBytes = keystoreV4.decrypt(keystore.vault, args.password) + + try { + const secret = decodeSecret(decryptedBytes) + const derive = createDeriveFn(secret) + + const derivedKeyPair = derive(args.path) + const publicKeyHex = toHex(derivedKeyPair.publicKey) + + const updatedDerivations = [ + ...derivations, + { name: args.name, path: args.path, publicKey: publicKeyHex } + ] + + await setAccountDerivations(keystore.metadata.chainId, keystore.metadata.publicKey, updatedDerivations) + await setActiveAccount({ + chainId: keystore.metadata.chainId, + activeAccount: { publicKey: args.publicKey, path: args.path } }) - keystore.meta[cryptoKeyIndex].derivationPaths.push(...newDerivationPaths) - setKeystore(keystore) - } - - const clearCryptoKey = async (name: string, index: number) => { - const keystore = await getKeystore() - assert(keystore, "keyring must be setup") - assert(currentPassword, "keyring must be unlocked") - const cryptoKeyIndex = keystore.meta.findIndex(obj => obj.name === name) - assert(cryptoKeyIndex != -1, "keyring name not found") - const secrets = decodeSecrets(keystoreV4.decrypt(keystore, currentPassword)) - switch (keystore.meta.at(cryptoKeyIndex)?.type) { - case "KeysetKeystore": - keystore.meta.at(cryptoKeyIndex)?.derivationPaths.splice(index, 1) - setKeystore(keystore) - break - case "KeypairKeystore": - secrets.splice(cryptoKeyIndex, 1) - keystore.meta.splice(cryptoKeyIndex, 1) - const newKeystore = keystoreV4.create( - currentPassword, - encodeSecrets(secrets), - ) - setKeystore({ - ...newKeystore, - meta: keystore.meta, - }) - break - default: - throw new Error("invalid keystore type") + } finally { + decryptedBytes.fill(0) } } - const clearCryptoKeys = async (args: RemoveCryptoKeyArgs) => { - const keystore = await getKeystore() - assert(keystore, "keyring must be setup") - assert(currentPassword, "keyring must be unlocked") - const cryptoKeyIndex = keystore.meta.findIndex(obj => obj.name === args.name) - assert(cryptoKeyIndex != -1, "keyring name not found") - const secrets = decodeSecrets(keystoreV4.decrypt(keystore, currentPassword)) - secrets.splice(cryptoKeyIndex, 1) - const newKeystore = keystoreV4.create( - currentPassword, - encodeSecrets(secrets), + const renameAccount = async (args: RenameAccountArgs) => { + assert(args.newName?.trim(), "new name cannot be empty") + + const keystoreStorage = await getKeystoreStorage() + const keystore = keystoreStorage.keystores.find( + (keystore: KeystoreV4WithMeta) => keystore.metadata.publicKey === args.publicKey ) - keystore.meta.splice(cryptoKeyIndex, 1) - setKeystore({ - ...newKeystore, - meta: keystore.meta, - }) + + assert(keystore, "keystore with this public key does not exist") + if (args.path) { + const metadata = keystore.metadata + const derivations = await getAccountDerivations(metadata) + const derivation = derivations.find(d => d.path === args.path) + + assert(derivation, "derivation not found by derivation path") + derivation.name = args.newName + await setAccountDerivations(metadata.chainId, metadata.publicKey, derivations) + } else { + keystore.metadata.name = args.newName + await setKeystoreStorage({ keystores: keystoreStorage.keystores }) + } } - const revealCryptoKey = async (name: string, index: number) => { - const keystore = await getKeystore() - assert(keystore, "keyring must be setup") - assert(currentPassword, "keyring must be unlocked") - const cryptoKeyIndex = keystore.meta.findIndex((k) => k.name === name) - assert(cryptoKeyIndex != -1, "keyring name not found") - const secret = decodeSecrets( - keystoreV4.decrypt(keystore, currentPassword) - ).at(cryptoKeyIndex) - assert(secret, "secret key should exists") - // TODO: need to get secret from secret and derivation path - return secret + const removeAccount = async (args: RemoveAccountArgs) => { + assert(args.password, "password is required for this operation") + + const keystoreStorage = await getKeystoreStorage() + const keystore = keystoreStorage.keystores.find( + (keystore: KeystoreV4WithMeta) => keystore.metadata.publicKey === args.publicKey + ) + assert(keystore, "specific crypto vault not found based on account name") + assert(keystoreV4.verifyPassword(keystore.vault, args.password), "invalid password for keystore provided") + + if (args.path) { + const metadata = keystore.metadata + const derivations = await getAccountDerivations(metadata) + const updatedDerivations = derivations.filter(d => d.path !== args.path) + + await setAccountDerivations(metadata.chainId, metadata.publicKey, updatedDerivations) + await setActiveAccount({ + chainId: metadata.chainId, + activeAccount: { publicKey: metadata.publicKey } + }) + } else { + const updatedKeystores = keystoreStorage.keystores.filter( + (keystore: KeystoreV4WithMeta) => keystore.metadata.publicKey !== args.publicKey + ) + const nextActiveAccount: ActiveAccount = { publicKey: updatedKeystores.at(0)?.metadata.publicKey } + + await setKeystoreStorage({ keystores: updatedKeystores }) + await setActiveAccount({ + chainId: keystore.metadata.chainId, + activeAccount: nextActiveAccount + }) + } } - let currentPassword: string | undefined + const prepareBackup = async (publicKeys: string[]): Promise => { + const keystoreStorage = await getKeystoreStorage() + return Promise.all( + keystoreStorage.keystores + .filter((keystore: KeystoreV4WithMeta) => publicKeys.includes(keystore.metadata.publicKey)) + .map(async (keystore: KeystoreV4WithMeta) => { + const derivations = await getAccountDerivations(keystore.metadata) + return { ...keystore, derivations } + }) + ) + } - return { - async unlock(password: string) { - const keystore = await getKeystore() - assert(keystore, "keyring must be setup") - if (!keystoreV4.verifyPassword(keystore, password)) - throw new Error("invalid password") - currentPassword = password - }, - async lock() { - assert(await getKeystore(), "keyring must be setup") - assert(currentPassword, "keyring must be unlocked") - currentPassword = undefined - }, - async isLocked() { - return !currentPassword || !(await getKeystore()) - }, - async reset(password: string) { - const keystore = await getKeystore() - assert(keystore, "keyring must be setup") - assert(currentPassword, "keyring must be unlocked") - if (!keystoreV4.verifyPassword(keystore, password)) - throw new Error("invalid password") - currentPassword = undefined - await removeKeystore() - }, - async changePassword(password: string, newPassword: string) { - const keystore = await getKeystore() - assert(keystore, "keyring must be setup") - assert(currentPassword, "keyring must be unlocked") - if (!keystoreV4.verifyPassword(keystore, password)) - throw new Error("invalid password") - currentPassword = newPassword - await setKeystore({ - ...keystoreV4.create( - newPassword, - keystoreV4.decrypt(keystore, password), - ), - meta: keystore.meta, - }) - }, - async setup(password: string) { - assert(!(await getKeystore()), "keyring is already setup") - await setKeystore({ - ...keystoreV4.create( - password, - new TextEncoder().encode(JSON.stringify([])), - ), - meta: [], - }) - currentPassword = password - }, - async hasPassword() { - return !!(await getKeystore()) - }, - getAccounts, - insertCryptoKey, - updateCryptoKey, - getCryptoKeys, - async getCryptoKey(name: string) { - return (await getCryptoKeys())?.find((m) => m.name === name) - }, - revealCryptoKey, - clearCryptoKey, - clearCryptoKeys, - async getKeypair(chainId: string, publicKey: string) { - assert(currentPassword, "keyring must be unlocked") - const keystore = await getKeystore() - assert(keystore, "keyring must be setup") + const extendKeystore = async (args: ExtendKeystoreArgs): Promise => { + const { includeDerivations, overwriteExisting, keystores } = args + const currentStorage = await getKeystoreStorage() - const keysetIndex = keystore.meta.findIndex((keyset) => { - switch (keyset.type) { - case "KeysetKeystore": - return keyset.derivationPaths.some( - (d) => d.chainId === chainId && d.publicKey === publicKey, - ) - case "KeypairKeystore": - return keyset.publicKey === publicKey - default: - throw new Error("invalid keystore type") - } - }) + const namesSet = new Set((currentStorage?.keystores || []) + .map((acc: KeystoreV4WithMeta) => acc.metadata.name) + ) - if (keysetIndex === -1) { - throw new Error("unknown account") + const publicKeysMap = new Map((currentStorage?.keystores || []) + .map((acc: KeystoreV4WithMeta) => [acc.metadata.publicKey, acc]) + ) + + for (const newAcc of keystores) { + const publicKey = newAcc.metadata.publicKey + if (!overwriteExisting && publicKeysMap.has(publicKey)) continue + + let finalName = newAcc.metadata.name + while (namesSet.has(finalName)) { + const postfix = Math.random().toString(16).substring(2, 6) + finalName = `${finalName}_${postfix}` } - const secret = decodeSecrets( - keystoreV4.decrypt(keystore, currentPassword), - )[keysetIndex] + namesSet.add(finalName) - const cryptoKey = keystore.meta[keysetIndex] - switch (cryptoKey.type) { - case "KeysetKeystore": { - const { derivationPaths, scheme } = cryptoKey - const derivationPath = derivationPaths.find( - (d) => d.publicKey === publicKey, - )! - const createDeriveFn = createDeriveFnMap[scheme] - if (!createDeriveFn) throw new Error("invalid signature scheme") - return [ - createDeriveFn(secret)(derivationPath.path), - scheme as "Sr25519" | "Ed25519" | "Ecdsa", - ] as const - } - case "KeypairKeystore": { - let keypair = createKeyPair(secret, cryptoKey.scheme) + const newKeystoreItem: KeystoreMeta = { ...newAcc.metadata, name: finalName } + publicKeysMap.set(publicKey, { metadata: newKeystoreItem, vault: newAcc.vault }) - return [keypair, cryptoKey.scheme] as const + if (includeDerivations) { + const currentDerivations = await getAccountDerivations(newKeystoreItem) + + const derivationNameSet = new Set(currentDerivations.map(der => der.name)) + const derivationMap = new Map( + (currentDerivations || []).map(der => [der.path, der]) + ) + + for (const der of newAcc.derivations) { + const path = der.path + if (!overwriteExisting && derivationMap.has(path)) continue + + const derPublicKey = derivationMap.get(path)?.publicKey + if (derPublicKey && der.publicKey !== derPublicKey) continue + + let derFinalName = der.name + if (derivationNameSet.has(derFinalName)) { + const postfix = Math.random().toString(16).substring(2, 6) + derFinalName = `${derFinalName}_${postfix}` + } + + derivationNameSet.add(derFinalName) + derivationMap.set(der.path, { ...der, name: derFinalName }) } + + const finalDerivations = Array.from(derivationMap.values()) + await setAccountDerivations(newKeystoreItem.chainId, newKeystoreItem.publicKey, finalDerivations) + } + } + + const finalKeystores = Array.from(publicKeysMap.values()) + await setKeystoreStorage({ keystores: finalKeystores }) + + if (currentStorage.keystores.length === 0 && finalKeystores.length > 0) { + const first = finalKeystores[0].metadata + await setActiveAccount({ + chainId: first.chainId, + activeAccount: { publicKey: first.publicKey } + }) + } + } + + const revealSecretSeed = async (args: RevealSecretSeedArgs): Promise => { + assert(args.password, "password is required for this operation") + + const keystoreStorage = await getKeystoreStorage() + const keystore = keystoreStorage.keystores.find( + (keystore: KeystoreV4WithMeta) => keystore.metadata.publicKey === args.publicKey + ) + + assert(keystore, "specific crypto vault not found based on account name") + assert(keystoreV4.verifyPassword(keystore.vault, args.password), "invalid password for keystore provided") + + const decryptedBytes = keystoreV4.decrypt(keystore.vault, args.password) + let finalSecret = "" + + try { + const masterSecret = decodeSecret(decryptedBytes) + finalSecret = masterSecret + } finally { + decryptedBytes.fill(0) + } + + return finalSecret + } + + return { + async changePassword(args: ChangePasswordArgs) { + assert(args.password, "password is required for this operation") + assert(args.newPassword, "new password is required for this operation") + assert(args.password !== args.newPassword, "new password must be different") + + const keystoreStorage = await getKeystoreStorage() + const keystoreIndex = keystoreStorage.keystores.findIndex( + (keystore: KeystoreV4WithMeta) => keystore.metadata.publicKey === args.publicKey + ) + + assert(keystoreIndex !== -1, "specific crypto vault not found based on account name") + const keystore = keystoreStorage.keystores[keystoreIndex] + const decryptedBytes = keystoreV4.decrypt(keystore.vault, args.password) + + try { + const secret = decodeSecret(decryptedBytes) + const newEncryptedVault = keystoreV4.create(args.newPassword, encodeSecret(secret)) + + keystoreStorage.keystores[keystoreIndex] = { + ...keystore, + vault: newEncryptedVault + } + } finally { + decryptedBytes.fill(0) + } + + await setKeystoreStorage({ keystores: keystoreStorage.keystores }) + }, + setActiveAccount, + getActiveAccount, + getAccounts, + getAccountDerivations, + + insertAccount, + deriveAccount, + + renameAccount, + revealSecretSeed, + removeAccount, + prepareBackup, + extendKeystore, + + async getKeypairWithPassword(chainId: string, password: string) { + const activeAccount = await getActiveAccount(chainId) + assert(activeAccount, "no active account found") + + const keystoreStorage = await getKeystoreStorage() + const keystore = keystoreStorage.keystores.find((k: KeystoreV4WithMeta) => + k.metadata.chainId === chainId && k.metadata.publicKey === activeAccount.publicKey + ) + + assert(keystore, "unknown account targets mapped") + assert(keystoreV4.verifyPassword(keystore.vault, password), "invalid password for keystore provided") + + const decryptedBytes = keystoreV4.decrypt(keystore.vault, password) + + try { + const masterSecret = decodeSecret(decryptedBytes) + const { scheme } = keystore.metadata + + const keypair = createKeyPair(masterSecret, scheme, activeAccount.path ?? "") + return [keypair, scheme as "Sr25519" | "Ed25519" | "Ecdsa"] as const + } finally { + decryptedBytes.fill(0) } }, } diff --git a/src/background/keystore/types.ts b/src/background/keystore/types.ts index 4e158db..a6d7d4a 100644 --- a/src/background/keystore/types.ts +++ b/src/background/keystore/types.ts @@ -1,29 +1,57 @@ import { KeystoreV4 } from "./keystoreV4" -export type DerivationPath = { - chainId: string +export type Derivation = { + name: string path: string publicKey: string } +export type DerivationWithAddress = Derivation & { + address: string +} + export type BaseKeystore = { name: string scheme: "Sr25519" | "Ed25519" | "Ecdsa" - createdAt: number } -export type KeysetKeystore = BaseKeystore & { - type: "KeysetKeystore" - derivationPaths: DerivationPath[] -} - -export type KeypairKeystore = BaseKeystore & { - type: "KeypairKeystore" +export type KeystoreMeta = BaseKeystore & { publicKey: string + chainId: string } -export type KeystoreMeta = KeysetKeystore | KeypairKeystore - -export type KeystoreV4WithMeta = KeystoreV4 & { - meta: KeystoreMeta[] +export type KeystoreMetaWithAddress = KeystoreMeta & { + address: string +} + +export type ExposedAccount = { + publicKey: string, + address: string, +} + +export type AccountTree = KeystoreMetaWithAddress & { + derivations: DerivationWithAddress[] +} + +export type KeystoreV4WithMeta = { + metadata: KeystoreMeta + vault: KeystoreV4 +} + +export type KeyringStorage = { + keystores: KeystoreV4WithMeta[] +} + +export type FullKeystoreMeta = { + metadata: KeystoreMeta + derivations: Derivation[] +} + +export type FullKeystoreMetaWithVault = FullKeystoreMeta & { + vault: KeystoreV4 +} + +export type ActiveAccount = { + publicKey?: string + path?: string } diff --git a/src/background/pjs.ts b/src/background/pjs.ts index 7132008..6d961a4 100644 --- a/src/background/pjs.ts +++ b/src/background/pjs.ts @@ -4,6 +4,8 @@ import { getDynamicBuilder, getLookupFn } from "@polkadot-api/metadata-builders" import { Bytes, Struct, + decAnyMetadata, + unifyMetadata, _void, compact, u32, @@ -21,15 +23,13 @@ export const getSignaturePayload = async ( provider: JsonRpcProvider, payload: Pjs.SignerPayloadJSON, ) => { - const { metadata$, unfollow } = getObservableClient( - createClient(provider), - ).chainHead$() - const metadata = await firstValueFrom( - metadata$.pipe(filter(Boolean)), - ).finally(unfollow) + const { metadata$, unfollow } = getObservableClient(createClient(provider)).chainHead$() + const rawMetadata = await firstValueFrom(metadata$.pipe(filter(Boolean))).finally(unfollow) + + const metadata = unifyMetadata(rawMetadata as any) const dynamicBuilder = getDynamicBuilder(getLookupFn(metadata)) const [extra, additionalSigned] = metadata.extrinsic.signedExtensions.reduce< - [extra: Uint8Array[], additionalSigned: Uint8Array[]] + [extra: Uint8Array[], additionalSigned: Uint8Array[]] >( ( [extra, additionalSigned], @@ -93,8 +93,8 @@ export const getSignaturePayload = async ( ...additionalSigned, ) return signaturePayload.length > 256 - ? blake2b256(signaturePayload) - : signaturePayload + ? blake2b256(signaturePayload) + : signaturePayload } export const getUserSignedExtensions = (payload: Pjs.SignerPayloadJSON) => { @@ -103,15 +103,16 @@ export const getUserSignedExtensions = (payload: Pjs.SignerPayloadJSON) => { userSignedExtensions.CheckMortality = // Ser mortality encoding https://spec.polkadot.network/id-extrinsics#sect-mortality-encoding mortality.length === 1 - ? { mortal: false } - : { mortal: true, period: 2 << u16.dec(mortality) % (1 << 4) } + ? { mortal: false } + : { mortal: true, period: 2 << u16.dec(mortality) % (1 << 4) } + if (payload.signedExtensions.includes("ChargeTransactionPayment")) - userSignedExtensions.ChargeTransactionPayment = Number(payload.tip) + userSignedExtensions.ChargeTransactionPayment = BigInt(payload.tip || 0) else if (payload.signedExtensions.includes("ChargeAssetTxPayment")) - userSignedExtensions.ChargeAssetTxPayment = { - // @ts-expect-error FIXME: bigint needs to be serialized - tip: Number(payload.tip), - } + userSignedExtensions.ChargeAssetTxPayment = { + // @ts-expect-error FIXME: bigint needs to be serialized + tip: Number(payload.tip), + } return userSignedExtensions } diff --git a/src/background/rpc/chainspec.ts b/src/background/rpc/chainspec.ts index b022430..436ecdb 100644 --- a/src/background/rpc/chainspec.ts +++ b/src/background/rpc/chainspec.ts @@ -28,41 +28,3 @@ export const listChainSpecsHandler: RpcMethodHandlers< return chainSpecs } - -export const addChainSpecHandler: RpcMethodHandlers< - BackgroundRpcSpec, - Context ->["addChainSpec"] = async ([chainSpec], { lightClientPageHelper }) => { - const chainSpecParsed = chainSpecSchema.parse(JSON.parse(chainSpec)) - - const relayChainChainSpec = await lightClientPageHelper - .getChains() - .then((chains) => - chains.map((chain) => ({ - ...chainSpecSchema.parse(JSON.parse(chain.chainSpec)), - genesisHash: chain.genesisHash, - })), - ) - .then((chains) => - chains.find((chain) => chain.id === chainSpecParsed.relay_chain), - ) - - if (chainSpecParsed.relay_chain && !relayChainChainSpec) { - throw new Error("relay chain not found") - } - if (relayChainChainSpec?.relay_chain) { - throw new Error("relay chain cannot be a parachain") - } - - await lightClientPageHelper.persistChain( - chainSpec, - relayChainChainSpec?.genesisHash, - ) -} - -export const removeChainSpecHandler: RpcMethodHandlers< - BackgroundRpcSpec, - Context ->["removeChainSpec"] = async ([genesisHash], { lightClientPageHelper }) => { - await lightClientPageHelper.deleteChain(genesisHash) -} diff --git a/src/background/rpc/types.ts b/src/background/rpc/types.ts index aa4ccb0..cf5c956 100644 --- a/src/background/rpc/types.ts +++ b/src/background/rpc/types.ts @@ -4,6 +4,7 @@ import { Account, SignRequest } from "../types" export type SignResponse = { userSignedExtensions: Partial + password: string } export type InternalSignRequest = { diff --git a/src/background/storage.ts b/src/background/storage.ts index ebd2260..cfb57b2 100644 --- a/src/background/storage.ts +++ b/src/background/storage.ts @@ -2,28 +2,27 @@ import type { KeystoreV4WithMeta } from "./keystore" const STORAGE_PREFIX = "ghost-extension/" -type StorageConfig = { - keystore: KeystoreV4WithMeta -} - -type StorageKey = keyof StorageConfig - -const getKey = (key: StorageKey) => `${STORAGE_PREFIX}${key}` - -export const remove = (keyOrKeys: StorageKey | StorageKey[]): Promise => - chrome.storage.local.remove( +export const remove = >( + keyOrKeys: keyof Config | Array +): Promise => { + const getKey = (key: keyof Config) => `${STORAGE_PREFIX}${String(key)}` + return chrome.storage.local.remove( Array.isArray(keyOrKeys) ? keyOrKeys.map(getKey) : getKey(keyOrKeys), ) +} -export const get = async ( - key: K, -): Promise => { - const key_ = getKey(key) - const { [key_]: value } = await chrome.storage.local.get([key_]) +export const get = async , K extends keyof Config>( + key: K +): Promise => { + const fullKey = `${STORAGE_PREFIX}${String(key)}` + const { [fullKey]: value } = await chrome.storage.local.get([fullKey]) return value } -export const set = ( +export const set = , K extends keyof Config>( key: K, - value: StorageConfig[K], -): Promise => chrome.storage.local.set({ [getKey(key)]: value }) + value: Config[K], +): Promise => { + const fullKey = `${STORAGE_PREFIX}${String(key)}` + return chrome.storage.local.set({ [fullKey]: value }) +} diff --git a/src/background/types.ts b/src/background/types.ts index a053ac9..9d0662d 100644 --- a/src/background/types.ts +++ b/src/background/types.ts @@ -4,6 +4,17 @@ import { UserSignedExtensions, } from "../types/UserSignedExtension" +import { + FullKeystoreMeta, + FullKeystoreMetaWithVault, + KeystoreMeta, + ActiveAccount, + AccountTree, + ExposedAccount, + KeystoreV4WithMeta, + KeystoreMetaWithAddress, +} from "./keystore/types" + /** * 1:1 representation of chain spec JSON format with addition fields */ @@ -20,21 +31,22 @@ export type Account = { address: string } -export type KeystoreAccount = - | ({ type: "Keyset" } & DerivationPath) - | { type: "Keypair"; publicKey: string } +export type KeystoreAccount = { + publicKey: string, + name: string, + chainId: string, + derivations: Derivation[] +} -export type DerivationPath = { - chainId: string +export type Derivation = { + name: string path: string - publicKey: string } export type CryptoKey = { name: string scheme: "Sr25519" | "Ed25519" | "Ecdsa" accounts: KeystoreAccount[] - createdAt: number } export type SignRequest = { @@ -47,39 +59,64 @@ export type SignRequest = { | { type: "values"; values: Partial } } -export type InsertCryptoKeyArgs = { +export type InsertAccountArgs = { name: string scheme: "Sr25519" | "Ed25519" | "Ecdsa" - createdAt: number -} & ( - | { - type: "Keyset" - miniSecret: string - derivationPaths: DerivationPath[] - } - | { - type: "Keypair" - privatekey: string - } -) - -type KeyringState = { - isLocked: boolean - hasPassword: boolean + password: string + chainId: string + miniSecret: string } -export type UpdateCryptoKeyArgs = { - name: string, - deviation: number, +export type DeriveAccountArgs = { + publicKey: string + password: string + name: string + path: string } -export type RemoveCryptoKeyArgs = { - name: string, +export type ValidatePasswordArgs = { + name: string + publicKey: string + password: string } -export type RevealCryptoKeyArgs = { - meta: string, - index: number, +export type RevealSecretSeedArgs = { + publicKey: string + password: string +} + +export type RemoveAccountArgs = { + publicKey: string + password: string + path?: string +} + +export type RenameAccountArgs = { + path?: string + newName: string + publicKey: string +} + +export type ChangePasswordArgs = { + publicKey: string + password: string + newPassword: string +} + +export type AccountTreeArgs = { + chainId: string + publicKey: string +} + +export type ExtendKeystoreArgs = { + includeDerivations: boolean, + overwriteExisting: boolean, + keystores: FullKeystoreMetaWithVault[], +} + +export type ActiveAccountArgs = { + chainId: string, + activeAccount?: ActiveAccount } export namespace Pjs { @@ -92,31 +129,32 @@ export namespace Pjs { } export type BackgroundRpcSpec = { - getAccounts(chainId: string): Promise - createTx(chainId: string, from: string, callData: string): Promise + getAccount(chainId: string): Promise pjsSignPayload(payload: Pjs.SignerPayloadJSON): Promise + createTx(chainId: string, from: string, callData: string): Promise // private methods getSignRequests(): Promise> approveSignRequest( id: string, userSignedExtensions: Partial, + password: string ): Promise cancelSignRequest(id: string): Promise - lockKeyring(): Promise - resetKeyring(password: string): Promise - unlockKeyring(password: string): Promise - changePassword(currentPassword: string, newPassword: string): Promise - createPassword(password: string): Promise - getCryptoKeys(): Promise - insertCryptoKey(args: InsertCryptoKeyArgs): Promise - updateCryptoKey(args: UpdateCryptoKeyArgs): Promise - getCryptoKey(name: string): Promise - revealCryptoKey(name: string, index: number): Promise - clearCryptoKey(name: string, index: number): Promise - clearCryptoKeys(args: RemoveCryptoKeyArgs): Promise - getKeyringState(): Promise + changePassword(args: ChangePasswordArgs): Promise + revealSecretSeed(args: RevealSecretSeedArgs): Promise + getAccounts(chainId: string): Promise + renameAccount(args: RenameAccountArgs): Promise + setActiveAccount(args: ActiveAccountArgs): Promise getChainSpecs(): Promise - addChainSpec(chainSpec: string): Promise - removeChainSpec(genesisHash: string): Promise + + // new ones + getAccountDerivations(metadata: KeystoreMeta | undefined): Promise + getActiveAccount(chainId: string): Promise + prepareBackup(publicKeys: string[]): Promise + + insertAccount(args: InsertAccountArgs): Promise + deriveAccount(args: DeriveAccountArgs): Promise + removeAccount(args: RemoveAccountArgs): Promise + extendKeystore(args: ExtendKeystoreArgs): Promise } diff --git a/src/components/Bootnodes.tsx b/src/components/Bootnodes.tsx index 3a7b442..7ed4e9f 100644 --- a/src/components/Bootnodes.tsx +++ b/src/components/Bootnodes.tsx @@ -146,7 +146,7 @@ export const Bootnodes = () => { return (
-

+

Networks

@@ -159,10 +159,10 @@ export const Bootnodes = () => { name={selectedChain} value={selectedChain} > - + - + {networks.map(network => ( { - Bootnodes - Default + Bootnodes + Default
{selectedChainDefaultBn?.map((bn) => (
-
+
{bn}
{
))}
- Custom + Custom
{customBn.map((c) => (
-
+
{c.bootnode}
@@ -226,13 +225,13 @@ export const Bootnodes = () => {
))}
- Add custom Bootnode + Add custom Bootnode
{ addMessage && setAddMessage(undefined) @@ -240,7 +239,7 @@ export const Bootnodes = () => { }} /> - ))} + ) diff --git a/src/containers/WalletPopup/components/DecodedCallData.tsx b/src/containers/WalletPopup/components/DecodedCallData.tsx index 4fd861c..5fad388 100644 --- a/src/containers/WalletPopup/components/DecodedCallData.tsx +++ b/src/containers/WalletPopup/components/DecodedCallData.tsx @@ -24,6 +24,7 @@ type Props = { export const DecodedCallData = ({ chainId, callData }: Props) => { const { data: decodedCallData, + error, isLoading, } = useSWR([chainId, callData], ([chainId, callData]) => decodeCallData(chainId, callData), @@ -31,24 +32,40 @@ export const DecodedCallData = ({ chainId, callData }: Props) => { if (isLoading) { return ( - +
+
+ + Trying to decode calldata, wait for it... +
+
+ ) + } + + if (error || !decodedCallData) { + return ( +
+ Decoding failed +

+ {error?.message || "The call data could not be parsed with the current chain metadata."} +

+
) } return ( -
+
Pallet Call
-
- {decodedCallData!.pallet.value.name} {decodedCallData!.call.value.name} +
+ {decodedCallData?.pallet?.value?.name} :: {decodedCallData?.call?.value?.name}
+
Args
- +
+ +
) } @@ -78,16 +95,16 @@ const DecodedPrimitiveValue = ({ value }: DecodedPrimitiveProps) => { case "char": case "str": case "Bytes": { - return
{value.value}
+ return
{value.value}
} case "AccountId": { - return
{value.value.address}
+ return
{value.value.address}
} case "bitSequence": { // FIXME: improve bitsequence UI // for example: n1,n2,...,n return ( -
BitSequence: {toHex(value.value.bytes)}({value.value.bitsLen})
+
BitSequence: {toHex(value.value.bytes)}({value.value.bitsLen})
) } case "compactBn": @@ -116,7 +133,7 @@ type DecodedComplexProps = { const DecodedComplexValue = ({ value }: DecodedComplexProps) => { switch (value.codec) { case "BytesArray": { - return
{value.value}
+ return
{value.value}
} case "Tuple": case "Sequence": @@ -126,7 +143,7 @@ const DecodedComplexValue = ({ value }: DecodedComplexProps) => { case "Enum": { return ( <> -
{value.value.type}
+
{value.value.type}
) @@ -141,7 +158,7 @@ const DecodedComplexValue = ({ value }: DecodedComplexProps) => { case "Result": { return ( <> -
{value.value.success ? "Ok" : "Error"}
+
{value.value.success ? "Ok" : "Error"}
) @@ -149,13 +166,13 @@ const DecodedComplexValue = ({ value }: DecodedComplexProps) => { case "Struct": { return Object.entries(value.value).map(([key, value]) => ( <> -
{key}
+
{key}
)) } default: - return
{jsonStringify(value)}
+ return
{jsonStringify(value)}
} } diff --git a/src/containers/WalletPopup/components/JohnMcAffee.tsx b/src/containers/WalletPopup/components/JohnMcAffee.tsx new file mode 100644 index 0000000..2fd4b41 --- /dev/null +++ b/src/containers/WalletPopup/components/JohnMcAffee.tsx @@ -0,0 +1,302 @@ +import { SVGProps, useEffect, useRef } from "react" + +interface McAffeeProps extends SVGProps { + leftEyeRef: React.RefObject + rightEyeRef: React.RefObject +} + +export const JohnMcAffeeIcon = (props: SVGProps) => { + const leftEyeRef = useRef(null) + const rightEyeRef = useRef(null) + + useEffect(() => { + const maxDistance = 10 + + const targetMouse = { x: 0, y: 0 } + const currentPos = { x: 0, y: 0 } + + let isResetting = false + let isAwaitingMovement = false + let resetTimer: NodeJS.Timeout | null = null + + const handleMouseMove = (event: MouseEvent) => { + if (isResetting || isAwaitingMovement) { + isResetting = false + isAwaitingMovement = false + } + + targetMouse.x = event.clientX + targetMouse.y = event.clientY + + if (resetTimer) clearTimeout(resetTimer) + resetTimer = setTimeout(() => { + triggerEyeReset() + }, 1500) + } + + const triggerEyeReset = () => { + if (!isAwaitingMovement) { + isResetting = true + } + } + + let animationFrameId: number + + const updateAnimation = () => { + const eyeRefs = [leftEyeRef, rightEyeRef] + + eyeRefs.forEach((ref) => { + const eyeElement = ref.current + if (!eyeElement) return + + const rect = eyeElement.getBoundingClientRect() + const centerX = rect.left + rect.width / 2 + const centerY = rect.top + rect.height / 2 + + let targetX = 0 + let targetY = 0 + + if (isResetting || isAwaitingMovement) { + targetX = 0 + targetY = 0 + } else { + const dx = targetMouse.x - centerX + const dy = targetMouse.y - centerY + const angle = Math.atan2(dy, dx) + const distance = Math.hypot(dx, dy) + + const limitedDistance = Math.min(distance * 0.2, maxDistance) + targetX = Math.cos(angle) * limitedDistance + targetY = Math.sin(angle) * limitedDistance + } + + const speed = 0.12 + + currentPos.x += (targetX - currentPos.x) * speed + currentPos.y += (targetY - currentPos.y) * speed + + eyeElement.setAttribute("transform", `translate(${currentPos.x}, ${currentPos.y})`) + }) + + if (isResetting) { + const distanceToResetTarget = Math.hypot(currentPos.x - 0, currentPos.y - 0) + if (distanceToResetTarget < 0.1) { + isResetting = false + isAwaitingMovement = true + if (resetTimer) clearTimeout(resetTimer) + } + } + + animationFrameId = requestAnimationFrame(updateAnimation) + } + + document.addEventListener("mousemove", handleMouseMove) + animationFrameId = requestAnimationFrame(updateAnimation) + + return () => { + document.removeEventListener("mousemove", handleMouseMove) + cancelAnimationFrame(animationFrameId) + if (resetTimer) clearTimeout(resetTimer) + } + }, []) + + return ( + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ) +} diff --git a/src/containers/WalletPopup/components/NetworkLogo.tsx b/src/containers/WalletPopup/components/NetworkLogo.tsx new file mode 100644 index 0000000..39d48ea --- /dev/null +++ b/src/containers/WalletPopup/components/NetworkLogo.tsx @@ -0,0 +1,88 @@ +const ghostLogo = () => { + return ( + + + + + + + + + + + + + + + + + + + + + + + + ) +} + +const casperLogo = () => { + return ( + + + + + + + + + + + + + + + + + + + + + + + + ) +} + +const logoMap: Record>> = { + casper_staging_testnet: casperLogo, +}; + +export const NetworkLogo = ({ value, className }: { value: string | undefined; className?: string }) => { + const Logo = logoMap[value ?? ""]; + if (!Logo) return
; + + return ( +
+ +
+ ) +}; diff --git a/src/containers/WalletPopup/components/ProtectedRoute.tsx b/src/containers/WalletPopup/components/ProtectedRoute.tsx deleted file mode 100644 index f30049e..0000000 --- a/src/containers/WalletPopup/components/ProtectedRoute.tsx +++ /dev/null @@ -1,33 +0,0 @@ -import { ReactNode, useEffect } from "react" -import { Navigate, Outlet, useLocation } from "react-router-dom" -import { useKeyring } from "../hooks" - -export const ProtectedRoute = ({ children }: { children?: ReactNode }) => { - const location = useLocation() - const { - lock, - keyring: { isLocked, hasPassword }, - } = useKeyring() - - useEffect(() => { - if (!isLocked && hasPassword && location.key === "default") { - lock() - } - }, []); - - return !hasPassword ? ( - - ) : isLocked ? ( - - ) : ( - (children ?? ) - ) -} diff --git a/src/containers/WalletPopup/components/UserSignedExtensionInputs.tsx b/src/containers/WalletPopup/components/UserSignedExtensionInputs.tsx index c66384f..ef0a578 100644 --- a/src/containers/WalletPopup/components/UserSignedExtensionInputs.tsx +++ b/src/containers/WalletPopup/components/UserSignedExtensionInputs.tsx @@ -12,8 +12,8 @@ import { SelectContent, SelectGroup, SelectItem, -} from "../../../components/ui/select" -import { Input } from "../../../components/ui/input" +} from "@/components/ui/select" +import { Input } from "@/components/ui/input" const reducer = ( state: Partial, @@ -73,20 +73,26 @@ export const UserSignedExtensionInputs = ({ type TipInputProps = { onChange(value: UserSignedExtensions["ChargeTransactionPayment"]): void - defaultValue?: number + defaultValue?: bigint } -const TipInput = ({ onChange, defaultValue = 0 }: TipInputProps) => { - // @ts-expect-error TODO: improve bigint types +const TipInput = ({ onChange, defaultValue = 0n }: TipInputProps) => { useEffect(() => onChange(defaultValue), []) return ( -
+
Tip Amount
onChange(+e.target.value)} + onChange={e => { + const val = e.target.value.trim() + try { + onChange(val ? BigInt(val) : 0n) + } catch { + onChange(0n) + } + }} aria-label="Request Origin" type="number" - className="pr-10 text-muted-foreground" - value={Number(defaultValue)} + className="bg-input border-none text-white placeholder:text-white/40 h-11 px-4 rounded-lg focus-visible:ring-1 focus-visible:ring-white/20" + value={defaultValue.toString()} />
) @@ -107,10 +113,10 @@ const MortalityInput = ({ }: MortalityInputProps) => { useEffect(() => onChange(defaultValue), []) return ( -
+
Transaction Mortality
- onChange({ mortal: true, period: Number(value) })}> + diff --git a/src/containers/WalletPopup/components/UserSignedExtensions.tsx b/src/containers/WalletPopup/components/UserSignedExtensions.tsx index cdb07fc..90ced5b 100644 --- a/src/containers/WalletPopup/components/UserSignedExtensions.tsx +++ b/src/containers/WalletPopup/components/UserSignedExtensions.tsx @@ -1,4 +1,5 @@ import { type UserSignedExtensions as UserSignedExtensionsTy } from "../../../types/UserSignedExtension" +import { Input } from "@/components/ui/input" type Props = { userSignedExtensions: Partial diff --git a/src/containers/WalletPopup/components/index.ts b/src/containers/WalletPopup/components/index.ts index e6b1bc7..31a6bac 100644 --- a/src/containers/WalletPopup/components/index.ts +++ b/src/containers/WalletPopup/components/index.ts @@ -1,8 +1,9 @@ export * from "./DecodedCallData" export * from "./UserSignedExtensionInputs" export * from "./UserSignedExtensions" -export * from "./ProtectedRoute" export * from "./Header" export * from "./BottomNavBar" export * from "./CopyButton" +export * from "./JohnMcAffee" +export * from "./NetworkLogo" export { default as NetworkTab } from "./NetworkTab" diff --git a/src/containers/WalletPopup/hooks/index.ts b/src/containers/WalletPopup/hooks/index.ts index 8529338..52a9dd4 100644 --- a/src/containers/WalletPopup/hooks/index.ts +++ b/src/containers/WalletPopup/hooks/index.ts @@ -1 +1,2 @@ -export * from "./useKeyring" +export * from "./useChain" +export * from "./useAccounts" diff --git a/src/containers/WalletPopup/hooks/useAccounts.tsx b/src/containers/WalletPopup/hooks/useAccounts.tsx new file mode 100644 index 0000000..aa09b75 --- /dev/null +++ b/src/containers/WalletPopup/hooks/useAccounts.tsx @@ -0,0 +1,271 @@ +import { ReactNode, createContext, useContext, useState, useEffect, useMemo } from "react" +import useSWR, { KeyedMutator } from "swr" +import { z } from "zod" +import { rpc } from "../api" +import { useChain } from "./" + +import { + KeystoreMetaWithAddress, + ActiveAccount, + AccountTree, +} from "@/background/keystore/types" + +import { RenameAccountArgs } from "@/background/types" + +import { useForm } from "react-hook-form" +import { zodResolver } from "@hookform/resolvers/zod" + +import { Card, CardHeader, CardTitle, CardContent, CardFooter } from "@/components/ui/card" +import { Form, FormField, FormItem, FormControl } from "@/components/ui/form" +import { Input } from "@/components/ui/input" +import { Button } from "@/components/ui/button" +import { Layout2 } from "@/components/Layout2" +import { JohnMcAffeeIcon } from "../components/JohnMcAffee" + +const passwordSchema = z.object({ + password: z.string().min(1, "password is required"), +}) + +type AccountsContextType = { + accounts: KeystoreMetaWithAddress[] | undefined + activeAccount: ActiveAccount | undefined + accountTree: AccountTree | undefined + + activeAccountName: string + isFetchingAccounts: boolean + isFetchingActiveAccounts: boolean + isFetchingAccountTree: boolean + + renameAccount(args: RenameAccountArgs): Promise + selectAccount(activeAccount: ActiveAccount): Promise + accountsStorageMutate(): Promise + requestPassword: (fn: (password: string) => Promise) => Promise +} + +const AccountsContext = createContext({} as AccountsContextType) + +export const AccountsProvider = ({ children }: { children?: ReactNode }) => { + const { currentChain } = useChain() + + const [isSubmitting, setIsSubmitting] = useState(false) + const [password, setPassword] = useState("") + const [rpcError, setRpcError] = useState(undefined) + const [isPasswordOpen, setIsPasswordOpen] = useState(false) + + const [deferred, setDeferred] = useState<{ + execute: (password: string) => Promise + resolve: (value: any) => void + reject: (err: Error) => void + } | null>(null) + + const form = useForm>({ + resolver: zodResolver(passwordSchema), + defaultValues: { password: "" }, + }) + + const { formState: passwordFormState, watch } = form + const passwordValue = watch("password") + + useEffect(() => { + if (rpcError) setRpcError(undefined) + }, [passwordValue]) + + const requestPassword = (fn: (password: string) => Promise): Promise => { + return new Promise((resolve, reject) => { + setRpcError(undefined) + setIsPasswordOpen(true) + setDeferred({ + execute: async (password: string): Promise => { + return await fn(password) + }, + resolve, + reject + }); + }) + } + + const { data: accounts, mutate: accountsMutate, isLoading: isFetchingAccounts } = useSWR( + currentChain?.chainId ? ["rpc.getAccounts", currentChain.chainId] : null, + ([_, id]) => rpc.client.getAccounts(id), + { + revalidateOnFocus: true, + }, + ) + + const { data: activeAccount, mutate: activeAccountMutate, isLoading: isFetchingActiveAccounts } = useSWR( + currentChain?.chainId ? ["rpc.getActiveAccount", currentChain.chainId] : null, + ([_, id]) => rpc.client.getActiveAccount(id), + { + revalidateOnFocus: true, + }, + ) + + const currentAccountMetadata = useMemo(() => { + if (!activeAccount || !accounts) return undefined + return accounts.find(acc => acc.publicKey === activeAccount.publicKey) + }, [accounts, activeAccount]) + + const { data: accountTree, mutate: accountTreeMutate, isLoading: isFetchingAccountTree } = useSWR( + currentAccountMetadata ? ["rpc.getAccountDerivations", currentAccountMetadata] : null, + ([_, acc]) => rpc.client.getAccountDerivations(acc), + { + revalidateOnFocus: true, + }, + ) + + const selectAccount = async (newActiveAccount: ActiveAccount): Promise => { + if (!currentChain) throw "current chain not found" + await rpc.client.setActiveAccount({ + chainId: currentChain.chainId, + activeAccount: newActiveAccount, + }) + await activeAccountMutate() + } + + const renameAccount = async (args: RenameAccountArgs): Promise => { + await rpc.client.renameAccount(args) + await accountsMutate() + await activeAccountMutate() + await accountTreeMutate() + } + + const accountsStorageMutate = async (): Promise => { + await activeAccountMutate() + await accountsMutate() + await accountTreeMutate() + } + + const activeAccountName = useMemo(() => { + if (!accountTree || !activeAccount) return "" + + if (activeAccount.path) { + const activeDer = accountTree.derivations.find( + (der) => der.path === activeAccount.path + ) + return `${accountTree.name} ● ${activeDer?.path ?? ""}` + } + + return accountTree.name + }, [accountTree, activeAccount]) + + const handleClose = () => { + if (deferred) deferred.reject(new Error("password input cancelled")) + setDeferred(null) + setRpcError(undefined) + setIsPasswordOpen(false) + form.reset() + } + + const onPasswordSubmit = async (data: z.infer) => { + if (!deferred) return + setIsSubmitting(true) + setRpcError(undefined) + + try { + const result = await deferred.execute(data.password) + deferred.resolve(result) + + setDeferred(null) + setIsPasswordOpen(false) + + form.reset() + } catch (error: any) { + const errorMsg = error?.message || "incorrect password given"; + setRpcError(errorMsg) + deferred.reject(new Error(errorMsg)) + } finally { + setIsSubmitting(false) + } + } + + const value: AccountsContextType = { + accounts, + activeAccountName, + activeAccount, + accountTree, + + isFetchingAccounts, + isFetchingActiveAccounts, + isFetchingAccountTree, + + accountsStorageMutate, + selectAccount, + renameAccount, + requestPassword, + } + + return ( + +
+ {children} +
+ {isPasswordOpen && ( + +
+ + + +
+ +
+ +

+ Submit Password for {currentAccountMetadata?.name ?? ""} +

+ + {rpcError && (
+ {rpcError} +
)} + +
+ ( + + + + + + )} + /> +
+
+ + + + + + +
+
+ +
+ )} +
+ ) +} + +export const useAccounts = () => useContext(AccountsContext) diff --git a/src/containers/WalletPopup/hooks/useChain.tsx b/src/containers/WalletPopup/hooks/useChain.tsx new file mode 100644 index 0000000..4d94330 --- /dev/null +++ b/src/containers/WalletPopup/hooks/useChain.tsx @@ -0,0 +1,69 @@ +import { ReactNode, createContext, useContext, useState, useEffect, useMemo } from "react" +import useSWR from "swr" +import { rpc } from "../api" + +import { networks, NetworkType } from "@/constants" +import * as storage from "@/background/storage" + +type ChainContextType = { + currentChain: NetworkType | undefined + allChains: NetworkType[] + setCurrentChainId(id: string): Promise + isLoadingChain: boolean +} + +const ChainContext = createContext({} as ChainContextType) + +const STORAGE_CHAIN_KEY = "current_selected_chain_id" + +export const ChainProvider = ({ children }: { children?: ReactNode }) => { + const [isLoadingChain, setIsLoadingChain] = useState(true) + const [currentChainId, setCurrentChainIdState] = useState("") + + useEffect(() => { + const loadSavedChain = async () => { + const stored = await storage.get(STORAGE_CHAIN_KEY) + const savedId = stored?.[STORAGE_CHAIN_KEY] + + if (savedId && networks.some(n => n.chainId === savedId)) { + setCurrentChainIdState(savedId) + } else { + const defaultChainId = networks[0].chainId + setCurrentChainIdState(defaultChainId) + await storage.set(STORAGE_CHAIN_KEY, defaultChainId) + } + + setIsLoadingChain(false) + } + + loadSavedChain() + }, []) + + const currentChain = useMemo(() => { + return networks.find(n => n.chainId === currentChainId) + }, [currentChainId]) + + const setCurrentChainId = async (id: string) => { + const chainExists = networks.some(n => n.chainId === id) + if (!chainExists) return + + setCurrentChainIdState(id) + + try { + await storage.set(STORAGE_CHAIN_KEY, id) + } catch (error) { + console.error("Failed to save chain change:", error) + } + } + + const value: ChainContextType = { + currentChain, + allChains: networks, + setCurrentChainId, + isLoadingChain + } + + return {children} +} + +export const useChain = () => useContext(ChainContext) diff --git a/src/containers/WalletPopup/hooks/useKeyring.tsx b/src/containers/WalletPopup/hooks/useKeyring.tsx deleted file mode 100644 index 59d55da..0000000 --- a/src/containers/WalletPopup/hooks/useKeyring.tsx +++ /dev/null @@ -1,75 +0,0 @@ -import { ReactNode, createContext, useContext, useEffect } from "react" -import { useLocation, useNavigate } from "react-router-dom" -import useSWR from "swr" -import { rpc } from "../api" -import { z } from "zod" - -type Context = { - keyring: { - isLocked: boolean - hasPassword: boolean - } - unlock(password: string): Promise - reset(password: string): Promise - lock(): Promise - refresh(): Promise -} - -const LocationStateSchema = z.object({ - pathname: z.string(), - search: z.string().optional(), -}) - -const LockContext = createContext({} as Context) - -export const KeyringProvider = ({ children }: { children?: ReactNode }) => { - const { - data: keyring, - isLoading, - error, - mutate, - } = useSWR("rpc.getKeyringState", () => rpc.client.getKeyringState()) - - const navigate = useNavigate() - const location = useLocation() - - if (isLoading || error) return null - - const refresh = async () => { - await mutate() - } - const unlock = async (password: string) => { - await rpc.client.unlockKeyring(password) - await refresh() - - const locationResult = LocationStateSchema.safeParse(location.state) - if (locationResult.success) { - navigate({ - pathname: locationResult.data.pathname, - search: locationResult.data.search, - }) - } else { - navigate("/accounts") - } - } - const lock = async () => { - await rpc.client.lockKeyring() - await refresh() - navigate("/unlock-keyring") - } - const reset = async (password: string) => { - await rpc.client.resetKeyring(password) - await refresh() - navigate("/welcome") - } - const value = { - keyring: keyring!, - unlock, - lock, - reset, - refresh, - } - return {children} -} - -export const useKeyring = () => useContext(LockContext) diff --git a/src/containers/WalletPopup/pages/Accounts/AccountDetails.tsx b/src/containers/WalletPopup/pages/Accounts/AccountDetails.tsx index e182986..52c42dc 100644 --- a/src/containers/WalletPopup/pages/Accounts/AccountDetails.tsx +++ b/src/containers/WalletPopup/pages/Accounts/AccountDetails.tsx @@ -1,9 +1,11 @@ import { ArrowLeft, Trash2, Eye } from "lucide-react" import * as clipboard from "@zag-js/clipboard" import { useMachine, normalizeProps } from "@zag-js/react" -import React, { useId, useState } from "react" +import React, { useId, useState, useMemo } from "react" import { useLocation, useParams, useNavigate } from "react-router-dom" -import { Layout2 } from "@/components/Layout2" +import { Copy, Check } from 'lucide-react' +import { Layout } from "@/components/Layout" +import { Label } from "@/components/ui/label" import { Card, CardHeader, @@ -16,154 +18,218 @@ import { Button } from "@/components/ui/button" import { Input } from "@/components/ui/input" import { Textarea } from "@/components/ui/textarea" import { rpc } from "@/containers/WalletPopup/api" +import { cn } from "@/lib/utils" + import { BottomNavBar, Header } from "../../components" +import { useAccounts, useChain } from "../../hooks" export const AccountDetails: React.FC = () => { const defaultSecretKey = "0xg00db0ybutwh4tareyou7ry1ngtofindhere1t1ss3cr3tinf0g0aw4yr1gh7n0w" const navigate = useNavigate() const location = useLocation() - const [secretKey, setSecretKey] = useState(defaultSecretKey) - const [error, setError] = useState() + const { currentChain } = useChain() + const { + activeAccountName, + accountTree, + renameAccount, + requestPassword, + } = useAccounts() + + const [secretSeed, setSecretSeed] = useState(defaultSecretKey) + const [newName, setNewName] = useState() const [isRevealed, setIsRevealed] = useState() - const [isPending, setIsPending] = useState() - const query = new URLSearchParams(location.search) - const cryptoName = query.get("meta") - const cryptoIndex = query.get("index") + const { derivation } = useParams<{ derivation?: string }>() + const derivationPath = useMemo(() => { + return derivation ? decodeURIComponent(derivation) : undefined; + }, [derivation]) + + const currentName = useMemo(() => { + if (!accountTree) return "" + + if (!derivationPath) { + return accountTree.name + } else { + const der = accountTree.derivations.find(d => d.path == derivationPath) + return der ? der.name : "" + } + }, [derivationPath, accountTree]) + + const currentAddress = useMemo(() => { + if (!accountTree) return "" + + if (!derivationPath) { + return accountTree.address + } else { + const der = accountTree.derivations.find(d => d.path == derivationPath) + return der ? der.address : "" + } + }, [derivationPath, accountTree]) - const { accountId } = useParams<{ accountId: string }>() const [state, send] = useMachine( clipboard.machine({ id: useId(), - value: accountId, + value: currentAddress ?? "", }), ) const api = clipboard.connect(state, send, normalizeProps) - if (!accountId) { - return null - } + const handleBlur = async () => { + if (!newName) { + setNewName(currentName) + return + } - const pendingWrapper = (func) => { - return async (...args) => { - try { - setError("") - setIsPending(true) - await func(...args) - } catch (e) { - setError(e.message) - } finally { - setIsPending(false) - setSecretKey(defaultSecretKey) - } + const trimmedName = newName.trim() + + if (trimmedName === "") { + setNewName(currentName) + return + } + + if (trimmedName === currentName || !accountTree) return + + try { + await renameAccount({ + newName: trimmedName, + publicKey: accountTree.publicKey, + path: derivationPath + }) + setNewName(trimmedName) + } catch { + setNewName(currentName) } } - const requestSecretKey = async () => { - const key = await rpc.client.revealCryptoKey(cryptoName, cryptoIndex) - setSecretKey(key) + const requestSecretSeed = async () => { + if (isRevealed || !accountTree) return + + const seed = await requestPassword(async (password) => { + return await rpc.client.revealSecretSeed({ + publicKey: accountTree.publicKey, + password, + }) + }) + + setSecretSeed(`0x${seed}`) setIsRevealed(true) - await new Promise((resolve) => { + + await new Promise((resolve) => { setTimeout(() => { setIsRevealed(false) + setSecretSeed(defaultSecretKey) resolve() }, 5000) }) } - const deleteCryptoKey = async () => { - await rpc.client.clearCryptoKey(cryptoName, cryptoIndex) - navigate(-1) - } - - const wrappedRequestSecretKey = pendingWrapper(requestSecretKey) - const wrappedDeleteCryptoKey = pendingWrapper(deleteCryptoKey) - return ( - -
- - - Address Details + + + + + {derivationPath ? "Derivation" : "Account"} Details + - -
-
- Public address, {api.isCopied - ? copied to clipboard - : click on it to copy - } -
-