feat: sign and decode in the extension itself via @quantus/codec
Tier 1 proved the crypto in a script. This puts the same path inside the
extension, which is what tier 2 needs: the background builds its signing payload
from the runtime's own metadata, and the approval screen decodes the call the
same way.
- RequestExtrinsicSign takes a Runtime instead of a polkadot-js Registry and
builds the payload with it. `ExtrinsicPayload.sign(pair)` could not survive
either half of this: @polkadot/types cannot describe this chain (fixed arrays
capped at 2048 bytes, ML-DSA signatures are 5261 and 7219), and it has
nowhere to put a FIPS 204 context, which an ML-DSA pair will not sign
without.
- metadataExpand builds a Runtime rather than calling registry.setMetadata,
which would throw on this chain's metadata. Chain.registry survives carrying
chain properties only.
- Extrinsic.tsx decodes the call through the runtime. Without this the approval
screen falls back to raw hex for every transaction — the failure mode where a
user approves bytes nobody read to them.
- A chain whose metadata the extension does not have is one it now **refuses**
to sign an extrinsic for, rather than guessing at the format. The refusal
rejects the dapp's promise as well as the popup's, so a page is told rather
than left waiting.
Raw-bytes signing is unchanged and still needs no metadata: there is no payload
to build and no extension to read.
The dapp-supplied `era` arrives already SCALE-encoded, and is round-tripped
against the runtime's own Era type rather than appended on trust — @quantus/codec
0.2.0 adds that, and rejects anything that does not come back identical.
Tests: upstream's `custom user extension` block was five variations on "does this
agree with @polkadot/api", each computing an expected signature with
registry.createType('ExtrinsicPayload').sign(pair). That comparison is no longer
meaningful, and the `userExtensions` mechanism it exercised — a dapp declaring in
JavaScript what an unrecognised signed extension contributes — is gone, which is
the point: the runtime declares its extensions and the wallet reads them, so
there is nothing for a dapp to assert and no way for it to be believed. Replaced
with tests for what the fork actually guarantees, against real Heisenberg
metadata committed as a fixture.
The harness now loads the keyring with type 'dilithium65' as background.ts does;
it had been making sr25519 accounts and testing the one keypair type this fork
does not target. The derivation specs name ed25519 explicitly, which is what they
were silently getting, and gain a test that an ML-DSA parent cannot derive.
Verified end to end after the rewire: included at Heisenberg block 1050581,
nonce 2762 -> 2763. 79 tests pass, lint clean, extension builds.
Refs #7, quantus/wasm#3
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012uDUodEcRbBwNRi3UCmw8f
This commit is contained in:
@@ -34,7 +34,7 @@
|
||||
"@polkadot/ui-settings": "^3.16.7",
|
||||
"@polkadot/util": "^14.0.3",
|
||||
"@polkadot/util-crypto": "^14.0.3",
|
||||
"@quantus/codec": "^0.1.0",
|
||||
"@quantus/codec": "^0.2.0",
|
||||
"@quantus/crypto": "^0.1.1",
|
||||
"eventemitter3": "^5.0.1",
|
||||
"rxjs": "^7.8.1",
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
// Copyright 2019-2026 @polkadot/extension-base authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import type { Runtime } from '@quantus/codec';
|
||||
import type { KeyringPair } from '@polkadot/keyring/types';
|
||||
import type { TypeRegistry } from '@polkadot/types';
|
||||
import type { SignerPayloadRaw } from '@polkadot/types/types';
|
||||
import type { HexString } from '@polkadot/util/types';
|
||||
import type { RequestSignBytes } from './types.js';
|
||||
@@ -37,7 +37,7 @@ export default class RequestBytesSign implements RequestSignBytes {
|
||||
this.payload = payload;
|
||||
}
|
||||
|
||||
sign (_registry: TypeRegistry, pair: KeyringPair): { signature: HexString } {
|
||||
sign (_runtime: Runtime | null, pair: KeyringPair): { signature: HexString } {
|
||||
// For an ML-DSA pair this returns `signature ‖ publicKey`, which is not a
|
||||
// convenience: the account id is a one-way Poseidon2 hash of the public key,
|
||||
// so a verifier holding only an address cannot recover the key to check
|
||||
|
||||
@@ -3,54 +3,68 @@
|
||||
|
||||
/// <reference types="@polkadot/dev-test/globals.d.ts" />
|
||||
|
||||
import type { Runtime } from '@quantus/codec';
|
||||
import type { SignerPayloadJSON, SignerPayloadRaw } from '@polkadot/types/types';
|
||||
import type { HexString } from '@polkadot/util/types';
|
||||
|
||||
import { contextForSpec, Scheme, sizes } from '@quantus/crypto';
|
||||
|
||||
import { Keyring } from '@polkadot/keyring';
|
||||
import { TypeRegistry } from '@polkadot/types';
|
||||
import { hexToU8a, u8aWrapBytes } from '@polkadot/util';
|
||||
import { hexToU8a, u8aToHex, u8aWrapBytes } from '@polkadot/util';
|
||||
import { dilithiumVerify } from '@polkadot/util-crypto';
|
||||
|
||||
import { HEISENBERG_GENESIS, heisenbergRuntime } from '../test/metadata.js';
|
||||
import RequestBytesSign from './RequestBytesSign.js';
|
||||
import RequestExtrinsicSign from './RequestExtrinsicSign.js';
|
||||
|
||||
const GENESIS = '0xfb5487c0be6ae4ade2d41d16e50465129861636c2b8d61fa94d7a19631626fba';
|
||||
|
||||
// A balances.transfer_keep_alive-shaped call. The bytes do not need to decode
|
||||
// against real metadata for a signature test — what is signed is the payload
|
||||
// envelope, and the registry only has to encode it.
|
||||
const METHOD = '0x0403001cbd2d43530a44705ad088af313e18f80b53ef16b36177cd4b77b846f2a5f07c0700e40b5402';
|
||||
|
||||
function payloadFor (specVersion: `0x${string}`): SignerPayloadJSON {
|
||||
return {
|
||||
address: 'qzq29m9WvneDAeXbtgueKCREtNe1rVVs6bXSMLmjr6shqvwq6',
|
||||
blockHash: GENESIS,
|
||||
blockNumber: '0x00000000',
|
||||
era: '0x0000',
|
||||
genesisHash: GENESIS,
|
||||
method: METHOD,
|
||||
nonce: '0x00000000',
|
||||
signedExtensions: ['CheckSpecVersion', 'CheckTxVersion', 'CheckGenesis', 'CheckMortality', 'CheckNonce', 'CheckWeight', 'ChargeTransactionPayment'],
|
||||
specVersion,
|
||||
tip: '0x00000000000000000000000000000000',
|
||||
transactionVersion: '0x00000001',
|
||||
version: 4
|
||||
};
|
||||
}
|
||||
const ADDRESS = 'qzq29m9WvneDAeXbtgueKCREtNe1rVVs6bXSMLmjr6shqvwq6';
|
||||
|
||||
describe('signing with an ML-DSA pair', (): void => {
|
||||
const registry = new TypeRegistry();
|
||||
const runtime = heisenbergRuntime();
|
||||
const keyring = new Keyring({ ss58Format: 189, type: 'dilithium65' });
|
||||
const pair = keyring.createFromUri('bottom drive obey lake curtain smoke basket hold race lonely fit walk', {}, 'dilithium65');
|
||||
const s65 = sizes(Scheme.MlDsa65);
|
||||
// Encoded by the runtime rather than pasted, so this is a call this chain
|
||||
// actually has rather than bytes that merely look like one.
|
||||
const method = u8aToHex(runtime.encodeCall('Balances', 'transfer_keep_alive', {
|
||||
dest: { Id: u8aToHex(pair.addressRaw) },
|
||||
value: '1000000000'
|
||||
}));
|
||||
|
||||
const signExtrinsic = (specVersion: `0x${string}`) => {
|
||||
const payload = payloadFor(specVersion);
|
||||
function payloadFor (specVersion: HexString): SignerPayloadJSON {
|
||||
return {
|
||||
address: ADDRESS,
|
||||
blockHash: HEISENBERG_GENESIS,
|
||||
blockNumber: '0x00000000',
|
||||
era: '0x00', // immortal
|
||||
genesisHash: HEISENBERG_GENESIS,
|
||||
method,
|
||||
nonce: '0x00000000',
|
||||
signedExtensions: ['CheckSpecVersion', 'CheckTxVersion', 'CheckGenesis', 'CheckMortality', 'CheckNonce', 'CheckWeight', 'ChargeTransactionPayment'],
|
||||
specVersion,
|
||||
tip: '0x00000000000000000000000000000000',
|
||||
transactionVersion: '0x00000006',
|
||||
version: 4
|
||||
};
|
||||
}
|
||||
|
||||
registry.setSignedExtensions(payload.signedExtensions);
|
||||
const signExtrinsic = (specVersion: HexString, on: Runtime | null = runtime) =>
|
||||
hexToU8a(new RequestExtrinsicSign(payloadFor(specVersion)).sign(on, pair).signature);
|
||||
|
||||
return hexToU8a(new RequestExtrinsicSign(payload).sign(registry, pair).signature);
|
||||
// What the runtime will actually verify against, built the same way the signer
|
||||
// builds it. This pins the context and the wire shape; that the *payload* is
|
||||
// right is pinned by quantus/extension#7 tier 1, which submits one.
|
||||
const payloadBytes = (specVersion: number) => {
|
||||
const json = payloadFor(`0x${specVersion.toString(16).padStart(8, '0')}`);
|
||||
|
||||
return runtime.signerPayload(hexToU8a(json.method), runtime.standardExtensions({
|
||||
blockHash: json.blockHash,
|
||||
eraHex: json.era,
|
||||
genesisHash: json.genesisHash,
|
||||
nonce: 0,
|
||||
specVersion,
|
||||
transactionVersion: 6
|
||||
}));
|
||||
};
|
||||
|
||||
// The whole point of the change. ExtrinsicPayload.sign(pair) has nowhere to put
|
||||
@@ -69,16 +83,22 @@ describe('signing with an ML-DSA pair', (): void => {
|
||||
expect(signed.length).toEqual(s65.signatureWithPublicKey + 1);
|
||||
});
|
||||
|
||||
// Without the runtime there is no way to know what this chain's signed
|
||||
// extensions contribute, and signing anyway would mean guessing — a signature
|
||||
// over the wrong bytes, which the chain reports as BadProof and nothing local
|
||||
// can tell from a wrong key. Refusing is the honest answer. See quantus/api#1.
|
||||
it('refuses to sign for a chain whose metadata it does not have', (): void => {
|
||||
expect(
|
||||
() => signExtrinsic('0x00000094', null)
|
||||
).toThrow(/No metadata for chain/);
|
||||
});
|
||||
|
||||
// spec 148 = 0x94. The context is chosen from the payload, and getting it
|
||||
// wrong is the failure that cannot be detected locally — a valid signature the
|
||||
// chain rejects.
|
||||
it('signs spec >= 148 under QUANTUS_EXTRINSIC', (): void => {
|
||||
const signed = signExtrinsic('0x00000094');
|
||||
const payload = payloadFor('0x00000094');
|
||||
|
||||
registry.setSignedExtensions(payload.signedExtensions);
|
||||
|
||||
const encoded = registry.createType('ExtrinsicPayload', payload, { version: payload.version }).toU8a({ method: true });
|
||||
const encoded = payloadBytes(148);
|
||||
|
||||
expect(dilithiumVerify(encoded, signed.subarray(1), pair.addressRaw, 'dilithium65', contextForSpec(148))).toEqual(true);
|
||||
expect(dilithiumVerify(encoded, signed.subarray(1), pair.addressRaw, 'dilithium65', contextForSpec(147))).toEqual(false);
|
||||
@@ -86,11 +106,7 @@ describe('signing with an ML-DSA pair', (): void => {
|
||||
|
||||
it('signs an earlier spec under the empty context', (): void => {
|
||||
const signed = signExtrinsic('0x00000093'); // spec 147
|
||||
const payload = payloadFor('0x00000093');
|
||||
|
||||
registry.setSignedExtensions(payload.signedExtensions);
|
||||
|
||||
const encoded = registry.createType('ExtrinsicPayload', payload, { version: payload.version }).toU8a({ method: true });
|
||||
const encoded = payloadBytes(147);
|
||||
|
||||
expect(dilithiumVerify(encoded, signed.subarray(1), pair.addressRaw, 'dilithium65', contextForSpec(147))).toEqual(true);
|
||||
expect(dilithiumVerify(encoded, signed.subarray(1), pair.addressRaw, 'dilithium65', contextForSpec(148))).toEqual(false);
|
||||
@@ -98,21 +114,22 @@ describe('signing with an ML-DSA pair', (): void => {
|
||||
|
||||
describe('raw bytes', (): void => {
|
||||
const raw: SignerPayloadRaw = {
|
||||
address: 'qzq29m9WvneDAeXbtgueKCREtNe1rVVs6bXSMLmjr6shqvwq6',
|
||||
address: ADDRESS,
|
||||
data: '0x68656c6c6f',
|
||||
type: 'bytes'
|
||||
};
|
||||
|
||||
it('returns sig || pk, with no variant byte', (): void => {
|
||||
const signature = hexToU8a(new RequestBytesSign(raw).sign(registry, pair).signature);
|
||||
const signature = hexToU8a(new RequestBytesSign(raw).sign(null, pair).signature);
|
||||
|
||||
expect(signature.length).toEqual(s65.signatureWithPublicKey);
|
||||
});
|
||||
|
||||
// Without the public key riding along, nothing could verify this: the account
|
||||
// id is a one-way hash, so an address alone cannot produce the key.
|
||||
// Raw bytes need no runtime: there is no payload to build and no extension to
|
||||
// read, which is why this channel still signs for a chain the extension has
|
||||
// never heard of.
|
||||
it('verifies against the signer address', (): void => {
|
||||
const signature = hexToU8a(new RequestBytesSign(raw).sign(registry, pair).signature);
|
||||
const signature = hexToU8a(new RequestBytesSign(raw).sign(null, pair).signature);
|
||||
|
||||
expect(dilithiumVerify(u8aWrapBytes(raw.data), signature, pair.addressRaw, 'dilithium65', new Uint8Array())).toEqual(true);
|
||||
});
|
||||
@@ -120,7 +137,7 @@ describe('signing with an ML-DSA pair', (): void => {
|
||||
// The separation that stops a dapp-login signature being replayed as a
|
||||
// transfer.
|
||||
it('is not valid under the extrinsic context', (): void => {
|
||||
const signature = hexToU8a(new RequestBytesSign(raw).sign(registry, pair).signature);
|
||||
const signature = hexToU8a(new RequestBytesSign(raw).sign(null, pair).signature);
|
||||
|
||||
expect(dilithiumVerify(u8aWrapBytes(raw.data), signature, pair.addressRaw, 'dilithium65', contextForSpec(148))).toEqual(false);
|
||||
});
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
// Copyright 2019-2026 @polkadot/extension-base authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import type { Runtime } from '@quantus/codec';
|
||||
import type { KeyringPair } from '@polkadot/keyring/types';
|
||||
import type { TypeRegistry } from '@polkadot/types';
|
||||
import type { SignerPayloadJSON } from '@polkadot/types/types';
|
||||
import type { HexString } from '@polkadot/util/types';
|
||||
import type { RequestSignExtrinsic } from './types.js';
|
||||
|
||||
import { contextForSpec } from '@quantus/crypto';
|
||||
|
||||
import { hexToNumber, u8aToHex } from '@polkadot/util';
|
||||
import { hexToNumber, hexToU8a, u8aToHex } from '@polkadot/util';
|
||||
import { blake2AsU8a } from '@polkadot/util-crypto';
|
||||
|
||||
/**
|
||||
@@ -27,27 +27,74 @@ export default class RequestExtrinsicSign implements RequestSignExtrinsic {
|
||||
this.payload = payload;
|
||||
}
|
||||
|
||||
sign (registry: TypeRegistry, pair: KeyringPair): { signature: HexString } {
|
||||
const payload = registry.createType('ExtrinsicPayload', this.payload, { version: this.payload.version });
|
||||
/**
|
||||
* Build the payload from the runtime's own description of itself, then sign it.
|
||||
*
|
||||
* Upstream called `registry.createType('ExtrinsicPayload', …).sign(pair)`.
|
||||
* Neither half of that survives here.
|
||||
*
|
||||
* The **encoding** cannot, because `@polkadot/types` cannot describe this
|
||||
* chain: it caps fixed arrays at 2048 bytes where ML-DSA signatures are 5261
|
||||
* and 7219, and — the part that would have gone unnoticed — it writes zero
|
||||
* bytes for any signed extension it does not recognise, logging
|
||||
* `treating them as no-effect`. That guess is right only while every
|
||||
* unrecognised extension happens to be zero-sized. When it stops being right
|
||||
* this would keep signing: valid signatures over a payload missing bytes the
|
||||
* runtime put there, which the chain reports as `BadProof` — the same thing it
|
||||
* reports for a wrong key. See quantus/api#1.
|
||||
*
|
||||
* The **signing** cannot, because `ExtrinsicPayload.sign` has nowhere to put a
|
||||
* FIPS 204 context, and an ML-DSA pair will not sign without one.
|
||||
*
|
||||
* So the payload comes from `@quantus/codec`, which walks the extensions the
|
||||
* runtime declares, in order, and refuses to build anything at all when one
|
||||
* that encodes to something has no value here. A wallet that cannot sign is a
|
||||
* bug report; a wallet that signs the wrong bytes is a support case nobody
|
||||
* diagnoses.
|
||||
*/
|
||||
sign (runtime: Runtime | null, pair: KeyringPair): { signature: HexString } {
|
||||
if (!runtime) {
|
||||
// Refusing is the honest outcome. Without the runtime's own description
|
||||
// there is no way to know what this chain's extensions contribute, and
|
||||
// signing anyway would mean guessing — which is the failure this whole
|
||||
// path exists to remove. The dapp's remedy is to provide its metadata.
|
||||
throw new Error(`No metadata for chain ${this.payload.genesisHash}; it must be provided before this extension can sign for it`);
|
||||
}
|
||||
|
||||
// Deliberately not `payload.sign(pair)`. That helper calls
|
||||
// `pair.sign(encoded, { withType: true })` and has nowhere to put a signing
|
||||
// context — and an ML-DSA pair will not sign without one, because the wrong
|
||||
// context yields a signature that is cryptographically valid, rejected by
|
||||
// the chain, and indistinguishable from a correct one without asking a node.
|
||||
//
|
||||
// So the two lines it would have run are reproduced here, with the context
|
||||
// added. Forking @polkadot/types to thread an option through was the
|
||||
// alternative, and is a much larger commitment for the same result.
|
||||
const encoded = payload.toU8a({ method: true });
|
||||
const toSign = encoded.length > HASH_ABOVE
|
||||
? blake2AsU8a(encoded)
|
||||
: encoded;
|
||||
const { blockHash, era, genesisHash, metadataHash, method, mode, nonce, specVersion, tip, transactionVersion } = this.payload;
|
||||
|
||||
const values = runtime.standardExtensions({
|
||||
blockHash,
|
||||
// `era` arrives already SCALE-encoded, because the dapp encoded it. It is
|
||||
// round-tripped against the runtime's own Era type rather than appended on
|
||||
// trust — see @quantus/codec.
|
||||
eraHex: era,
|
||||
genesisHash,
|
||||
metadataHash: metadataHash ?? null,
|
||||
nonce: hexToNumber(nonce),
|
||||
specVersion: hexToNumber(specVersion),
|
||||
tip,
|
||||
transactionVersion: hexToNumber(transactionVersion)
|
||||
});
|
||||
|
||||
if (mode !== undefined) {
|
||||
values['CheckMetadataHash'] = {
|
||||
...values['CheckMetadataHash'],
|
||||
extra: mode === 1 ? 'Enabled' : 'Disabled'
|
||||
};
|
||||
}
|
||||
|
||||
const payload = runtime.signerPayload(hexToU8a(method), values);
|
||||
const toSign = payload.length > HASH_ABOVE
|
||||
? blake2AsU8a(payload)
|
||||
: payload;
|
||||
|
||||
// Only the caller knows the runtime version, and it arrives in the payload:
|
||||
// spec >= 148 verifies under QUANTUS_EXTRINSIC, earlier specs under the
|
||||
// empty context. Curve pairs ignore the option entirely.
|
||||
const context = contextForSpec(hexToNumber(this.payload.specVersion));
|
||||
// empty context. The wrong one yields a signature that is cryptographically
|
||||
// valid, rejected by the chain, and indistinguishable from a correct one
|
||||
// without asking a node.
|
||||
const context = contextForSpec(hexToNumber(specVersion));
|
||||
|
||||
return {
|
||||
signature: u8aToHex(pair.sign(toSign, { context, withType: true }))
|
||||
|
||||
@@ -7,18 +7,19 @@ import '@polkadot/extension-mocks/chrome';
|
||||
|
||||
import type * as _ from '@polkadot/dev-test/globals.d.ts';
|
||||
import type { ResponseSigning } from '@polkadot/extension-base/background/types';
|
||||
import type { MetadataDef } from '@polkadot/extension-inject/types';
|
||||
import type { KeyringPair } from '@polkadot/keyring/types';
|
||||
import type { ExtDef } from '@polkadot/types/extrinsic/signedExtensions/types';
|
||||
import type { SignerPayloadJSON } from '@polkadot/types/types';
|
||||
import type { HexString } from '@polkadot/util/types';
|
||||
import type { KeypairType } from '@polkadot/util-crypto/types';
|
||||
|
||||
import { TypeRegistry } from '@polkadot/types';
|
||||
import { Scheme, sizes } from '@quantus/crypto';
|
||||
|
||||
import keyring from '@polkadot/ui-keyring';
|
||||
import { stringToHex } from '@polkadot/util';
|
||||
import { hexToU8a, stringToHex } from '@polkadot/util';
|
||||
import { cryptoWaitReady } from '@polkadot/util-crypto';
|
||||
|
||||
import { AccountsStore } from '../../stores/index.js';
|
||||
import { HEISENBERG_GENESIS, heisenbergMetadataDef } from '../../test/metadata.js';
|
||||
import Extension from './Extension.js';
|
||||
import State from './State.js';
|
||||
import Tabs from './Tabs.js';
|
||||
@@ -34,7 +35,9 @@ describe('Extension', () => {
|
||||
try {
|
||||
await cryptoWaitReady();
|
||||
|
||||
keyring.loadAll({ store: new AccountsStore() });
|
||||
// As background.ts does. Without it the harness makes sr25519 accounts and
|
||||
// tests the one keypair type this fork does not target.
|
||||
keyring.loadAll({ store: new AccountsStore(), type: 'dilithium65' });
|
||||
|
||||
state = new State({}, 0);
|
||||
await state.init();
|
||||
@@ -48,27 +51,19 @@ describe('Extension', () => {
|
||||
}
|
||||
}
|
||||
|
||||
// `type` is passed through rather than special-cased for ethereum: the default
|
||||
// is now ML-DSA, so a test that needs a derivable parent has to say so.
|
||||
const createAccount = async (type?: KeypairType): Promise<string> => {
|
||||
await extension.handle('id', 'pri(accounts.create.suri)', type && type === 'ethereum'
|
||||
? {
|
||||
name: 'parent',
|
||||
password,
|
||||
suri,
|
||||
type
|
||||
}
|
||||
: {
|
||||
name: 'parent',
|
||||
password,
|
||||
suri
|
||||
}, {} as chrome.runtime.Port);
|
||||
const { address } = await extension.handle('id', 'pri(seed.validate)', type && type === 'ethereum'
|
||||
? {
|
||||
suri,
|
||||
type
|
||||
}
|
||||
: {
|
||||
suri
|
||||
}, {} as chrome.runtime.Port);
|
||||
await extension.handle('id', 'pri(accounts.create.suri)', {
|
||||
name: 'parent',
|
||||
password,
|
||||
suri,
|
||||
...(type ? { type } : {})
|
||||
}, {} as chrome.runtime.Port);
|
||||
const { address } = await extension.handle('id', 'pri(seed.validate)', {
|
||||
suri,
|
||||
...(type ? { type } : {})
|
||||
}, {} as chrome.runtime.Port);
|
||||
|
||||
return address;
|
||||
};
|
||||
@@ -91,8 +86,24 @@ describe('Extension', () => {
|
||||
describe('account derivation', () => {
|
||||
let address: string;
|
||||
|
||||
// An ed25519 parent, explicitly — which is what upstream's harness got by
|
||||
// default, hence the vectors below. Naming it matters now: the default here
|
||||
// is ML-DSA, as in production, and derivation is a property of the key type.
|
||||
// Quantus derives every account from the mnemonic independently along
|
||||
// m/44'/189189'/<account>'/0'/<scheme>', so an ML-DSA pair holds nothing a
|
||||
// child could come from. See quantus/common#4 and `canDerive`.
|
||||
beforeEach(async () => {
|
||||
address = await createAccount();
|
||||
address = await createAccount('ed25519');
|
||||
});
|
||||
|
||||
it('refuses to derive from an ML-DSA parent', async () => {
|
||||
const mldsa = await createAccount();
|
||||
|
||||
await expect(extension.handle('id', 'pri(derivation.validate)', {
|
||||
parentAddress: mldsa,
|
||||
parentPassword: password,
|
||||
suri: '//path'
|
||||
}, {} as chrome.runtime.Port)).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('pri(derivation.validate) passes for valid suri', async () => {
|
||||
@@ -125,6 +136,8 @@ describe('Extension', () => {
|
||||
});
|
||||
|
||||
it('pri(derivation.create) adds a derived account', async () => {
|
||||
const before = keyring.getAccounts().length;
|
||||
|
||||
await extension.handle('id', 'pri(derivation.create)', {
|
||||
name: 'child',
|
||||
parentAddress: address,
|
||||
@@ -132,7 +145,7 @@ describe('Extension', () => {
|
||||
password,
|
||||
suri: '//path'
|
||||
}, {} as chrome.runtime.Port);
|
||||
expect(keyring.getAccounts()).toHaveLength(2);
|
||||
expect(keyring.getAccounts()).toHaveLength(before + 1);
|
||||
});
|
||||
|
||||
it('pri(derivation.create) saves parent address in meta', async () => {
|
||||
@@ -182,298 +195,93 @@ describe('Extension', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('custom user extension', () => {
|
||||
let address: string, payload: SignerPayloadJSON, pair: KeyringPair;
|
||||
describe('signing an extrinsic', () => {
|
||||
// Upstream's block here was five variations on "does the extension agree with
|
||||
// @polkadot/api", each computing an expected signature with
|
||||
// `registry.createType('ExtrinsicPayload', …).sign(pair)` and comparing.
|
||||
//
|
||||
// None of that survives the fork. @polkadot/types cannot describe this chain
|
||||
// — it caps fixed arrays at 2048 bytes and ML-DSA signatures are 5261 and
|
||||
// 7219 — and `ExtrinsicPayload.sign` has nowhere to put a FIPS 204 context,
|
||||
// which an ML-DSA pair will not sign without. quantus/api#1 has the evidence.
|
||||
//
|
||||
// Those tests were also built on `userExtensions`: a dapp declaring, in
|
||||
// JavaScript, what an unrecognised signed extension contributes to the
|
||||
// payload. That mechanism is gone, and its absence is the point. The runtime
|
||||
// *declares* its extensions and @quantus/codec reads them, so there is
|
||||
// nothing for a dapp to tell the wallet and no way for it to be believed.
|
||||
let address: string, pair: KeyringPair;
|
||||
|
||||
const payloadFor = (genesisHash: string): SignerPayloadJSON => ({
|
||||
address,
|
||||
blockHash: HEISENBERG_GENESIS,
|
||||
blockNumber: '0x00000393',
|
||||
era: '0x00', // immortal
|
||||
genesisHash: genesisHash as HexString,
|
||||
method: '0x020300d43593c715fdd31c61141abd04a99fd6822c8558854ccde39a5684e7a56da27d02286bee',
|
||||
nonce: '0x00000000',
|
||||
signedExtensions: ['CheckSpecVersion', 'CheckTxVersion', 'CheckGenesis', 'CheckMortality', 'CheckNonce', 'CheckWeight', 'ChargeTransactionPayment'],
|
||||
specVersion: '0x00000094',
|
||||
tip: '0x00000000000000000000000000000000',
|
||||
transactionVersion: '0x00000006',
|
||||
version: 4
|
||||
});
|
||||
|
||||
// The newest request, not the oldest: requests from earlier tests in this
|
||||
// file are still queued, and approving one of those silently tests nothing.
|
||||
const approve = () => extension.handle('1615192072290.7', 'pri(signing.approve.password)', {
|
||||
id: state.allSignRequests[state.allSignRequests.length - 1].id,
|
||||
password,
|
||||
savePass: false
|
||||
}, {} as chrome.runtime.Port);
|
||||
|
||||
beforeEach(async () => {
|
||||
address = await createAccount();
|
||||
pair = keyring.getPair(address);
|
||||
pair.decodePkcs8(password);
|
||||
payload = {
|
||||
});
|
||||
|
||||
it('signs an extrinsic for a chain whose metadata it has', async () => {
|
||||
await state.saveMetadata(heisenbergMetadataDef());
|
||||
|
||||
const signing = tabs.handle('1615191860871.5', 'pub(extrinsic.sign)', payloadFor(HEISENBERG_GENESIS), 'http://localhost:3000', {} as chrome.runtime.Port);
|
||||
|
||||
expect(await approve()).toEqual(true);
|
||||
|
||||
const { signature } = await signing as ResponseSigning;
|
||||
const bytes = hexToU8a(signature);
|
||||
|
||||
// Variant byte for the pair's scheme, then sig ‖ pk as a fixed array.
|
||||
expect(bytes.length).toEqual(sizes(Scheme.MlDsa65).signatureWithPublicKey + 1);
|
||||
expect(bytes[0]).toEqual(1);
|
||||
});
|
||||
|
||||
// The refusal that replaces guessing. Without the runtime's own description
|
||||
// there is no way to know what its signed extensions contribute, and a
|
||||
// signature over the wrong bytes comes back from a node as `BadProof` —
|
||||
// indistinguishable from a wrong key, and only ever seen by the user.
|
||||
it('refuses to sign for a chain whose metadata it does not have', async () => {
|
||||
const unknown = '0x242a54b35e1aad38f37b884eddeb71f6f9931b02fac27bf52dfb62ef754e5e62';
|
||||
const signing = tabs.handle('1615191860871.6', 'pub(extrinsic.sign)', payloadFor(unknown), 'http://localhost:3000', {} as chrome.runtime.Port);
|
||||
|
||||
await expect(approve()).rejects.toThrow(/No metadata for chain/);
|
||||
await expect(signing).rejects.toThrow(/No metadata for chain/);
|
||||
});
|
||||
|
||||
// Raw bytes carry no payload to build and no extension to read, so this
|
||||
// channel still works for a chain the extension has never heard of.
|
||||
it('signs raw bytes without any metadata', async () => {
|
||||
const signing = tabs.handle('1615191860871.7', 'pub(bytes.sign)', {
|
||||
address,
|
||||
blockHash: '0xe1b1dda72998846487e4d858909d4f9a6bbd6e338e4588e5d809de16b1317b80',
|
||||
blockNumber: '0x00000393',
|
||||
era: '0x3601',
|
||||
genesisHash: '0x242a54b35e1aad38f37b884eddeb71f6f9931b02fac27bf52dfb62ef754e5e62',
|
||||
method: '0x040105fa8eaf04151687736326c9fea17e25fc5287613693c912909cb226aa4794f26a4882380100',
|
||||
nonce: '0x0000000000000000',
|
||||
signedExtensions: ['CheckSpecVersion', 'CheckTxVersion', 'CheckGenesis', 'CheckMortality', 'CheckNonce', 'CheckWeight', 'ChargeTransactionPayment'],
|
||||
specVersion: '0x00000026',
|
||||
tip: '0x00000000000000000000000000000000',
|
||||
transactionVersion: '0x00000005',
|
||||
version: 4
|
||||
};
|
||||
});
|
||||
data: '0x68656c6c6f',
|
||||
type: 'bytes'
|
||||
}, 'http://localhost:3000', {} as chrome.runtime.Port);
|
||||
|
||||
it('signs with default signed extensions', async () => {
|
||||
const registry = new TypeRegistry();
|
||||
expect(await approve()).toEqual(true);
|
||||
|
||||
registry.setSignedExtensions(payload.signedExtensions);
|
||||
const { signature } = await signing as ResponseSigning;
|
||||
|
||||
const signatureExpected = registry
|
||||
.createType('ExtrinsicPayload', payload, { version: payload.version }).sign(pair);
|
||||
|
||||
// eslint-disable-next-line jest/valid-expect-in-promise
|
||||
tabs.handle('1615191860871.5', 'pub(extrinsic.sign)', payload, 'http://localhost:3000', {} as chrome.runtime.Port)
|
||||
.then((result) => {
|
||||
// eslint-disable-next-line jest/no-conditional-expect
|
||||
expect((result as ResponseSigning)?.signature).toEqual(signatureExpected.signature);
|
||||
}).catch((err) => console.log(err));
|
||||
|
||||
const res = await extension.handle('1615192072290.7', 'pri(signing.approve.password)', {
|
||||
id: state.allSignRequests[0].id,
|
||||
password,
|
||||
savePass: false
|
||||
}, {} as chrome.runtime.Port);
|
||||
|
||||
expect(res).toEqual(true);
|
||||
});
|
||||
|
||||
it('signs with default signed extensions - ethereum', async () => {
|
||||
const ethAddress = await createAccount('ethereum');
|
||||
const ethPair = keyring.getPair(ethAddress);
|
||||
|
||||
ethPair.decodePkcs8(password);
|
||||
const ethPayload: SignerPayloadJSON = {
|
||||
address: ethAddress,
|
||||
blockHash: '0xf9fc354edc3ff49f43d5e2c14e3c609a0c4ba469ed091edf893d672993dc9bc0',
|
||||
blockNumber: '0x00000393',
|
||||
era: '0x3601',
|
||||
genesisHash: '0xf9fc354edc3ff49f43d5e2c14e3c609a0c4ba469ed091edf893d672993dc9bc0',
|
||||
method: '0x03003cd0a705a2dc65e5b1e1205896baa2be8a07c6e0070010a5d4e8',
|
||||
nonce: '0x00000000',
|
||||
signedExtensions: [
|
||||
'CheckSpecVersion',
|
||||
'CheckTxVersion',
|
||||
'CheckGenesis',
|
||||
'CheckMortality',
|
||||
'CheckNonce',
|
||||
'CheckWeight',
|
||||
'ChargeTransactionPayment'
|
||||
],
|
||||
specVersion: '0x000003e9',
|
||||
tip: '0x00000000000000000000000000000000',
|
||||
transactionVersion: '0x00000002',
|
||||
version: 4
|
||||
};
|
||||
const registry = new TypeRegistry();
|
||||
|
||||
registry.setSignedExtensions(payload.signedExtensions);
|
||||
|
||||
const signatureExpected = registry
|
||||
.createType('ExtrinsicPayload', ethPayload, { version: ethPayload.version }).sign(ethPair);
|
||||
|
||||
// eslint-disable-next-line jest/valid-expect-in-promise
|
||||
tabs.handle('1615191860871.5', 'pub(extrinsic.sign)', ethPayload, 'http://localhost:3000', {} as chrome.runtime.Port)
|
||||
.then((result) => {
|
||||
// eslint-disable-next-line jest/no-conditional-expect
|
||||
expect((result as ResponseSigning)?.signature).toEqual(signatureExpected.signature);
|
||||
}).catch((err) => console.log(err));
|
||||
|
||||
const res = await extension.handle('1615192072290.7', 'pri(signing.approve.password)', {
|
||||
id: state.allSignRequests[0].id,
|
||||
password,
|
||||
savePass: false
|
||||
}, {} as chrome.runtime.Port);
|
||||
|
||||
expect(res).toEqual(true);
|
||||
});
|
||||
|
||||
it('signs with user extensions, known types', async () => {
|
||||
const types = {} as unknown as Record<string, string>;
|
||||
|
||||
const userExtensions = {
|
||||
MyUserExtension: {
|
||||
extrinsic: {
|
||||
assetId: 'AssetId'
|
||||
},
|
||||
payload: {}
|
||||
}
|
||||
} as unknown as ExtDef;
|
||||
|
||||
const meta: MetadataDef = {
|
||||
chain: 'Development',
|
||||
color: '#191a2e',
|
||||
genesisHash: '0x242a54b35e1aad38f37b884eddeb71f6f9931b02fac27bf52dfb62ef754e5e62',
|
||||
icon: '',
|
||||
specVersion: 38,
|
||||
ss58Format: 0,
|
||||
tokenDecimals: 12,
|
||||
tokenSymbol: '',
|
||||
types,
|
||||
userExtensions
|
||||
};
|
||||
|
||||
await state.saveMetadata(meta);
|
||||
|
||||
const payload: SignerPayloadJSON = {
|
||||
address,
|
||||
blockHash: '0xe1b1dda72998846487e4d858909d4f9a6bbd6e338e4588e5d809de16b1317b80',
|
||||
blockNumber: '0x00000393',
|
||||
era: '0x3601',
|
||||
genesisHash: '0x242a54b35e1aad38f37b884eddeb71f6f9931b02fac27bf52dfb62ef754e5e62',
|
||||
method: '0x040105fa8eaf04151687736326c9fea17e25fc5287613693c912909cb226aa4794f26a4882380100',
|
||||
nonce: '0x0000000000000000',
|
||||
signedExtensions: ['MyUserExtension'],
|
||||
specVersion: '0x00000026',
|
||||
tip: '0x00000000000000000000000000000000',
|
||||
transactionVersion: '0x00000005',
|
||||
version: 4
|
||||
};
|
||||
|
||||
const registry = new TypeRegistry();
|
||||
|
||||
registry.setSignedExtensions(payload.signedExtensions, userExtensions);
|
||||
registry.register(types);
|
||||
|
||||
const signatureExpected = registry
|
||||
.createType('ExtrinsicPayload', payload, { version: payload.version }).sign(pair);
|
||||
|
||||
// eslint-disable-next-line jest/valid-expect-in-promise
|
||||
tabs.handle('1615191860771.5', 'pub(extrinsic.sign)', payload, 'http://localhost:3000', {} as chrome.runtime.Port)
|
||||
.then((result) => {
|
||||
// eslint-disable-next-line jest/no-conditional-expect
|
||||
expect((result as ResponseSigning)?.signature).toEqual(signatureExpected.signature);
|
||||
}).catch((err) => console.log(err));
|
||||
|
||||
const res = await extension.handle('1615192062290.7', 'pri(signing.approve.password)', {
|
||||
id: state.allSignRequests[0].id,
|
||||
password,
|
||||
savePass: false
|
||||
}, {} as chrome.runtime.Port);
|
||||
|
||||
expect(res).toEqual(true);
|
||||
});
|
||||
|
||||
it('override default signed extension', async () => {
|
||||
const types = {
|
||||
FeeExchangeV1: {
|
||||
assetId: 'Compact<AssetId>',
|
||||
maxPayment: 'Compact<Balance>'
|
||||
},
|
||||
PaymentOptions: {
|
||||
feeExchange: 'FeeExchangeV1',
|
||||
tip: 'Compact<Balance>'
|
||||
}
|
||||
} as unknown as Record<string, string>;
|
||||
|
||||
const userExtensions = {
|
||||
ChargeTransactionPayment: {
|
||||
extrinsic: {
|
||||
transactionPayment: 'PaymentOptions'
|
||||
},
|
||||
payload: {}
|
||||
}
|
||||
} as unknown as ExtDef;
|
||||
|
||||
const meta: MetadataDef = {
|
||||
chain: 'Development',
|
||||
color: '#191a2e',
|
||||
genesisHash: '0x242a54b35e1aad38f37b884eddeb71f6f9931b02fac27bf52dfb62ef754e5e62',
|
||||
icon: '',
|
||||
specVersion: 38,
|
||||
ss58Format: 0,
|
||||
tokenDecimals: 12,
|
||||
tokenSymbol: '',
|
||||
types,
|
||||
userExtensions
|
||||
};
|
||||
|
||||
await state.saveMetadata(meta);
|
||||
|
||||
const registry = new TypeRegistry();
|
||||
|
||||
registry.setSignedExtensions(payload.signedExtensions, userExtensions);
|
||||
registry.register(types);
|
||||
|
||||
const signatureExpected = registry
|
||||
.createType('ExtrinsicPayload', payload, { version: payload.version }).sign(pair);
|
||||
|
||||
// eslint-disable-next-line jest/valid-expect-in-promise
|
||||
tabs.handle('1615191860771.5', 'pub(extrinsic.sign)', payload, 'http://localhost:3000', {} as chrome.runtime.Port)
|
||||
.then((result) => {
|
||||
// eslint-disable-next-line jest/no-conditional-expect
|
||||
expect((result as ResponseSigning)?.signature).toEqual(signatureExpected.signature);
|
||||
}).catch((err) => console.log(err));
|
||||
|
||||
const res = await extension.handle('1615192062290.7', 'pri(signing.approve.password)', {
|
||||
id: state.allSignRequests[0].id,
|
||||
password,
|
||||
savePass: false
|
||||
}, {} as chrome.runtime.Port);
|
||||
|
||||
expect(res).toEqual(true);
|
||||
});
|
||||
|
||||
it('signs with user extensions, additional types', async () => {
|
||||
const types = {
|
||||
myCustomType: {
|
||||
feeExchange: 'Compact<AssetId>',
|
||||
tip: 'Compact<Balance>'
|
||||
}
|
||||
} as unknown as Record<string, string>;
|
||||
|
||||
const userExtensions = {
|
||||
MyUserExtension: {
|
||||
extrinsic: {
|
||||
myCustomType: 'myCustomType'
|
||||
},
|
||||
payload: {}
|
||||
}
|
||||
} as unknown as ExtDef;
|
||||
|
||||
const meta: MetadataDef = {
|
||||
chain: 'Development',
|
||||
color: '#191a2e',
|
||||
genesisHash: '0x242a54b35e1aad38f37b884eddeb71f6f9931b02fac27bf52dfb62ef754e5e62',
|
||||
icon: '',
|
||||
specVersion: 38,
|
||||
ss58Format: 0,
|
||||
tokenDecimals: 12,
|
||||
tokenSymbol: '',
|
||||
types,
|
||||
userExtensions
|
||||
};
|
||||
|
||||
await state.saveMetadata(meta);
|
||||
|
||||
const payload = {
|
||||
address,
|
||||
blockHash: '0xe1b1dda72998846487e4d858909d4f9a6bbd6e338e4588e5d809de16b1317b80',
|
||||
blockNumber: '0x00000393',
|
||||
era: '0x3601',
|
||||
genesisHash: '0x242a54b35e1aad38f37b884eddeb71f6f9931b02fac27bf52dfb62ef754e5e62',
|
||||
method: '0x040105fa8eaf04151687736326c9fea17e25fc5287613693c912909cb226aa4794f26a4882380100',
|
||||
nonce: '0x0000000000000000',
|
||||
signedExtensions: ['MyUserExtension', 'CheckTxVersion', 'CheckGenesis', 'CheckMortality', 'CheckNonce', 'CheckWeight', 'ChargeTransactionPayment'],
|
||||
specVersion: '0x00000026',
|
||||
tip: null,
|
||||
transactionVersion: '0x00000005',
|
||||
version: 4
|
||||
} as unknown as SignerPayloadJSON;
|
||||
|
||||
const registry = new TypeRegistry();
|
||||
|
||||
registry.setSignedExtensions(payload.signedExtensions, userExtensions);
|
||||
registry.register(types);
|
||||
|
||||
const signatureExpected = registry
|
||||
.createType('ExtrinsicPayload', payload, { version: payload.version }).sign(pair);
|
||||
|
||||
// eslint-disable-next-line jest/valid-expect-in-promise
|
||||
tabs.handle('1615191860771.5', 'pub(extrinsic.sign)', payload, 'http://localhost:3000', {} as chrome.runtime.Port)
|
||||
.then((result) => {
|
||||
// eslint-disable-next-line jest/no-conditional-expect
|
||||
expect((result as ResponseSigning)?.signature).toEqual(signatureExpected.signature);
|
||||
}).catch((err) => console.log(err));
|
||||
|
||||
const res = await extension.handle('1615192062290.7', 'pri(signing.approve.password)', {
|
||||
id: state.allSignRequests[0].id,
|
||||
password,
|
||||
savePass: false
|
||||
}, {} as chrome.runtime.Port);
|
||||
|
||||
expect(res).toEqual(true);
|
||||
expect(hexToU8a(signature).length).toEqual(sizes(Scheme.MlDsa65).signatureWithPublicKey);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -482,18 +290,22 @@ describe('Extension', () => {
|
||||
|
||||
beforeEach(async () => {
|
||||
address = await createAccount();
|
||||
// A chain the extension has metadata for: these tests are about how a
|
||||
// request is routed and displayed, and without it signing refuses before
|
||||
// any of that is reached.
|
||||
await state.saveMetadata(heisenbergMetadataDef());
|
||||
payload = {
|
||||
address,
|
||||
blockHash: '0xe1b1dda72998846487e4d858909d4f9a6bbd6e338e4588e5d809de16b1317b80',
|
||||
blockHash: HEISENBERG_GENESIS,
|
||||
blockNumber: '0x00000393',
|
||||
era: '0x3601',
|
||||
genesisHash: '0x242a54b35e1aad38f37b884eddeb71f6f9931b02fac27bf52dfb62ef754e5e62',
|
||||
method: '0x040105fa8eaf04151687736326c9fea17e25fc5287613693c912909cb226aa4794f26a4882380100',
|
||||
nonce: '0x0000000000000000',
|
||||
era: '0x00',
|
||||
genesisHash: HEISENBERG_GENESIS,
|
||||
method: '0x020300d43593c715fdd31c61141abd04a99fd6822c8558854ccde39a5684e7a56da27d02286bee',
|
||||
nonce: '0x00000000',
|
||||
signedExtensions: ['CheckSpecVersion', 'CheckTxVersion', 'CheckGenesis', 'CheckMortality', 'CheckNonce', 'CheckWeight', 'ChargeTransactionPayment'],
|
||||
specVersion: '0x00000026',
|
||||
specVersion: '0x00000094',
|
||||
tip: '0x00000000000000000000000000000000',
|
||||
transactionVersion: '0x00000005',
|
||||
transactionVersion: '0x00000006',
|
||||
version: 4
|
||||
};
|
||||
});
|
||||
|
||||
@@ -3,9 +3,9 @@
|
||||
|
||||
/* global chrome */
|
||||
|
||||
import type { Runtime } from '@quantus/codec';
|
||||
import type { MetadataDef } from '@polkadot/extension-inject/types';
|
||||
import type { KeyringPair, KeyringPair$Json, KeyringPair$Meta } from '@polkadot/keyring/types';
|
||||
import type { Registry } from '@polkadot/types/types';
|
||||
import type { SubjectInfo } from '@polkadot/ui-keyring/observable/types';
|
||||
import type { KeypairType } from '@polkadot/util-crypto/types';
|
||||
import type { AccountJson, AllowedPath, AuthorizeRequest, MessageTypes, MetadataRequest, RequestAccountBatchExport, RequestAccountChangePassword, RequestAccountCreateExternal, RequestAccountCreateSuri, RequestAccountEdit, RequestAccountExport, RequestAccountForget, RequestAccountShow, RequestAccountTie, RequestAccountValidate, RequestActiveTabsUrlUpdate, RequestAuthorizeApprove, RequestBatchRestore, RequestDeriveCreate, RequestDeriveValidate, RequestJsonRestore, RequestMetadataApprove, RequestMetadataReject, RequestSeedCreate, RequestSeedValidate, RequestSigningApprovePassword, RequestSigningApproveSignature, RequestSigningCancel, RequestSigningIsLocked, RequestTypes, RequestUpdateAuthorizedAccounts, ResponseAccountExport, ResponseAccountsExport, ResponseAuthorizeList, ResponseDeriveValidate, ResponseJsonGetAccountInfo, ResponseSeedCreate, ResponseSeedValidate, ResponseSigningIsLocked, ResponseType, SigningRequest } from '../types.js';
|
||||
@@ -14,7 +14,6 @@ import type State from './State.js';
|
||||
|
||||
import { ALLOWED_PATH, PASSWORD_EXPIRY_MS } from '@polkadot/extension-base/defaults';
|
||||
import { metadataExpand } from '@polkadot/extension-chains';
|
||||
import { TypeRegistry } from '@polkadot/types';
|
||||
import { keyring } from '@polkadot/ui-keyring';
|
||||
import { accounts as accountsObservable } from '@polkadot/ui-keyring/observable/accounts';
|
||||
import { assert, isHex } from '@polkadot/util';
|
||||
@@ -357,32 +356,38 @@ export default class Extension {
|
||||
pair.decodePkcs8(password);
|
||||
}
|
||||
|
||||
// construct a new registry (avoiding pollution), between requests
|
||||
let registry: Registry;
|
||||
// The runtime's own description of the chain being signed for, from the
|
||||
// metadata a dapp provided. `null` when none is known, which
|
||||
// RequestExtrinsicSign turns into a refusal rather than a guess — without it
|
||||
// there is no way to know what this chain's signed extensions contribute to
|
||||
// the payload, and a signature over the wrong bytes comes back from the node
|
||||
// as `BadProof`, indistinguishable from a wrong key.
|
||||
//
|
||||
// Raw-bytes signing needs none of this and ignores the argument.
|
||||
let runtime: Runtime | null = null;
|
||||
|
||||
if (isExtrinsicRequest(request)) {
|
||||
const payload = request.payload;
|
||||
|
||||
// Get the metadata for the genesisHash
|
||||
const metadata = this.#state.knownMetadata.find(({ genesisHash }) => genesisHash === payload.genesisHash);
|
||||
const metadata = this.#state.knownMetadata.find(({ genesisHash }) => genesisHash === request.payload.genesisHash);
|
||||
|
||||
if (metadata) {
|
||||
// we have metadata, expand it and extract the info/registry
|
||||
const expanded = metadataExpand(metadata, false);
|
||||
|
||||
registry = expanded.registry;
|
||||
registry.setSignedExtensions(payload.signedExtensions, expanded.definition.userExtensions);
|
||||
} else {
|
||||
// we have no metadata, create a new registry
|
||||
registry = new TypeRegistry();
|
||||
registry.setSignedExtensions(payload.signedExtensions);
|
||||
runtime = metadataExpand(metadata, false).runtime;
|
||||
}
|
||||
} else {
|
||||
// for non-payload, just create a registry to use
|
||||
registry = new TypeRegistry();
|
||||
}
|
||||
|
||||
const result = request.sign(registry, pair);
|
||||
let result;
|
||||
|
||||
try {
|
||||
result = request.sign(runtime, pair);
|
||||
} catch (error) {
|
||||
// Tell the dapp, not just the popup. Signing can now refuse outright — a
|
||||
// chain whose metadata this extension does not have is one it will not
|
||||
// guess a payload for — and an unanswered `signPayload` leaves the page
|
||||
// waiting forever with nothing on screen to explain it.
|
||||
pair.lock();
|
||||
reject(error as Error);
|
||||
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (savePass) {
|
||||
// unlike queued.account.address the following
|
||||
|
||||
@@ -3,10 +3,11 @@
|
||||
|
||||
/* eslint-disable no-use-before-define */
|
||||
|
||||
import type { Runtime } from '@quantus/codec';
|
||||
import type { InjectedAccount, InjectedMetadataKnown, MetadataDef, ProviderList, ProviderMeta } from '@polkadot/extension-inject/types';
|
||||
import type { KeyringPair, KeyringPair$Json, KeyringPair$Meta } from '@polkadot/keyring/types';
|
||||
import type { JsonRpcResponse } from '@polkadot/rpc-provider/types';
|
||||
import type { Registry, SignerPayloadJSON, SignerPayloadRaw } from '@polkadot/types/types';
|
||||
import type { SignerPayloadJSON, SignerPayloadRaw } from '@polkadot/types/types';
|
||||
import type { KeyringPairs$Json } from '@polkadot/ui-keyring/types';
|
||||
import type { HexString } from '@polkadot/util/types';
|
||||
import type { KeypairType } from '@polkadot/util-crypto/types';
|
||||
@@ -388,7 +389,7 @@ export type MessageTypesWithSubscriptions = keyof SubscriptionMessageTypes;
|
||||
export type MessageTypesWithNoSubscriptions = Exclude<MessageTypes, keyof SubscriptionMessageTypes>
|
||||
|
||||
interface RequestSignBase {
|
||||
sign (registry: Registry, pair: KeyringPair): { signature: HexString };
|
||||
sign (runtime: Runtime | null, pair: KeyringPair): { signature: HexString };
|
||||
}
|
||||
|
||||
// `channel` is set by the background from the message type the request arrived
|
||||
|
||||
File diff suppressed because one or more lines are too long
52
packages/extension-base/src/test/metadata.ts
Normal file
52
packages/extension-base/src/test/metadata.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
// Copyright 2019-2026 @polkadot/extension-base authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import type { MetadataDef } from '@polkadot/extension-inject/types';
|
||||
import type { HexString } from '@polkadot/util/types';
|
||||
|
||||
import { Runtime } from '@quantus/codec';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
import { hexToU8a } from '@polkadot/util';
|
||||
|
||||
/**
|
||||
* Metadata captured from Heisenberg at spec 148 — the runtime quantus/extension#7
|
||||
* tier 1 submits to.
|
||||
*
|
||||
* A real blob, not a hand-built one. The whole argument for @quantus/codec is
|
||||
* that a runtime describes itself and a test that describes it instead proves
|
||||
* only that the test and the code agree. This one carries the two extensions no
|
||||
* Substrate-shaped decoder knows about — ReversibleTransactionExtension and
|
||||
* WormholeProofRecorderExtension — which is exactly the case worth covering.
|
||||
*/
|
||||
export const HEISENBERG_GENESIS = '0xa5aa9e5c84d4a3722c152295e7973c9af522f2fb1ef7db5afaa3d5f4dc8d3b4f' as HexString;
|
||||
|
||||
export const HEISENBERG_SPEC = 148;
|
||||
|
||||
const raw = readFileSync(
|
||||
join(dirname(fileURLToPath(import.meta.url)), 'heisenberg-v148.metadata.hex'),
|
||||
'utf-8'
|
||||
).trim();
|
||||
|
||||
export const heisenbergRawMetadata = `0x${raw}` as HexString;
|
||||
|
||||
export function heisenbergRuntime (): Runtime {
|
||||
return Runtime.fromMetadata(hexToU8a(heisenbergRawMetadata));
|
||||
}
|
||||
|
||||
/** The metadata a dapp would provide, as the extension stores it. */
|
||||
export function heisenbergMetadataDef (genesisHash: HexString = HEISENBERG_GENESIS): MetadataDef {
|
||||
return {
|
||||
chain: 'Quantus Heisenberg',
|
||||
genesisHash,
|
||||
icon: 'substrate',
|
||||
rawMetadata: heisenbergRawMetadata,
|
||||
specVersion: HEISENBERG_SPEC,
|
||||
ss58Format: 189,
|
||||
tokenDecimals: 12,
|
||||
tokenSymbol: 'HEI',
|
||||
types: {}
|
||||
};
|
||||
}
|
||||
@@ -25,6 +25,7 @@
|
||||
"@polkadot/networks": "^14.0.3",
|
||||
"@polkadot/util": "^14.0.3",
|
||||
"@polkadot/util-crypto": "^14.0.3",
|
||||
"@quantus/codec": "^0.2.0",
|
||||
"tslib": "^2.8.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
|
||||
@@ -5,7 +5,9 @@ import type { MetadataDef } from '@polkadot/extension-inject/types';
|
||||
import type { ChainProperties } from '@polkadot/types/interfaces';
|
||||
import type { Chain } from './types.js';
|
||||
|
||||
import { Metadata, TypeRegistry } from '@polkadot/types';
|
||||
import { Runtime } from '@quantus/codec';
|
||||
|
||||
import { TypeRegistry } from '@polkadot/types';
|
||||
import { hexToU8a } from '@polkadot/util';
|
||||
import { base64Decode } from '@polkadot/util-crypto';
|
||||
|
||||
@@ -20,6 +22,23 @@ const definitions = new Map<string, MetadataDef>(
|
||||
|
||||
const expanded = new Map<string, Chain>();
|
||||
|
||||
/**
|
||||
* Parse metadata, or report why it could not be used and carry on without it.
|
||||
*
|
||||
* A chain whose metadata will not parse is one this extension declines to sign
|
||||
* for — see `RequestExtrinsicSign` — which is a better outcome than an exception
|
||||
* thrown from a metadata lookup on an unrelated screen.
|
||||
*/
|
||||
function fromMetadata (bytes: Uint8Array, genesisHash: string): Runtime | null {
|
||||
try {
|
||||
return Runtime.fromMetadata(bytes);
|
||||
} catch (error) {
|
||||
console.error(`Unable to read the metadata for ${genesisHash}: ${(error as Error).message}`);
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function metadataExpand (definition: MetadataDef, isPartial = false): Chain {
|
||||
const cached = expanded.get(definition.genesisHash);
|
||||
|
||||
@@ -27,7 +46,7 @@ export function metadataExpand (definition: MetadataDef, isPartial = false): Cha
|
||||
return cached;
|
||||
}
|
||||
|
||||
const { chain, genesisHash, icon, metaCalls, rawMetadata, specVersion, ss58Format, tokenDecimals, tokenSymbol, types, userExtensions } = definition;
|
||||
const { chain, genesisHash, icon, metaCalls, rawMetadata, specVersion, ss58Format, tokenDecimals, tokenSymbol, types } = definition;
|
||||
const registry = new TypeRegistry();
|
||||
|
||||
if (!isPartial) {
|
||||
@@ -40,16 +59,26 @@ export function metadataExpand (definition: MetadataDef, isPartial = false): Cha
|
||||
tokenSymbol
|
||||
}) as unknown as ChainProperties);
|
||||
|
||||
const metadataBytes = metaCalls
|
||||
? base64Decode(metaCalls)
|
||||
: rawMetadata
|
||||
? hexToU8a(rawMetadata)
|
||||
// `rawMetadata` first: `metaCalls` is upstream's calls-only stripping, and
|
||||
// this chain's signed extensions and extrinsic type parameters have to survive
|
||||
// for anything here to encode a payload.
|
||||
const metadataBytes = rawMetadata
|
||||
? hexToU8a(rawMetadata)
|
||||
: metaCalls
|
||||
? base64Decode(metaCalls)
|
||||
: null;
|
||||
const hasMetadata = !!metadataBytes && !isPartial;
|
||||
|
||||
if (hasMetadata) {
|
||||
registry.setMetadata(new Metadata(registry, metadataBytes), undefined, userExtensions);
|
||||
}
|
||||
// Deliberately NOT `registry.setMetadata`. @polkadot/types cannot hold this
|
||||
// chain's metadata at all — PortableRegistry refuses a fixed array longer than
|
||||
// 2048 bytes and the ML-DSA signature types are 5261 and 7219 — and even where
|
||||
// it can, it guesses at signed extensions it does not recognise rather than
|
||||
// reading them. quantus/api#1 has the evidence; @quantus/codec is the
|
||||
// replacement, and it is the runtime describing itself rather than a decoder
|
||||
// written against a version of it.
|
||||
const runtime = metadataBytes && !isPartial
|
||||
? fromMetadata(metadataBytes, genesisHash)
|
||||
: null;
|
||||
const hasMetadata = !!runtime;
|
||||
|
||||
const isUnknown = genesisHash === '0x';
|
||||
|
||||
@@ -63,6 +92,7 @@ export function metadataExpand (definition: MetadataDef, isPartial = false): Cha
|
||||
isUnknown,
|
||||
name: chain,
|
||||
registry,
|
||||
runtime,
|
||||
specVersion,
|
||||
ss58Format,
|
||||
tokenDecimals,
|
||||
|
||||
@@ -1,18 +1,31 @@
|
||||
// Copyright 2019-2026 @polkadot/extension-chains authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import type { Runtime } from '@quantus/codec';
|
||||
import type { MetadataDef } from '@polkadot/extension-inject/types';
|
||||
import type { Registry } from '@polkadot/types/types';
|
||||
|
||||
export interface Chain {
|
||||
definition: MetadataDef;
|
||||
genesisHash?: string;
|
||||
/** Whether {@link Chain.runtime} was built. Nothing decodes or signs without it. */
|
||||
hasMetadata: boolean;
|
||||
icon: string;
|
||||
isEthereum?: boolean;
|
||||
isUnknown?: boolean;
|
||||
name: string;
|
||||
/**
|
||||
* Chain properties only — ss58 format, token symbol and decimals. Deliberately
|
||||
* carries no metadata: `@polkadot/types` cannot hold this chain's, and
|
||||
* everything that decodes or encodes goes through {@link Chain.runtime}.
|
||||
*/
|
||||
registry: Registry;
|
||||
/**
|
||||
* The runtime's own description of itself, from the metadata a dapp provided.
|
||||
* `null` when no metadata is known, in which case this extension will not sign
|
||||
* an extrinsic for the chain rather than guess at its format.
|
||||
*/
|
||||
runtime: Runtime | null;
|
||||
specVersion: number;
|
||||
ss58Format: number;
|
||||
tokenDecimals: number;
|
||||
|
||||
@@ -2,22 +2,24 @@
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import type { Chain } from '@polkadot/extension-chains/types';
|
||||
import type { Call, ExtrinsicEra, ExtrinsicPayload } from '@polkadot/types/interfaces';
|
||||
import type { AnyJson, SignerPayloadJSON } from '@polkadot/types/types';
|
||||
import type { ExtrinsicEra, ExtrinsicPayload } from '@polkadot/types/interfaces';
|
||||
import type { SignerPayloadJSON } from '@polkadot/types/types';
|
||||
import type { BN } from '@polkadot/util';
|
||||
import type { TFunction } from '../../hooks/useTranslation.js';
|
||||
|
||||
import { convertMultilocationToUrl } from '@paraspell/xcm-analyser';
|
||||
import React, { useMemo, useRef } from 'react';
|
||||
|
||||
import { bnToBn, formatNumber } from '@polkadot/util';
|
||||
import { bnToBn, formatNumber, hexToU8a } from '@polkadot/util';
|
||||
|
||||
import { Table } from '../../components/index.js';
|
||||
import { useMetadata, useTranslation } from '../../hooks/index.js';
|
||||
|
||||
interface Decoded {
|
||||
args: AnyJson | null;
|
||||
method: Call | null;
|
||||
/** The call's arguments, by the names the runtime gives them. */
|
||||
args: Record<string, unknown> | null;
|
||||
/** `pallet.call`, as the runtime names them. */
|
||||
name: string | null;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
@@ -31,29 +33,47 @@ function displayDecodeVersion (message: string, chain: Chain, specVersion: BN):
|
||||
return `${message}: chain=${chain.name}, specVersion=${chain.specVersion.toString()} (request specVersion=${specVersion.toString()})`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode the call the user is being asked to approve, using the runtime's own
|
||||
* description of itself.
|
||||
*
|
||||
* Not `registry.createType('Call', data)`. `@polkadot/types` cannot hold this
|
||||
* chain's metadata — see `metadataExpand` — so this screen would have nothing to
|
||||
* show and would fall back to raw hex for every transaction, which is the
|
||||
* failure mode where a user approves bytes nobody read to them.
|
||||
*
|
||||
* The spec-version check stays: metadata from a different runtime decodes a call
|
||||
* into something plausible and wrong, and showing that is worse than showing
|
||||
* hex.
|
||||
*/
|
||||
function decodeMethod (data: string, chain: Chain, specVersion: BN): Decoded {
|
||||
let args: AnyJson | null = null;
|
||||
let method: Call | null = null;
|
||||
|
||||
try {
|
||||
if (specVersion.eqn(chain.specVersion)) {
|
||||
method = chain.registry.createType('Call', data);
|
||||
args = (method.toHuman() as { args: AnyJson }).args;
|
||||
} else {
|
||||
if (!specVersion.eqn(chain.specVersion)) {
|
||||
console.log(displayDecodeVersion('Outdated metadata to decode', chain, specVersion));
|
||||
|
||||
return { args: null, name: null };
|
||||
}
|
||||
|
||||
if (!chain.runtime) {
|
||||
return { args: null, name: null };
|
||||
}
|
||||
|
||||
// `{ Pallet: { call_name: { arg: value } } }` — two nested variants, named
|
||||
// by the runtime.
|
||||
const decoded = chain.runtime.decodeCall(hexToU8a(data)) as Record<string, Record<string, Record<string, unknown>>>;
|
||||
const pallet = Object.keys(decoded)[0];
|
||||
const call = Object.keys(decoded[pallet])[0];
|
||||
|
||||
return { args: decoded[pallet][call], name: `${pallet}.${call}` };
|
||||
} catch (error) {
|
||||
console.error(`${displayDecodeVersion('Error decoding method', chain, specVersion)}:: ${(error as Error).message}`);
|
||||
|
||||
args = null;
|
||||
method = null;
|
||||
return { args: null, name: null };
|
||||
}
|
||||
|
||||
return { args, method };
|
||||
}
|
||||
|
||||
function renderMethod (data: string, { args, method }: Decoded, t: TFunction): React.ReactNode {
|
||||
if (!args || !method) {
|
||||
function renderMethod (data: string, { args, name }: Decoded, t: TFunction): React.ReactNode {
|
||||
if (!args || !name) {
|
||||
return (
|
||||
<tr>
|
||||
<td className='label'>{t('method data')}</td>
|
||||
@@ -63,31 +83,15 @@ function renderMethod (data: string, { args, method }: Decoded, t: TFunction): R
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<tr>
|
||||
<td className='label'>{t('method')}</td>
|
||||
<td className='data'>
|
||||
<details>
|
||||
<summary>{method.section}.{method.method}{
|
||||
method.meta
|
||||
? `(${method.meta.args.map(({ name }) => name).join(', ')})`
|
||||
: ''
|
||||
}</summary>
|
||||
<pre>{JSON.stringify(args, null, 2)}</pre>
|
||||
</details>
|
||||
</td>
|
||||
</tr>
|
||||
{method.meta && (
|
||||
<tr>
|
||||
<td className='label'>{t('info')}</td>
|
||||
<td className='data'>
|
||||
<details>
|
||||
<summary>{method.meta.docs.map((d) => d.toString().trim()).join(' ')}</summary>
|
||||
</details>
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</>
|
||||
<tr>
|
||||
<td className='label'>{t('method')}</td>
|
||||
<td className='data'>
|
||||
<details>
|
||||
<summary>{name}({Object.keys(args).join(', ')})</summary>
|
||||
<pre>{JSON.stringify(args, null, 2)}</pre>
|
||||
</details>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -122,7 +126,7 @@ function Extrinsic ({ className, payload, request: { blockNumber, genesisHash, m
|
||||
const decoded = useMemo(
|
||||
() => chain && chain.hasMetadata
|
||||
? decodeMethod(method, chain, specVersion)
|
||||
: { args: null, method: null },
|
||||
: { args: null, name: null },
|
||||
[method, chain, specVersion]
|
||||
);
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ import { isExtrinsicRequest } from '@polkadot/extension-base/utils';
|
||||
import { TypeRegistry } from '@polkadot/types';
|
||||
|
||||
import { Address, VerticalSpace, Warning } from '../../../components/index.js';
|
||||
import { useMetadata, useTranslation } from '../../../hooks/index.js';
|
||||
import { useTranslation } from '../../../hooks/index.js';
|
||||
import Bytes from '../Bytes.js';
|
||||
import Extrinsic from '../Extrinsic.js';
|
||||
import SignArea from './SignArea.js';
|
||||
@@ -36,21 +36,6 @@ export default function Request ({ account: { isExternal, isHardware }, buttonTe
|
||||
const [{ hexBytes, payload }, setData] = useState<Data>({ hexBytes: null, payload: null });
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const { t } = useTranslation();
|
||||
// Raw vs extrinsic follows the channel the request arrived on, never the
|
||||
// payload fields - those are dapp-supplied and can describe either shape.
|
||||
// Use payload genesis for transaction-signing flow. Account genesis can be null
|
||||
// for allow-any accounts and should not drive payload decoding/signing setup.
|
||||
const payloadGenesisHash = isExtrinsicRequest(request)
|
||||
? request.payload.genesisHash
|
||||
: null;
|
||||
const chain = useMetadata(payloadGenesisHash);
|
||||
|
||||
useEffect((): void => {
|
||||
// When the chain and request are ready, configure the chain's registry.
|
||||
if (chain && isExtrinsicRequest(request)) {
|
||||
chain.registry.setSignedExtensions(request.payload.signedExtensions, chain.definition.userExtensions);
|
||||
}
|
||||
}, [chain, request]);
|
||||
|
||||
useEffect((): void => {
|
||||
if (isExtrinsicRequest(request)) {
|
||||
|
||||
11
yarn.lock
11
yarn.lock
@@ -982,7 +982,7 @@ __metadata:
|
||||
"@polkadot/ui-settings": "npm:^3.16.7"
|
||||
"@polkadot/util": "npm:^14.0.3"
|
||||
"@polkadot/util-crypto": "npm:^14.0.3"
|
||||
"@quantus/codec": "npm:^0.1.0"
|
||||
"@quantus/codec": "npm:^0.2.0"
|
||||
"@quantus/crypto": "npm:^0.1.1"
|
||||
eventemitter3: "npm:^5.0.1"
|
||||
rxjs: "npm:^7.8.1"
|
||||
@@ -998,6 +998,7 @@ __metadata:
|
||||
"@polkadot/networks": "npm:^14.0.3"
|
||||
"@polkadot/util": "npm:^14.0.3"
|
||||
"@polkadot/util-crypto": "npm:^14.0.3"
|
||||
"@quantus/codec": "npm:^0.2.0"
|
||||
tslib: "npm:^2.8.1"
|
||||
peerDependencies:
|
||||
"@polkadot/api": "*"
|
||||
@@ -1603,13 +1604,13 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@quantus/codec@npm:^0.1.0":
|
||||
version: 0.1.0
|
||||
resolution: "@quantus/codec@npm:0.1.0::__archiveUrl=https%3A%2F%2Fgit.lair.cafe%2Fapi%2Fpackages%2Fquantus%2Fnpm%2F%2540quantus%252Fcodec%2F-%2F0.1.0%2Fcodec-0.1.0.tgz"
|
||||
"@quantus/codec@npm:^0.2.0":
|
||||
version: 0.2.0
|
||||
resolution: "@quantus/codec@npm:0.2.0::__archiveUrl=https%3A%2F%2Fgit.lair.cafe%2Fapi%2Fpackages%2Fquantus%2Fnpm%2F%2540quantus%252Fcodec%2F-%2F0.2.0%2Fcodec-0.2.0.tgz"
|
||||
dependencies:
|
||||
fflate: "npm:^0.8.2"
|
||||
tslib: "npm:^2.7.0"
|
||||
checksum: 10/90d40e0909383f5f0fea3f662772702d78f1b6fbecb9e8cac901e8a20b5adafff97782a4c13faa7e2278abd97c3c12275695d2a21d19790a359df01267febe84
|
||||
checksum: 10/ef03f65170153ac3f389677e1793dee65803ca8fc03e3e48e3c47885461f79b8bd020052b2fff500437de4b96afaa200045ef88c26f3647f1c04496fc2c22823
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
|
||||
Reference in New Issue
Block a user