feat: Add support for the Ledger Generic App (#1408)

* initial commit

* issue with payload

* test commit

* cleanup

* Update signer and get result back to client

* Cleanup

* removed getMetadataRaw and useMetadataRaw

* updated metadata update message

* added assert checks

* updated prefix search for Ledger

* fixed ledger account import

---------

Co-authored-by: tarikgul <tariksnow37@gmail.com>
This commit is contained in:
Alberto Nicolas Penayo 2024-07-29 09:03:36 -03:00 committed by GitHub
parent d751a923c5
commit d2f164084c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
12 changed files with 130 additions and 29 deletions

View File

@ -409,14 +409,14 @@ export default class Extension {
return true; return true;
} }
private signingApproveSignature ({ id, signature }: RequestSigningApproveSignature): boolean { private signingApproveSignature ({ id, signature, signedTransaction }: RequestSigningApproveSignature): boolean {
const queued = this.#state.getSignRequest(id); const queued = this.#state.getSignRequest(id);
assert(queued, 'Unable to find request'); assert(queued, 'Unable to find request');
const { resolve } = queued; const { resolve } = queued;
resolve({ id, signature }); resolve({ id, signature, signedTransaction });
return true; return true;
} }

View File

@ -299,6 +299,7 @@ export interface RequestSigningApprovePassword {
export interface RequestSigningApproveSignature { export interface RequestSigningApproveSignature {
id: string; id: string;
signature: HexString; signature: HexString;
signedTransaction?: HexString;
} }
export interface RequestSigningCancel { export interface RequestSigningCancel {
@ -358,6 +359,7 @@ export type TransportResponseMessage<TMessageType extends MessageTypes> =
export interface ResponseSigning { export interface ResponseSigning {
id: string; id: string;
signature: HexString; signature: HexString;
signedTransaction?: HexString;
} }
export interface ResponseDeriveValidate { export interface ResponseDeriveValidate {

View File

@ -26,7 +26,7 @@ export function metadataExpand (definition: MetadataDef, isPartial = false): Cha
return cached; return cached;
} }
const { chain, genesisHash, icon, metaCalls, specVersion, ss58Format, tokenDecimals, tokenSymbol, types, userExtensions } = definition; const { chain, genesisHash, icon, metaCalls, rawMetadata, specVersion, ss58Format, tokenDecimals, tokenSymbol, types, userExtensions } = definition;
const registry = new TypeRegistry(); const registry = new TypeRegistry();
if (!isPartial) { if (!isPartial) {
@ -41,8 +41,8 @@ export function metadataExpand (definition: MetadataDef, isPartial = false): Cha
const hasMetadata = !!metaCalls && !isPartial; const hasMetadata = !!metaCalls && !isPartial;
if (hasMetadata) { if (hasMetadata || !!rawMetadata) {
registry.setMetadata(new Metadata(registry, base64Decode(metaCalls)), undefined, userExtensions); registry.setMetadata(new Metadata(registry, hasMetadata ? base64Decode(metaCalls) : rawMetadata), undefined, userExtensions);
} }
const isUnknown = genesisHash === '0x'; const isUnknown = genesisHash === '0x';

View File

@ -66,6 +66,7 @@ export interface MetadataDef extends MetadataDefBase {
tokenSymbol: string; tokenSymbol: string;
types: Record<string, Record<string, string> | string>; types: Record<string, Record<string, string> | string>;
metaCalls?: string; metaCalls?: string;
rawMetadata?: HexString;
userExtensions?: ExtDef; userExtensions?: ExtDef;
} }

View File

@ -23,6 +23,7 @@
"@fortawesome/free-solid-svg-icons": "^6.5.1", "@fortawesome/free-solid-svg-icons": "^6.5.1",
"@fortawesome/react-fontawesome": "^0.2.0", "@fortawesome/react-fontawesome": "^0.2.0",
"@paraspell/xcm-analyser": "^1.3.0", "@paraspell/xcm-analyser": "^1.3.0",
"@polkadot-api/merkleize-metadata": "^1.1.0",
"@polkadot/api": "^12.2.1", "@polkadot/api": "^12.2.1",
"@polkadot/extension-base": "0.49.4-0-x", "@polkadot/extension-base": "0.49.4-0-x",
"@polkadot/extension-chains": "0.49.4-0-x", "@polkadot/extension-chains": "0.49.4-0-x",

View File

@ -70,7 +70,7 @@ function Request ({ className, metaId, request, url }: Props): React.ReactElemen
</Table> </Table>
<div className='requestInfo'> <div className='requestInfo'>
<Warning className='requestWarning'> <Warning className='requestWarning'>
{t('This approval will add the metadata to your extension instance, allowing future requests to be decoded using this metadata.')} {t('This approval will add the metadata to your extension instance, allowing future requests to be decoded using this metadata. It will also allow the use of Ledger\'s Generic Polkadot App.')}
</Warning> </Warning>
<Button <Button
className='btnAccept' className='btnAccept'

View File

@ -1,15 +1,19 @@
// Copyright 2019-2024 @polkadot/extension-ui authors & contributors // Copyright 2019-2024 @polkadot/extension-ui authors & contributors
// SPDX-License-Identifier: Apache-2.0 // SPDX-License-Identifier: Apache-2.0
import type { ExtrinsicPayload } from '@polkadot/types/interfaces'; import type { Chain } from '@polkadot/extension-chains/types';
import type { SignerPayloadJSON } from '@polkadot/types/types';
import type { HexString } from '@polkadot/util/types'; import type { HexString } from '@polkadot/util/types';
import { faSync } from '@fortawesome/free-solid-svg-icons'; import { faSync } from '@fortawesome/free-solid-svg-icons';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import React, { useCallback, useEffect, useState } from 'react'; import React, { useCallback, useEffect, useState } from 'react';
import { assert, objectSpread, u8aToHex } from '@polkadot/util';
import { merkleizeMetadata } from '@polkadot-api/merkleize-metadata';
import { Button, Warning } from '../../components/index.js'; import { Button, Warning } from '../../components/index.js';
import { useLedger, useTranslation } from '../../hooks/index.js'; import { useLedger, useMetadata, useTranslation } from '../../hooks/index.js';
import { styled } from '../../styled.js'; import { styled } from '../../styled.js';
interface Props { interface Props {
@ -18,21 +22,44 @@ interface Props {
className?: string; className?: string;
error: string | null; error: string | null;
genesisHash?: string; genesisHash?: string;
onSignature?: ({ signature }: { signature: HexString }) => void; onSignature?: ({ signature }: { signature: HexString }, signedTransaction: HexString) => void;
payload?: ExtrinsicPayload; payload?: SignerPayloadJSON;
setError: (value: string | null) => void; setError: (value: string | null) => void;
} }
function getMetadataProof (chain: Chain, payload: SignerPayloadJSON) {
const m = chain.definition.rawMetadata;
assert(m, 'To sign with Ledger\'s Polkadot Generic App, the metadata must be present in the extension.');
const merkleizedMetadata = merkleizeMetadata(m, {
base58Prefix: chain.ss58Format,
decimals: chain.tokenDecimals,
specName: chain.name.toLowerCase(),
specVersion: chain.specVersion,
tokenSymbol: chain.tokenSymbol
});
const metadataHash = u8aToHex(merkleizedMetadata.digest());
const newPayload = objectSpread<SignerPayloadJSON>({}, payload, { metadataHash, mode: 1 });
const raw = chain.registry.createType('ExtrinsicPayload', newPayload);
return {
raw,
txMetadata: merkleizedMetadata.getProofForExtrinsicPayload(u8aToHex(raw.toU8a(true)))
};
}
function LedgerSign ({ accountIndex, addressOffset, className, error, genesisHash, onSignature, payload, setError }: Props): React.ReactElement<Props> { function LedgerSign ({ accountIndex, addressOffset, className, error, genesisHash, onSignature, payload, setError }: Props): React.ReactElement<Props> {
const [isBusy, setIsBusy] = useState(false); const [isBusy, setIsBusy] = useState(false);
const { t } = useTranslation(); const { t } = useTranslation();
const chain = useMetadata(genesisHash);
const { error: ledgerError, isLoading: ledgerLoading, isLocked: ledgerLocked, ledger, refresh, warning: ledgerWarning } = useLedger(genesisHash, accountIndex, addressOffset); const { error: ledgerError, isLoading: ledgerLoading, isLocked: ledgerLocked, ledger, refresh, warning: ledgerWarning } = useLedger(genesisHash, accountIndex, addressOffset);
useEffect(() => { useEffect(() => {
if (ledgerError) { if (ledgerError) {
setError(ledgerError); setError(ledgerError);
} }
}, [ledgerError, setError]); }, [chain, ledgerError, setError]);
const _onRefresh = useCallback(() => { const _onRefresh = useCallback(() => {
refresh(); refresh();
@ -41,21 +68,43 @@ function LedgerSign ({ accountIndex, addressOffset, className, error, genesisHas
const _onSignLedger = useCallback( const _onSignLedger = useCallback(
(): void => { (): void => {
if (!ledger || !payload || !onSignature) { if (!ledger || !payload || !onSignature || !chain) {
return; return;
} }
if (!chain?.definition.rawMetadata) {
setError('No metadata found for this chain. You must upload the metadata to the extension in order to use Ledger.');
}
const { raw, txMetadata } = getMetadataProof(chain, payload);
const metaBuff = Buffer.from(txMetadata);
setError(null); setError(null);
setIsBusy(true); setIsBusy(true);
ledger.sign(payload.toU8a(true), accountIndex, addressOffset) ledger.signWithMetadata(raw.toU8a(true), accountIndex, addressOffset, { metadata: metaBuff })
.then((signature) => { .then((signature) => {
onSignature(signature); const extrinsic = chain.registry.createType(
'Extrinsic',
{ method: raw.method },
{ version: 4 }
);
ledger.getAddress(chain.ss58Format, false, accountIndex, addressOffset)
.then(({ address }) => {
extrinsic.addSignature(address, signature.signature, raw.toHex());
onSignature(signature, extrinsic.toHex());
})
.catch((e: Error) => {
setError(e.message);
setIsBusy(false);
});
}).catch((e: Error) => { }).catch((e: Error) => {
setError(e.message); setError(e.message);
setIsBusy(false); setIsBusy(false);
}); });
}, },
[accountIndex, addressOffset, ledger, onSignature, payload, setError] [accountIndex, addressOffset, chain, ledger, onSignature, payload, setError]
); );
return ( return (
@ -93,7 +142,7 @@ function LedgerSign ({ accountIndex, addressOffset, className, error, genesisHas
); );
} }
export default styled(LedgerSign)<Props>` export default styled(LedgerSign) <Props>`
flex-direction: column; flex-direction: column;
padding: 6px 24px; padding: 6px 24px;

View File

@ -68,8 +68,8 @@ export default function Request ({ account: { accountIndex, addressOffset, genes
}, [request]); }, [request]);
const _onSignature = useCallback( const _onSignature = useCallback(
({ signature }: { signature: HexString }): void => { ({ signature }: { signature: HexString }, signedTransaction?: HexString): void => {
approveSignSignature(signId, signature) approveSignSignature(signId, signature, signedTransaction)
.then(() => onAction()) .then(() => onAction())
.catch((error: Error): void => { .catch((error: Error): void => {
setError(error.message); setError(error.message);
@ -117,7 +117,7 @@ export default function Request ({ account: { accountIndex, addressOffset, genes
error={error} error={error}
genesisHash={json.genesisHash} genesisHash={json.genesisHash}
onSignature={_onSignature} onSignature={_onSignature}
payload={payload} payload={json}
setError={setError} setError={setError}
/> />
)} )}

View File

@ -6,13 +6,16 @@
/* eslint-disable deprecation/deprecation */ /* eslint-disable deprecation/deprecation */
import type { Network } from '@polkadot/networks/types'; import type { Network } from '@polkadot/networks/types';
import type { HexString } from '@polkadot/util/types';
import { useCallback, useEffect, useMemo, useState } from 'react'; import { useCallback, useEffect, useMemo, useState } from 'react';
import { Ledger } from '@polkadot/hw-ledger'; import { LedgerGeneric } from '@polkadot/hw-ledger';
import { knownLedger } from '@polkadot/networks/defaults';
import { settings } from '@polkadot/ui-settings'; import { settings } from '@polkadot/ui-settings';
import { assert } from '@polkadot/util'; import { assert } from '@polkadot/util';
import chains from '../../../extension-ui/src/util/chains';
import ledgerChains from '../util/legerChains.js'; import ledgerChains from '../util/legerChains.js';
import useTranslation from './useTranslation.js'; import useTranslation from './useTranslation.js';
@ -26,7 +29,7 @@ interface State extends StateBase {
error: string | null; error: string | null;
isLoading: boolean; isLoading: boolean;
isLocked: boolean; isLocked: boolean;
ledger: Ledger | null; ledger: LedgerGeneric | null;
refresh: () => void; refresh: () => void;
warning: string | null; warning: string | null;
} }
@ -44,8 +47,8 @@ function getState (): StateBase {
}; };
} }
function retrieveLedger (genesis: string): Ledger { function retrieveLedger (genesis: string): LedgerGeneric {
let ledger: Ledger | null = null; let ledger: LedgerGeneric | null = null;
const { isLedgerCapable } = getState(); const { isLedgerCapable } = getState();
@ -55,7 +58,11 @@ function retrieveLedger (genesis: string): Ledger {
assert(def, 'There is no known Ledger app available for this chain'); assert(def, 'There is no known Ledger app available for this chain');
ledger = new Ledger('webusb', def.network); assert(def.slip44, 'Slip44 is not available for this network, please report an issue to update this chains slip44');
// All chains use the `slip44` from polkadot in their derivation path in ledger.
// This interface is specific to the underlying PolkadotGenericApp.
ledger = new LedgerGeneric('webusb', def.network, knownLedger['polkadot']);
return ledger; return ledger;
} }
@ -68,6 +75,7 @@ export default function useLedger (genesis?: string | null, accountIndex = 0, ad
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [address, setAddress] = useState<string | null>(null); const [address, setAddress] = useState<string | null>(null);
const { t } = useTranslation(); const { t } = useTranslation();
const ledger = useMemo(() => { const ledger = useMemo(() => {
setError(null); setError(null);
setIsLocked(false); setIsLocked(false);
@ -102,7 +110,15 @@ export default function useLedger (genesis?: string | null, accountIndex = 0, ad
setError(null); setError(null);
setWarning(null); setWarning(null);
ledger.getAddress(false, accountIndex, addressOffset) // This is used with a genesisHash only when importing the Ledger account
// and when signing with Ledger. In both cases, the genesisHash is known and
// will be in this array.
const chosenNetwork = chains.find(({ genesisHash }) => genesisHash === genesis as HexString);
// Just in case, but this shouldn't be triggered
assert(chosenNetwork, t('This network is not available, please report an issue to update the known chains'));
ledger.getAddress(chosenNetwork.ss58Format, false, accountIndex, addressOffset)
.then((res) => { .then((res) => {
setIsLoading(false); setIsLoading(false);
setAddress(res.address); setAddress(res.address);

View File

@ -118,8 +118,8 @@ export async function approveSignPassword (id: string, savePass: boolean, passwo
return sendMessage('pri(signing.approve.password)', { id, password, savePass }); return sendMessage('pri(signing.approve.password)', { id, password, savePass });
} }
export async function approveSignSignature (id: string, signature: HexString): Promise<boolean> { export async function approveSignSignature (id: string, signature: HexString, signedTransaction?: HexString): Promise<boolean> {
return sendMessage('pri(signing.approve.signature)', { id, signature }); return sendMessage('pri(signing.approve.signature)', { id, signature, signedTransaction });
} }
export async function createAccountExternal (name: string, address: string, genesisHash: HexString | null): Promise<boolean> { export async function createAccountExternal (name: string, address: string, genesisHash: HexString | null): Promise<boolean> {

View File

@ -68,7 +68,6 @@
"decimals": "", "decimals": "",
"symbol": "", "symbol": "",
"upgrade": "", "upgrade": "",
"This approval will add the metadata to your extension instance, allowing future requests to be decoded using this metadata.": "",
"Yes, do this metadata update": "", "Yes, do this metadata update": "",
"Reject": "", "Reject": "",
"Restore from JSON": "", "Restore from JSON": "",
@ -176,5 +175,8 @@
"Is your ledger locked?": "", "Is your ledger locked?": "",
"App \"{{network}}\" does not seem to be open": "", "App \"{{network}}\" does not seem to be open": "",
"Ledger error: {{errorMessage}}": "", "Ledger error: {{errorMessage}}": "",
"assetId": "" "assetId": "",
"This approval will add the metadata to your extension instance, allowing future requests to be decoded using this metadata. It will also allow the use of Ledger's Generic Polkadot App.": "",
"No metadata found for this chain. You must upload the metadata to the extension in order to use Ledger.": "",
"This network is not available, please report an issue to update the known chains": ""
} }

View File

@ -668,6 +668,16 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"@polkadot-api/merkleize-metadata@npm:^1.1.0":
version: 1.1.1
resolution: "@polkadot-api/merkleize-metadata@npm:1.1.1"
dependencies:
"@polkadot-api/substrate-bindings": "npm:0.6.1"
"@polkadot-api/utils": "npm:0.1.1"
checksum: 10/dc0d1aa9e15049fdf263451ea1a8d7a4c5c5907e8b9b7ac8861f2e3281a82e99b5b07619a2c5cab105d8c14253007a006a7bc9d44fcaef1eb1d0356fc63d0485
languageName: node
linkType: hard
"@polkadot-api/metadata-builders@npm:0.0.1": "@polkadot-api/metadata-builders@npm:0.0.1":
version: 0.0.1 version: 0.0.1
resolution: "@polkadot-api/metadata-builders@npm:0.0.1" resolution: "@polkadot-api/metadata-builders@npm:0.0.1"
@ -704,6 +714,18 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"@polkadot-api/substrate-bindings@npm:0.6.1":
version: 0.6.1
resolution: "@polkadot-api/substrate-bindings@npm:0.6.1"
dependencies:
"@noble/hashes": "npm:^1.3.1"
"@polkadot-api/utils": "npm:0.1.1"
"@scure/base": "npm:^1.1.1"
scale-ts: "npm:^1.6.0"
checksum: 10/bce8bd7758174302b9e72da1addce04103ab0e9d0626ff2eacffdfb28c7a058071edaf4b47a7569bd7c8c73dd1595e1d78f90d419abafb86a6eadf078c83d1e4
languageName: node
linkType: hard
"@polkadot-api/substrate-client@npm:0.0.1": "@polkadot-api/substrate-client@npm:0.0.1":
version: 0.0.1 version: 0.0.1
resolution: "@polkadot-api/substrate-client@npm:0.0.1" resolution: "@polkadot-api/substrate-client@npm:0.0.1"
@ -718,6 +740,13 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"@polkadot-api/utils@npm:0.1.1":
version: 0.1.1
resolution: "@polkadot-api/utils@npm:0.1.1"
checksum: 10/ffd30039cd91396abb5528132bea9a72465eae9cdd088ecbd463ce962aa51c01b9e8c7d3e64c16c8438c0e55966c4d36fe4d18744a01b2b61f7e778f61dbf2b0
languageName: node
linkType: hard
"@polkadot/api-augment@npm:12.2.1": "@polkadot/api-augment@npm:12.2.1":
version: 12.2.1 version: 12.2.1
resolution: "@polkadot/api-augment@npm:12.2.1" resolution: "@polkadot/api-augment@npm:12.2.1"
@ -996,6 +1025,7 @@ __metadata:
"@fortawesome/free-solid-svg-icons": "npm:^6.5.1" "@fortawesome/free-solid-svg-icons": "npm:^6.5.1"
"@fortawesome/react-fontawesome": "npm:^0.2.0" "@fortawesome/react-fontawesome": "npm:^0.2.0"
"@paraspell/xcm-analyser": "npm:^1.3.0" "@paraspell/xcm-analyser": "npm:^1.3.0"
"@polkadot-api/merkleize-metadata": "npm:^1.1.0"
"@polkadot/api": "npm:^12.2.1" "@polkadot/api": "npm:^12.2.1"
"@polkadot/dev-test": "npm:^0.79.3" "@polkadot/dev-test": "npm:^0.79.3"
"@polkadot/extension-base": "npm:0.49.4-0-x" "@polkadot/extension-base": "npm:0.49.4-0-x"