From a154f9bd5486ac95a74180f7712ce676937a4bb6 Mon Sep 17 00:00:00 2001 From: Tarik Gul <47201679+TarikGul@users.noreply.github.com> Date: Wed, 17 Sep 2025 11:51:33 -0400 Subject: [PATCH] Ethereum Ledger signing (#1542) * [TESTING] Ecdsa signing * remove hardcoding * Remove consoles * Fix import of exdsa accounts from ledger * Add signing * Add correct keypair type to hardware meta * lint * Update ui libs to 3.14.1 * Most recent changes * Detect chain type and hash message accordingly * testing logs * Only use pubkey * Make Ethereum signing work * lint * Remove rogue console.logs * Change to isHex() --------- Co-authored-by: Valentin Fernandez --- .../src/background/handlers/Extension.ts | 4 +- .../extension-base/src/background/types.ts | 1 + packages/extension-chains/src/types.ts | 1 + .../extension-ui/src/Popup/ImportLedger.tsx | 25 +++++-- .../src/Popup/Signing/LedgerSign.tsx | 72 ++++++++++++------- .../src/Popup/Signing/Request/index.tsx | 3 +- .../extension-ui/src/components/Address.tsx | 4 +- packages/extension-ui/src/hooks/useLedger.ts | 36 +++++++--- packages/extension-ui/src/messaging.ts | 4 +- .../public/locales/en/translation.json | 2 + 10 files changed, 105 insertions(+), 47 deletions(-) diff --git a/packages/extension-base/src/background/handlers/Extension.ts b/packages/extension-base/src/background/handlers/Extension.ts index 3658ffd8..f6170ee6 100644 --- a/packages/extension-base/src/background/handlers/Extension.ts +++ b/packages/extension-base/src/background/handlers/Extension.ts @@ -64,8 +64,8 @@ export default class Extension { return true; } - private accountsCreateHardware ({ accountIndex, address, addressOffset, genesisHash, hardwareType, name }: RequestAccountCreateHardware): boolean { - keyring.addHardware(address, hardwareType, { accountIndex, addressOffset, genesisHash, name }); + private accountsCreateHardware ({ accountIndex, address, addressOffset, genesisHash, hardwareType, name, type }: RequestAccountCreateHardware): boolean { + keyring.addHardware(address, hardwareType, { accountIndex, addressOffset, genesisHash, name, type }); return true; } diff --git a/packages/extension-base/src/background/types.ts b/packages/extension-base/src/background/types.ts index 1e2912b1..d5c7171d 100644 --- a/packages/extension-base/src/background/types.ts +++ b/packages/extension-base/src/background/types.ts @@ -204,6 +204,7 @@ export interface RequestAccountCreateHardware { genesisHash: HexString; hardwareType: string; name: string; + type: KeypairType; } export interface RequestAccountChangePassword { diff --git a/packages/extension-chains/src/types.ts b/packages/extension-chains/src/types.ts index 3d350f35..534b1138 100644 --- a/packages/extension-chains/src/types.ts +++ b/packages/extension-chains/src/types.ts @@ -9,6 +9,7 @@ export interface Chain { genesisHash?: string; hasMetadata: boolean; icon: string; + isEthereum?: boolean; isUnknown?: boolean; name: string; registry: Registry; diff --git a/packages/extension-ui/src/Popup/ImportLedger.tsx b/packages/extension-ui/src/Popup/ImportLedger.tsx index 99656bba..bf19825c 100644 --- a/packages/extension-ui/src/Popup/ImportLedger.tsx +++ b/packages/extension-ui/src/Popup/ImportLedger.tsx @@ -9,7 +9,7 @@ import React, { useCallback, useContext, useEffect, useRef, useState } from 'rea import { settings } from '@polkadot/ui-settings'; -import { ActionContext, Address, Button, ButtonArea, Dropdown, VerticalSpace, Warning } from '../components/index.js'; +import { ActionContext, Address, Button, ButtonArea, Dropdown, Switch, VerticalSpace, Warning } from '../components/index.js'; import { useLedger, useTranslation } from '../hooks/index.js'; import { createAccountHardware } from '../messaging.js'; import { Header, Name } from '../partials/index.js'; @@ -39,9 +39,10 @@ function ImportLedger ({ className }: Props): React.ReactElement { const [error, setError] = useState(null); const [isBusy, setIsBusy] = useState(false); const [genesis, setGenesis] = useState(null); + const [isEthereum, setIsEthereum] = useState(false); const onAction = useContext(ActionContext); const [name, setName] = useState(null); - const { address, error: ledgerError, isLoading: ledgerLoading, isLocked: ledgerLocked, refresh, warning: ledgerWarning } = useLedger(genesis, accountIndex, addressOffset); + const { address, error: ledgerError, isLoading: ledgerLoading, isLocked: ledgerLocked, refresh, type, warning: ledgerWarning } = useLedger(genesis, accountIndex, addressOffset, isEthereum); useEffect(() => { if (address) { @@ -72,10 +73,10 @@ function ImportLedger ({ className }: Props): React.ReactElement { const _onSave = useCallback( () => { - if (address && genesis && name) { + if (address && genesis && name && type) { setIsBusy(true); - createAccountHardware(address, 'ledger', accountIndex, addressOffset, name, genesis) + createAccountHardware(address, 'ledger', accountIndex, addressOffset, name, genesis, type) .then(() => onAction('/')) .catch((error: Error) => { console.error(error); @@ -85,7 +86,7 @@ function ImportLedger ({ className }: Props): React.ReactElement { }); } }, - [accountIndex, address, addressOffset, genesis, name, onAction] + [accountIndex, address, addressOffset, genesis, name, onAction, type] ); // select element is returning a string @@ -105,7 +106,16 @@ function ImportLedger ({ className }: Props): React.ReactElement { isExternal isHardware name={name} + type={type ?? undefined} /> +
+ +
+ )} {!!ledgerWarning && ( @@ -183,4 +194,8 @@ export default styled(ImportLedger)` .refreshIcon { margin-right: 0.3rem; } + + .ethereum-toggle { + margin: 1rem 0; + } `; diff --git a/packages/extension-ui/src/Popup/Signing/LedgerSign.tsx b/packages/extension-ui/src/Popup/Signing/LedgerSign.tsx index c808a18c..6e55bc47 100644 --- a/packages/extension-ui/src/Popup/Signing/LedgerSign.tsx +++ b/packages/extension-ui/src/Popup/Signing/LedgerSign.tsx @@ -27,6 +27,7 @@ interface Props { className?: string; error: string | null; genesisHash?: string; + isEthereum?: boolean; onSignature?: ({ signature }: { signature: HexString }, signedTransaction?: HexString) => void; payloadJson?: SignerPayloadJSON; payloadExt?: ExtrinsicPayload @@ -55,11 +56,11 @@ function getMetadataProof (chain: Chain, payload: SignerPayloadJSON) { }; } -function LedgerSign ({ accountIndex, addressOffset, className, error, genesisHash, onSignature, payloadExt, payloadJson, setError }: Props): React.ReactElement { +function LedgerSign ({ accountIndex, addressOffset, className, error, genesisHash, isEthereum = false, onSignature, payloadExt, payloadJson, setError }: Props): React.ReactElement { const [isBusy, setIsBusy] = useState(false); 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, type: _ledgerType, warning: ledgerWarning } = useLedger(genesisHash, accountIndex, addressOffset, isEthereum); useEffect(() => { if (ledgerError) { @@ -94,30 +95,53 @@ function LedgerSign ({ accountIndex, addressOffset, className, error, genesisHas } const { raw, txMetadata } = getMetadataProof(chain, payloadJson); - const metaBuff = Buffer.from(txMetadata); - (ledger as LedgerGeneric).signWithMetadata(raw.toU8a(true), accountIndex, addressOffset, { metadata: metaBuff }) - .then((signature) => { - const extrinsic = chain.registry.createType( - 'Extrinsic', - { method: raw.method }, - { version: 4 } - ); + if (isEthereum) { + (ledger as LedgerGeneric).signWithMetadataEcdsa(raw.toU8a(true), accountIndex, addressOffset, { metadata: metaBuff }) + .then(({ signature }) => { + const extrinsic = chain.registry.createType( + 'Extrinsic', + { method: raw.method }, + { version: 4 } + ); - (ledger as LedgerGeneric).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) => { - setError(e.message); - setIsBusy(false); - }); + (ledger as LedgerGeneric).getAddressEcdsa(false, accountIndex, addressOffset) + .then(({ address }) => { + extrinsic.addSignature(`0x${address}`, signature, raw.toHex()); + onSignature({ signature }, extrinsic.toHex()); + }) + .catch((e: Error) => { + setError(e.message); + setIsBusy(false); + }); + }).catch((e: Error) => { + setError(e.message); + setIsBusy(false); + }); + } else { + (ledger as LedgerGeneric).signWithMetadata(raw.toU8a(true), accountIndex, addressOffset, { metadata: metaBuff }) + .then((signature) => { + const extrinsic = chain.registry.createType( + 'Extrinsic', + { method: raw.method }, + { version: 4 } + ); + + (ledger as LedgerGeneric).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) => { + setError(e.message); + setIsBusy(false); + }); + } } else if (currApp === 'chainSpecific') { (ledger as Ledger).sign(payloadExt.toU8a(true), accountIndex, addressOffset) .then((signature) => { @@ -128,7 +152,7 @@ function LedgerSign ({ accountIndex, addressOffset, className, error, genesisHas }); } }, - [accountIndex, addressOffset, chain, ledger, onSignature, payloadJson, payloadExt, setError] + [accountIndex, addressOffset, chain, ledger, onSignature, payloadJson, payloadExt, setError, isEthereum] ); return ( diff --git a/packages/extension-ui/src/Popup/Signing/Request/index.tsx b/packages/extension-ui/src/Popup/Signing/Request/index.tsx index 6dd0f23c..93e541e2 100644 --- a/packages/extension-ui/src/Popup/Signing/Request/index.tsx +++ b/packages/extension-ui/src/Popup/Signing/Request/index.tsx @@ -43,7 +43,7 @@ function isRawPayload (payload: SignerPayloadJSON | SignerPayloadRaw): payload i return !!(payload as SignerPayloadRaw).data; } -export default function Request ({ account: { accountIndex, addressOffset, genesisHash, isExternal, isHardware }, buttonText, isFirst, request, signId, url }: Props): React.ReactElement | null { +export default function Request ({ account: { accountIndex, addressOffset, genesisHash, isExternal, isHardware, type }, buttonText, isFirst, request, signId, url }: Props): React.ReactElement | null { const onAction = useContext(ActionContext); const [{ hexBytes, payload }, setData] = useState({ hexBytes: null, payload: null }); const [error, setError] = useState(null); @@ -125,6 +125,7 @@ export default function Request ({ account: { accountIndex, addressOffset, genes addressOffset={addressOffset || 0} error={error} genesisHash={json.genesisHash} + isEthereum={type === 'ethereum'} onSignature={_onSignature} payloadExt={payload} payloadJson={json} diff --git a/packages/extension-ui/src/components/Address.tsx b/packages/extension-ui/src/components/Address.tsx index ca0bfaa2..d389863a 100644 --- a/packages/extension-ui/src/components/Address.tsx +++ b/packages/extension-ui/src/components/Address.tsx @@ -14,6 +14,7 @@ import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import React, { useCallback, useContext, useEffect, useRef, useState } from 'react'; import CopyToClipboard from 'react-copy-to-clipboard'; +import { hexToU8a, isHex } from '@polkadot/util'; import { decodeAddress, encodeAddress } from '@polkadot/util-crypto'; import details from '../assets/details.svg'; @@ -71,8 +72,7 @@ function findAccountByAddress (accounts: AccountJson[], _address: string): Accou // recodes an supplied address using the prefix/genesisHash, include the actual saved account & chain function recodeAddress (address: string, accounts: AccountWithChildren[], chain: Chain | null, settings: SettingsStruct): Recoded { // decode and create a shortcut for the encoded address - const publicKey = decodeAddress(address); - + const publicKey = isHex(address) ? hexToU8a(address) : decodeAddress(address); // find our account using the actual publicKey, and then find the associated chain const account = findSubstrateAccount(accounts, publicKey); const prefix = chain ? chain.ss58Format : (settings.prefix === -1 ? 42 : settings.prefix); diff --git a/packages/extension-ui/src/hooks/useLedger.ts b/packages/extension-ui/src/hooks/useLedger.ts index c7534b36..e57c0560 100644 --- a/packages/extension-ui/src/hooks/useLedger.ts +++ b/packages/extension-ui/src/hooks/useLedger.ts @@ -7,6 +7,7 @@ import type { Network } from '@polkadot/networks/types'; import type { HexString } from '@polkadot/util/types'; +import type { KeypairType } from '@polkadot/util-crypto/types'; import { useCallback, useEffect, useMemo, useState } from 'react'; @@ -31,6 +32,7 @@ interface State extends StateBase { isLocked: boolean; ledger: LedgerGeneric | Ledger | null; refresh: () => void; + type: KeypairType | null; warning: string | null; } @@ -82,13 +84,14 @@ function retrieveLedger (genesis: string): LedgerGeneric | Ledger { return ledger; } -export default function useLedger (genesis?: string | null, accountIndex = 0, addressOffset = 0): State { +export default function useLedger (genesis?: string | null, accountIndex = 0, addressOffset = 0, isEthereum = false): State { const [isLoading, setIsLoading] = useState(false); const [isLocked, setIsLocked] = useState(false); const [refreshLock, setRefreshLock] = useState(false); const [warning, setWarning] = useState(null); const [error, setError] = useState(null); const [address, setAddress] = useState(null); + const [type, setType] = useState(null); const { t } = useTranslation(); const handleGetAddressError = (e: Error, genesis: string) => { @@ -154,17 +157,28 @@ export default function useLedger (genesis?: string | null, accountIndex = 0, ad // 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')); - const currApp = settings.get().ledgerApp; if (currApp === 'generic' || currApp === 'migration') { - (ledger as LedgerGeneric).getAddress(chosenNetwork.ss58Format, false, accountIndex, addressOffset) - .then((res) => { - setIsLoading(false); - setAddress(res.address); - }).catch((e: Error) => { - handleGetAddressError(e, genesis); - }); + if (isEthereum) { + (ledger as LedgerGeneric).getAddressEcdsa(false, accountIndex, addressOffset) + .then((res) => { + setIsLoading(false); + setAddress(`0x${res.address}`); + setType('ethereum'); + }).catch((e: Error) => { + handleGetAddressError(e, genesis); + }); + } else { + (ledger as LedgerGeneric).getAddress(chosenNetwork.ss58Format, false, accountIndex, addressOffset) + .then((res) => { + setIsLoading(false); + setAddress(res.address); + setType('ed25519'); + }).catch((e: Error) => { + handleGetAddressError(e, genesis); + }); + } } else if (currApp === 'chainSpecific') { (ledger as Ledger).getAddress(false, accountIndex, addressOffset) .then((res) => { @@ -177,7 +191,7 @@ export default function useLedger (genesis?: string | null, accountIndex = 0, ad // If the dependency array is exhaustive, with t, the translation function, it // triggers a useless re-render when ledger device is connected. // eslint-disable-next-line react-hooks/exhaustive-deps - }, [accountIndex, addressOffset, genesis, ledger]); + }, [accountIndex, addressOffset, genesis, ledger, isEthereum]); const refresh = useCallback(() => { setRefreshLock(true); @@ -185,5 +199,5 @@ export default function useLedger (genesis?: string | null, accountIndex = 0, ad setWarning(null); }, []); - return ({ ...getState(), address, error, isLoading, isLocked, ledger, refresh, warning }); + return ({ ...getState(), address, error, isLoading, isLocked, ledger, refresh, type, warning }); } diff --git a/packages/extension-ui/src/messaging.ts b/packages/extension-ui/src/messaging.ts index 97acb762..f1724389 100644 --- a/packages/extension-ui/src/messaging.ts +++ b/packages/extension-ui/src/messaging.ts @@ -143,8 +143,8 @@ export async function createAccountExternal (name: string, address: string, gene return sendMessage('pri(accounts.create.external)', { address, genesisHash, name }); } -export async function createAccountHardware (address: string, hardwareType: string, accountIndex: number, addressOffset: number, name: string, genesisHash: HexString): Promise { - return sendMessage('pri(accounts.create.hardware)', { accountIndex, address, addressOffset, genesisHash, hardwareType, name }); +export async function createAccountHardware (address: string, hardwareType: string, accountIndex: number, addressOffset: number, name: string, genesisHash: HexString, type: KeypairType): Promise { + return sendMessage('pri(accounts.create.hardware)', { accountIndex, address, addressOffset, genesisHash, hardwareType, name, type }); } export async function createAccountSuri (name: string, password: string, suri: string, type?: KeypairType, genesisHash?: HexString | null): Promise { diff --git a/packages/extension/public/locales/en/translation.json b/packages/extension/public/locales/en/translation.json index 6d536776..0e49b03f 100644 --- a/packages/extension/public/locales/en/translation.json +++ b/packages/extension/public/locales/en/translation.json @@ -182,6 +182,8 @@ "Ledger App": "", "Don't ask again": "", "Previous": "", + "ED25519 Account": "", + "Ethereum Account": "", "It looks like this request is coming from an suspicious origin. Please verify the source carefully.": "", "Password is not strong enough": "", "Password must be at least {{length}} characters long": ""