Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitmodules
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
[submodule "quip-protocol-rs"]
path = quip-protocol-rs
url = git@gitlab.com:quip.network/quip-protocol-rs.git
43 changes: 43 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
@@ -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
8 changes: 8 additions & 0 deletions docker/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -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

# ===========================================================
Expand Down
13 changes: 10 additions & 3 deletions packages/apps/src/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -20,6 +21,12 @@ if (!rootElement) {
throw new Error(`Unable to find element with id '${rootId}'`);
}

createRoot(rootElement).render(
<Root isElectron={false} />
);
void initQuipSigner()
.catch((error): void => {
console.error('Quip dev signer initialization failed', error);
})
.finally((): void => {
createRoot(rootElement).render(
<Root isElectron={false} />
);
});
140 changes: 140 additions & 0 deletions packages/apps/src/initQuipSigner.ts
Original file line number Diff line number Diff line change
@@ -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<string>;
}

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<void> {
if (isInjected || !shouldInjectQuipSigner()) {
return;
}

isInjected = true;

@augmentcode augmentcode Bot Jun 18, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

packages/apps/src/initQuipSigner.ts:84 — isInjected is set to true before the async dynamic imports/wasm init. If any of those steps throw, the session will be permanently marked as injected and later calls to initQuipSigner() won’t retry even though nothing was actually injected.

Severity: medium

Fix This in Augment

🤖 Was this useful? React with 👍 or 👎, or 🚀 if it prevented an incident/outage.


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<string> {
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<string, unknown>, type?: string) => void;
}).loadInjected(address, { name, source: 'quip' });

return address;
}
5 changes: 5 additions & 0 deletions packages/apps/webpack.base.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}),
Expand All @@ -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'),
Expand Down
16 changes: 16 additions & 0 deletions packages/page-accounts/src/Accounts/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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<Balances>({ accounts: {} });
const [filterOn, setFilter] = useState<string>('');
Expand Down Expand Up @@ -323,6 +326,12 @@ function Overview ({ className = '', onStatusChange }: Props): React.ReactElemen
onStatusChange={onStatusChange}
/>
)}
{isQuipOpen && (
<QuipMnemonic
onClose={toggleQuip}
onStatusChange={onStatusChange}
/>
)}
{isExportAll && (
<ExportAll
accountsByGroup={grouped}
Expand Down Expand Up @@ -394,6 +403,13 @@ function Overview ({ className = '', onStatusChange }: Props): React.ReactElemen
label={t('From Qr')}
onClick={toggleQr}
/>
{hasQuipSigner && (
<Button
icon='shield-halved'
label={t('From Quip mnemonic')}
onClick={toggleQuip}
/>
)}
{isLedgerEnabled && (
<Button
icon='project-diagram'
Expand Down
Loading