From 5f1d355bc3652598ba5a01db2e71dad339754623 Mon Sep 17 00:00:00 2001 From: Roman Useinov Date: Tue, 28 Jul 2026 15:21:41 +0200 Subject: [PATCH 1/3] feature: extrinsic signing --- package.json | 3 +- packages/apps/src/initQuipSigner.spec.ts | 54 ++++++++++++++++++++++++ packages/apps/src/initQuipSigner.ts | 23 +++++++--- packages/react-signer/src/TxSigned.tsx | 45 ++++++++++++++++++-- scripts/quipSigning.mjs | 7 +++ 5 files changed, 123 insertions(+), 9 deletions(-) create mode 100644 packages/apps/src/initQuipSigner.spec.ts create mode 100644 scripts/quipSigning.mjs diff --git a/package.json b/package.json index 02adbeb7e6a3..8c01dcca1924 100644 --- a/package.json +++ b/package.json @@ -59,11 +59,12 @@ "packElectron:win": "yarn build:release:electron && electron-builder build --win --project packages/apps-electron", "postinstall": "polkadot-dev-yarn-only", "postinstall:electron": "electron-builder install-app-deps", - "start": "yarn clean && cd packages/apps && yarn polkadot-exec-webpack serve --config webpack.serve.cjs --port 3000", + "start": "yarn clean && cd packages/apps && yarn polkadot-exec-webpack serve --config webpack.serve.cjs --port 3001", "start:electron": "yarn clean:electronBuild && concurrently 'yarn build:devElectronMain && cd packages/apps-electron && electron ./build/electron.js' 'yarn build:devElectronRenderer'", "test": "polkadot-dev-run-test --env browser ^typesBundle ^chainEndpoints ^chainTypes ^page- ^react- ^apps-electron", "test:all": "polkadot-dev-run-test --env browser ^chainEndpoints ^chainTypes", "test:one": "polkadot-dev-run-test --env browser", + "test:quip-signing": "node scripts/quipSigning.mjs", "test:skipped": "echo 'tests skipped'" }, "devDependencies": { diff --git a/packages/apps/src/initQuipSigner.spec.ts b/packages/apps/src/initQuipSigner.spec.ts new file mode 100644 index 000000000000..65950804edbe --- /dev/null +++ b/packages/apps/src/initQuipSigner.spec.ts @@ -0,0 +1,54 @@ +// Copyright 2017-2026 @polkadot/apps authors & contributors +// SPDX-License-Identifier: Apache-2.0 + +/// + +import { shouldInjectQuipSigner } from './initQuipSigner.js'; + +describe('Quip development signer gating', (): void => { + const originalNodeEnv = process.env.NODE_ENV; + const originalQuipDevSigner = process.env.QUIP_DEV_SIGNER; + + beforeEach((): void => { + process.env.NODE_ENV = 'test'; + delete process.env.QUIP_DEV_SIGNER; + window.localStorage.clear(); + window.history.replaceState({}, '', '/'); + }); + + afterAll((): void => { + process.env.NODE_ENV = originalNodeEnv; + + if (originalQuipDevSigner === undefined) { + delete process.env.QUIP_DEV_SIGNER; + } else { + process.env.QUIP_DEV_SIGNER = originalQuipDevSigner; + } + }); + + it('is opt-in in development', (): void => { + expect(shouldInjectQuipSigner()).toBe(false); + + process.env.QUIP_DEV_SIGNER = '1'; + + expect(shouldInjectQuipSigner()).toBe(true); + }); + + it('supports the explicit local query and storage toggles', (): void => { + window.history.replaceState({}, '', '/?quipSigner=1'); + expect(shouldInjectQuipSigner()).toBe(true); + + window.history.replaceState({}, '', '/'); + window.localStorage.setItem('quip:devSigner', 'true'); + expect(shouldInjectQuipSigner()).toBe(true); + }); + + it('cannot be enabled in a production bundle', (): void => { + process.env.NODE_ENV = 'production'; + process.env.QUIP_DEV_SIGNER = '1'; + window.history.replaceState({}, '', '/?quipSigner=1'); + window.localStorage.setItem('quip:devSigner', 'true'); + + expect(shouldInjectQuipSigner()).toBe(false); + }); +}); diff --git a/packages/apps/src/initQuipSigner.ts b/packages/apps/src/initQuipSigner.ts index 877693ef6cb8..1c6fe1b963f9 100644 --- a/packages/apps/src/initQuipSigner.ts +++ b/packages/apps/src/initQuipSigner.ts @@ -1,6 +1,8 @@ // Copyright 2017-2026 @polkadot/apps authors & contributors // SPDX-License-Identifier: Apache-2.0 +import { GenericExtrinsicSignatureV4 } from '@polkadot/types'; + const ENABLED_VALUES = new Set(['1', 'true', 'yes', 'on']); const STORAGE_KEY = 'quip:devSigner'; @@ -20,6 +22,7 @@ const DEV_SEEDS = [ ]; interface QuipDevProvider { + hasAccount: (address: string) => boolean; importMnemonic: ( name: string, mnemonic: string, @@ -33,6 +36,7 @@ interface QuipDevProvider { * (which would be a circular dependency). */ export interface QuipSignerUiApi { + canSign: (address: string) => boolean; importMnemonic: (name: string, mnemonic: string) => Promise; } @@ -70,7 +74,13 @@ function isEnabledByStorage (): boolean { } } -function shouldInjectQuipSigner (): boolean { +export function shouldInjectQuipSigner (): boolean { + // The page-memory seed provider is intentionally development-only. Query + // parameters and localStorage must never turn it on in a production bundle. + if (process.env.NODE_ENV === 'production') { + return false; + } + return isEnabledValue(process.env.QUIP_DEV_SIGNER) || isEnabledByQuery() || isEnabledByStorage(); @@ -84,8 +94,8 @@ export async function initQuipSigner (): Promise { 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') + 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(); @@ -93,7 +103,7 @@ export async function initQuipSigner (): Promise { // 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(); + signerModule.patchExtrinsicSignFake(GenericExtrinsicSignatureV4); const { accounts, provider } = await signerModule.DevSeedProvider.fromSeeds(wasmModule, DEV_SEEDS); @@ -103,7 +113,10 @@ export async function initQuipSigner (): Promise { }); quipProvider = provider; - globalThis.quipSigner = { importMnemonic: importQuipMnemonic }; + globalThis.quipSigner = { + canSign: (address) => provider.hasAccount(address), + importMnemonic: importQuipMnemonic + }; console.info(`Quip dev signer injected ${accounts.length} account${accounts.length === 1 ? '' : 's'}`); } diff --git a/packages/react-signer/src/TxSigned.tsx b/packages/react-signer/src/TxSigned.tsx index c4e8a305fd61..83920bc73874 100644 --- a/packages/react-signer/src/TxSigned.tsx +++ b/packages/react-signer/src/TxSigned.tsx @@ -57,6 +57,38 @@ const EMPTY_INNER: InnerTx = { innerHash: null, innerTx: null }; let qrId = 0; +interface QuipSignerUiApi { + canSign: (address: string) => boolean; +} + +function quipSigningError (address: string | null): string | null { + if (!address) { + return null; + } + + let source: unknown; + + try { + source = keyring.getPair(address).meta.source; + } catch { + return null; + } + + if (source !== 'quip') { + return null; + } + + const quipSigner = (globalThis as unknown as { quipSigner?: QuipSignerUiApi }).quipSigner; + + if (!quipSigner) { + return 'Quip signing is unavailable. Enable the development signer or connect a Quip signer.'; + } + + return quipSigner.canSign(address) + ? null + : 'This is a view-only Quip account. Its signing key is not available.'; +} + function unlockAccount ({ isUnlockCached, signAddress, signPassword }: AddressProxy): string | null { let publicKey; @@ -215,9 +247,15 @@ async function extractParams (api: ApiPromise, address: string, options: Partial throw new Error(`Unable to find injected source for ${address}`); } + const unavailable = quipSigningError(address); + + if (unavailable) { + throw new Error(unavailable); + } + const injected = await web3FromSource(source); - assert(injected, `Unable to find a signer for ${address}`); + assert(injected?.signer, `Injected signer "${source}" is unavailable for ${address}`); return ['signing', address, { ...options, signer: injected.signer }, false]; } @@ -256,7 +294,7 @@ function TxSigned ({ className, currentItem, isQueueSubmit, queueSize, requestAd useEffect((): void => { setFlags(tryExtract(senderInfo.signAddress)); - setPasswordError(null); + setPasswordError(quipSigningError(senderInfo.signAddress)); }, [senderInfo]); // when we are sending the hash only, get the wrapped call for display (proxies if required) @@ -420,6 +458,7 @@ function TxSigned ({ className, currentItem, isQueueSubmit, queueSize, requestAd }, [flags.isQr, flags.isLocal, isSubmit, t]); const isAutoCapable = senderInfo.signAddress && (queueSize > 1) && isSubmit && !(flags.isHardware || flags.isMultisig || flags.isProxied || flags.isQr || flags.isUnlockable) && !isRenderError; + const isQuipSigningUnavailable = !!quipSigningError(senderInfo.signAddress); if (!isBusy && isAutoCapable && initialIsQueueSubmit) { setBusy(true); @@ -508,7 +547,7 @@ function TxSigned ({ className, currentItem, isQueueSubmit, queueSize, requestAd : 'sign-in-alt' } isBusy={isBusy} - isDisabled={!senderInfo.signAddress || isRenderError} + isDisabled={!senderInfo.signAddress || isRenderError || isQuipSigningUnavailable} label={signLabel} onClick={_doStart} tabIndex={2} diff --git a/scripts/quipSigning.mjs b/scripts/quipSigning.mjs new file mode 100644 index 000000000000..8a7aea2bf791 --- /dev/null +++ b/scripts/quipSigning.mjs @@ -0,0 +1,7 @@ +// Copyright 2017-2026 @polkadot/apps authors & contributors +// SPDX-License-Identifier: Apache-2.0 + +// Runs the canonical signer integration from the protocol repository. Keeping +// this Apps entry point avoids duplicating protocol assertions or dependency +// resolution between the two workspaces. +await import('../../quip-protocol-rs/js/quip-signer/test/local-node.mjs'); From c26832ababd2d3c384c4ccd566dd047235233978 Mon Sep 17 00:00:00 2001 From: Roman Useinov Date: Tue, 28 Jul 2026 16:03:17 +0200 Subject: [PATCH 2/3] fix path to use submodule --- scripts/quipSigning.mjs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/quipSigning.mjs b/scripts/quipSigning.mjs index 8a7aea2bf791..ef6a05919726 100644 --- a/scripts/quipSigning.mjs +++ b/scripts/quipSigning.mjs @@ -4,4 +4,4 @@ // Runs the canonical signer integration from the protocol repository. Keeping // this Apps entry point avoids duplicating protocol assertions or dependency // resolution between the two workspaces. -await import('../../quip-protocol-rs/js/quip-signer/test/local-node.mjs'); +await import('../quip-protocol-rs/js/quip-signer/test/local-node.mjs'); From 04ac96c60218254b7158f02b6a1546f3e6aa67f2 Mon Sep 17 00:00:00 2001 From: Roman Useinov Date: Sat, 8 Aug 2026 15:41:19 +0200 Subject: [PATCH 3/3] bump submodule --- quip-protocol-rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/quip-protocol-rs b/quip-protocol-rs index ed7f83a5fa7c..ad1321f37627 160000 --- a/quip-protocol-rs +++ b/quip-protocol-rs @@ -1 +1 @@ -Subproject commit ed7f83a5fa7c2330424f751fadb0f20564215935 +Subproject commit ad1321f3762707852713e8f3fecc1b3973398099