From dfd93d0c26630500c7a1f07a8fddadcdfdaf8f8b Mon Sep 17 00:00:00 2001 From: Roman Useinov Date: Fri, 19 Jun 2026 00:57:00 +0200 Subject: [PATCH 1/5] feat: hybrid signature and mnemonic import --- packages/apps/src/index.tsx | 13 +- packages/apps/src/initQuipSigner.ts | 140 ++++++++++++++++++ packages/apps/webpack.base.cjs | 5 + packages/page-accounts/src/Accounts/index.tsx | 16 ++ .../page-accounts/src/modals/QuipMnemonic.tsx | 109 ++++++++++++++ 5 files changed, 280 insertions(+), 3 deletions(-) create mode 100644 packages/apps/src/initQuipSigner.ts create mode 100644 packages/page-accounts/src/modals/QuipMnemonic.tsx diff --git a/packages/apps/src/index.tsx b/packages/apps/src/index.tsx index dac546e4bf87..82fb098f5bfc 100644 --- a/packages/apps/src/index.tsx +++ b/packages/apps/src/index.tsx @@ -11,6 +11,7 @@ import '@polkadot/api-augment/substrate'; import React from 'react'; import { createRoot } from 'react-dom/client'; +import { initQuipSigner } from './initQuipSigner.js'; import Root from './Root.js'; const rootId = 'root'; @@ -20,6 +21,12 @@ if (!rootElement) { throw new Error(`Unable to find element with id '${rootId}'`); } -createRoot(rootElement).render( - -); +void initQuipSigner() + .catch((error): void => { + console.error('Quip dev signer initialization failed', error); + }) + .finally((): void => { + createRoot(rootElement).render( + + ); + }); diff --git a/packages/apps/src/initQuipSigner.ts b/packages/apps/src/initQuipSigner.ts new file mode 100644 index 000000000000..8574cacdd76c --- /dev/null +++ b/packages/apps/src/initQuipSigner.ts @@ -0,0 +1,140 @@ +// Copyright 2017-2026 @polkadot/apps authors & contributors +// SPDX-License-Identifier: Apache-2.0 + +const ENABLED_VALUES = new Set(['1', 'true', 'yes', 'on']); +const STORAGE_KEY = 'quip:devSigner'; + +const DEV_SEEDS = [ + { + name: 'Quip Alice', + seedHex: '0xe5be9a5092b81bca64be81d212e7f2f9eba183bb7a90954f7b76361f6edb5c0a' + }, + { + name: 'Quip Bob', + seedHex: '0x398f0c28f98885e046333d4a41c19cee4c37368a9832c6502f6cfd182e2aef89' + }, + { + name: 'Quip Alice Stash', + seedHex: '0x3c881bc4d45926680c64a7f9315eeda3dd287f8d598f3653d7c107799c5422b3' + } +]; + +interface QuipDevProvider { + importMnemonic: ( + name: string, + mnemonic: string, + genesisHash?: string | null + ) => Promise<{ address: string }>; +} + +/** + * Cross-package handle published on `window` so the page-accounts UI can import + * Quip accounts without `page-accounts` importing back into the `apps` package + * (which would be a circular dependency). + */ +export interface QuipSignerUiApi { + importMnemonic: (name: string, mnemonic: string) => Promise; +} + +declare global { + // eslint-disable-next-line no-var + var quipSigner: QuipSignerUiApi | undefined; +} + +let isInjected = false; +let quipProvider: QuipDevProvider | null = null; + +function isEnabledValue (value: string | null | undefined): boolean { + return !!value && ENABLED_VALUES.has(value.toLowerCase()); +} + +function isEnabledByQuery (): boolean { + const params = new URLSearchParams(window.location.search); + + return ['quipSigner', 'quip-signer', 'quipDevSigner'].some((key) => { + if (!params.has(key)) { + return false; + } + + const value = params.get(key); + + return value === '' || value === null || isEnabledValue(value); + }); +} + +function isEnabledByStorage (): boolean { + try { + return isEnabledValue(window.localStorage.getItem(STORAGE_KEY)); + } catch { + return false; + } +} + +function shouldInjectQuipSigner (): boolean { + return isEnabledValue(process.env.QUIP_DEV_SIGNER) || + isEnabledByQuery() || + isEnabledByStorage(); +} + +export async function initQuipSigner (): Promise { + if (isInjected || !shouldInjectQuipSigner()) { + return; + } + + isInjected = true; + + const [signerModule, wasmModule] = await Promise.all([ + import('../../../../quip-protocol-rs/js/quip-signer/src/index.js'), + import('../../../../quip-protocol-rs/js/quip-transaction-crypto-wasm/quip_transaction_crypto_wasm.js') + ]); + + await wasmModule.default(); + + // Quip's hybrid signature (3828 bytes) is larger than polkadot-js's hardcoded + // 256-byte fake signature, which breaks `paymentInfo`/fee estimation. Patch + // signFake to size the fake from the registry before any tx flow runs. + signerModule.patchExtrinsicSignFake(); + + const { accounts, provider } = await signerModule.DevSeedProvider.fromSeeds(wasmModule, DEV_SEEDS); + + signerModule.injectQuip({ + accounts, + signer: new signerModule.QuipSigner(provider) + }); + + quipProvider = provider; + globalThis.quipSigner = { importMnemonic: importQuipMnemonic }; + + console.info(`Quip dev signer injected ${accounts.length} account${accounts.length === 1 ? '' : 's'}`); +} + +/** Whether the Quip dev signer has been injected this session. */ +export function isQuipSignerActive (): boolean { + return quipProvider !== null; +} + +/** + * Imports a Quip account from a BIP39 phrase (or `0x` seed hex), registering + * its seed with the injected Quip signer and adding it to the keyring so it + * appears in the UI and signs through the injected signer. + */ +export async function importQuipMnemonic (name: string, mnemonic: string): Promise { + if (!quipProvider) { + throw new Error('Quip dev signer is not active'); + } + + const { address } = await quipProvider.importMnemonic(name, mnemonic, null); + + const { keyring } = await import('@polkadot/ui-keyring'); + + // Use the same path the keyring uses for extension accounts: `loadInjected` + // sets `meta.isInjected` (so react-signer routes signing through the injected + // Quip signer) and updates the live account subject so the UI refreshes + // without a reload. `addExternal` would instead set `isExternal`, which + // routes to the QR signer. `loadInjected` is not in the public typings. + (keyring as unknown as { + loadInjected: (address: string, meta: Record, type?: string) => void; + }).loadInjected(address, { name, source: 'quip' }); + + return address; +} diff --git a/packages/apps/webpack.base.cjs b/packages/apps/webpack.base.cjs index c8cc255a4c9c..20858b4ff54d 100644 --- a/packages/apps/webpack.base.cjs +++ b/packages/apps/webpack.base.cjs @@ -135,6 +135,7 @@ function createWebpack (context, mode = 'production') { new webpack.DefinePlugin({ 'process.env': { NODE_ENV: JSON.stringify(mode), + QUIP_DEV_SIGNER: JSON.stringify(process.env.QUIP_DEV_SIGNER), WS_URL: JSON.stringify(process.env.WS_URL) } }), @@ -149,6 +150,10 @@ function createWebpack (context, mode = 'production') { '.js': ['.js', '.ts', '.tsx'] }, extensions: ['.js', '.jsx', '.mjs', '.ts', '.tsx'], + modules: [ + 'node_modules', + path.resolve(context, '../../node_modules') + ], fallback: { assert: require.resolve('assert/'), crypto: require.resolve('crypto-browserify'), diff --git a/packages/page-accounts/src/Accounts/index.tsx b/packages/page-accounts/src/Accounts/index.tsx index b01989c8f3e8..23aa2bf894e1 100644 --- a/packages/page-accounts/src/Accounts/index.tsx +++ b/packages/page-accounts/src/Accounts/index.tsx @@ -25,6 +25,7 @@ import Local from '../modals/LocalAdd.js'; import Multisig from '../modals/MultisigCreate.js'; import Proxy from '../modals/ProxiedAdd.js'; import Qr from '../modals/Qr.js'; +import QuipMnemonic from '../modals/QuipMnemonic.js'; import { useTranslation } from '../translate.js'; import { SORT_CATEGORY, sortAccounts } from '../util.js'; import Account from './Account.js'; @@ -107,8 +108,10 @@ function Overview ({ className = '', onStatusChange }: Props): React.ReactElemen const [isProxyOpen, toggleProxy] = useToggle(); const [isLocalOpen, toggleLocal] = useToggle(); const [isQrOpen, toggleQr] = useToggle(); + const [isQuipOpen, toggleQuip] = useToggle(); const [isExportAll, toggleExportAll] = useToggle(); const [isImportAll, toggleImportAll] = useToggle(); + const hasQuipSigner = typeof globalThis !== 'undefined' && !!(globalThis as { quipSigner?: unknown }).quipSigner; const [favorites, toggleFavorite] = useFavorites(STORE_FAVS); const [balances, setBalances] = useState({ accounts: {} }); const [filterOn, setFilter] = useState(''); @@ -323,6 +326,12 @@ function Overview ({ className = '', onStatusChange }: Props): React.ReactElemen onStatusChange={onStatusChange} /> )} + {isQuipOpen && ( + + )} {isExportAll && ( + {hasQuipSigner && ( +