// SPDX-FileCopyrightText: © 2026 LEDGER SAS // SPDX-License-Identifier: Apache-2.0 import type { RawOperation, StellarMemo } from '../types' /* * Serialization functions from Horizon to Ledger Live types */ import type { Operation, Unit } from '@ledgerhq/coin-module-framework/api/types' import type { Horizon } from '@stellar/stellar-sdk' import { parseCurrencyUnit } from '@ledgerhq/coin-module-framework/currencies' import BigNumber from 'bignumber.js' // Constants const BASE_RESERVE_MIN_COUNT = 2 export const BASE_RESERVE = 0.5 export const MIN_BALANCE = 1 /** * Stellar operation types that produce a typed (non-FEES) Operation when the * address is involved. Other types fall back to a single FEES row per tx (when * the address paid the fee) — see `formatFeeOnlyOperation`. */ const SUPPORTED_OPERATION_TYPES = new Set([ 'create_account', 'payment', 'path_payment_strict_send', 'path_payment_strict_receive', 'change_trust', 'liquidity_pool_deposit', 'liquidity_pool_withdraw', ]) export function getReservedBalance(account: Horizon.ServerApi.AccountRecord): BigNumber { const numOfSponsoringEntries = Number(account.num_sponsoring) const numOfSponsoredEntries = Number(account.num_sponsored) const nativeAsset = account.balances?.find( (b): b is Horizon.HorizonApi.BalanceLineNative => b.asset_type === 'native' ) const amountInOffers = new BigNumber(nativeAsset?.selling_liabilities || 0) const numOfEntries = new BigNumber(account.subentry_count || 0) return new BigNumber(BASE_RESERVE_MIN_COUNT) .plus(numOfEntries) .plus(numOfSponsoringEntries) .minus(numOfSponsoredEntries) .times(BASE_RESERVE) .plus(amountInOffers) } export async function rawOperationsToOperations( unit: Unit, operations: RawOperation[], addr: string, accountId: string, minHeight: number ): Promise { // First pass: per raw op, format it when it passes the address+type filters. // A single raw op can produce zero, one, or several `Operation` rows (e.g. // a self-swap path payment surfaces both its native source debit and its // token destination credit; a liquidity_pool_deposit surfaces one row per // deposited reserve). const formatted = await Promise.all( operations.map(async (operation) => { if (!operationInvolvesAddress(operation, addr)) return [] if (!SUPPORTED_OPERATION_TYPES.has(operation.type)) return [] return formatOperation(unit, operation, accountId, addr, minHeight) }) ) // Stellar transactions whose fee is already carried by at least one surfaced // Operation. We never emit a redundant fee row for these. const txHashesAlreadySurfaced = new Set(formatted.flat().map((op) => op.tx.hash)) // Second pass: for every Stellar tx the address actually paid the fee on but // that produced no surfaced Operation (e.g. `create_claimable_balance`-only, // `set_options`-only, `bump_sequence`-only, ...), emit a single fee-only // Operation so its `fee_charged` is not silently dropped from the operations // list. We slot it at the position of the first raw op of that tx in the // input order to preserve the page's descending order. const feeOnlyEmitted = new Set() const feeOnly = await Promise.all( operations.map(async (operation) => { if (txHashesAlreadySurfaced.has(operation.transaction_hash)) return undefined if (feeOnlyEmitted.has(operation.transaction_hash)) return undefined feeOnlyEmitted.add(operation.transaction_hash) return formatFeeOnlyOperation(operation, accountId, addr, minHeight) }) ) const result: Operation[] = [] for (let i = 0; i < operations.length; i++) { const feeOp = feeOnly[i] if (feeOp) result.push(feeOp) result.push(...formatted[i]) } return result } function operationInvolvesAddress(operation: RawOperation, addr: string): boolean { return ( operation.from === addr || operation.to === addr || operation.funder === addr || operation.account === addr || operation.trustor === addr || operation.source_account === addr ) } type FormatCtx = { unit: Unit rawOperation: RawOperation accountId: string addr: string transaction: Awaited> baseTxFields: Operation['tx'] baseDetails: { pagingToken: string blockTime: Date index: string memo: StellarMemo | undefined sequence: string | undefined } } async function formatOperation( unit: Unit, rawOperation: RawOperation, accountId: string, addr: string, minHeight: number ): Promise { const transaction = await rawOperation.transaction() if (transaction.ledger_attr < minHeight) return [] // Block hash left empty — A4 resolves it via the blockInfo(height) endpoint, // which benefits from Cloudflare caching and deduplicates across ops in the // same ledger. This avoids the N+1 .ledger() calls that were hitting Horizon // rate limits (BACK-12184). const blockTime = new Date(rawOperation.created_at) const memo = decodeMemo(transaction) const baseTxFields: Operation['tx'] = { hash: rawOperation.transaction_hash, block: { hash: '', time: blockTime, height: transaction.ledger_attr, }, fees: BigInt(transaction.fee_charged), date: new Date(rawOperation.created_at), failed: !rawOperation.transaction_successful, ...(transaction.fee_account ? { feesPayer: transaction.fee_account } : {}), } const baseDetails = { pagingToken: rawOperation.paging_token, blockTime, index: rawOperation.id, memo, sequence: parseSequence(transaction.source_account_sequence), } const ctx: FormatCtx = { unit, rawOperation, accountId, addr, transaction, baseTxFields, baseDetails, } switch (rawOperation.type) { case 'path_payment_strict_send': case 'path_payment_strict_receive': return formatPathPayment(ctx) case 'liquidity_pool_deposit': return formatLiquidityPoolDeposit(ctx) case 'liquidity_pool_withdraw': return formatLiquidityPoolWithdraw(ctx) default: return formatSimpleOperation(ctx) } } export function parseSequence(seq: string | number | undefined): string | undefined { const bn = new BigNumber(seq ?? '') return bn.isNaN() ? undefined : bn.toString() } /** * Format a single-leg operation — `payment`, `create_account`, `change_trust`. * Emits exactly one Operation. Token movements use `type: "IN"|"OUT"` with * `value` set to the real token amount (in stroops), matching every other * coin-module. The token identity is on `asset` and `details.assetCode` / * `details.assetIssuer`. */ function formatSimpleOperation(ctx: FormatCtx): Operation[] { const { rawOperation, accountId, transaction, baseTxFields, baseDetails, addr } = ctx const type = getSimpleOperationType(rawOperation, addr) const value = getSimpleOperationValue(ctx.unit, rawOperation, transaction, type) const recipients = getRecipients(rawOperation) const isToken = !!(rawOperation.asset_code && rawOperation.asset_issuer) // For failed token ops the transfer didn't execute — no tokens moved, only XLM fee was charged. // The fee is already on tx.fees, so the token operation value is 0. const isFailed = !rawOperation.transaction_successful const finalValue = isToken && isFailed ? 0n : BigInt(value.toString()) return [ { id: `${accountId}-${rawOperation.transaction_hash}-${type}`, value: finalValue, type, senders: [rawOperation.source_account], recipients, tx: baseTxFields, asset: isToken && rawOperation.asset_code && rawOperation.asset_issuer ? { type: 'token', assetReference: rawOperation.asset_code, assetOwner: rawOperation.asset_issuer, } : { type: 'native' }, details: { ...baseDetails, assetCode: rawOperation.asset_code, assetIssuer: rawOperation.asset_issuer, assetAmount: rawOperation.asset_code ? finalValue.toString() : undefined, ledgerOpType: type, }, }, ] } // ===== path_payment_strict_send / path_payment_strict_receive ===== /** * Format a `path_payment_strict_*` op. Horizon exposes both the source asset * (`source_asset_*` / `source_amount`) and the destination asset (`asset_*` / * `amount`) on the same operation record. Each side that touches the listed * address is surfaced as its own row so the native XLM debit/credit is never * dropped: * - if `from === addr`, emit the source-side row (OUT) using `source_amount`; * - if `to === addr`, emit the destination-side row (IN) using `amount`; * - if both are the address (self-swap), both rows are emitted. * * Both native and token rows use `type: OUT|IN` and carry the real amount * in `value`. Token amounts are also written to `details.assetAmount`. */ function formatPathPayment(ctx: FormatCtx): Operation[] { const { rawOperation, addr } = ctx const ops: Operation[] = [] const isSource = rawOperation.from === addr const isDest = rawOperation.to === addr const isMultiLeg = isSource && isDest const sourceAsset = readSourceAsset(rawOperation) const destAsset = readDestAsset(rawOperation) if (isSource) { const amountStr = 'source_amount' in rawOperation && typeof rawOperation.source_amount === 'string' ? rawOperation.source_amount : 'amount' in rawOperation && typeof rawOperation.amount === 'string' ? rawOperation.amount : '0' ops.push( buildPathPaymentLeg( ctx, 'OUT', sourceAsset, parseCurrencyUnit(ctx.unit, amountStr), isMultiLeg ) ) } if (isDest) { const amountStr = 'amount' in rawOperation && typeof rawOperation.amount === 'string' ? rawOperation.amount : '0' ops.push( buildPathPaymentLeg(ctx, 'IN', destAsset, parseCurrencyUnit(ctx.unit, amountStr), isMultiLeg) ) } return ops } type AssetInfo = { type: 'native' } | { type: 'token'; code: string; issuer: string } function readSourceAsset(rawOperation: RawOperation): AssetInfo { if ('source_asset_type' in rawOperation && rawOperation.source_asset_type === 'native') { return { type: 'native' } } if ( 'source_asset_code' in rawOperation && typeof rawOperation.source_asset_code === 'string' && 'source_asset_issuer' in rawOperation && typeof rawOperation.source_asset_issuer === 'string' ) { return { type: 'token', code: rawOperation.source_asset_code, issuer: rawOperation.source_asset_issuer, } } return { type: 'native' } } function readDestAsset(rawOperation: RawOperation): AssetInfo { if ('asset_type' in rawOperation && rawOperation.asset_type === 'native') return { type: 'native' } if (rawOperation.asset_code && rawOperation.asset_issuer) { return { type: 'token', code: rawOperation.asset_code, issuer: rawOperation.asset_issuer, } } return { type: 'native' } } // Failed-tx convention: IN rows go to 0. // For native OUT rows, value = fee_charged (the XLM fee is the only thing lost). // For token OUT rows, value = 0 (no tokens moved; the XLM fee is on tx.fees). function computeLegValue( isFailed: boolean, direction: 'IN' | 'OUT', isNative: boolean, amount: BigNumber, feeCharged: string | number ): bigint { if (isFailed) { if (direction === 'IN') return 0n return isNative ? BigInt(feeCharged) : 0n } return BigInt(amount.toString()) } type LegAssetFields = { label: string asset: Operation['asset'] assetCode: string | undefined assetIssuer: string | undefined assetAmount: string | undefined } function describeLegAsset(asset: AssetInfo, amount: BigNumber): LegAssetFields { if (asset.type === 'native') { return { label: 'NATIVE', asset: { type: 'native' }, assetCode: undefined, assetIssuer: undefined, assetAmount: undefined, } } return { label: `TOKEN-${asset.code}`, asset: { type: 'token', assetReference: asset.code, assetOwner: asset.issuer }, assetCode: asset.code, assetIssuer: asset.issuer, assetAmount: amount.toString(), } } function buildPathPaymentLeg( { rawOperation, accountId, addr, transaction, baseTxFields, baseDetails }: FormatCtx, direction: 'IN' | 'OUT', asset: AssetInfo, amount: BigNumber, isMultiLeg: boolean ): Operation { const isNative = asset.type === 'native' const value = computeLegValue( !rawOperation.transaction_successful, direction, isNative, amount, transaction.fee_charged ) const ledgerOpType = direction const { label, asset: opAsset, assetCode, assetIssuer, assetAmount: rawAssetAmount, } = describeLegAsset(asset, amount) // For failed ops, assetAmount must match value (0) — no tokens moved. const assetAmount = rawAssetAmount !== undefined ? value.toString() : undefined const legSuffix = isMultiLeg ? `-${direction}-${label}` : '' // Counterparties: for the OUT leg the recipient is whatever Horizon recorded // as `to`; for the IN leg the sender is whoever Horizon recorded as `from` // (or the operation's `source_account` when `from` is missing in test // fixtures). const senders = direction === 'OUT' ? [rawOperation.source_account] : [rawOperation.from || rawOperation.source_account] const recipients = direction === 'OUT' ? [rawOperation.to || addr] : [addr] return { id: `${accountId}-${rawOperation.transaction_hash}-${ledgerOpType}${legSuffix}`, value, type: ledgerOpType, senders, recipients, tx: baseTxFields, asset: opAsset, details: { ...baseDetails, index: isMultiLeg ? `${rawOperation.id}${legSuffix}` : rawOperation.id, assetCode, assetIssuer, assetAmount, ledgerOpType, }, } } // ===== liquidity_pool_deposit / liquidity_pool_withdraw ===== /** * Format a `liquidity_pool_deposit` op into one row per asset that left the * account (Horizon stores the actual deposited amounts on `reserves_deposited`, * which mirror the `liquidity_pool_deposited` effect). */ function formatLiquidityPoolDeposit(ctx: FormatCtx): Operation[] { const reserves = readPoolReserves(ctx.unit, ctx.rawOperation, 'reserves_deposited') return reserves.map((reserve) => buildPoolLeg(ctx, 'OUT', reserve, reserves.length > 1)) } /** * Format a `liquidity_pool_withdraw` op into one row per asset that came back * to the account (Horizon stores the actual withdrawn amounts on * `reserves_received`, which mirror the `liquidity_pool_withdrew` effect). * Failed withdraws often omit `asset` on those reserves (amount is `0`); those * entries are skipped so the tx can still surface as a fee-only Operation. */ function formatLiquidityPoolWithdraw(ctx: FormatCtx): Operation[] { const reserves = readPoolReserves(ctx.unit, ctx.rawOperation, 'reserves_received') return reserves.map((reserve) => buildPoolLeg(ctx, 'IN', reserve, reserves.length > 1)) } type PoolReserve = { asset: AssetInfo; amount: BigNumber } function readPoolReserves( unit: Unit, rawOperation: RawOperation, field: 'reserves_deposited' | 'reserves_received' ): PoolReserve[] { const raw = rawOperation[field] if (!raw || raw.length === 0) return [] const reserves: PoolReserve[] = [] for (const r of raw) { if (typeof r.asset !== 'string') continue reserves.push({ asset: parseAssetString(r.asset), amount: parseCurrencyUnit(unit, r.amount) }) } return reserves } function parseAssetString(asset: string): AssetInfo { if (asset === 'native') return { type: 'native' } const idx = asset.indexOf(':') if (idx === -1) return { type: 'native' } return { type: 'token', code: asset.slice(0, idx), issuer: asset.slice(idx + 1), } } function buildPoolLeg( { rawOperation, accountId, addr, transaction, baseTxFields, baseDetails }: FormatCtx, direction: 'IN' | 'OUT', reserve: PoolReserve, isMultiLeg: boolean ): Operation { const { asset, amount } = reserve const isFailed = !rawOperation.transaction_successful const isNative = asset.type === 'native' const value = isFailed ? direction === 'IN' ? 0n : isNative ? BigInt(transaction.fee_charged) : 0n : BigInt(amount.toString()) const ledgerOpType = direction // Pool ops always involve at least 2 reserves (one of which is usually // native), so we always disambiguate per-leg ids/indices to avoid clashes // when a single raw op produces multiple rows. const legSuffix = isMultiLeg ? `-${asset.type === 'native' ? 'NATIVE' : `TOKEN-${asset.code}`}` : '' return { id: `${accountId}-${rawOperation.transaction_hash}-${ledgerOpType}${legSuffix}`, value, type: ledgerOpType, senders: [addr], recipients: [], tx: baseTxFields, asset: asset.type === 'native' ? { type: 'native' } : { type: 'token', assetReference: asset.code, assetOwner: asset.issuer }, details: { ...baseDetails, index: isMultiLeg ? `${rawOperation.id}${legSuffix}` : rawOperation.id, assetCode: asset.type === 'native' ? undefined : asset.code, assetIssuer: asset.type === 'native' ? undefined : asset.issuer, assetAmount: asset.type === 'native' ? undefined : value.toString(), ledgerOpType, }, } } /** * Emit a single fee-only Operation that carries `fee_charged` for a Stellar * transaction the address paid for but which produced no other surfaced * Operation. The Operation is marked `type: "FEES"` so the downstream * generic-coin-framework adapter renders it as a fee entry with * `value = 0 + fees` and `fee = fees`, matching the on-chain XLM debit. */ async function formatFeeOnlyOperation( rawOperation: RawOperation, accountId: string, addr: string, minHeight: number ): Promise { const transaction = await rawOperation.transaction() if (transaction.ledger_attr < minHeight) return undefined // For non-fee-bump transactions Horizon sets `fee_account === source_account`; // for fee-bump transactions it is the outer fee payer. Only emit a fee row // when the address we are listing is the actual payer of the network fee. const feePayer = transaction.fee_account ?? transaction.source_account if (feePayer !== addr) return undefined const blockTime = new Date(rawOperation.created_at) const memo = decodeMemo(transaction) return { id: `${accountId}-${rawOperation.transaction_hash}-FEES`, value: 0n, type: 'FEES', senders: [feePayer], recipients: [], tx: { hash: rawOperation.transaction_hash, block: { hash: '', time: blockTime, height: transaction.ledger_attr, }, fees: BigInt(transaction.fee_charged), date: new Date(rawOperation.created_at), failed: !rawOperation.transaction_successful, ...(transaction.fee_account ? { feesPayer: transaction.fee_account } : {}), }, asset: { type: 'native' }, details: { pagingToken: rawOperation.paging_token, assetCode: undefined, assetIssuer: undefined, assetAmount: undefined, ledgerOpType: 'FEES', blockTime, index: rawOperation.id, memo, sequence: parseSequence(transaction.source_account_sequence), }, } } export function decodeMemo( transaction: Horizon.ServerApi.TransactionRecord ): StellarMemo | undefined { switch (transaction.memo_type) { case 'none': return { type: 'NO_MEMO' } case 'id': return transaction.memo ? { type: 'MEMO_ID', value: transaction.memo } : undefined case 'text': return transaction.memo ? { type: 'MEMO_TEXT', value: transaction.memo } : undefined case 'return': return transaction.memo ? { type: 'MEMO_RETURN', value: Buffer.from(transaction.memo, 'base64').toString('hex'), } : undefined case 'hash': return transaction.memo ? { type: 'MEMO_HASH', value: Buffer.from(transaction.memo, 'base64').toString('hex'), } : undefined default: return undefined } } function getRecipients(operation: RawOperation): string[] { switch (operation.type) { case 'create_account': return [operation.account] case 'payment': return [operation.to_muxed || operation.to] default: return [] } } function getSimpleOperationValue( unit: Unit, operation: RawOperation, transaction: Horizon.ServerApi.TransactionRecord, type: string ): BigNumber { let value = new BigNumber(0) if (!operation.transaction_successful) { return type === 'IN' ? value : new BigNumber(transaction.fee_charged || 0) } switch (operation.type) { case 'create_account': return parseCurrencyUnit(unit, operation.starting_balance) case 'payment': return parseCurrencyUnit(unit, operation.amount) default: return type !== 'IN' ? new BigNumber(transaction.fee_charged) : value } } function getSimpleOperationType(operation: RawOperation, addr: string): string { switch (operation.type) { case 'create_account': return operation.funder === addr ? 'OUT' : 'IN' case 'payment': if (operation.from === addr && operation.to !== addr) { return 'OUT' } return 'IN' case 'change_trust': if (new BigNumber(operation.limit).eq(0)) { return 'OPT_OUT' } return 'OPT_IN' default: if (operation.source_account === addr) { return 'OUT' } return 'IN' } }