From 5f1d355bc3652598ba5a01db2e71dad339754623 Mon Sep 17 00:00:00 2001 From: Roman Useinov Date: Tue, 28 Jul 2026 15:21:41 +0200 Subject: [PATCH 1/5] 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/5] 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/5] 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 From 29c95bb1e37fe25d628a362e68392c3417ca0a14 Mon Sep 17 00:00:00 2001 From: Roman Useinov Date: Wed, 12 Aug 2026 15:04:15 +0200 Subject: [PATCH 4/5] submodule rename --- .gitmodules | 6 +++--- Makefile | 6 +++--- packages/apps/src/initQuipSigner.ts | 4 ++-- quip-protocol-rs => quip-validator | 0 scripts/quipSigning.mjs | 2 +- 5 files changed, 9 insertions(+), 9 deletions(-) rename quip-protocol-rs => quip-validator (100%) diff --git a/.gitmodules b/.gitmodules index 7f873d0aa863..98d99f656226 100644 --- a/.gitmodules +++ b/.gitmodules @@ -1,3 +1,3 @@ -[submodule "quip-protocol-rs"] - path = quip-protocol-rs - url = git@gitlab.com:quip.network/quip-protocol-rs.git +[submodule "quip-validator"] + path = quip-validator + url = git@gitlab.com:quip.network/quip-validator.git diff --git a/Makefile b/Makefile index eaa0bc522f8c..a9e594da1939 100644 --- a/Makefile +++ b/Makefile @@ -1,7 +1,7 @@ # Quip hybrid-signature integration for the polkadot-js apps fork. # # The Quip transaction signer (sr25519 + ML-DSA-44 hybrid) lives in the -# `quip-protocol-rs` git submodule, pinned to a specific commit. Its browser +# `quip-validator` git submodule, pinned to a specific commit. Its browser # WASM is a generated, git-ignored artifact, so it must be built locally before # the dev signer (packages/apps/src/initQuipSigner.ts) can load it. # @@ -12,7 +12,7 @@ # # Requires `wasm-pack` (cargo install wasm-pack) and the Rust toolchain. -QUIP_SUBMODULE := quip-protocol-rs +QUIP_SUBMODULE := quip-validator WASM_OUT := $(QUIP_SUBMODULE)/js/quip-transaction-crypto-wasm/quip_transaction_crypto_wasm_bg.wasm .PHONY: all quip-signer quip-submodule start @@ -26,7 +26,7 @@ quip-submodule: # Build the git-ignored hybrid-signer WASM inside the submodule. The submodule's # own `wasm-signer` target runs wasm-pack and writes the artifacts into -# quip-protocol-rs/js/quip-transaction-crypto-wasm/, which is exactly where +# quip-validator/js/quip-transaction-crypto-wasm/, which is exactly where # initQuipSigner.ts imports them from. Always rebuilds. quip-signer: quip-submodule $(MAKE) -C $(QUIP_SUBMODULE) wasm-signer diff --git a/packages/apps/src/initQuipSigner.ts b/packages/apps/src/initQuipSigner.ts index 1c6fe1b963f9..2fa1dc29b07f 100644 --- a/packages/apps/src/initQuipSigner.ts +++ b/packages/apps/src/initQuipSigner.ts @@ -94,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-validator/js/quip-signer/src/index.js'), + import('../../../../quip-validator/js/quip-transaction-crypto-wasm/quip_transaction_crypto_wasm.js') ]); await wasmModule.default(); diff --git a/quip-protocol-rs b/quip-validator similarity index 100% rename from quip-protocol-rs rename to quip-validator diff --git a/scripts/quipSigning.mjs b/scripts/quipSigning.mjs index ef6a05919726..4bb50e4c9505 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-validator/js/quip-signer/test/local-node.mjs'); From cc61aa35d127053e207b2387cd03052f924099c0 Mon Sep 17 00:00:00 2001 From: Roman Useinov Date: Wed, 12 Aug 2026 15:16:53 +0200 Subject: [PATCH 5/5] fix: report pre-signing failures on the queue item wrapTx/extractParams run before any queueSetTxStatus call in _onSend and _onSign, so a failure there (e.g. a view-only Quip account with an unavailable signing key) left the queue item pending and stalled auto-submitting queues. Catch, mark the item as error with the underlying message, and rethrow so the modal error handler still fires. --- packages/react-signer/src/TxSigned.tsx | 41 +++++++++++++++++++++----- 1 file changed, 33 insertions(+), 8 deletions(-) diff --git a/packages/react-signer/src/TxSigned.tsx b/packages/react-signer/src/TxSigned.tsx index 83920bc73874..caba534c0c7b 100644 --- a/packages/react-signer/src/TxSigned.tsx +++ b/packages/react-signer/src/TxSigned.tsx @@ -380,10 +380,24 @@ function TxSigned ({ className, currentItem, isQueueSubmit, queueSize, requestAd const _onSend = useCallback( async (queueSetTxStatus: QueueTxMessageSetStatus, currentItem: QueueTx, senderInfo: AddressProxy): Promise => { if (senderInfo.signAddress) { - const [tx, [status, pairOrAddress, options, isMockSign]] = await Promise.all([ - wrapTx(api, currentItem, senderInfo), - extractParams(api, senderInfo.signAddress, { nonce: -1, tip, withSignedTransaction: true, ...signedOptions }, getLedger, setQrState) - ]); + let prepared: [SubmittableExtrinsic<'promise'>, ['qr' | 'signing', string, Partial, boolean]]; + + try { + prepared = await Promise.all([ + wrapTx(api, currentItem, senderInfo), + extractParams(api, senderInfo.signAddress, { nonce: -1, tip, withSignedTransaction: true, ...signedOptions }, getLedger, setQrState) + ]); + } catch (error) { + // wrapTx/extractParams run before any status update — surface their + // failures (e.g. an unavailable signing key) on the queue item + // instead of leaving it pending, then rethrow so the modal's error + // handler still fires. + queueSetTxStatus(currentItem.id, 'error', {}, error as Error); + + throw error; + } + + const [tx, [status, pairOrAddress, options, isMockSign]] = prepared; queueSetTxStatus(currentItem.id, status); @@ -396,10 +410,21 @@ function TxSigned ({ className, currentItem, isQueueSubmit, queueSize, requestAd const _onSign = useCallback( async (queueSetTxStatus: QueueTxMessageSetStatus, currentItem: QueueTx, senderInfo: AddressProxy): Promise => { if (senderInfo.signAddress) { - const [tx, [, pairOrAddress, options, isMockSign]] = await Promise.all([ - wrapTx(api, currentItem, senderInfo), - extractParams(api, senderInfo.signAddress, { ...signedOptions, tip, withSignedTransaction: true }, getLedger, setQrState) - ]); + let prepared: [SubmittableExtrinsic<'promise'>, ['qr' | 'signing', string, Partial, boolean]]; + + try { + prepared = await Promise.all([ + wrapTx(api, currentItem, senderInfo), + extractParams(api, senderInfo.signAddress, { ...signedOptions, tip, withSignedTransaction: true }, getLedger, setQrState) + ]); + } catch (error) { + // See _onSend: report pre-signing failures on the queue item. + queueSetTxStatus(currentItem.id, 'error', {}, error as Error); + + throw error; + } + + const [tx, [, pairOrAddress, options, isMockSign]] = prepared; setSignedTx(await signAsync(queueSetTxStatus, currentItem, tx, pairOrAddress, options, api, isMockSign)); }