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/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..2fa1dc29b07f 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-validator/js/quip-signer/src/index.js'), + import('../../../../quip-validator/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..caba534c0c7b 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) @@ -342,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); @@ -358,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)); } @@ -420,6 +483,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 +572,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/quip-protocol-rs b/quip-protocol-rs deleted file mode 160000 index ed7f83a5fa7c..000000000000 --- a/quip-protocol-rs +++ /dev/null @@ -1 +0,0 @@ -Subproject commit ed7f83a5fa7c2330424f751fadb0f20564215935 diff --git a/quip-validator b/quip-validator new file mode 160000 index 000000000000..ad1321f37627 --- /dev/null +++ b/quip-validator @@ -0,0 +1 @@ +Subproject commit ad1321f3762707852713e8f3fecc1b3973398099 diff --git a/scripts/quipSigning.mjs b/scripts/quipSigning.mjs new file mode 100644 index 000000000000..4bb50e4c9505 --- /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-validator/js/quip-signer/test/local-node.mjs');