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');