// SPDX-FileCopyrightText: © 2026 LEDGER SAS // SPDX-License-Identifier: Apache-2.0 import type { StellarCoinConfig, StellarContext } from '../config' import type { StellarMemo } from '../types' import type { CoinModuleImpl, CraftedTransaction, FeeEstimation, ListOperationsOptions, Operation, Page, TransactionIntent, TxDataNotSupported, } from '@ledgerhq/coin-module-framework/api/index' import { rejectBalanceOptions } from '@ledgerhq/coin-module-framework/api/getBalance/rejectBalanceOptions' import { craftTransactionData } from '@ledgerhq/coin-module-framework/logic/craftTransactionData' import { xdr } from '@stellar/stellar-sdk' import { broadcast } from '../logic/broadcast' import { combine } from '../logic/combine' import { craftTransaction } from '../logic/craftTransaction' import { estimateFees } from '../logic/estimateFees' import { getBalance } from '../logic/getBalance' import { getBlock } from '../logic/getBlock' import { getBlockInfo } from '../logic/getBlockInfo' import { lastBlock } from '../logic/lastBlock' import { listOperations } from '../logic/listOperations' import { validateAddress } from '../logic/validateAddress' import { validateIntent } from '../logic/validateIntent' import { fetchSequence, registerHorizonInterceptors } from '../network' // The caller builds the {@link StellarContext} (config + logger) and passes it to each method // (ADR-019). Horizon interceptors (logging + URL rewrite) need a context, so — since createApi no // longer holds one — they are registered lazily on the first call through `withInterceptors` // (registerHorizonInterceptors is idempotent). // Checked against CoinModuleImpl with `satisfies` rather than annotated as it, so the precise shape // survives and a caller sees exactly which methods exist. // // Omitted rather than stubbed: `call`, `register`, `craftRawTransaction`, `getStakes`, `getRewards` // and `getValidators` — Stellar has no staking positions to report here, takes no externally-built // transaction, and exposes no contract-read or enrollment step. The consumer resolver applies // `withDefaults`, which answers "not supported" for each. export function createApi() { const withInterceptors = (fn: (ctx: StellarContext, ...args: A) => R) => (ctx: StellarContext, ...args: A): R => { registerHorizonInterceptors(ctx) return fn(ctx, ...args) } return { broadcast: withInterceptors((ctx, tx, _options?) => broadcast(ctx, tx)), combine: (_ctx, tx, signature, options?) => compose(tx, signature, options?.pubkey), craftTransaction: withInterceptors((ctx, transactionIntent, options?) => craft(ctx, transactionIntent, options?.customFees) ), // Stellar's fee comes from the network's base fee, so the intent carries nothing this needs. estimateFees: withInterceptors((ctx, _intent, _options?) => estimate(ctx)), getBalance: withInterceptors((ctx, address, options?) => rejectBalanceOptions(() => getBalance(ctx, address), options) ), lastBlock: withInterceptors((ctx) => lastBlock(ctx)), listOperations: withInterceptors((ctx, address, options) => operations(ctx, address, options)), getBlock: withInterceptors((ctx, height) => getBlock(ctx, height)), getBlockInfo: withInterceptors((ctx, height) => getBlockInfo(ctx, height)), validateIntent: withInterceptors((ctx, transactionIntent, balances, options?) => validateIntent(ctx, transactionIntent, balances, options?.customFees) ), getNextSequence: withInterceptors(async (ctx, address) => { const sequence = await fetchSequence(await ctx.config(), address) return BigInt(sequence.plus(1).toFixed()) }), validateAddress: (_ctx, address, parameters) => validateAddress(address, parameters), craftTransactionData: (_ctx, intent) => craftTransactionData(intent), } satisfies CoinModuleImpl } async function craft( context: StellarContext, transactionIntent: TransactionIntent, customFees?: FeeEstimation ): Promise { const fees = customFees?.value || (await estimateFees(context)) // NOTE: check how many memos, throw if more than one? // if (transactionIntent.memos && transactionIntent.memos.length > 1) { // throw new Error("Stellar only supports one memo per transaction."); // } const memo = 'memo' in transactionIntent ? transactionIntent.memo : undefined const tx = await craftTransaction( context, { address: transactionIntent.sender }, { type: transactionIntent.type, recipient: transactionIntent.recipient, amount: transactionIntent.amount, fee: fees, ...(transactionIntent.asset.type !== 'native' && 'assetReference' in transactionIntent.asset ? { assetCode: transactionIntent.asset.assetReference, assetIssuer: transactionIntent.asset.assetOwner, } : {}), memoType: memo?.type, ...(memo && 'value' in memo ? { memoValue: memo.value } : {}), } ) // Note: the API returns the signature base, not the full XDR, see BACK-8727 for more context return { transaction: tx.signatureBase } } function compose(tx: string, signature: string[], pubkey?: string): string { if (!pubkey) { throw new Error('Missing pubkey') } // note: accept here `TransactionEnvelope` or `TransactionSignaturePayload`, see BACK-8727 for more context return combine(envelopeFromAnyXDR(tx, 'base64'), signature, pubkey) } async function estimate(context: StellarContext): Promise { const value = await estimateFees(context) return { value } } async function operations( context: StellarContext, address: string, options: ListOperationsOptions ): Promise> { const { items, next } = await listOperations(context, address, options) return { items, next: next || undefined } } /** * Deserialize a transaction envelope, also accepting transaction signature payload form. * * @param input serialized `TransactionEnvelope` or `TransactionSignaturePayload` * @param format serialization encoding */ export function envelopeFromAnyXDR( input: string, format: 'hex' | 'base64' ): xdr.TransactionEnvelope { try { return xdr.TransactionEnvelope.fromXDR(input, format) } catch (envelopeError) { try { return signatureBaseToEnvelope(xdr.TransactionSignaturePayload.fromXDR(input, format)) } catch (signatureBaseError) { throw new Error( `Failed decoding transaction as an envelope (${envelopeError}) or as a signature base: (${signatureBaseError})` ) } } } /** * Convert a `TransactionSignaturePayload` into a `TransactionEnvelope`. * * @param signatureBase deserialized `TransactionSignaturePayload` */ function signatureBaseToEnvelope( signatureBase: xdr.TransactionSignaturePayload ): xdr.TransactionEnvelope { const tx = signatureBase.taggedTransaction().value() if (tx instanceof xdr.Transaction) { return xdr.TransactionEnvelope.envelopeTypeTx( new xdr.TransactionV1Envelope({ tx, signatures: [] }) ) } else { return xdr.TransactionEnvelope.envelopeTypeTxFeeBump( new xdr.FeeBumpTransactionEnvelope({ tx, signatures: [] }) ) } }