// Copyright 2017-2025 @pezkuwi/hw-ledger authors & contributors // SPDX-License-Identifier: Apache-2.0 import type { TransportDef, TransportType } from '@pezkuwi/hw-ledger-transports/types'; import type { AccountOptionsGeneric, LedgerAddress, LedgerSignature, LedgerVersion } from './types.js'; import { PolkadotGenericApp } from '@zondax/ledger-substrate'; import { transports } from '@pezkuwi/hw-ledger-transports'; import { hexAddPrefix, u8aToBuffer, u8aWrapBytes } from '@pezkuwi/util'; import { ledgerApps } from './defaults.js'; export { packageInfo } from './packageInfo.js'; type Chain = keyof typeof ledgerApps; type WrappedResult = Awaited>; // FIXME This type is a copy of the `class ResponseError` // imported from `@zondax/ledger-js`. Happens because ledger-js includes // circular dependencies. This is a hack to avoid versioning issues // with Deno. interface ResponseError { errorMessage: string returnCode: number } /** @internal Wraps a PolkadotGenericApp call, checking the result for any errors which result in a rejection */ async function wrapError (promise: Promise): Promise { let result: T; try { result = await promise; } catch (e: unknown) { // We check to see if the propogated error is the newer ResponseError type. // The response code use to be part of the result, but with the latest breaking changes from 0.42.x // the interface and it's types have completely changed. if ((e as ResponseError).returnCode) { throw new Error(`${(e as ResponseError).returnCode}: ${(e as ResponseError).errorMessage}`); } throw new Error((e as Error).message); } return result; } /** @internal Wraps a signEd25519/signRawEd25519 call and returns the associated signature */ function sign (method: 'signEd25519' | 'signRawEd25519', message: Uint8Array, slip44: number, accountIndex = 0, addressOffset = 0): (app: PolkadotGenericApp) => Promise { const bip42Path = `m/44'/${slip44}'/${accountIndex}'/${0}'/${addressOffset}'`; return async (app: PolkadotGenericApp): Promise => { const { signature } = await wrapError(app[method](bip42Path, u8aToBuffer(message))); return { signature: hexAddPrefix(signature.toString('hex')) }; }; } /** @internal Wraps a signEcdsa/signRawEcdsa call and returns the associated signature */ function signEcdsa (method: 'signEcdsa' | 'signRawEcdsa', message: Uint8Array, slip44: number, accountIndex = 0, addressOffset = 0): (app: PolkadotGenericApp) => Promise { const bip42Path = `m/44'/${slip44}'/${accountIndex}'/${0}'/${addressOffset}'`; return async (app: PolkadotGenericApp): Promise => { const { r, s, v } = await wrapError(app[method](bip42Path, u8aToBuffer(message))); const signature = Buffer.concat([r, s, v]); return { signature: hexAddPrefix(signature.toString('hex')) }; }; } /** @internal Wraps a signWithMetadataEd25519 call and returns the associated signature */ function signWithMetadata (message: Uint8Array, slip44: number, accountIndex = 0, addressOffset = 0, { metadata }: Partial = {}): (app: PolkadotGenericApp) => Promise { const bip42Path = `m/44'/${slip44}'/${accountIndex}'/${0}'/${addressOffset}'`; return async (app: PolkadotGenericApp): Promise => { if (!metadata) { throw new Error('The metadata option must be present when using signWithMetadata'); } const bufferMsg = Buffer.from(message); const { signature } = await wrapError(app.signWithMetadataEd25519(bip42Path, bufferMsg, metadata)); return { signature: hexAddPrefix(signature.toString('hex')) }; }; } /** @internal Wraps a signWithMetadataEcdsa call and returns the associated signature */ function signWithMetadataEcdsa (message: Uint8Array, slip44: number, accountIndex = 0, addressOffset = 0, { metadata }: Partial = {}): (app: PolkadotGenericApp) => Promise { const bip42Path = `m/44'/${slip44}'/${accountIndex}'/${0}'/${addressOffset}'`; return async (app: PolkadotGenericApp): Promise => { if (!metadata) { throw new Error('The metadata option must be present when using signWithMetadata'); } const bufferMsg = Buffer.from(message); const { r, s, v } = await wrapError(app.signWithMetadataEcdsa(bip42Path, bufferMsg, metadata)); const signature = Buffer.concat([r, s, v]); return { signature: hexAddPrefix(signature.toString('hex')) }; }; } /** * @name Ledger * * @description * A very basic wrapper for a ledger app - * - it connects automatically on use, creating an underlying interface as required * - Promises reject with errors (unwrapped errors from @zondax/ledger-substrate-js) */ export class LedgerGeneric { readonly #transportDef: TransportDef; readonly #slip44: number; /** * The chainId is represented by the chains token in all lowercase. Example: Polkadot -> dot */ readonly #chainId?: string; /** * The metaUrl is seen as a server url that the underlying `PolkadotGenericApp` will use to * retrieve the signature given a tx blob, and a chainId. It is important to note that if you would like to avoid * having any network calls made, use `signWithMetadata`, and avoid `sign`. */ readonly #metaUrl?: string; #app: PolkadotGenericApp | null = null; constructor (transport: TransportType, chain: Chain, slip44: number, chainId?: string, metaUrl?: string) { const ledgerName = ledgerApps[chain]; const transportDef = transports.find(({ type }) => type === transport); if (!ledgerName) { throw new Error(`Unsupported Ledger chain ${chain}`); } else if (!transportDef) { throw new Error(`Unsupported Ledger transport ${transport}`); } this.#metaUrl = metaUrl; this.#chainId = chainId; this.#slip44 = slip44; this.#transportDef = transportDef; } /** * @description Returns the address associated with a specific Ed25519 account & address offset. Optionally * asks for on-device confirmation */ public async getAddress (ss58Prefix: number, confirm = false, accountIndex = 0, addressOffset = 0): Promise { const bip42Path = `m/44'/${this.#slip44}'/${accountIndex}'/${0}'/${addressOffset}'`; return this.withApp(async (app: PolkadotGenericApp): Promise => { const { address, pubKey } = await wrapError(app.getAddressEd25519(bip42Path, ss58Prefix, confirm)); return { address, publicKey: hexAddPrefix(pubKey) }; }); } /** * @description Returns the address associated with a specific ecdsa account & address offset. Optionally * asks for on-device confirmation */ public async getAddressEcdsa (confirm = false, accountIndex = 0, addressOffset = 0) { const bip42Path = `m/44'/${this.#slip44}'/${accountIndex}'/${0}'/${addressOffset}'`; return this.withApp(async (app: PolkadotGenericApp): Promise => { const { address, pubKey } = await wrapError(app.getAddressEcdsa(bip42Path, confirm)); return { address, publicKey: hexAddPrefix(pubKey) }; }); } /** * @description Returns the version of the Ledger application on the device */ public async getVersion (): Promise { return this.withApp(async (app: PolkadotGenericApp): Promise => { const { deviceLocked: isLocked, major, minor, patch, testMode: isTestMode } = await wrapError(app.getVersion()); return { isLocked: !!isLocked, isTestMode: !!isTestMode, version: [major || 0, minor || 0, patch || 0] }; }); } /** * @description Signs a transaction on the Ledger device. This requires the LedgerGeneric class to be instantiated with `chainId`, and `metaUrl` */ public async sign (message: Uint8Array, accountIndex?: number, addressOffset?: number): Promise { return this.withApp(sign('signEd25519', message, this.#slip44, accountIndex, addressOffset)); } /** * @description Signs a message (non-transactional) on the Ledger device */ public async signRaw (message: Uint8Array, accountIndex?: number, addressOffset?: number): Promise { return this.withApp(sign('signRawEd25519', u8aWrapBytes(message), this.#slip44, accountIndex, addressOffset)); } /** * @description Signs a transaction on the Ledger device with Ecdsa. This requires the LedgerGeneric class to be instantiated with `chainId`, and `metaUrl` */ public async signEcdsa (message: Uint8Array, accountIndex?: number, addressOffset?: number): Promise { return this.withApp(signEcdsa('signEcdsa', u8aWrapBytes(message), this.#slip44, accountIndex, addressOffset)); } /** * @description Signs a message with Ecdsa (non-transactional) on the Ledger device */ public async signRawEcdsa (message: Uint8Array, accountIndex?: number, addressOffset?: number): Promise { return this.withApp(signEcdsa('signRawEcdsa', u8aWrapBytes(message), this.#slip44, accountIndex, addressOffset)); } /** * @description Signs a transaction on the ledger device provided some metadata. */ public async signWithMetadata (message: Uint8Array, accountIndex?: number, addressOffset?: number, options?: Partial): Promise { return this.withApp(signWithMetadata(message, this.#slip44, accountIndex, addressOffset, options)); } /** * @description Signs a transaction on the ledger device for an ecdsa signature provided some metadata. */ public async signWithMetadataEcdsa (message: Uint8Array, accountIndex?: number, addressOffset?: number, options?: Partial) { return this.withApp(signWithMetadataEcdsa(message, this.#slip44, accountIndex, addressOffset, options)); } /** * @internal * * Returns a created PolkadotGenericApp to perform operations against. Generally * this is only used internally, to ensure consistent bahavior. */ async withApp (fn: (app: PolkadotGenericApp) => Promise): Promise { try { if (!this.#app) { const transport = await this.#transportDef.create(); // We need this override for the actual type passing - the Deno environment // is quite a bit stricter and it yields invalids between the two (specifically // since we mangle the imports from .default in the types for CJS/ESM and between // esm.sh versions this yields problematic outputs) // // eslint-disable-next-line @typescript-eslint/no-unsafe-argument, @typescript-eslint/no-explicit-any this.#app = new PolkadotGenericApp(transport as any, this.#chainId, this.#metaUrl); } return await fn(this.#app); } catch (error) { this.#app = null; throw error; } } }