// SPDX-FileCopyrightText: © 2024 LEDGER SAS // SPDX-License-Identifier: Apache-2.0 import type { CurrencyConfig, Context } from '../../config' import type { CoinModuleImpl } from '../impl' import type { BlockInfo, ListOperationsOptions, TransactionIntent } from '../types' import { apiMethodKeys } from '../impl' import { withDefaults } from '../withDefaults' import { instrumentApi } from './instrumenter' type Cfg = CurrencyConfig type Ctx = Context const minimalModule: CoinModuleImpl = { lastBlock: (_ctx: Ctx): Promise => Promise.reject(new Error('stub')), getBalance: (_ctx: Ctx, _addr: string) => Promise.resolve([]), listOperations: (_ctx: Ctx, _addr: string, _opts: ListOperationsOptions) => Promise.resolve({ items: [] }), craftTransaction: (_ctx: Ctx, _intent: TransactionIntent) => Promise.reject(new Error('stub')), estimateFees: (_ctx: Ctx, _intent: TransactionIntent) => Promise.reject(new Error('stub')), combine: (_ctx: Ctx, tx: string, _sig: string[]): string => tx, broadcast: (_ctx: Ctx, _tx: string) => Promise.reject(new Error('stub')), craftTransactionData: (_ctx: Ctx, _intent: TransactionIntent) => ({ type: 'none' }) as const, } const passthrough = ( _name: string, fn: (...args: unknown[]) => unknown, args: unknown[] ): unknown => fn(...args) const ctx: Ctx = { config: () => Promise.reject(new Error('stub')), logger: () => undefined } const isMethod = (value: unknown): value is (...args: unknown[]) => unknown => typeof value === 'function' const plainHelper = (): string => 'plain-call' describe('instrumentApi', () => { it('returns a referentially stable wrapper for repeated reads of a method', () => { const api = instrumentApi(withDefaults(minimalModule), passthrough) // A consumer using a method as a dependency key (React effect, memo cache) must not // see a new function on every property access. expect(api.getBalance).toBe(api.getBalance) expect(api.combine).toBe(api.combine) }) it('gives each method its own wrapper', () => { const api = instrumentApi(withDefaults(minimalModule), passthrough) expect(api.getBalance).not.toBe(api.combine) }) it('instruments every method the contract declares', () => { // The guarantee the previous Proxy got for free from intercepting any function: nothing // the contract declares escapes instrumentation. Now that the wrapped set is an explicit // list, this is what holds it to the contract — together with the compile-time // exhaustiveness guard on `apiMethodKeys` itself. const seen: string[] = [] // Records the call without invoking the underlying method, so the "not supported" // stubs can be reached without throwing. const api: Record = instrumentApi(withDefaults(minimalModule), (name) => { seen.push(name) return undefined }) const present = apiMethodKeys.filter((name) => isMethod(api[name])) for (const name of present) { const method = api[name] if (isMethod(method)) method(ctx) } expect(seen).toEqual([...present]) // `listFeeOptions` is the one contract method withDefaults leaves absent; everything // else must be there. expect(apiMethodKeys.filter((name) => !present.includes(name))).toEqual(['listFeeOptions']) }) it('routes each call through the instrument function', () => { const seen: string[] = [] const api = instrumentApi(withDefaults(minimalModule), (name, fn, args) => { seen.push(name) return fn(...args) }) expect(api.combine(ctx, 'tx', [])).toBe('tx') expect(seen).toEqual(['combine']) }) it('forwards non-function properties untouched', () => { const api = instrumentApi( { ...withDefaults(minimalModule), marker: 'plain-value' }, passthrough ) expect(api.marker).toBe('plain-value') }) it('leaves members outside the contract uninstrumented', () => { // Ledger Live resolves a `CoinModuleApi & BridgeApi`: the non-API half must survive, and // instrumenting it would be a wrapper reaching outside the contract it wraps. const seen: string[] = [] const api = instrumentApi({ ...withDefaults(minimalModule), helper: plainHelper }, (name) => { seen.push(name) return undefined }) expect(api.helper).toBe(plainHelper) expect(api.helper()).toBe('plain-call') expect(api.supports('getStakes')).toBe(false) expect(seen).toEqual([]) }) it('preserves the receiver, so a method reading `this` still works', () => { // Each method is bound once at wrap time rather than on every call. A module written // with shorthand methods that reach a sibling through `this` depends on that binding. const selfReferential = { ...withDefaults(minimalModule), suffix: '-signed', combine(_ctx: Ctx, tx: string, _sig: string[]): string { return tx + this.suffix }, } const api = instrumentApi(selfReferential, passthrough) expect(api.combine(ctx, 'tx', [])).toBe('tx-signed') }) })