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/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
diff --git a/scripts/quipSigning.mjs b/scripts/quipSigning.mjs
new file mode 100644
index 000000000000..ef6a05919726
--- /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');