diff --git a/.gitignore b/.gitignore index c5cd3087ae18..67140fe7c9bf 100644 --- a/.gitignore +++ b/.gitignore @@ -10,6 +10,8 @@ tmp/ .env.production.local .npmrc .yarn/* +!.yarn/patches +!.yarn/patches/** !.yarn/releases !.yarn/plugins .pnp.* 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/.yarn/patches/@polkadot-types-npm-16.5.6-6fe2703ed8.patch b/.yarn/patches/@polkadot-types-npm-16.5.6-6fe2703ed8.patch new file mode 100644 index 000000000000..7a892db22cbb --- /dev/null +++ b/.yarn/patches/@polkadot-types-npm-16.5.6-6fe2703ed8.patch @@ -0,0 +1,136 @@ +diff --git a/cjs/extrinsic/v4/ExtrinsicSignature.js b/cjs/extrinsic/v4/ExtrinsicSignature.js +index d2809f1b7a761c2140ea82c132e22bc883ed3dbb..2d020950a8aa88f15541aa5d89cee849043bbb91 100644 +--- a/cjs/extrinsic/v4/ExtrinsicSignature.js ++++ b/cjs/extrinsic/v4/ExtrinsicSignature.js +@@ -15,12 +15,16 @@ function toAddress(registry, address) { + * A container for the [[Signature]] associated with a specific [[Extrinsic]] + */ + class GenericExtrinsicSignatureV4 extends types_codec_1.Struct { ++ #isSigned; + #signKeys; + constructor(registry, value, { isSigned } = {}) { + const signTypes = registry.getSignedExtensionTypes(); + super(registry, (0, util_1.objectSpread)( + // eslint-disable-next-line sort-keys + { signer: 'Address', signature: 'ExtrinsicSignature' }, signTypes), GenericExtrinsicSignatureV4.decodeExtrinsicSignature(value, isSigned)); ++ this.#isSigned = isSigned === undefined ++ ? !this.signature.isEmpty ++ : isSigned; + this.#signKeys = Object.keys(signTypes); + (0, util_1.objectProperties)(this, this.#signKeys, (k) => this.get(k)); + } +@@ -48,7 +52,7 @@ class GenericExtrinsicSignatureV4 extends types_codec_1.Struct { + * @description `true` if the signature is valid + */ + get isSigned() { +- return !this.signature.isEmpty; ++ return this.#isSigned; + } + /** + * @description The [[ExtrinsicEra]] (mortal or immortal) this signature applies to +@@ -117,6 +121,7 @@ class GenericExtrinsicSignatureV4 extends types_codec_1.Struct { + // additional fields (exposed in struct itself) + this.set('signer', signer); + this.set('signature', signature); ++ this.#isSigned = true; + return this; + } + /** +diff --git a/cjs/extrinsic/v5/ExtrinsicSignature.js b/cjs/extrinsic/v5/ExtrinsicSignature.js +index 10e370372d98a496aa8af1584d89c540b8db5ddf..2db1afa1ecfeabcbd3e419c27ad0114755b5217a 100644 +--- a/cjs/extrinsic/v5/ExtrinsicSignature.js ++++ b/cjs/extrinsic/v5/ExtrinsicSignature.js +@@ -11,12 +11,16 @@ const ExtrinsicPayload_js_1 = require("./ExtrinsicPayload.js"); + * A container for the [[Signature]] associated with a specific [[Extrinsic]] + */ + class GenericExtrinsicSignatureV5 extends types_codec_1.Struct { ++ #isSigned; + #signKeys; + constructor(registry, value, { isSigned } = {}) { + const signTypes = registry.getSignedExtensionTypes(); + super(registry, (0, util_1.objectSpread)( + // eslint-disable-next-line sort-keys + { signer: 'Address', signature: 'ExtrinsicSignature', transactionExtensionVersion: 'u8' }, signTypes), GenericExtrinsicSignatureV5.decodeExtrinsicSignature(value, isSigned)); ++ this.#isSigned = isSigned === undefined ++ ? !this.signature.isEmpty ++ : isSigned; + this.#signKeys = Object.keys(signTypes); + (0, util_1.objectProperties)(this, this.#signKeys, (k) => this.get(k)); + } +@@ -44,7 +48,7 @@ class GenericExtrinsicSignatureV5 extends types_codec_1.Struct { + * @description `true` if the signature is valid + */ + get isSigned() { +- return !this.signature.isEmpty; ++ return this.#isSigned; + } + /** + * @description The [[ExtrinsicEra]] (mortal or immortal) this signature applies to +diff --git a/extrinsic/v4/ExtrinsicSignature.js b/extrinsic/v4/ExtrinsicSignature.js +index ba67bdb4e530d0df4efce7615ef70f50026248e9..9fa3bf9e02dfe49e147e496b5a3d50a8ac81feec 100644 +--- a/extrinsic/v4/ExtrinsicSignature.js ++++ b/extrinsic/v4/ExtrinsicSignature.js +@@ -12,12 +12,16 @@ function toAddress(registry, address) { + * A container for the [[Signature]] associated with a specific [[Extrinsic]] + */ + export class GenericExtrinsicSignatureV4 extends Struct { ++ #isSigned; + #signKeys; + constructor(registry, value, { isSigned } = {}) { + const signTypes = registry.getSignedExtensionTypes(); + super(registry, objectSpread( + // eslint-disable-next-line sort-keys + { signer: 'Address', signature: 'ExtrinsicSignature' }, signTypes), GenericExtrinsicSignatureV4.decodeExtrinsicSignature(value, isSigned)); ++ this.#isSigned = isSigned === undefined ++ ? !this.signature.isEmpty ++ : isSigned; + this.#signKeys = Object.keys(signTypes); + objectProperties(this, this.#signKeys, (k) => this.get(k)); + } +@@ -45,7 +49,7 @@ export class GenericExtrinsicSignatureV4 extends Struct { + * @description `true` if the signature is valid + */ + get isSigned() { +- return !this.signature.isEmpty; ++ return this.#isSigned; + } + /** + * @description The [[ExtrinsicEra]] (mortal or immortal) this signature applies to +@@ -114,6 +118,7 @@ export class GenericExtrinsicSignatureV4 extends Struct { + // additional fields (exposed in struct itself) + this.set('signer', signer); + this.set('signature', signature); ++ this.#isSigned = true; + return this; + } + /** +diff --git a/extrinsic/v5/ExtrinsicSignature.js b/extrinsic/v5/ExtrinsicSignature.js +index 845a2f828b837f7d2340daaad6238b9ed12164a3..e0af841efab043f809835cf86b8e126c567ceffc 100644 +--- a/extrinsic/v5/ExtrinsicSignature.js ++++ b/extrinsic/v5/ExtrinsicSignature.js +@@ -8,12 +8,16 @@ import { GenericExtrinsicPayloadV5 } from './ExtrinsicPayload.js'; + * A container for the [[Signature]] associated with a specific [[Extrinsic]] + */ + export class GenericExtrinsicSignatureV5 extends Struct { ++ #isSigned; + #signKeys; + constructor(registry, value, { isSigned } = {}) { + const signTypes = registry.getSignedExtensionTypes(); + super(registry, objectSpread( + // eslint-disable-next-line sort-keys + { signer: 'Address', signature: 'ExtrinsicSignature', transactionExtensionVersion: 'u8' }, signTypes), GenericExtrinsicSignatureV5.decodeExtrinsicSignature(value, isSigned)); ++ this.#isSigned = isSigned === undefined ++ ? !this.signature.isEmpty ++ : isSigned; + this.#signKeys = Object.keys(signTypes); + objectProperties(this, this.#signKeys, (k) => this.get(k)); + } +@@ -41,7 +45,7 @@ export class GenericExtrinsicSignatureV5 extends Struct { + * @description `true` if the signature is valid + */ + get isSigned() { +- return !this.signature.isEmpty; ++ return this.#isSigned; + } + /** + * @description The [[ExtrinsicEra]] (mortal or immortal) this signature applies to 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/README.md b/README.md index 0362f2ba5b2a..2a32b86c36f7 100644 --- a/README.md +++ b/README.md @@ -29,6 +29,21 @@ To get started - 5. Ready! Now you can launch the UI (assuming you have a local Polkadot Node running), via `yarn run start` 6. Access the UI via [http://localhost:3000](http://localhost:3000) +### Quip extrinsic decoding + +This fork carries a Yarn patch for `@polkadot/types` so Apps can decode Quip +blocks that contain V5 bare inherents and V4 signed transactions. Quip uses a +custom hybrid transaction signature envelope instead of the standard +`MultiSignature`. The upstream decoder previously inferred signed-ness from +whether the decoded signature object looked empty; that heuristic breaks for a +struct-like custom signature because a default struct can look non-empty even +when the extrinsic wire byte marks the item as bare/unsigned. + +The patch makes the decoder trust the explicit `isSigned` flag derived from the +extrinsic version/type byte. This preserves compatibility with valid SDK-format +blocks such as `0x05` bare V5 inherents followed by `0x84` signed V4 +transactions. + ## Docker 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/package.json b/package.json index 3a20515868db..02adbeb7e6a3 100644 --- a/package.json +++ b/package.json @@ -106,7 +106,7 @@ "@polkadot/rpc-core": "^16.5.6", "@polkadot/rpc-provider": "^16.5.6", "@polkadot/typegen": "^16.5.6", - "@polkadot/types": "^16.5.6", + "@polkadot/types": "patch:@polkadot/types@npm%3A16.5.6#~/.yarn/patches/@polkadot-types-npm-16.5.6-6fe2703ed8.patch", "@polkadot/types-augment": "^16.5.6", "@polkadot/types-codec": "^16.5.6", "@polkadot/types-create": "^16.5.6", 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 && ( +