Skip to content

Commit

Permalink
feat: Generic Ledger app support (#2181)
Browse files Browse the repository at this point in the history
  • Loading branch information
rossbulat authored Jul 5, 2024
1 parent 71b218f commit e4b2c0c
Show file tree
Hide file tree
Showing 24 changed files with 443 additions and 399 deletions.
4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@
"@fortawesome/free-regular-svg-icons": "^6.5.2",
"@fortawesome/free-solid-svg-icons": "^6.5.2",
"@fortawesome/react-fontawesome": "^0.2.2",
"@ledgerhq/hw-transport-webhid": "^6.28.6",
"@ledgerhq/hw-transport-webhid": "^6.29.0",
"@polkadot/api": "^12.0.2",
"@polkadot/keyring": "^12.6.2",
"@polkadot/rpc-provider": "10.11.2",
Expand All @@ -36,7 +36,7 @@
"@w3ux/react-polkicon": "1.2.0",
"@w3ux/utils": "^0.3.0",
"@w3ux/validator-assets": "0.2.0-alpha.0",
"@zondax/ledger-substrate": "^0.44.2",
"@zondax/ledger-substrate": "^0.44.5",
"bignumber.js": "^9.1.2",
"bn.js": "^5.2.1",
"buffer": "^6.0.3",
Expand Down
8 changes: 4 additions & 4 deletions src/config/ledger.ts
Original file line number Diff line number Diff line change
@@ -1,19 +1,19 @@
// Copyright 2024 @polkadot-cloud/polkadot-staking-dashboard authors & contributors
// SPDX-License-Identifier: GPL-3.0-only

import type { LedgerApp } from 'contexts/LedgerHardware/types';
import type { LedgerChain } from 'contexts/LedgerHardware/types';
import KusamaSVG from 'img/appIcons/kusama.svg?react';
import PolkadotSVG from 'img/appIcons/polkadot.svg?react';

export const LedgerApps: LedgerApp[] = [
export const LedgerChains: LedgerChain[] = [
{
network: 'polkadot',
appName: 'Polkadot',
txMetadataChainId: 'dot',
Icon: PolkadotSVG,
},
{
network: 'kusama',
appName: 'Kusama',
txMetadataChainId: 'ksm',
Icon: KusamaSVG,
},
];
2 changes: 2 additions & 0 deletions src/consts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,3 +36,5 @@ export const TipsThresholdMedium = 1200;
*/
export const MaxPayoutDays = 60;
export const MaxEraRewardPointsEras = 10;
export const ZondaxMetadataHashApiUrl =
'https://api.zondax.ch/polkadot/node/metadata/hash';
4 changes: 2 additions & 2 deletions src/contexts/LedgerHardware/Utils.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
// SPDX-License-Identifier: GPL-3.0-only

import { localStorageOrDefault } from '@w3ux/utils';
import { LedgerApps } from 'config/ledger';
import { LedgerChains } from 'config/ledger';
import type { LedgerAddress } from './types';

// Ledger error keyed by type of error.
Expand Down Expand Up @@ -42,7 +42,7 @@ export const getLedgerErrorType = (err: string) => {

// Gets ledger app from local storage, fallback to first entry.
export const getLedgerApp = (network: string) =>
LedgerApps.find((a) => a.network === network) || LedgerApps[0];
LedgerChains.find((a) => a.network === network) || LedgerChains[0];

// Gets saved ledger addresses from local storage.
export const getLocalLedgerAddresses = (network?: string) => {
Expand Down
9 changes: 5 additions & 4 deletions src/contexts/LedgerHardware/defaults.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,8 @@ export const defaultLedgerHardwareContext: LedgerHardwareContextInterface = {
transportResponse: null,
integrityChecked: false,
setIntegrityChecked: (checked) => {},
checkRuntimeVersion: async (appName) => new Promise((resolve) => resolve()),
checkRuntimeVersion: async (txMetadataChainId) =>
new Promise((resolve) => resolve()),
setStatusCode: (a, s) => {},
setIsExecuting: (b) => {},
getIsExecuting: () => false,
Expand All @@ -23,10 +24,10 @@ export const defaultLedgerHardwareContext: LedgerHardwareContextInterface = {
setFeedback: (s, h) => {},
resetFeedback: () => {},
handleUnmount: () => {},
handleErrors: (appName, err) => {},
handleGetAddress: (appName, accountIndex) =>
handleErrors: (err) => {},
handleGetAddress: (txMetadataChainId, accountIndex, ss58Prefix) =>
new Promise((resolve) => resolve()),
handleSignTx: (appName, uid, index, payload) =>
handleSignTx: (txMetadataChainId, uid, index, payload) =>
new Promise((resolve) => resolve()),
handleResetLedgerTask: () => {},
runtimesInconsistent: false,
Expand Down
47 changes: 21 additions & 26 deletions src/contexts/LedgerHardware/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,8 @@ export const LedgerHardwareProvider = ({
children: ReactNode;
}) => {
const { t } = useTranslation('modals');
const { transactionVersion } = useApi().chainState.version;
const { chainState } = useApi();
const { transactionVersion } = chainState.version;

// Store whether a Ledger device task is in progress.
const [isExecuting, setIsExecutingState] = useState<boolean>(false);
Expand Down Expand Up @@ -86,37 +87,36 @@ export const LedgerHardwareProvider = ({
const runtimesInconsistent = useRef<boolean>(false);

// Checks whether runtime version is inconsistent with device metadata.
const checkRuntimeVersion = async (appName: string) => {
const checkRuntimeVersion = async (txMetadataChainId: string) => {
try {
setIsExecuting(true);
const { app } = await Ledger.initialise(appName);
const { app } = await Ledger.initialise(txMetadataChainId);
const result = await Ledger.getVersion(app);
const major = result?.major || 0;

if (Ledger.isError(result)) {
throw new Error(result.error_message);
}
setIsExecuting(false);
resetFeedback();

if (result.major < transactionVersion) {
if (major < transactionVersion) {
runtimesInconsistent.current = true;
}
setIntegrityChecked(true);
} catch (err) {
handleErrors(appName, err);
handleErrors(err);
}
};

// Gets an address from Ledger device.
const handleGetAddress = async (appName: string, accountIndex: number) => {
const handleGetAddress = async (
txMetadataChainId: string,
accountIndex: number,
ss58Prefix: number
) => {
try {
setIsExecuting(true);
const { app, productName } = await Ledger.initialise(appName);
const result = await Ledger.getAddress(app, accountIndex);
const { app, productName } = await Ledger.initialise(txMetadataChainId);
const result = await Ledger.getAddress(app, accountIndex, ss58Prefix);

if (Ledger.isError(result)) {
throw new Error(result.error_message);
}
setIsExecuting(false);
setFeedback(t('successfullyFetchedAddress'));
setTransportResponse({
Expand All @@ -129,29 +129,24 @@ export const LedgerHardwareProvider = ({
body: [result],
});
} catch (err) {
handleErrors(appName, err);
handleErrors(err);
}
};

// Signs a payload on Ledger device.
const handleSignTx = async (
appName: string,
txMetadataChainId: string,
uid: number,
index: number,
payload: AnyJson
) => {
try {
setIsExecuting(true);
const { app, productName } = await Ledger.initialise(appName);
const { app, productName } = await Ledger.initialise(txMetadataChainId);
setFeedback(t('approveTransactionLedger'));

const result = await Ledger.signPayload(app, index, payload);

if (Ledger.isError(result)) {
throw new Error(result.error_message);
}
setIsExecuting(false);
setFeedback(t('signedTransactionSuccessfully'));
setTransportResponse({
statusCode: 'SignedPayload',
device: { productName },
Expand All @@ -161,12 +156,12 @@ export const LedgerHardwareProvider = ({
},
});
} catch (err) {
handleErrors(appName, err);
handleErrors(err);
}
};

// Handles errors that occur during device calls.
const handleErrors = (appName: string, err: unknown) => {
const handleErrors = (err: unknown) => {
// Update feedback and status code state based on error received.
switch (getLedgerErrorType(String(err))) {
// Occurs when the device does not respond to a request within the timeout period.
Expand Down Expand Up @@ -223,7 +218,7 @@ export const LedgerHardwareProvider = ({
// Occurs when the app (e.g. Polkadot) is not open.
case 'appNotOpen':
setStatusFeedback({
message: t('openAppOnLedger', { appName }),
message: t('openAppOnLedger'),
helpKey: 'Open App On Ledger',
code: 'TransactionRejected',
});
Expand All @@ -245,7 +240,7 @@ export const LedgerHardwareProvider = ({
break;
// Handle all other errors.
default:
setFeedback(t('openAppOnLedger', { appName }), 'Open App On Ledger');
setFeedback(t('openAppOnLedger'), 'Open App On Ledger');
setStatusCode('failure', 'AppNotOpen');
}

Expand Down
55 changes: 21 additions & 34 deletions src/contexts/LedgerHardware/static/ledger.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,10 @@
// SPDX-License-Identifier: GPL-3.0-only

import TransportWebHID from '@ledgerhq/hw-transport-webhid';
import { newSubstrateApp, type SubstrateApp } from '@zondax/ledger-substrate';
import { PolkadotGenericApp } from '@zondax/ledger-substrate';
import { withTimeout } from '@w3ux/utils';
import { u8aToBuffer } from '@polkadot/util';
import type { AnyJson } from '@w3ux/types';

const LEDGER_DEFAULT_ACCOUNT = 0x80000000;
const LEDGER_DEFAULT_CHANGE = 0x80000000;
const LEDGER_DEFAULT_INDEX = 0x80000000;

export class Ledger {
// The ledger device transport. `null` when not actively in use.
static transport: AnyJson | null;
Expand All @@ -19,9 +14,13 @@ export class Ledger {
static isPaired = false;

// Initialise ledger transport, initialise app, and return with device info.
static initialise = async (appName: string) => {
static initialise = async (txMetadataChainId: string) => {
this.transport = await TransportWebHID.create();
const app = newSubstrateApp(Ledger.transport, appName);
const app = new PolkadotGenericApp(
Ledger.transport,
txMetadataChainId,
'https://api.zondax.ch/polkadot/transaction/metadata'
);
const { productName } = this.transport.device;
return { app, productName };
};
Expand All @@ -40,19 +39,8 @@ export class Ledger {
}
};

// Check if a response is an error.
static isError = (result: AnyJson) => {
const error = result?.error_message;
if (error) {
if (!error.startsWith('No errors')) {
return true;
}
}
return false;
};

// Gets device runtime version.
static getVersion = async (app: SubstrateApp) => {
static getVersion = async (app: PolkadotGenericApp) => {
await this.ensureOpen();
const result = await withTimeout(3000, app.getVersion(), {
onTimeout: () => this.transport?.close(),
Expand All @@ -62,16 +50,18 @@ export class Ledger {
};

// Gets an address from transport.
static getAddress = async (app: SubstrateApp, index: number) => {
static getAddress = async (
app: PolkadotGenericApp,
index: number,
ss58Prefix: number
) => {
await this.ensureOpen();

const bip42Path = `m/44'/354'/${index}'/${0}'/${0}'`;

const result = await withTimeout(
3000,
app.getAddress(
LEDGER_DEFAULT_ACCOUNT + index,
LEDGER_DEFAULT_CHANGE,
LEDGER_DEFAULT_INDEX + 0,
false
),
app.getAddress(bip42Path, ss58Prefix, false),
{
onTimeout: () => this.transport?.close(),
}
Expand All @@ -82,17 +72,14 @@ export class Ledger {

// Signs a payload on device.
static signPayload = async (
app: SubstrateApp,
app: PolkadotGenericApp,
index: number,
payload: AnyJson
) => {
await this.ensureOpen();
const result = await app.sign(
LEDGER_DEFAULT_ACCOUNT + index,
LEDGER_DEFAULT_CHANGE,
LEDGER_DEFAULT_INDEX + 0,
u8aToBuffer(payload.toU8a(true))
);

const bip42Path = `m/44'/354'/${index}'/${0}'/${0}'`;
const result = await app.sign(bip42Path, Buffer.from(payload.toU8a(true)));
await this.ensureClosed();
return result;
};
Expand Down
16 changes: 10 additions & 6 deletions src/contexts/LedgerHardware/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import type { MaybeString, NetworkName } from 'types';
export interface LedgerHardwareContextInterface {
integrityChecked: boolean;
setIntegrityChecked: (checked: boolean) => void;
checkRuntimeVersion: (appName: string) => Promise<void>;
checkRuntimeVersion: (txMetadataChainId: string) => Promise<void>;
transportResponse: AnyJson;
setStatusCode: (ack: string, statusCode: LedgerStatusCode) => void;
setIsExecuting: (v: boolean) => void;
Expand All @@ -19,11 +19,15 @@ export interface LedgerHardwareContextInterface {
setFeedback: (s: MaybeString, helpKey?: MaybeString) => void;
resetFeedback: () => void;
handleUnmount: () => void;
handleErrors: (appName: string, err: unknown) => void;
handleErrors: (err: unknown) => void;
runtimesInconsistent: boolean;
handleGetAddress: (appName: string, accountIndex: number) => Promise<void>;
handleGetAddress: (
txMetadataChainId: string,
accountIndex: number,
ss58Prefix: number
) => Promise<void>;
handleSignTx: (
appName: string,
txMetadataChainId: string,
uid: number,
index: number,
payload: AnyJson
Expand Down Expand Up @@ -72,9 +76,9 @@ export interface LedgerAddress {
pubKey: string;
}

export interface LedgerApp {
export interface LedgerChain {
network: NetworkName;
appName: string;
txMetadataChainId: string;
Icon: FunctionComponent<SVGProps<SVGSVGElement>>;
}

Expand Down
3 changes: 2 additions & 1 deletion src/contexts/TxMeta/defaults.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,8 @@ export const defaultTxMeta: TxMetaContextInterface = {
notEnoughFunds: false,
getPayloadUid: () => 0,
getTxPayload: () => {},
setTxPayload: (p, u) => {},
getTxPayloadValue: () => {},
setTxPayload: (payload, payloadValue, uid) => {},
incrementPayloadUid: () => 0,
resetTxPayload: () => {},
getTxSignature: () => null,
Expand Down
Loading

0 comments on commit e4b2c0c

Please sign in to comment.