// SPDX-FileCopyrightText: © 2026 LEDGER SAS // SPDX-License-Identifier: Apache-2.0 import type { CoinModuleImpl, OptionalApiKey } from '../api/impl' import type { CoinModuleApi, Memo, TxData } from '../api/types' import type { Context, CurrencyConfig } from '../config' import { optionalApiKeys } from '../api/impl' import { withDefaults } from '../api/withDefaults' /** Narrows a member reached by name to a callable without asserting its type. */ const isMethod = (value: unknown): value is (...args: unknown[]) => unknown => typeof value === 'function' /** * What a module reports about the capabilities it does not implement, as a consumer sees it * through `withDefaults`. Meant to be asserted whole: * * ```ts * await expect(capabilityReport(createApi(), context)).resolves.toEqual({ * unsupported: ['call', 'craftRawTransaction', 'getRewards', 'getValidators', 'register'], * inconsistent: [], * }) * ``` * * One expectation then covers what a module's api test used to spell out method by method: * that each capability is absent, that reaching it raises `" is not supported"`, and * that `supports()` agrees. `toEqual` makes it exhaustive — a capability dropped or newly * implemented changes the list, so the test notices instead of silently still passing. * * It reads a module written either way: a `CoinModuleImpl` that omits its capabilities, or a * value built by spreading `notSupportedApi()`. Both are "not implemented" as far as * `supports()` is concerned, which is what this observes. */ export type CapabilityReport = { /** Capabilities the module does not implement, sorted, each verified to raise its error. */ unsupported: OptionalApiKey[] /** * Capabilities `supports()` calls absent but which did not raise `" is not * supported"` when reached — a missing backfill, or an error naming another method. * Expected to be empty. */ inconsistent: OptionalApiKey[] } /** * Builds a {@link CapabilityReport} for a coin module. * * Only the capabilities `supports()` reports absent are actually invoked — a real * implementation is never called, so this never reaches the network. * * `getAccountInfo` is excluded: per ADR-045 its default is the `{ type: 'none' }` sentinel * rather than an error, so it is not a capability in the sense the rest of this list is. */ export async function capabilityReport< ConfigType extends CurrencyConfig, MemoType extends Memo, TxDataType extends TxData, >( impl: CoinModuleImpl, context: Context ): Promise { const resolved: CoinModuleApi & { supports: (method: OptionalApiKey) => boolean } = withDefaults(impl) const asRecord: Record = resolved const unsupported: OptionalApiKey[] = [] const inconsistent: OptionalApiKey[] = [] for (const name of optionalApiKeys) { if (name === 'getAccountInfo' || resolved.supports(name)) continue const method = asRecord[name] if (!isMethod(method)) { inconsistent.push(name) continue } // The framework default throws synchronously, but a module may still carry a stub written // as an async function, which rejects instead. Both count as raising the error. const raised = await Promise.resolve() .then(() => method(context)) .then( () => undefined, (error: unknown) => (error instanceof Error ? error.message : String(error)) ) if (raised === `${name} is not supported`) unsupported.push(name) else inconsistent.push(name) } return { unsupported: unsupported.sort(), inconsistent: inconsistent.sort() } }