UNPKG

@ledgerhq/coin-stellar

Version:
645 lines (576 loc) 20.8 kB
// SPDX-FileCopyrightText: © 2026 LEDGER SAS // SPDX-License-Identifier: Apache-2.0 import type { BalanceAsset, RawOperation, StellarMemo } from '../types' /* * Serialization functions from Horizon to Ledger Live types */ import type { Operation } from '@ledgerhq/coin-module-framework/api/types' import { Horizon } from '@stellar/stellar-sdk' import BigNumber from 'bignumber.js' import { parseAPIValue } from '../logic/common' // 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<string>([ '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.asset_type === 'native') as BalanceAsset 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( operations: RawOperation[], addr: string, accountId: string, minHeight: number ): Promise<Operation[]> { // 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(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<string>() 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 = { rawOperation: RawOperation accountId: string addr: string transaction: Awaited<ReturnType<RawOperation['transaction']>> baseTxFields: Operation['tx'] baseDetails: { pagingToken: string blockTime: Date index: string memo: StellarMemo | undefined sequence: string | undefined } } async function formatOperation( rawOperation: RawOperation, accountId: string, addr: string, minHeight: number ): Promise<Operation[]> { const transaction = await rawOperation.transaction() if (transaction.ledger_attr < minHeight) return [] const { hash: blockHash, closed_at: blockTime } = await transaction.ledger() const memo = decodeMemo(transaction) const baseTxFields: Operation['tx'] = { hash: rawOperation.transaction_hash, block: { hash: blockHash, time: new Date(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: new Date(blockTime), index: rawOperation.id, memo, sequence: parseSequence(transaction.source_account_sequence), } const ctx: FormatCtx = { 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 keep their pre-existing * convention of `type: "NONE"` (so they are filtered out of the native * account view) with `value = fee_charged` and the actual token amount stored * on `details.assetAmount` for the sub-account adapter to consume. */ function formatSimpleOperation(ctx: FormatCtx): Operation[] { const { rawOperation, accountId, transaction, baseTxFields, baseDetails, addr } = ctx const type = getSimpleOperationType(rawOperation, addr) const value = getSimpleOperationValue(rawOperation, transaction, type) const recipients = getRecipients(rawOperation) const isToken = !!(rawOperation.asset_code && rawOperation.asset_issuer) const finalType = isToken && !['OPT_IN', 'OPT_OUT'].includes(type) ? 'NONE' : type const finalValue = isToken ? BigInt(transaction.fee_charged) : BigInt(value.toString()) return [ { id: `${accountId}-${rawOperation.transaction_hash}-${type}`, value: finalValue, type: finalType, 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 ? value.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. * * Native-asset rows use `type: OUT|IN` and carry the value in stroops. * Token-asset rows keep the legacy `type: "NONE"` / `value = fee_charged` * convention with the real token amount 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) const pathOp = rawOperation as RawOperation & { source_amount?: string amount?: string } if (isSource) { const amountStr = pathOp.source_amount ?? pathOp.amount ?? '0' ops.push(buildPathPaymentLeg(ctx, 'OUT', sourceAsset, parseAPIValue(amountStr), isMultiLeg)) } if (isDest) { const amountStr = pathOp.amount ?? '0' ops.push(buildPathPaymentLeg(ctx, 'IN', destAsset, parseAPIValue(amountStr), isMultiLeg)) } return ops } type AssetInfo = { type: 'native' } | { type: 'token'; code: string; issuer: string } function readSourceAsset(rawOperation: RawOperation): AssetInfo { const o = rawOperation as RawOperation & { source_asset_type?: string source_asset_code?: string source_asset_issuer?: string } if (o.source_asset_type === 'native') return { type: 'native' } if (o.source_asset_code && o.source_asset_issuer) { return { type: 'token', code: o.source_asset_code, issuer: o.source_asset_issuer } } return { type: 'native' } } function readDestAsset(rawOperation: RawOperation): AssetInfo { const o = rawOperation as RawOperation & { asset_type?: string } if (o.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' } } // Match the existing failed-tx convention: IN rows go to 0, OUT rows carry the // fee charged so downstream consumers see the same fee debit they would for a // non-path-payment failure (see `getSimpleOperationValue`). function computeLegValue( isFailed: boolean, direction: 'IN' | 'OUT', isNative: boolean, amount: BigNumber, feeCharged: string | number ): bigint { if (isFailed) { return direction === 'IN' ? 0n : BigInt(feeCharged) } return isNative ? BigInt(amount.toString()) : BigInt(feeCharged) } 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 = isNative ? direction : 'NONE' const { label, asset: opAsset, assetCode, assetIssuer, assetAmount, } = describeLegAsset(asset, amount) 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.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). */ function formatLiquidityPoolWithdraw(ctx: FormatCtx): Operation[] { const reserves = readPoolReserves(ctx.rawOperation, 'reserves_received') return reserves.map((reserve) => buildPoolLeg(ctx, 'IN', reserve, reserves.length > 1)) } type PoolReserve = { asset: AssetInfo; amount: BigNumber } function readPoolReserves( rawOperation: RawOperation, field: 'reserves_deposited' | 'reserves_received' ): PoolReserve[] { const arr = ( rawOperation as RawOperation & { reserves_deposited?: Array<{ asset: string; amount: string }> reserves_received?: Array<{ asset: string; amount: string }> } )[field] if (!arr || arr.length === 0) return [] return arr.map((r) => ({ asset: parseAssetString(r.asset), amount: parseAPIValue(r.amount), })) } 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 value = isFailed ? direction === 'IN' ? 0n : BigInt(transaction.fee_charged) : asset.type === 'native' ? BigInt(amount.toString()) : BigInt(transaction.fee_charged) const ledgerOpType = asset.type === 'native' ? direction : 'NONE' // 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 : amount.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<Operation | undefined> { 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 { hash: blockHash, closed_at: blockTime } = await transaction.ledger() 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: blockHash, time: new Date(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: new Date(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( 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 parseAPIValue(operation.starting_balance) case 'payment': return parseAPIValue(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' } }