diff --git a/.gitmodules b/.gitmodules
new file mode 100644
index 000000000000..7f873d0aa863
--- /dev/null
+++ b/.gitmodules
@@ -0,0 +1,3 @@
+[submodule "quip-protocol-rs"]
+ path = quip-protocol-rs
+ url = git@gitlab.com:quip.network/quip-protocol-rs.git
diff --git a/Makefile b/Makefile
new file mode 100644
index 000000000000..eaa0bc522f8c
--- /dev/null
+++ b/Makefile
@@ -0,0 +1,43 @@
+# 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
+# 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.
+#
+# Usage:
+# make quip-signer # init submodule + (re)build the hybrid-signer WASM
+# make start # build WASM if missing, then run the dev server with
+# # the Quip hybrid signer enabled
+#
+# Requires `wasm-pack` (cargo install wasm-pack) and the Rust toolchain.
+
+QUIP_SUBMODULE := quip-protocol-rs
+WASM_OUT := $(QUIP_SUBMODULE)/js/quip-transaction-crypto-wasm/quip_transaction_crypto_wasm_bg.wasm
+
+.PHONY: all quip-signer quip-submodule start
+
+# Default target builds everything needed for hybrid-sig support.
+all: quip-signer
+
+# Check out the submodule at its pinned commit (idempotent).
+quip-submodule:
+ git submodule update --init $(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
+# initQuipSigner.ts imports them from. Always rebuilds.
+quip-signer: quip-submodule
+ $(MAKE) -C $(QUIP_SUBMODULE) wasm-signer
+ @echo "Hybrid signer WASM ready. Enable it in the apps with QUIP_DEV_SIGNER=1 (or ?quipSigner)."
+
+# Build the WASM only when it is missing (so `make start` doesn't recompile the
+# crate every run). Run `make quip-signer` explicitly to force a rebuild.
+$(WASM_OUT):
+ $(MAKE) quip-signer
+
+# Start the apps dev server (webpack-serve on :3000) with the Quip hybrid signer
+# injected. Builds the signer WASM first if it isn't present yet.
+start: $(WASM_OUT)
+ QUIP_DEV_SIGNER=1 yarn start
diff --git a/docker/Dockerfile b/docker/Dockerfile
index 7dc388724c39..05ebc67fee0c 100644
--- a/docker/Dockerfile
+++ b/docker/Dockerfile
@@ -14,6 +14,14 @@ RUN npm install yarn -g
WORKDIR /apps
COPY . .
+# Quip hybrid dev signer, baked into the static bundle by default. webpack's
+# DefinePlugin reads this at build time (the same `process.env.QUIP_DEV_SIGNER`
+# initQuipSigner.ts checks), so it must be set before `yarn build:www` — exactly
+# like `QUIP_DEV_SIGNER=1 yarn start` locally. Override with
+# `--build-arg QUIP_DEV_SIGNER=` to build an image without the signer.
+ARG QUIP_DEV_SIGNER=1
+ENV QUIP_DEV_SIGNER=$QUIP_DEV_SIGNER
+
RUN yarn && NODE_ENV=production yarn build:www
# ===========================================================
diff --git a/packages/apps/src/index.tsx b/packages/apps/src/index.tsx
index dac546e4bf87..82fb098f5bfc 100644
--- a/packages/apps/src/index.tsx
+++ b/packages/apps/src/index.tsx
@@ -11,6 +11,7 @@ import '@polkadot/api-augment/substrate';
import React from 'react';
import { createRoot } from 'react-dom/client';
+import { initQuipSigner } from './initQuipSigner.js';
import Root from './Root.js';
const rootId = 'root';
@@ -20,6 +21,12 @@ if (!rootElement) {
throw new Error(`Unable to find element with id '${rootId}'`);
}
-createRoot(rootElement).render(
-
-);
+void initQuipSigner()
+ .catch((error): void => {
+ console.error('Quip dev signer initialization failed', error);
+ })
+ .finally((): void => {
+ createRoot(rootElement).render(
+
+ );
+ });
diff --git a/packages/apps/src/initQuipSigner.ts b/packages/apps/src/initQuipSigner.ts
new file mode 100644
index 000000000000..877693ef6cb8
--- /dev/null
+++ b/packages/apps/src/initQuipSigner.ts
@@ -0,0 +1,140 @@
+// Copyright 2017-2026 @polkadot/apps authors & contributors
+// SPDX-License-Identifier: Apache-2.0
+
+const ENABLED_VALUES = new Set(['1', 'true', 'yes', 'on']);
+const STORAGE_KEY = 'quip:devSigner';
+
+const DEV_SEEDS = [
+ {
+ name: 'Quip Alice',
+ seedHex: '0xe5be9a5092b81bca64be81d212e7f2f9eba183bb7a90954f7b76361f6edb5c0a'
+ },
+ {
+ name: 'Quip Bob',
+ seedHex: '0x398f0c28f98885e046333d4a41c19cee4c37368a9832c6502f6cfd182e2aef89'
+ },
+ {
+ name: 'Quip Alice Stash',
+ seedHex: '0x3c881bc4d45926680c64a7f9315eeda3dd287f8d598f3653d7c107799c5422b3'
+ }
+];
+
+interface QuipDevProvider {
+ importMnemonic: (
+ name: string,
+ mnemonic: string,
+ genesisHash?: string | null
+ ) => Promise<{ address: string }>;
+}
+
+/**
+ * Cross-package handle published on `window` so the page-accounts UI can import
+ * Quip accounts without `page-accounts` importing back into the `apps` package
+ * (which would be a circular dependency).
+ */
+export interface QuipSignerUiApi {
+ importMnemonic: (name: string, mnemonic: string) => Promise;
+}
+
+declare global {
+ // eslint-disable-next-line no-var
+ var quipSigner: QuipSignerUiApi | undefined;
+}
+
+let isInjected = false;
+let quipProvider: QuipDevProvider | null = null;
+
+function isEnabledValue (value: string | null | undefined): boolean {
+ return !!value && ENABLED_VALUES.has(value.toLowerCase());
+}
+
+function isEnabledByQuery (): boolean {
+ const params = new URLSearchParams(window.location.search);
+
+ return ['quipSigner', 'quip-signer', 'quipDevSigner'].some((key) => {
+ if (!params.has(key)) {
+ return false;
+ }
+
+ const value = params.get(key);
+
+ return value === '' || value === null || isEnabledValue(value);
+ });
+}
+
+function isEnabledByStorage (): boolean {
+ try {
+ return isEnabledValue(window.localStorage.getItem(STORAGE_KEY));
+ } catch {
+ return false;
+ }
+}
+
+function shouldInjectQuipSigner (): boolean {
+ return isEnabledValue(process.env.QUIP_DEV_SIGNER) ||
+ isEnabledByQuery() ||
+ isEnabledByStorage();
+}
+
+export async function initQuipSigner (): Promise {
+ if (isInjected || !shouldInjectQuipSigner()) {
+ return;
+ }
+
+ 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')
+ ]);
+
+ await wasmModule.default();
+
+ // 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();
+
+ const { accounts, provider } = await signerModule.DevSeedProvider.fromSeeds(wasmModule, DEV_SEEDS);
+
+ signerModule.injectQuip({
+ accounts,
+ signer: new signerModule.QuipSigner(provider)
+ });
+
+ quipProvider = provider;
+ globalThis.quipSigner = { importMnemonic: importQuipMnemonic };
+
+ console.info(`Quip dev signer injected ${accounts.length} account${accounts.length === 1 ? '' : 's'}`);
+}
+
+/** Whether the Quip dev signer has been injected this session. */
+export function isQuipSignerActive (): boolean {
+ return quipProvider !== null;
+}
+
+/**
+ * Imports a Quip account from a BIP39 phrase (or `0x` seed hex), registering
+ * its seed with the injected Quip signer and adding it to the keyring so it
+ * appears in the UI and signs through the injected signer.
+ */
+export async function importQuipMnemonic (name: string, mnemonic: string): Promise {
+ if (!quipProvider) {
+ throw new Error('Quip dev signer is not active');
+ }
+
+ const { address } = await quipProvider.importMnemonic(name, mnemonic, null);
+
+ const { keyring } = await import('@polkadot/ui-keyring');
+
+ // Use the same path the keyring uses for extension accounts: `loadInjected`
+ // sets `meta.isInjected` (so react-signer routes signing through the injected
+ // Quip signer) and updates the live account subject so the UI refreshes
+ // without a reload. `addExternal` would instead set `isExternal`, which
+ // routes to the QR signer. `loadInjected` is not in the public typings.
+ (keyring as unknown as {
+ loadInjected: (address: string, meta: Record, type?: string) => void;
+ }).loadInjected(address, { name, source: 'quip' });
+
+ return address;
+}
diff --git a/packages/apps/webpack.base.cjs b/packages/apps/webpack.base.cjs
index c8cc255a4c9c..20858b4ff54d 100644
--- a/packages/apps/webpack.base.cjs
+++ b/packages/apps/webpack.base.cjs
@@ -135,6 +135,7 @@ function createWebpack (context, mode = 'production') {
new webpack.DefinePlugin({
'process.env': {
NODE_ENV: JSON.stringify(mode),
+ QUIP_DEV_SIGNER: JSON.stringify(process.env.QUIP_DEV_SIGNER),
WS_URL: JSON.stringify(process.env.WS_URL)
}
}),
@@ -149,6 +150,10 @@ function createWebpack (context, mode = 'production') {
'.js': ['.js', '.ts', '.tsx']
},
extensions: ['.js', '.jsx', '.mjs', '.ts', '.tsx'],
+ modules: [
+ 'node_modules',
+ path.resolve(context, '../../node_modules')
+ ],
fallback: {
assert: require.resolve('assert/'),
crypto: require.resolve('crypto-browserify'),
diff --git a/packages/page-accounts/src/Accounts/index.tsx b/packages/page-accounts/src/Accounts/index.tsx
index b01989c8f3e8..23aa2bf894e1 100644
--- a/packages/page-accounts/src/Accounts/index.tsx
+++ b/packages/page-accounts/src/Accounts/index.tsx
@@ -25,6 +25,7 @@ import Local from '../modals/LocalAdd.js';
import Multisig from '../modals/MultisigCreate.js';
import Proxy from '../modals/ProxiedAdd.js';
import Qr from '../modals/Qr.js';
+import QuipMnemonic from '../modals/QuipMnemonic.js';
import { useTranslation } from '../translate.js';
import { SORT_CATEGORY, sortAccounts } from '../util.js';
import Account from './Account.js';
@@ -107,8 +108,10 @@ function Overview ({ className = '', onStatusChange }: Props): React.ReactElemen
const [isProxyOpen, toggleProxy] = useToggle();
const [isLocalOpen, toggleLocal] = useToggle();
const [isQrOpen, toggleQr] = useToggle();
+ const [isQuipOpen, toggleQuip] = useToggle();
const [isExportAll, toggleExportAll] = useToggle();
const [isImportAll, toggleImportAll] = useToggle();
+ const hasQuipSigner = typeof globalThis !== 'undefined' && !!(globalThis as { quipSigner?: unknown }).quipSigner;
const [favorites, toggleFavorite] = useFavorites(STORE_FAVS);
const [balances, setBalances] = useState({ accounts: {} });
const [filterOn, setFilter] = useState('');
@@ -323,6 +326,12 @@ function Overview ({ className = '', onStatusChange }: Props): React.ReactElemen
onStatusChange={onStatusChange}
/>
)}
+ {isQuipOpen && (
+
+ )}
{isExportAll && (
+ {hasQuipSigner && (
+
+ )}
{isLedgerEnabled && (